HERO476/dsh-sidecard-ask ↗★ 0

dsh-sidecard-ask

DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。 适合阅读长文或代码时,需要就地选中文本进行快速追问和流式阅读的用户。

パッケージ
dsh-sidecard-ask
互換性
未検証
バージョン
1.2.0
ライセンス
MIT
最終更新
2026/09/28

インストール

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:HERO476/dsh-sidecard-ask

ドキュメント

README 全文を読む ↗

3) 重启 DSH;旧配置目录 \selection-followup 可以删掉(新名字用 \sidecard-ask)


> 改了 `client.js` 后如果 `pnpm run dev:web` 没有在跑,浏览器需要刷新页面;改了 `index.js` 需要重启 DSH。

---

## 四、配置项说明表

配置有**三层**,优先级从低到高:

1. **内置默认值**(`index.js` 的 `DEFAULT_CONFIG`)
2. **bundle 补丁层**:`cordis.patch.yml` 的 `config:`(改这里需要重启)
3. **用户层**:设置页保存后写入 `/sidecard-ask/config.json`
   (`DSH_HOME` 未设置或为空白时回退到 `~/.dsh`;**不会**写进程当前目录)

设置页点「重置为 patch 配置」会清空用户层。

| 配置项 | 类型 / 取值 | 默认 | 作用 | 生效方式 |
|---|---|---|---|---|
| `trigger` | `selection` \| `shortcut` \| `both` | `selection` | **触发方式**:选中即浮出按钮 / 只用快捷键 / 两者都要 | 改后立即(客户端读 /state) |
| `defaultCarrier` | `main` \| `side` | `side` | **默认作答位置**:提问框里仍可临时切换 | 立即 |
| `sideSurface` | `auto` \| `native-rightbar` \| `better-sidebar` \| `flow` | `auto` | **窗口模式(侧边承载面)**:自动挑选 / 强制 DSH 原生右侧栏 / 强制 dsh-better-sidebar / 强制内置浮层卡片 | 立即 |
| `maxChars` | 200–60000 | `4000` | **最大字符数**:超出部分头尾保留、中间截断并标注 | 立即 |
| `shortcut` | 形如 `Alt+Q`、`Ctrl+Shift+K` | `Alt+Q` | **快捷键**:唤起提问框(`trigger` 允许时) | 立即 |
| `captureZones` | `auto` \| `chat` \| `task` \| `chat+task` | `auto` | 只在哪些区域触发 | 立即 |
| `showInUnclassified` | 布尔 | `true` | 无法归类的区域是否也触发 | 立即 |
| `minChars` | 0–200 | `2` | 少于该字符数的选区不触发 | 立即 |
| `maxConcurrentAsks` | 1–12 | `3` | 侧边卡片并发作答上限,超出返回 `busy`(可重试) | 立即 |
| `sideTools` | `readonly` \| `inherit` | `readonly` | 侧边作答者的工具权限:只读白名单 / 继承当前会话 | 下一次提问 |
| `sideTimeoutMs` | 5000–3600000 | `180000` | 单次侧边作答超时 | 下一次提问 |
| `sideProvider` | 字符串 \| `auto` | `auto` | 指定子代理 provider(`auto` 优先 `spawn`) | 下一次提问 |

非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。

---

## 五、侧边承载面与"侧边卡片插件"适配

侧边卡片的渲染面按**能力探测**依次挑选,任何一层不可用都不影响整体可用:

| 顺序 | 承载面 | 依赖 | 失败时 |
|---|---|---|---|
| 1 | DSH 原生右侧栏 | `ctx.sidebarRightTabs`(注册 tab 类型)+ `ctx.sidebarRight`(`openTab`),并占用槽位 `sidebar.right.pane.tab` / `…tab.title` | 记 `surfaces.native.error`,落到下一层;**类型注册会回滚**(见下) |
| 2 | **dsh-better-sidebar**(侧边卡片插件) | 客户端服务 `ctx.betterSidebar`:`registerTab` + `openTab(seed, scope)`;卡片 id 走 `tab.meta.cardId`,因此**要求其 `features` 含 `tabMeta`** | 同上(缺 `tabMeta` 时记 `no-tab-meta` 并跳过,而不是开一个空 tab) |
| 3 | **内置浮层卡片**(默认兜底) | 只需要 `shell.overlay` 槽位 | ——(这是保底面,永远可用) |

