dsh-sidecard-ask 1.9.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0
4
+
5
+ **主题:追问卡片标题栏的 `×` 从「隐藏整个浮层」改成「删除这条追问对话」——一次 Breaking 的语义重定义,顺带把「隐藏浮层」这套能力整体下线,并移除反馈表单里「用邮件客户端发送」这条出口。**
6
+
7
+ `×` 原来是全局开关:点一下整个浮层缩成一个带计数的圆点,再点圆点恢复。用户实际想要的是**删掉眼前这条追问**,
8
+ 而当时删除只能绕两条路走(卡片底部按钮 / 设置页历史管理器),且 `×` 的「隐藏」在多卡场景下会连着别的卡片一起收走。
9
+ 2.0.0 把 `×` 直接对齐到用户心智,并**不再保留**「隐藏整个浮层」这套能力(不是挪位置,是不留替代入口)。
10
+
11
+ ### Breaking
12
+
13
+ - **`×` 语义变更**:卡片标题栏最右侧的 `×` 由「隐藏整个浮层」改为「**删除这条追问对话**」,`title` / `aria-label`
14
+ 改用新键 `cardDelete`。删除会同时从活动卡片列表与持久历史里移除该卡片,**不可撤销**。
15
+ - **`floatMode` 移除 `off`**:枚举从 `['capsule', 'full', 'off']` 收窄为 `['capsule', 'full']`,设置页该下拉只剩两项。
16
+ 旧配置里存过 `'off'` 的**无需手工迁移**:宿主 `sanitizeLayer` 判定为非法值 → 忽略并在 `problems` 里提示,
17
+ 回退默认 `capsule`(不写迁移代码,也不静默改写用户配置)。
18
+ - **小圆点 launcher 删除**:浮层不再有「只剩一个计数圆点」的形态,相关渲染分支、样式与恢复入口一并删除。
19
+ 浮层只剩 `capsule`(一行高胶囊)与 `full`(完整卡片栈,铺满右栏 dock 时为 `dock`)。
20
+ - **删除 4 个 i18n 键**:`setFloatModeOff`(设置页「隐藏」项)、`floatHide`(`×` 的旧 title)、
21
+ `floatRestore`(从圆点恢复浮层的入口)、`floatCount`(圆点计数)。`floatStreaming` / `floatDone` 保留(胶囊仍在用)。
22
+
23
+ ### 新增:删除交互
24
+
25
+ - **空卡直接删**:该卡无任何已完成追问轮(`history.length === 0`)且主回答正文与思考过程都为空、且不在作答中
26
+ (非 `pending` / `streaming`)时,点 `×` **直接删除,不弹确认**。
27
+ - **其余情况弹插件内轻量确认层**(`DeleteConfirmDialog`,不使用 `window.confirm`):标题「删除这条追问对话?」,
28
+ 正文说明会同时从浮层与追问历史里移除、不可撤销;已有追问轮次时追加一行「含 {n} 轮追问」;
29
+ 两个按钮「取消」/「删除」(删除为危险色 `.dsa-btn-danger`)。
30
+ - **无障碍与误触防护**:`role="dialog"` / `aria-modal="true"` / `aria-label` 取标题;默认焦点固定在被标记 `autoFocus`
31
+ 的「取消」上(不落在危险按钮,防连按 Enter);`Esc` 取消(`stopPropagation`,不冒泡到卡片自身的 Esc 处理)、
32
+ 点遮罩取消(仅当 `event.target === event.currentTarget`);确认层打开时卡片的 `R` / `C` / `Esc` 快捷键全部失效。
33
+ - **流式中删除**:先 `abort()` 中止请求 + 通知宿主 `cancel`,丢弃该卡进行中的增量缓冲(`pendingDeltas` / `pendingReasonings`),再执行删除。
34
+ - **删除动作顺序固定**:按同一 `id` 从 `history` 与 `cards` 双移除 → 立即写盘持久化 → 关闭该卡已打开的侧栏 / host tab 表面
35
+ → 浮层 toast「已删除这条追问对话」(2600ms 自动消失)。
36
+ - **删除后重排**:剩余卡片按原顺序塌缩补位;删掉最后一张时 `total === 0` → 浮层整体不渲染(无空态)。
37
+ - **上下文隔离**:每张卡的追问上下文本就按卡隔离,删除不影响其它卡片;后续生成只读取剩余卡片自身历史。
38
+
39
+ ### 移除:反馈表单的「用邮件客户端发送」出口
40
+
41
+ - **UI**:反馈弹窗从三条出口减到两条(`发送` / `复制内容`),删掉 `data-feedback-mailto` 按钮与
42
+ `sendFeedbackByMailApp()`;没有发信服务时弹窗仍按两种就绪状态说明缺什么,并指向「复制内容」。
43
+ - **Host**:删掉 `POST /feedback-mailto` 路由、`buildMailtoUrl()` 构造器与 `API_METHODS` 里的条目。
44
+ 未命中的方法由既有兜底返回 `404 not-found`(`error.message` 带方法名),旧页面调用会明确失败而不是静默。
45
+ - **`/state`**:`capabilities.feedback.transport` 在未配置时由 `'mailto'` 改为 `'none'`;`reason` 只回 SMTP 的原因。
46
+ - **i18n**:`feedbackMailto` 删除,`feedbackMailtoOnly` 重命名为 `feedbackNoCredentials`(文案改指向「复制内容」),
47
+ `feedbackUnconfigured` 同步改写;zh/en 两侧键集仍然完全一致。
48
+ - **理由**:这条出口只是把同一封邮件交给系统邮件应用,与「复制内容」的实际效果重叠,却让 Host 多出一个
49
+ 任何人都能调的 URL 构造接口(还会把明文收件地址拼进 URL)。删掉它,反馈的发送面只剩 SMTP 一条。
50
+ - **测试**:`test/contract-test.mjs` 删除 `buildMailtoUrl` / 路由 200 / 按钮存在三类断言,改为断言
51
+ `transport === 'none'`、`/feedback-mailto` 两次 404(有/无收件人)、`data-feedback-mailto` 不存在、
52
+ 两个退役 i18n 键在 zh/en 都是 `undefined`(`node test/verify.mjs` 的 `t()` 键扫描同时覆盖新键名)。
53
+
54
+ ### 清理:1.9.1 遗留的 10 个死 i18n 键
55
+
56
+ - **删掉 10 个 `feedbackSmtp*` 标签键**:`feedbackSmtpShow` / `feedbackSmtpHide` / `feedbackSmtpEnvNote` /
57
+ `feedbackSmtpHost` / `feedbackSmtpSecure` / `feedbackSmtpPassSaved` / `feedbackSmtpPass` / `feedbackSmtpFrom` /
58
+ `feedbackSmtpHint` / `feedbackSmtpSaveHint`。它们只属于 1.9.1 删掉的设置页 SMTP 表单,全仓已无任何
59
+ `t('feedbackSmtp…')` 调用点,留着只是让翻译者以为这些输入框还在。
60
+ - **配置键不动**:`feedbackSmtpHost` / `feedbackSmtpPort` / `feedbackSmtpSecure` / `feedbackSmtpUser` /
61
+ `feedbackSmtpPass` / `feedbackSmtpFrom` 作为 `config.json` 字段与 `client.js` 的 `CLIENT_DEFAULTS` 继续存在,
62
+ 按环境变量或用户层配置生效;删的只是同名的**界面标签**。
63
+ - **zh/en 同步删**,键集仍完全一致;`test/contract-test.mjs` 的退役键断言扩到 12 个键(含上一节的两个),
64
+ 两个字典都必须取到 `undefined`。
65
+
66
+ ### 兼容性与边界
67
+
68
+ - **不涉及**:无服务端会话存储、无多端同步、无遥测埋点(这些维度 N/A);删除不进「回收站」,**不提供撤销**
69
+ (无 Undo、无快照、无 5 秒窗口)。
70
+ - **承载面差异**:`×` 只出现在**浮层 / 内嵌浮层**(含 dock 到右侧栏的浮层卡片)上。被宿主 tab 承载的同一张卡
71
+ (`better-sidebar` / `native-rightbar` 独立标签页)**不提供 `×`**,沿用它自己的标签关闭 + 底部「关闭」——
72
+ 「关闭」只把卡片摘离浮层、**历史仍保留**在设置页历史管理器里,与「删除」是两种真实需求。
73
+ - **版本号**:`1.9.1 → 2.0.0`,三处同步(`package.json` 的 `version`、`index.js` 的 `PLUGIN_VERSION`、`client.js` 的 `CLIENT_VERSION`)。
74
+ - **测试**:`test/float-placement.mjs` 增补 2.0.0 行为断言(无 `dsa-launcher` / `floatRestore`、`floatControl('capsule')` 存在、
75
+ `onDelete` 已接线、无 `mode === 'off'` 渲染分支、`floatMode` 默认 `capsule`);`test/contract-test.mjs` 增补
76
+ `sanitizeLayer` 丢弃 `'off'`、设置页 option 序列为 `['capsule','full']`、卡片头恰有一枚 `aria-label === cardDelete` 的按钮、
77
+ `deleteCard` 的四类行为、4 个已删键为 `undefined`。
78
+ - **文档**:README 的配置表 / dock 形态说明 / 自测第 8–9 条同步改写,反馈章节的「三条出口」表改为两条并记下移除理由;
79
+ `docs/PRD-followup-delete.md` 出 v3 修订(D0 定稿 + 隐藏能力下线 + 规格逐条对齐已交付代码,路由清单去掉 `feedback-mailto`)。
80
+
3
81
  ## 1.9.1
