CLnum42/dsh-DSchat ↗★ 1

dsh-dschat

Chat with DeepSeek's web端 (chat.deepseek.com) inside DeepSeek Harness as a native panel, then distill or replay the conversation into a new harness session. 适合希望免API额度使用网页端,并能将对话无缝导入本地继续开发的用户。

Package
dsh-dschat
Compatibility
Unverified
Harness peer range
*
Version
0.3.10
License
Apache-2.0
Last updated
Oct 4, 2026

Install

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:CLnum42/dsh-DSchat

dsh-DSchat

在 DeepSeek Harness 桌面版里,用原生面板聊 DeepSeek 网页端(chat.deepseek.com), 聊完把整段对话迁移成 harness 会话继续开发 —— 复用你的网页登录,不消耗 API 额度。

包名 dsh-dschat(npm 强制小写),界面上显示为 dsh-DSchat。

浅色深色
dsh-DSchat 面板(浅色)dsh-DSchat 面板(深色)

思考用时(流式期间实测)、正文里的 [citation:N] 芯片、默认收起的参考来源:

思考用时 / 引用芯片 / 参考来源

这三张是真实组件在真实浏览器 + DSH 自己的主题表里渲染出来的 (node scripts/ui-shot.mjs --synthetic),会话列表是合成样例 —— 脚本默认读本机真实记录,--synthetic 关掉它,公开的图里不会出现任何人的真实对话。

与 dsh-webchat 的关系

它是 dsh-webchat 的重写版:浏览器引擎、网页端解析、蒸馏与迁移机制沿用(Apache-2.0), 但界面改成 DSH 原生面板,并把整套命名空间独立成 dsh-dschat,两个插件可以共存。

最关键的一处改动是集成方式:

dsh-webchatdsh-DSchat
中心面板MutationObserver 往中心栏塞 DOM,再用 CSS 把原生会话区 display:none注册 main 槽位(key dschat),真正的原生面板
侧边栏入口往侧边栏插一个按钮,还要自我修复被 React 顶掉的行注册 sidebar.panellist(id dschat,order 20),与「插件」「任务」同级
设置页无注册 settings.section(id dschat,order 50),与「通用」「模型」「插件」同级
与会话区关系互斥,靠 html 属性互相驱逐无关:main 面板不绑定 session,切换不打断原生会话

这三条都有运行时证据:安装后 cordis_inspect_query 的 Slots.listSubTree 显示 main 的 occupants 里出现 dschat,sidebar.panellist 里出现 id: "dschat", order: 20。

