会话总结与决策记录(2026-08-21)

September 16, 2026 · View on GitHub

给后续 DSH 会话的交接文档:新会话继续完善本项目前,先读本文件 + README。 2026-08-22 更新:0.2.0 模型余额已接入,见文末「八、续作会话(2026-08-22)」; 其中 8.7 记录了「CSS 全局名冲突致 dsh 启动崩溃」事故与修复,改 client 半部前必读。

一、本项目是什么

dsh-dock(中文名:功能坞)—— DeepSeek Harness 功能坞插件。一张管理面板统一注册、开关所有小功能(模型余额、Token 用量记录、任务动画,以及未来任意新功能)。已发布 npm:dsh-dock@0.1.0(maintainer: wycto),0.2.0(模型余额 + 侧栏入口按钮 + 功能弹层)代码完成待发布。

二、本会话做了什么

  1. 架构咨询结论:个人工具集用"单中枢 + 注册表 + 独立功能模块"(即当前实现),而非拆成互不相识的多个插件;只有信任级别不同或需要独立生命周期的功能才拆单独包。三个既有功能包(@wycto/dsh-balance-panel / dsh-token-usage / dsh-task-pulse)后续按路线图吸收或注册进中枢。
  2. 命名决策:无作用域名 dsh-dock(初选 dsh-hub 已被他人占位;作用域备选 @wycto/dsh-hub 保留)。2026-08-21 已发布 npm 占用(0.1.0,latest)。
  3. 基础框架落地:继承本地已安装原型 feature-hub(静态 bundle 路线),正式化为可发布 npm 包。
  4. 本项目初始化:本仓库从 /Users/weiyi/develop/test/dsh-dock(发布源,2 commit)复制代码并整理 README + 本文档。

三、关键决策与原因(不要推翻)

