diff --git a/00_index/README.md b/00_index/README.md index 59595d9..0eeea5f 100644 --- a/00_index/README.md +++ b/00_index/README.md @@ -2,7 +2,7 @@ - [README](../README.md) — 项目概述与目录结构 - [PRD](../02_process/PRD.md) — 需求与设计(含接手分界线 §11) -- [兼容与安全](../docs/compatibility-and-security.md) — 当前 0.5.0 范围、接口与隔离限制 +- [兼容与安全](../docs/compatibility-and-security.md) — 当前 0.5.1 范围、接口与隔离限制 - [AGENTS](../AGENTS.md) — 项目规则 - [01_content](../01_content) — 插件包 - [02_process](../02_process) — 过程文档 diff --git a/01_content/README.md b/01_content/README.md index d907c0d..28eb0e0 100644 --- a/01_content/README.md +++ b/01_content/README.md @@ -17,13 +17,25 @@ - 状态监控为宿主会话运行时快照的事件订阅镜像;视图/项目/绑定状态存 localStorage。 - 客户端:TypeScript + React(host externals)+ 原生 CSS;服务端:Node。 -## 0.5.0:兼容与安全说明 +## 0.5.1:rc.5兼容、控制室与更新提醒修复 + +公开状态以 GitHub Release 为准。 + +- 兼容范围仅列 **DSH 0.2.0-rc.2 / 0.2.1-rc.3 / 0.2.1-rc.5**。新增 rc.5 的精确兼容声明,修复宿主因原版本声明不匹配而跳过加载;不通配放开未测宿主。旧 0.1.x 不在本版支持范围,macOS 尚未真机验证。 +- 会话停止统一显示中性的“本轮已结束”,不把失败或停止当作成功;正在运行优先于旧结束标记。卡片摘要只取可读助手答复,过滤注入上下文及模型替换副本,无答复时留空。 +- 设置与控制室共用版本排序,只有确定高于本机且未忽略的版本才显示升级卡;正式版高于同号测试版,构建元数据不参与排序。缓存读取重验版本,检查失败或无更新清掉旧提示,无效远端版本报告检查失败,不引导降级。 +- rc.5 已有使用 Desktop 自带运行时的隔离网页/浏览器检查;候选已安装官方 Windows Desktop,用户确认面板恢复、更新误报消失、看起来正常。正式包另做独立安装与自动回归,不能将这些证据称为正式包双端全功能验收。更早两版沿用此前页面证据与当前代码回归。 +- 沿用 0.5.0 的权限、网页安全隔离、传输与项目存储边界,不迁移、不清空用户数据。本版不包含宿主旧会话 HTTP 400 的修复,也不改变 Session Log 上传设置。 + +已知限制:rc.5 输入框中已有确认的 /命令标签时,标注追加可能退回剪贴板。新旧宿主的命令坐标表示不同,本版保留原安全降级,不以未验证的能力猜测影响旧宿主。 + +## 0.5.0:安全基础与历史验收说明 公开发布状态以 GitHub Release 为准。 - HTTP 文件/目录/写入/Git/工作区与终端握手先通过宿主身份验证,仅放行 owner(0.2.0 使用单 operator 身份);缺少可确认身份的服务时返回 503,不降级开放。health 仍可公开读取状态。以前直接用无登录 curl 调用这些接口的方式将被拒绝;不提供跳过鉴权开关。 - 候选 dev.1 已在 Windows 隔离数据目录验证 **DSH 0.2.0-rc.2 / 0.2.1-rc.3 网页端**的冷启动、加载、分栏、文件与网页资源;0.2.1 另测创建/写入、控制室绑定和终端,未发送模型请求。用户已在官方 Windows Desktop 0.2.1-rc.3 验证 dev.2 的项目打开、分栏缩放与侧栏设置弹窗正常;前轮新项目也已点测。正式包另做安装与回归检查,这些证据不等于双端全功能验收。 -- 本版的 `engines.dsh` 与兼容矩阵只列上述两个 0.2 版本。旧 0.1.x 不在本版支持范围;下方 v0.4.0 的记录是历史范围,不代表 0.5.0 继续支持旧宿主。 +- 0.5.0 当时的 `engines.dsh` 与兼容矩阵只列上述两个 0.2 版本;0.5.1 的当前范围以上节为准。旧 0.1.x 不在本版支持范围;下方 v0.4.0 的记录是历史范围,不代表 0.5.0 继续支持旧宿主。 - 折叠侧栏与展开侧栏共用一份弹窗层,使控制室新建项目、绑定、图标与删除弹窗不因侧栏收起而消失,避免重复渲染。 - 修复分栏 margin 的小数值被浏览器规范化后误判为外部改写,以及会话根更换后观察器仍盯着旧元素的问题。 @@ -84,7 +96,7 @@ 先保存任务,从应用菜单/托盘完整退出(只关窗口不够);将占位路径替换为实际桌面端安装目录,在 PowerShell 执行,再手动重新打开应用: - & "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.5.0/dsh-worktable-0.5.0.tgz" + & "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.5.1/dsh-worktable-0.5.1.tgz" 不要以不固定版本的 npx 或网页端 CLI 代替桌面端自带 CLI;保留实际 DSH_HOME,勿把测试数据目录覆盖正式数据。新建控制室管理对话时,DSH 0.2 用户请选择分组,或直接绑定可用的现有对话。 diff --git a/01_content/dsh.plugin.json b/01_content/dsh.plugin.json index 79613df..aeb3b16 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.5.0", + "version": "0.5.1", "entry": { "name": "dsh-worktable", "inject": [] diff --git a/01_content/lib/client.js b/01_content/lib/client.js index e7ab575..9ff5fb9 100644 --- a/01_content/lib/client.js +++ b/01_content/lib/client.js @@ -7951,9 +7951,9 @@ var css = xterm_default + "\n" + [ ".dsh-wt_bindBtn{position:absolute;right:26px;top:50%;transform:translateY(-50%);width:22px;height:22px;margin:0;display:block;border-radius:6px;color:var(--dsw-alias-label-secondary,#9aa4b2);cursor:pointer;user-select:none}", ".dsh-wt_bindBtn:hover{background:var(--dsw-alias-fill-l2,rgba(255,255,255,.09));color:var(--dsw-alias-label-primary,#e6e8eb)}", ".dsh-wt_bindBtn[data-bound=true]{color:var(--dsw-alias-label-primary,#e6e8eb)}", - // 任务完成提醒镜像:绿色发光 / 待决提醒:黄色发光(点击项目即 ack 恢复常态实心); + // 本轮结束提醒:中性发光 / 待决提醒:黄色发光(点击项目即 ack 恢复常态实心); // 工作中:蓝色发光 + 两圆交替亮灭(对应 DSH 对话的转圈标记,无需 ack,running 变 false 自动停) - ".dsh-wt_bindBtn[data-bound=done]{color:#3fb950}", + ".dsh-wt_bindBtn[data-bound=done]{color:#94a3b8}", ".dsh-wt_bindBtn[data-bound=need]{color:#d29922}", ".dsh-wt_bindBtn[data-bound=busy]{color:#4f8ef7}", "@keyframes dsh-wt-busyA{0%,44%{opacity:1}50%,94%{opacity:.12}100%{opacity:1}}", @@ -8332,12 +8332,12 @@ var css = xterm_default + "\n" + [ ".dsh-wt_consoleStatus{flex:none;font-size:22px;font-weight:700;line-height:30px;color:var(--wt-text2)}", ".dsh-wt_consoleStatus-busy{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}", ".dsh-wt_consoleStatus-need{color:var(--dsw-alias-state-warning,#d29922)}", - ".dsh-wt_consoleStatus-done{color:var(--dsw-alias-state-success,#3fb950)}", + ".dsh-wt_consoleStatus-done{color:var(--wt-text2)}", ".dsh-wt_consoleRuntime{flex:none;font-size:17px;font-weight:600;line-height:24px;color:var(--wt-text2);font-variant-numeric:tabular-nums}", ".dsh-wt_consolePreview{display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;font-size:8.5px;line-height:13px;color:var(--wt-text2);word-break:break-word}", ".dsh-wt_consolePreviewNone{color:var(--wt-text3);font-style:italic;-webkit-line-clamp:1}", - // 三色光效:整卡霓虹描边光晕(像侧栏双圆点的发光质感)——完成=绿、待决=黄;工作=蓝色描边顺时针旋转 - ".dsh-wt_consoleCard-glowDone{border-color:rgba(63,185,80,.9);box-shadow:0 0 8px rgba(63,185,80,.5),0 0 26px rgba(63,185,80,.3),inset 0 0 16px rgba(63,185,80,.07)}", + // 结束只用中性提醒光,不作为成功证明;待决=黄,工作=蓝色描边顺时针旋转 + ".dsh-wt_consoleCard-glowDone{border-color:rgba(148,163,184,.8);box-shadow:0 0 8px rgba(148,163,184,.35),0 0 26px rgba(148,163,184,.2),inset 0 0 16px rgba(148,163,184,.05)}", ".dsh-wt_consoleCard-glowNeed{border-color:rgba(210,153,34,.95);box-shadow:0 0 8px rgba(210,153,34,.5),0 0 26px rgba(210,153,34,.3),inset 0 0 16px rgba(210,153,34,.08)}", '@property --consoleAngle{syntax:"";initial-value:0deg;inherits:false}', ".dsh-wt_consoleCard-busy{box-shadow:0 0 10px rgba(94,160,255,.55),0 0 34px rgba(79,142,247,.4)}", @@ -8503,7 +8503,7 @@ var zh = { "bind.titleUnbound": "\u7ED1\u5B9A\u5BF9\u8BDD\uFF08\u672A\u7ED1\u5B9A \u25CB\u25CB\uFF09\uFF1A\u70B9\u51FB\u9009\u62E9\u8981\u8DDF\u968F\u7684\u5BF9\u8BDD", "bind.titleBound": "\u5DF2\u7ED1\u5B9A\u5BF9\u8BDD\uFF08\u25CF\u25CF\uFF09\uFF1A\u70B9\u51FB\u66F4\u6362\u6216\u89E3\u7ED1", "bind.tipUnbound": "\u672A\u7ED1\u5B9A\u5BF9\u8BDD\uFF1A\u70B9\u51FB\u9009\u62E9\u8981\u8DDF\u968F\u7684\u5BF9\u8BDD", - "bind.tipDone": "\uFF08\u4EFB\u52A1\u5DF2\u5B8C\u6210\uFF0C\u70B9\u51FB\u9879\u76EE\u67E5\u770B\uFF09", + "bind.tipDone": "\uFF08\u672C\u8F6E\u5DF2\u7ED3\u675F\uFF0C\u70B9\u51FB\u9879\u76EE\u67E5\u770B\uFF09", "bind.tipBusy": "\uFF08\u5BF9\u8BDD\u6B63\u5728\u5904\u7406\u4EFB\u52A1\u2026\uFF09", "bind.tipNeed": "\uFF08\u9700\u8981\u4F60\u7684\u51B3\u5B9A\uFF0C\u70B9\u51FB\u9879\u76EE\u5904\u7406\uFF09", "bind.folder": "\u9879\u76EE\u6587\u4EF6\u5939", @@ -8559,9 +8559,9 @@ var zh = { "console.idle": "\u7A7A\u95F2", "console.busy": "\u5DE5\u4F5C\u4E2D", "console.need": "\u5F85\u4F60\u51B3\u5B9A", - "console.done": "\u5DF2\u5B8C\u6210", + "console.done": "\u672C\u8F6E\u5DF2\u7ED3\u675F", "console.kids": "\u5B50\u4EE3\u7406", - "console.noPreview": "\u6682\u65E0\u6D88\u606F", + "console.noPreview": "\u6682\u65E0\u52A9\u624B\u56DE\u590D", "console.unboundShort": "\u672A\u7ED1\u5B9A\u5BF9\u8BDD", "console.jump": "\u8DF3\u8F6C\u5230\u7ED1\u5B9A\u5BF9\u8BDD", "console.empty": "\u8FD8\u6CA1\u6709\u9879\u76EE", @@ -8736,7 +8736,7 @@ var en = { "bind.titleUnbound": "Bind conversation (unbound \u25CB\u25CB): pick the conversation to follow", "bind.titleBound": "Conversation bound (\u25CF\u25CF): click to change or unbind", "bind.tipUnbound": "No conversation bound \u2014 click to pick one", - "bind.tipDone": " (task completed \u2014 click the project to view)", + "bind.tipDone": " (run ended \u2014 click the project to view)", "bind.tipBusy": " (conversation is working on the task\u2026)", "bind.tipNeed": " (needs your decision \u2014 click the project to handle)", "bind.folder": "Project folder", @@ -8792,9 +8792,9 @@ var en = { "console.idle": "Idle", "console.busy": "Working", "console.need": "Needs you", - "console.done": "Done", + "console.done": "Run ended", "console.kids": "subagents", - "console.noPreview": "No messages yet", + "console.noPreview": "No assistant reply yet", "console.unboundShort": "No conversation bound", "console.jump": "Jump to bound conversation", "console.empty": "No projects yet", @@ -9020,7 +9020,23 @@ function SandboxedFrame(props) { } // src/client/changelog.ts -var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.5.0 +var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.5.1 + +\u{1F527} DSH 0.2.1-rc.5 \u517C\u5BB9\u4E0E\u66F4\u65B0\u63D0\u9192\u4FEE\u590D + +\u3010\u52A0\u8F7D\u4FEE\u590D\u3011\u5C06 rc.5 \u517C\u5BB9\u76EE\u6807\u52A0\u5165\u7CBE\u786E\u7248\u672C\u58F0\u660E\uFF0C\u5904\u7406\u5BBF\u4E3B\u56E0\u7248\u672C\u58F0\u660E\u4E0D\u5339\u914D\u800C\u8DF3\u8FC7\u5DE5\u4F5C\u53F0\u52A0\u8F7D\u7684\u95EE\u9898\uFF1B\u4E0D\u901A\u914D\u653E\u5F00\u672A\u6765\u7248\u672C\u3002 + +\u3010\u66F4\u65B0\u63D0\u9192\u3011\u4FEE\u590D\u6D4B\u8BD5\u7248\u7248\u672C\u53F7\u6BD4\u8F83\u51FA\u9519\u3001\u628A\u65E7\u6B63\u5F0F\u7248\u5F53\u6210\u65B0\u7248\u672C\u7684\u95EE\u9898\u3002\u8BBE\u7F6E\u4E0E\u63A7\u5236\u5BA4\u5171\u7528\u7248\u672C\u6392\u5E8F\u89C4\u5219\uFF0C\u8BFB\u53D6\u7F13\u5B58\u548C\u663E\u793A\u5361\u7247\u65F6\u91CD\u65B0\u786E\u8BA4\u76EE\u6807\u66F4\u9AD8\u4E14\u672A\u5FFD\u7565\uFF1B\u68C0\u67E5\u5931\u8D25\u6216\u65E0\u66F4\u65B0\u65F6\u6E05\u6389\u65E7\u63D0\u793A\uFF0C\u4E0D\u5F15\u5BFC\u964D\u7EA7\u3002 + +\u3010\u63A7\u5236\u5BA4\u663E\u793A\u3011\u4F1A\u8BDD\u505C\u6B62\u7EDF\u4E00\u663E\u793A\u4E2D\u6027\u7684\u201C\u672C\u8F6E\u5DF2\u7ED3\u675F\u201D\uFF0C\u4E0D\u518D\u628A\u5931\u8D25\u6216\u505C\u6B62\u5F53\u4F5C\u6210\u529F\uFF1B\u6B63\u5728\u8FD0\u884C\u4F18\u5148\u4E8E\u65E7\u7ED3\u675F\u6807\u8BB0\u3002\u5361\u7247\u6458\u8981\u53EA\u663E\u793A\u53EF\u8BFB\u7684\u52A9\u624B\u7B54\u590D\uFF0C\u8FC7\u6EE4\u6CE8\u5165\u4E0A\u4E0B\u6587\u4E0E\u4EC5\u4F9B\u6A21\u578B\u7684\u66FF\u6362\u526F\u672C\uFF1B\u65E0\u7B54\u590D\u65F6\u7559\u7A7A\u3002 + +\u3010\u517C\u5BB9\u4E0E\u9A8C\u6536\u3011\u4EC5\u5217\u5DF2\u6D4B DSH 0.2.0-rc.2 / 0.2.1-rc.3 / 0.2.1-rc.5\u3002rc.5 \u5DF2\u6709\u9694\u79BB\u7F51\u9875\u68C0\u67E5\uFF1B\u7528\u6237\u5728\u5B98\u65B9 Windows Desktop \u5B89\u88C5\u5019\u9009\u540E\u786E\u8BA4\u5DE5\u4F5C\u53F0\u6062\u590D\u3001\u66F4\u65B0\u8BEF\u62A5\u6D88\u5931\u3002\u6B63\u5F0F\u5305\u53E6\u505A\u72EC\u7ACB\u5B89\u88C5\u4E0E\u81EA\u52A8\u56DE\u5F52\uFF0C\u4E0D\u5C06\u5019\u9009\u9875\u9762\u8BC1\u636E\u6269\u5927\u4E3A\u6B63\u5F0F\u5305\u5168\u529F\u80FD\u9A8C\u6536\uFF1B\u65E7 0.1.x\u3001\u672A\u6D4B\u5BBF\u4E3B\u53CA macOS \u4E0D\u4F5C\u517C\u5BB9\u4FDD\u8BC1\u3002 + +\u3010\u4FDD\u7559\u8FB9\u754C\u3011\u6CBF\u7528 0.5.0 \u7684\u7F51\u9875\u5B89\u5168\u9694\u79BB\u3001\u63A5\u53E3\u8EAB\u4EFD\u6821\u9A8C\u4E0E\u9879\u76EE\u5B58\u50A8\u65B9\u5F0F\u3002\u65B0\u7248\u8F93\u5165\u6846\u5DF2\u6709 /\u547D\u4EE4\u6807\u7B7E\u65F6\uFF0C\u6807\u6CE8\u8FFD\u52A0\u53EF\u80FD\u9000\u56DE\u526A\u8D34\u677F\uFF1B\u6B64\u9879\u8DE8\u7248\u672C\u5750\u6807\u9002\u914D\u5F85\u540E\u7EED\u5904\u7406\u3002\u672C\u7248\u4E0D\u5305\u542B\u65E7\u4F1A\u8BDD\u6A21\u578B\u8BF7\u6C42 HTTP 400 \u7684\u4FEE\u590D\u3002 + +\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + +\u6B64\u524D\u7248\u672C\u8BF4\u660E\uFF08v0.5.0\uFF09 \u{1F6E1}\uFE0F \u65B0\u7248\u5BBF\u4E3B\u517C\u5BB9\u3001\u5B89\u5168\u9694\u79BB\u4E0E\u4FA7\u680F\u4FEE\u590D @@ -9144,21 +9160,49 @@ var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.5.0 \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.5.0"; +var LOCAL_VERSION = false ? "dev" : "0.5.1"; var UPDATE_REPO = "Aisland-SJL/dsh-worktable"; var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1"; var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1"; var K_SKIP = "dsh.worktable.skipVersion.v1"; var K_CACHE = "dsh.worktable.updateCache.v1"; +function versionParts(value) { + if (typeof value !== "string") return null; + const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value.trim()); + if (!match) return null; + const pre = match[4]?.split(".") ?? []; + if (pre.some((part) => /^\d+$/.test(part) && part.length > 1 && part[0] === "0")) return null; + return { core: match.slice(1, 4), pre }; +} +function numericOrder(a, b) { + if (a.length !== b.length) return a.length > b.length ? 1 : -1; + return a === b ? 0 : a > b ? 1 : -1; +} function cmpVer(a, b) { - const pa = String(a).split(".").map((x) => parseInt(x, 10) || 0); - const pb = String(b).split(".").map((x) => parseInt(x, 10) || 0); - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const d = (pa[i] || 0) - (pb[i] || 0); - if (d !== 0) return d > 0 ? 1 : -1; + const pa = versionParts(a), pb = versionParts(b); + if (!pa || !pb) return null; + for (let i = 0; i < 3; i++) { + const order = numericOrder(pa.core[i], pb.core[i]); + if (order !== 0) return order; + } + if (!pa.pre.length || !pb.pre.length) return pa.pre.length ? -1 : pb.pre.length ? 1 : 0; + for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) { + const x = pa.pre[i], y = pb.pre[i]; + if (x === void 0 || y === void 0) return x === void 0 ? -1 : 1; + if (x === y) continue; + const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y); + if (nx && ny) return numericOrder(x, y); + if (nx !== ny) return nx ? -1 : 1; + return x > y ? 1 : -1; } return 0; } +function newerUpdateInfo(value) { + const info = value; + if (!info || typeof info.latest !== "string" || typeof info.notes !== "string" || typeof info.url !== "string") return null; + if (cmpVer(info.latest, LOCAL_VERSION) !== 1 || cmpVer(info.latest, getSkipVersion()) === 0) return null; + return info; +} function getSkipVersion() { try { return localStorage.getItem(K_SKIP) ?? ""; @@ -9190,8 +9234,8 @@ function readCache() { const raw = localStorage.getItem(K_CACHE); if (raw) { const c = JSON.parse(raw); - if (c && typeof c.status === "string") { - return { status: c.status === "checking" ? "idle" : c.status, info: c.info ?? null }; + if (c?.status === "uptodate" || c?.status === "failed" || c?.status === "idle") { + return { status: c.status, info: c.status === "uptodate" ? newerUpdateInfo(c.info) : null }; } } } catch { @@ -9200,13 +9244,18 @@ function readCache() { } function writeCache(status, info) { try { - localStorage.setItem(K_CACHE, JSON.stringify({ status, info })); + localStorage.setItem(K_CACHE, JSON.stringify({ status, info: status === "uptodate" ? newerUpdateInfo(info) : null })); } catch { } } async function checkUpdate(force = false) { - const last = Number(localStorage.getItem(K_LAST_CHECK) ?? "0"); - if (!force && Date.now() - last < 24 * 3600 * 1e3) return readCache(); + let last = 0; + try { + last = Number(localStorage.getItem(K_LAST_CHECK) ?? "0"); + } catch { + } + const age = Date.now() - last; + if (!force && Number.isFinite(last) && last > 0 && age >= 0 && age < 24 * 3600 * 1e3) return readCache(); try { localStorage.setItem(K_LAST_CHECK, String(Date.now())); } catch { @@ -9228,16 +9277,17 @@ async function checkUpdate(force = false) { writeCache("failed", null); return { status: "failed", info: null }; } - const tag = (d.tag_name ?? "").replace(/^v/, ""); - if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { + const tag = typeof d.tag_name === "string" ? d.tag_name.trim() : ""; + const order = cmpVer(tag, LOCAL_VERSION); + if (order == null) { + writeCache("failed", null); + return { status: "failed", info: null }; + } + if (order <= 0) { writeCache("uptodate", null); return { status: "uptodate", info: null }; } - if (getSkipVersion() === tag) { - writeCache("uptodate", null); - return { status: "uptodate", info: null }; - } - const info = { latest: tag, notes: (d.body ?? "").slice(0, 800), url: d.html_url ?? "" }; + const info = newerUpdateInfo({ latest: tag.replace(/^v/, ""), notes: typeof d.body === "string" ? d.body.slice(0, 800) : "", url: typeof d.html_url === "string" ? d.html_url : "" }); writeCache("uptodate", info); return { status: "uptodate", info }; } @@ -18394,7 +18444,8 @@ function ConsolePane() { const [glowSpeed, setGlowSpeedState] = (0, import_react2.useState)(() => splitEnv?.console?.getGlowSpeed?.() ?? 50); const [annOpen, setAnnOpen] = (0, import_react2.useState)(false); const [updStatus, setUpdStatus] = (0, import_react2.useState)(() => readCache().status); - const [updInfo, setUpdInfo] = (0, import_react2.useState)(() => readCache().info); + const [rawUpdInfo, setUpdInfo] = (0, import_react2.useState)(() => readCache().info); + const updInfo = newerUpdateInfo(rawUpdInfo); const [autoCheckOn, setAutoCheckOn] = (0, import_react2.useState)(() => getAutoCheck()); const updBusyRef = (0, import_react2.useRef)(false); const photoUrlRef = (0, import_react2.useRef)(""); @@ -20217,6 +20268,13 @@ function createSessionSnapshotAdapter() { return { ...snapshot, byId, subagentsByParent }; }; } +function consoleStatusOf(entry, pending, legacyFace) { + if (!entry) return "idle"; + if (pending != null) return "need"; + if (entry.running === true) return "busy"; + if (entry.completed === true || legacyFace?.completed === true) return "done"; + return "idle"; +} function childSessionIdsOf(snapshot, sid) { const children = /* @__PURE__ */ new Set(); for (const [id, row] of Object.entries(snapshot?.byId ?? {})) { @@ -20441,13 +20499,27 @@ function previewFromEvents(entries) { if (!Array.isArray(entries)) return ""; for (let i = entries.length - 1; i >= 0; i--) { const ev = entries[i]?.event; - if (ev?.type !== "user/message" && ev?.type !== "assistant/message") continue; - const data = ev.type === "assistant/message" ? ev.data?.message ?? ev.data : ev.data; + if (ev?.type !== "assistant/message") continue; + if (ev.surfaceOp != null && ev.surfaceOp !== "append") continue; + const data = ev.data?.message ?? ev.data; const blocks = data?.content ?? data?.blocks; if (!Array.isArray(blocks)) continue; const raw = blocks.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n"); const text2 = cleanPreviewText(raw); - if (text2.length >= 8) return text2.slice(0, 220); + if (text2) return text2.slice(0, 220); + } + return ""; +} +function previewFromNodes(nodes) { + if (!Array.isArray(nodes)) return ""; + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + if (node?.kind !== "assistant" || node.interrupted === true) continue; + const blocks = node.blocks; + if (!Array.isArray(blocks)) continue; + const raw = blocks.filter((b) => b?.kind === "text" && typeof b.text === "string").map((b) => b.text).join("\n"); + const text2 = cleanPreviewText(raw); + if (text2) return text2.slice(0, 220); } return ""; } @@ -20663,7 +20735,6 @@ var WAVE_BG_B64 = "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQUFBAYFBQUHBgYHCQ8KCQgIC // src/client/index.tsx var import_jsx_runtime3 = require("react/jsx-runtime"); -var LOCAL_VERSION2 = false ? "dev" : "0.5.0"; var UPDATE_REPO2 = "Aisland-SJL/dsh-worktable"; var { command: UPGRADE_CMD, prompt: UPGRADE_AI2, desktop: DESKTOP_HOST } = worktableUpgrade(); var ICON_SYNC = /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("svg", { viewBox: "0 0 16 16", "aria-hidden": true, children: [ @@ -20676,16 +20747,6 @@ var ICON_SPARK = /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("svg", { viewBox: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("path", { d: "M8 1.6l1.5 3.9 3.9 1.5-3.9 1.5L8 12.4 6.5 8.5 2.6 7l3.9-1.5z", fill: "none", stroke: "currentColor", strokeWidth: "1.3", strokeLinejoin: "round" }), /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("path", { d: "M12.6 11.2l.5 1.3 1.3.5-1.3.5-.5 1.3-.5-1.3-1.3-.5 1.3-.5z", fill: "currentColor" }) ] }); -function cmpVer2(a, b) { - const pa = a.split(".").map(Number); - const pb = b.split(".").map(Number); - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const x = pa[i] ?? 0; - const y = pb[i] ?? 0; - if (x !== y) return x - y; - } - return 0; -} async function copyText(text2) { try { if (navigator.clipboard?.writeText) { @@ -21146,24 +21207,7 @@ function lastTextOf(sid) { const binding = sessionBridge?.sessions?.binding?.(sid); if (binding?.eventSource?.getSnapshot) return previewFromEvents(binding.eventSource.getSnapshot().entries); const face = binding?.session?.getSnapshot?.(); - const nodes = face?.nodes ?? []; - for (let i = nodes.length - 1; i >= 0; i--) { - const n = nodes[i]; - const blocks = Array.isArray(n?.blocks) ? n.blocks : Array.isArray(n?.content) ? n.content : []; - let raw = ""; - for (const b of blocks) { - const s = typeof b?.text === "string" ? b.text.trim() : ""; - if (!s) continue; - if (b?.type === "text") { - raw = s; - break; - } - if (!raw) raw = s; - } - if (!raw) continue; - const clean = cleanPreviewText(raw); - if (clean.length >= 8) return clean.slice(0, 220); - } + return previewFromNodes(face?.nodes); } catch { } return ""; @@ -21262,7 +21306,7 @@ function buildWindowTaskText(projectId, projectName, windowLabel, requirement, f 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 = [ - "\u3010\u5DE5\u4F5C\u53F0\u81EA\u5B9A\u4E49\u7A97\u53E3\u4EFB\u52A1\u3011", + "\u3010\u5DE5\u4F5C\u53F0\u3011", "\u4EE5\u4E0B\u5185\u5BB9\u7531 dsh-worktable\uFF08\u5DE5\u4F5C\u53F0\uFF09\u63D2\u4EF6" + (mode === "new" ? "\u81EA\u52A8\u53D1\u9001" : "\u53D1\u9001") + "\uFF1A\u7528\u6237\u60F3\u628A\u300C" + projectName + "\u300D\u9879\u76EE\u4E2D\u7684\u300C" + win + "\u300D\u7A97\u53E3\u6253\u9020\u6210\u4ED6\u60F3\u8981\u7684\u5185\u5BB9\u3002", "1. dsh-worktable \u662F\u4FA7\u8FB9\u680F\u5E95\u90E8\u7684\u81EA\u5EFA\u300C\u5DE5\u4F5C\u53F0\u300D\u63D2\u4EF6\uFF08\u5B98\u65B9\u6587\u6863\u6CA1\u6709\u5B83\u7684\u8BF4\u660E\uFF0C\u4E0D\u4E86\u89E3\u4E4B\u5904\u53EF\u76F4\u63A5\u5411\u7528\u6237\u63D0\u95EE\uFF09\u3002", "2. \u7528\u6237\u9700\u6C42\uFF1A" + requirement, @@ -21442,6 +21486,7 @@ function clearNotifyAck(sid) { function sessionNotifyState(entry) { if (!entry) return null; if (entry.pendingInteraction != null) return "need"; + if (entry.running === true) return null; if (entry.completed === true) return "done"; return null; } @@ -21561,7 +21606,8 @@ function WorktableSection(props) { const [registeredIds, setRegisteredIds] = (0, import_react3.useState)(() => [...registryStore.ids]); const [addOpen, setAddOpen] = (0, import_react3.useState)(false); const [viewOptionsOpen, setViewOptionsOpen] = (0, import_react3.useState)(false); - const [updateInfo, setUpdateInfo] = (0, import_react3.useState)(null); + const [rawUpdateInfo, setUpdateInfo] = (0, import_react3.useState)(null); + const updateInfo = newerUpdateInfo(rawUpdateInfo); const [updateCheckOn, setUpdateCheckOn] = (0, import_react3.useState)(() => localStorage.getItem("dsh.worktable.updateCheck.v1") !== "0"); const [updateCopied, setUpdateCopied] = (0, import_react3.useState)(false); const [updateStatus, setUpdateStatus] = (0, import_react3.useState)("idle"); @@ -21572,9 +21618,15 @@ function WorktableSection(props) { }, []); const checkUpdates = (0, import_react3.useCallback)(async (force = false) => { if (updateCheckingRef.current) return; - const last = Number(localStorage.getItem("dsh.worktable.lastUpdateCheck.v1") ?? "0"); - if (!force && Date.now() - last < 24 * 3600 * 1e3) return; + let last = 0; + try { + last = Number(localStorage.getItem("dsh.worktable.lastUpdateCheck.v1") ?? "0"); + } catch { + } + const age = Date.now() - last; + if (!force && Number.isFinite(last) && last > 0 && age >= 0 && age < 24 * 3600 * 1e3) return; updateCheckingRef.current = true; + setUpdateInfo(null); setUpdateStatus("checking"); try { let d = null; @@ -21595,17 +21647,21 @@ function WorktableSection(props) { setUpdateStatus("failed"); return; } - localStorage.setItem("dsh.worktable.lastUpdateCheck.v1", String(Date.now())); - const tag = (d.tag_name ?? "").replace(/^v/, ""); - if (!tag || cmpVer2(tag, LOCAL_VERSION2) <= 0) { + try { + localStorage.setItem("dsh.worktable.lastUpdateCheck.v1", String(Date.now())); + } catch { + } + const tag = typeof d.tag_name === "string" ? d.tag_name.trim() : ""; + const order = cmpVer(tag, LOCAL_VERSION); + if (order == null) { + setUpdateStatus("failed"); + return; + } + if (order <= 0) { setUpdateStatus("uptodate"); return; } - if ((localStorage.getItem("dsh.worktable.skipVersion.v1") ?? "") === tag) { - setUpdateStatus("uptodate"); - return; - } - setUpdateInfo({ latest: tag, notes: (d.body ?? "").slice(0, 800), url: d.html_url ?? "" }); + setUpdateInfo(newerUpdateInfo({ latest: tag.replace(/^v/, ""), notes: typeof d.body === "string" ? d.body.slice(0, 800) : "", url: typeof d.html_url === "string" ? d.html_url : "" })); setUpdateStatus("uptodate"); } finally { updateCheckingRef.current = false; @@ -21698,15 +21754,12 @@ function WorktableSection(props) { if (!sid) return "idle"; const e = byId[sid]; if (!e) return "idle"; - if (pendingAckForSession(sid) != null) return "need"; - if (e.completed === true) return "done"; + let face; try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.(); - if (face && face.completed === true) return "done"; + face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.(); } catch { } - if (e.running === true) return "busy"; - return "idle"; + return consoleStatusOf(e, pendingAckForSession(sid), face); }; const runtimeOf = (sid) => { if (!sid) return null; @@ -23163,7 +23216,7 @@ function WorktableSection(props) { /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "dsh-wt_updateVers", children: [ t("update.current"), " v", - LOCAL_VERSION2, + LOCAL_VERSION, " \u2192 v", updateInfo.latest ] }), @@ -23299,7 +23352,7 @@ function WorktableSection(props) { /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "dsh-wt_versionRow", children: [ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { children: [ "v", - LOCAL_VERSION2, + LOCAL_VERSION, updateStatus === "uptodate" && !updateInfo ? " \xB7 " + t("update.upToDate") : "", updateStatus === "failed" && !updateInfo ? " \xB7 " + t("update.checkFail") : "" ] }), diff --git a/01_content/lib/index.js b/01_content/lib/index.js index fcc4678..065d09c 100644 --- a/01_content/lib/index.js +++ b/01_content/lib/index.js @@ -225,7 +225,7 @@ function resolveDshHomeSafe() { cachedDshHome = baseDshHome(); return cachedDshHome; } -var PLUGIN_VERSION = false ? "dev" : "0.5.0"; +var PLUGIN_VERSION = false ? "dev" : "0.5.1"; 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 53b1f5e..7da5b4b 100644 --- a/01_content/package-lock.json +++ b/01_content/package-lock.json @@ -1,16 +1,16 @@ { "name": "dsh-worktable", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-worktable", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "engines": { "node": ">=22", - "dsh": "0.2.0-rc.2 || 0.2.1-rc.3" + "dsh": "0.2.0-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.5" }, "devDependencies": { "esbuild": "^0.25.0", diff --git a/01_content/package.json b/01_content/package.json index 0645b8f..8915fa0 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.5.0", + "version": "0.5.1", "repository": { "type": "git", "url": "https://github.com/Aisland-SJL/dsh-worktable.git", @@ -9,7 +9,7 @@ }, "engines": { "node": ">=22", - "dsh": "0.2.0-rc.2 || 0.2.1-rc.3" + "dsh": "0.2.0-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.5" }, "license": "MIT", "type": "module", @@ -40,10 +40,11 @@ ] }, "compatibility": { - "dsh": "0.2.0-rc.2 || 0.2.1-rc.3", + "dsh": "0.2.0-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.5", "dshReleases": { "0.2.0-rc.2": "compatible", - "0.2.1-rc.3": "compatible" + "0.2.1-rc.3": "compatible", + "0.2.1-rc.5": "compatible" } } }, @@ -56,6 +57,7 @@ "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:update": "node ../04_test/update-check.test.mjs", "test:transport": "node ../04_test/host-transport.test.mjs", "test:widget": "node ../04_test/widget-mount.test.mjs", "test:security": "node --test ../04_test/request-auth.test.mjs ../04_test/preview-files.test.mjs ../04_test/sandboxed-frame.test.mjs", diff --git a/01_content/release-prep.mjs b/01_content/release-prep.mjs index f482bda..6312177 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', 'host-transport.test.mjs', 'widget-mount.test.mjs', 'request-auth.test.mjs', 'preview-files.test.mjs', 'sandboxed-frame.test.mjs'] +const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs', 'host-transport.test.mjs', 'widget-mount.test.mjs', 'request-auth.test.mjs', 'preview-files.test.mjs', 'sandboxed-frame.test.mjs', 'update-check.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 44ca899..59e67ee 100644 --- a/01_content/src/client/changelog.ts +++ b/01_content/src/client/changelog.ts @@ -1,6 +1,22 @@ /** 更新公告正文;公开发布状态以 GitHub Release 为准。 */ -export const CHANGELOG_VERSION = 'v0.5.0' -export const CHANGELOG_V030 = `更新公告 · v0.5.0 +export const CHANGELOG_VERSION = 'v0.5.1' +export const CHANGELOG_V030 = `更新公告 · v0.5.1 + +🔧 DSH 0.2.1-rc.5 兼容与更新提醒修复 + +【加载修复】将 rc.5 兼容目标加入精确版本声明,处理宿主因版本声明不匹配而跳过工作台加载的问题;不通配放开未来版本。 + +【更新提醒】修复测试版版本号比较出错、把旧正式版当成新版本的问题。设置与控制室共用版本排序规则,读取缓存和显示卡片时重新确认目标更高且未忽略;检查失败或无更新时清掉旧提示,不引导降级。 + +【控制室显示】会话停止统一显示中性的“本轮已结束”,不再把失败或停止当作成功;正在运行优先于旧结束标记。卡片摘要只显示可读的助手答复,过滤注入上下文与仅供模型的替换副本;无答复时留空。 + +【兼容与验收】仅列已测 DSH 0.2.0-rc.2 / 0.2.1-rc.3 / 0.2.1-rc.5。rc.5 已有隔离网页检查;用户在官方 Windows Desktop 安装候选后确认工作台恢复、更新误报消失。正式包另做独立安装与自动回归,不将候选页面证据扩大为正式包全功能验收;旧 0.1.x、未测宿主及 macOS 不作兼容保证。 + +【保留边界】沿用 0.5.0 的网页安全隔离、接口身份校验与项目存储方式。新版输入框已有 /命令标签时,标注追加可能退回剪贴板;此项跨版本坐标适配待后续处理。本版不包含旧会话模型请求 HTTP 400 的修复。 + +─────── + +此前版本说明(v0.5.0) 🛡️ 新版宿主兼容、安全隔离与侧栏修复 diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index ac612a1..e6c585c 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -5,9 +5,10 @@ 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 { LOCAL_VERSION, cmpVer, newerUpdateInfo, type UpdateInfo } from './updateCheck' 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, blankSessionNeedsWorkspace } from './sessionDetails' +import { currentSessionOf, openHostSession, sendHostSession, createSessionSnapshotAdapter, childSessionIdsOf, pendingAckOf, sessionRuntimeMs, consoleStatusOf } from './sessionCompat' +import { previewFromEvents, previewFromNodes, 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' @@ -29,8 +30,6 @@ type OrderBy = 'manual' | 'recent' type DockMode = 'footer' | 'float' // ── 更新检查(客户端直连 GitHub Releases API,只读 GET;失败静默)── -declare const __WT_VERSION__: string -const LOCAL_VERSION = typeof __WT_VERSION__ === 'undefined' ? 'dev' : __WT_VERSION__ const UPDATE_REPO = 'Aisland-SJL/dsh-worktable' const { command: UPGRADE_CMD, prompt: UPGRADE_AI, desktop: DESKTOP_HOST } = worktableUpgrade() // 更新提示图标(手绘 SVG,避免 emoji 跨平台渲染差异) @@ -48,17 +47,6 @@ const ICON_SPARK = ( ) -type UpdateInfo = { latest: string; notes: string; url: string } -function cmpVer(a: string, b: string): number { - const pa = a.split('.').map(Number) - const pb = b.split('.').map(Number) - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const x = pa[i] ?? 0 - const y = pb[i] ?? 0 - if (x !== y) return x - y - } - return 0 -} async function copyText(text: string): Promise { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true } @@ -618,27 +606,13 @@ function schedulePreviewSweep() { previewTimer = window.setTimeout(() => { previewTimer = null; sweepPreviews() }, 6000) } -/** 从会话快照节点里提取最近一条文本(纯读内存镜像,零 Token;代码清洗后无则 '') */ +/** 从宿主内存镜像提取最近助手文本;无可读答复则留空,不回退注入/推理/工具内容。 */ function lastTextOf(sid: string): string { try { const binding = sessionBridge?.sessions?.binding?.(sid) if (binding?.eventSource?.getSnapshot) return previewFromEvents(binding.eventSource.getSnapshot().entries) const face = binding?.session?.getSnapshot?.() - const nodes: any[] = face?.nodes ?? [] - for (let i = nodes.length - 1; i >= 0; i--) { - const n = nodes[i] - const blocks = Array.isArray(n?.blocks) ? n.blocks : (Array.isArray(n?.content) ? n.content : []) - let raw = '' - for (const b of blocks) { - const s = typeof b?.text === 'string' ? b.text.trim() : '' - if (!s) continue - if (b?.type === 'text') { raw = s; break } - if (!raw) raw = s - } - if (!raw) continue - const clean = cleanPreviewText(raw) - if (clean.length >= 8) return clean.slice(0, 220) - } + return previewFromNodes(face?.nodes) } catch {} return '' } @@ -759,7 +733,7 @@ function buildWindowTaskText(projectId: string, projectName: string, windowLabel ? '项目文件夹:' + folder + '(本项目所有产出文件一律放进这个文件夹;不要写到别的默认位置)。' : '本项目暂未设置专属文件夹:产出文件先向用户确认存放位置,不要随便写。' const lines = [ - '【工作台自定义窗口任务】', + '【工作台】', '以下内容由 dsh-worktable(工作台)插件' + (mode === 'new' ? '自动发送' : '发送') + ':用户想把「' + projectName + '」项目中的「' + win + '」窗口打造成他想要的内容。', '1. dsh-worktable 是侧边栏底部的自建「工作台」插件(官方文档没有它的说明,不了解之处可直接向用户提问)。', '2. 用户需求:' + requirement, @@ -921,7 +895,7 @@ function buildMountContent(folder: string, d: any): any { /** 每会话最近观察到的待决身份;同为 need 的问题替换也必须重新点亮。 */ const notifyStateSeenRef: { current: Record } = { current: {} } -/** 每会话最近观察到的「已完成」布尔值:新一次完成时清除旧 done ack(控制室绿光重新点亮) */ +/** 每会话最近观察到的结束边沿:新一轮结束清除旧 done ack;结束不表示成功。 */ const doneSeenRef: { current: Record } = { current: {} } /** 清除某会话的 ack 记录(状态转移时调用,保证新问题不会被旧确认压住) */ function clearNotifyAck(sid: string) { @@ -934,11 +908,12 @@ function clearNotifyAck(sid: string) { } catch {} } -/** 会话状态 → 提醒类型:待决(黄) 优先于 完成(绿)——等待用户判断时 pendingInteraction 与 +/** 会话状态 → 提醒类型:待决(黄) 优先于 本轮结束(中性)——等待用户判断时 pendingInteraction 与 * running 会同时为真,原生 UI 以黄点优先,镜像必须一致 */ function sessionNotifyState(entry: any): 'done' | 'need' | null { if (!entry) return null if (entry.pendingInteraction != null) return 'need' + if (entry.running === true) return null if (entry.completed === true) return 'done' return null } @@ -1062,7 +1037,8 @@ function WorktableSection(props: any) { const [addOpen, setAddOpen] = useState(false) const [viewOptionsOpen, setViewOptionsOpen] = useState(false) // 更新检查:徽标 / 更新卡 / 版本行共用;节流一天一次,忽略按版本号存 localStorage - const [updateInfo, setUpdateInfo] = useState(null) + const [rawUpdateInfo, setUpdateInfo] = useState(null) + const updateInfo = newerUpdateInfo(rawUpdateInfo) const [updateCheckOn, setUpdateCheckOn] = useState(() => localStorage.getItem('dsh.worktable.updateCheck.v1') !== '0') const [updateCopied, setUpdateCopied] = useState(false) const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'uptodate' | 'failed'>('idle') @@ -1071,9 +1047,12 @@ function WorktableSection(props: any) { useEffect(() => () => { updateAliveRef.current = false }, []) const checkUpdates = useCallback(async (force = false) => { if (updateCheckingRef.current) return // 防重入:并发点击只保留一个 in-flight - const last = Number(localStorage.getItem('dsh.worktable.lastUpdateCheck.v1') ?? '0') - if (!force && Date.now() - last < 24 * 3600 * 1000) return // 自动检查节流;手动「立即检查」绕过节流 + let last = 0 + try { last = Number(localStorage.getItem('dsh.worktable.lastUpdateCheck.v1') ?? '0') } catch {} + const age = Date.now() - last + if (!force && Number.isFinite(last) && last > 0 && age >= 0 && age < 24 * 3600 * 1000) return // 自动检查节流;手动「立即检查」绕过节流 updateCheckingRef.current = true + setUpdateInfo(null) // Every new result replaces the old card, including failure/no update. setUpdateStatus('checking') try { let d: { tag_name?: string; body?: string; html_url?: string } | null = null @@ -1089,11 +1068,12 @@ function WorktableSection(props: any) { } if (!updateAliveRef.current) return // 组件已卸载:不再更新状态 if (!d) { setUpdateStatus('failed'); return } - localStorage.setItem('dsh.worktable.lastUpdateCheck.v1', String(Date.now())) - const tag = (d.tag_name ?? '').replace(/^v/, '') - if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { setUpdateStatus('uptodate'); return } - if ((localStorage.getItem('dsh.worktable.skipVersion.v1') ?? '') === tag) { setUpdateStatus('uptodate'); return } - setUpdateInfo({ latest: tag, notes: (d.body ?? '').slice(0, 800), url: d.html_url ?? '' }) + try { localStorage.setItem('dsh.worktable.lastUpdateCheck.v1', String(Date.now())) } catch {} + const tag = typeof d.tag_name === 'string' ? d.tag_name.trim() : '' + const order = cmpVer(tag, LOCAL_VERSION) + if (order == null) { setUpdateStatus('failed'); return } + if (order <= 0) { setUpdateStatus('uptodate'); return } + setUpdateInfo(newerUpdateInfo({ latest: tag.replace(/^v/, ''), notes: typeof d.body === 'string' ? d.body.slice(0, 800) : '', url: typeof d.html_url === 'string' ? d.html_url : '' })) setUpdateStatus('uptodate') } finally { updateCheckingRef.current = false @@ -1198,15 +1178,10 @@ function WorktableSection(props: any) { if (!sid) return 'idle' const e = byId[sid] if (!e) return 'idle' - if (pendingAckForSession(sid) != null) return 'need' - if (e.completed === true) return 'done' - // 会话面兜底:宿主快照可能滞后或缺少完成字段 - try { - const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() - if (face && face.completed === true) return 'done' - } catch {} - if (e.running === true) return 'busy' - return 'idle' + // Read an existing binding only; never activate a cold session to classify status. + let face: any + try { face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() } catch {} + return consoleStatusOf(e, pendingAckForSession(sid), face) } // 运行时长:任务 startedAt → 新版 chat legacy.turnTimings → 旧会话面;未知不虚构。 const runtimeOf = (sid: string | undefined): number | null => { @@ -1217,7 +1192,7 @@ function WorktableSection(props: any) { const make = (id: string, name: string, icon: string, self: boolean): ConsoleCardData => { const sid = pr.projects.bindings[id] const status = statusOf(sid) - // 发光 = 完成/待决且本轮未被确认;点卡片确认后熄灭 + // 发光 = 本轮结束/待决且未被确认;结束不承诺任务成功。 const glow = !!sid && ((status === 'done' && ackMap[sid] !== 'done') || (status === 'need' && ackMap[sid] !== pendingAckForSession(sid))) return { id, name, icon, @@ -1976,7 +1951,7 @@ function buildCustomLayoutPrompt(req: string): string { return childSessionIdsOf(sessionsSnapshotStore.snapshot, sid) }, []) - /** 项目 → 提醒态:绑定会话(含其子代理)待决(黄) > 完成(绿) > 工作中(蓝); + /** 项目 → 提醒态:绑定会话(含其子代理)待决(黄) > 本轮结束(中性) > 工作中(蓝); * 状态转移自动清除旧 ack——原生 UI 每个新问题都会重新亮黄,镜像不得被旧确认压住 */ const bindNotifyMap: Record = useMemo(() => { const map: Record = {} diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 0ef83f6..b149706 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -105,7 +105,7 @@ export const zh = { 'bind.titleUnbound': '绑定对话(未绑定 ○○):点击选择要跟随的对话', 'bind.titleBound': '已绑定对话(●●):点击更换或解绑', 'bind.tipUnbound': '未绑定对话:点击选择要跟随的对话', - 'bind.tipDone': '(任务已完成,点击项目查看)', + 'bind.tipDone': '(本轮已结束,点击项目查看)', 'bind.tipBusy': '(对话正在处理任务…)', 'bind.tipNeed': '(需要你的决定,点击项目处理)', 'bind.folder': '项目文件夹', @@ -161,9 +161,9 @@ export const zh = { 'console.idle': '空闲', 'console.busy': '工作中', 'console.need': '待你决定', - 'console.done': '已完成', + 'console.done': '本轮已结束', 'console.kids': '子代理', - 'console.noPreview': '暂无消息', + 'console.noPreview': '暂无助手回复', 'console.unboundShort': '未绑定对话', 'console.jump': '跳转到绑定对话', 'console.empty': '还没有项目', @@ -343,7 +343,7 @@ export const en = { 'bind.titleUnbound': 'Bind conversation (unbound ○○): pick the conversation to follow', 'bind.titleBound': 'Conversation bound (●●): click to change or unbind', 'bind.tipUnbound': 'No conversation bound — click to pick one', - 'bind.tipDone': ' (task completed — click the project to view)', + 'bind.tipDone': ' (run ended — click the project to view)', 'bind.tipBusy': ' (conversation is working on the task…)', 'bind.tipNeed': ' (needs your decision — click the project to handle)', 'bind.folder': 'Project folder', @@ -399,9 +399,9 @@ export const en = { 'console.idle': 'Idle', 'console.busy': 'Working', 'console.need': 'Needs you', - 'console.done': 'Done', + 'console.done': 'Run ended', 'console.kids': 'subagents', - 'console.noPreview': 'No messages yet', + 'console.noPreview': 'No assistant reply yet', 'console.unboundShort': 'No conversation bound', 'console.jump': 'Jump to bound conversation', 'console.empty': 'No projects yet', diff --git a/01_content/src/client/sessionCompat.ts b/01_content/src/client/sessionCompat.ts index 581a29f..e7ccfc4 100644 --- a/01_content/src/client/sessionCompat.ts +++ b/01_content/src/client/sessionCompat.ts @@ -86,6 +86,17 @@ export function createSessionSnapshotAdapter() { } } +/** Display only: done means the run ended, never that the task succeeded. + * Keep the adapter's completion edge unchanged for widget-result/ack handling. + */ +export function consoleStatusOf(entry: any, pending: string | null, legacyFace?: any): 'idle' | 'busy' | 'need' | 'done' { + if (!entry) return 'idle' + if (pending != null) return 'need' + if (entry.running === true) return 'busy' + if (entry.completed === true || legacyFace?.completed === true) return 'done' + return 'idle' +} + /** Parent links and the host's child catalog can each contain otherwise missing children. */ export function childSessionIdsOf(snapshot: any, sid: string): Set { const children = new Set() diff --git a/01_content/src/client/sessionDetails.ts b/01_content/src/client/sessionDetails.ts index c5be9ea..3fce79c 100644 --- a/01_content/src/client/sessionDetails.ts +++ b/01_content/src/client/sessionDetails.ts @@ -12,13 +12,31 @@ export function previewFromEvents(entries: any): string { if (!Array.isArray(entries)) return '' for (let i = entries.length - 1; i >= 0; i--) { const ev = entries[i]?.event - if (ev?.type !== 'user/message' && ev?.type !== 'assistant/message') continue - const data = ev.type === 'assistant/message' ? ev.data?.message ?? ev.data : ev.data + // user/message also carries injected runtime context. Preview assistant replies only. + if (ev?.type !== 'assistant/message') continue + // New hosts keep model-only replacement copies out of the visible conversation. + if (ev.surfaceOp != null && ev.surfaceOp !== 'append') continue + const data = ev.data?.message ?? ev.data const blocks = data?.content ?? data?.blocks if (!Array.isArray(blocks)) continue const raw = blocks.filter((b: any) => b?.type === 'text' && typeof b.text === 'string').map((b: any) => b.text).join('\n') const text = cleanPreviewText(raw) - if (text.length >= 8) return text.slice(0, 220) + if (text) return text.slice(0, 220) + } + return '' +} + +/** Legacy in-memory nodes have kind-discriminated blocks, not event content blocks. */ +export function previewFromNodes(nodes: any): string { + if (!Array.isArray(nodes)) return '' + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i] + if (node?.kind !== 'assistant' || node.interrupted === true) continue + const blocks = node.blocks + if (!Array.isArray(blocks)) continue + const raw = blocks.filter((b: any) => b?.kind === 'text' && typeof b.text === 'string').map((b: any) => b.text).join('\n') + const text = cleanPreviewText(raw) + if (text) return text.slice(0, 220) } return '' } diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index dfc5359..087068f 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -3,7 +3,7 @@ import { parentPathOf } from './pathutil' import { hostWebSocketUrl, worktableUpgrade } from './hostTransport' import { SandboxedFrame } from './SandboxedFrame' import { CHANGELOG_V030 } from './changelog' -import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck' +import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, newerUpdateInfo, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck' import { Terminal } from 'xterm' import MarkdownIt from 'markdown-it' @@ -207,19 +207,19 @@ export type ConsoleCardData = { id: string name: string icon: string - /** 三态 + 空闲:need(待决黄) > done(完成绿) > busy(工作中蓝) > idle */ + /** 展示待决/运行/本轮结束/空闲;结束为中性提醒,不判定任务成功。 */ status: 'idle' | 'busy' | 'need' | 'done' /** 正在运行时的本轮已用毫秒;非运行态 null */ runtimeMs: number | null /** 子代理数量 */ kids: number - /** 最近一条消息预览(单行文本,可能为空) */ + /** 最近可读的助手答复(单行文本,可能为空) */ preview: string /** 是否绑定对话 */ bound: boolean /** 是否为「工作台」自己(卡片点击无操作) */ self: boolean - /** 完成/待决且未被确认:卡片发对应色光(点击确认后熄灭) */ + /** 本轮结束/待决且未被确认:卡片发提醒光(点击确认后熄灭) */ glow: boolean } @@ -1629,7 +1629,8 @@ function ConsolePane() { const [glowSpeed, setGlowSpeedState] = useState(() => splitEnv?.console?.getGlowSpeed?.() ?? 50) const [annOpen, setAnnOpen] = useState(false) const [updStatus, setUpdStatus] = useState(() => readCache().status) - const [updInfo, setUpdInfo] = useState(() => readCache().info) + const [rawUpdInfo, setUpdInfo] = useState(() => readCache().info) + const updInfo = newerUpdateInfo(rawUpdInfo) const [autoCheckOn, setAutoCheckOn] = useState(() => getAutoCheck()) const updBusyRef = useRef(false) const photoUrlRef = useRef('') diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index 4f370c8..d74d034 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -198,9 +198,9 @@ export const css = xtermCss + '\n' + [ '.dsh-wt_bindBtn{position:absolute;right:26px;top:50%;transform:translateY(-50%);width:22px;height:22px;margin:0;display:block;border-radius:6px;color:var(--dsw-alias-label-secondary,#9aa4b2);cursor:pointer;user-select:none}', '.dsh-wt_bindBtn:hover{background:var(--dsw-alias-fill-l2,rgba(255,255,255,.09));color:var(--dsw-alias-label-primary,#e6e8eb)}', '.dsh-wt_bindBtn[data-bound=true]{color:var(--dsw-alias-label-primary,#e6e8eb)}', - // 任务完成提醒镜像:绿色发光 / 待决提醒:黄色发光(点击项目即 ack 恢复常态实心); + // 本轮结束提醒:中性发光 / 待决提醒:黄色发光(点击项目即 ack 恢复常态实心); // 工作中:蓝色发光 + 两圆交替亮灭(对应 DSH 对话的转圈标记,无需 ack,running 变 false 自动停) - '.dsh-wt_bindBtn[data-bound=done]{color:#3fb950}', + '.dsh-wt_bindBtn[data-bound=done]{color:#94a3b8}', '.dsh-wt_bindBtn[data-bound=need]{color:#d29922}', '.dsh-wt_bindBtn[data-bound=busy]{color:#4f8ef7}', '@keyframes dsh-wt-busyA{0%,44%{opacity:1}50%,94%{opacity:.12}100%{opacity:1}}', @@ -581,12 +581,12 @@ export const css = xtermCss + '\n' + [ '.dsh-wt_consoleStatus{flex:none;font-size:22px;font-weight:700;line-height:30px;color:var(--wt-text2)}', '.dsh-wt_consoleStatus-busy{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', '.dsh-wt_consoleStatus-need{color:var(--dsw-alias-state-warning,#d29922)}', - '.dsh-wt_consoleStatus-done{color:var(--dsw-alias-state-success,#3fb950)}', + '.dsh-wt_consoleStatus-done{color:var(--wt-text2)}', '.dsh-wt_consoleRuntime{flex:none;font-size:17px;font-weight:600;line-height:24px;color:var(--wt-text2);font-variant-numeric:tabular-nums}', '.dsh-wt_consolePreview{display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;font-size:8.5px;line-height:13px;color:var(--wt-text2);word-break:break-word}', '.dsh-wt_consolePreviewNone{color:var(--wt-text3);font-style:italic;-webkit-line-clamp:1}', - // 三色光效:整卡霓虹描边光晕(像侧栏双圆点的发光质感)——完成=绿、待决=黄;工作=蓝色描边顺时针旋转 - '.dsh-wt_consoleCard-glowDone{border-color:rgba(63,185,80,.9);box-shadow:0 0 8px rgba(63,185,80,.5),0 0 26px rgba(63,185,80,.3),inset 0 0 16px rgba(63,185,80,.07)}', + // 结束只用中性提醒光,不作为成功证明;待决=黄,工作=蓝色描边顺时针旋转 + '.dsh-wt_consoleCard-glowDone{border-color:rgba(148,163,184,.8);box-shadow:0 0 8px rgba(148,163,184,.35),0 0 26px rgba(148,163,184,.2),inset 0 0 16px rgba(148,163,184,.05)}', '.dsh-wt_consoleCard-glowNeed{border-color:rgba(210,153,34,.95);box-shadow:0 0 8px rgba(210,153,34,.5),0 0 26px rgba(210,153,34,.3),inset 0 0 16px rgba(210,153,34,.08)}', '@property --consoleAngle{syntax:"";initial-value:0deg;inherits:false}', '.dsh-wt_consoleCard-busy{box-shadow:0 0 10px rgba(94,160,255,.55),0 0 34px rgba(79,142,247,.4)}', diff --git a/01_content/src/client/updateCheck.ts b/01_content/src/client/updateCheck.ts index 7459ac8..f4ae984 100644 --- a/01_content/src/client/updateCheck.ts +++ b/01_content/src/client/updateCheck.ts @@ -11,14 +11,45 @@ const K_LAST_CHECK = 'dsh.worktable.lastUpdateCheck.v1' const K_SKIP = 'dsh.worktable.skipVersion.v1' const K_CACHE = 'dsh.worktable.updateCache.v1' -export function cmpVer(a: string, b: string): number { - const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0) - const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0) - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const d = (pa[i] || 0) - (pb[i] || 0) - if (d !== 0) return d > 0 ? 1 : -1 +/** Strict version precedence; invalid input is incomparable, never a potential update. */ +function versionParts(value: string): { core: string[]; pre: string[] } | null { + if (typeof value !== 'string') return null + const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value.trim()) + if (!match) return null + const pre = match[4]?.split('.') ?? [] + if (pre.some((part) => /^\d+$/.test(part) && part.length > 1 && part[0] === '0')) return null + return { core: match.slice(1, 4), pre } +} +function numericOrder(a: string, b: string): number { + if (a.length !== b.length) return a.length > b.length ? 1 : -1 + return a === b ? 0 : a > b ? 1 : -1 +} +export function cmpVer(a: string, b: string): number | null { + const pa = versionParts(a), pb = versionParts(b) + if (!pa || !pb) return null + for (let i = 0; i < 3; i++) { + const order = numericOrder(pa.core[i], pb.core[i]) + if (order !== 0) return order } - return 0 + if (!pa.pre.length || !pb.pre.length) return pa.pre.length ? -1 : pb.pre.length ? 1 : 0 + for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) { + const x = pa.pre[i], y = pb.pre[i] + if (x === undefined || y === undefined) return x === undefined ? -1 : 1 + if (x === y) continue + const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y) + if (nx && ny) return numericOrder(x, y) + if (nx !== ny) return nx ? -1 : 1 + return x > y ? 1 : -1 + } + return 0 // Build metadata has no effect on version precedence. +} + +/** Shared by cache reads and both UIs. Preserve identity to avoid render-effect churn. */ +export function newerUpdateInfo(value: unknown): UpdateInfo | null { + const info = value as UpdateInfo | null + if (!info || typeof info.latest !== 'string' || typeof info.notes !== 'string' || typeof info.url !== 'string') return null + if (cmpVer(info.latest, LOCAL_VERSION) !== 1 || cmpVer(info.latest, getSkipVersion()) === 0) return null + return info } export function getSkipVersion(): string { @@ -38,21 +69,23 @@ export function readCache(): { status: UpdateStatus; info: UpdateInfo | null } { const raw = localStorage.getItem(K_CACHE) if (raw) { const c = JSON.parse(raw) - if (c && typeof c.status === 'string') { - return { status: c.status === 'checking' ? 'idle' : c.status, info: c.info ?? null } + if (c?.status === 'uptodate' || c?.status === 'failed' || c?.status === 'idle') { + return { status: c.status, info: c.status === 'uptodate' ? newerUpdateInfo(c.info) : null } } } } catch {} return { status: 'idle', info: null } } export function writeCache(status: UpdateStatus, info: UpdateInfo | null) { - try { localStorage.setItem(K_CACHE, JSON.stringify({ status, info })) } catch {} + try { localStorage.setItem(K_CACHE, JSON.stringify({ status, info: status === 'uptodate' ? newerUpdateInfo(info) : null })) } catch {} } /** 检查更新:节流一天一次(force 绕过);8s 超时 × 3 重试;防重入由调用方保证 */ export async function checkUpdate(force = false): Promise<{ status: UpdateStatus; info: UpdateInfo | null }> { - const last = Number(localStorage.getItem(K_LAST_CHECK) ?? '0') - if (!force && Date.now() - last < 24 * 3600 * 1000) return readCache() + let last = 0 + try { last = Number(localStorage.getItem(K_LAST_CHECK) ?? '0') } catch {} + const age = Date.now() - last + if (!force && Number.isFinite(last) && last > 0 && age >= 0 && age < 24 * 3600 * 1000) return readCache() try { localStorage.setItem(K_LAST_CHECK, String(Date.now())) } catch {} let d: { tag_name?: string; body?: string; html_url?: string } | null = null for (let attempt = 0; attempt < 3 && !d; attempt++) { @@ -66,10 +99,11 @@ export async function checkUpdate(force = false): Promise<{ status: UpdateStatus finally { clearTimeout(timer) } } if (!d) { writeCache('failed', null); return { status: 'failed', info: null } } - const tag = (d.tag_name ?? '').replace(/^v/, '') - if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { writeCache('uptodate', null); return { status: 'uptodate', info: null } } - if (getSkipVersion() === tag) { writeCache('uptodate', null); return { status: 'uptodate', info: null } } - const info: UpdateInfo = { latest: tag, notes: (d.body ?? '').slice(0, 800), url: d.html_url ?? '' } + const tag = typeof d.tag_name === 'string' ? d.tag_name.trim() : '' + const order = cmpVer(tag, LOCAL_VERSION) + if (order == null) { writeCache('failed', null); return { status: 'failed', info: null } } + if (order <= 0) { writeCache('uptodate', null); return { status: 'uptodate', info: null } } + const info = newerUpdateInfo({ latest: tag.replace(/^v/, ''), notes: typeof d.body === 'string' ? d.body.slice(0, 800) : '', url: typeof d.html_url === 'string' ? d.html_url : '' }) writeCache('uptodate', info) return { status: 'uptodate', info } } diff --git a/02_process/PRD.md b/02_process/PRD.md index aa2e6e6..8fa8ce5 100644 --- a/02_process/PRD.md +++ b/02_process/PRD.md @@ -3,7 +3,7 @@ > 版本:v0.2 草案 · 日期:2026-08-16 · 状态:v2 已实现并验收;§12 多项目分栏框架(v3)与 §13 乐高式工作区框架(v4)设计定案、待实现 > 关联项目:dsh-travelatlas(第一个入驻项目)、上游参考 dsh-reminder(文件夹结构) -> 文档边界:§3/§9–§13 包含早期目标、历史验收表与设计草案,不是当前版本全部实现的规格书。当前 0.5.0的安装、兼容与验证状态以 README / 包内 README 为准;§6/§8 为当前架构与权限说明。 +> 文档边界:§3/§9–§13 包含早期目标、历史验收表与设计草案,不是当前版本全部实现的规格书。当前 0.5.1的安装、兼容与验证状态以 README / 包内 README 为准;§6/§8 为当前架构与权限说明。 ## 1. 项目定位 @@ -384,8 +384,8 @@ type SplitPane = { 分组、与发送到会话同源),绑定后打开该项目时右侧对话窗自动切换(sessions.open);解绑即 不再切换;绑定关系存 projects.v1.bindings(项目 id → 会话 id)。 - **任务完成/待决提醒镜像**(2026-08-18):绑定会话在宿主快照 byId 里 completed=true → 项目卡 - 双圆点绿色发光(data-bound=done);pendingInteraction != null → 黄色发光(data-bound=need), - 与原生对话小绿点/小黄点同步;点开项目即确认(ack,notifyAck.v1 按会话存状态)恢复常态实心; + 双圆点中性发光(data-bound=done,仅表示本轮结束);pendingInteraction != null → 黄色发光(data-bound=need), + 仅同步宿主结束/待决提醒,不把结束视为成功;点开项目即确认(ack,notifyAck.v1 按会话存状态)恢复常态实心; 状态切换(完成↔待决)会重新点亮;新版 pending key/kind 或待决子会话集合变化也重新点亮。 控制室计时只读后台任务或新版 chat.legacy.turnTimings / 旧会话面,未知起点不虚构时长。 数据源 sessionsSnapshotStore(syncSessionScope 推送完整 @@ -454,7 +454,7 @@ type SplitPane = { 用户自建布局 = 同一引擎的运行时产物,共用 tiling 引擎与持久化; - 本地快捷方式、接入指引保留于「+」面板第二入口。 -## 14. 当前实现参考(0.5.0) +## 14. 当前实现参考(0.5.1) 以下为界面、状态与产物协议的实现细节,供改动对应组件时对照源码;约束与验证入口见 AGENTS.md,发布状态见 README。0.2 导航使用 openHostSession,新旧 API 桥接见 sessionCompat.ts/sessionDetails.ts;不要将旧接口示例当作通用公开 API。 @@ -513,7 +513,7 @@ type SplitPane = { 参考模板。新增组件样式只加到 dshell.css,保持单一来源。 - **任务完成/待决提醒镜像**:绑定会话在宿主快照 byId 里 completed=true → 项目卡双圆点变 - 绿色发光(data-bound=done);pendingInteraction != null → 黄色发光(data-bound=need); + 中性发光(data-bound=done,仅表示本轮结束);pendingInteraction != null → 黄色发光(data-bound=need); 点开项目 = ack(notifyAck.v1 按会话存状态)恢复常态实心。数据源 = sessionsSnapshotStore (syncSessionScope 写入完整快照并通知监听者);跨状态(done↔need)会重新点亮。 **工作中(busy)**:byId[sid].running === true → data-bound=busy,蓝色 #4f8ef7 发光 + @@ -535,7 +535,7 @@ type SplitPane = { 未绑定 → 强制绑定弹窗(左「加入现有对话」列表 / 右「新建对话」:分组 无/现有/新建, 仅在宿主允许且用户分组选择有效时创建空会话并绑定;DSH 0.2 未分组空会话提示并禁用创建,不改组、不发激活消息)。绑定也走 projects.v1.bindings。 - 控制室面板(split.tsx ConsolePane):卡片网格每行 3 张、超出换行;每卡 = 图标/名称/ - 状态大字与三色光效(need>done>busy>idle,不过滤 ack,永远显示事实状态)/运行时长 + 状态大字与提醒光效(need>busy>done>idle,不过滤 ack;done 只表示本轮结束,不表示成功)/运行时长 (后台任务 JobView.startedAt → 新版 uiConversation 的 chat.legacy.turnTimings → 旧会话面, 未加载/无起点不显示时长;不为计时激活 chat 或持有冷会话)/最近消息预览。数据组装 = index.tsx getConsoleCards(env.console 注入),刷新走 consoleListeners(项目/会话 @@ -550,7 +550,7 @@ type SplitPane = { - 状态光效(整卡霓虹描边,参考侧栏双圆点发光质感):工作=蓝色彗星式光点顺时针绕卡旋转 (.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);完成=绿光、待决=黄光 + rgba(140,190,255,.85) 光点自带辉光、外发光双层 10px+34px);本轮结束=中性光、待决=黄光 (-glowDone/-glowNeed:亮色描边 + 双层外发光 + 微弱内辉光;glow 字段 = done/need 且 本轮未 ack,点卡片先 onAck 熄光再进入,与提醒 ack 生命周期一致)。 - 命名:侧栏区块标题 = 「工作台」(title locale,整个插件);默认项目卡名与面板标题 = @@ -575,15 +575,14 @@ type SplitPane = { 新版本横幅=复制升级指令(点击变绿并提示「请在任意对话中发送」)/查看发布页(蓝 hover)/忽略此版本(红 hover)+ CHANGELOG_V030 正文(changelog.ts 纯文本,不做 md 渲染); 检查核心 = updateCheck.ts(updateCache.v1 缓存,与设置面板旧更新卡共用 lastUpdateCheck/skipVersion/updateCheck 键, - 旧卡暂保留未去重);铃铛按钮发现新版本时右上角琥珀呼吸灯(dockBadge)。 + 两处请求循环暂保留,版本排序/候选过滤统一用 updateCheck.ts;缓存重验 latest>本机且未忽略,非新版/失败清旧提示,避免降级提醒);铃铛按钮确有更高版本时右上角琥珀呼吸灯(dockBadge)。 - 指哪打哪标注:窗格折叠键旁 annotBtn——蓝泡光标→点选/拖框(小拖=点)→输入→✓ 注入宿主 textarea(不发送,失败回退剪贴板)。 payload v3.2:窗口身份(编号+窗格标题+内容类型/URL)+ 主目标(caretPositionFromPoint 取字 + computed style 字号)+ 整行 + 候选; 隔离 iframe 下钻取字被浏览器拒绝时输出「读取受限」+ src,保留窗口级信息;不能取消 sandbox 恢复取字。提示词含「禁止编造,缺失时如实说明,建议截图或视觉模型」; 知识包含标注协议行(窗口编号+处理方式);回退锚点 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 字符回退更早消息)→ + (旧版运行期内建方法、非公开接口);尾部仅扫 assistant/message 的 text 块,排除事件根 surfaceOp 明确非 append 的模型替换副本 → cleanPreviewText(滤除 ```围栏与行内代码、压缩空白;短答复保留,纯代码回退更早助手文本,无答复留空)→ previewCache;sweepPreviews 在打开控制室时 + 控制室开着且会话快照变化防抖 6s 触发; 失败静默回退缓存/内存路径 lastTextOf;预热期间不因自身 retain/release 排入新一轮。拉取是带宽成本不是 Token 成本。 - 状态指示:卡片右上角小圆点已删(整卡光效表达状态);状态计算不变。 diff --git a/04_test/session-compat.test.mjs b/04_test/session-compat.test.mjs index d337517..34a5586 100644 --- a/04_test/session-compat.test.mjs +++ b/04_test/session-compat.test.mjs @@ -9,7 +9,7 @@ const { buildSync } = require('esbuild') const code = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/sessionCompat.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text const module = { exports: {} } runInNewContext(code, { module, exports: module.exports, setTimeout }) -const { createSessionSnapshotAdapter, sendHostSession, childSessionIdsOf, pendingAckOf, sessionRuntimeMs } = module.exports +const { createSessionSnapshotAdapter, sendHostSession, childSessionIdsOf, pendingAckOf, sessionRuntimeMs, consoleStatusOf } = module.exports const list = (running = false) => ({ phase: 'ready', ids: ['a'], byId: { a: { id: 'a', running, retainedBy: { mainView: 1 } } }, projectionsBySession: {} }) const source = (patch = {}) => ({ getSnapshot: () => new Map([['a', { running: false, pendingInteraction: undefined, completionUnread: false, ...patch }]]) }) @@ -58,6 +58,17 @@ test('ready removal clears run memory; transient pending list does not', () => { adapt({ phase: 'ready', byId: {} }) assert.equal(adapt(list()).byId.a.completed, false) }) +test('console run-stop is neutral ended status even when the host reports an error', () => { + assert.equal(consoleStatusOf({ completed: true, running: false, error: { code: 'INVALID_REQUEST' } }, null), 'done') + assert.equal(consoleStatusOf({ running: false }, null, { completed: true }), 'done') + assert.equal(consoleStatusOf({ running: false }, null), 'idle') + assert.equal(consoleStatusOf(undefined, null), 'idle') +}) +test('console running overrides a stale completion, while pending stays first', () => { + assert.equal(consoleStatusOf({ completed: true, running: true }, null), 'busy') + assert.equal(consoleStatusOf({ running: true }, null, { completed: true }), 'busy') + assert.equal(consoleStatusOf({ completed: true, running: true }, 'need:fixture'), 'need') +}) function sendFixture(outcome = { kind: 'success' }) { const calls = [] const face = { prompt() { assert.fail('must not resend through prompt') } } diff --git a/04_test/session-details.test.mjs b/04_test/session-details.test.mjs index 933363f..9120655 100644 --- a/04_test/session-details.test.mjs +++ b/04_test/session-details.test.mjs @@ -10,7 +10,7 @@ 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, blankSessionNeedsWorkspace } = module.exports +const { cleanPreviewText, previewFromEvents, previewFromNodes, 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', () => { @@ -24,12 +24,47 @@ test('reasoning, tools and transient chunks never become a preview', () => { const entries = [msg('A readable earlier message'), { event: { type: 'assistant/message', data: { content: [{ type: 'reasoning', text: 'private reasoning' }] } } }, msg('tool argument', 'tool/call'), msg('stream token', 'assistant/live-chunk')] assert.equal(previewFromEvents(entries), 'A readable earlier message') }) -test('short/code-only latest message falls back, malformed input stays empty, length bounded', () => { +test('code-only latest message falls back, malformed input stays empty, length bounded', () => { assert.equal(previewFromEvents([msg('Earlier readable message'), msg('```js\ncode\n```')]), 'Earlier readable message') assert.equal(previewFromEvents(null), '') assert.equal(previewFromEvents([{ event: { type: 'user/message', data: { content: 'not blocks' } } }]), '') assert.equal(previewFromEvents([msg('x'.repeat(300))]).length, 220) }) +test('preview uses assistant replies instead of later user or injected context', () => { + const entries = [msg('Actual assistant answer'), msg('A real new question', 'user/message'), + { event: { type: 'user/message', data: { source: { kind: 'runtime-context' }, content: [{ type: 'text', text: 'Time sampled while preparing turn: injected context' }] } } }] + const before = JSON.stringify(entries) + assert.equal(previewFromEvents(entries), 'Actual assistant answer') + assert.equal(previewFromEvents(entries.slice(1)), '') + assert.equal(JSON.stringify(entries), before) +}) +test('short assistant replies are retained, not replaced by earlier text', () => { + assert.equal(previewFromEvents([msg('An earlier long answer'), msg('好了')]), '好了') +}) +test('model-only replacement messages never replace the visible assistant preview', () => { + const replacement = msg('Internal rewritten model-only text') + replacement.event.surfaceOp = { op: 'replace', startSeq: 1, endSeq: 2 } + assert.equal(previewFromEvents([msg('Visible answer'), replacement]), 'Visible answer') + assert.equal(previewFromEvents([replacement]), '') + const appended = msg('Visible appended reply') + appended.event.surfaceOp = 'append' + assert.equal(previewFromEvents([appended]), 'Visible appended reply') +}) + +test('legacy nodes include only assistant text blocks, never context or reasoning', () => { + const nodes = [{ kind: 'assistant', blocks: [{ kind: 'text', text: 'Earlier reply' }] }, + { kind: 'assistant', blocks: [{ kind: 'reasoning', text: 'private reasoning' }, { kind: 'text', text: '好了' }] }, + { kind: 'context', blocks: [{ kind: 'text', text: 'Injected auxiliary context' }] }, + { kind: 'user', blocks: [{ kind: 'text', text: 'Latest question' }] }, + { blocks: [{ type: 'text', text: 'Unknown node, must not guess' }] }, + { kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: 'Interrupted unfinished reply' }] }] + const before = JSON.stringify(nodes) + assert.equal(previewFromNodes(nodes), '好了') + assert.equal(previewFromNodes(nodes.slice(2)), '') + assert.equal(previewFromNodes(null), '') + assert.equal(JSON.stringify(nodes), before) +}) + function held(binding, action) { const calls = [] let active = false diff --git a/04_test/sidebar-popup.test.mjs b/04_test/sidebar-popup.test.mjs index 5f76d36..99a9b2e 100644 --- a/04_test/sidebar-popup.test.mjs +++ b/04_test/sidebar-popup.test.mjs @@ -1,5 +1,5 @@ /** - * WorktableSection popup regression against the actual client bundle. + * Sidebar popup and control-room presentation regressions against the actual client bundle. * Usage: node 04_test/sidebar-popup.test.mjs [lib/client.js] * The small React hook/JSX harness runs the exported component and its event handlers; * it does not claim browser layout or desktop GUI coverage. @@ -77,7 +77,7 @@ function fakeElement(tagName = 'div') { } } -function loadComponent() { +function loadComponent({ stored = {}, fetch: fetchFixture } = {}) { const values = new Map([ ['dsh.worktable.updateCheck.v1', '0'], ['dsh.worktable.projects.v1', JSON.stringify({ @@ -90,6 +90,7 @@ function loadComponent() { }], })], ]) + for (const [key, value] of Object.entries(stored)) values.set(key, String(value)) const localStorage = { getItem: (key) => values.has(key) ? values.get(key) : null, setItem: (key, value) => values.set(key, String(value)), @@ -132,7 +133,7 @@ function loadComponent() { console, atob, btoa, - fetch: async () => ({ ok: false, status: 404, json: async () => ({}) }), + fetch: fetchFixture ?? (async () => ({ ok: false, status: 404, json: async () => ({}) })), setTimeout: window.setTimeout, clearTimeout: window.clearTimeout, setInterval: window.setInterval, @@ -147,7 +148,12 @@ function loadComponent() { indexedDB: undefined, } createContext(sandbox) - runInNewContext(source, sandbox, { filename: BUNDLE, timeout: 20000 }) + // Expose existing bundle locals in memory only; the delivered module stays unchanged. + const footer = 'return module.exports; } });' + assert(source.split(footer).length === 2, 'client bundle footer must be unique') + const instrumented = source.replace(footer, + 'Object.defineProperty(module.exports, "__consoleTest", { value: Object.freeze({ ConsolePane, setSplitEnv, setSplitT, zh, en, css, sessionNotifyState, LOCAL_VERSION, cmpVer, readCache }) });\n' + footer) + runInNewContext(instrumented, sandbox, { filename: BUNDLE, timeout: 20000 }) assert(typeof window.captured?.factory === 'function', 'client bundle did not register a factory') const requireExternal = (id) => { if (id === 'react') return hooks.React @@ -158,7 +164,7 @@ function loadComponent() { } const exports = window.captured.factory(requireExternal) assert(typeof exports.WorktableSection === 'function', 'bundle did not export WorktableSection') - return { component: exports.WorktableSection, hooks, window, values } + return { component: exports.WorktableSection, hooks, window, values, consoleTest: exports.__consoleTest } } function walk(node, predicate, out = []) { @@ -241,4 +247,149 @@ async function main() { console.log('all sidebar-popup tests passed: add, icon, binding, delete confirmation, wide/collapsed switching') } +function testConsolePresentation() { + for (const [language, ended] of [['zh', '本轮已结束'], ['en', 'Run ended']]) { + const { hooks, consoleTest: api } = loadComponent() + const dictionary = api[language] + const actions = [] + const cards = ['done', 'need', 'busy'].map((status) => ({ + id: status, name: status, icon: '•', status, runtimeMs: null, kids: 0, + preview: 'Actual assistant reply', bound: true, self: false, glow: status !== 'busy', + })) + api.setSplitT((key) => dictionary[key] ?? key) + api.setSplitEnv({ console: { + getCards: () => cards, getTheme: () => 'dark', getBg: () => 'plain', + subscribe: () => () => {}, getPhotoLib: async () => ({ list: [], activeId: null }), + onAck: (id) => actions.push('ack:' + id), onOpen: (id) => actions.push('open:' + id), + } }) + let tree = hooks.render(api.ConsolePane, {}) + for (const status of ['done', 'need', 'busy']) { + const labels = byClass(tree, 'dsh-wt_consoleStatus-' + status) + const expected = status === 'done' ? ended : dictionary['console.' + status] + assert(labels.length === 1 && labels[0].props.children === expected, language + ' incorrect ' + status + ' label') + } + const tip = dictionary['bind.tipDone'] + assert(tip.includes(language === 'zh' ? ended : 'run ended'), language + ' sidebar tip does not describe a run ending') + assert(!/已完成|成功|completed|success/i.test(tip), language + ' sidebar tip still claims success') + assert(byClass(tree, 'dsh-wt_consoleCard-glowDone').length === 1, 'unread end reminder was lost') + assert(byClass(tree, 'dsh-wt_consoleCard-glowNeed').length === 1, 'pending reminder was lost') + assert(byClass(tree, 'dsh-wt_consoleCard-busy').length === 1, 'working indicator was lost') + assert(byClass(tree, 'dsh-wt_consoleSweep').length === 1, 'working animation was lost') + const previews = byClass(tree, 'dsh-wt_consolePreview') + assert(previews.length === 3 && previews.every((node) => node.props.children === 'Actual assistant reply'), 'card preview was missing or replaced') + click(byClass(tree, 'dsh-wt_consoleCard-glowDone')[0]) + assert(actions.join(',') === 'ack:done,open:done', 'ended card must acknowledge before opening') + cards[0].glow = false + tree = hooks.render(api.ConsolePane, {}) + assert(byClass(tree, 'dsh-wt_consoleCard-glowDone').length === 0, 'acknowledged end still glows') + assert(byClass(tree, 'dsh-wt_consoleStatus-done')[0].props.children === ended, 'acknowledgement erased ended status') + + const rule = (selector) => { + const start = api.css.indexOf(selector + '{') + assert(start >= 0, 'missing bundled CSS rule: ' + selector) + return api.css.slice(start + selector.length + 1, api.css.indexOf('}', start)) + } + for (const selector of ['.dsh-wt_bindBtn[data-bound=done]', '.dsh-wt_consoleStatus-done', '.dsh-wt_consoleCard-glowDone']) { + assert(!/state-success|#3fb950|rgba?\(63,\s*185,\s*80/i.test(rule(selector)), 'ended reminder still uses success green: ' + selector) + } + assert(rule('.dsh-wt_bindBtn[data-bound=need]').includes('#d29922'), 'sidebar pending color changed') + assert(rule('.dsh-wt_bindBtn[data-bound=busy]').includes('#4f8ef7'), 'sidebar working color changed') + assert(rule('.dsh-wt_consoleStatus-need').includes('state-warning'), 'control-room pending color changed') + assert(rule('.dsh-wt_consoleStatus-busy').includes('state-accent-primary'), 'control-room working color changed') + assert(api.sessionNotifyState({ running: true, completed: true }) === null, 'stale end must not hide a running sidebar session') + assert(api.sessionNotifyState({ running: true, completed: true, pendingInteraction: { key: 'fixture' } }) === 'need', 'pending must remain first') + assert(api.sessionNotifyState({ running: false, completed: true }) === 'done', 'run ending lost its existing ack kind') + } + console.log('all control-room presentation tests passed: zh/en, neutral end, need/busy, preview, acknowledgement') +} + +async function testUpdatePresentation() { + const releaseUrl = 'https://api.github.com/repos/Aisland-SJL/dsh-worktable/releases/latest' + for (const outcome of ['older', 'same', 'forbidden', 'invalid', 'invalid-prefix', 'skip']) { + let response = { status: 200, tag: 'v9.0.0' } + let requests = 0 + const { component, hooks, values, consoleTest: api } = loadComponent({ fetch: async (url) => { + assert(url === releaseUrl, 'unexpected request in update fixture: ' + url) + requests++ + return { ok: response.status === 200, status: response.status, + json: async () => ({ tag_name: response.tag, body: 'Fixture release', html_url: 'https://example.invalid/release' }) } + } }) + assert(api.cmpVer('0.5.0', api.LOCAL_VERSION) < 0, 'bundled comparator does not reject the old release') + const render = () => hooks.render(component, { wide: true, t: (key) => key }) + let tree = render() + click(walk(tree, (node) => node.type === 'button' && node.props?.['aria-label'] === 'menu.viewOptions')[0]) + tree = render() + const check = async () => { + click(byClass(byClass(tree, 'dsh-wt_versionRow')[0], 'dsh-wt_updateBtn')[0]) + tree = render() + assert(byClass(byClass(tree, 'dsh-wt_versionRow')[0], 'dsh-wt_updateBtn')[0].props.disabled, 'check button did not enter checking state') + await new Promise(setImmediate) + tree = render() + } + await check() + assert(requests === 1, 'manual check did not issue one fixture request') + assert(byClass(tree, 'dsh-wt_updateCard').length === 1 && byClass(tree, 'dsh-wt_updateBadge').length === 1, 'genuine newer release did not show card and badge') + assert(JSON.stringify(byClass(tree, 'dsh-wt_updateVers')[0].props.children).includes('9.0.0'), 'newer release label missing') + if (outcome === 'skip') { + click(walk(tree, (node) => node.type === 'button' && node.props?.children === 'update.skip')[0]) + tree = render() + assert(values.get('dsh.worktable.skipVersion.v1') === '9.0.0', 'skip button did not persist the shown release') + assert(byClass(tree, 'dsh-wt_updateCard').length === 0 && byClass(tree, 'dsh-wt_updateBadge').length === 0, 'skip button left visible update UI') + } else { + response = outcome === 'forbidden' ? { status: 403 } : { status: 200, + tag: outcome === 'older' ? 'v0.5.0' : outcome === 'same' ? 'v' + api.LOCAL_VERSION : outcome === 'invalid-prefix' ? 'vv9.0.0' : 'not-a-version' } + } + await check() + assert(requests === 2, outcome + ': unexpected retries or skipped manual request') + for (const name of ['dsh-wt_updateCard', 'dsh-wt_updateBadge', 'dsh-wt_updateBtnCopy']) { + assert(byClass(tree, name).length === 0, outcome + ': stale update UI survived: ' + name) + } + if (['forbidden', 'invalid', 'invalid-prefix'].includes(outcome)) { + assert(JSON.stringify(byClass(tree, 'dsh-wt_versionRow')[0].props.children).includes('update.checkFail'), 'failed check was hidden by old update info') + } + } + + const localVersion = loadComponent().consoleTest.LOCAL_VERSION + for (const [label, latest, skipped, shown] of [ + ['older', '0.5.0', '', false], ['same', localVersion, '', false], + ['invalid', 'not-a-version', '', false], ['skipped', '9.0.0', '9.0.0', false], + ['newer', '9.0.0', '', true], + ]) { + let requests = 0 + const { hooks, values, consoleTest: api } = loadComponent({ + stored: { + 'dsh.worktable.updateCheck.v1': '0', + 'dsh.worktable.lastUpdateCheck.v1': String(Date.now()), + 'dsh.worktable.skipVersion.v1': skipped, + 'dsh.worktable.updateCache.v1': JSON.stringify({ status: 'uptodate', info: { latest, notes: 'Fixture cache', url: 'https://example.invalid/release' } }), + }, + fetch: async () => { requests++; return { ok: false, status: 404 } }, + }) + api.setSplitT((key) => key) + api.setSplitEnv({ console: { + getCards: () => [], getTheme: () => 'dark', getBg: () => 'plain', + subscribe: () => () => {}, getPhotoLib: async () => ({ list: [], activeId: null }), + } }) + const render = () => hooks.render(api.ConsolePane, {}) + let tree = render() + assert(byClass(tree, 'dsh-wt_dockBadge').length === Number(shown), label + ': wrong cached dock badge') + click(walk(tree, (node) => node.type === 'button' && node.props?.['aria-label'] === 'annot.updateTitle')[0]) + tree = render() + for (const name of ['dsh-wt_announceUpdate', 'dsh-wt_announceUpg']) { + assert(byClass(tree, name).length === Number(shown), label + ': wrong cached announcement: ' + name) + } + assert((api.readCache().info != null) === shown, label + ': cache reader still exposes an ineligible release') + if (shown) { + values.set('dsh.worktable.skipVersion.v1', latest) + tree = render() + assert(byClass(tree, 'dsh-wt_announceUpdate').length === 0 && byClass(tree, 'dsh-wt_dockBadge').length === 0, 'already-mounted control room retained ignored update info') + } + await new Promise(setImmediate) + assert(requests === 0, label + ': disabled automatic update check made a request') + } + console.log('all update presentation tests passed: settings transitions and control-room cache, no network') +} + await main() +testConsolePresentation() +await testUpdatePresentation() diff --git a/04_test/update-check.test.mjs b/04_test/update-check.test.mjs new file mode 100644 index 0000000..af8bb40 --- /dev/null +++ b/04_test/update-check.test.mjs @@ -0,0 +1,118 @@ +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 LOCAL = '0.5.1-dev.2' +const code = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/updateCheck.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs', define: { __WT_VERSION__: JSON.stringify(LOCAL) } }).outputFiles[0].text +const CACHE = 'dsh.worktable.updateCache.v1', LAST = 'dsh.worktable.lastUpdateCheck.v1', SKIP = 'dsh.worktable.skipVersion.v1' +function fixture(reply = { tag_name: 'v0.5.0' }) { + const values = new Map(), calls = [], module = { exports: {} } + let response = reply + const localStorage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, String(value)) } + runInNewContext(code, { module, exports: module.exports, localStorage, AbortController, setTimeout, clearTimeout, + fetch: async (url, options) => { + calls.push([url, options]) + if (response instanceof Error) throw response + return { ok: !response?.http, status: response?.http ?? 200, json: async () => response } + }, + }) + return { api: module.exports, values, calls, localStorage, respond: value => { response = value } } +} +const info = latest => ({ latest, notes: 'Release notes', url: 'https://github.com/Aisland-SJL/dsh-worktable/releases/tag/v' + latest }) +const { cmpVer } = fixture().api + +test('version precedence covers old release, candidate, stable, numeric prerelease and build metadata', () => { + const pairs = [ + ['0.5.0', LOCAL, -1], [LOCAL, '0.5.1', -1], ['0.5.1-dev.2', '0.5.1-dev.10', -1], + ['0.5.1-alpha', '0.5.1-alpha.1', -1], ['0.5.1-alpha.1', '0.5.1-alpha.beta', -1], + ['0.5.1-beta.11', '0.5.1-rc.1', -1], ['0.5.1-1', '0.5.1-alpha', -1], + ['0.5.1-A', '0.5.1-a', -1], ['v0.5.1', '0.5.1', 0], + ['0.5.1+build.9', '0.5.1+build.10', 0], ['0.5.1-dev.2+one', LOCAL, 0], + ['99999999999999999999.0.0', '99999999999999999998.9.9', 1], + ['0.5.1-dev.99999999999999999999', '0.5.1-dev.99999999999999999998', 1], + ] + for (const [a, b, expected] of pairs) { + assert.equal(cmpVer(a, b), expected, a + ' vs ' + b) + assert.equal(cmpVer(b, a), expected === 0 ? 0 : -expected) + } +}) +test('invalid versions are explicitly incomparable, never NaN or an upgrade', () => { + for (const bad of ['', 'dev', '0.5', '01.5.1', '0.5.1-dev.02', '0.5.1-', '0.5.1+','0.5.1-dev..2', 'v0.5.1x', 'vv0.5.1', '0.5.1.2', 42, null]) { + assert.equal(cmpVer(bad, LOCAL), null, String(bad)) + assert.equal(cmpVer(LOCAL, bad), null, String(bad)) + } +}) +test('cache rejects older, equal and malformed update info without touching unrelated state', () => { + const f = fixture(); f.values.set('fixture.project', 'keep') + for (const value of [info('0.5.0'), info(LOCAL), info('not-a-version'), { latest: '9.0.0' }, ['9.0.0'], null]) { + f.values.set(CACHE, JSON.stringify({ status: 'uptodate', info: value })) + assert.equal(f.api.readCache().info, null) + } + f.values.set(CACHE, '{broken json') + assert.equal(f.api.readCache().info, null) + f.values.set(CACHE, JSON.stringify({ status: 'made-up', info: info('9.0.0') })) + assert.equal(f.api.readCache().status, 'idle') + assert.equal(f.values.get('fixture.project'), 'keep') +}) +test('newer cache remains usable but rechecks a later skip on every read', () => { + const f = fixture(); f.values.set(CACHE, JSON.stringify({ status: 'uptodate', info: info('9.0.0') })) + assert.equal(f.api.readCache().info.latest, '9.0.0') + f.api.setSkipVersion('v9.0.0') + assert.equal(f.api.readCache().info, null) +}) +test('24-hour throttle and disabled automatic checks cannot resurrect an old cache', async () => { + const f = fixture() + f.values.set(CACHE, JSON.stringify({ status: 'uptodate', info: info('0.5.0') })) + f.values.set(LAST, String(Date.now())); f.values.set('dsh.worktable.updateCheck.v1', '0') + assert.equal(f.api.getAutoCheck(), false) + assert.equal(f.api.readCache().info, null) + assert.equal((await f.api.checkUpdate()).info, null) + assert.equal(f.calls.length, 0) +}) +test('a real stable release above this candidate is offered, notes are bounded', async () => { + const f = fixture({ tag_name: 'v0.5.1', body: 'x'.repeat(1000), html_url: info('0.5.1').url }) + const result = await f.api.checkUpdate(true) + assert.equal(result.status, 'uptodate'); assert.equal(result.info.latest, '0.5.1'); assert.equal(result.info.notes.length, 800) + assert.equal(f.calls.length, 1) + assert.equal(f.api.readCache().info.latest, '0.5.1') +}) +test('older, equal and skipped network releases clear a previous update', async () => { + for (const tag of ['0.5.0', LOCAL, '9.0.0']) { + const f = fixture({ tag_name: 'v' + tag }); f.values.set(CACHE, JSON.stringify({ status: 'uptodate', info: info('9.0.0') })) + if (tag === '9.0.0') f.api.setSkipVersion(tag) + const result = await f.api.checkUpdate(true) + assert.equal(result.status, 'uptodate'); assert.equal(result.info, null) + assert.equal(f.api.readCache().info, null) + } +}) +test('malformed release versions fail rather than claiming an update or latest', async () => { + for (const payload of [{}, { tag_name: 123 }, { tag_name: 'banana' }, { tag_name: '0.5' }, { tag_name: 'vv9.0.0' }]) { + const f = fixture(payload); const result = await f.api.checkUpdate(true) + assert.equal(result.status, 'failed'); assert.equal(result.info, null) + } +}) +test('network failures clear stale updates, retry at most three times and stop on 403/404', async () => { + for (const [reply, expectedCalls] of [[new Error('offline'), 3], [{ http: 500 }, 3], [{ http: 403 }, 1], [{ http: 404 }, 1]]) { + const f = fixture(reply); f.values.set(CACHE, JSON.stringify({ status: 'uptodate', info: info('9.0.0') })) + const result = await f.api.checkUpdate(true) + assert.equal(result.status, 'failed'); assert.equal(result.info, null); assert.equal(f.api.readCache().info, null) + assert.equal(f.calls.length, expectedCalls) + } +}) +test('invalid and future check timestamps do not suppress a fresh request', async () => { + for (const last of ['NaN', '-1', String(Date.now() + 86400000)]) { + const f = fixture(); f.values.set(LAST, last) + await f.api.checkUpdate(false) + assert.equal(f.calls.length, 1) + } +}) +test('storage access failure cannot turn an older release into an update', async () => { + const f = fixture(); f.localStorage.getItem = () => { throw new Error('storage unavailable') } + f.localStorage.setItem = () => { throw new Error('storage unavailable') } + const result = await f.api.checkUpdate(true) + assert.equal(result.status, 'uptodate'); assert.equal(result.info, null) +}) diff --git a/AGENTS.md b/AGENTS.md index c20fea5..ab1ad73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,13 +32,23 @@ - **平台边界**:Windows 网页端已测范围见 `01_content/README.md`,不宣称全功能验收;桌面已测版本与候选边界同包内 README;macOS 为实验性支持(核心文件路径代码已做跨平台适配, 尚未真机端到端验证)。路径拼接必须走 `pathutil.ts` helper 或 Node `path` API,不手写分隔符。 -## 当前 0.5.0 安全与兼容范围 +## 当前 0.5.1 发布批次 + +- 用户于2026-10-31明确授权:统一0.5.1版本/公告/安装链接,重新打包验收,通过后提交、推送并发布;不覆盖旧Release资产。公开状态以GitHub Release为准。 +- rc.5 的dev.1隔离GUI 15项通过;dev.2修正中性结束提醒与助手摘要,dev.3修复更新误报。用户已在本机官方Desktop确认dev.3误报消失、看起来正常;正式0.5.1另按最终包验收,不称正式包全功能GUI验证。本机仍装dev.3(SHA 620b3bc8b798…),发布不自动替换或重启正式宿主。临时测试环境已清理。 +- rc.5 claimed command 是单字符 chip,但 0.2.0 mainView 仍是显示文本,不能只据 mainView 判断命令坐标;本轮保持既有安全剪贴板降级,后续需明确能力契约再适配。 +- 兼容目标精确增加 DSH 0.2.1-rc.5;不得以通配或真实环境豁免代替隔离验证。 +- rc.5 适配日志位于 E:/AI_Workspace/DeepseekHarness/log/2026-10-30_worktable-rc5兼容适配;dev.2 显示修正与最终包检查见 log/2026-10-31_worktable控制室显示修正/2026-10-31_控制室显示修正与候选检查报告.md(相对 DeepseekHarness 根)。更新误报修正与dev.3安装见同目录2026-10-31_更新误报修复与dev3安装报告.md。先看实测记录再描述完成范围。 +- 保留任务开始前 index.tsx 的一行任务标题文案修改,不能将其说成本轮接口修复;旧 0.5.0 发布资产不覆盖。 +- 以下 0.5.0 条目是既有安全边界与历史证据,rc.5 以本轮候选验收记录为准。 + +## 0.5.0安全基础(0.5.1继续保留) - 私有 HTTP/终端握手统一调用 `requestAuth.ts`:新宿主要求 owner,0.2.0 仅放行同一 operator 对象;未知/不可用服务关闭访问,不用 requestRejection-only 回退。health 与随机只读预览地址是明确例外。 - `SandboxedFrame` 禁止 `allow-same-origin`;本地目录先鉴权创建临时只读挂载,Desktop 资源走 Host HTTP 地址。不得为恢复页面存储/自动取字而取消隔离。影响、保活与过期语义必须见包内 README。 - 预览读取必须绑定同一文件句柄、限量并复核文件身份/路径及撤销状态;保留目录替换/检查后增长等回归。该机制不等于恶意本地进程的 OS 文件沙箱,文档不得作绝对防护承诺。 -- 当前版本只声明 DSH 0.2.0-rc.2 / 0.2.1-rc.3;网页隔离实测与 Desktop 传输单测不能冒充真实 Desktop 全功能验收。下文旧版本兼容条目是历史依据。 -- `npm run test:security` = 22 项源码/夹具回归;流水线源码回归合计 122 项。安装产物另跑 `node 04_test/split-margin.test.mjs [client.js 路径]` 的 5 项真实 Chromium 回归(Edge/Chrome 或 `DSH_TEST_CHROMIUM`),改 margin/换根观察器必须保留旧产物变红的回归证据。 +- 0.5.0当时只声明 DSH 0.2.0-rc.2 / 0.2.1-rc.3;0.5.1增加已测rc.5。网页隔离实测与Desktop传输单测不能冒充真实Desktop全功能验收,下文旧版本条目只作历史依据。 +- `npm run test:security` = 22 项源码/夹具回归;流水线源码回归合计 139 项。安装产物另跑 `node 04_test/split-margin.test.mjs [client.js 路径]` 的 5 项真实 Chromium 回归(Edge/Chrome 或 `DSH_TEST_CHROMIUM`),改 margin/换根观察器必须保留旧产物变红的回归证据。 ## 构建与验证 @@ -54,7 +64,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 严格结构)→ - 版本一致性 → **会话/输入/传输/挂载/安全回归**(当前 122 项,评估源码契约及夹具)→ 构建(cwd 固定 01_content)→ node --check → npm pack → 结构清单断言(package/ 前缀 + 精确 7 文件, + 版本一致性 → **会话/输入/传输/挂载/安全回归**(当前 139 项,评估源码契约及夹具)→ 构建(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 回归**(14 场景,评估安装产物 lib/client.js; @@ -81,8 +91,8 @@ node --check lib/index.js - **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` 必须区分显式未分组(第四参数 `none`)与隐式默认(`auto`)。自定义窗口显式未分组仅传 cwd(无目录则空选项),禁止注册/复用工作区或 initializeDefault;新版控制室空未分组会话输入区禁用,必须提示并阻止创建,由用户选组/加入可用现有会话,不偷偷改组、不发激活消息。现有分组未选 ID 同样拒绝;显式现有/新建组才传 workspaceId。分组列表晚返回不得覆盖用户已选分组。隐式默认保留此前工作区兜底,旧宿主保留原分支,不移动既有会话。预览读临时持有的 eventSource,模型目录在持有期间使用,结束即释放;预设接 remote.agentPresets。`npm run test:details` 为 30 项回归,含两处真实调用接线与延迟默认分组;不能替代真实输入区/模型会话验收。 +- **0.2 会话状态/发送**:可选订阅 `uiSession.sessionStatus`,只读归一化列表与 subagentCatalog;completionUnread 不是任务成功证明,打开主会话会清除。已观察到的 running→false 供原产物握手使用,下一轮 running 清除;停止不等于成功。新会话发送通过 `sessions.using` 临时持有,等待 ready 后发送并由宿主释放;业务拒绝/超时不得再换通道重发。`npm run test:session` 为 22 项回归;0.2.0-rc.2 已实测短文本发送/回复,真实待决/子代理仍未验收。 +- **0.2 新建/预览/选择接口**:`createHostSession` 必须区分显式未分组(第四参数 `none`)与隐式默认(`auto`)。自定义窗口显式未分组仅传 cwd(无目录则空选项),禁止注册/复用工作区或 initializeDefault;新版控制室空未分组会话输入区禁用,必须提示并阻止创建,由用户选组/加入可用现有会话,不偷偷改组、不发激活消息。现有分组未选 ID 同样拒绝;显式现有/新建组才传 workspaceId。分组列表晚返回不得覆盖用户已选分组。隐式默认保留此前工作区兜底,旧宿主保留原分支,不移动既有会话。预览读临时持有的 eventSource,模型目录在持有期间使用,结束即释放;预设接 remote.agentPresets。`npm run test:details` 为 34 项回归,含两处真实调用接线与延迟默认分组;不能替代真实输入区/模型会话验收。 - **窗口编号**:用户说「窗口1/2/3…」指布局里按「左栏 → 顶行 → 主行」顺序的第 N 个内容窗。 例:田字格预设(g4)窗口1/2 = 顶行左右、窗口3/4 = 底行左右;l13 窗口1 = 顶部大窗, @@ -103,8 +113,8 @@ node --check lib/index.js 插件通用知识;窗口任务提示词只发用户自己的会话,允许携带该项目自己的文件夹路径。 分组下拉只是会话创建工作区的选择,绝不进入任何提示词文本。 - **提醒红线**: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。插件仅提示不自升级,安装/重启留给用户。 +- **控制室红线**:wt-console 固定首位、不可删除、唯一 console 标签不可关;隐藏项目不入网格。状态/计时/预览只镜像宿主,不为计时激活冷会话;done 仅为中性“本轮已结束”,不能用成功绿或宣称成功,running 优先于旧 completed。摘要仅取助手 text,排除事件根 surfaceOp 的非 append 副本;旧 nodes 只取 assistant/text 且非 interrupted。内部 completed 与产物握手保持独立;业务临时持有结束释放。 +- **更新检查/发布**:设置与控制室请求循环暂未去重,但必须共用 updateCheck.ts 的 cmpVer/newerUpdateInfo;仅有效且更高、未忽略的版本能进入更新卡/徽标/复制入口。排序支持正式/预发布、数字标识与构建元数据;缓存读取重验当前版本及skip,不信旧info,失败/无更新清旧卡。`npm run test:update` 为11组纯夹具回归,并入打包门禁;安装产物另测两处UI入口。共用 updateCheck/lastUpdateCheck/skipVersion.v1,updateCache.v1 仅控制室。单次 8s、最多 3 次尝试;失败不可报最新。版本从 package.json 构建注入;新版本需 tag+Release,双资产固定名 dsh-worktable.tgz + 版本化名同 SHA。插件仅提示不自升级,安装/重启留给用户。 ## link 加载兼容边界 diff --git a/README.md b/README.md index 7ffcb45..ea911a4 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,15 @@ **An agent-project workbench for DeepSeek Harness** — a sidebar app drawer that turns every project into dockable windows, plus a built-in control room that watches them all in real time. -> Current source is **0.5.0**; publication is determined by GitHub Release. It adds host identity checks, sandboxed project pages, preview read limits, fractional-margin split fixes and collapsed-sidebar popups. Windows Web checks cover DSH 0.2.0-rc.2 / 0.2.1-rc.3; the user tested project opening, split resizing and sidebar settings in dev.2 on official Windows Desktop 0.2.1-rc.3. Formal-package installation/regressions are separate from candidate GUI evidence. DSH 0.1.x is outside this release’s support range. Embedded storage, login, WebGL textures and automatic annotation text extraction can be affected. See [compatibility and security](docs/compatibility-and-security.md); v0.4.0 sections below are historical release records. +> Source version **0.5.1** adds exact DSH **0.2.1-rc.5** compatibility, neutral run-ended status and assistant-only previews, and fixes downgrade alerts caused by prerelease comparison and stale update caches. Publication status follows [GitHub Releases](https://github.com/Aisland-SJL/dsh-worktable/releases). Existing sandbox/authentication boundaries remain; this is not a fix for the separate old-session HTTP 400 issue. + +> Compatibility is limited to the tested DSH versions **0.2.0-rc.2 / 0.2.1-rc.3 / 0.2.1-rc.5** on Windows; coverage varies by environment. The user confirmed that the downgrade alert disappeared and the page looked normal after installing 0.5.1-dev.3. This confirmation and earlier candidate GUI checks do not establish full final-package Desktop acceptance. DSH 0.1.x is outside this release’s support range. Version 0.5.1 retains the identity checks, page isolation, preview limits and layout fixes introduced in 0.5.0. Embedded storage, login, WebGL textures and automatic annotation text extraction can be affected. See [compatibility and security](docs/compatibility-and-security.md) for evidence and limits. ## 📸 Screenshots | | | |---|---| -| Control room | **🖥️ Control room** — the built-in default project: a live card grid watching every project (working / needs you / done) with glassmorphism cards on a blueprint grid | +| Control room | **🖥️ Control room** — the built-in default project: a live card grid watching every project (working / needs you / run ended) with glassmorphism cards on a blueprint grid | | Worktable sidebar | **🧩 Worktable sidebar** — the app drawer: projects, shortcuts and the pinned control-room entry | | Split workspace | **🪟 Our projects** — every project opens as a dockable split workspace (resident apps like Travel Atlas included) | @@ -34,7 +36,7 @@ ### 🖥️ Control room (built-in default project) - A pinned, undeletable first project — bind one management conversation on first open -- A configurable card grid mirrors visible projects: working / needs you / done with available runtime and a cleaned message preview +- A configurable card grid mirrors visible projects: working / needs you / run ended, with available runtime and the latest readable assistant reply. Run ended does not indicate task success. - Event-driven host snapshot mirroring; status monitoring does not call a model - Glassmorphism cards, dark / light / system theme, neon status glows and a rotating comet on busy cards @@ -53,9 +55,9 @@ --- -## Quick start (published v0.4.0) +## Quick start (v0.5.1) -**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. +The current compatibility declaration lists only DSH **0.2.0-rc.2 / 0.2.1-rc.3 / 0.2.1-rc.5**; DSH 0.1.x is not supported. The [compatibility guide](docs/compatibility-and-security.md) separates Windows Web checks from Desktop evidence. Back up important data and check other plugins before upgrading; use GitHub Releases to confirm that the requested version is published. 1. **Install** (pick one): @@ -83,13 +85,17 @@ 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.5.0/dsh-worktable-0.5.0.tgz" +& "\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.5.1/dsh-worktable-0.5.1.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. +### v0.4.0 compatibility history + +**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. + --- ## Architecture @@ -116,7 +122,7 @@ node --check lib/index.js - The client bundle keeps the `window.__ModuleLoader__.load` handshake; `react` and `@deepseek-ai/*` stay external - Historical diagnostic scripts such as `04_test/functional-diag.cjs` and browser probes contain local assumptions; inspect their paths/ports before running and use an isolated profile. - Installed-artifact checks: `04_test/anchor-dom.test.mjs` (14 DOM scenarios, including fractional geometry), `04_test/sidebar-popup.test.mjs` (expanded/collapsed sidebar popups), `04_test/split-margin.test.mjs` (5 real Chromium cases), and `04_test/server-home.test.mjs` (3 groups). Use Edge/Chrome or `DSH_TEST_CHROMIUM` for the browser check. -- Release packaging uses `npm run pack` only. It also runs 122 source regressions, independent installation and client-factory checks. `npm run test:sidebar` runs the popup checks alone; `npm run test:widget` covers 26 ownership/session/late-result cases. After an approved release, run `npm run verify:remote -- --expect-sha ` and check `latest` separately. +- Release packaging uses `npm run pack` only. It also runs 139 source regressions, independent installation and client-factory checks. `npm run test:sidebar` runs the popup checks alone; `npm run test:widget` covers 26 ownership/session/late-result cases. After an approved release, run `npm run verify:remote -- --expect-sha ` and check `latest` separately. --- @@ -155,7 +161,7 @@ Every window title bar has a small **annotate button** (chat-bubble with a plus) - Sandboxing can break embedded storage, login and WebGL/Cesium textures. StarMap’s moon-texture issue was fixed separately in that project; the worktable package does not contain this project-specific fix. A working new project does not establish compatibility for every existing page. -- **Platform**: see the release scope above and [compatibility guide](docs/compatibility-and-security.md). Dev.2 passed the specified user Desktop checks; this does not establish complete formal-package Desktop acceptance; macOS has not been tested on real hardware. +- **Platform**: see the release scope above and [compatibility guide](docs/compatibility-and-security.md). Earlier candidate Desktop checks and the user's 0.5.1-dev.3 confirmation cover the recorded interactions only; they do not establish complete formal-package Desktop acceptance. macOS has not been tested 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 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. diff --git a/README.zh.md b/README.zh.md index f7e5cc5..d44aeec 100644 --- a/README.zh.md +++ b/README.zh.md @@ -4,13 +4,15 @@ **DeepSeek Harness 的 agent 项目工作台**——侧边栏「应用抽屉」把每个项目变成可停靠的窗口,再加一个实时监控所有项目的内置「控制室」。 -> 当前源码为 **0.5.0**,公开状态以 GitHub Release 为准:接口身份校验、项目网页隔离、预览读取限制、小数分栏修复及折叠侧栏弹窗补全。Windows 隔离网页端已测 DSH 0.2.0-rc.2 / 0.2.1-rc.3;用户已在官方 Windows Desktop 0.2.1-rc.3 验证 dev.2 的项目打开、分栏缩放和侧栏设置弹窗。正式包安装/回归与候选 GUI 证据分开记录,不宣称全功能验收。0.1.x 不在本版支持范围。浏览器存储、登录、WebGL 贴图及标注自动取字可能受隔离影响,详见 [兼容与安全说明](docs/compatibility-and-security.md)。下方 v0.4.0 内容为历史发布记录。 +> 当前源码版本 **0.5.1**:精确增加 DSH **0.2.1-rc.5** 兼容,中性显示本轮结束状态与助手答复摘要,修复测试版版本比较和旧缓存造成的降级更新误报。公开发布状态以 [GitHub Releases](https://github.com/Aisland-SJL/dsh-worktable/releases) 为准。保留既有安全隔离与鉴权,不包含旧会话 HTTP 400 的宿主修复。 + +> 兼容声明仅覆盖 Windows 下已测的 **DSH 0.2.0-rc.2 / 0.2.1-rc.3 / 0.2.1-rc.5**,各环境的验证范围不同。用户已确认安装 0.5.1-dev.3 后降级更新误报消失、页面看起来正常;这与前轮候选 GUI 检查均不能外推为正式包全功能桌面验收。0.1.x 不在本版支持范围。0.5.1 保留 0.5.0 引入的接口身份校验、网页隔离、预览读取限制和布局修复。浏览器存储、登录、WebGL 贴图及标注自动取字可能受隔离影响,证据与边界见 [兼容与安全说明](docs/compatibility-and-security.md)。 ## 📸 截图 | | | |---|---| -| 控制室主界面 | **🖥️ 控制室主界面** —— 内置默认项目:实时卡片网格监控每个项目(工作中 / 待你决定 / 已完成),蓝图网格上的玻璃拟态卡片 | +| 控制室主界面 | **🖥️ 控制室主界面** —— 内置默认项目:实时卡片网格监控每个项目(工作中 / 待你决定 / 本轮已结束),蓝图网格上的玻璃拟态卡片 | | 工作台侧边栏 | **🧩 工作台侧边栏** —— 应用抽屉:项目、快捷方式与固定首位的控制室入口 | | 我们的项目 | **🪟 我们的项目** —— 每个项目打开为可停靠的分栏工作区(含旅行 Atlas 等入驻应用) | @@ -34,7 +36,7 @@ ### 🖥️ 控制室(内置默认项目) - 固定在首位的不可删除项目——首次打开绑定一条管理对话即可 -- 可调列数的卡片网格镜像可见项目的状态:工作中 / 待你决定 / 已完成,附带可用的运行时长与清洗后的最近消息预览 +- 可调列数的卡片网格镜像可见项目的状态:工作中 / 待你决定 / 本轮已结束,附带可用的运行时长与最近可读的助手答复;结束状态不代表任务成功 - 宿主会话快照的事件订阅镜像;状态监控不调用模型 - 玻璃拟态卡片、深色/白色/跟随系统主题、霓虹状态光效与工作中卡片的旋转彗星光点 @@ -53,9 +55,9 @@ --- -## 快速开始(已发布 v0.4.0) +## 快速开始(v0.5.1) -**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 为准。 +当前兼容声明仅列 **DSH 0.2.0-rc.2 / 0.2.1-rc.3 / 0.2.1-rc.5**,不支持旧 0.1.x。Windows 网页端与桌面端的具体证据分别见[兼容说明](docs/compatibility-and-security.md)。升级前备份重要数据并核对其他插件;目标版本是否已发布,以 GitHub Releases 为准。 1. **安装**(二选一): @@ -83,13 +85,17 @@ 先保存任务,从应用菜单/托盘完整退出(只关窗口不够);将安装目录占位符替换为实际位置,在 PowerShell 执行,再手动重新打开: ```powershell -& "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.5.0/dsh-worktable-0.5.0.tgz" +& "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.5.1/dsh-worktable-0.5.1.tgz" ``` 必须使用桌面端自带 CLI 与 `desktop` profile,不用不固定版本的 npx 或网页 CLI 代替。保留实际 DSH_HOME;即便共用会话数据目录,网页与桌面来源的项目/布局/媒体也不会自动同步。 自定义窗口明确选「未分组」时不再偷偷加入宿主分组。控制室新建的是**空对话**:已测 DSH 0.2 的未分组空对话不能使用原生输入框,因此插件提示并禁用这一创建,请自行选分组或加入可用的现有对话;不自动改组、不发送激活消息。现有分组未选具体项时也不退回默认分组,旧宿主未分组创建行为保持。 +### v0.4.0 兼容历史 + +**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 为准。 + --- ## 架构 @@ -98,7 +104,7 @@ - **宿主**:`/api/worktable/*` 路由——健康检查、文件系统、git、文件读写、站点托管、mkdir、工作区、原生皮肤模板;WebSocket `/api/worktable/term` 提供终端窗格(Windows 下为 PowerShell) - **客户端**:经 slot 协议注入侧边栏与 shell overlay;分栏引擎、标签模型、拖拽与持久化均为自研 -- **控制室**:读取宿主会话列表快照(运行中/待决/已完成、后台任务、子代理目录)——事件驱动镜像,模型不参与 +- **控制室**:读取宿主会话列表快照(运行中/待决/结束标记、后台任务、子代理目录)——事件驱动镜像,模型不参与 - **窗口任务**:「自定义」任务登记项目/窗格/绑定身份,agent 在 `.dsh-worktable/project-<项目 ID>/` 下写专属结果;新项目不因共用文件夹而继承其他项目的窗口内容 --- @@ -117,7 +123,7 @@ node --check lib/index.js - 历史诊断 `04_test/functional-diag.cjs` 与专项探针有本机路径/端口假设,运行前先核对,限定隔离 profile; 发布流水线内嵌:分栏锚点 DOM 回归 `04_test/anchor-dom.test.mjs`(14 场景,含小数几何)与 数据目录解析回归 `04_test/server-home.test.mjs`(3 组场景,无循环/路径展开/官方分支夹具) -- 发布打包唯一入口为 `npm run pack`,含 122 项源码回归、独立安装与客户端工厂门禁;安装产物另跑侧栏弹窗、14 项锚点、5 项真实 Chromium 与数据目录回归。`npm run test:sidebar` 可单跑弹窗;`npm run test:widget` 为 26 项归属/会话隔离/补挂回归。批准发布后,用 `npm run verify:remote -- --expect-sha <最终 SHA> <发布 tag>` 核对,并另查 latest。 +- 发布打包唯一入口为 `npm run pack`,含 139 项源码回归、独立安装与客户端工厂门禁;安装产物另跑侧栏弹窗、14 项锚点、5 项真实 Chromium 与数据目录回归。`npm run test:sidebar` 可单跑弹窗;`npm run test:widget` 为 26 项归属/会话隔离/补挂回归。批准发布后,用 `npm run verify:remote -- --expect-sha <最终 SHA> <发布 tag>` 核对,并另查 latest。 --- @@ -156,7 +162,7 @@ node --check lib/index.js - 网页隔离可能影响存储、登录及 WebGL/Cesium 贴图。StarMap 月球纹理已在独立项目中修复,不包含在工作台包中;新项目成功不能证明所有旧网页都兼容。 -- **平台**:本版范围见页首与[兼容说明](docs/compatibility-and-security.md)。dev.2 已通过用户指定的桌面交互检查,不能外推正式包全部业务;macOS 尚未真机验证。 +- **平台**:本版范围见页首与[兼容说明](docs/compatibility-and-security.md)。前轮候选的桌面检查与用户对 0.5.1-dev.3 的确认只覆盖已记录交互,不能外推正式包全部业务;macOS 尚未真机验证。 - 状态在浏览器本地(localStorage 与 IndexedDB)——项目、绑定、视图与媒体不跨设备同步 - 终端窗格在 Windows 上是朴素的 PowerShell 宿主(与原生终端应用无 PTY 对等) - 自动挂载要求已登记「自定义」任务且写出身份匹配的 v2 结果文件。旧已保存窗口保留,根目录旧 `widget-result.json` 不再导入;旧对话可从目标窗口「自定义 → 发送到会话」发一次任务建立新关联,随后在同一对话直接继续修改。手动关闭/替换内容会撤销关联,此前误挂的标签需关闭一次。同目录项目若主动写同一个实际文件,文件内容仍可能互相影响。 diff --git a/docs/compatibility-and-security.md b/docs/compatibility-and-security.md index 5f080cb..479b93e 100644 --- a/docs/compatibility-and-security.md +++ b/docs/compatibility-and-security.md @@ -1,16 +1,24 @@ -# 工作台 0.5.0:兼容、隔离与故障处理 +# 工作台 0.5.1:兼容、隔离与故障处理 -适用源码:0.5.0。公开版本以 GitHub Release 为准。本文解释当前代码契约;验证范围与安装命令见 [包内说明](../01_content/README.md)。 +适用源码:0.5.1。公开发布状态以 GitHub Release 为准。本文解释当前代码契约;验证范围与安装命令见 [包内说明](../01_content/README.md)。 -## 兼容范围 +## 0.5.1 改动与验证边界 + +0.5.0 的精确 engines.dsh 声明没有包含 0.2.1-rc.5,新宿主据此跳过工作台加载。0.5.1 精确增加 rc.5 兼容目标,将结束提醒改为中性“本轮已结束”、摘要限定为助手答复,并修复测试版版本比较及旧缓存造成的降级更新误报。既有安全、传输与存储契约保留;不包含旧会话 HTTP 400 修复,也不对其他插件作兼容豁免。 + +## 兼容范围与已有证据 + +版本声明仅覆盖 DSH **0.2.0-rc.2 / 0.2.1-rc.3 / 0.2.1-rc.5**。以下区分历史候选、隔离页面与用户实际确认,不把版本声明放行等同于功能验收。 | 环境 | 已有证据 | 不能外推 | | --- | --- | --- | -| Windows Web / DSH 0.2.0-rc.2、0.2.1-rc.3 | 前轮 dev.1 冷启动、页面、分栏、资源和鉴权隔离检查 | 全部模型任务、审批/子代理、所有自定义站点 | -| 官方 Windows Desktop / 0.2.1-rc.3 | 已装 dev.2,用户确认项目打开、分栏缩放和侧栏设置弹窗正常;新项目前轮已测 | 正式 0.5.0 已装入用户 Desktop、完整桌面端验收 | +| Windows Web / DSH 0.2.0-rc.2、0.2.1-rc.3 | 0.5.0 发布前候选的冷启动、页面、分栏、资源和鉴权隔离检查 | 全部模型任务、审批/子代理、所有自定义站点 | +| 官方 Windows Desktop / 0.2.1-rc.3 | 历史候选 dev.2 的项目打开、分栏缩放和侧栏设置弹窗获用户确认;新项目前轮已测 | 0.5.1 正式包在此宿主上完成完整桌面端验收 | +| Windows / Desktop 捆绑的 0.2.1-rc.5 运行时 Web UI | 0.5.1-dev.1 最终候选包在隔离环境通过 15 项 GUI 检查,覆盖入口、绑定、项目创建、分栏、侧栏弹层、身份与预览撤销 | 完整 Electron Desktop、模型任务、原生目录选择、真实审批/子代理与桌面终端全链路 | +| 官方 Windows Desktop / 0.2.1-rc.5 | 0.5.1-dev.3 安装文件与候选包一致、组件检查通过;用户确认降级更新误报消失、页面看起来正常 | 0.5.1 正式包全功能 GUI 验收、所有控制室业务与模型任务 | | 旧 DSH 0.1.x、macOS、社区桌面壳 | 不在当前版本已测支持范围 | 不能因旧兼容分支仍在就声明支持 | -正式包的弹层与分栏回归在最终安装产物上运行;这不替代宿主真实页面验收,用户 Desktop 仍保留已验收的 dev.2。关闭并重新打开正式 DSH 由用户操作,不自动迁移会话或浏览器数据。 +0.5.1 正式包须单独通过打包流水线的安装与回归门禁;已有 dev.3 页面确认不替代正式包全功能 GUI 验收。关闭并重新打开正式 DSH 由用户操作,不自动迁移会话或浏览器数据。 ## 接口与访问身份 @@ -77,4 +85,4 @@ - `node ../04_test/split-margin.test.mjs [client.js]`:5 个真实 Chromium 场景。 - `npm run test:security`:鉴权、只读预览及生命周期;不等于完整渗透测试。 -正式版本/公告需同步;源码、包内 README 或版本变化后必须重新打包。新最终包完成验收后再申请发布,上传后按最终 SHA 跑 verify:remote 并核对指定 tag 与 latest;同 tag 资产不覆盖。 +正式版本/公告需同步;源码、包内 README 或版本变化后必须重新打包。最终包验收通过并获得发布授权后上传,随后按最终 SHA 跑 verify:remote 并核对指定 tag 与 latest;同 tag 资产不覆盖。