功能

  • 网页聊天:真实浏览器驱动 chat.deepseek.com,流式回复,深度思考(R1)与智能搜索开关。
  • 文件附件:「上传文件」按钮点开 macOS 原生文件对话框(隐藏的 ``, 和宿主自己的输入框走同一条路),选完即经 /attach 落盘并回到输入框;粘贴(⌘V)和拖进输入区 走同一条落盘链路。图片、PDF、Word、Excel、PPT、txt、md 等都可以,单文件上限 24 MB、 单条消息最多 10 个;附件 7 天后自动清理。 这是唯一能拿到本地文件的入口——打包后的 harness 没有任何"返回文件路径"的原生对话框 (唯一的原生对话框是选目录的 dsh-desktop:directory-pick),ctx.fileUpload 收的是字节、 ctx.conversation.pickFiles() 只回一个 boolean,都不给路径。输入区那一排是两组: 左边 深度思考 → 智能搜索(这条消息要怎么答),右边 附件 → 发送(现在就动这条消息; 附件按钮只有图标,文字在 aria-label / title 上,位置与网页端一致——回形针是发送圆的左邻)。
  • 链接一律交给本机默认浏览器:面板里没有内嵌浏览器,也没有「点开跑到别处」的歧义—— 正文链接、[citation:N] 芯片、参考来源每一行都走 window.open(url, "_blank"),即宿主自己的 外链通道(Electron 主进程 setWindowOpenHandler → shell.openExternal),并用系统默认浏览器 打开;非 http(s) 协议(mailto: 等)不接管,仍由锚点自己处理。
  • 回复里的来源排在正文之后,且默认折叠:网页端在搜索结束时就编好了来源表(早于正文的第一个 字),面板在流式期间只渲染 [citation:N] 芯片、不渲染来源列表,等正文结束再出一个 「参考来源(N)」标题——顺序与网页端最终的版式一致(正文 → 参考来源),而不是「先冒出参考来源、 再被正文顶下去」。列表默认收起(一次搜索可能引用二十个页面,全展开会把正文挤没),点标题展开。
  • 思考段独立成行,带真实用时:assistant 消息的推理不再渲染成 ``,而是一个 「已思考(用时 X 分 Y 秒)」的行——时间是引擎在流式期间实测的(首个推理片段 → 首个正文片段, 见「思考用时」)。这一行默认折叠,屏幕上只有一行; 点它才展开,展开后只有完整推理,那一行本身不再显示,点推理内容任意处即可收起(见 「一行,或者完整内容,不会同时出现」)。推理进行中时这一行是实时的: 折叠态显示「思考中:」,单行、跟着流式内容不断左移,永远露出最新写出来的那几个字; 推理期间它处在展开态,看到的是推理全文(滚动体自动跟到底部)——那行实时摘要属于折叠态。 回答一开始就自动收成一行,换成用时。 从网页恢复的会话(历史里只有推理文本、没有时间)只显示「已思考」。正文不再被折进折叠框里。
  • 消息操作:assistant 消息的「复制回复」「引用到输入框」只取正文,「复制思考过程」单独取 推理段(见「正文 / 思考必须分开」)。
  • 头部三个窗口按钮:标题栏左侧是会话列表 / 搜索 / 新建对话(网页端自己的顺序与图标, 34px 方块 + 16px 线性图标)。列表可以整个收起把宽度让给对话,状态记在 localStorage(dsh-dschat.rail.open);搜索按钮会展开列表并把光标放进搜索框。
  • 会话列表可拖宽:左侧对话记录栏右侧的拖拽条可以调宽度(170–460px,双击恢复默认, 键盘 ←/→ 也能调),宽度记在 localStorage(dsh-dschat.rail.width)。
  • 迁移到 Harness:头部「在 Harness 中继续」,可选 蒸馏成任务简报(默认)或 原文完整迁移, 可指定目标工作区或追加到已有会话;带三段式进度(蒸馏 → 建会话 → 打开),完成后自动跳进新会话。
  • 从网页恢复:三层来源(页面自己的 history 接口 → 应用 IndexedDB 缓存 → DOM 全量抓取), 单会话 70–400ms 拿回整段对话(含思考),再次恢复会把之前导入不全的记录就地补全。
  • 导出 markdown 默认进「下载」文件夹:面板不再把文件丢进"最近那个工作区"或进程 cwd (exportDir 可改,留空即 ~/Downloads),导完的提示给的是完整路径, 因为「已导出到 dschat-….md」等于没说文件在哪。
  • 会话搜索 / 删除可撤销。
  • 原生设置页:状态、运行参数(浏览器渠道、无头、超时、数据目录、profile 目录、蒸馏模型)与 快捷操作;参数本身在「插件」页的 dsh-DSchat 行里编辑。
  • Agent 工具:dschat_status / dschat_send / dschat_recover / dschat_import / dschat_transfer。

安装

用 DSH 自带的插件安装器(不要手改 profile,也不要直接在 profile 里跑 pnpm):

npm pack --pack-destination ~/.dsh/dsh-dschat-dist     # 先打成 tarball
plugin_manager action=install_bundle target=file:

安装后 dsh.profile.bundles 会多出 dsh-dschat。