决策原因
静态 bundle 路线(cordis.patch.yml + dsh.bundle.patch + exports["./client"]本部署的模型网关会把对象类型工具参数序列化为字符串,cordis_define 动态插件路径被阻塞;静态包才是一致、常驻、长期使用的形态
Client half 手写 lazy-CJS 工厂(window.__ModuleLoader__.load无需构建器,改完直接生效(profile 里是 link 依赖,重启 dsh web 即加载新代码)
每功能 = 注册表一条 + 视图一个 + 独立开关(内存态)与 feature-hub 原型一致;个人工具集足够,拒绝过度设计
Host↔Client 数据通道用 webServer 回环路由 + 同源 fetch,不走 typert见 8.3:@wycto/dsh-balance-panel@0.1.1 同部署实测可用,无需 typert/connection RPC
余额策略表吸收 dsh-balance-panel 实现(外人视角即"复用",非重写)同作者 MIT 代码,官方计费接口,已被生产验证;避免重复造轮子
npm 包名 dsh-dock(无作用域)用户拍板;已发布即占用,勿改

四、当前状态快照(2026-08-22 更新)

  • npm:dsh-dock@0.1.0 latest(待 0.2.0 发布后 latest 更新)
  • 代码:0.2.0 模型余额已接入并本地验证通过(本仓库 main,待发布)
  • 运行环境:web profile 已换为 dsh-dock(link 本仓库,隔离实例验证 ✅);线上 dsh web(端口 3080)仍是旧组合,用户重启后生效(重启会中断会话)
  • 代码源:本仓库(gitea main)+ 发布源副本 /Users/weiyi/develop/test/dsh-dock
  • 发布脚本:scripts/publish.sh(含登录检查、--cache 绕坑)

五、下一步(按顺序)

2026-08-22 更新:前两项已完成,最新清单见「八、续作会话」8.5。

  1. 本地替换验证(profile 已替换并在隔离实例验证通过;用户重启 dsh web 即生效)。
  2. 0.2.0 模型余额(2026-08-22 完成代码 + 本地验证,待发布)。
  3. 0.3.0 Token 用量hostSetups.tokenlog 监听事件记账 + 统计视图。
  4. 0.4.0 任务动画:纯 Client 模块,可注册对话区 slot。
  5. 0.5.0 持久化:开关状态落盘。
  6. GitHub 发版:建 wycto/dsh-dock(GitHub)→ package.json repository 改实际地址 → 发布 → tag 发 Release。

六、环境备忘(踩过的坑)

  • npm 缓存 EPERM~/.npm 有 root 属主历史文件。绕过:CACHE_DIR=/tmp/dsh-dock-npm-cache ./scripts/publish.sh;根治:sudo chown -R 501:20 ~/.npm
  • registry 读取一致延迟npm publish 成功后立刻 curl 可能 404,等几秒重试即 200,非失败。
  • 沙箱:gitea 目录在会话工作区外,本会话已由用户开放全权限写入。
  • git 身份:本仓库需设置 user.name/user.email(本机无全局身份),用 wycto。
  • typert 联通(已推翻,见 8.3):静态 bundle 下 Client↔Host 的私有 RPC 原假设走 typert 服务;实测采用 webServer 回环路由 + 同源 fetch 即可。

七、命名与生态备忘

  • 现有功能包家族:@wycto/dsh-balance-panel@wycto/dsh-token-usage@wycto/dsh-task-pulse
  • 面板包:dsh-dock;后续新功能包建议 @wycto/dsh-<功能名> 或吸收为 dock 内模块
  • dsh-hub 无作用域名已被他人占位(0.0.1),勿用

八、续作会话(2026-08-22)

8.1 本会话做了什么

  1. 0.2.0 模型余额接入完成(代码 + README + 本文档;发布与本地安装另行确认):
    • index.js:新增 hostSetups.balance,在回环 webServer 注册 GET /dsh-dock/balance 路由; Provider 枚举(llm.listConfigurableProviders + settings.describe(redactSecrets) + agentDefaultModel.currentSelection)、 余额策略表、凭证解析全部沿用 @wycto/dsh-balance-panel@0.1.1(MIT,同作者)——策略覆盖 DeepSeek / StepFun / Kimi Coding / OpenRouter / MiniMax / xAI,另含 qwen-token-plan-* 等登录跳转与控制台链接。
    • 补上 export const inject = ['webServer'](0.1.0 框架从未声明 inject;balance 是第一个真实 Host 功能, 实测发现 apply 在 webServer 就绪前执行导致路由注册失败,补齐后与 @wycto/dsh-balance-panel 一致)。
    • client.js:balance 占位移除 planned,新增真实视图(fetch('/dsh-dock/balance'),5 分钟自动刷新 + 手动刷新, 每 Provider 卡片带独立主题色、余额/配额/登录跳转/不支持等状态),开关默认启用。
    • package.json:版本 0.2.0,新增依赖 @deepseek-ai/dsh-credentials@^0.1.0-rc.6(与 web profile 已装版本一致)。
  2. 环境验证:Node v26(全局 fetch + AbortSignal.timeout 可用);webServer.register 返回 disposer; @deepseek-ai/dsh-credentials 在 web profile node_modules 已就位(0.1.0-rc.6)。
  3. 本地验证全绿(详见 8.6):
    • 冒烟测试:以真实部署配置桩驱动 apply(),Provider 分类正确;
    • Client SSR:以真实 react/react-dom 渲染整个面板(五个功能卡片 + 余额视图初始态 + 规划占位)全过;
    • 真实实例验证:用 DSH_HOME=/tmp/dsh-verify-home 隔离起一个同组合 dsh web(端口 3999), GET /dsh-dock/balance 返回 200,分类正确(qwen-token-plan-cn→需登录带百炼链接、fangzhou/jiyuanlvdong→不支持、 deepseek 系→未配置密钥不发网络请求);/dsh-balance-panel(旧包)仍 200,两包并存无冲突。

8.2 未做(留给后续会话/用户)

  • 重启线上实例看面板:web profile 已替换为 dsh-dock(link 本仓库)并验证通过(8.6), 但当前跑着的 dsh web(PID 66740,端口 3080)还是旧组合——需用户手动重启 dsh web 才会加载 dsh-dock; 重启会中断当前 DSH 会话,故本会话没有动它。
  • npm 发布 0.2.0npm publish 需用户确认(wycto 账号),用 CACHE_DIR=/tmp/dsh-dock-npm-cache ./scripts/publish.sh。 ⚠️ 发布前注意:package.jsondependencies 只在非 link 安装(npm 安装)时生效; 本地 link 模式需要仓库内 shim(见 8.4/8.6),那是 gitignored 的,不随包发布。

8.3 重要决策更正:Client↔Host 联通不走 typert,用 webServer 回环路由

旧文档(第六节)假设"静态 bundle 下私有 RPC 走 typert"。实测并经 @wycto/dsh-balance-panel@0.1.1 (同部署已安装可用)验证:Host 在回环 webServer 注册同源 HTTP 路由、Client 直接 fetch 即可, 无需 typert、无需 connection RPC、任何回环部署都能工作。0.2.0 采纳该路线:

  • 路由路径 GET /dsh-dock/balance(与余额面板包的 /dsh-balance-panel 错开,两包并存不冲突);
  • 数据是纯 JSON 归一化视图(generatedAt / default / providers[]),不是内部对象;
  • 密钥只在 Host 进程内 credentials.resolve(credentialRef(apiKeyEnv)) 取用,绝不下发浏览器。

8.4 环境备忘(延续)

  • git 身份:本仓库仍需 wycto 的 user.name/user.email(本机无全局身份)。
  • 本部署 settings(~/.dsh/settings.yaml)Provider 清单见 8.1 第 3 条;新增 Provider 只要 profile 配了 baseURL/apiKeyEnv 就会被自动枚举。
  • link 模式依赖坑:profile 里 dsh-dock: link:本仓库,Node 按真实路径解析 @deepseek-ai/dsh-credentials, 仓库内必须有 node_modules/@deepseek-ai/dsh-credentials(-> symlink profile 已装版本,gitignored)。 否则插件行 import 失败(boot 不报错但功能静默缺失)。npm 真实安装后不需要 shim。

8.5 下一步(按顺序)

  1. v0.2.0 追加(本次会话):中文名定为「功能坞」;新增侧栏入口按钮 (sidebar.footer.action id dsh-dock,order 1,靠右端 = 设置旁) + 功能弹层(仿 dsh 设置:居中模态、左导航 + 右内容区) (shell.overlay id dsh-dock-panel,order 21); CSS 由页内 <style> 改为 apply 时全局注入(ensureCss/data-plugin-css="dsh-dock")。 首页总揽(2026-08-22 追加):导航首项「首页」为默认选中页,网格卡片总揽全部子功能 (状态徽章 + 运行概要 + 快捷开关,点卡片跳对应功能页);为此把余额数据提升为模块级共享快照 balanceStore(首页概要与余额视图共用一份请求/5 分钟刷新),心跳改用模块级 loadedAt 运行时长基准。 位置对齐:设置 button 在宽栏是满宽 42px 行(margin:4px -2px,行带 50px)、窄栏 36px 居中(margin:8px 0 10px,行带 54px)→ 入口按钮用 transform: translateY(46px)(宽)/44px(窄)+ zIndex:1 下移进设置行右端空白区, 与设置按钮同底对齐(transform 不动布局、不遮齿轮图标)。 注意:设置面板的 open/select 是 ui-settings-general 内部 useState,无公开 API—— 入口按钮只能打开自己的弹层,无法编程式打开设置页并选中 section(若未来需要,得给 ui-settings-general 加服务或事件)。 ⚠️ 2026-08-22 修复:弹层 JSX 链尾部缺一个右括号(client.js 曾处于语法非法状态, 页面能跑是 HMR 缓存的旧有效版本);已补齐并以 node --check + SSR 冒烟把关。 状态:用户已重启 dsh web,静态版生效(动态预览随之清空);重启后曾发生启动崩溃事故, 根因与修复见 8.7。
  2. 用户在页面确认:入口按钮与设置按钮同底对齐不再悬空;功能弹层默认打开「首页」总揽 (卡片网格:状态/概要/快捷开关,点击进入模块页),下方菜单为各子功能;设置页「功能坞」面板有样式。
  3. 确认后:CACHE_DIR=/tmp/dsh-dock-npm-cache ./scripts/publish.sh 发布 0.2.0 → 本仓库提交/推送。
  4. 0.3.0 Token 用量记录hostSetups.tokenlog 监听 LLM API 事件记账 + 面板统计视图,参考 @wycto/dsh-token-usage
  5. 0.4.0 任务动画:纯 Client 模块。
  6. 0.5.0 持久化 + 双侧注册表打通:开关状态落盘;Host 侧 setEnabled 现在只在 load 时按 defaultEnabled 执行一次,届时接上 Client 面板开关同步(可把数据通道升级为同一路由体系下的 POST 控制接口,或沿用 8.3 的 fetch 模式)。

8.6 本地验证怎么做(可复用)

  1. 组合层dsh web --dump-config → 应看到 # == dsh-dock / - id: dsh-dock 行。
  2. Host 冒烟/tmp/dock-smoke/smoke.mjs(stub ctx + 真实部署配置形状)→ 路由输出分类断言全绿。
  3. Client SSR/tmp/dock-smoke/client-ssr.mjs(真实 react + react-dom 渲染整个面板)→ 五卡片全渲染。
  4. 真实实例(不碰线上):DSH_HOME=/tmp/dsh-verify-home 拷贝 profile(node_modules 里 dsh-dock 改回绝对链接指向本仓库)+ settings.yaml,起 dsh web --port 3999, curl /dsh-dock/balance 断言 200 与分类。⚠️ 别用真实 home 起第二实例,避免与线上进程争存储。

8.7 事故复盘:CSS.join is not a function 导致 dsh 整页启动崩溃(务必读)

  • 现象:重启 dsh web 后页面报 Failed to load plugins / dsh-dock / failed to apply loader entry <rev> (dsh-dock): CSS.join is not a function,整个 GUI 起不来。
  • 根因:bundle 的样式数组被命名为 CSS,与浏览器全局 window.CSS(命名空间对象,无 .join) 同名。在浏览器执行环境里标识符解析歧义,ensureCss() 里的 CSS.join("\n") 实际调到了全局 命名空间 → 插件 apply 抛错 → 该 entry 加载失败 → 整页启动失败
  • 为什么本地验证没拦住:SSR 冒烟里 typeof document === "undefined"ensureCss() 提前 return,CSS.join 那行根本没执行;node --check 只查语法。即:只在浏览器端炸的 环境相关 bug,纯 Node 冒烟查不出
  • 修复(已落地)
    1. 变量改名 DOCK_CSS(唯一命名,任何作用域都解析不到全局);
    2. 恢复 tag.textContent = DOCK_CSS.join("\n") 正确注入(注意:事故后他人先用 textContent = CSS(数组直接赋值)止血,那会让 CSS 被逗号拼接成碎样式,已一并修正);
    3. ensureCss() 整体 try/catch + Array.isArray 防御:注入永不抛错,坏了只降级不炸页
  • 验证升级(防复发)client-ssr.mjs 增加「浏览器模拟」段:注入毒化的全局 CSS(无 join 的命名空间)+ 假 document,让 ensureCss() 真实执行,断言样式按行 join、 幂等、且带 data-plugin-css。今后任何把样式数组命名为 CSS/作用域错位的改动,测试当场红。
  • 硬规矩(勿违反):bundle 顶层变量一律避开浏览器全局名(CSSwindowdocumentfetchAbortSignal 等直接用但自定义变量不要占这些名字);改 client 半部后 SSR 冒烟 + 浏览器模拟段必须全绿,且真实 dsh web 启动验收前不允许发布/提交收尾。

8.9 续作会话(2026-08-22 下午):0.3.0 模型设置 + 弹层窗口化

  1. 0.3.0 模型设置(集成官方链路,不改内核)
    • 官方链路勘察结论(勿重复探索):会话模型选择器(ui-model-selection,slot conversation.input.model) 按 model.reasoning.efforts 展示强度档;档位源自 Provider 配置—— pi-ai 每模型 reasoningEfforts键=档位,值=wire 值off: null = 支持关闭不发参数;false = 不支持思考; 档位全集 off/minimal/low/medium/high/xhigh/max); deepseek 官方连接级 thinking(enabled/disabled)+ reasoningEffort 默认档(off/low/high/max,全模型共享)。
    • 输入模态:官方 schema 仅收 text/image(pi-ai input、deepseek inputModalities); schemastery 保留未知字段(实测)→「视频/音频/文档」以 dockTags 标注随官方配置持久化,不参与请求路由。
    • Host(index.js):GET/POST /dsh-dock/models——GET 枚举目录(deepseek 官方始终可编辑,schema 默认兜底; 其余按"已配置"口径);POST 经 settings.mutate(ns, ops, revision) 写回(revision 乐观锁,冲突 409)。
    • Client(client.js):modelconfig 模块(FEATURES 首位 = 导航第二项,紧跟「首页」): Provider chips + 模型行编辑(id/名称/上下文/最大输出/输入类型/标注/思考强度三态)+ 添加/删除 + 保存/重新拉取; modelsStore 共享快照(首页总揽概要 "N 个 Provider · M 个模型")。
    • 验证:Host 冒烟 /tmp/dock-smoke/models-smoke.mjs(读分类/写回 ops/拒绝用例/409)全绿; 隔离实例(/tmp/dsh-verify-home3,端口 3999,profile link 仓库 + @deepseek-ai/dsh-credentials 软链) 浏览器实测:GET 5 Provider;编辑 qwen glm-5.2 为自定义档(off/low/high)+ 视频标注 → 保存 → 隔离 settings.yaml 出现 reasoningEfforts: {off: null, low: low, high: high} + dockTags: [video] ✅。
  2. 弹层窗口化:默认 min(1080px,vw32)×min(700px,vh32)\text{min}(1080\text{px}, \text{vw}-32) \times \text{min}(700\text{px}, \text{vh}-32);标题栏 ─ ▢ ✕ 三键 (最小化折叠内容、最大化 inset 10px 铺满、双击标题栏切换);标题栏 pointer 拖动(视口钳制)、 右下角 16px 手柄缩放(≥640×420);lastGeom 页面生命周期内记忆几何。实测拖动/缩放/最大化/最小化/还原全过。
  3. 版本:package.json 0.3.0;路线图顺延(0.4.0 tokenlog、0.5.0 动画、0.6.0 持久化)。 ⚠️ 线上 dsh web(3080)需用户手动重启才会加载新 Host 半部(/dsh-dock/models 路由在 Host 进程内注册)。

8.10 事故复盘:开思考后 qwen 请求 400「developer is not one of …」(2026-08-22 晚,已修复)

  • 现象:用户给 qwen 路由模型开思考档后,选强度发消息即 400: developer is not one of ['system','assistant','user','tool','function'](百炼端点)。 且只要模型 reasoning=true不选强度也 400(system 一直被改写为 developer 角色)。
  • 根因链(关键代码均已核实):
    1. pi-ai openai-completions.js:787useDeveloperRole = model.reasoning && compat.supportsDeveloperRole
    2. 非内置目录模型无 catalog compat → 检测默认 supportsDeveloperRole: true(标准端点);
    3. qwen3.8-max / deepseek-v4-*-0731/0813 不在 pi-ai 内置目录glm-5.2 在,自带 {thinkingFormat: qwen, supportsDeveloperRole: false} 兜底,所以它没事);
    4. 我们的模型设置让用户给非目录模型开了 reasoningEffortsreasoning=true → 触发 2+3。 另:qwen 路由是纯目录路由(用户层只有 models,api/baseURL 靠内置目录),修复时不能依赖 profile.api。
  • 修复(index.js piAiModelWrite
    1. raw round-trip:GET 带出原条目 raw,写回基于 raw 合并——未知字段(用户自配 compat/description 等)不再丢失(旧版保存会把它们清掉,这次也顺带修了);
    2. 思考 compat 兜底:凡启用 custom 档位,显式写 compat.supportsDeveloperRole: false (system 角色全端点通用);目录路由(无 api)视作 openai 兼容;路由 id/baseURL 命中 qwen|dashscope|aliyuncs 再补 thinkingFormat: 'qwen'(与目录内模型一致,实测 schema 接受)。
  • 存量治愈:直接给 ~/.dsh/settings.yaml qwen 路由 4 个模型插入 compat: {thinkingFormat: qwen, supportsDeveloperRole: false}(备份 .bak-dock-heal); settings.yaml 被 chokidar 监听(watch:true)热生效。
  • 端到端验证(隔离实例 3999 + 治愈后配置 + 修复代码 + 拷贝凭证): qwen3.8-max · High 发送成功,出现思考块且正常回复,无 400。 (坑:隔离 home 缺 .credentials.yaml 会先报 MISSING_CREDENTIAL——从 ~/.dsh 拷 .credentials.yaml 即可。)
  • 遗留提醒:线上 3080 跑的还是旧写回代码——在面板重复保存 qwen 模型会把 compat 再次清掉, 需重启 dsh web 加载新 Host 代码后才能安全使用面板保存。

8.8 数据通道(确认可行,勿改)

  • 静态版本:GET /dsh-dock/balance(webServer 路由 + 同源 fetch);动态预览:harness.handle
    • host.call。两者等价,静态为主。

8.10 续作会话(2026-08-22 晚):模型设置一键批量操作

  • 需求:模型的输入支持(文本/图片 + 标注 视频/音频/文档)与思考强度档,支持一键全勾选和取消勾选。
  • 实现(client.js ModelsView
    • 每个模型行:输入行尾部加「全选 / 取消全选」;强度行加「全选 / 取消全选」 (强度全选/取消 = 切 custom 并把全部档置 true/false);
    • Provider 区工具栏(dkm-toolbar):输入全选 / 输入取消全选 / 强度全选 / 强度取消全选, 作用于该 Provider 全部模型草稿;官方 deepseek Provider 强度为 Provider 级共享,隐藏强度批量按钮;
    • 语义安全:输入取消全选 = input: [] = 继承目录默认(Host 写回时删除 input 字段, 见 index.js 写回逻辑,不会写坏配置)。
  • 样式:dkm-mini / dkm-toolbar。验证:node --check + SSR(含弹层整树)+ Host 冒烟全绿。
  • ⚠️ 8.9 的遗留提醒仍有效:若线上进程未重启加载新 Host 写回代码,面板保存可能清掉存量 compat 治愈字段——保存前先确认已重启过 dsh web

8.11 图片理解代理(2026-08-22 晚,v0.3.0 内追加)

  • 需求:纯文本模型也能"看图"——收图时自动调用配置的视觉模型识别,识别文本替换图片; 多模态模型不受影响;视觉模型从模型目录(多模态模型)里点选。
  • 官方现状:纯文本模型收图时运行时把图片替换为 [image omitted …] 占位(projectImagesForTextModel, 发生在 LlmRuntime.adapterStream,判定依据 prepareCall 返回的 modelInfo.inputModalities);没有视觉代理机制。
  • 拦截点勘察(勿重复探索)
    1. llm/stream 是 cordis waterfall,但 next() 不收参数、请求对象 frozen——监听器无法改写请求(只读);
    2. llm.registerAdapter 对已有 provider 抛 DUPLICATE_ADAPTER——不能包装官方适配器;
    3. 主对话走 llm.prepareCallpreparedCall.stream(request),其余走 llm.stream,两路都汇于 streamWithRegistration
    4. 结论:方法级包装 llm.stream + llm.prepareCall(原函数存 llm.__dockOrigStream,own-property 覆盖, dispose 时 delete 恢复原型方法)——唯一的请求级拦截层。dsh 升级若改这两个方法需回归验证。
  • 实现
    • 自有 settings 命名空间 dsh-docksettings.register + schemastery):visionProxy{enabled,provider,model}, 写走 settings.mutate(revision 乐观锁),读走 settings.get(内存 resolved 值,热生效);
    • transformForVisionProxy:目标是目录中输入类型不含 image 的模型且请求带图(含 tool-result 嵌套)→ 逐图调用视觉模型(BlockAssembler 聚合文本;maxTokens 4096——视觉模型带思考时小限额会被思考吃光,实测), 图片块替换为 [图片内容(由视觉模型 p/m 识别):…];识别失败降级为说明文本;总兜底异常不改写不阻断;
    • 递归保护:识别调用走 __dockOrigStream(天然绕开包装)+ dockVisionProxy 标记双保险; options.purpose 存在(内部辅助调用)不代理;视觉模型自身不代理;
    • 目录判定用 readModelDirectory 的 10s TTL 缓存(避免每请求全量 settings.describe);
    • GET /dsh-dock/models 附带 visionProxy 与 dsh-dock revision;POST 支持 {visionProxy, revisions} 分支。
  • Client:模型设置页顶部「图片理解代理」面板——启用开关 + 视觉模型下拉 (候选 = 目录中输入类型含 image 的全部模型,跨 Provider;当前值不在候选时保留"(当前)"选项)+ 独立保存按钮。
  • 验证:Host 冒烟(改写/多模态不改写/关闭不代理/visionProxy 读写/缺模型 400)全绿; 隔离实例 UI 保存 → settings.yaml 持久化 → GET 反映 ✓;真实 API 验证 deepseek-v4-flash-vision-exp 识别小图正确(答「红色」)。⚠️ in-app browser 无文件选择器,完整"发图→识别→回复"流待用户真实浏览器验证。
  • 依赖新增:@deepseek-ai/dsh-llm、@deepseek-ai/dsh-settings、@deepseek-ai/schemastery (package.json 声明 + 仓库 node_modules 软链 shim,同 dsh-credentials 模式)。

8.12 事故复盘:图片代理包装致「全模型 stream is not async iterable」(已修复)

  • 现象:重启加载图片理解代理后,所有模型每轮请求报 本轮运行失败 stream is not async iterable
  • 根因prepareCall 包装层用了 async (options) => … —— 返回 Promise 而非 AsyncIterable。 消费方(agent-loop)const stream = preparedCall.stream(request); for await (const chunk of stream)of 表达式不会先 await Promise(Node 语义:直接取 Symbol.asyncIterator),Promise 上没有 该符号 → 全量失败。(llm.stream 包装用的是 IIFE async generator,同步返回可迭代,没炸; 只有 prepareCall 路径炸——主对话全走这条路。)
  • 修复:两层包装统一为 (options) => (async function* () { … })() —— 同步返回 async generator。
  • 防复发:Host 冒烟新增「包装契约」断言——llm.stream(...)prepareCall().stream(...) 都必须同步返回 AsyncIterable(非 Promise、有 Symbol.asyncIterator、可迭代)。
  • 教训:包装官方函数必须逐条保契约(返回类型同步性也在内);冒烟当时只测了 llm.stream 路径, 漏了 prepareCall 主路径——以后每个被包装的入口都要有契约断言。

8.13 事故复盘:误勾「图片」输入致模型瞎折腾 14 分钟 + PyYAML 二次事故(已修复)

  • 用户现象:给 deepseek-v4-flash-0731 发图问「图中有什么」,模型 87 步 / 14 分钟用 bash+tesseract+swift OCR 自己折腾图片文件,而非走视觉模型。
  • 根因 1(配置语义):此前「批量输入全选」把 qwen 路由 4 个模型全勾了「图片」—— 其中 glm-5.2 / deepseek-v4-flash-0731 / deepseek-v4-pro-0813 在 pi-ai 内置目录里是纯文本 (GLM-5.x 全系纯文本;qwen 编程套餐目录里多模态的只有 kimi-k2.5+、qwen3.6+/3.7-plus/3.8-max-preview)。 代理规则「多模态模型自己识别」→ 跳过;图片原样发端点 → 端点不认 → 模型只拿到附件文本引用 → 工具瞎找。
  • 修复 1:治愈 settings(4 处 input 删除、留空继承目录=纯文本;qwen3.8-max 真多模态保留)。 语义已在面板写明:勾「图片」= 端点原生支持、原图直发;不确定勿勾,交给代理。
  • 根因 2(判定源不一致):代理此前用插件目录副本判多模态——条目留空时副本显示路由默认 (纯文本),但运行时解析链是 entry.input ?? 内置目录 ?? 路由默认:kimi-k2.7(qwen 路由)条目 留空却靠目录继承 [text,image],会被误拦。 修复 2:判定改用 llm.resolveModelInfo(provider, model)(dsh-llm 运行时公开方法,与官方 投影同源);旧宿主无此方法/单次解析失败时回退插件目录。GET 目录逐模型附 runtimeInput, 面板「图片理解代理」区展示「原图直发(多模态)/ 走视觉代理(纯文本)」分组。
  • 提速:识别调用先试 reasoningEffort: 'off'(关思考求快);报 UNSUPPORTED_REASONING_EFFORT 则回退默认档重试一次。
  • 二次事故(自查自纠):治愈脚本用 PyYAML load/dump 转储 settings.yaml——PyYAML 是 YAML 1.1, 把 off: 键解析成布尔 False 再写回成 false: null键名腐蚀);且全新启动时 llm-pi-ai schema 校验不过 → 全部 pi-ai Provider 消失(热加载进程不重启则无感,极具迷惑性)。 补救:从备份做纯文本手术(只删 input 行、不动其他字节)。 教训:绝不用 PyYAML 转储用户 settings.yaml(YAML 1.1 布尔陷阱 off/yes/on/no);要改就文本级 手术,或让 settings 服务自己写(settings.mutate)。
  • 验证:双实例(3080 线上 / 3999 隔离)全新启动后 5 Provider 齐全;runtime 判定 治愈模型=text(走代理)、qwen3.8-max=text,image(直发+当视觉模型);Host 冒烟新增 5 断言 (runtimeInput 附带 / 目录继承多模态不误拦 / 解析失败回退 / off 被拒自动重试 / off 先行尝试)全绿。
  • GLM-5.3(fangzhou 自定义路由):未进内置目录 → 运行时默认纯文本 → 收图自动走视觉代理 ✓。

8.14 终局复盘:图片理解代理「配了还是不能识别」三层根因(已修复,真实全链路验证通过)

