dsh-sidecard-ask 2.0.2 → 2.1.1
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 +83 -0
- package/README.md +47 -4
- package/client.js +338 -80
- package/index.js +30 -12
- package/package.json +69 -69
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.1
|
|
4
|
+
|
|
5
|
+
**主题:卡片记录只收侧边卡片——改发到主对话的问答不再进持久化历史。** 无 API/配置变更。
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **主对话承载的问答混进卡片记录**。`syncPersisted()` 一直只收 `carrier === 'side'`,但 v3 信封或
|
|
10
|
+
v1/v2 迁移数据里可能存在 `carrier: 'main'` 的条目(旧版本写下的),
|
|
11
|
+
`parseEnvelope()` 会把它当普通记录读回来并在启动时以侧边卡片回放,与主对话内容重复。
|
|
12
|
+
现在读取时直接丢弃这类条目:它不写 localStorage、不进设置页的历史管理器、也不进「导出全部 .md」。
|
|
13
|
+
- **`sendToMain()` 成功后清理记录**。卡片改投主对话时从 durable history 删除对应 id
|
|
14
|
+
(`draft` 模式只是把文本放进输入框、尚未真正发出,保留记录)。
|
|
15
|
+
|
|
16
|
+
CHANGELOG 对应回归测试:`test/contract-test.mjs` 的 `main-carrier records` 段(main 提交不写盘、
|
|
17
|
+
旧 payload 里的 main 条目被丢弃、`sendToMain` 删除记录)。
|
|
18
|
+
|
|
19
|
+
## 2.1.0
|
|
20
|
+
|
|
21
|
+
**主题:跨端核查后的性能与可观测性收口——流式热路径从「每次 delta 全树重渲染」改成「只重渲染变化的卡」,
|
|
22
|
+
新增加载预算与活动卡上限,补上一个不免鉴权的探活口,并修掉部署前缀缺少尾斜杠时的基址丢失。**
|
|
23
|
+
|
|
24
|
+
排查依据见 `CROSS-END-AUDIT-2.0.2.md`(网页端 dsh 0.2.1-rc.2 × 桌面端 dsh-desktop 0.2.0-rc.2 /
|
|
25
|
+
Electron 43.3.0)。本版不含破坏性变更,配置项与接口签名不变。
|
|
26
|
+
|
|
27
|
+
### 性能
|
|
28
|
+
|
|
29
|
+
- **流式热路径只重渲染变化的卡片**。`useStoreState()` 订阅整个 store,而 `FloatingLayer` 每次渲染都给
|
|
30
|
+
`AnswerCard` 新建 16 个箭头函数,于是任何一次 `store.set` 都会让所有卡片的 props 全部不等——
|
|
31
|
+
`React.memo` 无从下手。现在:
|
|
32
|
+
- `AnswerCard` 经 `React.memo` 包装(对没有 `memo` 的 React 自动退化为原组件);
|
|
33
|
+
- 新增 `useCardHandlers(actions)`:每个卡片 id 只建一次回调包,回调通过 ref 读当时的卡片
|
|
34
|
+
(否则"复制"会复制到两个 delta 之前的文本),因此未变化的卡片 props 引用稳定、整棵子树跳过。
|
|
35
|
+
- **一次 flush 只提交一次状态**。`flushStreamBuffers` 原来对正文和思考各调用一次
|
|
36
|
+
`appendCardText`/`appendCardReasoning`,每次都是一次 `store.set`;3 路并发时理论上 120 次全树渲染/秒。
|
|
37
|
+
现在两个缓冲合并成一个 `cards` 补丁、一次提交(两个旧 helper 随之删除)。
|
|
38
|
+
- **流式节奏按文本长度自适应**:答案超过 32 K 字符时刷新间隔 50 ms → 100 ms
|
|
39
|
+
(实测 64 K 答案的 `renderRichText` 为 1.2–2.0 ms/帧,翻倍间隔即减半工作量,肉眼无差)。
|
|
40
|
+
- **右栏观察器不再被 class/style 吵醒**。面板测量的 `MutationObserver` 原来把 `class`、`style`
|
|
41
|
+
放进 `attributeFilter`,聊天区的 hover/滚动/徽标切换每次都会触发一次 rAF 内的
|
|
42
|
+
`getBoundingClientRect`(强制布局);现在只监听 `data-sidebar-right-open` 与
|
|
43
|
+
`data-dockkit-pane-active` 两个真正表示"右栏几何变了"的属性。
|
|
44
|
+
- **活动卡片上限 20**。此前 `store.state.cards` 无上限,长会话会把每条答案的 React 子树一直挂着。
|
|
45
|
+
超过上限时最旧的**已完成**卡片隐藏为 `open:false`(数据保留、正文卸载、历史可恢复),
|
|
46
|
+
正在流式的卡片永不隐藏。
|
|
47
|
+
- **冷启动回放预算 200 K 字符**。回放原来把所有历史卡片以 `open:true` 恢复,仅受卡数(默认 10)
|
|
48
|
+
与单字段 64 K 限制,最坏要挂 64 万字符的 markdown;现在最新一张必定展开,其余按累计字符预算展开,
|
|
49
|
+
超预算的以折叠态回来。
|
|
50
|
+
|
|
51
|
+
### 修复
|
|
52
|
+
|
|
53
|
+
- **部署前缀不带尾斜杠时基址丢失**。`new URL('.', 'https://host/dsh')` 会把 `dsh` 当文件名,
|
|
54
|
+
于是 `API` 退化成 `/sidecard-ask/api`,前缀部署下全部 404。现在首次 404 会回退到根绝对路径重试一次
|
|
55
|
+
(401/403 都**不**改基址:那是鉴权/围栏的答案,不是"路径不对")。正常部署(文档根或带尾斜杠)
|
|
56
|
+
取值与旧值完全相同,不产生额外请求。
|
|
57
|
+
|
|
58
|
+
### 可观测性
|
|
59
|
+
|
|
60
|
+
- **新增 `GET /sidecard-ask/api/ping`**:唯一免宿主会话的接口,只回 `{version, at}`。
|
|
61
|
+
2.0.2 的会话门把脚本/CI 的探活路径全挡住了(连 README 里的 `Invoke-WebRequest` 都变成 401),
|
|
62
|
+
这条补回"插件是否加载"的判别能力,同时不重新打开"无 cookie 就能 spawn 子代理"的口子。
|
|
63
|
+
它仍受 loopback/Origin 围栏保护,且**不咨询** `connection` 服务——会话服务坏掉时探活照常工作。
|
|
64
|
+
- 路由分发顺序随之调整:路径解析提前到会话门之前(`ping` 在此之前应答),未知路径仍然对所有动词
|
|
65
|
+
回同一个 404。
|
|
66
|
+
|
|
67
|
+
### 文档
|
|
68
|
+
|
|
69
|
+
- README §10.1 的自测配方改为"先 `/ping` 判存活,其余接口需会话"(旧配方在 2.0.2 之后恒 401)。
|
|
70
|
+
- 新增 README §5.4:2026-10-22 桌面端复核(宿主版本落差、Chromium 150、scheme 特权、请求转发、
|
|
71
|
+
权限、AUMID、加速键、产物缓存)。
|
|
72
|
+
- README §14 补 12–15 四条限制(`/ping` 的边界、活动卡上限、回放预算、前缀重试)。
|
|
73
|
+
- 更正 2.0.2 的一处结论:「桌面端在本机冷启动即崩」**2026-10-22 未能复现**(冷启动成功、宿主在 19387
|
|
74
|
+
正常服务),改为"与安装包/启动方式相关,需按版本复验"。
|
|
75
|
+
|
|
76
|
+
### 测试
|
|
77
|
+
|
|
78
|
+
- `verify` 57、`contract` **1408**(原 1377)、`float-placement` **83**(原 82)、`smoke` **271**(原 262),
|
|
79
|
+
合计 **1819 通过,0 失败**。
|
|
80
|
+
- 新增覆盖:`apiBaseAfter` 的四种状态码(404 改基址、401/403 不改、根绝对不循环)、
|
|
81
|
+
`capLiveCards` 的隐藏规则(只藏最旧、跳过流式卡片、已隐藏不占配额、不改入参)、
|
|
82
|
+
`replayCardOpen` 的预算边界、`React.memo` 与稳定回调包的接线、一次 flush 只有一次 `store.set`、
|
|
83
|
+
`attributeFilter` 不再含 `class`、`/ping` 免鉴权与 405、`/ping` 不咨询 `connection`、
|
|
84
|
+
其余接口仍 fail-closed、未知路径仍 404。
|
|
85
|
+
|
|
3
86
|
## 2.0.2
|
|
4
87
|
|
|
5
88
|
**主题:跨端(网页端 / 桌面端)排查后的六个修复 —— 插件 API 补上宿主会话鉴权、诊断上报不再互相覆盖、prompt 预算真正守住 60k、SMTP 密码状态自洽、未知接口状态码对称、用户层配置按 profile 分文件。**
|
package/README.md
CHANGED
|
@@ -486,6 +486,25 @@ dsh-better-sidebar 为此专门带了一条防御(`html[data-platform] body>[d
|
|
|
486
486
|
`Alt+Q` 是否会撞上桌面 App 的菜单加速键(Electron 的菜单加速键由菜单先处理,页面的 keydown 可能收不到;
|
|
487
487
|
若冲突,在设置页改成 `Ctrl+Alt+Q` 之类即可);Electron 下 `navigator.clipboard` 的写入权限(已有"选中文本"降级兜底)。
|
|
488
488
|
|
|
489
|
+
### 5.4 2026-10-22 复核(v2.1.0 / 桌面 0.2.0-rc.2)
|
|
490
|
+
|
|
491
|
+
上一轮之后桌面端与网页端的形态都变过,本轮把能静态取证的部分重新核了一遍,**完整报告见
|
|
492
|
+
[`CROSS-END-AUDIT-2.0.2.md`](CROSS-END-AUDIT-2.0.2.md)**(含实测数字与未验证清单)。要点:
|
|
493
|
+
|
|
494
|
+
| 维度 | 事实 | 对插件意味着什么 |
|
|
495
|
+
|---|---|---|
|
|
496
|
+
| 宿主版本落差 | 桌面 `dsh-desktop 0.2.0-rc.2`(包内 `dsh` 同版本);网页 **`0.2.1-rc.2`** | 适配矩阵要按两端分别列,不能写成"DSH 0.2.x" |
|
|
497
|
+
| 渲染内核 | Electron **43.3.0 = Chromium 150.0.7871.212**(`~/.dsh/electron/version` 亦为 43.3.0);网页端取决于访问者浏览器(本机对照 Edge/Chromium 155) | `renderRichText` 在 150 与 155 上实测同量级(8 k 0.2–0.3 ms,64 k 1.2–2.0 ms) |
|
|
498
|
+
| 自定义 scheme | `dsh-app://app/` 特权:`standard/secure/supportFetchAPI/corsEnabled/stream/codeCache` | `fetch` + 流式读取可用,不需要 EventSource |
|
|
499
|
+
| 请求转发 | `forwardWebRequest`:只放行 `origin=dsh-app://app`,删 `host/origin/cookie/sec-fetch-site` 后注入宿主 cookie,`duplex:"half"` | 2.0.2 起的会话门在桌面端靠这条链路通过;插件侧 `origin` 缺失时被判为可信(`isTrustedRequest`) |
|
|
500
|
+
| 权限 | 主窗口 `setPermissionRequestHandler`:非 `media` 一律放行;登录窗/侧栏 webview 一律拒绝并拦下载 | 剪贴板与通知**有**权限;导出下载走主窗口,不受拦截 |
|
|
501
|
+
| 通知 | 全包 **无 `setAppUserModelId`** | Windows toast 可能不弹;`notifyOnDone` 默认关,打开后仍以卡片状态为兜底 |
|
|
502
|
+
| 菜单加速键 | 全包 `Alt+Q` **0 命中** | 外壳不抢默认快捷键(输入法/其它程序仍可能抢) |
|
|
503
|
+
| 插件产物缓存 | `/plugins/*` 被壳改写为 `cache-control: no-store` | 每次加载重新拉 349 KB(localhost,传输≈0,代价在解析) |
|
|
504
|
+
|
|
505
|
+
> 另:2.0.2 CHANGELOG 里"桌面端在本机冷启动即崩"的结论,2026-10-22 **未能复现**——本次冷启动成功、
|
|
506
|
+
> 宿主在 19387 正常服务。该条已按"与安装包/启动方式相关,需按版本复验"处理,不再当作既定事实。
|
|
507
|
+
|
|
489
508
|
---
|
|
490
509
|
|
|
491
510
|
## 六、未知 DSH API:占位接口与替换方式
|
|
@@ -694,6 +713,8 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
|
|
|
694
713
|
7e. 设置页勾选「完成后桌面通知」会请求通知权限;授权后切到其它标签页/窗口,作答结束时应弹系统通知。
|
|
695
714
|
7f. 设置页「历史追问卡片」:可搜索、恢复到浮层、单条删除、清空;「历史卡片保存数量」设 0 后新卡片不再写盘;
|
|
696
715
|
注意:**关闭浮层卡片不会删历史**(1.8.0 起历史只在管理中心删除)。
|
|
716
|
+
**改发到主对话(`carrier: 'main'`)的问答不算卡片记录**(2.1.1 起):它只出现在主对话里,不写
|
|
717
|
+
localStorage,也不出现在历史管理器与「导出全部 .md」;旧版本写进去的 main 条目在读取时丢弃。
|
|
697
718
|
7g. **回答风格(1.8.0)**:设置页把「回答风格」切成「精简」并保存 → 下一次追问的答案应明显更短、
|
|
698
719
|
不复述原文;切「详细」→ 应看到展开与依据。自检办法:`GET /state` 的 `config.answerStyle` 应为所选值;
|
|
699
720
|
若 provider 不支持 persona,指令会内联进提示词(卡片上的 provider 行为不变)。
|
|
@@ -816,11 +837,21 @@ node tools/compat-probe.mjs 0.1.7-rc.2 # 也可只探某个版本
|
|
|
816
837
|
|
|
817
838
|
### 10.1 对已安装实例做真实联调(本机实测通过)
|
|
818
839
|
|
|
840
|
+
> **2.1.0 起除 `/ping` 外的每个接口都走宿主会话门。** 不带浏览器登录 cookie 的脚本调用会拿到
|
|
841
|
+
> `401 {"error":{"code":"unauthorized"}}`,这表示"没登录",**不表示插件没起来**——先用第 0 步区分。
|
|
842
|
+
> 页面内自检(设置 → 侧边卡片追问 → 运行自检)读的是同一份 `/state`,不需要任何脚本。
|
|
843
|
+
|
|
819
844
|
```powershell
|
|
820
|
-
#
|
|
821
|
-
(Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/
|
|
845
|
+
# 0) 探活:唯一免鉴权的接口,只回插件版本与宿主时钟
|
|
846
|
+
(Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/ping -UseBasicParsing).Content
|
|
847
|
+
# → {"ok":true,"value":{"version":"2.1.1","at":1792...}} 200 = 插件宿主半已加载
|
|
848
|
+
|
|
849
|
+
# 1) 宿主接口与能力(archived 父会话检测也在这里)——需要会话
|
|
850
|
+
# 在本机浏览器已登录的前提下复用会话:先 -WebSession 登录,或把浏览器的 Cookie 头带进来
|
|
851
|
+
(Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state `
|
|
852
|
+
-Headers @{ Cookie = $cookie } -UseBasicParsing).Content
|
|
822
853
|
|
|
823
|
-
# 2) 端到端流式作答:把 sessionId 换成当前会话 id($env:DSH_SESSION_ID
|
|
854
|
+
# 2) 端到端流式作答:把 sessionId 换成当前会话 id($env:DSH_SESSION_ID);同样需要会话
|
|
824
855
|
# 期望事件序列:start → reasoning*/delta* → status → done(done.text 是真实答案)
|
|
825
856
|
$body = @{ id='probe'; question='用一句话说明这句话在说什么。';
|
|
826
857
|
selection='槽位出口会带上 data-slot 标记。'; zone='chat';
|
|
@@ -828,11 +859,13 @@ $body = @{ id='probe'; question='用一句话说明这句话在说什么。';
|
|
|
828
859
|
$tmp = Join-Path $env:TEMP 'probe.json'
|
|
829
860
|
[System.IO.File]::WriteAllText($tmp, $body, (New-Object System.Text.UTF8Encoding($false)))
|
|
830
861
|
(Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/ask -Method POST `
|
|
862
|
+
-Headers @{ Cookie = $cookie } `
|
|
831
863
|
-ContentType 'application/json; charset=utf-8' -InFile $tmp -TimeoutSec 240 -UseBasicParsing).Content
|
|
832
864
|
|
|
833
865
|
# 3) 客户端半的真实状态(浏览器跑的那份代码/槽位落点/两个适配器)
|
|
834
866
|
# 页面加载后 GET /state,看 value.client —— 这是唯一能从页面外读到客户端状态的通道
|
|
835
|
-
((Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state
|
|
867
|
+
((Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state `
|
|
868
|
+
-Headers @{ Cookie = $cookie } -UseBasicParsing).Content | ConvertFrom-Json).value.client
|
|
836
869
|
```
|
|
837
870
|
|
|
838
871
|
**客户端状态样例**(1.3.0 起):
|
|
@@ -986,4 +1019,14 @@ npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>" # 旧名
|
|
|
986
1019
|
自己的数据);**唯一的例外是「清空全部历史」**——显式清空会同时删除 v3/v2/v1 三代键(1.8.1)。
|
|
987
1020
|
这条例外是必需的:只删当前键会让 `readPersisted()` 的 v3→v2→v1 回落把刚清掉的历史从旧键读回来,
|
|
988
1021
|
刷新一次就复活(1.8.1 复现并修复)。
|
|
1022
|
+
12. **接口鉴权(2.0.2 起,2.1.0 补 `/ping`)**:除 `/ping` 外的每个接口都要宿主会话;从页面外读
|
|
1023
|
+
`/state` 会得到 `401 unauthorized`。`/ping` 是唯一免鉴权的探活口,只回 `{version, at}`——
|
|
1024
|
+
不要往里加字段,它存在的理由是"能给监控用",不是"能省登录"。
|
|
1025
|
+
13. **活动卡片上限 20(2.1.0)**:超过后最旧的**已完成**卡片自动隐藏(`open:false`,数据仍在,
|
|
1026
|
+
历史里可恢复),正在流式的卡片永不隐藏。这不是删除,但会"少一张卡片在浮层里"。
|
|
1027
|
+
14. **冷启动回放预算 200 K 字符(2.1.0)**:最新一张必定展开,其余按累计字符预算展开;
|
|
1028
|
+
超预算的旧卡片以折叠态回来,点开才解析。改 `historyMaxCards` 不改变这个预算。
|
|
1029
|
+
15. **部署前缀不带尾斜杠时(2.1.0)**:`API` 基址会先按 `document.baseURI` 猜一次,若得到 404
|
|
1030
|
+
则回退到根绝对路径重试一次。这是唯一安全的判别方式(401/403 都不能用来改基址),
|
|
1031
|
+
代价是每次页面加载最多多一次 404 往返——正常部署下不会触发。
|
|
989
1032
|
|
package/client.js
CHANGED
|
@@ -62,6 +62,50 @@ window.__ModuleLoader__.load({
|
|
|
62
62
|
return '/sidecard-ask/api'
|
|
63
63
|
}
|
|
64
64
|
})()
|
|
65
|
+
|
|
66
|
+
/** Root-absolute base — the retry target when the prefix guess misses. */
|
|
67
|
+
const API_ROOT = '/sidecard-ask/api'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The base actually used from now on.
|
|
71
|
+
*
|
|
72
|
+
* `new URL('.', base)` treats the last path segment as a FILE name, so a
|
|
73
|
+
* deployment whose public URL has no trailing slash (`https://host/dsh`)
|
|
74
|
+
* resolves to the document root and loses the prefix. There is no way to
|
|
75
|
+
* tell `/dsh` (directory) from `/dsh` (file) without asking the server, so
|
|
76
|
+
* the first `404` retries once against the root-absolute base: 404 is the
|
|
77
|
+
* only unambiguous signal here — auth answers `401`, a foreign origin
|
|
78
|
+
* `403`, and `404` means the route is not mounted at this path at all.
|
|
79
|
+
*/
|
|
80
|
+
let apiBase = API
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The base to use after one response.
|
|
84
|
+
* @param {number} status - HTTP status of the attempt.
|
|
85
|
+
* @param {string} current - the base that produced it.
|
|
86
|
+
* @returns {string} the base for the next attempt (unchanged unless 404).
|
|
87
|
+
*/
|
|
88
|
+
function apiBaseAfter(status, current) {
|
|
89
|
+
return status === 404 && current !== API_ROOT ? API_ROOT : current
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `fetch` against the plugin API, retrying once on a 404 with the other
|
|
94
|
+
* base. A 404 body is never consumed here: the caller's own error path
|
|
95
|
+
* reads it (or ignores it), exactly as before.
|
|
96
|
+
* @param {string} path - `/state` or `/ask`, appended to the base.
|
|
97
|
+
* @param {RequestInit} [init] - fetch options (body must be replayable).
|
|
98
|
+
* @returns {Promise<Response>} the response of whichever attempt answered.
|
|
99
|
+
*/
|
|
100
|
+
async function fetchApi(path, init) {
|
|
101
|
+
const attempted = apiBase
|
|
102
|
+
let response = await fetch(`${attempted}${path}`, init)
|
|
103
|
+
if (response.status === 404 && attempted !== API_ROOT) {
|
|
104
|
+
apiBase = apiBaseAfter(response.status, attempted)
|
|
105
|
+
response = await fetch(`${apiBase}${path}`, init)
|
|
106
|
+
}
|
|
107
|
+
return response
|
|
108
|
+
}
|
|
65
109
|
/** Slot ids contributed by this plugin (also the overlay entry ids). */
|
|
66
110
|
const IDS = {
|
|
67
111
|
overlay: 'sidecard-ask',
|
|
@@ -73,7 +117,7 @@ window.__ModuleLoader__.load({
|
|
|
73
117
|
* the Host and the module's `api.version` both read it, so a report can
|
|
74
118
|
* never claim a generation the browser is not actually running.
|
|
75
119
|
*/
|
|
76
|
-
const CLIENT_VERSION = '2.
|
|
120
|
+
const CLIENT_VERSION = '2.1.1'
|
|
77
121
|
|
|
78
122
|
/**
|
|
79
123
|
* Tab kind served by the DSH native right rail (also its implementation id).
|
|
@@ -109,6 +153,35 @@ window.__ModuleLoader__.load({
|
|
|
109
153
|
const HISTORY_TURN_Q_CHARS = 500
|
|
110
154
|
const HISTORY_TURN_A_CHARS = 4000
|
|
111
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Streaming flush cadence.
|
|
158
|
+
*
|
|
159
|
+
* 50 ms is the cadence that reads as "live"; a 32 k+ answer costs 1.2–2.0 ms
|
|
160
|
+
* of markdown parsing per frame (measured, both Chromium 150 and 155), so
|
|
161
|
+
* past that length the cadence doubles — the eye cannot tell, and the work
|
|
162
|
+
* halves.
|
|
163
|
+
*/
|
|
164
|
+
const STREAM_FLUSH_MS = 50
|
|
165
|
+
const STREAM_FLUSH_SLOW_MS = 100
|
|
166
|
+
const STREAM_SLOW_CHARS = 32_000
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* How many cards may stay mounted at once. Past this the oldest finished
|
|
170
|
+
* card is hidden (not deleted): its body unmounts, its data stays, and the
|
|
171
|
+
* durable history still restores it.
|
|
172
|
+
*/
|
|
173
|
+
const MAX_LIVE_CARDS = 20
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* How many characters of history a page load may re-render unfolded.
|
|
177
|
+
*
|
|
178
|
+
* Replay used to restore every saved card `open:true`, bounded only by the
|
|
179
|
+
* card count (default 10) and a 64 k per-field limit — a worst case of
|
|
180
|
+
* ~640 k characters of markdown parsed and mounted before the page is
|
|
181
|
+
* usable. Past this budget the older cards come back collapsed.
|
|
182
|
+
*/
|
|
183
|
+
const REPLAY_OPEN_BUDGET_CHARS = 200_000
|
|
184
|
+
|
|
112
185
|
/**
|
|
113
186
|
* Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
|
|
114
187
|
* before `/state` answers (first paint) and when the Host is unreachable;
|
|
@@ -1121,7 +1194,7 @@ window.__ModuleLoader__.load({
|
|
|
1121
1194
|
async function postJson(method, payload) {
|
|
1122
1195
|
let response
|
|
1123
1196
|
try {
|
|
1124
|
-
response = await
|
|
1197
|
+
response = await fetchApi(`/${method}`, {
|
|
1125
1198
|
method: 'POST',
|
|
1126
1199
|
headers: { 'content-type': 'application/json' },
|
|
1127
1200
|
body: JSON.stringify(payload ?? {}),
|
|
@@ -1145,7 +1218,7 @@ window.__ModuleLoader__.load({
|
|
|
1145
1218
|
async function getState() {
|
|
1146
1219
|
let response
|
|
1147
1220
|
try {
|
|
1148
|
-
response = await
|
|
1221
|
+
response = await fetchApi('/state', { method: 'GET' })
|
|
1149
1222
|
} catch (error) {
|
|
1150
1223
|
throw Object.assign(new Error(String(error?.message ?? error)), { code: 'unreachable' })
|
|
1151
1224
|
}
|
|
@@ -1173,7 +1246,7 @@ window.__ModuleLoader__.load({
|
|
|
1173
1246
|
async function streamAsk(payload, handlers) {
|
|
1174
1247
|
let response
|
|
1175
1248
|
try {
|
|
1176
|
-
response = await
|
|
1249
|
+
response = await fetchApi('/ask', {
|
|
1177
1250
|
method: 'POST',
|
|
1178
1251
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
1179
1252
|
body: JSON.stringify(payload),
|
|
@@ -1323,6 +1396,57 @@ window.__ModuleLoader__.load({
|
|
|
1323
1396
|
return store.state
|
|
1324
1397
|
}
|
|
1325
1398
|
|
|
1399
|
+
/**
|
|
1400
|
+
* Per-card handler bundle with a STABLE identity.
|
|
1401
|
+
*
|
|
1402
|
+
* `store.set` bumps a revision on every streamed delta, and the floating
|
|
1403
|
+
* layer re-renders on each one. Handing `AnswerCard` a fresh arrow per
|
|
1404
|
+
* render makes every prop compare unequal, so `React.memo` could never bail
|
|
1405
|
+
* out. The bundle is therefore created once per card id and reads the card
|
|
1406
|
+
* through a ref at call time — a closure must never capture a stale card,
|
|
1407
|
+
* or "copy" would copy the text from two deltas ago.
|
|
1408
|
+
* @param {object} actions - the plugin's action table (stable for the
|
|
1409
|
+
* lifetime of `apply`).
|
|
1410
|
+
* @returns {(card: object) => object} handler bundle for one card.
|
|
1411
|
+
*/
|
|
1412
|
+
function useCardHandlers(actions) {
|
|
1413
|
+
const latest = React.useRef(new Map())
|
|
1414
|
+
const cache = React.useRef(new Map())
|
|
1415
|
+
return React.useCallback((card) => {
|
|
1416
|
+
if (card === null || card === undefined) return null
|
|
1417
|
+
latest.current.set(card.id, card)
|
|
1418
|
+
let bundle = cache.current.get(card.id)
|
|
1419
|
+
if (bundle === undefined) {
|
|
1420
|
+
// The maps are keyed by card id and live for the whole session, so a
|
|
1421
|
+
// long session would keep one tiny bundle per card ever shown. Past a
|
|
1422
|
+
// generous ceiling both are dropped: the live cards simply get a
|
|
1423
|
+
// fresh (still stable) bundle and re-render once.
|
|
1424
|
+
if (cache.current.size > 64) {
|
|
1425
|
+
cache.current.clear()
|
|
1426
|
+
latest.current.clear()
|
|
1427
|
+
}
|
|
1428
|
+
const current = () => latest.current.get(card.id) ?? card
|
|
1429
|
+
bundle = {
|
|
1430
|
+
onCopy: element => actions.copy(current(), element),
|
|
1431
|
+
onFollowUp: text => actions.followUp(current(), text),
|
|
1432
|
+
onRetry: () => actions.retry(current()),
|
|
1433
|
+
onRetryEdit: text => actions.retryEdit(current(), text),
|
|
1434
|
+
onCancel: () => actions.cancel(current()),
|
|
1435
|
+
onSendToMain: () => { void actions.sendToMain(current()) },
|
|
1436
|
+
onClose: () => actions.close(current()),
|
|
1437
|
+
onLocate: target => actions.locateCard(target ?? current()),
|
|
1438
|
+
onExport: target => actions.exportCard(target ?? current()),
|
|
1439
|
+
onInsert: target => actions.insertAnswer(target ?? current()),
|
|
1440
|
+
onReplace: target => actions.insertAnswer(target ?? current(), { replace: true }),
|
|
1441
|
+
onViewVersion: index => actions.viewVersion(current(), index),
|
|
1442
|
+
onDelete: () => actions.deleteCard(current()),
|
|
1443
|
+
}
|
|
1444
|
+
cache.current.set(card.id, bundle)
|
|
1445
|
+
}
|
|
1446
|
+
return bundle
|
|
1447
|
+
}, [actions])
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1326
1450
|
// ─────────────────────────────────────────────────────────────────────
|
|
1327
1451
|
// §4.5 family singleton
|
|
1328
1452
|
//
|
|
@@ -1430,20 +1554,63 @@ window.__ModuleLoader__.load({
|
|
|
1430
1554
|
return cards.find(card => card.id === id)
|
|
1431
1555
|
}
|
|
1432
1556
|
|
|
1433
|
-
/**
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1557
|
+
/**
|
|
1558
|
+
* Hide the oldest finished cards once a stack exceeds its cap.
|
|
1559
|
+
*
|
|
1560
|
+
* Pure so the rule can be asserted without a renderer: the newest card and
|
|
1561
|
+
* anything still streaming are never hidden, `open:false` cards are skipped
|
|
1562
|
+
* (already hidden), and the input array is not mutated.
|
|
1563
|
+
* @param {object[]} cards - newest first.
|
|
1564
|
+
* @param {number} [cap] - how many may stay visible.
|
|
1565
|
+
* @param {string|null} [keptId] - id that must survive this pass.
|
|
1566
|
+
* @returns {object[]} the same list, with overflow entries closed.
|
|
1567
|
+
*/
|
|
1568
|
+
function capLiveCards(cards, cap = MAX_LIVE_CARDS, keptId = null) {
|
|
1569
|
+
if (!Array.isArray(cards) || cards.length <= cap) return cards
|
|
1570
|
+
const next = [...cards]
|
|
1571
|
+
let excess = next.length - cap
|
|
1572
|
+
for (let index = next.length - 1; index >= 0 && excess > 0; index -= 1) {
|
|
1573
|
+
const item = next[index]
|
|
1574
|
+
if (item === null || item === undefined) continue
|
|
1575
|
+
if (item.id === keptId || item.open === false) continue
|
|
1576
|
+
if (item.status === 'pending' || item.status === 'streaming') continue
|
|
1577
|
+
next[index] = { ...item, open: false }
|
|
1578
|
+
excess -= 1
|
|
1579
|
+
}
|
|
1580
|
+
return next
|
|
1439
1581
|
}
|
|
1440
1582
|
|
|
1441
|
-
/**
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1583
|
+
/**
|
|
1584
|
+
* Whether replayed card `index` comes back unfolded.
|
|
1585
|
+
*
|
|
1586
|
+
* The newest replay always does — a page load that hides everything would
|
|
1587
|
+
* look like data loss — and the rest only while the running total stays
|
|
1588
|
+
* inside the budget.
|
|
1589
|
+
* @param {number} index - 0 = newest.
|
|
1590
|
+
* @param {number} replayedChars - characters replayed so far, inclusive.
|
|
1591
|
+
* @returns {boolean} true when the card should mount unfolded.
|
|
1592
|
+
*/
|
|
1593
|
+
function replayCardOpen(index, replayedChars) {
|
|
1594
|
+
return index === 0 || replayedChars <= REPLAY_OPEN_BUDGET_CHARS
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Prepend one card, hiding the oldest FINISHED card once the stack grows
|
|
1599
|
+
* past the live cap.
|
|
1600
|
+
*
|
|
1601
|
+
* Cards used to accumulate for the whole session: nothing capped
|
|
1602
|
+
* `store.state.cards`, so a long session kept every answer mounted (each
|
|
1603
|
+
* one a full React subtree). Past the cap the oldest finished card is
|
|
1604
|
+
* hidden — `open:false` keeps its data, unmounts its body, and the durable
|
|
1605
|
+
* history still offers it back. A card that is still streaming is never
|
|
1606
|
+
* hidden: that would orphan its stream.
|
|
1607
|
+
* @param {object} card - the card to put on top.
|
|
1608
|
+
* @param {object} [extra] - additional store fields to commit in the same
|
|
1609
|
+
* update (the carrier's surface label, for instance).
|
|
1610
|
+
*/
|
|
1611
|
+
function pushCard(card, extra = null) {
|
|
1612
|
+
const cards = capLiveCards([card, ...store.state.cards], MAX_LIVE_CARDS, card.id)
|
|
1613
|
+
store.set(extra === null ? { cards } : { ...extra, cards })
|
|
1447
1614
|
}
|
|
1448
1615
|
|
|
1449
1616
|
/** Show a transient toast line. */
|
|
@@ -2956,7 +3123,13 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
2956
3123
|
* @returns {string} the file body.
|
|
2957
3124
|
*/
|
|
2958
3125
|
function buildHistoryMarkdown(entries) {
|
|
2959
|
-
|
|
3126
|
+
// 主对话承载的问答不算卡片记录(2026-10-23,用户要求)——即使调用方
|
|
3127
|
+
// 递来一条带 `carrier: 'main'` 的旧条目,导出也不该把它当成侧边卡片。
|
|
3128
|
+
const list = Array.isArray(entries)
|
|
3129
|
+
? entries.filter(entry => entry !== null
|
|
3130
|
+
&& typeof entry === 'object'
|
|
3131
|
+
&& (entry.carrier === undefined || entry.carrier === 'side'))
|
|
3132
|
+
: []
|
|
2960
3133
|
if (list.length === 0) return ''
|
|
2961
3134
|
const sections = list.map((entry, index) => {
|
|
2962
3135
|
const body = buildCardMarkdown(entry)
|
|
@@ -4431,6 +4604,19 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4431
4604
|
: null)
|
|
4432
4605
|
}
|
|
4433
4606
|
|
|
4607
|
+
/**
|
|
4608
|
+
* Memoised {@link AnswerCard}.
|
|
4609
|
+
*
|
|
4610
|
+
* Only the card that actually changed gets a new `card` object
|
|
4611
|
+
* (`patchCard` / the stream flush keep every other card by reference), and
|
|
4612
|
+
* the handler bundle from {@link useCardHandlers} is stable per id — so a
|
|
4613
|
+
* delta on card A no longer re-renders cards B..N. `React.memo` is probed
|
|
4614
|
+
* because the test double (and any trimmed-down React a host might hand a
|
|
4615
|
+
* plugin) does not have to implement it; without it this is the same
|
|
4616
|
+
* component, just un-memoised.
|
|
4617
|
+
*/
|
|
4618
|
+
const MemoAnswerCard = typeof React.memo === 'function' ? React.memo(AnswerCard) : AnswerCard
|
|
4619
|
+
|
|
4434
4620
|
/**
|
|
4435
4621
|
* The flow surface, i.e. the floating layer (A).
|
|
4436
4622
|
*
|
|
@@ -4450,6 +4636,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4450
4636
|
const containerRef = React.useRef(null)
|
|
4451
4637
|
const dockRef = React.useRef(null)
|
|
4452
4638
|
const state = useStoreState()
|
|
4639
|
+
const handlersFor = useCardHandlers(actions)
|
|
4453
4640
|
const [dock, setDock] = React.useState(null)
|
|
4454
4641
|
const visible = cards.filter(card => card.surface === 'flow' && card.open)
|
|
4455
4642
|
const total = visible.length
|
|
@@ -4563,16 +4750,20 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4563
4750
|
}
|
|
4564
4751
|
if (typeof window.addEventListener === 'function') window.addEventListener('resize', schedule)
|
|
4565
4752
|
// Opening/closing the rail changes no size this effect already watches,
|
|
4566
|
-
// so the DOM itself has to be the trigger.
|
|
4567
|
-
//
|
|
4568
|
-
//
|
|
4753
|
+
// so the DOM itself has to be the trigger. The two data attributes are
|
|
4754
|
+
// the only signals that MEAN "the rail geometry changed". `class` and
|
|
4755
|
+
// `style` used to be in this list too, and a chat pane rewrites both
|
|
4756
|
+
// constantly (hover, scroll state, streaming badges) — every one of
|
|
4757
|
+
// those woke this observer and forced a layout read on the next frame.
|
|
4758
|
+
// Streaming tokens rewrite text nodes, which `childList`-free observing
|
|
4759
|
+
// already ignores.
|
|
4569
4760
|
if (typeof window.MutationObserver === 'function' && document.body !== null && document.body !== undefined) {
|
|
4570
4761
|
try {
|
|
4571
4762
|
mutation = new window.MutationObserver(schedule)
|
|
4572
4763
|
mutation.observe(document.body, {
|
|
4573
4764
|
attributes: true,
|
|
4574
4765
|
subtree: true,
|
|
4575
|
-
attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active'
|
|
4766
|
+
attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active'],
|
|
4576
4767
|
})
|
|
4577
4768
|
} catch { mutation = null }
|
|
4578
4769
|
}
|
|
@@ -4625,24 +4816,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4625
4816
|
: null),
|
|
4626
4817
|
}, children)
|
|
4627
4818
|
|
|
4628
|
-
const cardNodes = visible.map(card => h(
|
|
4819
|
+
const cardNodes = visible.map(card => h(MemoAnswerCard, {
|
|
4629
4820
|
key: card.id,
|
|
4630
4821
|
card,
|
|
4631
4822
|
surfaceLabel: null,
|
|
4632
|
-
onCopy: element => actions.copy(card, element),
|
|
4633
|
-
onFollowUp: text => actions.followUp(card, text),
|
|
4634
|
-
onRetry: () => actions.retry(card),
|
|
4635
|
-
onRetryEdit: text => actions.retryEdit(card, text),
|
|
4636
|
-
onCancel: () => actions.cancel(card),
|
|
4637
|
-
onSendToMain: () => actions.sendToMain(card),
|
|
4638
|
-
onClose: () => actions.close(card),
|
|
4639
|
-
onLocate: target => actions.locateCard(target ?? card),
|
|
4640
|
-
onExport: target => actions.exportCard(target ?? card),
|
|
4641
|
-
onInsert: target => actions.insertAnswer(target ?? card),
|
|
4642
|
-
onReplace: target => actions.insertAnswer(target ?? card, { replace: true }),
|
|
4643
|
-
onViewVersion: index => actions.viewVersion(card, index),
|
|
4644
4823
|
onFloatMode: setFloatMode,
|
|
4645
|
-
|
|
4824
|
+
// Stable per card id (see useCardHandlers), which is what lets
|
|
4825
|
+
// React.memo skip every card that did not change in this tick.
|
|
4826
|
+
...handlersFor(card),
|
|
4646
4827
|
}))
|
|
4647
4828
|
|
|
4648
4829
|
if (mode === 'capsule') {
|
|
@@ -5518,7 +5699,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5518
5699
|
const value = JSON.parse(raw)
|
|
5519
5700
|
if (value?.version !== version || !Array.isArray(value.cards)) return []
|
|
5520
5701
|
return value.cards
|
|
5521
|
-
|
|
5702
|
+
// 主对话承载的问答不算卡片记录(2026-10-23,用户要求)。旧版本
|
|
5703
|
+
// 曾把 `carrier: 'main'` 的条目写进 v3/legacy 信封:读取时丢弃,
|
|
5704
|
+
// 磁盘上的副本等下一次写回自然消失;没有 carrier 字段的 v1/v2
|
|
5705
|
+
// 条目仍按侧边卡片处理(它们当时只可能来自侧边卡片)。
|
|
5706
|
+
.filter(entry => typeof entry?.id === 'string'
|
|
5707
|
+
&& entry.id !== ''
|
|
5708
|
+
&& typeof entry.question === 'string'
|
|
5709
|
+
&& (entry.carrier === undefined || entry.carrier === 'side'))
|
|
5522
5710
|
} catch { return [] }
|
|
5523
5711
|
}
|
|
5524
5712
|
|
|
@@ -5581,6 +5769,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5581
5769
|
/** Recompute the durable set from the live store and write it. */
|
|
5582
5770
|
const syncPersisted = () => {
|
|
5583
5771
|
const cap = historyCap()
|
|
5772
|
+
// 只有侧边卡片算「记录」:改发到主对话的问答由主对话自己保存,
|
|
5773
|
+
// 不再进 durable history(2026-10-23,用户要求)。carrier 过滤是
|
|
5774
|
+
// 这条边界的唯一入口,下游 writePersisted 不要再放宽。
|
|
5584
5775
|
const finished = store.state.cards
|
|
5585
5776
|
.filter(card => card.carrier === 'side'
|
|
5586
5777
|
&& (card.status === 'done' || card.status === 'stopped')
|
|
@@ -5646,37 +5837,47 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5646
5837
|
if (cap > 0 && (currentRaw === null || currentRaw === undefined)) {
|
|
5647
5838
|
writePersisted(saved)
|
|
5648
5839
|
}
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
:
|
|
5679
|
-
|
|
5840
|
+
// Replay budget: the newest card always comes back unfolded, and the
|
|
5841
|
+
// rest stay unfolded only while the running total of replayed
|
|
5842
|
+
// characters stays under the budget. A page load used to mount up to
|
|
5843
|
+
// 10 × 64 k characters of markdown before it was usable; past the
|
|
5844
|
+
// budget the older cards come back collapsed and are parsed when the
|
|
5845
|
+
// user opens them.
|
|
5846
|
+
let replayedChars = 0
|
|
5847
|
+
const replayed = saved.map((entry, index) => {
|
|
5848
|
+
replayedChars += String(entry.text ?? '').length + String(entry.reasoning ?? '').length
|
|
5849
|
+
return {
|
|
5850
|
+
id: entry.id,
|
|
5851
|
+
question: entry.question,
|
|
5852
|
+
selected: String(entry.selected ?? ''),
|
|
5853
|
+
selection: String(entry.selection ?? ''),
|
|
5854
|
+
text: String(entry.text ?? ''),
|
|
5855
|
+
reasoning: String(entry.reasoning ?? ''),
|
|
5856
|
+
status: entry.status === 'stopped' ? 'stopped' : 'done',
|
|
5857
|
+
carrier: 'side',
|
|
5858
|
+
zone: entry.zone,
|
|
5859
|
+
truncated: entry.truncated === true,
|
|
5860
|
+
droppedChars: Number(entry.droppedChars ?? 0),
|
|
5861
|
+
streaming: null,
|
|
5862
|
+
error: null,
|
|
5863
|
+
provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
|
|
5864
|
+
elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
|
|
5865
|
+
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
|
|
5866
|
+
// P2-5b: replayed versions stay switchable after a reload.
|
|
5867
|
+
versions: replayVersions(entry.versions),
|
|
5868
|
+
viewingVersion: null,
|
|
5869
|
+
actionId: typeof entry.actionId === 'string' && entry.actionId !== '' ? entry.actionId : null,
|
|
5870
|
+
// A native/better tab cannot survive a reload, so history always
|
|
5871
|
+
// comes back on the flow stack where it is visible immediately.
|
|
5872
|
+
surface: 'flow',
|
|
5873
|
+
open: replayCardOpen(index, replayedChars),
|
|
5874
|
+
history: Array.isArray(entry.history)
|
|
5875
|
+
? entry.history
|
|
5876
|
+
.filter(turn => turn !== null && typeof turn === 'object')
|
|
5877
|
+
.map(turn => ({ question: String(turn.question ?? ''), answer: String(turn.answer ?? '') }))
|
|
5878
|
+
: [],
|
|
5879
|
+
}
|
|
5880
|
+
})
|
|
5680
5881
|
store.set({ cards: [...replayed, ...store.state.cards] })
|
|
5681
5882
|
}
|
|
5682
5883
|
|
|
@@ -5750,7 +5951,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5750
5951
|
if (store.state.cards.some(card => card.id === id)) {
|
|
5751
5952
|
patchCard(id, { surface: 'flow', open: true })
|
|
5752
5953
|
} else {
|
|
5753
|
-
|
|
5954
|
+
pushCard(historyEntryToCard(entry))
|
|
5754
5955
|
}
|
|
5755
5956
|
// A collapsed (capsule) layer would swallow the restored card;
|
|
5756
5957
|
// this is a session-only override, exactly like clicking expand.
|
|
@@ -5879,7 +6080,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5879
6080
|
actionId: payload?.actionId ?? null,
|
|
5880
6081
|
}
|
|
5881
6082
|
if (payload.carrier === 'main') {
|
|
5882
|
-
|
|
6083
|
+
pushCard(card)
|
|
5883
6084
|
try {
|
|
5884
6085
|
const result = await askInMainConversation({
|
|
5885
6086
|
ctx,
|
|
@@ -5905,7 +6106,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5905
6106
|
}
|
|
5906
6107
|
const { adapter, fellBack } = pickSurface(config.sideSurface)
|
|
5907
6108
|
card.surface = adapter.kind
|
|
5908
|
-
|
|
6109
|
+
pushCard(card, { surface: adapter.kind })
|
|
5909
6110
|
if (fellBack) {
|
|
5910
6111
|
store.set({ surfaceNote: t('surfaceFellBack', { name: adapter.label() }) })
|
|
5911
6112
|
toast(t('surfaceUnavailable'))
|
|
@@ -6159,6 +6360,20 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6159
6360
|
error: null,
|
|
6160
6361
|
streaming: null,
|
|
6161
6362
|
})
|
|
6363
|
+
// 主对话承载的问答不算卡片记录(2026-10-23,用户要求)。这条
|
|
6364
|
+
// 卡片一旦改投主对话,durable history 里若还有它的副本就必须
|
|
6365
|
+
// 删掉,否则刷新后它会以侧边卡片的身份被恢复,和主对话重复。
|
|
6366
|
+
// `draft` 模式只是把文本放进输入框(还没有真正发出),保留记录。
|
|
6367
|
+
// cap === 0 与 deleteCard 同规则:不动已存的键,只镜像内存。
|
|
6368
|
+
if (result.mode !== 'draft') {
|
|
6369
|
+
const kept = store.state.history
|
|
6370
|
+
.filter(item => item.id !== card.id)
|
|
6371
|
+
.slice(0, historyCap())
|
|
6372
|
+
if (kept.length !== store.state.history.length) {
|
|
6373
|
+
if (historyCap() > 0) writePersisted(kept)
|
|
6374
|
+
store.set({ history: kept })
|
|
6375
|
+
}
|
|
6376
|
+
}
|
|
6162
6377
|
toast(result.mode === 'draft' ? t('draftInserted') : t('mainSent'))
|
|
6163
6378
|
} catch (error) {
|
|
6164
6379
|
patchCard(card.id, {
|
|
@@ -6241,31 +6456,65 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6241
6456
|
const pendingReasonings = new Map()
|
|
6242
6457
|
let deltaFlushTimer = null
|
|
6243
6458
|
|
|
6459
|
+
/**
|
|
6460
|
+
* Commit every buffered chunk for this tick as ONE store update.
|
|
6461
|
+
*
|
|
6462
|
+
* The previous shape published text and reasoning separately, so a
|
|
6463
|
+
* single 50 ms tick could bump the store twice per streaming card —
|
|
6464
|
+
* and every bump re-renders the whole overlay. With the concurrency
|
|
6465
|
+
* cap at 3 that is up to 120 tree renders per second for two string
|
|
6466
|
+
* appends. Cards that saw no chunk keep their identity, so a memoised
|
|
6467
|
+
* card is not touched at all.
|
|
6468
|
+
*/
|
|
6244
6469
|
const flushStreamBuffers = () => {
|
|
6245
6470
|
if (deltaFlushTimer !== null) {
|
|
6246
6471
|
clearTimeout(deltaFlushTimer)
|
|
6247
6472
|
deltaFlushTimer = null
|
|
6248
6473
|
}
|
|
6249
|
-
if (pendingDeltas.size
|
|
6250
|
-
|
|
6251
|
-
pendingDeltas.
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6474
|
+
if (pendingDeltas.size === 0 && pendingReasonings.size === 0) return
|
|
6475
|
+
const cards = store.state.cards.map((card) => {
|
|
6476
|
+
const text = pendingDeltas.get(card.id)
|
|
6477
|
+
const reasoning = pendingReasonings.get(card.id)
|
|
6478
|
+
if (text === undefined && reasoning === undefined) return card
|
|
6479
|
+
const next = { ...card }
|
|
6480
|
+
if (text !== undefined) next.text = `${card.text}${text}`
|
|
6481
|
+
if (reasoning !== undefined) next.reasoning = `${card.reasoning}${reasoning}`
|
|
6482
|
+
return next
|
|
6483
|
+
})
|
|
6484
|
+
pendingDeltas.clear()
|
|
6485
|
+
pendingReasonings.clear()
|
|
6486
|
+
store.set({ cards })
|
|
6487
|
+
}
|
|
6488
|
+
|
|
6489
|
+
/**
|
|
6490
|
+
* Milliseconds to wait before the next flush.
|
|
6491
|
+
*
|
|
6492
|
+
* 50 ms is the "feels live" cadence, but the cost of one frame grows
|
|
6493
|
+
* with the answer: `renderRichText` measured 0.2–0.3 ms at 8 k
|
|
6494
|
+
* characters and 1.2–2.0 ms at 64 k. Past 32 k a 100 ms cadence is
|
|
6495
|
+
* indistinguishable to the eye and halves the work.
|
|
6496
|
+
* @returns {number} the delay for the next flush tick.
|
|
6497
|
+
*/
|
|
6498
|
+
const streamFlushDelay = () => {
|
|
6499
|
+
let longest = 0
|
|
6500
|
+
for (const card of store.state.cards) {
|
|
6501
|
+
if (card.status === 'streaming' && typeof card.text === 'string' && card.text.length > longest) {
|
|
6502
|
+
longest = card.text.length
|
|
6503
|
+
}
|
|
6256
6504
|
}
|
|
6505
|
+
return longest > STREAM_SLOW_CHARS ? STREAM_FLUSH_SLOW_MS : STREAM_FLUSH_MS
|
|
6257
6506
|
}
|
|
6258
6507
|
|
|
6259
6508
|
const bufferStreamDelta = (cardId, chunk) => {
|
|
6260
6509
|
if (chunk === '') return
|
|
6261
6510
|
pendingDeltas.set(cardId, (pendingDeltas.get(cardId) ?? '') + chunk)
|
|
6262
|
-
if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers,
|
|
6511
|
+
if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, streamFlushDelay())
|
|
6263
6512
|
}
|
|
6264
6513
|
|
|
6265
6514
|
const bufferStreamReasoning = (cardId, chunk) => {
|
|
6266
6515
|
if (chunk === '') return
|
|
6267
6516
|
pendingReasonings.set(cardId, (pendingReasonings.get(cardId) ?? '') + chunk)
|
|
6268
|
-
if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers,
|
|
6517
|
+
if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, streamFlushDelay())
|
|
6269
6518
|
}
|
|
6270
6519
|
|
|
6271
6520
|
/** Stream one side answer into its card. */
|
|
@@ -6868,7 +7117,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6868
7117
|
}
|
|
6869
7118
|
return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '', style: { padding: '10px' } },
|
|
6870
7119
|
h(Styles, null),
|
|
6871
|
-
h(
|
|
7120
|
+
h(MemoAnswerCard, {
|
|
6872
7121
|
card,
|
|
6873
7122
|
surfaceLabel: state.surface === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
|
|
6874
7123
|
onCopy: element => actions.copy(card, element),
|
|
@@ -7043,6 +7292,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7043
7292
|
CARD_KIND,
|
|
7044
7293
|
CARD_KIND_BETTER,
|
|
7045
7294
|
HISTORY_TURNS,
|
|
7295
|
+
API_ROOT,
|
|
7296
|
+
apiBaseAfter,
|
|
7297
|
+
MAX_LIVE_CARDS,
|
|
7298
|
+
REPLAY_OPEN_BUDGET_CHARS,
|
|
7299
|
+
STREAM_FLUSH_MS,
|
|
7300
|
+
STREAM_FLUSH_SLOW_MS,
|
|
7301
|
+
STREAM_SLOW_CHARS,
|
|
7302
|
+
capLiveCards,
|
|
7303
|
+
replayCardOpen,
|
|
7046
7304
|
estimateLabelWidth,
|
|
7047
7305
|
parseSseBlock,
|
|
7048
7306
|
truncateSelection,
|
package/index.js
CHANGED
|
@@ -45,7 +45,7 @@ export const name = 'dsh-sidecard-ask'
|
|
|
45
45
|
export const inject = ['webServer']
|
|
46
46
|
|
|
47
47
|
/** Version of this plugin (kept in step with package.json by test/verify.mjs). */
|
|
48
|
-
export const PLUGIN_VERSION = '2.
|
|
48
|
+
export const PLUGIN_VERSION = '2.1.1'
|
|
49
49
|
|
|
50
50
|
/** Route prefix of the plugin's own API. */
|
|
51
51
|
export const ROUTE_PREFIX = '/sidecard-ask/api'
|
|
@@ -2591,8 +2591,13 @@ export function apply(ctx, patchConfig) {
|
|
|
2591
2591
|
/**
|
|
2592
2592
|
* The HTTP methods the plugin serves; kept in one place because the route
|
|
2593
2593
|
* registration has two shapes (see below).
|
|
2594
|
+
*
|
|
2595
|
+
* `ping` is the one method that answers WITHOUT the browser session: it is
|
|
2596
|
+
* the only way a script, a CI job or a monitor can tell "the plugin is up"
|
|
2597
|
+
* apart from "the Host is up" once the session gate is in front of
|
|
2598
|
+
* everything else. It exposes nothing but the version and the Host clock.
|
|
2594
2599
|
*/
|
|
2595
|
-
const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
|
|
2600
|
+
const API_METHODS = ['ping', 'state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
|
|
2596
2601
|
|
|
2597
2602
|
/**
|
|
2598
2603
|
* The Host's own browser-session gate, borrowed for the plugin's routes.
|
|
@@ -2643,16 +2648,11 @@ export function apply(ctx, patchConfig) {
|
|
|
2643
2648
|
writeError(res, 403, 'forbidden', 'forbidden')
|
|
2644
2649
|
return
|
|
2645
2650
|
}
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
unauthorized === 401 ? 'unauthorized' : 'forbidden',
|
|
2652
|
-
unauthorized === 401 ? '需要先通过 DSH 登录(宿主会话未认证)' : '请求来源不受信任',
|
|
2653
|
-
)
|
|
2654
|
-
return
|
|
2655
|
-
}
|
|
2651
|
+
// Path parsing runs BEFORE the session gate now, because `ping` must be
|
|
2652
|
+
// answerable without a browser session. The order is deliberate: the fence
|
|
2653
|
+
// still rejects a foreign origin first, the method name is validated
|
|
2654
|
+
// before anything is dispatched, and `ping` is the ONLY verb that skips the
|
|
2655
|
+
// gate — everything else falls through to it immediately below.
|
|
2656
2656
|
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
2657
2657
|
const method = pathname.startsWith(`${ROUTE_PREFIX}/`)
|
|
2658
2658
|
? pathname.slice(ROUTE_PREFIX.length + 1)
|
|
@@ -2663,6 +2663,24 @@ export function apply(ctx, patchConfig) {
|
|
|
2663
2663
|
writeError(res, 404, 'not-found', `未知接口 "${method}"`)
|
|
2664
2664
|
return
|
|
2665
2665
|
}
|
|
2666
|
+
if (method === 'ping') {
|
|
2667
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2668
|
+
writeError(res, 405, 'method-error', 'method not allowed')
|
|
2669
|
+
return
|
|
2670
|
+
}
|
|
2671
|
+
writeOk(res, { version: PLUGIN_VERSION, at: Date.now() })
|
|
2672
|
+
return
|
|
2673
|
+
}
|
|
2674
|
+
const unauthorized = authRejection(req)
|
|
2675
|
+
if (unauthorized !== undefined) {
|
|
2676
|
+
writeError(
|
|
2677
|
+
res,
|
|
2678
|
+
unauthorized,
|
|
2679
|
+
unauthorized === 401 ? 'unauthorized' : 'forbidden',
|
|
2680
|
+
unauthorized === 401 ? '需要先通过 DSH 登录(宿主会话未认证)' : '请求来源不受信任',
|
|
2681
|
+
)
|
|
2682
|
+
return
|
|
2683
|
+
}
|
|
2666
2684
|
// Everything that can throw runs inside ONE try: `/state` assembles the
|
|
2667
2685
|
// whole diagnostic surface (capabilities, SMTP resolution, provenance), and
|
|
2668
2686
|
// it used to sit outside this block, so a single bad config value answered
|
package/package.json
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-sidecard-ask",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "index.js",
|
|
7
|
-
"scripts": {
|
|
8
|
-
"test": "node test/verify.mjs && node test/contract-test.mjs && node test/float-placement.mjs && node test/smoke-test.mjs"
|
|
9
|
-
},
|
|
10
|
-
"exports": {
|
|
11
|
-
".": "./index.js",
|
|
12
|
-
"./client": "./client.js",
|
|
13
|
-
"./package.json": "./package.json",
|
|
14
|
-
"./locale/*.json": "./locale/*.json"
|
|
15
|
-
},
|
|
16
|
-
"files": [
|
|
17
|
-
"index.js",
|
|
18
|
-
"client.js",
|
|
19
|
-
"cordis.patch.yml",
|
|
20
|
-
"locale/*.json",
|
|
21
|
-
"icon.svg",
|
|
22
|
-
"README.md",
|
|
23
|
-
"CHANGELOG.md",
|
|
24
|
-
"LICENSE"
|
|
25
|
-
],
|
|
26
|
-
"engines": {
|
|
27
|
-
"node": ">=20"
|
|
28
|
-
},
|
|
29
|
-
"license": "MIT",
|
|
30
|
-
"keywords": [
|
|
31
|
-
"dsh",
|
|
32
|
-
"dsh-plugin",
|
|
33
|
-
"deepseek-harness",
|
|
34
|
-
"selection",
|
|
35
|
-
"划词",
|
|
36
|
-
"选中追问",
|
|
37
|
-
"ask",
|
|
38
|
-
"side-card",
|
|
39
|
-
"侧边卡片"
|
|
40
|
-
],
|
|
41
|
-
"repository": {
|
|
42
|
-
"type": "git",
|
|
43
|
-
"url": "git+https://github.com/HERO476/dsh-sidecard-ask.git"
|
|
44
|
-
},
|
|
45
|
-
"homepage": "https://github.com/HERO476/dsh-sidecard-ask#readme",
|
|
46
|
-
"bugs": {
|
|
47
|
-
"url": "https://github.com/HERO476/dsh-sidecard-ask/issues"
|
|
48
|
-
},
|
|
49
|
-
"icon": "./icon.svg",
|
|
50
|
-
"meta": {
|
|
51
|
-
"title": "侧边卡片追问",
|
|
52
|
-
"description": "选中聊天或任务里的文本就地追问;答案由独立子代理在侧边卡片里流式呈现(也可落回主对话)。"
|
|
53
|
-
},
|
|
54
|
-
"dsh": {
|
|
55
|
-
"manifestVersion": 1,
|
|
56
|
-
"bundle": {
|
|
57
|
-
"patch": "./cordis.patch.yml"
|
|
58
|
-
},
|
|
59
|
-
"client": {
|
|
60
|
-
"platform": "web",
|
|
61
|
-
"immediately": true,
|
|
62
|
-
"inject": [
|
|
63
|
-
"@deepseek-ai/dsh-client-locale",
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-slots",
|
|
65
|
-
"@deepseek-ai/dsh-client-ui-conversation"
|
|
66
|
-
]
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-sidecard-ask",
|
|
3
|
+
"version": "2.1.1",
|
|
4
|
+
"description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node test/verify.mjs && node test/contract-test.mjs && node test/float-placement.mjs && node test/smoke-test.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.js",
|
|
12
|
+
"./client": "./client.js",
|
|
13
|
+
"./package.json": "./package.json",
|
|
14
|
+
"./locale/*.json": "./locale/*.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.js",
|
|
18
|
+
"client.js",
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"locale/*.json",
|
|
21
|
+
"icon.svg",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"dsh",
|
|
32
|
+
"dsh-plugin",
|
|
33
|
+
"deepseek-harness",
|
|
34
|
+
"selection",
|
|
35
|
+
"划词",
|
|
36
|
+
"选中追问",
|
|
37
|
+
"ask",
|
|
38
|
+
"side-card",
|
|
39
|
+
"侧边卡片"
|
|
40
|
+
],
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/HERO476/dsh-sidecard-ask.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/HERO476/dsh-sidecard-ask#readme",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/HERO476/dsh-sidecard-ask/issues"
|
|
48
|
+
},
|
|
49
|
+
"icon": "./icon.svg",
|
|
50
|
+
"meta": {
|
|
51
|
+
"title": "侧边卡片追问",
|
|
52
|
+
"description": "选中聊天或任务里的文本就地追问;答案由独立子代理在侧边卡片里流式呈现(也可落回主对话)。"
|
|
53
|
+
},
|
|
54
|
+
"dsh": {
|
|
55
|
+
"manifestVersion": 1,
|
|
56
|
+
"bundle": {
|
|
57
|
+
"patch": "./cordis.patch.yml"
|
|
58
|
+
},
|
|
59
|
+
"client": {
|
|
60
|
+
"platform": "web",
|
|
61
|
+
"immediately": true,
|
|
62
|
+
"inject": [
|
|
63
|
+
"@deepseek-ai/dsh-client-locale",
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|