4
82
 
5
83
  **主题:反馈入口重做——从「设置页里一整块常驻表单」改成「顶部一行横幅 + 居中弹窗」。**
package/README.md CHANGED
@@ -22,7 +22,8 @@
22
22
 
23
23
  > **1.5.0 增强一览**(详见 CHANGELOG):内置浮层不再压住主对话——宽度改由纯函数 `floatPlacement`
24
24
  > 按「对话区右侧可用带宽」计算(旧实现是固定 400px,在用户截图上压入正文约 113px);浮层默认是**一行高的胶囊**,
25
- > 可随时「收起 / 隐藏」,运行时的形态切换不写回配置;新增 `floatMode`(默认 `capsule`)与
25
+ > 可随时「收起为胶囊」,运行时的形态切换不写回配置;卡片标题栏右侧的 `×` 自 2.0.0 起是**删除这条追问对话**
26
+ > (连同追问历史一起删),不再是隐藏浮层;新增 `floatMode`(默认 `capsule`)与
26
27
  > `floatMaxWidth`(默认 340)两项设置;卡片阴影减半。
27
28
 
28
29
  > **1.4.0 增强一览**(详见 CHANGELOG):卡片**思考过程**(reasoning)可折叠展示并随答案流式渲染;
@@ -163,9 +164,9 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
163
164
  | `defaultCarrier` | `main` \| `side` | `side` | **默认作答位置**:提问框里仍可临时切换 | 立即 |
164
165
  | `sideSurface` | `auto` \| `native-rightbar` \| `better-sidebar` \| `flow` | `auto` | **窗口模式(侧边承载面)**:自动挑选(先 dsh-better-sidebar,再内置浮层卡片)/ 强制 DSH 原生右侧栏(不参与自动)/ 强制 dsh-better-sidebar / 强制内置浮层卡片 | 立即 |
165
166
  | `maxChars` | 200–60000 | `4000` | **最大字符数**:超出部分头尾保留、中间截断并标注 | 立即 |
166
- | `floatMode` | `capsule` \| `full` \| `off` | `capsule` | **内置浮层的默认形态**:胶囊(一行,最小遮挡)/ 完整卡片栈 / 只留一个计数圆点。运行时可即时收起、隐藏,但不写回本项——刷新后仍回到这里设的默认 | 立即 |
167
+ | `floatMode` | `capsule` \| `full` | `capsule` | **内置浮层的默认形态**:胶囊(一行,最小遮挡)/ 完整卡片栈。运行时可即时收起为胶囊,但不写回本项——刷新后仍回到这里设的默认。旧配置里存过 `off` 的会在启动时校验失败、回退为 `capsule`,并在 `problems` 里给出提示(`index.js` 的 `sanitizeLayer`),无需手工迁移 | 立即 |
167
168
  | `floatMaxWidth` | 240–560 | `340` | **内置浮层宽度上限**(px):实际宽度 = `min(本项, 对话区右侧可用带宽)`,可用带宽不足 240px 时才会压入对话区 | 立即 |
168
- | `dockRail` | 布尔 | `true` | **右侧栏打开时占满它**:答案卡铺满右栏标签条下方的内容区,读起来像栏内的一页,而不是 340px 的小浮层。收起为胶囊、关闭浮层、右栏宽度 < 280px 或高度 < 240px 时自动回到常规浮层;实现靠测量右栏 DOM 矩形,**没有**走 DSH 的右栏 API(见 §4.2 与 §6.1) | 立即 |
169
+ | `dockRail` | 布尔 | `true` | **右侧栏打开时占满它**:答案卡铺满右栏标签条下方的内容区,读起来像栏内的一页,而不是 340px 的小浮层。收起为胶囊、点卡片底部「关闭」、右栏宽度 < 280px 或高度 < 240px 时自动回到常规浮层;实现靠测量右栏 DOM 矩形,**没有**走 DSH 的右栏 API(见 §4.2 与 §6.1) | 立即 |
169
170
  | `shortcut` | 形如 `Alt+Q`、`Ctrl+Shift+K` | `Alt+Q` | **快捷键**:唤起提问框(`trigger` 允许时) | 立即 |
170
171
  | `captureZones` | `auto` \| `chat` \| `task` \| `chat+task` | `auto` | 只在哪些区域触发 | 立即 |
171
172
  | `showInUnclassified` | 布尔 | `true` | 无法归类的区域是否也触发 | 立即 |
@@ -181,7 +182,7 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
181
182
  | `answerStyle` | `concise` \| `standard` \| `detailed` | `standard` | **回答风格档位**:只给最小完整答案 / 现状 / 必要时展开并给出依据。指令追加在人格之后,**不会**覆盖"用提问的语言作答"与"选中文本是数据"这两条 | 下一次提问 |
182
183
  | `customPersona` | 字符串,≤500 码点 | `''` | **自定义指令**:追加在风格档位之后,以 `Additional user instruction:` 标注随每次提问发送(例如"尽量给可验证来源") | 下一次提问 |
183
184
  | `followUpSuggestions` | 布尔 | `false` | **模型生成的建议追问**:让模型在答案末尾用 `<followups>问1 \| 问2</followups>` 给出 2–3 个可点的下一步问题(客户端剥离该块并渲染为芯片,**不额外发请求**) | 下一次提问 |
184
- | `feedbackAddress` | 字符串(邮箱或 `b64:` 编码)\| `''` | 维护者收件箱(`b64:` 混淆存储) | **反馈收件邮箱**:设置页「反馈」弹窗把问题与建议发到这里。默认值指向维护者自己的邮箱,以 `b64:<base64>` 形式打包——**这是混淆不是加密**,`decodeAddress()` 就在同包里,只能挡住正则抓取;环境变量 `DSH_SIDECARD_FEEDBACK_TO` / `DSH_CRB_FEEDBACK_TO` **优先于**本项,置空则只留「用邮件客户端发送」回退路径 | 立即 |
185
+ | `feedbackAddress` | 字符串(邮箱或 `b64:` 编码)\| `''` | 维护者收件箱(`b64:` 混淆存储) | **反馈收件邮箱**:设置页「反馈」弹窗把问题与建议发到这里。默认值指向维护者自己的邮箱,以 `b64:<base64>` 形式打包——**这是混淆不是加密**,`decodeAddress()` 就在同包里,只能挡住正则抓取;环境变量 `DSH_SIDECARD_FEEDBACK_TO` / `DSH_CRB_FEEDBACK_TO` **优先于**本项,置空则反馈弹窗只留「复制内容」 | 立即 |
185
186
  | `feedbackSmtpHost` | 字符串,≤255 | `''` | **SMTP 服务器**。1.9.1 起只从**环境变量或用户层配置**读。**环境变量优先于配置**(`DSH_SIDECARD_SMTP_URL` / `DSH_CRB_SMTP_URL` 等能凑出完整端点时,配置里的整段不生效);只有用户层配置会存它,**不进仓库** | 立即 |
186
187
  | `feedbackSmtpPort` | 数字,1–65535 | `465` | SMTP 端口。465 配隐式 TLS,587 配 STARTTLS(服务器没声明 STARTTLS 时不会硬升级) | 立即 |
187
188
  | `feedbackSmtpSecure` | 布尔 | `true` | 隐式 TLS(465)。关掉则按 STARTTLS 处理 | 立即 |
@@ -223,7 +224,7 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
223
224
  - 位置 = 右栏**标签条之下**的内容区矩形,宽度取右栏宽度,高度取到底部;右栏自己的标签条仍然露出、可点。
224
225
  - 卡片在这个形态下没有圆角/描边/阴影(`.dsa-card` 的装饰被 `[data-dsa-float-mode="dock"]` 覆盖),只有一条左边线与右栏内容区分界。
225
226
  - 标题栏(追问 + 徽标 + ⇥ / ×)与底部操作行固定上下两端,中间 `AnswerBody` 独立滚动——长答案不再把操作行推出视野。
226
- - 收起为胶囊(⇥)、关闭(×)、或把 `dockRail` 关掉,都立即回到常规浮层;右栏宽度 < 280px 或高度 < 240px 时也回退(窄栏里铺满会把正文挤成每行 8–9 字)。
227
+ - 收起为胶囊(⇥)、删除这条追问对话(×)、或把 `dockRail` 关掉,都立即回到常规浮层;右栏宽度 < 280px 或高度 < 240px 时也回退(窄栏里铺满会把正文挤成每行 8–9 字)。注意 × 是**删除**:卡片从浮层与追问历史里一并移除;只想摘离浮层、保留历史请用底部的「关闭」。
227
228
  - 折叠的右栏不会被误用:`railOnScreen()` 会拒绝停在帧外(`left >= frameWidth - 1`)或尺寸不足的面板,量不到就回退,绝不把卡片画到帧外。
228
229
  - 矩形从哪来:**优先量本插件自己渲染在右栏里的 `CardHost` 节点**(`[data-dsa-rail-host]`)——它所在的位置按定义就是「一页正文」,不依赖任何未被文档承诺的标记;找不到时再按 `data-sidebar-right-*`、dockkit 世代、`.rightbar`、`sidebar.right` 槽逐代回退。
229
230
  - 什么时候重算:窗口改宽、右栏开合(只盯属性的 `MutationObserver`)、右栏自身改宽(`ResizeObserver`),以及挂载后的四次延时重测(罩住右栏的滑入动画)。流式刷新不会触发几何重算。
