メインコンテンツへスキップ
H

dsh-sidecard-ask

hero476/dsh-sidecard-ask

DSH Web GUIでテキストを選び、その場で質問(フローティングボタンまたはAlt+Q)。親コンテキストを引き継がない独立subagentが回答し、ネイティブ右サイドバー、dsh-better-sidebar、または内蔵overlayのside cardへストリーミング表示。質問をメイン会話に送ることもできます。

インストール

dsh plugin --profile web add github:hero476/dsh-sidecard-ask

README

dsh-sidecard-ask · 侧边卡片追问

在 DSH Web GUI 里选中一段文本就能就地追问:选中处浮出「追问选中内容」按钮 → 弹出提问框 → 答案落在两个可切换的承载位置上:

  • 独立侧边卡片(默认):由子代理在自己的会话里作答(不继承父上下文、不打扰主对话),答案在卡片里逐字流式渲染,可关闭 / 复制 / 继续追问;
  • 主对话:追问内容带着引用块进入当前会话,答案随对话原生流式呈现。

聊天记录、任务详情、右侧栏内容……只要是能选中的文本都能用;没装侧边卡片插件也能跑(自动回退到内置浮层卡片)。

发布要点与命名历史(1.4.0 → 1.8.4 的四段「增强一览」,以及本插件两次改名的原因)已移到 docs/release-highlights.md。当前版本的变更见 CHANGELOG.md;只想装起来用的话,从下面的 §一 开始。


一、三条关键路径(先看这个)

路径操作期望结果
A. 就地追问(侧边卡片)在聊天区选中一句话 → 点浮出的按钮(或点旁边的「解释/翻译/总结/润色/纠错」芯片一键发起)→ 输入问题 → Enter右下角浮出卡片,答案逐字流出;卡片底部有「复制 / 继续追问 / 改问 / 关闭」,代码块右上角可单独复制
B. 主对话追问选中 → 提问框里把「作答位置」切到主对话 → Enter追问以引用块形式出现在主对话,答案由当前会话正常流式输出
C. 快捷键 + 降级选中 → 按 Alt+Q(默认)直接弹出提问框;若侧边作答引擎不可用,卡片给出错误提示与「改到主对话」按钮

二、目录结构与逐文件用途

