From 6cb86b6c73b3c356373e06cd81e08952542ea040 Mon Sep 17 00:00:00 2001 From: Aisland Date: Sat, 3 Oct 2026 13:02:20 +0800 Subject: [PATCH] feat(v0.4.0): support Windows Desktop and isolate project widget mounts --- 01_content/README.md | 35 +- 01_content/dsh.plugin.json | 2 +- 01_content/lib/client.js | 451 +++++++++++++++++------- 01_content/lib/index.js | 2 +- 01_content/package-lock.json | 7 +- 01_content/package.json | 4 +- 01_content/release-prep.mjs | 2 +- 01_content/src/client/changelog.ts | 34 +- 01_content/src/client/hostTransport.ts | 28 ++ 01_content/src/client/index.tsx | 260 +++++++------- 01_content/src/client/locales.ts | 6 + 01_content/src/client/sessionDetails.ts | 13 +- 01_content/src/client/split.tsx | 32 +- 01_content/src/client/styles.ts | 1 + 01_content/src/client/widgetMount.ts | 121 +++++++ 02_process/PRD.md | 168 ++++++++- 04_test/host-transport.test.mjs | 45 +++ 04_test/session-details.test.mjs | 183 +++++++++- 04_test/widget-mount.test.mjs | 366 +++++++++++++++++++ AGENTS.md | 151 ++------ README.md | 24 +- README.zh.md | 24 +- 22 files changed, 1507 insertions(+), 452 deletions(-) create mode 100644 01_content/src/client/hostTransport.ts create mode 100644 01_content/src/client/widgetMount.ts create mode 100644 04_test/host-transport.test.mjs create mode 100644 04_test/widget-mount.test.mjs diff --git a/01_content/README.md b/01_content/README.md index d0377b1..e765c7a 100644 --- a/01_content/README.md +++ b/01_content/README.md @@ -6,8 +6,8 @@ - **侧边栏「工作台」区块**:收纳用户自建项目与入驻插件项目,支持改名/图标/排序/显示隐藏、项目 × 对话绑定、项目文件夹。 - **分栏工作区引擎(自研)**:声明式布局预设(左栏/顶行/主行 + 右侧对话窗),窗格可拖拽分割、标签页模型;内置 资源管理器 / 终端 / 浏览器 / 动画 / 自定义窗口。 -- **控制室**:默认自带项目(固定首位、不可删除),项目卡片网格实时监控所有项目的状态(工作中/待你决定/已完成),零轮询零 Token。 -- **自动挂载握手**:项目内 agent 完成窗口任务后写 widget-result.json,产物自动挂进对应窗口。 +- **控制室**:默认自带项目(固定首位、不可删除),项目卡片网格镜像可见绑定项目的宿主状态(工作中/待你决定/已完成);状态订阅不调用模型,计时与预览另有刷新机制。 +- **项目独立挂载**:窗口任务使用项目 ID、窗格 ID 与绑定 ID 对应的专属结果文件;同一文件夹里的新项目仍从空白窗口开始,不自动读取其他项目的旧结果。 - **平台**:Windows 是本轮验证平台,范围见下方兼容说明;macOS 为实验性支持(核心文件路径代码已做跨平台适配,尚未真机端到端验证)。 ## 技术底座 @@ -17,15 +17,26 @@ - 状态监控为宿主会话运行时快照的事件订阅镜像;视图/项目/绑定状态存 localStorage。 - 客户端:TypeScript + React(host externals)+ 原生 CSS;服务端:Node。 -## v0.3.4 兼容与验证范围 +## v0.4.0:兼容与验证范围 - 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 并刷新页面,仍加载失败需保留完整错误。 +- Windows + 0.2.0-rc.2 网页端已有冷启动、工作台/控制室、新建绑定与短文本回复、关闭回切等验收基础;桌面主要页面与交互已点测,共目录串挂修复获用户确认。最终包经过 98 项源码回归、独立安装与客户端加载门禁;这些检查不等于完整桌面端业务验收。旧版沿用此前页面验收并补定向代码回归,未重跑整套旧版 GUI。 +- 实际审批、子代理、自动挂载全链路尚未完成业务验收;macOS 未真机验证。升级后请完整重启 DSH 并刷新页面,仍加载失败需保留完整错误。 + +## v0.4.0 改动 + +- 针对 Windows 官方 Desktop 0.2.0-rc.2 补上终端连接:从宿主注入的 streamBaseUrl 取得实际 Host 地址,不把 dsh-app://app 当作 WebSocket 主机。普通 HTTP/HTTPS 网页连接保留。 +- 更新指令区分 web/desktop profile;桌面安装必须使用该应用自带的 CLI,安装前完整退出,之后由用户手动重新打开。不要把网页端 CLI 当作桌面管理入口。 +- 自定义窗口新建对话明确选择「未分组」时,保留项目工作目录但不自动加入宿主工作区;异步默认分组不覆盖用户选择。不移动已经创建的对话。 +- 控制室创建的是空对话:新版 DSH 的未分组空对话无法使用原生输入框,因此在这一选择下提示并禁用创建,须由用户选择分组或加入可用的现有对话;不自动改组、不发送激活消息。现有分组未选具体项时也不允许创建。旧宿主未分组分支保留。 +- 自动挂载只接收经「自定义」任务登记的项目/窗口结果,握手文件位于项目目录的 `.dsh-worktable/project-<项目 ID>/` 下。换目录、重绑项目、修改布局或手动关闭/替换窗口内容后,旧关联不再自动补挂。 +- 同一项目的不同会话只得到和更新各自关联的窗口;同会话追改同名作品可以重新加载。补挂读取失败保留待办,下次打开可重试,成功后才清除。 +- 已保存的旧窗口与产物文件保留;根目录旧 `widget-result.json` 不再自动导入。旧对话若需继续自动挂载,请从目标窗口的「自定义 → 发送到会话」发一次任务以建立新关联,随后可在同一对话直接继续修改。不自动清除此前误挂的标签;多个项目主动改写同一实际文件时,文件内容仍可能互相影响。 +- 两端共享同一 DSH_HOME 时不必复制会话;项目/布局/媒体和外观设置按浏览器来源保存,不会自动从网页来源同步到桌面来源。不要复制整个浏览器配置或登录状态。 ## 权限与外部服务(如实披露) -- **文件访问**:服务端读写工作区与用户指定的目录——文件窗目录列表、MD 编辑保存、项目文件夹 mkdir、站点静态托管、widget 产物读取。除用户当次操作外,插件在加载时、项目状态变化时、绑定会话完成时会自动读取已配置项目的数据(如挂载产物 widget-result.json、项目文件夹与绑定信息),并读取宿主提供的工作区数据(workspaces 路由)。读写路径均来自用户配置或宿主数据;失败返回错误或降级。 +- **文件访问**:服务端读写工作区与用户指定的目录——文件窗目录列表、MD 编辑保存、项目文件夹 mkdir、站点静态托管、widget 产物读取。除用户当次操作外,插件在加载时、项目状态变化时、已登记窗口任务完成时会自动读取当前关联的 `.dsh-worktable/` 结果文件及其指定产物,并读取宿主提供的工作区数据(workspaces 路由)。不扫描根目录旧 `widget-result.json`;产物路径可来自任务结果,这不是文件系统沙箱。失败返回错误或降级。 - **网络**:① 更新检查向 api.github.com 发只读 GET(无凭据,请求仅含公开仓库名,不携带会话内容);② 插件与宿主的通信走宿主自身的 API / WebSocket 通道(由宿主管理);③ 「浏览器」「动画」等网页窗加载用户指定的页面,页面自身可能发起任意网络请求——等同于用户在自己的浏览器中打开这些页面。 - **命令执行**:① 「源代码管理」窗运行 git 只读状态命令;② 「终端」窗经 node-pty 启动交互式 Shell(Windows 下为 PowerShell)——这是用户主动使用的完整终端:终端内执行的任何命令都可能读写文件、联网,行为等同用户本人操作。 - **凭据与环境**:插件代码不专门提取、存储或上传任何 API Key / Token / Cookie;但终端窗把宿主进程的完整环境变量传给子 Shell(其中可能包含敏感信息),终端内命令的行为不受插件约束。插件对 process.env 的读取用于运行参数(数据目录 DSH_HOME、终端默认 Shell SHELL 等),不将其外发。以上声明均有对应源码可查;本说明不是安全保证,高权限能力如实列出,使用风险由用户在自身环境评估。 @@ -33,7 +44,9 @@ ## 安装 -方式 A(推荐,无需 Git)——直接安装 GitHub Release 的安装包: +### 网页端 + +方式 A(推荐,无需 Git)——直接安装 GitHub Release 的最新安装包: dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz" @@ -44,6 +57,14 @@ 两种方式 `add` 都会把 `dsh-worktable` 注册进 profile 的 bundle 列表(写入 `~/.dsh`),装完重启 dsh web、刷新界面生效。 +### Windows 官方桌面端 + +先保存任务,从应用菜单/托盘完整退出(只关窗口不够);将占位路径替换为实际桌面端安装目录,在 PowerShell 执行,再手动重新打开应用: + + & "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.4.0/dsh-worktable-0.4.0.tgz" + +不要以不固定版本的 npx 或网页端 CLI 代替桌面端自带 CLI;保留实际 DSH_HOME,勿把测试数据目录覆盖正式数据。新建控制室管理对话时,DSH 0.2 用户请选择分组,或直接绑定可用的现有对话。 + ## 从源码构建 cd 01_content diff --git a/01_content/dsh.plugin.json b/01_content/dsh.plugin.json index d25e525..971c6a6 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.4", + "version": "0.4.0", "entry": { "name": "dsh-worktable", "inject": [] diff --git a/01_content/lib/client.js b/01_content/lib/client.js index 51c5649..f5e631c 100644 --- a/01_content/lib/client.js +++ b/01_content/lib/client.js @@ -8372,6 +8372,7 @@ var css = xterm_default + "\n" + [ ".dsh-wt_consoleSelect,.dsh-wt_consoleInput{width:100%;box-sizing:border-box;padding:6px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11.5px;line-height:16px;outline:none}", ".dsh-wt_consoleSelect:focus,.dsh-wt_consoleInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}", ".dsh-wt_consoleErr{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-state-danger,#f85149)}", + ".dsh-wt_consoleHint{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#aab6cd)}", ".dsh-wt_consoleCreateBtn{margin-top:2px;padding:7px 10px;border:1px solid var(--dsw-alias-state-accent-primary,#4f8ef7);border-radius:8px;background:transparent;color:var(--dsw-alias-state-accent-primary,#4f8ef7);font:inherit;font-size:12px;line-height:18px;cursor:pointer}", ".dsh-wt_consoleCreateBtn:hover:not(:disabled){background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}", ".dsh-wt_consoleCreateBtn:disabled{opacity:.55;cursor:default}", @@ -8569,6 +8570,8 @@ var zh = { "console.groupNone": "\u65E0\u5206\u7EC4\uFF08\u7528\u9879\u76EE\u6587\u4EF6\u5939\uFF09", "console.groupExisting": "\u52A0\u5165\u73B0\u6709\u5206\u7EC4", "console.groupNew": "\u65B0\u5EFA\u5206\u7EC4", + "console.chooseGroup": "\u9009\u62E9\u5206\u7EC4\u2026", + "console.blankNeedsGroup": "\u5F53\u524D DSH \u7684\u672A\u5206\u7EC4\u7A7A\u5BF9\u8BDD\u65E0\u6CD5\u8F93\u5165\uFF0C\u8BF7\u9009\u62E9\u5206\u7EC4\u6216\u52A0\u5165\u73B0\u6709\u5BF9\u8BDD\u3002", "console.newParentPh": "\u5206\u7EC4\u7236\u76EE\u5F55\uFF0C\u4F8B\u5982 E:\\AI_Workspace", "console.newNamePh": "\u5206\u7EC4\u6587\u4EF6\u5939\u540D\uFF08\u5C06\u521B\u5EFA\u5E76\u6CE8\u518C\uFF09", "console.createBind": "\u65B0\u5EFA\u5E76\u7ED1\u5B9A", @@ -8630,7 +8633,8 @@ var zh = { "update.copied": "\u5DF2\u590D\u5236", "update.copyFail": "\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u9009\u62E9\u6587\u672C", "update.skipDone": "\u5DF2\u5FFD\u7565\u6B64\u7248\u672C\uFF08\u4E0B\u4E00\u7248\u53D1\u5E03\u65F6\u518D\u63D0\u9192\uFF09", - "update.upgradeHint": "\u6267\u884C\u547D\u4EE4\u540E\u9700\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762" + "update.upgradeHint": "\u6267\u884C\u547D\u4EE4\u540E\u9700\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762", + "update.upgradeHintDesktop": "\u8BF7\u4F7F\u7528\u684C\u9762\u7AEF\u81EA\u5E26 CLI\uFF1B\u5B89\u88C5\u524D\u5B8C\u6574\u9000\u51FA\uFF0C\u5B8C\u6210\u540E\u624B\u52A8\u91CD\u65B0\u6253\u5F00\u684C\u9762\u7AEF" }; var en = { "title": "Worktable", @@ -8799,6 +8803,8 @@ var en = { "console.groupNone": "No group (use project folder)", "console.groupExisting": "Join existing group", "console.groupNew": "New group", + "console.chooseGroup": "Choose a group\u2026", + "console.blankNeedsGroup": "Blank ungrouped chats cannot accept input in this DSH. Choose a group or join an existing chat.", "console.newParentPh": "Group parent folder, e.g. E:\\AI_Workspace", "console.newNamePh": "Group folder name (created & registered)", "console.createBind": "Create & bind", @@ -8860,7 +8866,8 @@ var en = { "update.copied": "Copied", "update.copyFail": "Copy failed \u2014 select the text manually", "update.skipDone": "Skipped (will remind on the next release)", - "update.upgradeHint": "Run the command, then restart dsh web and refresh" + "update.upgradeHint": "Run the command, then restart dsh web and refresh", + "update.upgradeHintDesktop": "Use the Desktop bundled CLI; fully quit before installing, then reopen Desktop manually" }; var NS = "worktable"; @@ -8895,8 +8902,58 @@ function basenameOf(p) { // src/client/split.tsx var import_react = require("react"); +// src/client/hostTransport.ts +function hostWebSocketUrl(path, surface = { + href: location.href, + protocol: location.protocol, + streamBaseUrl: globalThis.__DSH_TRANSPORT__?.streamBaseUrl +}) { + const desktop = surface.protocol === "dsh-app:"; + if (desktop && !surface.streamBaseUrl) throw new Error("Desktop Host stream URL unavailable"); + const url = new URL(path, desktop ? surface.streamBaseUrl : surface.href); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Unsupported Host transport protocol: " + url.protocol); + } + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.href; +} +function worktableUpgrade(protocol = typeof location === "undefined" ? "http:" : location.protocol) { + const desktop = protocol === "dsh-app:"; + const command = "dsh plugin --profile " + (desktop ? "desktop" : "web") + ' add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"'; + const prompt = desktop ? "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\u3002\u6211\u5F53\u524D\u4F7F\u7528\u5B98\u65B9\u684C\u9762\u7AEF\uFF1A\u5148\u5B9A\u4F4D\u8BE5\u684C\u9762\u7AEF\u81EA\u5E26\u7684 dsh CLI \u5E76\u51C6\u5907\u66F4\u65B0\uFF0C\u518D\u63D0\u9192\u6211\u5B8C\u6574\u9000\u51FA\u684C\u9762\u7AEF\u3002\u786E\u8BA4\u9000\u51FA\u540E\u4F7F\u7528\u8BE5 CLI \u6267\u884C " + command + "\u3002\u4E0D\u8981\u4F7F\u7528\u7F51\u9875\u7AEF CLI\uFF0C\u4E0D\u8981\u4FEE\u6539 web profile\uFF0C\u4E0D\u8981\u81EA\u52A8\u6740\u8FDB\u7A0B\u6216\u91CD\u542F\uFF1B\u5B8C\u6210\u540E\u63D0\u9192\u6211\u624B\u52A8\u91CD\u65B0\u6253\u5F00\u684C\u9762\u7AEF\u3002" : "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\uFF1A\u6267\u884C " + command + "\uFF0C\u5B8C\u6210\u540E\u63D0\u9192\u6211\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762"; + return { command, prompt, desktop }; +} + // src/client/changelog.ts -var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.4 +var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.4.0 + +\u{1F5A5}\uFE0F \u7F51\u9875\u7AEF\u4E0E Windows \u684C\u9762\u7AEF\u9002\u914D\uFF0C\u4FEE\u590D\u9879\u76EE\u5185\u5BB9\u4E32\u6302 + +\u3010\u53CC\u7AEF\u4F7F\u7528\u3011\u540C\u4E00\u4E2A\u5DE5\u4F5C\u53F0\u63D2\u4EF6\u5305\u652F\u6301\u7F51\u9875\u7AEF\u4E0E\u5B98\u65B9 Windows \u684C\u9762\u7AEF\u3002\u5EF6\u7EED v0.3.4 \u5BF9 DSH 0.2 \u7F51\u9875\u7AEF\u7684\u9879\u76EE\u4E0E\u5BF9\u8BDD\u8054\u52A8\u3001\u6807\u6CE8\u8F93\u5165\u3001\u63A7\u5236\u5BA4\u72B6\u6001\u53CA\u6D88\u606F\u9884\u89C8\u9002\u914D\uFF0C\u672C\u6B21\u8865\u4E0A\u684C\u9762\u7AEF\u8FDE\u63A5\u4E0E\u5347\u7EA7\u5165\u53E3\u3002 + +\u3010\u684C\u9762\u7AEF\u7EC8\u7AEF\u4E0E\u66F4\u65B0\u3011\u7EC8\u7AEF\u4F7F\u7528\u684C\u9762\u5BBF\u4E3B\u63D0\u4F9B\u7684\u8FDE\u63A5\u5730\u5740\uFF0C\u7F51\u9875\u7AEF\u4FDD\u7559\u539F\u8FDE\u63A5\u65B9\u5F0F\u3002\u66F4\u65B0\u63D0\u793A\u4F1A\u6309\u6240\u5728\u73AF\u5883\u9009\u62E9 web \u6216 desktop \u914D\u7F6E\uFF1B\u684C\u9762\u7528\u6237\u8BF7\u4F7F\u7528\u5E94\u7528\u81EA\u5E26\u7684 CLI\uFF0C\u5B8C\u6574\u9000\u51FA\u540E\u5B89\u88C5\uFF0C\u518D\u624B\u52A8\u91CD\u65B0\u6253\u5F00\u3002 + +\u3010\u4FDD\u7559\u201C\u672A\u5206\u7EC4\u201D\u9009\u62E9\u3011\u81EA\u5B9A\u4E49\u7A97\u53E3\u65B0\u5EFA\u5BF9\u8BDD\u65F6\uFF0C\u9009\u62E9\u201C\u672A\u5206\u7EC4\u201D\u5C31\u4FDD\u6301\u672A\u5206\u7EC4\uFF0C\u9879\u76EE\u6587\u4EF6\u5939\u4EC5\u4F5C\u4E3A\u5DE5\u4F5C\u76EE\u5F55\u3002\u4FEE\u590D\u9ED8\u8BA4\u5206\u7EC4\u665A\u8FD4\u56DE\u65F6\u8986\u76D6\u7528\u6237\u9009\u62E9\u7684\u95EE\u9898\uFF1B\u5DF2\u6709\u5BF9\u8BDD\u7684\u5206\u7EC4\u4FDD\u6301\u539F\u6837\u3002 + +\u3010\u63A7\u5236\u5BA4\u65B0\u5EFA\u5BF9\u8BDD\u3011\u5DF2\u6D4B DSH 0.2 \u7684\u672A\u5206\u7EC4\u7A7A\u5BF9\u8BDD\u65E0\u6CD5\u4F7F\u7528\u539F\u751F\u8F93\u5165\u6846\uFF0C\u63A7\u5236\u5BA4\u4F1A\u63D0\u793A\u539F\u56E0\u5E76\u7981\u7528\u8FD9\u4E00\u521B\u5EFA\u65B9\u5F0F\uFF0C\u8BF7\u9009\u62E9\u73B0\u6709/\u65B0\u5EFA\u5206\u7EC4\uFF0C\u6216\u52A0\u5165\u53EF\u7528\u7684\u73B0\u6709\u5BF9\u8BDD\u3002\u9009\u62E9\u201C\u73B0\u6709\u5206\u7EC4\u201D\u4F46\u5C1A\u672A\u9009\u5B9A\u5177\u4F53\u9879\u65F6\uFF0C\u4E5F\u4E0D\u4F1A\u9000\u56DE\u9ED8\u8BA4\u5206\u7EC4\u521B\u5EFA\uFF1B\u65E7\u5BBF\u4E3B\u4FDD\u7559\u539F\u672A\u5206\u7EC4\u521B\u5EFA\u884C\u4E3A\u3002 + +\u3010\u65B0\u9879\u76EE\u4ECE\u7A7A\u767D\u5F00\u59CB\u3011\u65B0\u9879\u76EE\u5373\u4F7F\u4E0E\u65E7\u9879\u76EE\u540C\u540D\u3001\u4F7F\u7528\u540C\u4E00\u4E2A\u6587\u4EF6\u5939\uFF0C\u4E5F\u4E0D\u4F1A\u81EA\u52A8\u52A0\u8F7D\u65E7\u9879\u76EE\u7684\u7A97\u53E3\u5185\u5BB9\u3002\u81EA\u52A8\u6302\u8F7D\u5206\u522B\u8BC6\u522B\u9879\u76EE\u3001\u7A97\u53E3\u4E0E\u672C\u6B21\u4EFB\u52A1\u7684\u5173\u8054\uFF0C\u4E3A\u5404\u7A97\u53E3\u4F7F\u7528\u4E13\u5C5E\u7ED3\u679C\u6587\u4EF6\uFF1B\u9519\u8BEF\u5F52\u5C5E\u6216\u65E0\u6548\u7A97\u53E3\u7ED3\u679C\u4E0D\u4F1A\u88AB\u585E\u8FDB\u7A97\u53E31\u3002 + +\u3010\u4E0D\u540C\u5BF9\u8BDD\u4E92\u4E0D\u4E32\u6302\u3011\u540C\u4E00\u9879\u76EE\u7684\u4E0D\u540C\u5BF9\u8BDD\u53EA\u83B7\u5F97\u548C\u66F4\u65B0\u5404\u81EA\u5173\u8054\u7684\u7A97\u53E3\uFF1B\u4E00\u4E2A\u5BF9\u8BDD\u5B8C\u6210\u65F6\uFF0C\u4E0D\u4F1A\u5F3A\u5236\u91CD\u6302\u53E6\u4E00\u5BF9\u8BDD\u7684\u65E7\u7ED3\u679C\u3002\u540C\u4E00\u5BF9\u8BDD\u7EE7\u7EED\u4FEE\u6539\u540C\u540D\u4F5C\u54C1\u65F6\uFF0C\u7A97\u53E3\u4ECD\u53EF\u91CD\u65B0\u52A0\u8F7D\u66F4\u65B0\u540E\u7684\u5185\u5BB9\u3002\u4EFB\u52A1\u63D0\u793A\u8BCD\u8981\u6C42\u786E\u8BA4\u9875\u9762\u5B9E\u9645\u6302\u8F7D\u540E\u518D\u62A5\u544A\u201C\u5DF2\u6302\u8F7D\u201D\uFF0C\u907F\u514D\u53EA\u5199\u51FA\u7ED3\u679C\u6587\u4EF6\u5C31\u62A5\u6210\u529F\u3002 + +\u3010\u4FDD\u7559\u624B\u5DE5\u8C03\u6574\u4E0E\u8865\u6302\u91CD\u8BD5\u3011\u624B\u5DE5\u5173\u95ED\u3001\u66FF\u6362\u6216\u79FB\u52A8\u6807\u7B7E\uFF0C\u4EE5\u53CA\u66F4\u6362\u76EE\u5F55\u3001\u5E03\u5C40\u6216\u9879\u76EE\u7ED1\u5B9A\u540E\uFF0C\u65E7\u5173\u8054\u4E0D\u4F1A\u7EE7\u7EED\u8865\u6302\u6216\u8986\u76D6\u4F60\u7684\u8C03\u6574\uFF1B\u8FDF\u5230\u7684\u65E7\u8BF7\u6C42\u4E5F\u4F1A\u91CD\u65B0\u6838\u5BF9\u5F52\u5C5E\u3002\u9879\u76EE\u672A\u6253\u5F00\u65F6\u4FDD\u7559\u5F85\u6302\u8F7D\u8BB0\u5F55\uFF0C\u91CD\u65B0\u6253\u5F00\u4F1A\u8BFB\u53D6\u6700\u65B0\u7ED3\u679C\uFF1B\u8BFB\u53D6\u5931\u8D25\u540E\u8BB0\u5F55\u4ECD\u4FDD\u7559\uFF0C\u4E0B\u6B21\u6253\u5F00\u53EF\u91CD\u8BD5\uFF0C\u6210\u529F\u6302\u8F7D\u540E\u624D\u6E05\u9664\u3002 + +\u3010\u65E7\u9879\u76EE\u7EE7\u7EED\u4F7F\u7528\u3011\u5DF2\u4FDD\u5B58\u7684\u7A97\u53E3\u3001\u4F5C\u54C1\u3001\u4F1A\u8BDD\u7ED1\u5B9A\u4E0E\u5A92\u4F53\u4FDD\u7559\u3002\u6839\u76EE\u5F55\u65E7 widget-result.json \u4E0D\u518D\u81EA\u52A8\u5BFC\u5165\uFF1B\u65E7\u5BF9\u8BDD\u82E5\u9700\u7EE7\u7EED\u81EA\u52A8\u6302\u8F7D\uFF0C\u8BF7\u4ECE\u76EE\u6807\u7A97\u53E3\u7684\u201C\u81EA\u5B9A\u4E49 \u2192 \u53D1\u9001\u5230\u4F1A\u8BDD\u201D\u53D1\u4E00\u6B21\u9700\u6C42\uFF0C\u5EFA\u7ACB\u65B0\u5173\u8054\u540E\u5373\u53EF\u7EE7\u7EED\u8FFD\u6539\u3002\u65E7\u6587\u4EF6\u4E0D\u81EA\u52A8\u5220\u9664\uFF0C\u6B64\u524D\u8BEF\u6302\u5E76\u4FDD\u5B58\u7684\u6807\u7B7E\u8BF7\u5173\u95ED\u4E00\u6B21\uFF1B\u591A\u4E2A\u4EFB\u52A1\u4E3B\u52A8\u5199\u540C\u4E00\u4E2A\u5B9E\u9645\u4F5C\u54C1\u6587\u4EF6\uFF0C\u6587\u4EF6\u5185\u5BB9\u4ECD\u53EF\u80FD\u4E92\u76F8\u8986\u76D6\u3002 + +\u3010\u53D1\u5E03\u68C0\u67E5\u8865\u5168\u3011\u589E\u52A0\u684C\u9762\u8FDE\u63A5\u3001\u5206\u7EC4\u9009\u62E9\u3001\u9879\u76EE\u5F52\u5C5E\u3001\u4F1A\u8BDD\u9694\u79BB\u548C\u8865\u6302\u91CD\u8BD5\u56DE\u5F52\uFF0C\u53D1\u5E03\u6D41\u7A0B\u5171\u8FD0\u884C 98 \u9879\u6E90\u7801\u68C0\u67E5\uFF0C\u5E76\u7EE7\u7EED\u6838\u5BF9\u72EC\u7ACB\u5B89\u88C5\u3001\u5BA2\u6237\u7AEF\u52A0\u8F7D\u4E0E\u53CC\u5B89\u88C5\u5305\u4E00\u81F4\u6027\u3002\u8FD9\u4E9B\u68C0\u67E5\u7528\u4E8E\u51CF\u5C11\u53D1\u5E03\u9057\u6F0F\uFF0C\u4E0D\u80FD\u66FF\u4EE3\u771F\u5B9E\u9875\u9762\u548C\u6A21\u578B\u4EFB\u52A1\u9A8C\u6536\u3002 + +\u3010\u517C\u5BB9\u4E0E\u9A8C\u8BC1\u8303\u56F4\u3011\u7F51\u9875\u7AEF\u517C\u5BB9\u58F0\u660E\u4EC5\u5217 DSH 0.1.1-rc.2\u30010.1.2-rc.1\u30010.2.0-rc.2\uFF1B\u684C\u9762\u7AEF\u8303\u56F4\u4E3A\u5DF2\u6D4B Windows \u5B98\u65B9\u684C\u9762\u7AEF + DSH 0.2.0-rc.2\u3002\u65E7\u7248\u6CBF\u7528\u6B64\u524D\u9875\u9762\u9A8C\u6536\u5E76\u8865\u4EE3\u7801\u56DE\u5F52\uFF0C\u672C\u8F6E\u672A\u91CD\u8DD1\u5B8C\u6574\u65E7\u7248 GUI\uFF1B\u684C\u9762\u4E3B\u8981\u4EA4\u4E92\u5DF2\u70B9\u6D4B\uFF0C\u539F\u5171\u76EE\u5F55\u4E32\u6302\u95EE\u9898\u5DF2\u7531\u7528\u6237\u53CD\u9988\u786E\u8BA4\u4FEE\u590D\uFF0C\u4F46\u672C\u7248\u6700\u7EC8\u5305\u5728\u6B63\u5F0F\u684C\u9762\u7AEF\u7684\u5B8C\u6574\u590D\u9A8C\u3001\u5B9E\u9645\u5BA1\u6279/\u5B50\u4EE3\u7406\u53CA\u6A21\u578B\u751F\u6210\u5230\u81EA\u52A8\u6302\u8F7D\u7684\u5B8C\u6574\u4E1A\u52A1\u9A8C\u6536\u4ECD\u5F85\u5B8C\u6210\u3002macOS \u5C1A\u672A\u771F\u673A\u9A8C\u8BC1\u3002 + +\u3010\u5347\u7EA7\u4E0E\u6570\u636E\u3011\u7F51\u9875\u7AEF\u548C\u684C\u9762\u7AEF\u7684\u9879\u76EE\u3001\u5E03\u5C40\u3001\u5A92\u4F53\u53CA\u5916\u89C2\u8BBE\u7F6E\u6309\u5404\u81EA\u6D4F\u89C8\u5668\u6765\u6E90\u4FDD\u5B58\uFF0C\u4E0D\u4F1A\u81EA\u52A8\u540C\u6B65\uFF1B\u5171\u7528 DSH_HOME \u4E5F\u4E0D\u610F\u5473\u7740\u8FD9\u4E9B\u754C\u9762\u6570\u636E\u5171\u7528\u3002\u5B89\u88C5\u524D\u5907\u4EFD\u91CD\u8981\u6570\u636E\u5E76\u6838\u5BF9\u5176\u4ED6\u63D2\u4EF6\u517C\u5BB9\u6027\uFF0C\u88C5\u5B8C\u5B8C\u6574\u91CD\u542F\u5BBF\u4E3B\uFF0C\u7F51\u9875\u7AEF\u518D\u5237\u65B0\u9875\u9762\uFF1B\u82E5\u4ECD\u52A0\u8F7D\u5931\u8D25\uFF0C\u8BF7\u4FDD\u7559\u7B2C\u4E00\u6761\u5B8C\u6574\u9519\u8BEF\u3002\u672C\u5DE5\u4F5C\u53F0\u5305\u4E0D\u5305\u542B\u5BBF\u4E3B\u6A21\u578B\u8BF7\u6C42 HTTP 400 \u7684\u4FEE\u590D\uFF1B\u516C\u5F00\u53D1\u5E03\u72B6\u6001\u4EE5 GitHub Release \u4E3A\u51C6\u3002 + +\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + +\u6B64\u524D\u7248\u672C\u8BF4\u660E\uFF08v0.3.4\uFF09 \u{1F6E0}\uFE0F \u672C\u6B21\u66F4\u65B0\uFF1A\u9002\u914D DSH 0.2 \u7F51\u9875\u7AEF @@ -8970,7 +9027,7 @@ var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.4 \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.4"; +var LOCAL_VERSION = false ? "dev" : "0.4.0"; var UPDATE_REPO = "Aisland-SJL/dsh-worktable"; var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1"; var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1"; @@ -17097,8 +17154,7 @@ function boxPayload(x0, y0, x1, y1) { } return { primary, line, candidates, limited, src }; } -var UPGRADE_CMD = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"'; -var UPGRADE_AI = "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\uFF1A\u6267\u884C " + UPGRADE_CMD + "\uFF0C\u5B8C\u6210\u540E\u63D0\u9192\u6211\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762"; +var { prompt: UPGRADE_AI } = worktableUpgrade(); async function copyTextSafe(text2) { try { if (navigator.clipboard?.writeText) { @@ -17366,6 +17422,7 @@ var splitStore = { lastMarginRight: "", lastMarginTop: "", onSpecMutated: null, + onPaneContentEdited: null, listeners: /* @__PURE__ */ new Set(), open(spec) { if (this.active) { @@ -17654,7 +17711,7 @@ var splitStore = { lockPane(row, i, content) { const spec = this.spec; if (!spec) return; - const tab = { id: "t" + Date.now().toString(36), title: tabTitleOf(content), content, active: 0 }; + const tab = { id: "t" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2), title: tabTitleOf(content), content, active: 0 }; const mutate = (pane) => ({ ...pane, content: null, tabs: [tab], active: 0 }); if (row === "left") { if (!spec.left || i !== 0) return; @@ -17678,6 +17735,8 @@ var splitStore = { setTabContent(row, index, tabId, content) { const spec = this.spec; if (!spec) return; + const edited = row === "left" ? spec.left : row === "top" ? spec.top?.[index] : spec.main[index]; + if (edited?.tabs?.some((t) => t.id === tabId)) this.onPaneContentEdited?.(spec.id, edited.id); const mutate = (pane) => { const tabs = (pane.tabs ?? []).map((t) => t.id === tabId ? { ...t, content, title: tabTitleOf(content) } : t); return { ...pane, tabs }; @@ -17703,6 +17762,8 @@ var splitStore = { openTab(row, i, content) { const spec = this.spec; if (!spec) return; + const edited = row === "left" ? spec.left : row === "top" ? spec.top?.[i] : spec.main[i]; + if (edited) this.onPaneContentEdited?.(spec.id, edited.id); const mutate = (pane) => { const tabs = [...pane.tabs ?? []]; const existing = tabs.findIndex((t) => sameContent(t.content, content)); @@ -17732,6 +17793,8 @@ var splitStore = { closeTab(row, i, tabId) { const spec = this.spec; if (!spec) return; + const edited = row === "left" ? spec.left : row === "top" ? spec.top?.[i] : spec.main[i]; + if (edited?.tabs?.some((t) => t.id === tabId)) this.onPaneContentEdited?.(spec.id, edited.id); const mutate = (pane) => { const tabs = (pane.tabs ?? []).filter((t) => t.id !== tabId); return { ...pane, tabs, active: 0 }; @@ -17769,6 +17832,8 @@ var splitStore = { if (!fromPane || !toPane) return; const tab = (fromPane.tabs ?? []).find((t) => t.id === tabId); if (!tab) return; + this.onPaneContentEdited?.(spec.id, fromPane.id); + this.onPaneContentEdited?.(spec.id, toPane.id); const fromTabs = (fromPane.tabs ?? []).filter((t) => t.id !== tabId); const toTabs = [...toPane.tabs ?? [], tab]; const setPane = (row, i, pane) => { @@ -19137,9 +19202,8 @@ function TerminalPane() { focusTerm(); el.addEventListener("pointerdown", focusTerm); const scope = splitEnv?.getScope?.(); - const proto = location.protocol === "https:" ? "wss:" : "ws:"; - const url = proto + "//" + location.host + "/api/worktable/term?sessionId=" + encodeURIComponent(scope?.sessionId ?? "") + "&cwd=" + encodeURIComponent(scope?.cwd ?? "") + "&cols=80&rows=24"; try { + const url = hostWebSocketUrl("/api/worktable/term?sessionId=" + encodeURIComponent(scope?.sessionId ?? "") + "&cwd=" + encodeURIComponent(scope?.cwd ?? "") + "&cols=80&rows=24"); ws = new WebSocket(url); } catch { term.dispose(); @@ -19349,7 +19413,7 @@ function SelectPop(props) { ] }); } function CustomPane(props) { - const paneTitle = props.paneTitle ?? ""; + const paneTitle = props.row === void 0 ? props.paneTitle ?? "" : windowLabelOf(props.row, props.index ?? 0).match(/^窗口\d+/)?.[0] ?? ""; try { window.__dshLastCustomPaneTitle = paneTitle; } catch { @@ -19364,6 +19428,7 @@ function CustomPane(props) { const [wsGroups, setWsGroups] = (0, import_react.useState)([]); const [groupMode, setGroupMode] = (0, import_react.useState)("none"); const [groupId, setGroupId] = (0, import_react.useState)(null); + const groupTouchedRef = (0, import_react.useRef)(false); const [newGroupParent, setNewGroupParent] = (0, import_react.useState)(""); const [newGroupName, setNewGroupName] = (0, import_react.useState)(""); const [busy, setBusy] = (0, import_react.useState)(false); @@ -19382,7 +19447,7 @@ function CustomPane(props) { setSessionId(flat.find((s) => s.isCurrent)?.id ?? flat[0]?.id ?? null); const curId = flat.find((s) => s.isCurrent)?.id; const home = curId ? (custom?.getWorkspaces?.() ?? []).find((w) => (w.sessionIds ?? []).includes(curId)) : null; - if (home) { + if (home && !groupTouchedRef.current) { setGroupMode("existing"); setGroupId(home.id); } @@ -19496,6 +19561,7 @@ function CustomPane(props) { }], placeholder: T("custom.group"), onChange: (id) => { + groupTouchedRef.current = true; if (id === "__none") setGroupMode("none"); else if (id === "__new") setGroupMode("new"); else { @@ -19564,7 +19630,7 @@ function PaneTabBody(props) { if (content.type === "scm") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GitPane, {}); if (content.type === "tasks") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(JobsPane, {}); if (content.type === "terminal") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TerminalPane, {}); - if (content.type === "custom") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CustomPane, { paneTitle: props.paneTitle ?? "" }); + if (content.type === "custom") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CustomPane, { paneTitle: props.paneTitle ?? "", row: props.row, index: props.index }); return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-wt_paneWip", children: [ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-wt_paneWipIcon", "aria-hidden": true, children: BUILTIN_ICONS[content.type] }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-wt_paneWipText", children: T("pane.wip") }) @@ -20135,6 +20201,116 @@ function appendHostInput(bridge, text2, doc = document) { } } +// src/client/widgetMount.ts +var WIDGET_BINDINGS_KEY = "dsh.worktable.widgetBindings.v2"; +function newWidgetId(prefix = "binding") { + const id = globalThis.crypto?.randomUUID?.() ?? Date.now().toString(36) + "-" + Math.random().toString(36).slice(2); + return prefix + "-" + id; +} +function widgetTargets(spec) { + if (!spec) return []; + const out = []; + const add = (row, panes) => panes.forEach((pane, index) => { + if (pane && typeof pane.id === "string") out.push({ row, index, pane, window: "\u7A97\u53E3" + (out.length + 1) }); + }); + add("left", spec.left ? [spec.left] : []); + add("top", spec.top ?? []); + add("main", spec.main ?? []); + return out; +} +function widgetManifestPath(binding) { + return joinPath(binding.folder, joinPath(".dsh-worktable", joinPath( + "project-" + encodeURIComponent(binding.projectId), + "pane-" + encodeURIComponent(binding.paneId) + "-" + binding.bindingId + ".json" + ))); +} +var WidgetMountRegistry = class { + constructor(storage, makeId = () => newWidgetId()) { + this.storage = storage; + this.makeId = makeId; + try { + const data = JSON.parse(storage?.getItem(WIDGET_BINDINGS_KEY) ?? "{}"); + for (const [projectId, panes] of Object.entries(data ?? {})) { + if (!panes || typeof panes !== "object" || Array.isArray(panes)) continue; + const valid = /* @__PURE__ */ Object.create(null); + for (const [paneId, b] of Object.entries(panes)) { + const v = b; + if (v?.projectId !== projectId || v.paneId !== paneId || typeof v.folder !== "string" || !v.folder || typeof v.sessionId !== "string" || !v.sessionId || typeof v.window !== "string" || typeof v.bindingId !== "string" || !/^[a-z0-9-]+$/i.test(v.bindingId) || typeof v.enabled !== "boolean") continue; + valid[paneId] = { ...v }; + } + if (Object.keys(valid).length) this.records[projectId] = valid; + } + } catch { + } + } + records = /* @__PURE__ */ Object.create(null); + listeners = /* @__PURE__ */ new Set(); + persist() { + this.storage?.setItem(WIDGET_BINDINGS_KEY, JSON.stringify(this.records)); + for (const fn of this.listeners) fn(); + } + subscribe(fn) { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + } + entries(projectId) { + const projects = projectId ? [this.records[projectId] ?? {}] : Object.values(this.records); + return projects.flatMap((panes) => Object.values(panes)).filter((b) => b.enabled); + } + begin(projectId, spec, folder, sessionId, window2) { + if (!this.storage) throw new Error("project storage unavailable"); + if (!folder || !sessionId || spec.id !== projectId) throw new Error("project folder or session unavailable"); + const targets = widgetTargets(spec); + const selected = targets.find((t) => t.window === window2); + if (!selected || new Set(targets.map((t) => t.pane.id)).size !== targets.length) throw new Error("project window changed"); + const before = this.records[projectId]; + const next = /* @__PURE__ */ Object.create(null); + for (const t of targets) { + const old = before?.[t.pane.id]; + next[t.pane.id] = t === selected || !old || old.folder !== folder || old.enabled && old.window !== t.window ? { projectId, paneId: t.pane.id, window: t.window, folder, sessionId, bindingId: this.makeId(), enabled: true } : old; + } + this.records[projectId] = next; + try { + this.persist(); + } catch (error2) { + if (before) this.records[projectId] = before; + else delete this.records[projectId]; + throw error2; + } + return this.entries(projectId).filter((b) => b.sessionId === sessionId); + } + revokePane(projectId, paneId) { + const b = this.records[projectId]?.[paneId]; + if (!b || !b.enabled) return; + this.records[projectId] = { ...this.records[projectId], [paneId]: { ...b, enabled: false } }; + try { + this.persist(); + } catch { + } + } + revokeProject(projectId) { + if (!this.records[projectId]) return; + for (const b of this.entries(projectId)) this.revokePane(projectId, b.paneId); + } + isCurrent(binding, spec, folder) { + const current = this.records[binding.projectId]?.[binding.paneId]; + return !!current?.enabled && current.bindingId === binding.bindingId && current.sessionId === binding.sessionId && current.folder === folder && binding.folder === folder && spec?.id === binding.projectId && widgetTargets(spec).filter((t) => t.pane.id === binding.paneId).length === 1; + } + resolve(binding, raw, spec, folder) { + if (!this.isCurrent(binding, spec, folder)) return null; + try { + const d = JSON.parse(raw); + if (!d || Array.isArray(d) || d.version !== 2 || d.projectId !== binding.projectId || d.paneId !== binding.paneId || d.bindingId !== binding.bindingId || d.window !== binding.window || !["html", "url", "file"].includes(d.kind) || typeof d.path !== "string" || !d.path.trim()) return null; + const target = widgetTargets(spec).find((t) => t.pane.id === binding.paneId); + return { target, item: { kind: d.kind, path: d.path.trim() } }; + } catch { + return null; + } + } +}; + // 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(); @@ -20186,7 +20362,14 @@ function modelApiOf(ctx, sessions, legacy) { selectModel: async ({ sessionId, ...selection }) => withDirectory(sessionId, async (directory) => ({ result: await directory.select(selection) })) }; } -async function createHostSession(sessions, workspaces, options = {}) { +function blankSessionNeedsWorkspace(sessions, grouping) { + return grouping === "none" && typeof sessions?.using === "function"; +} +async function createHostSession(sessions, workspaces, options = {}, grouping = "auto") { + if (grouping === "none") { + if (options.workspaceId) throw new Error("ungrouped session cannot specify a workspace"); + return sessions.create(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"); @@ -20358,10 +20541,9 @@ 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.4"; +var LOCAL_VERSION2 = false ? "dev" : "0.4.0"; 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"; +var { command: UPGRADE_CMD, prompt: UPGRADE_AI2, desktop: DESKTOP_HOST } = worktableUpgrade(); var ICON_SYNC = /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("svg", { viewBox: "0 0 16 16", "aria-hidden": true, children: [ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", d: "M1.5 8a6.5 6.5 0 0 1 11.1-4.6L14.5 5" }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", d: "M14.5 1.5V5h-3.5" }), @@ -20432,7 +20614,7 @@ function buildLayout(presetId, name) { const top = Array.from({ length: def.topCount }, (_, i) => mk(def.leftCount + i)); const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.leftCount + def.topCount + i)); return { - id: "layout-" + Date.now().toString(36), + id: newWidgetId("layout"), title: name, left: left ?? null, top: top.length > 0 ? top : null, @@ -20949,12 +21131,12 @@ var KNOWLEDGE_PACK = [ " \u754C\u9762\u5373\u53EF\u4E0E DSH \u539F\u751F\u98CE\u683C\u4E00\u81F4\uFF1B\u81EA\u9020\u6837\u5F0F\u63A7\u5236\u5728\u6700\u5C0F\u8303\u56F4\u3002", "- \u670D\u52A1\u7AEF\u80FD\u529B\uFF1A/api/worktable/fs\uFF08\u5217\u76EE\u5F55\uFF09\u3001/api/worktable/write\uFF08\u5199\u6587\u4EF6\uFF09\u3001/api/worktable/mkdir\uFF08\u5EFA\u76EE\u5F55\uFF09\u3001", " /api/worktable/git\uFF08git \u72B6\u6001\uFF09\u3001/api/worktable/site\uFF08\u9759\u6001\u6258\u7BA1\uFF09\u3002", - "- \u6539\u5B8C\u63D2\u4EF6\u5728 01_content \u76EE\u5F55\u6267\u884C npm run build\uFF1B\u91CD\u542F dsh web \u6216\u6D4F\u89C8\u5668 F5 \u751F\u6548\u3002", + "- \u6539\u5B8C\u63D2\u4EF6\u5728 01_content \u76EE\u5F55\u6267\u884C npm run build\uFF1B\u7F51\u9875\u7AEF\u91CD\u542F dsh web \u5E76\u5237\u65B0\uFF0C\u684C\u9762\u7AEF\u5B8C\u6574\u9000\u51FA\u540E\u624B\u52A8\u91CD\u65B0\u6253\u5F00\u3002", "- \u6240\u6709\u4EA7\u51FA\u6587\u4EF6\u4E00\u5F8B\u653E\u8FDB\u672C\u4EFB\u52A1\u6807\u6CE8\u7684\u9879\u76EE\u6587\u4EF6\u5939\uFF0C\u4FDD\u6301\u7528\u6237\u76EE\u5F55\u5E72\u51C0\u3002", "- \u6807\u6CE8\u534F\u8BAE\uFF1A\u6536\u5230\u300C\u{1F4CC} \u6807\u6CE8-\u7A97\u53E3N \u2026\uFF08\u5750\u6807/\u5143\u7D20/\u6846\u5185\u6587\u5B57\u2026\uFF09\u2026\u300D= \u5DE5\u4F5C\u53F0\u6807\u6CE8\uFF08\u7A97\u53E3\u7F16\u53F7\u7EA6\u5B9A\uFF1A\u5DE6\u680F\u2192\u9876\u884C\u2192\u4E3B\u884C\uFF0C\u4ECE 1 \u8D77\uFF09\u3002", " \u5904\u7406\uFF1A\u6807\u6CE8\u5DF2\u6807\u660E\u6846\u9009\u533A\u57DF\u4E0E\u300C\u6700\u53EF\u80FD\u76EE\u6807\u300D\u65F6\u76F4\u63A5\u4F5C\u7B54\u3001\u4E0D\u8981\u4E8C\u6B21\u8BE2\u95EE\uFF08\u4EC5\u5F53\u786E\u5B9E\u65E0\u6CD5\u4ECE\u6807\u6CE8\u5B9A\u4F4D\u65F6\u624D\u95EE\u4E00\u6B21\uFF09\uFF1B\u80FD\u67E5\u770B\u622A\u56FE\u6216\u6253\u5F00\u7A97\u53E3\u5219\u5148\u6838\u5B9E\u518D\u56DE\u7B54\u3002" ].join("\n"); -function buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, mode) { +function buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, mode, widgetBindings = []) { const win = windowLabel || "\u4E00\u4E2A\u5185\u5BB9\u7A97"; const folderLine = folder ? "\u9879\u76EE\u6587\u4EF6\u5939\uFF1A" + folder + "\uFF08\u672C\u9879\u76EE\u6240\u6709\u4EA7\u51FA\u6587\u4EF6\u4E00\u5F8B\u653E\u8FDB\u8FD9\u4E2A\u6587\u4EF6\u5939\uFF1B\u4E0D\u8981\u5199\u5230\u522B\u7684\u9ED8\u8BA4\u4F4D\u7F6E\uFF09\u3002" : "\u672C\u9879\u76EE\u6682\u672A\u8BBE\u7F6E\u4E13\u5C5E\u6587\u4EF6\u5939\uFF1A\u4EA7\u51FA\u6587\u4EF6\u5148\u5411\u7528\u6237\u786E\u8BA4\u5B58\u653E\u4F4D\u7F6E\uFF0C\u4E0D\u8981\u968F\u4FBF\u5199\u3002"; const lines = [ @@ -20968,8 +21150,9 @@ function buildWindowTaskText(projectId, projectName, windowLabel, requirement, f " - \u6587\u6863 / \u6F14\u793A\uFF08PPT\u3001\u62A5\u544A\u3001\u8868\u683C\uFF09\u2192 \u751F\u6210\u771F\u5B9E\u6587\u4EF6\uFF08.pptx / .md / .xlsx\uFF09\u653E\u8FDB\u9879\u76EE\u6587\u4EF6\u5939\uFF0C\u7528\u6237\u7528\u672C\u673A\u8F6F\u4EF6\u6253\u5F00\u7F16\u8F91\uFF1B", " - \u89C6\u9891 / \u52A8\u753B \u2192 \u751F\u6210 .mp4 / .gif\uFF08\u6216 Lottie JSON\uFF09\u6587\u4EF6\u653E\u8FDB\u9879\u76EE\u6587\u4EF6\u5939\uFF1B", " - \u5DE5\u4F5C\u53F0\u5DF2\u6709\u5185\u7F6E\u7A97\u80FD\u529B\uFF08\u8D44\u6E90\u7BA1\u7406\u5668 / \u7EC8\u7AEF / \u6D4F\u89C8\u5668 / \u52A8\u753B\u7AD9\uFF09\u2192 \u4E0D\u8981\u91CD\u590D\u9020\u8F6E\u5B50\uFF0C\u76F4\u63A5\u5EFA\u8BAE\u7528\u6237\u6539\u7528\u5185\u7F6E\u7A97\u3002", - "5. \u5B8C\u6210\u5185\u5BB9\u540E\uFF0C\u7528\u4E00\u4E24\u53E5\u8BDD\u544A\u77E5\u7528\u6237\u6302\u8F7D\u7ED3\u679C\uFF08\u4E0D\u8981\u63D0\u95EE\u3001\u4E0D\u8981\u7B49\u5F85\u7528\u6237\u786E\u8BA4\uFF09\uFF1A\u4F8B\u5982\u300C\u5DF2\u5B8C\u6210\u5E76\u81EA\u52A8\u6302\u5230\u300C" + win + "\u300D\u7A97\u53E3\uFF0C\u60F3\u8C03\u6574\u76F4\u63A5\u8BF4\uFF0C\u6211\u6539\u5B8C\u4F1A\u81EA\u52A8\u66F4\u65B0\u300D\u3002\u8BE5\u7A97\u53E3\u4F4D\u4E8E\u300C" + projectName + "\u300D\u9879\u76EE\u5185\uFF0C\u5982\u9700\u8981\u4E5F\u53EF\u534F\u52A9\u8BE5\u9879\u76EE\u540E\u7EED\u7684\u5176\u4ED6\u81EA\u5B9A\u4E49\u5DE5\u4F5C\u3002", - '6. \u5B8C\u6210\u540E\u5199\u5165\u300C\u4EA7\u7269\u6E05\u5355\u300D\u6587\u4EF6\uFF1A\u5728\u9879\u76EE\u6587\u4EF6\u5939\u91CC\u521B\u5EFA widget-result.json\u3002\u6302\u8F7D\u5355\u4E2A\u7A97\u53E3\u65F6\u5199\u5355\u5BF9\u8C61 {"window":"' + win + '","path":"\u4EA7\u7269\u76F8\u5BF9\u8DEF\u5F84","kind":"html"}\uFF1B\u4E00\u6B21\u6302\u8F7D\u591A\u4E2A\u7A97\u53E3\uFF08\u5982\u7A97\u53E31 + \u7A97\u53E32\uFF09\u65F6\u5199 JSON \u6570\u7EC4\uFF0C\u6BCF\u4E2A\u5143\u7D20\u4E3A\u4E0A\u8FF0\u5355\u5BF9\u8C61\uFF08\u53EF\u5F15\u7528\u591A\u4E2A\u4EA7\u7269\u6587\u4EF6\uFF09\u3002kind \u53EF\u9009 html\uFF08\u672C\u5730\u9875\u9762\uFF0Cpath \u76F8\u5BF9\u9879\u76EE\u6587\u4EF6\u5939\uFF09/ url\uFF08\u5916\u90E8\u94FE\u63A5\uFF0Cpath \u4E3A\u5B8C\u6574 URL\uFF09/ file\uFF08\u5176\u4ED6\u6587\u4EF6\uFF0Cpath \u76F8\u5BF9\u9879\u76EE\u6587\u4EF6\u5939\uFF09\u3002\u5199\u5B8C\u8FD9\u4E2A\u6587\u4EF6\uFF0C\u5DE5\u4F5C\u53F0\u4F1A\u81EA\u52A8\u628A\u4EA7\u7269\u6302\u8F7D\u8FDB\u6E05\u5355\u5BF9\u5E94\u7684\u5404\u4E2A\u7A97\u53E3\u5E76\u9501\u5B9A\u4FDD\u5B58\uFF0C\u7528\u6237\u65E0\u9700\u624B\u52A8\u64CD\u4F5C\uFF1B\u7528\u6237\u4E0B\u6B21\u6253\u5F00\u5DE5\u4F5C\u53F0\u65F6\u5404\u7A97\u53E3\u76F4\u63A5\u663E\u793A\u4EA7\u7269\uFF0C\u4E0D\u4F1A\u4E22\u5931\u6216\u91CD\u7F6E\u3002', + "5. \u5B8C\u6210\u540E\u7528\u4E00\u4E24\u53E5\u8BDD\u8BF4\u660E\u4F5C\u54C1\u4E0E\u76EE\u6807\u7A97\u53E3\u3002\u5199\u6E05\u5355\u4E0D\u4EE3\u8868\u754C\u9762\u5DF2\u786E\u8BA4\u6302\u8F7D\u6210\u529F\uFF1B\u53EA\u6709\u5B9E\u9645\u770B\u5230\u6302\u8F7D\u7ED3\u679C\u65F6\uFF0C\u624D\u8BF4\u300C\u5DF2\u6302\u8F7D\u300D\u3002\u8BE5\u7A97\u53E3\u5C5E\u4E8E\u300C" + projectName + "\u300D\u9879\u76EE\u3002", + "6. \u4EA7\u7269\u63E1\u624B v2\uFF1A\u53EA\u4F7F\u7528\u4E0B\u5217\u672C\u9879\u76EE\u4E13\u5C5E\u6E05\u5355\u8DEF\u5F84\uFF0C\u4E0D\u8BFB\u5199\u9879\u76EE\u6839\u76EE\u5F55\u65E7\u7684 widget-result.json\uFF0C\u4E5F\u4E0D\u8981\u590D\u7528\u5176\u4ED6\u9879\u76EE\u7684\u6E05\u5355\u3002\u4F5C\u54C1\u4ECD\u653E\u5728\u9879\u76EE\u6587\u4EF6\u5939\uFF0Cpath \u6309\u8BE5\u6587\u4EF6\u5939\u89E3\u6790\u3002\u5148\u5B8C\u6210\u4F5C\u54C1\uFF0C\u518D\u521B\u5EFA\u6E05\u5355\u7236\u76EE\u5F55\u5E76\u5199\u5165\u5BF9\u5E94 JSON\uFF1Bkind \u4E3A html / url / file\uFF0C\u66FF\u6362\u793A\u4F8B\u4E2D\u7684 path \u4E0E kind\uFF0C\u5176\u4ED6\u8EAB\u4EFD\u5B57\u6BB5\u4FDD\u6301\u539F\u6837\u3002\u4E00\u6B21\u751F\u6210\u591A\u4E2A\u7A97\u53E3\u65F6\u5206\u522B\u5199\u5404\u7A97\u53E3\u7684\u6E05\u5355\u3002\u4EE5\u540E\u5728\u672C\u4F1A\u8BDD\u91CC\u7EE7\u7EED\u4FEE\u6539\u540C\u540D\u4F5C\u54C1\uFF0C\u4ECD\u66F4\u65B0\u8FD9\u4E2A\u6E05\u5355\uFF1B\u4E0D\u5F97\u6839\u636E\u6E05\u5355\u6587\u4EF6\u540D\u53D8\u5316\u4E0E\u5426\u5224\u65AD\u662F\u5426\u5B8C\u6210\u3002", + widgetBindings.length ? widgetBindings.map((b) => b.window + "\uFF1A" + widgetManifestPath(b) + "\n" + JSON.stringify({ version: 2, projectId: b.projectId, paneId: b.paneId, bindingId: b.bindingId, window: b.window, path: "\u4EA7\u7269\u76F8\u5BF9\u8DEF\u5F84", kind: "html" })).join("\n") : "\u5F53\u524D\u6CA1\u6709\u6709\u6548\u7684\u81EA\u52A8\u6302\u8F7D\u7ED1\u5B9A\uFF0C\u8BF7\u7528\u6237\u4ECE\u76EE\u6807\u7A97\u53E3\u91CD\u65B0\u53D1\u9001\u81EA\u5B9A\u4E49\u4EFB\u52A1\uFF1B\u4E0D\u8981\u5199\u65E7\u6E05\u5355\u731C\u6D4B\u5F52\u5C5E\u3002", KNOWLEDGE_PACK ]; return lines.join("\n"); @@ -20977,7 +21160,6 @@ function buildWindowTaskText(projectId, projectName, windowLabel, requirement, f async function createCustomSession(projectId, projectName, requirement, group, windowLabel = "", folder = null) { const b = sessionBridge; if (!b || typeof b.sessions?.create !== "function") throw new Error("sessions unavailable"); - const text2 = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, "new"); let workspaceId = null; const g = group ?? { kind: "none" }; if (g.kind === "existing") { @@ -21006,7 +21188,7 @@ async function createCustomSession(projectId, projectName, requirement, group, w let createOpts = {}; if (workspaceId) createOpts = { workspaceId }; else if (folder) createOpts = { cwd: folder }; - const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts); + const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts, g.kind === "none" ? "none" : "auto"); await ensureSessionPreset(sessionId); await ensureSessionModel(sessionId); markPluginSessionOpen(sessionId); @@ -21014,13 +21196,16 @@ async function createCustomSession(projectId, projectName, requirement, group, w await openHostSession(clientCtx, b.sessions, sessionId); } catch { } + const widgetBindings = prepareWidgetTask(projectId, folder, sessionId, windowLabel); + const text2 = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, "new", widgetBindings); await promptIntoSession(sessionId, text2); return sessionId; } async function sendCustomToSession(sessionId, projectId, projectName, requirement, windowLabel = "", folder = null) { const b = sessionBridge; if (!b) throw new Error("bridge unavailable"); - const text2 = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, "send"); + const widgetBindings = prepareWidgetTask(projectId, folder, sessionId, windowLabel); + const text2 = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, "send", widgetBindings); markPluginSessionOpen(sessionId); try { await openHostSession(clientCtx, b.sessions, sessionId); @@ -21060,50 +21245,55 @@ function saveNotifyAck(sid, state) { } } var mountConsumedRef = { current: /* @__PURE__ */ new Set() }; +var widgetRegistry = new WidgetMountRegistry((() => { + try { + return localStorage; + } catch { + return null; + } +})()); +var widgetProjectOf = () => ({}); +function prepareWidgetTask(projectId, folder, sessionId, windowLabel) { + if (!folder) return []; + const current = widgetProjectOf(projectId); + if (!folder || current.folder !== folder || !current.spec) throw new Error("project folder or window changed"); + return widgetRegistry.begin(projectId, current.spec, folder, sessionId, windowLabel); +} var pendingMountRef = { current: (() => { try { - return JSON.parse(localStorage.getItem("dsh.worktable.pendingMount.v1") ?? "{}") ?? {}; + return JSON.parse(localStorage.getItem("dsh.worktable.pendingMount.v2") ?? "{}") ?? {}; } catch { return {}; } })() }; var mountedWidgetRef = { current: (() => { try { - return JSON.parse(localStorage.getItem("dsh.worktable.mountedWidget.v1") ?? "{}") ?? {}; + return JSON.parse(localStorage.getItem("dsh.worktable.mountedWidget.v2") ?? "{}") ?? {}; } catch { return {}; } })() }; -function recordMountedWidget(projectId, raw) { - if (!projectId || !raw) return; - mountedWidgetRef.current[projectId] = raw; +var widgetReadSeqRef = { current: {} }; +function recordMountedWidget(bindingId, raw) { + if (!bindingId || !raw) return; + mountedWidgetRef.current[bindingId] = raw; try { - localStorage.setItem("dsh.worktable.mountedWidget.v1", JSON.stringify(mountedWidgetRef.current)); + localStorage.setItem("dsh.worktable.mountedWidget.v2", JSON.stringify(mountedWidgetRef.current)); } catch { } } -function windowLabelToPane(spec, label) { - const m = /^窗口(\d+)$/.exec(String(label ?? "").trim()); - if (!m) return null; - const n = parseInt(m[1], 10); - if (!Number.isFinite(n) || n < 1) return null; - let idx = n - 1; - if (spec?.left) { - if (idx === 0) return { row: "left", index: 0 }; - idx -= 1; +function persistPendingWidgets() { + try { + localStorage.setItem("dsh.worktable.pendingMount.v2", JSON.stringify(pendingMountRef.current)); + } catch { } - const top = spec?.top ?? []; - if (idx < top.length) return { row: "top", index: idx }; - idx -= top.length; - const main = spec?.main ?? []; - if (idx < main.length) return { row: "main", index: idx }; - return null; } -function paneExists(spec, row, index) { - if (!spec) return false; - if (row === "left") return !!spec.left && index === 0; - if (row === "top") return !!spec.top && !!spec.top[index]; - return !!spec.main && !!spec.main[index]; +function invalidateWidgetProject(projectId) { + widgetRegistry.revokeProject(projectId); + for (const [id, pending] of Object.entries(pendingMountRef.current)) { + if (pending?.binding?.projectId === projectId) delete pendingMountRef.current[id]; + } + persistPendingWidgets(); } function buildMountContent(folder, d) { const p = String(d?.path ?? "").trim(); @@ -21348,6 +21538,7 @@ function WorktableSection(props) { const [consoleName, setConsoleName] = (0, import_react2.useState)(""); const [consoleErr, setConsoleErr] = (0, import_react2.useState)(false); const [consoleBusy, setConsoleBusy] = (0, import_react2.useState)(false); + const consoleNeedsWorkspace = blankSessionNeedsWorkspace(sessionBridge?.sessions, consoleMode === "none" ? "none" : "auto"); const [customOpen, setCustomOpen] = (0, import_react2.useState)(false); const [customLayoutText, setCustomLayoutText] = (0, import_react2.useState)(""); const [copiedToast, setCopiedToast] = (0, import_react2.useState)(null); @@ -21678,8 +21869,16 @@ function WorktableSection(props) { return { ...prev, views: { ...prev.views, [spec.id]: spec } }; }); }; + splitStore.onPaneContentEdited = (projectId, paneId) => { + widgetRegistry.revokePane(projectId, paneId); + for (const [id, p] of Object.entries(pendingMountRef.current)) { + if (p?.binding?.projectId === projectId && p.binding.paneId === paneId) delete pendingMountRef.current[id]; + } + persistPendingWidgets(); + }; return () => { splitStore.onSpecMutated = null; + splitStore.onPaneContentEdited = null; }; }, []); (0, import_react2.useEffect)(() => { @@ -21831,23 +22030,9 @@ function WorktableSection(props) { } } ackProjectNotify(spec.id); - const pending = pendingMountRef.current[spec.id]; - if (pending) { - try { - const entries = Array.isArray(pending.entries) ? pending.entries : pending.content ? [{ content: pending.content, row: pending.row, index: pending.index ?? 0 }] : []; - let allOk = entries.length > 0; - for (const e of entries) { - splitStore.lockPane(e.row, e.index ?? 0, e.content); - if (!paneExists(splitStore.spec, e.row, e.index ?? 0)) allOk = false; - } - if (allOk && pending.fingerprint) recordMountedWidget(spec.id, pending.fingerprint); - } catch { - } - delete pendingMountRef.current[spec.id]; - try { - localStorage.setItem("dsh.worktable.pendingMount.v1", JSON.stringify(pendingMountRef.current)); - } catch { - } + for (const pending of Object.values(pendingMountRef.current)) { + if (pending?.binding?.projectId !== spec.id) continue; + void applyWidgetManifest(pending.binding, null, true); } } }, [projects.views]); @@ -21908,6 +22093,11 @@ function WorktableSection(props) { setConsoleErr(true); return; } + if (blankSessionNeedsWorkspace(b.sessions, consoleMode === "none" ? "none" : "auto")) return; + if (consoleMode === "existing" && !consoleWsId) { + setConsoleErr(true); + return; + } if (consoleMode === "new" && (!consoleParent.trim() || !consoleName.trim())) { setConsoleErr(true); return; @@ -21940,7 +22130,7 @@ function WorktableSection(props) { let createOpts = {}; if (workspaceId) createOpts = { workspaceId }; else if (folder) createOpts = { cwd: folder }; - const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts); + const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts, consoleMode === "none" ? "none" : "auto"); await ensureSessionPreset(sessionId); await ensureSessionModel(sessionId); markPluginSessionOpen(sessionId); @@ -22043,6 +22233,7 @@ function WorktableSection(props) { setWsFolderParent(p); setWsFolderError(false); } else if (bindPick) { + if (projectsRef.current.projects.folders[bindPick.id] !== p) invalidateWidgetProject(bindPick.id); persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } })); } setManualPathFor(null); @@ -22052,10 +22243,12 @@ function WorktableSection(props) { const changeBindFolder = () => { if (!bindPick || pickBusyRef.current) return; pickFolder("bind", (p) => { + if (projectsRef.current.projects.folders[bindPick.id] !== p) invalidateWidgetProject(bindPick.id); persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } })); }); }; const setProjectBinding = (id, sessionId) => { + if ((projectsRef.current.projects.bindings[id] ?? null) !== sessionId) invalidateWidgetProject(id); persistProjects((prev) => { const next = { ...prev.bindings }; if (sessionId) next[id] = sessionId; @@ -22113,100 +22306,79 @@ function WorktableSection(props) { (0, import_react2.useEffect)(() => { notifyConsole(); }, [projects]); - const applyWidgetManifest = (0, import_react2.useCallback)(async (projectId, rawManifest) => { - const folder = projectsRef.current.projects.folders[projectId]; - if (!folder) return; + const [widgetTick, setWidgetTick] = (0, import_react2.useState)(0); + (0, import_react2.useEffect)(() => widgetRegistry.subscribe(() => setWidgetTick((n) => n + 1)), []); + const applyWidgetManifest = (0, import_react2.useCallback)(async (binding, rawManifest, force = false) => { + let current = widgetProjectOf(binding.projectId); + if (!mountedRef.current || !widgetRegistry.isCurrent(binding, current.spec, current.folder)) return; + const readSeq = (widgetReadSeqRef.current[binding.bindingId] ?? 0) + 1; + widgetReadSeqRef.current[binding.bindingId] = readSeq; try { let raw = rawManifest; if (raw == null) { - const r = await fetch("/api/worktable/file?path=" + encodeURIComponent(joinPath(folder, "widget-result.json")), { cache: "no-store" }); + const r = await fetch("/api/worktable/file?path=" + encodeURIComponent(widgetManifestPath(binding)), { cache: "no-store" }); if (!r.ok) return; raw = (await r.text()).trim(); } - if (!raw) return; - let d = null; - try { - d = JSON.parse(raw); - } catch { - } - if (!d) return; - const items = Array.isArray(d) ? d : [d]; - const open = splitStore.active && splitStore.spec?.id === projectId; - const saved = open ? splitStore.spec : projectsRef.current.projects.views[projectId] ?? projectsRef.current.projects.layouts.find((l) => l.id === projectId); - const targets = []; - for (const it of items) { - if (!it) continue; - const content = buildMountContent(folder, it); - if (!content) continue; - const pane = windowLabelToPane(saved, it.window); - const row = pane?.row ?? "main"; - const index = pane?.index ?? 0; - if (targets.some((t2) => t2.row === row && t2.index === index)) continue; - targets.push({ row, index, content }); - } - if (!targets.length) return; + current = widgetProjectOf(binding.projectId); + if (!mountedRef.current || !raw || widgetReadSeqRef.current[binding.bindingId] !== readSeq) return; + const resolved = widgetRegistry.resolve(binding, raw, current.spec, current.folder); + if (!resolved || !force && mountedWidgetRef.current[binding.bindingId] === raw) return; + const content = buildMountContent(binding.folder, resolved.item); + if (!content) return; + const open = splitStore.active && splitStore.spec?.id === binding.projectId; if (open) { - let allOk = true; - for (const t2 of targets) { - splitStore.lockPane(t2.row, t2.index, t2.content); - if (!paneExists(splitStore.spec, t2.row, t2.index)) allOk = false; - } - if (allOk) recordMountedWidget(projectId, raw); + splitStore.lockPane(resolved.target.row, resolved.target.index, content); + recordMountedWidget(binding.bindingId, raw); + delete pendingMountRef.current[binding.bindingId]; + persistPendingWidgets(); } else { - pendingMountRef.current[projectId] = { entries: targets, fingerprint: raw }; - try { - localStorage.setItem("dsh.worktable.pendingMount.v1", JSON.stringify(pendingMountRef.current)); - } catch { - } + pendingMountRef.current[binding.bindingId] = { binding }; + persistPendingWidgets(); } } catch { } }, []); - const tryAutoMount = (0, import_react2.useCallback)(async (projectId, sid) => { - void sid; - await applyWidgetManifest(projectId, null); - }, [applyWidgetManifest]); (0, import_react2.useEffect)(() => { const byId = sessionsSnapshotStore.snapshot?.byId ?? {}; + const scopePairs = new Map(widgetRegistry.entries().map((b) => [JSON.stringify([b.projectId, b.sessionId]), b])); + for (const key of mountConsumedRef.current) if (!scopePairs.has(key)) mountConsumedRef.current.delete(key); for (const [pid, sid] of Object.entries(projects.bindings)) { const e = byId[sid]; if (!e) continue; if (e.completed === true) { if (doneSeenRef.current[sid] !== true) clearNotifyAck(sid); doneSeenRef.current[sid] = true; - if (!mountConsumedRef.current.has(sid)) { - mountConsumedRef.current.add(sid); - tryAutoMount(pid, sid); - } } else { doneSeenRef.current[sid] = false; - mountConsumedRef.current.delete(sid); } } - }, [notifyTick, projects.bindings, tryAutoMount]); + for (const [key, binding] of scopePairs) { + const e = byId[binding.sessionId]; + if (!e) continue; + if (e.completed !== true) { + mountConsumedRef.current.delete(key); + continue; + } + if (mountConsumedRef.current.has(key)) continue; + mountConsumedRef.current.add(key); + for (const b of widgetRegistry.entries(binding.projectId)) { + if (b.sessionId === binding.sessionId) void applyWidgetManifest(b, null, true); + } + } + }, [notifyTick, projects.bindings, widgetTick, applyWidgetManifest]); (0, import_react2.useEffect)(() => { let cancelled = false; - const folders = projects.folders ?? {}; (async () => { - for (const pid of Object.keys(folders)) { + for (const binding of widgetRegistry.entries()) { if (cancelled) return; - const folder = folders[pid]; - if (!folder) continue; - try { - const r = await fetch("/api/worktable/file?path=" + encodeURIComponent(joinPath(folder, "widget-result.json")), { cache: "no-store" }); - if (!r.ok) continue; - const raw = (await r.text()).trim(); - if (!raw) continue; - if (mountedWidgetRef.current[pid] === raw) continue; - await applyWidgetManifest(pid, raw); - } catch { - } + await applyWidgetManifest(binding, null); } })(); return () => { cancelled = true; }; - }, [projects.folders, applyWidgetManifest]); + }, [projects.folders, widgetTick, applyWidgetManifest]); const collectKids = (0, import_react2.useCallback)((sid) => { return childSessionIdsOf(sessionsSnapshotStore.snapshot, sid); }, []); @@ -22267,6 +22439,10 @@ function WorktableSection(props) { [registeredIds, projects.removed] ); projectsRef.current = { projects, metas, aliveRegisteredIds }; + widgetProjectOf = (id) => ({ + folder: projectsRef.current.projects.folders[id], + spec: (splitStore.active && splitStore.spec?.id === id ? splitStore.spec : void 0) ?? projectsRef.current.projects.views[id] ?? projectsRef.current.projects.layouts.find((l) => l.id === id) + }); projectBindingsRef.current = projects.bindings; const allIds = (0, import_react2.useMemo)(() => [...aliveRegisteredIds, ...layoutIds], [aliveRegisteredIds, layoutIds]); const effectiveOrder = (0, import_react2.useMemo)(() => { @@ -22413,9 +22589,11 @@ function WorktableSection(props) { reportUsed(layout.id); }; const removeLayout = (id) => { + invalidateWidgetProject(id); persistProjects((prev) => ({ ...prev, layouts: prev.layouts.filter((l) => l.id !== id) })); }; const applyLayoutChange = (id, presetId) => { + invalidateWidgetProject(id); const layout = projects.layouts.find((l) => l.id === id); const meta = metas[id]; const current = layout ?? projects.views[id]; @@ -22459,6 +22637,7 @@ function WorktableSection(props) { }; const removeProject = (id) => { if (id === CONSOLE_ID) return; + invalidateWidgetProject(id); persistProjects((prev) => { const next = { ...prev, @@ -23010,7 +23189,7 @@ function WorktableSection(props) { updateInfo.latest ] }), updateInfo.notes && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_updateNotes", children: updateInfo.notes }), - /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_updateCmd", children: UPGRADE_CMD2 }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_updateCmd", children: UPGRADE_CMD }), /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_updateBtns", children: [ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_updateBtn dsh-wt_updateBtnCopy", onClick: () => void copyUpgradeAi(), children: updateCopied ? "\u2713 " + t("update.copied") : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [ ICON_SPARK, @@ -23019,7 +23198,7 @@ function WorktableSection(props) { ] }) }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_updateBtn", onClick: skipUpdate, children: t("update.skip") }) ] }), - /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_updateHint", children: t("update.upgradeHint") }) + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_updateHint", children: t(DESKTOP_HOST ? "update.upgradeHintDesktop" : "update.upgradeHint") }) ] }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_manageHead", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-wt_manageTitle", children: t("sort.label") }) }), /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_sortRow", children: [ @@ -23343,13 +23522,17 @@ function WorktableSection(props) { /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: "existing", children: t("console.groupExisting") }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: "new", children: t("console.groupNew") }) ] }), - consoleMode === "existing" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("select", { className: "dsh-wt_consoleSelect", value: consoleWsId, onChange: (e) => setConsoleWsId(e.target.value), children: listWorkspaces().map((w) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: w.id, children: w.title }, w.id)) }), + consoleMode === "existing" && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("select", { className: "dsh-wt_consoleSelect", value: consoleWsId, onChange: (e) => setConsoleWsId(e.target.value), children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: "", children: t("console.chooseGroup") }), + listWorkspaces().map((w) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: w.id, children: w.title }, w.id)) + ] }), consoleMode === "new" && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { className: "dsh-wt_consoleInput", placeholder: t("console.newParentPh"), value: consoleParent, onChange: (e) => setConsoleParent(e.target.value) }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { className: "dsh-wt_consoleInput", placeholder: t("console.newNamePh"), value: consoleName, onChange: (e) => setConsoleName(e.target.value) }) ] }), - consoleErr && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_consoleErr", children: t("console.bindFail") }), - /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_consoleCreateBtn", disabled: consoleBusy, onClick: bindConsoleNew, children: consoleBusy ? "\u2026" : t("console.createBind") }) + consoleNeedsWorkspace && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_consoleHint", children: t("console.blankNeedsGroup") }), + consoleErr && !consoleNeedsWorkspace && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_consoleErr", children: t("console.bindFail") }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_consoleCreateBtn", disabled: consoleBusy || consoleNeedsWorkspace || consoleMode === "existing" && !consoleWsId, onClick: bindConsoleNew, children: consoleBusy ? "\u2026" : t("console.createBind") }) ] }) ] }) }), customOpen && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_popBackdrop", style: { zIndex: 83 }, onClick: () => setCustomOpen(false) }), diff --git a/01_content/lib/index.js b/01_content/lib/index.js index 2f5e77f..6d89de7 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.4"; +var PLUGIN_VERSION = false ? "dev" : "0.4.0"; var name = "dsh-worktable"; var inject = ["webServer", "sessions"]; var HEALTH_PATH = "/api/worktable/health"; diff --git a/01_content/package-lock.json b/01_content/package-lock.json index ef0e096..cdef5e2 100644 --- a/01_content/package-lock.json +++ b/01_content/package-lock.json @@ -1,13 +1,16 @@ { "name": "dsh-worktable", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-worktable", - "version": "0.2.0", + "version": "0.4.0", "license": "MIT", + "engines": { + "node": ">=22" + }, "devDependencies": { "esbuild": "^0.25.0", "highlight.js": "^11.12.0", diff --git a/01_content/package.json b/01_content/package.json index b575612..8ca5f95 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.4", + "version": "0.4.0", "repository": { "type": "git", "url": "https://github.com/Aisland-SJL/dsh-worktable.git", @@ -55,6 +55,8 @@ "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", + "test:transport": "node ../04_test/host-transport.test.mjs", + "test:widget": "node ../04_test/widget-mount.test.mjs", "verify:remote": "node verify-remote.mjs" }, "peerDependencies": { diff --git a/01_content/release-prep.mjs b/01_content/release-prep.mjs index 63aebf2..c234845 100644 --- a/01_content/release-prep.mjs +++ b/01_content/release-prep.mjs @@ -83,7 +83,7 @@ if (!patchOk) process.exit(1) console.log('[release-prep] identity check passed (' + IDENTITY + ', cordis.patch.yml strict)') // ---------- 2. 会话/输入兼容回归 + 构建 + 语法检查 ---------- -const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs'] +const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs', 'host-transport.test.mjs', 'widget-mount.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) } diff --git a/01_content/src/client/changelog.ts b/01_content/src/client/changelog.ts index 66b8310..818f5a0 100644 --- a/01_content/src/client/changelog.ts +++ b/01_content/src/client/changelog.ts @@ -1,6 +1,34 @@ -/** 更新公告正文(v0.3.4:DSH 0.2 网页端适配;呈现时按纯文本保留换行,不做 md 渲染) */ -export const CHANGELOG_VERSION = 'v0.3.4' -export const CHANGELOG_V030 = `更新公告 · v0.3.4 +/** 更新公告正文(v0.4.0:双端适配、分组与项目内容隔离;纯文本呈现,发布状态以 GitHub 为准) */ +export const CHANGELOG_VERSION = 'v0.4.0' +export const CHANGELOG_V030 = `更新公告 · v0.4.0 + +🖥️ 网页端与 Windows 桌面端适配,修复项目内容串挂 + +【双端使用】同一个工作台插件包支持网页端与官方 Windows 桌面端。延续 v0.3.4 对 DSH 0.2 网页端的项目与对话联动、标注输入、控制室状态及消息预览适配,本次补上桌面端连接与升级入口。 + +【桌面端终端与更新】终端使用桌面宿主提供的连接地址,网页端保留原连接方式。更新提示会按所在环境选择 web 或 desktop 配置;桌面用户请使用应用自带的 CLI,完整退出后安装,再手动重新打开。 + +【保留“未分组”选择】自定义窗口新建对话时,选择“未分组”就保持未分组,项目文件夹仅作为工作目录。修复默认分组晚返回时覆盖用户选择的问题;已有对话的分组保持原样。 + +【控制室新建对话】已测 DSH 0.2 的未分组空对话无法使用原生输入框,控制室会提示原因并禁用这一创建方式,请选择现有/新建分组,或加入可用的现有对话。选择“现有分组”但尚未选定具体项时,也不会退回默认分组创建;旧宿主保留原未分组创建行为。 + +【新项目从空白开始】新项目即使与旧项目同名、使用同一个文件夹,也不会自动加载旧项目的窗口内容。自动挂载分别识别项目、窗口与本次任务的关联,为各窗口使用专属结果文件;错误归属或无效窗口结果不会被塞进窗口1。 + +【不同对话互不串挂】同一项目的不同对话只获得和更新各自关联的窗口;一个对话完成时,不会强制重挂另一对话的旧结果。同一对话继续修改同名作品时,窗口仍可重新加载更新后的内容。任务提示词要求确认页面实际挂载后再报告“已挂载”,避免只写出结果文件就报成功。 + +【保留手工调整与补挂重试】手工关闭、替换或移动标签,以及更换目录、布局或项目绑定后,旧关联不会继续补挂或覆盖你的调整;迟到的旧请求也会重新核对归属。项目未打开时保留待挂载记录,重新打开会读取最新结果;读取失败后记录仍保留,下次打开可重试,成功挂载后才清除。 + +【旧项目继续使用】已保存的窗口、作品、会话绑定与媒体保留。根目录旧 widget-result.json 不再自动导入;旧对话若需继续自动挂载,请从目标窗口的“自定义 → 发送到会话”发一次需求,建立新关联后即可继续追改。旧文件不自动删除,此前误挂并保存的标签请关闭一次;多个任务主动写同一个实际作品文件,文件内容仍可能互相覆盖。 + +【发布检查补全】增加桌面连接、分组选择、项目归属、会话隔离和补挂重试回归,发布流程共运行 98 项源码检查,并继续核对独立安装、客户端加载与双安装包一致性。这些检查用于减少发布遗漏,不能替代真实页面和模型任务验收。 + +【兼容与验证范围】网页端兼容声明仅列 DSH 0.1.1-rc.2、0.1.2-rc.1、0.2.0-rc.2;桌面端范围为已测 Windows 官方桌面端 + DSH 0.2.0-rc.2。旧版沿用此前页面验收并补代码回归,本轮未重跑完整旧版 GUI;桌面主要交互已点测,原共目录串挂问题已由用户反馈确认修复,但本版最终包在正式桌面端的完整复验、实际审批/子代理及模型生成到自动挂载的完整业务验收仍待完成。macOS 尚未真机验证。 + +【升级与数据】网页端和桌面端的项目、布局、媒体及外观设置按各自浏览器来源保存,不会自动同步;共用 DSH_HOME 也不意味着这些界面数据共用。安装前备份重要数据并核对其他插件兼容性,装完完整重启宿主,网页端再刷新页面;若仍加载失败,请保留第一条完整错误。本工作台包不包含宿主模型请求 HTTP 400 的修复;公开发布状态以 GitHub Release 为准。 + +─────── + +此前版本说明(v0.3.4) 🛠️ 本次更新:适配 DSH 0.2 网页端 diff --git a/01_content/src/client/hostTransport.ts b/01_content/src/client/hostTransport.ts new file mode 100644 index 0000000..ccb8b25 --- /dev/null +++ b/01_content/src/client/hostTransport.ts @@ -0,0 +1,28 @@ +/** Web and Desktop share HTTP routes; Desktop exposes its Host URL for streams. */ +export function hostWebSocketUrl( + path: string, + surface: { href: string; protocol: string; streamBaseUrl?: string } = { + href: location.href, + protocol: location.protocol, + streamBaseUrl: (globalThis as any).__DSH_TRANSPORT__?.streamBaseUrl, + }, +): string { + const desktop = surface.protocol === 'dsh-app:' + if (desktop && !surface.streamBaseUrl) throw new Error('Desktop Host stream URL unavailable') + const url = new URL(path, desktop ? surface.streamBaseUrl : surface.href) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('Unsupported Host transport protocol: ' + url.protocol) + } + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return url.href +} + +/** Keep update instructions on the active profile; Desktop must use its bundled CLI. */ +export function worktableUpgrade(protocol = typeof location === 'undefined' ? 'http:' : location.protocol) { + const desktop = protocol === 'dsh-app:' + const command = 'dsh plugin --profile ' + (desktop ? 'desktop' : 'web') + ' add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"' + const prompt = desktop + ? '帮我升级 dsh-worktable。我当前使用官方桌面端:先定位该桌面端自带的 dsh CLI 并准备更新,再提醒我完整退出桌面端。确认退出后使用该 CLI 执行 ' + command + '。不要使用网页端 CLI,不要修改 web profile,不要自动杀进程或重启;完成后提醒我手动重新打开桌面端。' + : '帮我升级 dsh-worktable:执行 ' + command + ',完成后提醒我重启 dsh web 并刷新页面' + return { command, prompt, desktop } +} diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 226d8cd..a33ce7b 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -4,8 +4,10 @@ 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 { worktableUpgrade } from './hostTransport' +import { WidgetMountRegistry, newWidgetId, widgetManifestPath, type WidgetBinding } from './widgetMount' import { currentSessionOf, openHostSession, sendHostSession, createSessionSnapshotAdapter, childSessionIdsOf, pendingAckOf, sessionRuntimeMs } from './sessionCompat' -import { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession } from './sessionDetails' +import { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession, blankSessionNeedsWorkspace } from './sessionDetails' import { photoStore, kindOf } from './photoStore' import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg' import { WAVE_BG_B64 } from './waveBg' @@ -30,8 +32,7 @@ type DockMode = 'footer' | 'float' declare const __WT_VERSION__: string const LOCAL_VERSION = typeof __WT_VERSION__ === 'undefined' ? 'dev' : __WT_VERSION__ const UPDATE_REPO = 'Aisland-SJL/dsh-worktable' -const UPGRADE_CMD = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"' -const UPGRADE_AI = '帮我升级 dsh-worktable:执行 ' + UPGRADE_CMD + ',完成后提醒我重启 dsh web 并刷新页面' +const { command: UPGRADE_CMD, prompt: UPGRADE_AI, desktop: DESKTOP_HOST } = worktableUpgrade() // 更新提示图标(手绘 SVG,避免 emoji 跨平台渲染差异) const ICON_SYNC = ( @@ -194,7 +195,7 @@ function buildLayout(presetId: string, name: string): LayoutSpec { const top = Array.from({ length: def.topCount }, (_, i) => mk(def.leftCount + i)) const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.leftCount + def.topCount + i)) return { - id: 'layout-' + Date.now().toString(36), + id: newWidgetId('layout'), title: name, left: left ?? null, top: top.length > 0 ? top : null, @@ -745,14 +746,14 @@ const KNOWLEDGE_PACK = [ ' 界面即可与 DSH 原生风格一致;自造样式控制在最小范围。', '- 服务端能力:/api/worktable/fs(列目录)、/api/worktable/write(写文件)、/api/worktable/mkdir(建目录)、', ' /api/worktable/git(git 状态)、/api/worktable/site(静态托管)。', - '- 改完插件在 01_content 目录执行 npm run build;重启 dsh web 或浏览器 F5 生效。', + '- 改完插件在 01_content 目录执行 npm run build;网页端重启 dsh web 并刷新,桌面端完整退出后手动重新打开。', '- 所有产出文件一律放进本任务标注的项目文件夹,保持用户目录干净。', '- 标注协议:收到「📌 标注-窗口N …(坐标/元素/框内文字…)…」= 工作台标注(窗口编号约定:左栏→顶行→主行,从 1 起)。', ' 处理:标注已标明框选区域与「最可能目标」时直接作答、不要二次询问(仅当确实无法从标注定位时才问一次);能查看截图或打开窗口则先核实再回答。', ].join('\n') /** 组装窗口任务提示词(新建/发送两模式共用;导出到 window 供自测校验) */ -function buildWindowTaskText(projectId: string, projectName: string, windowLabel: string, requirement: string, folder: string | null, mode: 'new' | 'send'): string { +function buildWindowTaskText(projectId: string, projectName: string, windowLabel: string, requirement: string, folder: string | null, mode: 'new' | 'send', widgetBindings: WidgetBinding[] = []): string { const win = windowLabel || '一个内容窗' const folderLine = folder ? '项目文件夹:' + folder + '(本项目所有产出文件一律放进这个文件夹;不要写到别的默认位置)。' @@ -768,8 +769,9 @@ function buildWindowTaskText(projectId: string, projectName: string, windowLabel ' - 文档 / 演示(PPT、报告、表格)→ 生成真实文件(.pptx / .md / .xlsx)放进项目文件夹,用户用本机软件打开编辑;', ' - 视频 / 动画 → 生成 .mp4 / .gif(或 Lottie JSON)文件放进项目文件夹;', ' - 工作台已有内置窗能力(资源管理器 / 终端 / 浏览器 / 动画站)→ 不要重复造轮子,直接建议用户改用内置窗。', - '5. 完成内容后,用一两句话告知用户挂载结果(不要提问、不要等待用户确认):例如「已完成并自动挂到「' + win + '」窗口,想调整直接说,我改完会自动更新」。该窗口位于「' + projectName + '」项目内,如需要也可协助该项目后续的其他自定义工作。', - '6. 完成后写入「产物清单」文件:在项目文件夹里创建 widget-result.json。挂载单个窗口时写单对象 {\"window\":\"' + win + '\",\"path\":\"产物相对路径\",\"kind\":\"html\"};一次挂载多个窗口(如窗口1 + 窗口2)时写 JSON 数组,每个元素为上述单对象(可引用多个产物文件)。kind 可选 html(本地页面,path 相对项目文件夹)/ url(外部链接,path 为完整 URL)/ file(其他文件,path 相对项目文件夹)。写完这个文件,工作台会自动把产物挂载进清单对应的各个窗口并锁定保存,用户无需手动操作;用户下次打开工作台时各窗口直接显示产物,不会丢失或重置。', + '5. 完成后用一两句话说明作品与目标窗口。写清单不代表界面已确认挂载成功;只有实际看到挂载结果时,才说「已挂载」。该窗口属于「' + projectName + '」项目。', + '6. 产物握手 v2:只使用下列本项目专属清单路径,不读写项目根目录旧的 widget-result.json,也不要复用其他项目的清单。作品仍放在项目文件夹,path 按该文件夹解析。先完成作品,再创建清单父目录并写入对应 JSON;kind 为 html / url / file,替换示例中的 path 与 kind,其他身份字段保持原样。一次生成多个窗口时分别写各窗口的清单。以后在本会话里继续修改同名作品,仍更新这个清单;不得根据清单文件名变化与否判断是否完成。', + widgetBindings.length ? widgetBindings.map((b) => b.window + ':' + widgetManifestPath(b) + '\n' + JSON.stringify({ version: 2, projectId: b.projectId, paneId: b.paneId, bindingId: b.bindingId, window: b.window, path: '产物相对路径', kind: 'html' })).join('\n') : '当前没有有效的自动挂载绑定,请用户从目标窗口重新发送自定义任务;不要写旧清单猜测归属。', KNOWLEDGE_PACK, ] return lines.join('\n') @@ -779,7 +781,6 @@ function buildWindowTaskText(projectId: string, projectName: string, windowLabel export async function createCustomSession(projectId: string, projectName: string, requirement: string, group?: NewSessionGroup, windowLabel = '', folder: string | null = null): Promise { const b = sessionBridge if (!b || typeof b.sessions?.create !== 'function') throw new Error('sessions unavailable') - const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'new') // 分组解析:existing → 直接带 workspaceId 建会话;new → 先建目录并注册为工作区 let workspaceId: string | null = null const g = group ?? { kind: 'none' as const } @@ -807,11 +808,13 @@ export async function createCustomSession(projectId: string, projectName: string let createOpts: any = {} if (workspaceId) createOpts = { workspaceId } else if (folder) createOpts = { cwd: folder } - const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts) + const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts, g.kind === 'none' ? 'none' : 'auto') await ensureSessionPreset(sessionId) // 新会话应用部署默认预设 await ensureSessionModel(sessionId) // 修复继承失效 provider(selectModel 同步存默认,顺带修复后续新会话) markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动 try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {} + const widgetBindings = prepareWidgetTask(projectId, folder, sessionId, windowLabel) + const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'new', widgetBindings) await promptIntoSession(sessionId, text) return sessionId } @@ -820,7 +823,8 @@ export async function createCustomSession(projectId: string, projectName: string export async function sendCustomToSession(sessionId: string, projectId: string, projectName: string, requirement: string, windowLabel = '', folder: string | null = null): Promise { const b = sessionBridge if (!b) throw new Error('bridge unavailable') - const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'send') + const widgetBindings = prepareWidgetTask(projectId, folder, sessionId, windowLabel) + const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'send', widgetBindings) markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动 try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {} await promptIntoSession(sessionId, text) @@ -867,48 +871,40 @@ function saveNotifyAck(sid: string, state: string) { localStorage.setItem('dsh.worktable.notifyAck.v1', JSON.stringify(ack)) } catch {} } -/** 自动挂载:已消费过完成事件的会话集(一次完成只挂载一次) */ +/** 自动挂载:按项目与会话消费完成边沿,同一会话可服务不同项目。 */ const mountConsumedRef: { current: Set } = { current: new Set() } -/** 待挂载(项目未打开时暂存):projectId → 挂载内容;刷新后从 localStorage 恢复 */ -const pendingMountRef: { current: Record } = { current: (() => { - try { return JSON.parse(localStorage.getItem('dsh.worktable.pendingMount.v1') ?? '{}') ?? {} } catch { return {} } +/** v1 无归属清单/pending 不执行;原有 layouts/views 仍按项目 id 恢复。 */ +const widgetRegistry = new WidgetMountRegistry((() => { try { return localStorage } catch { return null } })()) +let widgetProjectOf: (id: string) => { folder?: string; spec?: LayoutSpec } = () => ({}) +function prepareWidgetTask(projectId: string, folder: string | null, sessionId: string, windowLabel: string): WidgetBinding[] { + if (!folder) return [] + const current = widgetProjectOf(projectId) + if (!folder || current.folder !== folder || !current.spec) throw new Error('project folder or window changed') + return widgetRegistry.begin(projectId, current.spec, folder, sessionId, windowLabel) +} +/** 待挂载保存归属,打开时重新读取文件并校验当前目录/绑定/稳定窗格 id。 */ +const pendingMountRef: { current: Record } = { current: (() => { + try { return JSON.parse(localStorage.getItem('dsh.worktable.pendingMount.v2') ?? '{}') ?? {} } catch { return {} } })() } -/** 已挂载指纹:projectId → widget-result.json 原文。自愈扫挂据此去重: - * 同清单不重复锁定(不覆盖用户后续对窗口的手动调整),清单变化才重新挂载。 */ +/** 启动扫挂按绑定指纹去重;新的完成边沿允许同路径、同清单作品重新加载。 */ const mountedWidgetRef: { current: Record } = { current: (() => { - try { return JSON.parse(localStorage.getItem('dsh.worktable.mountedWidget.v1') ?? '{}') ?? {} } catch { return {} } + try { return JSON.parse(localStorage.getItem('dsh.worktable.mountedWidget.v2') ?? '{}') ?? {} } catch { return {} } })() } -function recordMountedWidget(projectId: string, raw: string) { - if (!projectId || !raw) return - mountedWidgetRef.current[projectId] = raw - try { localStorage.setItem('dsh.worktable.mountedWidget.v1', JSON.stringify(mountedWidgetRef.current)) } catch {} +const widgetReadSeqRef: { current: Record } = { current: {} } +function recordMountedWidget(bindingId: string, raw: string) { + if (!bindingId || !raw) return + mountedWidgetRef.current[bindingId] = raw + try { localStorage.setItem('dsh.worktable.mountedWidget.v2', JSON.stringify(mountedWidgetRef.current)) } catch {} } - -/** 「窗口N」→ 窗格位置(左栏 → 顶行 → 主行 编号规则,与 AGENTS.md 窗口编号一致) */ -function windowLabelToPane(spec: any, label: string): { row: 'left' | 'top' | 'main'; index: number } | null { - const m = /^窗口(\d+)$/.exec(String(label ?? '').trim()) - if (!m) return null - const n = parseInt(m[1], 10) - if (!Number.isFinite(n) || n < 1) return null - let idx = n - 1 - if (spec?.left) { - if (idx === 0) return { row: 'left', index: 0 } - idx -= 1 +function persistPendingWidgets() { + try { localStorage.setItem('dsh.worktable.pendingMount.v2', JSON.stringify(pendingMountRef.current)) } catch {} +} +function invalidateWidgetProject(projectId: string) { + widgetRegistry.revokeProject(projectId) + for (const [id, pending] of Object.entries(pendingMountRef.current)) { + if (pending?.binding?.projectId === projectId) delete pendingMountRef.current[id] } - const top = spec?.top ?? [] - if (idx < top.length) return { row: 'top', index: idx } - idx -= top.length - const main = spec?.main ?? [] - if (idx < main.length) return { row: 'main', index: idx } - return null -} - -/** 窗格是否存在于布局 spec(lockPane 对不存在的窗格会静默放弃;据此决定是否记录挂载指纹) */ -function paneExists(spec: any, row: 'left' | 'top' | 'main', index: number): boolean { - if (!spec) return false - if (row === 'left') return !!spec.left && index === 0 - if (row === 'top') return !!spec.top && !!spec.top[index] - return !!spec.main && !!spec.main[index] + persistPendingWidgets() } /** 产物清单 → 窗口内容(html=目录级托管 iframe / url=外链 iframe / file=文件预览) */ @@ -1153,6 +1149,7 @@ function WorktableSection(props: any) { const [consoleName, setConsoleName] = useState('') const [consoleErr, setConsoleErr] = useState(false) const [consoleBusy, setConsoleBusy] = useState(false) + const consoleNeedsWorkspace = blankSessionNeedsWorkspace(sessionBridge?.sessions, consoleMode === 'none' ? 'none' : 'auto') /** 自定义布局弹窗(预设网格末尾的 + 磁贴):描述 → 复制提示词到剪贴板 */ const [customOpen, setCustomOpen] = useState(false) @@ -1462,7 +1459,14 @@ function WorktableSection(props: any) { return { ...prev, views: { ...prev.views, [spec.id]: spec } } }) } - return () => { splitStore.onSpecMutated = null } + splitStore.onPaneContentEdited = (projectId, paneId) => { + widgetRegistry.revokePane(projectId, paneId) + for (const [id, p] of Object.entries(pendingMountRef.current)) { + if (p?.binding?.projectId === projectId && p.binding.paneId === paneId) delete pendingMountRef.current[id] + } + persistPendingWidgets() + } + return () => { splitStore.onSpecMutated = null; splitStore.onPaneContentEdited = null } }, []) // 分栏引擎 UI 文案(窗选择器等) @@ -1634,23 +1638,10 @@ function buildCustomLayoutPrompt(req: string): string { const bound = projectsRef.current.projects.bindings[spec.id] if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } ackProjectNotify(spec.id) - // 补挂:此前项目未打开时暂存的产物(entries = 多窗口挂载列表),现在自动挂进各目标窗格; - // 全部落位成功记录指纹(供自愈扫挂去重) - const pending = pendingMountRef.current[spec.id] - if (pending) { - try { - const entries = Array.isArray(pending.entries) - ? pending.entries - : (pending.content ? [{ content: pending.content, row: pending.row, index: pending.index ?? 0 }] : []) - let allOk = entries.length > 0 - for (const e of entries) { - splitStore.lockPane(e.row, e.index ?? 0, e.content) - if (!paneExists(splitStore.spec, e.row, e.index ?? 0)) allOk = false - } - if (allOk && pending.fingerprint) recordMountedWidget(spec.id, pending.fingerprint) - } catch {} - delete pendingMountRef.current[spec.id] - try { localStorage.setItem('dsh.worktable.pendingMount.v1', JSON.stringify(pendingMountRef.current)) } catch {} + // 读取失败保留 pending 供下次打开重试;成功挂载后才由 loader 清除。 + for (const pending of Object.values(pendingMountRef.current)) { + if (pending?.binding?.projectId !== spec.id) continue + void applyWidgetManifest(pending.binding, null, true) } } }, [projects.views]) @@ -1697,10 +1688,12 @@ function buildCustomLayoutPrompt(req: string): string { openConsole(sid) } - /** 强制绑定:新建空会话并绑定(分组同自定义窗:无 / 现有 / 新建) */ + /** 新版空会话需显式分组才能输入;不能替用户改组或自动发送激活消息。 */ const bindConsoleNew = async () => { const b = sessionBridge if (!b || typeof b.sessions?.create !== 'function') { setConsoleErr(true); return } + if (blankSessionNeedsWorkspace(b.sessions, consoleMode === 'none' ? 'none' : 'auto')) return + if (consoleMode === 'existing' && !consoleWsId) { setConsoleErr(true); return } if (consoleMode === 'new' && (!consoleParent.trim() || !consoleName.trim())) { setConsoleErr(true); return } setConsoleBusy(true); setConsoleErr(false) try { @@ -1725,7 +1718,7 @@ function buildCustomLayoutPrompt(req: string): string { let createOpts: any = {} if (workspaceId) createOpts = { workspaceId } else if (folder) createOpts = { cwd: folder } - const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts) + const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts, consoleMode === 'none' ? 'none' : 'auto') await ensureSessionPreset(sessionId) // 管理对话同样应用部署默认预设 await ensureSessionModel(sessionId) // 管理对话同样修复失效 provider markPluginSessionOpen(sessionId) @@ -1833,7 +1826,10 @@ function buildCustomLayoutPrompt(req: string): string { pickBusyRef.current = false setPickBusy(false) if (target === 'add') { setWsFolderParent(p); setWsFolderError(false) } - else if (bindPick) { persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } })) } + else if (bindPick) { + if (projectsRef.current.projects.folders[bindPick.id] !== p) invalidateWidgetProject(bindPick.id) + persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } })) + } setManualPathFor(null) setManualPathText('') setPickErr((prev) => ({ ...prev, [target]: '' })) @@ -1843,12 +1839,14 @@ function buildCustomLayoutPrompt(req: string): string { const changeBindFolder = () => { if (!bindPick || pickBusyRef.current) return pickFolder('bind', (p) => { + if (projectsRef.current.projects.folders[bindPick.id] !== p) invalidateWidgetProject(bindPick.id) persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } })) }) } /** 绑定 / 解绑会话(弹窗保持打开,绑定结果即时显示在「绑定对话」框里) */ const setProjectBinding = (id: string, sessionId: string | null) => { + if ((projectsRef.current.projects.bindings[id] ?? null) !== sessionId) invalidateWidgetProject(id) persistProjects((prev) => { const next = { ...prev.bindings } if (sessionId) next[id] = sessionId @@ -1898,106 +1896,80 @@ function buildCustomLayoutPrompt(req: string): string { // 项目表变化 → 控制室卡片刷新(打开中的面板即时更新) useEffect(() => { notifyConsole() }, [projects]) + const [widgetTick, setWidgetTick] = useState(0) + useEffect(() => widgetRegistry.subscribe(() => setWidgetTick((n) => n + 1)), []) - /** 执行产物清单挂载:项目开着 → 锁定各「窗口N」;未开 → 待挂载(打开项目时补挂)。 - * 清单支持单窗口对象(旧格式)与多窗口 JSON 数组(新格式,每元素 {window,path,kind})。 - * rawManifest 传入时免二次读取(自愈扫挂先读原文做指纹去重)。全部落位成功后记录挂载指纹。 */ - const applyWidgetManifest = useCallback(async (projectId: string, rawManifest: string | null): Promise => { - const folder = projectsRef.current.projects.folders[projectId] - if (!folder) return + /** 只处理当前有效绑定。await 后与实际替换前重新查归属,窗口编号非法时不退到窗口1。 */ + const applyWidgetManifest = useCallback(async (binding: WidgetBinding, rawManifest: string | null, force = false): Promise => { + let current = widgetProjectOf(binding.projectId) + if (!mountedRef.current || !widgetRegistry.isCurrent(binding, current.spec, current.folder)) return + const readSeq = (widgetReadSeqRef.current[binding.bindingId] ?? 0) + 1 + widgetReadSeqRef.current[binding.bindingId] = readSeq try { let raw = rawManifest if (raw == null) { - const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(joinPath(folder, 'widget-result.json')), { cache: 'no-store' }) + const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(widgetManifestPath(binding)), { cache: 'no-store' }) if (!r.ok) return raw = (await r.text()).trim() } - if (!raw) return - let d: any = null - try { d = JSON.parse(raw) } catch {} - if (!d) return - const items = Array.isArray(d) ? d : [d] - const open = splitStore.active && splitStore.spec?.id === projectId - const saved = open ? splitStore.spec : (projectsRef.current.projects.views[projectId] ?? projectsRef.current.projects.layouts.find((l) => l.id === projectId)) - // 逐项解析目标窗格;同窗格冲突时保留先出现者(防止把两个窗口压进同一个窗格互相覆盖) - const targets: { row: 'left' | 'top' | 'main'; index: number; content: any }[] = [] - for (const it of items) { - if (!it) continue - const content = buildMountContent(folder, it) - if (!content) continue - const pane = windowLabelToPane(saved, it.window) - const row = pane?.row ?? 'main' - const index = pane?.index ?? 0 - if (targets.some((t) => t.row === row && t.index === index)) continue - targets.push({ row, index, content }) - } - if (!targets.length) return + current = widgetProjectOf(binding.projectId) + if (!mountedRef.current || !raw || widgetReadSeqRef.current[binding.bindingId] !== readSeq) return + const resolved = widgetRegistry.resolve(binding, raw, current.spec, current.folder) + if (!resolved || (!force && mountedWidgetRef.current[binding.bindingId] === raw)) return + const content = buildMountContent(binding.folder, resolved.item) + if (!content) return + const open = splitStore.active && splitStore.spec?.id === binding.projectId if (open) { - // 项目开着:逐窗锁定;全部落位成功才记录指纹(有失败则留待下次自愈重试) - let allOk = true - for (const t of targets) { - splitStore.lockPane(t.row, t.index, t.content) - if (!paneExists(splitStore.spec, t.row, t.index)) allOk = false - } - if (allOk) recordMountedWidget(projectId, raw) + // 新标签 id 使同名 HTML 在下一次完成后重新加载;不会用 raw 指纹挡住追改。 + splitStore.lockPane(resolved.target.row, resolved.target.index, content) + recordMountedWidget(binding.bindingId, raw) + delete pendingMountRef.current[binding.bindingId] + persistPendingWidgets() } else { - // 项目没开:按项目已保存的 spec 解析目标窗格,暂存,打开项目时锁死补挂 - pendingMountRef.current[projectId] = { entries: targets, fingerprint: raw } - try { localStorage.setItem('dsh.worktable.pendingMount.v1', JSON.stringify(pendingMountRef.current)) } catch {} + pendingMountRef.current[binding.bindingId] = { binding } + persistPendingWidgets() } - } catch { /* 无清单文件 = 不挂载 */ } + } catch { /* 没有可验证的结果,不挂载旧目录的其他文件。 */ } }, []) - /** 自动挂载:绑定会话完成 → 读项目文件夹 widget-result.json → 产物自动挂进对应窗口 */ - const tryAutoMount = useCallback(async (projectId: string, sid: string) => { - void sid - await applyWidgetManifest(projectId, null) - }, [applyWidgetManifest]) - - // 完成事件 → 尝试自动挂载(一次完成只消费一次) + // 完成边沿只触发读取,不是成功证明;实际挂载仍要求有效 v2 结果。 useEffect(() => { const byId = sessionsSnapshotStore.snapshot?.byId ?? {} + const scopePairs = new Map(widgetRegistry.entries().map((b) => [JSON.stringify([b.projectId, b.sessionId]), b])) + for (const key of mountConsumedRef.current) if (!scopePairs.has(key)) mountConsumedRef.current.delete(key) for (const [pid, sid] of Object.entries(projects.bindings)) { const e = byId[sid] if (!e) continue if (e.completed === true) { - // 新一次完成 → 清除旧 done ack,控制室绿光重新点亮(即使旧完成已被确认过) if (doneSeenRef.current[sid] !== true) clearNotifyAck(sid) doneSeenRef.current[sid] = true - if (!mountConsumedRef.current.has(sid)) { - mountConsumedRef.current.add(sid) - tryAutoMount(pid, sid) - } } else { doneSeenRef.current[sid] = false - mountConsumedRef.current.delete(sid) } } - }, [notifyTick, projects.bindings, tryAutoMount]) + for (const [key, binding] of scopePairs) { + const e = byId[binding.sessionId] + if (!e) continue + if (e.completed !== true) { mountConsumedRef.current.delete(key); continue } + if (mountConsumedRef.current.has(key)) continue + mountConsumedRef.current.add(key) + for (const b of widgetRegistry.entries(binding.projectId)) { + if (b.sessionId === binding.sessionId) void applyWidgetManifest(b, null, true) + } + } + }, [notifyTick, projects.bindings, widgetTick, applyWidgetManifest]) - // 自愈扫挂:插件加载 / 项目文件夹变化时,扫描每个项目的 widget-result.json 并落位 - // (「完成事件」通道的兜底:事件丢失、绑定错位、完成时页面未加载等场景下仍能自动挂载; - // 按清单原文指纹去重——同清单不重复锁定,用户后续手动改窗口内容不会被覆盖)。 + // 只扫描已明确登记的专属路径。新项目没有绑定,所以即使共用目录也保持空白。 useEffect(() => { let cancelled = false - const folders = projects.folders ?? {} ;(async () => { - for (const pid of Object.keys(folders)) { + for (const binding of widgetRegistry.entries()) { if (cancelled) return - const folder = folders[pid] - if (!folder) continue - try { - const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(joinPath(folder, 'widget-result.json')), { cache: 'no-store' }) - if (!r.ok) continue - const raw = (await r.text()).trim() - if (!raw) continue - if (mountedWidgetRef.current[pid] === raw) continue - await applyWidgetManifest(pid, raw) - } catch { /* 单项目失败不影响其余 */ } + await applyWidgetManifest(binding, null) } })() return () => { cancelled = true } - }, [projects.folders, applyWidgetManifest]) + }, [projects.folders, widgetTick, applyWidgetManifest]) /** 收集某会话的子代理 id 集:byId 的 parentId 标注 + subagentsByParent 目录双通道 */ const collectKids = useCallback((sid: string): Set => { @@ -2056,6 +2028,11 @@ function buildCustomLayoutPrompt(req: string): string { [registeredIds, projects.removed], ) projectsRef.current = { projects, metas, aliveRegisteredIds } + widgetProjectOf = (id) => ({ + folder: projectsRef.current.projects.folders[id], + spec: (splitStore.active && splitStore.spec?.id === id ? splitStore.spec : undefined) ?? + projectsRef.current.projects.views[id] ?? projectsRef.current.projects.layouts.find((l) => l.id === id), + }) projectBindingsRef.current = projects.bindings const allIds = useMemo(() => [...aliveRegisteredIds, ...layoutIds], [aliveRegisteredIds, layoutIds]) const effectiveOrder = useMemo(() => { @@ -2206,12 +2183,14 @@ function buildCustomLayoutPrompt(req: string): string { } const removeLayout = (id: string) => { + invalidateWidgetProject(id) persistProjects((prev) => ({ ...prev, layouts: prev.layouts.filter((l) => l.id !== id) })) } // ── 变更视图:所有项目通用。布局项目 = 重建其布局条目;入驻项目 = 建立/更新视图覆盖。 // 现有窗内容(标签)按序迁入新拓扑,不丢失。 ── const applyLayoutChange = (id: string, presetId: string) => { + invalidateWidgetProject(id) const layout = projects.layouts.find((l) => l.id === id) const meta = metas[id] const current = layout ?? projects.views[id] @@ -2267,6 +2246,7 @@ function buildCustomLayoutPrompt(req: string): string { // 「工作台」控制室项目不可删除(界面不提供入口,这里兜底拒绝) const removeProject = (id: string) => { if (id === CONSOLE_ID) return + invalidateWidgetProject(id) persistProjects((prev) => { const next = { ...prev, @@ -2766,7 +2746,7 @@ function buildCustomLayoutPrompt(req: string): string { -
{t('update.upgradeHint')}
+
{t(DESKTOP_HOST ? 'update.upgradeHintDesktop' : 'update.upgradeHint')}
)}
@@ -3025,6 +3005,7 @@ function buildCustomLayoutPrompt(req: string): string { {consoleMode === 'existing' && ( )} @@ -3034,8 +3015,9 @@ function buildCustomLayoutPrompt(req: string): string { setConsoleName(e.target.value)} /> )} - {consoleErr &&

{t('console.bindFail')}

} -
diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index b695081..4ccc0c9 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -172,6 +172,8 @@ export const zh = { 'console.groupNone': '无分组(用项目文件夹)', 'console.groupExisting': '加入现有分组', 'console.groupNew': '新建分组', + 'console.chooseGroup': '选择分组…', + 'console.blankNeedsGroup': '当前 DSH 的未分组空对话无法输入,请选择分组或加入现有对话。', 'console.newParentPh': '分组父目录,例如 E:\\AI_Workspace', 'console.newNamePh': '分组文件夹名(将创建并注册)', 'console.createBind': '新建并绑定', @@ -234,6 +236,7 @@ export const zh = { 'update.copyFail': '复制失败,请手动选择文本', 'update.skipDone': '已忽略此版本(下一版发布时再提醒)', 'update.upgradeHint': '执行命令后需重启 dsh web 并刷新页面', + 'update.upgradeHintDesktop': '请使用桌面端自带 CLI;安装前完整退出,完成后手动重新打开桌面端', } satisfies Record /** The worktable namespace key union. */ @@ -407,6 +410,8 @@ export const en = { 'console.groupNone': 'No group (use project folder)', 'console.groupExisting': 'Join existing group', 'console.groupNew': 'New group', + 'console.chooseGroup': 'Choose a group…', + 'console.blankNeedsGroup': 'Blank ungrouped chats cannot accept input in this DSH. Choose a group or join an existing chat.', 'console.newParentPh': 'Group parent folder, e.g. E:\\AI_Workspace', 'console.newNamePh': 'Group folder name (created & registered)', 'console.createBind': 'Create & bind', @@ -469,6 +474,7 @@ export const en = { 'update.copyFail': 'Copy failed — select the text manually', 'update.skipDone': 'Skipped (will remind on the next release)', 'update.upgradeHint': 'Run the command, then restart dsh web and refresh', + 'update.upgradeHintDesktop': 'Use the Desktop bundled CLI; fully quit before installing, then reopen Desktop manually', } satisfies Record /** Locale namespace id registered under ctx.locale. */ diff --git a/01_content/src/client/sessionDetails.ts b/01_content/src/client/sessionDetails.ts index c979ced..c5be9ea 100644 --- a/01_content/src/client/sessionDetails.ts +++ b/01_content/src/client/sessionDetails.ts @@ -62,8 +62,17 @@ export function modelApiOf(ctx: any, sessions: any, legacy: any): any { } } -/** 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 { +/** On the retained-scope host, a blank ungrouped session has a disabled native composer. */ +export function blankSessionNeedsWorkspace(sessions: any, grouping: 'auto' | 'none'): boolean { + return grouping === 'none' && typeof sessions?.using === 'function' +} + +/** Preserve an explicit Ungrouped choice; only implicit defaults may register a Workspace. */ +export async function createHostSession(sessions: any, workspaces: any, options: { workspaceId?: string; cwd?: string; sessionId?: string } = {}, grouping: 'auto' | 'none' = 'auto'): Promise { + if (grouping === 'none') { + if (options.workspaceId) throw new Error('ungrouped session cannot specify a workspace') + return sessions.create(options) + } 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 diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 0868b9a..5038220 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -1,5 +1,6 @@ import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react' import { parentPathOf } from './pathutil' +import { hostWebSocketUrl, worktableUpgrade } from './hostTransport' import { CHANGELOG_V030 } from './changelog' import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck' import { Terminal } from 'xterm' @@ -99,6 +100,8 @@ type SplitState = { lastMarginRight: string lastMarginTop: string onSpecMutated: ((spec: LayoutSpec) => void) | null + /** Manual edits revoke that pane's automatic result binding; lockPane itself does not. */ + onPaneContentEdited: ((projectId: string, paneId: string) => void) | null listeners: Set<() => void> open(spec: LayoutSpec): boolean close(): void @@ -487,8 +490,7 @@ function boxPayload(x0: number, y0: number, x1: number, y1: number): { primary: } /** 更新方法:复制给 AI 的升级指令(插件不自更新;升级由用户或其 Agent 执行 + 重启) */ -const UPGRADE_CMD = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"' -const UPGRADE_AI = '帮我升级 dsh-worktable:执行 ' + UPGRADE_CMD + ',完成后提醒我重启 dsh web 并刷新页面' +const { prompt: UPGRADE_AI } = worktableUpgrade() async function copyTextSafe(text: string): Promise { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true } } catch {} @@ -758,6 +760,7 @@ export const splitStore: SplitState = { lastMarginRight: '', lastMarginTop: '', onSpecMutated: null, + onPaneContentEdited: null, listeners: new Set(), open(spec) { @@ -1070,7 +1073,7 @@ export const splitStore: SplitState = { lockPane(row, i, content) { const spec = this.spec if (!spec) return - const tab: PaneTab = { id: 't' + Date.now().toString(36), title: tabTitleOf(content), content, active: 0 } + const tab: PaneTab = { id: 't' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2), title: tabTitleOf(content), content, active: 0 } const mutate = (pane: SplitPane): SplitPane => ({ ...pane, content: null, tabs: [tab], active: 0 }) if (row === 'left') { if (!spec.left || i !== 0) return @@ -1095,6 +1098,8 @@ export const splitStore: SplitState = { setTabContent(row, index, tabId, content) { const spec = this.spec if (!spec) return + const edited = row === 'left' ? spec.left : row === 'top' ? spec.top?.[index] : spec.main[index] + if (edited?.tabs?.some((t) => t.id === tabId)) this.onPaneContentEdited?.(spec.id, edited.id) const mutate = (pane: SplitPane): SplitPane => { const tabs = (pane.tabs ?? []).map((t) => (t.id === tabId ? { ...t, content, title: tabTitleOf(content) } : t)) return { ...pane, tabs } @@ -1115,6 +1120,8 @@ export const splitStore: SplitState = { openTab(row, i, content) { const spec = this.spec if (!spec) return + const edited = row === 'left' ? spec.left : row === 'top' ? spec.top?.[i] : spec.main[i] + if (edited) this.onPaneContentEdited?.(spec.id, edited.id) const mutate = (pane: SplitPane): SplitPane => { const tabs = [...(pane.tabs ?? [])] // 去重:同内容已有标签 → 直接激活 @@ -1146,6 +1153,8 @@ export const splitStore: SplitState = { closeTab(row, i, tabId) { const spec = this.spec if (!spec) return + const edited = row === 'left' ? spec.left : row === 'top' ? spec.top?.[i] : spec.main[i] + if (edited?.tabs?.some((t) => t.id === tabId)) this.onPaneContentEdited?.(spec.id, edited.id) const mutate = (pane: SplitPane): SplitPane => { const tabs = (pane.tabs ?? []).filter((t) => t.id !== tabId) return { ...pane, tabs, active: 0 } @@ -1184,6 +1193,8 @@ export const splitStore: SplitState = { if (!fromPane || !toPane) return const tab = (fromPane.tabs ?? []).find((t) => t.id === tabId) if (!tab) return + this.onPaneContentEdited?.(spec.id, fromPane.id) + this.onPaneContentEdited?.(spec.id, toPane.id) const fromTabs = (fromPane.tabs ?? []).filter((t) => t.id !== tabId) const toTabs = [...(toPane.tabs ?? []), tab] const setPane = (row: PaneRow, i: number, pane: SplitPane) => { @@ -2418,9 +2429,8 @@ function TerminalPane() { focusTerm() el.addEventListener('pointerdown', focusTerm) const scope = splitEnv?.getScope?.() - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' - const url = proto + '//' + location.host + '/api/worktable/term?sessionId=' + encodeURIComponent(scope?.sessionId ?? '') + '&cwd=' + encodeURIComponent(scope?.cwd ?? '') + '&cols=80&rows=24' try { + const url = hostWebSocketUrl('/api/worktable/term?sessionId=' + encodeURIComponent(scope?.sessionId ?? '') + '&cwd=' + encodeURIComponent(scope?.cwd ?? '') + '&cols=80&rows=24') ws = new WebSocket(url) } catch { term.dispose() @@ -2623,8 +2633,9 @@ function SelectPop(props: { } /** 自定义窗口:居中对话框。两种模式:新建专属会话 / 发送到已有会话(默认当前会话)。 */ -function CustomPane(props: { paneTitle?: string }) { - const paneTitle = props.paneTitle ?? '' +function CustomPane(props: { paneTitle?: string; row?: PaneRow; index?: number }) { + const paneTitle = props.row === undefined ? (props.paneTitle ?? '') : + (windowLabelOf(props.row, props.index ?? 0).match(/^窗口\d+/)?.[0] ?? '') try { (window as any).__dshLastCustomPaneTitle = paneTitle } catch {} const custom = splitEnv?.custom const [requirement, setRequirement] = useState('') @@ -2637,6 +2648,7 @@ function CustomPane(props: { paneTitle?: string }) { const [wsGroups, setWsGroups] = useState<{ id: string; title: string; path: string }[]>([]) const [groupMode, setGroupMode] = useState<'none' | 'existing' | 'new'>('none') const [groupId, setGroupId] = useState(null) + const groupTouchedRef = useRef(false) const [newGroupParent, setNewGroupParent] = useState('') const [newGroupName, setNewGroupName] = useState('') const [busy, setBusy] = useState(false) @@ -2656,7 +2668,8 @@ function CustomPane(props: { paneTitle?: string }) { setSessionId(flat.find((s) => s.isCurrent)?.id ?? flat[0]?.id ?? null) const curId = flat.find((s) => s.isCurrent)?.id const home = curId ? (custom?.getWorkspaces?.() ?? []).find((w) => (w.sessionIds ?? []).includes(curId)) : null - if (home) { setGroupMode('existing'); setGroupId(home.id) } + // A delayed default lookup must not overwrite the user's explicit choice. + if (home && !groupTouchedRef.current) { setGroupMode('existing'); setGroupId(home.id) } }).catch(() => { setSessionGroups([]) }) }, [custom]) const submit = async () => { @@ -2757,6 +2770,7 @@ function CustomPane(props: { paneTitle?: string }) { }]} placeholder={T('custom.group')} onChange={(id) => { + groupTouchedRef.current = true if (id === '__none') setGroupMode('none') else if (id === '__new') setGroupMode('new') else { setGroupMode('existing'); setGroupId(id) } @@ -2814,7 +2828,7 @@ function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number; paneTit if (content.type === 'scm') return if (content.type === 'tasks') return if (content.type === 'terminal') return - if (content.type === 'custom') return + if (content.type === 'custom') return return (
{BUILTIN_ICONS[content.type]} diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index 6ac7d9a..4f370c8 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -621,6 +621,7 @@ export const css = xtermCss + '\n' + [ '.dsh-wt_consoleSelect,.dsh-wt_consoleInput{width:100%;box-sizing:border-box;padding:6px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11.5px;line-height:16px;outline:none}', '.dsh-wt_consoleSelect:focus,.dsh-wt_consoleInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', '.dsh-wt_consoleErr{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-state-danger,#f85149)}', + '.dsh-wt_consoleHint{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#aab6cd)}', '.dsh-wt_consoleCreateBtn{margin-top:2px;padding:7px 10px;border:1px solid var(--dsw-alias-state-accent-primary,#4f8ef7);border-radius:8px;background:transparent;color:var(--dsw-alias-state-accent-primary,#4f8ef7);font:inherit;font-size:12px;line-height:18px;cursor:pointer}', '.dsh-wt_consoleCreateBtn:hover:not(:disabled){background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}', '.dsh-wt_consoleCreateBtn:disabled{opacity:.55;cursor:default}', diff --git a/01_content/src/client/widgetMount.ts b/01_content/src/client/widgetMount.ts new file mode 100644 index 0000000..af19e4b --- /dev/null +++ b/01_content/src/client/widgetMount.ts @@ -0,0 +1,121 @@ +import { joinPath } from './pathutil' + +/** Browser-local ownership of auto-mounted results. Folders never identify projects. */ +export const WIDGET_BINDINGS_KEY = 'dsh.worktable.widgetBindings.v2' +export type WidgetSpec = { id: string; left?: any; top?: any[] | null; main: any[] } +export type WidgetBinding = { + projectId: string + paneId: string + window: string + bindingId: string + sessionId: string + folder: string + enabled: boolean +} +export type WidgetTarget = { row: 'left' | 'top' | 'main'; index: number; pane: any; window: string } +type Store = Pick + +export function newWidgetId(prefix = 'binding'): string { + const id = globalThis.crypto?.randomUUID?.() ?? (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)) + return prefix + '-' + id +} + +export function widgetTargets(spec: WidgetSpec | null | undefined): WidgetTarget[] { + if (!spec) return [] + const out: WidgetTarget[] = [] + const add = (row: WidgetTarget['row'], panes: any[]) => panes.forEach((pane, index) => { + if (pane && typeof pane.id === 'string') out.push({ row, index, pane, window: '窗口' + (out.length + 1) }) + }) + add('left', spec.left ? [spec.left] : []) + add('top', spec.top ?? []) + add('main', spec.main ?? []) + return out +} + +export function widgetManifestPath(binding: WidgetBinding): string { + // Binding-specific files also keep late writers from overwriting a newer binding's result. + return joinPath(binding.folder, joinPath('.dsh-worktable', joinPath( + 'project-' + encodeURIComponent(binding.projectId), + 'pane-' + encodeURIComponent(binding.paneId) + '-' + binding.bindingId + '.json', + ))) +} + +export class WidgetMountRegistry { + private records: Record> = Object.create(null) + private listeners = new Set<() => void>() + constructor(private storage: Store | null, private makeId = () => newWidgetId()) { + try { + const data = JSON.parse(storage?.getItem(WIDGET_BINDINGS_KEY) ?? '{}') + for (const [projectId, panes] of Object.entries(data ?? {})) { + if (!panes || typeof panes !== 'object' || Array.isArray(panes)) continue + const valid: Record = Object.create(null) + for (const [paneId, b] of Object.entries(panes)) { + const v = b as WidgetBinding + if (v?.projectId !== projectId || v.paneId !== paneId || typeof v.folder !== 'string' || !v.folder || + typeof v.sessionId !== 'string' || !v.sessionId || typeof v.window !== 'string' || + typeof v.bindingId !== 'string' || !/^[a-z0-9-]+$/i.test(v.bindingId) || typeof v.enabled !== 'boolean') continue + valid[paneId] = { ...v } + } + if (Object.keys(valid).length) this.records[projectId] = valid + } + } catch { /* Invalid registry cannot authorize an old manifest. */ } + } + private persist() { + // A send must not proceed with ownership that cannot survive reload. + this.storage?.setItem(WIDGET_BINDINGS_KEY, JSON.stringify(this.records)) + for (const fn of this.listeners) fn() + } + subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => { this.listeners.delete(fn) } } + entries(projectId?: string): WidgetBinding[] { + const projects = projectId ? [this.records[projectId] ?? {}] : Object.values(this.records) + return projects.flatMap((panes) => Object.values(panes)).filter((b) => b.enabled) + } + begin(projectId: string, spec: WidgetSpec, folder: string, sessionId: string, window: string): WidgetBinding[] { + if (!this.storage) throw new Error('project storage unavailable') + if (!folder || !sessionId || spec.id !== projectId) throw new Error('project folder or session unavailable') + const targets = widgetTargets(spec) + const selected = targets.find((t) => t.window === window) + if (!selected || new Set(targets.map((t) => t.pane.id)).size !== targets.length) throw new Error('project window changed') + const before = this.records[projectId] + const next: Record = Object.create(null) + for (const t of targets) { + const old = before?.[t.pane.id] + // Unassigned windows can participate in one multi-window result. A manually revoked + // window stays revoked until explicitly selected for another custom task. + next[t.pane.id] = t === selected || !old || old.folder !== folder || (old.enabled && old.window !== t.window) + ? { projectId, paneId: t.pane.id, window: t.window, folder, sessionId, bindingId: this.makeId(), enabled: true } + : old + } + this.records[projectId] = next + try { this.persist() } catch (error) { if (before) this.records[projectId] = before; else delete this.records[projectId]; throw error } + return this.entries(projectId).filter((b) => b.sessionId === sessionId) + } + revokePane(projectId: string, paneId: string) { + const b = this.records[projectId]?.[paneId] + if (!b || !b.enabled) return + this.records[projectId] = { ...this.records[projectId], [paneId]: { ...b, enabled: false } } + try { this.persist() } catch { /* Revocation takes effect in memory even if storage is full. */ } + } + revokeProject(projectId: string) { + if (!this.records[projectId]) return + for (const b of this.entries(projectId)) this.revokePane(projectId, b.paneId) + } + isCurrent(binding: WidgetBinding, spec: WidgetSpec | null | undefined, folder: string | undefined): boolean { + const current = this.records[binding.projectId]?.[binding.paneId] + return !!current?.enabled && current.bindingId === binding.bindingId && current.sessionId === binding.sessionId && + current.folder === folder && binding.folder === folder && spec?.id === binding.projectId && + widgetTargets(spec).filter((t) => t.pane.id === binding.paneId).length === 1 + } + resolve(binding: WidgetBinding, raw: string, spec: WidgetSpec | null | undefined, folder: string | undefined): + { target: WidgetTarget; item: { kind: 'html' | 'url' | 'file'; path: string } } | null { + if (!this.isCurrent(binding, spec, folder)) return null + try { + const d = JSON.parse(raw) + if (!d || Array.isArray(d) || d.version !== 2 || d.projectId !== binding.projectId || + d.paneId !== binding.paneId || d.bindingId !== binding.bindingId || d.window !== binding.window || + !['html', 'url', 'file'].includes(d.kind) || typeof d.path !== 'string' || !d.path.trim()) return null + const target = widgetTargets(spec).find((t) => t.pane.id === binding.paneId)! + return { target, item: { kind: d.kind, path: d.path.trim() } } + } catch { return null } + } +} diff --git a/02_process/PRD.md b/02_process/PRD.md index d29ad2e..0fdf0a3 100644 --- a/02_process/PRD.md +++ b/02_process/PRD.md @@ -3,6 +3,8 @@ > 版本:v0.2 草案 · 日期:2026-08-16 · 状态:v2 已实现并验收;§12 多项目分栏框架(v3)与 §13 乐高式工作区框架(v4)设计定案、待实现 > 关联项目:dsh-travelatlas(第一个入驻项目)、上游参考 dsh-reminder(文件夹结构) +> 文档边界:§3/§9–§13 包含早期目标、历史验收表与设计草案,不是当前版本全部实现的规格书。当前 v0.4.0 的安装、兼容与验证状态以 README / 包内 README 为准;§6/§8 为当前架构与权限说明。 + ## 1. 项目定位 **dsh-worktable 是 DeepSeek Harness Web GUI 的一个「工作台」容器插件**:在左侧侧边栏的「工作区」 @@ -156,10 +158,10 @@ ctx.slots.register({ ## 6. 架构与技术方案 - 插件包:`01_content/`(dsh.plugin.json + cordis.patch.yml + build.mjs,参照 dsh-travelatlas / dsh-usage); -- 服务端:最小 cordis 插件,注册 `GET /api/worktable/health`(`inject: ['webServer']`),无业务路由; +- 服务端:Cordis 插件,`inject: ['webServer','sessions']`;`/api/worktable/*` 包含 health、fs、git、file、write、site、mkdir、workspaces、template 等业务路由,`/api/worktable/term` 为终端 WebSocket。数据目录由官方 home-paths(可解析时)或无循环 baseDshHome 兜底定位,不写死 ~/.dsh。 - 客户端:单文件 CJS(`window.__ModuleLoader__.load` 握手),external react / @deepseek-ai/*; - 注入座位:`sidebar.footer.action`(order 20,位于 dsh-usage 之后); - - 服务注入:`['slots','locale']`(locale 词典见 §5.7;宿主缺席时回退 zh 词典); + - 服务注入:`['slots','locale','sessions','conversation','workspaces']`;新版可选服务经 `ctx.get` 探测,缺席时保留旧版分支;locale 缺席回退 zh。Desktop 的 platform 仍为 web,终端地址经 `hostTransport.ts` 从宿主 transport 取得。 - 图标为 emoji 字符(🔍/☰/+/≡); - 排序机制:owner props 下发 order 序列,卡片以 CSS order 参与排序(渲染器 display:contents 锚点已核实,见 §5.3); - 子座位注册跟踪:apply 中 `ctx.slots.subscribe` + `entries()` 维护模块级 id 序列; @@ -176,13 +178,15 @@ ctx.slots.register({ - views:入驻项目与「控制室」(wt-console)的视图覆盖(LayoutSpec); - bindings:项目 → 绑定会话(含 wt-console 管理对话); - folders:项目 → 项目文件夹(含 wt-console); + - 自动挂载 v2:`dsh.worktable.widgetBindings.v2` / `pendingMount.v2` / `mountedWidget.v2` + (后两者同为 dsh.worktable 前缀);分别记录归属、待补挂与已消费结果,结构及迁移见 §14。 - `dsh.worktable.notifyAck.v1`:会话 id → `done` / 旧版 `need` / `need:` 加排序后的待决身份集合; 新版身份仅含父/子会话 id、kind 与 opaque key,不含正文、答案或凭据;同一问题刷新后仍可确认, 替换问题必须重新点亮。旧无 key 主机保留布尔状态退路,无法区分无 key 的连续替换。 - 卡片上报的 meta 注册表仅存内存,不持久化; - - 更新检查:`dsh.worktable.lastUpdateCheck.v1`(上次成功检查时间戳,节流一天一次)、 + - 更新检查:`dsh.worktable.lastUpdateCheck.v1`(一天节流时间戳:控制室请求前写入,设置面板获得结果后写入)、 `dsh.worktable.skipVersion.v1`(忽略的版本号)、`dsh.worktable.updateCheck.v1`(自动检查开关,'0'=关)、 - `dsh.worktable.updateCache.v1`(公告页缓存 {status,info});控制室第 5 键「更新公告」与设置面板共用这些键; + `dsh.worktable.updateCache.v1`(仅控制室公告页缓存 {status,info});两入口共用前三键,缓存键不共用; - 媒体库(自定义背景):IndexedDB `dsh-worktable/photoRecords`(id/createdAt/kind/blob/order,v2); 首次使用预置两张默认图(defaultBg.ts SVG 极光 + waveBg.ts JPEG),标记 `dsh.worktable.defaultBgSeeded.v1`; - 样式:暗色优先,跟随 `--dsw-alias-*` 设计变量(与 dsh-usage / dsh-travelatlas 一致)。 @@ -198,10 +202,11 @@ ctx.slots.register({ ## 8. 隐私与安全 -- 无个人数据采集;所有状态仅存 localStorage; -- 搜索仅在本机过滤项目名; -- 不读写任何工作区文件、不请求任何网络资源(除插件自身静态资源)。 -- 更新检查为可关闭的只读 GET(GitHub Releases API,自动每天最多一次 + 手动「立即检查」),不上传任何数据。 +- 不新增分析上报服务;项目/布局/绑定存 localStorage,媒体存 IndexedDB,网页与桌面来源各自独立。搜索在本机过滤项目名。 +- 文件窗、编辑保存、mkdir、站点与 widget 产物握手会读写配置的项目目录;workspaces 路由读取宿主工作区数据,部分读取由加载/项目变化/会话完成自动触发。 +- 更新检查为可关闭的 GitHub Releases 只读 GET,不携带会话内容;其余通信包括宿主 API/WebSocket 与网页窗加载内容自身的请求。不能概括为「不联网」。 +- git 状态与交互式终端具有命令执行能力;终端继承宿主完整环境变量,用户命令可以读写文件、联网。插件不专门提取/存储/上传凭据,但不能保证终端绝不接触敏感信息。完整权限与失败边界见 `01_content/README.md`,不作安全保证。 +- 新版控制室不创建未分组空会话(宿主原生输入区禁用);由用户明确选分组/现有对话,不代替选择、不自动发送激活消息。自定义窗口的显式未分组只传 cwd,不自动登记工作区;已有会话不迁移。 ## 9. 验收清单(v1) @@ -446,3 +451,150 @@ type SplitPane = { 用户自建布局 = 同一引擎的运行时产物,共用 tiling 引擎与持久化; - 本地快捷方式、接入指引保留于「+」面板第二入口。 +## 14. 当前实现参考(由规则手册迁入,v0.4.0) + +以下为界面、状态与产物协议的实现细节,供改动对应组件时对照源码;约束与验证入口见 AGENTS.md,发布状态见 README。0.2 导航使用 openHostSession,新旧 API 桥接见 sessionCompat.ts/sessionDetails.ts;不要将旧接口示例当作通用公开 API。 + +- **新会话预设修复**:新建会话(createCustomSession / bindConsoleNew)创建后调用 + ensureSessionPreset——用宿主 api.agentPresets.list/select 显式应用「部署默认预设」 + (isDefault ?? 首个,失败逐个尝试其余预设;select 仅对 blank 会话生效)。 +- **新会话模型修复(真根因)**:会话级模型选择独立于预设、随默认选择持久化——用户删掉 + provider 后新会话继承失效选择,prompt 报 model-unavailable。ensureSessionModel: + ① 无条件继承「当前会话」正在用的模型(用户控制用哪个就用哪个,相同则跳过); + ② 无当前会话且新会话不可用时 → 最近会话众数 → 失效选择的家族词匹配 → 目录首个。 + session.selectModel 同时把新选择存为默认(继承的 Pro 会写回默认)。失败静默、缺 API 跳过。 +- **对话绑定**:projects.v1.bindings = { 项目id → 会话id };打开项目时经 openHostSession + 切到绑定会话(openSplit / DOM 桥两处入口;新 uiWorkspace、旧 sessions.open);未绑定/解绑 = 不切换。 +- **项目×对话联动**:打开项目记录「打开前会话」(projectAttachRef.sessionId);项目打开期间切到 + 非绑定会话 = 自动关项目(suppressRestoreRef 跳过回切);✕/反选关项目 = 回切「打开前会话」。 + 未绑定项目的归属会话 = 打开前会话。 + **例外**:插件自身经 openHostSession 发起的会话切换(新建对话、发送到会话)不得触发 + 自动关项目——createCustomSession/sendCustomToSession 用 markPluginSessionOpen 豁免 + (pluginOpenedSessionsRef),用户要继续在项目里跟新对话沟通;同时 CustomPane 在发送成功后 + 调用 autoBind:项目未绑定则自动绑定到新建/选中的会话。 + +- **项目文件夹**:projects.v1.folders = { 项目id → 绝对路径 };新建项目强制填写(父目录必填, + 文件夹名留空 = 用项目名),保存时走 /api/worktable/mkdir 建目录;绑定面板可改。自定义窗口 + 新建会话(未选分组时)用 sessions.create({cwd: 项目文件夹}),提示词携带文件夹与「所有产出 + 放进该文件夹」指令——用户要求项目产出文件不得落到默认位置。 + +- **窗口任务提示词**:buildWindowTaskText 统一组装(窗口身份「项目+窗口N」+ 项目文件夹 + + 插件知识包);知识包注明「不要重新侦察插件源码」,改提示词时保持这个原则。 + +- **自动挂载 v2(0.4.0 的项目归属协议)**:项目名称和文件夹均不是身份,布局用独立 projectId; + 「自定义」任务发送前由 WidgetMountRegistry 登记 projectId/paneId/bindingId/sessionId/folder。 + 提示词为每个可参与窗口给出专属结果路径: + `.dsh-worktable/project-/pane--.json`, + 路径相对项目目录;单文件只接受 `{version:2,projectId,paneId,bindingId,window,path,kind}`, + kind 为 html/url/file,产物相对路径仍按项目目录解析。多窗分别写各自结果文件,不接收旧数组或无归属对象。 + 登记必须成功持久化才发送;选中的窗格更新 bindingId,未登记窗格可参与同一批任务, + 已手动撤销的其他窗格不自动复活;提示词仅提供本次 sessionId 的有效绑定,不携带其他会话的清单。 + 窗口编号用于提示词,实际挂载按稳定 paneId 查当前位置, + 无效窗口/身份不回退窗口1;不同项目即使名称/目录相同,也不共用结果入口。 + 启动仅扫描仍有效的已登记结果;会话完成边沿只读取相同 projectId 且相同 sessionId 的绑定(不证明任务成功), + 同一会话后续修改继续更新同一结果文件。新完成强制刷新同名作品,普通启动跳过已消费的相同结果。 + 读取后必须复验组件存活、最新读取序号、当前项目目录/布局/关联;目录变更、项目重绑、 + 布局修改、项目删除及手动关闭/替换/移动标签会撤销相应关联,迟到旧结果不得改回窗口。 + **持久化**:项目/视图原有键保留;新增 `dsh.worktable.widgetBindings.v2` 登记关联、 + `dsh.worktable.pendingMount.v2` 保存待补挂的 binding、`dsh.worktable.mountedWidget.v2` 保存已消费结果。 + 项目关闭时只存 binding;重新打开须重读专属文件并复验身份,不使用缓存内容或旧 row/index。 + HTTP/读取失败保留待补挂记录,下次打开可重试;仅成功挂载后清除。 + 挂载经 splitStore.lockPane 清空该窗格标签、设唯一产物并通过 onSpecMutated 保存;旧已保存窗口仍恢复。 + **旧协议与限制**:不执行 v1 待挂载索引、不扫描根目录旧 widget-result.json;不删除旧文件或误挂标签。 + 旧会话可从目标窗口「自定义 → 发送到会话」发一次任务建立新关联。`.dsh-worktable/` 仅存握手元数据, + 不复制 DSH 数据;项目隔离不是文件系统沙箱,多项目主动写同一实际文件仍可冲突。 + 2026-08-18 的旧握手记录属于历史实现,当前协议以本节为准。提示词不得仅凭写文件就宣称页面已挂载。 + +- **原生皮肤模板**:01_content/template/dshell.css + dshell.html(esbuild text loader 嵌入服务端 + bundle,/api/worktable/template 路由下发);知识包要求产出 HTML 一律引用该样式表,组件类 + 参考模板。新增组件样式只加到 dshell.css,保持单一来源。 + +- **任务完成/待决提醒镜像**:绑定会话在宿主快照 byId 里 completed=true → 项目卡双圆点变 + 绿色发光(data-bound=done);pendingInteraction != null → 黄色发光(data-bound=need); + 点开项目 = ack(notifyAck.v1 按会话存状态)恢复常态实心。数据源 = sessionsSnapshotStore + (syncSessionScope 写入完整快照并通知监听者);跨状态(done↔need)会重新点亮。 + **工作中(busy)**:byId[sid].running === true → data-bound=busy,蓝色 #4f8ef7 发光 + + dsh-wt-busyA/B 关键帧两圆交替亮灭(对应 DSH 转圈标记);优先级 need > done > busy——等待判断时 pendingInteraction 与 running 同时为真, + 原生 UI 以黄点优先,镜像必须一致;busy 无需 ack,running 变 false 自动切换。 + **子代理聚合**:待决状态常挂在子代理会话上(父会话只有 running)——bindNotifyMap 用 + collectKids(byId.parentId + subagentsByParent 双通道)聚合父会话及其子代理的 pending; + 会话面 binding(id).session.getSnapshot().pending 非空也判 need(列表不映射时的兜底); + ackProjectNotify 同步 ack 子代理。 + **ack 生命周期**:新版按父/子会话的 opaque pending key+kind 集合保存 `need:...`,不保存问题正文/答案; + 同为 need 的问题替换也重新点亮,排序/重复目录不触发误亮。旧无 key 主机继续存 need,无法识别无 key 的直接替换。 + 状态/身份转移清旧 ack 时同步清除本次读取副本;点开确认同时记录已见身份,防下一次渲染反清刚保存的 ack。 + +- **「工作台」控制室项目(默认自带)**: + - 固定 id `wt-console`(CONSOLE_ID),卡片恒排项目列表第一位(order 0)、不可删除 + (不进设置管理列表 + removeProject 兜底拒绝);图标 🖥️,名称走 locale console.name。 + - 点开:已绑定 → openConsole(默认布局 buildConsoleSpec:单一大窗格 content + {kind:'builtin',type:'console'} + 右侧对话,spec 持久化在 views['wt-console']); + 未绑定 → 强制绑定弹窗(左「加入现有对话」列表 / 右「新建对话」:分组 无/现有/新建, + 仅在宿主允许且用户分组选择有效时创建空会话并绑定;DSH 0.2 未分组空会话提示并禁用创建,不改组、不发激活消息)。绑定也走 projects.v1.bindings。 + - 控制室面板(split.tsx ConsolePane):卡片网格每行 3 张、超出换行;每卡 = 图标/名称/ + 状态大字与三色光效(need>done>busy>idle,不过滤 ack,永远显示事实状态)/运行时长 + (后台任务 JobView.startedAt → 新版 uiConversation 的 chat.legacy.turnTimings → 旧会话面, + 未加载/无起点不显示时长;不为计时激活 chat 或持有冷会话)/最近消息预览。数据组装 + = index.tsx getConsoleCards(env.console 注入),刷新走 consoleListeners(项目/会话 + 快照变化推送)+ 面板每秒 tick。 + - 卡片动作:点卡片 = 打开该项目(openSplit 或入驻项目切绑定对话);工作台自己的卡片 + 点击无操作。💬 跳转按钮已删除(用户定案无意义)。 + - 主题:面板三选一开关(图标按钮 🌙/☀️/🖥️,title/aria 保留文字;存 view.v1 consoleTheme); + system 读宿主 html 的 color-scheme(DSH 深色/白色/跟随系统设置都会反映到它)+ + prefers-color-scheme 兜底;落成 .dsh-wt_console[data-wt-theme=dark|light] 作用域变量 + --wt-*(宿主不发布 --dsw-alias-*,工作台全站一直靠回退色渲染——控制室自带主题作用域, + 不受其影响)。 + - 状态光效(整卡霓虹描边,参考侧栏双圆点发光质感):工作=蓝色彗星式光点顺时针绕卡旋转 + (.dsh-wt_consoleCard-busy ::before conic-gradient + @property --consoleAngle + + consoleAngleSpin;环 inset -4/padding 4、高亮段 #dcebff→#9cc6ff、filter drop-shadow + rgba(140,190,255,.85) 光点自带辉光、外发光双层 10px+34px);完成=绿光、待决=黄光 + (-glowDone/-glowNeed:亮色描边 + 双层外发光 + 微弱内辉光;glow 字段 = done/need 且 + 本轮未 ack,点卡片先 onAck 熄光再进入,与提醒 ack 生命周期一致)。 + - 命名:侧栏区块标题 = 「工作台」(title locale,整个插件);默认项目卡名与面板标题 = + 「控制室」(console.name/console.title locale,工作台的控制室)。 + - 控制室标签不可关:PaneBody 对 content.type==='console' 的标签 locked(不渲染 ✕、 + 禁拖拽)——关掉会退化成窗格选择器,不可逆。 + - 布局尺度:网格 gap 64px(4 倍间距)不变、max-width 856px 左右居中;卡片 1:1(面积 + 2×边长 1.4,实测 236px:名字 20/状态 22/预览 8.5 四行截断);标题下横向分隔线;无子代理 + 徽章、无 💬 跳转按钮;网格最后一位恒为「创建卡片」(虚线+)→ openAddPanel;入口卡无描边。 + - 背景三选一(data-wt-bg):纯色/流光/自定义,各自独立记忆——色相/饱和度/明度(-180..180/0..200,纯色随主题默认:深 #0a0d13、浅 #eef1f5)、 + 贴片模糊 B(0-20;预设 纯色0/流光8/自定义8)、网格线不透明度 T(0-30;纯色/流光各自)、 + 流光速度 S(0-100,0=完全不动,默认50=原速,consoleGlowSpeed;--wt-glowScale=50/速度;恢复初始一并复位)、 + 照片网格开关(consoleBgPhotoGrid)+ 网格透明度(consoleBgGridOpacity);卡片 glass 底含 backdrop-filter blur(var(--wt-cardBlur))。 + - 自定义背景 = 媒体库(照片+视频):IndexedDB photoRecords(id/createdAt/kind/blob/order); + 首用预置两张默认图(defaultBg.ts SVG 极光 + waveBg.ts JPEG 181KB,标记 defaultBgSeeded.v1); + 缩略图类型角标/全行删除/抓手拖拽(槽位制+FLIP,photoStore.reorder);视频背景双轨首尾帧交叉渐融(ConsoleVideo ≥1.2s 最长2s、备轨就绪才淡入)。 + - 底部操作台:5 个 ghost 按钮玻璃 dock(主题/形状/背景/每行数量/更新公告);菜单点选保持打开、点空白关闭; + 下拉宽度 = dock 总宽 186px(媒体宽版 264px)。 + - 顶部标题克制:控制室页只保留侧栏入口卡一个「控制室」——分栏标题栏对 wt-console + 不渲染 title(保留 ⇄/✕)、PaneBody singleConsole 不渲染标签栏;控制项集中在底部操作台 5 键。 + - 更新公告(第 5 键,反选切换大磁片替代网格视图):顶部=当前版本/检查更新/自动检查开关; + 新版本横幅=复制升级指令(点击变绿并提示「请在任意对话中发送」)/查看发布页(蓝 hover)/忽略此版本(红 hover)+ + CHANGELOG_V030 正文(changelog.ts 纯文本,不做 md 渲染); + 检查核心 = updateCheck.ts(updateCache.v1 缓存,与设置面板旧更新卡共用 lastUpdateCheck/skipVersion/updateCheck 键, + 旧卡暂保留未去重);铃铛按钮发现新版本时右上角琥珀呼吸灯(dockBadge)。 + - 指哪打哪标注:窗格折叠键旁 annotBtn——蓝泡光标→点选/拖框(小拖=点)→输入→✓ 注入宿主 textarea(不发送,失败回退剪贴板)。 + payload v3.2:窗口身份(编号+窗格标题+内容类型/URL)+ 主目标(caretPositionFromPoint 取字 + computed style 字号)+ 整行 + 候选; + 同源 iframe 下钻取字(boxPayload),跨域输出「读取受限」+ src;提示词含「禁止编造,缺失时如实说明,建议截图或视觉模型」; + 知识包含标注协议行(窗口编号+处理方式);回退锚点 tag:pre-annotate-v3 / pre-annotate-v3.1 / pre-annotate-v3.2。 + - 分隔线:DIVIDER=4(分栏可拖分隔条更细)。 + - 冷会话消息预览:0.2 用 sessions.using 临时持有后读 eventSource 的已加载事件窗口;旧版预热走 face.history({maxMessages:6}) + (旧版运行期内建方法、非公开接口);尾部扫 user/message 与 assistant/message 的 + text 块 → cleanPreviewText(滤除 ```围栏与行内代码、压缩空白;不足 8 字符回退更早消息)→ + previewCache;sweepPreviews 在打开控制室时 + 控制室开着且会话快照变化防抖 6s 触发; + 失败静默回退缓存/内存路径 lastTextOf;预热期间不因自身 retain/release 排入新一轮。拉取是带宽成本不是 Token 成本。 + - 状态指示:卡片右上角小圆点已删(整卡光效表达状态);状态计算不变。 + +- **更新检查(v0.2.2)**:客户端直连 GitHub Releases API 比版本(只读 GET;自动每天最多 + 一次,手动「立即检查」绕过节流;单次 8s 超时(AbortController)+ 最多 3 次尝试, + in-flight 防重入、检查中按钮禁用、设置面板组件卸载后停止重试与状态更新(控制室另见 updateCheck.ts,不外推该守卫);失败后状态行显示「上次检查未成功」)。 + 状态四态 idle/checking/uptodate/failed;徽标 = 「工作台」标题右侧琥珀呼吸小圆(SVG 同步 + 图标、不显示版本号),仅发现更新时出现;更新卡在设置面板顶部(复制 AI 提示词 + 忽略此 + 版本 + 命令框供终端用户手抄),版本号与自动检查开关在面板底部;设置弹窗右上角 ✕ 关闭、 + 底部防溢出钳制(POP_BOTTOM_MARGIN=12,贴底后向上生长;按钮与开关必须平级防冒泡)。 + localStorage 键 lastUpdateCheck.v1 / skipVersion.v1 / updateCheck.v1(控制室第 5 键公告页共用,另缓存 updateCache.v1)。 + **发布纪律**:改动≠发布,只有 tag+Release 才触发提醒;新版本 = 新 tag + 新 Release,每个 + Release 必须同时附固定名资产 `dsh-worktable.tgz`(供 releases/latest/download 永久链接) + 与版本化资产。版本注入:build.mjs 把 package.json version 打进 __WT_VERSION__,发版前 + 改 version 再构建;升级动作(执行 add + 重启)永远留给用户或其 Agent,插件不自更新。 + diff --git a/04_test/host-transport.test.mjs b/04_test/host-transport.test.mjs new file mode 100644 index 0000000..da483d9 --- /dev/null +++ b/04_test/host-transport.test.mjs @@ -0,0 +1,45 @@ +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 source = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/hostTransport.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text +const module = { exports: {} } +runInNewContext(source, { module, exports: module.exports, URL }) +const { hostWebSocketUrl, worktableUpgrade } = module.exports + +test('Web HTTP uses its current authority and preserves encoded query', () => { + assert.equal(hostWebSocketUrl('/api/worktable/term?cwd=C%3A%2Ftest&cols=80', { href: 'http://127.0.0.1:3080/', protocol: 'http:' }), 'ws://127.0.0.1:3080/api/worktable/term?cwd=C%3A%2Ftest&cols=80') +}) +test('Web HTTPS keeps the secure WebSocket transport', () => { + assert.equal(hostWebSocketUrl('/api/worktable/term', { href: 'https://example.test/dsh', protocol: 'https:' }), 'wss://example.test/api/worktable/term') +}) +test('Desktop uses the injected Host port, never the app scheme authority', () => { + assert.equal(hostWebSocketUrl('/api/worktable/term?sessionId=fixture', { href: 'dsh-app://app/', protocol: 'dsh-app:', streamBaseUrl: 'http://127.0.0.1:19499' }), 'ws://127.0.0.1:19499/api/worktable/term?sessionId=fixture') +}) +test('Desktop without its bridge fails visibly rather than connecting to ws://app', () => { + assert.throws(() => hostWebSocketUrl('/api/worktable/term', { href: 'dsh-app://app/', protocol: 'dsh-app:' }), /stream URL unavailable/) +}) +test('Unknown stream protocols are rejected', () => { + assert.throws(() => hostWebSocketUrl('/api/worktable/term', { href: 'dsh-app://app/', protocol: 'dsh-app:', streamBaseUrl: 'file:///fixture' }), /Unsupported Host transport/) +}) + +test('Web update instructions retain the web profile', () => { + const { command, prompt, desktop } = worktableUpgrade('https:') + assert.equal(desktop, false) + assert.match(command, /--profile web add/) + assert.match(prompt, /重启 dsh web/) +}) + +test('Desktop update instructions require its bundled CLI and manual restart', () => { + const { command, prompt, desktop } = worktableUpgrade('dsh-app:') + assert.equal(desktop, true) + assert.match(command, /--profile desktop add/) + assert.doesNotMatch(command, /--profile web/) + assert.match(prompt, /桌面端自带的 dsh CLI/) + assert.match(prompt, /不要修改 web profile/) + assert.match(prompt, /手动重新打开桌面端/) +}) diff --git a/04_test/session-details.test.mjs b/04_test/session-details.test.mjs index a0f899b..933363f 100644 --- a/04_test/session-details.test.mjs +++ b/04_test/session-details.test.mjs @@ -3,13 +3,14 @@ import { test } from 'node:test' import { createRequire } from 'node:module' import { runInNewContext } from 'node:vm' import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' const require = createRequire(new URL('../01_content/package.json', import.meta.url)) -const { buildSync } = require('esbuild') +const { buildSync, transformSync } = 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 { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession, blankSessionNeedsWorkspace } = 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', () => { @@ -142,3 +143,181 @@ test('session creation failure is propagated without a second create attempt', a await assert.rejects(createHostSession(sessions, { async create() { return { workspaceId: 'ws' } } }, { cwd: 'C:/path' }), /workspace attach failed/) assert.equal(calls, 1) }) + +test('explicit Ungrouped preserves project cwd and does not create or reuse a workspace', async () => { + const options = { cwd: 'C:/fixture/Projects', sessionId: 'ungrouped' } + const sessions = { using() {}, async create(value) { assert.equal(value, options); return 's' } } + const workspaces = new Proxy({}, { get() { assert.fail('Ungrouped must not inspect workspace services') } }) + assert.equal(await createHostSession(sessions, workspaces, options, 'none'), 's') + assert.deepEqual(options, { cwd: 'C:/fixture/Projects', sessionId: 'ungrouped' }) +}) + +test('Ungrouped without a project directory does not initialize the default workspace', async () => { + const sessions = { using() {}, async create(options) { assert.deepEqual({ ...options }, {}); return 's' } } + assert.equal(await createHostSession(sessions, null, {}, 'none'), 's') +}) + +test('legacy Ungrouped preserves cwd and grouping is never sent as a host API parameter', async () => { + const sessions = { async create(options) { assert.deepEqual({ ...options }, { cwd: '/fixture/project' }); return 's' } } + assert.equal(await createHostSession(sessions, null, { cwd: '/fixture/project' }, 'none'), 's') +}) + +test('contradictory Ungrouped plus workspace and failed Ungrouped creation do not regroup or retry', async () => { + let calls = 0 + const sessions = { using() {}, async create() { calls++; throw new Error('create refused') } } + await assert.rejects(createHostSession(sessions, null, { workspaceId: 'ws' }, 'none'), /ungrouped session cannot/) + assert.equal(calls, 0) + await assert.rejects(createHostSession(sessions, null, { cwd: '/fixture' }, 'none'), /create refused/) + assert.equal(calls, 1) +}) + +const indexSource = readFileSync(new URL('../01_content/src/client/index.tsx', import.meta.url), 'utf8') +function sourceFunction(source, startText, endText, name, bindings) { + source = source.replace(/\r\n/g, '\n') + const start = source.indexOf(startText) + assert.ok(start >= 0) + const end = source.indexOf(endText, start) + assert.ok(end > start) + const code = transformSync(source.slice(start, endText === '\n}\n' ? end + 2 : end).replace(/^export /, '') + '\nexport { ' + name + ' }', { loader: 'tsx', format: 'cjs', jsxFactory: 'h', jsxFragment: 'Fragment' }).code + const module = { exports: {} } + runInNewContext(code, { module, exports: module.exports, ...bindings }) + return module.exports[name] +} + +test('actual custom-window creation forwards Ungrouped and project cwd through the host helper', async () => { + const calls = [] + const create = sourceFunction(indexSource, 'export async function createCustomSession(', '\n/** 把自定义需求', 'createCustomSession', { + sessionBridge: { sessions: { using() {}, async create(opts) { calls.push({ ...opts }); return 'synthetic' } }, workspaces: new Proxy({}, { get() { assert.fail('no workspace for explicit Ungrouped') } }) }, + createHostSession, prepareWidgetTask: () => [], buildWindowTaskText: () => 'synthetic task', ensureSessionPreset: async () => {}, ensureSessionModel: async () => {}, + markPluginSessionOpen: () => {}, clientCtx: {}, openHostSession: async () => {}, promptIntoSession: async () => {}, + }) + await create('p', 'project', 'synthetic', { kind: 'none' }, '', 'C:/fixture/Projects') + assert.deepEqual(calls, [{ cwd: 'C:/fixture/Projects' }]) +}) + +test('blank-session restriction only applies to explicit Ungrouped on the retained-scope host', () => { + assert.equal(blankSessionNeedsWorkspace({ using() {} }, 'none'), true) + assert.equal(blankSessionNeedsWorkspace({ using() {} }, 'auto'), false) + assert.equal(blankSessionNeedsWorkspace({}, 'none'), false) + assert.equal(blankSessionNeedsWorkspace(null, 'none'), false) +}) + +function controlRoomFixture({ modern = true, mode = 'none', workspaceId = '' } = {}) { + const calls = [], effects = [], errors = [] + const sessions = { async create(options) { calls.push({ ...options }); return 'synthetic' } } + if (modern) sessions.using = () => {} + const create = sourceFunction(indexSource, ' const bindConsoleNew = async () => {', '\n actionsRef.current', 'bindConsoleNew', { + sessionBridge: { sessions, workspaces: new Proxy({}, { get() { assert.fail('must not silently register a workspace') } }) }, + createHostSession, blankSessionNeedsWorkspace, consoleMode: mode, consoleWsId: workspaceId, consoleParent: '', consoleName: '', CONSOLE_ID: 'wt-console', + projectsRef: { current: { projects: { folders: {} } } }, setConsoleBusy: (value) => effects.push(['busy', value]), setConsoleErr: (value) => errors.push(value), + ensureSessionPreset: async () => effects.push('preset'), ensureSessionModel: async () => effects.push('model'), markPluginSessionOpen: () => effects.push('mark'), + persistProjects: () => effects.push('bind'), setConsoleBind: () => effects.push('close'), openConsole: () => effects.push('open'), + }) + return { create, calls, effects, errors } +} + +test('actual control-room handler rejects a modern blank Ungrouped session before any side effects', async () => { + const f = controlRoomFixture() + await f.create() + assert.deepEqual(f.calls, []) + assert.deepEqual(f.effects, []) + assert.deepEqual(f.errors, []) +}) + +test('actual control-room handler preserves legacy Ungrouped creation', async () => { + const f = controlRoomFixture({ modern: false }) + await f.create() + assert.deepEqual(f.calls, [{}]) + assert.ok(f.effects.includes('open')) + assert.deepEqual(f.errors, [false]) +}) + +test('actual control-room handler preserves the explicitly chosen modern workspace', async () => { + const f = controlRoomFixture({ mode: 'existing', workspaceId: 'chosen-workspace' }) + await f.create() + assert.deepEqual(f.calls, [{ workspaceId: 'chosen-workspace' }]) + assert.ok(f.effects.includes('bind')) + assert.ok(f.effects.includes('open')) + assert.deepEqual(f.errors, [false]) +}) + +test('actual control-room handler rejects an empty existing-group selection rather than defaulting', async () => { + for (const modern of [true, false]) { + const f = controlRoomFixture({ modern, mode: 'existing' }) + await f.create() + assert.deepEqual(f.calls, []) + assert.deepEqual(f.effects, []) + assert.deepEqual(f.errors, [true]) + } +}) + +test('actual control-room JSX exposes the restriction, disables creation and includes an empty group option', () => { + const source = indexSource.replace(/\r\n/g, '\n') + const start = source.indexOf('
') + const end = source.indexOf('\n
\n
\n )}', start) + assert.ok(start >= 0 && end > start) + const code = transformSync('export const render = () => (' + source.slice(start, end) + ')', { loader: 'tsx', format: 'cjs', jsxFactory: 'h', jsxFragment: 'Fragment' }).code + const h = (type, props, ...children) => ({ type, props: { ...props, children } }) + const find = (node, match) => { + if (!node || typeof node !== 'object') return null + if (match(node)) return node + for (const child of Array.isArray(node) ? node : node.props?.children ?? []) { + const found = find(child, match) + if (found) return found + } + return null + } + for (const [modern, mode, workspaceId, blocked] of [[true, 'none', '', true], [false, 'none', '', false], [true, 'existing', '', true], [true, 'existing', 'chosen', false]]) { + const sessions = modern ? { using() {} } : {} + const consoleNeedsWorkspace = sourceFunction(source, ' const consoleNeedsWorkspace =', '\n\n', 'consoleNeedsWorkspace', { sessionBridge: { sessions }, consoleMode: mode, blankSessionNeedsWorkspace }) + const module = { exports: {} } + runInNewContext(code, { module, exports: module.exports, h, Fragment: Symbol('fragment'), t: key => key, + consoleMode: mode, consoleWsId: workspaceId, consoleNeedsWorkspace, consoleBusy: false, consoleErr: false, + consoleParent: '', consoleName: '', listWorkspaces: () => [{ id: 'chosen', title: 'fixture' }], bindConsoleNew: () => {}, + setConsoleMode: () => {}, setConsoleWsId: () => {}, setConsoleParent: () => {}, setConsoleName: () => {}, + }) + const tree = module.exports.render() + assert.equal(find(tree, node => node.props?.className === 'dsh-wt_consoleCreateBtn').props.disabled, blocked) + assert.equal(Boolean(find(tree, node => node.props?.className === 'dsh-wt_consoleHint')), modern && mode === 'none') + if (mode === 'existing') assert.ok(find(tree, node => node.type === 'option' && node.props.value === '' && node.props.children.includes('console.chooseGroup'))) + } +}) + +test('actual group picker ignores a delayed default after the user explicitly selects Ungrouped', async () => { + const source = readFileSync(new URL('../01_content/src/client/split.tsx', import.meta.url), 'utf8') + let resolveGroups + const groupPromise = new Promise((resolve) => { resolveGroups = resolve }) + const states = [], refs = [], effects = [] + let stateIndex = 0, refIndex = 0, initial = true + const SelectPop = () => {} + const h = (type, props, ...children) => ({ type, props: { ...props, children } }) + const custom = { getProjects: () => [{ id: 'p', name: 'project' }], currentProjectId: () => 'p', getWorkspaces: () => [{ id: 'ws', title: 'Projects', sessionIds: ['current'] }], getSessions: () => groupPromise } + const pane = sourceFunction(source, 'function CustomPane(', '\n}\n', 'CustomPane', { + window: {}, splitEnv: { custom }, SelectPop, h, Fragment: Symbol('fragment'), T: (key) => key, + useState(value) { + const index = stateIndex++ + if (!(index in states)) states[index] = typeof value === 'function' ? value() : value + return [states[index], (next) => { states[index] = typeof next === 'function' ? next(states[index]) : next }] + }, + useRef(value) { const index = refIndex++; return refs[index] ??= { current: value } }, + useEffect(fn) { if (initial) effects.push(fn) }, + }) + const render = () => { stateIndex = 0; refIndex = 0; return pane({}) } + const pickerIn = (node) => { + if (!node || typeof node !== 'object') return null + if (node.type === SelectPop && node.props.placeholder === 'custom.group') return node + for (const child of Array.isArray(node) ? node : node.props?.children ?? []) { + const picker = pickerIn(child) + if (picker) return picker + } + return null + } + render(); initial = false + effects.forEach((fn) => fn()) + states[3] = 'new' + pickerIn(render()).props.onChange('__none') + resolveGroups({ groups: [{ title: 'Projects', sessions: [{ id: 'current', title: 'current', isCurrent: true }] }] }) + await groupPromise + await Promise.resolve() + assert.equal(pickerIn(render()).props.value, '__none') +}) diff --git a/04_test/widget-mount.test.mjs b/04_test/widget-mount.test.mjs new file mode 100644 index 0000000..47a97e6 --- /dev/null +++ b/04_test/widget-mount.test.mjs @@ -0,0 +1,366 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' + +const require = createRequire(new URL('../01_content/package.json', import.meta.url)) +const { buildSync, transformSync } = require('esbuild') +function sourceModule(relative) { + const code = buildSync({ entryPoints: [fileURLToPath(new URL(relative, import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text + const module = { exports: {} } + runInNewContext(code, { module, exports: module.exports }) + return module.exports +} +const { WidgetMountRegistry, widgetManifestPath, widgetTargets, newWidgetId, WIDGET_BINDINGS_KEY } = sourceModule('../01_content/src/client/widgetMount.ts') +const paths = sourceModule('../01_content/src/client/pathutil.ts') +const indexSource = readFileSync(new URL('../01_content/src/client/index.tsx', import.meta.url), 'utf8').replace(/\r\n/g, '\n') +const splitSource = readFileSync(new URL('../01_content/src/client/split.tsx', import.meta.url), 'utf8').replace(/\r\n/g, '\n') +function section(source, start, end) { + const a = source.indexOf(start), b = source.indexOf(end, a + start.length) + assert.ok(a >= 0 && b > a, 'source section missing: ' + start) + return source.slice(a, b) +} +function evaluate(code, bindings = {}) { + const module = { exports: {} } + runInNewContext(transformSync(code, { loader: 'tsx', format: 'cjs' }).code, { module, exports: module.exports, ...bindings }) + return module.exports +} +function storage(seed = {}) { + const values = new Map(Object.entries(seed)) + return { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), values } +} +const specOf = id => ({ id, title: id, left: null, top: [{ id: 'p1', title: '窗口1', content: null }], main: [{ id: 'p2', title: '窗口2', content: null }] }) +const resultOf = (b, extra = {}) => JSON.stringify({ version: 2, projectId: b.projectId, paneId: b.paneId, bindingId: b.bindingId, window: b.window, path: 'daily-station.html', kind: 'html', ...extra }) +function registryFixture() { + const store = storage() + let next = 0 + return { store, registry: new WidgetMountRegistry(store, () => 'binding-' + (++next)) } +} + +test('new project in an old/shared folder has no auto-mount ownership', () => { + const { registry } = registryFixture() + const a = specOf('A'), b = specOf('B'), folder = 'C:/shared' + registry.begin('A', a, folder, 'S', '窗口1') + assert.equal(registry.entries('B').length, 0) + const ownedByA = registry.entries('A')[0] + assert.equal(registry.resolve(ownedByA, resultOf(ownedByA), b, folder), null) + assert.equal(b.top[0].content, null) +}) +test('same folder and same display name still yield distinct project paths', () => { + const { registry } = registryFixture() + const a = specOf('A'), b = specOf('B') + a.title = b.title = '同名项目' + const aa = registry.begin('A', a, 'C:/shared', 'S', '窗口1')[0] + const bb = registry.begin('B', b, 'C:/shared', 'S', '窗口1')[0] + assert.notEqual(widgetManifestPath(aa), widgetManifestPath(bb)) + assert.equal(registry.resolve(bb, resultOf(aa), b, 'C:/shared'), null) +}) +test('raw legacy object/array never authorizes a current result', () => { + const { registry } = registryFixture(), spec = specOf('A') + const b = registry.begin('A', spec, '/shared', 'S', '窗口1')[0] + for (const raw of ['{"window":"窗口1","kind":"html","path":"old.html"}', '[{"window":"窗口1","path":"old.html"}]']) { + assert.equal(registry.resolve(b, raw, spec, '/shared'), null) + } +}) +test('ownership, window, kind and malformed JSON are checked independently', () => { + const { registry } = registryFixture(), spec = specOf('A') + const b = registry.begin('A', spec, '/shared', 'S', '窗口1')[0] + for (const extra of [{ version: 1 }, { projectId: 'B' }, { paneId: 'missing' }, { bindingId: 'old' }, { window: '窗口99' }, { kind: 'unknown' }, { path: '' }]) { + assert.equal(registry.resolve(b, resultOf(b, extra), spec, '/shared'), null) + } + assert.equal(registry.resolve(b, '{bad', spec, '/shared'), null) + assert.ok(registry.resolve(b, resultOf(b), spec, '/shared')) +}) +test('refresh restores valid ownership and independent multi-window bindings', () => { + const { registry, store } = registryFixture(), spec = specOf('A') + const entries = registry.begin('A', spec, 'C:/shared', 'S', '窗口1') + assert.equal(entries.length, 2) + assert.notEqual(widgetManifestPath(entries[0]), widgetManifestPath(entries[1])) + const restored = new WidgetMountRegistry(store) + for (const b of entries) assert.ok(restored.resolve(b, resultOf(b), spec, 'C:/shared')) + assert.ok(store.values.has(WIDGET_BINDINGS_KEY)) +}) +test('re-customizing one pane rejects its old writer without canceling another pane', () => { + const { registry } = registryFixture(), spec = specOf('A') + const old = registry.begin('A', spec, '/shared', 'S1', '窗口1') + const next = registry.begin('A', spec, '/shared', 'S2', '窗口1') + assert.notEqual(widgetManifestPath(old[0]), widgetManifestPath(next[0])) + assert.equal(registry.resolve(old[0], resultOf(old[0]), spec, '/shared'), null) + assert.ok(registry.resolve(old[1], resultOf(old[1]), spec, '/shared')) +}) +test('manual revocation survives reload and an unrelated custom task', () => { + const { registry, store } = registryFixture(), spec = specOf('A') + const old = registry.begin('A', spec, '/shared', 'S', '窗口1') + registry.revokePane('A', 'p1') + const restored = new WidgetMountRegistry(store, () => 'binding-new') + restored.begin('A', spec, '/shared', 'S', '窗口2') + assert.equal(restored.entries('A').some(b => b.paneId === 'p1'), false) + assert.equal(restored.resolve(old[0], resultOf(old[0]), spec, '/shared'), null) +}) +test('another session receives only its own window bindings in the real task prompt', () => { + const { registry } = registryFixture(), spec = specOf('A') + const first = registry.begin('A', spec, '/shared', 'S1', '窗口1') + const second = registry.begin('A', spec, '/shared', 'S2', '窗口2') + const build = evaluate(section(indexSource, 'function buildWindowTaskText(', '\n/** 自定义窗口任务') + '\nexport { buildWindowTaskText }', { KNOWLEDGE_PACK: 'generic knowledge', widgetManifestPath }).buildWindowTaskText + const prompt = build('A', '项目 A', '窗口2', 'widget', '/shared', 'send', second) + assert.deepEqual(Array.from(second, b => b.sessionId), ['S2']) + assert.ok(prompt.includes(widgetManifestPath(second[0]))) + assert.ok(!prompt.includes(widgetManifestPath(first[0]))) + assert.ok(registry.resolve(first[0], resultOf(first[0]), spec, '/shared')) +}) +test('changing directory or removing target makes a previously valid result invalid', () => { + const { registry } = registryFixture(), spec = specOf('A') + const b = registry.begin('A', spec, '/shared', 'S', '窗口1')[0] + assert.equal(registry.resolve(b, resultOf(b), spec, '/other'), null) + assert.equal(registry.resolve(b, resultOf(b), { ...spec, top: [] }, '/shared'), null) + registry.revokeProject('A') + assert.equal(registry.resolve(b, resultOf(b), spec, '/shared'), null) +}) +test('moved panes resolve by stable id rather than their previous row/index', () => { + const { registry } = registryFixture(), spec = specOf('A') + const b = registry.begin('A', spec, '/shared', 'S', '窗口1')[0] + const moved = { ...spec, top: [spec.main[0]], main: [spec.top[0]] } + const resolved = registry.resolve(b, resultOf(b), moved, '/shared') + assert.equal(resolved.target.row, 'main') + assert.equal(resolved.target.pane.id, 'p1') +}) +test('storage failure prevents sending with a nonpersistent new binding', () => { + const registry = new WidgetMountRegistry({ getItem: () => null, setItem() { throw new Error('quota') } }, () => 'binding-test') + assert.throws(() => registry.begin('A', specOf('A'), '/shared', 'S', '窗口1'), /quota/) + assert.equal(registry.entries().length, 0) +}) +test('project ids are independent even when generated within one clock tick', () => { + assert.equal(new Set(Array.from({ length: 100 }, () => newWidgetId('layout'))).size, 100) +}) + +// Execute the real async loader and real lockPane method, not a reimplementation. +function mountingFixture() { + const { registry, store } = registryFixture() + const specs = { A: specOf('A'), B: specOf('B') }, folders = { A: 'C:/shared', B: 'C:/shared' } + const lockMethod = section(splitSource, ' lockPane(row, i, content) {', '\n /** 更新指定标签页') + const splitStore = evaluate('const splitStore = { active: true, spec: null, onSpecMutated: null, persist() {}, notify() {}, ' + lockMethod + ' }; export { splitStore }', { tabTitleOf: c => c.title }).splitStore + splitStore.spec = specs.A + splitStore.onSpecMutated = s => { specs[s.id] = s } + const calls = [], reads = [], files = new Map() + const lock = splitStore.lockPane + splitStore.lockPane = function (...args) { calls.push(args); lock.apply(this, args) } + const pendingMountRef = { current: {} }, mountedWidgetRef = { current: {} }, mountedRef = { current: true } + const mountContent = evaluate(section(indexSource, 'function buildMountContent(', '\n/** 每会话最近观察') + '\nexport { buildMountContent }', paths).buildMountContent + const bindings = { + useCallback: fn => fn, widgetRegistry: registry, + widgetProjectOf: id => ({ folder: folders[id], spec: splitStore.active && splitStore.spec?.id === id ? splitStore.spec : specs[id] }), + widgetManifestPath, mountedRef, mountedWidgetRef, pendingMountRef, widgetReadSeqRef: { current: {} }, splitStore, buildMountContent: mountContent, + recordMountedWidget: (id, raw) => { mountedWidgetRef.current[id] = raw }, persistPendingWidgets() {}, + async fetch(url) { + const path = new URL(url, 'http://fixture').searchParams.get('path') + reads.push(path) + const value = files.get(path) + return value ? { ok: true, text: typeof value === 'function' ? value : async () => value } : { ok: false } + }, + } + const apply = evaluate(section(indexSource, ' const applyWidgetManifest = useCallback(', '\n // 完成边沿') + '\nexport { applyWidgetManifest }', bindings).applyWidgetManifest + return { registry, store, specs, folders, splitStore, calls, reads, files, pendingMountRef, mountedWidgetRef, mountedRef, apply } +} +test('actual loader never reads the root legacy file and cannot mount project A in B', async () => { + const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0] + f.files.set('C:/shared/widget-result.json', '{"window":"窗口1","path":"old.html"}') + f.files.set(widgetManifestPath(b), resultOf(b)) + f.splitStore.spec = f.specs.B + await f.apply(b, null) + assert.equal(f.calls.length, 0) + assert.ok(f.pendingMountRef.current[b.bindingId]) + assert.equal(f.specs.B.top[0].content, null) + assert.deepEqual(f.reads, [widgetManifestPath(b)]) +}) +test('actual loader blocks wrong identity and nonexistent window rather than falling back to window1', async () => { + const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0] + await f.apply(b, resultOf(b, { projectId: 'B' })) + await f.apply(b, resultOf(b, { window: '窗口99' })) + assert.equal(f.calls.length, 0) +}) +test('a next completion reloads the same HTML even when the manifest text is unchanged', async () => { + const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0] + const raw = resultOf(b) + await f.apply(b, raw) + const firstTab = f.splitStore.spec.top[0].tabs[0].id + await f.apply(b, raw) + assert.equal(f.calls.length, 1) + await f.apply(b, raw, true) + assert.equal(f.calls.length, 2) + assert.notEqual(f.splitStore.spec.top[0].tabs[0].id, firstTab) +}) +test('old reads finishing after rebind, directory change or manual replacement cannot overwrite a pane', async () => { + for (const action of ['rebind', 'folder', 'manual', 'unmount']) { + const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0] + let finish + const waiting = new Promise(resolve => { finish = resolve }) + f.files.set(widgetManifestPath(b), () => waiting) + const loading = f.apply(b, null) + await Promise.resolve() + if (action === 'rebind') f.registry.begin('A', f.specs.A, f.folders.A, 'S2', '窗口1') + if (action === 'folder') f.folders.A = 'C:/other' + if (action === 'manual') f.registry.revokePane('A', b.paneId) + if (action === 'unmount') f.mountedRef.current = false + finish(resultOf(b)) + await loading + assert.equal(f.calls.length, 0, action) + } +}) +test('pending result is revalidated after refresh/move and rejected after manual close', async () => { + const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0] + f.splitStore.active = false + await f.apply(b, resultOf(b)) + const pending = f.pendingMountRef.current[b.bindingId] + f.files.set(widgetManifestPath(b), resultOf(b)) + f.splitStore.active = true + f.splitStore.spec = { ...f.specs.A, top: [f.specs.A.main[0]], main: [f.specs.A.top[0]] } + await f.apply(pending.binding, null, true) + assert.equal(f.calls[0][0], 'main') + f.registry.revokePane('A', 'p1') + await f.apply(pending.binding, null, true) + assert.equal(f.calls.length, 1) +}) +test('loading legacy project state preserves its saved windows without using old pending', () => { + const saved = specOf('old') + saved.top[0].tabs = [{ id: 'old-tab', content: { kind: 'iframe', url: 'old.html' } }] + const store = storage({ 'dsh.worktable.projects.v1': JSON.stringify({ layouts: [saved], folders: { old: 'C:/shared' } }), 'dsh.worktable.pendingMount.v1': JSON.stringify({ fresh: { content: 'old.html' } }) }) + const load = evaluate(section(indexSource, 'function loadProjects()', '\nconst clamp') + '\nexport { loadProjects }', { localStorage: store, PROJECTS_KEY: 'dsh.worktable.projects.v1', DEFAULT_PROJECTS: {} }).loadProjects + assert.equal(load().layouts[0].top[0].tabs[0].content.url, 'old.html') + assert.equal(new WidgetMountRegistry(store).entries().length, 0) + assert.ok(!indexSource.includes("getItem('dsh.worktable.pendingMount.v1')")) +}) +test('real project opening retains failed pending reads and retries on the next open', async () => { + for (const failure of ['http', 'read']) { + const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0] + f.splitStore.active = false + await f.apply(b, resultOf(b)) + f.splitStore.open = spec => { f.splitStore.active = true; f.splitStore.spec = spec } + if (failure === 'read') f.files.set(widgetManifestPath(b), async () => { throw new Error('read failed') }) + const open = evaluate(section(indexSource, ' const openSplit = useCallback(', '\n /** 打开「工作台」控制室') + '\nexport { openSplit }', { + useCallback: fn => fn, engineIdsRef: { current: new Set() }, projects: { views: {} }, splitStore: f.splitStore, + currentSessionOf: () => null, sessionBridge: null, projectAttachRef: {}, projectsRef: { current: { projects: { bindings: {} } } }, + ackProjectNotify() {}, pendingMountRef: f.pendingMountRef, applyWidgetManifest: f.apply, persistPendingWidgets() {}, + }).openSplit + open(f.specs.A) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(f.calls.length, 0, failure) + assert.ok(f.pendingMountRef.current[b.bindingId], failure + ' keeps retry record') + f.splitStore.active = false + f.files.set(widgetManifestPath(b), resultOf(b)) + open(f.specs.A) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(f.calls.length, 1, failure) + assert.equal(f.pendingMountRef.current[b.bindingId], undefined, failure + ' clears only on success') + } +}) + +test('actual startup sweep ignores every unregistered project, even with a legacy result in its folder', async () => { + const f = mountingFixture() + f.files.set('C:/shared/widget-result.json', '{"window":"窗口1","path":"old.html","kind":"html"}') + const effects = [] + evaluate(section(indexSource, ' // 只扫描已明确登记', '\n /** 收集某会话') + '\nexport {}', { + useEffect: fn => effects.push(fn), projects: { folders: f.folders }, widgetTick: 0, widgetRegistry: f.registry, applyWidgetManifest: f.apply, + }) + effects[0]() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(f.reads, []) + assert.equal(f.calls.length, 0) +}) +test('actual completion effect processes both projects sharing one session and supports the next round', async () => { + const f = mountingFixture() + const aa = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0] + const bb = f.registry.begin('B', f.specs.B, f.folders.B, 'S', '窗口1')[0] + f.files.set(widgetManifestPath(aa), resultOf(aa)) + f.files.set(widgetManifestPath(bb), resultOf(bb)) + const effects = [], snapshot = { snapshot: { byId: { S: { completed: true } } } } + evaluate(section(indexSource, ' // 完成边沿只触发读取', '\n // 只扫描已明确登记') + '\nexport {}', { + useEffect: fn => effects.push(fn), sessionsSnapshotStore: snapshot, widgetRegistry: f.registry, projects: { bindings: { A: 'S', B: 'S' } }, + doneSeenRef: { current: {} }, clearNotifyAck() {}, mountConsumedRef: { current: new Set() }, applyWidgetManifest: f.apply, notifyTick: 0, widgetTick: 0, + }) + effects[0]() + await new Promise(resolve => setImmediate(resolve)) + assert.equal(f.calls.length, 1) + assert.ok(f.pendingMountRef.current[bb.bindingId]) + const firstTab = f.splitStore.spec.top[0].tabs[0].id + effects[0]() + await new Promise(resolve => setImmediate(resolve)) + assert.equal(f.calls.length, 1) + snapshot.snapshot.byId.S.completed = false + effects[0]() + snapshot.snapshot.byId.S.completed = true + effects[0]() + await new Promise(resolve => setImmediate(resolve)) + assert.equal(f.calls.length, 2) + assert.notEqual(f.splitStore.spec.top[0].tabs[0].id, firstTab) +}) +test('a slower earlier read cannot overwrite a newer result from the same binding', async () => { + const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0] + let finishOld + f.files.set(widgetManifestPath(b), () => new Promise(resolve => { finishOld = resolve })) + const oldRead = f.apply(b, null, true) + await Promise.resolve() + f.files.set(widgetManifestPath(b), resultOf(b, { path: 'new.html' })) + await f.apply(b, null, true) + finishOld(resultOf(b, { path: 'old.html' })) + await oldRead + assert.equal(f.calls.length, 1) + assert.equal(f.splitStore.spec.top[0].tabs[0].content.title, 'new.html') +}) +test('completion of one session never reads or reloads another session window in the same project', async () => { + const f = mountingFixture() + const first = f.registry.begin('A', f.specs.A, f.folders.A, 'S1', '窗口1')[0] + f.registry.begin('A', f.specs.A, f.folders.A, 'S2', '窗口2') + const second = f.registry.entries('A').find(b => b.sessionId === 'S2') + f.files.set(widgetManifestPath(first), resultOf(first)) + f.files.set(widgetManifestPath(second), resultOf(second)) + await f.apply(first, resultOf(first)) + const firstTab = f.splitStore.spec.top[0].tabs[0].id + const effects = [], snapshot = { snapshot: { byId: { S1: { completed: false }, S2: { completed: true } } } } + evaluate(section(indexSource, ' // 完成边沿只触发读取', '\n // 只扫描已明确登记') + '\nexport {}', { + useEffect: fn => effects.push(fn), sessionsSnapshotStore: snapshot, widgetRegistry: f.registry, projects: { bindings: { A: 'S2' } }, + doneSeenRef: { current: {} }, clearNotifyAck() {}, mountConsumedRef: { current: new Set() }, applyWidgetManifest: f.apply, notifyTick: 0, widgetTick: 0, + }) + effects[0]() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(f.reads, [widgetManifestPath(second)]) + assert.equal(f.splitStore.spec.top[0].tabs[0].id, firstTab) + assert.equal(f.calls.length, 2) + snapshot.snapshot.byId.S1.completed = true + effects[0]() + await new Promise(resolve => setImmediate(resolve)) + assert.equal(f.calls.length, 3) + assert.notEqual(f.splitStore.spec.top[0].tabs[0].id, firstTab) +}) +test('real closeTab revokes ownership before saving the empty window', async () => { + const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0] + await f.apply(b, resultOf(b)) + const method = evaluate('const close = { ' + section(splitSource, ' closeTab(row, i, tabId) {', '\n moveTab(') + ' }; export { close }').close.closeTab + f.splitStore.onPaneContentEdited = (id, paneId) => f.registry.revokePane(id, paneId) + method.call(f.splitStore, 'top', 0, f.splitStore.spec.top[0].tabs[0].id) + assert.equal(f.splitStore.spec.top[0].tabs.length, 0) + await f.apply(b, resultOf(b), true) + assert.equal(f.calls.length, 1) +}) +test('actual new-layout builder keeps both projects empty and independently identified', () => { + const build = evaluate(section(indexSource, 'function buildLayout(', '\n/** 布局缩略图') + '\nexport { buildLayout }', { + PRESET_DEFS: [{ id: 'grid', leftCount: 0, topCount: 2, contentCount: 1 }], newWidgetId, + }).buildLayout + const a = build('grid', 'A'), b = build('grid', 'B') + assert.notEqual(a.id, b.id) + a.top[0].content = { kind: 'iframe', url: 'old.html' } + assert.equal(b.top[0].content, null) + assert.equal(b.main[0].content, null) +}) +test('actual task prompt uses only this project binding paths and preserves follow-up instructions', () => { + const { registry } = registryFixture(), spec = specOf('A') + const owned = registry.begin('A', spec, 'C:/shared', 'S', '窗口1') + registry.begin('B', specOf('B'), 'C:/shared', 'S', '窗口1') + const build = evaluate(section(indexSource, 'function buildWindowTaskText(', '\n/** 自定义窗口任务') + '\nexport { buildWindowTaskText }', { KNOWLEDGE_PACK: 'generic knowledge', widgetManifestPath }).buildWindowTaskText + const prompt = build('A', '项目 A', '窗口1', 'new widget', 'C:/shared', 'send', owned) + for (const b of owned) assert.ok(prompt.includes(widgetManifestPath(b))) + assert.ok(prompt.includes('继续修改同名作品')) + assert.ok(!prompt.includes('project-B')) + assert.ok(prompt.includes('不读写项目根目录旧的 widget-result.json')) +}) diff --git a/AGENTS.md b/AGENTS.md index 6c0804a..bfa9e89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,10 +18,8 @@ 「解包 / 复制 / 实跑用户路径」;「构建成功」「bundle 里有字符串」「退出码 0」不算验收。 - **发布前自跑最终产物清单**:干净目录安装、最终包逐文件核对、双资产哈希、关键行逐字 grep —— 先自查再报告,不等外部复审来抓。 -- **发布包验收=结构检查 + 真实安装,两道都保留**:npm pack 标准格式(package/ 前缀 + files - 白名单精确 7 文件,不含 src/、不含 .map)与「独立目录 npm install + import() 断言」 - 缺一不可。教训:v0.3.0 手工 tar 出无前缀平铺包且误卷源码树,「解包核对(文件都在)」发现不了, - 只有 npm install 复现失败;「文件都在」 ≠ 「消费方(npm/dsh plugin add)能装」。 +- **发布包验收=结构检查 + 真实安装**:标准 package/ 前缀、白名单精确 7 文件(无 src/、.map), + 再独立目录 npm install + import() 断言;仅核对文件存在不能证明包可安装。 - **声明可证伪,不许写满**:验收结论只写已验证范围。「通知全覆盖」「等价端到端」这类表述会被 反向检查打脸——只写「版本比较逻辑上重新激活」「验证了安装与服务端模块导入」。 - **同 tag 资产不可覆盖(用户已拍板)**:坏包处理 = 发新版本号 + 旧 Release 正文标注问题, @@ -34,7 +32,7 @@ - **不替换、不禁用任何官方插件**(ui-sidebar / ui-workspace / ui-layout)。 - 项目/布局/绑定存 localStorage,媒体存 IndexedDB;文件窗、mkdir 与产物握手会读写用户配置的项目目录,权限边界见 `01_content/README.md`。 - dsh-travelatlas 是入驻项目而非本仓库的一部分;协议见 `02_process/PRD.md` §5.3/5.4。 -- **平台边界**:Windows 网页端已测范围见 `01_content/README.md`,不宣称全功能或桌面端验收;macOS 为实验性支持(核心文件路径代码已做跨平台适配, +- **平台边界**:Windows 网页端已测范围见 `01_content/README.md`,不宣称全功能验收;桌面仅列已测 Windows 0.2.0-rc.2 范围;macOS 为实验性支持(核心文件路径代码已做跨平台适配, 尚未真机端到端验证)。路径拼接必须走 `pathutil.ts` helper 或 Node `path` API,不手写分隔符。 ## 构建与验证 @@ -51,7 +49,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 严格结构)→ - 版本一致性 → **会话/输入兼容回归**(test:input / test:session / test:details 共 53 项,评估源码契约)→ 构建(cwd 固定 01_content)→ node --check → npm pack → 结构清单断言(package/ 前缀 + 精确 7 文件, + 版本一致性 → **会话/输入/传输/挂载回归**(test:input / test:session / test:details / test:transport / test:widget 共 98 项,评估源码契约)→ 构建(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; @@ -59,6 +57,7 @@ node --check lib/index.js 脚本零 git/gh 动作,发布上传由 gh 手动完成。 **发布禁止裸 npm pack 或手工 tar 生成发布包**;脚本从仓库任意目录调用均安全(以自身位置解析)。 - **配套检查入口**:`npm run test:gate` = 工厂门禁 10 个失败/正向用例; + `npm run test:widget` = 项目/会话归属、共目录、延迟结果与补挂重试 26 项回归(执行实际函数,不等于模型生成端到端验收); `node 04_test/anchor-dom.test.mjs [lib/client.js 路径]` = 分栏锚点 8 场景(缺省用工作目录构建产物); `node 04_test/server-home.test.mjs [lib/index.js 路径]` = 数据目录解析 3 组场景(子进程隔离夹具; **突变体验证法**:把 loadPkg 兜底的 baseDshHome() 故意改回 resolveDshHomeSafe() 恢复循环,测试必须红)。 @@ -69,9 +68,14 @@ node --check lib/index.js ## 领域约定(会话中必须遵守) +| 实现参考 | 对应材料 | +| --- | --- | +| 控制室 UI、预设/模型、提醒、产物握手、更新状态 | `02_process/PRD.md` §14(修改对应组件时对照源码,不以历史 API 示例替代新旧桥接) | + +- **Desktop 兼容边界**:0.4.0 保留客户端 platform=web(Desktop 0.2.0-rc.2 也按此契约加载)。终端 WebSocket 经 `hostTransport.ts` 使用宿主 `__DSH_TRANSPORT__.streamBaseUrl`,不能由 dsh-app://app 拼 ws://app;更新提示须指向 desktop profile 与桌面端自带 CLI,不能修改 web profile。页面点测与源码回归不能替代完整桌面端业务验收,不宣称桌面全功能兼容。 - **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 项回归,不能替代真实模型会话验收。 +- **0.2 新建/预览/选择接口**:`createHostSession` 必须区分显式未分组(第四参数 `none`)与隐式默认(`auto`)。自定义窗口显式未分组仅传 cwd(无目录则空选项),禁止注册/复用工作区或 initializeDefault;新版控制室空未分组会话输入区禁用,必须提示并阻止创建,由用户选组/加入可用现有会话,不偷偷改组、不发激活消息。现有分组未选 ID 同样拒绝;显式现有/新建组才传 workspaceId。分组列表晚返回不得覆盖用户已选分组。隐式默认保留此前工作区兜底,旧宿主保留原分支,不移动既有会话。预览读临时持有的 eventSource,模型目录在持有期间使用,结束即释放;预设接 remote.agentPresets。`npm run test:details` 为 30 项回归,含两处真实调用接线与延迟默认分组;不能替代真实输入区/模型会话验收。 - **窗口编号**:用户说「窗口1/2/3…」指布局里按「左栏 → 顶行 → 主行」顺序的第 N 个内容窗。 例:田字格预设(g4)窗口1/2 = 顶行左右、窗口3/4 = 底行左右;l13 窗口1 = 顶部大窗, @@ -93,133 +97,19 @@ node --check lib/index.js ~ 与 ~/ 与 ~\ 展开、相对路径按 cwd、默认 ~/.dsh;trim 只用于空白判断、路径保留原字符串。 **不把 dsh-home-paths 声明为生产依赖**(其 peer cordis ^4.0.2 不满足 0.1.1-rc.2 的 4.0.1, 已试过并撤回)。改这块必须跑 server-home 3 组场景 + 突变体验证(恢复循环测试必须红)。 -- **新会话预设修复**:新建会话(createCustomSession / bindConsoleNew)创建后调用 - ensureSessionPreset——用宿主 api.agentPresets.list/select 显式应用「部署默认预设」 - (isDefault ?? 首个,失败逐个尝试其余预设;select 仅对 blank 会话生效)。 -- **新会话模型修复(真根因)**:会话级模型选择独立于预设、随默认选择持久化——用户删掉 - provider 后新会话继承失效选择,prompt 报 model-unavailable。ensureSessionModel: - ① 无条件继承「当前会话」正在用的模型(用户控制用哪个就用哪个,相同则跳过); - ② 无当前会话且新会话不可用时 → 最近会话众数 → 失效选择的家族词匹配 → 目录首个。 - session.selectModel 同时把新选择存为默认(继承的 Pro 会写回默认)。失败静默、缺 API 跳过。 -- **对话绑定**:projects.v1.bindings = { 项目id → 会话id };打开项目时引擎自动 - sessions.open(绑定会话)(openSplit / DOM 桥两处入口);未绑定/解绑 = 不切换。 -- **项目×对话联动**:打开项目记录「打开前会话」(projectAttachRef.sessionId);项目打开期间切到 - 非绑定会话 = 自动关项目(suppressRestoreRef 跳过回切);✕/反选关项目 = 回切「打开前会话」。 - 未绑定项目的归属会话 = 打开前会话。 - **例外**:插件自身发起的会话切换(新建对话 sessions.open(新会话)、发送到会话)不得触发 - 自动关项目——createCustomSession/sendCustomToSession 用 markPluginSessionOpen 豁免 - (pluginOpenedSessionsRef),用户要继续在项目里跟新对话沟通;同时 CustomPane 在发送成功后 - 调用 autoBind:项目未绑定则自动绑定到新建/选中的会话。 -- **项目文件夹**:projects.v1.folders = { 项目id → 绝对路径 };新建项目强制填写(父目录必填, - 文件夹名留空 = 用项目名),保存时走 /api/worktable/mkdir 建目录;绑定面板可改。自定义窗口 - 新建会话(未选分组时)用 sessions.create({cwd: 项目文件夹}),提示词携带文件夹与「所有产出 - 放进该文件夹」指令——用户要求项目产出文件不得落到默认位置。 -- **窗口任务提示词**:buildWindowTaskText 统一组装(窗口身份「项目+窗口N」+ 项目文件夹 + - 插件知识包);知识包注明「不要重新侦察插件源码」,改提示词时保持这个原则。 -- **自动挂载(widget-result.json 握手)**:提示词第 5 条要求 agent 完成后用一句话 - 告知用户挂载结果(不提问、不等确认,例:「已自动挂到窗口N,想调整直接说」); - 第 6 条要求 agent 完成后在项目文件夹写 - widget-result.json {window:'窗口N', path, kind:html|url|file};客户端监听绑定会话 completed → - buildMountContent 转换产物 → 项目开着直接 openTab 进「窗口N」(windowLabelToPane 按窗口 - 编号规则定位),项目没开暂存 pendingMountRef(localStorage,打开项目时补挂); - mountConsumedRef 保证一次完成只消费一次。 - **锁死语义**:挂载用 splitStore.lockPane——清空该窗格原有标签,把产物作为唯一固定标签 - (active 0),onSpecMutated 立即持久化;用户下次打开工作台窗口直接显示产物,不丢失不重置。 - 待挂载记录带 {content,row,index},补挂同样 lockPane。 -- **原生皮肤模板**:01_content/template/dshell.css + dshell.html(esbuild text loader 嵌入服务端 - bundle,/api/worktable/template 路由下发);知识包要求产出 HTML 一律引用该样式表,组件类 - 参考模板。新增组件样式只加到 dshell.css,保持单一来源。 +- **新会话策略**:创建后应用默认预设与有效模型;桥接保持原选择策略,持有结束即释放。失败不重复发送;插件导航须 markPluginSessionOpen,未绑定项目成功任务会 autoBind。 +- **项目文件夹**:保存要求绝对路径,mkdir 非 2xx 必须中止;自定义任务带项目 cwd,产出落该目录。 +- **窗口任务**:buildWindowTaskText 统一携带窗口身份/项目目录/通用知识;不重复侦察插件源码。 +- **产物握手 v2**:目录不是项目身份。`widgetMount.ts` 校验 projectId/paneId/bindingId,禁止扫旧根清单或回退窗口1。提示词与完成读取限本会话;补挂重读并复验归属,成功后才清待办。手工关闭/替换撤销关联,追改可复用,完成不证明成功。协议见 PRD §14。 +- **原生皮肤**:template/dshell.css + dshell.html 为单一来源,经 /api/worktable/template 下发;新增样式只加模板。 - **提示词零泄漏(硬约束)**:所有对外生成的提示词(buildWindowTaskText 窗口任务提示词、 buildCustomLayoutPrompt 剪贴板布局提示词)禁止写入用户的个人工作区分组名(如 Projects / DeepseekHarness)、其他用户的项目名与私人路径。剪贴板提示词会发给别人的 DSH,必须只含 插件通用知识;窗口任务提示词只发用户自己的会话,允许携带该项目自己的文件夹路径。 分组下拉只是会话创建工作区的选择,绝不进入任何提示词文本。 -- **任务完成/待决提醒镜像**:绑定会话在宿主快照 byId 里 completed=true → 项目卡双圆点变 - 绿色发光(data-bound=done);pendingInteraction != null → 黄色发光(data-bound=need); - 点开项目 = ack(notifyAck.v1 按会话存状态)恢复常态实心。数据源 = sessionsSnapshotStore - (syncSessionScope 写入完整快照并通知监听者);跨状态(done↔need)会重新点亮。 - **工作中(busy)**:byId[sid].running === true → data-bound=busy,蓝色 #4f8ef7 发光 + - dsh-wt-busyA/B 关键帧两圆交替亮灭(对应 DSH 转圈标记);优先级 need > done > busy——等待判断时 pendingInteraction 与 running 同时为真, - 原生 UI 以黄点优先,镜像必须一致;busy 无需 ack,running 变 false 自动切换。 - **子代理聚合**:待决状态常挂在子代理会话上(父会话只有 running)——bindNotifyMap 用 - collectKids(byId.parentId + subagentsByParent 双通道)聚合父会话及其子代理的 pending; - 会话面 binding(id).session.getSnapshot().pending 非空也判 need(列表不映射时的兜底); - ackProjectNotify 同步 ack 子代理。 - **ack 生命周期**:新版按父/子会话的 opaque pending key+kind 集合保存 `need:...`,不保存问题正文/答案; - 同为 need 的问题替换也重新点亮,排序/重复目录不触发误亮。旧无 key 主机继续存 need,无法识别无 key 的直接替换。 - 状态/身份转移清旧 ack 时同步清除本次读取副本;点开确认同时记录已见身份,防下一次渲染反清刚保存的 ack。 -- **「工作台」控制室项目(默认自带)**: - - 固定 id `wt-console`(CONSOLE_ID),卡片恒排项目列表第一位(order 0)、不可删除 - (不进设置管理列表 + removeProject 兜底拒绝);图标 🖥️,名称走 locale console.name。 - - 点开:已绑定 → openConsole(默认布局 buildConsoleSpec:单一大窗格 content - {kind:'builtin',type:'console'} + 右侧对话,spec 持久化在 views['wt-console']); - 未绑定 → 强制绑定弹窗(左「加入现有对话」列表 / 右「新建对话」:分组 无/现有/新建, - 建空会话 sessions.create 后自动绑定并打开控制室)。绑定也走 projects.v1.bindings。 - - 控制室面板(split.tsx ConsolePane):卡片网格每行 3 张、超出换行;每卡 = 图标/名称/ - 状态大字与三色光效(need>done>busy>idle,不过滤 ack,永远显示事实状态)/运行时长 - (后台任务 JobView.startedAt → 新版 uiConversation 的 chat.legacy.turnTimings → 旧会话面, - 未加载/无起点不显示时长;不为计时激活 chat 或持有冷会话)/最近消息预览。数据组装 - = index.tsx getConsoleCards(env.console 注入),刷新走 consoleListeners(项目/会话 - 快照变化推送)+ 面板每秒 tick。 - - 卡片动作:点卡片 = 打开该项目(openSplit 或入驻项目切绑定对话);工作台自己的卡片 - 点击无操作。💬 跳转按钮已删除(用户定案无意义)。 - - 主题:面板三选一开关(图标按钮 🌙/☀️/🖥️,title/aria 保留文字;存 view.v1 consoleTheme); - system 读宿主 html 的 color-scheme(DSH 深色/白色/跟随系统设置都会反映到它)+ - prefers-color-scheme 兜底;落成 .dsh-wt_console[data-wt-theme=dark|light] 作用域变量 - --wt-*(宿主不发布 --dsw-alias-*,工作台全站一直靠回退色渲染——控制室自带主题作用域, - 不受其影响)。 - - 状态光效(整卡霓虹描边,参考侧栏双圆点发光质感):工作=蓝色彗星式光点顺时针绕卡旋转 - (.dsh-wt_consoleCard-busy ::before conic-gradient + @property --consoleAngle + - consoleAngleSpin;环 inset -4/padding 4、高亮段 #dcebff→#9cc6ff、filter drop-shadow - rgba(140,190,255,.85) 光点自带辉光、外发光双层 10px+34px);完成=绿光、待决=黄光 - (-glowDone/-glowNeed:亮色描边 + 双层外发光 + 微弱内辉光;glow 字段 = done/need 且 - 本轮未 ack,点卡片先 onAck 熄光再进入,与提醒 ack 生命周期一致)。 - - 命名:侧栏区块标题 = 「工作台」(title locale,整个插件);默认项目卡名与面板标题 = - 「控制室」(console.name/console.title locale,工作台的控制室)。 - - 控制室标签不可关:PaneBody 对 content.type==='console' 的标签 locked(不渲染 ✕、 - 禁拖拽)——关掉会退化成窗格选择器,不可逆。 - - 布局尺度:网格 gap 64px(4 倍间距)不变、max-width 856px 左右居中;卡片 1:1(面积 - 2×边长 1.4,实测 236px:名字 20/状态 22/预览 8.5 四行截断);标题下横向分隔线;无子代理 - 徽章、无 💬 跳转按钮;网格最后一位恒为「创建卡片」(虚线+)→ openAddPanel;入口卡无描边。 - - 背景三选一(data-wt-bg):纯色/流光/自定义,各自独立记忆——色相/饱和度/明度(-180..180/0..200,纯色随主题默认:深 #0a0d13、浅 #eef1f5)、 - 贴片模糊 B(0-20;预设 纯色0/流光8/自定义8)、网格线不透明度 T(0-30;纯色/流光各自)、 - 流光速度 S(0-100,0=完全不动,默认50=原速,consoleGlowSpeed;--wt-glowScale=50/速度;恢复初始一并复位)、 - 照片网格开关(consoleBgPhotoGrid)+ 网格透明度(consoleBgGridOpacity);卡片 glass 底含 backdrop-filter blur(var(--wt-cardBlur))。 - - 自定义背景 = 媒体库(照片+视频):IndexedDB photoRecords(id/createdAt/kind/blob/order); - 首用预置两张默认图(defaultBg.ts SVG 极光 + waveBg.ts JPEG 181KB,标记 defaultBgSeeded.v1); - 缩略图类型角标/全行删除/抓手拖拽(槽位制+FLIP,photoStore.reorder);视频背景双轨首尾帧交叉渐融(ConsoleVideo ≥1.2s 最长2s、备轨就绪才淡入)。 - - 底部操作台:5 个 ghost 按钮玻璃 dock(主题/形状/背景/每行数量/更新公告);菜单点选保持打开、点空白关闭; - 下拉宽度 = dock 总宽 186px(媒体宽版 264px)。 - - 顶部标题克制:控制室页只保留侧栏入口卡一个「控制室」——分栏标题栏对 wt-console - 不渲染 title(保留 ⇄/✕)、PaneBody singleConsole 不渲染标签栏;控制项集中在底部操作台 5 键。 - - 更新公告(第 5 键,反选切换大磁片替代网格视图):顶部=当前版本/检查更新/自动检查开关; - 新版本横幅=复制升级指令(点击变绿并提示「请在任意对话中发送」)/查看发布页(蓝 hover)/忽略此版本(红 hover)+ - CHANGELOG_V030 正文(changelog.ts 纯文本,不做 md 渲染); - 检查核心 = updateCheck.ts(updateCache.v1 缓存,与设置面板旧更新卡共用 lastUpdateCheck/skipVersion/updateCheck 键, - 旧卡暂保留未去重);铃铛按钮发现新版本时右上角琥珀呼吸灯(dockBadge)。 - - 指哪打哪标注:窗格折叠键旁 annotBtn——蓝泡光标→点选/拖框(小拖=点)→输入→✓ 注入宿主 textarea(不发送,失败回退剪贴板)。 - payload v3.2:窗口身份(编号+窗格标题+内容类型/URL)+ 主目标(caretPositionFromPoint 取字 + computed style 字号)+ 整行 + 候选; - 同源 iframe 下钻取字(boxPayload),跨域输出「读取受限」+ src;提示词含「禁止编造,缺失时如实说明,建议截图或视觉模型」; - 知识包含标注协议行(窗口编号+处理方式);回退锚点 tag:pre-annotate-v3 / pre-annotate-v3.1 / pre-annotate-v3.2。 - - 分隔线:DIVIDER=4(分栏可拖分隔条更细)。 - - 冷会话消息预览:0.2 用 sessions.using 临时持有后读 eventSource 的已加载事件窗口;旧版预热走 face.history({maxMessages:6}) - (旧版运行期内建方法、非公开接口);尾部扫 user/message 与 assistant/message 的 - text 块 → cleanPreviewText(滤除 ```围栏与行内代码、压缩空白;不足 8 字符回退更早消息)→ - previewCache;sweepPreviews 在打开控制室时 + 控制室开着且会话快照变化防抖 6s 触发; - 失败静默回退缓存/内存路径 lastTextOf;预热期间不因自身 retain/release 排入新一轮。拉取是带宽成本不是 Token 成本。 - - 状态指示:卡片右上角小圆点已删(整卡光效表达状态);状态计算不变。 -- **更新检查(v0.2.2)**:客户端直连 GitHub Releases API 比版本(只读 GET;自动每天最多 - 一次,手动「立即检查」绕过节流;单次 8s 超时(AbortController)+ 最多 3 次重试, - in-flight 防重入、检查中按钮禁用、组件卸载后停止重试与状态更新;失败后状态行显示「上次检查未成功」)。 - 状态四态 idle/checking/uptodate/failed;徽标 = 「工作台」标题右侧琥珀呼吸小圆(SVG 同步 - 图标、不显示版本号),仅发现更新时出现;更新卡在设置面板顶部(复制 AI 提示词 + 忽略此 - 版本 + 命令框供终端用户手抄),版本号与自动检查开关在面板底部;设置弹窗右上角 ✕ 关闭、 - 底部防溢出钳制(POP_BOTTOM_MARGIN=12,贴底后向上生长;按钮与开关必须平级防冒泡)。 - localStorage 键 lastUpdateCheck.v1 / skipVersion.v1 / updateCheck.v1(控制室第 5 键公告页共用,另缓存 updateCache.v1)。 - **发布纪律**:改动≠发布,只有 tag+Release 才触发提醒;新版本 = 新 tag + 新 Release,每个 - Release 必须同时附固定名资产 `dsh-worktable.tgz`(供 releases/latest/download 永久链接) - 与版本化资产。版本注入:build.mjs 把 package.json version 打进 __WT_VERSION__,发版前 - 改 version 再构建;升级动作(执行 add + 重启)永远留给用户或其 Agent,插件不自更新。 +- **提醒红线**:need > done > busy,聚合父/子会话。ack 只存 opaque key/kind,不存正文;身份更换重新亮,读取副本与持久化同步清理,确认先记已见身份;旧无 key 退路无法识别直接替换。 +- **控制室红线**:wt-console 固定首位、不可删除、唯一 console 标签不可关;隐藏项目不入网格。状态/计时/预览只镜像宿主,不为计时激活冷会话,不把完成标记当成功证明;业务临时持有结束释放。 +- **更新检查/发布**:设置与控制室检查暂未去重,共用 updateCheck/lastUpdateCheck/skipVersion.v1,updateCache.v1 仅控制室。单次 8s、最多 3 次尝试;失败不可报最新。版本从 package.json 构建注入;新版本需 tag+Release,双资产固定名 dsh-worktable.tgz + 版本化名同 SHA。插件仅提示不自升级,安装/重启留给用户。 ## 旧版 link 加载兼容边界 @@ -237,5 +127,6 @@ node --check lib/index.js - 注册:`dsh plugin --profile web add "link:/01_content"`(写 ~/.dsh,需用户授权)。 - 发布版安装(给用户):`dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"`(依赖每个 Release 的固定名资产)。 -- bundle 层只在启动时组合:改动后必须重启 dsh web 并刷新 GUI。 +- Desktop:用实际安装目录 resources/runtime/cli/bin/dsh.cmd 对 desktop profile 安装;不得以网页 CLI 或不固定 npx 替代。安装前用户完整退出,安装后用户手动重开。 +- bundle 启动时组合:网页重启 dsh web 并刷新;桌面完整退出/手动重开。不得自动重启正式宿主。 diff --git a/README.md b/README.md index 97f598a..757a18c 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ ## 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. +**v0.4.0** adds Windows official Desktop support within the tested scope below. Web compatibility lists only DSH **0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2**; Desktop checks cover **Windows + 0.2.0-rc.2**. Desktop page checks and targeted regressions do not establish full final-package Desktop end-to-end acceptance. Older versions rely on prior page checks plus code regressions, not a newly repeated full GUI suite. Back up important data and check other plugins before upgrading; GitHub Release is the publication authority. 1. **Install** (pick one): @@ -76,6 +76,18 @@ v0.3.4 declares compatibility with DSH Web **0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc. 3. **Open the control room**: click the pinned 🖥️ control-room card → bind one conversation (join existing or create new) → you get the live card grid 4. **Create projects**: sidebar + → pick a layout preset, set a project folder +### Windows official Desktop + +Save your tasks, fully exit the app from its menu/tray (closing the window is not enough), replace the installation-directory placeholder, run in PowerShell, then reopen the app manually: + +```powershell +& "\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.4.0/dsh-worktable-0.4.0.tgz" +``` + +Use the Desktop-bundled CLI and `desktop` profile, not an unpinned npx or Web CLI. Preserve the actual DSH_HOME. Browser-local projects/layouts/media do not automatically sync between Web and Desktop origins, even when sessions share one data home. + +Explicit **Ungrouped** in a custom-window task no longer silently joins a workspace. For a **blank control-room conversation**, the tested DSH 0.2 host disables the ungrouped composer: choose a group or join a usable existing conversation. The plugin explains this and blocks that blank creation, without changing your choice or sending an activation message. An empty existing-group selection cannot silently default either; legacy ungrouped creation stays unchanged. + --- ## Architecture @@ -85,7 +97,7 @@ One package ships the **host Cordis plugin** and the **web client**: - **host**: `/api/worktable/*` routes — health, file system, git, file read/write, site serving, mkdir, workspaces, native skin template; WebSocket `/api/worktable/term` for the terminal pane (PowerShell on Windows) - **client**: injected into the sidebar and the shell overlay via the slot protocol; the split engine, tab model, drag/drop and persistence are self-built - **control room**: reads the host session list snapshot (running / pending / completed, jobs, subagent catalogs) — an event-driven mirror, no model involvement -- **window tasks**: the agent writes `widget-result.json` into the project folder on completion; the client mounts the artifact into the addressed window and locks it +- **window tasks**: custom tasks register project/pane/binding ownership and write a dedicated result under `.dsh-worktable/project-/`; sharing a folder does not make a new project inherit another project's windows --- @@ -102,7 +114,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. +- Release packaging uses `npm run pack` only. It also runs 98 input/session/details/transport/widget regressions, installation and client-factory gates. `npm run test:widget` covers 26 ownership, session-isolation, shared-folder, late-result and retry cases; after uploading, run `npm run verify:remote -- --expect-sha v0.4.0` and check `latest` separately. --- @@ -115,7 +127,7 @@ Projects, bindings and layouts live in browser localStorage; media lives in Inde **Case A: Harness works, only the worktable needs updating** - Open the worktable "Settings" → click "Check now"; when the amber update badge appears next to the worktable title, click it and choose "Copy AI prompt" to hand the upgrade to your AI assistant; -- Or simply re-run the install command (always installs the latest), then restart dsh web and refresh: +- For Web, re-run the install command (installs the latest published release), then restart dsh web and refresh. Desktop users must follow the Desktop instructions above: ```bash dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz" @@ -139,10 +151,10 @@ Every window title bar has a small **annotate button** (chat-bubble with a plus) ## Known limits -- **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. +- **Platform**: Windows Web and the official Windows Desktop have the version/flow limits above. Full final-package Desktop end-to-end acceptance is not complete; macOS 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 +- Auto-mount requires a registered custom task and its matching v2 result file. Old saved windows remain; unowned root-level `widget-result.json` files are not imported. To re-enable an old conversation, send one task via the target window's Custom → Send to conversation action. Later edits in that conversation may reuse the binding. Manually closing/replacing content revokes it; existing mistakenly mounted tabs must be closed once. Shared-folder projects can still overwrite the same artifact if explicitly told to write the same physical file. - The control room monitors projects that are **bound** to a conversation; unbound projects show as idle --- diff --git a/README.zh.md b/README.zh.md index 6580b72..9469f5e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -53,7 +53,7 @@ ## 快速开始 -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。官方桌面端尚未验证。升级宿主前请备份重要数据,并核对其他插件的兼容情况。 +**v0.4.0** 在下述已测范围内增加 Windows 官方桌面端支持。网页端兼容声明仅列 **DSH 0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2**;桌面已测范围为 **Windows 官方桌面端 + 0.2.0-rc.2**。桌面页面点测与定向回归不等于最终包完整桌面端业务验收;旧版沿用此前页面验收并补代码回归,未重跑整套旧版 GUI。升级前备份重要数据并核对其他插件,公开发布状态以 GitHub Release 为准。 1. **安装**(二选一): @@ -76,6 +76,18 @@ v0.3.4 网页端兼容声明仅列 **DSH 0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2** 3. **打开控制室**:点击固定首位的 🖥️ 控制室卡片 → 绑定一条对话(加入现有或新建)→ 得到实时卡片网格 4. **创建项目**:侧边栏 + → 选布局预设、填项目文件夹 +### Windows 官方桌面端 + +先保存任务,从应用菜单/托盘完整退出(只关窗口不够);将安装目录占位符替换为实际位置,在 PowerShell 执行,再手动重新打开: + +```powershell +& "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.4.0/dsh-worktable-0.4.0.tgz" +``` + +必须使用桌面端自带 CLI 与 `desktop` profile,不用不固定版本的 npx 或网页 CLI 代替。保留实际 DSH_HOME;即便共用会话数据目录,网页与桌面来源的项目/布局/媒体也不会自动同步。 + +自定义窗口明确选「未分组」时不再偷偷加入宿主分组。控制室新建的是**空对话**:已测 DSH 0.2 的未分组空对话不能使用原生输入框,因此插件提示并禁用这一创建,请自行选分组或加入可用的现有对话;不自动改组、不发送激活消息。现有分组未选具体项时也不退回默认分组,旧宿主未分组创建行为保持。 + --- ## 架构 @@ -85,7 +97,7 @@ v0.3.4 网页端兼容声明仅列 **DSH 0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2** - **宿主**:`/api/worktable/*` 路由——健康检查、文件系统、git、文件读写、站点托管、mkdir、工作区、原生皮肤模板;WebSocket `/api/worktable/term` 提供终端窗格(Windows 下为 PowerShell) - **客户端**:经 slot 协议注入侧边栏与 shell overlay;分栏引擎、标签模型、拖拽与持久化均为自研 - **控制室**:读取宿主会话列表快照(运行中/待决/已完成、后台任务、子代理目录)——事件驱动镜像,模型不参与 -- **窗口任务**:agent 完成后在项目文件夹写 `widget-result.json`,客户端把产物挂载进指定窗口并锁死 +- **窗口任务**:「自定义」任务登记项目/窗格/绑定身份,agent 在 `.dsh-worktable/project-<项目 ID>/` 下写专属结果;新项目不因共用文件夹而继承其他项目的窗口内容 --- @@ -103,7 +115,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。 +- 发布打包唯一入口为 `npm run pack`,另含 98 项输入/会话/详情/传输/挂载回归、独立安装与客户端工厂门禁;`npm run test:widget` 单独运行 26 项归属、会话隔离、共目录、延迟结果与重试回归。上传后用 `npm run verify:remote -- --expect-sha <最终 SHA> v0.4.0` 核对,并另查 latest。 --- @@ -116,7 +128,7 @@ node --check lib/index.js **情况 A:Harness 正常,只是工作台需要更新** - 打开工作台「设置」→ 点「立即检查」;侧栏「工作台」旁出现琥珀色更新徽标时,点它选择「复制 AI 提示词」,把那段话发给你的 AI 助手执行即可; -- 或直接重跑安装命令(永远装最新版),装完重启 dsh web 并刷新: +- 网页端可重跑安装命令(安装最新已发布版本),装完重启 dsh web 并刷新;桌面用户须用上方桌面安装步骤: ```bash dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz" @@ -140,10 +152,10 @@ node --check lib/index.js ## 已知限制 -- **平台**:Windows 网页端已测范围见上方版本与流程说明;官方桌面端未验证,macOS 为实验性支持且尚未真机端到端验证。 +- **平台**:Windows 网页端与官方桌面端的已测版本/流程见上方说明,最终包完整桌面端业务验收仍未完成;macOS 为实验性支持且尚未真机端到端验证。 - 状态在浏览器本地(localStorage 与 IndexedDB)——项目、绑定、视图与媒体不跨设备同步 - 终端窗格在 Windows 上是朴素的 PowerShell 宿主(与原生终端应用无 PTY 对等) -- 自动挂载要求 agent 确实在项目文件夹写出 `widget-result.json` +- 自动挂载要求已登记「自定义」任务且写出身份匹配的 v2 结果文件。旧已保存窗口保留,根目录旧 `widget-result.json` 不再导入;旧对话可从目标窗口「自定义 → 发送到会话」发一次任务建立新关联,随后在同一对话直接继续修改。手动关闭/替换内容会撤销关联,此前误挂的标签需关闭一次。同目录项目若主动写同一个实际文件,文件内容仍可能互相影响。 - 控制室只监控**已绑定对话**的项目;未绑定的项目显示为空闲 ---