@@ -242,7 +243,7 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
242
243
  | `no-rail` | 没找到已展开的右栏(右栏关着,或这个构建的标记与预期不同) |
243
244
  | `rail-too-small` | 找到了,但宽 < 280px / 高 < 240px,或它停在帧外(折叠态被拒绝) |
244
245
  | `off` | 设置里的 `dockRail` 被关掉了 |
245
- | `mode` | 当前不是完整卡片形态(胶囊 / 隐藏),本来就不该铺满 |
246
+ | `mode` | 当前不是完整卡片形态(胶囊 / 折叠态),本来就不该铺满 |
246
247
 
247
248
  ### 4.3 选区旁的动作条(外观)
248
249
 
@@ -269,14 +270,17 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
269
270
  它把真正的设置项全挤到屏幕外,被否掉后重做成现在这种「一条横幅 + 一个弹窗」。表单字段:类型(问题 / 建议 / 疑问 / 其他)、
270
271
  标题、详细描述、可选的**你的邮箱**(填了就写进邮件的 `Reply-To:`,维护者按「回复」能直接回给你;留空即匿名)。
271
272
  **设置页上没有任何 SMTP 输入框**:发信凭据属于部署方/维护者,走环境变量或用户层配置(见下),填报人不用管。
272
- 三条出口,从首选到兜底:
273
+ 两条出口,从首选到兜底:
273
274
 
274
275
  | 出口 | 何时可用 | 说明 |
275
276
  |---|---|---|
276
277
  | **发送**(推荐) | 收件邮箱已配置 **且** SMTP 凭证可用(环境变量或用户层配置) | 插件直接把邮件投给 SMTP 服务器;成功后清空草稿 |
277
- | **用邮件客户端发送** | 只要有收件邮箱 | 拼一个 `mailto:` 交给系统邮件应用,**不需要任何凭证** |
278
278
  | **复制内容** | 总是 | 把同样的纯文本(含版本与环境)放进剪贴板,用户自己粘到任意渠道 |
279
279
 
280
+ > 1.9.x 曾有的第三条出口「用邮件客户端发送」(`POST /feedback-mailto` 返回拼好的 `mailto:` URL)已在
281
+ > 2.0.0 整体移除:它让插件多出一条谁都可以调的出口,却只是把同一封邮件交给系统邮件应用,与「复制内容」的
282
+ > 实际效果重叠。现在没有发信服务时,弹窗直接说明缺什么,并指向「复制内容」。
283
+
280
284
  **收件邮箱默认可用,也可被环境变量接管。** 打包默认值是维护者邮箱(`b64:` 混淆存储,见上表);
281
285
  环境变量 `DSH_SIDECARD_FEEDBACK_TO` 或 `DSH_CRB_FEEDBACK_TO`(与 dsh-conversation-recycle-bin 共用)
282
286
  **优先**于它。用户层配置落在 `$DSH_HOME/sidecard-ask/config.json`
@@ -331,20 +335,19 @@ DSH_SIDECARD_SMTP_FROM="user@example.com" # 可选,默认等于 USER
331
335
  - `/state` 的 `config` 里也**删掉了** `feedbackAddress`,页面拿不到它;
332
336
  - 设置页不显示收件人的任何形式——没有掩码行、没有更换入口、没有地址输入框。收件人只由用户层配置
333
337
  `feedbackAddress` 或环境变量 `DSH_SIDECARD_FEEDBACK_TO` 提供,那是部署方/维护者的事,填写者不用管;
334
- - 「用邮件客户端发送」点击时向 Host 要一条**拼好的** `mailto:` URL(`POST /feedback-mailto`),
335
- 浏览器只负责跳转,不自己拼地址。
338
+ - 页面也**不会**拿到 `mailto:` URL:2.0.0 删掉了 `/feedback-mailto` 路由与其 URL 构造器,
339
+ 未命中的方法一律得到 `404 not-found`。
336
340
  - **`From:` 仍是维护者自己的 SMTP 账号,但填报人的邮箱会写进 `Reply-To:`**:QQ/163 会拒收不属于本账号的
337
341
  `From:`(553),所以发件身份不变;填报人在「你的邮箱」里填了地址,Host 就把它作为 `Reply-To:` 写进邮件头,
338
342
  维护者按「回复」直接回到填报人。该值在 Host 侧先过邮箱格式校验且不含 CR/LF,因此无法注入头字段;
339
- 留空则整行不写(匿名反馈)。`mailto:` 回退路径里没有 `Reply-To` 可用,同一个地址同时留在正文 `Contact:` 行。
343
+ 留空则整行不写(匿名反馈)。
340
344
 
341
- - 三种就绪状态在界面上分开说,不混成一个「未配置」:没有收件人 → 提示配置;有收件人但没有 SMTP →
342
- 提示「缺的只是发信凭据,现在可用『用邮件客户端发送』」;齐全 → 什么都不提示。
345
+ - 两种就绪状态在界面上分开说,不混成一个「未配置」:没有收件人 → 说没有可用发信服务;有收件人但没有 SMTP
346
+ 凭证 → 说「只缺发信凭据」;齐全 → 什么都不提示。两种情况都指向「复制内容」,不再留一条邮件应用捷径。
343
347
  - **关于「加密」的实话**:这里的邮箱只是一次 SMTP 的收件地址,插件必须能解出明文才能发信,任何随插件
344
348
  分发的「加密」都等于把钥匙一起发出去,只能算混淆——打包默认值用的 `b64:` 就是这种混淆,只挡正则抓取。
345
349
  真正有效的保护是**只存本机配置 + 明文不进浏览器 + 日志打码 + 可被环境变量整体接管**——这四条都已实现。
346
- 仍然避免不了的两处:`mailto:` URL 本身必然含明文(由 Host 组好直接交给系统邮件应用,不经页面渲染),
347
- 以及**已经提交进 git 历史的作者邮箱**(见下文)。
350
+ 仍然避免不了的一处是**已经提交进 git 历史的作者邮箱**(见下文)。
348
351
 
349
352
  失败排查顺序:`/state` 的 `capabilities.feedback.reason`(它会直接说「账号或密码还没填全」这类话)→
350
353
  用户层配置是否整行都在(服务器/端口/账号/密码;**端口必须是数字**,字符串会被判非法)→ 环境变量是否
@@ -369,7 +372,7 @@ DSH_SIDECARD_SMTP_FROM="user@example.com" # 可选,默认等于 USER
369
372
  这是 `auto` 的**第一选择**。
370
373
  - 没装:自动使用内置浮层卡片,功能完全一致——**这条路径是第二选择,也是保底路径**。
371
374
  浮层默认是**贴帧右边缘的胶囊**(一行高,只有状态与问题首句),点击才展开成完整卡片,
372
- 标题栏右侧的「收起 / 隐藏」可随时收回;它的宽度由 `floatPlacement` 计算,
375
+ 标题栏右侧的 ⇥ 可随时收起为胶囊、× 则删除这条追问对话(连同追问历史,不可撤销);它的宽度由 `floatPlacement` 计算,
373
376
  只会占用**对话区右侧那条预留带**,不会压住正文(细节见 §4.1)。
374
377
  - **原生右侧栏为什么退出 `auto`**:卡片被塞进右栏只是一个 tab,而这个 tab 的生命周期不归插件管——
375
378
  卡片关闭后右栏仍留着一条读作「该卡片已关闭」的空标签,和你自己的右栏标签抢位置,
@@ -712,16 +715,19 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
712
715
  「第 1 版」;点它 → 显示**旧问题 + 旧答案**并标注"正在查看历史版本(只读)",点「返回最新」回到新答案。
713
716
  刷新页面后历史卡片仍能切版本(v3 持久化)。连续改问 6 次后只保留最近 5 版。
714
717
  8. **浮层形态与位置(1.5.0)**:默认应是一个**一行高的胶囊**(状态 + 问题首句),点它才展开成完整卡片;
715
- 展开后标题栏右侧有「收起为胶囊」(⇥)与「隐藏浮层」(×)——隐藏后只剩一个带计数的圆点,点它恢复。
718
+ 展开后标题栏右侧有**收起为胶囊(⇥)**与**删除这条追问对话(×)**——× 会把该卡片从浮层与追问历史里
719
+ 一并移除,**无法撤销**(有内容时会先弹插件内确认层;点浮层底部的「关闭」则只把它摘离浮层、历史仍保留在
720
+ 设置页的历史管理器里)。2.0.0 起不再有「隐藏整个浮层 / 只留一个计数圆点」这套形态。
716
721
  用 F12 选中浮层根节点(`[data-dsa-root]`)读两个值自检:
717
- - `data-dsa-float-mode` 应为 `capsule` / `full` / `off`;
722
+ - `data-dsa-float-mode` 应为 `capsule` / `full`(铺满右栏时为 `dock`);
718
723
  - `data-layer-width` 应等于 `min(floatMaxWidth, 对话区右边界到帧右边缘的可用带宽)`。
719
724
  **关键判据:胶囊/卡片的左边缘不得越过对话区(`[data-slot^="conversation"]`)的右边界**。
720
725
  把窗口从 1920px 拖到 900px、再开关一次右侧栏,浮层应随之变窄而不是往左压住正文;
721
726
  只有窗口窄到可用带宽不足 240px 时才允许压入(此时是设计内的降级,不是 bug)。
722
727
  9. **占满右侧栏(1.8.3,`dockRail`)**:先把右侧栏打开(任意面板,例如「团队任务板」),再追问一次 →
