From fc17ca229747fed2b3eedc370dc607b42e109a54 Mon Sep 17 00:00:00 2001 From: techflag <562635045@qq.com> Date: Thu, 17 Sep 2026 16:32:33 +0800 Subject: [PATCH] feat: enforce project instruction budget --- docs/acceptance.json | 6 +++-- docs/evidence/projects-alpha-ui05.md | 22 +++++++++++++++++++ packages/contracts/src/projects.ts | 9 ++++++++ .../projects/src/client/ProjectsPanel.tsx | 6 ++--- .../plugins/projects/src/client/styles.ts | 2 +- .../projects/src/remote/connection-api.ts | 2 +- .../projects/src/services/project-manager.ts | 4 ++-- .../plugins/projects/tests/projects.test.mjs | 2 +- 8 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 docs/evidence/projects-alpha-ui05.md diff --git a/docs/acceptance.json b/docs/acceptance.json index 9421be6acc..056a96b156 100644 --- a/docs/acceptance.json +++ b/docs/acceptance.json @@ -452,8 +452,10 @@ "tasks": [ "P1-08" ], - "status": "pending", - "evidence": [] + "status": "passed", + "evidence": [ + "docs/evidence/projects-alpha-ui05.md" + ] }, { "id": "UI06", diff --git a/docs/evidence/projects-alpha-ui05.md b/docs/evidence/projects-alpha-ui05.md new file mode 100644 index 0000000000..de1517b7f4 --- /dev/null +++ b/docs/evidence/projects-alpha-ui05.md @@ -0,0 +1,22 @@ +# Projects Alpha UI05 evidence + +Date: 2026-09-17 + +## Automated checks + +- `ProjectManager.updateConfig` uses the current configuration ID as `expectedRevisionId`; a stale save returns `projects/revision-conflict` and does not overwrite the newer revision. +- Saving creates a new immutable `ProjectConfigRevision`. An already linked task keeps its previous `configRevisionId` after a later project configuration update. +- Project instructions now share a contract-level 8,000-token reserved budget estimator. The Host rejects an 8,001-token CJK instruction with `projects/instruction-budget-exceeded`; it no longer truncates instruction text at 10,000 characters. +- The project test verifies over-budget rejection leaves the current revision unchanged, stale revision rejection, new revision creation, and old-task revision retention across restart. +- Project tests and the root typecheck passed. + +## Runtime observations + +The packaged preview was opened against the Host-backed project `Host持久化验证`. + +- Editing the instruction to `取消不应保存`, cancelling, and reopening restored the prior empty instruction and still showed configuration revision 2. +- Saving `运行态保存验证` closed the dialog, updated the configuration card, and reopening showed configuration revision 3. +- Entering 8,001 CJK characters displayed `预计 8,001 / 8,000 tokens` and the actionable guidance to remove repeated background or store long material as a project asset. +- Pressing Save kept the dialog and draft open, returned the budget error, and left the saved card value as `运行态保存验证`. + +This evidence covers UI05. diff --git a/packages/contracts/src/projects.ts b/packages/contracts/src/projects.ts index 75ce83b818..198078c75a 100644 --- a/packages/contracts/src/projects.ts +++ b/packages/contracts/src/projects.ts @@ -5,6 +5,15 @@ export type WorkItemStatus = 'todo' | 'doing' | 'review' | 'done' | 'cancelled'; export type WorkItemPriority = 'none' | 'low' | 'medium' | 'high' | 'urgent'; export type ProjectCapabilityKind = 'skill' | 'expert' | 'connector'; +/** Reserved project-instruction budget before task text, assets and tool context are added. */ +export const PROJECT_INSTRUCTION_TOKEN_BUDGET = 8_000; +/** Conservative tokenizer-independent estimate: CJK/emoji cost one token, Latin runs four chars per token. */ +export function estimateProjectInstructionTokens(value: string): number { + let wide = 0, narrow = 0; + for (const character of value) (/[^\u0000-\u00ff]/u.test(character) ? wide++ : narrow++); + return wide + Math.ceil(narrow / 4); +} + export interface ProjectCapabilityRef { readonly kind: ProjectCapabilityKind; readonly id: string; readonly revision?: string; readonly label: string; readonly scope?: 'personal' | 'public'; } export interface ProjectConfig { readonly instruction: string; readonly capabilities: readonly ProjectCapabilityRef[]; } export interface ProjectConfigRevision extends ProjectConfig { readonly id: string; readonly projectId: string; readonly number: number; readonly createdBy: string; readonly createdAt: string; } diff --git a/packages/plugins/projects/src/client/ProjectsPanel.tsx b/packages/plugins/projects/src/client/ProjectsPanel.tsx index 067371db50..c92425a3f2 100644 --- a/packages/plugins/projects/src/client/ProjectsPanel.tsx +++ b/packages/plugins/projects/src/client/ProjectsPanel.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { Project, ProjectAssetRef, ProjectCapabilityKind, ProjectCapabilityRef, ProjectInputRef, ProjectSnapshot, ProjectTemplate, ProjectWorkItem } from 'workdsh-contracts/projects'; +import { PROJECT_INSTRUCTION_TOKEN_BUDGET, estimateProjectInstructionTokens, type Project, type ProjectAssetRef, type ProjectCapabilityKind, type ProjectCapabilityRef, type ProjectInputRef, type ProjectSnapshot, type ProjectTemplate, type ProjectWorkItem } from 'workdsh-contracts/projects'; import type { ProjectClient } from './management.js'; import { projectsCss } from './styles.js'; type Props={management:ProjectClient;startTask:(snapshot:ProjectSnapshot,prompt:string,references:readonly ProjectInputRef[])=>Promise}; type Tab='activity'|'plan'|'tasks'|'assets'; type Choice={id:string;label:string;description?:string;kind:ProjectCapabilityKind;scope?:'personal'|'public';revision?:string;stagedImportId?:string}; @@ -25,11 +25,11 @@ const openCatalog=()=>{setCatalogBase(new Set(picked));setCatalogOpen(true)}; const cancelCatalog=()=>{const stagedIds=new Set(stagedSkills.map(row=>row.inspection.name));setPicked(new Set([...catalogBase].filter(id=>!stagedIds.has(id))));setChoices(current=>current.filter(row=>!row.stagedImportId));void discardStaged();setCatalogOpen(false)}; const cancelCapability=()=>{void discardStaged();setModal(undefined);setError('')}; const savePicker=async()=>{if(!selected||!modal||modal==='instruction'||modal==='create'||modal==='assets')return;try{let available=choices;if(modal==='skill'&&stagedSkills.length)available=await commitStaged(picked);const retained=selected.config.capabilities.filter(x=>x.kind!==modal),added=available.filter(x=>picked.has(x.id)).map((x):ProjectCapabilityRef=>({kind:x.kind,id:x.id,label:x.label,...(x.scope?{scope:x.scope}:{}),...(x.revision?{revision:x.revision}:{})}));await management.updateConfig(selected.project.id,{instruction:selected.config.instruction,capabilities:[...retained,...added]},selected.config.id);await reload();setModal(undefined);setError('')}catch(e){setError(e instanceof Error?e.message:'技能已保留,可重试项目关联')}}; -const saveInstruction=async()=>{if(!selected)return;try{await management.updateConfig(selected.project.id,{instruction:draft,capabilities:selected.config.capabilities},selected.config.id);await reload();setModal(undefined);setError('')}catch(e){setError(e instanceof Error?e.message:'保存项目指令失败')}}; +const saveInstruction=async()=>{if(!selected)return;if(estimateProjectInstructionTokens(draft)>PROJECT_INSTRUCTION_TOKEN_BUDGET){setError('项目指令超过预留上下文预算,请删减重复背景或改为项目资料后重试。');return}try{await management.updateConfig(selected.project.id,{instruction:draft,capabilities:selected.config.capabilities},selected.config.id);await reload();setModal(undefined);setError('')}catch(e){setError(e instanceof Error?e.message:'保存项目指令失败')}}; const addWork=async()=>{if(!selected)return;setError('');try{const item=await management.addWorkItem(selected.project.id,'新待办');setSelected({...selected,workItems:[...selected.workItems,item]})}catch(e){setError(e instanceof Error?e.message:'新增待办失败')}}; const updateWork=async(item:ProjectWorkItem,patch:Partial)=>{if(!selected)return;setError('');try{const next={...item,...patch};await management.updateWorkItem(selected.project.id,{id:next.id,title:next.title,status:next.status,assignee:next.assignee,priority:next.priority,tags:next.tags},item.revision);await reload()}catch(e){setError(e instanceof Error?e.message:'更新待办失败')}}; if(!selected)return
{error&&modal!=='create'&&!createPicker&&
{error}
}

项目

多人协同,把资料、技能与 AI 任务组织在一个工作台。

👥 🤖 📁

我的项目

setQuery(e.target.value)} placeholder="搜索项目"/>
{projects.map(p=>
)}