必须用 tarball,不要用 link:。 link: 会让 profile 里出现一个指向工作区的符号链接, Node 按真实路径(即工作区)向上找 node_modules,插件自己的 playwright-core 与 harness 提供的 @deepseek-ai/* 都解析不到,表现为 dsh: warning: 1 entry did not activate 加上 dschat (dsh-dschat): failed to import。tarball 安装落盘的是真实目录,解析链才正确。

两个会让插件"静默加载失败"的坑

harness 的 loader 把插件入口的任何导入期异常都压缩成同一句 failed to import, 真实错误被 ctx.logger.error() 吞掉,日志里查不到。踩过的两个:

  1. profile 里是符号链接(见上)。
  2. 导入了不存在的具名导出。ESM 的具名导入在链接期解析,名字对不上就抛 SyntaxError,同样报成 failed to import。所以:
    • 宿主半区只从 @deepseek-ai/dsh-llm / dsh-session / dsh-tools 导入,且每个名字都 在真实包里核对过;@deepseek-ai/dsh-settings 不导出 settingsNamespace / installSettingsSection(那是臆造的 API,真实设置能力走 ctx.settings 服务)。
    • test/stubs/harness.ts 里只能放真实存在的导出。桩里多一个不存在的名字, 离线测试会通过,而线上必挂 —— 这是本项目最贵的 30 分钟。
    • 插件能在运行时导入哪些包,由它自己 package.json 的 dependencies + peerDependencies 的名字集合决定(dsh-app-boot 的 profileDependencyNames), 所以用到的 harness 包必须显式声明为 peer。

目录

src/index.ts              宿主入口:Config schema、路由、工具、system prompt 公告
src/protocol.ts           两端共享的线协议(API 路径、数据模型、阶段推导)
src/routes.ts             /api/dsh-dschat/* 路由族(loopback 围栏)
src/tools.ts              dschat_* agent 工具
src/transfer.ts           蒸馏 / 原文迁移 / 导出
src/store.ts              本地对话记录
src/engine/engine.ts      playwright-core 驱动 chat.deepseek.com 的引擎(沿用 dsh-webchat)
src/client/index.ts       浏览器入口:locale、样式、槽位注册(panellist / main / settings.section);
                          工作区服务面(pickDirectory / createWorkspace)按 call 取
src/client/panel/         DSchatPanel(header / rail / thread / composer,链接外开、附件走 /attach)/
                          DSchatSettings / Markdown(含 Thinking 与 SourceList 两个折叠块)/
                          reply(正文 vs 思考)/ styles
scripts/build.mjs         esbuild 构建
scripts/ui-shot.mjs       把真实面板挂进真实浏览器(Chrome)截图并量尺寸:拖宽、折叠状态、
                          布局断言都在这里看,单测只能证明「没崩」
test/host-smoke.test.ts   宿主半区:导入、路由、工具、附件落盘、/tail 的思考用时、loopback 围栏
test/streaming.test.ts    流式链路:增量解析器与整段解析等价、/tail 增量契约、落盘合并
test/reply.test.ts        正文 / 思考切分:引用与复制取正文,流式中不吐半截标签
test/client-render.test.ts 浏览器半区:组件真渲染 + 信封与槽位注册自检 + 外链接管 +
                          思考用时三种状态 + 来源列表折叠与时序 + 会话列表拖宽 +
                          composer 两组控件的位置 + 「点一下只启动一次」的接线
test/stale-reply.test.ts  上一轮回复不许被当成这一轮的答案:内容身份判据 + 假页面的
                          「只重排旧行、不给新答案」与「稍后给出新答案」两条链路
test/stale-reply-e2e.test.ts 真浏览器 e2e:本地假会话页回放同一个故障(旧答案绝不落库)
                          与它的反例(真正的新答案照常落库,含思考)
test/wake-e2e.test.ts     真浏览器 e2e:本地假聊天页上验证 wake 只启动一次、
                          「打开登录窗口」复用已登录的浏览器(不重启)
docs/screenshots/         README 顶部的三张图:真实组件 + 真实主题的渲染,
                          由 scripts/ui-shot.mjs --synthetic 的产物转成 1600px WebP
prototype/                定稿的 UI 原型与截图(静态 HTML,不是插件本身)
refs/                     从 DSH 应用包中提取的主题/侧边栏样式与插件开发指南

构建与测试

npm install --ignore-scripts   # 只装 esbuild 与 react(测试用,不执行任何安装脚本)
npm run build                  # 产出 lib/index.js 与 lib/client.js
npm test                       # 宿主半区 + 浏览器半区
node scripts/ui-shot.mjs       # 可选:真实浏览器渲染 + 截图 + 几何断言(写 .tmp-ui-shot/)

对着官方网页端量尺寸用的几个脚本(只在需要重新量一次时跑,产物落在 scripts/ui-ref-*.mjs 的第二个参数目录里):

node scripts/ui-ref-shot.mjs      # 打开 chat.deepseek.com(复制登录 profile 到临时目录),
                                       # 截图 + 全量计算样式 report.json
node scripts/ui-ref-styles.mjs    # 输入卡片 / 胶囊 / 头部按钮的完整样式与高清裁切
node scripts/ui-ref-dark.mjs      # 深色主题 + 开关"关闭"态(点一下再点回来,不落设置)
node scripts/ui-ref-svg.mjs       # 把官方内联 SVG 的 path 原样抠出来(icons.tsx 的来源)
node scripts/ui-ref-icons.mjs  
   # 把抠出来的图标放大画一遍,先看再抄
node scripts/ui-icon-check.mjs 
   # 把 DSchat 的标和 host 自己的图标按真实渲染尺寸排一张对比图
node scripts/token-probe.mjs           # 验证 --dsw-static-deepseek-* 在宿主主题表里真的解析得到
node scripts/asar-probe.mjs  [out]  # 只读地从 app.asar 里抠单个文件,不解包 121 MB
                                             # (宿主包的真实 API 以打包副本为准,见 refs/README.md)

ui-ref-* 需要登录态:脚本会把 ~/.dsh/dsh-webchat/browser-profile 拷到临时目录再打开, 因为正在运行的插件引擎锁着原目录。

静态渲染证明"组件不崩",证明不了"布局是对的":拖拽条是否真的抓得住、工具行是否还排得下、 折叠行是否真的只有一行高,都要在真浏览器里量。scripts/ui-shot.mjs 用真实组件、真实样式表、 真实词条和真实会话记录(~/.dsh/dsh-dschat/transcripts.json)渲染一页,然后用 Chrome 截图, 并把 getBoundingClientRect 的结果打成 JSON——「附件在深度思考右边」「DOM 里没有 ``」 「拖到 170px 后 localStorage 是 170」「胶囊点亮后底/边/字/图标同色」「思考中的那一行真的把尾部 滚到了可视区(translateX(-超出量))」「点了会话列表按钮后 rail 与手柄都不在 DOM 里」 这些结论都是它给出的。

加 --synthetic 才适合给别人看:不带这个参数时,会话列表从本机真实记录 (~/.dsh/dsh-dschat/transcripts.json)里取,标题属于隐私;带上它就是一份固定的合成样例, 任何机器上跑出来都一样。README 顶部那三张正是这么来的:

node scripts/ui-shot.mjs --synthetic   # 产物落 .tmp-ui-shot/(含 report.json)
# 再把 01-default / 03-sources-open / 05-dark 转成 1600px WebP(q88)
# → docs/screenshots/panel-light.webp / sources-and-thinking.webp / panel-dark.webp

测试为什么长这样:@deepseek-ai/* 只在运行中的 harness 里可解析,所以宿主测试用 esbuild 把这些 specifier 别名到 test/stubs/harness.ts 再跑真 apply();浏览器测试用 react-dom/server 把两个 组件真渲染成静态标记(组件渲染抛错会清空整个槽位条目,且只有打开界面才看得见,所以必须在 这里拦住);React 必须 external,否则 bundle 自带一份 React、hook dispatcher 为 null。

桩的纪律见上文「两个会让插件静默加载失败的坑」第 2 条:桩里只允许出现真实导出。

两个 artifact 的约定

  • lib/index.js —— 宿主半区(ESM)。@deepseek-ai/dsh-* 与 playwright-core 保持 external; 其余全部内联。schemastery 必须内联:宿主 bundle 只被 harness 从 profile 目录导入, 裸导入 schemastery 在那里解析不到(这正是 dsh-webchat 把它内联的原因)。
  • lib/client.js —— 浏览器半区,包在 window.__ModuleLoader__.load({ id, factory }) 信封里, id 必须等于包名。React 从宿主模块表 require 取得,所以 react/react-dom/jsx-runtime 保持 external。

依赖 profile 的一点

浏览器 profile 默认复用 ~/.dsh/dsh-webchat/browser-profile,所以从 dsh-webchat 切过来 不需要重新登录 DeepSeek 网页端(可在设置里改 profileDir)。对话记录存在独立的 ~/.dsh/dsh-dschat/。

网页端选择器:只认结构,不认 class

chat.deepseek.com 用的是哈希化的 CSS-module 类名(_546d736、c08e6e93……),每次发版都会变。 [class*="conversation"] 这类猜测对当前页面实测匹配 0 个元素,而失败表现是 「从网页恢复」列表永远为空且不报错。所以:

  • 侧边栏会话行 = a[href*="/a/chat/s/"]。这是站内深链的路由契约,站点自己改不掉; 分组标题(置顶 / 今天 / 昨天)在 a 之外,不会混进标题;行内按钮无文字, 所以 a.textContent 就是标题。
  • 消息列表 = [data-virtual-list-item-key](虚拟列表的 item key,结构性而非样式类); 回复正文 .ds-assistant-message-main-content / .ds-markdown,思考块 .ds-think-content。 这些在真实会话页实测有效。

test/host-smoke.test.ts 有一条守卫测试,一旦有人把 class 猜测加回来就会失败。

同类陷阱:列出网页会话属于用户发起的读取,引擎冷启动时也必须先把浏览器拉起来 (listWebConversations 早先直接返回 [],于是恢复列表恒为空)。

迁移落库:sessionPersistence 是句柄制,不是服务制

ctx.get('sessionPersistence') 上没有 append。服务只负责 create / open, 两者都返回一个写句柄,append / flush / close 挂在句柄上:

const handle = await persistence.create(header)
try { await handle.append(events); await handle.flush() } finally { await handle.close() }

另外会话格式 v4 的 header 里 isSeeded 是必需字段(新建会话为 false); 漏掉它在写入时不报错,只在恢复时以 format v4 header lacks required field isSeeded 失败。

这两处都是从 dsh-webchat 原样继承、且从未真正跑通过的死代码(它那边同样调用 persistence.append)。教训:跨包服务的调法必须去读运行时的真实契约—— cordis_inspect_query 的 Service provider 会给出精确签名与方法列表,不能照抄旧插件。 test/host-smoke.test.ts 用符合真实契约的假 persistence 跑了一遍完整迁移: 把句柄上的 append 改回服务上,就会复现 TypeError: persistence.append is not a function。

迁移后的会话必须「被看见」:冷落库 ≠ 出现在 GUI

persistence.create 绕过了 ctx.sessions,所以不会触发 session/created,会话控制器也就不会 向浏览器转发 api-session/added。后果有两个,而且是同一个根因——用户报的「点完没有新会话, 更没有打开新会话」:

  1. 侧边栏不会出现这个会话。客户端的会话列表只在连接换代时整体重拉,平时只吃 api-session/added 增量;落库不经过 live Session,就没有任何增量。
  2. 「迁移后自动打开」必然失败。uiWorkspace.openSession(id) 内部是 sessions.retain(target), 它对列表里没有的 id 直接抛 sessions.retain: unknown session ;插件把这个异常包在 try/catch 里只 console.warn,于是点击表现为「什么都没发生」。

修复就是补上那条缺失的通知:落库 + flush + 关闭写句柄之后,ctx.emit('api-session/added', row) (row 即 SessionSummary:sessionId / updatedAt / running:false / agentAvailable:false / blank:false / cwd)。这是 dsh-api-remotes 转发白名单里的公开 emit 事件,描述正是「A Session became visible」;客户端 handleSessionAdded 会 upsert 这一行, 于是这个会话既能被列出、也能被 retain。追加路径同理补 api-session/activity,否则侧边栏的排序 时间不会更新(那条写入同样没走 live Session)。守卫测试:test/host-smoke.test.ts 的 «transfer writes a cold session through the persistence write handle»(删掉 emit 即失败)。

三个配套细节:

  • HTTP 响应和事件 socket 不是同一条连接,行可能晚一两拍到达,所以客户端 openSession 以 60ms 重试最多 3 秒(OPEN_SESSION_ATTEMPTS),而不是只调一次。
  • uiWorkspace / workspaces 是别的客户端插件提供的,本插件没把它们写进 inject (它们缺失时面板也要能渲染)。因此必须在调用时用 ctx.get 取:在 apply 时取一次并缓存, 一旦本插件先于工作区 UI 挂载,缓存下来的就是 undefined,迁移会静默不动。
  • 「追加到已有会话」的目标必须是真正的 harness 会话。面板原来把网页对话(chat-…)当目标, 宿主拿去 persistence.open 只会得到 SessionPersistenceNotFoundError: session "chat-…" not found。 现在目标来自 main 槽的标准 prop useSessions,且只列 agentAvailable !== true 的冷会话—— 活会话自己持有写句柄,追加必然被拒。两类拒绝都翻译成人话(找不到 / 正在使用中)。

UI 几条硬约定

  • hover 操作按钮必须贴着它操作的内容。 assistant 消息是通栏正文、头部在左,所以按钮走头部行右端; user 消息是右对齐窄气泡,按钮若锚在「消息框」上会跑到最左边、离气泡几百像素。因此 user 的气泡被包进 .dsh-dschat-msg-line(收缩到气泡宽),它才是按钮的包含块,right:100% 才能在任何气泡宽度下紧贴其外侧。 两种角色的按钮顺序也统一为「复制」在前,位置不会在消息之间跳。
  • 浮层材质只有一个来源:--dschat-surface(派生自 --dsw-specific-menu)。 不要用 alias-bg-overlay:它在浅色下是不透明的 #e9ecf2,在深色下是不透明的 #61666b——不是浮层材质, 浅色下像背景、深色下就是面板上的一块白板(用户报的「菜单太白了」)。specific-menu 是宿主自己画菜单用的 材质,并且已经带平台分支(darwin:近不透明 #f8f9faf0 / #303136f0)。深色菜单还要把描边换成 border-l3,#ffffff0f 的发丝线在深色半透明底上看不见。
  • 按钮的「强调」配色必须落在 accent 上,不能用 button-primary-* 家族。 该家族解析到 brand-primary,是随主题反转的反色:深色下近白 #f9fafb。主按钮在深色下因此成了整块面板最亮的 东西(用户报的「在 Harness 中继续太白了」)。state-business-primary 才是 accent 本身 (#4176e6 浅 / #7aaaff 深),按百分比叠在背景上,一条规则两种主题都对。主按钮与「深度思考」开关 用完全相同的 tint——实测两者在两种主题下合成出同一个填充色(浅 #e6edfc、深 #222835)。 同理禁用态也不能用 button-primary-dimmed(浅色浅灰、深色深灰,还是反色家族)。
  • .dsh-dschat button { color: inherit } 会吃掉所有变体的文字色。 它的优先级是 (0,1,1),压过 .dsh-dschat-toggle-on 这样的单类名 (0,1,0),所以 toggle 的 accent 文字色、以及每个变体 :disabled 的文字色都被静默丢弃,标签回落到继承色。变体要显式声明 color;:disabled 的 (0,2,0) 才压得住。 排查时别只看 getComputedStyle().color:color 是可继承属性,而「变量缺失导致的计算期无效值」会让它 报出父元素的颜色,于是 accent 按钮会被误读成 label-primary。要读就直接量截图像素。
  • PANEL_CSS 是一个反引号模板字符串,正文里不能出现反引号。 CSS 注释里写 `.foo` 会提前结束字符串, 剩下的被解析成减法表达式(`str`.dsh - dschat - msg - line),esbuild 会正常编译,只在运行时抛 dschat is not defined,症状是面板空白。test/client-render.test.ts 有一条守卫测试盯着模板体。
  • 消息 id 是身份,不是标签。 面板拿它当 React key、upsertMessage 拿它定位、 tailDelta 拿它认领「这一轮的回复」。所以同一个会话里出现两个相同 id 不是「数据难看」而已: React 的 keyed 协调会丢掉其中一个 fiber,那个消息的 DOM 节点不会被移除,而是留在被复用的父节点里 (用户报的「开新会话,结果还留着上一段对话」就是这么来的,见下文「消息 id 是身份」)。 写入侧(store 的 load / import / append)保证唯一,渲染侧(threadKeys)再兜一层。
  • 图标要和宿主同一支笔:16 格网格 + ICON_MEDIUM_STROKE。 宿主自己的图标集 (dsh-client-ui-primitives)只有两档:ICON_REGULAR_STROKE = 1(挤在文字里的)和 ICON_MEDIUM_STROKE = 1.3(独立控件)。面板原来整套 1.4,比同列的侧边栏图标明显重一档, 用户报的「图标线条太粗,和 DSH 的插件图标不一致」就是这个。现在 icons.tsx 的默认值是 1.25, 加号 / 对勾 / 叉 / 折角这几个标记(10–14px)各自留在 1.5–1.8 并且在代码里写明理由—— 它们是标识不是图形,1.25 在 10px 上会细成一根头发。包图标 icon.svg 用同一套几何 (36 格 × 2.4 ≈ ChatIcon 在 16 格上的 1.07),去掉深色圆角方块背景:host 是按 ROW_ARTWORK_SIZE = 30 / CARD_ARTWORK_SIZE = 36 把整张 SVG 放进 , 带背景的方块在插件列表里像一枚 app 图标,而 host 的插件图案(pinwheel)是纯线稿。 另外 里的 SVG 拿不到 currentColor,也不随宿主的 data-ds-dark-theme 变(实测), 所以描边只能自带颜色——这里用官方插件同族的蓝渐变,两种主题下都成立。 对比图:node scripts/ui-icon-check.mjs(里面把旧图标、新图标、host 的 pinwheel / 文件夹 按真实尺寸排在一起,深色浅色各一行)。
  • 颜色改动要真的量,不要靠读代码。 校验脚本用 Chromium 加载宿主的 token 表 + 本文件的 PANEL_CSS, 再从截图里取合成后的像素(半透明、color-mix、级联结果都算在内),对比度按 WCAG 公式算。 三处守卫测试(浮层材质 / 主按钮配色 / 模板字符串)都验证过「回退修复即失败」。
  • 头部必须让开 macOS 的窗口 chrome 区。 桌面窗口是 titleBarStyle: "hiddenInset",红黄绿灯 (x=16 起,到 ~80px)浮在页面左上角。侧边栏展开时,宿主自己那 280px 的列顺手把中间面板推开了, 所以写死 padding: 0 14px 看着没问题;一收起侧边栏,该列变 0,面板直接贴到窗口左边缘, brand 图标和 "DSchat" 名字就压在红黄绿灯底下,宿主的 shell.leading 窗口控件(打开侧边栏 / 新建会话,88–152px)还叠在 brand 名字上——这就是用户报的「侧边栏收起后按钮不兼容」。 宿主用 --dsh-frame-leading-clearance 预留这条带(只在 [data-sidebar-collapsed] 时设在 frame 上: 160px,全屏 84px,Web/Windows 干脆不设),自己的 Conversation 头也是读它。 所以这里写 padding-inline-start: max(14px, var(--dsh-frame-leading-clearance, 0px)): 收起时内容正好落在宿主的 clearance 边界(160px,全屏 84px),展开/Web/Windows 仍是原来的 14px。 实测(真 GUI + html[data-platform=darwin]):14px → 1