dsh-sidecard-ask/
├── package.json          清单:包名/版本/导出/dsh.bundle.patch/dsh.client(web 平台、immediately、inject)
├── cordis.patch.yml      bundle 补丁:向 profile 插入 id 为 sidecard-ask 的宿主行,并给出主要默认配置(未列出的键取代码里的 DEFAULT_CONFIG)
├── index.js              Host 半(宿主侧):插件自有 API(JSON + SSE)、配置三层合并与持久化、
│                         独立作答引擎(ctx.subagents 子代理 + agent/assistant-stream 桥接)、请求信任围栏
├── client.js             Client 半(浏览器侧):dsh.client 浏览器产物,单文件、无构建步骤
│                         §1 常量与默认值 §2 i18n §3 线协议客户端 §4 模块级 store §5 选区引擎
│                         §6 两种承载方式 §7 侧边承载面适配 §8 视图工具 §9 组件 §10 插件入口
├── locale/zh.json        插件元数据的中文显示文案(Plugins 页卡片标题/描述)
├── locale/en.json        同上,英文
├── icon.svg              Plugins 页卡片图标(<256 KiB,相对路径)
├── README.md             本文件
├── CHANGELOG.md          版本变更记录
├── LICENSE               MIT
├── design-preview/       方案讨论资料,**不参与发布**:00–02 是手绘 SVG 示意图(非截图),
│                         03-prototype-ac.html 是可直接双击打开的可点按原型(自带近似色板,非真实观感),
│                         07-carrier-seg-{light,dark}.png 是 1.8.2 的离屏渲染对比图(无头 Edge + 真实 CSS
│                         与真实令牌值,仍非运行中窗口截图)
├── tools/
│   ├── compat-probe.mjs  DSH 版本能力探测(§7 矩阵的来源)
│   └── gen-locale.mjs    从 client.js 内嵌 DICT 重生成 locale/*.json(2.1.13:词典是唯一事实源)
├── docs/
│   ├── release-highlights.md  1.4.0→1.8.4 的「增强一览」与两次改名的历史(2.1.13 从首屏搬出)
│   └── archive/README.md      .compat-report/ 与 design-preview/ 为什么冻结、为什么不要"修正"行号
├── .github/workflows/test.yml CI 门禁:push/PR 在 node 20·22·24 + windows 上跑 npm test(2.1.13 新增)
└── test/
    ├── harness.mjs       测试替身:假宿主 ctx / 假 req·res / 假子代理 provider / 合成浏览器 + 迷你 React
    ├── verify.mjs        静态完整性:清单、补丁、两半导出面、i18n 键覆盖、零依赖承诺、文档行号锚点
    ├── contract-test.mjs 两端契约:常量一致性、响应信封形状、SSE 逐帧往返、纯函数等价、槽位注册与渲染
    ├── float-placement.mjs 浮层几何:宽度预算不越过对话区右边界、胶囊默认形态、源码钩子(1.5.0 新增)
    └── smoke-test.mjs    宿主端到端:流式、截断、取消、持久化与重启、失败分支、并发上限、传输围栏

不引入任何外部依赖:宿主半只 import node: 内建模块;客户端半只 require('react')(由浏览器模块表提供)。 test/、tools/、docs/、design-preview/、.github/ 都不进 files,不会发布。 npm test 串起四个套件 + gen-locale --check,约 19 秒;CI 跑的就是这一条。


三、安装与启用

方式 1:从 npm 安装(推荐)

# 在 profile 目录(默认 %USERPROFILE%\.dsh\profiles\web)执行
pnpm add dsh-sidecard-ask
# 然后把包名写进 profile package.json 的 dsh.profile.bundles 数组
#    "dsh.profile": { "bundles": [ ..., "dsh-sidecard-ask" ] }
# 重启 DSH(重启后插件行、客户端产物、设置页一起生效)

方式 2:让 DSH 自己装(本工作区源码)

在会话里让 Agent 执行 plugin_manager 的 install_bundle,target 指向本目录的绝对路径:

plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sidecard-ask")

install_bundle 会自行完成 profile 的写入与 bundle 选择,不要手改 profile 的 package.json / cordis.patch.yml。

方式 3:本地联调(junction)

cmd /c mklink /J "%USERPROFILE%\.dsh\profiles\web\node_modules\dsh-sidecard-ask" "D:\Users\34332\AI\dsh-sidecard-ask"
# 再手动把 "dsh-sidecard-ask" 加进 profile package.json 的 dsh.profile.bundles

启用与验证

  1. 重启 DSH(新包需要重启才会加载一行全新的 JavaScript 模块)。
  2. 打开 Web GUI → 设置 → 插件,应能看到卡片「侧边卡片追问」。
  3. 打开设置 → 侧边卡片追问页,点「运行自检」:应显示宿主接口可用、侧边作答引擎可用、provider 列表(本机为 spawn)、活动会话代理数。
  4. 若自检显示"宿主接口不可达",说明 /sidecard-ask/api 路由没起来——检查 profile 里该 bundle 是否在 dsh.profile.bundles 中、以及 DSH 是否重启过。

从旧名字升级(1.0.x → 1.1.0)

# 1) 卸掉旧 bundle(profile 里旧名字是 dsh-selection-followup)
plugin_manager(action="remove_bundle", target="dsh-selection-followup")
# 2) 装新名字(本工作区源码)
plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sidecard-ask")
# 3) 重启 DSH;旧配置目录 <DSH_HOME>\selection-followup 可以删掉(新名字用 <DSH_HOME>\sidecard-ask)

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


四、配置项说明表

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

  1. 内置默认值(index.js 的 DEFAULT_CONFIG)
  2. bundle 补丁层:cordis.patch.yml 的 config:(改这里需要重启)
  3. 用户层:设置页保存后写入 <DSH_HOME>/sidecard-ask/config.<profile>.json,其中 <profile> 取自 DSH_PROFILE(web 端与桌面端各一份,互不覆盖);DSH_PROFILE 未设置时用旧名 config.json (DSH_HOME 未设置或为空白时回退到 ~/.dsh;不会写进程当前目录)

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

配置项类型 / 取值默认作用生效方式
triggerselection | shortcut | bothselection触发方式:选中即浮出按钮 / 只用快捷键 / 两者都要改后立即(客户端读 /state)
defaultCarriermain | sideside默认作答位置:提问框里仍可临时切换立即
sideSurfaceauto | native-rightbar | better-sidebar | flowauto窗口模式(侧边承载面):自动挑选(先 dsh-better-sidebar,再内置浮层卡片)/ 强制 DSH 原生右侧栏(不参与自动)/ 强制 dsh-better-sidebar / 强制内置浮层卡片立即
maxChars200–600004000最大字符数:超出部分头尾保留、中间截断并标注立即
floatModecapsule | fullcapsule内置浮层的默认形态:胶囊(一行,最小遮挡)/ 完整卡片栈。运行时可即时收起为胶囊,但不写回本项——刷新后仍回到这里设的默认。旧配置里存过 off 的会在启动时校验失败、回退为 capsule,并在 problems 里给出提示(index.js 的 sanitizeLayer),无需手工迁移立即
autoExpandOnDone布尔true答完自动展开卡片:回答结束(done / stopped / error)时把浮层从胶囊切到卡片形态并展开该卡,答案正文直接显示在侧边卡片里。置 false 则始终停在胶囊,点一下才展开(2.1.9 起)立即
floatMaxWidth240–560340内置浮层宽度上限(px):实际宽度 = min(本项, 对话区右侧可用带宽),可用带宽不足 240px 时才会压入对话区立即
dockRail布尔true右侧栏打开时占满它:答案卡铺满右栏标签条下方的内容区,读起来像栏内的一页,而不是 340px 的小浮层。收起为胶囊、点卡片底部「关闭」、右栏宽度 < 280px 或高度 < 240px 时自动回到常规浮层;实现靠测量右栏 DOM 矩形,没有走 DSH 的右栏 API(见 §4.2 与 §6.1)立即
shortcut形如 Alt+Q、Ctrl+Shift+KAlt+Q快捷键:唤起提问框(trigger 允许时)立即
captureZonesauto | chat | task | chat+taskauto只在哪些区域触发立即
showInUnclassified布尔true无法归类的区域是否也触发立即
minChars0–2002少于该字符数的选区不触发立即
maxConcurrentAsks1–123侧边卡片并发作答上限,超出返回 busy(可重试)立即
sideToolsreadonly | inheritinherit侧边作答者的工具权限。2.1.10 起代码默认 inherit,2.1.11 起 bundle 补丁也对齐(2.1.10 的补丁仍写着 readonly,默认层覆盖代码默认层,所以在该宿主上追问一律被拒):0.2.2-alpha.3 的 SubagentCapabilities 只有 {agentOptions, agentPreset, outputSchema, depthLimit},既没有 toolFilter 也没有 persona,宿主不提供任何只读载体。选 readonly 时,只有「下一条 preset」或宿主真的认 toolFilter 才成立,否则直接拒绝作答(readonly-unavailable)——不会继承、也没有任何“探针拒写”兜底(2.1.9 及更早的 README 那句话是错的,已删除)下一次提问
sideAgentPreset字符串,≤120 字符''只读 preset:子代理以该 agent preset 启动,工具集由 preset 决定——这是 0.2.2 上唯一可靠的收窄通道(agentPreset 能力位存在时生效)。留空 = 不启用;此时 sideTools: readonly 若没有宿主 toolFilter 载体就会拒绝作答下一次提问
sideTimeoutMs5000–3600000180000单次侧边作答超时下一次提问
sideProvider字符串 | autoauto指定子代理 provider(auto 优先 spawn)下一次提问
quickActions动作数组(≤12 条)5 个内置动作一键快捷动作:选中后按钮旁的动作芯片。条目形如 {id, enabled, shortcut};内置 id 为 builtin:explain/translate/summarize/polish/fix,自定义动作(非 builtin: 前缀)还需 label(≤20 字)与 question(≤500 字,可用 {selection} 内嵌选中正文)。设置页可视化编辑,用户层整表覆盖默认值立即(快捷键下次按键生效)
notifyOnDone布尔false页面在后台时,作答完成/失败是否弹系统通知;首次开启需浏览器通知权限立即
contextMenu布尔true是否在可触发选区的右键菜单顶部加入追问与快捷动作;与宿主菜单冲突时可关立即
historyMaxCards0–5010历史卡片持久化上限(localStorage v3);0 = 不写盘(已有历史保留到显式清空)立即
answerStyleconcise | standard | detailedstandard回答风格档位:只给最小完整答案 / 现状 / 必要时展开并给出依据。指令追加在人格之后,不会覆盖"用提问的语言作答"与"选中文本是数据"这两条下一次提问
customPersona字符串,≤500 码点''自定义指令:追加在风格档位之后,以 Additional user instruction: 标注随每次提问发送(例如"尽量给可验证来源")下一次提问
followUpSuggestions布尔false模型生成的建议追问:让模型在答案末尾用 <followups>问1 | 问2</followups> 给出 2–3 个可点的下一步问题(客户端剥离该块并渲染为芯片,不额外发请求)下一次提问
feedbackAddress字符串(邮箱或 b64: 编码)| ''维护者收件箱(b64: 混淆存储)反馈收件邮箱:设置页「反馈」弹窗把问题与建议发到这里。默认值指向维护者自己的邮箱,以 b64:<base64> 形式打包——这是混淆不是加密,decodeAddress() 就在同包里,只能挡住正则抓取;环境变量 DSH_SIDECARD_FEEDBACK_TO / DSH_CRB_FEEDBACK_TO 优先于本项,置空则反馈弹窗只留「复制内容」立即
feedbackSmtpHost字符串,≤255''SMTP 服务器。1.9.1 起只从环境变量或用户层配置读。环境变量优先于配置(DSH_SIDECARD_SMTP_URL / DSH_CRB_SMTP_URL 等能凑出完整端点时,配置里的整段不生效);只有用户层配置会存它,不进仓库立即
feedbackSmtpPort数字,1–65535465SMTP 端口。465 配隐式 TLS,587 配 STARTTLS(服务器没声明 STARTTLS 时不会硬升级)立即
feedbackSmtpSecure布尔true隐式 TLS(465)。关掉则按 STARTTLS 处理立即
feedbackSmtpUser字符串,≤254''SMTP 登录账号。部分中继给的是裸账号名而非邮箱,故不强制邮箱格式立即
feedbackSmtpPass字符串,≤512''密码 / 授权码(QQ、163 必须用授权码)。只写不读:/state 会删掉它、页面永远拿到空串;留空保存=保留原值(要删掉已存的密码用下一行的 feedbackSmtpPassClear)立即
feedbackSmtpPassClear布尔false显式清除已存的 SMTP 密码(典型场景:改回用环境变量)。只在 /config 请求里生效,本身不落盘;与 feedbackSmtpPass 同时出现时以清除为准立即
feedbackSmtpFrom字符串(邮箱)| ''''发信账号显式指定的 From:,留空等于 feedbackSmtpUser。必须是 SMTP 服务商允许的地址立即

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

4.1 内置浮层的位置规则(floatPlacement / floatBottom)

用户实测报告过一个问题:浮层是 position:fixed; right:16px; width:min(400px,92vw) 的实心卡片。 实测那一次(1400px 宽的窗口)对话区右边界在 x≈971,而浮层左边界在 x≈858,压住正文约 113px, 最后几行答案被盖住。两点如实说明:旧实现并不是任何窗口宽度下都越界——1400px 窗口理论上 1400-16-400=984,还在 971 右侧;越界与否取决于当时的帧宽与对话区带宽,而固定 400px 的选择 让「窗口一窄就必然压住正文」。这条读数(971 / 858 / 113px)是截图上的像素测量,不是日志里的一手数据。 现在的位置由 client.js 里的纯函数 floatPlacement 决定:

// floatPlacement({ frameWidth, laneRight, railLeft = null, maxWidth = floatMaxWidth })
avail = 帧宽 - 8 - 对话区右边界          // 对话区右侧那条「预留带」
if (右栏存在) avail = min(avail, 帧宽 - 右栏左边界 - 10)
avail = max(240, floor(avail))            // 240px 以下胶囊也不可用,故为硬下限
width = min(floatMaxWidth, avail)
left  = 帧宽 - 8 - width                  // 右锚定
bleeds = 对话区右边界 - left > 1          // 只有带宽 < 240px 时才可能为 true
  • 浮层只使用对话区右侧的预留带(右栏也在这条带里),窗口越窄它越窄,而不是越靠左。
  • capped: true 表示「上限没用满,是被可用带宽压住的」——这是正常的自适应,不是错误。
  • 挂载时、窗口 resize 时、右栏开合时(ResizeObserver)都会重算;流式刷新只重渲染,不动几何。
  • 浮层根节点带 data-layer-width(实际宽度)、data-layer-bottom(距窗口底的距离)与 data-dsa-float-mode:报告问题时可直接读这三个值。

垂直位置(2.1.3):宽度不是唯一的问题——浮层原本固定在 bottom:16px,而输入框就在会话列底部, 折叠态胶囊必然压在输入框上(2026-10-25 实拍报告「挡住聊天框了」)。垂直位置由纯函数 floatBottom 决定:

// floatBottom({ frameHeight, composerTop = null, fallback = 16, limit = 240 })
if (composerTop 未知 || 帧高为 0) return 16      // 测不到就保持旧锚点
if (composerTop < 帧高 / 2)      return 16       // 居中输入框(空会话)不是底栏,不抬
bottom = clamp(帧高 - composerTop + 8, 16, 240)  // 抬到输入框上方 8px,上限 240px

composerTop 取自 DSH 的 [data-composer-card](输入框那张卡片的顶边),该节点也进了 ResizeObserver——输入多行时卡片会往上长,浮层跟着重算,不会重新沉回输入框上。 取舍要讲清楚:抬起来之后它改压最后一两行消息,所以胶囊同时改成按内容收缩(≤240px、右对齐), 尽量把覆盖面积压到最小。

4.2 右侧栏打开时「占满它」(dockRail,1.8.3)

340px 的浮层在右栏打开时会同时压住两块内容:主对话的最后几行、以及右栏自己的正文。1.8.3 起默认 (dockRail: true)把整张卡铺到右栏里,读起来像栏内的一页:

  • 位置 = 右栏标签条之下的内容区矩形,宽度取右栏宽度,高度取到底部;右栏自己的标签条仍然露出、可点。
  • 卡片在这个形态下没有圆角/描边/阴影(.dsa-card 的装饰被 [data-dsa-float-mode="dock"] 覆盖),只有一条左边线与右栏内容区分界。
  • 标题栏(追问 + 徽标 + ⇥ / ×)与底部操作行固定上下两端,中间 AnswerBody 独立滚动——长答案不再把操作行推出视野。
  • 收起为胶囊(⇥)、删除这条追问对话(×)、或把 dockRail 关掉,都立即回到常规浮层;右栏宽度 < 280px 或高度 < 240px 时也回退(窄栏里铺满会把正文挤成每行 8–9 字)。注意 × 是删除:卡片从浮层与追问历史里一并移除;只想摘离浮层、保留历史请用底部的「关闭」。
  • 折叠的右栏不会被误用:railOnScreen() 会拒绝停在帧外(left >= frameWidth - 1)或尺寸不足的面板,量不到就回退,绝不把卡片画到帧外。
  • 矩形从哪来:优先量本插件自己渲染在右栏里的 CardHost 节点([data-dsa-rail-host])——它所在的位置按定义就是「一页正文」,不依赖任何未被文档承诺的标记;找不到时再按 data-sidebar-right-*、dockkit 世代、.rightbar、sidebar.right 槽逐代回退。
  • 什么时候重算:窗口改宽、右栏开合(只盯属性的 MutationObserver)、右栏自身改宽(ResizeObserver),以及挂载后的四次延时重测(罩住右栏的滑入动画)。流式刷新不会触发几何重算。

为什么是「测量」而不是「右栏内的一页」:DSH 的右栏服务确实存在(sidebarRightTabs / sidebarRight, 见 §6.1),本插件也有完整注册代码,但在当前构建里 openTab 路径实测仍回落到浮层,且没定位到抛错点。 在它修好之前,dockRail 是视觉上等价、失败即回退的兜底层——它盖住右栏内容区的其他面板(那些面板仍在 下面,取消勾选或收起胶囊即恢复),也会暂时压住右栏的拖拽改宽手柄。

诊断:data-dsa-float-mode="dock" 表示处于该形态;.dsa-stack 上的 data-dsa-dock-body="<px>" 是量到的 宽度(与卡片宽度一致)。浮层根节点还有 data-dsa-dock,它的取值直接说明为什么没有铺满:

值含义
dock正在铺满
no-rail没找到已展开的右栏(右栏关着,或这个构建的标记与预期不同)
rail-too-small找到了,但宽 < 280px / 高 < 240px,或它停在帧外(折叠态被拒绝)
off设置里的 dockRail 被关掉了
mode当前不是完整卡片形态(胶囊 / 折叠态),本来就不该铺满

4.3 选区旁的动作条(外观)

选中文本后浮出的那条东西以前是六枚等价的胶囊排成一排(追问选中内容 与 解释/翻译/总结/润色/纠错), 彼此同色同粗、各带边框和阴影:既没有主次,也没有分组,窗口窄一点或选区靠右时整条还会顶出右边缘被裁掉。 现在它是一块面板 + 一个主操作:

  • 面板 .dsa-quickbar:padding:3px、border-radius:12px、中灰描边、0 6px 20px 投影,整条读作一个控件;
  • 主按钮(.dsa-trigger-embedded):品牌实底 + 白字 + 600 字重(深色主题改为深墨字,原因同 §九 A 的分段芯片: uiskin 深色品牌 #5b9cf5 上白字只有 2.79:1);
  • 细分隔线 .dsa-quickbar-sep(1px × 15px,border-l1)把「做那一件事」和「或从这些现成问题里挑一个」分开;
  • 五个快捷动作降级为无边框文字胶囊(.dsa-quickbar .dsa-chip):透明底 + label-secondary 字色, hover 才浮出 bg-layer-2 底与 label-primary 字色——层级低于主按钮,但仍然一眼可点; 2.1.14 起它们默认关闭(面板默认只有主按钮,原因见下);
  • 宽度不再靠估算:组件给面板加 max-width: min(620, 视口宽 - 面板左边 - 8),放不下就换行, 不会被窗口边缘切掉;分隔线随主按钮留在第一行,换行的胶囊继续从左边排。

2.1.14:默认只留主按钮。 五个内置动作的 enabled 默认值改成 false,选中处只剩「追问选中内容」。 原因是锚点冲突:这条面板与页面里其它选中工具条(DSH 自带的、别的插件的)落在同一个选区锚点上, 一排胶囊画在同一点上会被读成"叠字",第一次用的人分不清哪个词属于哪个插件。功能一个都没删—— 设置页里逐个打开就把胶囊放回来,quickActions 的持久化列表会整份覆盖默认值,所以原先已经开着的用户不受影响。

外观之外没有行为变化:键盘漫游、Alt+1…5、右键菜单、点击后直接发问都照旧(Alt+数字 只对已启用的动作生效)。

4.4 设置页的「反馈」入口(邮件)

设置页最顶部是一条品牌色描边的反馈横幅(dsa-feedback-bar):一行文字 + 右侧主按钮,点开才是表单, 表单放在一个居中对话框(role="dialog",Esc / 点背景 / 右上角 × 都能关)。1.9.1 之前是「顶部常驻内联表单」, 它把真正的设置项全挤到屏幕外,被否掉后重做成现在这种「一条横幅 + 一个弹窗」。表单字段:类型(问题 / 建议 / 疑问 / 其他)、 标题、详细描述、可选的你的邮箱(填了就写进邮件的 Reply-To:,维护者按「回复」能直接回给你;留空即匿名)。 设置页上没有任何 SMTP 输入框:发信凭据属于部署方/维护者,走环境变量或用户层配置(见下),填报人不用管。 两条出口,从首选到兜底:

出口何时可用说明
发送(推荐)收件邮箱已配置 且 SMTP 凭证可用(环境变量或用户层配置)插件直接把邮件投给 SMTP 服务器;成功后清空草稿
复制内容总是把同样的纯文本(含版本与环境)放进剪贴板,用户自己粘到任意渠道

1.9.x 曾有的第三条出口「用邮件客户端发送」(POST /feedback-mailto 返回拼好的 mailto: URL)已在 2.0.0 整体移除:它让插件多出一条谁都可以调的出口,却只是把同一封邮件交给系统邮件应用,与「复制内容」的 实际效果重叠。现在没有发信服务时,弹窗直接说明缺什么,并指向「复制内容」。

收件邮箱默认可用,也可被环境变量接管。 打包默认值是维护者邮箱(b64: 混淆存储,见上表); 环境变量 DSH_SIDECARD_FEEDBACK_TO 或 DSH_CRB_FEEDBACK_TO(与 dsh-conversation-recycle-bin 共用) 优先于它。用户层配置落在 $DSH_HOME/sidecard-ask/config.<profile>.json (<profile> = DSH_PROFILE;Windows 默认 C:\Users\<你>\.dsh\sidecard-ask\config.web.json / config.desktop.json,两个壳各一份)——不在插件目录里,因此不会被提交、不会进 npm 包。

SMTP 凭证有两种给法,都落在部署方/维护者这一侧(1.9.1 起设置页不再提供填写框):

  1. 用户层配置($DSH_HOME/sidecard-ask/config.<profile>.json,Windows 默认 C:\Users\<你>\.dsh\sidecard-ask\config.web.json,或 profile 的 cordis.patch.yml 里给该行写 config): feedbackSmtpHost / feedbackSmtpPort / feedbackSmtpSecure / feedbackSmtpUser / feedbackSmtpPass / feedbackSmtpFrom。密码只写不读:/state 删除该键、页面永远拿到空串,留空保存表示保留原值。 QQ 邮箱请填 smtp.qq.com / 465 / secure: true / 你的 QQ 邮箱 / 授权码 (不是 QQ 密码,要在 QQ 邮箱后台「设置 → 账户 → POP3/SMTP 服务」里生成)。
  2. 环境变量(推荐,优先级高于用户层配置),有两种写法:
# 写法一:一个 URL(smtps = 隐式 TLS,端口默认 465;smtp = STARTTLS,端口默认 587)
DSH_SIDECARD_SMTP_URL="smtps://user%40example.com:app-password@smtp.example.com"
# 也可以直接复用 dsh-conversation-recycle-bin 的那一个:
DSH_CRB_SMTP_URL="smtps://user%40example.com:app-password@smtp.example.com"

# 写法二:分开写(密码里带 @ 或 : 时不用手动转义)
DSH_SIDECARD_SMTP_HOST="smtp.example.com"
DSH_SIDECARD_SMTP_PORT="465"
DSH_SIDECARD_SMTP_SECURE="1"        # 1/true/yes = 隐式 TLS
DSH_SIDECARD_SMTP_USER="user@example.com"
DSH_SIDECARD_SMTP_PASS="app-password"
DSH_SIDECARD_SMTP_FROM="user@example.com"   # 可选,默认等于 USER

补充约定(都会体现在 /state 的 capabilities.feedback 里,反馈弹窗据此显示「已就绪 / 未配置」):

  • 环境变量优先于用户层配置,而且只认「完整的那一套」:环境里能凑出主机+账号+密码就用环境(smtp.source = 'env'), 否则才看用户层 config.json 填的;两边都不完整就报「还没填全」。这条顺序是有原因的——本机曾出现「环境变量是好的、 配置里留着半套(填了主机没填密码)」,旧实现先看配置,于是一套能用的凭证被半套配置挡住, 反馈永远发不出去。1.9.1 起设置页不自带 SMTP 表单,这条优先级只作用于「环境变量 vs 用户层配置」。

  • 发件人必须像邮箱:?from= / DSH_SIDECARD_SMTP_FROM / USER 三者取到的地址必须通过邮箱格式检查, 否则整条 SMTP 路径判为未配置——MAIL FROM 里塞不进一个合法地址就该早点说清楚。

  • STARTTLS 只在服务器声明时才升级:先看 EHLO 的多行回复里有没有 STARTTLS,没有就直接明文对话; 否则一个没开 STARTTLS 的内网中继会被自己的升级请求判成「发信失败」。

  • 本地/内网中继可以用 smtp://user:pass@host:25?starttls=0 关掉升级;不要对公网服务器这么写。

  • 邮件正文是 text/plain; charset=UTF-8 + base64,主题用 RFC 2047 编码(中文标题不会变乱码); 正文里附插件版本、DSH 版本、Node 版本、平台与时间,不含主机名、用户名或任何路径。

  • 限速在 Host 侧:30 秒内最多一封、1 小时内最多 5 封;失败返回 feedback-too-fast / feedback-too-many 并带 retry-after 头。只有真正投递成功才计入配额,中继故障不烧配额。

  • 日志只记 kind 与打码后的收件人(o***@example.com),从不记录正文。

  • 收件人从页面里彻底消失(两轮返工的终点:先删掉「显示收件人」开关,再按用户要求删掉掩码行与 「更换收件人」按钮):

    • /state 的 capabilities.feedback 只给 hasRecipient / toMasked 这类摘要,没有 to;
    • /state 的 config 里也删掉了 feedbackAddress,页面拿不到它;
    • 设置页不显示收件人的任何形式——没有掩码行、没有更换入口、没有地址输入框。收件人只由用户层配置 feedbackAddress 或环境变量 DSH_SIDECARD_FEEDBACK_TO 提供,那是部署方/维护者的事,填写者不用管;
    • 页面也不会拿到 mailto: URL:2.0.0 删掉了 /feedback-mailto 路由与其 URL 构造器, 未命中的方法一律得到 404 not-found。
  • From: 仍是维护者自己的 SMTP 账号,但填报人的邮箱会写进 Reply-To::QQ/163 会拒收不属于本账号的 From:(553),所以发件身份不变;填报人在「你的邮箱」里填了地址,Host 就把它作为 Reply-To: 写进邮件头, 维护者按「回复」直接回到填报人。该值在 Host 侧先过邮箱格式校验且不含 CR/LF,因此无法注入头字段; 留空则整行不写(匿名反馈)。

  • 两种就绪状态在界面上分开说,不混成一个「未配置」:没有收件人 → 说没有可用发信服务;有收件人但没有 SMTP 凭证 → 说「只缺发信凭据」;齐全 → 什么都不提示。两种情况都指向「复制内容」,不再留一条邮件应用捷径。

  • 关于「加密」的实话:这里的邮箱只是一次 SMTP 的收件地址,插件必须能解出明文才能发信,任何随插件 分发的「加密」都等于把钥匙一起发出去,只能算混淆——打包默认值用的 b64: 就是这种混淆,只挡正则抓取。 真正有效的保护是只存本机配置 + 明文不进浏览器 + 日志打码 + 可被环境变量整体接管——这四条都已实现。 仍然避免不了的一处是已经提交进 git 历史的作者邮箱(见下文)。

失败排查顺序:/state 的 capabilities.feedback.reason(它会直接说「账号或密码还没填全」这类话)→ 用户层配置是否整行都在(服务器/端口/账号/密码;端口必须是数字,字符串会被判非法)→ 环境变量是否 被 DSH 进程继承(改完要重启 DSH)→ SMTP 服务商是否要求应用专用密码(Gmail / QQ 邮箱 / 163 都要求, 登录密码会被拒)→ 端口与 TLS 写法是否匹配(465 用隐式 TLS;587 让服务器自己声明 STARTTLS)→ 中继是否在白名单里要求 FROM 与 USER 同域。 feedback-send-failed 的 message 是服务器原文,直接读它比猜快。


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

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

顺序承载面依赖失败时
1dsh-better-sidebar(侧边卡片插件)客户端服务 ctx.betterSidebar:registerTab + openTab(seed, scope);卡片 id 走 tab.meta.cardId,因此要求其 features 含 tabMeta记 surfaces.better.error(缺 tabMeta 时记 no-tab-meta 并跳过,而不是开一个空 tab),落到下一层
2内置浮层卡片(兜底)只需要 shell.overlay 槽位——(这是保底面,永远可用)
—DSH 原生右侧栏ctx.sidebarRightTabs(注册 tab 类型)+ ctx.sidebarRight(openTab),并占用槽位 sidebar.right.pane.tab / …tab.title不在 auto 链里:只有显式 sideSurface: 'native-rightbar' 才用它;注册失败会回滚(见下)
  • 装了 dsh-better-sidebar:卡片以它的 tab 形式出现在它的面板里,关闭卡片会同时 closeTab,不残留空 tab。 这是 auto 的第一选择。
  • 没装:自动使用内置浮层卡片,功能完全一致——这条路径是第二选择,也是保底路径。 浮层默认是贴帧右边缘的胶囊(一行高,只有状态与问题首句),点击才展开成完整卡片, 标题栏右侧的 ⇥ 可随时收起为胶囊、× 则删除这条追问对话(连同追问历史,不可撤销);它的宽度由 floatPlacement 计算, 只会占用对话区右侧那条预留带,不会压住正文(细节见 §4.1)。
  • 原生右侧栏为什么退出 auto:卡片被塞进右栏只是一个 tab,而这个 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"。

5.2 与 DSH 0.2.0-rc.1 的适配核对(2026-09-29)

本机实际运行的就是 0.2.0-rc.1(@deepseek-ai/dsh 的 next 通道;latest 仍是 0.1.7-rc.2)。核对分两层:

① 包内容层面(node tools/compat-probe.mjs 0.1.7-rc.2 0.2.0-rc.1): 16 项能力逐项与 0.1.7-rc.2 完全一致——shell.overlay、conversation.input.right、settings.section、 data-slot 锚点、原生右侧栏、sessions.using/retain、归档门、agent/assistant-stream、子代理、tools.schemas、 webServer 路由……全部 ✅。

② 在运行中的实例上实测:

检查结果
宿主半是否加载✅ /sidecard-ask/api/state 返回 dsh-sidecard-ask v1.2.0,sideEngine=true(provider spawn,fork)、capabilities.parent 正常
客户端半是否注册✅ shell.overlay 有 sidecard-ask(order 45、active)、conversation.input.right 有 sidecard-ask-session-probe、settings.section 有设置页
依赖的槽位与标准 prop✅ conversation.input.right 仍是 list/session,standardProps 仍含 sessionId 与 inputActions
依赖的客户端服务✅ sessions(retain/using/scope)、slots、locale、layout、theme 全在;sessions.fork/uiWorkspace.forkSession 只是新增了可选 onCreated,不影响既有调用
端到端作答✅ 真机 start→delta×31→status→done(1.36 s,streamSource=frames),只读工具白名单照常生效

③ 由此发现并修掉的真实缺陷(1.2.1):运行中的 sidebar.right.pane.tab 清单里只有 dsh-better-sidebar:sidecard-ask:card,没有我的原生 sidecard-ask:card。原因是两个适配器用了同一个 tab kind: better-sidebar 会把收到的每个 tab 描述符镜像注册进同一个原生注册表(sidebarRightTabs.register,band extension), 而该注册表对同 band 同 kind 的二次注册直接抛错(源码注释:"Everything else colliding on a kind throws")—— 后注册的那一方就此失败,而且在界面上不可见。修法:

  1. better-sidebar 承载面改用独立类型 sidecard-ask:card:workbench,两端 kind 不再重叠;
  2. contract-test 模拟该注册表的"重复 kind 抛错"规则,此类冲突今后会直接让测试失败;
  3. 设置页自检行改为同时报告两个适配器的状态(此前原生适配器的错误压根看不到)。

④ 修复后的运行实例复核(2026-09-29,1.3.0):sidebar.right.pane.tab 的占用者现在同时包含

key来源
sidecard-ask:card本插件的原生右侧栏承载面(1.2.1 之前完全缺失)
dsh-better-sidebar:sidecard-ask:card:workbench经 dsh-better-sidebar 镜像注册的承载面(独立 kind)

两者共存、均 active: true —— 即"同 kind 冲突"已被消除。auto 走的是 dsh-better-sidebar → 内置浮层卡片这条链(1.8.3 起原生右侧栏只在显式 sideSurface: native-rightbar 时使用),两个强制选项都仍可用。

5.3 与 DSH 桌面版(Electron)的适配核对(2026-09-30)

桌面版与命令行/web 版不是同一套运行形态,核对结论如下。

维度事实(实测)
桌面 AppDeepSeek Harness.exe 0.2.0-rc.2(resources\app-update.yml 更新通道 nightly,源 download.deepseek.com/dsh-desk/feeds/win-x64/)
运行形态Electron-as-Node 跑 app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\index.js;框架包全部打包在 app.asar 内(121 MB 归档,普通 shell/glob/grep 读不到,只能靠运行实例的 inspection 查)
自带运行时resources\runtime\versions.json:node 24.18.1 + pnpm 11.7.0(装插件用的是它自带的 pnpm)
profileDSH_PROFILE=desktop → C:\Users\34332\.dsh\profiles\desktop,与 web 版的 profiles\web 互相独立:两者各有自己的 package.json/node_modules/dsh.profile.bundles,插件必须分别安装
本插件安装状态桌面 profile 的 package.json 中为 "dsh-sidecard-ask": "link:D:/Users/34332/AI/dsh-sidecard-ask",且已在 dsh.profile.bundles 内 → 无需改动即加载
宿主半GET http://127.0.0.1:19387/sidecard-ask/api/state → dsh-sidecard-ask v1.3.1、sideEngine=true(provider spawn,fork)、capabilities.parent 正常、配置落点 ~/.dsh/sidecard-ask/config.json
客户端半由本插件自己的自检上报读出:version=1.3.1、slots={overlay: shell.overlay, settings: settings.section, sessionProbe: conversation.input.right}、native.available=true、better.available=true 且 better.version=0.24.1
端到端真机 start→reasoning×106→delta×46→status→done,1.89 s,done.text 是真实答案(子代理 + 流式帧 + 只读工具白名单全部正常)
客户端服务目录与 web 版完全一致(layout / locale / sessions / slots / theme / timer / uiWorkspace / workspaces),无桌面专属差异

桌面版用到的 dsh-better-sidebar 是 0.24.1(比上轮核对的 0.22.1 新):0.22.1 → 0.24.1 的 lib/types/client/service.d.ts 差异是纯增量——target?: 'right' | 'bottom' 增加 'side'(右侧栏第二窗格), 并新增可选 preferNewPane?: boolean;SIDEBAR_FEATURES 仍含 tabMeta,dsh.client.inject 未变。 本插件不传这两个字段 → 不受影响(默认落点行为未变)。

桌面版特有的适配修改(1.3.2):窗口拖拽区。 桌面外壳会把顶层元素标记为窗口拖拽区 (-webkit-app-region: drag),落在拖拽区内的点击会变成拖动窗口而不是点中 UI。同生态的 dsh-better-sidebar 为此专门带了一条防御(html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}

  • [data-dsh-panel-host]>*{-webkit-app-region:no-drag})。本插件的浮层此前没有这条防御,现已在样式表里为 每个自有根节点声明 -webkit-app-region:no-drag (.dsa-layer/.dsa-trigger/.dsa-pop/.dsa-stack/.dsa-card/.dsa-toast/.dsa-settings),并加回归测试锁定该规则。 该属性在浏览器里无效,不影响 web 版。

桌面形态下确认「不需要改」的两点:

  • styles.insert / host.call 属于动态运行时的插件能力(inspection 的 Builtin 定义就是 "Plain-JavaScript symbols available to a dynamic Client half")。本插件是静态客户端半 (__ModuleLoader__.load + require('react')),官方形态就是手工插入 <style>——同为静态形态的 uiskin-theme 在源码里注明 "the static twin of the dynamic runner's styles.insert";宿主通信走自有的 /sidecard-ask/api 路由;
  • 名为 shortcuts 与 conversation 的客户端服务都不在运行实例的服务目录里(inspection 明确报 no catalogued Service named ...)。所以"并入宿主快捷键注册表"与"使用 composer 草稿服务"两条路都不存在: 本插件继续自带 keydown 监听(快捷键可配置)与 PLACEHOLDER: composer-draft 降级路径。

仍未验证的部分(如实记录):桌面窗口内的视觉与点击行为(本工作区无 GUI 自动化,无法点按验证); Alt+Q 是否会撞上桌面 App 的菜单加速键(Electron 的菜单加速键由菜单先处理,页面的 keydown 可能收不到; 若冲突,在设置页改成 Ctrl+Alt+Q 之类即可);Electron 下 navigator.clipboard 的写入权限(已有"选中文本"降级兜底)。

5.5 2026-10-24 复核(v2.1.2 / 桌面 0.2.1-rc.3 × 网页 0.2.1-rc.3)

本轮把双端都升到 0.2.1-rc.3 后重新取证,完整报告见 CROSS-END-AUDIT-2.1.1.md。 结论先行:插件可加载、可用(宿主半路由注册成功、客户端半被 ClientModuleRegistry 组装并由 /plugins 路由取到, GET /sidecard-ask/api/ping 实机 200),默认承载路径(侧边卡片插件 / 浮层 / 输入区)全部正常; 原生右侧栏这条可选路径此前在 rc.3 上是坏的(D0/D2),2.1.2 已修。

编号rc.3 事实影响2.1.2 处置
D0tab 记录只有 {id,kind,contentId,title},body 无 tab 属性、无 tab.meta原生栏卡片恒走「已关闭」兜底 + 600 ms 后被挪去浮层hostCardIdOf() 三级回退,含适配器记录的 cardId
D1SubagentStartRequest/Capabilities 无 toolFilter、无 personasideTools: readonly 没有载体新增 sideAgentPreset(agentPreset 是唯一真实收窄通道);2.1.10 起 readonly 无载体即拒绝作答,默认值改为 inherit(2.1.11 才把 bundle 补丁也对齐)(“探针拒写”从未实现,相关表述已删)
D2控制器无 closeTab(kind, options)关闭卡片后 tab 残留改用 openTabs.getSnapshot() + closeIn(sessionId, tabId)
D3locale 快照是 {active, locales, revision}切换语言不跟随先读 active
D4persona 能力位不存在persona 拼进 prompt(无损失)保持,文档标注
P1end 帧只有 outcome,无 stopReasonSSE 该字段恒空前端取 result.stopReason 兜底
P2webRuntime 服务不存在trustedHosts 恒空 ⇒ 非 loopback 403改读 connection.trustedHosts
P3无 engines.dsh安装期无版本门禁声明 >=0.1.2-rc.1 <0.3.0
P6.rightbar / [data-dsh-panel-host] 全树 0 命中后备选择器白跑已从锚点阶梯与观察器移除
P7conversation.input.for() 不在服务目录草稿降级为第二顺位保持防御式最后一档

仍然存在的结构限制(非缺陷):内置右栏的 tab body 拿不到 tab,useTabInfo() 是它给自家 body 的通道, 插件 bundle 无法 import ⇒ 原生承载的"一张卡一个 tab"做不到,只能是按 kind 单例(同时一张卡)。 auto 本就不选原生栏,这条限制只影响显式开 native-rightbar 的人。

5.4 2026-10-22 复核(v2.1.0 / 桌面 0.2.0-rc.2)

上一轮之后桌面端与网页端的形态都变过,本轮把能静态取证的部分重新核了一遍,完整报告见 CROSS-END-AUDIT-2.0.2.md(含实测数字与未验证清单)。要点:

维度事实对插件意味着什么
宿主版本落差桌面 dsh-desktop 0.2.0-rc.2(包内 dsh 同版本);网页 0.2.1-rc.2适配矩阵要按两端分别列,不能写成"DSH 0.2.x"
渲染内核Electron 43.3.0 = Chromium 150.0.7871.212(~/.dsh/electron/version 亦为 43.3.0);网页端取决于访问者浏览器(本机对照 Edge/Chromium 155)renderRichText 在 150 与 155 上实测同量级(8 k 0.2–0.3 ms,64 k 1.2–2.0 ms)
自定义 schemedsh-app://app/ 特权:standard/secure/supportFetchAPI/corsEnabled/stream/codeCachefetch + 流式读取可用,不需要 EventSource
请求转发forwardWebRequest:只放行 origin=dsh-app://app,删 host/origin/cookie/sec-fetch-site 后注入宿主 cookie,duplex:"half"2.0.2 起的会话门在桌面端靠这条链路通过;插件侧 origin 缺失时被判为可信(isTrustedRequest)
权限主窗口 setPermissionRequestHandler:非 media 一律放行;登录窗/侧栏 webview 一律拒绝并拦下载剪贴板与通知有权限;导出下载走主窗口,不受拦截
通知全包 无 setAppUserModelIdWindows toast 可能不弹;notifyOnDone 默认关,打开后仍以卡片状态为兜底
菜单加速键全包 Alt+Q 0 命中外壳不抢默认快捷键(输入法/其它程序仍可能抢)
插件产物缓存/plugins/* 被壳改写为 cache-control: no-store每次加载重新拉 349 KB(localhost,传输≈0,代价在解析)

另:2.0.2 CHANGELOG 里"桌面端在本机冷启动即崩"的结论,2026-10-22 未能复现——本次冷启动成功、 宿主在 19387 正常服务。该条已按"与安装包/启动方式相关,需按版本复验"处理,不再当作既定事实。


5.6 2026-11-03 复核(v2.1.9 / dsh-better-sidebar 0.28.2)

本轮把适配核对的对象从上表的 0.22.1(网页)/ 0.24.1(桌面)更新到本机实际运行的 0.28.2(SIDEBAR_SERVICE_VERSION = "0.28.2",SIDEBAR_FEATURES 14 项),并修掉四处 按旧模型写的假设。证据分三类:源码级(读 0.28.2 的 lib/types/client/service.d.ts 与 src/client/*.ts)、替身复现(test/ 下的 0.28.2 形状替身)、真机(本轮未做,见末尾)。

0.28.2 的关键事实(相对 0.22.1 的漂移)
#事实对适配的影响
1组件类 seed 的 openTab 实际落到 DSH 原生右栏(service.ts:1120-1145),不再进 better 自己的 workbenchbetter 与 native 共用同一个物理容器;dockRail 的锚点会指到别人的页面
2tab id 由宿主 mint(tab1…),seed id 被忽略,closeTab(seed.id) 是 no-op关闭必须用 CardHost 记录的真实 id(2.1.6 S1 已做)
3同 kind 每会话只有一个 page 地址(无 multiple)同会话再开一张 = 导航,旧卡必须迁回浮层(2.1.6 S2 已做)
4isTabEnabled(type) 禁用后 openTab warn + return,不抛错禁用态会被误判成「承载面未证实」并弹假故障(2.1.9 A5 修)
5每个注册类型自动进 + 菜单,并生成 guide 胶囊点开是一张无 cardId 的空页(2.1.9 A4 用 hidden: true 修)
6OpenTabSeed.target 支持 right/bottom/side,另有 preferNewPane 与 split pane本插件不传 target(默认 right);分栏时 panel 级回退会横跨两栏
7非当前会话的 openTab 是排队,不是拒绝;只有 on-screen 才 mount600 ms 渲染证明会把排队中的卡误判为失败(2.1.9 A6 修)
8镜像注册的 id 是 dsh-better-sidebar:<id>、kind 是描述符 id两个适配器同 kind 相撞的旧缺陷已清除;但 closeRailRow 需容忍带前缀的 kind

.d.ts 里 openTab 的注释写「每次 open 落在 bottom workbench」,而 OpenTabSeed.target 的注释写默认 'right' 是原生右栏——两者矛盾,只有 src/client/service.ts 给出真值。 下一位维护者不要只看 .d.ts。

本版修掉的适配缺陷
编号缺陷修法
A1跨会话关闭/删除不关 better tab(进程级单槽 cardId 误判归属)归属改为按 (kind, session) 记(owners Map);「本会话有别的卡持有才拒绝」,会话未知时退回单槽
A2刷新后宿主还原的 better tab 无人认领、没有出口空页渲染「关闭此页」;只有本页真的挂载过该 tab 才自动关(别的窗口的 tab 不抢)
A3dockRail 把浮层卡铺到另一张卡自己的页面上CardHost 根节点声明 data-dsa-card-id;findRail 拒绝不属于本层的 body,全被拒时 data-dsa-dock="occupied"
A4+ 菜单两条「点开即空页」胶囊descriptor 加 hidden: true(保留可 open,只是不进菜单);原生 guide 那条缺宿主语义材料,仍会显示
A5禁用的 tab 类型仍被 auto 选中并报假故障available() 读 isTabEnabled,禁用即 reason='tab-disabled',直接落浮层
A6非当前会话的卡被 600 ms 证明迁走证明只对「当前屏会话」武装
A7自检看不出「到底有没有落栏」上报增 railTabs(宿主清单里本插件行数)与 mountedTabId(最近一次真实挂载的宿主 tab id)
安全相关(同一轮,详见 §十二)
  • /config 改 feedbackSmtpHost/Port/Secure/User 任一项 → 已存授权码作废。否则「空密码 = 保留旧值」 等于把凭据交给新主机,而 AUTH PLAIN 是明文。
  • /config 写 sideTools: "inherit" 需要 confirmInheritTools: true。这不是安全边界 (同源脚本可以伪造这个字段),只让这次提权必须是有意的;同源信任边界由宿主权限决定。
  • 只读护栏改为 fail-closed:工具注册表答了却认不出任何只读工具时,除非另有 agentPreset 收窄,否则拒跑(新错误码 no-readonly-guard,卡片文案中英齐全)。
  • cardId 改为不可预测(card-<base36 时间>-<序号>-<随机 6 位>);better tab 标题不再携带问题原文 (0.28.x 会把 title/meta 随布局持久化);tab.meta.cardId 增加「属于本会话」校验。
0.28.2 新 API 的准入条件(采用前必须满足)
  • updateTab(tabId, { title, path, meta }, sessionId):① 只允许 embeddedTabIds 里的自持 tabId; ② sessionId === card.sessionId;③ 永不带 path(那等于给出「在后台会话打开任意文件路径」的原语)。
  • stateSubscription / getSnapshot:只用来读,禁止用快照的 sessionId 重定向在飞请求。
  • pluginSettings:只放 UI 偏好,绝不放卡片正文或选中文本。
仍未验证(如实记录)
  • 真实浏览器 / Electron 页面里的点按与视觉:本机无 GUI 自动化。dockRail 的 occupied、 hidden 之后的 + 菜单、跨窗口清扫都没有被真机执行。
  • 桌面端:desktop profile 同样是 dsh-better-sidebar ^0.28.2,§5.3 的 0.24.1 核对已过期; 本版按同一套源码事实处理,但没跑桌面。
  • macOS / Linux:零用例,仓库没有 CI。
  • openTabs 是否跨窗口共享(app.asar 内的 sidebar-right 读不到):因此「自动清扫」只采用 「本页挂载过」这一按页成立的判据,没有做 boot 期盲扫。

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

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

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

// 现状:结构性子集 + try/catch,任何一步不成立就置 error 并降级
ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => { ... })
  • 已实测的调用形态(0.2.1-rc.3):registry.register({ id, kind, title, guide: [{ id, order, title, description }] })、 controller.openTab(kind, { params, revealIfOpened })、槽位 sidebar.right.pane.tab(keyed,注册项只有 key, 没有 inject)、关闭走 closeIn(sessionId, tabId)(tab 行来自 controller.openTabs.getSnapshot())。
  • rc.3 的硬限制:该槽位的 body 拿不到 tab、也拿不到 navigation.params(内置 body 靠 useTabInfo(), 插件无法 import)⇒ 卡片 id 只能由适配器在 open() 时记下(surfaces.native.cardId)。这也是原生承载 按 kind 单例的原因。
  • 未逐版本实测的部分(占位):guide[].icon、canOpen/patterns、closeTarget/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|diagnose), 不依赖任何 harness 内部 RPC;若未来 harness 提供正式的同进程 RPC,替换点就是 client.js §3 的 postJson/streamAsk。
  • POST /diagnose(客户端自检上报):宿主看不到浏览器里的客户端半,所以客户端在启动、槽位落点变化、 承载面回退时把自己的一份受限摘要(版本、承载面、区域锚点、槽位落点、两个适配器的可用性与原因、侧边卡片插件版本与能力) POST 给宿主;随后 GET /state 的 value.client 就能读到它。宿主侧对上报做白名单 + 长度截断(未知键丢弃、 字符串截断到 200 字符、能力列表最多 40 项),所以页面即使被注入垃圾也不会污染状态面。 这条通道是在两次"客户端静默失效只能靠人工翻槽位清单才发现"之后补上的。

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

3. client.js §8c —— 答案写回输入框(1.8.0,已做真机探针)

2026-10-17 用隔离 DSH_HOME + 无头 Edge(插件不参与)对真实 DSH 页面做了探针, 脚本见 .compat-report/scripts/composer-probe.mjs,证据见 .compat-report/evidence/p2-probe-2026-10-17.json,三条硬结论:

问题结论证据来源
composer 的真实 DOMLexical contenteditable:<div contenteditable="true" role="textbox" data-composer-input="true" data-lexical-editor="true">,槽位 conversation.composer页面探针
native setter + input 事件能否同步N/A 且无效:不是 textarea,没有 value setter;document.execCommand('insertText') 返回 false、文本不变。有效的是合成 beforeinput(inputType:'insertText'):文本落地、发送按钮由禁用转可用页面探针
有没有官方写入面有。槽位标准 prop inputActions 暴露 captureInsertion() / insertText(text, span) / setDraft(text) / submit()。sessions.using(...).binding.session.prompt 只负责提交,不能写草稿产物层核验(见下),不是页面探针

1.8.1 勘误:上面第三行原先被写成"探针的第④条结论",但 composer-probe.mjs 的正文 从未探测 inputActions(只有脚本头的注释承诺过)——这条声明当时没有可核验的证据。 经复核,声明本身成立,现改由产物层证据背书:npm pack @deepseek-ai/dsh-client-ui-conversation@0.2.0-rc.2 与 @0.2.1-rc.2 解包后, lib/types/client/contract/input.d.ts 含 captureInsertion(): TokenSpan 与 insertText(text: string, span: TokenSpan): boolean(且文档明确 "returns false when the draft changed",与实现"拒绝先于写入、故可安全回落 DOM 路径"的假设一致), lib/client.js 含 var SessionInputShell = class。逐条命中与行号见 .compat-report/evidence/p2-inputactions-contract-2026-10-17.json。

因此实现是三级阶梯:inputActions.insertText(重新 captureInsertion 后可重试一次)→ DOM beforeinput(追加到末尾)→ 失败时 toast。脚本设定的 Range 不被 Lexical 采纳, 所以 DOM 路径只能追加、不能定位光标;"替换原文"仅在捕获到文本框内选区时提供。 替换点:client.js 搜 P2-2,函数 insertIntoComposer / composerWriteSupport。


七、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、两端可用时选 better-sidebar;float-placement 钉住自动链里不出现原生右侧栏
≤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 真机验证记录(1.6.0,2026-10-11;1.7.1 复核,2026-10-16)

1.6.0 把 §7.1/§7.2 的产物层证据升级为真机证据:最近 10 个发布版(0.1.5-rc.3 → 0.2.1-alpha.2, 按 npm 发布时间)全部在隔离 DSH_HOME 中真实安装、启动并读取运行时能力面; 0.2.0-rc.2(= npm latest)与 0.2.1-alpha.2(= npm next)另做无头浏览器真机验证。

  • 0.1.5-rc.3 … 0.2.1-alpha.1:引擎可用,provider=spawn/fork,0 启动告警。
  • 0.2.1-alpha.2:1.5.1 真机不可用 → 1.6.0 已修复。该版本把子代理 API 从 subagents.start(provider, request) 重构为 subagents.startActivation({provider, label, request, signal, delivery}),1.5.1 的能力门只认旧方法 (provider 其实注册成功,自检却显示"不可用")。1.6.0 按运行时方法面自动选择两代 API,新 API 用 delivery:'caller' 保持"答案不进主对话",流式帧按子会话 id 归因。
  • 更早的 0.1.2-rc.1 … 0.1.4 仍只有产物层证据(§7.1/§7.2 的降级分支仍保留,双 API 调度不改动旧路径)。
  • 生态侧:25 个侧边卡片/侧边栏类插件做了产物扫描 + 4 组共载真机,localStorage 键 / 路由前缀 / tab kind / patch id / 配置目录 / Alt+Q 零硬冲突;旧名包 dsh-selection-followup@1.0.1 共载时的双重触发由 1.6.0 的页面内家族单例选举解决。完整矩阵、复现脚本与截图见 .compat-report/。

1.7.1 复核(2026-10-16):0.2.1-alpha.3 / 0.2.1-alpha.4——两版均无需任何代码改动(基线 = 1.6.0 已适配的 alpha.2)。

  • alpha.3(2026-10-14 发布)与 alpha.4(2026-10-15 发布,alpha 通道当前指向)在隔离 DSH_HOME 中 从零安装(官方 CLI 装入 npm pack 产物)、启动并做无头 Edge 断言:28/28 × 两版全过—— 含 subagents.startActivation 实调出真实 childId、三个槽位落点、选区恰 1 个触发按钮、Alt+Q 恰 1 个弹层、 0 启动告警、0 未捕获页面错误。
  • 产物层复核:16 项能力矩阵在 alpha.2/3/4 上逐项一致;13 个相关包的逐文件 SHA256 diff 证实 alpha.3 → alpha.4 宿主侧零变化(dsh-subagent 的 lib/index.js 逐字节一致),alpha.4 的增量全在 客户端侧且为纯增量;alpha.2 → alpha.3 的子代理能力模型重排(persona/toolFilter 退出协商、 agentPreset 进入)不命中插件消费面——1.6.0 的字段门控原样覆盖。
  • 插件四套自测重跑 1004 项全过;完整证据(脚本 / 原始输出 / 截图,wb-* 前缀为本轮独立复核产物) 见 .compat-report/,过程细节(含两处探针口径修正的如实记录)见该目录 COMPATIBILITY-REPORT-alpha3-alpha4.md §8。

2.1.2 复核(2026-10-24):0.2.1-rc.3(桌面端与网页端同为 rc.3)——发现 4 项契约漂移并全部修复(详见 §5.5 与 CROSS-END-AUDIT-2.1.1.md)。

  • 实机证据:桌面端进程(0.2.1-rc.3,Electron 43.3.0)在 127.0.0.1:14582 上 GET /sidecard-ask/api/ping 返回 200(前缀路由注册成功),GET /plugins/??dsh-sidecard-ask/client.js&rev=… 返回 369 873 字节(客户端半被 ClientModuleRegistry 组装并从 /plugins 路由可取)。
  • 产物级证据:@deepseek-ai 下 307 个包全为 0.2.1-rc.3;tools/compat-probe.mjs 在 rc.2/rc.3 上矩阵完全一致。
  • 契约漂移:D0(右栏 tab 无 tab.meta)、D1(无 toolFilter/persona)、D2(无 closeTab)、 D3(locale 字段名 active)——这三项会让原生右侧栏与语言跟随在 rc.3 上表现异常,2.1.2 已修; 默认承载路径(侧边卡片插件 / 浮层 / 输入区)不受影响。
  • 四套自测重跑 1839 项全过(verify 57 / contract 1427 / float-placement 84 / smoke 271)。

7.4 更早版本仍未做真机验证的部分(如实标注)

  • 上表所有 ✅/❌ 都是包内容层面的证据(字符串/签名存在于该版本的发布产物),不等于"插件在该版本上跑起来过"。 真机覆盖为 0.1.5-rc.3 → 0.2.1-alpha.4(1.6.0 与 1.7.1 两轮实测)与 0.1.7-rc.2(§10.1 的历史实测)。
  • 在 0.1.2-rc.1 … 0.1.4 这几个版本上,只验证了"所需 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)
找不到原文(1.8.0)签名匹配失败 / 页面无文本节点 → toast「原文已变化或不在当前视图」或「当前环境不支持定位」,不静默locateText → locatePassageRange(client §8b)
写回失败(1.8.0)无输入框 / 草稿已变 / 写入被拒 → 分别是「找不到可写入的输入框」「输入框内容已变化…」「插入失败,请手动复制」;只在真能写时才渲染按钮insertIntoComposer / canInsertAnswer(client §8c)

错误码对照(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. 点主按钮后提问框出现(顶部同样有一排芯片),焦点在输入框,引文预览显示选中文本; 底部「作答位置」是两枚带内联图标的芯片(气泡=主对话,带右轨的框=侧边卡片):选中侧为品牌实底 + 白字 600 + 内白环, 未选侧只有描边;深色主题下选中侧改深墨字(品牌色在深色被提亮到 #5b9cf5,白字只有 2.79:1,深墨字 6.57:1); 控件带 role="group" 与 aria-pressed,键盘 Tab 可见品牌色焦点环;
  4. 输入问题按 Enter → 提问框关闭,右下角出现卡片,状态先为「作答中…」并逐字增长;
  5. 结束后状态变「已完成」;底部出现「改问 / 复制 / 继续追问 / 关闭」;答案里代码块的右上角有「⧉ 复制代码」;
  6. 点「复制」→ 出现「已复制」提示;点「继续追问」→ 输入第二条问题(追加一轮),卡片内容重置换行后继续流式; 点「改问」→ 修改当前问题后重新生成这一轮(不追加历史);
  7. 点「关闭」→ 卡片消失;若承载面是 better-sidebar/原生右侧栏,对应 tab 也应关闭。 7b. 设置页「快捷动作」区:可停用内置动作、给动作配快捷键(如 Alt+T)、添加自定义动作 (名称 + 问题指令,指令里可写 {selection});配好后选中文字直接按快捷键等价于点芯片。 7c. 答案完成后:问题下方显示「模型 spawn · 耗时 Xs」,答案下方出现 「更简短 / 更详细 / 举个例子 / 译成英文」芯片;卡片可 Tab 聚焦,R 重试、C 复制、Esc 失焦。 7d. 在选中文字上右键:应出现本插件菜单(追问 + 前三个快捷动作);设置页关闭「选区右键菜单」后, 右键恢复为宿主原生菜单。 7e. 设置页勾选「完成后桌面通知」会请求通知权限;授权后切到其它标签页/窗口,作答结束时应弹系统通知。 7f. 设置页「历史追问卡片」:可搜索、恢复到浮层、单条删除、清空;「历史卡片保存数量」设 0 后新卡片不再写盘; 注意:关闭浮层卡片不会删历史(1.8.0 起历史只在管理中心删除)。 改发到主对话(carrier: 'main')的问答不算卡片记录(2.1.1 起):它只出现在主对话里,不写 localStorage,也不出现在历史管理器与「导出全部 .md」;旧版本写进去的 main 条目在读取时丢弃。 7g. 回答风格(1.8.0):设置页把「回答风格」切成「精简」并保存 → 下一次追问的答案应明显更短、 不复述原文;切「详细」→ 应看到展开与依据。自检办法:GET /state 的 config.answerStyle 应为所选值; 若 provider 不支持 persona,指令会内联进提示词(卡片上的 provider 行为不变)。 在「自定义指令」里写一句可辨认的要求(如"每条都用表格")→ 下一次答案应体现;超过 500 字时 保存会被拒绝(invalid-config),不会半保存。 7h. 插入到输入框(1.8.0):在主对话输入框里先随便打几个字(不要发送),再选中一段正文 → 点「润色」芯片 → 答案完成后卡片上出现「插入到输入框」;点它 → 输入框末尾应追加答案, 并提示「已插入到输入框,请按 Enter 发送」。已发送的消息不得被改动。 判据:插入后输入框里的内容 = 原有草稿 + 答案(末尾追加,不是光标处插入);再点一次会再追加一份。 若在输入框里先选中一段文字(文本框或 textarea),才可能看到「替换原文」。 7i. 查看原文(1.8.0):在聊天区选中一段文字追问 → 答案完成后点「查看原文」→ 页面应滚动回那段 原文并高亮约 4 秒;连点两次不应叠加高亮。把会话滚动到别处、或让该消息被虚拟列表回收后再点 → 应看到提示「原文已变化或不在当前视图」(这不是 bug,是如实降级)。 7j. 建议追问(1.8.0):设置页打开「答案末尾给出建议追问」→ 下一次答案末尾应出现「建议追问」 与 2–3 个芯片;点其中一个 → 它以新一轮追问发出,旧问答进入卡片历史。 关掉开关 → 新答案不再出现该行。模型没有按格式输出时(可人为在提示词上干扰)应只有答案、没有芯片。 7k. 导出(1.8.0):卡片上点「导出 .md」→ 应下载 sidecard-ask-YYYY-MM-DD-HHMM.md, 内容依次是问题标题、> 原文 引用块、答案原文(Markdown 原样);设置页历史区点「导出全部 .md」→ 一条历史一节、--- 分隔。浏览器若拦截下载,应看到「导出失败:浏览器不允许下载」而不是静默。 7l. 多版本(1.8.0):对一张已完成卡片点「改问」并改成另一个问题 → 重新作答后卡片上出现 「第 1 版」;点它 → 显示旧问题 + 旧答案并标注"正在查看历史版本(只读)",点「返回最新」回到新答案。 刷新页面后历史卡片仍能切版本(v3 持久化)。连续改问 6 次后只保留最近 5 版。
  8. 浮层形态与位置(1.5.0):默认应是一个一行高的胶囊(状态 + 问题首句),点它才展开成完整卡片; 展开后标题栏右侧有收起为胶囊(⇥)与删除这条追问对话(×)——× 会把该卡片从浮层与追问历史里 一并移除,无法撤销(有内容时会先弹插件内确认层;点浮层底部的「关闭」则只把它摘离浮层、历史仍保留在 设置页的历史管理器里)。2.0.0 起不再有「隐藏整个浮层 / 只留一个计数圆点」这套形态。 用 F12 选中浮层根节点([data-dsa-root])读三个值自检:
    • data-dsa-float-mode 应为 capsule / full(铺满右栏时为 dock);
    • data-layer-width 应等于 min(floatMaxWidth, 对话区右边界到帧右边缘的可用带宽);
    • data-layer-bottom 应等于 帧高 - 输入框([data-composer-card])顶边 + 8(空会话/测不到时为 16)。 关键判据:胶囊/卡片的左边缘不得越过对话区([data-slot^="conversation"])的右边界, 下边缘不得落在输入框上。 把窗口从 1920px 拖到 900px、再开关一次右侧栏,浮层应随之变窄而不是往左压住正文; 只有窗口窄到可用带宽不足 240px 时才允许压入(此时是设计内的降级,不是 bug)。
  9. 占满右侧栏(1.8.3,dockRail):先把右侧栏打开(任意面板,例如「团队任务板」),再追问一次 → 答案卡应铺满标签条下方的整块内容区:右边缘贴帧边、下边缘贴窗口底,右栏的标签条仍然露出可点。 点卡片标题栏的 ⇥(收起为胶囊)→ 卡片立即退出占满、回到常规浮层,右栏内容区恢复;点 × 则是删除这条 追问对话(同样退出占满,但卡片与历史都不再存在)。 自检:F12 读浮层根节点,data-dsa-float-mode 应为 dock,data-dsa-dock 也应为 dock; .dsa-stack 上的 data-dsa-dock-body 应等于卡片宽度(px)。没铺满就看 data-dsa-dock: no-rail = 没找到右栏,rail-too-small = 找到了但太窄或在帧外,off = 开关关了,mode = 不是完整形态。 把右栏拖到 260px 宽 → 应自动回退成常规浮层(低于 280px 不铺满);再拖回 400px → 应重新铺满。 设置页取消「右侧栏打开时占满它」并保存 → 同一操作不再铺满。

失败信号:卡片停在「作答中…」不动 = 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/float-placement.mjs   # 浮层定位矩阵
node test/smoke-test.mjs        # 冒烟:装载、槽位、渲染、错误路径

当前规模(本机实测,四项均 exit 0):verify 57 项 + contract 1377 项 + float-placement 82 项 + smoke 262 项 = 1778 项全部通过(较 1.8.4 的 1359 多 419 项;contract 从 989 涨到 1377,新增大多来自 反馈功能与 2.0.x 的跨端修复: 纯函数边界、parseSmtpUrl / readSmtpFromEnv 两种写法(含复用 DSH_CRB_SMTP_URL)、b64: 收件人解码、 「环境变量优先于用户层配置」的优先级回归、Reply-To: 组头、限速闸门,以及一条真 socket 的 /feedback 端到端——本地 SMTP 假中继逐条断言 AUTH PLAIN、信封、DATA、编码后的主题与正文、429 与 retry-after, 并断言正文没有进日志;1.9.1 又把内联表单的断言整块换成「横幅一行 + 弹窗表单」的断言)。

四个脚本都以 process.exitCode 反映结果,失败会列出具体条目;测试会把 DSH_HOME 指向临时目录,不会污染真实配置。 1.8.4 为 1359 项:contract 989 → 998,新增 6 条钉住选区动作条的版式(单一面板、品牌实底主按钮、 chips 去边框、分隔线规则、深色墨字覆盖、面板自带 maxWidth 上限)。 1.8.3 为 1350 项:contract 972 → 989,新增 8 项钉住设置页的「占满 右侧栏」复选框、3 个新 i18n 键,以及承载面 auto 的新顺序;float-placement 38 → 78,新增 40 项覆盖 railDock 的夹取/最小尺寸/ clipped 语义、真机上 dock 没生效那次发现的三个缺陷(残留的 rail 变量、右栏开合不触发重算、 右栏锚点必须优先用插件自己的 [data-dsa-rail-host] 节点),以及「自动链里不出现原生右侧栏」这条顺序断言)。更早的 1.8.1 为 1277 项,其中 contract 由 956 → 972 是提问框「作答位置」分段芯片的 16 项——唯一 .dsa-seg、两个按钮、恰好一个 aria-pressed、各带一枚内联图标、品牌实底规则与深色覆盖规则;点击切换本身不可测,原因见 1.8.2 的 CHANGELOG。

真机探针(不属于四套自测):.compat-report/scripts/composer-probe.mjs 需要 playwright-core 与本机 Edge,对真实 DSH 页面回答 composer DOM / 哪条写入路径真能改变 编辑器状态 / CSS.highlights 是否可用三个问题(证据 .compat-report/evidence/p2-probe-2026-10-17.json)。 它不探测 inputActions——那是通过槽位 prop 交给插件的面,页面侧脚本读不到;该写入面的存在性 改由产物层证据背书(.compat-report/evidence/p2-inputactions-contract-2026-10-17.json)。 探针是 P2-2 的落地依据,不能被四套自测替代,也不替代人工观感确认。

1.5.0 之前这里还放过 tools/balance-check.mjs 与 tools/i18n-check.mjs 两个「替代检查」。 它们已被删除:前者用「正则字面量 vs 除号」的启发式判断,把 8 个源文件全部误报为残损; 后者定位词典块的缩进假设写错,解析出 0 个键并误报上百条缺失。跑不了测试时宁可标注「未验证」, 也不要引入会撒谎的检查器——真实把关始终只有上面那四个 test/*.mjs。

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 对已安装实例做真实联调(本机实测通过)

2.1.0 起除 /ping 外的每个接口都走宿主会话门。 不带浏览器登录 cookie 的脚本调用会拿到 401 {"error":{"code":"unauthorized"}},这表示"没登录",不表示插件没起来——先用第 0 步区分。 页面内自检(设置 → 侧边卡片追问 → 运行自检)读的是同一份 /state,不需要任何脚本。

# 0) 探活:唯一免鉴权的接口,只回插件版本与宿主时钟
(Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/ping -UseBasicParsing).Content
# → {"ok":true,"value":{"version":"2.1.1","at":1792...}}    200 = 插件宿主半已加载

# 1) 宿主接口与能力(archived 父会话检测也在这里)——需要会话
#    在本机浏览器已登录的前提下复用会话:先 -WebSession 登录,或把浏览器的 Cookie 头带进来
(Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state `
   -Headers @{ Cookie = $cookie } -UseBasicParsing).Content

# 2) 端到端流式作答:把 sessionId 换成当前会话 id($env:DSH_SESSION_ID);同样需要会话
#    期望事件序列:start → reasoning*/delta* → status → done(done.text 是真实答案)
$body = @{ id='probe'; question='用一句话说明这句话在说什么。';
           selection='槽位出口会带上 data-slot 标记。'; zone='chat';
           sessionId=$env:DSH_SESSION_ID } | ConvertTo-Json -Compress
