# 照着 dsh-global-memory 写一个 host + client 双端 DSH 插件 这份指引面向「想给 DeepSeek Harness(DSH)写插件」的开发者:本插件(host 注入 + Agent 工具 + HTTP 路由 + 设置页面板)是一个**最小但完整**的双端样本,照抄结构即可起步。文中所有形态都取自本仓库的真实代码。 ## 0. 你需要哪一端 | 你的需求 | host | client | | --- | --- | --- | | 改 system prompt / 提供工具 / 提供 HTTP 接口 | ✅ 必需 | — | | 在设置页或界面上出一块 UI | ✅(提供数据接口) | ✅ 必需 | | 纯后台逻辑、无界面 | ✅ | — | host 是 Node 侧(插件包的 `main`),client 是浏览器侧(`exports["./client"]`),两者通过**同源 HTTP** 通信——**不要**试图让 client 直接读文件或 import host 模块,浏览器里没有这些东西。 ## 1. 最小文件骨架 ``` my-plugin/ ├── index.js # host 入口(必需) ├── lib/ # 你自己的 host 逻辑(示例:store.js / api.js) ├── src/client/index.tsx # client 源码(双端才需要) ├── build.mjs # 打包 client → lib/client.js ├── lib/client.js # 构建产物,随仓库提交 ├── cordis.patch.yml # 装配自己(bundle 补丁层) └── package.json ``` ## 2. package.json 元数据约定(最容易漏的一步) ```jsonc { "name": "my-plugin", "version": "0.1.0", "type": "module", "main": "index.js", // host 入口 "exports": { ".": "./index.js", "./client": "./lib/client.js", // ← client 端入口必须导出 "./client" "./cordis.patch.yml": "./cordis.patch.yml" }, "engines": { "node": ">=22" }, "dsh": { "client": { // ← 有 UI 才需要 "platform": "web", "inject": [ // client 侧依赖的客户端服务 "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale" ] }, "bundle": { "patch": "./cordis.patch.yml" } // ← 安装时自动应用这个补丁层 } } ``` - `exports["./client"]` 与 `dsh.client` 缺任何一个,设置页面板都不会挂载(host 照常工作)。 - `cordis.patch.yml` 里声明自己,安装(把包名加进 profile 的 `dsh.profile.bundles`)后由 bundles 自动应用: ```yaml - insert: - id: my-plugin # 插件 id(同时是 loader entry id) name: my-plugin # 包名 config: enabled: true # 自己的装配开关,apply 里读 config.enabled ``` ## 3. host 侧:三条链路 完整可复制片段见仓库根 [README 的「与宿主 API 的对接点」](../README.md#与宿主-api-的对接点)。要点: ```js export const name = 'my-plugin' export const inject = ['systemPrompt'] // 必需服务;缺它插件不激活 export function apply(ctx, config) { if (config?.enabled === false) return // 注入:注册返回 disposer,交给 ctx.effect 才能在卸载/热重载时撤销 ctx.effect(() => ctx.systemPrompt.section({ name: 'my-plugin', order: 555, text: '……' }), 'my-plugin: section') // 工具、路由:可选服务用 ctx.inject 等就绪,不要同步 get ctx.inject(['tools'], (child) => { /* child.get('tools').register({...}) */ }) ctx.inject(['webServer'], (child) => { /* child.get('webServer').register({ kind:'prefix', path, handler }) */ }) } ``` 三条硬规则: 1. **必需服务**写在 `export const inject = [...]`;**可选服务**(可能永远不存在,例如没有 web 服务的 profile)用 `ctx.inject([...], child => …)`,回调里再 `child.get(name)`,并判 `undefined`——服务缺失时插件必须照常可用。 2. **一切注册都包在 `ctx.effect(fn, '标签')` 里**,`fn` 返回撤销函数。否则热重载/卸载会留下残留的 prompt 段、工具或路由。 3. **服务用字符串名取**,可以不 import 任何 `@deepseek-ai/*`。这样包在任何 profile 里都不会因为依赖解析失败而加载不了;代价是字段形状要自己对齐(`tools.register` 只校验 `parameters` + `output.schema` + `output.render`)。 ## 4. client 侧:源码约定 client 入口只被宿主消费两个导出:`inject` 与 `apply`(本插件额外导出了 `Panel`、`__internals` 供测试,无副作用)。 ```ts import React from 'react' const NS = 'my-plugin' const API = '/my-plugin/api' // client 侧服务:slots(注册界面)与 locale(i18n) export const inject = ['slots', 'locale'] export const ZH = { title: '我的插件', save: '保存' } export const EN = { title: 'My Plugin', save: 'Save' } export function apply(ctx: any) { // i18n 字典:zh 必给,en 缺失时宿主回退 zh ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'my-plugin: locale') const bound = typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : null const t = (key: string) => (bound && bound(key)) || (ZH as any)[key] || key // 注册一块设置页 section ctx.effect( () => ctx.slots.inject('settings.section', () => ctx.slots.register( { name: 'settings.section', id: NS, order: 30, label: () => t('title'), locale: NS, inject: () => ({}) }, () => React.createElement(Panel, { t }), ), ), 'my-plugin: settings section', ) } ``` 约定与建议: - `slots.inject(slotName, () => slots.register(定义, 渲染函数))`:`inject` 负责「槽位就绪后再注册」,别绕过它。 - `id` 在同一个 slot 内必须唯一;`order` 决定排序(本插件用 `30`,排在通用设置之后)。 - **样式内联**:用 `