从模板创建

{templates.map(t=>)}
{modal==='create'&&void openCreatePicker(kind)} cancel={()=>{void discardStaged();setModal(undefined);setError('')}} confirm={()=>void create()} error={error}/>} {createPicker&&{if(createPicker==='skill')void discardNewStaged();setPicked(new Set(catalogBase));closeCreatePicker()}} confirm={saveCreatePicker} error={error} loading={pickerLoading} upload={createPicker==='skill'?stageSkill:undefined}/>}
; -const caps=(kind:ProjectCapabilityKind)=>selected.config.capabilities.filter(x=>x.kind===kind),composer=composerDrafts[selected.project.id]??{text:'',references:[]},setComposer=(next:ComposerDraft)=>setComposerDrafts(current=>({...current,[selected.project.id]:next}));const capabilityModal=modal==='skill'||modal==='expert'||modal==='connector'?modal:undefined;return
{error&&!capabilityModal&&modal!=='instruction'&&
{error}
}{notice&&
{notice}
}
void startTask(selected,composer.text,composer.references).then(()=>{setComposer({text:'',references:[]});setNotice('已创建原生任务,并固定当前项目配置及引用修订。');setError('')}).catch(e=>setError(e instanceof Error?e.message:'创建项目任务失败'))}/>
{modal==='instruction'&&{setModal(undefined);setError('')}} confirm={()=>void saveInstruction()}>{error&&
{error}
}

项目下新建任务会固定引用保存时的指令修订;任务创建时还会按模型上下文预算校验。