diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index a531b64..acda8da 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -455,6 +455,7 @@ function buildWindowTaskText(projectId: string, projectName: string, windowLabel ' - 视频 / 动画 → 生成 .mp4 / .gif(或 Lottie JSON)文件放进项目文件夹;', ' - 工作台已有内置窗能力(资源管理器 / 终端 / 浏览器 / 动画站)→ 不要重复造轮子,直接建议用户改用内置窗。', '5. 完成内容后,用一两句话说明用户把它放进窗口的步骤。该窗口位于「' + projectName + '」项目内,如需要也可协助该项目后续的其他自定义工作。', + '6. 完成后写入「产物清单」文件:在项目文件夹里创建 widget-result.json,内容示例 {\"window\":\"' + win + '\",\"path\":\"产物相对路径\",\"kind\":\"html\"};kind 可选 html(本地页面,path 相对项目文件夹)/ url(外部链接,path 为完整 URL)/ file(其他文件,path 相对项目文件夹)。写完这个文件,工作台会自动把产物挂载进「' + win + '」窗口,用户无需手动操作。', KNOWLEDGE_PACK, ] return lines.join('\n') @@ -550,6 +551,44 @@ function saveNotifyAck(sid: string, state: string) { localStorage.setItem('dsh.worktable.notifyAck.v1', JSON.stringify(ack)) } catch {} } +/** 自动挂载:已消费过完成事件的会话集(一次完成只挂载一次) */ +const mountConsumedRef: { current: Set } = { current: new Set() } +/** 待挂载(项目未打开时暂存):projectId → 挂载内容;刷新后从 localStorage 恢复 */ +const pendingMountRef: { current: Record } = { current: (() => { + try { return JSON.parse(localStorage.getItem('dsh.worktable.pendingMount.v1') ?? '{}') ?? {} } catch { return {} } +})() } + +/** 「窗口N」→ 窗格位置(左栏 → 顶行 → 主行 编号规则,与 AGENTS.md 窗口编号一致) */ +function windowLabelToPane(spec: any, label: string): { row: 'left' | 'top' | 'main'; index: number } | null { + const m = /^窗口(\d+)$/.exec(String(label ?? '').trim()) + if (!m) return null + const n = parseInt(m[1], 10) + if (!Number.isFinite(n) || n < 1) return null + let idx = n - 1 + if (spec?.left) { + if (idx === 0) return { row: 'left', index: 0 } + idx -= 1 + } + const top = spec?.top ?? [] + if (idx < top.length) return { row: 'top', index: idx } + idx -= top.length + const main = spec?.main ?? [] + if (idx < main.length) return { row: 'main', index: idx } + return null +} + +/** 产物清单 → 窗口内容(html=目录级托管 iframe / url=外链 iframe / file=文件预览) */ +function buildMountContent(folder: string, d: any): any { + const p = String(d?.path ?? '').trim() + if (!p) return null + if (d?.kind === 'url') return { kind: 'iframe', url: p, title: p } + const full = p.indexOf(':') >= 0 ? p : (folder + '\\' + p.replace(/^[\\/]+/, '')) + if (d?.kind === 'file') return { kind: 'file', path: full } + const dir = full.replace(/[\\/][^\\/]*$/, '') + const name = full.split(/[\\/]/).pop() ?? full + return { kind: 'iframe', url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(name), title: name } +} + /** 每会话最近一次观察到的「需要判断」布尔值:状态转移时清除旧 ack(新一轮待决重新点亮) */ const notifyStateSeenRef: { current: Record } = { current: {} } /** 清除某会话的 ack 记录(状态转移时调用,保证新问题不会被旧确认压住) */ @@ -979,6 +1018,13 @@ function buildCustomLayoutPrompt(req: string): string { const bound = projectsRef.current.projects.bindings[spec.id] if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} } ackProjectNotify(spec.id) + // 补挂:此前项目未打开时暂存的产物,现在自动挂进主行第一格 + const pending = pendingMountRef.current[spec.id] + if (pending) { + try { splitStore.openTab('main', 0, pending) } catch {} + delete pendingMountRef.current[spec.id] + try { localStorage.setItem('dsh.worktable.pendingMount.v1', JSON.stringify(pendingMountRef.current)) } catch {} + } } }, [projects.views]) @@ -1056,6 +1102,46 @@ function buildCustomLayoutPrompt(req: string): string { return () => { sessionsSnapshotStore.listeners.delete(l) } }, []) + /** 自动挂载:绑定会话完成 → 读项目文件夹 widget-result.json → 产物自动挂进对应窗口 */ + const tryAutoMount = useCallback(async (projectId: string, sid: string) => { + const folder = projectsRef.current.projects.folders[projectId] + if (!folder) return + try { + const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(folder + '\\widget-result.json'), { cache: 'no-store' }) + if (!r.ok) return + const d = await r.json().catch(() => null) + if (!d) return + const content = buildMountContent(folder, d) + if (!content) return + if (splitStore.active && splitStore.spec?.id === projectId) { + // 项目开着:直接挂进「窗口N」对应窗格(找不到就落主行第一格) + const pane = windowLabelToPane(splitStore.spec, d.window) + splitStore.openTab(pane?.row ?? 'main', pane?.index ?? 0, content) + } else { + // 项目没开:暂存,打开项目时补挂 + pendingMountRef.current[projectId] = content + try { localStorage.setItem('dsh.worktable.pendingMount.v1', JSON.stringify(pendingMountRef.current)) } catch {} + } + } catch { /* 无清单文件 = 不挂载 */ } + }, []) + + // 完成事件 → 尝试自动挂载(一次完成只消费一次) + useEffect(() => { + const byId = sessionsSnapshotStore.snapshot?.byId ?? {} + for (const [pid, sid] of Object.entries(projects.bindings)) { + const e = byId[sid] + if (!e) continue + if (e.completed === true) { + if (!mountConsumedRef.current.has(sid)) { + mountConsumedRef.current.add(sid) + tryAutoMount(pid, sid) + } + } else { + mountConsumedRef.current.delete(sid) + } + } + }, [notifyTick, projects.bindings, tryAutoMount]) + /** 收集某会话的子代理 id 集:byId 的 parentId 标注 + subagentsByParent 目录双通道 */ const collectKids = useCallback((sid: string): Set => { const kids = new Set() diff --git a/02_process/PRD.md b/02_process/PRD.md index 0c4aae8..e205ba2 100644 --- a/02_process/PRD.md +++ b/02_process/PRD.md @@ -369,6 +369,9 @@ type SplitPane = { - **窗口任务提示词升级**(2026-08-18):携带窗口身份(项目名 + 窗口N,窗口N = 窗格标题)、项目 文件夹与「插件知识包」(窗模型/内容类型/服务端路由/构建方式,注明「不要重新侦察插件源码」), 避免接收会话从头侦察源码导致响应过慢。 +- **自动挂载**(2026-08-18):提示词第 6 条要求 agent 完成后写 widget-result.json(window/path/ + kind);客户端在绑定会话 completed 时读取并自动把产物挂进对应窗口(项目开着直接挂、没开 + 暂存补挂);用户不再需要手动去资源管理器点开产物。 - **提示词零泄漏硬约束**(2026-08-18):任何对外生成的提示词(窗口任务提示词 / 剪贴板布局提示词) 禁止写入用户的个人工作区分组名(Projects / DeepseekHarness 等)、他人项目名与私人路径; 分组下拉只用于会话创建工作区,绝不进入提示词文本;剪贴板提示词只含插件通用知识。 diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index 24193b2..915ef6b 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -1041,6 +1041,20 @@ 不可见 ✓。 - 备注:纯客户端改动,F5 即生效。回归期间发现宿主侧故障(见下条)导致全量回归无法通过。 +## 补记(自动挂载:完成后产物自动进窗口) + +- 用户要求:窗口任务完成后,产物应自动显示在窗口里,而不是让用户手动去资源管理器找 HTML。 +- 实现:① 提示词第 6 条加「产物清单握手」——agent 完成后在项目文件夹写 widget-result.json + {window:'窗口N', path, kind:html|url|file};② 客户端监听绑定会话 completed(mountConsumedRef + 一次完成只消费一次)→ 读清单 → buildMountContent(html=目录级托管 iframe / url=外链 / + file=文件预览)→ 项目开着 openTab 进「窗口N」(windowLabelToPane 按左栏→顶行→主行编号 + 规则定位,找不到落主行首格);项目没开则暂存 pendingMountRef(localStorage)打开时补挂。 +- 验证(probe-batch11.cjs):提示词含握手 ✓;tabsBefore=0 → 伪造完成 → 自动挂出 iframe 标签 + (site URL 指向夹具 page.html,title=page.html)✓;关项目后再完成 → pendingStored ✓ → + 重开项目 mountedAfterReopen ✓;宿主重启后 functional-diag 全 20 STEP ERROR_COUNT: 0。 +- 备注:纯客户端改动,F5 即生效;夹具目录已清理。 + + diff --git a/04_test/probe-batch11.cjs b/04_test/probe-batch11.cjs new file mode 100644 index 0000000..49ff255 --- /dev/null +++ b/04_test/probe-batch11.cjs @@ -0,0 +1,109 @@ +// probe-batch11.cjs — 自动挂载:完成后读 widget-result.json → 产物自动挂进窗口;项目未开则补挂 +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const PORT = 9367; +const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--window-size=1440,900', '--force-device-scale-factor=1', + '--remote-debugging-port=' + PORT, + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-batch11', + 'about:blank', +], { stdio: 'ignore' }); +const getJSON = (p) => new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{try{res(JSON.parse(d))}catch(e){rej(e)}}) }).on('error', rej); +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +(async () => { + let list = null; + for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; const pending = new Map(); + const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); }); + ws.on('message', (raw) => { const m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Page.enable'); + await send('Page.addScriptToEvaluateOnNewDocument', { + source: "try{ if(window===window.top && location.origin==='http://127.0.0.1:3080'){ localStorage.setItem('dsh.worktable.projects.v1', JSON.stringify({order:[],lastUsed:{},hidden:[],nameOverrides:{},shortcuts:[],layouts:[{id:'t-layout',title:'L',top:null,left:null,main:[{id:'p1',title:'\u7a97\u53e31',min:200,content:null}],chatWidth:{default:320,min:240,max:600},chatSide:'right',chatFullHeight:false}],bindings:{},folders:{'t-layout':'C:\\\\Users\\\\SJL\\\\AppData\\\\Local\\\\Temp\\\\wt-mount-fixture'}})); localStorage.removeItem('dsh.worktable.notifyAck.v1'); localStorage.removeItem('dsh.worktable.pendingMount.v1'); } }catch(e){}", + }); + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text }; + return r.result && r.result.result ? r.result.result.value : undefined; + }; + let ready = null; + for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshWorktable && !!window.__dshSyncSessionScope"); if (ready === true) break; } + await sleep(1500); + const step = await evaluate(`(async function(){ + var out = {}; + var sleep = function(ms){ return new Promise(function(r){ setTimeout(r, ms); }); }; + var vis = function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); }; + var postJson = async function(url, body){ return await fetch(url, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) }); }; + try { + // 提示词握手 + var prompt = window.__dshBuildWindowTaskText('t-layout', '测试项目', '窗口1', '做个鲜花页面', 'E:\\foo', 'new'); + out.promptHasHandshake = prompt.indexOf('widget-result.json') >= 0 && prompt.indexOf('窗口1') >= 0; + // 准备夹具:目录 + page.html + widget-result.json + var fix = 'C:\\\\Users\\\\SJL\\\\AppData\\\\Local\\\\Temp\\\\wt-mount-fixture'; + await postJson('/api/worktable/mkdir', { path: fix }); + await postJson('/api/worktable/write', { path: fix + '\\\\page.html', content: '

