# 界面实现说明 `dsh-prompt-adapt` 的**客户端半边**(设置里的一页)。宿主半边由另一个代理同时编写,目录共用。 **交付物** | 文件 | 说明 | |---|---| | `lib/client.js` | 客户端半边本体。2276 行,**手写 DSH bundle**,无构建步骤 | | `tools/calibrate.mjs` | 自检(145 项断言,含与宿主 `lib/http.js` 的集成)+ 用真实画像样本标定修订阈值 | | `package.json` | 我**增量**加了 `exports` 与 `dsh.client`,原有字段一字未改 | | `UI-IMPLEMENTATION.md` | 本文件 | 自检结论:`node tools/calibrate.mjs` → **通过 145 项,失败 0 项**(退出码 0)。 其中第 7 节是**与宿主真实路由代码的集成测试**:把宿主 `lib/http.js` 的 `registerRoutes()` 挂到假 webServer 上,再把 `fetch` 接到那个 handler 上,于是客户端的每个请求都真的过一遍宿主的路径分发 / 方法校验 / body 解析 / 状态码 / 响应序列化。**客户端与宿主侧的接口是对着跑过的,不是靠约定。** > **时效说明**:写作过程中宿主侧在并行推进。我第一遍读 `lib/index.js` 时它只有服务、没有传输;后来它补齐了 `lib/http.js`(prefix 路由)与 `lib/hall.js`。本文档已按**最终实际状态**更新,并保留了"我是在哪一步发现双方不一致、又怎么对齐的"记录。 --- ## 一、接口查证结论(含 inspect 调用与返回) ### 1.1 先说结论:4 个 client Inspect Provider **全部超时**,我没有拿到它们的实际返回 我按任务要求先查了 Inspect,但**一个都没查到**。如实记录: ``` cordis_inspect_list → {"providers":[ {"platform":"host", "id":"Service","methods":["listService"]}, {"platform":"host", "id":"Event", "methods":["listEvents"]}, {"platform":"host", "id":"Config", "methods":["listConfigs"]}, {"platform":"host", "id":"Tool", "methods":["listTools"]}, {"platform":"client","id":"Service","methods":["listService"]}, {"platform":"client","id":"Event", "methods":["listEvents"]}, {"platform":"client","id":"Builtin","methods":["listBuiltins"]}, {"platform":"client","id":"Slots", "methods":["listSubTree"]}, {"platform":"client","id":"Theme", "methods":["listTokens"]} ]} ``` 5 个 client provider 存在(说明曾有页面把 manifest 同步上来过),但每一次查询都是同一个结果: ``` cordis_inspect_query {platform:"client", provider:"Slots", method:"listSubTree"} ×8 次 cordis_inspect_query {platform:"client", provider:"Service", method:"listService"} ×2 次 cordis_inspect_query {platform:"client", provider:"Builtin", method:"listBuiltins"} ×2 次 cordis_inspect_query {platform:"client", provider:"Theme", method:"listTokens"} ×1 次 → Error: Slots.listSubTree: Client inspect query Slots.listSubTree timed out after 10000ms. Open or reconnect the Harness page, then retry. (四个 provider 的错误文本同构,只有 provider/method 名不同) ``` **原因排查**:GUI 服务器本身是活的 —— `http://127.0.0.1:19387/` 未鉴权访问返回 **401**,`Get-NetTCPConnection -LocalPort 19387 -State Listen` 显示 **pid 40732** 在监听。工具说明写着「A Client query waits for the first valid page response within the configured timeout」,所以是**当时没有页面在应答 inspect 路由**,不是插件或端口的问题。我没有浏览器可以自己打开页面,因此这一项**确实没查到**。 **我改用了什么**:随包发布的 `.d.ts` 与 `lib/client.js`。这些是 DSH 自己构建产物里的**权威契约**(比运行时 inspect 更硬:inspect 只能反映"当前这一刻"),而且都能给出实际调用与实际返回。下面每一条都注明来源文件与行号。 ### 1.2 唯一一个**真的跑通**的 Inspect:宿主侧 `webServer` 契约 这一条是我定义客户端↔宿主传输方式的依据,用 host provider 查的(本地执行,不需要页面): ``` cordis_inspect_query {platform:"host", provider:"Service", method:"listService", input:{service:"webServer"}} → data.service.methods[0] = { "signature": "register(route: WebRoute): () => void", "description": "Register a named route. Duplicate (kind, path) throws — route patterns are a composition-level contract, so a collision is a misconfiguration.", "returns": "the disposer removing the route." } referencedTypes: WebRoute = { kind: WebRouteKind; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void | Promise } WebRouteKind = 'exact' | 'prefix' 另有:tapIndex(transform:(html:string)=>string): () => void registerFallback / registerUpgrade / applyIndexTaps / collectIndexInjections / renderIndex ``` **因此客户端的传输方式定为 HTTP + `webServer.register({kind:'prefix'})`。** 这是本文件里唯一一条"我实际查到"的通信契约。 ### 1.3 `Slots`:`ctx.slots` 是什么,`settings.section` 是谁声明的 *来源:`C:\Users\<用户名>\.dsh\profiles\node_modules\@deepseek-ai\`* **(a) 客户端 `ctx.slots` 的真实类型 —— `dsh-client-ui-renderer/lib/types/client/registry.d.ts`** ```ts declare module '@deepseek-ai/cordis' { interface Context { slots: SlotRegistry } } // :27 class SlotRegistry extends Service { readonly register: SlotCore['register']; // :85 注册一个贡献 inject(key: keyof SlotMap & string, callback: () => SlotInjectionEffect): () => void; // :111 等槽位被声明后再注册 entries(key): readonly StoredEntry[]; // :164 entriesOfSlot(key): readonly StoredEntry[]; // :174 spec(key): SlotSpec | undefined; // :198 subscribe(key, fn): () => void; // :205 微任务批量的变更订阅 getVersion(key): number; // :211 uSES 配对的版本号 snapshot(root?): LiveCompositionNode[]; // :180 只读拓扑导出 } ``` 文件注释里还写明了关键语义:`register` **必须**保持原型方法(service 代理把 `this.ctx` 绑到**调用方**的 ctx,所以 effect 与卸载级联落在调用方 fiber 上);`entries()` 返回**缓存引用**、在两次变更之间稳定,可以安全当 uSES 的 getSnapshot 源。这两点决定了我的 store 必须保证 `getSnapshot()` 引用稳定。 **(b) `settings.section` 与 `settings.general.item` 的类型 —— `dsh-client-ui-settings/lib/types/client/contract/slots.d.ts`** ```ts 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }; // :73-77 'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }; // :120-124 interface SettingsSectionOwnerProps { close: () => void } // :154-157 interface SettingsGeneralItemOwnerProps { children?: never } // :128-131 ``` 同一文件的注释原文(决定了我选哪个,见 §二): - 关于 `settings.general.item`:*"One preference row inside the General section — the additive seat for a single setting that needs no page of its own (a whole page is `settings.section`)… The section column only stacks rows, so a row draws its own internals, including its label: **nothing projects a `label` here and the owner passes no props at all**"* - 关于 `settings.section`:*"One settings page per list entry. Registrant options carry the nav identity: `id` (section key, drives `only` filtering), `order` (nav position), `label` (registrant-localized display text — the registrant re-registers with fresh text on locale change…)"* **(c) `settings.section` 的**声明者**是外壳 —— `dsh-client-ui-settings-general/lib/client.js:1136`** ``` Select-String dsh-client-ui-settings-general/lib/client.js -Pattern '"settings\.section":\s*\{' -Context 4,6 → :1136 "settings.section": { kind: "list", scope: "root" }, ← 在某个 register() 的 children 表里 :1140 "settings.onboarding": { kind: "list", scope: "root" }, ``` 这条很重要:**声明归外壳(ui-settings-general)**,我的插件只往里注册。因为插件加载顺序不保证,我**不能**直接 `register`(注册到未声明的槽位会 fail-loud 抛错),必须走 `slots.inject` 等声明。 **(d) 真实的注册写法 —— `dsh-client-ui-settings-plugins/lib/client.js:201-212`** ```js ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "plugins", order: 15, label: () => t("nav"), locale: NS, inject: sectionInjected, children: { "settings.plugins.tab": { kind: "list", scope: "root" } } }, PluginsSettingsSection)); ``` 注意 `label: () => t("nav")` 是 **thunk**:契约文档说 thunk 每次读都重新求值,所以语言切换不需要重新注册。我照抄了这个形态。 **(e) 导出的 `inject` 是**服务名**,不是包名 —— `dsh-client-ui-settings-plugins/lib/client.js:162`** ```js const inject = ["slots", "locale"]; // 服务 key exports.inject = inject; ``` 而 `package.json` 里的 `dsh.client.inject` 是**包名**,两者不是一回事: ```json // dsh-client-ui-settings-plugins/package.json "dsh": { "client": { "inject": ["@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings"], "platform": "web" } } ``` ### 1.4 bundle 格式:客户端插件就是一个注册 factory 的脚本 *来源:`dsh-client-ui-settings-plugins/lib/client.js:1-19`* ```js window.__ModuleLoader__.load({ id: "@deepseek-ai/dsh-client-ui-settings-plugins", // ← 模块身份 = 包名 factory: (require) => { var module = { exports: {} }; var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); let _deepseek_ai_dsh_client_ui_slots = require("@deepseek-ai/dsh-client-ui-slots"); let react_jsx_runtime = require("react/jsx-runtime"); let react = require("react"); // … CSS 注入:document.querySelector('style[data-plugin-css=""]') 为 null 时建