用户反馈配好了代理仍不能识图(模型抱怨"sha256 不是文件路径")。隔离实例 + API 注图复现, 探针矩阵二分定位,共三层根因(一次比一次深):

  1. 官方带图准入拦截(首层):apiproxy 的 session.prompt 收到图片先查 ctx.llm.resolveModelInfo(provider, model) 的 inputModalities,纯文本模型直接拒绝 MODEL_DOES_NOT_SUPPORT_IMAGES——请求根本进不到 llm.stream 包装层。 修复:安装时同时包装 resolveModelInfo(虚拟多模态):代理启用且目标≠视觉模型时, 纯文本模型的 inputModalities 对外补 'image'(浅拷贝,不改 truth);改写判定继续用原函数。 副作用勘察:其余调用方(模型选择器目录只用 reasoning 字段;mcp-client/tool-fs 的图片 声明检查——放行反而正确,工具产图也走代理);官方内部投影不走此公共方法。
  2. BlockAssembler.finish 是 getter,恒真值(最深的根因):finish 未收到 finish chunk 时返回 {kind:'stop'}——if (assembler.finish) break 会在第一个 chunk 后就 break, 识别永远只消费 1 个块(block-start)→ 空文本 → "视觉模型没有返回文本"。 ~350ms 秒败 + 位置相关曾误导出"端点限流"假说(探针 A-E 用 c.type==='finish' 恰好成功, F-J 用 asm.finish 恰好失败,与调用次序重合)。修复if (chunk.type === 'finish') break。 教训:消费官方对象先核对属性语义(getter/方法/默认值),冒烟的 fake 流第一个 chunk 就带文本所以测不出该 bug。
  3. 识别调用通道:改走原 prepareCall().stream()(与 agent-loop 带图请求同通道,被验证 路径);options 以 prepared.config(resolved)为底构造,否则 callConfigEquals 拒绝。 不带 sessionId(llm/stream 的会话检查点监听对带 sessionId 的调用先做检查点)。

识别结果缓存(防真实限流):attachmentId+视觉模型 → 文本,TTL 10 分钟、上限 64 条。 agent 每轮重试/多步都带历史图片重发,逐图重识别既慢又易撞端点节流;失败不缓存。 导出 __clearDescribeCache() 供冒烟隔离。

验证(隔离实例 3999 真实全链路,API 注图):

  • deepseek-official/deepseek-v4-flash-vision-exp 视觉模型:识别 168 字,主模型 deepseek-v4-flash-0731 正确答出"蓝底+黄方块+绿长条"(与测试图一致),无工具折腾;
  • qwen-token-plan-cn/qwen3.8-max 视觉模型(用户实际配置):识别 174 字,同样正确。 Host 冒烟新增:虚拟多模态(启用宣称/停用保真/多模态不重复加)、识别缓存命中断言。

8.15 v0.4.0:模块化架构 + 用量记录(2026-08-22 深夜)

需求:①新功能【用量记录】放第二个菜单,功能与页面参照 /Users/weiyi/develop/gitea/wycto/dsh-token-usage (仅作参照,不动那个仓库与 npm 包);②dsh-dock 像dsh 一样由一个个独立功能模块组成,任何模块可拎出单独打包发布,又能装回面板。

架构落地(feature 模块化)

  • 仓库重构:features/<id>/host.js(宿主半部,纯 ESM 零构建)+ features/<id>/view.js(x)(客户端视图)+ src/client.jsx(外壳组装)+ src/host-core.js(共享内核);index.js 只 import 组装(1359 行 → ~90 行)。
  • 客户端放弃零构建(本版唯一推翻的历史决策,见三、表格 8.7 条上下文):esbuild bundle 成单文件 client.js(react 系外部化走运行时 seed,@wycto/dsh-token-usage 同款线上已验证模式)。理由:650 行 JSX 视图手写 createElement 不可维护、真实源码文件无从模块化。宿主半部保持零构建。改客户端后必须 node scripts/build-client.mjs
  • 模块契约:视图导出 { id, name, order, accent, description, css, View, HomeStat?, Chip? }(order 定菜单次序,当前:modelconfig=100/tokenlog=110/balance=120/animation=130/heartbeat=140/theme=150);宿主导出 { id, name, description, defaultEnabled, setup(ctx)→disposer };视图收到 { ctx, feature, params } props(params 来自导航总线)。
  • 会话区 chips(0.4.0 追加):外壳注册 conversation.input.left(模型选择器左侧工具行,官方文档指定可点击小控件的座位),渲染各已启用且「会话页显示」功能的 Chip 组件;余额 chip 显示当前选中 Provider 余额(agentDefaultModel 快照),点击 openPanel('balance', {provider}) 定位并高亮行;用量 chip 显示当前会话总 Token(query sessionId,10s 刷新),点击 openPanel('tokenlog', {sessionId}) 按会话筛选。开关状态经 shared.js 总线(面板导航 + 功能开关 + chip 显隐,localStorage 持久化 dsh-dock/chips/v1),避免外壳↔模块循环依赖。
  • dockBridge 回装通道client.js 导出 dockBridge.register(def);独立功能包(scripts/extract-feature.mjs 生成的骨架)在浏览器端 ctx.modules.import('dsh-dock') 成功→注册进功能坞(独立入口隐藏),失败→自己独立面板。跨插件 import 是 DSH client-modules 官方机制(懒 CJS 注册表 + boot 图,已读 dsh-client-modules 源码验证),非 hack。外部视图有 FeatureBoundary 错误边界 + 「外部」徽章 + package 标注。
  • 提取脚手架 scripts/extract-feature.mjs <id>:镜像仓库布局生成独立包(宿主入口/双形态客户端入口/构建脚本/patch/package.json),脚手架性质、发布前需裁剪实测。

用量记录(tokenlog):宿主移植 dsh-token-usage/lib/index.js(session/event 实时 + 启动全量扫描、sessionId:seq 去重、turn/end 两遍回填、峰谷定价 + 官网价目 24h 抓取 + dsh-dock-tokenlog 命名空间覆盖),RPC POST /dsh-dock/tokenlog/query|export|scan;视图移植其 client v6(筛选/KPI/分组/明细/CSV/5s 自刷新/localStorage 暂存),CSS 前缀 dtok-,dock 内自适应(无 overlay 壳,全屏用弹窗最大化)。顺带修了原版分组按钮一次旧维度过期查询(runQuery 带 dimOverride)。

验证node --check 全绿;构建产物含 ModuleLoader 壳/exports.dockBridge/仅 react 系外部依赖(bundle 纯度断言,8.7/8.12 教训延续);/tmp/dock-smoke-v040/smoke.cjs 全绿——假 react(手搓 hooks 派发)渲染整棵弹层树断言菜单次序(首页/用量记录/模型设置…)、外部桥注册排序与徽章、错误边界;宿主假 ctx 装配断言三路由注册、tokenlog 摄取→查询→分组→CSV 闭环(含 token 桶/派生字段/llmMs/状态回填/金额非零)。浏览器实测见后续补充。

注意

  • 冒烟假 ctx 的 effect(fn) 必须立即执行 fn(cordis 语义),否则 tokenlog 路由注册不到——曾在此误判路由缺失。
  • Node 26 有实验性全局 localStorage,冒烟里 typeof localStorage 不再是 undefined(try/catch 兜底无碍)。
  • visionproxy 依赖 modelconfig 的 readModelDirectory(模块间 import),提取时两个模块要一起拎。

8.16 v0.5.0:任务动画(2026-08-23)

需求:参照 /Users/weiyi/develop/gitea/wycto/dsh-task-pulse 做任务动画功能,但动效不要花哨、要克制高级;支持单独开启动画 / 单独开启通知;所有配置持久化。

实现(features/animation/ 模块,占位转正)

  • Host(features/animation/host.js):会话级任务追踪移植自 task-pulse(agent/status 驱动开始/结束、session/event 补回合/步骤/工具/Token/结束原因/首条输入与末条摘要、agent/disposed 兜底;readTitle 异步回填标题),去掉钉钉推送。RPC POST /dsh-dock/animation/status|config;配置经 settings.get/mutate(DOCK_NS) 持久化到 settings.yaml dsh-dock.animation 段(schema 在 host-core.js DockConfig 扩展:animationEnabled/effectMode/notifyEnabled/notifyOnComplete/notifyOnError/notifyStayMs/systemNotify)。config 增量合并、逐字段类型校验(未知 effectMode 忽略、stayMs 钳 0~600000)。
  • Client(features/animation/view.jsx)
    • 新模块契约 Overlay:功能描述符可挂全局浮层组件(props {ctx, feature}),外壳新增 shell.overlay 注册 dsh-dock-feature-overlays(order 22)统一渲染已启用功能的 Overlay(FeatureBoundary 包裹;dockBridge 外部功能同样支持)——与 Chip(会话区小控件)互补,Overlay 是常驻整页 UI。
    • 动效(全部走 dsw 主题变量、暗/亮自适应):任务进行中右下角玻璃拟态状态徽标(任务数+计时,点击 openPanel("animation"));三选一动效模式 flow=顶部 2px 流光细线(默认)/breathe=徽标圆点呼吸/ring=圆点细环匀速旋转;任务结束徽标与细线自动消失,完成瞬间一缕一次性流光掠过(成功绿/异常红,onAnimationEnd 清场)。
    • 通知:右上角 toast 卡片栈(标题/模型/耗时区间/回合步骤工具/Token/摘要/错误),完成与异常可分别开关、停留时长 4s~30s 或常驻、最多叠 4 张、out 动画后卸载;可选浏览器系统通知(仅 document.hidden 时推送,开关点击时 requestPermission——用户手势)。结束检测靠轮询 diff(2s 活跃/6s 空闲/15s 出错自适应,visibilitychange 立即刷新)。
    • 面板页:动画/通知两组独立开关 + 模式选择卡(缩微实时预览)+ 进行中/最近完成列表;每个开关即时 RPC 保存(乐观更新,回包经 animationStore.applyConfig 对齐)。共享 animationStore(浮层轮询驱动,View/HomeStat 只读)。
  • 功能开关持久化(0.5.0 路线图项顺手落地):shared.js dsh-dock/features/v1 localStorage(与 chips 显隐同模式);Host↔Client 开关双向同步仍未做(面板停用只影响浏览器侧,Host 侧 defaultEnabled 为准——见 index.js 注释)。

验证node --check 全绿;构建产物仅 react 系外部依赖;冒烟 /tmp/dock-smoke-v050/smoke.cjs 全绿(bundle 契约/5 slot/导航次序/浮层渲染、动画追踪闭环 running→事件→idle→status、config 合并→mutate 落盘→status 反映、非法值钳制、tokenlog 双监听共存无回归);隔离实例(/tmp/dsh-verify-home3,端口 3998)真实 RPC:status 默认配置 ✓、config 写入 settings.yaml animation 段 ✓、status 反映新值 ✓、/plugins/dsh-dock/client.js 服务的就是新构建(字节数一致)✓。

注意

  • 冒烟假 react 曾把 useEffect(fn) 的 fn 当 useState 初始化函数立即执行(v040 遗留),撞上 overlay effect 里的 document 才暴露;已改为 effect 只存不执行(React 语义)。
  • 会话标题是异步回填:毫秒级结束的任务可能落到 firstPrompt 兜底标题(task-pulse 同款行为,可接受)。
  • 线上 3080 旧宿主对 /dsh-dock/animation 返回 405(无路由);用户需手动重启 dsh web 后动画路由与新 client bundle(rev 模块映射在 boot 时生成)才生效。
  • v040 冒烟脚本 7 项"失败"均为预期变化(slot 4→5、动画转正无规划徽章、单监听器假 ctx 被 animation 覆盖 tokenlog),非产品回归;v050 冒烟已断言双监听共存。

