# dsh-prompt-toolkit · 架构说明
> 面向要改这个仓库的人。使用说明见 [`../README.md`](../README.md)。
---
## 1. 设计原则
本插件的职责只有一项:**把输入框里的草稿整理成一条可直接执行的命令**。
以下三条不变量由该职责推导而来,是本仓库全部设计取舍的依据:
1. **输入框是内容的唯一来源。** 优化只读取它,不移动、不冻结、不代为发送。
2. **唯一的发送入口是面板中的「填入并发送」**,必须由使用者点击触发;
**流结束时不会发送任何内容**。
3. **丢弃是零副作用操作。** 它只丢弃一次后台计算的结果,不需要确认,也不需要补偿。
这三条决定了插件**不接管任何按键**,且**默认不执行任何操作**:
未点击胶囊左侧的 `✦` 时,插件不产生任何行为。
由于优化结果从不主动写回输入框(仅在按下「填入」时写入),
「丢弃」不需要回退,「重新生成」不需要保存草稿快照,因而没有状态机需要维护。
实现上必须遵守的约束:
- `fillAndSend()` 在无法取得官方发送链路时**不得伪装为已发送**:应如实提示,并把产出留在输入框。
- `finishRun()` 是 done / error / aborted / snapshot 四条路径的共同收尾,以 `finished` 保证幂等:
宿主对**已结束**的运行只发送一条快照便关闭流,因此 `done` 事件可能永远不会到达。
- 读取草稿必须使用 `editorTextOf()`,**不得使用 `innerText`**,见 §3。
---
## 2. 交互
### 2.1 两段式胶囊
胶囊由两段构成,以竖分隔线区分:
- 左侧 `✦`:读取当前草稿并执行优化,结果写入输入框(不发送)。
- 右侧「优化 · 普通 ▾」:显示当前档位;点击展开设置菜单
(档位 / 上下文 / 优化模型 / 查看产出 / 收藏夹 / 帮助)。
约束:
- **回车始终执行原生发送**,插件不接管按键,也不注册捕获监听。
- **优化默认关闭**:未点击 `✦` 时不执行任何操作。
- 两段为不同的动作,因此以 1px 竖线分隔,悬停时仅高亮指向的一段。
三级对比度取自该作用域内 DSH 已定义的 token(6% 底 / 14% 悬停半区 / 12% 分隔线),
深浅主题均随之变化;引用未定义的 token 会静默采用 fallback,主题切换后即失效。
- 「查看产出」位于设置菜单中,仅在存在产出(done / error)时出现。
面板在每次优化开始时由 `openOptimizeTab()` 自动展开,且面板本身即右侧栏的一个 tab,
因此胶囊上无需再设入口。
### 2.2 一次优化的数据流
```
使用者输入 输入框 = 内容的唯一来源(插件只读,不移动)
│
├─ 点击 ✦ ───────────► POST /run { 草稿快照, 档位, 上下文模式, 模型 }
│ │
│ ├─ 宿主流式执行(GET /stream,SSE 推送 reasoning / text / usage)
│ │
│ └─ 右侧栏面板显示:状态 · 产出(可编辑)· 思考 · 查证
│
└─ 使用者决定 ───────► 填入输入框 / 填入并发送 / 复制 / 收藏 / 重跑 / 丢弃
```
出口动作按"是否修改输入框"分为两组:左组(停止·丢弃 / 复制 / 收藏 / 收藏夹)不修改输入框,
右组(重新生成 / 填入输入框 / **填入并发送**)会写入输入框。
### 2.3 优化期间可以继续改草稿
由于结果从不主动写回输入框,草稿无需冻结,也不需要在校验"草稿是否被修改过"。
---
## 3. 读取输入框
DSH 的输入框是 **Lexical**:一行一个块(`
`),空行是 `
`。
下面两种直观读法都会得到错误结果:
| 读法 | 实际文本为 `A\n\nB` 时读出的内容 | 原因 |
|---|---|---|
| `innerText` | `A\n\n\n\n\nB` | 在**块与块之间**额外插入换行,使交给优化 AI 的草稿行距翻倍 |
| `textContent` | `AB` | 丢弃全部换行,多行内容被合并为一行 |
| `editorTextOf()` | `A\n\nB` ✓ | 逐块取文本(块内用 `innerText`,保住 Shift+Enter 的软换行),块之间只补一个 `\n` |
该函数为纯函数,并有单测覆盖(`test/client-chunks.test.mjs` 中以假节点直接求值对应分片)。
---
## 4. 模块边界
```
浏览器半边(src/client/** → esbuild → lib/client.js)
11 个分片按文件名排序拼接为一个模块,共享同一闭包作用域
00-deps 10-config 20-store 30-view 40-dom 60-capsule 70-favorites
75-msg-actions 80-run 85-panel 90-plugin
│
▼ 唯一的对外出口:/prompt-toolkit/api/*
────────────────────────────────────────────────────────────────────
│ HTTP(JSON + SSE)
▼
宿主半边(src/host/**,免构建,直接作为发布内容)
plugin.js 入口:仅负责装配
├── routes.js HTTP 面:state / favorites / models / run / stream /
│ trace / beacon / runs / run-abort / commands
├── live.js 一次运行 = 一条记录 + 若干 SSE 订阅者(可中止;有上限淘汰)
├── tools.js 只读工具循环(读文件 / 列目录),每一步记入 trace
├── llm.js 共享可变状态(llmState.lastRoute / optimizerCallDepth)
├── history.js 会话历史投影(读取上下文用)
├── context.js 项目结构摘要(目录树 + 关键文件)
├── settings.js 设置与落盘 + 平台设置命名空间镜像
├── favorites.js 收藏夹(独立文件;独立收藏夹插件在场时客户端让位)
├── beacon.js 客户端遥测落盘(写入 DSH_HOME,带上限截断)
├── root.js 自定位(定位包根;包名字面量的唯一出处)
├── util.js readJson / readBody
└── prompt/ ★ 提示词层:纯数据 + 纯函数,node --test 直接单测
rules · tiers · assemble · message · index
```
**关键设计**:提示词层是**纯函数**(`buildSystem(tier, {historyMode, projectContext}) → string`),
不访问 ctx、网络与 DOM,因此修改提示词后可直接以单测验证,无需启动浏览器。
客户端半边**必须是单个文件**:DSH 的客户端模块加载器只按**包名**解析依赖
(见 `@deepseek-ai/dsh-client-modules`),不接受相对路径。
宿主半边无此限制:标准 ESM 包,`main` 直接指向 `src/host/plugin.js`,**无需构建**。
---
## 5. 客户端为何采用拼接而非 ESM 依赖图
这些 UI 代码**共享同一个闭包作用域**(`store` / `emit` / `API` 以及组件之间互相调用)。
改为 ESM 会立即产生循环依赖与初始化顺序问题(`const` 的 TDZ 会实际触发错误),
因此分片按**文件名排序拼接**为一个模块:
- 前缀数字(00/10/20…)即作用域顺序,**新增分片必须插入正确的序号位置**;
- 分片之间没有 `import`,依赖作用域共享(函数声明会提升,故 `30-view` 可调用 `40-dom` 中定义的函数);
- 顺序与文件名由 `test/client-chunks.test.mjs` 固定;改名或增删分片会导致测试失败。
代价是无法 tree-shake,因此构建中**显式关闭** `treeShaking`:
本架构下写错的引用不会报错,esbuild 会将该符号视为未被使用,
连同整棵子树一并**静默删除**(构建退出码 0、无警告,直到页面运行时才抛出 ReferenceError)。
同一个测试文件里还断言了产物必须包含一批关键符号。
---
## 6. 目录结构
```
dsh-prompt-toolkit/
├─ package.json # main → src/host/plugin.js;exports["./client"] → lib/client.js
├─ cordis.patch.yml # bundle 层插入声明
├─ pnpm-workspace.yaml # allowBuilds: { esbuild: true }(pnpm 12 需要显式放行)
├─ LICENSE # 上游署名保留(BSD-3-Clause)
├─ src/
│ ├─ host/ # 宿主半边:免构建,直接发布
│ │ ├─ plugin.js routes.js live.js tools.js llm.js
│ │ ├─ history.js context.js settings.js favorites.js beacon.js
│ │ ├─ root.js util.js
│ │ └─ prompt/ # 纯函数提示词层(rules/tiers/assemble/message/index)
│ └─ client/ # 浏览器半边:11 个分片按文件名排序拼接成一个模块
│ ├─ 00-deps.js 10-config.js 20-store.js 30-view.js 40-dom.js
│ ├─ 60-capsule.js # 两段式胶囊 + 设置菜单 + 帮助
│ ├─ 70-favorites.js 75-msg-actions.js
│ ├─ 80-run.js # 整条运行链路:SSE、收尾、出口动作
│ ├─ 85-panel.js # 右侧栏面板外壳 + tab 注册
│ └─ 90-plugin.js # 样式表 + 挂载 + inject/apply
├─ lib/
│ └─ client.js # 构建产物(提交进仓库:安装方无需构建)
├─ tools/
│ └─ build-client.mjs # esbuild 打包 + 套 __ModuleLoader__ 外壳
├─ test/ # node --test,85 项,均无需浏览器
└─ docs/
└─ ARCHITECTURE.md # 本文
```
### 数据落在哪
| 文件 | 内容 |
|---|---|
| `$DSH_HOME/prompt-toolkit.json` | 设置(档位 / 模型 / 上下文 / 按会话的档位) |
| `$DSH_HOME/prompt-toolkit-favorites.json` | 收藏夹 |
| `$DSH_HOME/prompt-toolkit-beacon.jsonl` | 客户端动作遥测(用于排查;上限 512KB,超出后截断) |
运行期文件写入 `DSH_HOME`,**不写入插件目录**:安装进 profile 后插件目录可能只读。
---
## 7. 构建、测试与分发
```bash
pnpm install # 唯一的开发期依赖是 esbuild
pnpm build # src/client/** → lib/client.js
node --test # 85 项,均无需浏览器
```
**宿主半边无需构建**:`src/host/**` 即发布内容,`package.json` 的 `main` 直接指向它。
**客户端半边必须打包成单文件**:DSH 的客户端加载器只按包名解析依赖,不支持相对路径。
产物 `lib/client.js` **提交进仓库**,因此安装方与 git 来源的安装都不需要构建
(包内也没有 `prepare` 脚本,不会触发 pnpm 对 git 依赖构建脚本的拦截)。
### 安装流程
DSH 插件安装在某个 profile 中,没有全局安装这一层。`dsh plugin --profile <名字> <参数...>`
在 profile 目录内转发给 pnpm,并在成功后对账 `dsh.profile.bundles`:
```js
// @deepseek-ai/dsh/lib/plugin-Bk_PbPwP.js(转发与对账)
const result = spawnSync("pnpm", args, { cwd: dir, stdio: "inherit" })
if (exitCode === 0) reconcilePlugins(before, dir)
```
对账按**安装后的实际状态**进行,而不是按依赖差异:遍历 `dependencies`,凡是解析到的包
声明了 `dsh.bundle.patch` 就加入层栈,不再声明(被移除,或新版本去掉了声明)则退出层栈。
因此清单无需手工维护;`update` 也会让新版本中新增的 `dsh.bundle` 声明自动生效。
一个值得注意的实现细节:对账用 `resolveBundleDir` 解析依赖,**解析失败会被当作
"这个包不是 bundle"** 而返回 false,于是该依赖不会进入 `bundles`。
本地目录安装(`link:`)在目录被删除或改名后就会走到这条路径上 ——
条目仍是 enabled,状态却是 `unmounted`,页面无反应也无报错。
判断某个配置改动是否生效时,先确认这一点。
### bundle 层在 web 启动时入栈
因此安装或升级之后必须**重启该 profile 的 web**,刷新页面不生效。
---
## 8. 明确不做
- **不做**回车拦截(插件不接管任何按键)。
- **不做**自动发送(流结束时发送任何东西)。
- **不做**"撤销填入":结果仅在用户按下「填入」时写入,目前没有证据表明该动作需要撤销入口。
- **不做**基于浏览器的自检体系:提示词层是纯函数,`node --test` 即可验证,无需浏览器。
- **不做**多包拆分(收藏夹位于 `host/favorites.js` 与面板中的一节)。
---
## 9. 改这个仓库时的约定
1. **涉及读写的模块改动,必须跑 `node --test`。** 本仓库大量 `try/catch` 采用
"捕获错误、返回 fallback"的写法(`readJson`、`loadPluginState` 等),
遗漏 import 或写错标识符**不会报错、不会中断**,只会静默返回默认值,
表现为"用户设置被重置"。语法检查与"模块可加载"都无法发现这类问题。
2. **交互或 DOM 读取的改动,须在真实浏览器中断言最终结果**:断言对象是
"优化 AI 或工作 AI 实际收到的内容",而不是"某个函数被调用过"。
`innerText` 使换行数量翻倍这类问题,只能在比对最终输入内容时发现。
3. **`src/client/**` 的任何改动都必须重新构建并提交产物**:
测试会断言产物中包含一批关键符号。
4. **深浅主题都必须使用 DSH 已定义的 token。** 引用未定义的 CSS 变量不会报错,
会静默采用 fallback,主题切换后即失效。