# DSH 0.2.0-rc.2 桌面端契约审计(鲸鱼娘适配事实清单) > 审计对象:`D:\DeepseekHarnessDesktop\resources\app.asar`(桌面壳 `@deepseek-ai/dsh-desktop@0.2.0-rc.2`,内嵌 `dsh` 运行时) > 用途:为 `lib/client.js`(客户端插件半边)、`lib/index.js`(宿主半边)、`assets/dsh-whale-moe.js`(DOM 呈现层)的适配提供**可直接照做**的事实依据 > 审计方式:纯静态读 asar(`tools/asar.mjs` + `_reference/desktop-0.2.0-rc.2/_audit/{rd,scan,ctxscan}.mjs` 内存扫描),零浏览器、零服务器 > 证据约定:`包名/文件:行号` = asar 内该文件的行号;`@偏移` = 压缩单行文件内的字符偏移 --- ## 0. 结论速查 | # | 契约项 | 结论 | 一句话 | |---|---|---|---| | 1.1 | `dsh.client.platform` 必须为 `"web"` | ✅ 仍有效 | 取值不等于 `"web"` 的包被直接跳过 | | 1.2 | `exports["./client"]` 必需 | ✅ 仍有效 | 缺则启动即抛错 | | 1.3 | 必须预构建 `lib/client.js` 实体文件 | ✅ 仍有效 | 宿主启动时同步 `readFileSync` 快照 | | 1.4 | `window.__ModuleLoader__.load({id,factory})` 唯一注册入口 | ✅ 仍有效 | 且 `id` 必须等于包名 | | 1.5 | `require("react")` / `require("react/jsx-runtime")` | ✅ 可用 | 平台种子表里有 | | 1.6 | 第三方插件可被 `dsh-client-modules` 扫到 | ✅ 可以 | 扫的是 Loader 条目,与厂商无关 | | 1.7 | `dsh.compatibility.dshReleases` 兼容性声明 | ❌ 失效 | 本版本代码零引用;门在 `peerDependencies` | | 1.8 | 鲸鱼娘在 desktop profile 已安装 | ❌ 没有 | `profiles/desktop` 的 bundles 里没有它 | | 2.1 | `settings.section` slot | ✅ 仍有效 | 现在由 `dsh-client-ui-settings-general` 声明并渲染 | | 2.2 | `settings.mascot.item`(插件自声明子 slot) | ✅ 模式仍有效 | 与宿主 `settings.general.item` 完全同构 | | 2.3 | `slots.inject(name, cb)` | ✅ 仍有效 | 声明感知,塌缩会销毁、再声明会重跑 | | 2.4 | `slots.register(options, component)` | ⚠️ 形状微变 | list 必填 `id`;同名二次声明**抛错**;`label` 可为函数 | | 2.5 | settings 面板渲染包 | ⚠️ 换包 | `ui-settings` 退化为领域底座,面板壳在 `ui-settings-general` | | 2.6 | `dsh-client-ui-settings-shell` 的语义 | ⚠️ 完全变了 | 现在是「终端执行器」设置页,不是设置面板壳 | | 2.7 | `[data-slot="sidebar.settings"]` | ✅ 仍在 | 但内部按钮已变成账户菜单 | | 2.8 | `[data-slot="settings.trigger"]` | ❌ 桌面端不渲染 | `settings.launcher` 被 AccountMenu 占用 | | 2.9 | 「设置面板已打开」判据 | ❌ `[role="dialog"]` 不可用 | 唯一可靠 = `[data-shortcut-modal="settings"]`(引导弹窗是同款 `role=dialog`) | | 3.1 | `[data-slot="conversation.chat.node"]` | ✅ 仍有效 | 每个聊天气泡/工具的 outlet 锚点 | | 3.2 | `[data-slot="conversation.composer.bar"] textarea` | ❌ 输入框不是 textarea | 换成 `[data-composer-input][contenteditable]` | | 3.3 | `[data-slot="sidebar.settings"] button` | ⚠️ 语义变了 | 现在是账户菜单按钮 | | 3.4 | `[data-running]` | ✅ 仍有效 | `[data-terminal][data-running]` = 终端块运行中 | | 3.5 | `[data-state="ongoing"]` | ✅ 仍有效 | `StateDot` 转圈点(含终端运行中) | | 3.6 | `[data-state="running"]` | ⚠️ 语义反转 | 0.2.0-rc.2 里结束会翻成 `"ok"`,**是**可用的活信号 | | 3.7 | `[aria-busy="true"]` | ⚠️ 窄化 | 只剩回合导航轨的忙碌刻度 | | 3.8 | `[data-status="pending"\|"running"\|"error"\|"success"]` | ❌ 基本失效 | 只剩 Todo 面板 `pending\|in_progress\|completed` | | 3.9 | `[data-role="tool"]` / `[data-tool="true"]` / `[data-tool-card="true"]` | ❌ 全失效 | 替代:`[data-tool]`(值=工具名) | | 3.10 | `pre` | ✅ 仍有效 | 命令卡与 markdown 代码块 | | 3.11 | `[data-slot="terminal"]` | ❌ 不存在 | 替代:`[data-terminal]` | | 3.12 | `[data-phase="session"]` | ❌ 失效 | `data-phase` 的值是 `settling\|hero\|active` | | 3.13 | `[class*="stream" i]` | ❌ 失效 | 替代:`[data-streaming]` / `[data-shimmer]` | | 3.14 | 新增可用:`[data-chat-running]` | ➕ 最佳活信号 | 会话运行中才挂载 | | 3.15 | 新增可用:`[data-tool][data-state="running"\|"preparing"]` | ➕ 工具运行中 | 精确到工具名 | | 3.16 | 新增可用:`[data-turn-process]` | ⚠️ 反向语义 | **回合结束后**才出现,不能当忙碌信号 | | 4.1 | `data-theme` 属性 | ❌ 不存在 | 明暗 = `body[data-ds-dark-theme]` | | 4.2 | 主题根元素约定 | ⚠️ 变了 | `html[data-ds-theme-source=light\|dark\|system]` + `body[data-ds-dark-theme]`(值恒为空串) | | 4.3 | localStorage 命名空间 | ⚠️ origin 变了 | 桌面端 origin = `dsh-app://app`,旧 Web 的 `whale-moe:*` 不会带过来 | | 5.0 | 插件路由前缀 | ❌ 不能落在 `/assets/` | 壳内 dist 抢答,永远到不了 webServer;鲸鱼娘的 `/api/…` 安全 | | 5.1 | `webServer.register({kind:'exact',path,handler})` | ✅ 仍有效 | handler 是 `(req,res)` | | 5.2 | `ctx.inject(['webServer'], ...)` | ✅ 仍有效 | `dsh-client-modules` 自己就这么用 | | 5.3 | 桌面端真的启用了 webServer | ✅ 是 | `--port 19387`,并 `collectIndexInjections()` | --- ## 1. 运行环境事实基线 | 事实 | 值 | 证据 | |---|---|---| | 桌面壳包名/版本 | `@deepseek-ai/dsh-desktop` `0.2.0-rc.2` | `/package.json:2,4`(asar 根) | | 桌面壳入口 | `lib/main.js`(Electron 主进程,490 KB) | `/package.json:8` | | 内嵌 dsh 运行时根 | asar 内 `/dsh`,其 `package.json` 为 dsh 本体 | `/dsh/package.json`,`dsh-package.json.full` | | DSH home | `D:\DeepseekHarness_Data\.dsh`(`DSH_HOME`) | `.dsh\profiles\{acp,desktop,headless,web}` 存在 | | 桌面 profile 名 | `desktop` | `dsh-desktop-host/lib/index.js:225` | | 桌面侧启动参数 | `["--no-open","--port","19387"]` | `dsh-desktop-host/lib/index.js:231-235` | | 宿主 URL | `http://127.0.0.1:${ctx.webServer.port}`(带认证 token) | `dsh-desktop-host/lib/index.js:337` | | **渲染载体 origin** | **`dsh-app://app`**(自定义 scheme,不是 `http://127.0.0.1:19387`) | `/lib/main.js`:`protocol.registerSchemesAsPrivileged([{scheme:"dsh-app",privileges:{standard:true,secure:true,supportFetchAPI:true,corsEnabled:true,stream:true,codeCache:true}}])` @435928;`protocol.handle("dsh-app", …)` @456515 | | `/assets/**` 的去向 | **由壳内打包的 dist 直接服务,不经过 webServer** | `/lib/main.js` @456515:`url.hostname === "app"` 且 pathname 为 `/`、`/index.html`、`/assets/**`、`/favicon.svg`、`/manifest.webmanifest` → `serveWebDocument(request, /dist)`;其余 → `forwardWebRequest(request, hostUrl, hostCookie)` 反代到 webServer | | 反代来源校验 | `origin` 必须是 `dsh-app://app`(否则 403) | `/lib/main.js` `forwardWebRequest` @305946 | | 窗口 session | Electron 默认 session(主窗口未指定 `partition`) | `/lib/main.js` `function createWindow(preload, show = false, primary = false)` @438554,`webPreferences` 无 `partition` | | 桌面 profile 的 bundles | `@deepseek-ai/dsh-base`、`dsh-web-app`、`dsh-experimental-agent-team-profile`、`dsh-experimental-auto-review` | `D:\DeepseekHarness_Data\.dsh\profiles\desktop\package.json:7-12` | | 鲸鱼娘是否在桌面 profile | **不在** | 同上(用户 patch 层 `cordis.patch.yml` 也没有它的条目) | | 旧 web profile(对照) | 把 `dsh-whale-musume` 写进了 `dependencies` 与 `dsh.profile.bundles` | `.dsh\profiles\web\package.json:7,17` | > 桌面端的「Web 载体」与旧版 Web 是同一套客户端代码:`dsh-web-app` bundle 挂 `frontend-static` 的 fallback 座位,用 `ctx.webServer.renderIndex()` 渲染 index(`dsh-web-app/lib/index.js:8,32`;`dsh-host-frontend-static/lib/index.js:81-97`)。所以「Web 版能跑」的插件机制在桌面端原则上都还在,**变的是包名分工、DOM 标记和主题属性**。 > > ⚠️ 但**文档 origin 变了**:主窗口加载的是 `dsh-app://app/…`,壳通过 `protocol.handle("dsh-app", …)` 把非 `/assets/**` 的请求反代到 `http://127.0.0.1:`(见上表)。对本插件的三条硬影响: > 1. `localStorage` 的 key 空间挂在 `dsh-app://app` 下 → 旧 Web origin 的 `whale-moe:*` **不会**带过来(见 §5.3)。 > 2. 插件的路由路径**不能落在 `/assets/` 前缀下** —— 那条前缀被壳内打包的 dist 吞掉,永远到不了 `webServer`。鲸鱼娘用 `/api/dsh-whale-musume/assets` → ✅ 安全。 > 3. 反代会校验 `Origin: dsh-app://app`,同源 `fetch("/api/…")` 天然满足;`privileges.stream:true` 也保证了流式响应可用。 --- ## 2. 客户端插件装入契约(对应任务问题 1) ### 2.1 `dsh.client` 字段形状 ✅ 仍有效 **唯一校验器**(宿主与 roster 生成器共用): ```js // dsh-client-modules/lib/index.js:61-75 function parseDshClient(pkgName, value) { if (value === void 0) return void 0; if (typeof value !== "object" || value === null) throw ... if (typeof decl.platform !== "string") throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`); const inject = optionalStringArray(pkgName, "dsh.client.inject", decl.inject); const external = optionalStringArray(pkgName, "dsh.client.external", decl.external); if (decl.immediately !== void 0 && typeof decl.immediately !== "boolean") throw ... return { platform, inject?, external?, immediately? }; } ``` 形状与现版**完全一致**:`{ platform: string, inject?: string[], external?: string[], immediately?: boolean }`。 **`platform` 必须恰好是 `"web"`**,否则整个包装作非客户端包被静默跳过: ```js // dsh-client-modules/lib/index.js:713-717 const decl = parseDshClient(packageName, dsh !== null && typeof dsh === "object" ? dsh.client : void 0); if (decl === void 0 || decl.platform !== "web") { this.pkgMeta.set(sourceKey, null); return null; } ``` 鲸鱼娘现状 `package.json:34-36` `"client": { "platform": "web" }` → ✅ 无需改动。 - `inject`:额外的包 id 列表,强制先到达(`dsh-client-modules/lib/client.js:656-659`)。 - `external`:构建期外部依赖声明,请求别的包的行(`:650-655`);**不能声明自己**(`lib/index.js:426-428` 会抛错)。 - `immediately`:`true` 进「立即预取档」,由外壳 `prefetchImmediateTier()` 提前拉(`dsh-web-frontend/dist/assets/index-5SrrfWpU.js` @630925)。 ### 2.2 `exports["./client"]` 必需 ✅ 仍有效 ```js // dsh-client-modules/lib/index.js:170-180 function clientExportOf(pkgName, exportsField) { const client = exportsField["./client"]; if (client === void 0) return void 0; if (typeof client === "string") return client; if (typeof client === "object" && client !== null) { const fallback = client.default; ... } throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`); } // :718-719 const clientRel = clientExportOf(packageName, pkg.exports); if (clientRel === void 0) throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`); ``` 鲸鱼娘 `package.json:15` `"./client": "./lib/client.js"` → ✅ 无需改动(字符串形式被接受)。 ### 2.3 必须预构建 `lib/client.js` 实体文件 ✅ 仍有效 宿主在**注册表构造期**同步读整个 bundle 进内存(不是按 URL 懒读): ```js // dsh-client-modules/lib/index.js:811-817 initialBundleSnapshot(pkgName, clientPath) { try { const baseline = this.captureArtifactBaseline(clientPath); return { bundle: readFileSync(clientPath), baseline }; } catch (error) { if (error.code !== "ENOENT") throw error; ... } } ``` 缺失 → `MissingClientBundleError` → 归入组合失败并**整体 loud throw**(`:135`、`:542-544`)。所以「临时编译/TS 源码」不行,必须是现成的 JS 产物。鲸鱼娘 `lib/client.js`(37 KB,手写 CJS 风格)满足。 bundle 内容本身也有硬约束(`:168-169`):只接受 `client..js` 这类相对 chunk 名做按需加载。 ### 2.4 `window.__ModuleLoader__.load({id, factory})` 仍是唯一注册入口 ✅ 宿主往 `` 注入的 queue facade 原文: ```js // dsh-client-modules/lib/index.js:453-475 window.__ModuleLoader__={ mode:"queue", pendingQueue, load(registration){pendingQueue.push(registration)}, create(options){ ... const index=pendingQueue.findIndex(r=>r.id==="@deepseek-ai/dsh-client-modules") ... } } ``` 客户端执行 bundle 后**逐个校验「这个 URL 是否用 `__ModuleLoader__.load` 注册了 id」**,否则判失败: ```js // dsh-client-modules/lib/client.js:625 failures.push(`${url}: loaded without registering "${id}" via __ModuleLoader__.load`); // :739 if (!this.factories.has(id)) throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`); ``` 约定原文见 `dsh-client-modules/lib/client.js:17`:「…factory (`window.__ModuleLoader__.load({id, factory})`); every module body…」。 ⚠️ **`id` 必须是包名,不能是自定义别名。** 图行 id 取自 `packageName`(`lib/index.js:720-728`),且 `require("/client")` 会剥掉 `/client` 后缀再查表(`:98-100`): ```js function stripClientSuffix(spec) { return spec.endsWith("/client") ? spec.slice(0, -7) : spec; } ``` 鲸鱼娘 `lib/client.js:15` `id: "dsh-whale-musume"` 与 `package.json:2` `name` 一致 → ✅ 无需改动。 ### 2.5 `factory(require)` 能 require 到什么 ✅ `require` 的解析顺序(`dsh-client-modules/lib/client.js:698-706`):平台种子表 → 已物化模块 → 已注册包工厂 → 抛错。 **平台种子表**(外壳 Vite bundle 里的 `rM()`,`dsh-web-frontend/dist/assets/index-5SrrfWpU.js` @628285,原文): ```js function rM(){return{ "react":Ef, "react/jsx-runtime":If, "react-dom":Rf, "react-dom/client":Df, "@deepseek-ai/cordis":sf, "@deepseek-ai/dsh-client-store":lh, "@deepseek-ai/dsh-client-ui-slots":hh, "@deepseek-ai/dsh-client-ui-primitives":sE, "@deepseek-ai/dsh-client-ui-dockkit":XS }} ``` → 鲸鱼娘 `lib/client.js:20-23` 的 `require("react")`、`require("react/jsx-runtime")` ✅ 可用。 → 想用 UI 原语,应 `require("@deepseek-ai/dsh-client-ui-primitives")`(该包**没有** `lib/client.js`,是共享库名,见 `_reference/…/dsh-client-ui-primitives/package.json`)。 ### 2.6 第三方插件能否被 `dsh-client-modules` 扫到 ✅ 能 扫的**不是**某张内置白名单,而是 **Cordis Loader 的全部条目**: ```js // dsh-client-modules/lib/index.js:525-544 constructor(ctx) { super(ctx, "clientModules"); ctx.on("internal/plugin", (fiber) => { // 运行期新增插件也会进 dirty 集合 const entryName = fiber.entry?.options.name; if (entryName === void 0) return; this.dirty.add(entryName); ... this.flush(...); }); for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name); // ← 启动期全量扫 this.composed = this.compose(); ... if (failures.length > 0) throw new ClientPackageCompositionError(failures); ``` 包清单定位走 Loader 解析(`locatePkgJson`,`:743-773`),既支持包名 specifier,也支持路径 / `file:`(`nearestPackage` 向上找最近的 `package.json`,`:774-790`;`exactPackageSpecifier` 只接受裸包名,`:82-88`)。 **结论**:只要第三方插件作为 Loader 行真的被挂载(`dsh.profile.bundles` 或 profile `cordis.patch.yml` 的 `insert`),且 manifest 满足 2.1–2.3,它的客户端半边就会被扫到、组合进 `window.__DSH_BOOT__` 并加载。 ### 2.7 兼容性门槛 ⚠️ 换了字段(重要) `dsh.compatibility.dshReleases`(鲸鱼娘 `package.json:37-44` 声明了 `0.1.1-rc.2 / 0.1.2-alpha.4 / 0.1.2-alpha.5 / 0.1.2-rc.1`)在 0.2.0-rc.2 **全代码库零引用**(对 `_reference/desktop-0.2.0-rc.2/**` 全量 grep `dshReleases` / `dsh.compatibility` → 0 命中)。 真正的门是 **`peerDependencies`**: ```js // dsh-app-boot/lib/index.js:286-302 function evaluatePluginCompatibility(manifest, exemptions = {}, runtimeVersion = getDshRuntimeVersion()) { if (!Object.hasOwn(fields, "peerDependencies")) return void 0; // ← 没有 peer 依赖 = 不设门 for (const [name, range] of Object.entries(dependencies)) { if (name !== "@deepseek-ai/dsh" && !name.startsWith("@deepseek-ai/dsh-")) continue; ... if (!semver.satisfies(runtimeVersion, requirement, { includePrerelease: true })) peers[name] = range; } ``` 豁免落在 **profile 目录的 `compatibility.json`**(`dsh-app-boot/lib/index.js:328` `PROFILE_COMPATIBILITY_FILENAME = "compatibility.json"`,读取 `:404-410`,写入 `:421-441`;`dsh plugin allow-version` / 插件管理器可授权)。 **适配动作**: - 鲸鱼娘当前**没有** `peerDependencies` → `evaluatePluginCompatibility` 返回 `undefined` → 不阻塞加载。✅ - 但请在 `package.json` 里补 `peerDependencies: { "@deepseek-ai/dsh": "^0.2.0-rc.2" }` 之类的显式声明,让「装错版本」在启动时 loud 失败,而不是运行时炸;同时删掉已失效的 `dsh.compatibility.dshReleases`(或保留但注明无效)。 - 桌面 profile 的 `compatibility.json` 目前**不存在**,需要豁免时用 `dsh plugin allow-version` 生成。 ### 2.8 装入方式(桌面端)✅ 双面包模型未变 鲸鱼娘 `cordis.patch.yml` 只做一件事: ```yaml # cordis.patch.yml:7-9 - insert: - id: dsh-whale-musume name: dsh-whale-musume ``` 宿主半边(`lib/index.js`,`main` 字段)与客户端半边(`exports["./client"]`)由**同一个包名**两个面提供 —— 这正是 `dsh-client-modules` 文档里说的 "dual-face package"(`lib/index.js:104-105`)。 桌面端要装上,二选一: 1. 在 `%DSH_HOME%\profiles\desktop\package.json` 的 `dependencies` 加上包,并把包名追加进 `dsh.profile.bundles`(与 `profiles/web/package.json:7,17` 同构);bundle 的 `cordis.patch.yml` 会被自动叠加(`dsh-app-boot/lib/index.js:468-481,920-955`)。 2. 或直接在 `%DSH_HOME%\profiles\desktop\cordis.patch.yml` 追加同一条 `insert` 条目(该文件已是用户的 patch 层,现有 7 处 `- id:/name:` 条目可照抄格式)。 --- ## 3. slot 契约(对应任务问题 2) ### 3.1 `slots.register(options, component)` 的 options 形状 ⚠️ 微变 ```js // dsh-client-ui-slots/lib/index.js:163-243(纯核心;运行时包装在 ui-renderer) register(options, component) { const rec = this.records.get(options.name); if (!rec?.spec) throw new Error(`slot "${options.name}" is not declared (a parent entry's children table must declare it)`); switch (spec.kind) { case "single": ... throw new Error(`single slot "${options.name}" already has a registration ...`); case "keyed": if (options.key === void 0) throw ... ; // 同 key 同 priority 重复 → 抛错 case "list": if (options.id === void 0) throw new Error(`list slot "${options.name}" requires options.id`); case "chain": if (options.select === void 0) throw ...; } if (options.children) for (const childKey of Object.keys(options.children)) { const childRec = this.records.get(childKey); if (childRec?.spec) throw new Error(`slot "${childKey}" is already declared (by ${childRec.declaredBy ?? "an unknown entry"})`); } ``` - 认可的字段:`name`(必填)、`key`(keyed 必填)、`id`(list 必填)、`order`、`label`、`priority`、`select`(chain 必填)、`inject`、`children`、`store`、`locale`、`registrant`(`:204-219`)。 - `label` **可以是函数**(跟随 locale),读数用 `resolveSlotLabel`(`:27-29`)。 - list 排序 = `priority` 升序 → `order` 升序(`:221`)。 - **⚠️ 新增的硬约束**:`options.children` 里声明一个**已被声明过**的键会抛错(`:191-194`,`registerFactory` 同规则 `:78-81`)。鲸鱼娘自声明的 `settings.mascot.item` 是私有键,不冲突;但**不要**试图声明宿主的键(例如 `settings.trigger`)来抢渲染权。 - 运行时包装:`register = ctx.effect(() => this._register(options, component), "slots.register()")`(`dsh-client-ui-renderer/lib/client.js:1788-1791`)→ 注册随 fiber 卸载自动回收。 ### 3.2 `slots.inject(name, cb)` ✅ 语义一致且更明确 ```js // dsh-client-ui-renderer/lib/client.js:1343-1402(要点) inject(key, callback) { const disposeController = ctx.effect(() => { ... const reconcile = () => { const spec = this._core.specDynamic(key); const epoch = this._core.declarationEpoch(key); if (active !== void 0 && activeEpoch === epoch) return; dispose?.(); if (spec === void 0) return; const disposeEffect = ctx.effect(callback, `slots.inject(${JSON.stringify(key)}): declaration`); ... }; unsubscribe = this._core.subscribeDeclaration(key, changed); try { reconcile(); } catch (error) { stop(); throw error; } // 已声明 → 同步执行 ``` - 声明已存在 → **同步**执行 callback;未声明 → 等声明;**声明塌缩 → 销毁 contribution;再声明 → 重跑**。 - 所以「声明 + 注册」两步都写 `slots.inject` 是**正确且推荐**的写法(宿主的 `ui-settings-general` 全程这么写)。 ### 3.3 组件能拿到 `props.renderSlot` 的条件 ✅ 与现版一致 组件 props 的「child-render share」只在**自身注册时声明了 `children`** 才注入: ```js // dsh-client-ui-renderer/lib/client.js:732-740 if (entry.children !== void 0) { kit["renderSlot"] = boundRenderSlot(host, entry); if (Object.values(entry.children).some((spec) => spec.kind === "chain")) kit["renderSlotChain"] = ...; if (Object.values(entry.children).some((spec) => spec.scope !== "root")) { ... kit["SessionProvider"] = ...; } } ``` 调用时校验归属,越权抛 `SlotOwnershipError`: ```js // dsh-client-ui-renderer/lib/client.js:331-333 const declared = entry.children?.[key]; if (declared === void 0) throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`); ``` 鲸鱼娘 `lib/client.js:463-465` 用 `children: { "settings.mascot.item": { kind: "list", scope: "root" } }` 声明、`lib/client.js:446` 用 `props.renderSlot("settings.mascot.item", {})` 渲染 → ✅ **模式完全有效**,且与宿主 `settings.general.item` 的写法逐字同构: ```js // dsh-client-ui-settings-general/lib/client.js:1177-1180 children: { "settings.general.item": { kind: "list", scope: "root" } } // :638 children: renderSlot("settings.general.item", {}) ``` ### 3.4 `settings.section` ✅ 仍存在(但归属包变了) ```js // dsh-client-ui-settings-general/lib/client.js:1171-1181 ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "general", order: 0, label: () => t("general.nav"), locale: NS, children: { "settings.general.item": { kind: "list", scope: "root" } } }, GeneralSection)); ``` 该 slot 的 spec 声明在 `sidebar.settings` 占用者的 children 表里: ```js // dsh-client-ui-settings-general/lib/client.js:1111-1146 const disposeSlot = ctx.slots.register({ name: "sidebar.settings", locale: NS, store: shellStore, children: { "settings.launcher": { kind: "single", scope: "root" }, "settings.trigger": { kind: "single", scope: "root" }, "settings.header": { kind: "single", scope: "root" }, "settings.action": { kind: "list", scope: "root" }, "settings.close": { kind: "single", scope: "root" }, "settings.section": { kind: "list", scope: "root" }, "settings.onboarding": { kind: "list", scope: "root" } }, inject: shellInjected }, SettingsRoot); ``` 导航行只投影 `id / order / label` 三个字段: ```js // dsh-client-ui-settings-general/lib/client.js:1023-1028 rows = ctx.slots.entries("settings.section").map((e) => ({ id: e.options.id ?? "", order: e.options.order ?? 0, label: resolveSlotLabel(e.options.label) ?? "" })).sort((a, b) => a.order - b.order); ``` 内容区**同一时刻只渲染被选中的那一节**: ```js // dsh-client-ui-settings-general/lib/client.js:337 children: active !== void 0 && renderSlot("settings.section", { close: onClose }, { only: active }) ``` ### 3.5 `settings.mascot.item` ⚠️ 不是宿主内置键,但模式合法且推荐 宿主 0.2.0-rc.2 的 SlotMap 中**没有** `settings.mascot.item`(全量 slot 键枚举见附录 A)。它是鲸鱼娘**自声明**的子 slot,靠 3.1/3.3 的 `children` 机制成立 —— 这套机制在 0.2.0-rc.2 依然完整。 **结论**:`settings.mascot.item` 无需改名,可原样保留。真正的变化是**父席位**(`settings.section`)现在由 `dsh-client-ui-settings-general` 声明,而它仍然声明在 `sidebar.settings` 的 children 里、仍以 `{ only: active }` 渲染 —— 与旧版行为一致。 ### 3.6 settings 面板由哪个包渲染 ⚠️ 换包(易踩) | 包 | 0.2.0-rc.2 的真实职责 | 证据 | |---|---|---| | `dsh-client-ui-settings` | **领域底座**,只有宿主半边(`lib/index.js` 20 行,`ctx.settings.configure`),**没有客户端面板** | `dsh-client-ui-settings/lib/index.js:14-18`;`package.json` 无 `dsh.client` | | **`dsh-client-ui-settings-general`** | **设置面板壳**:`sidebar.settings` 占用者、`SettingsRoot`/`SettingsPanel`、`settings.header/trigger/action/close/section/onboarding` 的声明方、通用页 | `lib/client.js:1058-1181`、`:280-341` | | `dsh-client-ui-settings-shell` | **不是**设置面板壳!是「终端执行器」设置页(`NS="settings.shell"`,注册进 `plugins.item`) | `lib/client.js:159-190`(`ShellCardController`、`timeoutMs`/`maxOutputBytes`) | | `dsh-client-ui-settings-plugins` | 「插件」分区宿主,渲染 `settings.plugins.tab` | `lib/client.js:68,124` | | `dsh-client-ui-settings-account` | 占据 `settings.launcher`(侧边栏底部账户菜单)+ 注册 `settings.section id="account"` | `lib/client.js:4496-4511,1636-1788` | > 只解出 `dsh-client-ui-settings` / `-shell` 两个包会得出「设置面板壳没了」的错误结论。**面板壳在 `-settings-general`**,该包已解到 `_reference/desktop-0.2.0-rc.2/dsh-client-ui-settings-general/`。 ### 3.7 侧边栏设置入口的 DOM ❌ 选择器需重写 结构(`dsh-client-ui-sidebar/lib/client.js:399-408`,spec `:511-514` kind=single/scope=root): ```html
…
…
``` `sidebar.settings` 的占用者是 `SettingsRoot`(settings-general),它渲染 `settings.launcher` 并带 fallback: ```js // dsh-client-ui-settings-general/lib/client.js:432-463 renderSlot("settings.launcher", { wide, settingsOpen: open, openSettings: actions.open, ... }, { fallback: jsx(Tooltip, { label: t("trigger"), children: jsx("button", { type: "button", "aria-label": t("trigger"), "aria-haspopup": "dialog", "aria-expanded": open, children: renderSlot("settings.trigger", { wide }) }}) }) ``` **但 desktop profile 下 `settings.launcher` 被占用了**: ```js // dsh-client-ui-settings-account/lib/client.js:4496-4500(无条件注册) ctx.slots.inject("settings.launcher", () => ctx.slots.register({ name: "settings.launcher", locale: "settings.account", inject: () => operations }, AccountMenu)); ``` 而 `AccountMenu` 注册时**没有** `children` → 它无法渲染 `settings.trigger`;同时 fallback 按钮整体不挂载 → **`[data-slot="settings.trigger"]` 在 desktop profile 下不渲染**。 > 前提已核实:`settings-account` 确实在客户端花名册里 —— `dsh-web-app/cordis.patch.yml:137-138` 载入 `@deepseek-ai/dsh-client-ui-settings-account`(同文件 `:291-292` 载入 `ui-settings-general`),而 `dsh-web-app` 是桌面 profile 的 bundle 之一(`profiles/desktop/package.json:9`)。 AccountMenu 的实际 DOM: ```js // dsh-client-ui-settings-account/lib/client.js:1707-1726 anchor: jsx("button", { type: "button", "data-collapsed": !wide, "data-signed-out": !signedIn, "aria-label": t("menu"), "aria-haspopup": "menu", "aria-expanded": open, ... }) ``` 点开后弹出的菜单项(`items` 里 `id: "settings"`,`:1728-1733`)由 `Menu` 渲染为 `button[role="menuitem"]`(`dsh-client-ui-primitives/lib/index.js:4203,4237,4247,4250`,`role="menu"` 容器)。 **可替代的确定标记**: | 用途 | 0.2.0-rc.2 可用选择器 | 元素/语义 | |---|---|---| | 侧边栏设置席位容器(恒在) | `[data-slot="sidebar.settings"]` | `SlotOutlet` 锚点 `
` | | 底部账号/设置菜单按钮 | `[data-slot="sidebar.settings"] button[aria-haspopup="menu"]` | AccountMenu 触发按钮(`data-signed-out` 可判登录态) | | 设置面板(打开后) | **`[data-shortcut-modal="settings"]`**(唯一判据) | 面板根 `
` | | 设置面板头部席位 | `[data-slot="settings.header"]` | 面板内的 outlet(仅面板打开时存在) | | 打开设置 | 键盘 `Ctrl+,`(desktop 默认绑定) | `dsh-client-ui-settings-general/lib/client.js:1065-1110` 注册 `settings.open` | | 菜单里的「设置」项(点开后) | `button[role="menuitem"]`,按 textContent 匹配 | Menu 行 | ⚠️ `[data-slot="sidebar.settings"] button` 会**同时匹配** `ConnectionIndicator` 的重连按钮与 `DesktopUpdateIndicator`(settings-general:464-479),必须加 `[aria-haspopup]` 收窄。 --- ## 4. DOM 信号契约(对应任务问题 3 · 最关键) ### 4.1 总原则:`data-slot` 仍由渲染器统一发出 ✅ 每一个 slot outlet 都会被包一层锚点 div: ```js // dsh-client-ui-renderer/lib/client.js:1094-1104 const ANCHOR_STYLE = { display: "contents" }; function SlotOutlet({ slotKey, ownerProps, opts }) { ... return jsx("div", { "data-slot": slotKey, style: ANCHOR_STYLE, children: renderOutletContent(...) }); } // :1217-1219(root 同构) return jsx("div", { "data-slot": "root", style: ANCHOR_STYLE, ... }); ``` 即:**只要某个 `renderSlot("")` 被渲染,DOM 里就一定有 `
`**(哪怕该 slot 暂无注册项)。 ⚠️ 注意 `display:contents`:锚点自身**没有盒**,`getComputedStyle(el).display === "contents"`,鲸鱼娘的 `isVisible()` 判定「`display === "none"` 才算不可见」→ 锚点会被判为可见 ✅;但 `offsetWidth/offsetHeight` 恒为 0,**不要**用宽高判可见。 ### 4.2 逐条核对 `assets/dsh-whale-moe.js:26-42` 现版原文: ```js // assets/dsh-whale-moe.js:26-43 var VIEW_SELECTORS = Object.freeze({ settings: '[role="dialog"], [data-slot="settings.header"]', workbench: '[data-slot="conversation.chat.node"], [data-phase="session"]' }); var SIGNAL_BANKS = Object.freeze({ thinking: ['[aria-busy="true"]', '[data-status="pending"]', '[data-state="loading"]', '[data-slot="conversation.chat.node"] [class*="stream" i]'], tool: ['[data-role="tool"]', '[data-tool="true"]', '[data-tool-card="true"]', '[data-status="running"]', '[data-running]', '[data-state="ongoing"]', '[data-state="running"]'], error: ['[data-state="error"]:not([class*="turnErrorDot"])', '[data-status="error"]', '[aria-invalid="true"]'], success: ['[data-state="success"]', '[data-status="success"]'], code: ['pre', '[data-slot="terminal"]', '[data-role="log"]', '[data-terminal]'], chat: ['[data-slot="conversation.chat.node"]'] }); ``` #### `[data-slot="conversation.chat.node"]` ✅ 仍有效 槽声明与渲染: ```js // dsh-client-ui-chat/lib/client.js:12390-12407(声明:keyed / session) ctx.slots.inject("conversation.view", () => ctx.slots.register({ name: "conversation.view", id: "chat", order: 0, locale: NS, children: { "conversation.chat.node": { kind: "keyed", scope: "session", inject: CHAT_NODE_INJECT }, "conversation.message.images": { kind: "single", scope: "session" } }, store: chatStore, inject: ... })); // :1770(渲染:每个 flow item 一个 key) children: renderSlot("conversation.chat.node", routedOwner, { ... }) ``` **并额外确认:工具卡就在这些节点内部**(这决定了下一条的修法): ```js // dsh-client-ui-tool/lib/client.js:4554-4564 ctx.slots.inject("conversation.chat.node", () => ctx.slots.register({ name: "conversation.chat.node", key: "tool-call", locale: CONVERSATION_NS, children: { "tool.call.toolview": { kind: "keyed", scope: "session", ... } }, inject: toolInject }, ToolCallTree)); ``` **适配动作**:保留该选择器作为「workbench 视图」判据。但注意它现在是**数量型**指标(每个已挂载的节点一个锚点),不要把它当「有一条新消息」的可靠计数(见 §7.3)。 #### `[data-slot="conversation.composer.bar"] textarea` ❌ 输入框不是 `