# better-webui 开发记录(dsh 插件机制手册 + v0.5→v0.26 历史日志) > **v0.14 起**:本插件已重构为 monorepo(元包 + 5 个功能小包)。本文的 `src/host.js`、 > `src/client.bundle.js`、`tests/*.mjs` 等单包描述已成为历史布局;当前布局、设计模式、 > 维护规则见 [docs/manual/monorepo.md](monorepo.md),构建/测试命令见根 README「开发」。 > 本文仍保留为机制与裁决的历史依据。 > > **v0.27 工程清理**:本轮删除了下述"写了但没接线/用户没要求"的投机代码(详见根 > README 与各包 README)——`similarityFn`/`cosine`/`minLineLength`(repeater-detect > 的假想 seq2vec seam)、`mode:'hot'`(modelparams 热调,照抄参考插件)、REST/API-key > 路径(search,用户只要免密钥)、repeater-detect 的旧命名空间迁移、retry / > modelparams / repeater-detect 客户端的 stale 握手 UI;并把 retry / repeater-detect > 的「恢复默认」按钮真正接到宿主 reset。下文涉及这些功能的旧记录仅作历史,不再反映 > 现行行为。 > > **v0.28 变更(2026-08-27)**:repeater-detect 的"思考跳过"规则反转 —— 用户裁决 > "repeat detection should not skip thinks, it will check thinks for avoiding > constant thinkings"。现正文与思考(原生 `reasoning-delta` + 可见 > ``/`` 块内容)都计数,模型循环思考也会被截停;仅代码围栏与 > `tool-call-delta` 不计数。另接入新功能包 context-archive(会话上下文归档)。详见 > [docs/discussion/repeater-detect.md](../discussion/repeater-detect.md) v0.28 节与 > [docs/task/01-context-archive.md](../task/01-context-archive.md)。下文 v0.25 关于 > "思考不计"的旧记录仅作历史,不再反映现行行为。 这份记录面向两件事:**恢复现场**(服务器、profile、热加载链路)与**未来插件编写**(哪些机制可用、契约长什么样、坑在哪)。文中所有结论均已在 2026-08-17/18 的实机上验证或从 harness 源码直接读出(v0.5 针对 dsh 0.1.0-rc.7 复核)。 --- ## 1. 部署拓扑(恢复现场用) | 项 | 值 | |---|---| | 运行中的服务 | `dsh web`,PID 由 `ps aux | grep "dsh web"` 找,监听 `http://127.0.0.1:3080` | | 服务实际 home | `DSH_HOME=/home/archie/.dsh`(注意:工作区里的 `.dsh-better/` 不是本服务的 home,是历史试验残留) | | 实际使用的 profile | `/home/archie/.dsh/profiles/web/`,`package.json` 的 `dsh.profile.bundles` 是 `dsh-base` + `dsh-web-app` + `dsh-coteam` + `dsh-better-webui`(v0.21 拆包后共 8 个小包) | | 插件安装方式 | 优先用 **dsh 指令**:`dsh plugin --profile web add file:/home/archie/forge/dsh-better-webui`(本地开发必须 `file:`,不要 `link:`;见下方操作经验) | | 插件源码 | `/home/archie/forge/dsh-better-webui`(本仓库) | | 插件回收站数据 | `$DSH_HOME/better-webui/trash/`(`trash.json` 索引 + 每会话一个目录) | | dsh 源码(只读参考) | `/home/archie/forge/deepseek-harness` | | 全局安装的 dsh | `/home/archie/.nvm/versions/node/v24.18.0/lib/node_modules/@deepseek-ai/dsh/`(运行的正是它,不是源码 checkout) | **关键点**:当前 3080 服务是全局安装的 dsh,profile 里通过 `file:` 依赖把元包及 8 个小包装进 `profiles/web/node_modules`。插件源码改动后需要**重启 `dsh web`**(node half 是启动时加载的),client bundle 改动则会被 stat-poll 热加载(见 §3)。 ### 操作经验(2026-08) - **优先用 dsh 指令添加包**:直接编辑 `package.json` 容易漏掉 `bundles` / 依赖形状,统一走 ```sh dsh plugin --profile web add # 已发布包,或 dsh plugin --profile web add file:/abs/path # 本地开发(会装齐传递依赖) dsh plugin --profile web remove # 卸载 ``` `dsh plugin` 会把剩余参数转发给 profile 目录里的 pnpm。 - **`file:` 没有热加载,仓库改了要每次 rm/add**:本 profile 用 `file:`(不用 `link:`, 因为 `link:` 不装 8 个小包的传递依赖,v0.21 拆出 `better-webui-retry` 后启动即 `ERR_MODULE_NOT_FOUND`)。`file:` 的代价是 pnpm 把它钉进 lockfile——**仓库里新增 依赖/新小包/改 `package.json` 后,profile 的 `node_modules` 不会自动跟着变**,要 `dsh plugin --profile web remove @blueriverlhr/dsh-better-webui` 再 `add` 一次(或 在 profile 目录重跑 `CI=true pnpm install --no-frozen-lockfile`)才能拿到更新。 `lib/` 与 `cordis.patch.yml` 已提交进 git,所以**改完仓库记得先 `npm run build` 再 rm/add**,否则装的是旧产物。 ### 重启命令模板 ```sh # 杀掉旧进程后,在用户 shell 里重新拉起: dsh web # 默认 127.0.0.1:3080 ``` 注意:dsh web 是前台进程(`Sl+` 状态),由用户终端持有;agent 沙箱里重启的实例可能落在不同 DSH_HOME。**重启操作请交给用户做**,或明确设置 `DSH_HOME=/home/archie/.dsh`。 --- ## 2. 插件双面结构(dsh 插件模型) dsh 插件是 **npm 包 + 两个 half**: ``` better-webui/ package.json # 声明 dsh.bundle.patch 与 dsh.client;devDependencies 仅测试用 cordis.patch.yml # 向 profile 插入 host 行 build.mjs # 一条命令产出两个产物(无需 tsc) src/ host.js # host half:函数插件(export inject / apply),复制到 lib/index.js client.bundle.js # client half 源码(纯 JS,被包裹后产出 lib/client.js) lib/ index.js # host 产物(Node 加载) client.js # client 产物(浏览器加载) tests/ smoke.mjs # jsdom 集成测试(驱动构建产物,23 项断言) primitives-stub.mjs ``` ### package.json 关键字段 ```json { "main": "lib/index.js", "exports": { ".": "./lib/index.js", "./client": "./lib/client.js", "./cordis.patch.yml": "./cordis.patch.yml" }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { "inject": ["@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-slots"], "platform": "web" } } } ``` - `dsh.bundle.patch` → host 行插入 profile(cordis loader)。 - `dsh.client` → client-modules registry 识别本包有浏览器 half,服务 `/plugins//client.js`。 - `dsh.client.inject` 只是**信息性**的(预取/HMR diff 用),不决定 apply 顺序;apply 顺序由 cordis fiber 的 service inject 决定。 ### cordis.patch.yml(host 行) ```yaml - insert: - id: dsh-better-webui name: '@blueriverlhr/dsh-better-webui' ``` host half 以函数插件(`export const inject` / `export function apply`)形式加载;class 插件(default export Service)也支持,但函数形态免编译、生命周期干净,本插件用它。 --- ## 3. client bundle 格式(lib/client.js) 浏览器侧**不是普通 ESM**,是这个信封: ```js window.__ModuleLoader__.load({ id: '@blueriverlhr/dsh-better-webui', factory: (require) => { var module = { exports: {} }; var exports = module.exports; // ...你的代码,exports.inject = [...] / exports.apply = function (ctx) {...} return module.exports; } }); ``` - **factory 是工厂形态的 CJS**(`ClientModuleSystem.materialize` 的契约):`require` 是唯一参数,**factory 的返回值就是模块导出**;体内必须自带 `var module = { exports: {} }; var exports = module.exports;` 前奏并以 `return module.exports;` 结尾。直接写裸的 `exports.foo = ...` 会抛 `exports is not defined`,整个浏览器 boot 失败(横幅 "Failed to load plugins")。build.mjs 已内置该前奏/结尾,`src/client.bundle.js` 只写主体即可。 - `require` 只认**平台静态表**:`react`, `react/jsx-runtime`, `react-dom`, `react-dom/client`, `@deepseek-ai/cordis`, `@deepseek-ai/dsh-client-ui-slots`, `@deepseek-ai/dsh-client-web-react`, `@deepseek-ai/dsh-client-ui-primitives`, `@deepseek-ai/dsh-client-ui-attachment`, `@deepseek-ai/dsh-client-schema-form`。 完整清单在 `packages/client/web/src/platform.ts` 的 `PLATFORM_MODULES`。 - **不能** require 其它插件包(跨插件值导入被禁),不能 import css 文件——样式用 `