# dsh-plugin-choice-refresh 设计说明

作者向文档：方案、约束、边界。

## 1. 要解决的问题

DSH 的选择类工具（原生 `ask_user_question`、image-tools 的 `ask_user_choice`）
都是「模型给一批选项 → 用户只能选/跳过/放弃」。用户对选项不满意时只能：
打字说明（麻烦）、放弃后手动再问（打断流程）。本插件提供两个一键操作：

1. **refresh（重新生成选项）**：都不满意 → 换一批；
2. **more（更多选项）**：选项太少 → 补充到 6~10 个。

## 2. 为什么用「取消 + steer 注入」而非新工具 / 改服务端

- **不改核心包**：`ask_user_question` 注册在 preset 的 tools 层，同层重名注册会
  抛 `already registered`，无法覆盖；新增同名不同名工具模型也不会主动用。
- **不新增工具**：模型本来就会用 `ask_user_question` / `ask_user_choice`。
  用户点击后真正需要的是「让模型重新问一次」，而 `session.prompt(mode='steer')`
  正是客户端向 agent 注入用户消息、打断当前回合的标准通道。
- **协议闭环**：
  1. `pending.cancel()`：以「用户关闭」解除当前待答问题（与原生「放弃整组问题」
     同一 wire 编码 `{ok:false, error:{code:'cancelled'}}`）；
  2. `ctx.sessions.binding(sessionId).session.prompt([{type:'text',text}], 'steer')`
     （兜底 `ctx.remote.sessions.prompt({mode:'steer'})`）注入结构化指令；
  3. 模型在下一回合看到指令与上一组选项的上下文，重新调用提问工具。
- **对任何提问工具生效**：协议挂在交互层（question/requested 帧）而不是工具
  输出层，所以原生与 image-tools 的提问都被覆盖。

## 3. 客户端渲染策略：链条目全量接管（priority -300）

`conversation.composer` 是 chain 槽：多个条目按 `priority` 升序选举，第一个
`select` 返回非 null 者渲染（README 原话："first non-null return elects its
entry"）。要给所有选择卡加按钮，只有两条路：

- **DOM 注入**：MutationObserver 给卡片加按钮——拿不到 React 持有的
  `PendingWait`（`wait.respond`），无法解除问题，**否决**；
- **全量接管**：以 -300（先于 image-tools 的 -100 与原生 0）认领全部
  question 交互，自己渲染选择卡（含图片卡），并在页脚放两个增强按钮。

选择全量接管，代价是复刻原生卡片 UI（约 600 行，样式全走 `--dsw-*` 变量），
换来对交互载体的完全控制与对两种提问工具的覆盖。

### 边界处理

- **plan-review 意图放行**：`isPlanReviewBatch`（单题 + `intent.kind==='plan-review'`）
  时 `select` 返回 null，落回原生 `PlanReviewPanel`，避免把计划审阅卡降级成
  通用选项列表。
- **无选项的纯文本提问**：不显示两个增强按钮（没有选项可刷新/补充），
  渲染与原生一致的 textarea。
- **多题批**：刷新/补充作用于当前题，steer 文案附带整批题目清单，
  提示模型把其余题目一并重新询问（cancel 会整批解除，必须让模型重新问全）。
- **图片卡**：解析 image-tools 的 `<!--dsh-pick:v1:...-->` 标记，图片走其
  公开路由 `/dsh-plugin-image-tools/<pickId>/<index>`；路由 404 时降级为
  「图片加载失败」占位。未安装 image-tools 时无标记可解析，自动退化为纯文字。

## 4. 与 image-tools 的耦合边界

- 只耦合其**公开路由**与**标记格式**（`buildPickMarker`/`parsePickMarker` 的
  wire 形状），二者在 image-tools 源码与文档中固定；
- 不 `require('dsh-plugin-image-tools')`（客户端模块表禁止跨插件值导入），
  全部自包含；
- 若未来 image-tools 改动标记格式，本插件的 `parseMarker` 需要同步。

## 5. 已知限制

- **软增强**：刷新质量依赖模型对注入指令的执行力。指令写得非常明确
  （换角度/数量/保留原选项/立即重新调用工具），但仍非硬保证；用户可再次点击
  或打字说明。
- **取消的副作用**：`cancel()` 解除的是整批问题（与原生 ✕ 一致）。多题批里
  点刷新会连其它题一起解除，靠 steer 文案让模型重新问全批。
- **执行顺序**：先 cancel 后 steer。若 cancel 成功而 steer 失败（如会话已
  关闭），问题已解除且无重新生成——卡片随即消失，用户可重新提问；失败路径
  会在卡片消失前短暂显示错误文案。
- **steer 消息入历史**：`【系统 · 选项刷新/更多选项】` 会作为用户消息留在
  会话记录里（这也是模型能理解上下文的原因），多次点击会累积多条。
  文案刻意保持简短（一句话点明意图 + 数量要求 + 推荐项标注），
  多题批时附一行「其余题目也请一并重新询问」。

## 6. 测试

- `scripts/selfcheck.mjs`：13 个纯函数断言（标记解析 / plan-review 识别 /
  steer 文案 zh/en / 选择器行为 / 推荐标注剥离）。
- `scripts/smoke-client.mjs`：真实 react-dom/server 渲染纯文字卡、图片卡、
  Lightbox，并断言 plan-review 放行。
- 服务端半边为空（`apply` 无注册项），无 server 冒烟。

## 7. 挂载方式

采用「profile 手动 insert」（同 workbench/novel）：`cordis.patch.yml` 是
HMR 层，insert/remove 无需重启 dsh；客户端 bundle 由 `dsh.client` 声明经
`/plugins/dsh-plugin-choice-refresh/client.js` 送达，profile 的 client-hmr
监控 bundle 变更自动广播（否则手动刷新一次）。
