# DSH 插件前端开发指南(Web GUI / Client 插件) > 本指南是 DSH 插件开发工作区的“插件前端”专题部分,与 > [插件开发详解](./插件开发详解.md)(通用 Cordis/Host 插件)和 > [发布到社区指南](./发布到社区指南.md)(分发链路)配套阅读。 > > **版本基线**:`deepseek-harness` 源码仓库 `0.1.0-rc.5`, > commit `47f943859b`。该版本仍是 developer preview,README 明确声明后续可能有兼容性破坏; > 所有“服务有哪些方法、槽位有哪些字段”类问题,最终以源码 TypeScript 接口为准。 ## 0. 一分钟结论 1. **前端插件 = 同一个 Cordis 插件的“浏览器半”**。浏览器里跑的是第二套 Cordis 树: Host 侧扫描带 `dsh.client` 声明的包,把 `lib/client.js` 组成启动图注入页面; 浏览器 Shell 再把每个 bundle 作为 Loader entry 激活。 2. **优先使用增量槽位(Slot)**,不要注册 `root`,也不要覆盖 `sidebar` / `conversation` 等父级 single 槽位——那会连它声明的所有子槽位一起带走。 3. **最稳的第三方扩展面**:`conversation.input.left/right`、`shell.overlay`、 `settings.section`、`settings.plugin.item`、`settings.general.item`、 `conversation.view`、`conversation.session.header.actions`、 `tool.call.toolview`、`conversation.composer`(chain)、theme token override。 4. **官方构建协议没有 npm SDK**。仓库内用 `packages/client/tsdown.client.ts` preset; 仓库外独立包目前需要复制该 preset 的等价配置(本指南 §9 给出可直接使用的模板)。 5. **不要 monkey-patch 官方 dist,不要绕过 bundle purity gate**。跨插件协作只走 Cordis 服务/事件、Slot、Remote/RPC 和 owner props。 ## 1. 前端插件是什么:整体架构 ### 1.1 启动链路 ```mermaid flowchart TD A["profile cordis.yml
(bundle patch)"] --> B[Host Loader 装配插件树] B --> C["ClientModuleRegistry 扫描
声明 dsh.client 的包"] C --> D["组成 window.__DSH_BOOT__ 图
(id / inject / immediately)"] C --> E["服务每个 bundle:
/plugins/<id>/client.js"] D --> F["index tap 把 __DSH_BOOT__
注入 HTML"] F --> G[AppWebEntry 两阶段 boot] G --> H["阶段一:种子模块表
react / cordis / ui-slots / …"] G --> I["阶段二:每个图行 = 一个
Loader entry(Cordis fiber)"] I --> J["全部 fiber ACTIVE 才切换真实 UI
(all-or-nothing)"] J --> K["renderSlot('root')
→ ui-layout AppFrame"] K --> L["sidebar / conversation / details
+ shell.overlay + 各子槽位"] ``` 关键源码(相对 `deepseek-harness`): | 环节 | 源码 | |---|---| | Host 半扫描 `dsh.client`、组成图、`/plugins/*` 路由、index tap | `packages/client/modules/src/index.ts` | | 浏览器侧模块表(lazy CJS、`window.__ModuleLoader__`) | `packages/client/modules/src/client/**` | | Shell 两阶段 boot、全 fiber ACTIVE 门 | `packages/client/web/src/boot.tsx` | | 平台共享模块白名单(bundle 外部化的唯一事实源) | `packages/client/web/src/platform.ts` | | 官方 client bundle 构建 preset | `packages/client/tsdown.client.ts` | | Slot 纯核心(register/声明/四种 share) | `packages/client/ui-slots/src/index.ts` | | Slot 的 Cordis 服务层(`ctx.slots`) | `packages/client/runtime/src/client/slots.ts` | | 各槽位目录(declare-merge) | `packages/client/ui-{layout,sidebar,settings,settings-plugins,conversation,tool,input-trigger,workspace}/src/client/**` | | 完整架构审计(本指南的上游依据之一) | `本地架构审计文档(不入库)` | ### 1.2 三个必须理解的名词 | 名词 | 含义 | |---|---| | **Host 半 / Node 半** | 包主入口 `lib/index.js`(`exports["."]`),在 Node 进程里跑。Host Loader 要 import 它,所以它必须存在并导出 `apply`(哪怕 apply 是空实现)。 | | **Client 半 / 浏览器半** | `lib/client.js`(`exports["./client"]`),一个 CJS closure-factory bundle。脚本执行时只调用 `window.__ModuleLoader__.load({ id, factory })` 注册工厂,所有副作用(含 CSS 注入)都在工厂真正被物化时发生。 | | **frozen module table** | 浏览器 Shell 种子化的共享模块表:`react`、`react-dom`、`@deepseek-ai/cordis`、`ui-slots`、`web-react`、`ui-primitives`、`ui-attachment`、`schema-form` 等。你的 bundle 必须把它们**外部化**(`require()`),其余第三方依赖**打进 bundle**。require 一个不在表里的模块 = 运行时抛错。 | ## 2. 一个前端插件包的完整契约 以 `dsh-voice-webspeech`(纯浏览器语音插件)为模板:Host 半几乎为空,价值全在 Client 半。 ### 2.1 `package.json`(必须项) ```jsonc { "name": "dsh-voice-webspeech", // = 插件 id = bundle id = /plugins//client.js "version": "0.1.0", "type": "module", "main": "lib/index.js", // Host 半 "types": "lib/types/index.d.ts", "exports": { ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, "./package.json": "./package.json" }, "dsh": { "client": { "platform": "web", // 必填;ClientModuleRegistry 只认 "web" "inject": [ // 浏览器侧“模块边”元数据(包 id,不是服务名) "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-settings-plugins" ] // "immediately": true // 可选:进入阶段一并行 prefetch 层 }, "bundle": { "patch": "./cordis.patch.yml" } // Host 侧安装时自动应用的补丁 }, "files": [ "lib/**/*.js", "lib/**/*.map", "lib/types/**/*.d.ts", "cordis.patch.yml", "src", "README.md", "LICENSE" ], "scripts": { "build": "node -e \"fs.rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.json && tsdown" } } ``` 要点: - `dsh.client.platform` 必须是 `"web"`;`exports["./client"]` 必须存在且指向构建好的 bundle, 否则 ClientModuleRegistry 构造时**响亮地抛错**(页面停在 loading 并列出问题)。 - `dsh.client.inject` 写的是**包 id**(即启动图里的 row id);插件源码里导出的 `export const inject = ['slots', 'locale']` 写的是 **Cordis 服务名**——fiber 会等这些服务就绪。 - `immediately` 只标“阶段一并行预取”。普通 UI 插件不必开;runtime/locale 这类被同步 `require` 依赖的内核包才需要。 - 一个包可以**只有 Host 半没有 Client 半**(如 `@anweat/dsh-browser`、`dsh-web-search-pro`), 那就不写 `dsh.client`、不需要 `./client` export、也不需要 tsdown 前端配置。 ### 2.2 `cordis.patch.yml` ```yaml # 纯浏览器插件:host 半为空,只是让自己进入插件树,从而被 client 扫描发现 - insert: - id: dsh-voice-webspeech name: dsh-voice-webspeech # 安装后写包名;源码调试可写绝对路径 config: {} ``` - 该补丁随 `dsh.bundle.patch` 被 profile 合成,Host Loader 由此把包放进插件树; ClientModuleRegistry 再沿 Loader rows 扫描 `dsh.client`。 - 包解析锚定在 `ctx.baseUrl`(cordis.yml 所在目录):profile 的 `package.json` 必须把 该包声明为依赖(`dsh plugin add` 会替你完成)。 ### 2.3 Host 半的最小形态 ```ts // src/index.ts —— 纯 client 插件可以空到只剩一个日志 import type { Context } from '@deepseek-ai/cordis' export const name = 'dsh-voice-webspeech' export function apply(ctx: Context): void { console.log('[dsh-voice-webspeech] loaded') } ``` > Host+Client 双插件(如 `dsh-restart`)则把工具/命令/设置 section/HTTP 路由写在 Host 半, > 把设置卡片写在 Client 半,两者通过 settings namespace 或自有路由协作(§6.3、§11)。 ## 3. 两条开发路径:仓库内 vs 独立 npm 包 | 维度 | 仓库内(`packages/client/`) | 独立 npm 包(本工作区推荐给社区插件) | |---|---|---| | 构建配置 | 直接用官方 `packages/client/tsdown.client.ts` 的 `clientBundle(id, libEntry)` | 自带一份等价 tsdown 配置(§9 模板) | | tsconfig | 继承 `tsconfig.base.client.json` | 自带 `tsconfig.json`(§9) | | 加载 | `pnpm dsh web --patch `(源码直接跑) | `pnpm dsh plugin --profile web add `,重启 `dsh web` | | HMR | `pnpm run dev:web` 官方 watcher | 官方 watcher 不扫描仓库外包;稳定回路是 rebuild + 刷新/重启(§10) | | 类型获取 | 源码相对路径直连 | 通过 `optional peerDependencies` 安装 `@deepseek-ai/dsh-client-*`,开发时可用 `dev:link-dsh` 软链到源码仓库 | | 分发 | 随仓库发布 | npm / GitHub / tarball(见发布指南) | ### 3.1 仓库内开发的完整回路 ```sh # 仓库根:deepseek-harness pnpm dsh web --patch /abs/path/to/plugins/<插件名>/cordis.yml # 另一终端: pnpm run dev:web # 只监视 packages/*/* 中带 dsh.client 声明的包并重写 lib/client.js ``` `dev:web` 只重写 bundle;Shell/HTML/平台模块/普通 package 改动需要重新 build web artifacts 并刷新。 ### 3.2 独立包开发的完整回路 ```sh # 1. 首次:把 deepseek-harness 源码里的类型契约软链进本包(参考 dsh-voice-webspeech) pnpm install pnpm run dev:link-dsh -- --source /absolute/path/to/deepseek-harness # 2. 迭代:tsc 出 lib/index.js + lib/types,tsdown 出 lib/client.js pnpm run build # 3. 激活(三选一) pnpm dsh plugin --profile web add . # 本地 checkout 链接进 profile pnpm dsh plugin --profile web add github:anweat/ # git 安装(需已提交 lib/) pnpm dsh plugin --profile web add @ # npm 安装 # 4. 重启 dsh web(客户端 bundle 在启动时进图) pnpm dsh web ``` > 独立包的开发环境建议直接以 `dsh-voice-webspeech` / `dsh-restart` 为骨架: > `package.json` + `tsconfig.json` + `tsdown.config.ts` + `scripts/link-dsh-workspace.mjs` > + `scripts/check-client-bundle.mjs` 五件套,缺一不可。 ## 4. Client 插件源码骨架 ### 4.1 最小入口 ```ts // src/client/index.ts import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-slots' // SlotMap / PropsLocale import type {} from '@deepseek-ai/dsh-client-locale/client' // ctx.locale 的 declare merge import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // conversation.* 槽位类型 import { MicButton } from './MicButton.tsx' import { zh, en } from './locales.ts' export const name = 'my-widget-client' export const inject = ['slots', 'locale'] // Cordis 服务依赖 export const NS = 'my.widget' export function apply(ctx: ClientContext): void { // 1. 词典注册:可逆 effect,卸载自动移除 ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'my-widget: dictionaries') // 2. 等槽位被声明后再注册贡献(声明者先启动、后启动都安全) ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'my-widget-mic', // list 槽位唯一 id order: 10, // 升序排列 locale: NS, // 组件收到标准 t seat inject: () => ({}), // 组件额外 props(业务注入面) }, MicButton)) } ``` ### 4.2 类型与 Context 外部包没有仓库内 tsconfig 的 path 映射,靠**类型 import + declare merge** 获得全部类型: ```ts // src/client/context-types.ts import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { zh } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { 'my.widget': keyof typeof zh // t('...') 的键被静态检查 } } export type Context = ClientContext ``` 要点: - **值 import 与类型 import 的边界就是生死线**:bundle purity gate 只拦 `@deepseek-ai/*` 的**值**导入;`import type` 会被擦除,可以随便引用任意包的 client 类型。 - 想要某个服务的 `ctx.xxx` 类型,就 `import type {} from '.../client'` 拉进它的 declare merge。 - `ClientContext` 本质就是合并后的 Cordis `Context`。 ### 4.3 常用服务速查(`ctx.*`) | 服务名 | 提供方 | 用途 | |---|---|---| | `slots` | `dsh-client-runtime` | `register / inject / entries / subscribe / getVersion / snapshot`,前端插件的核心 API | | `sessions` / `workspaces` | `dsh-client-runtime` | 会话/工作区对象层与列表动作 | | `locale` | `dsh-client-locale` | `register(ns, {zh,en})` / `bind(ns)` / `subscribe` | | `settingsScope` | `dsh-client-ui-settings` | `bind({ namespace })` → Host 设置的浏览器镜像(§6.3) | | `connection` | `dsh-client-connection` | `ConnectionHandle`(`api.*` RPC、连接生命周期) | | `remote` | `dsh-api-remotes`(client 半) | `$on` 转发事件、allowlist 内的 RPC | | `theme` | `dsh-client-ui-theme` | 主题注册 / token override(§7) | | `layout` | `dsh-client-ui-layout` | 面板几何动作(开 details 面板等),依赖官方 layout 在组合中 | 依赖怎么写:源码里 `export const inject = [...]`(服务名), `package.json` 的 `dsh.client.inject` 里写对应**包 id**(用于启动图元数据)。 ## 5. Slot(槽位)系统:前端扩展的唯一正确入口 ### 5.1 模型 - **声明即授权**:某个 slot 只能有一个声明者(declaring entry)。父槽位的 occupant 在同一笔 `register({ children })` 里声明子槽位;父 registration 卸载会**递归撤销**全部子槽位。 - **贡献 vs 替换**:`list`/`keyed`/`chain` 是增量贡献;`single` 只有一个 winner, 再注册一个就是**替换(shadow)**整区。 - **作用域**: - `root`:页面级; - `session`:每个会话一个实例,框架注入 `sessionId` 与 session 标准 kit; - `session-maybe`:跨“无会话 hero ↔ 有会话”保持同一 React 身份。 - **动态插件降权**:动态 runner 注册的非 chain 槽位会拿到低于 shipped UI 的 priority, 对 single 槽位而言它会成为 winner——**动态 shadow 一个 single 槽位 = 整区只剩你**。 ### 5.2 `ctx.slots.register(options, Component)` 的选项 | 选项 | 适用 | 含义 | |---|---|---| | `name` | 全部 | 目标槽位键 | | `id` | list/keyed | 稳定 id(list 去重、keyed 路由);建议带插件前缀 | | `order` | list | 升序渲染顺序 | | `key` | keyed | 业务 key(如 tool 名),open-key 域,拼错只会不渲染 | | `label` | settings.section / settings.plugins.tab | 导航文案,写函数以跟随语言切换重注册 | | `locale` | 全部 | 词典 namespace;组件获得 `t` | | `inject` | 全部 | 返回组件的业务 props(见下) | | `store` | 全部 | 共享 store handle 或独占工厂(`useStore`/`actions` 成为标准 props) | | `children` | 仅声明者 | 一次声明子槽位树(`{ key: { kind, scope } }`) | | `select` / `priority` | chain | 纯函数竞选器 / 升序优先级,首个非 null 结果当选,组件收到 `matched` | 组件的 props 是**四份 share 的交集**: ```text PropsRuntime(owner props + session/global 标准 kit + 框架 hooks) & PropsRenderSlots(你声明过的子槽位 → renderSlot 函数) & PropsStore(useStore + actions) & inject() 返回值(业务面,钩子放入保留的 hooks 分区) & PropsLocale(t) ``` `ctx.slots.register()` 已经跑在调用方 fiber 的 effect 里:**插件卸载时自动注销**,不需要自己保存 disposer。 ### 5.3 `ctx.slots.inject(name, callback)`:声明等待 大多数第三方插件都往**别人声明的槽位**里注册。直接 `register` 到未声明槽位会 throw; 正确姿势是 `ctx.slots.inject`: - 槽位已经声明 → 同步执行 callback; - 还没声明 → 等;声明 collapse → 清掉贡献;重新声明 → 重跑 callback; - callback 可以返回一个 disposer,也可以返回**可迭代 disposer(generator)**, 把多个 `register` 装进一个事务:中途失败回滚、卸载逆序清理。 ```ts ctx.slots.inject('settings.plugin.item', function* () { yield ctx.slots.register({ name: 'settings.plugin.item', id: 'card-a', order: 10 }, CardA) yield ctx.slots.register({ name: 'settings.plugin.item', id: 'card-b', order: 20 }, CardB) }) ``` ### 5.4 槽位目录(第三方插件常用子集) > 全量目录以各包 `src/client/**` 的 `interface SlotMap` declare merge 为准(审计时为约 42 个)。 > 下表标注了 kind/scope,并给出“加功能该往哪放”。 #### 全局壳层 | 槽位 | kind/scope | 说明 | |---|---|---| | `root` | single / root | 页面根。**永远不要注册**——shadow 它会替换整个 AppFrame,所有子槽位消失 | | `shell.overlay` | list / root | 页面级浮层(toast、徽标、全局面板)。click-through,默认不挡交互 | | `sidebar` | single / root | 整条左栏(官方 occupant 声明了 workspaces/settings/footer 子槽位)。**替换需自担全部子面** | | `sidebar.footer.action` | list / root | 左栏底部 Settings 旁的额外动作按钮 | | `details` | single / session | 右侧详情栏(替换需自担 tool 面板) | #### 设置面 | 槽位 | kind/scope | 说明 | |---|---|---| | `settings.section` | list / root | 一个完整设置页(owner 给 `close`;用 `id/order/label` 表达导航) | | `settings.plugins.tab` | list / root | “插件”设置页里的一个 tab | | `settings.plugin.item` | list / root | **插件自己的配置卡片**(§6.2/6.3),设置 → 插件 → 可配置 | | `settings.general.item` | list / root | 常规设置页里的单行偏好 | | `settings.action` | list / root | 设置面板标题栏动作 | | `settings.onboarding` | list / root | 引导步骤(较特殊,先别碰) | | `settings.trigger` / `settings.header` / `settings.close` | single / root | 设置壳的文案/内容位,一般不需要动 | #### 会话与输入面 | 槽位 | kind/scope | 说明 | |---|---|---| | `conversation` | single / session-maybe | 整个中栏。**替换会带走全部会话子槽位** | | `conversation.session` | single / session | 单个会话 body(替换即自己渲染整个会话) | | `conversation.session.header` | single / session | 会话标题行 | | `conversation.session.header.actions` | list / session | 标题行动作按钮(增量) | | `conversation.session.header.utilities` | list / session | 标题行右端工具 | | `conversation.view` | list / session | 会话视图 tab(如新增“轨迹/规划”式整页视图) | | `conversation.chat.node` | keyed / session | 按业务 node kind 分发最终渲染器 | | `conversation.chat.commandview` | keyed / session | 按 `/command` 名分发命令行卡片 | | `conversation.chat.turnTail` | chain / session | Turn 尾部扩展链 | | `conversation.chat.assistant-actions` | list / session | 消息级动作(增量) | | `conversation.composer` | chain / session | **composer 接管链**(selector 路由,全拒绝回退默认输入条) | | `conversation.composer.bar` | single / session-maybe | 默认输入条本身(替换需自担 plan/model 等子位) | | `conversation.composer.dock` | list / session | 输入卡下方的环境读数行 | | `conversation.input.dock` | list / session | 输入卡上方整行(队列/todo/goal bar) | | `conversation.input.left` | list / session | 输入条左端小控件(语音按钮等,**最常用**) | | `conversation.input.right` | list / session | 输入条右端小控件 | | `conversation.input.overlay` | list / session | 输入浮层锚点 | | `conversation.input.plan` / `conversation.input.model` | single / session | 具名 plan/model 位(替换需自担整个能力) | | `conversation.hero.workspace` | single / root | 空白态 workspace 选择器 | | `conversation.hero.agentPreset` | single / root | 空白态 agent preset 位 | #### 工具 / 目录流 | 槽位 | kind/scope | 说明 | |---|---|---| | `tool.call.toolview` | keyed / session | 按工具名分发工具调用视图(**给自己的工具做 UI 卡片的入口**) | | `conversation.details.tool` | single / session | 详情栏里的整块工具输出面板(替换需渲染所有工具) | | `conversation.hero.workspace.directoryFlow` | single / root | workspace 目录选择交互(native chooser / 浏览对话框插件) | | `sidebar.workspaces.directoryFlow` | single / root | 同上,侧栏入口 | ### 5.5 选槽位的决策表 | 你想做什么 | 用哪个槽位 | |---|---| | 输入框旁边放一个小按钮 | `conversation.input.left`(靠发送键用 `.right`) | | 全局浮层 / 状态提示 | `shell.overlay` | | 插件自己的设置页 | `settings.section` | | 插件自己的配置卡片 | `settings.plugin.item` | | 单个偏好行 | `settings.general.item` | | 自己的工具调用有专属视图 | `tool.call.toolview`,`key: ''` | | 替换整块 composer(审批、问题面板) | `conversation.composer`(chain + selector) | | 给会话加个新视图 tab | `conversation.view` | | 会话标题栏加按钮 | `conversation.session.header.actions` | ## 6. 三个实战模式(本工作区已上线插件验证) ### 6.1 Composer 小控件(`dsh-voice-webspeech` 模式) ```tsx // src/client/MicButton.tsx(简化) import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { InputZone } from '@deepseek-ai/dsh-client-ui-conversation/client' export type MicButtonProps = PropsLocale<'voice.webspeech'> & InputZone export function MicButton({ t }: MicButtonProps) { return } ``` ```ts // src/client/index.ts(注册,见 4.1) ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'voice-webspeech-recorder', order: 10, locale: LOCALE_NS, inject: () => ({}), }, RecorderButton)) ``` 已上线实现:`\src\client\`。 偏好存 localStorage(`loadPrefs / updatePrefs / subscribePrefs`), 组件用 `useSyncExternalStore` 订阅——**快照必须返回稳定引用**,否则 React 判定每次都变而无限重渲染 (该插件实测踩过这个坑:按钮被错误边界 abdicate 后直接消失)。 ### 6.2 插件配置卡片(localStorage 版,无需 Host 写权限) 适合纯浏览器偏好,不落 `settings.yaml`: ```ts ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', id: 'my-plugin', order: 30, locale: NS, inject: () => ({}), // 不需要 Host 写路径,注入面留空 }, SettingsCard)) ``` 卡片组件契约(`settings.plugin.item` 的 owner 是空面,只给 `t`): ```tsx import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' export type SettingsCardProps = PropsLocale<'my.widget'> export function SettingsCard({ t }: SettingsCardProps) { // 根节点用
  • (该槽位渲染在
      卡片列表里),自绘折叠卡片 return
    • …{t('title')}…
    • } ``` 参考:`\src\client\SettingsCard.tsx`。 ### 6.3 插件配置卡片(Host settings 版,`dsh-restart` 模式) 让卡片编辑的数据**持久化到 `$DSH_HOME/settings.yaml` 并让 Host 半读到**: Host 半(`src/index.ts`): ```ts import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { Schema } from '@deepseek-ai/schemastery' export const inject = ['tools', 'commands', 'agents', 'shell', 'sandboxPolicy'] export interface RestartConfig { legacyRestart: boolean continuePrompt: string watchdogEnabled: boolean // ... } export const RestartConfigSchema: Schema = Schema.object({ legacyRestart: Schema.boolean().default(false), continuePrompt: Schema.string().default(''), watchdogEnabled: Schema.boolean().default(false), // …其余字段与 RestartConfig 接口一一对应 }) export const DEFAULT_CONFIG: RestartConfig = { // 每个字段给一个默认值(与 Schema.default 保持一致) legacyRestart: false, continuePrompt: '', watchdogEnabled: false, } export function apply(ctx: Context): void { let resolveConfig = () => DEFAULT_CONFIG installSettingsSection(ctx, settingsNamespace('dsh-restart'), RestartConfigSchema, DEFAULT_CONFIG, { setSource: (get) => { resolveConfig = get }, // 每次动态取当前配置 onChange: () => {}, }) // ...后面注册工具/命令/HTTP 路由 } ``` Client 半(`src/client/index.ts`,简化自 dsh-restart): ```ts import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' export const inject = ['slots', 'locale', 'settingsScope'] export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register(NS, { zh, en })) // 绑定 Host 侧同名 namespace 的浏览器镜像 const scope = ctx.settingsScope.bind({ namespace: 'dsh-restart' }) const project = (): CardState => { const snap = scope.getSnapshot() // snap: { status:'loading'|'ready'|'unavailable', value, base, user, // revision, writable, mode:'host'|'memory' } return { available: snap.status === 'ready', writable: snap.writable, legacyRestart: (snap.value ?? {}).legacyRestart === true, // ... } } const store: SnapshotStore = createSnapshotStore(project()) scope.subscribe(() => { store.set(project()) }) ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', id: 'dsh-restart', order: 40, locale: NS, inject: () => ({ hooks: { dshRestart: store }, // 渲染器绑定成 useDshRestart hook set: (field, value) => { void scope.set(field, value) }, clear: (field) => { void scope.unset(field) }, }), }, SettingsCard)) } ``` 卡片组件里: ```tsx export function SettingsCard(props: SettingsCardProps) { const { t, set, clear } = props const state = props.useDshRestart(snapshot => snapshot) if (!state.available) return null // namespace 未就绪:不渲染 const disabled = !state.writable // 远端页面/memory mode:只读 // set('legacyRestart', checked) / clear('continuePrompt') … } ``` 写语义要点(`runtime/src/client/contract/settings-scope.ts`): - `status === 'ready'` 才可用;`unavailable` 表示该 namespace 未对本客户端暴露。 - `set/unset` 按 revision 排队防并发写冲突;被拒绝的最新写会回读 Host 状态。 - 字段是否“被覆盖”看 `user` 层**是否存在该键**,不看值是否等于默认值。 参考:`\src\client\index.ts` 与 `SettingsCard.tsx`。 ### 6.4 给工具注册专属视图(keyed) ```ts // key 域是开放的 wire tool name:拼错 = 永不渲染,不报错 ctx.slots.inject('tool.call.toolview', () => ctx.slots.register( { name: 'tool.call.toolview', key: 'my_tool_name', locale: NS }, MyToolView, )) ``` 组件收到 `ToolCallOwnerProps`:`callId / toolName / block / cwd / openFile / inspect`。 参考 `packages/client/ui-skill/src/client/index.ts`(key `'skill'`)。 ## 7. 主题与 CSS ### 7.1 Theme 服务 `ctx.theme` 由 `ui-theme` 提供(默认 web 组合里有;依赖 `ui-layout` 的 ThemePresenter 写 DOM): ```ts import type {} from '@deepseek-ai/dsh-client-ui-theme/client' // ctx.theme 类型 export const inject = ['slots', 'theme'] export function apply(ctx: Context): void { // 1) 注册一个可选主题(tokens 是 --dsw-alias-* 的覆盖字典) ctx.effect(() => ctx.theme.register({ id: 'ocean', colorScheme: 'dark', tokens: { '--dsw-alias-bg-base': '#0b1220', '--dsw-alias-label-primary': '#e6edf3', }, })) // 2) 或叠加一个 token 覆盖层(可逆,不用整主题) ctx.effect(() => ctx.theme.overrideTokens('my-plugin', { '--dsw-alias-bg-base': { light: '#ffffff', dark: '#0b1220' }, })) // 3) 切到某个已注册主题 / 观察变化 ctx.theme.setTheme('ocean') ctx.on('theme/change', (snap) => { /* snap.active.tokens … */ }) } ``` 约束: - `overrideTokens` 的值必须是 `{ light, dark }` 对;传裸字符串会得到教学型 TypeError。 - 第三方 theme/override 要等 Client 插件激活后才生效,**不能承诺自定义品牌主题零闪烁**。 - ThemePresenter 属于 `ui-layout`:Profile 里禁用官方 layout 会连 DOM theme 呈现一起丢。 ### 7.2 CSS Modules 官方 preset 用 lightningcss 把 `*.module.css` 编译成 hashed class map,并在工厂执行时注入 `