FLOWER

' }); + await postJson('/api/worktable/write', { path: fix + '\\\\widget-result.json', content: JSON.stringify({ window: '\u7a97\u53e31', path: 'page.html', kind: 'html' }) }); + // 绑定到第一个会话 + var S = window.__dshSessions; + var card = document.querySelector('.dsh-wt_layout'); + var bbtn = card.querySelector('.dsh-wt_bindBtn'); + bbtn.click(); + await sleep(900); + var items = [].slice.call(document.querySelectorAll('.dsh-wt_bindList .dsh-wt_selectItem')); + if (items[0]) { items[0].click(); } + await sleep(800); + var boundId = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).bindings['t-layout']; + // 打开项目(窗口1 无内容) + card.click(); + await sleep(1200); + var st = window.__dshWorktable.splitStore; + out.projectOpen = st.active; + out.tabsBefore = st.spec && st.spec.main[0].tabs ? st.spec.main[0].tabs.length : null; + // 伪造完成事件 → 自动挂载 + var listStore = S.list; + var orig = listStore.getSnapshot.bind(listStore); + listStore.getSnapshot = function(){ var s = orig(); var e = s.byId && s.byId[boundId]; if (e) { s.byId = Object.assign({}, s.byId); s.byId[boundId] = Object.assign({}, e, { completed: true, running: false }); } return s; }; + window.__dshSyncSessionScope(); + await sleep(2000); + var tabs = st.spec && st.spec.main[0].tabs; + out.tabsAfter = tabs ? tabs.map(function(t){ return { kind: t.content && t.content.kind, url: t.content && t.content.url ? t.content.url.slice(0, 90) : null, title: t.title }; }) : null; + out.mounted = !!(tabs && tabs.some(function(t){ return t.content && t.content.url && t.content.url.indexOf('wt-mount-fixture') >= 0; })); + // 挂起路径:关项目 → 未完成 → 再完成 → 重开项目补挂 + st.close(); + await sleep(400); + listStore.getSnapshot = function(){ var s = orig(); var e = s.byId && s.byId[boundId]; if (e) { s.byId = Object.assign({}, s.byId); s.byId[boundId] = Object.assign({}, e, { completed: false, running: false }); } return s; }; + window.__dshSyncSessionScope(); + await sleep(400); + listStore.getSnapshot = function(){ var s = orig(); var e = s.byId && s.byId[boundId]; if (e) { s.byId = Object.assign({}, s.byId); s.byId[boundId] = Object.assign({}, e, { completed: true, running: false }); } return s; }; + window.__dshSyncSessionScope(); + await sleep(1800); + out.pendingStored = !!(JSON.parse(localStorage.getItem('dsh.worktable.pendingMount.v1')||'{}')['t-layout']); + card.click(); + await sleep(1400); + var tabs2 = st.spec && st.spec.main[0].tabs; + out.mountedAfterReopen = !!(tabs2 && tabs2.some(function(t){ return t.content && t.content.url && t.content.url.indexOf('wt-mount-fixture') >= 0; })); + // 清理 + listStore.getSnapshot = orig; + st.close(); + await sleep(400); + bbtn = card.querySelector('.dsh-wt_bindBtn'); + bbtn.click(); + await sleep(900); + var unbind = vis('.dsh-wt_bindUnbind'); + if (unbind) { unbind.click(); } + await sleep(500); + localStorage.removeItem('dsh.worktable.pendingMount.v1'); + } catch(e) { out.err = String(e); } + return JSON.stringify(out); + })()`); + console.log('BATCH11:', JSON.stringify(step)); + ws.close(); proc.kill(); process.exit(0); +})().catch((e) => { console.error('FATAL', e && e.stack || e); try { proc.kill(); } catch {} process.exit(1); }); diff --git a/AGENTS.md b/AGENTS.md index c688038..97d36fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,11 @@ node --check lib/index.js 放进该文件夹」指令——用户要求项目产出文件不得落到默认位置。 - **窗口任务提示词**:buildWindowTaskText 统一组装(窗口身份「项目+窗口N」+ 项目文件夹 + 插件知识包);知识包注明「不要重新侦察插件源码」,改提示词时保持这个原则。 +- **自动挂载(widget-result.json 握手)**:提示词第 6 条要求 agent 完成后在项目文件夹写 + widget-result.json {window:'窗口N', path, kind:html|url|file};客户端监听绑定会话 completed → + buildMountContent 转换产物 → 项目开着直接 openTab 进「窗口N」(windowLabelToPane 按窗口 + 编号规则定位),项目没开暂存 pendingMountRef(localStorage,打开项目时补挂); + mountConsumedRef 保证一次完成只消费一次。 - **原生皮肤模板**:01_content/template/dshell.css + dshell.html(esbuild text loader 嵌入服务端 bundle,/api/worktable/template 路由下发);知识包要求产出 HTML 一律引用该样式表,组件类 参考模板。新增组件样式只加到 dshell.css,保持单一来源。