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 +78 -0
- package/README.md +25 -19
- package/client.js +225 -116
- package/index.js +17 -76
- package/package.json +1 -1
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
|
-
>
|
|
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`
|
|
167
|
+
| `floatMode` | `capsule` \| `full` | `capsule` | **内置浮层的默认形态**:胶囊(一行,最小遮挡)/ 完整卡片栈。运行时可即时收起为胶囊,但不写回本项——刷新后仍回到这里设的默认。旧配置里存过 `off` 的会在启动时校验失败、回退为 `capsule`,并在 `problems` 里给出提示(`index.js` 的 `sanitizeLayer`),无需手工迁移 | 立即 |
|
|
167
168
|
| `floatMaxWidth` | 240–560 | `340` | **内置浮层宽度上限**(px):实际宽度 = `min(本项, 对话区右侧可用带宽)`,可用带宽不足 240px 时才会压入对话区 | 立即 |
|
|
168
|
-
| `dockRail` | 布尔 | `true` | **右侧栏打开时占满它**:答案卡铺满右栏标签条下方的内容区,读起来像栏内的一页,而不是 340px
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
343
|
+
留空则整行不写(匿名反馈)。
|
|
340
344
|
|
|
341
|
-
-
|
|
342
|
-
|
|
345
|
+
- 两种就绪状态在界面上分开说,不混成一个「未配置」:没有收件人 → 说没有可用发信服务;有收件人但没有 SMTP
|
|
346
|
+
凭证 → 说「只缺发信凭据」;齐全 → 什么都不提示。两种情况都指向「复制内容」,不再留一条邮件应用捷径。
|
|
343
347
|
- **关于「加密」的实话**:这里的邮箱只是一次 SMTP 的收件地址,插件必须能解出明文才能发信,任何随插件
|
|
344
348
|
分发的「加密」都等于把钥匙一起发出去,只能算混淆——打包默认值用的 `b64:` 就是这种混淆,只挡正则抓取。
|
|
345
349
|
真正有效的保护是**只存本机配置 + 明文不进浏览器 + 日志打码 + 可被环境变量整体接管**——这四条都已实现。
|
|
346
|
-
|
|
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
|
-
|
|
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
|
|
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 = '
|
|
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
|
-
|
|
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 “
|
|
1001
|
-
|
|
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
|
|
1242
|
-
* collapse
|
|
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
|
|
1369
|
+
* @param {string} mode - `capsule` | `full`.
|
|
1388
1370
|
*/
|
|
1389
1371
|
function setFloatMode(mode) {
|
|
1390
|
-
if (mode !== 'capsule' && mode !== 'full'
|
|
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
|
|
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/
|
|
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
|
-
.
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
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`
|
|
4014
|
-
* collapse
|
|
4015
|
-
* (the layer is the only surface that can cover the
|
|
4016
|
-
*
|
|
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
|
-
|
|
4103
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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/
|
|
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,
|
|
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:
|
|
4645
|
-
// usable transport
|
|
4646
|
-
//
|
|
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('
|
|
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
|
|
5029
|
-
//
|
|
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
|
|
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
|
|
6759
|
-
* the test shim has no working setState,
|
|
6760
|
-
*
|
|
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 = '
|
|
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
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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'
|
|
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
|
|
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
|
|
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: '
|
|
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:
|
|
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
|
|
2096
|
-
//
|
|
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
|
|
2155
|
-
* `feedback-
|
|
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'
|
|
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)
|