$tmp = Join-Path $env:TEMP 'probe.json'
[System.IO.File]::WriteAllText($tmp, $body, (New-Object System.Text.UTF8Encoding($false)))
(Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/ask -Method POST `
   -Headers @{ Cookie = $cookie } `
   -ContentType 'application/json; charset=utf-8' -InFile $tmp -TimeoutSec 240 -UseBasicParsing).Content

# 3) 客户端半的真实状态(浏览器跑的那份代码/槽位落点/两个适配器)
#    页面加载后 GET /state,看 value.client —— 这是唯一能从页面外读到客户端状态的通道
((Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state `
    -Headers @{ Cookie = $cookie } -UseBasicParsing).Content | ConvertFrom-Json).value.client

客户端状态样例(1.3.0 起):

{
  "at": 1790555771338,
  "version": "1.3.0",
  "surface": "native-rightbar",
  "zoneAnchors": true,
  "sessionKnown": true,
  "slots": { "overlay": "shell.overlay", "settings": "settings.section", "sessionProbe": "conversation.input.right" },
  "native": { "available": true },
  "better": { "available": true, "version": "0.22.1", "features": ["badge", "…", "tabMeta"] }
}

native.reason / better.reason 会写明为什么某个承载面不可用(例如 no-tab-meta 或注册表的报错原文)—— 1.2.1 修掉的那个 tab kind 冲突正是靠这类信息才定位到的。

两半代际不一致是常态:宿主半只在重启 DSH 时更新,客户端半在页面加载时更新。 所以"新客户端 + 旧宿主"很常见——此时 /diagnose 还不存在,客户端会识别这一点、只提示一次并停止重试 (not-found / SPA 兜底 HTML / 不可达三类失败),不会每次加载都刷警告。

2026-09-28 在 DSH 0.1.7-rc.2 上的实测结果(provider=spawn、sideTools=readonly): start x1 → reasoning x181 → delta x90 → status x1 → done x1,耗时 2.6 s,done.text 为真实模型答案。 这条记录同时说明:子代理启动、只读工具白名单(与真实工具表求交后)、流式帧桥接、SSE 线协议、运行结束后的 dispose() 都是在真实宿主上跑通的, 不只是单元测试里的替身。

改过 index.js 之后必须重启 DSH 才会加载新的宿主半代码:插件行在 profile 启动时已被 Loader 导入,重新启停该行不会重新读盘 (本机实测:set_plugin 关开、remove_bundle + install_bundle 都不会刷新已加载的模块;只有进程重启会)。 client.js 的改动则需要刷新页面(客户端产物带修订号,刷新即重新拉取)。


十一、架构与数据流

选中文本 (client §5)
   │  page → data-slot 链 → 区域归类 → 触发按钮
   ▼
提问框 (client §9)  ── 作答位置选择 ──┐
   │                                  │
   │ side                             │ main
   ▼                                  ▼
POST /sidecard-ask/api/ask      ctx.sessions.using(id).binding.session.prompt(...)
   │  (SSE)                          │  ↓ 草稿降级 / 复制兜底
   ▼                                 答案随主对话原生流式
Host: ctx.subagents.start('spawn')
   → 子代理(自己的会话、零父上下文)
   → agent/assistant-stream 帧 → sink.send('delta')
   → run.result 结算 → sink.send('done') → run.dispose()
   ▼
卡片流式渲染 / 复制 / 继续追问 / 关闭

为什么侧边卡片用子代理而不是"另开一个会话":子代理由进程内 spawn provider 建立,拥有自己的会话与系统提示、不继承父上下文, 且随 dispose() 释放——这正是"独立附属"的语义;同时它不需要往工作区/侧边栏注册新会话,用户不会在会话列表里看到一堆临时条目。


十二、隐私与安全

  • 选中文本与问题会发给你自己配置的模型(走 DSH 既有的模型路由),插件不做任何额外外发。
  • 插件只在本地写一个配置文件:<DSH_HOME>/sidecard-ask/config.<profile>.json(原子写入:先写 .tmp 再 rename; POSIX 下目录 0700、文件 0600,因为里面可能有 SMTP 授权码)。
  • 插件路由带两层围栏:回环 / 可信域 + 同源校验(Host 头、Origin、Sec-Fetch-Site),然后交给宿主的 connection.requestRejection() 校验 DSH 浏览器会话(宿主没挂载该服务时才只保留第一层)。 因此未登录的外部程序拿到 loopback 地址也读不到 /state、更发不出 /ask。
  • 2.2.0 起,改写状态的请求必须有 Origin:GET / HEAD / OPTIONS 之外,缺 Origin 一律 403 no-origin。 浏览器页面一定会带,脚本、原生客户端、同机其它 App 不会带 —— 而手机上所有 App 共享网络命名空间, "回环"并不等于"应用隔离",所以"没有 Origin"不能再被当成"同源"。 这条是给移动端补的:它同时也让"用脚本直接打 /ask"变成显式失败,而不是悄悄花掉一次子代理。 必须说清它挡的是什么:Origin 是 Fetch 规范里的 forbidden header name,页面脚本无法设置, 所以对浏览器(含手机 WebView)这是一条真控制;而 curl、原生客户端、同机其它 App 可以自由写这个头, 对它们只是摩擦,不是边界。 ⇒ 在宿主没挂 connection 服务(围栏降级为 fence-only)的部署里,一个愿意写 Origin 的本机进程仍能通过第一层, 之后没有任何控制。真正的门始终是宿主的 connection.requestRejection()(DSH 登录会话)。 不要因为这条加固就降低对宿主门的依赖。
  • 侧边作答者默认继承父代理的工具(sideTools: inherit)。改成只读(readonly)时,插件不会假装只读: 宿主若不提供只读载体(agentPreset 或 toolFilter),追问会被直接拒绝(readonly-unavailable), 错误文案会告诉你改回「继承父代理工具」。
  • 提示词里选中文本被包在围栏中并显式声明"这是数据不是指令",降低提示注入面;围栏长度取选区里最长的反引号串再加一, 所以选区里出现三连、四连反引号也不会提前闭合围栏。

十三、发布流程

npm whoami                     # 先确认登录态;本机用的是 granular Publish token
npm test                       # 四个套件 + locale 一致性检查(2.1.13 起,CI 跑同一条)
npm publish --access public    # 1.0.1 那次被 npm 暂存(staged)后才转正;再遇到可用 --otp=<验证码>
git push origin main           # 仓库:https://github.com/HERO476/dsh-sidecard-ask
git tag v1.5.0; git push origin v1.5.0   # 版本号与 package.json 一致

改名/迁移时的额外两步(1.1.0 实际做过):

plugin_manager(action="remove_bundle", target="<旧包名>")      # 卸旧 bundle(同时移除 profile 里的 link)
plugin_manager(action="install_bundle", target="D:\...\<新目录>")
npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>"       # 旧名指路,避免别人再装到废弃名

版本号同时出现在三处,必须一致:package.json 的 version、index.js 的 PLUGIN_VERSION、client.js 的 api.version (test/verify.mjs 会断言前两处;第三处在契约测试中同样被断言)。

发布前必查"地址类信息"(1.0.0 的教训):包名不能只查 npm —— 还要查 GitHub 仓库名是否已被占用,以及 awesome-dsh-plugin 收录索引 里是否已有同名插件(本插件第一次改名就是因为漏查了后者)。


十四、已知限制(含未在本机验证的部分)

  1. 区域归类是启发式的:data-slot 名称来自 harness 自己;其它插件若在自己的面板里不再经由槽位渲染, 该区域会被归为"其它区域"——用 captureZones: auto + showInUnclassified: true(默认)仍然可用,或改用 captureZones 限制。 本机实测能识别:conversation.*(聊天)、rightbar / sidebar.right.* / 名字含 task·todo·schedule·team·job·plan 的面板(任务)。

  2. 浏览器内的视觉与交互:1.6.0 起用无头 Edge(Playwright,系统浏览器 channel:'msedge')做真机验证, 脚本见 .compat-report/scripts/,截图见 .compat-report/shots/——选区浮按钮、Alt+Q 弹层、共载单实例、 引擎自检均已在 0.2.0-rc.2 与 0.2.1-alpha.2 上自动核对;0.2.1-alpha.3 / alpha.4 的复核见 §7.3。桌面 Electron 的视觉观感仍只有 2026-09-30 的人工实测(§5.3)。 已做的保障是:只使用 --dsw-alias-* 主题令牌、只在槽位内渲染(shell.overlay / settings.section / conversation.input.right)、不写 document.body、不 import harness 客户端包。 2b. 1.5.0 的浮层改动:四个测试脚本已在本机实跑通过(各 exit 0),但页面里的观感仍未验证: 写这个版本的前半段,本工作区的 shell 工具整体不可用(任何命令都返回 SetNamedSecurityInfoW failed (Win32 5): grantWrite(<工作区>)),所以当时的改动没有跑过任何命令, 我也没有资格说它"验证过"。后半段该故障解除后已补跑:node --check 对全部源文件通过, verify 53 / contract 296 / float-placement 38 / smoke 138 全部 0 failed。 其中 float-placement 第一轮曾报 7 条红,逐条查完全部是我把测试写错(旧实现的越界 fixture 算错、 avail 期望值用了带右栏的用例、capped 语义写反、以及去找一个源码里并不存在的 setFloatMode('capsule') 调用点——真实实现走 floatControl('capsule','floatCollapse','⇥') 的 onFloatMode 回调),实现本身没有因此改动。 仍未验证的是浏览器内的视觉观感:浮层实际宽度是否等于预算、是否真的不越过对话区右边界, 必须按第九节 A 路径第 8 条在真实页面上确认(读 data-dsa-float-mode 与 data-layer-width),测试替代不了。 此外,本版一度新增过 tools/balance-check.mjs 与 tools/i18n-check.mjs 两个"替代检查", 现已删除:它们本身有错(前者把 8 个源文件全误报为残损,后者解析出 0 个词典键并误报上百条缺失), 属于会撒谎的检查器——跑不了测试时应当标注"未验证",而不是用一个错的检查器去证明没问题。

  3. 原生右侧栏承载面未在真实宿主上验证:本机 profile 虽已启用 @deepseek-ai/dsh-client-ui-sidebar-right,但没有浏览器控件去确认 tab 是否真的渲染。 为此加了一道渲染证明:适配器接受了打开请求后 600ms 内若没有观察到我们的卡片组件挂载,就自动把卡片移到内置浮层(流式内容不丢,因为卡片数据在 store 里)。

  4. 主对话承载的"发送"依赖客户端会话服务:ctx.sessions.using(...).prompt(...) 在会话未被保留/不可用时降级为草稿写入, 降级时会在卡片上明确显示,需要手动按 Enter。

  5. 宿主半代码更新必须重启 DSH:本机实测 set_plugin 关开与 remove_bundle + install_bundle 都不会让已加载的模块重新读盘。 本仓库当前文件比运行中的宿主模块新(1.0.1 的「归档父会话不再一刀切 / 持久化 chunk 流式源 / 能力门控 / 路由降级」), 重启后这些改动才生效;/state 的 version 与 capabilities.parent 字段可以直接确认是否已加载新版。

  6. "安装启动"真机验证从 0.1.5-rc.3 起:1.6.0 实测 0.1.5-rc.3 → 0.2.1-alpha.2,1.7.1 复核 0.2.1-alpha.3 / alpha.4(见 §7.3); 更早版本只有产物层证据——验证到的是"这些版本缺哪些 API + 插件对每个缺失都有分支",分支本身由 1267 项自测覆盖。

  7. 原文定位(1.8.0)不承诺 100% 命中:签名匹配依赖"那段文字此刻仍在 DOM 里"。虚拟列表回收、 消息折叠、流式重渲染、富文本把文本拆进多个节点都会导致找不到——此时卡片明示 「原文已变化或不在当前视图」,不会静默无反应。高亮优先 CSS.highlights(不改 DOM, 本机 Edge 155 实测可用),不支持时退化为 window.getSelection() 选中,观感不同属预期。

  8. 写回输入框(1.8.0)只保证"追加到末尾":DSH 的 composer 是 Lexical contenteditable, 脚本设定的 Range 不被采纳(探针实测),所以无法精确插到光标处;document.execCommand('insertText') 在该编辑器上无效(返回 false、文本不变)。写入优先走槽位 inputActions.insertText, 取不到时用 beforeinput 追加。已发送的消息不会被改动;"替换原文"只在捕获到文本框内选区时出现, contenteditable 不提供替换。真实页面里的按钮点击与手感尚未人工验证(见 CHANGELOG 1.8.0 未验证栏)。

  9. 导出(1.8.0)不往 document.body 插节点:下载锚点是游离节点,Chromium/Safari 会触发下载, 个别浏览器(老 Firefox)可能要求节点在文档里——那种情况下插件只报「导出失败」,不会伪造成功。 Electron 外壳的下载落盘目录与权限未在本版复测。

  10. 建议追问(1.8.0)依赖模型遵循格式:模型不写 <followups>、写半个、或写得过长时, 客户端只会"少几个芯片",答案正文照常显示;这是设计内的降级,不是错误。该功能不额外发请求。

  11. 多版本(1.8.0)只在「重试 / 改问」时产生:普通「继续追问」把旧问答存进 history(作为上下文), 不进 versions;每卡上限 5 条,超出丢最旧。v3 持久化默认保留 v2/v1 旧键(回退旧版本仍可读 自己的数据);唯一的例外是「清空全部历史」——显式清空会同时删除 v3/v2/v1 三代键(1.8.1)。 这条例外是必需的:只删当前键会让 readPersisted() 的 v3→v2→v1 回落把刚清掉的历史从旧键读回来, 刷新一次就复活(1.8.1 复现并修复)。

  12. 接口鉴权(2.0.2 起,2.1.0 补 /ping):除 /ping 外的每个接口都要宿主会话;从页面外读 /state 会得到 401 unauthorized。/ping 是唯一免鉴权的探活口,只回 {version, at}—— 不要往里加字段,它存在的理由是"能给监控用",不是"能省登录"。

  13. 活动卡片上限 20(2.1.0):超过后最旧的已完成卡片自动隐藏(open:false,数据仍在, 历史里可恢复),正在流式的卡片永不隐藏。这不是删除,但会"少一张卡片在浮层里"。

  14. 冷启动回放预算 200 K 字符(2.1.0):最新一张必定展开,其余按累计字符预算展开; 超预算的旧卡片以折叠态回来,点开才解析。改 historyMaxCards 不改变这个预算。

  15. 部署前缀不带尾斜杠时(2.1.0):API 基址会先按 document.baseURI 猜一次,若得到 404 则回退到根绝对路径重试一次。这是唯一安全的判别方式(401/403 都不能用来改基址), 代价是每次页面加载最多多一次 404 往返——正常部署下不会触发。

  16. /state 会回配置文件的绝对路径(provenance.persistedPath,2.1.5 复核后保留):这是设置页 用来说明"到底读的是哪个文件"的诊断字段,且该接口要求宿主登录(页面外拿到的是 401 unauthorized)。它不是无鉴权暴露面;如果你把 /state 的响应转发到别处,请自行删掉 这个字段。同一响应里的 persistedError 会带上 errno 的中文提示(EACCES / EPERM / ENOSPC / EBUSY 等),原始错误码仍保留在 error.code 上供程序分支。

  17. /cancel 校验归属(2.1.5 起收紧,2.1.7 补全):run 记录了发起会话时,请求体必须带相等的 sessionId;不带或带错一律按"没找到"返回 cancelled: false(不回 403,避免把别的会话的 run id 是否存在当成探测面)。只有 run 本身不知道归属(老引擎、无会话)时才放行——所以"老客户端不带 sessionId 也能取消"只在那种情况下成立,2.1.6 之前那句"向后兼容优先于收紧"已不再准确。

  18. 选中原文与答案明文落在 localStorage(2.3.0 明确写清):默认保留最近 historyMaxCards (10)张卡的原文片段与完整答案(单字段上限 64 K 字符),键是 dsh-sidecard-ask:cards:v3。它只在本机、只在同一个 origin 内,但同源的任何脚本 (包括同页其它插件)都能 localStorage.getItem 读走。想不留正文:把 historyMaxCards 设为 0(不再写盘,已有历史保留到显式清空)。桌面端与网页端是两个 origin,各自一套存储。

  19. 写操作要带 x-sidecard-ask: 1(2.3.0 起):/ask /cancel /config /reset /diagnose /feedback 都要求这个请求头,缺了回 403 missing-request-marker。插件的页面 客户端自动添加;用 curl / 脚本调接口时请显式带上。/state 是读,不需要。它的作用是挡 「跨站表单/图片式请求」和「随手猜路径的本机页面」——同源脚本仍能设这个头,所以它不是授权 边界,真正的门依旧是宿主会话认证。

  20. 只支持主文档选区(2.1.12 明确):readSelection() 只读 window.getSelection(),iframe 内的 选区不触发浮按钮。PDF/HTML 预览面板里选中文字就是这种情况——不是坏了,是边界。跨源 iframe 在浏览器里本来就不可能拿到选区,同源 iframe 也需要宿主提供 API。

  21. profile 隔离只在提供 profileContext 的宿主上生效(2.1.12 明确):桌面端 runtime 有该服务, 写 config.<profile>.json;网页端(0.2.2-alpha.2 依赖树)既没有该服务、也不设 DSH_PROFILE, 因此两端共用同一份 config.json。/state.provenance.profileSource 如实报 profile-context / env / shared,设置页不必猜"到底读的哪个文件"。

  22. 绑定地址只在存在鉴权闸门时才进信任集(2.1.12):绑到具体内网地址且 connection 服务缺席时, 只有回环可达(/state.provenance.authGate 报 fence-only)。闸门缺席时围栏是唯一控制,把 内网绑定地址也信任进去等于把 API 交给同网段任何主机。

  23. 跨端历史各存各的(2.1.12 更正):桌面端页面在 dsh-app://app/、网页端在 http://127.0.0.1:<port>,是两个 origin ⇒ 两套 localStorage,storage 事件不跨端; 只有同端多标签之间才同步。此前代码注释写成"桌面窗口 + 浏览器两个页面共享",是错的。

関連プラグイン