- 装了 `dsh-better-sidebar`:卡片以它的 tab 形式出现在它的面板里,关闭卡片会同时 `closeTab`,不残留空 tab。
- 没装:自动使用内置浮层卡片(右下角卡片栈),功能完全一致——**这条路径是本插件的默认与保底路径**。
- 强制指定了一个不可用的承载面:回退到内置浮层,并在卡片上标注「已回退」。

### 5.1 与 dsh-better-sidebar 0.22.1 的适配核对(2026-09-28)

做法:把 0.22.0 与 0.22.1 的发布产物都拉下来、解包、逐文件比对(`npm pack` + SHA256)。

| 检查项 | 结果 |
|---|---|
| 消费端契约 `lib/types/client/service.d.ts` | **除 `SIDEBAR_SERVICE_VERSION` 常量外逐字节相同** → `BetterSidebarService` 的方法/参数/返回类型没变 |
| `SIDEBAR_FEATURES`(能力清单) | **未变**(`tabMeta` 等仍在;契约承诺"Features are never removed") |
| `dsh.client.inject` / peer 依赖 | 未变(仍是 locale / ui-slots / ui-conversation / ui-sidebar-right / client-modules) |
| `src/client/native/index.ts`(0.22.1 唯一大体量改动) | 是它**自己原生胶水的健壮性修复**:`disposeSafely` + 槽位注册失败时回滚已注册的 tab 类型;不涉及我们调用的任何字段 |
| 本插件实际用到的面 | `registerTab({id,title,description,order,dedupeKey,component})`、`openTab({type,id,title,meta},{sessionId})`、`closeTab(id)`、`features`、`version` —— 全部仍在 |

**因此 0.22.1 下适配为「无需改动即兼容」**;本轮据此做了三处加固:

1. **能力门控而非版本判断**:适配器只在 `features` 含 `tabMeta` 时可用(0.12 起才有,旧版会开出空 tab);
   不满足时 `auto` 直接跳到原生右侧栏/内置浮层,设置页自检里写明原因(`no-tab-meta`)。
2. **原生承载面类型注册回滚**:先注册 tab 类型、再注册槽位;槽位注册抛错(reload/teardown 期的失活 context)时
   **释放已占用的类型 id**——这正是 0.22.1 在自己原生胶水里修的同一类问题(否则该 kind 会永久占用并显示宿主"没有实现"的空面)。
3. **运行时可见**:设置页「运行自检」新增一行,直接显示**侧边卡片插件自己的 `version` 与能力项数**,
   以及本插件对它的判定(可用 / 版本过旧 / 未检测到),不再依赖 package.json 的声明。

> 注意:`version` 是加载到浏览器里的那份模块报告的版本。页面刷新后即可用它确认"跑的是不是 0.22.1"。

---

## 六、未知 DSH API:占位接口与替换方式

开发时以**运行时能力探测**为主,任何"本机没验证到"的接口都在代码里显式留了占位与替换点。它们集中在两处:

### 1. `client.js` §7 —— `surfaces.native`(原生右侧栏适配器)

