diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 4b92858..c89bcbd 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -722,7 +722,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 + '」窗口,用户无需手动操作;挂载后该产物会作为「' + win + '」窗口的固定内容锁定保存,用户下次打开工作台时窗口直接显示它,不会丢失或重置。', + '6. 完成后写入「产物清单」文件:在项目文件夹里创建 widget-result.json。挂载单个窗口时写单对象 {\"window\":\"' + win + '\",\"path\":\"产物相对路径\",\"kind\":\"html\"};一次挂载多个窗口(如窗口1 + 窗口2)时写 JSON 数组,每个元素为上述单对象(可引用多个产物文件)。kind 可选 html(本地页面,path 相对项目文件夹)/ url(外部链接,path 为完整 URL)/ file(其他文件,path 相对项目文件夹)。写完这个文件,工作台会自动把产物挂载进清单对应的各个窗口并锁定保存,用户无需手动操作;用户下次打开工作台时各窗口直接显示产物,不会丢失或重置。', KNOWLEDGE_PACK, ] return lines.join('\n') @@ -826,6 +826,16 @@ const mountConsumedRef: { current: Set } = { current: new Set() const pendingMountRef: { current: Record } = { current: (() => { try { return JSON.parse(localStorage.getItem('dsh.worktable.pendingMount.v1') ?? '{}') ?? {} } catch { return {} } })() } +/** 已挂载指纹:projectId → widget-result.json 原文。自愈扫挂据此去重: + * 同清单不重复锁定(不覆盖用户后续对窗口的手动调整),清单变化才重新挂载。 */ +const mountedWidgetRef: { current: Record } = { current: (() => { + try { return JSON.parse(localStorage.getItem('dsh.worktable.mountedWidget.v1') ?? '{}') ?? {} } catch { return {} } +})() } +function recordMountedWidget(projectId: string, raw: string) { + if (!projectId || !raw) return + mountedWidgetRef.current[projectId] = raw + try { localStorage.setItem('dsh.worktable.mountedWidget.v1', JSON.stringify(mountedWidgetRef.current)) } catch {} +} /** 「窗口N」→ 窗格位置(左栏 → 顶行 → 主行 编号规则,与 AGENTS.md 窗口编号一致) */ function windowLabelToPane(spec: any, label: string): { row: 'left' | 'top' | 'main'; index: number } | null { @@ -846,6 +856,14 @@ function windowLabelToPane(spec: any, label: string): { row: 'left' | 'top' | 'm return null } +/** 窗格是否存在于布局 spec(lockPane 对不存在的窗格会静默放弃;据此决定是否记录挂载指纹) */ +function paneExists(spec: any, row: 'left' | 'top' | 'main', index: number): boolean { + if (!spec) return false + if (row === 'left') return !!spec.left && index === 0 + if (row === 'top') return !!spec.top && !!spec.top[index] + return !!spec.main && !!spec.main[index] +} + /** 产物清单 → 窗口内容(html=目录级托管 iframe / url=外链 iframe / file=文件预览) */ function buildMountContent(folder: string, d: any): any { const p = String(d?.path ?? '').trim() @@ -1471,12 +1489,20 @@ function buildCustomLayoutPrompt(req: string): string { const bound = projectsRef.current.projects.bindings[spec.id] if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} } ackProjectNotify(spec.id) - // 补挂:此前项目未打开时暂存的产物,现在自动挂进主行第一格 + // 补挂:此前项目未打开时暂存的产物(entries = 多窗口挂载列表),现在自动挂进各目标窗格; + // 全部落位成功记录指纹(供自愈扫挂去重) const pending = pendingMountRef.current[spec.id] if (pending) { try { - if (pending.content && typeof pending.row === 'string') splitStore.lockPane(pending.row, pending.index ?? 0, pending.content) - else splitStore.openTab('main', 0, pending) + const entries = Array.isArray(pending.entries) + ? pending.entries + : (pending.content ? [{ content: pending.content, row: pending.row, index: pending.index ?? 0 }] : []) + let allOk = entries.length > 0 + for (const e of entries) { + splitStore.lockPane(e.row, e.index ?? 0, e.content) + if (!paneExists(splitStore.spec, e.row, e.index ?? 0)) allOk = false + } + if (allOk && pending.fingerprint) recordMountedWidget(spec.id, pending.fingerprint) } catch {} delete pendingMountRef.current[spec.id] try { localStorage.setItem('dsh.worktable.pendingMount.v1', JSON.stringify(pendingMountRef.current)) } catch {} @@ -1648,31 +1674,61 @@ function buildCustomLayoutPrompt(req: string): string { // 项目表变化 → 控制室卡片刷新(打开中的面板即时更新) useEffect(() => { notifyConsole() }, [projects]) - /** 自动挂载:绑定会话完成 → 读项目文件夹 widget-result.json → 产物自动挂进对应窗口 */ - const tryAutoMount = useCallback(async (projectId: string, sid: string) => { + /** 执行产物清单挂载:项目开着 → 锁定各「窗口N」;未开 → 待挂载(打开项目时补挂)。 + * 清单支持单窗口对象(旧格式)与多窗口 JSON 数组(新格式,每元素 {window,path,kind})。 + * rawManifest 传入时免二次读取(自愈扫挂先读原文做指纹去重)。全部落位成功后记录挂载指纹。 */ + const applyWidgetManifest = useCallback(async (projectId: string, rawManifest: string | null): Promise => { 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) + let raw = rawManifest + if (raw == null) { + const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(folder + '\\widget-result.json'), { cache: 'no-store' }) + if (!r.ok) return + raw = (await r.text()).trim() + } + if (!raw) return + let d: any = null + try { d = JSON.parse(raw) } catch {} 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.lockPane(pane?.row ?? 'main', pane?.index ?? 0, content) + const items = Array.isArray(d) ? d : [d] + const open = splitStore.active && splitStore.spec?.id === projectId + const saved = open ? splitStore.spec : (projectsRef.current.projects.views[projectId] ?? projectsRef.current.projects.layouts.find((l) => l.id === projectId)) + // 逐项解析目标窗格;同窗格冲突时保留先出现者(防止把两个窗口压进同一个窗格互相覆盖) + const targets: { row: 'left' | 'top' | 'main'; index: number; content: any }[] = [] + for (const it of items) { + if (!it) continue + const content = buildMountContent(folder, it) + if (!content) continue + const pane = windowLabelToPane(saved, it.window) + const row = pane?.row ?? 'main' + const index = pane?.index ?? 0 + if (targets.some((t) => t.row === row && t.index === index)) continue + targets.push({ row, index, content }) + } + if (!targets.length) return + if (open) { + // 项目开着:逐窗锁定;全部落位成功才记录指纹(有失败则留待下次自愈重试) + let allOk = true + for (const t of targets) { + splitStore.lockPane(t.row, t.index, t.content) + if (!paneExists(splitStore.spec, t.row, t.index)) allOk = false + } + if (allOk) recordMountedWidget(projectId, raw) } else { // 项目没开:按项目已保存的 spec 解析目标窗格,暂存,打开项目时锁死补挂 - const saved = projectsRef.current.projects.views[projectId] ?? projectsRef.current.projects.layouts.find((l) => l.id === projectId) - const pane = windowLabelToPane(saved, d.window) - pendingMountRef.current[projectId] = { content, row: pane?.row ?? 'main', index: pane?.index ?? 0 } + pendingMountRef.current[projectId] = { entries: targets, fingerprint: raw } try { localStorage.setItem('dsh.worktable.pendingMount.v1', JSON.stringify(pendingMountRef.current)) } catch {} } } catch { /* 无清单文件 = 不挂载 */ } }, []) + /** 自动挂载:绑定会话完成 → 读项目文件夹 widget-result.json → 产物自动挂进对应窗口 */ + const tryAutoMount = useCallback(async (projectId: string, sid: string) => { + void sid + await applyWidgetManifest(projectId, null) + }, [applyWidgetManifest]) + // 完成事件 → 尝试自动挂载(一次完成只消费一次) useEffect(() => { const byId = sessionsSnapshotStore.snapshot?.byId ?? {} @@ -1690,6 +1746,30 @@ function buildCustomLayoutPrompt(req: string): string { } }, [notifyTick, projects.bindings, tryAutoMount]) + // 自愈扫挂:插件加载 / 项目文件夹变化时,扫描每个项目的 widget-result.json 并落位 + // (「完成事件」通道的兜底:事件丢失、绑定错位、完成时页面未加载等场景下仍能自动挂载; + // 按清单原文指纹去重——同清单不重复锁定,用户后续手动改窗口内容不会被覆盖)。 + useEffect(() => { + let cancelled = false + const folders = projects.folders ?? {} + ;(async () => { + for (const pid of Object.keys(folders)) { + if (cancelled) return + const folder = folders[pid] + if (!folder) continue + try { + const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(folder + '\\widget-result.json'), { cache: 'no-store' }) + if (!r.ok) continue + const raw = (await r.text()).trim() + if (!raw) continue + if (mountedWidgetRef.current[pid] === raw) continue + await applyWidgetManifest(pid, raw) + } catch { /* 单项目失败不影响其余 */ } + } + })() + return () => { cancelled = true } + }, [projects.folders, applyWidgetManifest]) + /** 收集某会话的子代理 id 集:byId 的 parentId 标注 + subagentsByParent 目录双通道 */ const collectKids = useCallback((sid: string): Set => { const kids = new Set() diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index 07ff35e..3c0278c 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -1472,3 +1472,16 @@ - 验证(probe-batch18):先切到 Pro 会话(deepseek-v4-pro)再新建 → repairedSelection = deepseek-v4-pro、promptOk:true ✓;functional-diag 全 20 STEP ERROR_COUNT: 0。 - 备注:纯客户端改动,F5 即生效。 + +## 补记(第二十轮:宿主 0.1.1-rc.2 升级事故恢复验收) + +- 事故:rc.2 升级后 link: 挂载插件(dsh-reminder/dsh-worktable 等)peer 依赖解析回归, + 插件树加载失败、服务启动崩溃。另一 Agent 完成诊断(loader 原生 ESM import + realpath + + 符号链接三者叠加;--preserve-symlinks 破坏 native 模块不可用)并已建好临时修复: + E:\AI_Workspace\DeepseekHarness\node_modules junction → C:\Users\SJL\.dsh\profiles\node_modules。 +- 我们侧修复核查:① junction 已就位(覆盖本目录所有 link 插件),上游修好后必须删除; + ② dsh-worktable package.json peerDependencies 全部 "*" + optional(干净),inject 里的 + ui-slots/ui-primitives 是运行时服务注入、无版本解析,不删;无需改代码。 +- 恢复验收:/api/worktable/health {ok:true}、/workspaces 200;functional-diag 全 20 STEP + ERROR_COUNT: 0(分栏/绑定/终端/文件/控制室全链路正常)。 +- 备注:待上游 loader 修复 + npm 停更 peer 声明清理后,移除 junction 并再跑一次全量回归。 diff --git a/AGENTS.md b/AGENTS.md index aadf026..c06d453 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,22 @@ node --check lib/index.js 失败静默回退内存路径 lastTextOf。拉取是带宽成本不是 Token 成本。 - 状态指示:卡片右上角小圆点已删(整卡光效表达状态);状态计算不变。 +## 宿主升级事故(0.1.1-rc.2 link 插件回归) + +- 现象:rc.2 的 cordis-plugin-loader 对裸包名走原生 ESM import()(realpath 解析), + link: 挂载的自研插件(dsh-worktable 等服务端 cordis 插件)找不到 profile 里的 peer + 依赖,插件树加载失败、服务启动即崩。--preserve-symlinks 不可用(破坏 pnpm 虚拟存储 + 下 sharp/koffi 等 native 模块)。 +- 临时修复(已验证):在 link 插件共同祖先目录建 junction: + `E:\AI_Workspace\DeepseekHarness\node_modules` → `C:\Users\SJL\.dsh\profiles\node_modules` + (覆盖本目录下所有 link 插件;另一个 Agent 已建好)。**上游 loader 修复后必须删除该 + junction**,避免双解析叠加。 +- 我们仓库侧核查结论:package.json peerDependencies 全部 `"*"` + optional(干净); + dsh.client.inject 里的 dsh-client-ui-slots / dsh-client-ui-primitives 是运行时服务注入 + (无版本解析),工作正常,不删除。 +- 恢复后验收:functional-diag 全 20 STEP ERROR_COUNT: 0;/api/worktable/health ok、 + /workspaces 200。 + ## 安装 / 重启 - 注册:`dsh plugin --profile web add "link:/01_content"`(写 ~/.dsh,需用户授权)。