8.17 事故复盘:任务动画页排版全乱(2026-08-23,已修复 + 防复发断言)

  • 现象:用户反馈任务动画页排版错乱——模式选择卡变成一行挤在一起的纯文本、无卡片边框、第三张卡溢出内容区、设置行间距全无。
  • 根因features/animation/view.jsx 定义了 css 常量,但 feature 描述符导出时漏挂 css 字段——外壳 ensureCss() 只按 f.css 收集模块样式,动画模块的 28043-19507≈8.5KB 样式整体缺失,所有 dkan- 类裸奔(.dkan-mode 退化为 inline-block、.dkan-sec/.dkan-row 退化为 block)。
  • 为什么冒烟没拦住:v050 冒烟只断言了视图渲染与 RPC 闭环,没断言注入样式内容——又是 8.7 教训的重演(浏览器端才炸的问题,纯 Node 冒烟查不出)。
  • 定位手段(可复用):浏览器实测 + 只读 evaluate 量 computed style:style[data-plugin-css="dsh-dock"]includes('.dkan-mode') 为 false、标签尾部还是 balance 样式 → 直接锁定「css 没进 fullDockCss」。
  • 修复:feature 描述符补 css, 字段;顺带修掉系统通知说明文案的重复括号(permNote 已含「仅页面后台时推送」又拼了一次)。
  • 防复发:v050 冒烟新增 A2b——假 document 捕获注入样式,断言含 .dkan-mode{/.dkan-badge{(动画)与 .dtok-/.dkb-(存量),任何模块忘挂 css 当场红。
  • 验证:重建后浏览器实测——CSS 注入 28043 字符、三张模式卡 flex 并排各 269×93 带边框、sec/row/head 全部恢复 flex;浏览器点击「轨道光环」→ settings.yaml 即时出现 effectMode: ring(交互→RPC→落盘闭环)。

8.18 版本策略(2026-08-23 用户指示)

  • 内部调试一律用小版本递增(当前 0.4.2);等用户明确说发布时再升到目标大版本(任务动画对应 0.5.0)。
  • package.jsonsrc/client.jsx 的 DOCK_VERSION 保持同步 0.4.2。

8.19 钉钉群机器人推送(2026-08-23,0.4.2 内追加)

需求:任务动画的通知支持对接钉钉群机器人,参照 /Users/weiyi/develop/gitea/wycto/dsh-task-pulse(宿主侧 webhook 推送同款)。

实现

  • 配置(schema 扩展 dsh-dock.animation):dingtalkEnabled(默认 false)+ dingtalkWebhook(默认 '')。config RPC 校验:非空 Webhook 必须以 http(s):// 开头否则 400(顺带修了路由 catch 一律 500 的问题,改按 e.statusCode 映射)。
  • 宿主推送(features/animation/host.js):finishSession 归档后 pushDingtalkIfNeeded(record) 异步直发(不阻塞)——宿主侧发送,浏览器关着也能推;事件筛选跟随 notifyOnComplete/notifyOnError(完成推/异常推与页面通知共享语义,通道各自开关)。markdown 消息移植 task-pulse 版式(任务/模型/耗时区间/回合步骤工具/Token/结果/摘要/署名),标题含「任务」二字——钉钉自定义关键词填「任务」或「dsh」即可命中。sendDingtalk 判定成功 = HTTP 200 且业务 errcode===0(比 task-pulse 只看 res.ok 更严)。
  • test RPCPOST /dsh-dock/animation/test):用已保存 Webhook 发测试消息,返回 {sent, error}(连通失败也是 200 + sent:false,UI 内联展示错误,不当 HTTP 错误抛)。
  • 客户端(view.jsx):通知区之后新增「钉钉推送」卡片——开关 + Webhook 输入(草稿「保存」按钮 + 「发送测试消息」按钮 + 内联测试结果)+ 机器人创建指引。同步保护:配置轮询回包不再覆盖乐观值/输入草稿——pendingSavesRef(保存进行中跳过同步)+ editingWebhookRef(输入中跳过同步;保存成功后手动对齐一次,因同步 effect 在编辑期被跳过不会再触发)。
  • 验证:冒烟 B4(fetch 桩)——推送格式(msgtype=markdown/标题含任务完成/含任务与模型与摘要/署名)、开关门控(关推送不再发)、事件门控(notifyOnComplete=false 完成不推)、errcode≠0 与 HTTP 非 200 两态 sent:false、非法 Webhook 400;隔离实例真实 RPC——400 状态码映射 ✓、Webhook 落盘 ✓、拒连端口 test 优雅返回 sent:false("fetch failed") ✓;浏览器实测——钉钉区渲染/输入框/按钮 ✓、点击开关落盘 ✓。

8.20 动画增强:错位修复 + 速度驱动 + 环屏巡航 + 桌面机器人(2026-08-23,0.4.2 内追加)

用户反馈:①徽标/浮层与 dsh 自身 UI 错位;②动画不明显;③要更多动效(绕屏幕转圈、速度随任务);④桌面机器人(多显示器工位、思考/写码/查资料三态同步、要逼真);后追加:⑤机器人要侧身/背侧视角;⑥机器人可自由拖到屏幕任意位置防遮挡。

实现

  • 错位根因与修复:徽标固定 bottom:20px 压在 dsh 输入卡(--dsh-composer-height,约 152px)与 Details 区上。修复:徽标/机器人默认停泊抬到 bottom:calc(var(--dsh-composer-height,152px) + 20px)功能坞面板打开时环境动效整体隐藏(subscribePanel 驱动 ambientOn,避免与弹窗重叠);钉钉 Webhook 行 nowrap 防按钮换行错位。
  • 速度驱动:overlay 每次轮询按 Token 吞吐(Δtokens/Δt)算 speed=clamp(0.7+t/45, 0.7, 3),经 CSS 变量 --dkan-speed 下发;全部动效时长用 calc(base / var(--dkan-speed))——流光/呼吸/旋转/巡航/机器人敲击全随任务忙闲变速。
  • 新动效
    • orbit 环屏巡航:12px 光点(发光+拖影光晕)沿屏幕四边转整圈(keyframes top/left 四段)。
    • robot 桌面伙伴:背侧视角纯 CSS 场景(176×108)——横贯书桌、三台显示器(侧屏 rotateY ±24° 透视内倾)、机器人见后脑勺+双耳+天线+椅背+双肩前臂。data-phase 三态:think=屏幕调暗+歪头+思考泡泡;code=双臂高频交替敲击+中屏代码行滚动;search=头部左右扫视+侧屏滚动。整卡 pointer 拖拽(≥4px 位移阈值区分点击,拖后位置写 localStorage dsh-dock/anim/robot-pos/v1,挂载恢复时钳回视口内),默认停泊右下输入卡上方。
  • 阶段追踪(host)tool/call 事件带 data.name(dsh-agent-loop appendToolCall 实证:turn/step/callId/name/arguments)。分类:web|search|fetch|grep|glob|read|ls→search,其余(edit/bash…)→code;step/start 与 assistant/message→think。status RPC active 条目新增 phase/phaseAt/lastActivityAt;面板「运行状态」行显示阶段标签。
  • 可见度增强:流光细线 2px→3px、底色 16%→30%、扫光段加白端+辉光;呼吸/环加大加亮。

验证:冒烟全绿(新模式合法值、phase 三态断言 step/start→think / webfetch→search / str_replace_editor→code、新 CSS 注入断言 .dkan-orbit/.dkan-botcard/.dkan-bot-scene[)。隔离实例 + 真实任务 E2E:40s bash 任务运行中实测——host 侧 phase 实时流转(bash 期 code → 收尾 think);浏览器侧五种模式元素逐一出现(orbit/line/badge+dot/badge+ring/botcard 各=1);机器人卡片背侧结构齐全(臂x2 屏x3 椅x1 耳x2)、caption「1 个任务 · 00:07 · 编写代码」;cua.drag 拖到 (328,95) 成功、刷新页面后新挂载从 localStorage 恢复同位置;错误边界 0 触发。

注意

  • 会话视图的输入框 placeholder 从 "Describe what you want to build"(新会话)变为 "Message the agent"(进入会话后),自动化测试按角色名定位。
  • 机器人阶段是"最近一次事件"的粗粒度推断(事件驱动,无流式细节)——assistant/message 后短暂 think、bash 长跑期稳定 code,符合直觉即可,不是精确的 token 级流式状态。
  • dsh 输入卡高度走 --dsh-composer-height(152px 回退值),dsh 升级改高度时停泊位自适应。

8.21 3D 立体机器人 + 流级阶段同步(2026-08-23,0.4.2 内追加)

用户反馈:①机器人要 3D 立体、侧身面对镜头;②同步 bug——正文已经在输出,机器人还显示"思考中"。

同步 bug 根因与修复:旧阶段只靠事件级推导(step/start/assistant/message/tool/call),消息粒度太粗——模型流式输出期间没有任何事件更新阶段。修复:接 assistant/chunk 流级事件(dsh-agent-loop 每 token append 一条 {turn, step, chunk},chunk.type 为 reasoning-delta/text-delta 等,dsh-llm invariant.js 实证):reasoning-delta→think、text-delta→write(新增阶段"输出中");assistant/message 落盘后不再回跳 think(维持 chunk 末态,避免输完还闪思考)。

3D 实现(纯 CSS,无三方库):Box3 组件 = 六面长方体(前/后/左/右/顶/底,backface-visibility:hidden + 面级明暗渐变出光感);世界层统一 rotateX(-15deg) rotateY(-30deg) 取 3/4 视角(俯视 + 侧身面向镜头);场景 = 书桌+桌腿、三台薄盒显示器(组内扇形微转、屏幕贴前面板)、机器人(椅背/椅座/躯干/头组/双臂肩+肘两级关节/键盘)。阶段驱动:think=仰头(rotateZ)+泡泡+屏幕调暗;write/code=低头+肘部高频敲击+中屏代码滚动;search=头部 rotateY 左右扫视+侧屏滚动。速度仍走 --dkan-speed。

验证:冒烟补 chunk 断言(reasoning→think / text→write / message 不回跳)全绿;隔离实例真实任务每秒采样阶段时间线:thinkcode×5(bash)thinkwrite×9(600字输出流)\text{think} \text{code} \times 5(\text{bash}) \text{think} \text{write} \times 9(600字输出流) ·——bash 期 code、输出流期 write 精确对应;3D 结构体检(15 个长方体/3 屏/2 肘/2 眼、world matrix3d 生效)。 注意:环境动效在功能坞面板打开时自动隐藏(8.20 防重叠设计),浏览器验证机器人必须先关面板;面板打开期间观察不到卡片是预期行为。

8.22 机器人换 3D 动漫人物 + 修身体被桌遮挡(2026-08-23,0.4.2 内追加)

用户反馈:①机器人只见头不见身子;②换成 3D 动漫真人角色;③动作要随任务切换(思考完要有写代码动作)。

只见头的根因:8.21 的机器人组 translateZ(-6px) 与书桌同深度,躯干/椅/手臂埋进桌体被遮挡,只有头探出桌面。修复:人物组改 translateZ(30px) 坐到桌前近镜头侧(侧身面对屏幕、背对镜头偏侧),桌子收窄到右侧,键盘 z=14 放桌前缘手边——头/躯干/腿/椅/手臂全部可见。

换人:机器人 → 动漫人物(仍纯 CSS 长方体):后发+顶发(棕)、脸(肤色+深色眼睛+脖颈)、卫衣躯干、坐姿腿(大腿前伸+小腿垂下)、双臂带手(肤色手块)、椅子。配色走 dk3-skin/dk3-hair/dk3-hood/dk3-pants 面级渐变。动作语义不变(think 仰头+泡泡 / write+code 低头敲键盘 / search 扫视),阶段仍由 assistant/chunk 流级同步。

验证:结构体检(人物组/头/发×2/眼×2/躯干/腿×2/臂×2/手×2/椅,人物 matrix3d z=30);真实任务时间线 thinkcode×8(bash)thinkwrite×8(输出流)\text{think} \text{code} \times 8(\text{bash}) \text{think} \text{write} \times 8(输出流) ·——思考→写代码→输出逐段切换。

8.23 人物居中布局修正(2026-08-23,0.4.2 内追加)

  • 用户反馈:人物坐在桌子左边缘外,要坐到桌子中间。
  • 修正:场景加宽 200→220px;书桌 200 宽居中(x=110 中心);人物组 left 16→96(中心 x≈118 ≈ 桌中心);三屏收到人物右侧扇形(x=128/166/196,ry ±20°);键盘 x=126 在人物手边桌前缘。
  • 几何验证(面板预览卡实测):人物中心与桌中心偏差 8px(居中);人物 bottom 落在桌沿下(坐姿正确,腿在桌面下方);三屏在人物右侧、键盘在手边。
  • 注意:本节验证时隔离实例的模型配额已耗尽(429 insufficient_quota,周配额 08-28 重置)——真实任务 E2E 已不可行,布局验证改走面板预览卡(静态渲染同款 RobotScene)+ getBoundingClientRect 几何测量;阶段流转逻辑未改动(8.22 已验证)。

8.24 手机接力正式接入 + 局域网电脑直连(0.0.0.0)(2026-08-28,未发布)

需求:用户希望局域网内其他电脑能访问完整 DSH(会话记录、工作区文件夹选择等全部交互与 127.0.0.1 一致),并明确要求"以 0.0.0.0 启动",实现放在 dsh-dock 的【手机接力】里;主实例(3080)不得挂掉,另开新端口测试。

关键事实(实证自 dsh 0.1.1-rc.2 源码)

  • CLI 拒绝 --host 0.0.0.0dsh-web-app/lib/startup.js 显式 program.error,理由:浏览器表层无登录认证,直开会把代码执行能力交给同网任何人);
  • webServer 行 schema 本身支持 0.0.0.0dsh-host-webserver),合成配置里该行 id = webserver,host 来自 ctx.webStartup.host ?? '127.0.0.1'
  • 绑定 0.0.0.0 后 dsh-web-appresolveLanTrust 自动把全部局域网 IPv4 派生进 /api 信任白名单;目录选择器 directory-picker-autobindHost !== '127.0.0.1' 自动切成 browse 模式(浏览器内浏览/新建目录,host.listDirectory/host.createDirectory)——这正是局域网电脑选工作区文件夹所需;host.pickDirectory(本机原生对话框)属于 PRIVILEGED_METHODS,任何 LAN 访问都 403,与真 0.0.0.0 绑定行为一致。

实现(不复制会话,同资料共享)

  • features/mobile-relay/host.js 新增 lan / lan/start / lan/stop RPC:写一条 - id: webserver, config: {host: '0.0.0.0', port: N} 补丁到 ~/.dsh/dsh-dock-lan/lan-N.patch.yml,用 process.argv[1](当前 dsh 入口)spawn node <入口> --profile web --patch <补丁> --port N --no-open,stdout/stderr 进 lan-N.log;端口预检(0.0.0.0 绑定探测)、就绪轮询(25s 超时带日志尾部报错);
  • 子实例环境标记 DSH_DOCK_LAN_CHILD=1 + DSH_DOCK_LAN_PARENT_PID:子实例内 lan/start 拒绝(防递归嵌套);每 5s 检查 process.ppid,主实例退出即自退出(孤儿保护);
  • features/mobile-relay/view.jsx 新增 LanDirectCard(两种视图分支都渲染):端口输入(默认 3082)、状态徽标、地址列表 + 二维码 + 复制、一键启停、安全警示;
  • 接线:index.js FEATURES + src/client.jsx BUILTIN_FEATURES(order 80)+ 重建 client.js(版本号未提升,待发布时再定)。

集成测试实录(主实例 3080 全程存活)

  • 实例 A(0.0.0.0:3082,手动补丁同机制启动):loopback/LAN IP 均 200;Host: 172.18.98.20:3082host.describe/host.listDirectory/session.list 全部通过信任篱笆;host.pickDirectory LAN 403(预期特权钉死);pickDirectory loopback 报 composed picker serves "browse"(证明 browse 已挂载);host.listDirectory 返回目录树、host.createDirectory 可建目录;
  • RPC 链:A 上 lan/start {port:3083} → 子实例 B 1s 内就绪,LAN IP 可访问,lan 状态 child:true;B 上 lan/start 返回 400 拒绝;B 的 session.list 与主实例同一份;A lan/stop → B 进程退出、端口释放;停止后可重开;杀掉 A → B 在 5s 内自退出(孤儿保护生效);
  • 收尾:3082/3083 均无残留监听,http://127.0.0.1:3080/ 保持 200。
  • 遗留注意:macOS 防火墙首次放行 node 入站可能弹窗(本次测试 172.18.98.20 直连成功,未遇拦截);直连子实例与主实例同时写同一份 ~/.dsh 会话文件属预期共享语义,避免两端同时编辑同一会话。

8.25 局域网直连 crypto.randomUUID 修复(2026-08-28,未发布内追加)

用户反馈:局域网电脑打开直连实例后左侧无会话记录、不能打开工作目录,控制台报 crypto.randomUUID is not a function

根因:局域网 http://<IP> 不是浏览器安全上下文(只有 HTTPS 或 localhost 才是)。部分浏览器在非安全上下文只暴露 crypto.getRandomValues() 而省略 crypto.randomUUID()。DSH 的 connection 客户端在生成消息 ID(MessageId(crypto.randomUUID()))与 RPC ID(RpcId(crypto.randomUUID()))时直接调用它——缺失则整个客户端引导失败,表现为会话列表空、工作区不可用等"全面瘫痪"。手机接力网关此前已用代理注入 compat.js 解决同问题,但直连子实例是裸 DSH 服务、没有代理层,所以漏了这一步。

修复:直连子实例(isLanChildInstance() 为真)在 webServer.tapIndex 注册一个 transform,向 index.html 的 <head> 之后内联注入同款 UUID v4 兜底脚本(LAN_COMPAT_JS),先于任何 DSH 引导脚本执行;幂等(marker 防重注);本机回环下 randomUUID 已存在→脚本自我短路零副作用。仅子实例注册,主实例 HTML 完全不受影响。

验证:带子实例标记启动 0.0.0.0 实例,index.html 的 <head> 后即见 <script data-dsh-lan-compat>(loopback 与 LAN IP 均注入);VM 模拟非安全上下文(有 getRandomValues 无 randomUUID)跑注入脚本→randomUUID 被定义且产出合法 UUID v4;重启用户现役子实例后 session.list@LAN 正常返回 59 条会话,主实例 3080 保持 200。

2026-08-28 · v0.8.1 联调修复:手机接力/局域网直连三个链路级问题

用户反馈:手机端接力打开没有历史会话记录、不能选择文件夹工作。在 3480 测试实例(源码安装 pnpm dsh web,插件经 ~/.dsh/profiles/webfile: 依赖挂载)逐项复现与修复:

