feat: enforce project instruction budget

This commit is contained in:
techflag
2026-09-17 16:32:33 +08:00
parent 199417c45e
commit fc17ca2297
8 changed files with 43 additions and 10 deletions
+4 -2
View File
@@ -452,8 +452,10 @@
"tasks": [
"P1-08"
],
"status": "pending",
"evidence": []
"status": "passed",
"evidence": [
"docs/evidence/projects-alpha-ui05.md"
]
},
{
"id": "UI06",
+22
View File
@@ -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.
+9
View File
@@ -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; }
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
export const projectsCss=`
.wd-projects{--p-bg:#121212;--p-surface:#181818;--p-card:#202020;--p-hover:#292929;--p-line:#303030;--p-strong:#ededed;--p-text:#d2d2d2;--p-muted:#8f8f8f;--p-accent:#e8e8e8;height:100%;min-height:0;background:var(--p-bg);color:var(--p-text);color-scheme:dark;font:14px/1.5 "PingFang SC","Microsoft YaHei",sans-serif;overflow:auto;container-type:inline-size}.wd-projects *{box-sizing:border-box}.wd-projects button,.wd-projects input,.wd-projects textarea,.wd-projects select{font:inherit;color:inherit}.wd-projects button{appearance:none;cursor:pointer}.wd-projects button:disabled{opacity:1}.wd-p-center{padding:42px clamp(28px,5vw,72px) 80px}.wd-p-hero{min-height:220px;display:flex;align-items:center;justify-content:space-between;background:linear-gradient(120deg,#191919,#151515);border:1px solid var(--p-line);border-radius:16px;padding:34px 42px}.wd-p-hero h1{font-size:38px;color:var(--p-strong);margin:0 0 10px}.wd-p-hero p{max-width:560px;color:var(--p-muted);font-size:17px}.wd-p-hero-art{font-size:64px;letter-spacing:12px;filter:grayscale(1);opacity:.68}.wd-p-primary,.wd-p-outline{border:1px solid #454545;border-radius:9px;padding:9px 16px;background:#242424}.wd-projects .wd-p-primary{background:#2563eb;border-color:#3b82f6;color:#fff;box-shadow:0 1px 0 #ffffff12 inset}.wd-projects .wd-p-primary:hover:not(:disabled){background:#2f6fee;border-color:#5b8ff9}.wd-projects .wd-p-primary:disabled{background:#292929!important;border-color:#3a3a3a!important;color:#737373!important;box-shadow:none;cursor:not-allowed}.wd-p-outline:hover{background:#303030}.wd-p-section-head{display:flex;align-items:center;justify-content:space-between;margin:34px 0 15px}.wd-p-section-head h2,.wd-p-aside h2{color:var(--p-strong)}.wd-p-section-head input,.wd-p-toolbar input,.wd-p-assets-head input{border:1px solid #393939;border-radius:9px;padding:10px 13px;background:#1b1b1b;outline:none;min-width:270px}.wd-p-section-head input:focus,.wd-p-toolbar input:focus,.wd-p-assets-head input:focus{border-color:#707070}.wd-p-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(275px,1fr));gap:14px}.wd-p-card{border:1px solid var(--p-line);background:var(--p-surface);border-radius:12px;padding:18px;text-align:left;min-height:94px;display:flex;gap:14px;align-items:center}.wd-p-card:hover{background:var(--p-hover);border-color:#474747}.wd-p-card-icon{width:44px;height:44px;flex:0 0 44px;border-radius:10px;background:#2a2a2a;display:grid;place-items:center;font-size:22px;color:#cfcfcf}.wd-p-card strong{display:block;color:var(--p-strong);font-size:16px}.wd-p-card small{display:block;margin-top:4px;color:var(--p-muted)}.wd-p-project-card{position:relative;padding:0}.wd-p-card-open{min-width:0;flex:1;align-self:stretch;border:0;background:transparent;text-align:left;display:flex;gap:14px;align-items:center;padding:18px}.wd-p-card-menu{align-self:flex-start;margin:12px 10px 0 0;border:0;background:transparent;color:#888;border-radius:7px;padding:6px}.wd-p-card-menu:hover{background:#333;color:#eee}.wd-p-create-form{display:grid;gap:18px;max-width:680px}.wd-p-create-form label{display:grid;gap:7px;color:#aaa}.wd-p-create-form input,.wd-p-create-form textarea{width:100%;border:1px solid #494949;border-radius:9px;background:#151515;color:#eee;padding:12px;outline:none}.wd-p-create-form input:focus,.wd-p-create-form textarea:focus{border-color:#777}.wd-p-create-form textarea{min-height:120px;resize:vertical}
.wd-p-shell{height:100%;min-height:700px;display:grid;grid-template:64px 1fr/minmax(620px,1fr) 360px;overflow:hidden;transition:grid-template-columns .2s ease}.wd-p-shell.aside-collapsed{grid-template-columns:minmax(620px,1fr) 0}.wd-p-shell.aside-collapsed .wd-p-aside{display:none}.wd-p-top{grid-column:1/-1;padding:0 26px;border-bottom:1px solid var(--p-line);display:flex;align-items:center;justify-content:space-between;background:#151515}.wd-p-top>button:first-child{color:#aaa}.wd-p-top>div{display:flex;align-items:center;gap:9px}.wd-p-main{min-height:0;display:grid;grid-template-rows:58px 1fr 190px;padding:0 26px 18px;background:#121212}.wd-p-tabs{display:flex;gap:30px;align-items:end;border-bottom:1px solid var(--p-line)}.wd-p-tabs button{border:0;background:none;color:#aaa;font-size:16px;padding:17px 3px 13px}.wd-p-tabs button.active{color:var(--p-strong);font-weight:700;border-bottom:3px solid #e4e4e4}.wd-p-content{overflow:auto;padding:20px 0}.wd-p-content>section[hidden]{display:none}.wd-p-aside{border-left:1px solid var(--p-line);background:#151515;padding:20px;overflow:auto}.wd-p-aside>header{display:flex;align-items:center;justify-content:space-between}.wd-p-aside>header h2{margin:0}.wd-p-aside>header button{border:0;background:transparent;color:#aaa;font-size:28px}.wd-p-config-card{border:1px solid var(--p-line);background:#1b1b1b;border-radius:12px;padding:15px;margin:12px 0}.wd-p-config-card header{display:flex;justify-content:space-between;color:var(--p-strong);font-weight:700}.wd-p-config-card button{border:0;background:none;color:#bbb;font-size:21px}.wd-p-config-card p{color:var(--p-muted);white-space:pre-line;max-height:70px;overflow:hidden}.wd-p-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}.wd-p-badge{width:30px;height:30px;border-radius:8px;background:#303030;display:grid;place-items:center;font-weight:700;color:#ddd}.wd-p-composer{align-self:end;border:1px solid #343434;background:#202020;border-radius:16px;padding:16px;box-shadow:0 10px 30px #0004}.wd-p-composer textarea{width:100%;height:74px;border:0;outline:0;resize:none;background:transparent}.wd-p-composer footer{display:flex;justify-content:space-between;color:#aaa}.wd-p-feed-item,.wd-p-task-row{position:relative;padding:16px;border-bottom:1px solid var(--p-line)}.wd-p-feed-item small,.wd-p-task-row small,.wd-p-muted{color:var(--p-muted)}.wd-p-task-row small{display:block;margin:4px 0 0 24px}.wd-p-event-kind{margin-left:9px;border:1px solid #3a3a3a;border-radius:999px;padding:2px 7px;color:#989898;font-size:11px}.wd-p-toolbar{display:flex;gap:9px;align-items:center;margin-bottom:15px;flex-wrap:wrap}.wd-p-toolbar input{min-width:190px}.wd-p-toolbar select,.wd-p-assets-filters select{border:1px solid #414141;border-radius:9px;background:#1b1b1b;padding:9px 11px;outline:none}.wd-p-toolbar .active{background:#393939;color:#fff}.wd-p-segment{display:flex;padding:3px;border:1px solid #363636;border-radius:9px;background:#171717}.wd-p-segment button{border:0;border-radius:6px;background:transparent;color:#888;padding:7px 10px}.wd-p-segment button.active{background:#303030;color:#fff}.wd-p-board{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:12px;align-items:start;overflow:auto}.wd-p-board>section{min-height:280px;border:1px solid var(--p-line);border-radius:10px;background:#171717;padding:10px}.wd-p-board>section>header{display:flex;justify-content:space-between;color:#ddd;font-weight:700;padding:5px 4px 12px}.wd-p-board>section>header small{color:#888}.wd-p-board article{display:grid;gap:8px;margin-bottom:8px;border:1px solid #343434;border-radius:8px;background:#202020;padding:12px}.wd-p-board article span{color:#929292;font-size:12px}.wd-p-table{width:100%;border-collapse:collapse;background:#181818}.wd-p-table th,.wd-p-table td{border:1px solid var(--p-line);padding:11px;text-align:left}.wd-p-table th{color:#aaa;background:#1d1d1d}.wd-p-table input,.wd-p-table select{width:100%;border:0;background:transparent}.wd-p-assets-head{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}.wd-p-assets-filters{display:flex;gap:9px}.wd-p-assets-summary{margin-top:14px;border:1px solid #303030;border-radius:9px;background:#181818;padding:10px 13px;color:#929292}.wd-p-link-danger{border:0;background:transparent;color:#e58a8a}.wd-p-empty{display:grid;place-items:center;min-height:250px;color:var(--p-muted)}
.wd-p-modal-backdrop{position:fixed;inset:0;background:#000a;z-index:10000;display:grid;place-items:center;padding:40px}.wd-p-modal-backdrop+.wd-p-modal-backdrop{z-index:10010;background:#000c}.wd-p-modal{width:min(1120px,90vw);height:min(760px,84vh);background:#1b1b1b;border:1px solid #383838;border-radius:20px;display:grid;grid-template-rows:82px 1fr 82px;box-shadow:0 30px 90px #000b}.wd-p-modal>header,.wd-p-modal>footer{padding:22px 30px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--p-line)}.wd-p-modal>header h2{color:var(--p-strong)}.wd-p-modal>header button{border:0;background:transparent;font-size:22px}.wd-p-modal>footer{border-bottom:0;border-top:1px solid var(--p-line);justify-content:flex-end;gap:10px}.wd-p-modal-body{overflow:auto;padding:22px 30px}.wd-p-modal-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.wd-p-choice{border:1px solid #373737;background:#202020;border-radius:12px;padding:15px;display:flex;gap:13px;align-items:center;text-align:left}.wd-p-choice:hover{background:#292929}.wd-p-choice.selected{border-color:#4b76b8;background:#202b3b}.wd-p-choice input{width:18px;height:18px;accent-color:#3b82f6}.wd-p-choice>span:nth-of-type(2){min-width:0}.wd-p-choice em{margin-left:auto;color:#9b9b9b;font-style:normal;font-size:12px}.wd-p-picker-toolbar,.wd-p-config-modal-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.wd-p-picker-toolbar input{min-width:280px;flex:1;border:1px solid #414141;border-radius:10px;background:#151515;padding:11px 13px;outline:none}.wd-p-picker-toolbar input:focus{border-color:#3b82f6}.wd-p-picker-toolbar>div{display:flex;padding:3px;border:1px solid #3b3b3b;border-radius:9px;background:#151515}.wd-p-picker-toolbar button{border:0;border-radius:6px;background:transparent;color:#969696;padding:7px 13px}.wd-p-picker-toolbar button.active{background:#303030;color:#fff}.wd-p-config-modal-head p{margin:0;color:var(--p-muted)}.wd-p-loading{display:grid;place-items:center;min-height:260px;color:#aaa}.wd-p-instruction{width:100%;height:calc(100% - 72px);min-height:360px;border:1px solid #494949;border-radius:10px;padding:17px;resize:none;background:#151515;outline:none}.wd-p-counter{text-align:right;color:var(--p-muted);padding-top:8px}.wd-p-error{background:#3b2020;color:#ffaaaa;padding:10px 14px;border-radius:8px;margin:8px 0}.wd-p-notice{background:#1e3528;color:#9bd7b4;padding:10px 14px;border-radius:8px;margin:8px 0}.wd-p-create-modal{width:min(960px,90vw);height:min(900px,90vh)}.wd-p-create-modal>footer{justify-content:flex-end}.wd-p-create-modal>footer>span{margin-right:auto}.wd-p-create-body{display:grid;align-content:start;gap:16px}.wd-p-create-label{display:grid;grid-template-columns:1fr auto;gap:8px}.wd-p-create-label b{grid-column:1}.wd-p-create-label small{grid-column:2;color:var(--p-muted)}.wd-p-create-label input{grid-column:1/-1;height:48px;border:1px solid #494949;border-radius:9px;background:#151515;padding:0 13px;outline:none}.wd-p-create-instruction-head{display:flex;align-items:center;justify-content:space-between;margin-top:8px}.wd-p-create-instruction-head select{border:1px solid #494949;border-radius:9px;background:#242424;padding:9px 12px}.wd-p-create-instruction{min-height:170px;border:1px solid #494949;border-radius:10px;background:#151515;padding:15px;resize:vertical;outline:none}.wd-p-create-label input:focus,.wd-p-create-instruction:focus{border-color:#777}.wd-p-create-capability{min-height:66px;border:1px solid #3d3d3d;border-radius:10px;background:#1c1c1c;padding:13px 16px;display:flex;align-items:center;justify-content:space-between;text-align:left}.wd-p-create-capability:hover{background:#252525}.wd-p-create-capability em{font-style:normal;font-weight:400;color:var(--p-muted)}.wd-p-create-capability small{display:block;margin-top:4px;max-width:650px;color:var(--p-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wd-p-create-capability>strong{color:#ccc;white-space:nowrap}@media(max-width:1050px){.wd-p-shell{grid-template-columns:1fr}.wd-p-shell.aside-collapsed{grid-template-columns:1fr}.wd-p-aside{position:fixed;right:0;top:0;bottom:0;width:min(360px,88vw);z-index:20;box-shadow:-12px 0 35px #0008}.wd-p-shell.aside-collapsed .wd-p-aside{display:none}.wd-p-modal-grid{grid-template-columns:1fr}.wd-p-hero-art{display:none}}
.wd-p-modal-backdrop{position:fixed;inset:0;background:#000a;z-index:10000;display:grid;place-items:center;padding:40px}.wd-p-modal-backdrop+.wd-p-modal-backdrop{z-index:10010;background:#000c}.wd-p-modal{width:min(1120px,90vw);height:min(760px,84vh);background:#1b1b1b;border:1px solid #383838;border-radius:20px;display:grid;grid-template-rows:82px 1fr 82px;box-shadow:0 30px 90px #000b}.wd-p-modal>header,.wd-p-modal>footer{padding:22px 30px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--p-line)}.wd-p-modal>header h2{color:var(--p-strong)}.wd-p-modal>header button{border:0;background:transparent;font-size:22px}.wd-p-modal>footer{border-bottom:0;border-top:1px solid var(--p-line);justify-content:flex-end;gap:10px}.wd-p-modal-body{overflow:auto;padding:22px 30px}.wd-p-modal-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.wd-p-choice{border:1px solid #373737;background:#202020;border-radius:12px;padding:15px;display:flex;gap:13px;align-items:center;text-align:left}.wd-p-choice:hover{background:#292929}.wd-p-choice.selected{border-color:#4b76b8;background:#202b3b}.wd-p-choice input{width:18px;height:18px;accent-color:#3b82f6}.wd-p-choice>span:nth-of-type(2){min-width:0}.wd-p-choice em{margin-left:auto;color:#9b9b9b;font-style:normal;font-size:12px}.wd-p-picker-toolbar,.wd-p-config-modal-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.wd-p-picker-toolbar input{min-width:280px;flex:1;border:1px solid #414141;border-radius:10px;background:#151515;padding:11px 13px;outline:none}.wd-p-picker-toolbar input:focus{border-color:#3b82f6}.wd-p-picker-toolbar>div{display:flex;padding:3px;border:1px solid #3b3b3b;border-radius:9px;background:#151515}.wd-p-picker-toolbar button{border:0;border-radius:6px;background:transparent;color:#969696;padding:7px 13px}.wd-p-picker-toolbar button.active{background:#303030;color:#fff}.wd-p-config-modal-head p{margin:0;color:var(--p-muted)}.wd-p-loading{display:grid;place-items:center;min-height:260px;color:#aaa}.wd-p-instruction{width:100%;height:calc(100% - 72px);min-height:360px;border:1px solid #494949;border-radius:10px;padding:17px;resize:none;background:#151515;outline:none}.wd-p-counter{text-align:right;color:var(--p-muted);padding-top:8px}.wd-p-counter.over{color:#ff9b9b;font-weight:700}.wd-p-error{background:#3b2020;color:#ffaaaa;padding:10px 14px;border-radius:8px;margin:8px 0}.wd-p-notice{background:#1e3528;color:#9bd7b4;padding:10px 14px;border-radius:8px;margin:8px 0}.wd-p-create-modal{width:min(960px,90vw);height:min(900px,90vh)}.wd-p-create-modal>footer{justify-content:flex-end}.wd-p-create-modal>footer>span{margin-right:auto}.wd-p-create-body{display:grid;align-content:start;gap:16px}.wd-p-create-label{display:grid;grid-template-columns:1fr auto;gap:8px}.wd-p-create-label b{grid-column:1}.wd-p-create-label small{grid-column:2;color:var(--p-muted)}.wd-p-create-label input{grid-column:1/-1;height:48px;border:1px solid #494949;border-radius:9px;background:#151515;padding:0 13px;outline:none}.wd-p-create-instruction-head{display:flex;align-items:center;justify-content:space-between;margin-top:8px}.wd-p-create-instruction-head select{border:1px solid #494949;border-radius:9px;background:#242424;padding:9px 12px}.wd-p-create-instruction{min-height:170px;border:1px solid #494949;border-radius:10px;background:#151515;padding:15px;resize:vertical;outline:none}.wd-p-create-label input:focus,.wd-p-create-instruction:focus{border-color:#777}.wd-p-create-capability{min-height:66px;border:1px solid #3d3d3d;border-radius:10px;background:#1c1c1c;padding:13px 16px;display:flex;align-items:center;justify-content:space-between;text-align:left}.wd-p-create-capability:hover{background:#252525}.wd-p-create-capability em{font-style:normal;font-weight:400;color:var(--p-muted)}.wd-p-create-capability small{display:block;margin-top:4px;max-width:650px;color:var(--p-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wd-p-create-capability>strong{color:#ccc;white-space:nowrap}@media(max-width:1050px){.wd-p-shell{grid-template-columns:1fr}.wd-p-shell.aside-collapsed{grid-template-columns:1fr}.wd-p-aside{position:fixed;right:0;top:0;bottom:0;width:min(360px,88vw);z-index:20;box-shadow:-12px 0 35px #0008}.wd-p-shell.aside-collapsed .wd-p-aside{display:none}.wd-p-modal-grid{grid-template-columns:1fr}.wd-p-hero-art{display:none}}
.wd-p-library-tools{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.wd-p-library-tools select,.wd-p-library-tools input{border:1px solid #414141;border-radius:9px;background:#151515;padding:9px 11px;outline:none}.wd-p-library-tools input{min-width:220px}.wd-p-file-input{position:absolute;width:1px!important;height:1px;opacity:0;pointer-events:none}.wd-p-breadcrumb{display:flex;gap:7px;align-items:center;margin:16px 0;color:#777}.wd-p-breadcrumb button{border:0;background:transparent;color:#9bbcf2;padding:0}.wd-p-library-list{display:grid;grid-template-columns:1fr 1fr;gap:10px}.wd-p-library-row{min-width:0;min-height:72px;border:1px solid #363636;border-radius:11px;background:#1f1f1f;padding:12px;display:flex;align-items:center;gap:12px;text-align:left}.wd-p-library-row:hover{background:#272727}.wd-p-library-row.selected{border-color:#4b76b8;background:#202b3b}.wd-p-library-row>input{width:18px;height:18px;accent-color:#3b82f6}.wd-p-library-row>span:nth-last-of-type(1){min-width:0;flex:1}.wd-p-library-row strong,.wd-p-library-row small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wd-p-library-row small{color:#898989;margin-top:4px}.wd-p-library-row em{font-style:normal;color:#858585;white-space:nowrap}.wd-p-composer{position:relative}.wd-p-composer-chips{display:flex;gap:7px;flex-wrap:wrap;min-height:0}.wd-p-composer-chips button{display:flex;gap:6px;align-items:center;border:1px solid #3f4f68;border-radius:999px;background:#202b3b;color:#d6e5ff;padding:4px 9px;font-size:12px}.wd-p-composer-chips button span{color:#8eb5f5}.wd-p-composer footer>span{display:flex;gap:7px}.wd-p-composer footer>span button{width:31px;height:31px;border:0;border-radius:8px;background:#292929;color:#cfcfcf;font-size:18px}.wd-p-composer footer>span button:hover{background:#353535}.wd-p-reference-menu{position:absolute;left:14px;bottom:58px;width:min(520px,calc(100% - 28px));max-height:310px;overflow:auto;border:1px solid #444;border-radius:12px;background:#1a1a1a;box-shadow:0 18px 45px #000b;padding:10px;z-index:12}.wd-p-reference-menu header{display:flex;justify-content:space-between;align-items:center;padding:5px 7px 10px;color:#eee}.wd-p-reference-menu header button{border:0;background:transparent}.wd-p-reference-menu label{display:flex;align-items:center;gap:10px;border-radius:8px;padding:9px}.wd-p-reference-menu label:hover{background:#262626}.wd-p-reference-menu label>span{min-width:0}.wd-p-reference-menu label b,.wd-p-reference-menu label small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wd-p-reference-menu label small,.wd-p-reference-menu p{color:#888}.wd-p-reference-menu input{accent-color:#3b82f6}@media(max-width:760px){.wd-p-library-list{grid-template-columns:1fr}}
@container (max-width:900px){.wd-p-shell,.wd-p-shell.aside-collapsed{grid-template-columns:minmax(0,1fr)}.wd-p-main{padding:0 14px 14px}.wd-p-aside{position:absolute;right:0;top:64px;bottom:0;width:min(360px,92cqw);z-index:20;box-shadow:-12px 0 35px #000b}.wd-p-shell.aside-collapsed .wd-p-aside{display:none}.wd-p-top{padding:0 14px}.wd-p-top>div{gap:5px}.wd-p-tabs{gap:18px}.wd-p-toolbar,.wd-p-assets-head{align-items:stretch}.wd-p-toolbar input,.wd-p-assets-head input{min-width:150px;flex:1}.wd-p-modal-grid,.wd-p-library-list{grid-template-columns:1fr}.wd-p-hero-art{display:none}}
`;
@@ -22,4 +22,4 @@ if(endpoint==='remove-asset'&&typeof p.projectId==='string'&&typeof p.refId==='s
if(endpoint==='validate-input-refs'&&typeof p.projectId==='string'&&Array.isArray(p.references))return Response.json(ok(await m.validateInputRefs(a,p.projectId,p.references as unknown as ProjectInputRef[],request.signal)));
if(endpoint==='link-task'&&typeof p.projectId==='string'&&typeof p.sessionId==='string'&&typeof p.title==='string')return Response.json(ok(await m.linkTask(a,p.projectId,p.sessionId,p.title,typeof p.workItemId==='string'?p.workItemId:undefined,Array.isArray(p.references)?p.references as unknown as ProjectInputRef[]:[],request.signal)));
if(endpoint==='post-message'&&typeof p.projectId==='string'&&typeof p.text==='string')return Response.json(ok(await m.postMessage(a,p.projectId,p.text,request.signal)));
return Response.json(fail('projects/invalid-request','项目请求无效。'),{status:400});}catch(cause){const code=cause instanceof Error&&cause.message.startsWith('projects/')?cause.message:'projects/internal';const messages:Record<string,string>={'projects/not-found':'项目内容不存在或无权访问。','projects/revision-conflict':'内容已被更新,请刷新后重试。','projects/reference-stale':'引用已删除、更新或无权访问,请重新选择。','projects/invalid-name':'请输入有效的项目名称。','projects/invalid-title':'请输入有效标题。','projects/template-not-found':'项目模板不存在。'};return Response.json(fail(code,messages[code]??'项目操作失败。'),{status:code==='projects/internal'?500:400});}}});ctx.effect(()=>unregister,'workdsh.projects.fetch');}
return Response.json(fail('projects/invalid-request','项目请求无效。'),{status:400});}catch(cause){const code=cause instanceof Error&&cause.message.startsWith('projects/')?cause.message:'projects/internal';const messages:Record<string,string>={'projects/not-found':'项目内容不存在或无权访问。','projects/revision-conflict':'内容已被更新,请刷新后重试;当前草稿已保留。','projects/instruction-budget-exceeded':'项目指令超过预留上下文预算,请删减重复背景或改为项目资料后重试。','projects/reference-stale':'引用已删除、更新或无权访问,请重新选择。','projects/invalid-name':'请输入有效的项目名称。','projects/invalid-title':'请输入有效标题。','projects/template-not-found':'项目模板不存在。'};return Response.json(fail(code,messages[code]??'项目操作失败。'),{status:code==='projects/internal'?500:400});}}});ctx.effect(()=>unregister,'workdsh.projects.fetch');}
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { Context, Service } from '@deepseek-ai/cordis';
import type { KvTable } from '@deepseek-ai/dsh-storage-domain';
import type { ActorContext } from 'workdsh-contracts';
import type { CreateProjectInput, Project, ProjectActivity, ProjectAssetRef, ProjectConfig, ProjectConfigRevision, ProjectInputRef, ProjectService, ProjectSnapshot, ProjectTaskLink, ProjectTemplate, ProjectWorkItem } from 'workdsh-contracts/projects';
import { PROJECT_INSTRUCTION_TOKEN_BUDGET, estimateProjectInstructionTokens, type CreateProjectInput, type Project, type ProjectActivity, type ProjectAssetRef, type ProjectConfig, type ProjectConfigRevision, type ProjectInputRef, type ProjectService, type ProjectSnapshot, type ProjectTaskLink, type ProjectTemplate, type ProjectWorkItem } from 'workdsh-contracts/projects';
import { projectDomainSpec, projectStateKey, type ProjectState } from '../storage/domain.js';
declare module '@deepseek-ai/cordis' { interface Context { workdshProjects: ProjectService; } }
const timestamp = () => new Date().toISOString();
@@ -24,7 +24,7 @@ export class ProjectManager extends Service implements ProjectService {
create(actor: ActorContext, input: CreateProjectInput) { return this.enqueue(async () => { const state = await this.state(actor); const template = input.templateId ? templates.find(row => row.id === input.templateId) : undefined; if (input.templateId && !template) throw new Error('projects/template-not-found'); const now = timestamp(), projectId = randomUUID(), configId = randomUUID(); const project: Project = { id: projectId, name: clean(input.name), description: input.description?.trim() || template?.description || '', ...(template ? { templateId: template.id } : {}), owner: { organizationId: actor.organizationId, ownerPrincipalId: actor.principalId, scope: 'personal' }, status: 'active', configRevisionId: configId, createdAt: now, updatedAt: now }; const config: ProjectConfigRevision = { id: configId, projectId, number: 1, instruction: template?.instruction ?? '', capabilities: [], createdBy: actor.principalId, createdAt: now }; const activity = this.event(projectId, actor, 'project', `创建了项目「${project.name}」`); const next = { ...state, projects: { ...state.projects, [project.id]: project }, configs: { ...state.configs, [config.id]: config }, activity: { ...state.activity, [activity.id]: activity } }; await this.put(actor,next); return this.snapshot(next,project); }); }
get(actor: ActorContext, projectId: string) { return this.enqueue(async () => { const state=await this.state(actor); return this.snapshot(state,this.requireProject(state,projectId)); }); }
archive(actor: ActorContext, projectId: string) { return this.enqueue(async () => { const state=await this.state(actor), current=this.requireProject(state,projectId), project={...current,status:'archived' as const,updatedAt:timestamp()}; await this.put(actor,{...state,projects:{...state.projects,[projectId]:project}}); return project; }); }
updateConfig(actor: ActorContext, projectId: string, config: ProjectConfig, expectedRevisionId: string) { return this.enqueue(async () => { const state=await this.state(actor), project=this.requireProject(state,projectId), previous=state.configs[project.configRevisionId]; if (!previous || previous.id !== expectedRevisionId) throw new Error('projects/revision-conflict'); const now=timestamp(), next:ProjectConfigRevision={id:randomUUID(),projectId,number:previous.number+1,instruction:config.instruction.slice(0,10000),capabilities:[...config.capabilities],createdBy:actor.principalId,createdAt:now}; await this.put(actor,{...state,configs:{...state.configs,[next.id]:next},projects:{...state.projects,[projectId]:{...project,configRevisionId:next.id,updatedAt:now}}}); return next; }); }
updateConfig(actor: ActorContext, projectId: string, config: ProjectConfig, expectedRevisionId: string) { return this.enqueue(async () => { const state=await this.state(actor), project=this.requireProject(state,projectId), previous=state.configs[project.configRevisionId]; if (!previous || previous.id !== expectedRevisionId) throw new Error('projects/revision-conflict'); if(estimateProjectInstructionTokens(config.instruction)>PROJECT_INSTRUCTION_TOKEN_BUDGET)throw new Error('projects/instruction-budget-exceeded'); const now=timestamp(), next:ProjectConfigRevision={id:randomUUID(),projectId,number:previous.number+1,instruction:config.instruction,capabilities:[...config.capabilities],createdBy:actor.principalId,createdAt:now}; await this.put(actor,{...state,configs:{...state.configs,[next.id]:next},projects:{...state.projects,[projectId]:{...project,configRevisionId:next.id,updatedAt:now}}}); return next; }); }
addWorkItem(actor: ActorContext, projectId: string, title: string) { return this.enqueue(async()=>{const state=await this.state(actor);this.requireProject(state,projectId);const now=timestamp(),item:ProjectWorkItem={id:randomUUID(),projectId,title:clean(title,'projects/invalid-title',500),status:'todo',priority:'none',tags:[],revision:randomUUID(),createdAt:now,updatedAt:now};await this.put(actor,{...state,workItems:{...state.workItems,[item.id]:item}});return item;});}
updateWorkItem(actor: ActorContext, projectId: string, patch: Pick<ProjectWorkItem,'id'|'title'|'status'|'assignee'|'priority'|'tags'>, expectedRevision:string) { return this.enqueue(async()=>{const state=await this.state(actor),old=state.workItems[patch.id];if(!old||old.projectId!==projectId)throw new Error('projects/not-found');if(old.revision!==expectedRevision)throw new Error('projects/revision-conflict');const item={...old,...patch,title:clean(patch.title,'projects/invalid-title',500),revision:randomUUID(),updatedAt:timestamp()};await this.put(actor,{...state,workItems:{...state.workItems,[item.id]:item}});return item;});}
addAsset(actor: ActorContext, projectId:string, input:Omit<ProjectAssetRef,'id'|'projectId'|'createdAt'>){return this.enqueue(async()=>{const state=await this.state(actor);this.requireProject(state,projectId);const existing=Object.values(state.assets).find(row=>row.projectId===projectId&&row.assetId===input.assetId&&row.revisionId===input.revisionId);if(existing)return existing;const ref:ProjectAssetRef={...input,id:randomUUID(),projectId,createdAt:timestamp()};await this.put(actor,{...state,assets:{...state.assets,[ref.id]:ref}});return ref;});}
@@ -1,4 +1,4 @@
import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp,rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Context } from '@deepseek-ai/cordis'; import Storage from '@deepseek-ai/dsh-storage'; import * as JsonStorage from '@deepseek-ai/dsh-storage-json'; import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'; import { ProjectManager } from '../dist/services/project-manager.js';
const actor={principalId:'alice',organizationId:'org',requestId:'test',resolvedBy:'test'};
async function boot(root){const ctx=new Context();await ctx.plugin(Storage);await ctx.plugin(JsonStorage,{root});await ctx.plugin(StorageDomain,{backend:'json'});await ctx.plugin(ProjectManager);return ctx;}
test('projects persist, search, isolate revisions and keep library references',async()=>{const root=await mkdtemp(join(tmpdir(),'workdsh-projects-'));let ctx;try{ctx=await boot(root);const first=await ctx.workdshProjects.create(actor,{name:'钣金厂智能运营平台',templateId:'delivery'});const second=await ctx.workdshProjects.create(actor,{name:'独立项目'});assert.equal((await ctx.workdshProjects.list(actor,'钣金')).length,1);const revised=await ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'只引用已授权资料。',capabilities:[{kind:'skill',id:'organizer',revision:'skill-revision-1',label:'资料整理'}]},first.config.id);const secondRevised=await ctx.workdshProjects.updateConfig(actor,second.project.id,{instruction:'独立配置。',capabilities:[{kind:'skill',id:'organizer',revision:'skill-revision-1',label:'资料整理'}]},second.config.id);await assert.rejects(ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'覆盖',capabilities:[]},first.config.id),/projects\/revision-conflict/);const item=await ctx.workdshProjects.addWorkItem(actor,first.project.id,'完成需求核对');await assert.rejects(ctx.workdshProjects.updateWorkItem(actor,first.project.id,{...item,title:'并发覆盖'},'bad'),/projects\/revision-conflict/);const ref=await ctx.workdshProjects.addAsset(actor,first.project.id,{nodeId:'node-1',assetId:'asset-1',revisionId:'revision-1',name:'规范.md',kind:'markdown'});assert.equal((await ctx.workdshProjects.addAsset(actor,first.project.id,{nodeId:'node-1',assetId:'asset-1',revisionId:'revision-1',name:'规范.md',kind:'markdown'})).id,ref.id);const references=[{kind:'work-item',id:item.id,revision:item.revision,label:item.title},{kind:'asset',id:ref.id,revision:ref.revisionId,label:ref.name},{kind:'skill',id:'organizer',revision:'skill-revision-1',label:'资料整理'}];assert.equal((await ctx.workdshProjects.validateInputRefs(actor,first.project.id,references)).length,3);await assert.rejects(ctx.workdshProjects.validateInputRefs(actor,first.project.id,[{...references[0],revision:'stale'}]),/projects\/reference-stale/);const task=await ctx.workdshProjects.linkTask(actor,first.project.id,'session-1','核对资料',item.id,references);assert.equal(task.configRevisionId,revised.id);assert.equal(task.references.length,3);assert.equal((await ctx.workdshProjects.get(actor,second.project.id)).assets.length,0);const firstWithoutSkill=await ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'只引用已授权资料。',capabilities:[]},revised.id);assert.equal(firstWithoutSkill.capabilities.length,0);const secondAfterRemoval=await ctx.workdshProjects.get(actor,second.project.id);assert.equal(secondAfterRemoval.config.capabilities.length,1);assert.equal(secondAfterRemoval.config.capabilities[0].revision,'skill-revision-1');await ctx.fiber.dispose();ctx=await boot(root);const restored=await ctx.workdshProjects.get(actor,first.project.id);assert.equal(restored.assets.length,1);assert.equal(restored.assets[0].assetId,'asset-1');assert.equal(restored.assets[0].revisionId,'revision-1');assert.equal(restored.tasks[0].references.length,3);assert.equal(restored.config.id,firstWithoutSkill.id);await ctx.workdshProjects.removeAsset(actor,first.project.id,restored.assets[0].id);assert.equal((await ctx.workdshProjects.get(actor,first.project.id)).assets.length,0);await assert.rejects(ctx.workdshProjects.validateInputRefs(actor,first.project.id,[references[1]]),/projects\/reference-stale/);}finally{if(ctx)await ctx.fiber.dispose();await rm(root,{recursive:true,force:true});}});
test('projects persist, search, isolate revisions and keep library references',async()=>{const root=await mkdtemp(join(tmpdir(),'workdsh-projects-'));let ctx;try{ctx=await boot(root);const first=await ctx.workdshProjects.create(actor,{name:'钣金厂智能运营平台',templateId:'delivery'});const second=await ctx.workdshProjects.create(actor,{name:'独立项目'});assert.equal((await ctx.workdshProjects.list(actor,'钣金')).length,1);await assert.rejects(ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'长'.repeat(8001),capabilities:[]},first.config.id),/projects\/instruction-budget-exceeded/);assert.equal((await ctx.workdshProjects.get(actor,first.project.id)).config.id,first.config.id);const revised=await ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'只引用已授权资料。',capabilities:[{kind:'skill',id:'organizer',revision:'skill-revision-1',label:'资料整理'}]},first.config.id);const secondRevised=await ctx.workdshProjects.updateConfig(actor,second.project.id,{instruction:'独立配置。',capabilities:[{kind:'skill',id:'organizer',revision:'skill-revision-1',label:'资料整理'}]},second.config.id);await assert.rejects(ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'覆盖',capabilities:[]},first.config.id),/projects\/revision-conflict/);const item=await ctx.workdshProjects.addWorkItem(actor,first.project.id,'完成需求核对');await assert.rejects(ctx.workdshProjects.updateWorkItem(actor,first.project.id,{...item,title:'并发覆盖'},'bad'),/projects\/revision-conflict/);const ref=await ctx.workdshProjects.addAsset(actor,first.project.id,{nodeId:'node-1',assetId:'asset-1',revisionId:'revision-1',name:'规范.md',kind:'markdown'});assert.equal((await ctx.workdshProjects.addAsset(actor,first.project.id,{nodeId:'node-1',assetId:'asset-1',revisionId:'revision-1',name:'规范.md',kind:'markdown'})).id,ref.id);const references=[{kind:'work-item',id:item.id,revision:item.revision,label:item.title},{kind:'asset',id:ref.id,revision:ref.revisionId,label:ref.name},{kind:'skill',id:'organizer',revision:'skill-revision-1',label:'资料整理'}];assert.equal((await ctx.workdshProjects.validateInputRefs(actor,first.project.id,references)).length,3);await assert.rejects(ctx.workdshProjects.validateInputRefs(actor,first.project.id,[{...references[0],revision:'stale'}]),/projects\/reference-stale/);const task=await ctx.workdshProjects.linkTask(actor,first.project.id,'session-1','核对资料',item.id,references);assert.equal(task.configRevisionId,revised.id);assert.equal(task.references.length,3);assert.equal((await ctx.workdshProjects.get(actor,second.project.id)).assets.length,0);const firstWithoutSkill=await ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'只引用已授权资料。',capabilities:[]},revised.id);assert.equal(firstWithoutSkill.capabilities.length,0);assert.equal((await ctx.workdshProjects.get(actor,first.project.id)).tasks[0].configRevisionId,revised.id);const secondAfterRemoval=await ctx.workdshProjects.get(actor,second.project.id);assert.equal(secondAfterRemoval.config.capabilities.length,1);assert.equal(secondAfterRemoval.config.capabilities[0].revision,'skill-revision-1');await ctx.fiber.dispose();ctx=await boot(root);const restored=await ctx.workdshProjects.get(actor,first.project.id);assert.equal(restored.assets.length,1);assert.equal(restored.assets[0].assetId,'asset-1');assert.equal(restored.assets[0].revisionId,'revision-1');assert.equal(restored.tasks[0].references.length,3);assert.equal(restored.config.id,firstWithoutSkill.id);await ctx.workdshProjects.removeAsset(actor,first.project.id,restored.assets[0].id);assert.equal((await ctx.workdshProjects.get(actor,first.project.id)).assets.length,0);await assert.rejects(ctx.workdshProjects.validateInputRefs(actor,first.project.id,[references[1]]),/projects\/reference-stale/);}finally{if(ctx)await ctx.fiber.dispose();await rm(root,{recursive:true,force:true});}});