```js
// 现状:结构性子集 + try/catch,任何一步不成立就置 error 并降级
ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => { ... })
  • 已实测的调用形态:registry.register({ id, kind, title, guide: [{ id, order, title, description }] })、 controller.openTab(kind, { params, revealIfOpened })、槽位 sidebar.right.pane.tab(keyed,inject: sessionId => ({ sessionId }))。
  • 未逐版本实测的部分(占位):guide[].icon、canOpen/patterns、closeIn/activateIn。 替换方式:在 client.js 搜索 PLACEHOLDER: native-rightbar,按目标 DSH 版本的真实签名补齐; 补齐前该分支只会记录错误并降级,不会破坏插件。

2. client.js §6/§3 —— 主对话发送与线协议

  • 已实测:客户端服务 ctx.sessions 的 using(id, { source:'gateway' }, ref => ref.binding.session.prompt(parts, 'queue'))。
  • 占位 1(草稿降级):ctx.get('conversation').input.for(scope) → { state.getSnapshot().draft, setDraft(text) }。 这是未出现在服务目录里的接口(属 harness 内部形态),所以只作为第二顺位降级;搜索 PLACEHOLDER: composer-draft。
  • 占位 2(最后兜底):前两者都不可用时,插件抛出可读错误并提示用户复制文本,搜索 PLACEHOLDER: clipboard-fallback。
  • 线协议:客户端与宿主半之间是插件自有的 /sidecard-ask/api(GET /state、POST /ask|cancel|config|reset), 不依赖任何 harness 内部 RPC;若未来 harness 提供正式的同进程 RPC,替换点就是 client.js §3 的 postJson/streamAsk。

约定:所有占位点都写成 PLACEHOLDER: 注释 + 可运行的降级路径,替换时只需改该函数的实现,调用方(卡片、设置页)无需改动。


七、DSH 版本适配(近 10 余个版本,证据来自已发布产物)

不是靠字段笔记,而是把每个版本要用的包从 npm 拉下来、解包、按标记字符串判定:

node tools/compat-probe.mjs                        # 13 个版本 × 12 个包
node tools/compat-probe.mjs --json tools/.cache/matrix.json

脚本会 npm pack 相应包到 tools/.cache/tarballs/(已在 .gitignore),用内置 tar 读取器在内存里搜索, 然后打印下面的能力矩阵。矩阵里的 ❌ 就是插件必须降级的点,而插件对每一个 ❌ 都有对应分支。

7.1 能力矩阵(0.1.2-rc.1 → 0.1.7-rc.2)

能力(依赖的 API)0.1.2-rc.10.1.3-alpha.20.1.5-alpha.1 … 0.1.5-rc.30.1.6-alpha.10.1.6-alpha.20.1.7-alpha.1 … 0.1.7-rc.2
客户端产物协议 window.__ModuleLoader__.load✅✅✅✅✅✅
shell.overlay(浮层/触发按钮挂载点)✅✅✅✅✅✅
conversation.input.right(会话 id 采集)✅✅✅✅✅✅
settings.section(设置页)✅✅✅✅✅✅
data-slot 出口锚点(区域归类)✅✅✅✅✅✅
原生右侧栏 sidebarRightTabs + sidebar.right.pane.tab❌❌✅✅✅✅
主对话提交 sessions.using(target, options, operation)❌❌❌❌✅✅
主对话提交 sessions.retain(target, options)❌❌❌❌✅✅
归档会话门(拒绝归档血统的每一步)❌❌❌❌❌✅
进程内流式帧 agent/assistant-stream❌✅✅✅✅✅
持久化分块 assistant/chunk(会话事件)✅❌❌❌❌❌
子代理 subagents.start + SubagentRun.localAgent✅✅✅✅✅✅
provider 能力面(toolFilter / persona)✅✅✅✅✅✅
tools.schemas() + tools.restrict()✅✅✅✅✅✅
宿主路由 webServer.register({kind:'prefix'})✅✅✅✅✅✅

7.2 每个 ❌ 对应的适配(都在代码里)

缺口适配实现在真机/测试里的表现
0.1.2 / 0.1.3 没有原生右侧栏承载面探测链:原生右侧栏 → dsh-better-sidebar → 内置浮层卡片;不可用的一律不计入 pickSurfacecontract-test 断言 pickSurface('auto') 在无插件组合下落到 flow;auto 在可用时优先原生
≤0.1.6-alpha.1 没有 using/retain主对话提交四级阶梯(调用时逐级探测,不查版本表):sessions.using → sessions.retain+release → 槽位标准 prop inputActions(setDraft + submit)→ 仅写入输入框并明确提示"请按 Enter"contract-test 逐级断言 via:sessions.using / sessions.retain(并断言 release() 被调用)/ inputActions.submit / composer.draft / 全无时抛 no-main-carrier
≤0.1.6-alpha.2 没有归档门不再因"父会话已归档"直接拒绝:仍优先挑未归档代理,但只有归档候选时照常尝试,并在 start 事件里带 parentArchived:true;真被门拒绝时把空 turn 的 refusal 翻译成 blocked-step 并说明原因smoke-test:全归档组合仍能拿到答案;refusal 用例断言 code=blocked-step 且文案提到归档
0.1.2-rc.1 没有进程内帧增加第二条流式源:订阅 session/event 的持久化 assistant/chunk({turn,step,chunk},形状取自该版本自己的 chunk-rows.js),与帧源互斥(先说话的那个生效,绝不重复计一次文本)smoke-test:frameMode:'chunks' 用例断言 delta 拼出完整答案、streaming:true、streamSource:'chunks';同时断言两源并存时文本不重复
provider 能力面差异只发 provider 声明支持的启动字段(capabilities.persona/toolFilter);persona 不支持时内联进提示词;toolFilter 不支持时在 start 事件里报 toolFilter:'unsupported',卡片明说"本 provider 不支持工具白名单"smoke-test:capabilities:null 与 {toolFilter:false,persona:true} 两种 provider 的字段断言
槽位键在老版本可能不同每个注册走槽位阶梯(都是 list 槽,绝不碰 single 槽以免替换宿主 UI):浮层 shell.overlay → conversation.input.dock → conversation.composer.dock;设置页 settings.section → settings.plugins.tab;会话采集 conversation.input.right → conversation.input.left → composer.dock → input.dock。先到者胜,更好的槽位后到会顶掉兜底contract-test:absentSlots 模拟未声明的槽位,断言注册落到下一级且诊断里记录了落点
没有 data-slot 锚点检测到选区但槽位路径为空 → 判定"区域锚点不可用",停用区域过滤(否则 captureZones:chat 会静默失效)并在设置页自检里标明contract-test:shouldOffer(..., {anchors:false}) 断言放宽且返回 zoneFiltering:'unavailable'
路由 kind 不被接受prefix 注册失败 → 退化为逐方法精确路由(state/ask/cancel/config/reset),处理器不变前缀路由由 smoke 全流程覆盖;退化分支为纯 fallback(未在真机触发过)

7.3 还没做真机验证的部分(如实标注)

  • 上表所有 ✅/❌ 都是包内容层面的证据(字符串/签名存在于该版本的发布产物),不等于"插件在该版本上跑起来过"。 唯一跑过真机的是 0.1.7-rc.2(见 §10.1 的实测记录)。
  • 在 0.1.2-rc.1 … 0.1.6-alpha.2 这几个版本上,我只验证了"所需 API 是否存在 + 插件有为缺失准备的分支", 没有在那些版本上安装并启动过插件。
  • inputActions 作为会话槽位标准 prop:13 个版本的 dsh-client-ui-conversation 产物里都有该名字, 0.1.7-rc.2 的槽位目录也把它列为 conversation.input.right 的 standardProps;更早版本是否真的把它下发给该槽位条目未验证, 插件对此是探测式使用(取不到就走草稿降级)。

八、边界处理清单(对应需求第六点)

边界处理代码位置
空选 / 过短选区为空、折叠、或短于 minChars → 不浮出按钮并清掉上一次的触发态readSelection / shouldOffer(client §5)
编辑框内选择选区锚点/焦点落在 input/textarea/contenteditable 内 → 视为编辑操作,不触发isEditable(client §5)
跨区选择锚点与焦点区域不同 → 标为"跨区选择",按锚点区域归类并在徽标上显示;仍可追问readSelection 的 cross、zoneLabel
超长文本头 70% + 尾 30% 保留,中间插入「已省略中间 N 个字符」;提问框与卡片都显示截断徽标;宿主侧再做一次硬上限(60000 字符提示词上限)truncateSelection(两端一致,契约测试断言等价)
接口失败逐层降级:子代理不可用 → 卡片错误 + 「改到主对话」;主对话发送不可用 → 草稿写入;再不可用 → 明确报错不静默toWireError / askInMainConversation
重复触发同一选区 + 近似位置在 400ms 内只触发一次;同一卡片 id 的重复请求返回 duplicate;并发超限返回 busy(可重试)selectionSignature、runs.has(id)、maxConcurrentAsks
流式中断浏览器断开 / 点「停止作答」/ 超时 → AbortController 取消子代理:有部分文本时以 done{aborted:true} 收尾并保留已流出的内容(卡片显示「已停止」),一个字都没流出时才用 error.code='aborted'handleAsk 的 res.on('close') 判定、sideTimeoutMs
父会话已归档归档门会拒绝整条子代理血统里的每一步(表现为「没有发起任何模型请求的空 turn」),但它只存在于 0.1.7-alpha.1+。插件优先挑未归档代理;只剩归档候选时照常尝试并在 start 事件里带 parentArchived:true(旧版本本来就能正常作答,一刀切拒绝会误伤 0.1.2–0.1.6)resolveParent / archivedSessionIds(host)
步骤被宿主拒绝子代理接缝把这种空 turn 记为 refusal;插件翻译成 blocked-step,文案点明常见原因(会话已归档)并给出「改到主对话」ask() 的终局映射(host)
卸载插件卸载时取消全部进行中的作答、注销槽位、移除 DOM 监听、断开流式桥ctx.effect + disposers
跨站请求插件路由带信任围栏:Host 必须是回环或配置的可信域,sec-fetch-site: cross-site 或跨域 Origin 一律 403isTrustedRequest(host)

错误码对照(error 事件的 code)

code含义是否可重试建议动作
bad-request问题或选中文本为空否重新选中/输入
busy并发追问达上限是稍后重试
duplicate同一卡片 id 已有在跑的任务否等它结束
no-side-engine没有可用的子代理 provider否「改到主对话」
no-parent没有活动会话代理否「改到主对话」
blocked-step这一步被宿主的 agent/pre-step 拒绝(未发起请求);0.1.7+ 上最常见的原因是会话已归档是换会话或「改到主对话」
aborted被取消或超时,且没有已流出的内容是重试
engine-error / 其它子代理启动、模型调用或传输失败是重试或改到主对话

九、三条关键路径的自测要点

A. 路径一:就地追问 → 侧边卡片流式作答

  1. 在聊天区选中一句非输入框内的文本(例如助手消息里的半句话);
  2. 选区末端应浮出「💬 追问选中内容」按钮,按钮带区域徽标(聊天区 / 任务区 / 跨区选择);
  3. 点击后按钮消失、提问框出现,焦点在输入框,引文预览显示选中文本;
  4. 输入问题按 Enter → 提问框关闭,右下角出现卡片,状态先为「作答中…」并逐字增长;
  5. 结束后状态变「已完成」;底部出现「复制 / 继续追问 / 关闭」;
  6. 点「复制」→ 出现「已复制」提示;点「继续追问」→ 输入第二条问题,卡片内容重置换行后继续流式;
  7. 点「关闭」→ 卡片消失;若承载面是 better-sidebar/原生右侧栏,对应 tab 也应关闭。

失败信号:卡片停在「作答中…」不动 = SSE 帧没到达(看 console 是否有 /sidecard-ask/api/ask 报错);done.streaming=false = 该版本没有流式帧(属预期降级,卡片会写明)。

B. 路径二:主对话承载

  1. 选中文本 → 提问框里把「作答位置」切到主对话 → 输入问题 → Enter;

  2. 主对话应立刻出现一条用户消息,形如:

    > 选中的原文(逐行引用)
    > …(已截断时会有「已省略约 N 字」)
    
    【来源:聊天区】
    你的问题
    
  3. 该消息的答案由当前会话正常流式输出(这就是"主对话承载"的定义);

  4. 卡片侧显示「已发送到主对话」并说明答案在主对话中;若接口不可用而降级为草稿写入,卡片会明确显示「已填入输入框」,此时需手动按 Enter 发送(不算失败,但要能看到这条提示)。

C. 路径三:快捷键 + 引擎不可用的降级

  1. 选中文本 → 按 Alt+Q → 提问框直接出现(无需点按钮);
  2. 在设置页把「触发方式」改成「仅快捷键」→ 再选中文本时不应出现按钮,但 Alt+Q 仍可用;
  3. 把「侧边卡片承载面」强制设为 native-rightbar 或 better-sidebar(未安装/未启用时)→ 追问应回退到内置浮层卡片,并提示"已回退";
  4. 用一个没有子代理的 DSH 组合(或临时把 sideProvider 设成一个不存在的名字)→ 侧边作答应返回错误卡片: 文案说明"没有可用的子代理",并给出「改到主对话」按钮;点它应把同一问题转到主对话(不得丢失已选中的文本与问题)。

十、自测脚本

cd D:\Users\34332\AI\dsh-sidecard-ask
node test/verify.mjs          # 静态:清单/补丁/导出面/i18n/零依赖
node test/contract-test.mjs   # 契约:常量、信封、SSE 逐帧、纯函数、槽位注册与渲染
node test/smoke-test.mjs      # 端到端:流式/截断/取消/持久化/失败分支/并发/围栏

三个脚本都以 process.exitCode 反映结果,失败会列出具体条目;测试会把 DSH_HOME 指向临时目录,不会污染真实配置。 当前规模:verify 53 项 + contract 105 项 + smoke 118 项 = 276 项全部通过。

10.2 版本能力探测(§7 矩阵的来源)

node tools/compat-probe.mjs                       # 13 个版本 × 12 个包,逐个 npm pack 后按标记判定
node tools/compat-probe.mjs --json tools/.cache/matrix.json
node tools/compat-probe.mjs 0.1.7-rc.2            # 也可只探某个版本

首次运行会下载约 250 个包到 tools/.cache/tarballs/(已 gitignore,之后走缓存);输出的矩阵就是 README §7 的表。

10.1 对已安装实例做真实联调(本机实测通过)


## 四、配置项说明表

配置有**三层**,优先级从低到高:

1. **内置默认值**(`index.js` 的 `DEFAULT_CONFIG`)
2. **bundle 补丁层**:`cordis.patch.yml` 的 `config:`(改这里需要重启)
3. **用户层**:设置页保存后写入 `/sidecard-ask/config.json`
   (`DSH_HOME` 未设置或为空白时回退到 `~/.dsh`;**不会**写进程当前目录)

设置页点「重置为 patch 配置」会清空用户层。

| 配置项 | 类型 / 取值 | 默认 | 作用 | 生效方式 |
|---|---|---|---|---|
| `trigger` | `selection` \| `shortcut` \| `both` | `selection` | **触发方式**:选中即浮出按钮 / 只用快捷键 / 两者都要 | 改后立即(客户端读 /state) |
| `defaultCarrier` | `main` \| `side` | `side` | **默认作答位置**:提问框里仍可临时切换 | 立即 |
| `sideSurface` | `auto` \| `native-rightbar` \| `better-sidebar` \| `flow` | `auto` | **窗口模式(侧边承载面)**:自动挑选 / 强制 DSH 原生右侧栏 / 强制 dsh-better-sidebar / 强制内置浮层卡片 | 立即 |
| `maxChars` | 200–60000 | `4000` | **最大字符数**:超出部分头尾保留、中间截断并标注 | 立即 |
| `shortcut` | 形如 `Alt+Q`、`Ctrl+Shift+K` | `Alt+Q` | **快捷键**:唤起提问框(`trigger` 允许时) | 立即 |
| `captureZones` | `auto` \| `chat` \| `task` \| `chat+task` | `auto` | 只在哪些区域触发 | 立即 |
| `showInUnclassified` | 布尔 | `true` | 无法归类的区域是否也触发 | 立即 |
| `minChars` | 0–200 | `2` | 少于该字符数的选区不触发 | 立即 |
| `maxConcurrentAsks` | 1–12 | `3` | 侧边卡片并发作答上限,超出返回 `busy`(可重试) | 立即 |
| `sideTools` | `readonly` \| `inherit` | `readonly` | 侧边作答者的工具权限:只读白名单 / 继承当前会话 | 下一次提问 |
| `sideTimeoutMs` | 5000–3600000 | `180000` | 单次侧边作答超时 | 下一次提问 |
| `sideProvider` | 字符串 \| `auto` | `auto` | 指定子代理 provider(`auto` 优先 `spawn`) | 下一次提问 |

非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。

---