1) 手机端不能选文件夹(架构):首版接力网关上游指到主实例(127.0.0.1)→ 主实例目录选择器为 native(directory-picker-auto 按 bindHost 解析,win32+回环 → native),手机端 host.listDirectory 被 DSH 拒绝(directory-picker-unavailable,实测复现)。修复:手机接力与局域网直连共用同一个 0.0.0.0 子实例(ensureLanChild 幂等复用),子实例补丁钉死 browse 组合(停用 directory-picker-auto,直接挂 -browse 后端 + ui-directory-picker-browse 前端面);网关按路径分流:/dsh-dock/mobile-relay/* 配对 RPC 转发回主实例(配对状态/任务摘要在主进程),其余流量转发子实例。

2) 信任链路(会话列表):子实例绑定 0.0.0.0 后 DSH 派生局域网 IP 信任(web-app resolveLanTrust → connection 行 trustedHosts),信任篱笆(api-request-trust.ts)校验请求自带 Host/Origin 一致性而非回环名——网关代理头从「改写 Host/Origin 为回环 + 剥 cookie」改为原样透传(改写反而令 Origin 校验失败 403;GET 无 content-type 的 HTML 请求仅去 accept-encoding 以便注入 compat)。实测手机链路 session.list 返回与主实例同一份历史会话。

3) 源码安装下子实例 spawn 崩溃dshEntry() 取到 .ts 入口(tsx 运行),裸 spawn 无法执行——透传 process.execArgv--import tsx/esm);子命令参数顺序:web --patch <path> --port <port>(commander passThroughOptions 在第一个未知旗标后全部透传,--patch--port 后会报 unknown option '--patch');就绪超时 25s → 60s(tsx 冷启动实测 ~18s)。

其它:无设备配对 90s 僵尸回收(否则卡住 lan/stop 满配对窗口);lan/stop 守卫只看有设备在线的配对;DSH_DOCK_ENTRY/DSH_DOCK_LAN_PORT 测试桩(宿主单测覆盖「接力拉起共用子实例/有配对拒绝 lan/stop/end 后网关关/lan/stop 后子实例退」)。

验证(3480 实例全链路):LAN 直连 host.listDirectory(浏览 F:\workspace)+ session.list ✓;手机 connect→join(经网关分流回主实例)→status ✓;经网关 session.list/host.listDirectory ✓;end→网关关、lan/stop→子实例退 ✓。两个单测(gateway/host)在透传语义下通过。

部署注意:profile 里的插件是 file: 快照——同版本号 pnpm add 会跳过,改代码后须 pnpm remove dsh-dock && pnpm add file:F:/workspace/wycto/gitea/dsh-dock;仓库 checkout 无 node_modules,跑测试需 junction qrcode/@deepseek-ai/schemastery(已建,gitignore 内)。

2026-08-28(晚)· 架构定稿:服务器模式(单实例 0.0.0.0)

用户明确目标形态:"dsh 装在服务器上,任何设备访问都一样,包括正在进行的任务"。此前共用子实例方案的根本缺陷暴露:主实例与子实例是两个进程,agent 运行在各自进程里——3082 上继续的会话,127.0.0.1 看不到运行状态;反向亦然。任务同步跨进程不可行,唯一正解是单实例。

重构:删除整个子实例机制(spawn/补丁生成/孤儿保护/测试桩),改为「服务器模式」——插件把 - id: webserver / config: {host:'0.0.0.0', port} 写进用户 profile 补丁层(~/.dsh/profiles/web/cordis.patch.yml;js-yaml + 与 include 插件同款 !!js Type 无损往返,幂等 upsert),重启后主实例直接绑定 0.0.0.0:绑定即派生局域网信任、目录选择器自动 browse(不再需要钉死补丁);手机接力网关上游改回主实例本身(配对 RPC 分流也不再需要)。tapIndex 兼容注入改为无条件注册(回环自短路)。

附带结论:浏览式选目录本来就支持任意路径——顶部面包屑/铅笔是"点击可编辑路径",直接输入 F:\workspace\xxx 即可跨盘;"只有 C 盘"是没发现这个入口,不是缺陷。

验证(3480):lan/start 写补丁→重启→0.0.0.0:3480 LISTENING、127.0.0.1 与 10.31.1.4 均 200、compat 注入 OK;LAN IP 浏览选目录(列出 F:\workspace\wycto)+ session.list OK;手机 connect→join→status、经网关 session.list/host.listDirectory OK;lan/stop 补丁还原 []。两个单测(网关透传语义 / 服务器模式补丁往返+幂等)通过。

部署:profile 快照 file: 依赖同版本跳过——更新代码后必须 pnpm remove dsh-dock && pnpm add file:<仓库>;仓库 checkout 需 junction qrcode/js-yaml/@deepseek-ai/schemastery 到 profile node_modules(均已建,gitignore 覆盖 node_modules/)。新增 npm 依赖:js-yaml ^4.1.0。

2026-08-28(夜)· 定稿:远程访问 = 账号密码登录网关(主实例回仅本机)

用户要求"除 127.0.0.1 外的访问都要弹账号密码,能登录/退出"。最终架构:删除扫码访客与配对机制,网关升级为账号认证前门——登录页(纯静态 HTML+CSS,表单原生 POST,无内联脚本)→ 验证通过发 HttpOnly 会话 Cookie(7 天滑动)→ 代理到主实例;/__dsh_auth/logout 注销;失败按 IP 限速(10 次/10 分钟)。账号存 dsh-dock settings 命名空间(sha256+盐,timingSafeEqual 比对),auth/set 改密即 revokeAllSessions 全端下线。开启入口同时:写 browse 选择器钉死补丁(重启生效)+ 自动移除旧服务器模式 0.0.0.0 行(迁移)。主实例回到仅本机 = 结构上不存在免登录直连;单实例 = 任务进度全端一致。

踩坑:Mimosa 安全钩子对 <script> 字符串构造极敏感(loginPage 的静态路径拼接也被拦)——页面一律用无脚本纯 HTML(错误态走服务端 303 + 独立静态页,自动跳转用 meta refresh);window.__DSH_REMOTE__ 标记注入被拦后改用 /__dsh_auth/health 探针检测远程态。测试注意:node fetch 默认跟随 302(门禁断言须 redirect:'manual')且默认 Accept 不含 text/html(须显式带)。

验证:网关单测(门禁 302/401、登录页、错/对密码、Host 改写、Cookie 透传、WS、退出、revokeAllSessions)+ 宿主单测(未设账号 400、开启后凭据登录全链路、auth/set 改密旧会话作废、补丁幂等、关停清理)全绿;3480 实测 LAN IP:未登录 302 → 登录页 → 401/200 → 会话代理 → 退出 302。共享 3480 端口与用户活动实例相撞导致两轮误判("启动慢"实为 tsx 冷启动 + 端口被残留实例占用)——测试实例用独立端口或先清场。

2026-09-04 · v0.9.4 手机端抽屉按钮:侧栏「只收不放」修复

用户反馈:远程访问手机端只有默认会话视图,侧边栏不显示、也没有任何按钮能把会话抽屉拉出来。

根因(实证自 dsh-client-ui-layout/sidebar bundle):窄屏(<700px)下侧栏显隐由 layout store 的 narrowExpanded 驱动(toggleSidebar 翻转它),原生「打开侧边栏」按钮渲染在侧栏窄轨(rail)里;而 v0.8 的手机适配 CSS 把 [class$="frame"] 栅格压成 0px minmax(0,1fr) 0px——窄轨连同里面唯一的展开按钮一起被裁掉,侧栏从此只收不放。

修复(features/mobile-relay/host.js 注入层,本机与远程网关同层生效):

  • 悬浮抽屉按钮 .dsh-mobile-drawer-btn:行为脚本创建,窄屏 + 侧栏收起 + 无设置弹窗时才显示;停泊输入卡上方左下(bottom: calc(var(--dsh-composer-height,152px) + 14px),与任务动画徽标右侧停泊镜像),点击转发原生 button[aria-label="打开侧边栏"] toggle。
  • 抽屉遮罩 .dsh-mobile-scrim:抽屉展开时显示(z 序:按钮 70 < 遮罩 75 < 侧栏 80 < overlayLayer 90),点击经全局捕获 handler 收起——单一路径,不给同一事件两次 toggle 的机会(遮罩自身不挂 click,避免捕获/冒泡双触发把抽屉又顶开)。
  • 显隐同步:MutationObserver(data-sidebar-collapsed 属性翻转即时)+ 1.5s 低速轮询(覆盖路由重渲边角);注入点在 <head> 后 body 未解析,DOM 创建推迟到 DOMContentLoaded。全局捕获 handler 排除 .dsh-mobile-drawer-btn(否则展开动作会被"点外部收起"规则当场撤销)。

验证:host 单测新增 tapIndex 注入断言(抽屉按钮/遮罩/UUID 兜底 + 幂等)全绿;网关单测无回归;注入 JS 独立 node --check;手写最小 DOM 仿真(/tmp/dsh-inspect/behave-sim.mjs)跑通窄屏全交互(收起显按钮→点击展开→外部点击收起→选会话 300ms 自动收起)与宽屏零副作用。 部署注意:注入层在宿主进程(tapIndex 注册于插件加载时)——需重启 dsh web 后手机端刷新生效;client bundle 仅版本号变化。

2026-09-04(续)· 抽屉按钮改走客户端 Overlay:刷新即生效,免重启

转折:用户反馈"刷新了还是一样"。实证:宿主 tapIndex 注入在进程启动时注册(旧进程仍是旧注入,刷新无效),但 client bundle 的 rev 是按内容 hash 每请求实时算的(boot entries 里的 dsh-dock rev 随磁盘 client.js 变化)——客户端改动浏览器刷新即可生效,无需重启 dsh web。

落地:抽屉按钮 + 遮罩 + 行为逻辑复制为 MobileRelayOverlay(features/mobile-relay/view.jsx 的 feature.Overlay,渲染返回 null、effect 里直接操作 document.body 下的 DOM——必须逃出 overlayLayer z90 堆叠上下文才能压住侧栏抽屉 z80);抽屉 CSS 挂进模块 css(dock ensureCss 注入,同样刷新即生效)。

双版互斥window.__dshDockMobileDrawer 先到先得——宿主注入脚本在 先跑(重启后总是宿主版接管,设 "host");客户端 Overlay 看到旗标自动让位(自己设 "client",HMR 卸载时清理)。与旧版宿主行为脚本(无按钮只有自动收起)并存安全:收起链路同一事件内第二次 collapse() 因 aria-label 已翻转而空转。

注意:本机有个外部 watch 进程会在源文件变更后自动重建 client.js(字节数两次在无手动构建下变化、时间戳紧随编辑)——手动构建后若发现产物被"覆盖重建"属正常现象,以最新产物为准。macOS BSD grep 不支持 BRE 的 \| 交替,多模式用 grep -E

2026-09-04(续2)· 抽屉把手改贴左边缘 + 趣味游戏浮标上方

用户反馈:把手停在输入卡上方左下,遮挡内容、不直观。

改法(客户端 Overlay 与宿主注入两版同步改):

  • 视觉改为贴左边缘的抽屉把手(width 30 / height 48、右圆角、无左边框、玻璃底,与趣味游戏浮标 dgfab 同款边缘吸附语言),图标换通用抽屉符号(汉堡线 M4 7h16M4 12h16M4 17h10)。
  • place():动态定位在 .dgfab 正上方(fabTop - btnH - 8);浮标太靠上(top<64,会压顶栏标题)改放它下面;浮标隐藏(dgfab-hide)或不存在时兜底左侧中部 38% 视高。sync 每 1.5s 复测(拖拽浮标最迟一个周期跟上)+ window resize 立即复测。
  • 顶部左侧方案否决理由:手机端顶栏左上是会话标题,按钮必遮挡。

验证:注入 JS node --check、DOM 仿真全交互绿(仿真器 window 桩补 addEventListener/innerHeight)、host/网关单测绿、线上 3080 bundle(rev 54a2fc8ddd1e)与仓库一致。刷新即生效。

2026-09-09 · v0.9.9 点「任务动画」整个面板界面消失(waitingCount 跨块引用)

用户反馈:点击【任务动画】界面就没有了,而侧栏【功能坞】仍是选中态。

现象 → 机制(实证自 @deepseek-ai/dsh-client-ui-rendererSlotErrorBoundary):宿主把每个插槽条目包在错误边界里,条目内任何渲染抛错都被换成 <div data-slot-error="…"> 空节点("one registrant crashing must not take down siblings")。所以「弹层整块消失 + 侧栏入口仍高亮」= 插槽内组件渲染抛错,而不是面板自己关闭。

根因features/animation/view.jsxconst waitingCount = active.reduce(…) 写在 if (!cfg) {…} else {…}else 块内,但「运行状态」区块的 rows.push(…)块外if (saveErr) 之后)引用它 → ReferenceError: waitingCount is not defined。该行 v0.9.3(需确认提醒)引入,之后任务动画页一打开就崩(首帧即走那条 push,与 cfg 是否加载无关)。esbuild 的产物线索:声明被重命名为 waitingCount2、引用仍叫 waitingCount——同一绑定必然同步改名,名字不一致就说明源码里它们不是同一个变量

修复

  • 计数提到函数体作用域(active/recent 旁边),两条渲染路径共用;注释写明踩坑经过。
  • 内置视图也包 FeatureBoundary(此前只有外部包视图包):视图 / HomeStat / Chip / Overlay 全覆盖,带 key(切功能时重建边界,否则上一个功能的错误态会串到新页面)与 label;边界补 componentDidCatch 把错误 console.error 出来(原来只吞不报)。

反馈回路(本次的关键):新增 scripts/test-client-views.mjsnpm run test:client,无外部依赖,~130ms)。做法是在 vm 沙箱里加载构建产物 client.js(与浏览器同一份代码),用极简 React 替身(含 hook 按渲染路径缓存、多趟渲染、错误边界语义)逐个启用 8 个内置功能渲染设置页面板。它先以 waitingCount is not defined 变红 → 修复后全绿;另含两条隔离断言(外部视图崩溃、内置视图崩溃都只降级为一行提示)。新功能视图接入后请跑一次

验证npm run test:client 全绿(8 视图 + 2 隔离);client.js 重建后 waitingCount 声明与引用同为函数体作用域;无 [DEBUG-] 残留。 部署:纯客户端改动——浏览器刷新即生效(boot entries 的 rev 按 client.js 内容实时计算),无需重启 dsh web环境备忘:本机 npx esbuild 找不到包(/tmp/npm-cache 在 Windows 下无效);可用 C:\Users\wzy60\AppData\Local\npm-cache\_npx\beb367dfa21eb3f5\node_modules\.bin 前置到 PATH 后再跑 node scripts/build-client.mjs

2026-09-09 · 通知从「任务动画」拆出,独立成【任务通知】菜单项

需求:把【任务动画】里的通知部分独立出来做一个左侧菜单,同样支持启动停止。

拆法features/notify/,宿主 + 客户端两半,功能坞左侧菜单独立项,order: 140,accent 琥珀 #f59e0b):

  • 搬走:通知事件(完成 / 异常 / 需确认)、提醒方式(停留时长 / 系统通知 / 提示音 6 音效试听)、钉钉推送、飞书推送,外加一个「运行状态」(等待确认项 + 最近完成列表);提示音库、Toast 组件、.dkan-toast* / .dkan-row* / .dkan-sound* 等样式全部改前缀为 dknt-,两个模块样式自洽(各自可单独提取发布)。
  • 留在动画:运行动画开关 + 19 种模式 + 桌面伙伴大小 + 运行状态;浮层只做动效(流光/彩带/货运舰/徽标),不再有卡片栈。

关键设计

  1. 宿主侧任务追踪抽成共享模块 src/task-track.js,用引用计数共享一份实例:两个功能都开也只订阅一次 session/event(每 token 一条,不能订阅两份),两个都关立刻退订。API:acquireTaskTracker(ctx){ tracker: { snapshot(now), onFinish(fn), dispose() }, release() }。通知模块用 onFinish 接任务结束做群机器人推送。
  2. 配置分段DockConfig.animation 只剩动画字段,新增 notify 段。/dsh-dock/animation/*/dsh-dock/notify/* 各写自己的段,互不污染(宿主机测试有断言)。
  3. 一次性迁移migrateNotifyConfig(ctx)index.js 的 settings 注册回调里跑(早于任何面板保存——animation 保存整段写回会丢掉旧通知字段),把老 animation 段的通知字段搬进 notify 段并置 migratedFromAnimation: true,只写一次。schemastery 不剥离未知键,所以旧字段即使不在 schema 里也读得到。
  4. 模块启停 = 功能坞开关:notify 不再有第二个总开关(原 notifyEnabled 去掉),停用功能即卸载浮层 + 注销宿主路由与推送订阅。沿用 v0.9.5 默认关闭:升级后需在功能坞里手动开启「任务通知」。

验证npm run test:client 全绿(9 视图 + 2 隔离 + 新增「弹层左侧菜单含任务动画/任务通知」断言);新增 npm run test:hostscripts/test-task-notify-host.mjs)断言共享追踪只有一份订阅且停用后归零、双路由配置隔离、Webhook 校验与测试消息 400、钉钉推送跟随 notifyOnComplete/notifyOnError、迁移字段与幂等。client.js 重建后 dkan-toasts / dkm-miniswitch 在产物里为 0 处。

部署:宿主半部改了(新增路由 + 迁移),必须重启 dsh web 才生效;重启后到功能坞开启「任务通知」,旧的通知配置(含 Webhook)会自动迁移过来。

顺手修掉的提取脚本老毛病(验证「模块可单独提取发布」时发现,scripts/extract-feature.mjs 此前跑任何模块都会中途崩):① 生成的客户端入口硬编码 view.js,.jsx 模块直接 import 失败;② outDir/scripts/ 没创建就写 build-client.mjs → ENOENT;③ 生成 package.json 的 description 引用了脚本里根本不存在的 feature 变量 → ReferenceError。现按 view.jsx/view.js 自动选择、显式 mkdir、description 用 featureId;实测 notify(.jsx)与 balance(.js)都产出完整骨架。同时把 src/task-track.js 一并复制进独立包(notify 宿主依赖它)。

2026-09-09(续)· 用户实测「不得行」:开关只落在浏览器、宿主从未 setup

现象:重启 dsh web 后打开【任务通知】,页面报「宿主进程是旧版本(没有任务通知路由)」,首页卡片也报「通知状态不可用」。

排查(先排除「没重启」):netstat 查到 3080 由 PID 57640 监听、启动于 12:51:05,晚于本次改动(12:48);~/.dsh/settings.yamlnotify 段已迁移(migratedFromAnimation: true、钉钉 Webhook 完好)→ 宿主新代码确实在跑、迁移也跑了。但同一份文件里 features 只有 animation: true——宿主侧从未收到 notify 的开关fNotify.setup() 从没执行,路由自然不存在。面板那句「宿主进程是旧版本」是误报。

根因(客户端开关同步的缺口)toggleFeature 只在点击时推宿主,syncFeatureStateFromHost 启动时只对「本浏览器没表态过」的 id 跟随宿主。于是「本浏览器已表态 true、宿主从未记录过」这种组合永远补不上:点开关时宿主还是旧进程 → POST 404 → 只留在 localStorage;重启后宿主按 defaultEnabled=false 不 setup → 路由 404 → 面板误报。用户当时的手动绕过(开关关掉再打开)让 POST 打到了新宿主,features.notify 才落盘。

修法src/shared.js):启动同步时若某功能在宿主 persisted根本没有记录,就补推一次本浏览器的选择(true/false 都推);宿主显式记过 false 的绝不覆盖——多设备(手机 + 桌面)场景下不能拿本浏览器的旧值去顶别人的新决定。404/405 提示同步改成「宿主进程可能是旧版本,或该功能在宿主侧未启用(重启 dsh web,或把开关关掉再打开)」。

验证:新增用例「开关补推」(三种组合:宿主无记录→补推 true;宿主记过 false→不推;本地 false 且宿主无记录→补推 false),npm run test:client 全绿;线上实测 GET /dsh-dock/features 返回 notify enabled=truePOST /dsh-dock/notify/status 200 且带回迁移后的钉钉 Webhook、POST /dsh-dock/animation/status 200。教训:功能拆成新菜单项时,宿主侧「开关落地」必须与客户端状态对齐,否则表现为「面板好好的、功能全 404」。

2026-09-09(续2)· 开发/发版流程落地:git 修正 + 流程文档 + dsh-dock 开发预设

用户指正:开发阶段提交备注不要写「发版」;不要同步代码到发版库 github;只有用户明确说「发版」时才改包版本、发 npm、写发版备注、推 github 并打 tag。

处置(当轮全部完成):

  1. 撤销 githubgit push github +ea08c46:main --force-with-lease=refs/heads/main:df14934 → 发版库回到上一个发版提交(开发提交不再出现在 github)。
  2. 改写本地提交功能:通知从任务动画拆出成【任务通知】独立菜单项(各自启停)+ 开关补推修复(正文保留需求/根因/改法/验证),并 force-with-lease 同步到 origin。
  3. 回退版本号package.jsonsrc/client.jsxDOCK_VERSION 都回到 0.9.9(重建 client.js 后产物里也是 0.9.9);CHANGELOG.md 顶部段改成 ## 未发布(下一版)— 开发中;清掉代码注释里的 v0.10.0 字样(版本号未定,写死会在改号时全变错)。
  4. 新增 docs/workflow.md:开发/发版流程的唯一真源(远端角色、开发阶段 6 条铁律、每次改动必跑的检查、发版 12 步、模块架构约定、环境坑、预设维护)。
  5. 新建用户预设 dsh-dock 插件开发~/.dsh/.agent-presets/dsh-dock/):standard 的副本 + persona 里注入开发/发版铁律 + 预设自带技能 skills/dsh-dock-release/SKILL.mdstandingKeyFor('dsh-dock') 挂载校验通过。

教训:跨会话的流程约定必须落到文件里(本文件 + docs/workflow.md + 预设 persona),只存在于会话记忆里的约定,下一次会话就会漂回旧习惯。

2026-09-09(续3)· 运行状态从「任务动画」拆出,独立成【运行状态】菜单

需求:把【任务动画】下面的【运行状态】也独立成一个左侧面板菜单出来。

拆法features/runstate/,宿主 + 客户端两半,左侧独立菜单项 order: 150,accent 天蓝 #38bdf8):

  • 搬走:进行中任务(阶段标签 / 耗时 / 回合 / 步骤 / 工具 / Token)、等待确认横幅 + 每张卡的等待工具与说明、最近完成(最多 10 条,含结束原因与结束时间)。
  • 新模块只读:宿主半部只有 POST /dsh-dock/runstate/status(返回共享追踪快照),没有 settings 配置段、不写盘;客户端没有常驻浮层,只在面板打开时轮询(有任务 2s / 空闲 8s / 出错 15s),关掉面板即零开销。首页概要卡片首次进入拉一次。
  • 【任务动画】只剩动画(运行动画开关 + 19 种模式 + 桌面伙伴大小),【任务通知】只剩四组通知设置;两页各留一行指路。首页概要各归其位:运行状态报任务(进行中 / 等待确认 / 空闲·最近完成)、动画报当前动效模式与开关、通知报事件 + 提醒方式 + 已开启的推送通道。

为什么两个旧面板的运行状态都删:用户确认「两处都删,只留新菜单」——上一次拆分已经让 animation 与 notify 各有一份运行状态,再加新菜单就是三处重复;现在运行状态只有一个归属。

共享追踪src/task-track.js 仍按引用计数共享一份实例,三个功能都开也只订阅一次 session/event,全部停用才退订(宿主测试有断言)。

清理:动画页的 TaskRow / END_LABELS / fmtDur / fmtTimedkan-task*dkan-tag*dkan-refresh 样式,通知页的 NotifyTaskRow / phaseLabeldknt-task*dknt-tag*dknt-refreshdknt-warn 样式全部删掉;新模块用 dkrs- 前缀自成一套(同样可单独提取发布)。

测试

  • npm run test:client:新增运行状态视图用例(10 个内置视图全绿);「左侧菜单」断言改为三个独立菜单项;内置视图错误隔离用例改用运行状态视图当靶子——动画视图已不再做 active.reduce,原来那个 Proxy 触发器不再会崩。
  • npm run test:host(脚本由 test-task-notify-host.mjs 更名 test-task-host.mjs,npm 入口不变):断言三个模块共用一份订阅、逐个停用后引用计数正确、/dsh-dock/runstate 只有只读 status(config 404 且不写 settings)、完成记录三处共享。

部署:宿主新增路由 → 必须重启 dsh web 才生效;重启后在功能坞开启「运行状态」(新功能默认关闭,开关会经 /dsh-dock/features 持久化到宿主)。纯客户端部分刷新页面即可。

教训:拆功能时「重复区块」要一并处置——留着旧区块比多写几行代码更糟,用户看到的是三份一样的东西。

2026-09-09(续4)· 模型设置整页 404:宿主/客户端功能 id 劈叉(models vs modelconfig)

用户反馈:功能坞【模型设置】页报「模型目录拉取失败:模型目录接口 HTTP 404」,重试无效。

反馈回路(本次一分钟定位的关键):插件无条件注册的 GET /dsh-dock/features 就是现成探针——GET /dsh-dock/models 404、features 表里 models enabled:falsepersisted 没有它;再对比 POST:{id:"modelconfig"} → 404「未知功能」,{id:"models"} → 200 且路由立刻活了。全程 HTTP 探测,零代码插桩。

根因:客户端视图 id modelconfig(features/modelconfig/view.js)≠ 宿主功能 id models(features/modelconfig/host.js)——唯一不一致的模块(其余模块目录名 = 视图 id = 宿主 id 三方一致)。面板开关与 v0.9.7 的补推都 POST 客户端 id → 宿主 404 被客户端 .catch(()=>{}) 静默吞掉 → 宿主半部从不 setup → 路由不存在。v0.9.5 之前宿主默认开启所以从未暴露;用户浏览器 localStorage 还留着旧「已启用」标记,所以页面能进、看到的是 404 报错而非「已停用」提示。404 之所以无声:fetch 对 HTTP 404 是正常 resolve,.catch 根本接不住。

修法

  • 宿主 id 改为 modelconfig(features/modelconfig/host.js);index.js 功能清单注释同步。
  • src/host-core.js 新增 migrateModelsFeatureId(ctx):开关表旧键 features.modelsfeatures.modelconfig(幂等早退、新键已存在不覆盖),settings 注册回调里与 migrateNotifyConfig 并列调用;index.jspersistedFeatureMap() 读取侧做兼容别名——异步迁移落盘前,启动开关表与 /dsh-dock/features 响应第一时间就是新口径。
  • src/shared.js 开关补推失败改 console.warn(查 res.ok;网络错误仍静默——宿主旧版本/不可达时本地开关照常生效的设计不变)。
  • 回归测试:test-client-views.mjs 新增「双半部 id 一致」用例(7 个双半部模块:host.js 真导入 + view 源码正则抽 id);test-task-host.mjs 新增迁移用例(迁一次 / 幂等 / 新键已存在不覆盖)。
  • 顺手修构建(上个会话 587 行备忘的根治版):build-client.mjs npx 缓存 /tmp/npm-cacheos.tmpdir()(Windows 下 npx 解析不到 esbuild),esbuild 来源三级解析(DSH_DOCK_ESBUILD 环境变量 → node_modules/esbuild 本地安装 → npx 兜底);本次沙箱内 npm 缓存目录不可写(_cacache EPERM)且 npm install 会因存量 peer 冲突 ERESOLVE,用 DSH_DOCK_ESBUILD 指到系统 npx 缓存里现成的 esbuild 完成重建。

验证npm run test:client 全绿(11 用例含新增 id 一致);npm run test:host 全绿(含新迁移用例);线上 3080 应急开启后 GET /dsh-dock/models 200(13KB 目录)。宿主半部改动 → 重启 dsh web 生效:重启后迁移把 features.models:true 换成 features.modelconfig:true,开关状态无缝延续;不重启当前实例也已恢复(应急开启时路由已注册)。

教训:「目录名 = 视图 id = 宿主 id」的功能模块三方一致是开关同步的隐形契约。v0.9.7 的补推修复修的是时机(宿主旧进程时 POST 404),没查id 本身对不对得上——补推把错误 id 原样再推一遍,照样 404。跨半部契约必须用测试锁死,不能靠约定自觉。

2026-09-09(续5)· 发版 v0.10.0;github 发版库改为「每次发版一条提交」

发版:v0.10.0 已发布(两处版本号 → CHANGELOG 改名 → 重建产物 → 全量测试 → 发版提交 2560206 → tag v0.10.0 推两个远端 → npm 上线,registry latest 已返回 0.10.0)。

用户指正:发版同步 github 时不要把开发提交分批推上去——把代码同步成一条提交:主标题写「发版」,body 汇总开发库提交内容(相当于把开发库提交记录总结一次提交)。

改法

  1. 新增 scripts/push-github-release.sh:发版时用 git commit-tree 把 main 整棵树压成一条「发版 vX.Y.Z」提交(父提交 = 上一次发版提交,message 文件复用发版提交那份),快进推到 github/main;本地 release 分支跟踪 github/main勿推 origin)。
  2. docs/workflow.md:§1 远端表补「github main 只有发版提交 / release 分支不推 origin / 两库树同史不同」;§3 第 7-10 步改为 提交+推 origin → 脚本一条同步 github → tag(指向开发库发版提交)→ npm(Git Bash 路径);§5 补「本机 bash 解析到 WSL,跑 scripts/*.sh"D:\Program Files\Git\bin\bash.exe"」。
  3. 预设 dsh-dock 插件开发 三处同步:persona 铁律(github 一条发版提交同步)、skills/dsh-dock-release/SKILL.md(铁律 3 + 发版步骤 7-9 + 环境坑 Git Bash)、preset.yml 描述。

github 历史重写(用户拍板「重写为纯发版历史」):git commit-tree 'main^{tree}'(无 -p)造 v0.10.0 根提交 0bc4547(message 汇总本版内容),git push github +refs/heads/release:main --force-with-lease=refs/heads/main:2560206。github main 从此只有发版提交;旧开发提交不再出现在 main 线上(历史版本 tag 仍指向原提交,可访问);根提交树与 origin/main 完全一致(82bc8337)。origin 未动,完整开发史保留。

验证git fetch github maingit log 仅 1 条且树 = main 树;本地 release = github/main;origin/main 仍在 2560206;预设 preset.yml 直解、agent.cordis.yml 把 DSH 专有 !!js 标签替换成普通标量后结构解析通过(plain js-yaml 不认 !!js 属预期);bash -n 语法检查通过。

教训:tag 指向开发库的发版提交,在 github 上它不在 main 线上但树一致——这是约定不是事故:发版库看发版日志,开发库看开发史,npm 主页展示的是发版库。

2026-09-09(续6)· github 历史重写翻车修正:历史记录必须保留,改为「上一发版点 + 每版一条」

用户反馈:按「重写为纯发版历史」执行后,github Commits 页只剩 v0.10.0 一条,0.9.x 及更早的记录全部不可见——「搞错了吧……我0.9.x版本的记录呢?」

根因:把「每次发版一条提交」执行成了「抹掉全部历史、只留一条根提交」。用户要的是今后不再分批推开发提交,不是删掉历史;历史可见性是底线,重写公开仓库历史必须默认保留全部旧记录。

修正

  • git commit-tree '2560206^{tree}' -p ea08c46 造 v0.10.0 汇总提交 ef60b3f(树 = v0.10.0 发布树 82bc8337,父提交 = v0.9.9 发版提交),--force-with-lease 推回 github main。
  • 效果:github main 恢复 95 条——0.9.x 及更早完整历史全部可见;v0.10.0 循环的 4 条开发提交收进一条「发版 v0.10.0」汇总提交(符合新规矩);此后每次发版在 ef60b3f 之上快进追加一条(scripts/push-github-release.sh 逻辑不变)。
  • tag v0.10.0 仍指向原发版提交 2560206(其开发史经 tag 仍可达);本地 release 分支跟随到 ef60b3f

流程文档同步:workflow.md §1「github 的 main 只有发版提交」改为「github 不收分批的开发提交——既有历史原样保留、不重写不删,每次发版只多一条汇总提交」。

教训:「总结成一条」指的是增量(本次发版的开发提交收成一条),不是替换(把历史压掉)。涉及改写远端历史的操作,保留多少历史必须与用户逐字对齐;写进文档的规矩要写成「不删历史」的明文,不能只写「只推一条」。

2026-09-10 · 「模型列表加载慢」定位结论:宿主空转,与 dsh-dock 无关 + v0.10.1 文档/截图

现象

升级到 dsh 0.1.5-rc.1 后,原生「设置 → 模型」列表「很慢很慢甚至出不来」;随后侧边栏 / 文件面板也出现 client api: workspaceFiles/list failed: Failed to fetch

排查与根因(不是本插件的兼容问题

  1. 插件宿主接口全是毫秒级/dsh-dock/models 10ms、/dsh-dock/features 3ms;RPC 侧 llm/listProviders 12ms、llm/listConfigurableProviders 26ms、settings/describe 8ms。
  2. 宿主进程在空转:长时间运行的 dsh web 持续吃满约一整颗核(20 秒耗 20+ CPU 秒)。给该进程挂 Node inspector 采样,热点全在 dsh 自带包:existsSync(文件系统探测)→ @deepseek-ai/dsh-agent-presetspackageInstalled → rowResolves → unresolvableRows;异步栈为 @deepseek-ai/dsh-commandsCommandRuntime.layers → notifyChange 叠加 @deepseek-ai/cordisFiber._reload
  3. 自我维持的反馈回路CommandRuntime 构造期注册命令 → 每次注册 emit commands/change(宿主侧 notifyChange 实测 24 次/秒,cordis PresetTree fiber _reload 约 115 次/秒)→ 浏览器端官方 dsh-client-ui-commands 收到 commands/changeinvalidateAll() 重拉 /api/commands/list(实测约 35 次/秒)→ 重拉又 resolve agent / resume session → 再次 emit,闭环。浏览器同源连接池被这个高频请求占满,其它请求(workspaceFiles/list 等)便排队或 Failed to fetch——这才是「模型列表出不来、侧边栏失败」的直接原因。
  4. 与 dsh-dock 无关的证据:a) 停用插件全部功能(宿主侧 + 浏览器 localStorage + chips)后循环照旧(6 秒 198 次);b) 把 ~/.dsh/.agent-presets/dsh-dock 目录移走后宿主照样满载;c) 新起 dsh 实例(带 / 不带本插件)均完全空闲,循环不出现;d) 循环栈里没有一帧本插件代码。
  5. 兼容性核对:插件用到的宿主 API 在 rc.1 全部存在——llm.listConfigurableProviders / resolveModelInfo / prepareCall / registerAdapterBlockAssembler@deepseek-ai/dsh-llm)、credentialRef@deepseek-ai/dsh-credentials)。

处置

重启 dsh web 即清空该进程态(所有新实例均未复现);或先关掉全部 dsh 页面标签再重开——实测无浏览器连接后宿主自身就回到空闲(0.09 CPU 秒/10 秒)。本次未改任何插件代码:症状是宿主进程态问题,不属于本仓库可修范围。

顺带发现(待跟进,非本版改动)

  • ~/.dsh/.agent-presets/dsh-dock/agent.cordis.ymlstandard 预设的旧副本:比 rc.1 的 shipped standard 少一行 @deepseek-ai/dsh-tool-present(rc.1 新增)。仍可解析、不是本次循环的原因,但已与新版脱节;后续可用新版 standard 重做该副本并重新贴回 persona 铁律。

排查方法备忘(踩过的坑)

  • git tag | tail 会骗人:tag 是字典序排序,v0.10.0 排在 v0.9.x 前面,用 tail -10 / tail -6 看「最新 tag」会把 v0.10.0 漏掉、误判成「tag 缺失」。核对版本 tag 一律用 git tag | sort -V | tail,或按具体版本 git ls-remote --tags <remote> | grep v0.10。本次一度据此误报「v0.10.0 tag 缺失」,实际本地 / origin / github 三处都有(annotated tag 6b0af30 → 提交 2560206)。
  • tab.screenshot({ clip }) 在本环境会平铺错位:截指定区域时会得到重复平铺的错图,只能整窗截图再视需要裁(见下)。

本次发布内容(v0.10.1)

  • 截图全部重拍(10 张,统一 1280×720):新增模型余额 / 任务通知 / 运行状态 / 趣味游戏 / 设置→功能坞;用量记录 / 模型设置 / 任务动画 / 会话区小控件按 v0.10.0 拆分后的界面更新。任务通知页的钉钉 / 飞书 Webhook 已脱敏——这一步是必须的:仓库内文本文件(README、view.jsx 的 placeholder、测试脚本)本就只用 / x / test 占位、没有真实密钥,截图若不脱敏就会成为首次把真实群机器人地址写进公开仓库。
  • README:功能一览补【趣味游戏】;「手机接力」更正为菜单项实际名称【远程访问】;「各功能使用」下补齐截图(文件内「界面 / 安装 / 使用方法」整段有重复副本,两处同步)。
  • 版本号两处对齐 0.10.1package.json + src/client.jsxDOCK_VERSION),npm run build:client 重建 client.js(页脚版本号随产物刷新)。

截图做法(可复用)

本机 dsh 开了认证(/api 与根页都需要 dsh-auth-* Cookie)。截图时用一个注入 Cookie 的反向代理(对本机 127.0.0.1:3080 上游,用 ~/.dsh/.credentials.yamlclient-connection/browser-session 的 secret 现签一张 Cookie)起在本地端口,浏览器打开代理地址即可驱动真实 UI;WebSocket upgrade 也要一并转发。注意 tab.screenshot({ clip }) 在本环境会出现平铺错位,一律用整窗截图(1280×720)。

2026-09-10(续)· 「切模型卡住 → 模型操作失败」根因:dsh-dock agent 预设未跟上 dsh 0.1.5-rc.1 的 schema 漂移

现象

历史会话(原本用已删除的模型)切到别的模型时卡住、切不动,最后弹:

模型操作失败: gateway/internal: resume failed for session "session-f1f3959e-…":
RemoteError: agent-presets: preset "dsh-dock" failed to mount: failed to apply loader entry
persona (@deepseek-ai/dsh-persona): invalid config: - $.prefix missing required value
(at prefix) (C:\Users\wzy60\.dsh\.agent-presets\dsh-dock\agent.cordis.yml)

根因

dsh-dock agent 预设~/.dsh/.agent-presets/dsh-dock/agent.cordis.yml)是 shipped standard 在 2026-09-09 的副本。dsh 升到 0.1.5-rc.1@deepseek-ai/dsh-persona 的 Config schema 改了:

  • 旧:config.text: |(一整段,自带 {{model}}/{{cwd}} 占位)
  • 新:config.prefixz.string().required())+ config.suffixtext 字段已删除

预设里那行还是 config.textprefix 缺失 → 预设挂载失败。因为预设是 standing mount, 所有绑定该预设的会话都无法 resume(切模型、打开历史会话都会走 resume → 撞上挂载失败), 所以表现为「卡住 + 模型操作失败」的通用报错,而不是「persona 配置错」。

注意:这跟 dsh-dock 插件无关,是 agent 预设在 DSH_HOME 下的本地配置漂移;但预设名字叫 dsh-dock,报错里写的是 preset "dsh-dock",很容易误以为是插件坏了。

改法

把预设与新版 standard 重新对齐(diff 忽略注释空行后只剩 persona 正文一处差异):

  1. persona.configtext: |suffix: Your working directory is {{cwd}}. + prefix: |- + 原有正文(首行 You are a coding agent powered by the {{model}} model., 其余 dsh-dock 铁律原样保留)。
  2. 补上 rc.1 新增的末尾行 - id: present / name: '@deepseek-ai/dsh-tool-present'

验证(关键:只查 roster 不够

  • 单独用 Config(config) 校验 persona 行:✅ prefix 1495 字节、suffix 正确(旧行报 $.prefix missing required value)。
  • agentPresets/listdsh-dock ✅ ok——但这一步在修复前也是 ok,因为 roster 只校验 YAML 形状、不校验 config schema,真正失败的是 mount。以后不能只靠它。
  • 真机 mount(修复前失败、修复后通过):
    • session/createagentPreset: "dsh-dock" → ✅ 返回 agentPreset=dsh-dock
    • 对报错里那个会话 session-f1f3959e-4d6b-49ed-928c-dc678723bd45 执行 session/selectModel(deepseek-official / deepseek-flash)→ ✅ 切模型成功。
    • 注意 /api 的 RPC 参数形状:会话类接口要包一层 args.request(如 {"args":{"request":{…}}}),selectModel 用错形状会报 missing "request"

沉淀

  • docs/workflow.md §6 新增「升级核对(dsh 换版本后必做)」:diff 对齐 + 两处已知漂移表 + 真机 mount 验证命令。
  • 配套技能 dsh-dock-release/SKILL.md 新增「dsh 升级后必做:agent 预设对齐」一节(含 0.1.5-rc.1 踩坑记录),并顺手修正了环境坑(esbuild 用 DSH_DOCK_ESBUILD 显式指定; Git Bash 参数要 Windows 路径;git tag | tail 的字典序坑)。
  • 教训:凡是「预设 / 插件 schema 跨版本漂移」,症状往往是下游操作卡死 + 笼统报错 (resume failed → 模型操作失败),根因却在配置行;排查时先看报错里的完整原文—— 这次原文把文件名与字段名(persona$.prefix)都点出来了。

2026-09-10(续2)· README 截图引用整理

  • README 的截图引用移除 1 张不再适用的示例图(保留 9 张,各自对应功能页);screenshots/ 目录 与 package.jsonfiles 保持整洁一致。
  • 顺带修订发版提交的信息措辞:公开历史里的提交信息只描述改动内容,不展开事件经过/排查细节, 避免在仓库历史中留下不必要的注释;细节留在私有记录里。

2026-09-10(续3)· 用量记录支持自定义模型单价(费用按自填单价计费)

需求

费用"不准确"——因为单价是抓来的。用户要求:支持配置每个模型的单价、持久保存,并用保存的单价 算费用;并征询"单价在哪里设置比较好"。

根因(两处,改动前先查清)

  1. 原有的 settings 覆盖机制从未生效features/tokenlog/host.js 头部与 loadPricingConfig() 读的是 dsh-dock-tokenlog 命名空间,但该 namespace 从未注册。 查 dsh 源码 packages/settings/settings/src/index.ts 确认:get(ns) 只返回 this.registrations.get(ns)?.resolvedwrite()registration === undefined 时直接 throw new Error('settings namespace "..." is not registered')——未注册命名空间读写都不通。 所以文档里"改 settings.yaml 即可覆盖单价/汇率"的说法与代码行为不符。
  2. 抓取价优先级最高。原 resolvePricing() 顺序是「官网抓取 → settings 覆盖+内置 → 兜底」, 即便用户能配上,也会被抓取价压掉。这正是"费用不准又改不动"的直接原因。

改法

  • 配置位置:随插件一起注册的 dsh-dock 命名空间下新增 tokenlog 段 (src/host-core.jsDockConfig,schema 化:usdCnyRate / fetchOfficial / pricingUrl / pricingFetchIntervalHours / pricing[] / fallback)。选它而非新建命名空间,是为了复用 既有的「功能配置写在自己段里」约定(notify/animation/visionProxy 同款),web 端只跟 /dsh-dock/tokenlog/* 打交道。
  • 优先级反转resolvePricing() 改为 自定义单价 > 官网同步价 > 内置价 > 兜底价,并返回 来源标签(custom/official/builtin/fallback)。
  • 改价即时生效:费用不再只在采集时算死,而是在 query/export 时经 withCost() 按当前 单价重算(浅拷贝记录),因此改价无需重扫历史。KPI 卡/分组/明细/CSV 一并采用新价。
  • 面板:用量记录页筛选行加「单价设置」按钮,展开 PricingEditor——按模型逐条配单价 (支持峰谷时段)、兜底单价、汇率、官网同步开关;模型名可用已用/已配置模型下拉,也可手输子串。 明细中走兜底价的金额标「兜底」,详情弹窗显示「计价来源」。
  • 路由:新增 pricing(读)与 setpricing(写,校验后落盘);setpricing 校验失败按 statusCode 返回 4xx(路由 catch 块改为按 e.statusCode 定状态码)。
  • 安全pricingUrl 出站前 assertAllowedFetchUrl() 校验——仅 http/https,拒绝 localhost/.local/.internal、环回、10./172.16-31./192.168./169.254./组播保留、IPv6 环回与 链路本地(满足 Mimosa 生成前安全约束)。
  • 测试:新增 scripts/test-tokenlog-host.mjs(接入 npm run test:host),覆盖优先级、 即时重算、参数校验 4xx 不落盘、pricingUrl 出站拦截。

验证

  • npm run build:clientnpm run test:client 全绿(10 个功能视图 + 错误隔离 + 菜单 + id 一致性)。
  • npm run test:host 全绿(task/tokenlog 两个文件)。
  • 测试桩 fetch 的 HTML 需 >500 字节,否则被 fetchOfficialPricing() 的 "SPA shell too small" 早退、拿不到官网价——首次跑就踩到,fixture 里补了填充文本。
  • 顺带发现并修掉一个测试假通过scripts/test-client-views.mjs 的 fetch 桩从未返回 /dsh-dock/tokenlog/queryTokenLogView 渲染时读 data.counts.matching 直接抛错、被 FeatureBoundary 降级成一行提示;而该用例的 marker 是「用量」,恰好在功能卡片的描述文字 里命中,于是「✓ 用量记录渲染正常」长期是假的(视图其实一次都没真正渲染出来)。修法:补上 query 桩数据,marker 改成只有真实视图才有的「调用明细」,并追加明细表标题/单价入口按钮/金额 等断言。新增的「单价设置」面板因默认收起、测试桩无点击模拟,其内部渲染是用临时把默认改为 展开跑一遍确认的(表格/峰谷列/来源标注/内置价列表/KPI 金额均正常渲染),确认后已改回收起。

沉淀

  • docs/workflow.md §2 检查表补注:test:host 现由两个用例文件组成;新增宿主功能照此追加并串进 package.json
  • 教训:读未注册的 settings 命名空间永远是 undefined。写"可被 settings 覆盖"的配置前, 先确认该 namespace 有 settings.register(),否则文档与行为会长期背离。
  • 教训:marker 断言要选"只有真实渲染才出现"的字符串。用功能名/描述里的词做 marker,会在 视图抛错降级成提示时照样命中,把假通过藏起来;发现可疑的"渲染正常"时,先把渲染出的 HTML 打出来看一眼。

2026-09-10(续4)· 单价设置改为独立子弹窗 + 分时段价支持多段

需求

用户看到首版「单价设置」后提了三点:① 不要内嵌在用量页里,改成独立子弹窗;② 子弹窗要能 最大化、拖动、缩放;③ 峰谷时段要能多段(举例 DeepSeek——它有高峰/优惠多个时段, 单段表达不了)。

改法

  • 独立子弹窗PriceModal 覆盖层(position:fixedz-index:2100,高于功能坞弹层自身的 200),带标题栏拖动、右下角缩放手柄、最大化/还原、Esc 关闭、点遮罩关闭。拖动/缩放用 window 级 pointermove 监听,指针移出元素也不丢;最大化态禁用缩放手柄。原来内嵌的 PricingEditor 保留, 作为子弹窗内容(embedded 分支)。
  • 分时段多段:数据模型从单段 peak:{start,end,...} 改为 peaks:[{start,end,input,output, cacheRead,cacheWrite}, ...]src/host-core.js 的 schema 与 features/tokenlog/host.js)。
    • 匹配:pickPeakSegment() 按顺序命中第一段;start<end 普通区间、start>end 跨零点 (如 23~7)、start===end 全天;用小数小时getHours()+getMinutes()/60)以便支持 8.5 = 08:30 这类半点边界(DeepSeek 刊例就是 08:30 分界)。命中哪段用哪段,段外回落基准价。
    • 兼容:normalizePricing() 读旧数据时把 peak 归一为单元素 peaks,老配置不用手工迁移。
    • 校验:每段 start/end ∈ [0,24]、各价 ≥ 0,非法 4xx 不落盘。
  • UI:每个模型一张卡片(dtok-prow),卡片内「基准价」+ 可增删的多个「时段」行(dtok-pseg), 时段行显示 0~8.5(跨零点) 之类的实时摘要;内置价表的分时段列改成列出全部段。
  • 可测性TokenLogView 支持 params.openPricing 深链直接展开子弹窗(DockPanel 也改为 透传 props.params),使客户端渲染回归测试能覆盖到子弹窗内容,而不必模拟点击。

验证

  • npm run test:host:新增多段用例——03:00/08:15 命中优惠段、08:45 段外回落基准、10:00 命中 高价段;跨零点段覆盖凌晨 02:00 与深夜 23:30、段外回落;旧单段 peak 读取归一;非法时段 4xx 不落盘。全绿。
  • npm run test:client:tokenlog 用例带 params.openPricing,断言「分时段价」「添加时段」 「基准输入」「跨零点」等子弹窗内容;另加用例 8 用带 document 桩的沙箱断言子弹窗确实 createPortaldocument.body(防祖先 backdrop-filter/transform 改变 fixed 包含块)。全绿。
  • 真机浏览器验证(拖拽/缩放/最大化是 CSS 与指针行为,单元测试的 React 替身覆盖不到):本机 dsh 有认证,起了一个注入 `dsh-auth-*$ \text{Cookie} 的反向代理(临时脚本放仓库外,验完即删),用浏览器 实际打开用量记录 →「单价设置」,确认:
    • 覆盖层铺满视口(1280 \times 720,\text{x}/\text{y}=0),弹窗 980 \times 688 居中;最大化 → 1264 \times 704(贴边 \text{margin} 8), 还原回 980 \times 688;
    • 拖动标题栏 位移 (\text{x}+200, \text{y}+100);右下角手柄缩放 980 \times 688 → 840 \times 598;
    • 分时段:点两次「+ 添加时段」得到两段行,填入 $237914`,行内摘要实时显示 「237(跨零点)」** 与 **「914」;四个时段价输入框带 输入/输出/缓存命中/缓存写入 占位提示。
    • 说明:没有点「保存单价」——当时跑着的宿主进程是上一个修订版(/dsh-dock/tokenlog/pricing 还没有 peaks 字段),保存会被旧宿主丢掉分时段;真机验证只覆盖界面行为,数据往返由宿主测试覆盖。
  • 过程中发现并修掉一个自身引入的回归:DockPanel 原本未声明 props 形参,透传参数后全部功能 视图都报 props is not defined(测试立刻抓到)——补上 props 形参即恢复。

2026-09-14 · v0.11.2:用量 chip 点击即出当前会话结果(挂载查询竞态)+ chip 宽度放宽

现象

  • 用户反馈:从会话输入区「⛁ 用量」chip 打开用量记录,面板显示的是全量/旧条件数据 (会话下拉框已选中当前会话、KPI 却是 1.2 万条全量),必须再点一次「查询」才按会话出结果。
  • 用户反馈:配置单价后费用段「· ¥0.11」出现,用量/余额两个 chip 的数值被省略号截尾 (「¥0....」「余额 3014....」),小数位看不到。

根因

  • 竞态TokenLogView 挂载时两条初始查询并发——挂载 effect 先 scan(慢)再按 上次暂存条件查(不含本次带入的会话);navSession effect 立即按 params.sessionId 查(快)。 慢链路的结果最后到达覆盖快链路的会话结果。截图佐证:下拉框已是当前会话、统计仍是全量。
  • 截断:chip 行宽度上限 189px(≈25cqw)是 v0.10.x 实测收窄的值,当时「4~6M 用量 + 余额」 恰好放得下;配置单价后每 chip 多出「· ¥0.xx」一段,合计超出约两个字符。两个 chip 的文本 精度本来就够(费用/余额均 2 位小数),纯粹是宽度上限截断。

改法

  • features/tokenlog/view.jsx:sessionId 初值直接用 navSession(优先于暂存条件);挂载查询里 navSession 覆盖暂存的会话条件——初始只跑这一条查询;navSession effect 改为只处理 「面板已打开时再次点击」,以 navAt(chip 点击时间戳)判同跳过首次。chip 的 openPanelnavAt: Date.now(),同一会话再点也重查。
  • src/client.jsx.dockchip-row 上限 189px/25cqw → 216px/28cqw,注释里记录新测算 (两 chip 合计约 204px)与回退方案(若再挤换行,优先压用量 chip 的 ⛁ 前缀,别再收上限)。

验证

  • npm run build:client + npm run test:client 全绿(10 个视图渲染 + 错误隔离/portal 等检查)。
  • 真机验证(dsh web 3080 link 模式,注入 Cookie 反代 + 内置浏览器):
    • 点「会话用量」chip → 面板直接打开用量记录页,秒出「11 / 12354 条」(当前会话 11 条), 会话下拉框选中 session-6fd8c951-…,无需点「查询」;
    • chip 完整显示「⛁ 525.5K · ¥0.11」「余额 3008.89」,模型选择器与发送键同行不换行。
  • 顺手修正截图记忆里的反代做法:转发请求的 Host 头必须保持代理 authority(3990), 不能改写成上游 3080——上游按 Host 校验 Cookie,改写必 401;dsh 页面 chip 的 Playwright click 会因 React 周期刷新超时,改用 evaluate 原生 el.click()

2026-09-15 · 仓库文档卫生:README 去重 + 行尾统一 LF + 修正过时的远程访问口径

现象

  • README.md 里「界面 / 安装 / 使用方法」整段存在两份近似副本(L38–194 与 L195–351,其中 133 行完全相同), 正文等于讲两遍;两份已互相漂移,docs/workflow.md 甚至为此留了一条「两处都要改」的操作注记。
  • git status 长期显示 49 个文件「已修改」,git diff 合计 16422 增 / 16422 删,看着像全仓库重写。
  • 功能一览表【远程访问】那一行仍以「局域网电脑直连:另起一个 0.0.0.0 绑定的完整 DSH 子实例」为卖点。

根因

  • 重复副本:某次编辑是「追加而不是替换」,此后两份各自被改过几次。差异集中在「远程访问」段 (旧副本是 4 步骤版,新副本是登录网关版)与结尾(旧副本重复「会话区随身小控件」,新副本是「常见问题」)。
  • 假 diff:仓库位于 /mnt/f(Windows 盘),Windows 侧编辑器把工作区文件全写成 CRLF,而仓库既没有 .gitattributescore.autocrlf 也未配置,于是行尾差异全部暴露成内容改动。git diff --ignore-cr-at-eol 为空可证。
  • 过时口径:远程访问经历过「主实例+子实例」→「服务器模式 0.0.0.0」→「账号登录网关」两次重构, 前两者均已删除;表格行当年只改了名字,括号里的旧卖点没跟着删,与同文件「各功能使用」段及 features/mobile-relay/(页面明写「不存在免登录的直连路径」)相矛盾。

改法

  • README.md:保留较新的一份副本(含「常见问题」),删掉旧副本。删之前先把旧副本独有的 3 处信息并回: 异地组网(Tailscale/ZeroTier 用组网 IP 访问同一入口)、登录会话 Cookie 7 天滑动过期、 网关与主服务是同一台机器上的两个监听、不能共用一个端口;另给「心跳监视 / 主题信息」补空行。
  • README.md 功能一览表【远程访问】:去掉 0.0.0.0 子实例直连的说法,改为「主实例保持仅监听 127.0.0.1, 结构上不存在绕开登录的直连路径」。
  • 新增 .gitattributes* text=auto eol=lf + 二进制资源 binarygit checkout -- . 把 49 个文件还原成 LF。
  • docs/workflow.md §2.6:删除「README 存在重复副本、两处都要改」的注记,改为「单一副本,改一处即可」。

验证

  • 清理前 git diff --ignore-cr-at-eol --numstat | wc -l = 0 → 确认 49 个文件确为纯行尾差异,还原后 git status 干净。
  • 去重后做差集比对(原文件 vs 新文件的行集合):只多出 8 行「消失」行,逐条核对均为被新副本改写、 等价覆盖或仅缩进/编号变化的旧行,无信息丢失;## 界面 / ## 安装 各只剩 1 处。
  • .gitattributes 落地后 git ls-files --eol 全库均为 i/lf w/lf,无需重新规范化; git add -A -n 只列出本次的目标文件,没有冒出额外文件。
  • 仅文档与仓库元文件改动,未触碰 features/src/index.js:按 docs/workflow.md 的改动-测试对照表, 本次不需要跑 build:client / test:client / test:host

部署

  • 纯文档与仓库卫生改动,插件代码零改动、版本号未动(仍为 0.11.2),已发布的 npm 包不受影响 (docs/.gitattributes 都不在 package.jsonfiles 白名单内);用户侧无需任何操作。
  • 按仓库约定只推 origin(开发库),github 远端仍只在发版时同步。

2026-09-16 · 发版 v0.11.3 前的全量测试:分时价用例第二次时间耦合(21:00 后必挂)

现象

发版前跑 npm run test:host(当时 22:53),test-tokenlog-host.mjs 挂在内置价断言:

AssertionError [ERR_ASSERTION]: 内置价 1.5/4.5 生效
    at scripts/test-tokenlog-host.mjs:196

根因

内置表 deepseek-v4-flash / deepseek-v4 带 914 时高峰分时价,而 resolvePricing() 是按调用时刻的 本地小时数选段(pickPeakSegment)。v0.11.1 为「上午跑挂」改过一次:把时间戳从 Date.now() 换成 「倒退 12 小时」——那只是把失败窗口从上午挪到了晚上:21:00 之后 now - 12h 正好落进 914 峰段, 峰价 3/9 取代基准价 1.5/4.5,断言必失败。用例 6(export 结构化明细)用的是同一个时间戳,只是没断言金额。

改法

  • 文件头新增 atHour(h, m) = new Date(2026, 0, 15, h, m, 0, 0).getTime():写死「本地时刻」, 不随运行时刻漂;
  • 用例 1 / 用例 6 的时间戳由 Date.now() - 12h 改为 atHour(20)——20:00 在所有内置峰段 (914)与本文件自定义峰段(00:008:30、914、237)之外。

验证

  • npm run test:host 全绿(tokenlog host: ok …,共两个用例文件);
  • 额外用 TZ=UTCTZ=America/New_York 各跑一遍同样全绿:new Date(y, m, d, 20, 0) 按本地时间解释, 写死的是「本地 20:00」,与运行时刻、时区均无关。

部署

  • 仅测试文件改动,插件运行代码零改动(features/src/index.js 未动),用户侧无需操作。
  • 顺带把本机 agent 记忆目录 .workbuddy/ 加入 .gitignore(与 .mimosa/ 同类,属本机产物,不进包)。

2026-09-16(续)· 发版 v0.11.3:查询等待动画 + CSV 中文表头 + 开发机工具链

本版内容(自上版 v0.11.2 以来在开发库累积的全部提交)

  • 用量记录点「查询」有等待动画(03fa1a5):状态行 ⟳ 加载中...、按钮「查询中…」禁用、 数据区 sticky banner 轮流蹦跳换文案(>5 秒转安抚语气)、旧数据压暗不可点、结果到位闪 「✓ 数据已更新」;5 秒静默轮询不置 loading;遵守 prefers-reduced-motion
  • 用量记录 CSV 导出改中文表头(5d64756):export 路由改回结构化明细,前端按界面「调用明细」表 同列序同取值生成 14 列 CSV,末尾追加合计行、前置 UTF-8 BOM;旧宿主进程自动回退原形式。
  • 宿主删掉任务开始/结束的例行终端日志(b3b51ad):运行状态以「运行状态」页为准,终端只留报错。
  • 文档与仓库卫生(e3b9f5f、0f43952):README 去重(397 → 241 行)+ 修正过时的远程访问口径 + .gitattributes 统一 LF。
  • 开发机 link 安装 + 改码自动刷新(15b34e1、c3e5377、158120e、31dde8e):dev-link-deps.mjs / watch-client.mjs / npm run dev:link / npm run dev:client + workflow 4b 节。
  • esbuild 解析兼容 WSL / 本地命中(eea8e2a、a1fe734):.exe 直执 + wslpath 路径桥、本地命中改经 node 跑 JS 入口、win32 走 shell、失败包装报错信息。
  • 修复(97270a5):test-tokenlog-host.mjs 分时价用例写死本地 20:00,21:00 后跑不再必挂(上一节)。

发版动作(按 docs/workflow.md §3)

  1. 版本号两处改 0.11.3(package.json + src/client.jsxDOCK_VERSION); CHANGELOG.md 的「未发布(下一版)— 开发中」整段改名为 ## v0.11.3 — 2026-09-16
  2. npm run build:client 重建 client.jsnpm run test:client(10 视图 + 6 项隔离/一致性断言)与 npm run test:host(task/animation/notify/runstate + tokenlog)全绿,无跳过。
  3. 复查 README / package.json description:无写死的版本号需改;功能一览表未受影响。
  4. 发版提交(开发库)→ 推 origin;tag v0.11.3origingithub
  5. 发版库 githubscripts/push-github-release.sh 把 main 整棵树压成一条「发版 v0.11.3」提交 (父提交 = 上一发版提交 48d2293f = v0.11.2),快进推 github/main——不重写、不删既有历史。
  6. ./scripts/publish.sh 发布 npm dsh-dock@0.11.3npm pack --dry-run 预览 + 登录态检查 + npm publish)。
  7. 本地发版库检出对齐/Users/weiyi/develop/github/wycto/dsh-dock 的 main 长期停在 v0.9.0, 且自 v0.10.0 那次「github 历史重写再修正」起就与远端分叉(本地 10 条 / 远端 101 条互不包含)。 本次不 force、不丢历史:先把旧 main 存成 backup/main-pre-v0.11.3,再 fetch + reset --hardorigin/main,使其与 github/main 完全一致。