diff --git a/01_content/README.md b/01_content/README.md index 19d65aa..d0377b1 100644 --- a/01_content/README.md +++ b/01_content/README.md @@ -8,15 +8,21 @@ - **分栏工作区引擎(自研)**:声明式布局预设(左栏/顶行/主行 + 右侧对话窗),窗格可拖拽分割、标签页模型;内置 资源管理器 / 终端 / 浏览器 / 动画 / 自定义窗口。 - **控制室**:默认自带项目(固定首位、不可删除),项目卡片网格实时监控所有项目的状态(工作中/待你决定/已完成),零轮询零 Token。 - **自动挂载握手**:项目内 agent 完成窗口任务后写 widget-result.json,产物自动挂进对应窗口。 -- **平台**:Windows 是当前完整验证平台;macOS 为实验性支持(核心文件路径代码已做跨平台适配,尚未真机端到端验证)。 +- **平台**:Windows 是本轮验证平台,范围见下方兼容说明;macOS 为实验性支持(核心文件路径代码已做跨平台适配,尚未真机端到端验证)。 ## 技术底座 - Cordis 插件协议(客户端 bundle + 服务端路由 /api/worktable/*、终端 WebSocket)。 -- 界面经 slot 座位协议注入侧边栏与 shell.overlay;对话窗复用宿主会话服务(sessions.open)。 +- 界面经 slot 座位协议注入侧边栏与 shell.overlay;对话窗复用宿主会话服务(新版 uiWorkspace.openSession,旧版 sessions.open)。 - 状态监控为宿主会话运行时快照的事件订阅镜像;视图/项目/绑定状态存 localStorage。 - 客户端:TypeScript + React(host externals)+ 原生 CSS;服务端:Node。 +## v0.3.4 兼容与验证范围 + +- DSH 网页端兼容声明仅列 **0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2**,不是对任意新旧版本的保证。 +- 本轮在 Windows + 0.2.0-rc.2 验证冷启动、工作台/控制室、新建并绑定对话、短文本发送与模型回复、项目关闭回切和重新打开;旧版沿用此前页面验收并补接口回退与标注引用坐标回归,未重跑整套旧版 GUI。 +- 实际审批、子代理、自动挂载全链路尚未完成业务验收;官方桌面端未适配,macOS 未真机验证。升级后请完整重启 DSH 并刷新页面,仍加载失败需保留完整错误。 + ## 权限与外部服务(如实披露) - **文件访问**:服务端读写工作区与用户指定的目录——文件窗目录列表、MD 编辑保存、项目文件夹 mkdir、站点静态托管、widget 产物读取。除用户当次操作外,插件在加载时、项目状态变化时、绑定会话完成时会自动读取已配置项目的数据(如挂载产物 widget-result.json、项目文件夹与绑定信息),并读取宿主提供的工作区数据(workspaces 路由)。读写路径均来自用户配置或宿主数据;失败返回错误或降级。 diff --git a/01_content/dsh.plugin.json b/01_content/dsh.plugin.json index ded9c8d..d25e525 100644 --- a/01_content/dsh.plugin.json +++ b/01_content/dsh.plugin.json @@ -1,7 +1,7 @@ { "name": "dsh-worktable", "description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目", - "version": "0.3.3", + "version": "0.3.4", "entry": { "name": "dsh-worktable", "inject": [] diff --git a/01_content/lib/client.js b/01_content/lib/client.js index 37c3506..51c5649 100644 --- a/01_content/lib/client.js +++ b/01_content/lib/client.js @@ -8896,7 +8896,29 @@ function basenameOf(p) { var import_react = require("react"); // src/client/changelog.ts -var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.3 +var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.4 + +\u{1F6E0}\uFE0F \u672C\u6B21\u66F4\u65B0\uFF1A\u9002\u914D DSH 0.2 \u7F51\u9875\u7AEF + +\u3010\u9879\u76EE\u4E0E\u4F1A\u8BDD\u8054\u52A8\u3011\u63A5\u5165\u65B0\u7248 DSH \u7684\u4F1A\u8BDD\u8BC6\u522B\u4E0E\u8DF3\u8F6C\u63A5\u53E3\uFF0C\u9002\u914D\u6253\u5F00\u7ED1\u5B9A\u9879\u76EE\u3001\u5173\u95ED\u9879\u76EE\u540E\u56DE\u5230\u539F\u4F1A\u8BDD\u3001\u63A7\u5236\u5BA4\u5361\u7247\u8DF3\u8F6C\u7B49\u5165\u53E3\uFF0C\u4E0D\u66FF\u6362\u5BBF\u4E3B\u5B98\u65B9\u7EC4\u4EF6\u3002 + +\u3010\u65B0\u5EFA\u5BF9\u8BDD\u3011\u4FEE\u590D\u65B0\u7248 DSH \u4E2D\u53EA\u8BBE\u7F6E\u9879\u76EE\u8DEF\u5F84\u521B\u5EFA\u4F1A\u8BDD\uFF0C\u53EF\u80FD\u51FA\u73B0\u300C\u5BF9\u8BDD\u80FD\u6253\u5F00\uFF0C\u5374\u65E0\u6CD5\u8F93\u5165\u300D\u7684\u95EE\u9898\u3002\u65B0\u5EFA\u65F6\u5148\u901A\u8FC7\u5BBF\u4E3B\u767B\u8BB0\u6216\u590D\u7528\u5DE5\u4F5C\u533A\uFF0C\u518D\u521B\u5EFA\u4F1A\u8BDD\uFF1B\u9884\u8BBE\u4E0E\u6A21\u578B\u9009\u62E9\u4E5F\u63A5\u5165\u65B0\u7248\u63A5\u53E3\u3002\u6B64\u6539\u52A8\u4E0D\u81EA\u52A8\u8FC1\u79FB\u5DF2\u6709\u4F1A\u8BDD\u3002 + +\u3010\u6807\u6CE8\u8F93\u5165\u3011\u9002\u914D\u65B0\u7248\u804A\u5929\u8F93\u5165\u6846\uFF0C\u901A\u8FC7\u5BBF\u4E3B\u63A5\u53E3\u8FFD\u52A0\u6807\u6CE8\uFF0C\u4FDD\u7559\u5DF2\u6709\u8349\u7A3F\u4E0E\u6587\u4EF6\u5F15\u7528\uFF0C\u4E0D\u81EA\u52A8\u53D1\u9001\uFF1B\u65E0\u6CD5\u5199\u5165\u65F6\u4ECD\u53EF\u590D\u5236\u5230\u526A\u8D34\u677F\u3002\u4FEE\u590D\u65E7\u7248\u5DF2\u6709\u6587\u4EF6\u5F15\u7528\u65F6\u8FFD\u52A0\u4F4D\u7F6E\u8BA1\u7B97\u9519\u8BEF\u7684\u95EE\u9898\uFF1B\u65E7\u7248\u8F93\u5165\u6846\u4FDD\u7559\u517C\u5BB9\u5206\u652F\uFF0C\u4E0D\u518D\u8BEF\u9009\u5DE5\u4F5C\u53F0\u81EA\u5DF1\u7684\u6807\u6CE8\u6846\u6216\u6587\u4EF6\u7F16\u8F91\u6846\u3002 + +\u3010\u63A7\u5236\u5BA4\u72B6\u6001\u4E0E\u8BA1\u65F6\u3011\u9002\u914D\u65B0\u7248\u4F1A\u8BDD\u72B6\u6001\u3001\u5B50\u4EE3\u7406\u5217\u8868\u53CA\u6700\u8FD1\u6D88\u606F\u8BFB\u53D6\u3002\u652F\u6301\u8BC6\u522B\u65B0\u7684\u5F85\u786E\u8BA4\u4E8B\u9879\uFF1A\u786E\u8BA4\u8FC7\u4E0A\u4E00\u6761\u540E\uFF0C\u4E0B\u4E00\u6761\u4ECD\u4F1A\u91CD\u65B0\u4EAE\u8D77\u63D0\u9192\uFF1B\u8BA1\u65F6\u8BFB\u53D6\u5BBF\u4E3B\u5DF2\u6709\u7684\u4EFB\u52A1\u6216\u4F1A\u8BDD\u8D77\u70B9\uFF0C\u53D6\u4E0D\u5230\u6709\u6548\u8D77\u70B9\u65F6\u4E0D\u663E\u793A\uFF0C\u4E0D\u7F16\u9020\u8FD0\u884C\u65F6\u957F\u3002\u4EFB\u52A1\u505C\u6B62\u4E0D\u7B49\u4E8E\u4EFB\u52A1\u6210\u529F\u3002 + +\u3010\u53D1\u9001\u4E0E\u6D88\u606F\u9884\u89C8\u3011\u9002\u914D\u65B0\u7248\u4F1A\u8BDD\u7684\u4E34\u65F6\u6301\u6709\u4E0E\u91CA\u653E\u673A\u5236\u3002\u53D1\u9001\u8BF7\u6C42\u5931\u8D25\u6216\u88AB\u62D2\u7EDD\u540E\uFF0C\u4E0D\u518D\u6362\u53E6\u4E00\u6761\u901A\u9053\u91CD\u590D\u53D1\u9001\uFF1B\u63A7\u5236\u5BA4\u9884\u89C8\u4ECE\u5BBF\u4E3B\u5DF2\u52A0\u8F7D\u7684\u6D88\u606F\u7A97\u53E3\u63D0\u53D6\u666E\u901A\u6587\u672C\uFF0C\u4E0D\u4EE3\u8868\u8BFB\u53D6\u4E86\u5B8C\u6574\u5386\u53F2\u3002 + +\u3010\u6570\u636E\u4E0E\u5347\u7EA7\u3011\u672C\u6B21\u672A\u66F4\u540D\u6216\u8FC1\u79FB\u5DE5\u4F5C\u53F0\u7684\u9879\u76EE\u3001\u5E03\u5C40\u4E0E\u5A92\u4F53\u5B58\u50A8\uFF0C\u4E0D\u9700\u8981\u4E3A\u4E86\u672C\u6B21\u66F4\u65B0\u4E3B\u52A8\u6E05\u7A7A\u6D4F\u89C8\u5668\u6570\u636E\u3002\u5347\u7EA7\u524D\u8BF7\u5907\u4EFD\u914D\u7F6E\u53CA\u91CD\u8981\u6570\u636E\uFF0C\u786E\u8BA4\u5176\u4ED6\u63D2\u4EF6\u4E5F\u652F\u6301\u76EE\u6807 DSH \u7248\u672C\uFF1B\u5B89\u88C5\u540E\u5B8C\u6574\u9000\u51FA\u5E76\u91CD\u542F DSH\uFF0C\u518D\u5237\u65B0\u9875\u9762\u3002\u82E5\u4ECD\u52A0\u8F7D\u5931\u8D25\uFF0C\u8BF7\u4FDD\u7559\u5B8C\u6574\u9519\u8BEF\u4FE1\u606F\u53CD\u9988\uFF0C\u4E0D\u8981\u628A\u5B83\u5F53\u4F5C\u6B63\u5E38\u73B0\u8C61\u3002 + +\u3010\u9A8C\u8BC1\u8303\u56F4\u3011\u672C\u8F6E\u5728 Windows + DSH 0.2.0-rc.2 \u7F51\u9875\u7AEF\u9A8C\u8BC1\u4E86\u51B7\u542F\u52A8\u3001\u5DE5\u4F5C\u53F0/\u63A7\u5236\u5BA4\u9875\u9762\u3001\u901A\u8FC7\u5DE5\u4F5C\u53F0\u65B0\u5EFA\u5E76\u7ED1\u5B9A\u5BF9\u8BDD\u3001\u53D1\u9001\u77ED\u6587\u672C\u5E76\u6536\u5230\u6A21\u578B\u56DE\u590D\u3001\u5173\u95ED\u9879\u76EE\u540E\u56DE\u5207\u4E0E\u91CD\u65B0\u6253\u5F00\uFF1B\u540C\u65F6\u8865\u5145\u65E7\u7248\u670D\u52A1\u56DE\u9000\u4E0E\u5F15\u7528\u5750\u6807\u7B49\u5B9A\u5411\u56DE\u5F52\u3002\u517C\u5BB9\u58F0\u660E\u4EC5\u5217 0.1.1-rc.2\u30010.1.2-rc.1\u30010.2.0-rc.2\uFF1B\u524D\u4E24\u4E2A\u7248\u672C\u6CBF\u7528\u6B64\u524D\u9875\u9762\u9A8C\u6536\u57FA\u7840\u5E76\u505A\u4EE3\u7801\u56DE\u5F52\uFF0C\u672C\u8F6E\u672A\u91CD\u8DD1\u6574\u5957\u65E7\u7248 GUI\uFF0C\u4E0D\u627F\u8BFA\u5176\u4ED6\u7248\u672C\u5168\u90E8\u517C\u5BB9\u3002 + +\u3010\u5C1A\u672A\u95ED\u73AF\u3011\u5B9E\u9645\u5BA1\u6279/\u5B50\u4EE3\u7406\u6D41\u7A0B\u53CA\u81EA\u52A8\u6302\u8F7D\u5168\u94FE\u8DEF\u5C1A\u672A\u5B8C\u6210\u4E1A\u52A1\u9A8C\u6536\u3002\u5B98\u65B9\u684C\u9762\u7AEF\u5C1A\u672A\u9002\u914D\uFF0CmacOS \u5C1A\u672A\u771F\u673A\u9A8C\u8BC1\uFF1B\u672C\u6B21\u4E5F\u4E0D\u4EE3\u8868\u6240\u6709\u7528\u6237\u7684 DSH \u542F\u52A8\u5931\u8D25\u5DF2\u89E3\u51B3\u3002\u516C\u5F00\u53D1\u5E03\u4FE1\u606F\u4EE5 GitHub Release \u4E3A\u51C6\u3002 + +\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + +\u6B64\u524D\u7248\u672C\u8BF4\u660E\uFF08v0.3.3\uFF09 \u{1F6E0}\uFE0F \u672C\u7248\u672C\u66F4\u65B0 @@ -8948,7 +8970,7 @@ var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.3 \u30103.1 \u6587\u5B57\u4E0E\u7EC6\u8282\u6253\u78E8\u3011\u5168\u5C40\u5B57\u4F53\u4E0E\u5B57\u53F7\u7EDF\u4E00\u4F18\u5316\u3001\u4E0B\u62C9\u9762\u677F\u73BB\u7483\u5316\u4E0E\u5BF9\u9F50\u3001\u83DC\u5355\u70B9\u9009\u540E\u4FDD\u6301\u6253\u5F00\u4FBF\u4E8E\u8FDE\u7EED\u9884\u89C8\u3001\u6309\u94AE\u63CF\u8FB9\u4E0E\u60AC\u505C\u53CD\u9988\u7B49\u4EA4\u4E92\u7EC6\u8282\uFF1B\u540C\u65F6\u4FEE\u590D\u4E86\u591A\u9879\u4F53\u9A8C\u95EE\u9898\uFF08\u7167\u7247\u4E0A\u4F20\u6E05\u6670\u5EA6\u3001\u80CC\u666F\u7F51\u683C\u7EBF\u5728\u7167\u7247\u6A21\u5F0F\u4E0B\u4E0D\u751F\u6548\u3001\u6D45\u8272\u4E3B\u9898\u4E0B\u5DE5\u4F5C\u72B6\u6001\u5149\u6548\u4E0D\u53EF\u89C1\u7B49\uFF09\u3002`; // src/client/updateCheck.ts -var LOCAL_VERSION = false ? "dev" : "0.3.3"; +var LOCAL_VERSION = false ? "dev" : "0.3.4"; var UPDATE_REPO = "Aisland-SJL/dsh-worktable"; var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1"; var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1"; @@ -17098,24 +17120,7 @@ async function copyTextSafe(text2) { } } function fillHostInput(text2) { - try { - const ta = document.querySelector("textarea[data-phase]") ?? Array.from(document.querySelectorAll("textarea")).pop(); - if (!ta) return false; - const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; - const next = ta.value && ta.value.trim() ? ta.value + "\n\n" + text2 : text2; - if (setter) setter.call(ta, next); - else ta.value = next; - ta.dispatchEvent(new Event("input", { bubbles: true })); - try { - ta.focus(); - ta.dispatchEvent(new Event("change", { bubbles: true })); - ta.setSelectionRange(ta.value.length, ta.value.length); - } catch { - } - return true; - } catch { - return false; - } + return splitEnv?.fillHostInput(text2) ?? false; } function confirmAnnot() { const s = annotState; @@ -19940,6 +19945,255 @@ try { } catch { } +// src/client/sessionCompat.ts +function currentSessionOf(snapshot) { + if (typeof snapshot?.current === "string") return snapshot.current; + const rows = Object.values(snapshot?.byId ?? {}); + return rows.find((row) => (row?.retainedBy?.mainView ?? 0) > 0)?.id ?? ""; +} +function openHostSession(ctx, sessions, id) { + const ui = typeof ctx?.get === "function" ? ctx.get("uiWorkspace") : ctx?.uiWorkspace; + if (typeof ui?.openSession === "function") return ui.openSession(id); + if (typeof sessions?.open === "function") return sessions.open(id); + throw new Error("Host session navigation unavailable"); +} +async function sendHostSession(bridge, id, text2) { + const sessions = bridge?.sessions; + const check = (result) => { + if (result?.kind === "error" || result?.ok === false) { + throw new Error(result.text || result.error?.message || result.error?.code || "Session submission rejected"); + } + }; + const send = async (session) => { + if (session && typeof bridge?.conversation?.sendSession === "function") { + check(await bridge.conversation.sendSession(session, text2, [], "queue")); + return; + } + if (session && typeof session.prompt === "function") { + check(await session.prompt([{ type: "text", text: text2 }], "queue")); + return; + } + const conv = sessions?.scope?.(id)?.get?.("conversation"); + if (typeof conv?.send === "function") { + check(await conv.send(text2)); + return; + } + throw new Error("no send path: session face unavailable"); + }; + if (typeof sessions?.using === "function") { + await sessions.using(id, { source: "dshWorktable" }, async (ref) => { + const binding = await ref.ready; + await send(binding.session); + }); + return; + } + let face = null; + for (let i = 0; i < 10; i++) { + face = sessions?.binding?.(id)?.session; + if (face) break; + await new Promise((resolve) => setTimeout(resolve, 200)); + } + await send(face); +} +function createSessionSnapshotAdapter() { + const previousRunning = /* @__PURE__ */ new Map(); + const stopped = /* @__PURE__ */ new Set(); + return (snapshot, statusSource) => { + if (!snapshot || typeof snapshot.current === "string") return snapshot; + const statuses = statusSource?.getSnapshot?.(); + const byId = {}; + const subagentsByParent = { ...snapshot.subagentsByParent }; + for (const [id, row] of Object.entries(snapshot.byId ?? {})) { + const status = statuses?.get?.(id); + const running = status?.running ?? row.running; + if (running === true) stopped.delete(id); + else if (running === false && previousRunning.get(id) === true) stopped.add(id); + if (typeof running === "boolean") previousRunning.set(id, running); + byId[id] = { + ...row, + running, + pendingInteraction: status ? status.pendingInteraction : row.pendingInteraction, + // completionUnread clears when opening the main view. Keep a witnessed run-stop + // until the next run so an active project's widget-result handshake still runs. + completed: running !== true && (stopped.has(id) || status?.completionUnread === true || row.completed === true) + }; + const children = snapshot.projectionsBySession?.[id]?.values?.subagentCatalog ?? row.projectionValues?.subagentCatalog; + if (Array.isArray(children)) subagentsByParent[id] = children; + } + if (snapshot.phase === "ready") { + for (const id of previousRunning.keys()) if (!byId[id]) { + previousRunning.delete(id); + stopped.delete(id); + } + } + return { ...snapshot, byId, subagentsByParent }; + }; +} +function childSessionIdsOf(snapshot, sid) { + const children = /* @__PURE__ */ new Set(); + for (const [id, row] of Object.entries(snapshot?.byId ?? {})) { + if (id !== sid && row?.parentId === sid) children.add(id); + } + const catalog = snapshot?.subagentsByParent?.[sid]; + const rows = Array.isArray(catalog) ? catalog : catalog?.entries ?? catalog?.items ?? []; + if (Array.isArray(rows)) for (const row of rows) { + const id = row?.sessionId ?? row?.id; + if (typeof id === "string" && id !== sid) children.add(id); + } + return children; +} +function pendingAckOf(snapshot, sid, legacyFace) { + const identities = []; + let identified = false; + const add = (id, pending) => { + if (pending == null) return; + const key = typeof pending?.key === "string" ? pending.key : ""; + if (key) identified = true; + identities.push(JSON.stringify([id, String(pending?.kind ?? ""), key])); + }; + const ids = [sid, ...childSessionIdsOf(snapshot, sid)]; + for (const id of ids) add(id, snapshot?.byId?.[id]?.pendingInteraction); + if (snapshot?.byId?.[sid]?.pendingInteraction == null && Array.isArray(legacyFace?.pending)) { + for (const pending of legacyFace.pending) add(sid, pending); + } + if (!identities.length) return null; + return identified ? "need:" + JSON.stringify([...new Set(identities)].sort()) : "need"; +} +function sessionRuntimeMs(snapshot, sid, sessions, uiConversation, now = Date.now()) { + if (snapshot?.byId?.[sid]?.running !== true) return null; + const validStart = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0; + let start = null; + for (const job of snapshot?.jobsBySession?.[sid] ?? []) { + if (job?.status === "running" && validStart(job.startedAt) && (start == null || job.startedAt < start)) start = job.startedAt; + } + if (start != null) return Math.max(0, now - start); + let timings; + try { + timings = uiConversation?.binding?.(sid)?.target?.("chat")?.getSnapshot?.()?.legacy?.turnTimings; + } catch { + } + if (timings == null) { + try { + timings = sessions?.binding?.(sid)?.session?.getSnapshot?.()?.turnTimings; + } catch { + } + } + const turns = typeof timings?.values === "function" ? Array.from(timings.values()) : Object.values(timings ?? {}); + for (const turn of turns) { + if (turn?.endTime == null && validStart(turn?.startTime) && (start == null || turn.startTime > start)) start = turn.startTime; + } + return start == null ? null : Math.max(0, now - start); +} + +// src/client/hostInput.ts +function appendHostInput(bridge, text2, doc = document) { + try { + const resolver = bridge?.conversation?.input; + if (typeof resolver?.for === "function") { + const snapshot = bridge.sessions?.list?.getSnapshot?.(); + const id = currentSessionOf(snapshot); + const scope = id ? bridge.sessions?.scope?.(id) : null; + if (!scope) return false; + const input = resolver.for(scope); + const state = input?.state?.getSnapshot?.(); + if (!state || typeof state.draft !== "string" || !Number.isInteger(state.draftRev) || !["plain", "claimed"].includes(state.phase) || !Array.isArray(state.occurrences)) return false; + const compactReferences = typeof snapshot?.current !== "string"; + let end = state.draft.length; + let previousEnd = 0; + for (const item of state.occurrences) { + if (!Number.isInteger(item.offset) || !Number.isInteger(item.length) || item.length < 0 || item.offset < previousEnd || item.offset + item.length > state.draft.length) return false; + previousEnd = item.offset + item.length; + if (compactReferences) end += 1 - item.length; + } + const span = { start: end, end, draftRev: state.draftRev }; + const addition = (state.draft.trim() ? "\n\n" : "") + text2; + const inserted = typeof input.actions?.insertText === "function" ? input.actions.insertText(addition, span) === true : scope.bail?.("slash/input-insert-text", { text: addition, span }) === true; + if (inserted) { + try { + input.focus?.(); + } catch { + } + } + return inserted; + } + const ta = doc.querySelector("textarea[data-phase]"); + if (!ta || ta.disabled || ta.readOnly || ta.closest(".dsh-wt_annotUi")) return false; + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + const next = ta.value && ta.value.trim() ? ta.value + "\n\n" + text2 : text2; + if (setter) setter.call(ta, next); + else ta.value = next; + ta.dispatchEvent(new Event("input", { bubbles: true })); + try { + ta.focus(); + ta.dispatchEvent(new Event("change", { bubbles: true })); + ta.setSelectionRange(ta.value.length, ta.value.length); + } catch { + } + return true; + } catch { + return false; + } +} + +// src/client/sessionDetails.ts +function cleanPreviewText(raw) { + return String(raw ?? "").replace(/```[a-zA-Z0-9_+-]*[\s\S]*?```/g, " ").replace(/```[a-zA-Z0-9_+-]*[\s\S]*$/g, " ").replace(/`[^`\n]{1,200}`/g, " ").replace(/```/g, " ").replace(/\s+/g, " ").trim(); +} +function previewFromEvents(entries) { + if (!Array.isArray(entries)) return ""; + for (let i = entries.length - 1; i >= 0; i--) { + const ev = entries[i]?.event; + if (ev?.type !== "user/message" && ev?.type !== "assistant/message") continue; + const data = ev.type === "assistant/message" ? ev.data?.message ?? ev.data : ev.data; + const blocks = data?.content ?? data?.blocks; + if (!Array.isArray(blocks)) continue; + const raw = blocks.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n"); + const text2 = cleanPreviewText(raw); + if (text2.length >= 8) return text2.slice(0, 220); + } + return ""; +} +async function readSessionPreview(sessions, id) { + if (typeof sessions?.using === "function") { + return sessions.using(id, { source: "dshWorktablePreview" }, async (ref) => { + const binding = await ref.ready; + return previewFromEvents(binding.eventSource?.getSnapshot?.()?.entries); + }); + } + const face = sessions?.binding?.(id)?.session; + if (typeof face?.history !== "function") return ""; + const result = await face.history({ maxMessages: 6 }); + return previewFromEvents(result?.result?.value?.events); +} +function presetApiOf(ctx, legacy) { + const api = typeof ctx?.get === "function" ? ctx.get("remote.agentPresets") : ctx?.remote?.agentPresets; + if (typeof api?.list !== "function" || typeof api?.select !== "function") return legacy; + return { + list: async () => ({ result: await api.list() }), + select: async ({ sessionId, agentPreset }) => ({ result: await api.select(sessionId, agentPreset) }) + }; +} +function modelApiOf(ctx, sessions, legacy) { + if (typeof sessions?.using !== "function") return legacy; + const resolver = typeof ctx?.get === "function" ? ctx.get("modelDirectories") : ctx?.modelDirectories; + if (typeof resolver?.directoryFor !== "function") return legacy; + const withDirectory = (id, operation) => sessions.using(id, { source: "dshWorktableModel" }, async (ref) => { + await ref.ready; + return operation(resolver.directoryFor(id)); + }); + return { + models: async ({ sessionId }) => withDirectory(sessionId, async (directory) => ({ result: { ok: true, value: await directory.load() } })), + selectModel: async ({ sessionId, ...selection }) => withDirectory(sessionId, async (directory) => ({ result: await directory.select(selection) })) + }; +} +async function createHostSession(sessions, workspaces, options = {}) { + if (options.workspaceId || typeof sessions?.using !== "function") return sessions.create(options); + const workspace = options.cwd ? await workspaces?.create?.({ path: options.cwd }) : await workspaces?.initializeDefault?.(); + if (typeof workspace?.workspaceId !== "string" || !workspace.workspaceId) throw new Error("workspace unavailable for new session"); + const { cwd: _cwd, ...rest } = options; + return sessions.create({ ...rest, workspaceId: workspace.workspaceId }); +} + // src/client/photoStore.ts var DB_NAME = "dsh-worktable"; var STORE = "photoRecords"; @@ -20104,7 +20358,7 @@ var WAVE_BG_B64 = "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQUFBAYFBQUHBgYHCQ8KCQgIC // src/client/index.tsx var import_jsx_runtime2 = require("react/jsx-runtime"); -var LOCAL_VERSION2 = false ? "dev" : "0.3.3"; +var LOCAL_VERSION2 = false ? "dev" : "0.3.4"; var UPDATE_REPO2 = "Aisland-SJL/dsh-worktable"; var UPGRADE_CMD2 = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"'; var UPGRADE_AI2 = "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\uFF1A\u6267\u884C " + UPGRADE_CMD2 + "\uFF0C\u5B8C\u6210\u540E\u63D0\u9192\u6211\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762"; @@ -20391,7 +20645,7 @@ var sessionBridge = null; var clientCtx = null; var hostApi = null; async function ensureSessionPreset(sessionId) { - const api = hostApi?.agentPresets; + const api = presetApiOf(clientCtx, hostApi?.agentPresets); if (!api || typeof api.list !== "function" || typeof api.select !== "function") return; try { const listRes = await api.list({}); @@ -20447,7 +20701,7 @@ function pickInheritedModel(groups, baseModel) { return { provider: flat[0].provider, model: flat[0].model }; } async function ensureSessionModel(sessionId) { - const api = hostApi?.sessions; + const api = modelApiOf(clientCtx, sessionBridge?.sessions, hostApi?.sessions); if (!api || typeof api.models !== "function" || typeof api.selectModel !== "function") return; try { const mRes = await api.models({ sessionId }); @@ -20457,7 +20711,7 @@ async function ensureSessionModel(sessionId) { let target = null; let effort; try { - const cur = sessionBridge?.list?.getSnapshot?.()?.current; + const cur = currentSessionOf(sessionBridge?.list?.getSnapshot?.()); if (cur && cur !== sessionId) { const cRes = await api.models({ sessionId: cur }); if (cRes?.result?.ok) { @@ -20551,51 +20805,10 @@ function listWorkspaces() { return []; } } -function cleanPreviewText(raw) { - let s = String(raw ?? ""); - s = s.replace(/```[a-zA-Z0-9_+-]*[\s\S]*?```/g, " "); - s = s.replace(/```[a-zA-Z0-9_+-]*[\s\S]*$/g, " "); - s = s.replace(/`[^`\n]{1,200}`/g, " "); - s = s.replace(/```/g, " "); - s = s.replace(/\s+/g, " ").trim(); - return s; -} var previewCache = /* @__PURE__ */ new Map(); var previewFetching = /* @__PURE__ */ new Set(); var previewSweepBusy = false; var previewTimer = null; -async function coldPreviewOf(face) { - if (!face || typeof face.history !== "function") return ""; - const r1 = await face.history({ maxMessages: 6 }); - const evs = r1?.result?.value?.events; - if (!Array.isArray(evs)) return ""; - const textOf = (blocks) => { - let fallback = ""; - if (!Array.isArray(blocks)) return ""; - for (const b of blocks) { - const s = typeof b?.text === "string" ? b.text.trim() : ""; - if (!s) continue; - if (b?.type === "text") return s; - if (!fallback) fallback = s; - } - return fallback; - }; - for (let i = evs.length - 1; i >= 0; i--) { - const ev = evs[i]?.event; - if (!ev) continue; - const d = ev.data ?? {}; - let raw = ""; - if (ev.type === "user/message") raw = textOf(d.content ?? d.blocks); - else if (ev.type === "assistant/message") { - const m = d.message ?? d; - raw = textOf(m.content ?? m.blocks); - } - if (!raw) continue; - const clean = cleanPreviewText(raw); - if (clean.length >= 8) return clean.slice(0, 220); - } - return ""; -} async function sweepPreviews() { if (previewSweepBusy) return; previewSweepBusy = true; @@ -20605,12 +20818,9 @@ async function sweepPreviews() { if (previewFetching.has(sid)) continue; previewFetching.add(sid); try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session; - const txt = await coldPreviewOf(face); - if (txt) { - previewCache.set(sid, txt); - notifyConsole(); - } + const txt = await readSessionPreview(sessionBridge?.sessions, sid); + previewCache.set(sid, txt); + notifyConsole(); } catch { } finally { previewFetching.delete(sid); @@ -20621,7 +20831,7 @@ async function sweepPreviews() { } } function schedulePreviewSweep() { - if (previewTimer != null) return; + if (previewSweepBusy || previewTimer != null) return; previewTimer = window.setTimeout(() => { previewTimer = null; sweepPreviews(); @@ -20629,7 +20839,9 @@ function schedulePreviewSweep() { } function lastTextOf(sid) { try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.(); + const binding = sessionBridge?.sessions?.binding?.(sid); + if (binding?.eventSource?.getSnapshot) return previewFromEvents(binding.eventSource.getSnapshot().entries); + const face = binding?.session?.getSnapshot?.(); const nodes = face?.nodes ?? []; for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i]; @@ -20656,7 +20868,7 @@ async function fetchSessionGroups() { try { const snap = sessionBridge?.list?.getSnapshot?.(); const byId = snap?.byId ?? {}; - const current = snap?.current ?? ""; + const current = currentSessionOf(snap); const subKids = /* @__PURE__ */ new Set(); try { const map2 = snap?.subagentsByParent ?? {}; @@ -20723,41 +20935,7 @@ function hideBindTip() { async function promptIntoSession(sessionId, text2) { const b = sessionBridge; if (!b) throw new Error("bridge unavailable"); - const sessions = b.sessions; - let session = null; - for (let i = 0; i < 10; i++) { - try { - session = sessions?.binding?.(sessionId)?.session ?? null; - } catch { - session = null; - } - if (session) break; - await new Promise((r) => setTimeout(r, 200)); - } - if (session) { - if (typeof b.conversation?.sendSession === "function") { - try { - await b.conversation.sendSession(session, text2, [], "queue"); - return; - } catch { - } - } - if (typeof session.prompt === "function") { - const result = await session.prompt([{ type: "text", text: text2 }], "queue"); - if (result && result.ok) return; - if (result && !result.ok) throw new Error("session.prompt: " + (result.error?.code ?? "rejected") + (result.error?.message ? ": " + result.error.message : "")); - } - } - try { - const scoped = sessions?.scope?.(sessionId); - const conv = scoped?.get?.("conversation"); - if (conv && typeof conv.send === "function") { - await conv.send(text2); - return; - } - } catch { - } - throw new Error("no send path: session face unavailable"); + await sendHostSession(b, sessionId, text2); } var KNOWLEDGE_PACK = [ "\u3010\u63D2\u4EF6\u77E5\u8BC6\u5305\xB7\u8BF7\u76F4\u63A5\u91C7\u7528\uFF0C\u4E0D\u8981\u91CD\u65B0\u4FA6\u5BDF\u63D2\u4EF6\u6E90\u7801\u3011", @@ -20828,12 +21006,12 @@ async function createCustomSession(projectId, projectName, requirement, group, w let createOpts = {}; if (workspaceId) createOpts = { workspaceId }; else if (folder) createOpts = { cwd: folder }; - const sessionId = await b.sessions.create(createOpts); + const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts); await ensureSessionPreset(sessionId); await ensureSessionModel(sessionId); markPluginSessionOpen(sessionId); try { - await b.sessions.open?.(sessionId); + await openHostSession(clientCtx, b.sessions, sessionId); } catch { } await promptIntoSession(sessionId, text2); @@ -20845,7 +21023,7 @@ async function sendCustomToSession(sessionId, projectId, projectName, requiremen const text2 = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, "send"); markPluginSessionOpen(sessionId); try { - await b.sessions.open?.(sessionId); + await openHostSession(clientCtx, b.sessions, sessionId); } catch { } await promptIntoSession(sessionId, text2); @@ -20955,10 +21133,21 @@ function sessionNotifyState(entry) { if (entry.completed === true) return "done"; return null; } +var sessionStatusSource = null; +var sessionConversationSource = null; +var adaptSessionSnapshot = createSessionSnapshotAdapter(); +function pendingAckForSession(sid) { + let face; + try { + face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.(); + } catch { + } + return pendingAckOf(sessionsSnapshotStore.snapshot, sid, face); +} function syncSessionScope(list2) { try { - const snap = list2.getSnapshot(); - const current = snap?.current ?? ""; + const snap = adaptSessionSnapshot(list2.getSnapshot(), sessionStatusSource); + const current = currentSessionOf(snap); const entry = snap?.byId?.[current] ?? snap?.items?.find((it) => it.sessionId === current) ?? null ?? null; const cat = snap?.subagentsByParent?.[current]; let subagents = []; @@ -21190,32 +21379,13 @@ function WorktableSection(props) { const pr = projectsRef.current; const snap = sessionsSnapshotStore.snapshot; const byId = snap?.byId ?? {}; - const jobsMap = snap?.jobsBySession ?? {}; const now = Date.now(); - const kidsSetOf = (sid) => { - const set = /* @__PURE__ */ new Set(); - for (const [cid, ce] of Object.entries(byId)) if (ce?.parentId === sid) set.add(cid); - const v = (snap?.subagentsByParent ?? {})[sid]; - const arr = Array.isArray(v) ? v : v?.entries ?? v?.items ?? []; - if (Array.isArray(arr)) arr.forEach((c) => { - const cid = c?.sessionId ?? c?.id; - if (typeof cid === "string") set.add(cid); - }); - return set; - }; + const kidsSetOf = (sid) => childSessionIdsOf(snap, sid); const statusOf = (sid) => { if (!sid) return "idle"; const e = byId[sid]; if (!e) return "idle"; - if (sessionNotifyState(e) === "need") return "need"; - for (const cid of kidsSetOf(sid)) { - if (byId[cid] && sessionNotifyState(byId[cid]) === "need") return "need"; - } - try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.(); - if (Array.isArray(face?.pending) && face.pending.length > 0) return "need"; - } catch { - } + if (pendingAckForSession(sid) != null) return "need"; if (e.completed === true) return "done"; try { const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.(); @@ -21227,36 +21397,13 @@ function WorktableSection(props) { }; const runtimeOf = (sid) => { if (!sid) return null; - const e = byId[sid]; - if (!e || e.running !== true) return null; - let start = null; - const jobs = jobsMap[sid] ?? []; - for (const j of jobs) { - if (j?.status === "running" && typeof j.startedAt === "number" && (start == null || j.startedAt < start)) start = j.startedAt; - } - if (start != null) return Math.max(0, now - start); - try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.(); - const timings = face?.turnTimings; - if (timings instanceof Map) { - for (const t2 of Array.from(timings.values()).reverse()) { - if (t2 && typeof t2.startTime === "number" && t2.endTime == null) return Math.max(0, now - t2.startTime); - } - } else if (timings && typeof timings === "object") { - for (const k of Object.keys(timings).reverse()) { - const t2 = timings[k]; - if (t2 && typeof t2.startTime === "number" && t2.endTime == null) return Math.max(0, now - t2.startTime); - } - } - } catch { - } - return null; + return sessionRuntimeMs(snap, sid, sessionBridge?.sessions, sessionConversationSource, now); }; const ackMap = loadNotifyAck(); const make = (id, name, icon, self2) => { const sid = pr.projects.bindings[id]; const status = statusOf(sid); - const glow = !!sid && (status === "done" && ackMap[sid] !== "done" || status === "need" && ackMap[sid] !== "need"); + const glow = !!sid && (status === "done" && ackMap[sid] !== "done" || status === "need" && ackMap[sid] !== pendingAckForSession(sid)); return { id, name, @@ -21297,6 +21444,7 @@ function WorktableSection(props) { }; (0, import_react2.useEffect)(() => { const env = { + fillHostInput: (text2) => appendHostInput(sessionBridge, text2), getScope: () => { const s = sessionScopeStore.snapshot; return s ? { sessionId: s.sessionId, cwd: s.cwd } : null; @@ -21439,7 +21587,7 @@ function WorktableSection(props) { const bound = pr.bindings[id]; if (bound) { try { - sessionBridge?.sessions?.open?.(bound); + openHostSession(clientCtx, sessionBridge?.sessions, bound); } catch { } } @@ -21450,7 +21598,7 @@ function WorktableSection(props) { if (!sid) return; markPluginSessionOpen(sid); try { - sessionBridge?.sessions?.open?.(sid); + openHostSession(clientCtx, sessionBridge?.sessions, sid); } catch { } } @@ -21511,7 +21659,7 @@ function WorktableSection(props) { const prev = projectAttachRef.sessionId; if (prev) { try { - sessionBridge?.sessions?.open?.(prev); + openHostSession(clientCtx, sessionBridge?.sessions, prev); } catch { } } @@ -21670,7 +21818,7 @@ function WorktableSection(props) { if (splitStore.active && splitStore.spec?.id === spec.id) { let prev = null; try { - prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null; + prev = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) || null; } catch { } projectAttachRef.sessionId = prev; @@ -21678,7 +21826,7 @@ function WorktableSection(props) { const bound = projectsRef.current.projects.bindings[spec.id]; if (bound) { try { - sessionBridge?.sessions?.open?.(bound); + openHostSession(clientCtx, sessionBridge?.sessions, bound); } catch { } } @@ -21713,7 +21861,7 @@ function WorktableSection(props) { if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) { let prev = null; try { - prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null; + prev = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) || null; } catch { } projectAttachRef.sessionId = prev; @@ -21721,7 +21869,7 @@ function WorktableSection(props) { projectAttachRef.attached = bound ?? prev; if (bound) { try { - sessionBridge?.sessions?.open?.(bound); + openHostSession(clientCtx, sessionBridge?.sessions, bound); } catch { } } @@ -21792,7 +21940,7 @@ function WorktableSection(props) { let createOpts = {}; if (workspaceId) createOpts = { workspaceId }; else if (folder) createOpts = { cwd: folder }; - const sessionId = await b.sessions.create(createOpts); + const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts); await ensureSessionPreset(sessionId); await ensureSessionModel(sessionId); markPluginSessionOpen(sessionId); @@ -22060,20 +22208,7 @@ function WorktableSection(props) { }; }, [projects.folders, applyWidgetManifest]); const collectKids = (0, import_react2.useCallback)((sid) => { - const kids = /* @__PURE__ */ new Set(); - const snap = sessionsSnapshotStore.snapshot; - const byId = snap?.byId ?? {}; - for (const [cid, ce] of Object.entries(byId)) { - if (ce?.parentId === sid) kids.add(cid); - } - const subMap = snap?.subagentsByParent ?? {}; - const v = subMap[sid]; - const arr = Array.isArray(v) ? v : v?.entries ?? v?.items ?? []; - if (Array.isArray(arr)) arr.forEach((c) => { - const cid = c?.sessionId ?? c?.id; - if (typeof cid === "string") kids.add(cid); - }); - return kids; + return childSessionIdsOf(sessionsSnapshotStore.snapshot, sid); }, []); const bindNotifyMap = (0, import_react2.useMemo)(() => { const map2 = {}; @@ -22083,29 +22218,16 @@ function WorktableSection(props) { for (const [pid, sid] of Object.entries(projects.bindings)) { const e = byId[sid]; if (!e) continue; - let needNow = sessionNotifyState(e) === "need"; - if (!needNow) { - for (const cid of collectKids(sid)) { - const ce = byId[cid]; - if (ce && sessionNotifyState(ce) === "need") { - needNow = true; - break; - } + const pendingAck = pendingAckForSession(sid); + if (pendingAck !== seen[sid]) { + if (seen[sid] !== void 0) { + clearNotifyAck(sid); + delete ack[sid]; } + seen[sid] = pendingAck; } - if (!needNow) { - try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.(); - if (Array.isArray(face?.pending) && face.pending.length > 0) needNow = true; - } catch { - } - } - if (needNow !== seen[sid]) { - if (seen[sid] !== void 0) clearNotifyAck(sid); - seen[sid] = needNow; - } - if (needNow) { - if (ack[sid] !== "need") map2[pid] = "need"; + if (pendingAck != null) { + if (ack[sid] !== pendingAck) map2[pid] = "need"; continue; } if (sessionNotifyState(e) === "done" && ack[sid] !== "done") { @@ -22120,22 +22242,20 @@ function WorktableSection(props) { const sid = projectsRef.current.projects.bindings[projectId]; if (!sid) return; const byId = sessionsSnapshotStore.snapshot?.byId ?? {}; - let needNow = sessionNotifyState(byId[sid]) === "need"; + const pendingAck = pendingAckForSession(sid); for (const cid of collectKids(sid)) { if (sessionNotifyState(byId[cid]) === "need") { - needNow = true; - saveNotifyAck(cid, "need"); + const childAck = pendingAckForSession(cid); + if (childAck != null) { + saveNotifyAck(cid, childAck); + notifyStateSeenRef.current[cid] = childAck; + } } } - if (!needNow) { - try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.(); - if (Array.isArray(face?.pending) && face.pending.length > 0) needNow = true; - } catch { - } - } - if (needNow) saveNotifyAck(sid, "need"); - else { + if (pendingAck != null) { + saveNotifyAck(sid, pendingAck); + notifyStateSeenRef.current[sid] = pendingAck; + } else { const st = sessionNotifyState(byId[sid]); if (st === "done") saveNotifyAck(sid, "done"); } @@ -22479,7 +22599,7 @@ function WorktableSection(props) { const bound = projectsRef.current.projects.bindings[pid]; if (bound) { try { - sessionBridge?.sessions?.open?.(bound); + openHostSession(clientCtx, sessionBridge?.sessions, bound); } catch { } } @@ -22579,7 +22699,7 @@ function WorktableSection(props) { const bound = pr.bindings[id]; if (bound) { try { - sessionBridge?.sessions?.open?.(bound); + openHostSession(clientCtx, sessionBridge?.sessions, bound); } catch { } } @@ -23393,7 +23513,7 @@ function apply(ctx) { } catch { } try { - window.__dshOpenSession = (id) => ctx.sessions?.open?.(id); + window.__dshOpenSession = (id) => openHostSession(ctx, ctx.sessions, id); window.__dshSessions = ctx.sessions; window.__dshPromptIntoSession = (id, text2) => promptIntoSession(id, text2); window.__dshWorkspaces = ctx.workspaces; @@ -23454,9 +23574,29 @@ function apply(ctx) { ctx.effect(() => disposeSubscribe, "dsh-worktable: project registry watch"); const sessionsList = ctx.sessions?.list; if (sessionsList && typeof sessionsList.getSnapshot === "function") { + adaptSessionSnapshot = createSessionSnapshotAdapter(); syncSessionScope(sessionsList); const disposeScope = sessionsList.subscribe(() => syncSessionScope(sessionsList)); ctx.effect(() => disposeScope, "dsh-worktable: session scope watch"); + ctx.inject(["uiSession"], (statusCtx) => { + const source = statusCtx.uiSession?.sessionStatus; + if (!source?.getSnapshot || !source?.subscribe) return; + sessionStatusSource = source; + syncSessionScope(sessionsList); + const disposeStatus = source.subscribe(() => syncSessionScope(sessionsList)); + statusCtx.effect(() => () => { + disposeStatus(); + if (sessionStatusSource === source) sessionStatusSource = null; + }, "dsh-worktable: session status watch"); + }); + ctx.inject(["uiConversation"], (conversationCtx) => { + const source = conversationCtx.uiConversation; + if (typeof source?.binding !== "function") return; + sessionConversationSource = source; + conversationCtx.effect(() => () => { + if (sessionConversationSource === source) sessionConversationSource = null; + }, "dsh-worktable: conversation timer source"); + }); } ctx.slots.inject("shell.overlay", () => ctx.slots.register({ name: "shell.overlay", diff --git a/01_content/lib/index.js b/01_content/lib/index.js index d4fa695..2f5e77f 100644 --- a/01_content/lib/index.js +++ b/01_content/lib/index.js @@ -115,7 +115,7 @@ function resolveDshHomeSafe() { cachedDshHome = baseDshHome(); return cachedDshHome; } -var PLUGIN_VERSION = false ? "dev" : "0.3.3"; +var PLUGIN_VERSION = false ? "dev" : "0.3.4"; var name = "dsh-worktable"; var inject = ["webServer", "sessions"]; var HEALTH_PATH = "/api/worktable/health"; diff --git a/01_content/package.json b/01_content/package.json index 23be9a4..b575612 100644 --- a/01_content/package.json +++ b/01_content/package.json @@ -1,7 +1,7 @@ { "name": "dsh-worktable", "description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)", - "version": "0.3.3", + "version": "0.3.4", "repository": { "type": "git", "url": "https://github.com/Aisland-SJL/dsh-worktable.git", @@ -39,10 +39,11 @@ ] }, "compatibility": { - "dsh": "0.1.1-rc.2 || 0.1.2-rc.1", + "dsh": "0.1.1-rc.2 || 0.1.2-rc.1 || 0.2.0-rc.2", "dshReleases": { "0.1.1-rc.2": "compatible", - "0.1.2-rc.1": "compatible" + "0.1.2-rc.1": "compatible", + "0.2.0-rc.2": "compatible" } } }, @@ -51,6 +52,9 @@ "check": "node --check lib/index.js && node --check lib/client.js", "pack": "node release-prep.mjs", "test:gate": "node client-factory-gate.test.mjs", + "test:input": "node ../04_test/host-input.test.mjs", + "test:session": "node ../04_test/session-compat.test.mjs", + "test:details": "node ../04_test/session-details.test.mjs", "verify:remote": "node verify-remote.mjs" }, "peerDependencies": { diff --git a/01_content/release-prep.mjs b/01_content/release-prep.mjs index 3a2bbc7..63aebf2 100644 --- a/01_content/release-prep.mjs +++ b/01_content/release-prep.mjs @@ -82,7 +82,12 @@ if (patchOk && (patchEntries[0].id !== IDENTITY || patchEntries[0].name !== IDEN if (!patchOk) process.exit(1) console.log('[release-prep] identity check passed (' + IDENTITY + ', cordis.patch.yml strict)') -// ---------- 2. 构建 + 语法检查 ---------- +// ---------- 2. 会话/输入兼容回归 + 构建 + 语法检查 ---------- +const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs'] + .map((file) => join(HERE, '..', '04_test', file)) +const compatRun = spawnSync(process.execPath, ['--test', ...compatTests], { cwd: HERE, stdio: 'inherit' }) +if (compatRun.status !== 0) { console.error('[release-prep] FAIL: session/input compatibility tests'); process.exit(1) } +console.log('[release-prep] session/input compatibility tests passed (source contracts)') // 构建必带 cwd: HERE(脚本自身位置=01_content),否则从仓库根调用会输出到根 lib/ 造成旧包 const build = spawnSync(process.execPath, [join(HERE, 'build.mjs')], { stdio: 'inherit', cwd: HERE }) if (build.status !== 0) { console.error('[release-prep] FAIL: build failed'); process.exit(1) } diff --git a/01_content/src/client/changelog.ts b/01_content/src/client/changelog.ts index 269b358..66b8310 100644 --- a/01_content/src/client/changelog.ts +++ b/01_content/src/client/changelog.ts @@ -1,6 +1,28 @@ -/** 更新公告正文(v0.3.3:DSH 0.1.2 兼容 + 数据路径修复;呈现时按纯文本保留换行,不做 md 渲染) */ -export const CHANGELOG_VERSION = 'v0.3.3' -export const CHANGELOG_V030 = `更新公告 · v0.3.3 +/** 更新公告正文(v0.3.4:DSH 0.2 网页端适配;呈现时按纯文本保留换行,不做 md 渲染) */ +export const CHANGELOG_VERSION = 'v0.3.4' +export const CHANGELOG_V030 = `更新公告 · v0.3.4 + +🛠️ 本次更新:适配 DSH 0.2 网页端 + +【项目与会话联动】接入新版 DSH 的会话识别与跳转接口,适配打开绑定项目、关闭项目后回到原会话、控制室卡片跳转等入口,不替换宿主官方组件。 + +【新建对话】修复新版 DSH 中只设置项目路径创建会话,可能出现「对话能打开,却无法输入」的问题。新建时先通过宿主登记或复用工作区,再创建会话;预设与模型选择也接入新版接口。此改动不自动迁移已有会话。 + +【标注输入】适配新版聊天输入框,通过宿主接口追加标注,保留已有草稿与文件引用,不自动发送;无法写入时仍可复制到剪贴板。修复旧版已有文件引用时追加位置计算错误的问题;旧版输入框保留兼容分支,不再误选工作台自己的标注框或文件编辑框。 + +【控制室状态与计时】适配新版会话状态、子代理列表及最近消息读取。支持识别新的待确认事项:确认过上一条后,下一条仍会重新亮起提醒;计时读取宿主已有的任务或会话起点,取不到有效起点时不显示,不编造运行时长。任务停止不等于任务成功。 + +【发送与消息预览】适配新版会话的临时持有与释放机制。发送请求失败或被拒绝后,不再换另一条通道重复发送;控制室预览从宿主已加载的消息窗口提取普通文本,不代表读取了完整历史。 + +【数据与升级】本次未更名或迁移工作台的项目、布局与媒体存储,不需要为了本次更新主动清空浏览器数据。升级前请备份配置及重要数据,确认其他插件也支持目标 DSH 版本;安装后完整退出并重启 DSH,再刷新页面。若仍加载失败,请保留完整错误信息反馈,不要把它当作正常现象。 + +【验证范围】本轮在 Windows + DSH 0.2.0-rc.2 网页端验证了冷启动、工作台/控制室页面、通过工作台新建并绑定对话、发送短文本并收到模型回复、关闭项目后回切与重新打开;同时补充旧版服务回退与引用坐标等定向回归。兼容声明仅列 0.1.1-rc.2、0.1.2-rc.1、0.2.0-rc.2;前两个版本沿用此前页面验收基础并做代码回归,本轮未重跑整套旧版 GUI,不承诺其他版本全部兼容。 + +【尚未闭环】实际审批/子代理流程及自动挂载全链路尚未完成业务验收。官方桌面端尚未适配,macOS 尚未真机验证;本次也不代表所有用户的 DSH 启动失败已解决。公开发布信息以 GitHub Release 为准。 + +─────── + +此前版本说明(v0.3.3) 🛠️ 本版本更新 diff --git a/01_content/src/client/hostInput.ts b/01_content/src/client/hostInput.ts new file mode 100644 index 0000000..4f08e5f --- /dev/null +++ b/01_content/src/client/hostInput.ts @@ -0,0 +1,54 @@ +import { currentSessionOf } from './sessionCompat' + +/** Append annotations without sending or replacing existing reference chips. */ +export function appendHostInput(bridge: any, text: string, doc: Document = document): boolean { + try { + const resolver = bridge?.conversation?.input + if (typeof resolver?.for === 'function') { + const snapshot = bridge.sessions?.list?.getSnapshot?.() + const id = currentSessionOf(snapshot) + const scope = id ? bridge.sessions?.scope?.(id) : null + if (!scope) return false + const input = resolver.for(scope) + const state = input?.state?.getSnapshot?.() + if (!state || typeof state.draft !== 'string' || !Number.isInteger(state.draftRev) + || !['plain', 'claimed'].includes(state.phase) || !Array.isArray(state.occurrences)) return false + // DSH 0.1's current-based list uses display-text coordinates. DSH 0.2's + // mainView list uses one U+FFFC unit per chip in insertion coordinates. + const compactReferences = typeof snapshot?.current !== 'string' + let end = state.draft.length + let previousEnd = 0 + for (const item of state.occurrences) { + if (!Number.isInteger(item.offset) || !Number.isInteger(item.length) + || item.length < 0 || item.offset < previousEnd + || item.offset + item.length > state.draft.length) return false + previousEnd = item.offset + item.length + if (compactReferences) end += 1 - item.length + } + const span = { start: end, end, draftRev: state.draftRev } + const addition = (state.draft.trim() ? '\n\n' : '') + text + // Public action face provides revision/phase guards and an undo boundary. + // The scoped event is the public compatibility path when actions are absent. + const inserted = typeof input.actions?.insertText === 'function' + ? input.actions.insertText(addition, span) === true + : scope.bail?.('slash/input-insert-text', { text: addition, span }) === true + if (inserted) { try { input.focus?.() } catch {} } + return inserted + } + // Legacy DSH 0.1 textarea. Never select an arbitrary textarea: it may be + // the annotation editor itself or an open Worktable file editor. + const ta = doc.querySelector('textarea[data-phase]') + if (!ta || ta.disabled || ta.readOnly || ta.closest('.dsh-wt_annotUi')) return false + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + const next = ta.value && ta.value.trim() ? ta.value + '\n\n' + text : text + if (setter) setter.call(ta, next) + else ta.value = next + ta.dispatchEvent(new Event('input', { bubbles: true })) + try { + ta.focus() + ta.dispatchEvent(new Event('change', { bubbles: true })) + ta.setSelectionRange(ta.value.length, ta.value.length) + } catch {} + return true + } catch { return false } +} diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 5d2bdd4..226d8cd 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -3,6 +3,9 @@ import { css } from './styles' import { NS, zh, en, type WorktableKey } from './locales' import { isAbs, joinPath, parentPathOf, basenameOf } from './pathutil' import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane, type ConsoleCardData } from './split' +import { appendHostInput } from './hostInput' +import { currentSessionOf, openHostSession, sendHostSession, createSessionSnapshotAdapter, childSessionIdsOf, pendingAckOf, sessionRuntimeMs } from './sessionCompat' +import { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession } from './sessionDetails' import { photoStore, kindOf } from './photoStore' import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg' import { WAVE_BG_B64 } from './waveBg' @@ -421,7 +424,7 @@ let hostApi: { agentPresets?: any; sessions?: any } | null = null /** 新会话显式应用「部署默认预设」:宿主新会话座位同款逻辑(api.agentPresets.select,仅对 blank 会话生效)。 * 修复:用户删掉默认模型后,新建会话继承到失效预设,prompt 报 model-unavailable 导致窗口建不出来。 */ async function ensureSessionPreset(sessionId: string): Promise { - const api = hostApi?.agentPresets + const api = presetApiOf(clientCtx, hostApi?.agentPresets) if (!api || typeof api.list !== 'function' || typeof api.select !== 'function') return try { const listRes = await api.list({}) @@ -475,7 +478,7 @@ function pickInheritedModel(groups: any[], baseModel: string | undefined): { pro * 不擅自选模型——① 优先继承用户当前会话正在用的模型;② 兜底按失效选择里的模型家族词 * 找同款;③ 最后才目录首个。session.selectModel 同时把新选择存为默认,顺带修复后续新会话。 */ async function ensureSessionModel(sessionId: string): Promise { - const api = hostApi?.sessions + const api = modelApiOf(clientCtx, sessionBridge?.sessions, hostApi?.sessions) if (!api || typeof api.models !== 'function' || typeof api.selectModel !== 'function') return try { const mRes = await api.models({ sessionId }) @@ -486,7 +489,7 @@ async function ensureSessionModel(sessionId: string): Promise { let effort: string | undefined // ① 无条件继承用户当前会话的选择(用户正在用哪个模型,新会话就用哪个) try { - const cur = sessionBridge?.list?.getSnapshot?.()?.current + const cur = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) if (cur && cur !== sessionId) { const cRes = await api.models({ sessionId: cur }) if (cRes?.result?.ok) { @@ -582,57 +585,14 @@ function listWorkspaces(): { id: string; title: string; path: string }[] { } catch { return [] } } -/** 预览文本清洗:去掉围栏代码块(```…```,含 dsh-ui 等)与行内代码,压缩空白; - * 代码为主的片段会被滤空 → 调用方回退到更早的消息。只清洗显示用副本,不改原文。 */ -function cleanPreviewText(raw: string): string { - let s = String(raw ?? '') - s = s.replace(/```[a-zA-Z0-9_+-]*[\s\S]*?```/g, ' ') - s = s.replace(/```[a-zA-Z0-9_+-]*[\s\S]*$/g, ' ') - s = s.replace(/`[^`\n]{1,200}`/g, ' ') - s = s.replace(/```/g, ' ') - s = s.replace(/\s+/g, ' ').trim() - return s -} - -/** 冷会话最近消息缓存与预热:宿主 history 只读通道(face.history 为运行期内建方法,非公开接口, - * 只读侦察确认可用;拉取是带宽成本不是 Token 成本,模型不参与)。 */ +/** 冷会话最近消息缓存与预热:0.2 临时持有会话、读 eventSource 后释放;旧版保留 history 通道。 + * 只读消息文本,不请求模型;拉取是带宽成本,不产生模型 Token。 */ const previewCache = new Map() const previewFetching = new Set() let previewSweepBusy = false let previewTimer: number | null = null -/** 从 history 事件流尾部提取最近一条成品消息文本(优先 text 块;清洗代码后仍太短则回退更早消息) */ -async function coldPreviewOf(face: any): Promise { - if (!face || typeof face.history !== 'function') return '' - const r1 = await face.history({ maxMessages: 6 }) - const evs = r1?.result?.value?.events - if (!Array.isArray(evs)) return '' - const textOf = (blocks: any): string => { - let fallback = '' - if (!Array.isArray(blocks)) return '' - for (const b of blocks) { - const s = typeof b?.text === 'string' ? b.text.trim() : '' - if (!s) continue - if (b?.type === 'text') return s - if (!fallback) fallback = s - } - return fallback - } - for (let i = evs.length - 1; i >= 0; i--) { - const ev = evs[i]?.event - if (!ev) continue - const d = ev.data ?? {} - let raw = '' - if (ev.type === 'user/message') raw = textOf(d.content ?? d.blocks) - else if (ev.type === 'assistant/message') { const m = d.message ?? d; raw = textOf(m.content ?? m.blocks) } - if (!raw) continue - const clean = cleanPreviewText(raw) - if (clean.length >= 8) return clean.slice(0, 220) - } - return '' -} - -/** 预热所有已绑定会话的最近消息(逐个只读拉取;已在拉/已拉过未完成的跳过,失败静默回退内存路径) */ +/** 预热所有已绑定会话的最近消息(逐个只读拉取;同一在途请求不重复,失败回退缓存/内存路径) */ async function sweepPreviews() { if (previewSweepBusy) return previewSweepBusy = true @@ -642,9 +602,9 @@ async function sweepPreviews() { if (previewFetching.has(sid)) continue previewFetching.add(sid) try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session - const txt = await coldPreviewOf(face) - if (txt) { previewCache.set(sid, txt); notifyConsole() } + const txt = await readSessionPreview(sessionBridge?.sessions, sid) + previewCache.set(sid, txt) + notifyConsole() } catch {} finally { previewFetching.delete(sid) } } } finally { previewSweepBusy = false } @@ -652,14 +612,17 @@ async function sweepPreviews() { /** 防抖调度:控制室开着且会话快照变化时刷新预览(合并 6s 内的连续变化) */ function schedulePreviewSweep() { - if (previewTimer != null) return + // Temporary retain/release also changes the catalog: do not schedule ourselves forever. + if (previewSweepBusy || previewTimer != null) return previewTimer = window.setTimeout(() => { previewTimer = null; sweepPreviews() }, 6000) } /** 从会话快照节点里提取最近一条文本(纯读内存镜像,零 Token;代码清洗后无则 '') */ function lastTextOf(sid: string): string { try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() + const binding = sessionBridge?.sessions?.binding?.(sid) + if (binding?.eventSource?.getSnapshot) return previewFromEvents(binding.eventSource.getSnapshot().entries) + const face = binding?.session?.getSnapshot?.() const nodes: any[] = face?.nodes ?? [] for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i] @@ -684,7 +647,7 @@ async function fetchSessionGroups(): Promise<{ groups: { title: string; sessions try { const snap = sessionBridge?.list?.getSnapshot?.() const byId = snap?.byId ?? {} - const current = snap?.current ?? '' + const current = currentSessionOf(snap) // 子代理会话(后台产生的,用户面板看不到)排除 const subKids = new Set() try { @@ -763,33 +726,7 @@ function hideBindTip() { async function promptIntoSession(sessionId: string, text: string): Promise { const b = sessionBridge if (!b) throw new Error('bridge unavailable') - const sessions = b.sessions as any - // 新会话入列可能异步:最多等 2s 直到 binding 可解析 - let session: any = null - for (let i = 0; i < 10; i++) { - try { session = sessions?.binding?.(sessionId)?.session ?? null } catch { session = null } - if (session) break - await new Promise((r) => setTimeout(r, 200)) - } - if (session) { - // 1) 宿主包装(正确签名:会话面 + 空图片 + queue 投递) - if (typeof b.conversation?.sendSession === 'function') { - try { await b.conversation.sendSession(session, text, [], 'queue'); return } catch { /* 包装失败则直连 */ } - } - // 2) 直连会话面 prompt(宿主 sendSession 内部同款路径) - if (typeof session.prompt === 'function') { - const result = await session.prompt([{ type: 'text', text }], 'queue') - if (result && result.ok) return - if (result && !result.ok) throw new Error('session.prompt: ' + (result.error?.code ?? 'rejected') + (result.error?.message ? ': ' + result.error.message : '')) - } - } - // 3) 备用:作用域上下文里取 conversation 服务再发 - try { - const scoped = sessions?.scope?.(sessionId) - const conv = scoped?.get?.('conversation') - if (conv && typeof conv.send === 'function') { await conv.send(text); return } - } catch { /* 落入最终报错 */ } - throw new Error('no send path: session face unavailable') + await sendHostSession(b, sessionId, text) } /** 新建会话的分组选择:未分组 / 加入现有分组 / 新建一个分组(父目录 + 名称) */ @@ -870,11 +807,11 @@ export async function createCustomSession(projectId: string, projectName: string let createOpts: any = {} if (workspaceId) createOpts = { workspaceId } else if (folder) createOpts = { cwd: folder } - const sessionId = await b.sessions.create(createOpts) + const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts) await ensureSessionPreset(sessionId) // 新会话应用部署默认预设 await ensureSessionModel(sessionId) // 修复继承失效 provider(selectModel 同步存默认,顺带修复后续新会话) markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动 - try { await b.sessions.open?.(sessionId) } catch {} + try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {} await promptIntoSession(sessionId, text) return sessionId } @@ -885,7 +822,7 @@ export async function sendCustomToSession(sessionId: string, projectId: string, if (!b) throw new Error('bridge unavailable') const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'send') markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动 - try { await b.sessions.open?.(sessionId) } catch {} + try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {} await promptIntoSession(sessionId, text) } @@ -915,7 +852,7 @@ const sessionScopeStore: { /** 完整会话快照(模块级):项目卡片的任务完成/待决提醒镜像数据源 */ const sessionsSnapshotStore: { snapshot: any | null; listeners: Set<() => void> } = { snapshot: null, listeners: new Set() } -/** 提醒确认(ack)持久化:会话 id → 已确认的状态('done' | 'need') */ +/** 提醒确认:会话 id → done / 旧 need / need:不含正文的待决身份集合。 */ function loadNotifyAck(): Record { try { const raw = localStorage.getItem('dsh.worktable.notifyAck.v1') @@ -986,8 +923,8 @@ function buildMountContent(folder: string, d: any): any { return { kind: 'iframe', url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(name), title: name } } -/** 每会话最近一次观察到的「需要判断」布尔值:状态转移时清除旧 ack(新一轮待决重新点亮) */ -const notifyStateSeenRef: { current: Record } = { current: {} } +/** 每会话最近观察到的待决身份;同为 need 的问题替换也必须重新点亮。 */ +const notifyStateSeenRef: { current: Record } = { current: {} } /** 每会话最近观察到的「已完成」布尔值:新一次完成时清除旧 done ack(控制室绿光重新点亮) */ const doneSeenRef: { current: Record } = { current: {} } /** 清除某会话的 ack 记录(状态转移时调用,保证新问题不会被旧确认压住) */ @@ -1010,10 +947,18 @@ function sessionNotifyState(entry: any): 'done' | 'need' | null { return null } +let sessionStatusSource: any = null +let sessionConversationSource: any = null +let adaptSessionSnapshot = createSessionSnapshotAdapter() +function pendingAckForSession(sid: string): string | null { + let face: any + try { face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() } catch {} + return pendingAckOf(sessionsSnapshotStore.snapshot, sid, face) +} function syncSessionScope(list: any) { try { - const snap = list.getSnapshot() - const current: string = snap?.current ?? '' + const snap = adaptSessionSnapshot(list.getSnapshot(), sessionStatusSource) + const current = currentSessionOf(snap) const entry = snap?.byId?.[current] ?? snap?.items?.find((it: any) => it.sessionId === current) ?? null ?? null const cat = snap?.subagentsByParent?.[current] let subagents: any[] = [] @@ -1249,32 +1194,14 @@ function WorktableSection(props: any) { const pr = projectsRef.current const snap = sessionsSnapshotStore.snapshot const byId: Record = snap?.byId ?? {} - const jobsMap: Record = snap?.jobsBySession ?? {} const now = Date.now() - const kidsSetOf = (sid: string): Set => { - const set = new Set() - for (const [cid, ce] of Object.entries(byId)) if (ce?.parentId === sid) set.add(cid) - const v = (snap?.subagentsByParent ?? {})[sid] - const arr = Array.isArray(v) ? v : (v?.entries ?? v?.items ?? []) - if (Array.isArray(arr)) arr.forEach((c: any) => { - const cid = c?.sessionId ?? c?.id - if (typeof cid === 'string') set.add(cid) - }) - return set - } + const kidsSetOf = (sid: string) => childSessionIdsOf(snap, sid) // 三态判定(同卡片提醒逻辑,但不过滤 ack——监控室永远显示事实状态) const statusOf = (sid: string | undefined): ConsoleCardData['status'] => { if (!sid) return 'idle' const e = byId[sid] if (!e) return 'idle' - if (sessionNotifyState(e) === 'need') return 'need' - for (const cid of kidsSetOf(sid)) { - if (byId[cid] && sessionNotifyState(byId[cid]) === 'need') return 'need' - } - try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() - if (Array.isArray(face?.pending) && face.pending.length > 0) return 'need' - } catch {} + if (pendingAckForSession(sid) != null) return 'need' if (e.completed === true) return 'done' // 会话面兜底:宿主快照可能滞后或缺少完成字段 try { @@ -1284,39 +1211,17 @@ function WorktableSection(props: any) { if (e.running === true) return 'busy' return 'idle' } - // 运行时长:本会话正在运行的后台任务最早 startedAt 起算;无任务时读会话面 turnTimings 未结束轮次 + // 运行时长:任务 startedAt → 新版 chat legacy.turnTimings → 旧会话面;未知不虚构。 const runtimeOf = (sid: string | undefined): number | null => { if (!sid) return null - const e = byId[sid] - if (!e || e.running !== true) return null - let start: number | null = null - const jobs = jobsMap[sid] ?? [] - for (const j of jobs) { - if (j?.status === 'running' && typeof j.startedAt === 'number' && (start == null || j.startedAt < start)) start = j.startedAt - } - if (start != null) return Math.max(0, now - start) - try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() - const timings = face?.turnTimings - if (timings instanceof Map) { - for (const t of Array.from(timings.values()).reverse()) { - if (t && typeof t.startTime === 'number' && t.endTime == null) return Math.max(0, now - t.startTime) - } - } else if (timings && typeof timings === 'object') { - for (const k of Object.keys(timings).reverse()) { - const t = (timings as any)[k] - if (t && typeof t.startTime === 'number' && t.endTime == null) return Math.max(0, now - t.startTime) - } - } - } catch {} - return null + return sessionRuntimeMs(snap, sid, sessionBridge?.sessions, sessionConversationSource, now) } const ackMap = loadNotifyAck() const make = (id: string, name: string, icon: string, self: boolean): ConsoleCardData => { const sid = pr.projects.bindings[id] const status = statusOf(sid) // 发光 = 完成/待决且本轮未被确认;点卡片确认后熄灭 - const glow = !!sid && ((status === 'done' && ackMap[sid] !== 'done') || (status === 'need' && ackMap[sid] !== 'need')) + const glow = !!sid && ((status === 'done' && ackMap[sid] !== 'done') || (status === 'need' && ackMap[sid] !== pendingAckForSession(sid))) return { id, name, icon, status, @@ -1361,6 +1266,7 @@ function WorktableSection(props: any) { // 崩溃),改为 apply 里订阅 ctx.sessions.list 后写入模块级 store,此处直接读取。 useEffect(() => { const env = { + fillHostInput: (text: string) => appendHostInput(sessionBridge, text), getScope: () => { const s = sessionScopeStore.snapshot return s ? { sessionId: s.sessionId, cwd: s.cwd } : null @@ -1481,14 +1387,14 @@ function WorktableSection(props: any) { else { // 入驻项目无视图覆盖:仅切换其绑定对话(对齐卡片自带打开行为) const bound = pr.bindings[id] - if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} } + if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } } }, onJump: (id) => { const sid = projectsRef.current.projects.bindings[id] if (!sid) return markPluginSessionOpen(sid) // 插件发起的切换:不触发「切会话关项目」联动 - try { sessionBridge?.sessions?.open?.(sid) } catch {} + try { openHostSession(clientCtx, sessionBridge?.sessions, sid) } catch {} }, }, } @@ -1537,7 +1443,7 @@ function WorktableSection(props: any) { if (!splitStore.active) { if (!suppressRestoreRef.current) { const prev = projectAttachRef.sessionId - if (prev) { try { sessionBridge?.sessions?.open?.(prev) } catch {} } + if (prev) { try { openHostSession(clientCtx, sessionBridge?.sessions, prev) } catch {} } } projectAttachRef.sessionId = null projectAttachRef.attached = null @@ -1722,11 +1628,11 @@ function buildCustomLayoutPrompt(req: string): string { if (splitStore.active && splitStore.spec?.id === spec.id) { // 记录打开前会话(关项目时回切)与该项目「归属会话」(切到别的会话 = 自动关项目) let prev: string | null = null - try { prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null } catch {} + try { prev = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) || null } catch {} projectAttachRef.sessionId = prev projectAttachRef.attached = projectsRef.current.projects.bindings[spec.id] ?? prev const bound = projectsRef.current.projects.bindings[spec.id] - if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} } + if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } ackProjectNotify(spec.id) // 补挂:此前项目未打开时暂存的产物(entries = 多窗口挂载列表),现在自动挂进各目标窗格; // 全部落位成功记录指纹(供自愈扫挂去重) @@ -1760,14 +1666,14 @@ function buildCustomLayoutPrompt(req: string): string { splitStore.open(spec) if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) { let prev: string | null = null - try { prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null } catch {} + try { prev = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) || null } catch {} projectAttachRef.sessionId = prev const bound = explicitBound !== undefined ? explicitBound : projectsRef.current.projects.bindings[CONSOLE_ID] projectAttachRef.attached = bound ?? prev - if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} } + if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } ackProjectNotify(CONSOLE_ID) try { notifyConsole() } catch {} - // 打开即预热所有绑定会话的最近消息(冷会话走 history 只读通道) + // 打开即预热所有绑定会话的最近消息(新宿主临时持有,旧宿主 history) try { sweepPreviews() } catch {} } }, [projects.views, t]) @@ -1819,7 +1725,7 @@ function buildCustomLayoutPrompt(req: string): string { let createOpts: any = {} if (workspaceId) createOpts = { workspaceId } else if (folder) createOpts = { cwd: folder } - const sessionId = await b.sessions.create(createOpts) + const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts) await ensureSessionPreset(sessionId) // 管理对话同样应用部署默认预设 await ensureSessionModel(sessionId) // 管理对话同样修复失效 provider markPluginSessionOpen(sessionId) @@ -2095,20 +2001,7 @@ function buildCustomLayoutPrompt(req: string): string { /** 收集某会话的子代理 id 集:byId 的 parentId 标注 + subagentsByParent 目录双通道 */ const collectKids = useCallback((sid: string): Set => { - const kids = new Set() - const snap = sessionsSnapshotStore.snapshot - const byId = snap?.byId ?? {} - for (const [cid, ce] of Object.entries(byId)) { - if (ce?.parentId === sid) kids.add(cid) - } - const subMap = snap?.subagentsByParent ?? {} - const v = (subMap as any)[sid] - const arr = Array.isArray(v) ? v : (v?.entries ?? v?.items ?? []) - if (Array.isArray(arr)) arr.forEach((c: any) => { - const cid = c?.sessionId ?? c?.id - if (typeof cid === 'string') kids.add(cid) - }) - return kids + return childSessionIdsOf(sessionsSnapshotStore.snapshot, sid) }, []) /** 项目 → 提醒态:绑定会话(含其子代理)待决(黄) > 完成(绿) > 工作中(蓝); @@ -2121,26 +2014,13 @@ function buildCustomLayoutPrompt(req: string): string { for (const [pid, sid] of Object.entries(projects.bindings)) { const e = byId[sid] if (!e) continue - // 是否需要判断:自身列表字段 / 子代理 / 会话面兜底,三通道聚合 - let needNow = sessionNotifyState(e) === 'need' - if (!needNow) { - for (const cid of collectKids(sid)) { - const ce = byId[cid] - if (ce && sessionNotifyState(ce) === 'need') { needNow = true; break } - } - } - if (!needNow) { - try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() - if (Array.isArray(face?.pending) && face.pending.length > 0) needNow = true - } catch {} - } + const pendingAck = pendingAckForSession(sid) // 状态转移 → 清除旧 ack(新一轮待决重新点亮) - if (needNow !== seen[sid]) { - if (seen[sid] !== undefined) clearNotifyAck(sid) - seen[sid] = needNow + if (pendingAck !== seen[sid]) { + if (seen[sid] !== undefined) { clearNotifyAck(sid); delete ack[sid] } + seen[sid] = pendingAck } - if (needNow) { if (ack[sid] !== 'need') map[pid] = 'need'; continue } + if (pendingAck != null) { if (ack[sid] !== pendingAck) map[pid] = 'need'; continue } if (sessionNotifyState(e) === 'done' && ack[sid] !== 'done') { map[pid] = 'done'; continue } if (e.running === true) map[pid] = 'busy' } @@ -2152,17 +2032,14 @@ function buildCustomLayoutPrompt(req: string): string { const sid = projectsRef.current.projects.bindings[projectId] if (!sid) return const byId = sessionsSnapshotStore.snapshot?.byId ?? {} - let needNow = sessionNotifyState(byId[sid]) === 'need' + const pendingAck = pendingAckForSession(sid) for (const cid of collectKids(sid)) { - if (sessionNotifyState(byId[cid]) === 'need') { needNow = true; saveNotifyAck(cid, 'need') } + if (sessionNotifyState(byId[cid]) === 'need') { + const childAck = pendingAckForSession(cid) + if (childAck != null) { saveNotifyAck(cid, childAck); notifyStateSeenRef.current[cid] = childAck } + } } - if (!needNow) { - try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() - if (Array.isArray(face?.pending) && face.pending.length > 0) needNow = true - } catch {} - } - if (needNow) saveNotifyAck(sid, 'need') + if (pendingAck != null) { saveNotifyAck(sid, pendingAck); notifyStateSeenRef.current[sid] = pendingAck } else { const st = sessionNotifyState(byId[sid]) if (st === 'done') saveNotifyAck(sid, 'done') @@ -2543,7 +2420,7 @@ function buildCustomLayoutPrompt(req: string): string { } // 无视图覆盖:项目自带打开行为照旧,仅当绑定了会话时切换右侧对话窗 const bound = projectsRef.current.projects.bindings[pid] - if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} } + if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } ackProjectNotify(pid) } document.addEventListener('click', onDocClick, true) @@ -2655,7 +2532,7 @@ function buildCustomLayoutPrompt(req: string): string { if (view || layout) openSplit((view ?? layout) as LayoutSpec) else { const bound = pr.bindings[id] - if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} } + if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } } } @@ -3308,7 +3185,7 @@ export function apply(ctx: any) { clientCtx = ctx try { hostApi = ctx.get?.('connection')?.api ?? null } catch { hostApi = null } try { (window as any).__dshHostApi = hostApi } catch {} - try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces; (window as any).__dshBuildWindowTaskText = buildWindowTaskText; (window as any).__dshSyncSessionScope = () => syncSessionScope(sessionBridge?.list) } catch {} + try { (window as any).__dshOpenSession = (id: string) => openHostSession(ctx, ctx.sessions, id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces; (window as any).__dshBuildWindowTaskText = buildWindowTaskText; (window as any).__dshSyncSessionScope = () => syncSessionScope(sessionBridge?.list) } catch {} ctx.effect(() => { @@ -3367,9 +3244,31 @@ export function apply(ctx: any) { // 会话作用域(当前会话 cwd 与后台任务)→ 功能窗数据源 const sessionsList = ctx.sessions?.list if (sessionsList && typeof sessionsList.getSnapshot === 'function') { + adaptSessionSnapshot = createSessionSnapshotAdapter() syncSessionScope(sessionsList) const disposeScope = sessionsList.subscribe(() => syncSessionScope(sessionsList)) ctx.effect(() => disposeScope, 'dsh-worktable: session scope watch') + // Optional dependency: older DSH hosts have no uiSession service. + ctx.inject(['uiSession'], (statusCtx: any) => { + const source = statusCtx.uiSession?.sessionStatus + if (!source?.getSnapshot || !source?.subscribe) return + sessionStatusSource = source + syncSessionScope(sessionsList) + const disposeStatus = source.subscribe(() => syncSessionScope(sessionsList)) + statusCtx.effect(() => () => { + disposeStatus() + if (sessionStatusSource === source) sessionStatusSource = null + }, 'dsh-worktable: session status watch') + }) + // Optional read-only timer source; getting a snapshot does not activate a chat target. + ctx.inject(['uiConversation'], (conversationCtx: any) => { + const source = conversationCtx.uiConversation + if (typeof source?.binding !== 'function') return + sessionConversationSource = source + conversationCtx.effect(() => () => { + if (sessionConversationSource === source) sessionConversationSource = null + }, 'dsh-worktable: conversation timer source') + }) } // 分栏工作区浮层(M1 通用引擎,shell.overlay 座位) diff --git a/01_content/src/client/sessionCompat.ts b/01_content/src/client/sessionCompat.ts new file mode 100644 index 0000000..581a29f --- /dev/null +++ b/01_content/src/client/sessionCompat.ts @@ -0,0 +1,149 @@ +/** DSH 0.1 stores current on the list; DSH 0.2 uses mainView retention. */ +export function currentSessionOf(snapshot: any): string { + if (typeof snapshot?.current === 'string') return snapshot.current + const rows = Object.values(snapshot?.byId ?? {}) as any[] + return rows.find((row) => (row?.retainedBy?.mainView ?? 0) > 0)?.id ?? '' +} + +/** Navigate through the host's UI owner, without modifying the host service. */ +export function openHostSession(ctx: any, sessions: any, id: string): any { + // Cordis property access throws for an uninjected service; get() safely returns undefined. + const ui = typeof ctx?.get === 'function' ? ctx.get('uiWorkspace') : ctx?.uiWorkspace + if (typeof ui?.openSession === 'function') return ui.openSession(id) + if (typeof sessions?.open === 'function') return sessions.open(id) + throw new Error('Host session navigation unavailable') +} + +/** Own a temporary reference on 0.2; borrowing a cold binding is no longer sufficient. */ +export async function sendHostSession(bridge: any, id: string, text: string): Promise { + const sessions = bridge?.sessions + const check = (result: any) => { + if (result?.kind === 'error' || result?.ok === false) { + throw new Error(result.text || result.error?.message || result.error?.code || 'Session submission rejected') + } + } + const send = async (session: any) => { + // Select one available transport BEFORE sending. A rejection/timeout must never retry + // through a second transport: the first request may already have reached the host. + if (session && typeof bridge?.conversation?.sendSession === 'function') { + check(await bridge.conversation.sendSession(session, text, [], 'queue')) + return + } + if (session && typeof session.prompt === 'function') { + check(await session.prompt([{ type: 'text', text }], 'queue')) + return + } + const conv = sessions?.scope?.(id)?.get?.('conversation') + if (typeof conv?.send === 'function') { check(await conv.send(text)); return } + throw new Error('no send path: session face unavailable') + } + if (typeof sessions?.using === 'function') { + await sessions.using(id, { source: 'dshWorktable' }, async (ref: any) => { + const binding = await ref.ready + await send(binding.session) + }) + return + } + let face: any = null + for (let i = 0; i < 10; i++) { + face = sessions?.binding?.(id)?.session + if (face) break + await new Promise((resolve) => setTimeout(resolve, 200)) + } + await send(face) +} + +/** Read-only adapter. A stopped run is not proof that a task succeeded. */ +export function createSessionSnapshotAdapter() { + const previousRunning = new Map() + const stopped = new Set() + return (snapshot: any, statusSource?: any): any => { + // Preserve the legacy contract verbatim; idle old sessions must not become completed. + if (!snapshot || typeof snapshot.current === 'string') return snapshot + const statuses = statusSource?.getSnapshot?.() + const byId: Record = {} + const subagentsByParent = { ...snapshot.subagentsByParent } + for (const [id, row] of Object.entries(snapshot.byId ?? {}) as [string, any][]) { + const status = statuses?.get?.(id) + const running = status?.running ?? row.running + if (running === true) stopped.delete(id) + else if (running === false && previousRunning.get(id) === true) stopped.add(id) + if (typeof running === 'boolean') previousRunning.set(id, running) + byId[id] = { + ...row, running, + pendingInteraction: status ? status.pendingInteraction : row.pendingInteraction, + // completionUnread clears when opening the main view. Keep a witnessed run-stop + // until the next run so an active project's widget-result handshake still runs. + completed: running !== true && (stopped.has(id) || status?.completionUnread === true || row.completed === true), + } + const children = snapshot.projectionsBySession?.[id]?.values?.subagentCatalog ?? row.projectionValues?.subagentCatalog + if (Array.isArray(children)) subagentsByParent[id] = children + } + if (snapshot.phase === 'ready') { + for (const id of previousRunning.keys()) if (!byId[id]) { previousRunning.delete(id); stopped.delete(id) } + } + return { ...snapshot, byId, subagentsByParent } + } +} + +/** Parent links and the host's child catalog can each contain otherwise missing children. */ +export function childSessionIdsOf(snapshot: any, sid: string): Set { + const children = new Set() + for (const [id, row] of Object.entries(snapshot?.byId ?? {}) as [string, any][]) { + if (id !== sid && row?.parentId === sid) children.add(id) + } + const catalog = snapshot?.subagentsByParent?.[sid] + const rows = Array.isArray(catalog) ? catalog : (catalog?.entries ?? catalog?.items ?? []) + if (Array.isArray(rows)) for (const row of rows) { + const id = row?.sessionId ?? row?.id + if (typeof id === 'string' && id !== sid) children.add(id) + } + return children +} + +/** Persist only opaque interaction identities, never their questions or answers. + * Legacy hosts with no interaction key keep the old boolean-state acknowledgement. + * New hosts must not let acknowledgement of question A suppress replacement B. + */ +export function pendingAckOf(snapshot: any, sid: string, legacyFace?: any): string | null { + const identities: string[] = [] + let identified = false + const add = (id: string, pending: any) => { + if (pending == null) return + const key = typeof pending?.key === 'string' ? pending.key : '' + if (key) identified = true + identities.push(JSON.stringify([id, String(pending?.kind ?? ''), key])) + } + const ids = [sid, ...childSessionIdsOf(snapshot, sid)] + for (const id of ids) add(id, snapshot?.byId?.[id]?.pendingInteraction) + // Read the old face only when the parent list has not already supplied its pending state. + if (snapshot?.byId?.[sid]?.pendingInteraction == null && Array.isArray(legacyFace?.pending)) { + for (const pending of legacyFace.pending) add(sid, pending) + } + if (!identities.length) return null + return identified ? 'need:' + JSON.stringify([...new Set(identities)].sort()) : 'need' +} + +/** Read host memory only. Do not activate a chat target or retain a cold session to get a timer. */ +export function sessionRuntimeMs(snapshot: any, sid: string, sessions: any, uiConversation?: any, now = Date.now()): number | null { + if (snapshot?.byId?.[sid]?.running !== true) return null + const validStart = (value: any): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0 + let start: number | null = null + for (const job of snapshot?.jobsBySession?.[sid] ?? []) { + if (job?.status === 'running' && validStart(job.startedAt) && (start == null || job.startedAt < start)) start = job.startedAt + } + if (start != null) return Math.max(0, now - start) + + let timings: any + try { timings = uiConversation?.binding?.(sid)?.target?.('chat')?.getSnapshot?.()?.legacy?.turnTimings } catch {} + if (timings == null) { + try { timings = sessions?.binding?.(sid)?.session?.getSnapshot?.()?.turnTimings } catch {} + } + // ReadonlyMap/another realm's Map must work as well as the old object representation. + const turns: any[] = typeof timings?.values === 'function' + ? Array.from(timings.values()) : Object.values(timings ?? {}) + for (const turn of turns) { + if (turn?.endTime == null && validStart(turn?.startTime) && (start == null || turn.startTime > start)) start = turn.startTime + } + return start == null ? null : Math.max(0, now - start) +} diff --git a/01_content/src/client/sessionDetails.ts b/01_content/src/client/sessionDetails.ts new file mode 100644 index 0000000..c979ced --- /dev/null +++ b/01_content/src/client/sessionDetails.ts @@ -0,0 +1,75 @@ +/** Text-only preview. Never returns reasoning, tool arguments, or modifies stored messages. */ +export function cleanPreviewText(raw: string): string { + return String(raw ?? '') + .replace(/```[a-zA-Z0-9_+-]*[\s\S]*?```/g, ' ') + .replace(/```[a-zA-Z0-9_+-]*[\s\S]*$/g, ' ') + .replace(/`[^`\n]{1,200}`/g, ' ') + .replace(/```/g, ' ') + .replace(/\s+/g, ' ').trim() +} + +export function previewFromEvents(entries: any): string { + if (!Array.isArray(entries)) return '' + for (let i = entries.length - 1; i >= 0; i--) { + const ev = entries[i]?.event + if (ev?.type !== 'user/message' && ev?.type !== 'assistant/message') continue + const data = ev.type === 'assistant/message' ? ev.data?.message ?? ev.data : ev.data + const blocks = data?.content ?? data?.blocks + if (!Array.isArray(blocks)) continue + const raw = blocks.filter((b: any) => b?.type === 'text' && typeof b.text === 'string').map((b: any) => b.text).join('\n') + const text = cleanPreviewText(raw) + if (text.length >= 8) return text.slice(0, 220) + } + return '' +} + +export async function readSessionPreview(sessions: any, id: string): Promise { + if (typeof sessions?.using === 'function') { + return sessions.using(id, { source: 'dshWorktablePreview' }, async (ref: any) => { + const binding = await ref.ready + return previewFromEvents(binding.eventSource?.getSnapshot?.()?.entries) + }) + } + const face = sessions?.binding?.(id)?.session + if (typeof face?.history !== 'function') return '' + const result = await face.history({ maxMessages: 6 }) + return previewFromEvents(result?.result?.value?.events) +} + +/** Normalize only the transport envelope; preserve the existing selection policy. */ +export function presetApiOf(ctx: any, legacy: any): any { + const api = typeof ctx?.get === 'function' ? ctx.get('remote.agentPresets') : ctx?.remote?.agentPresets + if (typeof api?.list !== 'function' || typeof api?.select !== 'function') return legacy + return { + list: async () => ({ result: await api.list() }), + select: async ({ sessionId, agentPreset }: any) => ({ result: await api.select(sessionId, agentPreset) }), + } +} + +/** 0.2 model directories belong to retained scopes. Do not cache them after release. */ +export function modelApiOf(ctx: any, sessions: any, legacy: any): any { + if (typeof sessions?.using !== 'function') return legacy + const resolver = typeof ctx?.get === 'function' ? ctx.get('modelDirectories') : ctx?.modelDirectories + if (typeof resolver?.directoryFor !== 'function') return legacy + const withDirectory = (id: string, operation: (directory: any) => Promise) => + sessions.using(id, { source: 'dshWorktableModel' }, async (ref: any) => { + await ref.ready + return operation(resolver.directoryFor(id)) + }) + return { + models: async ({ sessionId }: any) => withDirectory(sessionId, async (directory) => ({ result: { ok: true, value: await directory.load() } })), + selectModel: async ({ sessionId, ...selection }: any) => withDirectory(sessionId, async (directory) => ({ result: await directory.select(selection) })), + } +} + +/** 0.2's blank composer requires a registered Workspace, not only a Session cwd. */ +export async function createHostSession(sessions: any, workspaces: any, options: { workspaceId?: string; cwd?: string; sessionId?: string } = {}): Promise { + if (options.workspaceId || typeof sessions?.using !== 'function') return sessions.create(options) + // The Host owns path canonicalization and idempotent registration. Never invent an id. + const workspace = options.cwd + ? await workspaces?.create?.({ path: options.cwd }) + : await workspaces?.initializeDefault?.() + if (typeof workspace?.workspaceId !== 'string' || !workspace.workspaceId) throw new Error('workspace unavailable for new session') + const { cwd: _cwd, ...rest } = options + return sessions.create({ ...rest, workspaceId: workspace.workspaceId }) +} diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 280eca6..0868b9a 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -220,6 +220,7 @@ export type ConsoleCardData = { } type SplitEnv = { + fillHostInput: (text: string) => boolean getScope: () => SplitScope | null getJobs: () => SplitJob[] getSubagents: () => any[] @@ -504,25 +505,7 @@ async function copyTextSafe(text: string): Promise { /** 注入宿主对话框输入框(不发送);失败返回 false */ function fillHostInput(text: string): boolean { - try { - const ta = document.querySelector('textarea[data-phase]') - ?? Array.from(document.querySelectorAll('textarea')).pop() - if (!ta) return false - const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set - // 连续标注:已有内容不顶掉,换行追加(用户可以同一对话连续标注多处) - const next = ta.value && ta.value.trim() ? ta.value + '\n\n' + text : text - if (setter) setter.call(ta, next) - else ta.value = next - ta.dispatchEvent(new Event('input', { bubbles: true })) - try { - ta.focus() - ta.dispatchEvent(new Event('change', { bubbles: true })) - ta.setSelectionRange(ta.value.length, ta.value.length) - } catch {} - return true - } catch { - return false - } + return splitEnv?.fillHostInput(text) ?? false } /** ✓ 确认:打包并注入,不发送 */ diff --git a/02_process/PRD.md b/02_process/PRD.md index e21aee6..d29ad2e 100644 --- a/02_process/PRD.md +++ b/02_process/PRD.md @@ -176,6 +176,9 @@ ctx.slots.register({ - views:入驻项目与「控制室」(wt-console)的视图覆盖(LayoutSpec); - bindings:项目 → 绑定会话(含 wt-console 管理对话); - folders:项目 → 项目文件夹(含 wt-console); + - `dsh.worktable.notifyAck.v1`:会话 id → `done` / 旧版 `need` / `need:` 加排序后的待决身份集合; + 新版身份仅含父/子会话 id、kind 与 opaque key,不含正文、答案或凭据;同一问题刷新后仍可确认, + 替换问题必须重新点亮。旧无 key 主机保留布尔状态退路,无法区分无 key 的连续替换。 - 卡片上报的 meta 注册表仅存内存,不持久化; - 更新检查:`dsh.worktable.lastUpdateCheck.v1`(上次成功检查时间戳,节流一天一次)、 `dsh.worktable.skipVersion.v1`(忽略的版本号)、`dsh.worktable.updateCheck.v1`(自动检查开关,'0'=关)、 @@ -375,7 +378,9 @@ type SplitPane = { - **任务完成/待决提醒镜像**(2026-08-18):绑定会话在宿主快照 byId 里 completed=true → 项目卡 双圆点绿色发光(data-bound=done);pendingInteraction != null → 黄色发光(data-bound=need), 与原生对话小绿点/小黄点同步;点开项目即确认(ack,notifyAck.v1 按会话存状态)恢复常态实心; - 状态切换(完成↔待决)会重新点亮。数据源 sessionsSnapshotStore(syncSessionScope 推送完整 + 状态切换(完成↔待决)会重新点亮;新版 pending key/kind 或待决子会话集合变化也重新点亮。 + 控制室计时只读后台任务或新版 chat.legacy.turnTimings / 旧会话面,未知起点不虚构时长。 + 数据源 sessionsSnapshotStore(syncSessionScope 推送完整 快照并通知监听)。 - **项目×对话联动**(2026-08-18):① 打开项目时记录「打开前会话」;② 项目打开期间切到非该 项目绑定的会话 → 自动关闭项目(保留用户新选的会话);③ ✕/反选关闭项目 → 自动回切「打开前 diff --git a/04_test/host-input.test.mjs b/04_test/host-input.test.mjs new file mode 100644 index 0000000..cca7d7e --- /dev/null +++ b/04_test/host-input.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createRequire } from 'node:module' +import { runInNewContext } from 'node:vm' +import { fileURLToPath } from 'node:url' + +const require = createRequire(new URL('../01_content/package.json', import.meta.url)) +const { buildSync } = require('esbuild') +const compiled = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/hostInput.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text +class Textarea { + value = '原草稿' + events = [] + closest() { return null } + dispatchEvent(e) { this.events.push(e.type) } + focus() {} + setSelectionRange() {} +} +const module = { exports: {} } +runInNewContext(compiled, { module, exports: module.exports, HTMLTextAreaElement: Textarea, Event }) +const { appendHostInput } = module.exports +const compatModule = { exports: {} } +const compatCode = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/sessionCompat.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text +runInNewContext(compatCode, { module: compatModule, exports: compatModule.exports }) +const { currentSessionOf, openHostSession } = compatModule.exports +const noDom = { querySelector() { throw new Error('modern input must not touch DOM') } } +function fixture(patch = {}) { + const calls = [] + const state = { draft: '', draftRev: 4, phase: 'plain', occurrences: [], ...patch } + const input = { state: { getSnapshot: () => state }, actions: { insertText: (text, span) => { calls.push({ text, span: { ...span } }); return true } }, focus() {}, submit() { assert.fail('must not send') }, setDraft() { assert.fail('must not replace draft/chips') } } + const scope = { bail: (event, request) => { calls.push({ event, ...request }); return true } } + const bridge = { sessions: { list: { getSnapshot: () => ({ byId: { selected: { id: 'session-1', retainedBy: { mainView: 1 } } } }) }, scope: id => { assert.equal(id, 'session-1'); return scope } }, conversation: { input: { for: ctx => { assert.equal(ctx, scope); return input } } } } + return { bridge, input, calls, state } +} +test('new host: empty input receives annotation, never submit', () => { + const f = fixture() + assert.equal(appendHostInput(f.bridge, '标注', noDom), true) + assert.deepEqual(f.calls, [{ text: '标注', span: { start: 0, end: 0, draftRev: 4 } }]) +}) +test('new host: resolve current by mainView, not sidebar or catalog order', () => { + const f = fixture() + f.bridge.sessions.list.getSnapshot = () => ({ byId: { other: { id: 'other', retainedBy: { sidebarView: 1 } }, selected: { id: 'session-1', retainedBy: { mainView: 1 } } } }) + assert.equal(appendHostInput(f.bridge, '标注', noDom), true) + assert.equal(currentSessionOf({ byId: { x: { id: 'x', retainedBy: { sidebarView: 1 } } } }), '') +}) +test('legacy empty current stays empty; never guess another session', () => { + assert.equal(currentSessionOf({ current: '', byId: { x: { id: 'x', retainedBy: { mainView: 1 } } } }), '') + assert.equal(currentSessionOf({ current: 'old-session' }), 'old-session') +}) +test('navigation uses new UI service with legacy fallback, preserving method receiver', () => { + const calls = [] + const ui = { openSession(id) { assert.equal(this, ui); calls.push(id) } } + const old = { open(id) { assert.equal(this, old); calls.push(id) } } + openHostSession({ get: () => ui }, old, 'new') + openHostSession({}, old, 'old') + assert.deepEqual(calls, ['new', 'old']) + assert.throws(() => openHostSession({}, {}, 'missing'), /navigation unavailable/) +}) +test('missing new service in a Cordis-style context reaches legacy navigation', () => { + const context = new Proxy({ get: () => undefined }, { + get(target, key) { + if (key in target) return target[key] + throw new Error('cannot get property without inject: ' + String(key)) + }, + }) + const old = { open(id) { assert.equal(this, old); return id } } + assert.equal(openHostSession(context, old, 'legacy-session'), 'legacy-session') +}) +test('new host: append instead of replacing an existing draft', () => { + const f = fixture({ draft: '原草稿' }) + assert.equal(appendHostInput(f.bridge, '标注', noDom), true) + assert.deepEqual(f.calls, [{ text: '\n\n标注', span: { start: 3, end: 3, draftRev: 4 } }]) +}) +test('reference chip projection: append without flattening or truncating chips', () => { + const f = fixture({ draft: '@abc 后文', occurrences: [{ offset: 0, length: 4 }] }) + assert.equal(appendHostInput(f.bridge, '标注', noDom), true) + assert.equal(f.calls[0].span.start, 4) +}) +test('legacy input resolver appends after display-text references without cutting them', () => { + const f = fixture({ draft: 'ABCDEFG tail', occurrences: [{ offset: 0, length: 7 }] }) + f.bridge.sessions.list.getSnapshot = () => ({ current: 'session-1' }) + delete f.input.actions + assert.equal(appendHostInput(f.bridge, 'ANNOTATION', noDom), true) + assert.equal(f.calls[0].span.start, f.state.draft.length) + const { text, span } = f.calls[0] + const next = f.state.draft.slice(0, span.start) + text + f.state.draft.slice(span.end) + assert.equal(next, 'ABCDEFG tail\n\nANNOTATION') + assert.deepEqual(f.state.occurrences, [{ offset: 0, length: 7 }]) +}) +test('new host scoped-event fallback still uses compact reference coordinates', () => { + const f = fixture({ draft: 'ABCDEFG tail', occurrences: [{ offset: 0, length: 7 }] }) + delete f.input.actions + assert.equal(appendHostInput(f.bridge, 'ANNOTATION', noDom), true) + assert.equal(f.calls[0].span.start, 6) + assert.equal(f.calls[0].span.end, 6) + assert.equal(f.calls[0].span.draftRev, 4) +}) +test('frozen input refuses edits', () => { + for (const phase of ['submitting', 'adjudicating']) { + const f = fixture({ phase }) + assert.equal(appendHostInput(f.bridge, '标注', noDom), false) + assert.equal(f.calls.length, 0) + } +}) +test('missing scope / rejected revision do not fall through to arbitrary textarea', () => { + const f = fixture() + f.bridge.sessions.scope = () => undefined + assert.equal(appendHostInput(f.bridge, '标注', noDom), false) + const g = fixture() + g.input.actions.insertText = () => false + assert.equal(appendHostInput(g.bridge, '标注', noDom), false) +}) +test('scoped event compatibility path retains revision guard', () => { + const f = fixture() + delete f.input.actions + assert.equal(appendHostInput(f.bridge, '标注', noDom), true) + assert.equal(f.calls[0].event, 'slash/input-insert-text') + assert.equal(f.calls[0].span.draftRev, 4) +}) +test('focus failure after insertion must not cause duplicate fallback', () => { + const f = fixture() + f.input.focus = () => { throw new Error('unmounted') } + assert.equal(appendHostInput(f.bridge, '标注', noDom), true) + assert.equal(f.calls.length, 1) +}) +test('malformed chip projection fails closed', () => { + const f = fixture({ draft: 'abc', occurrences: [{ offset: 0, length: 8 }] }) + assert.equal(appendHostInput(f.bridge, '标注', noDom), false) + assert.equal(f.calls.length, 0) +}) +test('legacy textarea appends and dispatches input, never selects annotation editor', () => { + const ta = new Textarea() + const doc = { querySelector: selector => { assert.equal(selector, 'textarea[data-phase]'); return ta } } + assert.equal(appendHostInput(null, '标注', doc), true) + assert.equal(ta.value, '原草稿\n\n标注') + assert.deepEqual(ta.events, ['input', 'change']) + ta.closest = () => ({}) + assert.equal(appendHostInput(null, '不可写', doc), false) + assert.equal(appendHostInput(null, '不可写', { querySelector: () => null }), false) +}) diff --git a/04_test/session-compat.test.mjs b/04_test/session-compat.test.mjs new file mode 100644 index 0000000..d337517 --- /dev/null +++ b/04_test/session-compat.test.mjs @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createRequire } from 'node:module' +import { runInNewContext } from 'node:vm' +import { fileURLToPath } from 'node:url' + +const require = createRequire(new URL('../01_content/package.json', import.meta.url)) +const { buildSync } = require('esbuild') +const code = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/sessionCompat.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text +const module = { exports: {} } +runInNewContext(code, { module, exports: module.exports, setTimeout }) +const { createSessionSnapshotAdapter, sendHostSession, childSessionIdsOf, pendingAckOf, sessionRuntimeMs } = module.exports +const list = (running = false) => ({ phase: 'ready', ids: ['a'], byId: { a: { id: 'a', running, retainedBy: { mainView: 1 } } }, projectionsBySession: {} }) +const source = (patch = {}) => ({ getSnapshot: () => new Map([['a', { running: false, pendingInteraction: undefined, completionUnread: false, ...patch }]]) }) + +test('legacy snapshots are passed through unchanged', () => { + const snapshot = { current: 'a', byId: { a: { completed: true, pendingInteraction: { key: 'x' } } } } + assert.equal(createSessionSnapshotAdapter()(snapshot, source()), snapshot) +}) +test('new idle baseline is not a completion; never mutates host data', () => { + const snapshot = list() + Object.freeze(snapshot.byId.a) + Object.freeze(snapshot.byId) + const result = createSessionSnapshotAdapter()(snapshot, source()) + assert.equal(result.byId.a.completed, false) + assert.equal(snapshot.byId.a.completed, undefined) + assert.notEqual(result.byId, snapshot.byId) +}) +test('active view run-stop survives unread reset and repeated refreshes, resets on next run', () => { + const adapt = createSessionSnapshotAdapter() + assert.equal(adapt(list(true), source({ running: true })).byId.a.completed, false) + assert.equal(adapt(list(), source()).byId.a.completed, true) + assert.equal(adapt(list(), source()).byId.a.completed, true) + assert.equal(adapt(list(true), source({ running: true })).byId.a.completed, false) +}) +test('background unread is visible; pending cleared by authoritative status', () => { + const adapt = createSessionSnapshotAdapter() + const snapshot = list() + snapshot.byId.a.pendingInteraction = { key: 'stale' } + assert.equal(adapt(snapshot, source({ completionUnread: true })).byId.a.completed, true) + assert.equal(adapt(snapshot, source()).byId.a.pendingInteraction, undefined) + const pending = { key: 'approval-2', kind: 'approval', sessionId: 'a' } + assert.equal(adapt(snapshot, source({ pendingInteraction: pending })).byId.a.pendingInteraction, pending) +}) +test('new child catalog is exposed for existing parent/child aggregation', () => { + const snapshot = list() + const children = [{ id: 'child' }] + snapshot.projectionsBySession.a = { values: { subagentCatalog: children } } + const result = createSessionSnapshotAdapter()(snapshot) + assert.equal(result.subagentsByParent.a, children) + assert.equal(snapshot.subagentsByParent, undefined) +}) +test('ready removal clears run memory; transient pending list does not', () => { + const adapt = createSessionSnapshotAdapter() + adapt(list(true)) + adapt({ phase: 'pending', byId: {} }) + assert.equal(adapt(list()).byId.a.completed, true) + adapt({ phase: 'ready', byId: {} }) + assert.equal(adapt(list()).byId.a.completed, false) +}) +function sendFixture(outcome = { kind: 'success' }) { + const calls = [] + const face = { prompt() { assert.fail('must not resend through prompt') } } + let retained = false + const bridge = { + sessions: { + async using(id, options, action) { + assert.equal(id, 'cold') + assert.equal(options.source, 'dshWorktable') + retained = true + calls.push('retain') + try { return await action({ ready: Promise.resolve({ session: face }) }) } + finally { retained = false; calls.push('release') } + }, + binding() { assert.fail('cold binding must not be borrowed') }, + scope() { assert.fail('must not retry via scoped send') }, + }, + conversation: { async sendSession(session, text, attachments, mode) { + assert.equal(retained, true) + assert.equal(session, face) + assert.equal(text, 'task') + assert.equal(attachments.length, 0) + assert.equal(mode, 'queue') + calls.push('send') + if (outcome instanceof Error) throw outcome + return outcome + } }, + } + return { bridge, calls } +} +test('cold new session retained until successful send settles', async () => { + const f = sendFixture() + await sendHostSession(f.bridge, 'cold', 'task') + assert.deepEqual(f.calls, ['retain', 'send', 'release']) +}) +test('resolved business error is surfaced; retained reference released without second send', async () => { + const f = sendFixture({ kind: 'error', text: 'model unavailable' }) + await assert.rejects(sendHostSession(f.bridge, 'cold', 'task'), /model unavailable/) + assert.deepEqual(f.calls, ['retain', 'send', 'release']) +}) +test('transport rejection does not resend an ambiguously accepted request', async () => { + const f = sendFixture(new Error('connection lost')) + await assert.rejects(sendHostSession(f.bridge, 'cold', 'task'), /connection lost/) + assert.deepEqual(f.calls, ['retain', 'send', 'release']) +}) +test('legacy sendSession void return remains supported, method receiver preserved', async () => { + const face = {} + const conversation = { async sendSession(session) { assert.equal(this, conversation); assert.equal(session, face) } } + await sendHostSession({ sessions: { binding: () => ({ session: face }) }, conversation }, 'old', 'task') +}) +test('legacy prompt fallback is selected only when wrapper absent and checks rejection', async () => { + const face = { async prompt() { assert.equal(this, face); return { ok: false, error: { code: 'rejected' } } } } + await assert.rejects(sendHostSession({ sessions: { binding: () => ({ session: face }) } }, 'old', 'task'), /rejected/) +}) + +const question = (key, kind = 'approval') => ({ key, kind, text: 'PRIVATE QUESTION', answers: ['PRIVATE ANSWER'] }) +test('pending acknowledgement distinguishes direct replacement A → B without a false interval', () => { + const snapshot = list(true) + snapshot.byId.a.pendingInteraction = question('A') + const acknowledged = pendingAckOf(snapshot, 'a') + assert.ok(acknowledged.startsWith('need:')) + assert.equal(pendingAckOf(snapshot, 'a'), acknowledged) + // Persist/reload acknowledgement for the same opaque identity, not just true/false. + assert.equal(JSON.parse(JSON.stringify({ a: acknowledged })).a, pendingAckOf(snapshot, 'a')) + snapshot.byId.a.pendingInteraction = question('B') + assert.notEqual(pendingAckOf(snapshot, 'a'), acknowledged) + assert.ok(!acknowledged.includes('PRIVATE')) +}) +test('pending identity includes kind/session and old generic need cannot suppress a keyed question', () => { + const snapshot = list() + snapshot.byId.a.pendingInteraction = question('same', 'approval') + const first = pendingAckOf(snapshot, 'a') + assert.notEqual(first, 'need') + snapshot.byId.a.pendingInteraction = question('same', 'question') + assert.notEqual(pendingAckOf(snapshot, 'a'), first) + const child = { ...snapshot, byId: { a: {}, child: { parentId: 'a', pendingInteraction: question('same', 'approval') } } } + assert.notEqual(pendingAckOf(child, 'a'), first) +}) +test('child pending replacements re-light parents; catalog order/duplicates do not change identity', () => { + const snapshot = { byId: { a: {}, b: { parentId: 'a', pendingInteraction: question('B') }, c: { pendingInteraction: question('C') } }, subagentsByParent: { a: [{ id: 'c' }, { sessionId: 'b' }, { id: 'a' }] } } + assert.deepEqual([...childSessionIdsOf(snapshot, 'a')].sort(), ['b', 'c']) + const first = pendingAckOf(snapshot, 'a') + snapshot.subagentsByParent.a.reverse() + snapshot.subagentsByParent.a.push({ id: 'b' }) + assert.equal(pendingAckOf(snapshot, 'a'), first) + snapshot.byId.c.pendingInteraction = question('C2') + assert.notEqual(pendingAckOf(snapshot, 'a'), first) + delete snapshot.byId.c.pendingInteraction + assert.notEqual(pendingAckOf(snapshot, 'a'), first) +}) +test('legacy keyless pending, empty baseline and keyed legacy face are supported', () => { + const snapshot = list() + assert.equal(pendingAckOf(snapshot, 'a'), null) + assert.equal(pendingAckOf(snapshot, 'a', { pending: [{}] }), 'need') + assert.equal(pendingAckOf(snapshot, 'a', { pending: [] }), null) + assert.equal(pendingAckOf(snapshot, 'a', { pending: [question('A')] }), pendingAckOf({ byId: { a: { pendingInteraction: question('A') } } }, 'a')) + snapshot.byId.a.pendingInteraction = question('LIST') + assert.equal(pendingAckOf(snapshot, 'a', { pending: [question('STALE')] }), pendingAckOf(snapshot, 'a')) +}) + +const legacyTimings = (turnTimings) => ({ binding: () => ({ session: { getSnapshot: () => ({ turnTimings }) } }) }) +const chatTimings = (turnTimings) => ({ binding(sid) { + assert.equal(sid, 'a') + return { target(target) { + assert.equal(target, 'chat') + return { getSnapshot: () => ({ legacy: { turnTimings } }), subscribe() { assert.fail('timer must not activate/subscribe') } } + }, activate() { assert.fail('timer must not activate') } } +} }) +test('runtime chooses the oldest running job before conversation timings', () => { + const snapshot = list(true) + snapshot.jobsBySession = { a: [{ status: 'running', startedAt: 300 }, { status: 'done', startedAt: 1 }, { status: 'running', startedAt: 100 }] } + const noBinding = { binding() { assert.fail('job timer must not read a binding') } } + assert.equal(sessionRuntimeMs(snapshot, 'a', noBinding, noBinding, 1000), 900) +}) +test('runtime reads new chat timings without legacy face, cold retention or target activation', () => { + const turns = new Map([[1, { startTime: 100, endTime: 150 }], [2, { startTime: 250 }]]) + const sessions = { binding() { assert.fail('new timings must win') }, using() { assert.fail('timer must not retain') } } + assert.equal(sessionRuntimeMs(list(true), 'a', sessions, chatTimings(turns), 1000), 750) +}) +test('runtime supports old object/foreign Map and picks newest unfinished timestamp, not insertion order', () => { + const turns = { newer: { startTime: 400 }, earlier: { startTime: 100 }, closed: { startTime: 900, endTime: 950 } } + assert.equal(sessionRuntimeMs(list(true), 'a', legacyTimings(turns), undefined, 1000), 600) + const foreignMap = runInNewContext('new Map([[2, {startTime: 400}], [1, {startTime: 100}]])') + assert.equal(sessionRuntimeMs(list(true), 'a', legacyTimings(foreignMap), undefined, 1000), 600) +}) +test('runtime unknown/finished/idle is null, invalid timestamps ignored and future start clamped', () => { + const fail = { binding() { assert.fail('idle must not read any binding') } } + assert.equal(sessionRuntimeMs(list(), 'a', fail, fail, 1000), null) + assert.equal(sessionRuntimeMs(list(true), 'missing', fail, fail, 1000), null) + assert.equal(sessionRuntimeMs(list(true), 'a', {}, {}, 1000), null) + assert.equal(sessionRuntimeMs(list(true), 'a', {}, chatTimings(new Map([[1, { startTime: 100, endTime: 200 }]])), 1000), null) + const snapshot = list(true) + snapshot.jobsBySession = { a: [{ status: 'running', startedAt: NaN }, { status: 'running', startedAt: Infinity }, { status: 'running', startedAt: -1 }] } + const turns = { invalid: { startTime: NaN }, future: { startTime: 1100 } } + assert.equal(sessionRuntimeMs(snapshot, 'a', legacyTimings(turns), undefined, 1000), 0) +}) +test('runtime falls back to legacy on unavailable chat source but not on authoritative closed timings', () => { + const sessions = legacyTimings({ open: { startTime: 100 } }) + assert.equal(sessionRuntimeMs(list(true), 'a', sessions, { binding() { throw new Error('not retained') } }, 1000), 900) + assert.equal(sessionRuntimeMs(list(true), 'a', sessions, chatTimings(new Map()), 1000), null) +}) diff --git a/04_test/session-details.test.mjs b/04_test/session-details.test.mjs new file mode 100644 index 0000000..a0f899b --- /dev/null +++ b/04_test/session-details.test.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createRequire } from 'node:module' +import { runInNewContext } from 'node:vm' +import { fileURLToPath } from 'node:url' + +const require = createRequire(new URL('../01_content/package.json', import.meta.url)) +const { buildSync } = require('esbuild') +const code = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/sessionDetails.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text +const module = { exports: {} } +runInNewContext(code, { module, exports: module.exports }) +const { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession } = module.exports +const msg = (text, type = 'assistant/message') => ({ type: 'event', event: { type, data: { content: [{ type: 'text', text }] } } }) + +test('preview filters code and joins final text blocks, without mutating events', () => { + const entries = [msg('old text message'), { event: { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: 'Latest final' }, { type: 'text', text: 'answer here' }] } } } }] + const before = JSON.stringify(entries) + assert.equal(previewFromEvents(entries), 'Latest final answer here') + assert.equal(JSON.stringify(entries), before) + assert.equal(cleanPreviewText('before ```js\ncode\n``` after `inline`'), 'before after') +}) +test('reasoning, tools and transient chunks never become a preview', () => { + const entries = [msg('A readable earlier message'), { event: { type: 'assistant/message', data: { content: [{ type: 'reasoning', text: 'private reasoning' }] } } }, msg('tool argument', 'tool/call'), msg('stream token', 'assistant/live-chunk')] + assert.equal(previewFromEvents(entries), 'A readable earlier message') +}) +test('short/code-only latest message falls back, malformed input stays empty, length bounded', () => { + assert.equal(previewFromEvents([msg('Earlier readable message'), msg('```js\ncode\n```')]), 'Earlier readable message') + assert.equal(previewFromEvents(null), '') + assert.equal(previewFromEvents([{ event: { type: 'user/message', data: { content: 'not blocks' } } }]), '') + assert.equal(previewFromEvents([msg('x'.repeat(300))]).length, 220) +}) +function held(binding, action) { + const calls = [] + let active = false + const sessions = { async using(id, options, fn) { + calls.push(['retain', id, options.source]) + active = true + try { return await fn({ ready: action ? action() : Promise.resolve(binding) }) } + finally { active = false; calls.push(['release', id]) } + } } + return { sessions, calls, active: () => active } +} +test('cold preview uses retained final event window and releases it', async () => { + const f = held({ eventSource: { getSnapshot() { assert.equal(f.active(), true); return { entries: [msg('Cold session final message')] } } } }) + assert.equal(await readSessionPreview(f.sessions, 'cold'), 'Cold session final message') + assert.deepEqual(f.calls, [['retain', 'cold', 'dshWorktablePreview'], ['release', 'cold']]) +}) +test('preview acquisition failure releases and rejects instead of returning stale data', async () => { + const f = held(null, () => Promise.reject(new Error('offline'))) + await assert.rejects(readSessionPreview(f.sessions, 'cold'), /offline/) + assert.equal(f.active(), false) + assert.equal(f.calls.at(-1)[0], 'release') +}) +test('empty new session stays empty without legacy private history calls', async () => { + const f = held({ eventSource: { getSnapshot: () => ({ entries: [] }) }, session: { history() { assert.fail('new host must not use private history') } } }) + assert.equal(await readSessionPreview(f.sessions, 'blank'), '') +}) +test('legacy preview uses bounded history with the correct receiver', async () => { + const face = { async history(opts) { assert.equal(this, face); assert.equal(opts.maxMessages, 6); return { result: { value: { events: [msg('Legacy message preview')] } } } } } + assert.equal(await readSessionPreview({ binding: () => ({ session: face }) }, 'old'), 'Legacy message preview') +}) +test('preset bridge converts new method arguments and envelope, does not mask failures', async () => { + const calls = [] + const remote = { async list(...args) { assert.equal(this, remote); assert.equal(args.length, 0); return { ok: true, value: { presets: [] } } }, async select(id, preset) { calls.push([id, preset]); return { ok: false, error: { message: 'not blank' } } } } + const api = presetApiOf({ get: name => { assert.equal(name, 'remote.agentPresets'); return remote } }, null) + assert.equal((await api.list({})).result.ok, true) + assert.equal((await api.select({ sessionId: 's', agentPreset: 'p' })).result.error.message, 'not blank') + assert.deepEqual(calls, [['s', 'p']]) +}) +test('legacy model and preset APIs keep their exact identity', () => { + const old = {} + assert.equal(presetApiOf({}, old), old) + assert.equal(modelApiOf({}, {}, old), old) +}) +test('uninjected optional services in a Cordis-style context safely fall back', () => { + const context = new Proxy({ get: () => undefined }, { + get(target, key) { + if (key in target) return target[key] + throw new Error('cannot get property without inject: ' + String(key)) + }, + }) + const old = {} + assert.equal(presetApiOf(context, old), old) + assert.equal(modelApiOf(context, { using() {} }, old), old) +}) +test('legacy model selection does not probe new services before the capability guard', () => { + const context = new Proxy({}, { get() { assert.fail('legacy model API must not probe new services') } }) + const old = {} + assert.equal(modelApiOf(context, {}, old), old) +}) +test('model read/select hold a scope and preserve reasoning selection', async () => { + const f = held({}) + const state = { current: { provider: 'p', model: 'm', reasoningEffort: 'high' }, routable: true, groups: [] } + const directory = { async load() { assert.equal(f.active(), true); return state }, async select(selection) { assert.equal(f.active(), true); assert.deepEqual({ ...selection }, { provider: 'p', model: 'm', reasoningEffort: 'high' }); return { ok: true, value: undefined } } } + const resolver = { directoryFor(id) { assert.equal(this, resolver); assert.equal(id, 's'); assert.equal(f.active(), true); return directory } } + const api = modelApiOf({ modelDirectories: resolver }, f.sessions, null) + assert.equal((await api.models({ sessionId: 's' })).result.value, state) + assert.equal((await api.selectModel({ sessionId: 's', ...state.current })).result.ok, true) + assert.equal(f.active(), false) + assert.equal(f.calls.length, 4) +}) +test('model lookup failure releases its reference and is not hidden by legacy fallback', async () => { + const f = held({}) + const api = modelApiOf({ modelDirectories: { directoryFor() { throw new Error('directory disposed') } } }, f.sessions, { models() { assert.fail('no retry via legacy') } }) + await assert.rejects(api.models({ sessionId: 's' }), /directory disposed/) + assert.equal(f.active(), false) +}) + +test('new host registers project path before creating a workspace-attached session', async () => { + const calls = [] + const opts = { cwd: 'C:/test/project', sessionId: 'preallocated' } + const sessions = { using() {}, async create(value) { calls.push(['session', { ...value }]); return 's' } } + const ws = { async create(value) { assert.equal(this, ws); calls.push(['workspace', { ...value }]); return { workspaceId: 'ws' } } } + assert.equal(await createHostSession(sessions, ws, opts), 's') + assert.deepEqual(calls, [['workspace', { path: 'C:/test/project' }], ['session', { sessionId: 'preallocated', workspaceId: 'ws' }]]) + assert.deepEqual(opts, { cwd: 'C:/test/project', sessionId: 'preallocated' }) +}) +test('new host without project path uses official default workspace, never guesses cwd', async () => { + let initialized = false + const ws = { async initializeDefault() { assert.equal(this, ws); initialized = true; return { workspaceId: 'default' } } } + const sessions = { using() {}, async create(value) { assert.equal(initialized, true); assert.deepEqual({ ...value }, { workspaceId: 'default' }); return 's' } } + assert.equal(await createHostSession(sessions, ws), 's') +}) +test('explicit workspace and legacy cwd keep existing creation behavior', async () => { + const opts = { workspaceId: 'selected' } + const modern = { using() {}, async create(value) { assert.equal(value, opts); return 'modern' } } + assert.equal(await createHostSession(modern, { create() { assert.fail('no extra registration') } }, opts), 'modern') + const oldOpts = { cwd: 'C:/old' } + const legacy = { async create(value) { assert.equal(value, oldOpts); return 'legacy' } } + assert.equal(await createHostSession(legacy, null, oldOpts), 'legacy') +}) +test('failed or unavailable workspace must not create an orphan blank session', async () => { + const sessions = { using() {}, create() { assert.fail('must not create orphan') } } + await assert.rejects(createHostSession(sessions, { async create() { throw new Error('invalid directory') } }, { cwd: 'C:/missing' }), /invalid directory/) + await assert.rejects(createHostSession(sessions, { async create() { return {} } }, { cwd: 'C:/path' }), /workspace unavailable/) + await assert.rejects(createHostSession(sessions, null), /workspace unavailable/) + await assert.rejects(createHostSession(sessions, { async initializeDefault() { return undefined } }), /workspace unavailable/) +}) +test('session creation failure is propagated without a second create attempt', async () => { + let calls = 0 + const sessions = { using() {}, async create() { calls++; throw new Error('workspace attach failed') } } + await assert.rejects(createHostSession(sessions, { async create() { return { workspaceId: 'ws' } } }, { cwd: 'C:/path' }), /workspace attach failed/) + assert.equal(calls, 1) +}) diff --git a/AGENTS.md b/AGENTS.md index 8a8cec4..6c0804a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,9 @@ - 插件包根目录 = `01_content/`;本仓库其余目录是项目文档与本地工具。 - **不替换、不禁用任何官方插件**(ui-sidebar / ui-workspace / ui-layout)。 -- 所有状态只存 localStorage(键 `dsh.worktable.view.v1`),不读写工作区文件。 +- 项目/布局/绑定存 localStorage,媒体存 IndexedDB;文件窗、mkdir 与产物握手会读写用户配置的项目目录,权限边界见 `01_content/README.md`。 - dsh-travelatlas 是入驻项目而非本仓库的一部分;协议见 `02_process/PRD.md` §5.3/5.4。 -- **平台边界**:Windows 是当前完整验证平台;macOS 为实验性支持(核心文件路径代码已做跨平台适配, +- **平台边界**:Windows 网页端已测范围见 `01_content/README.md`,不宣称全功能或桌面端验收;macOS 为实验性支持(核心文件路径代码已做跨平台适配, 尚未真机端到端验证)。路径拼接必须走 `pathutil.ts` helper 或 Node `path` API,不手写分隔符。 ## 构建与验证 @@ -51,7 +51,7 @@ node --check lib/index.js - **构建必须 `cd 01_content` 后执行**:误在仓库根跑会把 lib 写到仓库根 `lib/`,宿主仍加载 `01_content/lib` 旧 bundle,出现「改完不生效」假象(已有教训,见工作日志)。 - **发布打包唯一入口 = `npm run pack`(01_content/release-prep.mjs)**:身份断言(package/manifest/cordis.patch.yml 严格结构)→ - 版本一致性 → 构建(cwd 固定 01_content)→ node --check → npm pack → 结构清单断言(package/ 前缀 + 精确 7 文件, + 版本一致性 → **会话/输入兼容回归**(test:input / test:session / test:details 共 53 项,评估源码契约)→ 构建(cwd 固定 01_content)→ node --check → npm pack → 结构清单断言(package/ 前缀 + 精确 7 文件, 无 src/、无 .map)→ 独立临时目录 npm install + import() 断言(apply 函数/inject 含 webServer+sessions/name/ HEALTH_PATH/包内双 bundle 版本)→ **客户端工厂求值门禁**(ModuleLoader 恰好注册一次 + ID 校验 + 精确外部依赖 白名单 react/react/jsx-runtime + apply/inject 断言)→ **分栏锚点 DOM 回归**(8 场景,评估安装产物 lib/client.js; @@ -69,6 +69,10 @@ node --check lib/index.js ## 领域约定(会话中必须遵守) +- **0.3.4 网页端兼容**:声明已测版本 0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2,不外推 Desktop 或未测宿主。当前会话走 `currentSessionOf`(旧 current / 新 mainView),导航走 `openHostSession`(新 uiWorkspace / 旧 sessions.open),标注走 `appendHostInput`。Cordis 可选服务用 `ctx.get` 探测;返回 undefined 后不得再访问未 inject 的同名属性。旧 current 契约的引用坐标是显示文本位置,新 mainView 契约按单字符 chip 投影;不得因旧版也有 input.for 就误用新版坐标。`npm run test:input` 为 15 项定向回归。 +- **0.2 会话状态/发送**:可选订阅 `uiSession.sessionStatus`,只读归一化列表与 subagentCatalog;completionUnread 不是任务成功证明,打开主会话会清除。已观察到的 running→false 供原产物握手使用,下一轮 running 清除;停止不等于成功。新会话发送通过 `sessions.using` 临时持有,等待 ready 后发送并由宿主释放;业务拒绝/超时不得再换通道重发。`npm run test:session` 为 20 项回归;0.2.0-rc.2 已实测短文本发送/回复,真实待决/子代理仍未验收。 +- **0.2 新建/预览/选择接口**:`createHostSession` 在新宿主先用官方 workspaces 登记/复用项目路径(无路径则 initializeDefault),再按 workspaceId 创建;仅 cwd 会留下“可打开但不能输入”的未分组空会话。旧宿主保留原分支,不自动迁移既有会话。预览读临时持有的 eventSource,模型目录在持有期间使用,结束即释放;预设接 remote.agentPresets。`npm run test:details` 为 18 项回归,不能替代真实模型会话验收。 + - **窗口编号**:用户说「窗口1/2/3…」指布局里按「左栏 → 顶行 → 主行」顺序的第 N 个内容窗。 例:田字格预设(g4)窗口1/2 = 顶行左右、窗口3/4 = 底行左右;l13 窗口1 = 顶部大窗, 窗口2/3/4 = 底部三小窗(从左到右)。需要定位时按此映射,不要凭猜测。 @@ -141,8 +145,9 @@ node --check lib/index.js collectKids(byId.parentId + subagentsByParent 双通道)聚合父会话及其子代理的 pending; 会话面 binding(id).session.getSnapshot().pending 非空也判 need(列表不映射时的兜底); ackProjectNotify 同步 ack 子代理。 - **ack 生命周期**:ack 只在「同一轮待决未解决」期间压制提醒;状态转移(needNow 真↔假)时 - clearNotifyAck 自动清除旧 ack——原生 UI 每个新问题都重新亮黄,镜像必须同样重新点亮。 + **ack 生命周期**:新版按父/子会话的 opaque pending key+kind 集合保存 `need:...`,不保存问题正文/答案; + 同为 need 的问题替换也重新点亮,排序/重复目录不触发误亮。旧无 key 主机继续存 need,无法识别无 key 的直接替换。 + 状态/身份转移清旧 ack 时同步清除本次读取副本;点开确认同时记录已见身份,防下一次渲染反清刚保存的 ack。 - **「工作台」控制室项目(默认自带)**: - 固定 id `wt-console`(CONSOLE_ID),卡片恒排项目列表第一位(order 0)、不可删除 (不进设置管理列表 + removeProject 兜底拒绝);图标 🖥️,名称走 locale console.name。 @@ -152,7 +157,8 @@ node --check lib/index.js 建空会话 sessions.create 后自动绑定并打开控制室)。绑定也走 projects.v1.bindings。 - 控制室面板(split.tsx ConsolePane):卡片网格每行 3 张、超出换行;每卡 = 图标/名称/ 状态大字与三色光效(need>done>busy>idle,不过滤 ack,永远显示事实状态)/运行时长 - (后台任务 JobView.startedAt 或会话面 turnTimings 未结束轮次)/最近消息预览。数据组装 + (后台任务 JobView.startedAt → 新版 uiConversation 的 chat.legacy.turnTimings → 旧会话面, + 未加载/无起点不显示时长;不为计时激活 chat 或持有冷会话)/最近消息预览。数据组装 = index.tsx getConsoleCards(env.console 注入),刷新走 consoleListeners(项目/会话 快照变化推送)+ 面板每秒 tick。 - 卡片动作:点卡片 = 打开该项目(openSplit 或入驻项目切绑定对话);工作台自己的卡片 @@ -196,11 +202,11 @@ node --check lib/index.js 同源 iframe 下钻取字(boxPayload),跨域输出「读取受限」+ src;提示词含「禁止编造,缺失时如实说明,建议截图或视觉模型」; 知识包含标注协议行(窗口编号+处理方式);回退锚点 tag:pre-annotate-v3 / pre-annotate-v3.1 / pre-annotate-v3.2。 - 分隔线:DIVIDER=4(分栏可拖分隔条更细)。 - - 冷会话消息预览(方案 A):binding() 对冷会话不载入文本;预热走 face.history({maxMessages:6}) - (运行期内建方法、非公开接口,只读无副作用)尾部扫 user/message 与 assistant/message 的 + - 冷会话消息预览:0.2 用 sessions.using 临时持有后读 eventSource 的已加载事件窗口;旧版预热走 face.history({maxMessages:6}) + (旧版运行期内建方法、非公开接口);尾部扫 user/message 与 assistant/message 的 text 块 → cleanPreviewText(滤除 ```围栏与行内代码、压缩空白;不足 8 字符回退更早消息)→ previewCache;sweepPreviews 在打开控制室时 + 控制室开着且会话快照变化防抖 6s 触发; - 失败静默回退内存路径 lastTextOf。拉取是带宽成本不是 Token 成本。 + 失败静默回退缓存/内存路径 lastTextOf;预热期间不因自身 retain/release 排入新一轮。拉取是带宽成本不是 Token 成本。 - 状态指示:卡片右上角小圆点已删(整卡光效表达状态);状态计算不变。 - **更新检查(v0.2.2)**:客户端直连 GitHub Releases API 比版本(只读 GET;自动每天最多 一次,手动「立即检查」绕过节流;单次 8s 超时(AbortController)+ 最多 3 次重试, @@ -215,11 +221,11 @@ node --check lib/index.js 与版本化资产。版本注入:build.mjs 把 package.json version 打进 __WT_VERSION__,发版前 改 version 再构建;升级动作(执行 add + 重启)永远留给用户或其 Agent,插件不自更新。 -## 宿主升级事故(0.1.1-rc.2 link 插件回归,备忘) +## 旧版 link 加载兼容边界 -- rc.2 的 loader 对裸包名走原生 ESM import(),link: 插件找不到 profile 里的 peer 依赖 → 启动即崩。 - 临时修复:junction `E:\AI_Workspace\DeepseekHarness\node_modules` → `C:\Users\SJL\.dsh\profiles\node_modules`。 - **上游 loader 修复后必须删除该 junction**(双解析叠加)。peerDependencies 全部 `"*"` + optional 已核查(干净)。 +- 特定旧版 0.1.1-rc.2 的裸包 import / link peer 解析曾使用上级 node_modules junction 兜底,不是通用修法。 + 本机 0.2.0-rc.2 经无旧链接冷启动与真实工作台/控制室检查后已移除该链接,原目标目录保留。 + 重新遇到加载失败时先查实际宿主、profile、插件及第一条完整错误;不得直接创建更高层级链接或删除它指向的依赖目录。 ## 宿主 bug 跟踪(UTF-16 路径截断) diff --git a/README.md b/README.md index 9f36552..97f598a 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ ### 🖥️ Control room (built-in default project) - A pinned, undeletable first project — bind one management conversation on first open -- 3-column card grid mirrors **every** project: working / needs you / done with live runtime, subagent counts and a cleaned message preview -- Event-driven host snapshot mirroring — **zero polling, zero tokens** +- A configurable card grid mirrors visible projects: working / needs you / done with available runtime and a cleaned message preview +- Event-driven host snapshot mirroring; status monitoring does not call a model - Glassmorphism cards, dark / light / system theme, neon status glows and a rotating comet on busy cards --- @@ -44,15 +44,17 @@ |---|------| | 🧩 Plugin type | Cordis plugin — host routes + web client, pure additive (no official plugin replaced) | | 🪟 Workspace engine | Self-built split engine rendered into the host shell overlay seat | -| 💬 Chat pane | Reuses the host conversation — the plugin only selects sessions (`sessions.open`) | +| 💬 Chat pane | Reuses the host conversation; navigation uses the host's new or legacy session API | | 📡 Status data | Mirror of the host session runtime snapshots (subscription-driven) | -| 💾 State | localStorage only (`dsh.worktable.*`); no workspace files touched | +| 💾 State | Projects/layouts/bindings in localStorage; media in IndexedDB; file panes access configured project directories | | 🎨 UI | TypeScript + React (host externals) + vanilla CSS, dark-first with light theme | --- ## Quick start +v0.3.4 declares compatibility with DSH Web **0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2**. This round tested the listed core Windows Web flows on 0.2.0-rc.2; the two older versions use prior page validation plus targeted code regressions, not a newly repeated full GUI suite. Official Desktop support has not been verified. Back up important data and check your other plugins before upgrading the host. + 1. **Install** (pick one): **A · one-liner (recommended)** — straight from the GitHub Release tarball, no Git needed: @@ -100,6 +102,7 @@ node --check lib/index.js - The client bundle keeps the `window.__ModuleLoader__.load` handshake; `react` and `@deepseek-ai/*` stay external - Regression: `04_test/functional-diag.cjs` (20 steps, strict gate) plus targeted probes (control room, bind panel, collapsed rail, model inheritance), the path matrix (`04_test/pathutil-matrix.cjs`) and update-check scenarios (`04_test/probe-update-scenarios.cjs`) - In the release pipeline: split-anchor DOM regression `04_test/anchor-dom.test.mjs` (8 scenarios, both host conversation-root shapes) and data-home resolution regression `04_test/server-home.test.mjs` (3 groups: no-cycle fallback, path expansion, official-branch fixture) +- Release packaging uses `npm run pack` only. It also runs 53 input/session regressions, installation and client-factory gates; after uploading, run `npm run verify:remote -- --expect-sha v0.3.4` and check `latest` separately. --- @@ -107,7 +110,7 @@ node --check lib/index.js **Q: After a DeepSeek Harness update, the worktable fails to open / the service fails to start?** -Don't panic: your data is safe — projects, bindings and layouts live in the browser (localStorage), and project files stay in your own project folders; upgrading or repairing never touches them. Pick the case that matches: +Projects, bindings and layouts live in browser localStorage; media lives in IndexedDB, and project files remain in your project directories. Back up important data before changing your installation, and keep the same browser origin when you want to retain its worktable state. **Case A: Harness works, only the worktable needs updating** @@ -120,16 +123,10 @@ Don't panic: your data is safe — projects, bindings and layouts live in the br **Case B: Harness itself is down** (service fails to start / "Failed to load plugins") -- First choice: upgrade Harness to the latest (official fixes usually ship there): run `npx @deepseek-ai/dsh web`; -- If it still fails: copy the following to any AI assistant (Claude, Codex, GLM, … — DeepSeek Harness itself is not required): - -> Help me fix DeepSeek Harness custom-plugin loading (the error is usually "Failed to load plugins" or the service failing to start): -> 1. Run npx @deepseek-ai/dsh web to upgrade to the latest and start; if it works, done. -> 2. If it still fails: open ~/.dsh/profiles/web/package.json, find every dependency starting with link:, and compute their common parent directory (e.g. if all plugins live under D:\projects, the parent is D:\projects). -> 3. Create a node_modules directory junction in that parent pointing to ~/.dsh/profiles/node_modules: mklink /J "\node_modules" "%USERPROFILE%\.dsh\profiles\node_modules"; if that parent already has a node_modules, retry one level up. -> 4. Restart dsh web and open http://127.0.0.1:3080 to confirm the worktable is back. - -This issue has been reported to the DeepSeek Harness team and is being fixed; upgrading after the fix ships resolves it permanently. +- Record the actual DSH version, plugin version, profile/data-home location and the first complete error. Restart the process fully before checking again. +- Missing exports or failed imports require checking the named plugin against the installed host version. A newer host can change APIs; upgrading everything blindly is not a diagnosis. +- An ancestor `node_modules` junction was a local workaround for a particular old `link:` loader issue. Do not create one as a general fix; remove an existing workaround only after verifying that the installed plugins no longer depend on it, preserving its target directory. +- A model request returning HTTP 400 is a separate request problem. This plugin package does not include host-level repairs for it. ## Point-to-annotate 📌 @@ -142,8 +139,8 @@ Every window title bar has a small **annotate button** (chat-bubble with a plus) ## Known limits -- **Platform**: Windows is the fully tested platform. macOS support is experimental: the core file-path code has been adapted for cross-platform use, but no end-to-end test has been completed on macOS hardware. -- State lives in the browser (`localStorage`) — projects, bindings and views do not sync across machines +- **Platform**: Windows Web is the tested platform within the version and flow limits above. Official Desktop remains unverified; macOS support is experimental and has not been tested end to end on real hardware. +- State lives in the browser (localStorage and IndexedDB) — projects, bindings, views and media do not sync across machines - The terminal pane is a plain PowerShell host on Windows (no PTY feature parity with the native terminal app) - Auto-mount requires the agent to actually write `widget-result.json` in the project folder - The control room monitors projects that are **bound** to a conversation; unbound projects show as idle @@ -152,7 +149,7 @@ Every window title bar has a small **annotate button** (chat-bubble with a plus) ## Privacy -No telemetry, no network calls beyond the host APIs and the plugin routes. All user state stays in localStorage. Optional update check: a read-only GET to the GitHub Releases API (automatic at most once a day, plus a manual "Check now" button); nothing is uploaded, and it can be disabled in Settings. +The plugin does not add an analytics service. Update checks use a read-only GitHub Releases request and can be disabled in Settings. It also communicates with host APIs/WebSockets, loads user-selected pages, and provides an interactive terminal whose commands can access files and the network. Preferences and media use browser storage. See the [permissions and external-services disclosure](01_content/README.md#权限与外部服务如实披露) for file access, inherited terminal environment and other limits. --- diff --git a/README.zh.md b/README.zh.md index 10eecf6..6580b72 100644 --- a/README.zh.md +++ b/README.zh.md @@ -32,8 +32,8 @@ ### 🖥️ 控制室(内置默认项目) - 固定在首位的不可删除项目——首次打开绑定一条管理对话即可 -- 三列卡片网格实时镜像**每一个**项目的状态:工作中 / 待你决定 / 已完成,附带运行时长与清洗后的最近消息预览 -- 宿主会话快照的事件订阅镜像——**零轮询、零 Token** +- 可调列数的卡片网格镜像可见项目的状态:工作中 / 待你决定 / 已完成,附带可用的运行时长与清洗后的最近消息预览 +- 宿主会话快照的事件订阅镜像;状态监控不调用模型 - 玻璃拟态卡片、深色/白色/跟随系统主题、霓虹状态光效与工作中卡片的旋转彗星光点 --- @@ -44,15 +44,17 @@ |---|------| | 🧩 插件类型 | Cordis 插件——服务端路由 + Web 客户端,纯增量(不替换任何官方插件) | | 🪟 工作区引擎 | 自研分栏引擎,渲染进宿主的 shell overlay 座位 | -| 💬 对话窗 | 复用宿主对话——插件只做会话选择(sessions.open) | +| 💬 对话窗 | 复用宿主对话,导航接宿主新版或旧版会话接口 | | 📡 状态数据 | 宿主会话运行时快照的镜像(订阅驱动) | -| 💾 状态存储 | 仅 localStorage(dsh.worktable.*),不碰工作区文件 | +| 💾 状态存储 | 项目/布局/绑定存 localStorage,媒体存 IndexedDB;文件窗访问已配置项目目录 | | 🎨 界面 | TypeScript + React(宿主 external)+ 原生 CSS,暗色优先 + 浅色主题 | --- ## 快速开始 +v0.3.4 网页端兼容声明仅列 **DSH 0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2**。本轮在 Windows + 0.2.0-rc.2 验证主要网页流程;前两个版本沿用此前页面验收并补定向代码回归,未重跑整套旧版 GUI。官方桌面端尚未验证。升级宿主前请备份重要数据,并核对其他插件的兼容情况。 + 1. **安装**(二选一): **A · 一行命令(推荐)** —— 直接安装 GitHub Release 的安装包,无需 Git: @@ -101,6 +103,7 @@ node --check lib/index.js - 回归:`04_test/functional-diag.cjs`(20 步)+ 专项探测(控制室、绑定弹窗、收起态贴片、模型继承); 发布流水线内嵌:分栏锚点 DOM 回归 `04_test/anchor-dom.test.mjs`(8 场景,双宿主会话结构)与 数据目录解析回归 `04_test/server-home.test.mjs`(3 组场景,无循环/路径展开/官方分支夹具) +- 发布打包唯一入口为 `npm run pack`,另含 53 项输入/会话回归、独立安装与客户端工厂门禁;上传后用 `npm run verify:remote -- --expect-sha <最终 SHA> v0.3.4` 核对,并另查 latest。 --- @@ -108,7 +111,7 @@ node --check lib/index.js **Q:DeepSeek Harness 更新后,工作台打不开 / 服务启动失败?** -先放心:你的数据不会丢 —— 项目、绑定、布局都存在浏览器本地(localStorage),项目文件都在你自己的项目文件夹里,升级或修复都不会动它们。按下面两种情况处理: +项目、绑定和布局存在浏览器 localStorage,媒体存在 IndexedDB,项目文件保存在自己的项目目录。更改安装前先备份重要数据;需要保留工作台状态时,应保持原浏览器来源,不主动清空浏览器存储。 **情况 A:Harness 正常,只是工作台需要更新** @@ -121,16 +124,10 @@ node --check lib/index.js **情况 B:Harness 本身挂了**(服务起不来 / 报 Failed to load plugins) -- 首选:升级 Harness 到最新版(已知问题的官方修复通常都在新版里):终端执行 `npx @deepseek-ai/dsh web`; -- 如果升级后仍然失败:把下面这段复制发给**任意 AI 助手**(Claude、Codex、GLM 等都可以,不需要 DeepSeek Harness 本身): - -> 帮我修复 DeepSeek Harness 的自研插件加载问题(报错通常是 Failed to load plugins 或服务启动失败): -> 1. 终端执行 npx @deepseek-ai/dsh web 升级到最新版并启动;成功即结束。 -> 2. 若仍失败:打开 ~/.dsh/profiles/web/package.json,找出 dependencies 里所有 link: 开头的插件路径,计算它们的共同上级目录(例如都装在 D:\projects 下,上级就是 D:\projects)。 -> 3. 在该上级目录创建名为 node_modules 的目录联接,指向 ~/.dsh/profiles/node_modules:mklink /J "<上级目录>\node_modules" "%USERPROFILE%\.dsh\profiles\node_modules";若该上级目录已存在 node_modules,就改用它的上一级目录再试。 -> 4. 重启 dsh web,打开 http://127.0.0.1:3080 确认工作台恢复。 - -该问题已反馈 DeepSeek Harness 官方,正在修复;官方修复发布后,升级到新版即可彻底解决。 +- 记录实际 DSH 版本、插件版本、profile/数据目录位置和第一条完整错误;完整退出重启后再判断。 +- 缺少导出、模块导入失败时,要核对报错所指插件与宿主版本。新版宿主可能改变接口,盲目全部升级不能代替定位原因。 +- 上级 `node_modules` 目录链接曾用于某个旧版 `link:` 加载问题,不是通用修法。不要直接创建;已有链接应在确认插件无依赖后再移除,只删链接并保留目标目录。 +- 模型请求返回 HTTP 400 属于独立的请求问题,本工作台发布包不包含宿主层面的对应修复。 ## 指哪打哪标注 📌 @@ -143,8 +140,8 @@ node --check lib/index.js ## 已知限制 -- **平台**:Windows 是当前完整验证平台。macOS 为实验性支持:核心文件路径代码已做跨平台适配,但尚未在 macOS 真机完成端到端验证。 -- 状态在浏览器本地(localStorage)——项目、绑定与视图不跨设备同步 +- **平台**:Windows 网页端已测范围见上方版本与流程说明;官方桌面端未验证,macOS 为实验性支持且尚未真机端到端验证。 +- 状态在浏览器本地(localStorage 与 IndexedDB)——项目、绑定、视图与媒体不跨设备同步 - 终端窗格在 Windows 上是朴素的 PowerShell 宿主(与原生终端应用无 PTY 对等) - 自动挂载要求 agent 确实在项目文件夹写出 `widget-result.json` - 控制室只监控**已绑定对话**的项目;未绑定的项目显示为空闲 @@ -153,7 +150,7 @@ node --check lib/index.js ## 隐私 -无遥测;除宿主 API 与插件自身路由外无任何网络请求。用户状态全部留在 localStorage。可选更新检查:对 GitHub Releases API 做只读 GET(自动每天最多一次,另有手动「立即检查」),不上传任何数据,可在设置中关闭。 +插件没有新增分析统计服务。更新检查对 GitHub Releases API 做只读请求,可在设置中关闭;插件还使用宿主 API/WebSocket、加载用户选择的网页,并提供可读写文件和联网的交互式终端。偏好与媒体使用浏览器存储。文件访问、终端继承环境变量等具体边界见[权限与外部服务披露](01_content/README.md#权限与外部服务如实披露)。 ---