723
728
  答案卡应铺满**标签条下方**的整块内容区:右边缘贴帧边、下边缘贴窗口底,右栏的标签条仍然露出可点。
724
- 点卡片标题栏的 ⇥(收起为胶囊)或 × → 卡片立即退出占满、回到常规浮层,右栏内容区恢复。
729
+ 点卡片标题栏的 ⇥(收起为胶囊)→ 卡片立即退出占满、回到常规浮层,右栏内容区恢复;点 × 则是删除这条
730
+ 追问对话(同样退出占满,但卡片与历史都不再存在)。
725
731
  自检:F12 读浮层根节点,`data-dsa-float-mode` 应为 `dock`,`data-dsa-dock` 也应为 `dock`;
726
732
  `.dsa-stack` 上的 `data-dsa-dock-body` 应等于卡片宽度(px)。**没铺满就看 `data-dsa-dock`**:
727
733
  `no-rail` = 没找到右栏,`rail-too-small` = 找到了但太窄或在帧外,`off` = 开关关了,`mode` = 不是完整形态。
package/client.js CHANGED
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
47
47
  * the Host and the module's `api.version` both read it, so a report can
48
48
  * never claim a generation the browser is not actually running.
49
49
  */
50
- const CLIENT_VERSION = '1.9.1'
50
+ const CLIENT_VERSION = '2.0.0'
51
51
 
52
52
  /**
53
53
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -476,6 +476,12 @@ window.__ModuleLoader__.load({
476
476
  answerEmpty: '(没有内容)',
477
477
  cardClosed: '该卡片已关闭',
478
478
  cardMovedToFlow: '该卡片已移至浮层显示',
479
+ cardDelete: '删除这条追问对话',
480
+ cardDeleteTitle: '删除这条追问对话?',
481
+ cardDeleteBody: '会同时从浮层和追问历史里移除,且不可撤销。',
482
+ cardDeleteRounds: '含 {n} 轮追问',
483
+ cardDeleteConfirm: '删除',
484
+ cardDeleted: '已删除这条追问对话',
479
485
  reasoningTitle: '思考过程',
480
486
  copy: '复制',
481
487
  copied: '已复制',
@@ -528,7 +534,6 @@ window.__ModuleLoader__.load({
528
534
  setFloatMode: '浮层默认形态',
529
535
  setFloatModeCapsule: '胶囊(不遮挡对话,可随时展开)',
530
536
  setFloatModeFull: '直接展开成卡片',
531
- setFloatModeOff: '关闭浮层(只留小圆点)',
532
537
  setFloatModeHint: '浮层贴右侧边缘,只在空间不足时才可能压到对话区',
533
538
  setFloatMaxWidth: '浮层宽度上限(px)',
534
539
  setFloatMaxWidthHint: '空间不足时自动收窄,不会硬顶',
@@ -537,9 +542,6 @@ window.__ModuleLoader__.load({
537
542
  floatDocked: '已占满右侧栏',
538
543
  floatExpand: '展开侧边卡片',
539
544
  floatCollapse: '收起为胶囊(不遮挡对话)',
540
- floatHide: '隐藏浮层(只留小圆点)',
541
- floatRestore: '恢复浮层',
542
- floatCount: '{n} 条追问回答',
543
545
  floatStreaming: '正在作答…',
544
546
  floatDone: '已完成 {n} 条',
545
547
  setShortcut: '快捷键',
@@ -709,26 +711,15 @@ window.__ModuleLoader__.load({
709
711
  feedbackSend: '发送邮件',
710
712
  feedbackSending: '发送中…',
711
713
  feedbackSent: '已发送,谢谢反馈',
712
- feedbackMailto: '用邮件客户端发送',
713
714
  feedbackCopy: '复制内容',
714
715
  feedbackCopied: '已复制到剪贴板',
715
- feedbackUnconfigured: '没有可用的发信服务,可用“用邮件客户端发送”自行寄出',
716
- feedbackMailtoOnly: '收件人已填好,只缺发信凭据(SMTP 账号与授权码,由环境变量或用户层配置提供);现在也可以“用邮件客户端发送”',
716
+ feedbackUnconfigured: '没有可用的发信服务,暂时不能直接寄出;可用“复制内容”自己发给我',
717
+ feedbackNoCredentials: '收件人已填好,只缺发信凭据(SMTP 账号与授权码,由环境变量或用户层配置提供);可用“复制内容”自己发给我',
717
718
  feedbackNeedsTitle: '标题至少 4 个字',
718
719
  feedbackNeedsBody: '详细描述至少 10 个字',
719
720
  feedbackContactTooLong: '联系方式太长(≤{n} 字)',
720
721
  feedbackFailed: '发送失败:{message}',
721
722
  feedbackEnvNote: '自动附带:插件版本、Node/平台、发送时间',
722
- feedbackSmtpShow: '设置邮件发送(SMTP)',
723
- feedbackSmtpHide: '收起邮件发送设置',
724
- feedbackSmtpEnvNote: '当前凭证来自环境变量(优先级高于用户层配置)',
725
- feedbackSmtpHost: 'SMTP 服务器',
726
- feedbackSmtpSecure: 'SSL/TLS(465 端口)',
727
- feedbackSmtpPassSaved: '已保存,留空则不改动',
728
- feedbackSmtpPass: '密码 / 授权码',
729
- feedbackSmtpFrom: '发件邮箱(留空=同账号)',
730
- feedbackSmtpHint: 'QQ、163 等邮箱要用「授权码」而不是登录密码;密码只写进本地 config.json,页面读不回来,也不会随插件发布',
731
- feedbackSmtpSaveHint: '填完点「保存」(会把本页设置一起保存);不保存就点发送,密码不会被记住',
732
723
  feedbackTooFast: '刚发过一封,请稍等半分钟',
733
724
  feedbackTooMany: '发送过于频繁,请稍后再试',
734
725
  },
@@ -761,6 +752,12 @@ window.__ModuleLoader__.load({
761
752
  answerEmpty: '(empty)',
762
753
  cardClosed: 'This card was closed',
763
754
  cardMovedToFlow: 'This card moved to the floating stack',
755
+ cardDelete: 'Delete this follow-up conversation',
756
+ cardDeleteTitle: 'Delete this follow-up conversation?',
757
+ cardDeleteBody: 'It will be removed from both the floating layer and the follow-up history. This cannot be undone.',
758
+ cardDeleteRounds: 'Includes {n} follow-up turn(s)',
759
+ cardDeleteConfirm: 'Delete',
760
+ cardDeleted: 'Deleted this follow-up conversation',
764
761
  reasoningTitle: 'Reasoning',
765
762
  copy: 'Copy',
766
763
  copied: 'Copied',
@@ -813,7 +810,6 @@ window.__ModuleLoader__.load({
813
810
  setFloatMode: 'Floating layer default',
814
811
  setFloatModeCapsule: 'Capsule (never covers the conversation)',
815
812
  setFloatModeFull: 'Expanded card stack',
816
- setFloatModeOff: 'Hidden (small launcher only)',
817
813
  setFloatModeHint: 'The layer is anchored to the right edge and only overlaps on very narrow frames',
818
814
  setFloatMaxWidth: 'Floating layer max width (px)',
819
815
  setFloatMaxWidthHint: 'Narrows automatically when there is not enough room',
@@ -822,9 +818,6 @@ window.__ModuleLoader__.load({
822
818
  floatDocked: 'Filled the right rail',
823
819
  floatExpand: 'Expand the side cards',
824
820
  floatCollapse: 'Collapse to a capsule (stops covering the conversation)',
825
- floatHide: 'Hide the layer (leaves a small launcher)',
826
- floatRestore: 'Restore the floating layer',
827
- floatCount: '{n} follow-up answers',
828
821
  floatStreaming: 'Answering…',
829
822
  floatDone: '{n} done',
830
823
  setShortcut: 'Shortcut',
@@ -994,26 +987,15 @@ window.__ModuleLoader__.load({
994
987
  feedbackSend: 'Send mail',
995
988
  feedbackSending: 'Sending…',
996
989
  feedbackSent: 'Sent — thank you',
997
- feedbackMailto: 'Send with my mail app',
998
990
  feedbackCopy: 'Copy text',
999
991
  feedbackCopied: 'Copied to the clipboard',
1000
- feedbackUnconfigured: 'No mail service is configured; use “Send with my mail app” instead',
1001
- feedbackMailtoOnly: 'The recipient is set; only the SMTP credentials (environment variables) are missing, so use “Send with my mail app”',
992
+ feedbackUnconfigured: 'No mail service is configured, so the report cannot be sent from here; use “Copy text” and send it yourself',
993
+ feedbackNoCredentials: 'The recipient is set; only the SMTP credentials (environment variables or the user config) are missing, so use “Copy text” and send it yourself',
1002
994
  feedbackNeedsTitle: 'The title needs at least 4 characters',
1003
995
  feedbackNeedsBody: 'The details need at least 10 characters',
1004
996
  feedbackContactTooLong: 'The contact line is too long (max {n} characters)',
1005
997
  feedbackFailed: 'Send failed: {message}',
1006
998
  feedbackEnvNote: 'Attached automatically: plugin version, Node/platform, send time',
1007
- feedbackSmtpShow: 'Set up mail delivery (SMTP)',
1008
- feedbackSmtpHide: 'Hide mail delivery settings',
1009
- feedbackSmtpEnvNote: 'Credentials come from the environment right now (it outranks this page), so the fields below have no effect.',
1010
- feedbackSmtpHost: 'SMTP server',
1011
- feedbackSmtpSecure: 'SSL/TLS (port 465)',
1012
- feedbackSmtpPassSaved: 'Saved — leave empty to keep it',
1013
- feedbackSmtpPass: 'Password / app code',
1014
- feedbackSmtpFrom: 'From address (empty = the account)',
1015
- feedbackSmtpHint: 'QQ/163 need an app-specific code, not the login password. The password is written to this machine\'s config.json only; the page never reads it back and it never ships with the plugin.',
1016
- feedbackSmtpSaveHint: 'Press “Save” after filling this in (it saves the whole page). Without saving, the code is not remembered and sending keeps failing.',
1017
999
  feedbackTooFast: 'One mail just went out — wait half a minute',
1018
1000
  feedbackTooMany: 'Too many reports for now — try again later',
1019
1001
  },
@@ -1238,8 +1220,8 @@ window.__ModuleLoader__.load({
1238
1220
  surface: 'flow',
1239
1221
  surfaceNote: null,
1240
1222
  /**
1241
- * In-session override of `config.floatMode`, set by the layer's own
1242
- * collapse/hide buttons. `null` = follow the configured default.
1223
+ * In-session override of `config.floatMode`, set by the card header's
1224
+ * collapse button. `null` = follow the configured default.
1243
1225
  */
