# 「增强提示词」插件(dsh-prompt-boost-pro)实现方案
状态:**设计稿 + 代码骨架**(本轮不安装、不构建、不重启运行实例)
产物:本文件(方案)+ `D:\deepseek-harness-work\dsh-prompt-boost\`(可落地的骨架工程)
---
## 1. 需求确认(本轮问答结论)
| 议题 | 结论 |
|---|---|
| 图标位置 | 用**现成** `conversation.input.left` 插槽 → 落在**权限控制之后**(工具行左组,`.modes` 之后;零核心改动)。
历史:最初按"模型选择器旁边"选了 `conversation.input.right`,但那落在模型座位**左侧**;用户改为"权限控制后面",即本方案 |
| 增强模式 | 提供**两个选项让用户选**:① 结构化增强 ② 轻度增强 |
| 结果落地 | 弹出**对比预览**:接受 / 换模式重试 / 放弃 |
| 模型路由 | **跟随当前会话模型**:四级回退 —— 插件配置 → 客户端上报的界面选型 → 会话最近一次 `request/header` → 部署默认模型(`ctx.agentDefaultModel`) |
| 本轮交付 | 只出方案与代码骨架;不装进 profile、不重启、不碰运行环境 |
---
## 2. 运行环境事实(已核实,决定了实现路径)
- GUI 由 `D:\deepseek-harness\start-dsh.ps1`(`start-dsh.bat` 调用)以 `node --import tsx/esm apps/cli/src/bin.ts web --port 3080` 启动;
DshRoot = `D:\deepseek-harness`,WorkDir = `D:\deepseek-harness-work`,日志 `D:\deepseek-harness\logs\dsh-web-3080.log`。
- 运行的是 **profile `web`**:`C:\Users\18202770540\.dsh\profiles\web`(`package.json` 里 `dsh.profile.bundles` 已挂 dshmarket、dsh-at-file、@nanmicoder/dsh-agent-teams 等第三方组合包)。
- 因此:**插件必须做成独立「组合包(bundle)」**,用 `dsh plugin --profile web add` 装进 profile;**不需要改 DSH 核心源码,也不需要重编 Web 应用**(客户端模块系统会扫描启用了 `dsh.client` 的 Loader 行并提供其 `./client` 产物)。
- 关键契约位置(均已读源确认):
- 输入框工具行 DOM 顺序:左组 `.tools` = `+` 按钮 → `.modes`(权限控制 + `conversation.input.plan`)→ `conversation.input.left`;
右组 `.trailing` = `conversation.input.right` → `conversation.input.model`(模型座位)→ ContextMeter → 发送按钮
(`packages/client/ui-conversation/src/client/skeleton/InputBar.tsx:487-524`)。
- 因此"权限控制后面"= `conversation.input.left`;本机该插槽已有 `@michengai/dsh-agency-agents` 的「召唤专家」(`order: 0`),
本插件用 `order: -10` 排在它之前、紧贴权限控制(`input.right` 则已有 `@kenz1117/dsh-ui-usage-billing`)。
- 插槽声明:`packages/client/ui-conversation/src/client/contract/slots.ts:166-186`;`conversation.input.right` = `{ kind: 'list', scope: 'session' }`。
- 会话作用域标准 props(`useInput` / `inputActions` / `useConversation`):同上 `slots.ts:194-201`。
- 草稿读写:`InputState.draft` 读;`InputActions.setDraft(text)` 写(`contract/input.ts:238-249`)。
- 图标:插件自带 `src/client/icons.tsx` 的 `IconSparkleOutline16`(描边四角星 + 右上实心小星,16px viewBox、随 `currentColor`)。
核心 `ui-primitives` 里的 `IconEnhanceOutline16` 是四根横线(像"文本/左对齐"),语义不如 sparkle,且外部插件不应为一个图标改核心,故自带。
- 宿主一次性 LLM 调用:`ctx.llm.stream({ provider, model, messages, system, maxTokens, signal })` + `BlockAssembler`
(模板:`packages/session/session-title-llm/src/index.ts:231-296`)。
- 会话当前模型:`agent.session.requestHeader()?.config.{provider,model}`(`packages/core/session/src/index.ts:776`)——
但**新会话没有这个值**,所以还必须能退到部署默认模型:`ctx.agentDefaultModel.currentSelection()`(`packages/core/agent-default-model`;
模型座位在新会话里显示的正是它,见 `packages/api/session-controller/src/catalog.ts:18`)。
- 界面当前选型:会话投影 `modelSelection`(`{ next, lastUsed }`),客户端可直接 `useProjection('modelSelection')` 读取。
- 宿主↔浏览器通道:`ctx.connection.fetch.register({ path, methods, requestBody, fetch })`,路径必须在 `/api` 下、按 `ENDPOINT_SEGMENT_PATTERN` 合法,
且先过 Host/Origin + 浏览器 cookie 鉴权(`packages/client/connection/src/rpc-host.ts:96-156, 266-302`)。
- 观测源形状:`ObservableSnapshot = { getSnapshot(): T; subscribe(fn: () => void): () => void }`
(`packages/client/store/src/contract.ts:4-12`,手写模板见 `packages/client/ui-goal/src/client/activation-source.ts`)。
- 插件配置:cordis `resolveConfig` 在插件**没有**导出 `Config` schema 时**原样透传** config(`vendor/cordis/src/fiber.ts:50-62`)。
---
## 3. 架构
一个包,两个半边,一条同源路由:
```
┌────────────────────────── 浏览器(Client half, /plugins/dsh-prompt-boost-pro/client.js)──────────────────────────┐
│ conversation.input.left 入口 (order -10 → 权限控制之后、召唤专家之前) │
│ PromptBoostButton ──点击──▶ 模式菜单(结构化 / 轻度) │
│ │ │ │
│ │ useInput(s => s.draft) │ run(mode, draftSnapshot) │
│ ▼ ▼ │
│ PromptBoostSurface(每会话一个,手写 ObservableSnapshot:idle→menu→running→preview→applied / error) │
│ │ │ │
│ │ useBoost(selector) │ fetch POST /api/prompt-boost-pro/enhance │
│ ▼ ▼ (same-origin, cookie 鉴权, JSON) │
│ conversation.input.overlay 入口 (弹窗锚在输入卡片上方) │
│ PromptBoostDialog 原文 / 增强 对照 + 接受 · 换模式重试 · 放弃 │
│ └── 接受 ──▶ inputActions.setDraft(enhanced)(并记下 original 供一键撤销) │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
│
┌────────────────────────────────────────────▼────────────────── 宿主(Host half, lib/index.js)─────────────────┐
│ ctx.connection.fetch.register('/api/prompt-boost-pro/enhance', POST, buffered) │
│ 1) 校验 body { sessionId, draft, mode }、长度上限 │
│ 2) 解析路由:config.provider/model → agent.session.requestHeader().config │
│ 3) 组装 system(按模式)+ user(草稿)→ ctx.llm.stream(...)(AbortSignal.any[请求, 超时]) │
│ 4) BlockAssembler 收文本 → 清洗(去围栏/去前缀/去尾随解释)→ Response.json({ ok: true, text, route }) │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
**为什么不用 Typert Remote**:新增 Remote 命名空间需要 `./remote` + `./typert` 产物与代码生成,对树外插件是额外构建负担;
`ctx.connection.fetch.register` 是同源、已鉴权的既有一等通道(file-upload 的原始字节路由走的就是它),零代码生成、可流式、可传大文本。
---
## 4. 交互设计
### 4.1 按钮状态机
| 状态 | 图标表现 | 点击行为 |
|---|---|---|
| `idle` | 常态(自带 sparkle 图标,tooltip「增强提示词」) | 打开模式菜单 |
| `menu` | 高亮 | 选「结构化增强」或「轻度增强」;点外部/Esc 关闭 |
| `running` | 三点脉冲 + 禁用(`opacity: .85`,不被禁用态压掉) | 无(请求中) |
| `preview` | 常态 | 再次打开模式菜单(= 重新发起,可换模式) |
| `applied` | **仅当草稿此刻仍等于增强结果**时才显示「撤销增强」形态 | 还原为原文;条件不成立时这个形态根本不出现(发送清空草稿、或手改后,自动回到常态) |
| `error` | 告警形态(tooltip 显示错误) | 重新打开模式菜单 |
禁用条件(任一):`useInput(s => s.draft).trim() === ''`、`s.phase !== 'plain'`(adjudicating/claimed/submitting 时不与提交管线抢草稿)、已有 in-flight 请求。
**例外**:菜单打开时按钮**保持可用**——它是切换开关,禁用会让菜单失去这条关闭途径(第一版就是这么卡死的)。
#### 「撤销增强」的寿命(缺陷修复记录)
撤销**不是**一次性状态残留,而是**由草稿实时推导**的能力:`applied && samePromptText(draft, enhanced)`。
第一版把 `applied` 当作常驻状态,于是**增强结果发出去(草稿被清空)之后** tooltip 仍写着「撤销增强」,
点下去只会得到「草稿已被手动修改,无法一键撤销」的错误——正是用户报告的第二个 bug。
- 比对用 `samePromptText`(`src/contract.ts`):只容忍编辑器侧的换行与首尾空白差异;
任何实质编辑(包括发送后的清空)都让撤销入口消失。
- 因此:接受后=「撤销增强」;发送后/清空后=「增强提示词」(草稿为空时按钮禁用);手改后=「增强提示词」。
- `surface.undo()` 用同一函数做守卫,天然与 UI 判据一致。
#### 菜单的关闭途径(缺一不可)
| 途径 | 实现 | 是否依赖按钮可用 |
|---|---|---|
| 再点图标(切换) | `onClick` 的 `menuOpen` 分支 | 是(故有上面的例外) |
| 点菜单外部 | 菜单打开时挂 `document` 捕获阶段 `pointerdown`,目标在锚点之外即关 | 否 |
| 按 Esc | 同上的 keydown(捕获)监听 `Escape` | 否 |
| 选中某个模式 | `pick()` 先关菜单再发起 | 否 |
预览弹窗同理由 `Escape` 关闭;**不**做点外部关闭(避免误丢用户正在阅读的结果),页脚始终有显式的「放弃 / 关闭」。
### 4.2 对比预览弹窗
- 落点:`conversation.input.overlay`(输入卡片内的浮层,`bottom: 100%` 锚在卡片上方)。
- 内容:左「原文」/ 右「增强后」(窄屏上下堆叠),各自可滚动(`max-height` 复用 `--dsh-composer-text-max-height` 同量级)。
- 页脚:`接受并替换`(主)/ `换模式重试`(结构化⇄轻度)/ `复制` / `放弃`;右上角显示本次使用的 `provider · model`。
- 陈旧保护:弹窗打开期间若草稿被改动(`draft !== original`),页脚提示「原文已变化」,默认焦点落在「放弃」,接受按钮文案变为「以增强结果覆盖当前草稿」。
- 失败:同一弹窗切换为错误态,保留原文,提供「重试」与「关闭」。
### 4.3 边界与并发
- 每会话只允许一个 in-flight(surface 内 `AbortController`,新请求/会话切换/插件卸载都会 abort)。
- 会话切换(`sessionId` 变化)→ 新 surface;旧 surface 走 `dispose()`。
- 多标签页各自持 surface,宿主无状态,无冲突。
- 不做键盘快捷键(留作后续可选项,避免与输入框回车/斜杠菜单抢键)。
---
## 5. 两种模式的提示词策略
共同规则:**只输出改写后的提示词本体**,不解释、不寒暄、不加「以下是」、不套 Markdown 代码围栏;语言与原文一致(中文进中文出);不得虚构事实——不确定就要求执行者标注置信度与验证方式。
### 5.0 「不要向用户要信息」——一次缺陷修正(重要)
第一版规则写着「未提供的关键信息用 `[待补充:…]` 占位」并要求覆盖五个固定小节。在**一句话草稿**上,
模型于是把每个自己不知道的字段都标成待补充,产出的是**一张填表**而不是更好的提示词:
```
原文:你把代码在审查一遍看看会不会有什么问题
第一版产出(节选):
【背景与上下文】本次是复审(非首次审查)。[待补充:上次审查的结论/已修复的问题清单]
[待补充:项目技术栈、语言与框架版本、运行环境]
【输入与约束】输入(待审查对象):[待补充:仓库地址/文件路径/…]
约束:- 审查范围限定为 [待补充:指定文件、模块或提交区间],范围外内容不评。
…
```
问题不止是啰嗦,更是**场景错配**:本插件服务于**编码 agent**,仓库结构、技术栈、改动范围、历史结论
它自己就能查。把这些甩回给用户,等于让 agent 偷懒,还把负担转嫁给用户。
修正后的规则(`src/enhance.ts` 的 `NO_INTERROGATION`,两个模式共用):
| 规则 | 说明 |
|---|---|
| 可查明的信息 → 执行者的动作 | 例如"先按最近一次提交确定改动范围并说明范围",而不是 `[待补充]` |
| `[待补充:…]` **整段最多 1 处** | 且只用于"只有用户能拍板、猜错代价高"的事;没有就一处都不写 |
| 可给默认值的直接给默认值 | 并注明这是默认假设,例如"只读审查、不改代码;如需改动另行说明" |
| 宁可少写一节,也不要写"待补充" | 结构与篇幅服务于任务,不套模板 |
同一草稿在新规则下的产出(无占位符、可直接执行):
```
复审一遍代码,确认是否还有问题,并给出结论。
范围:以我指定的为准;若未指定,先按最近一次提交的 diff 确定范围,并在开头一行说明你实际审了哪些文件。
要求:
- 只读审查,不改代码;问题位置用 文件:行号 标出。
- 覆盖正确性、边界条件与错误处理;风格偏好单独归一节,不与缺陷混在一起。
- 每条给出:位置 / 问题 / 严重级别(高·中·低)/ 触发条件 / 建议改法。
- 上次已确认修复的不再重复;认为修得不彻底就单独指出并说明依据。
- 拿不准的标注置信度。
输出:问题清单 + 总体结论(是否存在阻塞性问题)。
```
回归:`tests/enhance-rules.mjs` 把上述策略约束钉死(27 项),防止后人调 prompt 时把"占位符上限"
或"不许反问用户"改没了。
### 5.1 结构化增强(`structure`)
面向「把一句话变成可执行的任务说明」,但**结构按需**:
- 只写对这次任务真正有用的小节,没有内容的小节整节省略,不留空标题;
- 篇幅与草稿相称:一句话的草稿 ≈ 6~10 行紧凑说明,信息丰富的草稿才展开;
- 用祈使句写给执行者,不是问句清单;
- 期望输出至少交代:产出形式、粒度、是否需要结论/风险分级。
### 5.2 轻度增强(`light`)
只做澄清与收紧:去歧义、补指代、纠错别字、把口语理顺、必要时补一句输出格式要求;**长度与原文接近(±30%)**,
保留作者语气,不改变任务规模;同样不得用 `[待补充:…]` 反问用户。
(两条 system prompt 都在 `src/enhance.ts` 里以常量形式给出;宿主半边把它们一并导出(`SYSTEM_PROMPTS`),
便于离线回归与线上排查。)
---
## 6. HTTP 契约
`POST /api/prompt-boost-pro/enhance`(`Content-Type: application/json`,同源 cookie 鉴权)
请求:
```json
{ "sessionId": "session-…", "draft": "帮我把这段代码改快一点", "mode": "structure" }
```
成功 `200`:
```json
{ "ok": true, "mode": "structure", "text": "【目标】…", "route": { "provider": "deepseek-official", "model": "deepseek-v4-flash" } }
```
输出撞到长度上限时**仍然是成功**,只多一个标记(文本可能被截断,由用户在对比预览里判断):
```json
{ "ok": true, "mode": "structure", "text": "【目标】…(被截断)", "truncated": true, "route": { … } }
```
失败(`400` / `409` / `502` / `504`):
```json
{ "ok": false, "error": { "code": "no-route", "message": "当前会话还没有可复用的模型路由,请先在插件配置里指定 provider/model" } }
```
错误码表:
| code | HTTP | 触发 |
|---|---|---|
| `invalid-body` | 400 | body 不是 JSON / 字段缺失 / mode 非法 |
| `empty-draft` | 400 | draft 去空白为空 |
| `draft-too-long` | 413 | draft 超过 `maxInputBytes` |
| `session-unavailable` | 409 | 该 sessionId 当前没有 live agent(文案同时给出"选模型 / 发一条消息 / 配 provider+model"三条出路) |
| `no-route` | 409 | 四级回退都没拿到路由 |
| `timeout` | 504 | 超过 `timeoutMs` |
| `llm-failed` | 502 | 适配器抛错 / finish 为 `error`(透出真实失败原因)·`aborted`·`tool-calls` |
| `empty-output` | 502 | 清洗后为空(含 `max-tokens` 但一个字都没产出,文案会说明可换模式/拆小草稿) |
> 注:`finish: max-tokens` **不再**是 `llm-failed`——只要已有文本就按成功返回并带 `truncated` 标记;
> 第一版把整段产出丢掉只回一句「模型未正常结束:max-tokens」,是本插件最糟的一次失败处理。
---
## 7. 宿主侧实现要点
1. **注册路由**(在 `apply` 内用 `ctx.effect` 持有生命周期):
`ctx.connection.fetch.register({ path: ENHANCE_PATH, methods: ['POST'], requestBody: 'buffered', fetch })`。
路径段用 `prompt-boost-pro`,与其它插件(如 `/api/session/uploadFileBinary`)不冲突。
**同时注册旧路径**(`LEGACY_ENHANCE_PATHS = ['/api/prompt-boost/enhance']`,改名前的线路径):
宿主半边与浏览器半边可能不同步——升级后"只刷新页面没重启宿主"(新 bundle 打新路径、旧宿主只认旧路径)
或"只重启宿主没刷新页面"(新宿主、页面里还是旧 bundle)。两条都注册、客户端在 404/405 时回退,
半同步状态就不会变成用户看到的「增强失败(HTTP 404)」。这条是 0.1.1 的实际缺陷修复记录:
改名发布后用户在未刷新的页面上点增强,正是撞上了这个 404。
2. **模型路由解析**(`src/route.ts`)——四级回退,顺序即优先级:
1. `config.provider` + `config.model`(部署用 patch 固定成便宜模型);
2. **请求体里的 `provider`/`model`**:客户端把界面模型座位此刻显示的选型(`modelSelection` 投影的 `next ?? lastUsed`)一起发上来;
3. `ctx.agents.get(...)?.session.requestHeader()?.config`:本会话上一次请求实际用的(本次会话已有历史时最准);
4. `ctx.get('agentDefaultModel')?.currentSelection()`:**部署默认模型**——新会话没发过请求时就是它(模型座位显示的也是它)。
四级都没有才报错,且文案给出三条出路(选模型 / 发一条消息 / 配 provider+model)。
> 缺陷修复记录:第 4 条是后补的。第一版只有第 1、3 条,于是**新会话里第一次点增强必然失败**
> ("会话还没有发过请求")——而那恰恰是最需要增强的时刻;用户就是这么撞上的。
> 第 2 条同时补上,用于"刚换了模型、还没来得及发消息"的情况,让增强用的模型与界面显示一致。
3. **调用**:`messages = [createUserMessage({ content: [{ type: 'text', text: draft }], source: { kind: 'plugin', plugin: 'dsh-prompt-boost-pro' } })]`,
`system` 用模式提示词,`maxTokens` 由 `resolveMaxOutputTokens(config, draft)` 求出;
`signal = AbortSignal.any([request.signal, AbortSignal.timeout(timeoutMs)])`;
**不传 `purpose`**(`GenerateOptions.purpose` 是闭合联合 `'compaction' | 'session-title'`,见 `packages/llm/llm/src/types.ts:458`;辅助调用留空即可)。
**推理档位要跟着路由走**:`GenerateOptions.reasoningEffort` 缺省时 LLM 层取模型档案的
`defaultEffort`——"始终思考"的模型(GLM-5.3-Flash)默认是 `off`,上游直接回
`400 {"code":"1210","message":"该模型始终思考,不支持关闭思考;请使用 low、high 或 max。"}`。
修复(0.1.2):客户端把界面选型里的档位一并上报(`PromptBoostRouteHint.reasoningEffort`),
会话日志的 `request/header.config.reasoningEffort` 也参与第三级回退;部署可用
`config.reasoningEffort` 钉死。档位**跟随各自来源**(配置钉死的路由只认配置档位),
避免把 A 模型的档位塞给 B 模型;宿主用 `ReasoningEffortId(...)` 打上品牌后传给 `llm.stream`。
4. **输出额度自适应**:`min(8192, max(config.maxOutputTokens, ceil(len(draft)×1.5) + 1200))`。
两个教训都在这里:默认 1600 太小;而且**推理 token 也算这个额度**——会话把推理等级设为 `High` 时,
长草稿几乎必然撞顶。配置值只当**下限**,硬上限 8192(超过模型自身输出上限会直接报错,所以不敢放开)。
5. **装配与收尾**:`BlockAssembler` 累积 →
拒收 `tool-call` 块(模型跑偏去调工具)→ 拼接 text → 去代码围栏、去「以下是…:」类前缀、压缩空行、`trim()`。
`finish` 的处理分三类:
- `stop` → 正常成功;
- `max-tokens` → **有文本就按成功返回并带 `truncated: true`**(弹窗提示可能不完整);一个字都没有才报 `empty-output`;
- `error` / `aborted` / `tool-calls` → 失败,但文案由 `describeFinish()` 说人话(透出适配器真实原因、或提示可重试),
不再把 `max-tokens` 这种术语直接抛给用户。
6. **长度与超时**:`maxInputBytes` 默认 16000(按 UTF-8 字节算);`timeoutMs` 默认 30000;`maxOutputTokens` 默认 4000(下限)。
7. **配置校验**:插件不导出 `Config` schema(cordis 会原样透传),由 `src/config.ts` 手写严格校验(未知键即抛错),与 `session-title-llm` 的手写风格一致。
若想获得 Loader 级校验,可改为导出 `@deepseek-ai/schemastery` 的 `Config`。
---
## 8. 客户端实现要点
1. **注册两个入口**(`src/client/index.ts`):
```ts
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
name: 'conversation.input.left', id: 'prompt-boost-pro', order: -10, locale: NS,
inject: (sessionId) => surfaceFor(sessionId).buttonFace(),
}, PromptBoostButton))
ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
name: 'conversation.input.overlay', id: 'prompt-boost-pro-dialog', order: 20, locale: NS,
inject: (sessionId) => surfaceFor(sessionId).dialogFace(),
}, PromptBoostDialog))
```
`inject` face 里的 `hooks: { boost }` 会被框架绑定成组件 props 上的 `useBoost` 选择器钩子(`ui-slots/src/index.ts:433-471`)。
2. **每会话 surface**:手写 `ObservableSnapshot`(`getSnapshot` / `subscribe`),模板见 `ui-goal/src/client/activation-source.ts`。
3. **草稿读写**:按钮/弹窗通过 `useInput(s => s.draft)` 读、`inputActions.setDraft(text)` 写;surface 自身不碰编辑器,避免与 Lexical 编辑器身份耦合。
4. **陈旧保护**:`run()` 记录 `original` 快照;`accept(current)` 由组件传入当前草稿,surface 只负责状态迁移,是否覆盖由弹窗 UI 明示。
5. **i18n**:`declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { 'prompt-boost-pro': PromptBoostKey } }`,`ctx.effect(() => ctx.locale.register(NS, { zh, en }))`。
6. **样式**:本骨架不引入 CSS Modules 工具链——`PromptBoost.css` 作为文本导入(esbuild `loader: { '.css': 'text' }`),在 `apply` 里注入一个 `