dsh-sidecard-ask 2.1.3 → 2.1.5
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 +91 -0
- package/README.md +8 -0
- package/client.js +138 -23
- package/index.js +84 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,96 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.5
|
|
4
|
+
|
|
5
|
+
**主题:修完「技术方案评审团」在 0.2.1-rc.3(桌面 Electron 44.7.0 / 网页端同源)上复核出的 8 条缺陷。**
|
|
6
|
+
四套脚本全绿(57 / 1454 / 100 / 279 = 1890),新增 10 条回归断言。
|
|
7
|
+
|
|
8
|
+
### 修复
|
|
9
|
+
|
|
10
|
+
- **R1(中)原生栏把卡迁回浮层时没展开,卡在两端同时不可见**。`surfaces.native.open()` 只把
|
|
11
|
+
被挤出的卡 `surface` 改回 `'flow'`,但浮层只挂载 `surface === 'flow' && open` 的卡
|
|
12
|
+
(`FloatingLayer`),于是那张卡既不在栏里、也不在屏幕上,流式输出还继续往它里面写。
|
|
13
|
+
现在迁移时一并置 `open: true`。
|
|
14
|
+
- **R2(中)关 A 卡片会关掉 B 的 tab**。`close()` 认领 tab 的判据是"`contentId` 为空就算我的",
|
|
15
|
+
而单地址模型下栏里那条 tab 可能正显示另一张卡;`this.cardId` 已被清空时(页面重载后)
|
|
16
|
+
就会误关别人的 tab,连带把别人的正文一起关掉。现在要求 `contentId === 本卡 id`,或
|
|
17
|
+
宿主没报 `contentId` 且适配器仍持有本卡地址。
|
|
18
|
+
- **R3(中)删历史 / 删卡片不释放原生栏的 tab**。tab 是宿主自己的状态,不随 `store.cards`
|
|
19
|
+
变化;删掉卡片后页面里留着一张渲染"已不存在卡片"的空壳,直到用户手动关。新增
|
|
20
|
+
`releaseNativeTab(id)`,`deleteHistoryCard` / `deleteCard` / `clearHistory`(仅历史态卡)都会调用。
|
|
21
|
+
- **R4(中)设置页把只读开关标成"只读(推荐)",而 0.2.1-rc.3 上它根本不生效**。
|
|
22
|
+
宿主的 `SubagentCapabilities` 没有 `toolFilter`,`readOnlyToolFilter()` 返回的白名单会被丢弃,
|
|
23
|
+
侧边作答实际继承会话全部工具——卡片上的 `toolFilter: 'unsupported'` 是对的,但设置页的
|
|
24
|
+
"推荐"是假的。标签改为「只读(需宿主支持,0.2.1 无效)」。
|
|
25
|
+
- **R5(低)`setSideToolsHint` 谎称"只读靠探针自动拒写"**。代码里没有任何"拒写探针",
|
|
26
|
+
真实机制是 `toolFilter`(宿主不支持即失效)或 `agentPreset`(0.2.1-rc.3 支持)。文案改为
|
|
27
|
+
指向 preset,并写明版本前提。
|
|
28
|
+
- **R6(中)`/cancel` 不校验归属**。`runs` 是进程级全局表,任何已认证页面拿到(或猜到)一个
|
|
29
|
+
run id 就能打断别的会话的追问。run 记录现在带 `sessionId`,`cancel(id, sessionId)` 在双方
|
|
30
|
+
都已知且不一致时按"没找到"处理;不带 `sessionId` 的老客户端保持可用。
|
|
31
|
+
- **R8(低)写盘失败的报错只有 errno 原文**。`toWireError()` 现按 `EACCES` / `EPERM` / `EROFS` /
|
|
32
|
+
`ENOSPC` / `EBUSY` / `ENOENT` 追加中文处置提示,`error.code` 不变(程序分支仍按码判断)。
|
|
33
|
+
|
|
34
|
+
### 评估后不改
|
|
35
|
+
|
|
36
|
+
- **R7 `/state` 回配置文件的绝对路径**:该接口要求宿主登录(页面外是 `401 unauthorized`),
|
|
37
|
+
且这个字段是设置页"到底读的哪个文件"的唯一依据。已写进 README §隐私与安全第 16 条,
|
|
38
|
+
说明暴露面边界与转发时的处置办法。
|
|
39
|
+
|
|
40
|
+
## 2.1.4
|
|
41
|
+
|
|
42
|
+
**主题:把「技术方案评审团」查出的 11 条缺陷一次修完。四套脚本全绿,新增 21 条回归断言。**
|
|
43
|
+
排查依据:Software Architect / Application Security Engineer / Reality Checker 三方评审 + 源码复核 + 实测复现。
|
|
44
|
+
|
|
45
|
+
### 修复
|
|
46
|
+
|
|
47
|
+
- **B1(阻断,静默数据丢失)关闭的卡片在下次答题完成时被抹掉**。`syncPersisted()` 只从
|
|
48
|
+
「活跃卡片」重建整个持久化集合再整键覆盖,而 `close()` 只把卡片移出活跃区、durable 快照
|
|
49
|
+
仍留在 `store.state.history` 里,于是关掉的那张被静默删除(`contract-test` 原先把破坏后的
|
|
50
|
+
结果当成了期望值)。现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目,按 id 去重(活跃方
|
|
51
|
+
优先)后按 newest-first 截到 cap。删除路径本来就先改 `store.history` 再写盘,并集不会把
|
|
52
|
+
它们删掉的条目复活。
|
|
53
|
+
- **B2(高,虚假安全保证)`agentPreset` 在 0.2.1-alpha.2+ 的 activation 面没被转发**。
|
|
54
|
+
`startSubagent` 只在旧 `start(provider, request)` 面转发 `agentPreset`,新面的 `request`
|
|
55
|
+
漏了它 → 子代理拿默认全套工具,而 start 帧仍按配置上报 `toolFilter: 'preset'`,卡片于是
|
|
56
|
+
对用户谎报「工具集由 preset 决定」。现在两个面都转发。
|
|
57
|
+
- **B3(中高,降级链断裂)Rung 1 `sessions.using()` 没有 try/catch**。它一抛(会话归档、
|
|
58
|
+
形状漂移,或它内部的 `no-session-face`),整条「改发到主对话」就断在第一档,后面
|
|
59
|
+
`retain` / `inputActions` / `draft` 三档全部不可达。现在与 Rung 2 对称降级,
|
|
60
|
+
`prompt-rejected` 仍照旧抛给调用方(那是用户可见的拒绝,不是通道缺失)。
|
|
61
|
+
- **B4(中,原生栏串卡)第二张卡开进原生右栏时第一张没有迁回浮层**。它只是被盖住——卡片
|
|
62
|
+
仍标 `native-rightbar`、store 里被过滤掉,流式输出继续往看不见的卡片里写,且 rail 上那张
|
|
63
|
+
永远关不掉。现在 `open()` 先把栏里其它卡片迁回 `flow`;`close()` 增加归属校验(栏被另一
|
|
64
|
+
张卡占用时不替它关)与 `contentId` 行匹配(多行时关「属于这张卡」的那行);`openTab`
|
|
65
|
+
抛错时回滚 `cardId`,避免 CardHost 去渲染一张根本没有 tab 的卡。
|
|
66
|
+
- **B5(中低)`/cancel` 把跨会话的全局 run id 列表回给页面**。`runs` 是跨会话的全局表,
|
|
67
|
+
回 `active` 等于泄露别的会话正在追问的卡片 id(`cancel` 本身也不校验归属),而 Client
|
|
68
|
+
从不消费它。现在只回 `{ cancelled }`。
|
|
69
|
+
- **B6(中低,迁移被永久挡住)空的 v3 信封会阻断 v1/v2 迁移**。`raw !== null` 就直接
|
|
70
|
+
return,损坏的 JSON 或旧版本留下的空信封同样挡住。现在写盘时带 `writtenAt` 标记,只有
|
|
71
|
+
「本版本自己写的空信封」才算权威(用户确实清空了历史,绝不从 v2/v1 复活),没有该标记的
|
|
72
|
+
空信封与损坏 JSON 继续向下探测。
|
|
73
|
+
- **B7(低,卸载泄漏)`dispose()` 只清了 `streamListeners`,漏了 `chunkListeners`**。第二个
|
|
74
|
+
流来源的订阅者会留下仍被 `session/event` 桥持有的闭包(它捕获整个 engine 状态)。
|
|
75
|
+
- **B9(低,死重量)持久化写死 `slice(-12)`,而 `followUp` 只发 6 轮**。改成
|
|
76
|
+
`HISTORY_TURNS`,与上下文预算同一个上限。
|
|
77
|
+
- **B10(低,死代码)`let finished = false` 只赋值不读取**,删除。
|
|
78
|
+
- **B11(低,可诊断性)信任围栏的 403 只说 `forbidden`**。现在说明「Host 不在信任范围 +
|
|
79
|
+
怎么放行(127.0.0.1 或 `trustedHosts`)」。有意**不**自动信任绑定地址:插件读不到宿主的
|
|
80
|
+
bind 配置,猜一个就等于自己拆掉 DNS-rebinding 防线。
|
|
81
|
+
- **B8(条件性,平台限制)配置里的 SMTP 密码以明文落在 `~/.dsh/` 下的用户层 JSON**。
|
|
82
|
+
写盘已带 `mode: 0o600`(POSIX 生效,Windows 忽略),代码注释与 README 均已注明;
|
|
83
|
+
无进程内可做的进一步缓解,**保留为已文档化的限制**,建议优先用环境变量。
|
|
84
|
+
|
|
85
|
+
### 验证
|
|
86
|
+
|
|
87
|
+
`node test/verify.mjs` / `contract-test.mjs` / `float-placement.mjs` / `smoke-test.mjs`:
|
|
88
|
+
**57 / 1446 / 100 / 277 全绿(1880 条,0 失败)**。相对修复前 1859 条新增 21 条回归断言,
|
|
89
|
+
逐条对应上面的缺陷:关卡片后历史仍在(contract)、activation 面转发 preset 且无能力时
|
|
90
|
+
不带该字段(smoke)、`using()` 抛错降级而 `prompt-rejected` 仍抛(contract)、空/损坏 v3
|
|
91
|
+
信封不挡迁移且自有空信封不被复活(contract)、原生栏单地址与关 tab 归属(contract)、
|
|
92
|
+
`/cancel` 不泄露 `active`(smoke)。
|
|
93
|
+
|
|
3
94
|
## 2.1.3
|
|
4
95
|
|
|
5
96
|
**主题:折叠态胶囊不再压在聊天输入框上(2026-10-25 实拍报告)。**
|
package/README.md
CHANGED
|
@@ -1088,4 +1088,12 @@ npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>" # 旧名
|
|
|
1088
1088
|
15. **部署前缀不带尾斜杠时(2.1.0)**:`API` 基址会先按 `document.baseURI` 猜一次,若得到 404
|
|
1089
1089
|
则回退到根绝对路径重试一次。这是唯一安全的判别方式(401/403 都不能用来改基址),
|
|
1090
1090
|
代价是每次页面加载最多多一次 404 往返——正常部署下不会触发。
|
|
1091
|
+
16. **`/state` 会回配置文件的绝对路径(`provenance.persistedPath`,2.1.5 复核后保留)**:这是设置页
|
|
1092
|
+
用来说明"到底读的是哪个文件"的诊断字段,且该接口要求宿主登录(页面外拿到的是
|
|
1093
|
+
`401 unauthorized`)。它**不是**无鉴权暴露面;如果你把 `/state` 的响应转发到别处,请自行删掉
|
|
1094
|
+
这个字段。同一响应里的 `persistedError` 会带上 errno 的中文提示(`EACCES` / `EPERM` /
|
|
1095
|
+
`ENOSPC` / `EBUSY` 等),原始错误码仍保留在 `error.code` 上供程序分支。
|
|
1096
|
+
17. **`/cancel` 校验归属(2.1.5)**:请求体带 `sessionId` 且与 run 的发起会话不一致时,按"没找到"
|
|
1097
|
+
返回 `cancelled: false`(不回 403,避免把别的会话的 run id 是否存在当成探测面)。不带
|
|
1098
|
+
`sessionId` 的老客户端仍然可以取消——向后兼容优先于收紧。
|
|
1091
1099
|
|
package/client.js
CHANGED
|
@@ -117,7 +117,7 @@ window.__ModuleLoader__.load({
|
|
|
117
117
|
* the Host and the module's `api.version` both read it, so a report can
|
|
118
118
|
* never claim a generation the browser is not actually running.
|
|
119
119
|
*/
|
|
120
|
-
const CLIENT_VERSION = '2.1.
|
|
120
|
+
const CLIENT_VERSION = '2.1.5'
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
123
|
* Tab kind served by the DSH native right rail (also its implementation id).
|
|
@@ -707,9 +707,9 @@ window.__ModuleLoader__.load({
|
|
|
707
707
|
setMinChars: '最小触发字符数',
|
|
708
708
|
setConcurrency: '并发作答上限',
|
|
709
709
|
setSideTools: '侧边作答工具权限',
|
|
710
|
-
setSideToolsReadonly: '
|
|
710
|
+
setSideToolsReadonly: '只读(需宿主支持,0.2.1 无效)',
|
|
711
711
|
setSideToolsInherit: '继承当前会话',
|
|
712
|
-
setSideToolsHint: '
|
|
712
|
+
setSideToolsHint: '这个开关只在宿主支持工具白名单时才生效;0.2.1-rc.3 不支持,侧边作答会继承当前会话的全部工具。要真正收窄,请填写下面的 preset(0.2.1-rc.3 已支持)。',
|
|
713
713
|
setSideAgentPreset: '只读 preset(可选)',
|
|
714
714
|
setSideAgentPresetPlaceholder: '留空则不启用',
|
|
715
715
|
setSideAgentPresetHint: '子代理以该 agent preset 启动,工具集由 preset 决定(0.2.1 唯一可靠的收窄通道);留空时退回探针拒写',
|
|
@@ -982,9 +982,9 @@ window.__ModuleLoader__.load({
|
|
|
982
982
|
setMinChars: 'Minimum selection length',
|
|
983
983
|
setConcurrency: 'Max concurrent answers',
|
|
984
984
|
setSideTools: 'Side answerer tool access',
|
|
985
|
-
setSideToolsReadonly: 'Read-only (
|
|
985
|
+
setSideToolsReadonly: 'Read-only (needs host support; no-op on 0.2.1)',
|
|
986
986
|
setSideToolsInherit: 'Inherit from the session',
|
|
987
|
-
setSideToolsHint: '
|
|
987
|
+
setSideToolsHint: 'This switch only works when the host supports a tool allow-list; 0.2.1-rc.3 does not, so the side answerer inherits every tool of the session. To really narrow it, set the preset below (supported on 0.2.1-rc.3).',
|
|
988
988
|
setSideAgentPreset: 'Read-only preset (optional)',
|
|
989
989
|
setSideAgentPresetPlaceholder: 'Leave empty to disable',
|
|
990
990
|
setSideAgentPresetHint: 'Starts the subagent with this agent preset, so the preset decides the tool set — the only reliable narrowing channel on 0.2.1; when empty the write-refusing probe is the fallback',
|
|
@@ -2178,16 +2178,26 @@ window.__ModuleLoader__.load({
|
|
|
2178
2178
|
}
|
|
2179
2179
|
|
|
2180
2180
|
// Rung 1 — `using()`: the documented helper, present from 0.1.6-alpha.2.
|
|
2181
|
+
// 与 Rung 2 必须对称地包 try/catch:`using()` 本身(或它内部的
|
|
2182
|
+
// `no-session-face` 抛出)一失败就该降级到 retain → inputActions →
|
|
2183
|
+
// draft,而不是让整个"改发到主对话"断在这里 —— 后两档存在的理由
|
|
2184
|
+
// 正是这种情况。`prompt-rejected` 仍然照旧抛给调用方(那是用户可见的
|
|
2185
|
+
// 拒绝,不是通道缺失)。
|
|
2181
2186
|
if (sessionId !== null && typeof sessions?.using === 'function') {
|
|
2182
|
-
|
|
2183
|
-
const
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2187
|
+
try {
|
|
2188
|
+
const result = await sessions.using(sessionId, { source: 'gateway' }, async (reference) => {
|
|
2189
|
+
const face = reference?.binding?.session
|
|
2190
|
+
if (face === undefined || typeof face.prompt !== 'function') {
|
|
2191
|
+
throw Object.assign(new Error('session face has no prompt()'), { code: 'no-session-face' })
|
|
2192
|
+
}
|
|
2193
|
+
return face.prompt(parts, 'queue')
|
|
2194
|
+
})
|
|
2195
|
+
accepted(result)
|
|
2196
|
+
return { mode: 'session', via: 'sessions.using' }
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
if (error?.code === 'prompt-rejected') throw error
|
|
2199
|
+
console.warn(`[${PLUGIN_ID}] sessions.using 提交失败,尝试下一级:`, error)
|
|
2200
|
+
}
|
|
2191
2201
|
}
|
|
2192
2202
|
|
|
2193
2203
|
// Rung 2 — `retain()` + explicit release: the same operation spelled out,
|
|
@@ -2307,8 +2317,26 @@ window.__ModuleLoader__.load({
|
|
|
2307
2317
|
cardId: null,
|
|
2308
2318
|
open(card) {
|
|
2309
2319
|
if (!this.available() || typeof this.controller.openTab !== 'function') return false
|
|
2320
|
+
// 同一个 kind 在原生栏里只有一个地址(见 cardId 的注释):先把栏里
|
|
2321
|
+
// 其它卡片迁回浮层,否则它们既不在栏里也不在浮层上,流式输出还会
|
|
2322
|
+
// 继续往一张看不见的卡片里写。
|
|
2323
|
+
for (const other of store.state.cards) {
|
|
2324
|
+
if (other.id !== card.id && other.surface === 'native-rightbar') {
|
|
2325
|
+
// `open: true` is load-bearing: the floating layer only mounts a
|
|
2326
|
+
// card whose `surface === 'flow'` AND `open` is true, so migrating
|
|
2327
|
+
// a card back to the flow while leaving it folded hid it on BOTH
|
|
2328
|
+
// surfaces — not in the rail, not on screen, still streaming.
|
|
2329
|
+
patchCard(other.id, { surface: 'flow', open: true })
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2310
2332
|
this.cardId = card.id
|
|
2311
|
-
|
|
2333
|
+
try {
|
|
2334
|
+
this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
|
|
2335
|
+
} catch (error) {
|
|
2336
|
+
// 开栏失败就把地址回滚,免得 CardHost 去渲染一张根本没有 tab 的卡。
|
|
2337
|
+
this.cardId = null
|
|
2338
|
+
throw error
|
|
2339
|
+
}
|
|
2312
2340
|
return true
|
|
2313
2341
|
},
|
|
2314
2342
|
close(card) {
|
|
@@ -2319,6 +2347,10 @@ window.__ModuleLoader__.load({
|
|
|
2319
2347
|
// and lets CardHost show its closed-card face.
|
|
2320
2348
|
const controller = this.controller
|
|
2321
2349
|
if (controller === null || controller === undefined) return false
|
|
2350
|
+
// 归属校验:栏里那条同 kind 的 tab 若正被另一张卡片占用,就不能替它
|
|
2351
|
+
// 关掉(否则关 A 会把 B 的 tab 一起关掉)。cardId === null 是"清理
|
|
2352
|
+
// 残留 tab"的路径,此时按 kind 关是对的。
|
|
2353
|
+
if (this.cardId !== null && this.cardId !== card.id) return false
|
|
2322
2354
|
let rows = []
|
|
2323
2355
|
try {
|
|
2324
2356
|
rows = typeof controller.openTabs?.getSnapshot === 'function'
|
|
@@ -2328,6 +2360,15 @@ window.__ModuleLoader__.load({
|
|
|
2328
2360
|
const row = Array.isArray(rows)
|
|
2329
2361
|
? rows.find(entry => entry?.kind === CARD_KIND
|
|
2330
2362
|
&& typeof entry?.tabId === 'string' && entry.tabId !== ''
|
|
2363
|
+
// Ownership, not just kind. The tab belongs to this card when the
|
|
2364
|
+
// host echoes the id it was opened with (`openTab` carries it in
|
|
2365
|
+
// `params.cardId`, which the rail publishes as `contentId`), or
|
|
2366
|
+
// when the host reported no id at all AND this adapter still holds
|
|
2367
|
+
// that card's address. Claiming a tab merely because `contentId`
|
|
2368
|
+
// was empty made "close A" close B's tab — and with it B's body.
|
|
2369
|
+
&& (entry.contentId === card.id
|
|
2370
|
+
|| ((entry.contentId === undefined || entry.contentId === null || entry.contentId === '')
|
|
2371
|
+
&& this.cardId === card.id))
|
|
2331
2372
|
&& (typeof entry?.sessionId !== 'string'
|
|
2332
2373
|
|| store.state.sessionId === null
|
|
2333
2374
|
|| entry.sessionId === store.state.sessionId))
|
|
@@ -5841,13 +5882,32 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5841
5882
|
} catch { return [] }
|
|
5842
5883
|
}
|
|
5843
5884
|
|
|
5885
|
+
/** 是否是「本版本自己写下的空信封」(带 writtenAt 标记)。 */
|
|
5886
|
+
function isOwnedEmptyEnvelope(raw) {
|
|
5887
|
+
try {
|
|
5888
|
+
const value = JSON.parse(raw)
|
|
5889
|
+
return value !== null
|
|
5890
|
+
&& typeof value === 'object'
|
|
5891
|
+
&& value.version === 3
|
|
5892
|
+
&& Array.isArray(value.cards)
|
|
5893
|
+
&& Number.isFinite(value.writtenAt)
|
|
5894
|
+
} catch {
|
|
5895
|
+
return false
|
|
5896
|
+
}
|
|
5897
|
+
}
|
|
5898
|
+
|
|
5844
5899
|
function readPersisted() {
|
|
5845
5900
|
let raw = null
|
|
5846
5901
|
try {
|
|
5847
5902
|
raw = window.localStorage?.getItem(PERSIST_KEY)
|
|
5848
5903
|
} catch { return [] }
|
|
5849
5904
|
const v3 = parseEnvelope(raw, 3).slice(0, 50)
|
|
5850
|
-
if (v3.length > 0
|
|
5905
|
+
if (v3.length > 0) return v3
|
|
5906
|
+
// 空的 v3 信封只有在「本版本自己写的」时候才算权威:它带 writtenAt,
|
|
5907
|
+
// 说明用户确实把历史清空了,此时绝不能回落到 v2/v1 把旧记录复活。
|
|
5908
|
+
// 而没有 writtenAt 的空信封(旧版本写的 / 迁移中途留下的)或损坏的
|
|
5909
|
+
// JSON 必须继续向下探测 —— 否则 v1、v2 的数据会被永久挡住。
|
|
5910
|
+
if (raw !== null && raw !== undefined && isOwnedEmptyEnvelope(raw)) return v3
|
|
5851
5911
|
// v2 → v3: v2 entries have no `versions`, so the field is seeded empty.
|
|
5852
5912
|
let v2raw = null
|
|
5853
5913
|
try {
|
|
@@ -5881,7 +5941,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5881
5941
|
let lastError = null
|
|
5882
5942
|
for (;;) {
|
|
5883
5943
|
try {
|
|
5884
|
-
window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining }))
|
|
5944
|
+
window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining, writtenAt: Date.now() }))
|
|
5885
5945
|
return remaining
|
|
5886
5946
|
} catch (error) {
|
|
5887
5947
|
lastError = error
|
|
@@ -5907,7 +5967,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5907
5967
|
.filter(card => card.carrier === 'side'
|
|
5908
5968
|
&& (card.status === 'done' || card.status === 'stopped')
|
|
5909
5969
|
&& card.text !== '')
|
|
5910
|
-
.slice(0, cap)
|
|
5911
5970
|
.map(card => ({
|
|
5912
5971
|
id: card.id,
|
|
5913
5972
|
question: card.question,
|
|
@@ -5923,7 +5982,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5923
5982
|
createdAt: Number.isFinite(card.createdAt) ? card.createdAt : Date.now(),
|
|
5924
5983
|
provider: typeof card.provider === 'string' ? card.provider : '',
|
|
5925
5984
|
elapsedMs: Number.isFinite(card.elapsedMs) ? card.elapsedMs : null,
|
|
5926
|
-
|
|
5985
|
+
// 持久化的历史轮次与上下文预算同一个上限(HISTORY_TURNS):
|
|
5986
|
+
// 这里曾经写死 `slice(-12)`,而 followUp 只发 6 轮,多出来的
|
|
5987
|
+
// 6 轮永远不会被读,是纯死重量。
|
|
5988
|
+
history: Array.isArray(card.history) ? card.history.slice(-HISTORY_TURNS) : [],
|
|
5927
5989
|
// P2-2: kept so a replayed rewrite card still offers "insert".
|
|
5928
5990
|
actionId: typeof card.actionId === 'string' ? card.actionId.slice(0, 60) : null,
|
|
5929
5991
|
// P2-5b: bounded, text-capped older answers. `question` is short
|
|
@@ -5947,7 +6009,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5947
6009
|
store.set({ history: [] })
|
|
5948
6010
|
return
|
|
5949
6011
|
}
|
|
5950
|
-
|
|
6012
|
+
// #6(2026-10-26 修复):关闭一张卡片只把它从「活跃区」移除,
|
|
6013
|
+
// durable 快照仍然留在 store.history 里。这里过去只从活跃卡片
|
|
6014
|
+
// 重建整个集合再整键覆盖,于是「关掉的那张」会在下一次答题完成
|
|
6015
|
+
// 时被静默抹掉(contract-test 把破坏后的结果当成了期望)。
|
|
6016
|
+
// 现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目(按 id 去重,
|
|
6017
|
+
// 活跃方优先),再按 newest-first 截到 cap。
|
|
6018
|
+
// 删除路径(deleteCard / deleteHistoryCard / 改投主对话 /
|
|
6019
|
+
// clearHistory)都是先改 store.history 再写盘,所以并集不会把它们
|
|
6020
|
+
// 删掉的条目复活。
|
|
6021
|
+
const liveIds = new Set(finished.map(entry => entry.id))
|
|
6022
|
+
const kept = store.state.history.filter(entry => entry !== null
|
|
6023
|
+
&& typeof entry === 'object'
|
|
6024
|
+
&& typeof entry.id === 'string'
|
|
6025
|
+
&& !liveIds.has(entry.id))
|
|
6026
|
+
const merged = [...finished, ...kept]
|
|
6027
|
+
.sort((left, right) => (Number(right.createdAt) || 0) - (Number(left.createdAt) || 0))
|
|
6028
|
+
.slice(0, cap)
|
|
6029
|
+
const persisted = writePersisted(merged)
|
|
5951
6030
|
store.set({ history: persisted })
|
|
5952
6031
|
}
|
|
5953
6032
|
|
|
@@ -6057,6 +6136,26 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6057
6136
|
: [],
|
|
6058
6137
|
})
|
|
6059
6138
|
|
|
6139
|
+
/**
|
|
6140
|
+
* Close the right-rail tab when it is showing this card.
|
|
6141
|
+
*
|
|
6142
|
+
* The rail's tab is not derived from `store.cards`: deleting a card (or
|
|
6143
|
+
* a history entry) only removed it from the stores, so a card that lived
|
|
6144
|
+
* in the native rail left its tab behind — a body rendering an id that
|
|
6145
|
+
* no longer exists, until the user closed it by hand. Only the tab this
|
|
6146
|
+
* adapter actually opened is touched; `close` re-checks ownership.
|
|
6147
|
+
* @param {string} id - the card id that is going away.
|
|
6148
|
+
*/
|
|
6149
|
+
function releaseNativeTab(id) {
|
|
6150
|
+
const native = surfaces.native
|
|
6151
|
+
if (native === null || native === undefined) return
|
|
6152
|
+
if (native.controller === null || native.controller === undefined) return
|
|
6153
|
+
if (native.cardId !== id) return
|
|
6154
|
+
try {
|
|
6155
|
+
native.close({ id })
|
|
6156
|
+
} catch { /* the rail owns its tab's lifetime */ }
|
|
6157
|
+
}
|
|
6158
|
+
|
|
6060
6159
|
const actions = {
|
|
6061
6160
|
openPopover(candidate) {
|
|
6062
6161
|
// P2-2: the composer must be remembered BEFORE the popover's
|
|
@@ -6095,6 +6194,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6095
6194
|
const next = store.state.history
|
|
6096
6195
|
.filter(item => item.id !== id)
|
|
6097
6196
|
.slice(0, historyCap())
|
|
6197
|
+
// A deleted entry must not keep a right-rail tab alive: the tab is
|
|
6198
|
+
// not part of the store, so nothing else would ever close it.
|
|
6199
|
+
releaseNativeTab(id)
|
|
6098
6200
|
writePersisted(next)
|
|
6099
6201
|
store.set({
|
|
6100
6202
|
history: next,
|
|
@@ -6122,7 +6224,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6122
6224
|
const controller = controllers.get(id)
|
|
6123
6225
|
if (controller !== undefined) {
|
|
6124
6226
|
controller.abort()
|
|
6125
|
-
void postJson('cancel', { id }).catch(() => {})
|
|
6227
|
+
void postJson('cancel', { id, sessionId: store.state.sessionId }).catch(() => {})
|
|
6126
6228
|
}
|
|
6127
6229
|
controllers.delete(id)
|
|
6128
6230
|
pendingDeltas.delete(id)
|
|
@@ -6139,6 +6241,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6139
6241
|
surfaces.native.close(card)
|
|
6140
6242
|
} catch { /* the right rail owns its lifecycle */ }
|
|
6141
6243
|
}
|
|
6244
|
+
// A card restored from durable history has no live `surface`, so the
|
|
6245
|
+
// branch above cannot see its tab; this checks the adapter's own
|
|
6246
|
+
// address instead and is a no-op when nothing is on the rail.
|
|
6247
|
+
releaseNativeTab(id)
|
|
6142
6248
|
const next = store.state.history
|
|
6143
6249
|
.filter(item => item.id !== id)
|
|
6144
6250
|
.slice(0, historyCap())
|
|
@@ -6165,6 +6271,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6165
6271
|
* by definition: it is the one action allowed to drop the old keys.
|
|
6166
6272
|
*/
|
|
6167
6273
|
clearHistory() {
|
|
6274
|
+
// A history-only card that happens to sit on the rail loses its
|
|
6275
|
+
// durable entry here; leaving the tab up would show a body for an
|
|
6276
|
+
// entry the manager no longer has. A card that is still live keeps
|
|
6277
|
+
// its tab — this action does not touch running cards.
|
|
6278
|
+
const railCard = surfaces.native?.cardId
|
|
6279
|
+
if (typeof railCard === 'string' && railCard !== ''
|
|
6280
|
+
&& !store.state.cards.some(card => card.id === railCard)) {
|
|
6281
|
+
releaseNativeTab(railCard)
|
|
6282
|
+
}
|
|
6168
6283
|
try {
|
|
6169
6284
|
for (const key of [PERSIST_KEY, PERSIST_V2_KEY, PERSIST_LEGACY_KEY]) {
|
|
6170
6285
|
window.localStorage?.removeItem(key)
|
|
@@ -6470,7 +6585,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6470
6585
|
// so POST /cancel too. Fire-and-forget on purpose — the card is
|
|
6471
6586
|
// already stopped locally, and a dead host must not turn the
|
|
6472
6587
|
// Stop button into a failure.
|
|
6473
|
-
void postJson('cancel', { id: card.id }).catch(() => {})
|
|
6588
|
+
void postJson('cancel', { id: card.id, sessionId: store.state.sessionId }).catch(() => {})
|
|
6474
6589
|
}
|
|
6475
6590
|
patchCard(card.id, { status: 'stopped' })
|
|
6476
6591
|
},
|
|
@@ -6519,7 +6634,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6519
6634
|
controller.abort()
|
|
6520
6635
|
// Same host-side release as the Stop button: closing a card
|
|
6521
6636
|
// mid-stream must not leave the child running server-side.
|
|
6522
|
-
void postJson('cancel', { id: card.id }).catch(() => {})
|
|
6637
|
+
void postJson('cancel', { id: card.id, sessionId: store.state.sessionId }).catch(() => {})
|
|
6523
6638
|
}
|
|
6524
6639
|
controllers.delete(card.id)
|
|
6525
6640
|
// A closed card's buffered-but-unflushed deltas belong to nobody:
|
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.1.
|
|
48
|
+
export const PLUGIN_VERSION = '2.1.5'
|
|
49
49
|
|
|
50
50
|
/** Route prefix of the plugin's own API. */
|
|
51
51
|
export const ROUTE_PREFIX = '/sidecard-ask/api'
|
|
@@ -1248,6 +1248,21 @@ export async function sendSmtpMessage(endpoint, mail, { timeoutMs = 20_000, now
|
|
|
1248
1248
|
}
|
|
1249
1249
|
}
|
|
1250
1250
|
|
|
1251
|
+
/**
|
|
1252
|
+
* Plain-language hints for the filesystem errnos that actually reach a save on
|
|
1253
|
+
* Windows/macOS/Linux. The raw `EACCES: permission denied, open '…'` line is
|
|
1254
|
+
* accurate but tells a user nothing about what to do; the code stays on the
|
|
1255
|
+
* wire (clients branch on it) and the hint is appended to the message.
|
|
1256
|
+
*/
|
|
1257
|
+
const ERRNO_HINTS = {
|
|
1258
|
+
EACCES: '(没有写权限:检查文件是否被其它程序占用,或改用可写目录)',
|
|
1259
|
+
EPERM: '(系统拒绝写入:文件可能被占用或只读)',
|
|
1260
|
+
EROFS: '(所在磁盘是只读的)',
|
|
1261
|
+
ENOSPC: '(磁盘空间不足,设置未保存)',
|
|
1262
|
+
EBUSY: '(文件正被其它进程占用,请稍后重试)',
|
|
1263
|
+
ENOENT: '(目标目录不存在)',
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1251
1266
|
/**
|
|
1252
1267
|
* Reduce any thrown value to a wire error the Client can present.
|
|
1253
1268
|
* @param {unknown} error - the thrown value.
|
|
@@ -1258,7 +1273,15 @@ export function toWireError(error, fallbackCode = 'internal') {
|
|
|
1258
1273
|
if (error !== null && typeof error === 'object') {
|
|
1259
1274
|
const code = typeof error.code === 'string' && error.code !== '' ? error.code : undefined
|
|
1260
1275
|
const message = typeof error.message === 'string' && error.message !== '' ? error.message : undefined
|
|
1261
|
-
if (message !== undefined)
|
|
1276
|
+
if (message !== undefined) {
|
|
1277
|
+
const hint = code !== undefined && Object.prototype.hasOwnProperty.call(ERRNO_HINTS, code)
|
|
1278
|
+
? ERRNO_HINTS[code]
|
|
1279
|
+
: undefined
|
|
1280
|
+
return {
|
|
1281
|
+
code: code ?? fallbackCode,
|
|
1282
|
+
message: hint === undefined || message.includes(hint) ? message : `${message}${hint}`,
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1262
1285
|
}
|
|
1263
1286
|
return { code: fallbackCode, message: String(error) }
|
|
1264
1287
|
}
|
|
@@ -1541,7 +1564,7 @@ function openSse(res) {
|
|
|
1541
1564
|
* Build the engine that owns every side-card run.
|
|
1542
1565
|
* @param {import('@deepseek-ai/cordis').Context} ctx - host plugin context.
|
|
1543
1566
|
* @param {() => Record<string, unknown>} configOf - live config reader.
|
|
1544
|
-
* @returns {{ask: Function, cancel: Function,
|
|
1567
|
+
* @returns {{ask: Function, cancel: Function, dispose: Function, capabilities: Function}}
|
|
1545
1568
|
*/
|
|
1546
1569
|
function createSideEngine(ctx, configOf) {
|
|
1547
1570
|
/** id → run record. */
|
|
@@ -1630,6 +1653,11 @@ function createSideEngine(ctx, configOf) {
|
|
|
1630
1653
|
parent: fields.parent,
|
|
1631
1654
|
...(fields.persona === undefined ? {} : { persona: fields.persona }),
|
|
1632
1655
|
...(fields.toolFilter === undefined ? {} : { toolFilter: fields.toolFilter }),
|
|
1656
|
+
// 0.2.1-alpha.2+ 的 `request` 与旧 `start(provider, request)` 是同一个
|
|
1657
|
+
// SubagentStartRequest 形状,`agentPreset` 必须一起转发。漏掉这一项
|
|
1658
|
+
// 会让子代理拿到默认全套工具,而 start 帧仍按配置上报
|
|
1659
|
+
// `toolFilter: 'preset'`,卡片于是对用户谎报"工具集由 preset 决定"。
|
|
1660
|
+
...(fields.agentPreset === undefined ? {} : { agentPreset: fields.agentPreset }),
|
|
1633
1661
|
},
|
|
1634
1662
|
})
|
|
1635
1663
|
return {
|
|
@@ -1893,11 +1921,19 @@ function createSideEngine(ctx, configOf) {
|
|
|
1893
1921
|
/** Which source produced deltas: process-local frames or durable chunks. */
|
|
1894
1922
|
let framesSeen = false
|
|
1895
1923
|
let chunksSeen = false
|
|
1896
|
-
let finished = false
|
|
1897
1924
|
|
|
1898
1925
|
/** Detach this run's stream listener (frames are per attempt). */
|
|
1899
1926
|
let detach = () => {}
|
|
1900
|
-
const runRecord = {
|
|
1927
|
+
const runRecord = {
|
|
1928
|
+
abort: () => controller.abort(),
|
|
1929
|
+
dispose: undefined,
|
|
1930
|
+
// Which session asked. `runs` is process-global, so `/cancel` needs an
|
|
1931
|
+
// owner to check against: without it any trusted page could abort any
|
|
1932
|
+
// other session's run by guessing (or sniffing) a run id.
|
|
1933
|
+
sessionId: typeof request.sessionId === 'string' && request.sessionId !== ''
|
|
1934
|
+
? request.sessionId
|
|
1935
|
+
: null,
|
|
1936
|
+
}
|
|
1901
1937
|
runs.set(id, runRecord)
|
|
1902
1938
|
|
|
1903
1939
|
try {
|
|
@@ -2078,7 +2114,6 @@ function createSideEngine(ctx, configOf) {
|
|
|
2078
2114
|
})
|
|
2079
2115
|
return
|
|
2080
2116
|
}
|
|
2081
|
-
finished = true
|
|
2082
2117
|
sink.send('done', {
|
|
2083
2118
|
id,
|
|
2084
2119
|
text,
|
|
@@ -2120,10 +2155,28 @@ function createSideEngine(ctx, configOf) {
|
|
|
2120
2155
|
}
|
|
2121
2156
|
}
|
|
2122
2157
|
|
|
2123
|
-
/**
|
|
2124
|
-
|
|
2158
|
+
/**
|
|
2159
|
+
* Abort one in-flight run.
|
|
2160
|
+
*
|
|
2161
|
+
* `runs` is process-global: every session shares it. When both sides know the
|
|
2162
|
+
* owning session and they differ, this is a cross-session cancel and is
|
|
2163
|
+
* refused (it reports `false`, exactly like an unknown id, so it does not
|
|
2164
|
+
* confirm that the id exists elsewhere). A request that cannot name its
|
|
2165
|
+
* session is still honoured — older clients and scripted calls must keep
|
|
2166
|
+
* working.
|
|
2167
|
+
* @param {string} id - the run id.
|
|
2168
|
+
* @param {string|null} [sessionId] - the session asking for the cancel.
|
|
2169
|
+
* @returns {boolean} whether a run was aborted.
|
|
2170
|
+
*/
|
|
2171
|
+
function cancel(id, sessionId = null) {
|
|
2125
2172
|
const record = runs.get(id)
|
|
2126
2173
|
if (record === undefined) return false
|
|
2174
|
+
const owner = record.sessionId
|
|
2175
|
+
if (typeof owner === 'string' && owner !== ''
|
|
2176
|
+
&& typeof sessionId === 'string' && sessionId !== ''
|
|
2177
|
+
&& owner !== sessionId) {
|
|
2178
|
+
return false
|
|
2179
|
+
}
|
|
2127
2180
|
record.abort()
|
|
2128
2181
|
return true
|
|
2129
2182
|
}
|
|
@@ -2140,9 +2193,12 @@ function createSideEngine(ctx, configOf) {
|
|
|
2140
2193
|
}
|
|
2141
2194
|
runs.clear()
|
|
2142
2195
|
streamListeners.clear()
|
|
2196
|
+
// 第二个流来源的订阅者也要清:只清 streamListeners 会在插件卸载后
|
|
2197
|
+
// 留下仍被 `session/event` 桥持有的闭包(它捕获了整个 engine 状态)。
|
|
2198
|
+
chunkListeners.clear()
|
|
2143
2199
|
}
|
|
2144
2200
|
|
|
2145
|
-
return { ask, cancel, dispose, capabilities
|
|
2201
|
+
return { ask, cancel, dispose, capabilities }
|
|
2146
2202
|
}
|
|
2147
2203
|
|
|
2148
2204
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -2594,7 +2650,15 @@ export function apply(ctx, patchConfig) {
|
|
|
2594
2650
|
writeError(res, 400, 'bad-request', '缺少 id')
|
|
2595
2651
|
return
|
|
2596
2652
|
}
|
|
2597
|
-
|
|
2653
|
+
// 只回本次取消的结果:`runs` 是跨会话的全局表,把它的 id 列表回给
|
|
2654
|
+
// 任意可信页面等于泄露别的会话正在追问的卡片 id(而 cancel 本身也不
|
|
2655
|
+
// 校验归属),Client 侧也没有任何地方消费这个字段。
|
|
2656
|
+
// 归属校验:请求方必须带上自己的 sessionId,与 run 记录不一致时按"没
|
|
2657
|
+
// 找到"处理(不回 403,避免把别的会话的 id 是否存在当成探测面)。
|
|
2658
|
+
const sessionId = typeof body?.sessionId === 'string' && body.sessionId !== ''
|
|
2659
|
+
? body.sessionId
|
|
2660
|
+
: null
|
|
2661
|
+
writeOk(res, { cancelled: engine.cancel(id, sessionId) })
|
|
2598
2662
|
}
|
|
2599
2663
|
|
|
2600
2664
|
/** `/config` — merge a partial config into the user layer and persist it. */
|
|
@@ -2707,7 +2771,16 @@ export function apply(ctx, patchConfig) {
|
|
|
2707
2771
|
/** One request → one response; the dispatcher owns path parsing and the fence. */
|
|
2708
2772
|
const routeHandler = async (req, res) => {
|
|
2709
2773
|
if (!isTrustedRequest(req, trustedHostsOf())) {
|
|
2710
|
-
|
|
2774
|
+
// 说明"为什么被拒 + 怎么放行":这条 403 只会出现在 DSH 被绑到非回环
|
|
2775
|
+
// 地址、或经反向代理访问时(Host 既不是 loopback,也不在 trustedHosts
|
|
2776
|
+
// 里)。有意不自动信任绑定地址:插件读不到宿主的 bind 配置,猜一个就等于
|
|
2777
|
+
// 自己把 DNS-rebinding 防线拆掉。
|
|
2778
|
+
writeError(
|
|
2779
|
+
res,
|
|
2780
|
+
403,
|
|
2781
|
+
'forbidden',
|
|
2782
|
+
'Host 不在信任范围:请用 http://127.0.0.1:<端口> 访问,或把该地址写进 trustedHosts 配置',
|
|
2783
|
+
)
|
|
2711
2784
|
return
|
|
2712
2785
|
}
|
|
2713
2786
|
// Path parsing runs BEFORE the session gate now, because `ping` must be
|