# 「增强提示词」插件(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` 里注入一个 `