dsh-sidecard-ask 1.7.0 → 1.7.2
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 +61 -0
- package/README.md +30 -7
- package/client.js +698 -22
- package/index.js +25 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.2
|
|
4
|
+
|
|
5
|
+
**主题:文档补丁版——把 DSH 0.2.1-alpha.3 / 0.2.1-alpha.4 的适配核查结论带上 npm 包。**
|
|
6
|
+
|
|
7
|
+
功能与 1.7.1 **完全一致,没有任何行为改动**(`index.js` 与 `client.js` 只有版本号字符串不同,
|
|
8
|
+
可用 `git diff` 逐字节核对)。这个补丁版存在的理由是发布两项文档更新:
|
|
9
|
+
|
|
10
|
+
- **README §7.3** 新增「1.7.1 复核(2026-10-16)」真机验证记录——**0.2.1-alpha.3 / 0.2.1-alpha.4
|
|
11
|
+
两版均无需任何代码改动**:产物层(16 项能力矩阵在 alpha.2/3/4 上逐项一致;13 个相关包的逐文件
|
|
12
|
+
SHA256 diff 证明 alpha.3 → alpha.4 宿主侧零变化,`dsh-subagent` 的 `lib/index.js` 两版逐字节一致;
|
|
13
|
+
主包唯一新增依赖 `dsh-experimental-plugin-market-bundle`)、真机层(隔离 `DSH_HOME` 从零安装、
|
|
14
|
+
官方 CLI 装入 `npm pack` 产物、无头 Edge:**28/28 断言 × 两版全过**,含
|
|
15
|
+
`subagents.startActivation` 实调出真实 childId)、插件四套自测 **1004 项全过**。核查针对 v1.7.1
|
|
16
|
+
完成;本版除版本号外与之逐字节相同,结论原样适用。
|
|
17
|
+
- **README §7.4 / §14**:真机覆盖范围更新至 0.2.1-alpha.4(1.6.0 与 1.7.1 两轮实测);§14 第 6 条
|
|
18
|
+
修正 1.6.0 轮次遗留的过期表述(「除 0.1.7-rc.2 外只做过产物层验证 / 289 项」→「安装启动验证从
|
|
19
|
+
0.1.5-rc.3 起 / 1004 项」),第 2 条补 §7.3 指向。
|
|
20
|
+
- 版本号三处同步到 1.7.2:`package.json` 的 `version`、`index.js` 的 `PLUGIN_VERSION`、
|
|
21
|
+
`client.js` 的 `CLIENT_VERSION`(`test/verify.mjs` 会同时断言三处一致)。
|
|
22
|
+
- 核查的完整证据(复现脚本 / 原始输出 / 截图)随本版提交进 `.compat-report/`——`wb-*` 前缀为
|
|
23
|
+
2026-10-16 的独立复核产物,报告见 `COMPATIBILITY-REPORT-alpha3-alpha4.md`。
|
|
24
|
+
|
|
25
|
+
本机实测(四项均 exit 0):verify 53 / contract 721 / float-placement 38 / smoke 192 = **1004 项全部通过**。
|
|
26
|
+
|
|
27
|
+
> 未验证(如实记录):真实模型流式作答(本机无凭据,验证到 spawn + childId 深度,完整
|
|
28
|
+
> `start→delta×N→done` 由 smoke 按真实事件形状覆盖);桌面 Electron 与 better-sidebar 共载本轮未复测。
|
|
29
|
+
|
|
30
|
+
## 1.7.1
|
|
31
|
+
|
|
32
|
+
**主题:P1 体验补齐——后台通知、历史卡片管理、答后追问芯片、右键菜单、作答透明信息、键盘操作。**
|
|
33
|
+
|
|
34
|
+
- **作答完成后台通知**:新增 `notifyOnDone`(默认关)。仅当页面处于后台(其它标签页/窗口)且作答
|
|
35
|
+
到达终态(完成/失败)时弹系统 Notification;设置页打开开关时即时请求浏览器通知权限,
|
|
36
|
+
权限拒绝/环境不支持时就地说明并静默降级;同一张卡至多通知一次,点击通知聚焦页面。
|
|
37
|
+
- **历史卡片管理中心**:localStorage schema **v1 → v2**(新增 `createdAt`、provider、耗时);
|
|
38
|
+
首次启动自动从 v1 非破坏式导入(v1 键保留,便于降级)。设置页新增「历史追问卡片」区:
|
|
39
|
+
关键字搜索(问题/答案)、恢复到浮层、复制、单条删除、清空全部(带确认)。
|
|
40
|
+
新增 `historyMaxCards`(0–50,默认 10,**0 = 不写盘**)。语义变化:**关闭浮层卡片不再删除
|
|
41
|
+
持久历史**(1.7.0 及以前会随关闭清空),历史改由管理中心显式删除。
|
|
42
|
+
- **答后通用追问芯片**:已完成/已停止的侧边卡片答案下出现「更简短 / 更详细 / 举个例子 /
|
|
43
|
+
译成英文」四个固定芯片,一键追加一轮(旧问答进入 history,区别于 1.7.0 的「改问」重新生成当前轮)。
|
|
44
|
+
- **选区右键菜单**:新增 `contextMenu`(默认开)。在可触发区域右键选中文字时,菜单顶部出现
|
|
45
|
+
「追问选中内容」+ 前三个已启用快捷动作;任何门槛不满足(编辑框内/区域过滤/开关关闭/非 leader)
|
|
46
|
+
都**不拦截**原生右键菜单,作为与宿主菜单冲突时的回退;滚动/Esc/外部点击自动关闭。
|
|
47
|
+
- **作答透明信息**:卡片问题下显示 provider(子代理)与本次作答耗时(`<1s`/`Ns`/`XmYs`),
|
|
48
|
+
随历史持久化;开始事件的 `provider` 字段下放到卡片。
|
|
49
|
+
- **键盘操作**:提问框中 `Alt+1..9` 直接触发对应芯片;芯片行支持 ←/→/Home/End 焦点环移;
|
|
50
|
+
卡片可聚焦(tabIndex 0),聚焦后 `R` 重试(失败/停止态)、`C` 复制答案、`Esc` 取消聚焦,
|
|
51
|
+
卡片标题悬停可见按键提示;插件自身输入框内不拦截任何快捷键。
|
|
52
|
+
- 新增样式 `.dsa-ctxmenu/.dsa-ctx-item/.dsa-fu-chips/.dsa-hist-*`,右键菜单根节点同步
|
|
53
|
+
Electron `-webkit-app-region:no-drag` 守卫;中英词典新增 40+ 键(整表奇偶断言锁定)。
|
|
54
|
+
|
|
55
|
+
本机实测:verify 53 / contract 721 / float-placement 38 / smoke 192 = **1004 项全部通过**
|
|
56
|
+
(较 1.7.0 的 861 项新增 143 项;含通知决策真值表/耗时格式化/v1→v2 迁移与
|
|
57
|
+
历史恢复-删除-清空/右键菜单拦截与开关回退/Alt+数字与答后芯片端到端/新配置宿主校验等)。
|
|
58
|
+
|
|
59
|
+
> 未验证(如实记录):浏览器与桌面 Electron 内的视觉观感与真实点按仍需人工确认——重点是
|
|
60
|
+
> ①系统通知在切后台后的真实弹出与权限弹窗;②右键菜单是否与宿主自身选区菜单冲突
|
|
61
|
+
> (Electron 菜单加速键也未在真机验证);③历史管理区长列表观感。测试覆盖组件树、
|
|
62
|
+
> 事件监听与端到端数据流,替代不了真机观感。
|
|
63
|
+
|
|
3
64
|
## 1.7.0
|
|
4
65
|
|
|
5
66
|
**主题:P0 交互提效——一键快捷动作、自定义动作模板、改问重试、代码块复制。**
|
package/README.md
CHANGED
|
@@ -160,6 +160,9 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
|
|
|
160
160
|
| `sideTimeoutMs` | 5000–3600000 | `180000` | 单次侧边作答超时 | 下一次提问 |
|
|
161
161
|
| `sideProvider` | 字符串 \| `auto` | `auto` | 指定子代理 provider(`auto` 优先 `spawn`) | 下一次提问 |
|
|
162
162
|
| `quickActions` | 动作数组(≤12 条) | 5 个内置动作 | **一键快捷动作**:选中后按钮旁的动作芯片。条目形如 `{id, enabled, shortcut}`;内置 id 为 `builtin:explain/translate/summarize/polish/fix`,自定义动作(非 `builtin:` 前缀)还需 `label`(≤20 字)与 `question`(≤500 字,可用 `{selection}` 内嵌选中正文)。设置页可视化编辑,用户层整表覆盖默认值 | 立即(快捷键下次按键生效) |
|
|
163
|
+
| `notifyOnDone` | 布尔 | `false` | 页面在后台时,作答完成/失败是否弹系统通知;首次开启需浏览器通知权限 | 立即 |
|
|
164
|
+
| `contextMenu` | 布尔 | `true` | 是否在可触发选区的右键菜单顶部加入追问与快捷动作;与宿主菜单冲突时可关 | 立即 |
|
|
165
|
+
| `historyMaxCards` | 0–50 | `10` | 历史卡片持久化上限(localStorage v2);`0` = 不写盘(已有历史保留到显式清空) | 立即 |
|
|
163
166
|
|
|
164
167
|
非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。
|
|
165
168
|
|
|
@@ -393,7 +396,7 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
|
|
|
393
396
|
| 没有 `data-slot` 锚点 | 检测到选区但槽位路径为空 → 判定"区域锚点不可用",**停用区域过滤**(否则 `captureZones:chat` 会静默失效)并在设置页自检里标明 | `contract-test`:`shouldOffer(..., {anchors:false})` 断言放宽且返回 `zoneFiltering:'unavailable'` |
|
|
394
397
|
| 路由 kind 不被接受 | `prefix` 注册失败 → 退化为**逐方法精确路由**(`state/ask/cancel/config/reset`),处理器不变 | 前缀路由由 smoke 全流程覆盖;退化分支为纯 fallback(未在真机触发过) |
|
|
395
398
|
|
|
396
|
-
### 7.3 真机验证记录(1.6.0,2026-10-11)
|
|
399
|
+
### 7.3 真机验证记录(1.6.0,2026-10-11;1.7.1 复核,2026-10-16)
|
|
397
400
|
|
|
398
401
|
1.6.0 把 §7.1/§7.2 的产物层证据升级为**真机证据**:最近 10 个发布版(0.1.5-rc.3 → 0.2.1-alpha.2,
|
|
399
402
|
按 npm 发布时间)全部在隔离 `DSH_HOME` 中真实安装、启动并读取运行时能力面;
|
|
@@ -409,10 +412,23 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
|
|
|
409
412
|
patch id / 配置目录 / Alt+Q 零硬冲突;旧名包 `dsh-selection-followup@1.0.1` 共载时的双重触发由
|
|
410
413
|
1.6.0 的页面内家族单例选举解决。完整矩阵、复现脚本与截图见 `.compat-report/`。
|
|
411
414
|
|
|
415
|
+
**1.7.1 复核(2026-10-16):0.2.1-alpha.3 / 0.2.1-alpha.4——两版均无需任何代码改动**(基线 = 1.6.0 已适配的 alpha.2)。
|
|
416
|
+
|
|
417
|
+
- alpha.3(2026-10-14 发布)与 alpha.4(2026-10-15 发布,`alpha` 通道当前指向)在隔离 `DSH_HOME` 中
|
|
418
|
+
从零安装(官方 CLI 装入 `npm pack` 产物)、启动并做无头 Edge 断言:**28/28 × 两版全过**——
|
|
419
|
+
含 `subagents.startActivation` 实调出真实 childId、三个槽位落点、选区恰 1 个触发按钮、`Alt+Q` 恰 1 个弹层、
|
|
420
|
+
0 启动告警、0 未捕获页面错误。
|
|
421
|
+
- 产物层复核:16 项能力矩阵在 alpha.2/3/4 上逐项一致;13 个相关包的逐文件 SHA256 diff 证实
|
|
422
|
+
alpha.3 → alpha.4 宿主侧零变化(`dsh-subagent` 的 `lib/index.js` 逐字节一致),alpha.4 的增量全在
|
|
423
|
+
客户端侧且为纯增量;alpha.2 → alpha.3 的子代理能力模型重排(`persona`/`toolFilter` 退出协商、
|
|
424
|
+
`agentPreset` 进入)不命中插件消费面——1.6.0 的字段门控原样覆盖。
|
|
425
|
+
- 插件四套自测重跑 **1004 项全过**;完整证据(脚本 / 原始输出 / 截图,`wb-*` 前缀为本轮独立复核产物)
|
|
426
|
+
见 `.compat-report/`,过程细节(含两处探针口径修正的如实记录)见该目录 `COMPATIBILITY-REPORT-alpha3-alpha4.md` §8。
|
|
427
|
+
|
|
412
428
|
### 7.4 更早版本仍未做真机验证的部分(如实标注)
|
|
413
429
|
|
|
414
430
|
- 上表所有 ✅/❌ 都是**包内容层面的证据**(字符串/签名存在于该版本的发布产物),不等于"插件在该版本上跑起来过"。
|
|
415
|
-
真机覆盖为 0.1.5-rc.3 → 0.2.1-alpha.
|
|
431
|
+
真机覆盖为 0.1.5-rc.3 → 0.2.1-alpha.4(1.6.0 与 1.7.1 两轮实测)与 0.1.7-rc.2(§10.1 的历史实测)。
|
|
416
432
|
- 在 0.1.2-rc.1 … 0.1.4 这几个版本上,只验证了"所需 API 是否存在 + 插件有为缺失准备的分支",
|
|
417
433
|
**没有**在那些版本上安装并启动过插件。
|
|
418
434
|
- `inputActions` 作为会话槽位标准 prop:13 个版本的 `dsh-client-ui-conversation` 产物里都有该名字,
|
|
@@ -467,6 +483,13 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
|
|
|
467
483
|
7. 点「关闭」→ 卡片消失;若承载面是 better-sidebar/原生右侧栏,对应 tab 也应关闭。
|
|
468
484
|
7b. 设置页「快捷动作」区:可停用内置动作、给动作配快捷键(如 `Alt+T`)、添加自定义动作
|
|
469
485
|
(名称 + 问题指令,指令里可写 `{selection}`);配好后选中文字直接按快捷键等价于点芯片。
|
|
486
|
+
7c. 答案完成后:问题下方显示「模型 spawn · 耗时 Xs」,答案下方出现
|
|
487
|
+
「更简短 / 更详细 / 举个例子 / 译成英文」芯片;卡片可 Tab 聚焦,`R` 重试、`C` 复制、`Esc` 失焦。
|
|
488
|
+
7d. 在选中文字上**右键**:应出现本插件菜单(追问 + 前三个快捷动作);设置页关闭「选区右键菜单」后,
|
|
489
|
+
右键恢复为宿主原生菜单。
|
|
490
|
+
7e. 设置页勾选「完成后桌面通知」会请求通知权限;授权后切到其它标签页/窗口,作答结束时应弹系统通知。
|
|
491
|
+
7f. 设置页「历史追问卡片」:可搜索、恢复到浮层、单条删除、清空;「历史卡片保存数量」设 0 后新卡片不再写盘;
|
|
492
|
+
注意:**关闭浮层卡片不会删历史**(1.8.0 起历史只在管理中心删除)。
|
|
470
493
|
8. **浮层形态与位置(1.5.0)**:默认应是一个**一行高的胶囊**(状态 + 问题首句),点它才展开成完整卡片;
|
|
471
494
|
展开后标题栏右侧有「收起为胶囊」(⇥)与「隐藏浮层」(×)——隐藏后只剩一个带计数的圆点,点它恢复。
|
|
472
495
|
用 F12 选中浮层根节点(`[data-dsa-root]`)读两个值自检:
|
|
@@ -515,8 +538,8 @@ node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失
|
|
|
515
538
|
```
|
|
516
539
|
|
|
517
540
|
四个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
|
|
518
|
-
当前规模(1.
|
|
519
|
-
smoke **
|
|
541
|
+
当前规模(1.8.0 本机实测,四项均 exit 0):verify **53** 项 + contract **721** 项 + float-placement **38** 项 +
|
|
542
|
+
smoke **192** 项 = **1004 项全部通过**。
|
|
520
543
|
|
|
521
544
|
> 1.5.0 之前这里还放过 `tools/balance-check.mjs` 与 `tools/i18n-check.mjs` 两个「替代检查」。
|
|
522
545
|
> 它们已被**删除**:前者用「正则字面量 vs 除号」的启发式判断,把 8 个源文件全部误报为残损;
|
|
@@ -658,7 +681,7 @@ npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>" # 旧名
|
|
|
658
681
|
本机实测能识别:`conversation.*`(聊天)、`rightbar` / `sidebar.right.*` / 名字含 task·todo·schedule·team·job·plan 的面板(任务)。
|
|
659
682
|
2. **浏览器内的视觉与交互**:1.6.0 起用无头 Edge(Playwright,系统浏览器 `channel:'msedge'`)做真机验证,
|
|
660
683
|
脚本见 `.compat-report/scripts/`,截图见 `.compat-report/shots/`——选区浮按钮、Alt+Q 弹层、共载单实例、
|
|
661
|
-
引擎自检均已在 0.2.0-rc.2 与 0.2.1-alpha.2
|
|
684
|
+
引擎自检均已在 0.2.0-rc.2 与 0.2.1-alpha.2 上自动核对;0.2.1-alpha.3 / alpha.4 的复核见 §7.3。桌面 Electron 的视觉观感仍只有 2026-09-30 的人工实测(§5.3)。
|
|
662
685
|
已做的保障是:只使用 `--dsw-alias-*` 主题令牌、只在槽位内渲染(`shell.overlay` / `settings.section` / `conversation.input.right`)、不写 `document.body`、不 import harness 客户端包。
|
|
663
686
|
2b. **1.5.0 的浮层改动:四个测试脚本已在本机实跑通过(各 exit 0),但页面里的观感仍未验证**:
|
|
664
687
|
写这个版本的前半段,本工作区的 shell 工具整体不可用(任何命令都返回
|
|
@@ -681,6 +704,6 @@ npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>" # 旧名
|
|
|
681
704
|
5. **宿主半代码更新必须重启 DSH**:本机实测 `set_plugin` 关开与 `remove_bundle` + `install_bundle` 都不会让已加载的模块重新读盘。
|
|
682
705
|
本仓库当前文件比运行中的宿主模块新(1.0.1 的「归档父会话不再一刀切 / 持久化 chunk 流式源 / 能力门控 / 路由降级」),
|
|
683
706
|
**重启后**这些改动才生效;`/state` 的 `version` 与 `capabilities.parent` 字段可以直接确认是否已加载新版。
|
|
684
|
-
6.
|
|
685
|
-
|
|
707
|
+
6. **"安装启动"真机验证从 0.1.5-rc.3 起**:1.6.0 实测 0.1.5-rc.3 → 0.2.1-alpha.2,1.7.1 复核 0.2.1-alpha.3 / alpha.4(见 §7.3);
|
|
708
|
+
更早版本只有产物层证据——验证到的是"这些版本缺哪些 API + 插件对每个缺失都有分支",分支本身由 1004 项自测覆盖。
|
|
686
709
|
|
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.7.
|
|
50
|
+
const CLIENT_VERSION = '1.7.2'
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Tab kind served by the DSH native right rail (also its implementation id).
|
|
@@ -108,6 +108,9 @@ window.__ModuleLoader__.load({
|
|
|
108
108
|
{ id: 'builtin:polish', enabled: true, shortcut: '' },
|
|
109
109
|
{ id: 'builtin:fix', enabled: true, shortcut: '' },
|
|
110
110
|
],
|
|
111
|
+
notifyOnDone: false,
|
|
112
|
+
contextMenu: true,
|
|
113
|
+
historyMaxCards: 10,
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
/**
|
|
@@ -136,6 +139,58 @@ window.__ModuleLoader__.load({
|
|
|
136
139
|
'builtin:fix': { labelKey: 'qaFix', questionKey: 'qaFixQ' },
|
|
137
140
|
}
|
|
138
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Fixed follow-up chips offered under a finished answer. They submit a
|
|
144
|
+
* follow-up TURN (the answer stays in history), so the questions refer to
|
|
145
|
+
* "the answer above" rather than restating the selection. Not user
|
|
146
|
+
* configurable in P1: they are four fixed, universally useful refinements.
|
|
147
|
+
*/
|
|
148
|
+
const FOLLOW_UP_CHIPS = [
|
|
149
|
+
{ labelKey: 'fuShorter', questionKey: 'fuShorterQ' },
|
|
150
|
+
{ labelKey: 'fuLonger', questionKey: 'fuLongerQ' },
|
|
151
|
+
{ labelKey: 'fuExample', questionKey: 'fuExampleQ' },
|
|
152
|
+
{ labelKey: 'fuToEnglish', questionKey: 'fuToEnglishQ' },
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
/** Resolve the fixed follow-up chips for the active language. */
|
|
156
|
+
function followUpChips() {
|
|
157
|
+
return FOLLOW_UP_CHIPS.map(meta => ({ label: t(meta.labelKey), question: t(meta.questionKey) }))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Format a side-answer elapsed time for the card meta line. Sub-second
|
|
162
|
+
* runs show as "<1s"; everything else rounds to whole seconds (and whole
|
|
163
|
+
* minutes past 60s). Pure so both the UI and the tests share one format.
|
|
164
|
+
* @param {number} ms - elapsed milliseconds.
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
function formatElapsed(ms) {
|
|
168
|
+
const value = Number(ms)
|
|
169
|
+
if (!Number.isFinite(value) || value < 0) return ''
|
|
170
|
+
if (value < 1000) return '<1s'
|
|
171
|
+
const seconds = Math.round(value / 1000)
|
|
172
|
+
if (seconds < 60) return `${seconds}s`
|
|
173
|
+
const minutes = Math.floor(seconds / 60)
|
|
174
|
+
const rest = seconds % 60
|
|
175
|
+
return rest === 0 ? `${minutes}m` : `${minutes}m${rest}s`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Deterministic local timestamp for the history manager: `YYYY-MM-DD
|
|
180
|
+
* HH:MM`. Returns '' for an absent/legacy entry (v1 entries had no
|
|
181
|
+
* createdAt) instead of showing 1970.
|
|
182
|
+
* @param {number} ts - epoch milliseconds.
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
function formatHistoryTime(ts) {
|
|
186
|
+
const value = Number(ts)
|
|
187
|
+
if (!Number.isFinite(value) || value <= 0) return ''
|
|
188
|
+
const date = new Date(value)
|
|
189
|
+
const pad = n => String(n).padStart(2, '0')
|
|
190
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
|
191
|
+
+ `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
192
|
+
}
|
|
193
|
+
|
|
139
194
|
/** Zone classification: slot-key patterns the harness itself renders. */
|
|
140
195
|
const ZONE_PATTERNS = {
|
|
141
196
|
chat: [/^conversation\b/],
|
|
@@ -365,6 +420,40 @@ window.__ModuleLoader__.load({
|
|
|
365
420
|
qaBadQuestion: '问题指令必填,且不超过 {n} 个字符',
|
|
366
421
|
qaBadShortcut: '“{label}”的快捷键格式不正确,请用 Alt+Q 这样的写法',
|
|
367
422
|
qaDuplicateShortcut: '“{label}”的快捷键与“{other}”冲突',
|
|
423
|
+
fuShorter: '更简短',
|
|
424
|
+
fuShorterQ: '请用更简短的方式重新回答上面的问题,保留关键信息。',
|
|
425
|
+
fuLonger: '更详细',
|
|
426
|
+
fuLongerQ: '请更详细地展开回答上面的问题,补充必要的细节。',
|
|
427
|
+
fuExample: '举个例子',
|
|
428
|
+
fuExampleQ: '请结合一个具体的例子来说明上面的回答。',
|
|
429
|
+
fuToEnglish: '译成英文',
|
|
430
|
+
fuToEnglishQ: 'Please give the same answer in English.',
|
|
431
|
+
metaElapsed: '耗时',
|
|
432
|
+
metaProvider: '模型',
|
|
433
|
+
setNotifyOnDone: '完成后桌面通知',
|
|
434
|
+
setNotifyOnDoneHint: '仅当页面切到后台(其它标签页或窗口)时,作答结束才弹系统通知;首次启用需要浏览器通知权限',
|
|
435
|
+
notifyGranted: '已获得通知权限',
|
|
436
|
+
notifyDenied: '通知权限被拒绝,可在浏览器站点设置中开启',
|
|
437
|
+
notifyUnsupported: '当前环境不支持系统通知',
|
|
438
|
+
notifyDoneTitle: '侧边追问已完成',
|
|
439
|
+
notifyErrorTitle: '侧边追问失败',
|
|
440
|
+
setContextMenu: '选区右键菜单',
|
|
441
|
+
setContextMenuHint: '在可触发区域右键选中文字时,菜单顶部显示追问与快捷动作;若与宿主自身右键菜单冲突可关闭',
|
|
442
|
+
setHistoryMax: '历史卡片保存数量',
|
|
443
|
+
setHistoryMaxHint: '0 = 不保存;上限 50 张,单卡文本最多 64K',
|
|
444
|
+
historyTitle: '历史追问卡片',
|
|
445
|
+
historyDesc: '已完成的侧边卡片保存在本浏览器中,可搜索、恢复到浮层或删除;清空浏览器数据会一并删除。',
|
|
446
|
+
historySearch: '搜索问题或答案…',
|
|
447
|
+
historyEmpty: '还没有历史卡片',
|
|
448
|
+
historyRestore: '恢复',
|
|
449
|
+
historyDelete: '删除',
|
|
450
|
+
historyClear: '清空全部',
|
|
451
|
+
historyClearConfirm: '确定清空全部历史卡片吗?此操作不可撤销。',
|
|
452
|
+
historyClearDone: '历史已清空',
|
|
453
|
+
historyRestored: '已恢复到浮层',
|
|
454
|
+
historyCount: '{n} 条',
|
|
455
|
+
historyDeleted: '已删除',
|
|
456
|
+
cardKeysHint: '键盘:R 重试 · C 复制 · Esc 取消聚焦',
|
|
368
457
|
familyLegacyDiag: '旧版共载',
|
|
369
458
|
familyPeers: '同页实例',
|
|
370
459
|
},
|
|
@@ -538,6 +627,40 @@ window.__ModuleLoader__.load({
|
|
|
538
627
|
qaBadQuestion: 'Questions are required and at most {n} characters',
|
|
539
628
|
qaBadShortcut: 'The shortcut for "{label}" is invalid; use a form like Alt+Q',
|
|
540
629
|
qaDuplicateShortcut: 'The shortcut for "{label}" conflicts with "{other}"',
|
|
630
|
+
fuShorter: 'Shorter',
|
|
631
|
+
fuShorterQ: 'Answer the question above again, more briefly, keeping the key points.',
|
|
632
|
+
fuLonger: 'More detail',
|
|
633
|
+
fuLongerQ: 'Expand the answer above with more detail where it helps.',
|
|
634
|
+
fuExample: 'Give an example',
|
|
635
|
+
fuExampleQ: 'Illustrate the answer above with one concrete example.',
|
|
636
|
+
fuToEnglish: 'In English',
|
|
637
|
+
fuToEnglishQ: 'Please give the same answer in English.',
|
|
638
|
+
metaElapsed: 'Elapsed',
|
|
639
|
+
metaProvider: 'Model',
|
|
640
|
+
setNotifyOnDone: 'Notify when done',
|
|
641
|
+
setNotifyOnDoneHint: 'Only fires a system notification when the page is in the background (another tab or window) and the answer finishes; enabling asks for the browser notification permission once',
|
|
642
|
+
notifyGranted: 'Notification permission granted',
|
|
643
|
+
notifyDenied: 'Notification permission denied; enable it in the browser site settings',
|
|
644
|
+
notifyUnsupported: 'This environment does not support system notifications',
|
|
645
|
+
notifyDoneTitle: 'Side answer done',
|
|
646
|
+
notifyErrorTitle: 'Side answer failed',
|
|
647
|
+
setContextMenu: 'Selection context menu',
|
|
648
|
+
setContextMenuHint: 'Right-clicking a qualifying selection shows Ask plus quick actions at the top of the menu; turn off if it conflicts with the host own menu',
|
|
649
|
+
setHistoryMax: 'Saved history cards',
|
|
650
|
+
setHistoryMaxHint: '0 = do not save; up to 50 cards, at most 64K text each',
|
|
651
|
+
historyTitle: 'History cards',
|
|
652
|
+
historyDesc: 'Finished side cards are kept in this browser; search, restore to the floating layer, or delete them. Clearing browser data removes them too.',
|
|
653
|
+
historySearch: 'Search questions or answers…',
|
|
654
|
+
historyEmpty: 'No history cards yet',
|
|
655
|
+
historyRestore: 'Restore',
|
|
656
|
+
historyDelete: 'Delete',
|
|
657
|
+
historyClear: 'Clear all',
|
|
658
|
+
historyClearConfirm: 'Clear every history card? This cannot be undone.',
|
|
659
|
+
historyClearDone: 'History cleared',
|
|
660
|
+
historyRestored: 'Restored to the floating layer',
|
|
661
|
+
historyCount: '{n} cards',
|
|
662
|
+
historyDeleted: 'Deleted',
|
|
663
|
+
cardKeysHint: 'Keys: R retry · C copy · Esc unfocus',
|
|
541
664
|
},
|
|
542
665
|
}
|
|
543
666
|
|
|
@@ -761,7 +884,14 @@ window.__ModuleLoader__.load({
|
|
|
761
884
|
sessionId: null,
|
|
762
885
|
trigger: null,
|
|
763
886
|
popover: null,
|
|
887
|
+
/** The custom selection context menu: {candidate, x, y} or null. */
|
|
888
|
+
contextMenu: null,
|
|
764
889
|
cards: [],
|
|
890
|
+
/**
|
|
891
|
+
* The durable card history (localStorage v2), mirrored into the store
|
|
892
|
+
* so the settings-page manager can render/search it without IO.
|
|
893
|
+
*/
|
|
894
|
+
history: [],
|
|
765
895
|
toast: null,
|
|
766
896
|
/** Whether the harness renders the `data-slot` zone anchors (null = unknown yet). */
|
|
767
897
|
zoneAnchors: null,
|
|
@@ -1195,6 +1325,84 @@ window.__ModuleLoader__.load({
|
|
|
1195
1325
|
return `${facts.ctrl ? 1 : 0}|${facts.alt ? 1 : 0}|${facts.shift ? 1 : 0}|${facts.meta ? 1 : 0}|${facts.key}`
|
|
1196
1326
|
}
|
|
1197
1327
|
|
|
1328
|
+
/** Whether this browser exposes the Notification API at all. */
|
|
1329
|
+
function notificationSupported() {
|
|
1330
|
+
return typeof window !== 'undefined' && typeof window.Notification === 'function'
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** The Notification permission flag, or 'unsupported' in a bare WebView. */
|
|
1334
|
+
function notificationPermission() {
|
|
1335
|
+
if (!notificationSupported()) return 'unsupported'
|
|
1336
|
+
const permission = window.Notification.permission
|
|
1337
|
+
return typeof permission === 'string' ? permission : 'default'
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Whether a finished run should raise a desktop notification right now.
|
|
1342
|
+
* All four conditions must hold: the feature is enabled, the page is in
|
|
1343
|
+
* the BACKGROUND (notifying a visible page is just noise), the browser
|
|
1344
|
+
* granted permission, and this card has not been notified yet.
|
|
1345
|
+
* @param {object} config - effective config.
|
|
1346
|
+
* @param {boolean} hidden - document.hidden at the terminal moment.
|
|
1347
|
+
* @param {string} permission - notificationPermission() result.
|
|
1348
|
+
* @param {boolean} alreadyNotified - guard against duplicate notices.
|
|
1349
|
+
*/
|
|
1350
|
+
function shouldNotifyFinish(config, hidden, permission, alreadyNotified) {
|
|
1351
|
+
return config?.notifyOnDone === true
|
|
1352
|
+
&& hidden === true
|
|
1353
|
+
&& permission === 'granted'
|
|
1354
|
+
&& alreadyNotified !== true
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** Title/body for one card's finish notification (error vs done). */
|
|
1358
|
+
function buildFinishNotification(card) {
|
|
1359
|
+
const title = card?.status === 'error' ? t('notifyErrorTitle') : t('notifyDoneTitle')
|
|
1360
|
+
const question = String(card?.question ?? '').trim()
|
|
1361
|
+
return { title, body: Array.from(question).slice(0, 80).join('') }
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Request the Notification permission once. Resolves to the resulting
|
|
1366
|
+
* permission string; a rejecting/absent API degrades to 'denied' so the
|
|
1367
|
+
* caller can render the toggle honestly without throwing.
|
|
1368
|
+
*/
|
|
1369
|
+
async function requestNotificationPermission() {
|
|
1370
|
+
if (!notificationSupported()) return 'unsupported'
|
|
1371
|
+
try {
|
|
1372
|
+
const result = await window.Notification.requestPermission()
|
|
1373
|
+
return typeof result === 'string' ? result : notificationPermission()
|
|
1374
|
+
} catch {
|
|
1375
|
+
// Older Safari used a callback-only API; treat a thrown promise as a
|
|
1376
|
+
// refusal rather than breaking the settings page.
|
|
1377
|
+
return notificationPermission() === 'granted' ? 'granted' : 'denied'
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Fire one notification for a finished card. Returns false when the
|
|
1383
|
+
* feature/permission/hidden guard fails, so the caller can stay silent.
|
|
1384
|
+
* Clicking the notification brings the tab forward.
|
|
1385
|
+
*/
|
|
1386
|
+
function notifyCardFinish(card) {
|
|
1387
|
+
const hidden = typeof document !== 'undefined' && document.hidden === true
|
|
1388
|
+
if (!shouldNotifyFinish(store.state.config, hidden, notificationPermission(), card.notified === true)) {
|
|
1389
|
+
return false
|
|
1390
|
+
}
|
|
1391
|
+
try {
|
|
1392
|
+
const { title, body } = buildFinishNotification(card)
|
|
1393
|
+
const notification = new window.Notification(title, { body, tag: `dsh-sidecard-ask:${card.id}` })
|
|
1394
|
+
notification.onclick = () => {
|
|
1395
|
+
try { window.focus() } catch { /* the WebView may refuse focus */ }
|
|
1396
|
+
try { notification.close() } catch { /* already closed */ }
|
|
1397
|
+
}
|
|
1398
|
+
patchCard(card.id, { notified: true })
|
|
1399
|
+
return true
|
|
1400
|
+
} catch (error) {
|
|
1401
|
+
console.warn(`[${PLUGIN_ID}] notification failed:`, error)
|
|
1402
|
+
return false
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1198
1406
|
/**
|
|
1199
1407
|
* Validate a settings-page quick-action draft BEFORE posting it, so the
|
|
1200
1408
|
* Host's all-or-nothing refusal never comes back as a surprise. Also
|
|
@@ -1591,7 +1799,7 @@ window.__ModuleLoader__.load({
|
|
|
1591
1799
|
(\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
|
|
1592
1800
|
plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
|
|
1593
1801
|
Every surface we draw declares it. The property is inert in a browser. */
|
|
1594
|
-
.dsa-layer,.dsa-trigger,.dsa-quickbar,.dsa-pop,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
|
|
1802
|
+
.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}
|
|
1595
1803
|
.dsa-layer{pointer-events:none;position:relative;z-index:60}
|
|
1596
1804
|
.dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
|
|
1597
1805
|
padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
@@ -1616,6 +1824,21 @@ window.__ModuleLoader__.load({
|
|
|
1616
1824
|
/* Chips rendered INSIDE the popover need no floating shadow. */
|
|
1617
1825
|
.dsa-pop-chips{display:flex;gap:6px;flex-wrap:wrap}
|
|
1618
1826
|
.dsa-pop-chips .dsa-chip{box-shadow:none}
|
|
1827
|
+
/* Post-answer follow-up chips live inside a card: quiet, pill-shaped, no
|
|
1828
|
+
floating shadow; the row is shown only for a terminal answer with text. */
|
|
1829
|
+
.dsa-fu-chips{display:flex;gap:6px;flex-wrap:wrap}
|
|
1830
|
+
.dsa-fu-chips .dsa-chip{box-shadow:none;padding:2px 9px;font-size:11px;line-height:15px}
|
|
1831
|
+
/* The selection context menu: a compact vertical list at the cursor. */
|
|
1832
|
+
.dsa-ctxmenu{pointer-events:auto;position:fixed;z-index:65;display:flex;flex-direction:column;
|
|
1833
|
+
min-width:168px;padding:4px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
1834
|
+
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
1835
|
+
box-shadow:0 10px 30px rgba(0,0,0,.24);font-size:13px}
|
|
1836
|
+
.dsa-ctx-item{display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 10px;
|
|
1837
|
+
border:none;border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:12px;
|
|
1838
|
+
line-height:17px;cursor:pointer;white-space:nowrap}
|
|
1839
|
+
.dsa-ctx-item:hover,.dsa-ctx-item:focus-visible{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.14));outline:none}
|
|
1840
|
+
.dsa-ctx-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1, rgba(127,127,127,.25))}
|
|
1841
|
+
.dsa-ctx-badge{margin-left:auto;font-size:10px;color:var(--dsw-alias-label-secondary, #888)}
|
|
1619
1842
|
/* Inside the trigger bar the main button and chips are one flex row: the
|
|
1620
1843
|
trigger drops its own fixed positioning, chips join the row directly. */
|
|
1621
1844
|
.dsa-trigger-embedded{position:static;box-shadow:0 2px 10px rgba(0,0,0,.18)}
|
|
@@ -1735,6 +1958,14 @@ window.__ModuleLoader__.load({
|
|
|
1735
1958
|
.dsa-qa-question{flex-basis:100%!important;min-width:0!important}
|
|
1736
1959
|
.dsa-qa-head{display:flex;align-items:center;gap:8px;font-size:12px;
|
|
1737
1960
|
color:var(--dsw-alias-label-secondary, #555)}
|
|
1961
|
+
/* History-card manager (settings page). */
|
|
1962
|
+
.dsa-hist{display:flex;flex-direction:column;gap:8px}
|
|
1963
|
+
.dsa-hist-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;
|
|
1964
|
+
padding:8px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px}
|
|
1965
|
+
.dsa-hist-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px}
|
|
1966
|
+
.dsa-hist-q{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
|
1967
|
+
color:var(--dsw-alias-label-primary, #111)}
|
|
1968
|
+
.dsa-hist-meta{font-size:11px;color:var(--dsw-alias-label-secondary, #777)}
|
|
1738
1969
|
.dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
|
|
1739
1970
|
color:var(--dsw-alias-label-secondary, #555)}
|
|
1740
1971
|
/* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
|
|
@@ -2018,10 +2249,28 @@ window.__ModuleLoader__.load({
|
|
|
2018
2249
|
* popover. The trigger bar lays chips out directly (`display:contents`);
|
|
2019
2250
|
* the popover wraps them in its own borderless chip row.
|
|
2020
2251
|
*/
|
|
2021
|
-
function QuickChips({ actions, onPick, inline = false }) {
|
|
2252
|
+
function QuickChips({ actions, onPick, inline = false, wrapClassName }) {
|
|
2022
2253
|
if (!Array.isArray(actions) || actions.length === 0) return null
|
|
2023
|
-
|
|
2024
|
-
|
|
2254
|
+
// Roving focus with the arrow keys: chip rows sit beside the trigger
|
|
2255
|
+
// button and inside the popover, and keyboard users must be able to walk
|
|
2256
|
+
// them without Tabbing through the whole page.
|
|
2257
|
+
const onChipKeyDown = (event, index) => {
|
|
2258
|
+
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft'
|
|
2259
|
+
&& event.key !== 'Home' && event.key !== 'End') return
|
|
2260
|
+
const siblings = typeof event.currentTarget?.parentElement?.querySelectorAll === 'function'
|
|
2261
|
+
? [...event.currentTarget.parentElement.querySelectorAll('.dsa-chip')]
|
|
2262
|
+
: []
|
|
2263
|
+
if (siblings.length === 0) return
|
|
2264
|
+
event.preventDefault()
|
|
2265
|
+
let next = index
|
|
2266
|
+
if (event.key === 'ArrowRight') next = (index + 1) % siblings.length
|
|
2267
|
+
else if (event.key === 'ArrowLeft') next = (index - 1 + siblings.length) % siblings.length
|
|
2268
|
+
else if (event.key === 'Home') next = 0
|
|
2269
|
+
else next = siblings.length - 1
|
|
2270
|
+
siblings[next]?.focus?.()
|
|
2271
|
+
}
|
|
2272
|
+
return h('div', { className: wrapClassName ?? (inline ? 'dsa-quickbar-chips' : 'dsa-pop-chips') },
|
|
2273
|
+
actions.map((action, index) => h('button', {
|
|
2025
2274
|
key: action.id,
|
|
2026
2275
|
type: 'button',
|
|
2027
2276
|
className: 'dsa-chip',
|
|
@@ -2030,6 +2279,7 @@ window.__ModuleLoader__.load({
|
|
|
2030
2279
|
: action.question,
|
|
2031
2280
|
'aria-label': `${action.label}:${action.question}`,
|
|
2032
2281
|
onMouseDown: (event) => { event.preventDefault() },
|
|
2282
|
+
onKeyDown: event => onChipKeyDown(event, index),
|
|
2033
2283
|
onClick: (event) => {
|
|
2034
2284
|
event.preventDefault()
|
|
2035
2285
|
event.stopPropagation()
|
|
@@ -2062,6 +2312,64 @@ window.__ModuleLoader__.load({
|
|
|
2062
2312
|
h(QuickChips, { actions, onPick: onQuick, inline: true }))
|
|
2063
2313
|
}
|
|
2064
2314
|
|
|
2315
|
+
/**
|
|
2316
|
+
* The selection context menu (#8). Opens at the cursor with the main Ask
|
|
2317
|
+
* entry plus the FIRST THREE enabled quick actions (kept short so it
|
|
2318
|
+
* never grows past a normal context menu). This is a capability-gated
|
|
2319
|
+
* overlay: the listener only suppresses the native menu when config
|
|
2320
|
+
* allows it AND the candidate passes the same shouldOffer() gate as the
|
|
2321
|
+
* trigger button.
|
|
2322
|
+
*/
|
|
2323
|
+
const CONTEXT_MENU_ACTION_LIMIT = 3
|
|
2324
|
+
function SelectionContextMenu({ candidate, position, config, onClose, onOpen, onQuick }) {
|
|
2325
|
+
const actions = quickActionsOf(config).slice(0, CONTEXT_MENU_ACTION_LIMIT)
|
|
2326
|
+
// Cursor rect: clampBox reads left/top/right/bottom.
|
|
2327
|
+
const point = {
|
|
2328
|
+
left: position?.x ?? 0,
|
|
2329
|
+
top: position?.y ?? 0,
|
|
2330
|
+
right: position?.x ?? 0,
|
|
2331
|
+
bottom: position?.y ?? 0,
|
|
2332
|
+
width: 0,
|
|
2333
|
+
height: 0,
|
|
2334
|
+
}
|
|
2335
|
+
const box = clampBox(point, 200, 40 + actions.length * 31, 'below')
|
|
2336
|
+
const item = (glyph, label, onClick, badge) => h('button', {
|
|
2337
|
+
type: 'button',
|
|
2338
|
+
className: 'dsa-ctx-item',
|
|
2339
|
+
onMouseDown: event => event.preventDefault(),
|
|
2340
|
+
onClick: (event) => {
|
|
2341
|
+
event.preventDefault()
|
|
2342
|
+
event.stopPropagation()
|
|
2343
|
+
onClose()
|
|
2344
|
+
onClick()
|
|
2345
|
+
},
|
|
2346
|
+
}, h('span', { 'aria-hidden': true }, glyph), h('span', null, label),
|
|
2347
|
+
badge !== undefined ? h('span', { className: 'dsa-ctx-badge' }, badge) : null)
|
|
2348
|
+
return h('div', {
|
|
2349
|
+
className: 'dsa-ctxmenu',
|
|
2350
|
+
role: 'menu',
|
|
2351
|
+
'aria-label': t('askTitle'),
|
|
2352
|
+
style: { left: `${box.left}px`, top: `${box.top}px` },
|
|
2353
|
+
onMouseDown: event => event.stopPropagation(),
|
|
2354
|
+
onKeyDown: (event) => { if (event.key === 'Escape') { event.stopPropagation(); onClose() } },
|
|
2355
|
+
},
|
|
2356
|
+
item('💬', t('triggerLabel'), () => onOpen(candidate), zoneLabel(candidate)),
|
|
2357
|
+
actions.length > 0 ? h('div', { className: 'dsa-ctx-sep', 'aria-hidden': true }) : null,
|
|
2358
|
+
actions.map(action => h('button', {
|
|
2359
|
+
key: action.id,
|
|
2360
|
+
type: 'button',
|
|
2361
|
+
role: 'menuitem',
|
|
2362
|
+
className: 'dsa-ctx-item',
|
|
2363
|
+
onMouseDown: event => event.preventDefault(),
|
|
2364
|
+
onClick: (event) => {
|
|
2365
|
+
event.preventDefault()
|
|
2366
|
+
event.stopPropagation()
|
|
2367
|
+
onClose()
|
|
2368
|
+
onQuick(action)
|
|
2369
|
+
},
|
|
2370
|
+
}, h('span', null, action.label))))
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2065
2373
|
/** The question popover. */
|
|
2066
2374
|
function AskPopover({ candidate, config, onClose, onSubmit, onQuick }) {
|
|
2067
2375
|
const [question, setQuestion] = React.useState('')
|
|
@@ -2100,6 +2408,19 @@ window.__ModuleLoader__.load({
|
|
|
2100
2408
|
onClose()
|
|
2101
2409
|
return
|
|
2102
2410
|
}
|
|
2411
|
+
// Alt+1..9 fires the Nth chip without leaving the keyboard. The alt
|
|
2412
|
+
// modifier keeps it from colliding with ordinary digit input, and a
|
|
2413
|
+
// composing IME sequence must pass through untouched.
|
|
2414
|
+
if (event.altKey && !event.ctrlKey && !event.metaKey && !event.isComposing
|
|
2415
|
+
&& /^[1-9]$/.test(event.key)) {
|
|
2416
|
+
const index = Number(event.key) - 1
|
|
2417
|
+
const action = quickActions[index]
|
|
2418
|
+
if (action !== undefined) {
|
|
2419
|
+
event.preventDefault()
|
|
2420
|
+
onQuick(action)
|
|
2421
|
+
}
|
|
2422
|
+
return
|
|
2423
|
+
}
|
|
2103
2424
|
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
|
2104
2425
|
event.preventDefault()
|
|
2105
2426
|
void submit()
|
|
@@ -2260,7 +2581,46 @@ window.__ModuleLoader__.load({
|
|
|
2260
2581
|
'aria-label': t(labelKey),
|
|
2261
2582
|
onClick: () => onFloatMode(mode),
|
|
2262
2583
|
}, glyph)
|
|
2263
|
-
|
|
2584
|
+
// #10: the card is keyboard-focusable; R retries a failed/stopped run,
|
|
2585
|
+
// C copies the answer, Esc releases focus. Plain keys are accepted only
|
|
2586
|
+
// without modifiers and never while composing or inside an editor.
|
|
2587
|
+
const onCardKeyDown = (event) => {
|
|
2588
|
+
if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return
|
|
2589
|
+
const tag = String(event.target?.tagName ?? '')
|
|
2590
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON'
|
|
2591
|
+
|| event.target?.closest?.('[contenteditable=""],[contenteditable="true"]') !== null) {
|
|
2592
|
+
if (event.key === 'Escape' && tag !== 'INPUT' && tag !== 'TEXTAREA') {
|
|
2593
|
+
event.currentTarget?.blur?.()
|
|
2594
|
+
}
|
|
2595
|
+
return
|
|
2596
|
+
}
|
|
2597
|
+
const key = String(event.key ?? '').toLowerCase()
|
|
2598
|
+
if (key === 'r' && (card.status === 'error' || card.status === 'stopped')) {
|
|
2599
|
+
event.preventDefault()
|
|
2600
|
+
onRetry()
|
|
2601
|
+
} else if (key === 'c' && card.text !== '') {
|
|
2602
|
+
event.preventDefault()
|
|
2603
|
+
onCopy(answerRef.current)
|
|
2604
|
+
} else if (event.key === 'Escape') {
|
|
2605
|
+
event.currentTarget?.blur?.()
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
const terminalWithAnswer = (card.status === 'done' || card.status === 'stopped')
|
|
2609
|
+
&& card.carrier === 'side' && card.text !== ''
|
|
2610
|
+
// #9: which model answered and how long it took. provider arrives on the
|
|
2611
|
+
// start event; elapsed is measured client-side across the stream.
|
|
2612
|
+
const metaBits = []
|
|
2613
|
+
if (typeof card.provider === 'string' && card.provider !== '') metaBits.push(`${t('metaProvider')} ${card.provider}`)
|
|
2614
|
+
if (typeof card.elapsedMs === 'number' && Number.isFinite(card.elapsedMs) && formatElapsed(card.elapsedMs) !== '') {
|
|
2615
|
+
metaBits.push(`${t('metaElapsed')} ${formatElapsed(card.elapsedMs)}`)
|
|
2616
|
+
}
|
|
2617
|
+
return h('section', {
|
|
2618
|
+
className: 'dsa-card',
|
|
2619
|
+
'aria-label': t('answerTitle'),
|
|
2620
|
+
tabIndex: 0,
|
|
2621
|
+
title: t('cardKeysHint'),
|
|
2622
|
+
onKeyDown: onCardKeyDown,
|
|
2623
|
+
},
|
|
2264
2624
|
h('div', { className: 'dsa-row' },
|
|
2265
2625
|
h('span', { className: 'dsa-title' }, t('answerTitle')),
|
|
2266
2626
|
h('span', { className: 'dsa-badge' }, zoneLabel(card)),
|
|
@@ -2272,6 +2632,7 @@ window.__ModuleLoader__.load({
|
|
|
2272
2632
|
floatControl('off', 'floatHide', '×'))
|
|
2273
2633
|
: null),
|
|
2274
2634
|
h('pre', { className: 'dsa-quote' }, card.question),
|
|
2635
|
+
metaBits.length > 0 ? h('div', { className: 'dsa-muted' }, metaBits.join(' · ')) : null,
|
|
2275
2636
|
h(AnswerBody, { card, containerRef: answerRef }),
|
|
2276
2637
|
card.toolFilter === 'unsupported'
|
|
2277
2638
|
? h('div', { className: 'dsa-muted' }, t('toolGuardUnavailable'))
|
|
@@ -2341,6 +2702,16 @@ window.__ModuleLoader__.load({
|
|
|
2341
2702
|
},
|
|
2342
2703
|
}, t('retryAskSubmit'))))
|
|
2343
2704
|
: null,
|
|
2705
|
+
// #7: one-tap refinements of a finished answer. They submit follow-up
|
|
2706
|
+
// TURNS through the same handler as the textarea, so the answer stays
|
|
2707
|
+
// in history and each chip costs one ordinary request.
|
|
2708
|
+
terminalWithAnswer
|
|
2709
|
+
? h(QuickChips, {
|
|
2710
|
+
wrapClassName: 'dsa-fu-chips',
|
|
2711
|
+
actions: followUpChips(),
|
|
2712
|
+
onPick: chip => onFollowUp(chip.question),
|
|
2713
|
+
})
|
|
2714
|
+
: null,
|
|
2344
2715
|
h(CardActions, {
|
|
2345
2716
|
card,
|
|
2346
2717
|
onCopy: () => onCopy(answerRef.current),
|
|
@@ -2526,6 +2897,16 @@ window.__ModuleLoader__.load({
|
|
|
2526
2897
|
onQuick: action => actions.quickAsk(popover.candidate, action),
|
|
2527
2898
|
})
|
|
2528
2899
|
: null,
|
|
2900
|
+
state.contextMenu !== null
|
|
2901
|
+
? h(SelectionContextMenu, {
|
|
2902
|
+
candidate: state.contextMenu.candidate,
|
|
2903
|
+
position: state.contextMenu.position,
|
|
2904
|
+
config,
|
|
2905
|
+
onClose: () => actions.closeContextMenu(),
|
|
2906
|
+
onOpen: (candidate) => actions.openPopover(candidate),
|
|
2907
|
+
onQuick: action => actions.quickAsk(state.contextMenu.candidate, action),
|
|
2908
|
+
})
|
|
2909
|
+
: null,
|
|
2529
2910
|
h(FloatingLayer, { cards: state.cards, actions }),
|
|
2530
2911
|
state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
|
|
2531
2912
|
}
|
|
@@ -2536,10 +2917,26 @@ window.__ModuleLoader__.load({
|
|
|
2536
2917
|
const [draft, setDraft] = React.useState(state.config)
|
|
2537
2918
|
const [status, setStatus] = React.useState('idle')
|
|
2538
2919
|
const [diag, setDiag] = React.useState(null)
|
|
2920
|
+
const [historyQuery, setHistoryQuery] = React.useState('')
|
|
2921
|
+
const [notifyPerm, setNotifyPerm] = React.useState(
|
|
2922
|
+
typeof notificationPermission === 'function' ? notificationPermission() : 'unsupported')
|
|
2539
2923
|
const config = state.config
|
|
2540
2924
|
|
|
2541
2925
|
React.useEffect(() => { setDraft(config) }, [config])
|
|
2542
2926
|
|
|
2927
|
+
// #5: enabling the toggle asks for the Notification permission
|
|
2928
|
+
// immediately, and the resulting verdict is shown beside the checkbox
|
|
2929
|
+
// so a denied toggle never looks silently broken.
|
|
2930
|
+
const onNotifyToggle = async (enabled) => {
|
|
2931
|
+
setDraft({ ...draft, notifyOnDone: enabled })
|
|
2932
|
+
if (enabled !== true) return
|
|
2933
|
+
if (notifyPerm === 'granted') return
|
|
2934
|
+
const result = await requestNotificationPermission()
|
|
2935
|
+
setNotifyPerm(result)
|
|
2936
|
+
if (result !== 'granted') toast(result === 'unsupported' ? t('notifyUnsupported') : t('notifyDenied'))
|
|
2937
|
+
else toast(t('notifyGranted'))
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2543
2940
|
const field = (label, control, hint) => h('div', { className: 'dsa-field' },
|
|
2544
2941
|
h('label', null, label),
|
|
2545
2942
|
control,
|
|
@@ -2677,6 +3074,73 @@ window.__ModuleLoader__.load({
|
|
|
2677
3074
|
}
|
|
2678
3075
|
}
|
|
2679
3076
|
|
|
3077
|
+
// ── history manager (#6) ─────────────────────────────────────────
|
|
3078
|
+
const zoneLabelOf = zone => (zone === 'chat' ? t('zoneChat') : zone === 'task' ? t('zoneTask') : t('zoneOther'))
|
|
3079
|
+
const query = historyQuery.trim().toLowerCase()
|
|
3080
|
+
const filteredHistory = query === ''
|
|
3081
|
+
? state.history
|
|
3082
|
+
: state.history.filter(entry =>
|
|
3083
|
+
String(entry.question ?? '').toLowerCase().includes(query)
|
|
3084
|
+
|| String(entry.text ?? '').toLowerCase().includes(query))
|
|
3085
|
+
const historySection = h('div', { className: 'dsa-field' },
|
|
3086
|
+
h('label', null, `${t('historyTitle')}(${t('historyCount', { n: state.history.length })})`),
|
|
3087
|
+
h('span', { className: 'dsa-muted' }, t('historyDesc')),
|
|
3088
|
+
h('input', {
|
|
3089
|
+
className: 'dsa-input',
|
|
3090
|
+
value: historyQuery,
|
|
3091
|
+
placeholder: t('historySearch'),
|
|
3092
|
+
onChange: event => setHistoryQuery(event.target.value),
|
|
3093
|
+
}),
|
|
3094
|
+
state.history.length === 0
|
|
3095
|
+
? h('div', { className: 'dsa-muted' }, t('historyEmpty'))
|
|
3096
|
+
: h('div', { className: 'dsa-hist' },
|
|
3097
|
+
filteredHistory.length === 0 && query !== ''
|
|
3098
|
+
? h('div', { className: 'dsa-muted' }, t('historyEmpty'))
|
|
3099
|
+
: null,
|
|
3100
|
+
filteredHistory.map(entry => h('div', { key: entry.id, className: 'dsa-hist-row' },
|
|
3101
|
+
h('div', { className: 'dsa-hist-main' },
|
|
3102
|
+
h('div', { className: 'dsa-hist-q', title: entry.question }, entry.question),
|
|
3103
|
+
h('div', { className: 'dsa-hist-meta' },
|
|
3104
|
+
[
|
|
3105
|
+
zoneLabelOf(entry.zone),
|
|
3106
|
+
formatHistoryTime(entry.createdAt),
|
|
3107
|
+
typeof entry.provider === 'string' && entry.provider !== ''
|
|
3108
|
+
? `${t('metaProvider')} ${entry.provider}`
|
|
3109
|
+
: '',
|
|
3110
|
+
Number.isFinite(entry.elapsedMs) ? `${t('metaElapsed')} ${formatElapsed(entry.elapsedMs)}` : '',
|
|
3111
|
+
].filter(part => part !== '').join(' · '))),
|
|
3112
|
+
h('button', {
|
|
3113
|
+
type: 'button',
|
|
3114
|
+
className: 'dsa-btn',
|
|
3115
|
+
onClick: () => actions.restoreHistoryCard(entry.id),
|
|
3116
|
+
}, t('historyRestore')),
|
|
3117
|
+
h('button', {
|
|
3118
|
+
type: 'button',
|
|
3119
|
+
className: 'dsa-btn',
|
|
3120
|
+
onClick: () => {
|
|
3121
|
+
void copyText(String(entry.text ?? '')).then((result) => {
|
|
3122
|
+
toast(result === 'copied' ? t('copied') : result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
|
|
3123
|
+
})
|
|
3124
|
+
},
|
|
3125
|
+
}, t('copy')),
|
|
3126
|
+
h('button', {
|
|
3127
|
+
type: 'button',
|
|
3128
|
+
className: 'dsa-btn',
|
|
3129
|
+
onClick: () => actions.deleteHistoryCard(entry.id),
|
|
3130
|
+
}, t('historyDelete')))),
|
|
3131
|
+
state.history.length > 0
|
|
3132
|
+
? h('div', null, h('button', {
|
|
3133
|
+
type: 'button',
|
|
3134
|
+
className: 'dsa-btn',
|
|
3135
|
+
onClick: () => {
|
|
3136
|
+
// confirm() is optional in embedded WebViews; when absent,
|
|
3137
|
+
// clear immediately (the manager is explicitly user-driven).
|
|
3138
|
+
if (typeof window.confirm === 'function' && !window.confirm(t('historyClearConfirm'))) return
|
|
3139
|
+
actions.clearHistory()
|
|
3140
|
+
},
|
|
3141
|
+
}, t('historyClear')))
|
|
3142
|
+
: null))
|
|
3143
|
+
|
|
2680
3144
|
// §4.5 a non-leader copy explains why its interaction surface is gone.
|
|
2681
3145
|
const familyBanner = state.familyLeader === false
|
|
2682
3146
|
? h('div', { className: 'dsa-family-notice', role: 'status' },
|
|
@@ -2732,7 +3196,8 @@ window.__ModuleLoader__.load({
|
|
|
2732
3196
|
['inherit', t('setSideToolsInherit')],
|
|
2733
3197
|
])),
|
|
2734
3198
|
field(t('setSideTimeout'), number('sideTimeoutMs', 5000, 3600000), t('setSideTimeoutHint')),
|
|
2735
|
-
field(t('setSideProvider'), providerSelect(), t('setSideProviderHint'))
|
|
3199
|
+
field(t('setSideProvider'), providerSelect(), t('setSideProviderHint')),
|
|
3200
|
+
field(t('setHistoryMax'), number('historyMaxCards', 0, 50), t('setHistoryMaxHint'))),
|
|
2736
3201
|
quickActionsEditor,
|
|
2737
3202
|
h('label', { className: 'dsa-check' },
|
|
2738
3203
|
h('input', {
|
|
@@ -2741,6 +3206,29 @@ window.__ModuleLoader__.load({
|
|
|
2741
3206
|
onChange: event => setDraft({ ...draft, showInUnclassified: event.target.checked }),
|
|
2742
3207
|
}),
|
|
2743
3208
|
t('setUnclassified')),
|
|
3209
|
+
h('label', { className: 'dsa-check' },
|
|
3210
|
+
h('input', {
|
|
3211
|
+
type: 'checkbox',
|
|
3212
|
+
checked: draft.contextMenu === true,
|
|
3213
|
+
onChange: event => setDraft({ ...draft, contextMenu: event.target.checked }),
|
|
3214
|
+
}),
|
|
3215
|
+
t('setContextMenu')),
|
|
3216
|
+
h('span', { className: 'dsa-muted' }, t('setContextMenuHint')),
|
|
3217
|
+
h('label', { className: 'dsa-check' },
|
|
3218
|
+
h('input', {
|
|
3219
|
+
type: 'checkbox',
|
|
3220
|
+
checked: draft.notifyOnDone === true,
|
|
3221
|
+
disabled: notifyPerm === 'unsupported',
|
|
3222
|
+
onChange: event => { void onNotifyToggle(event.target.checked) },
|
|
3223
|
+
}),
|
|
3224
|
+
t('setNotifyOnDone')),
|
|
3225
|
+
h('span', { className: 'dsa-muted' },
|
|
3226
|
+
notifyPerm === 'unsupported'
|
|
3227
|
+
? t('notifyUnsupported')
|
|
3228
|
+
: notifyPerm === 'denied'
|
|
3229
|
+
? t('notifyDenied')
|
|
3230
|
+
: t('setNotifyOnDoneHint')),
|
|
3231
|
+
historySection,
|
|
2744
3232
|
h('div', { className: 'dsa-row' },
|
|
2745
3233
|
h('button', {
|
|
2746
3234
|
type: 'button',
|
|
@@ -2895,28 +3383,52 @@ window.__ModuleLoader__.load({
|
|
|
2895
3383
|
// server round-trip) and replayed on boot. Everything here is optional
|
|
2896
3384
|
// by design — a private-mode window (throws on setItem), a full quota,
|
|
2897
3385
|
// or a tampered payload must degrade to "no history", never break boot.
|
|
2898
|
-
|
|
2899
|
-
|
|
3386
|
+
// v2 (1.8.0): adds `createdAt`, a configurable cap
|
|
3387
|
+
// (`historyMaxCards`, 0–50) and per-entry manager actions. The v1
|
|
3388
|
+
// payload is read once and imported into v2 on boot; the v1 key is
|
|
3389
|
+
// left untouched (no destructive migration), so a downgrade still
|
|
3390
|
+
// finds it.
|
|
3391
|
+
const PERSIST_KEY = 'dsh-sidecard-ask:cards:v2'
|
|
3392
|
+
const PERSIST_LEGACY_KEY = 'dsh-sidecard-ask:cards:v1'
|
|
3393
|
+
const PERSIST_FALLBACK_CARDS = 10
|
|
2900
3394
|
const PERSIST_TEXT_LIMIT = 65536
|
|
2901
3395
|
|
|
3396
|
+
/** Configured durable cap, clamped to the Host's 0–50 range. */
|
|
3397
|
+
const historyCap = () => {
|
|
3398
|
+
const value = Number(store.state.config.historyMaxCards)
|
|
3399
|
+
if (!Number.isFinite(value)) return PERSIST_FALLBACK_CARDS
|
|
3400
|
+
return Math.max(0, Math.min(50, Math.round(value)))
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
/** Parse one localStorage envelope of a given schema version. */
|
|
3404
|
+
const parseEnvelope = (raw, version) => {
|
|
3405
|
+
if (raw === null || raw === undefined) return []
|
|
3406
|
+
try {
|
|
3407
|
+
const value = JSON.parse(raw)
|
|
3408
|
+
if (value?.version !== version || !Array.isArray(value.cards)) return []
|
|
3409
|
+
return value.cards
|
|
3410
|
+
.filter(entry => typeof entry?.id === 'string' && entry.id !== '' && typeof entry.question === 'string')
|
|
3411
|
+
} catch { return [] }
|
|
3412
|
+
}
|
|
3413
|
+
|
|
2902
3414
|
function readPersisted() {
|
|
2903
3415
|
let raw = null
|
|
2904
3416
|
try {
|
|
2905
3417
|
raw = window.localStorage?.getItem(PERSIST_KEY)
|
|
2906
3418
|
} catch { return [] }
|
|
2907
|
-
|
|
3419
|
+
const v2 = parseEnvelope(raw, 2).slice(0, 50)
|
|
3420
|
+
if (v2.length > 0 || (raw !== null && raw !== undefined)) return v2
|
|
3421
|
+
// One-time v1 import: v1 entries carry no createdAt (0 = unknown).
|
|
3422
|
+
let legacy = null
|
|
2908
3423
|
try {
|
|
2909
|
-
|
|
2910
|
-
if (value?.version !== 1 || !Array.isArray(value.cards)) return []
|
|
2911
|
-
return value.cards
|
|
2912
|
-
.filter(entry => typeof entry?.id === 'string' && entry.id !== '' && typeof entry.question === 'string')
|
|
2913
|
-
.slice(0, PERSIST_MAX_CARDS)
|
|
3424
|
+
legacy = window.localStorage?.getItem(PERSIST_LEGACY_KEY)
|
|
2914
3425
|
} catch { return [] }
|
|
3426
|
+
return parseEnvelope(legacy, 1).slice(0, 50).map(entry => ({ ...entry, createdAt: 0 }))
|
|
2915
3427
|
}
|
|
2916
3428
|
|
|
2917
3429
|
function writePersisted(cards) {
|
|
2918
3430
|
try {
|
|
2919
|
-
window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version:
|
|
3431
|
+
window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 2, cards }))
|
|
2920
3432
|
} catch (error) {
|
|
2921
3433
|
// Quota or privacy mode — the cards stay on screen, just not durable.
|
|
2922
3434
|
console.warn(`[${PLUGIN_ID}] card history not persisted:`, error)
|
|
@@ -2925,11 +3437,12 @@ window.__ModuleLoader__.load({
|
|
|
2925
3437
|
|
|
2926
3438
|
/** Recompute the durable set from the live store and write it. */
|
|
2927
3439
|
const syncPersisted = () => {
|
|
3440
|
+
const cap = historyCap()
|
|
2928
3441
|
const finished = store.state.cards
|
|
2929
3442
|
.filter(card => card.carrier === 'side'
|
|
2930
3443
|
&& (card.status === 'done' || card.status === 'stopped')
|
|
2931
3444
|
&& card.text !== '')
|
|
2932
|
-
.slice(0,
|
|
3445
|
+
.slice(0, cap)
|
|
2933
3446
|
.map(card => ({
|
|
2934
3447
|
id: card.id,
|
|
2935
3448
|
question: card.question,
|
|
@@ -2942,15 +3455,38 @@ window.__ModuleLoader__.load({
|
|
|
2942
3455
|
zone: card.zone,
|
|
2943
3456
|
truncated: card.truncated === true,
|
|
2944
3457
|
droppedChars: Number(card.droppedChars ?? 0),
|
|
3458
|
+
createdAt: Number.isFinite(card.createdAt) ? card.createdAt : Date.now(),
|
|
3459
|
+
provider: typeof card.provider === 'string' ? card.provider : '',
|
|
3460
|
+
elapsedMs: Number.isFinite(card.elapsedMs) ? card.elapsedMs : null,
|
|
2945
3461
|
history: Array.isArray(card.history) ? card.history.slice(-12) : [],
|
|
2946
3462
|
}))
|
|
3463
|
+
// cap === 0 disables persistence: do not touch the existing key
|
|
3464
|
+
// (documented as "kept until an explicit clear"), just mirror the
|
|
3465
|
+
// in-memory state (empty manager view).
|
|
3466
|
+
if (cap === 0) {
|
|
3467
|
+
store.set({ history: [] })
|
|
3468
|
+
return
|
|
3469
|
+
}
|
|
2947
3470
|
writePersisted(finished)
|
|
3471
|
+
store.set({ history: finished })
|
|
2948
3472
|
}
|
|
2949
3473
|
|
|
2950
3474
|
/** Replay finished cards from a previous page load into the store. */
|
|
2951
3475
|
const restorePersisted = () => {
|
|
2952
|
-
|
|
3476
|
+
// Detect the v1→v2 migration BEFORE reading: when the v2 key is
|
|
3477
|
+
// absent but a legacy payload exists, the imported set is written
|
|
3478
|
+
// out under v2 once (non-destructively; v1 is kept).
|
|
3479
|
+
let v2Raw
|
|
3480
|
+
try {
|
|
3481
|
+
v2Raw = window.localStorage?.getItem(PERSIST_KEY)
|
|
3482
|
+
} catch { v2Raw = null }
|
|
3483
|
+
const cap = historyCap()
|
|
3484
|
+
const saved = readPersisted().slice(0, cap)
|
|
3485
|
+
store.set({ history: saved })
|
|
2953
3486
|
if (saved.length === 0) return
|
|
3487
|
+
if (cap > 0 && (v2Raw === null || v2Raw === undefined)) {
|
|
3488
|
+
writePersisted(saved)
|
|
3489
|
+
}
|
|
2954
3490
|
const replayed = saved.map(entry => ({
|
|
2955
3491
|
id: entry.id,
|
|
2956
3492
|
question: entry.question,
|
|
@@ -2965,6 +3501,9 @@ window.__ModuleLoader__.load({
|
|
|
2965
3501
|
droppedChars: Number(entry.droppedChars ?? 0),
|
|
2966
3502
|
streaming: null,
|
|
2967
3503
|
error: null,
|
|
3504
|
+
provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
|
|
3505
|
+
elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
|
|
3506
|
+
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
|
|
2968
3507
|
// A native/better tab cannot survive a reload, so history always
|
|
2969
3508
|
// comes back on the flow stack where it is visible immediately.
|
|
2970
3509
|
surface: 'flow',
|
|
@@ -2978,6 +3517,34 @@ window.__ModuleLoader__.load({
|
|
|
2978
3517
|
store.set({ cards: [...replayed, ...store.state.cards] })
|
|
2979
3518
|
}
|
|
2980
3519
|
|
|
3520
|
+
/** Map one durable history entry back to a live flow card. */
|
|
3521
|
+
const historyEntryToCard = (entry) => ({
|
|
3522
|
+
id: entry.id,
|
|
3523
|
+
question: entry.question,
|
|
3524
|
+
selected: String(entry.selected ?? ''),
|
|
3525
|
+
selection: String(entry.selection ?? ''),
|
|
3526
|
+
text: String(entry.text ?? ''),
|
|
3527
|
+
reasoning: String(entry.reasoning ?? ''),
|
|
3528
|
+
status: entry.status === 'stopped' ? 'stopped' : 'done',
|
|
3529
|
+
carrier: 'side',
|
|
3530
|
+
zone: entry.zone,
|
|
3531
|
+
truncated: entry.truncated === true,
|
|
3532
|
+
droppedChars: Number(entry.droppedChars ?? 0),
|
|
3533
|
+
streaming: null,
|
|
3534
|
+
error: null,
|
|
3535
|
+
provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
|
|
3536
|
+
elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
|
|
3537
|
+
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
|
|
3538
|
+
notified: true,
|
|
3539
|
+
surface: 'flow',
|
|
3540
|
+
open: true,
|
|
3541
|
+
history: Array.isArray(entry.history)
|
|
3542
|
+
? entry.history
|
|
3543
|
+
.filter(turn => turn !== null && typeof turn === 'object')
|
|
3544
|
+
.map(turn => ({ question: String(turn.question ?? ''), answer: String(turn.answer ?? '') }))
|
|
3545
|
+
: [],
|
|
3546
|
+
})
|
|
3547
|
+
|
|
2981
3548
|
const actions = {
|
|
2982
3549
|
openPopover(candidate) {
|
|
2983
3550
|
store.set({ popover: { candidate } })
|
|
@@ -2985,6 +3552,49 @@ window.__ModuleLoader__.load({
|
|
|
2985
3552
|
closePopover() {
|
|
2986
3553
|
store.set({ popover: null })
|
|
2987
3554
|
},
|
|
3555
|
+
openContextMenu(candidate, position) {
|
|
3556
|
+
store.set({ contextMenu: { candidate, position } })
|
|
3557
|
+
},
|
|
3558
|
+
closeContextMenu() {
|
|
3559
|
+
if (store.state.contextMenu !== null) store.set({ contextMenu: null })
|
|
3560
|
+
},
|
|
3561
|
+
/** Bring one durable card back onto the floating stack. */
|
|
3562
|
+
restoreHistoryCard(id) {
|
|
3563
|
+
const entry = store.state.history.find(item => item.id === id)
|
|
3564
|
+
if (entry === undefined) return false
|
|
3565
|
+
if (store.state.cards.some(card => card.id === id)) {
|
|
3566
|
+
patchCard(id, { surface: 'flow', open: true })
|
|
3567
|
+
} else {
|
|
3568
|
+
store.set({ cards: [historyEntryToCard(entry), ...store.state.cards] })
|
|
3569
|
+
}
|
|
3570
|
+
// A hidden (off/capsule) layer would swallow the restored card;
|
|
3571
|
+
// this is a session-only override, exactly like clicking expand.
|
|
3572
|
+
setFloatMode('full')
|
|
3573
|
+
toast(t('historyRestored'))
|
|
3574
|
+
return true
|
|
3575
|
+
},
|
|
3576
|
+
/** Delete one entry from durable history (and its live card). */
|
|
3577
|
+
deleteHistoryCard(id) {
|
|
3578
|
+
const next = store.state.history
|
|
3579
|
+
.filter(item => item.id !== id)
|
|
3580
|
+
.slice(0, historyCap())
|
|
3581
|
+
writePersisted(next)
|
|
3582
|
+
store.set({
|
|
3583
|
+
history: next,
|
|
3584
|
+
cards: store.state.cards.filter(card => card.id !== id),
|
|
3585
|
+
})
|
|
3586
|
+
toast(t('historyDeleted'))
|
|
3587
|
+
},
|
|
3588
|
+
/** Empty the durable history without touching running cards. */
|
|
3589
|
+
clearHistory() {
|
|
3590
|
+
try {
|
|
3591
|
+
window.localStorage?.removeItem(PERSIST_KEY)
|
|
3592
|
+
} catch (error) {
|
|
3593
|
+
console.warn(`[${PLUGIN_ID}] history clear failed:`, error)
|
|
3594
|
+
}
|
|
3595
|
+
store.set({ history: [] })
|
|
3596
|
+
toast(t('historyClearDone'))
|
|
3597
|
+
},
|
|
2988
3598
|
async submit(candidate, payload) {
|
|
2989
3599
|
store.set({ popover: null, trigger: null })
|
|
2990
3600
|
const config = store.state.config
|
|
@@ -3006,6 +3616,13 @@ window.__ModuleLoader__.load({
|
|
|
3006
3616
|
surface: 'flow',
|
|
3007
3617
|
open: true,
|
|
3008
3618
|
history: [],
|
|
3619
|
+
// #9 transparency: provider from the start event, elapsed
|
|
3620
|
+
// measured across the stream; createdAt feeds history v2.
|
|
3621
|
+
provider: null,
|
|
3622
|
+
elapsedMs: null,
|
|
3623
|
+
createdAt: Date.now(),
|
|
3624
|
+
// #5 guard: at most one notification per card.
|
|
3625
|
+
notified: false,
|
|
3009
3626
|
}
|
|
3010
3627
|
if (payload.carrier === 'main') {
|
|
3011
3628
|
store.set({ cards: [card, ...store.state.cards] })
|
|
@@ -3210,7 +3827,9 @@ window.__ModuleLoader__.load({
|
|
|
3210
3827
|
} catch { /* the right rail owns its lifecycle */ }
|
|
3211
3828
|
}
|
|
3212
3829
|
store.set({ cards: store.state.cards.filter(item => item.id !== card.id) })
|
|
3213
|
-
|
|
3830
|
+
// #6: closing a card only removes it from the live surface; the
|
|
3831
|
+
// durable history (written when the answer finished) is retained
|
|
3832
|
+
// and managed explicitly in the settings-page history manager.
|
|
3214
3833
|
},
|
|
3215
3834
|
async saveConfig(patch) {
|
|
3216
3835
|
const value = await postJson('config', patch)
|
|
@@ -3279,7 +3898,19 @@ window.__ModuleLoader__.load({
|
|
|
3279
3898
|
if (card === undefined) return
|
|
3280
3899
|
const controller = new AbortController()
|
|
3281
3900
|
controllers.set(cardId, controller)
|
|
3282
|
-
|
|
3901
|
+
// #9: wall time from the (re)start of THIS attempt to its terminal
|
|
3902
|
+
// frame. retry/retryEdit both run this function again, so the timer
|
|
3903
|
+
// naturally measures the fresh attempt only.
|
|
3904
|
+
const startedAt = Date.now()
|
|
3905
|
+
patchCard(cardId, {
|
|
3906
|
+
status: 'streaming',
|
|
3907
|
+
text: '',
|
|
3908
|
+
reasoning: '',
|
|
3909
|
+
error: null,
|
|
3910
|
+
provider: null,
|
|
3911
|
+
elapsedMs: null,
|
|
3912
|
+
notified: false,
|
|
3913
|
+
})
|
|
3283
3914
|
await streamAsk({
|
|
3284
3915
|
id: cardId,
|
|
3285
3916
|
question: card.question,
|
|
@@ -3297,6 +3928,7 @@ window.__ModuleLoader__.load({
|
|
|
3297
3928
|
// Reported by the host so the card can be honest about the
|
|
3298
3929
|
// read-only guard when the provider cannot take a tool filter.
|
|
3299
3930
|
toolFilter: typeof data?.toolFilter === 'string' ? data.toolFilter : null,
|
|
3931
|
+
provider: typeof data?.provider === 'string' ? data.provider : (card.provider ?? null),
|
|
3300
3932
|
})
|
|
3301
3933
|
},
|
|
3302
3934
|
onDelta: data => bufferStreamDelta(cardId, String(data?.text ?? '')),
|
|
@@ -3308,20 +3940,27 @@ window.__ModuleLoader__.load({
|
|
|
3308
3940
|
if (controller.signal.aborted) return
|
|
3309
3941
|
flushStreamBuffers()
|
|
3310
3942
|
const current = store.state.cards.find(item => item.id === cardId)
|
|
3943
|
+
const status = data?.aborted === true ? 'stopped' : 'done'
|
|
3311
3944
|
patchCard(cardId, {
|
|
3312
|
-
status
|
|
3945
|
+
status,
|
|
3313
3946
|
streaming: data?.streaming === true,
|
|
3314
3947
|
text: typeof data?.text === 'string' && data.text !== '' ? data.text : (current?.text ?? ''),
|
|
3315
3948
|
reasoning: typeof data?.reasoning === 'string' ? data.reasoning : (current?.reasoning ?? ''),
|
|
3949
|
+
elapsedMs: Date.now() - startedAt,
|
|
3316
3950
|
})
|
|
3317
3951
|
controllers.delete(cardId)
|
|
3318
3952
|
syncPersisted()
|
|
3953
|
+
// #5: background finish notification (guarded inside).
|
|
3954
|
+
const finished = store.state.cards.find(item => item.id === cardId)
|
|
3955
|
+
if (finished !== undefined) notifyCardFinish(finished)
|
|
3319
3956
|
},
|
|
3320
3957
|
onError: (error) => {
|
|
3321
3958
|
if (controller.signal.aborted) return
|
|
3322
3959
|
flushStreamBuffers()
|
|
3323
|
-
patchCard(cardId, { status: 'error', error })
|
|
3960
|
+
patchCard(cardId, { status: 'error', error, elapsedMs: Date.now() - startedAt })
|
|
3324
3961
|
controllers.delete(cardId)
|
|
3962
|
+
const failed = store.state.cards.find(item => item.id === cardId)
|
|
3963
|
+
if (failed !== undefined) notifyCardFinish(failed)
|
|
3325
3964
|
},
|
|
3326
3965
|
})
|
|
3327
3966
|
}
|
|
@@ -3380,6 +4019,27 @@ window.__ModuleLoader__.load({
|
|
|
3380
4019
|
const element = elementOf(event.target)
|
|
3381
4020
|
if (element !== null && element.closest('[data-dsa-root]') !== null) return
|
|
3382
4021
|
if (store.state.popover !== null) store.set({ popover: null })
|
|
4022
|
+
if (store.state.contextMenu !== null) store.set({ contextMenu: null })
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
// #8: right-click on a qualifying selection raises OUR menu instead
|
|
4026
|
+
// of the native one. The same gates as the trigger button apply —
|
|
4027
|
+
// leader, config toggle, editable-field exclusion, zone filter — and
|
|
4028
|
+
// when any gate fails we do NOT preventDefault, so the host's native
|
|
4029
|
+
// menu opens untouched (that is the documented conflict fallback).
|
|
4030
|
+
const onContextMenu = (event) => {
|
|
4031
|
+
if (store.state.familyLeader === false) return
|
|
4032
|
+
const config = store.state.config
|
|
4033
|
+
if (config.contextMenu !== true) return
|
|
4034
|
+
const candidate = readSelection()
|
|
4035
|
+
const decision = shouldOffer(candidate, config, { anchors: store.state.zoneAnchors !== false })
|
|
4036
|
+
if (!decision.ok) {
|
|
4037
|
+
if (store.state.contextMenu !== null) store.set({ contextMenu: null })
|
|
4038
|
+
return
|
|
4039
|
+
}
|
|
4040
|
+
event.preventDefault()
|
|
4041
|
+
event.stopPropagation()
|
|
4042
|
+
actions.openContextMenu(candidate, { x: event.clientX, y: event.clientY })
|
|
3383
4043
|
}
|
|
3384
4044
|
|
|
3385
4045
|
const onKeyDown = (event) => {
|
|
@@ -3418,11 +4078,13 @@ window.__ModuleLoader__.load({
|
|
|
3418
4078
|
}
|
|
3419
4079
|
if (event.key === 'Escape') {
|
|
3420
4080
|
if (store.state.popover !== null) store.set({ popover: null })
|
|
4081
|
+
if (store.state.contextMenu !== null) store.set({ contextMenu: null })
|
|
3421
4082
|
}
|
|
3422
4083
|
}
|
|
3423
4084
|
|
|
3424
4085
|
const onScroll = () => {
|
|
3425
4086
|
if (store.state.popover === null && store.state.trigger !== null) store.set({ trigger: null })
|
|
4087
|
+
if (store.state.contextMenu !== null) store.set({ contextMenu: null })
|
|
3426
4088
|
}
|
|
3427
4089
|
|
|
3428
4090
|
if (typeof document !== 'undefined') {
|
|
@@ -3430,6 +4092,7 @@ window.__ModuleLoader__.load({
|
|
|
3430
4092
|
document.addEventListener('pointerup', schedule, true)
|
|
3431
4093
|
document.addEventListener('keyup', schedule, true)
|
|
3432
4094
|
document.addEventListener('mousedown', onDocumentDown, true)
|
|
4095
|
+
document.addEventListener('contextmenu', onContextMenu, true)
|
|
3433
4096
|
document.addEventListener('keydown', onKeyDown, true)
|
|
3434
4097
|
window.addEventListener('scroll', onScroll, true)
|
|
3435
4098
|
window.addEventListener('resize', onScroll, true)
|
|
@@ -3438,6 +4101,7 @@ window.__ModuleLoader__.load({
|
|
|
3438
4101
|
document.removeEventListener('pointerup', schedule, true)
|
|
3439
4102
|
document.removeEventListener('keyup', schedule, true)
|
|
3440
4103
|
document.removeEventListener('mousedown', onDocumentDown, true)
|
|
4104
|
+
document.removeEventListener('contextmenu', onContextMenu, true)
|
|
3441
4105
|
document.removeEventListener('keydown', onKeyDown, true)
|
|
3442
4106
|
window.removeEventListener('scroll', onScroll, true)
|
|
3443
4107
|
window.removeEventListener('resize', onScroll, true)
|
|
@@ -3929,7 +4593,9 @@ window.__ModuleLoader__.load({
|
|
|
3929
4593
|
store.set({
|
|
3930
4594
|
trigger: null,
|
|
3931
4595
|
popover: null,
|
|
4596
|
+
contextMenu: null,
|
|
3932
4597
|
cards: [],
|
|
4598
|
+
history: [],
|
|
3933
4599
|
toast: null,
|
|
3934
4600
|
sessionId: null,
|
|
3935
4601
|
surface: 'flow',
|
|
@@ -3971,8 +4637,18 @@ window.__ModuleLoader__.load({
|
|
|
3971
4637
|
renderActionQuestion,
|
|
3972
4638
|
validateQuickActionsDraft,
|
|
3973
4639
|
shortcutFingerprint,
|
|
4640
|
+
followUpChips,
|
|
4641
|
+
formatElapsed,
|
|
4642
|
+
formatHistoryTime,
|
|
4643
|
+
notificationSupported,
|
|
4644
|
+
notificationPermission,
|
|
4645
|
+
shouldNotifyFinish,
|
|
4646
|
+
buildFinishNotification,
|
|
4647
|
+
requestNotificationPermission,
|
|
3974
4648
|
QUICK_ACTION_BUILTINS,
|
|
3975
4649
|
QUICK_ACTION_LIMITS,
|
|
4650
|
+
FOLLOW_UP_CHIPS,
|
|
4651
|
+
CONTEXT_MENU_ACTION_LIMIT,
|
|
3976
4652
|
classifyZone,
|
|
3977
4653
|
slotPathOf,
|
|
3978
4654
|
shouldOffer,
|
package/index.js
CHANGED
|
@@ -42,7 +42,7 @@ export const name = 'dsh-sidecard-ask'
|
|
|
42
42
|
export const inject = ['webServer']
|
|
43
43
|
|
|
44
44
|
/** Version of this plugin (kept in step with package.json by test/verify.mjs). */
|
|
45
|
-
export const PLUGIN_VERSION = '1.7.
|
|
45
|
+
export const PLUGIN_VERSION = '1.7.2'
|
|
46
46
|
|
|
47
47
|
/** Route prefix of the plugin's own API. */
|
|
48
48
|
export const ROUTE_PREFIX = '/sidecard-ask/api'
|
|
@@ -100,6 +100,25 @@ export const DEFAULT_CONFIG = {
|
|
|
100
100
|
{ id: 'builtin:polish', enabled: true, shortcut: '' },
|
|
101
101
|
{ id: 'builtin:fix', enabled: true, shortcut: '' },
|
|
102
102
|
],
|
|
103
|
+
/**
|
|
104
|
+
* Fire a desktop Notification when a side answer finishes while the page
|
|
105
|
+
* is hidden (another tab/window). Off by default: enabling it also requires
|
|
106
|
+
* the browser's Notification permission, requested lazily on first use.
|
|
107
|
+
*/
|
|
108
|
+
notifyOnDone: false,
|
|
109
|
+
/**
|
|
110
|
+
* Offer the plugin's own context menu on a qualifying selection (main
|
|
111
|
+
* "ask" entry plus the first quick actions). The page's native menu is
|
|
112
|
+
* suppressed only inside that menu; the feature can be turned off when a
|
|
113
|
+
* host composition ships a conflicting selection menu.
|
|
114
|
+
*/
|
|
115
|
+
contextMenu: true,
|
|
116
|
+
/**
|
|
117
|
+
* How many finished cards the Client mirrors into localStorage. `0`
|
|
118
|
+
* disables persistence entirely (no write, existing history is left on
|
|
119
|
+
* disk until the next explicit clear).
|
|
120
|
+
*/
|
|
121
|
+
historyMaxCards: 10,
|
|
103
122
|
}
|
|
104
123
|
|
|
105
124
|
/** Bounds for the `quickActions` user layer (defense in depth). */
|
|
@@ -135,6 +154,7 @@ const NUMBERS = {
|
|
|
135
154
|
maxConcurrentAsks: [1, 12],
|
|
136
155
|
sideTimeoutMs: [5_000, 3_600_000],
|
|
137
156
|
floatMaxWidth: [240, 560],
|
|
157
|
+
historyMaxCards: [0, 50],
|
|
138
158
|
}
|
|
139
159
|
|
|
140
160
|
/**
|
|
@@ -301,9 +321,10 @@ export function sanitizeLayer(raw, problems = []) {
|
|
|
301
321
|
if (typeof raw.sideProvider === 'string' && raw.sideProvider.length <= 80) out.sideProvider = raw.sideProvider
|
|
302
322
|
else problems.push('sideProvider: 必须是字符串,已忽略')
|
|
303
323
|
}
|
|
304
|
-
|
|
305
|
-
if (
|
|
306
|
-
|
|
324
|
+
for (const boolKey of ['showInUnclassified', 'notifyOnDone', 'contextMenu']) {
|
|
325
|
+
if (raw[boolKey] === undefined) continue
|
|
326
|
+
if (typeof raw[boolKey] === 'boolean') out[boolKey] = raw[boolKey]
|
|
327
|
+
else problems.push(`${boolKey}: 必须是布尔值,已忽略`)
|
|
307
328
|
}
|
|
308
329
|
if (raw.quickActions !== undefined) {
|
|
309
330
|
const list = sanitizeQuickActions(raw.quickActions, problems)
|