dsh-sidecard-ask 2.1.4 → 2.1.6
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 +72 -0
- package/README.md +8 -0
- package/client.js +257 -30
- package/index.js +61 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.6
|
|
4
|
+
|
|
5
|
+
**主题:修复桌面端 0.2.1-rc.3 / 网页端 0.2.1-rc.4 与 0.2.2-alpha.2 上的 4 个承载面与会话归属缺陷。**
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **S1(高)默认 better 承载面开第二张卡时第一张消失**。better-sidebar 0.28.2 把每个
|
|
10
|
+
描述符镜像到 DSH 原生右栏,而该 tab 类型没有 `multiple`:同一 kind、同一会话只有
|
|
11
|
+
一个 page 地址,第二次 `openTab` 会把这个地址导航到新卡。插件过去只在 native
|
|
12
|
+
适配器做「迁回浮层」,better 适配器没有,于是第一张卡既不在栏里也不在浮层上,
|
|
13
|
+
流式输出继续写进看不见的卡片。现在 `surfaces.better.open()` 与 native 一样,先把
|
|
14
|
+
同会话其它 better 卡片迁回浮层并置 `open:true`。覆盖原生右栏为唯一地址的 0.28.x。
|
|
15
|
+
- **S2(高)关闭/删除卡片不会关闭 better 承载面的 tab**。0.28.x 的原生 tab id 由宿主
|
|
16
|
+
mint(`tab1`…),插件传入的 seed id 被忽略,`closeTab(seed.id)` 是严格 no-op。
|
|
17
|
+
`CardHost` 现在从宿主传入的 `props.tab.id` 记录真实 tab id,关闭/删除用它;未挂载
|
|
18
|
+
过的卡片(刷新后恢复的栏 tab)按 kind + 会话在 `sidebarRight.openTabs` 清单里定位。
|
|
19
|
+
`deleteHistoryCard` / `clearHistory` 也会释放没有活跃卡片的 better tab。
|
|
20
|
+
- **S3(中)显式 `native-rightbar` 下关闭/删除失效(2.1.5 R2 回归)**。宿主的 page tab
|
|
21
|
+
把 `contentId` 固定写成 `sidebar://<kind>`,不是卡片 id;R2 用
|
|
22
|
+
`entry.contentId === card.id` 判归属,匹配恒失败,关闭流程永远返回 false。现在
|
|
23
|
+
归属只看适配器持有的 `surfaces.native.cardId`,定位按 kind + 会话;`releaseNativeTab`
|
|
24
|
+
与删除路径随之恢复生效。测试替身也改为真实 `contentId` 语义。
|
|
25
|
+
- **S4(中)切会话后停止/改投用错会话**。卡片过去不记录发起会话;用户在会话 A 追问、
|
|
26
|
+
切到 B 后点停止,`/cancel` 带的是 B,宿主 R6 归属校验按「没找到」处理,子代理继续
|
|
27
|
+
跑到超时;「改到主对话」也会投进 B。卡片现在在创建时记录 `sessionId`,停止、关闭、
|
|
28
|
+
删除、追问、重试、改投全部使用卡片自己的会话,并随 v3 历史持久化(旧条目无该字段
|
|
29
|
+
时回退当前会话)。
|
|
30
|
+
|
|
31
|
+
### 验证
|
|
32
|
+
|
|
33
|
+
`verify 57 / contract 1475 / float-placement 100 / smoke 279 = 1911 全绿`。净增 21 条
|
|
34
|
+
回归断言:better 第二张卡迁移、真实宿主 tab id 关闭、非所有者不得替他人关 tab、
|
|
35
|
+
未挂载 tab 的删除释放、原生 `contentId=sidebar://kind` 下的关闭与删除、切会话后
|
|
36
|
+
`/cancel` 仍带原会话、v3 条目持久化与重放 `sessionId`。
|
|
37
|
+
|
|
38
|
+
## 2.1.5
|
|
39
|
+
|
|
40
|
+
**主题:修完「技术方案评审团」在 0.2.1-rc.3(桌面 Electron 44.7.0 / 网页端同源)上复核出的 8 条缺陷。**
|
|
41
|
+
四套脚本全绿(57 / 1454 / 100 / 279 = 1890),新增 10 条回归断言。
|
|
42
|
+
|
|
43
|
+
### 修复
|
|
44
|
+
|
|
45
|
+
- **R1(中)原生栏把卡迁回浮层时没展开,卡在两端同时不可见**。`surfaces.native.open()` 只把
|
|
46
|
+
被挤出的卡 `surface` 改回 `'flow'`,但浮层只挂载 `surface === 'flow' && open` 的卡
|
|
47
|
+
(`FloatingLayer`),于是那张卡既不在栏里、也不在屏幕上,流式输出还继续往它里面写。
|
|
48
|
+
现在迁移时一并置 `open: true`。
|
|
49
|
+
- **R2(中)关 A 卡片会关掉 B 的 tab**。`close()` 认领 tab 的判据是"`contentId` 为空就算我的",
|
|
50
|
+
而单地址模型下栏里那条 tab 可能正显示另一张卡;`this.cardId` 已被清空时(页面重载后)
|
|
51
|
+
就会误关别人的 tab,连带把别人的正文一起关掉。现在要求 `contentId === 本卡 id`,或
|
|
52
|
+
宿主没报 `contentId` 且适配器仍持有本卡地址。
|
|
53
|
+
- **R3(中)删历史 / 删卡片不释放原生栏的 tab**。tab 是宿主自己的状态,不随 `store.cards`
|
|
54
|
+
变化;删掉卡片后页面里留着一张渲染"已不存在卡片"的空壳,直到用户手动关。新增
|
|
55
|
+
`releaseNativeTab(id)`,`deleteHistoryCard` / `deleteCard` / `clearHistory`(仅历史态卡)都会调用。
|
|
56
|
+
- **R4(中)设置页把只读开关标成"只读(推荐)",而 0.2.1-rc.3 上它根本不生效**。
|
|
57
|
+
宿主的 `SubagentCapabilities` 没有 `toolFilter`,`readOnlyToolFilter()` 返回的白名单会被丢弃,
|
|
58
|
+
侧边作答实际继承会话全部工具——卡片上的 `toolFilter: 'unsupported'` 是对的,但设置页的
|
|
59
|
+
"推荐"是假的。标签改为「只读(需宿主支持,0.2.1 无效)」。
|
|
60
|
+
- **R5(低)`setSideToolsHint` 谎称"只读靠探针自动拒写"**。代码里没有任何"拒写探针",
|
|
61
|
+
真实机制是 `toolFilter`(宿主不支持即失效)或 `agentPreset`(0.2.1-rc.3 支持)。文案改为
|
|
62
|
+
指向 preset,并写明版本前提。
|
|
63
|
+
- **R6(中)`/cancel` 不校验归属**。`runs` 是进程级全局表,任何已认证页面拿到(或猜到)一个
|
|
64
|
+
run id 就能打断别的会话的追问。run 记录现在带 `sessionId`,`cancel(id, sessionId)` 在双方
|
|
65
|
+
都已知且不一致时按"没找到"处理;不带 `sessionId` 的老客户端保持可用。
|
|
66
|
+
- **R8(低)写盘失败的报错只有 errno 原文**。`toWireError()` 现按 `EACCES` / `EPERM` / `EROFS` /
|
|
67
|
+
`ENOSPC` / `EBUSY` / `ENOENT` 追加中文处置提示,`error.code` 不变(程序分支仍按码判断)。
|
|
68
|
+
|
|
69
|
+
### 评估后不改
|
|
70
|
+
|
|
71
|
+
- **R7 `/state` 回配置文件的绝对路径**:该接口要求宿主登录(页面外是 `401 unauthorized`),
|
|
72
|
+
且这个字段是设置页"到底读的哪个文件"的唯一依据。已写进 README §隐私与安全第 16 条,
|
|
73
|
+
说明暴露面边界与转发时的处置办法。
|
|
74
|
+
|
|
3
75
|
## 2.1.4
|
|
4
76
|
|
|
5
77
|
**主题:把「技术方案评审团」查出的 11 条缺陷一次修完。四套脚本全绿,新增 21 条回归断言。**
|
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.6'
|
|
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',
|
|
@@ -2264,6 +2264,23 @@ window.__ModuleLoader__.load({
|
|
|
2264
2264
|
throw Object.assign(new Error(t('noMainCarrier')), { code: 'no-main-carrier' })
|
|
2265
2265
|
}
|
|
2266
2266
|
|
|
2267
|
+
/**
|
|
2268
|
+
* The session a card belongs to, falling back to the active one.
|
|
2269
|
+
*
|
|
2270
|
+
* Cards outlive session switches (the overlay is root-scoped), so every
|
|
2271
|
+
* follow-up transport must name the card's own session: the Host's `/cancel`
|
|
2272
|
+
* treats a known-but-different session as "not found", and a prompt sent to
|
|
2273
|
+
* the wrong session quotes the wrong conversation.
|
|
2274
|
+
* @param {object|undefined} card - the card being acted on.
|
|
2275
|
+
* @param {object} ctx - the client context (for the active-session fallback).
|
|
2276
|
+
* @returns {string|null} the owning session, or the active one.
|
|
2277
|
+
*/
|
|
2278
|
+
function cardSessionId(card, ctx) {
|
|
2279
|
+
const own = card?.sessionId
|
|
2280
|
+
if (typeof own === 'string' && own !== '') return own
|
|
2281
|
+
return currentSessionId(ctx)
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2267
2284
|
/** Best-effort active session id from every sanctioned source. */
|
|
2268
2285
|
function currentSessionId(ctx) {
|
|
2269
2286
|
const captured = store.state.sessionId
|
|
@@ -2322,7 +2339,11 @@ window.__ModuleLoader__.load({
|
|
|
2322
2339
|
// 继续往一张看不见的卡片里写。
|
|
2323
2340
|
for (const other of store.state.cards) {
|
|
2324
2341
|
if (other.id !== card.id && other.surface === 'native-rightbar') {
|
|
2325
|
-
|
|
2342
|
+
// `open: true` is load-bearing: the floating layer only mounts a
|
|
2343
|
+
// card whose `surface === 'flow'` AND `open` is true, so migrating
|
|
2344
|
+
// a card back to the flow while leaving it folded hid it on BOTH
|
|
2345
|
+
// surfaces — not in the rail, not on screen, still streaming.
|
|
2346
|
+
patchCard(other.id, { surface: 'flow', open: true })
|
|
2326
2347
|
}
|
|
2327
2348
|
}
|
|
2328
2349
|
this.cardId = card.id
|
|
@@ -2343,10 +2364,15 @@ window.__ModuleLoader__.load({
|
|
|
2343
2364
|
// and lets CardHost show its closed-card face.
|
|
2344
2365
|
const controller = this.controller
|
|
2345
2366
|
if (controller === null || controller === undefined) return false
|
|
2346
|
-
//
|
|
2347
|
-
//
|
|
2348
|
-
//
|
|
2349
|
-
|
|
2367
|
+
// 归属校验:只有"本卡就是适配器最后打开的那张"才能关。宿主的 page
|
|
2368
|
+
// tab 把 contentId 固定写成 `sidebar://<kind>`(不是卡片 id),所以
|
|
2369
|
+
// 过去用 `contentId === card.id` 判归属永远匹配失败、关闭恒失效
|
|
2370
|
+
// (2.1.5 R2 的回归)。kind 单地址模型下 `cardId` 才是唯一归属信号。
|
|
2371
|
+
if (this.cardId !== card.id) return false
|
|
2372
|
+
// 卡片自己的会话优先:用户切走会话后关闭 A 的 tab,仍应落在 A。
|
|
2373
|
+
const ownerSession = typeof card.sessionId === 'string' && card.sessionId !== ''
|
|
2374
|
+
? card.sessionId
|
|
2375
|
+
: store.state.sessionId
|
|
2350
2376
|
let rows = []
|
|
2351
2377
|
try {
|
|
2352
2378
|
rows = typeof controller.openTabs?.getSnapshot === 'function'
|
|
@@ -2356,15 +2382,11 @@ window.__ModuleLoader__.load({
|
|
|
2356
2382
|
const row = Array.isArray(rows)
|
|
2357
2383
|
? rows.find(entry => entry?.kind === CARD_KIND
|
|
2358
2384
|
&& typeof entry?.tabId === 'string' && entry.tabId !== ''
|
|
2359
|
-
//
|
|
2360
|
-
//
|
|
2361
|
-
// 否则会替另一张卡片关掉它的 tab。
|
|
2362
|
-
&& (typeof entry?.contentId !== 'string'
|
|
2363
|
-
|| entry.contentId === ''
|
|
2364
|
-
|| entry.contentId === card.id)
|
|
2385
|
+
// 该 kind 只有一个 page 地址(不是 multiple),所以 kind +
|
|
2386
|
+
// 会话就唯一定位本卡那张 tab;归属由上面的 adapter.cardId 保证。
|
|
2365
2387
|
&& (typeof entry?.sessionId !== 'string'
|
|
2366
|
-
||
|
|
2367
|
-
|| entry.sessionId ===
|
|
2388
|
+
|| ownerSession === null
|
|
2389
|
+
|| entry.sessionId === ownerSession))
|
|
2368
2390
|
: undefined
|
|
2369
2391
|
if (row === undefined) return false
|
|
2370
2392
|
try {
|
|
@@ -2390,6 +2412,14 @@ window.__ModuleLoader__.load({
|
|
|
2390
2412
|
error: null,
|
|
2391
2413
|
dispose: null,
|
|
2392
2414
|
features: [],
|
|
2415
|
+
/**
|
|
2416
|
+
* The card whose open last took the single better tab.
|
|
2417
|
+
*
|
|
2418
|
+
* 0.28.x hosts every better tab in the native rail under one page
|
|
2419
|
+
* address per kind, so only the newest open owns it. Closing a card
|
|
2420
|
+
* that is not this owner must not close someone else's tab.
|
|
2421
|
+
*/
|
|
2422
|
+
cardId: null,
|
|
2393
2423
|
/**
|
|
2394
2424
|
* Why this adapter is (not) usable, in the settings page's own words.
|
|
2395
2425
|
* `null` means "usable"; a string explains the refusal.
|
|
@@ -2414,20 +2444,56 @@ window.__ModuleLoader__.load({
|
|
|
2414
2444
|
},
|
|
2415
2445
|
open(card) {
|
|
2416
2446
|
if (!this.available() || typeof this.service.openTab !== 'function') return false
|
|
2447
|
+
// 0.28.x 把 better 的每个描述符镜像成原生右栏类型,而该类型没有
|
|
2448
|
+
// `multiple`:同一 kind 在一个会话里只有一个 page 地址。再开一张卡
|
|
2449
|
+
// 会把这个地址导航到新卡,上一张既不在栏里也没有回到浮层 —— 和
|
|
2450
|
+
// native 适配器 2.1.5 R1 修的是同一个缺陷。这里同样先把同会话的
|
|
2451
|
+
// 其它 better 卡片迁回浮层。
|
|
2452
|
+
const targetSession = card.sessionId ?? store.state.sessionId ?? null
|
|
2453
|
+
for (const other of store.state.cards) {
|
|
2454
|
+
if (other.id === card.id || other.surface !== 'better-sidebar') continue
|
|
2455
|
+
const otherSession = typeof other.sessionId === 'string' && other.sessionId !== ''
|
|
2456
|
+
? other.sessionId
|
|
2457
|
+
: null
|
|
2458
|
+
if (targetSession !== null && otherSession !== null && otherSession !== targetSession) continue
|
|
2459
|
+
patchCard(other.id, { surface: 'flow', open: true })
|
|
2460
|
+
}
|
|
2417
2461
|
const tabId = `${CARD_KIND_BETTER}:${card.id}`
|
|
2418
2462
|
// `meta` is the transport for the card id (feature `tabMeta`), and
|
|
2419
2463
|
// `dedupeKey` on our descriptor is what collapses repeat opens onto
|
|
2420
|
-
// the same tab; both are part of the stable consumer contract
|
|
2421
|
-
// (`lib/types/client/service.d.ts`, unchanged across 0.22.0 → 0.22.1).
|
|
2464
|
+
// the same tab; both are part of the stable consumer contract.
|
|
2422
2465
|
this.service.openTab(
|
|
2423
2466
|
{ type: CARD_KIND_BETTER, id: tabId, title: card.question.slice(0, 32), meta: { cardId: card.id } },
|
|
2424
|
-
|
|
2467
|
+
typeof targetSession === 'string' && targetSession !== '' ? { sessionId: targetSession } : undefined,
|
|
2425
2468
|
)
|
|
2469
|
+
this.cardId = card.id
|
|
2426
2470
|
return true
|
|
2427
2471
|
},
|
|
2428
2472
|
close(card) {
|
|
2429
2473
|
if (!this.available() || typeof this.service.closeTab !== 'function') return false
|
|
2430
|
-
|
|
2474
|
+
// 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab。
|
|
2475
|
+
if (this.cardId !== card.id) return false
|
|
2476
|
+
const sessionId = typeof card.sessionId === 'string' && card.sessionId !== ''
|
|
2477
|
+
? card.sessionId
|
|
2478
|
+
: (store.state.sessionId ?? undefined)
|
|
2479
|
+
const scope = typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : undefined
|
|
2480
|
+
// 0.28.x 的原生 tab id 由宿主 mint(tab1…),不是我们传入的 seed id;
|
|
2481
|
+
// CardHost 挂载时把真实 id 记进 embeddedTabIds。
|
|
2482
|
+
const mountedId = embeddedTabIds.get(card.id)
|
|
2483
|
+
if (typeof mountedId === 'string' && mountedId !== '') {
|
|
2484
|
+
this.service.closeTab(mountedId, scope)
|
|
2485
|
+
this.cardId = null
|
|
2486
|
+
return true
|
|
2487
|
+
}
|
|
2488
|
+
// 卡片还没挂载过(或已从 store 移除):按 kind + 会话在栏的清单里
|
|
2489
|
+
// 找到唯一那张 tab,经原生控制器关闭。
|
|
2490
|
+
if (closeRailRow(CARD_KIND_BETTER, sessionId)) {
|
|
2491
|
+
this.cardId = null
|
|
2492
|
+
return true
|
|
2493
|
+
}
|
|
2494
|
+
// 0.19 之前的 bottom workbench:seed id 就是 tab id。
|
|
2495
|
+
this.service.closeTab(`${CARD_KIND_BETTER}:${card.id}`, scope)
|
|
2496
|
+
this.cardId = null
|
|
2431
2497
|
return true
|
|
2432
2498
|
},
|
|
2433
2499
|
},
|
|
@@ -2513,6 +2579,17 @@ window.__ModuleLoader__.load({
|
|
|
2513
2579
|
*/
|
|
2514
2580
|
let renderedCardId = null
|
|
2515
2581
|
|
|
2582
|
+
/**
|
|
2583
|
+
* Host tab ids for cards mounted by an external embedder, keyed by card id.
|
|
2584
|
+
*
|
|
2585
|
+
* better-sidebar's descriptor component receives the host `SidebarTab`, whose
|
|
2586
|
+
* `id` is the tab id the native rail minted (`tab1`, …) — NOT the internal
|
|
2587
|
+
* `sidecard-ask-better:<cardId>` seed id. Since 0.28.x every better-sidebar
|
|
2588
|
+
* open lands in DSH's native rail, so `closeTab(seed.id)` is a strict no-op.
|
|
2589
|
+
* The body records the real id here and `surfaces.better.close` uses it.
|
|
2590
|
+
*/
|
|
2591
|
+
const embeddedTabIds = new Map()
|
|
2592
|
+
|
|
2516
2593
|
/**
|
|
2517
2594
|
* Pick the surface for a new card.
|
|
2518
2595
|
* @param {string} wanted - `auto` | adapter kind.
|
|
@@ -5973,6 +6050,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5973
6050
|
truncated: card.truncated === true,
|
|
5974
6051
|
droppedChars: Number(card.droppedChars ?? 0),
|
|
5975
6052
|
createdAt: Number.isFinite(card.createdAt) ? card.createdAt : Date.now(),
|
|
6053
|
+
// 卡片归属的会话,随历史一起持久化;旧条目没有该字段时按 null 读。
|
|
6054
|
+
sessionId: typeof card.sessionId === 'string' && card.sessionId !== '' ? card.sessionId : null,
|
|
5976
6055
|
provider: typeof card.provider === 'string' ? card.provider : '',
|
|
5977
6056
|
elapsedMs: Number.isFinite(card.elapsedMs) ? card.elapsedMs : null,
|
|
5978
6057
|
// 持久化的历史轮次与上下文预算同一个上限(HISTORY_TURNS):
|
|
@@ -6066,6 +6145,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6066
6145
|
provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
|
|
6067
6146
|
elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
|
|
6068
6147
|
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
|
|
6148
|
+
// 刷新后仍要能把追问/改投送回原来的会话;旧条目没有该字段。
|
|
6149
|
+
sessionId: typeof entry.sessionId === 'string' && entry.sessionId !== '' ? entry.sessionId : null,
|
|
6069
6150
|
// P2-5b: replayed versions stay switchable after a reload.
|
|
6070
6151
|
versions: replayVersions(entry.versions),
|
|
6071
6152
|
viewingVersion: null,
|
|
@@ -6116,6 +6197,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6116
6197
|
provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
|
|
6117
6198
|
elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
|
|
6118
6199
|
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
|
|
6200
|
+
// 旧历史没有这个字段:留 null,调用方回退到当前会话。
|
|
6201
|
+
sessionId: typeof entry.sessionId === 'string' && entry.sessionId !== '' ? entry.sessionId : null,
|
|
6119
6202
|
notified: true,
|
|
6120
6203
|
versions: replayVersions(entry.versions),
|
|
6121
6204
|
viewingVersion: null,
|
|
@@ -6129,6 +6212,102 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6129
6212
|
: [],
|
|
6130
6213
|
})
|
|
6131
6214
|
|
|
6215
|
+
/**
|
|
6216
|
+
* Close the one rail tab a kind owns in one session.
|
|
6217
|
+
*
|
|
6218
|
+
* The rail keeps one page tab per kind (no `multiple`), and its
|
|
6219
|
+
* `contentId` is `sidebar://<kind>` rather than any card id. Both
|
|
6220
|
+
* external surfaces therefore locate a tab by kind + session, never by
|
|
6221
|
+
* the id a body was opened with.
|
|
6222
|
+
* @param {string} kind - the tab kind registered with the rail.
|
|
6223
|
+
* @param {string|null|undefined} sessionId - the owning session, when known.
|
|
6224
|
+
* @returns {boolean} whether a tab was found and the rail accepted the close.
|
|
6225
|
+
*/
|
|
6226
|
+
function closeRailRow(kind, sessionId) {
|
|
6227
|
+
const controller = surfaces.native === null ? null : surfaces.native.controller
|
|
6228
|
+
if (controller === null || controller === undefined) return false
|
|
6229
|
+
let rows = []
|
|
6230
|
+
try {
|
|
6231
|
+
rows = typeof controller.openTabs?.getSnapshot === 'function'
|
|
6232
|
+
? controller.openTabs.getSnapshot()
|
|
6233
|
+
: []
|
|
6234
|
+
} catch { return false }
|
|
6235
|
+
if (!Array.isArray(rows)) return false
|
|
6236
|
+
const candidates = rows.filter(entry => entry?.kind === kind
|
|
6237
|
+
&& typeof entry?.tabId === 'string' && entry.tabId !== '')
|
|
6238
|
+
// 会话已知时按会话匹配;会话未知(旧历史没有 sessionId)时只有
|
|
6239
|
+
// 全进程恰好一张该 kind 的 tab 才敢关,否则可能误关别的会话。
|
|
6240
|
+
const row = typeof sessionId === 'string' && sessionId !== ''
|
|
6241
|
+
? candidates.find(entry => typeof entry?.sessionId !== 'string'
|
|
6242
|
+
|| entry.sessionId === sessionId)
|
|
6243
|
+
: (candidates.length === 1 ? candidates[0] : undefined)
|
|
6244
|
+
if (row === undefined) return false
|
|
6245
|
+
try {
|
|
6246
|
+
if (typeof controller.closeIn === 'function' && typeof row.sessionId === 'string') {
|
|
6247
|
+
controller.closeIn(row.sessionId, row.tabId)
|
|
6248
|
+
} else if (typeof controller.close === 'function') {
|
|
6249
|
+
controller.close(row.tabId)
|
|
6250
|
+
} else {
|
|
6251
|
+
return false
|
|
6252
|
+
}
|
|
6253
|
+
return true
|
|
6254
|
+
} catch {
|
|
6255
|
+
return false
|
|
6256
|
+
}
|
|
6257
|
+
}
|
|
6258
|
+
|
|
6259
|
+
/**
|
|
6260
|
+
* Release a better-sidebar tab that no longer has a live card body.
|
|
6261
|
+
*
|
|
6262
|
+
* The descriptor component records the host's real tab id on mount;
|
|
6263
|
+
* without that (a tab restored from a previous page load, or a delete
|
|
6264
|
+
* before the body mounted) the rail inventory locates it by kind.
|
|
6265
|
+
* @param {string} id - the card id whose tab should go away.
|
|
6266
|
+
* @param {string|null|undefined} sessionId - the card's own session.
|
|
6267
|
+
*/
|
|
6268
|
+
function releaseBetterTab(id, sessionId) {
|
|
6269
|
+
const better = surfaces.better
|
|
6270
|
+
if (better === null || better === undefined) return
|
|
6271
|
+
if (better.service === null || typeof better.service?.closeTab !== 'function') return
|
|
6272
|
+
// 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab;释放一张
|
|
6273
|
+
// 不是当前所有者的历史条目,会把现在真正在栏里的卡片连带关掉。
|
|
6274
|
+
if (better.cardId !== id) return
|
|
6275
|
+
const scope = typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : undefined
|
|
6276
|
+
const mountedId = embeddedTabIds.get(id)
|
|
6277
|
+
if (typeof mountedId === 'string' && mountedId !== '') {
|
|
6278
|
+
try { better.service.closeTab(mountedId, scope) } catch { /* the host may already be gone */ }
|
|
6279
|
+
better.cardId = null
|
|
6280
|
+
return
|
|
6281
|
+
}
|
|
6282
|
+
if (closeRailRow(CARD_KIND_BETTER, sessionId)) {
|
|
6283
|
+
better.cardId = null
|
|
6284
|
+
return
|
|
6285
|
+
}
|
|
6286
|
+
try { better.service.closeTab(`${CARD_KIND_BETTER}:${id}`, scope) } catch { /* legacy no-op */ }
|
|
6287
|
+
better.cardId = null
|
|
6288
|
+
}
|
|
6289
|
+
|
|
6290
|
+
/**
|
|
6291
|
+
* Close the right-rail tab when it is showing this card.
|
|
6292
|
+
*
|
|
6293
|
+
* The rail's tab is not derived from `store.cards`: deleting a card (or
|
|
6294
|
+
* a history entry) only removed it from the stores, so a card that lived
|
|
6295
|
+
* in the native rail left its tab behind — a body rendering an id that
|
|
6296
|
+
* no longer exists, until the user closed it by hand. Only the tab this
|
|
6297
|
+
* adapter actually opened is touched; `close` re-checks ownership.
|
|
6298
|
+
* @param {string} id - the card id that is going away.
|
|
6299
|
+
* @param {string|null|undefined} sessionId - the card's own session.
|
|
6300
|
+
*/
|
|
6301
|
+
function releaseNativeTab(id, sessionId) {
|
|
6302
|
+
const native = surfaces.native
|
|
6303
|
+
if (native === null || native === undefined) return
|
|
6304
|
+
if (native.controller === null || native.controller === undefined) return
|
|
6305
|
+
if (native.cardId !== id) return
|
|
6306
|
+
try {
|
|
6307
|
+
native.close({ id, sessionId })
|
|
6308
|
+
} catch { /* the rail owns its tab's lifetime */ }
|
|
6309
|
+
}
|
|
6310
|
+
|
|
6132
6311
|
const actions = {
|
|
6133
6312
|
openPopover(candidate) {
|
|
6134
6313
|
// P2-2: the composer must be remembered BEFORE the popover's
|
|
@@ -6164,9 +6343,21 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6164
6343
|
},
|
|
6165
6344
|
/** Delete one entry from durable history (and its live card). */
|
|
6166
6345
|
deleteHistoryCard(id) {
|
|
6346
|
+
const entry = store.state.history.find(item => item.id === id)
|
|
6167
6347
|
const next = store.state.history
|
|
6168
6348
|
.filter(item => item.id !== id)
|
|
6169
6349
|
.slice(0, historyCap())
|
|
6350
|
+
// A deleted entry must not keep a right-rail tab alive: the tab is
|
|
6351
|
+
// not part of the store, so nothing else would ever close it.
|
|
6352
|
+
// The live card (when the manager is deleting one) carries the
|
|
6353
|
+
// session the entry itself may predate.
|
|
6354
|
+
const live = entry === undefined
|
|
6355
|
+
? store.state.cards.find(card => card.id === id)
|
|
6356
|
+
: undefined
|
|
6357
|
+
if (entry !== undefined || live !== undefined) {
|
|
6358
|
+
releaseBetterTab(id, entry?.sessionId ?? live?.sessionId)
|
|
6359
|
+
}
|
|
6360
|
+
releaseNativeTab(id, entry?.sessionId ?? live?.sessionId)
|
|
6170
6361
|
writePersisted(next)
|
|
6171
6362
|
store.set({
|
|
6172
6363
|
history: next,
|
|
@@ -6194,7 +6385,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6194
6385
|
const controller = controllers.get(id)
|
|
6195
6386
|
if (controller !== undefined) {
|
|
6196
6387
|
controller.abort()
|
|
6197
|
-
void postJson('cancel', { id }).catch(() => {})
|
|
6388
|
+
void postJson('cancel', { id, sessionId: cardSessionId(card, ctx) }).catch(() => {})
|
|
6198
6389
|
}
|
|
6199
6390
|
controllers.delete(id)
|
|
6200
6391
|
pendingDeltas.delete(id)
|
|
@@ -6211,6 +6402,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6211
6402
|
surfaces.native.close(card)
|
|
6212
6403
|
} catch { /* the right rail owns its lifecycle */ }
|
|
6213
6404
|
}
|
|
6405
|
+
// A card restored from durable history has no live `surface`, so the
|
|
6406
|
+
// branch above cannot see its tab; this checks the adapter's own
|
|
6407
|
+
// address instead and is a no-op when nothing is on the rail.
|
|
6408
|
+
releaseNativeTab(id, card.sessionId)
|
|
6214
6409
|
const next = store.state.history
|
|
6215
6410
|
.filter(item => item.id !== id)
|
|
6216
6411
|
.slice(0, historyCap())
|
|
@@ -6237,6 +6432,22 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6237
6432
|
* by definition: it is the one action allowed to drop the old keys.
|
|
6238
6433
|
*/
|
|
6239
6434
|
clearHistory() {
|
|
6435
|
+
// A history-only card that happens to sit on the rail loses its
|
|
6436
|
+
// durable entry here; leaving the tab up would show a body for an
|
|
6437
|
+
// entry the manager no longer has. A card that is still live keeps
|
|
6438
|
+
// its tab — this action does not touch running cards.
|
|
6439
|
+
const railCard = surfaces.native?.cardId
|
|
6440
|
+
if (typeof railCard === 'string' && railCard !== ''
|
|
6441
|
+
&& !store.state.cards.some(card => card.id === railCard)) {
|
|
6442
|
+
const railSession = store.state.history.find(item => item.id === railCard)?.sessionId
|
|
6443
|
+
releaseNativeTab(railCard, railSession)
|
|
6444
|
+
}
|
|
6445
|
+
// better 承载面同理:历史条目被清空后,只为「没有活跃卡片」的
|
|
6446
|
+
// 条目释放它可能留下的栏 tab。
|
|
6447
|
+
for (const entry of store.state.history) {
|
|
6448
|
+
if (store.state.cards.some(card => card.id === entry.id)) continue
|
|
6449
|
+
releaseBetterTab(entry.id, entry.sessionId)
|
|
6450
|
+
}
|
|
6240
6451
|
try {
|
|
6241
6452
|
for (const key of [PERSIST_KEY, PERSIST_V2_KEY, PERSIST_LEGACY_KEY]) {
|
|
6242
6453
|
window.localStorage?.removeItem(key)
|
|
@@ -6260,6 +6471,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6260
6471
|
droppedChars: cut.droppedChars,
|
|
6261
6472
|
zone: candidate.zone,
|
|
6262
6473
|
carrier: payload.carrier,
|
|
6474
|
+
// 发起会话:卡片可能活过会话切换,停止/改投/追问必须回到它。
|
|
6475
|
+
sessionId: currentSessionId(ctx),
|
|
6263
6476
|
status: 'pending',
|
|
6264
6477
|
text: '',
|
|
6265
6478
|
reasoning: '',
|
|
@@ -6287,7 +6500,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6287
6500
|
try {
|
|
6288
6501
|
const result = await askInMainConversation({
|
|
6289
6502
|
ctx,
|
|
6290
|
-
sessionId:
|
|
6503
|
+
sessionId: cardSessionId(card, ctx),
|
|
6291
6504
|
selection: candidate.text,
|
|
6292
6505
|
question: payload.question,
|
|
6293
6506
|
zone: candidate.zone,
|
|
@@ -6474,7 +6687,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6474
6687
|
else {
|
|
6475
6688
|
void askInMainConversation({
|
|
6476
6689
|
ctx,
|
|
6477
|
-
sessionId:
|
|
6690
|
+
sessionId: cardSessionId(next, ctx),
|
|
6478
6691
|
selection: next.selection,
|
|
6479
6692
|
question: text,
|
|
6480
6693
|
zone: next.zone,
|
|
@@ -6542,7 +6755,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6542
6755
|
// so POST /cancel too. Fire-and-forget on purpose — the card is
|
|
6543
6756
|
// already stopped locally, and a dead host must not turn the
|
|
6544
6757
|
// Stop button into a failure.
|
|
6545
|
-
void postJson('cancel', { id: card.id }).catch(() => {})
|
|
6758
|
+
void postJson('cancel', { id: card.id, sessionId: cardSessionId(card, ctx) }).catch(() => {})
|
|
6546
6759
|
}
|
|
6547
6760
|
patchCard(card.id, { status: 'stopped' })
|
|
6548
6761
|
},
|
|
@@ -6550,7 +6763,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6550
6763
|
try {
|
|
6551
6764
|
const result = await askInMainConversation({
|
|
6552
6765
|
ctx,
|
|
6553
|
-
sessionId:
|
|
6766
|
+
sessionId: cardSessionId(card, ctx),
|
|
6554
6767
|
selection: card.selection,
|
|
6555
6768
|
question: card.question,
|
|
6556
6769
|
zone: card.zone,
|
|
@@ -6591,7 +6804,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6591
6804
|
controller.abort()
|
|
6592
6805
|
// Same host-side release as the Stop button: closing a card
|
|
6593
6806
|
// mid-stream must not leave the child running server-side.
|
|
6594
|
-
void postJson('cancel', { id: card.id }).catch(() => {})
|
|
6807
|
+
void postJson('cancel', { id: card.id, sessionId: cardSessionId(card, ctx) }).catch(() => {})
|
|
6595
6808
|
}
|
|
6596
6809
|
controllers.delete(card.id)
|
|
6597
6810
|
// A closed card's buffered-but-unflushed deltas belong to nobody:
|
|
@@ -6724,6 +6937,13 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6724
6937
|
const runSideCard = async (cardId) => {
|
|
6725
6938
|
const card = store.state.cards.find(item => item.id === cardId)
|
|
6726
6939
|
if (card === undefined) return
|
|
6940
|
+
// 追问/重试沿用卡片自己的会话,而不是用户此刻切到的会话。
|
|
6941
|
+
const ownerSession = typeof card.sessionId === 'string' && card.sessionId !== ''
|
|
6942
|
+
? card.sessionId
|
|
6943
|
+
: currentSessionId(ctx)
|
|
6944
|
+
if (typeof ownerSession === 'string' && ownerSession !== '' && card.sessionId !== ownerSession) {
|
|
6945
|
+
patchCard(cardId, { sessionId: ownerSession })
|
|
6946
|
+
}
|
|
6727
6947
|
const controller = new AbortController()
|
|
6728
6948
|
controllers.set(cardId, controller)
|
|
6729
6949
|
// #9: wall time from the (re)start of THIS attempt to its terminal
|
|
@@ -6745,7 +6965,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6745
6965
|
selection: card.selected,
|
|
6746
6966
|
zone: card.zone,
|
|
6747
6967
|
carrier: 'side',
|
|
6748
|
-
sessionId:
|
|
6968
|
+
sessionId: ownerSession,
|
|
6749
6969
|
history: card.history.slice(-HISTORY_TURNS).map(turn => ({
|
|
6750
6970
|
question: String(turn?.question ?? '').slice(0, HISTORY_TURN_Q_CHARS),
|
|
6751
6971
|
answer: String(turn?.answer ?? '').slice(0, HISTORY_TURN_A_CHARS),
|
|
@@ -7314,11 +7534,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7314
7534
|
const state = useStoreState()
|
|
7315
7535
|
const cardId = hostCardIdOf(props)
|
|
7316
7536
|
const card = state.cards.find(item => item.id === cardId)
|
|
7537
|
+
// The embedder's REAL tab id (better-sidebar 0.28.x mints it and
|
|
7538
|
+
// ignores the seed id we passed), recorded for close/delete.
|
|
7539
|
+
const hostTabId = typeof props?.tab?.id === 'string' && props.tab.id !== '' ? props.tab.id : null
|
|
7317
7540
|
// The proof hook MUST run before any early return (hook order), and
|
|
7318
7541
|
// it is what lets `submit` detect a host that never rendered us.
|
|
7319
7542
|
React.useEffect(() => {
|
|
7320
7543
|
if (typeof cardId !== 'string' || cardId === '') return undefined
|
|
7321
7544
|
renderedCardId = cardId
|
|
7545
|
+
if (hostTabId !== null) embeddedTabIds.set(cardId, hostTabId)
|
|
7322
7546
|
// Mounting settles the render proof, even when this host mounted
|
|
7323
7547
|
// late (a lazy tab panel slower than the 600ms timer) — the proof
|
|
7324
7548
|
// must never fire after the card is demonstrably on screen.
|
|
@@ -7329,8 +7553,11 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7329
7553
|
}
|
|
7330
7554
|
return () => {
|
|
7331
7555
|
if (renderedCardId === cardId) renderedCardId = null
|
|
7556
|
+
if (hostTabId !== null && embeddedTabIds.get(cardId) === hostTabId) {
|
|
7557
|
+
embeddedTabIds.delete(cardId)
|
|
7558
|
+
}
|
|
7332
7559
|
}
|
|
7333
|
-
}, [cardId])
|
|
7560
|
+
}, [cardId, hostTabId])
|
|
7334
7561
|
if (card === undefined) {
|
|
7335
7562
|
return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '' },
|
|
7336
7563
|
h(Styles, null),
|
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.6'
|
|
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
|
}
|
|
@@ -1901,7 +1924,16 @@ function createSideEngine(ctx, configOf) {
|
|
|
1901
1924
|
|
|
1902
1925
|
/** Detach this run's stream listener (frames are per attempt). */
|
|
1903
1926
|
let detach = () => {}
|
|
1904
|
-
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
|
+
}
|
|
1905
1937
|
runs.set(id, runRecord)
|
|
1906
1938
|
|
|
1907
1939
|
try {
|
|
@@ -2123,10 +2155,28 @@ function createSideEngine(ctx, configOf) {
|
|
|
2123
2155
|
}
|
|
2124
2156
|
}
|
|
2125
2157
|
|
|
2126
|
-
/**
|
|
2127
|
-
|
|
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) {
|
|
2128
2172
|
const record = runs.get(id)
|
|
2129
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
|
+
}
|
|
2130
2180
|
record.abort()
|
|
2131
2181
|
return true
|
|
2132
2182
|
}
|
|
@@ -2603,7 +2653,12 @@ export function apply(ctx, patchConfig) {
|
|
|
2603
2653
|
// 只回本次取消的结果:`runs` 是跨会话的全局表,把它的 id 列表回给
|
|
2604
2654
|
// 任意可信页面等于泄露别的会话正在追问的卡片 id(而 cancel 本身也不
|
|
2605
2655
|
// 校验归属),Client 侧也没有任何地方消费这个字段。
|
|
2606
|
-
|
|
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) })
|
|
2607
2662
|
}
|
|
2608
2663
|
|
|
2609
2664
|
/** `/config` — merge a partial config into the user layer and persist it. */
|