1244
1226
  floatMode: null,
1245
1227
  sessionId: null,
@@ -1384,10 +1366,10 @@ window.__ModuleLoader__.load({
1384
1366
  * NOT persisted: the settings page owns the durable default, and a reload
1385
1367
  * should return to it (a pill hidden by accident must not stay hidden
1386
1368
  * across sessions).
1387
- * @param {string} mode - `capsule` | `full` | `off`.
1369
+ * @param {string} mode - `capsule` | `full`.
1388
1370
  */
1389
1371
  function setFloatMode(mode) {
1390
- if (mode !== 'capsule' && mode !== 'full' && mode !== 'off') return
1372
+ if (mode !== 'capsule' && mode !== 'full') return
1391
1373
  store.set({ floatMode: mode })
1392
1374
  }
1393
1375
 
@@ -1789,7 +1771,7 @@ window.__ModuleLoader__.load({
1789
1771
  return { ok: true }
1790
1772
  }
1791
1773
 
1792
- /** One-line subject for the report (also used by the `mailto:` fallback). */
1774
+ /** One-line subject for the report. */
1793
1775
  function feedbackSubjectFor({ version, draft }) {
1794
1776
  const title = typeof draft?.title === 'string' ? draft.title.trim() : ''
1795
1777
  const kind = typeof draft?.kind === 'string' && draft.kind !== '' ? draft.kind : 'other'
@@ -2216,7 +2198,7 @@ window.__ModuleLoader__.load({
2216
2198
  (\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
2217
2199
  plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
2218
2200
  Every surface we draw declares it. The property is inert in a browser. */
2219
- .dsa-layer,.dsa-trigger,.dsa-quickbar,.dsa-pop,.dsa-ctxmenu,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
2201
+ .dsa-layer,.dsa-trigger,.dsa-quickbar,.dsa-pop,.dsa-ctxmenu,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings,.dsa-confirm{-webkit-app-region:no-drag}
2220
2202
  .dsa-layer{pointer-events:none;position:relative;z-index:60}
2221
2203
  .dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
2222
2204
  padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
@@ -2334,9 +2316,16 @@ body[data-ds-dark-theme] .dsa-quickbar .dsa-trigger-embedded{color:#0a1428}
2334
2316
  html[data-ds-dark-theme] .dsa-btn-primary,
2335
2317
  body[data-ds-dark-theme] .dsa-btn-primary{color:#0a1428}
2336
2318
  .dsa-btn:disabled{opacity:.55;cursor:default}
2337
- /* Icon-only variant (the floating layer's collapse/hide controls): square and
2338
- quiet so the card title bar stays one line and the title keeps the weight. */
2319
+ /* Icon-only variant (the floating layer's collapse / delete controls): square
2320
+ and quiet so the card title bar stays one line and the title keeps the weight. */
2339
2321
  .dsa-btn-icon{padding:4px 7px;line-height:1;font-size:13px;color:var(--dsw-alias-label-secondary, #666)}
2322
+ /* Destructive confirmation only: the irreversible button carries the error token
2323
+ so it can never be mistaken for the safe one rendered next to it. Placed after
2324
+ .dsa-btn-icon on purpose — same specificity, so source order decides. */
2325
+ .dsa-btn-danger{border-color:var(--dsw-alias-state-error-primary, #c0392b);
2326
+ background:var(--dsw-alias-state-error-primary, #c0392b);color:#fff;font-weight:500}
2327
+ .dsa-btn-danger:hover{border-color:var(--dsw-alias-state-error-primary, #c0392b);
2328
+ background:var(--dsw-alias-state-error-primary, #c0392b)}
2340
2329
  /* Segmented control (main chat / side card). These are the two destinations an
2341
2330
  answer can land in, so the pair has to read as a radio choice, not as two
2342
2331
  quiet labels: the group gets a recessed track with an explicit mid-grey edge
@@ -2422,11 +2411,18 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
2422
2411
  .dsa-capsule-q{min-width:0;flex:1;color:var(--dsw-alias-label-secondary, #666);
2423
2412
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2424
2413
  .dsa-capsule-chev{flex:none;color:var(--dsw-alias-label-secondary, #888)}
2425
- .dsa-launcher{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;padding:6px 11px;
2426
- border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
2427
- border:1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,.45));
2428
- background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-secondary, #666)}
2429
- .dsa-launcher:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
2414
+ /* Delete confirmation (2.0.0). Fixed, not inline: a card docked into the right
2415
+ rail lives inside an overflow:hidden page, which would clip a sheet anchored
2416
+ to the card. Same backdrop recipe as the feedback dialog. */
2417
+ .dsa-confirm-backdrop{position:fixed;inset:0;z-index:1100;display:flex;align-items:center;
2418
+ justify-content:center;padding:24px;background:rgba(0,0,0,.42)}
2419
+ .dsa-confirm{display:flex;flex-direction:column;gap:8px;width:min(360px, 100%);padding:14px 16px;
2420
+ border-radius:12px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
2421
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
2422
+ box-shadow:0 18px 48px rgba(0,0,0,.28)}
2423
+ .dsa-confirm-title{font-size:13px;font-weight:600}
2424
+ .dsa-confirm-body{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary, #666)}
2425
+ .dsa-confirm > .dsa-row{justify-content:flex-end}
2430
2426
  .dsa-answer{max-height:44vh;overflow:auto;word-break:break-word}
2431
2427
  .dsa-answer .dsa-p{margin:0 0 6px}
2432
2428
  .dsa-answer .dsa-ul{margin:0 0 6px;padding-left:18px}
@@ -4007,15 +4003,70 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4007
4003
  }, t('close')))
4008
4004
  }
4009
4005
 
4006
+ /**
4007
+ * The confirmation gate for an irreversible action.
4008
+ *
4009
+ * A component rather than an inline block for two reasons: every
4010
+ * destructive control can reuse it, and the test harness cannot drive a
4011
+ * click-driven state change, so rendering it directly is the only way this
4012
+ * markup gets asserted (same seam as {@link FeedbackDialog}).
4013
+ *
4014
+ * The safe choice is the default: Cancel takes focus, Escape aborts, and
4015
+ * only a click on the backdrop itself dismisses — a click that lands on the
4016
+ * dialog must not throw away the decision the user is making.
4017
+ */
4018
+ function DeleteConfirmDialog({ title, body, detail, cancelLabel, confirmLabel, onCancel, onConfirm }) {
4019
+ return h('div', {
4020
+ className: 'dsa-confirm-backdrop',
4021
+ onClick: event => {
4022
+ if (event.target === event.currentTarget) onCancel()
4023
+ },
4024
+ onKeyDown: event => {
4025
+ if (event.key !== 'Escape') return
4026
+ // Escape is the safe exit, so it stops here: the card's own Esc
4027
+ // handler and the global shortcut layer must not also react.
4028
+ event.stopPropagation()
4029
+ onCancel()
4030
+ },
4031
+ },
4032
+ h('div', {
4033
+ className: 'dsa-confirm',
4034
+ role: 'dialog',
4035
+ 'aria-modal': 'true',
4036
+ 'aria-label': title,
4037
+ },
4038
+ h('strong', { className: 'dsa-confirm-title' }, title),
4039
+ h('div', { className: 'dsa-confirm-body' }, body),
4040
+ detail !== null && detail !== undefined && detail !== '' ? h('div', { className: 'dsa-muted' }, detail) : null,
4041
+ h('div', { className: 'dsa-row' },
4042
+ h('button', {
4043
+ type: 'button',
4044
+ className: 'dsa-btn',
4045
+ // Safe default: a stray Enter on an unreviewed dialog must not
4046
+ // destroy anything.
4047
+ autoFocus: true,
4048
+ onClick: onCancel,
4049
+ }, cancelLabel),
4050
+ h('button', {
4051
+ type: 'button',
4052
+ className: 'dsa-btn dsa-btn-danger',
4053
+ onClick: onConfirm,
4054
+ }, confirmLabel))))
4055
+ }
4056
+
4010
4057
  /**
4011
4058
  * One answer card; `embedded` is true inside a host's tab.
4012
4059
  *
4013
- * `onFloatMode` is passed ONLY by the floating layer: it renders the
4014
- * collapse/hide controls that let the user get the layer out of the way
4015
- * (the layer is the only surface that can cover the conversation). A host
4016
- * tab that embeds the same card passes no callback and gets no buttons.
4060
+ * `onFloatMode` / `onDelete` are passed ONLY by the floating layer. The
4061
+ * first renders the "collapse to a capsule" control that gets the layer out
4062
+ * of the way (the layer is the only surface that can cover the
4063
+ * conversation); the second renders the × that DELETES this follow-up
4064
+ * conversation — its live card *and* its durable history entry — after a
4065
+ * confirmation. A host tab that embeds the same card passes neither and
4066
+ * keeps its own tab close plus the footer Close button, which by design
4067
+ * detaches the card without touching the history.
4017
4068
  */
4018
- function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain, onFloatMode, onLocate, onExport, onInsert, onReplace, onViewVersion }) {
4069
+ function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain, onFloatMode, onDelete, onLocate, onExport, onInsert, onReplace, onViewVersion }) {
4019
4070
  const [followOpen, setFollowOpen] = React.useState(false)
4020
4071
  const [draft, setDraft] = React.useState('')
4021
4072
  // Edit-&-retry editor: prefilled with the card's CURRENT question and
@@ -4023,6 +4074,12 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4023
4074
  // edit-retry regenerates THIS turn, keeping the card's earlier turns).
4024
4075
  const [editOpen, setEditOpen] = React.useState(false)
4025
4076
  const [editDraft, setEditDraft] = React.useState('')
4077
+ // Deleting a follow-up conversation is irreversible, so it goes through an
4078
+ // in-plugin confirmation — EXCEPT for a card that holds nothing: no
4079
+ // follow-up turn, no answer text, no reasoning, no run in flight. There is
4080
+ // nothing to lose there, and prompting on an empty card only trains the
4081
+ // user to dismiss the prompt that matters.
4082
+ const [deleteOpen, setDeleteOpen] = React.useState(false)
4026
4083
  const answerRef = React.useRef(null)
4027
4084
  const config = store.state.config
4028
4085
  // P2-5b: an older version is READ-ONLY content swapped into the same
@@ -4057,6 +4114,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4057
4114
  // without modifiers and never while composing or inside an editor.
4058
4115
  const onCardKeyDown = (event) => {
4059
4116
  if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return
4117
+ // The confirmation is modal: R/C must not act on a card the user is
4118
+ // currently deciding to delete.
4119
+ if (deleteOpen) return
4060
4120
  const tag = String(event.target?.tagName ?? '')
4061
4121
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON'
4062
4122
  || event.target?.closest?.('[contenteditable=""],[contenteditable="true"]') !== null) {
@@ -4085,6 +4145,19 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4085
4145
  if (typeof card.elapsedMs === 'number' && Number.isFinite(card.elapsedMs) && formatElapsed(card.elapsedMs) !== '') {
4086
4146
  metaBits.push(`${t('metaElapsed')} ${formatElapsed(card.elapsedMs)}`)
4087
4147
  }
4148
+ // "Empty" is judged on the card's own content, never on how it got here.
4149
+ const turns = Array.isArray(card.history) ? card.history.length : 0
4150
+ const busy = card.status === 'pending' || card.status === 'streaming'
4151
+ const nothingToLose = turns === 0
4152
+ && String(card.text ?? '').trim() === ''
4153
+ && String(card.reasoning ?? '').trim() === ''
4154
+ const requestDelete = () => {
4155
+ if (!busy && nothingToLose) {
4156
+ onDelete?.()
4157
+ return
4158
+ }
4159
+ setDeleteOpen(true)
4160
+ }
4088
4161
  return h('section', {
4089
4162
  className: 'dsa-card',
4090
4163
  'aria-label': t('answerTitle'),
@@ -4097,10 +4170,20 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4097
4170
  h('span', { className: 'dsa-badge' }, zoneLabel(card)),
4098
4171
  surfaceLabel !== null ? h('span', { className: 'dsa-badge' }, surfaceLabel) : null,
4099
4172
  card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null,
4100
- typeof onFloatMode === 'function'
4173
+ typeof onFloatMode === 'function' || typeof onDelete === 'function'
4101
4174
  ? h('span', { className: 'dsa-row', style: { marginLeft: 'auto' } },
4102
- floatControl('capsule', 'floatCollapse', '⇥'),
4103
- floatControl('off', 'floatHide', '×'))
4175
+ typeof onFloatMode === 'function'
4176
+ ? floatControl('capsule', 'floatCollapse', '⇥')
4177
+ : null,
4178
+ typeof onDelete === 'function'
4179
+ ? h('button', {
4180
+ type: 'button',
4181
+ className: 'dsa-btn dsa-btn-icon',
4182
+ title: t('cardDelete'),
4183
+ 'aria-label': t('cardDelete'),
4184
+ onClick: () => requestDelete(),
4185
+ }, '×')
4186
+ : null)
4104
4187
  : null),
4105
4188
  h('pre', { className: 'dsa-quote' }, shown.question),
4106
4189
  versions.length > 0
@@ -4251,17 +4334,34 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4251
4334
  onFollowUp: () => setFollowOpen(open => !open),
4252
4335
  onClose,
4253
4336
  }),
4254
- surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null)
4337
+ surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null,
4338
+ deleteOpen
4339
+ ? h(DeleteConfirmDialog, {
4340
+ title: t('cardDeleteTitle'),
4341
+ body: t('cardDeleteBody'),
4342
+ detail: turns > 0 ? t('cardDeleteRounds', { n: turns }) : null,
4343
+ cancelLabel: t('askCancel'),
4344
+ confirmLabel: t('cardDeleteConfirm'),
4345
+ onCancel: () => setDeleteOpen(false),
4346
+ onConfirm: () => {
4347
+ setDeleteOpen(false)
4348
+ onDelete?.()
4349
+ },
4350
+ })
4351
+ : null)
4255
4352
  }
4256
4353
 
4257
4354
  /**
4258
4355
  * The flow surface, i.e. the floating layer (A).
4259
4356
  *
4260
- * Three shapes, all sharing one right-anchored container:
4357
+ * Two shapes, both sharing one right-anchored container:
4261
4358
  * - `capsule`: a single ~34px pill (status + truncated question). The
4262
4359
  * default, because a one-line pill cannot bury the conversation.
4263
4360
  * - `full`: the classic card stack, collapsed on demand.
4264
- * - `off`: only a small launcher, so nothing covers the page until asked.
4361
+ *
4362
+ * The former `off` shape (hide the layer behind a small launcher) is gone
4363
+ * as of 2.0.0: the card header's × deletes that follow-up conversation
4364
+ * instead, and "hide everything" was never a state this layer needed.
4265
4365
  *
4266
4366
  * The width comes from {@link floatPlacement}, so the layer stops growing
4267
4367
  * left as soon as it would cross the conversation column's right boundary.
@@ -4462,17 +4562,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4462
4562
  onReplace: target => actions.insertAnswer(target ?? card, { replace: true }),
4463
4563
  onViewVersion: index => actions.viewVersion(card, index),
4464
4564
  onFloatMode: setFloatMode,
4565
+ onDelete: () => actions.deleteCard(card),
4465
4566
  }))
4466
4567
 
4467
- if (mode === 'off') {
4468
- return container(h('button', {
4469
- type: 'button',
4470
- className: 'dsa-launcher',
4471
- title: t('floatRestore'),
4472
- onClick: () => setFloatMode('full'),
4473
- }, dot, t('floatCount', { n: total })))
4474
- }
4475
-
4476
4568
  if (mode === 'capsule') {
4477
4569
  return container(h('button', {
4478
4570
  type: 'button',
@@ -4486,8 +4578,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4486
4578
 
4487
4579
  // Docked: the card takes the rail's whole rectangle, so it reads as one of
4488
4580
  // the rail's pages rather than a 340px card hovering over it. The card
4489
- // keeps its own header (title + badges + collapse/hide controls), which is
4490
- // what makes the page dismissible without leaving the rail.
4581
+ // keeps its own header (title + badges + collapse / delete controls),
4582
+ // which is what makes the page dismissible without leaving the rail.
4491
4583
  return container(h('div', {
4492
4584
  className: 'dsa-stack',
4493
4585
  'data-dsa-dock-body': docked ? String(dock.width) : undefined,
@@ -4554,7 +4646,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4554
4646
  function FeedbackDialog(props) {
4555
4647
  const {
4556
4648
  t, limits, draft, onChange, status, error, ready, reason, hasRecipient,
4557
- onClose, onSubmit, onMailto, onCopy,
4649
+ onClose, onSubmit, onCopy,
4558
4650
  } = props
4559
4651
  const sending = status === 'sending'
4560
4652
  // Escape closes it: a modal without a keyboard exit is a trap.
@@ -4641,15 +4733,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4641
4733
  })),
4642
4734
  h('span', { className: 'dsa-feedback-dialog-hint' }, t('feedbackContactHint')),
4643
4735
  h('span', { className: 'dsa-feedback-dialog-hint' }, t('feedbackEnvNote')),
4644
- // Readiness: three states, not two. No recipient at all, recipient but no
4645
- // usable transport (the mail-app path still works), fully ready. When the
4646
- // Host explains why, print its sentence verbatim.
4736
+ // Readiness: two states, not one. No recipient at all, or a recipient
4737
+ // but no usable transport. When the Host explains why, print its
4738
+ // sentence verbatim.
4647
4739
  ready === true
4648
4740
  ? null
4649
4741
  : h('span', { className: 'dsa-feedback-dialog-hint', 'data-feedback-readiness': 'unready' },
4650
4742
  String(reason ?? '') !== ''
4651
4743
  ? String(reason)
4652
- : (hasRecipient === true ? t('feedbackMailtoOnly') : t('feedbackUnconfigured'))),
4744
+ : (hasRecipient === true ? t('feedbackNoCredentials') : t('feedbackUnconfigured'))),
4653
4745
  h('div', { className: 'dsa-feedback-dialog-actions' },
4654
4746
  h('button', {
4655
4747
  type: 'button',
@@ -4659,12 +4751,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4659
4751
  title: ready === true ? undefined : t('feedbackUnconfigured'),
4660
4752
  onClick: onSubmit,
4661
4753
  }, sending ? t('feedbackSending') : t('feedbackSend')),
4662
- h('button', {
4663
- type: 'button',
4664
- className: 'dsa-btn',
4665
- 'data-feedback-mailto': '',
4666
- onClick: onMailto,
4667
- }, t('feedbackMailto')),
4668
4754
  h('button', {
4669
4755
  type: 'button',
4670
4756
  className: 'dsa-btn',
@@ -4758,32 +4844,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4758
4844
  }
4759
4845
  }
4760
4846
 
4761
- /**
4762
- * Fallback that needs no credentials: hand the draft to the mail app.
4763
- *
4764
- * The URL is built by the Host — deliberately, so the plaintext recipient
4765
- * is never held or rendered by this page. If the Host has no recipient,
4766
- * it answers 503 and the failure is shown like any other.
4767
- */
4768
- const sendFeedbackByMailApp = async () => {
4769
- try {
4770
- const value = await postJson('feedback-mailto', {
4771
- kind: feedbackDraft.kind,
4772
- title: feedbackDraft.title.trim(),
4773
- body: feedbackDraft.body.trim(),
4774
- contact: feedbackDraft.contact.trim(),
4775
- userAgent: feedbackUserAgent,
4776
- })
4777
- const url = typeof value?.url === 'string' ? value.url : ''
4778
- if (url === '') throw new Error('no mailto url')
4779
- if (typeof window !== 'undefined' && typeof window.open === 'function') window.open(url, '_self')
4780
- else if (typeof location !== 'undefined') location.href = url
4781
- } catch (error) {
4782
- setFeedbackStatus('error')
4783
- setFeedbackError(t('feedbackFailed', { message: localizeError(error) }))
4784
- }
4785
- }
4786
-
4787
4847
  const copyFeedbackDraft = async () => {
4788
4848
  const text = feedbackPlainTextFor({
4789
4849
  version: state.version ?? CLIENT_VERSION,
@@ -5025,8 +5085,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5025
5085
  // form. Nothing else about feedback lives on this page: the transport is
5026
5086
  // the Host's business (environment / user config), so an end user has
5027
5087
  // nothing to fill in before writing a report. The banner also works when
5028
- // no SMTP endpoint is configured — the `mailto:` path needs nothing but a
5029
- // recipient — so the dialog says that in one line instead of looking broken.
5088
+ // no SMTP endpoint is configured — the dialog says so in one line and
5089
+ // points at the copy path instead of looking broken.
5030
5090
  const feedbackConfigured = feedbackInfo !== null && feedbackInfo.configured === true
5031
5091
  const feedbackBanner = h('div', { className: 'dsa-feedback-bar', 'data-dsa-feedback-section': '' },
5032
5092
  h('div', { className: 'dsa-feedback-bar-text' },
@@ -5055,7 +5115,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5055
5115
  hasRecipient: feedbackInfo?.hasRecipient === true,
5056
5116
  onClose: () => setFeedbackOpen(false),
5057
5117
  onSubmit: () => { void submitFeedback() },
5058
- onMailto: sendFeedbackByMailApp,
5059
5118
  onCopy: () => { void copyFeedbackDraft() },
5060
5119
  })
5061
5120
 
@@ -5094,7 +5153,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5094
5153
  field(t('setFloatMode'), select('floatMode', [
5095
5154
  ['capsule', t('setFloatModeCapsule')],
5096
5155
  ['full', t('setFloatModeFull')],
5097
- ['off', t('setFloatModeOff')],
5098
5156
  ]), t('setFloatModeHint')),
5099
5157
  field(t('setFloatMaxWidth'), number('floatMaxWidth', 240, 560), t('setFloatMaxWidthHint')),
5100
5158
  field(t('setMinChars'), number('minChars', 0, 200)),
@@ -5565,7 +5623,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5565
5623
  } else {
5566
5624
  store.set({ cards: [historyEntryToCard(entry), ...store.state.cards] })
5567
5625
  }
5568
- // A hidden (off/capsule) layer would swallow the restored card;
5626
+ // A collapsed (capsule) layer would swallow the restored card;
5569
5627
  // this is a session-only override, exactly like clicking expand.
5570
5628
  setFloatMode('full')
5571
5629
  toast(t('historyRestored'))
@@ -5583,6 +5641,57 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5583
5641
  })
5584
5642
  toast(t('historyDeleted'))
5585
5643
  },
5644
+ /**
5645
+ * Delete ONE follow-up conversation: the live card AND its durable
5646
+ * history entry. This is the card header's ×.
5647
+ *
5648
+ * Deliberately stronger than {@link actions.close}: closing only
5649
+ * detaches the card from the live surface and keeps the history entry
5650
+ * on purpose (#6), while this removes the same id from both stores and
5651
+ * persists immediately, so a reload cannot resurrect it.
5652
+ *
5653
+ * An in-flight run is released first: the browser stream is aborted,
5654
+ * the Host child is told to cancel, and the card's buffered deltas are
5655
+ * dropped — a late flush would otherwise re-append text to a card that
5656
+ * no longer exists.
5657
+ */
5658
+ deleteCard(card) {
5659
+ const id = card?.id
5660
+ if (typeof id !== 'string' || id === '') return false
5661
+ const controller = controllers.get(id)
5662
+ if (controller !== undefined) {
5663
+ controller.abort()
5664
+ void postJson('cancel', { id }).catch(() => {})
5665
+ }
5666
+ controllers.delete(id)
5667
+ pendingDeltas.delete(id)
5668
+ pendingReasonings.delete(id)
5669
+ // Every surface gets the same chance to close its own container as
5670
+ // it does on close(): an orphaned tab reads to the user as a card
5671
+ // that refused to die.
5672
+ if (card.surface === 'better-sidebar') {
5673
+ try {
5674
+ surfaces.better.close(card)
5675
+ } catch { /* the host may already be gone */ }
5676
+ } else if (card.surface === 'native-rightbar') {
5677
+ try {
5678
+ surfaces.native.close(card)
5679
+ } catch { /* the right rail owns its lifecycle */ }
5680
+ }
5681
+ const next = store.state.history
5682
+ .filter(item => item.id !== id)
5683
+ .slice(0, historyCap())
5684
+ // cap === 0 means "nothing is persisted": leave the stored key alone
5685
+ // rather than writing an empty payload over it (same rule as
5686
+ // syncPersisted).
5687
+ if (historyCap() > 0) writePersisted(next)
5688
+ store.set({
5689
+ history: next,
5690
+ cards: store.state.cards.filter(item => item.id !== id),
5691
+ })
5692
+ toast(t('cardDeleted'))
5693
+ return true
5694
+ },
5586
5695
  /**
5587
5696
  * Empty the durable history without touching running cards.
5588
5697
  *
@@ -6755,11 +6864,11 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6755
6864
 
6756
6865
  /**
6757
6866
  * Test seam: components that can be rendered on their own. The feedback
6758
- * dialog lives here because the settings page mounts it only while open and
6759
- * the test shim has no working setState, so a simulated click cannot reach
6760
- * it.
6867
+ * dialog and the delete confirmation both live here because their host
6868
+ * mounts them only while open and the test shim has no working setState,
6869
+ * so a simulated click cannot reach them.
6761
6870
  */
6762
- components: Object.freeze({ FeedbackDialog }),
6871
+ components: Object.freeze({ FeedbackDialog, DeleteConfirmDialog }),
6763
6872
 
6764
6873
  /**
6765
6874
  * Test seam: pure helpers, frozen. The DSH client runtime only reads
package/index.js CHANGED
@@ -44,7 +44,7 @@ export const name = 'dsh-sidecard-ask'
44
44
  export const inject = ['webServer']
45
45
 
46
46
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
47
- export const PLUGIN_VERSION = '1.9.1'
47
+ export const PLUGIN_VERSION = '2.0.0'
48
48
 
49
49
  /** Route prefix of the plugin's own API. */
50
50
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -77,10 +77,12 @@ export const DEFAULT_CONFIG = {
77
77
  sideProvider: 'auto',
78
78
  /**
79
79
  * Floating-layer (A) surface. `capsule` = one-line pill that never covers
80
- * the conversation, `full` = the classic card stack, `off` = hidden behind a
81
- * small launcher. The geometry for all three lives in `client.js`:
82
- * the layer is anchored to the frame's right edge and never crosses the
83
- * conversation column's right boundary (see `FLOAT_*` there).
80
+ * the conversation, `full` = the classic card stack. (The former `off`
81
+ * "hidden behind a small launcher" shape was removed in 2.0.0; a stored
82
+ * `off` now fails validation and falls back to this default.) The geometry
83
+ * for both shapes lives in `client.js`: the layer is anchored to the frame's
84
+ * right edge and never crosses the conversation column's right boundary
85
+ * (see `FLOAT_*` there).
84
86
  */
85
87
  floatMode: 'capsule',
86
88
  /** Hard cap, in CSS px, for the floating layer's width. */
@@ -208,7 +210,7 @@ const ENUMS = {
208
210
  sideSurface: ['auto', 'native-rightbar', 'better-sidebar', 'flow'],
209
211
  captureZones: ['auto', 'chat', 'task', 'chat+task'],
210
212
  sideTools: ['readonly', 'inherit'],
211
- floatMode: ['capsule', 'full', 'off'],
213
+ floatMode: ['capsule', 'full'],
212
214
  answerStyle: ['concise', 'standard', 'detailed'],
213
215
  }
214
216
 
@@ -586,7 +588,7 @@ export function buildSidePrompt(input) {
586
588
  // 2. No dependency is added: the SMTP dialogue below runs on `node:net` and
587
589
  // `node:tls` only, because the Host half may import nothing but node:
588
590
  // built-ins (see the module header). A plugin that cannot reach an SMTP
589
- // relay still works: the Client offers a `mailto:` fallback.
591
+ // relay still works: the Client offers to copy the report text.
590
592
  // ────────────────────────────────────────────────────────────────────────────
591
593
 
592
594
  /** Bounds for one feedback mail, mirrored by the Client's own form checks. */
@@ -769,8 +771,7 @@ export function readSmtpFromEnv(env = process.env) {
769
771
  /**
770
772
  * Resolve the plaintext recipient. SERVER-SIDE ONLY: this value must never be
771
773
  * put into a response body or rendered by the Client — the settings page only
772
- * ever receives `toMasked` plus the `hasRecipient` flag, and asks for a finished
773
- * `mailto:` URL when the user actually clicks the mail-app button.
774
+ * ever receives `toMasked` plus the `hasRecipient` flag.
774
775
  * @param {Record<string, unknown>} config - the effective config.
775
776
  * @param {Record<string, string|undefined>} [env] - the process environment.
776
777
  * @returns {string} the address, or '' when unconfigured.
@@ -842,7 +843,7 @@ export function describeSmtpSettings(config = {}, env = process.env) {
842
843
  export function describeFeedback(config = {}, env = process.env) {
843
844
  const to = feedbackRecipient(config, env)
844
845
  const base = {
845
- transport: 'mailto',
846
+ transport: 'none',
846
847
  configured: false,
847
848
  hasRecipient: to !== '',
848
849
  toMasked: maskEmail(to),
@@ -854,7 +855,7 @@ export function describeFeedback(config = {}, env = process.env) {
854
855
  if (to === '') return { ...base, reason: '未配置收件邮箱:设置 feedbackAddress 或 DSH_SIDECARD_FEEDBACK_TO / DSH_CRB_FEEDBACK_TO' }
855
856
  if (!looksLikeEmail(to)) return { ...base, reason: '收件邮箱格式不合法' }
856
857
  const smtp = resolveSmtp(config, env)
857
- if (smtp.ok !== true) return { ...base, reason: `${smtp.reason}(可用“用邮件客户端发送”)` }
858
+ if (smtp.ok !== true) return { ...base, reason: smtp.reason }
858
859
  return { ...base, transport: 'smtp', configured: true, fromMasked: maskEmail(smtp.endpoint.from), reason: '' }
859
860
  }
860
861
 
@@ -920,26 +921,6 @@ export function describeEnvironment({ version, dshVersion = '', now = new Date()
920
921
  ].join('\n')
921
922
  }
922
923
 
923
- /**
924
- * Build the `mailto:` URL the mail-app button navigates to.
925
- *
926
- * Lives on the Host on purpose: the plaintext recipient must not be rendered or
927
- * held by the page. The Client asks for a finished URL and only follows it.
928
- * @returns {string} a `mailto:` URL with encoded subject and body.
929
- */
930
- export function buildMailtoUrl({ to, version, input, userAgent = '' }) {
931
- const subject = `[dsh-sidecard-ask ${version}] ${input.kind}: ${input.title}`
932
- const text = [
933
- `plugin: ${version}`,
934
- `kind: ${input.kind}`,
935
- input.body,
936
- '',
937
- `contact: ${input.contact === '' ? '(not provided)' : input.contact}`,
938
- ]
939
- if (userAgent !== '') text.push(`user agent: ${userAgent}`)
940
- return `mailto:${to}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(text.join('\n'))}`
941
- }
942
-
943
924
  /**
944
925
  * Send-rate gate: one mail per `minGapMs`, at most `maxPerWindow` per window.
945
926
  * Every send is recorded only after it actually went out, so a relay outage
@@ -2092,8 +2073,8 @@ export function apply(ctx, patchConfig) {
2092
2073
  /** `/state` — the Client's single source of truth at boot and after a save. */
2093
2074
  const handleState = (res) => {
2094
2075
  // Two secrets never reach the page: the recipient stays server-side (the page
2095
- // gets `hasRecipient` and asks for a finished `mailto:` URL), and the SMTP
2096
- // password is write-only (the page only learns whether one is stored).
2076
+ // gets `hasRecipient` and `toMasked` only), and the SMTP password is
2077
+ // write-only (the page only learns whether one is stored).
2097
2078
  const publicConfig = { ...state.config }
2098
2079
  delete publicConfig.feedbackAddress
2099
2080
  delete publicConfig.feedbackSmtpPass
@@ -2151,9 +2132,8 @@ export function apply(ctx, patchConfig) {
2151
2132
  * `/feedback` — mail one settings-page report to the configured recipient.
2152
2133
  *
2153
2134
  * Failure codes are the Client's contract: `feedback-unconfigured` (no
2154
- * recipient or no SMTP — the Client then offers `mailto:`),
2155
- * `feedback-invalid` (bounds), `feedback-too-fast` / `feedback-too-many`
2156
- * (gate), `feedback-send-failed` (relay refused). The report body is never
2135
+ * recipient or no SMTP), `feedback-invalid` (bounds), `feedback-too-fast` /
2136
+ * `feedback-too-many` (gate), `feedback-send-failed` (relay refused). The report body is never
2157
2137
  * logged; only kind, masked recipient and outcome are.
2158
2138
  */
2159
2139
  const handleFeedback = async (req, res) => {
@@ -2208,44 +2188,6 @@ export function apply(ctx, patchConfig) {
2208
2188
  }
2209
2189
  }
2210
2190
 
2211
- /**
2212
- * `/feedback-mailto` — hand the Client a finished `mailto:` URL.
2213
- *
2214
- * This exists so the plaintext recipient never has to enter the page: the
2215
- * settings form shows a mask, and when the user clicks "send with my mail app"
2216
- * the Host builds the URL and the Client only navigates to it.
2217
- */
2218
- const handleFeedbackMailto = async (req, res) => {
2219
- let body
2220
- try {
2221
- body = await readJsonBody(req)
2222
- } catch (error) {
2223
- writeError(res, 400, toWireError(error, 'bad-request').code, toWireError(error).message)
2224
- return
2225
- }
2226
- const input = sanitizeFeedbackInput(body)
2227
- if (input.ok !== true) {
2228
- writeError(res, 400, 'feedback-invalid', input.message)
2229
- return
2230
- }
2231
- const to = feedbackRecipient(state.config)
2232
- if (to === '') {
2233
- writeError(res, 503, 'feedback-unconfigured', '未配置收件邮箱:在设置页填写,或设置 DSH_SIDECARD_FEEDBACK_TO')
2234
- return
2235
- }
2236
- if (!looksLikeEmail(to)) {
2237
- writeError(res, 503, 'feedback-unconfigured', '收件邮箱格式不合法')
2238
- return
2239
- }
2240
- const url = buildMailtoUrl({
2241
- to,
2242
- version: PLUGIN_VERSION,
2243
- input: input.value,
2244
- userAgent: typeof body.userAgent === 'string' ? body.userAgent : '',
2245
- })
2246
- writeOk(res, { url, toMasked: maskEmail(to) })
2247
- }
2248
-
2249
2191
  /** `/ask` — one SSE stream per side answer. */
2250
2192
  const handleAsk = async (req, res) => {
2251
2193
  let body
@@ -2338,7 +2280,7 @@ export function apply(ctx, patchConfig) {
2338
2280
  * The HTTP methods the plugin serves; kept in one place because the route
2339
2281
  * registration has two shapes (see below).
2340
2282
  */
2341
- const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback', 'feedback-mailto']
2283
+ const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
2342
2284
 
2343
2285
  /** One request → one response; the dispatcher owns path parsing and the fence. */
2344
2286
  const routeHandler = async (req, res) => {
@@ -2373,7 +2315,6 @@ export function apply(ctx, patchConfig) {
2373
2315
  else if (method === 'reset') handleReset(req, res)
2374
2316
  else if (method === 'diagnose') await handleDiagnose(req, res)
2375
2317
  else if (method === 'feedback') await handleFeedback(req, res)
2376
- else if (method === 'feedback-mailto') await handleFeedbackMailto(req, res)
2377
2318
  else writeError(res, 404, 'not-found', `未知接口 "${method}"`)
2378
2319
  } catch (error) {
2379
2320
  const wire = toWireError(error)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "1.9.1",
3
+ "version": "2.0.0",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",