dsh-sidecard-ask 2.1.5 → 2.1.7
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 +80 -0
- package/client.js +310 -40
- package/index.js +21 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.7
|
|
4
|
+
|
|
5
|
+
**主题:修复「技术方案评审团」在 0.2.1-rc.5(桌面 Electron 44.7.0 / 网页端同源)上复核出的 8 条缺陷。**
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **A1(中)页面重载后右侧栏留下关不掉的「幽灵面板」**。宿主会持久化栏布局,重载
|
|
10
|
+
后把插件的 tab 还原出来;而「哪张卡拥有它」(`surfaces.native.cardId`)是进程内存,
|
|
11
|
+
卡片本身也不持久化。旧归属门是「`cardId !== card.id` 就拒绝」,于是没有卡片认领、
|
|
12
|
+
也永远关不掉,面板长期渲染「该卡片已关闭」。现在归属门改为**已知别的卡拥有才拒绝,
|
|
13
|
+
无主时按 kind + 会话定位**;并在注册完成时、以及 900 ms 后各清扫一次无主的同 kind
|
|
14
|
+
tab(绝不碰其它插件的 kinds)。
|
|
15
|
+
- **A2(中)删除历史条目时孤儿 tab 不关**。`deleteRailTab` 与 `surfaces.better.close`
|
|
16
|
+
在 `cardId === null` 时直接 return,删除后栏里留一块空面板。改为无主放行后,删除
|
|
17
|
+
路径能真正清掉那张 tab。
|
|
18
|
+
- **A4(低)承载面标签标错宿主**。`CardHost` 过去读全局 `state.surface`;原生栏与
|
|
19
|
+
better 镜像同时存在时,better 栏里的卡会被标成「原生右栏」。两个宿主槽现在都显式
|
|
20
|
+
传自己的 `surface`,标签优先采用它。
|
|
21
|
+
- **A8(低)设置页文案仍写死 `0.2.1-rc.3`**。只读开关与 preset 提示改为「0.2.x 宿主
|
|
22
|
+
均不支持(`0.2.1-rc.3` 实测、`rc.5` 复核)」。
|
|
23
|
+
- **CANCEL(中)`/cancel` 接受不带 sessionId 的调用**。run 有归属会话时,旧逻辑只在
|
|
24
|
+
「两边都非空且不等」才拒绝,于是不带 sessionId 的调用能中止任意会话的子代理。现在
|
|
25
|
+
只要 run 知道归属,就要求调用方给出**相等**的 sessionId,否则拒绝;老客户端一直带
|
|
26
|
+
sessionId,不受影响。
|
|
27
|
+
- **AUTH-EDGE(低)`requestRejection` 的意外返回值被当成放行**。旧实现把任何非
|
|
28
|
+
401/403 的值都当通过。现在只有 `undefined / null / false / 200` 算放行,其余按未
|
|
29
|
+
认证(403)处理并留一条 warn;`connection` 服务缺失时的日志由 info 改为 warn
|
|
30
|
+
(网页端就是这个形态,等于「没有宿主级认证」,不该静音)。
|
|
31
|
+
- **MULTITAB(低)跨页历史被「最后写者」复活**。桌面窗口与浏览器各持一份 store、每次
|
|
32
|
+
写整个数组:A 删掉一条后,B 的下一次同步会把它写回来。现在监听 `storage` 事件,
|
|
33
|
+
别的页面写盘后立即采用磁盘上的权威集合。
|
|
34
|
+
- **承载面未证实的空面板(低)**。卡被判定「承载面未证实」迁回浮层时,栏里那张 tab
|
|
35
|
+
没有关闭,留下「已移到浮层」的空面板。迁移时同时请求两个宿主关掉自己的 tab。
|
|
36
|
+
|
|
37
|
+
### 评估后不改
|
|
38
|
+
|
|
39
|
+
- **run 没有归属会话时 `/cancel` 仍允许取消**。这类 run(宿主没给 sessionId、脚本
|
|
40
|
+
直调)若一并拒绝,就只能等 120 s 超时才结束;归属会话已知时已经收紧,故保留。
|
|
41
|
+
|
|
42
|
+
### 验证
|
|
43
|
+
|
|
44
|
+
`verify 57 / contract 1487 / float-placement 100 / smoke 283 = 1927 全绿`。本轮净增
|
|
45
|
+
16 条断言:无主 tab 在注册时即被清扫且不碰其它 kind、删除历史条目时无主 tab 仍被
|
|
46
|
+
关闭、承载面标签跟随宿主而非全局偏好、`/cancel` 拒绝错会话与缺会话。
|
|
47
|
+
|
|
48
|
+
## 2.1.6
|
|
49
|
+
|
|
50
|
+
**主题:修复桌面端 0.2.1-rc.3 / 网页端 0.2.1-rc.4 与 0.2.2-alpha.2 上的 4 个承载面与会话归属缺陷。**
|
|
51
|
+
|
|
52
|
+
### 修复
|
|
53
|
+
|
|
54
|
+
- **S1(高)默认 better 承载面开第二张卡时第一张消失**。better-sidebar 0.28.2 把每个
|
|
55
|
+
描述符镜像到 DSH 原生右栏,而该 tab 类型没有 `multiple`:同一 kind、同一会话只有
|
|
56
|
+
一个 page 地址,第二次 `openTab` 会把这个地址导航到新卡。插件过去只在 native
|
|
57
|
+
适配器做「迁回浮层」,better 适配器没有,于是第一张卡既不在栏里也不在浮层上,
|
|
58
|
+
流式输出继续写进看不见的卡片。现在 `surfaces.better.open()` 与 native 一样,先把
|
|
59
|
+
同会话其它 better 卡片迁回浮层并置 `open:true`。覆盖原生右栏为唯一地址的 0.28.x。
|
|
60
|
+
- **S2(高)关闭/删除卡片不会关闭 better 承载面的 tab**。0.28.x 的原生 tab id 由宿主
|
|
61
|
+
mint(`tab1`…),插件传入的 seed id 被忽略,`closeTab(seed.id)` 是严格 no-op。
|
|
62
|
+
`CardHost` 现在从宿主传入的 `props.tab.id` 记录真实 tab id,关闭/删除用它;未挂载
|
|
63
|
+
过的卡片(刷新后恢复的栏 tab)按 kind + 会话在 `sidebarRight.openTabs` 清单里定位。
|
|
64
|
+
`deleteHistoryCard` / `clearHistory` 也会释放没有活跃卡片的 better tab。
|
|
65
|
+
- **S3(中)显式 `native-rightbar` 下关闭/删除失效(2.1.5 R2 回归)**。宿主的 page tab
|
|
66
|
+
把 `contentId` 固定写成 `sidebar://<kind>`,不是卡片 id;R2 用
|
|
67
|
+
`entry.contentId === card.id` 判归属,匹配恒失败,关闭流程永远返回 false。现在
|
|
68
|
+
归属只看适配器持有的 `surfaces.native.cardId`,定位按 kind + 会话;`releaseNativeTab`
|
|
69
|
+
与删除路径随之恢复生效。测试替身也改为真实 `contentId` 语义。
|
|
70
|
+
- **S4(中)切会话后停止/改投用错会话**。卡片过去不记录发起会话;用户在会话 A 追问、
|
|
71
|
+
切到 B 后点停止,`/cancel` 带的是 B,宿主 R6 归属校验按「没找到」处理,子代理继续
|
|
72
|
+
跑到超时;「改到主对话」也会投进 B。卡片现在在创建时记录 `sessionId`,停止、关闭、
|
|
73
|
+
删除、追问、重试、改投全部使用卡片自己的会话,并随 v3 历史持久化(旧条目无该字段
|
|
74
|
+
时回退当前会话)。
|
|
75
|
+
|
|
76
|
+
### 验证
|
|
77
|
+
|
|
78
|
+
`verify 57 / contract 1475 / float-placement 100 / smoke 279 = 1911 全绿`。净增 21 条
|
|
79
|
+
回归断言:better 第二张卡迁移、真实宿主 tab id 关闭、非所有者不得替他人关 tab、
|
|
80
|
+
未挂载 tab 的删除释放、原生 `contentId=sidebar://kind` 下的关闭与删除、切会话后
|
|
81
|
+
`/cancel` 仍带原会话、v3 条目持久化与重放 `sessionId`。
|
|
82
|
+
|
|
3
83
|
## 2.1.5
|
|
4
84
|
|
|
5
85
|
**主题:修完「技术方案评审团」在 0.2.1-rc.3(桌面 Electron 44.7.0 / 网页端同源)上复核出的 8 条缺陷。**
|
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.7'
|
|
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: '只读(需宿主支持,0.2.
|
|
710
|
+
setSideToolsReadonly: '只读(需宿主支持,0.2.x 无效)',
|
|
711
711
|
setSideToolsInherit: '继承当前会话',
|
|
712
|
-
setSideToolsHint: '这个开关只在宿主支持工具白名单时才生效;0.2.1-rc.3
|
|
712
|
+
setSideToolsHint: '这个开关只在宿主支持工具白名单时才生效;0.2.x 宿主(0.2.1-rc.3 实测、rc.5 复核)都不支持,侧边作答会继承当前会话的全部工具。要真正收窄,请填写下面的 preset(0.2.1 起支持)。',
|
|
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 (needs host support; no-op on 0.2.
|
|
985
|
+
setSideToolsReadonly: 'Read-only (needs host support; no-op on 0.2.x)',
|
|
986
986
|
setSideToolsInherit: 'Inherit from the session',
|
|
987
|
-
setSideToolsHint: 'This switch only works when the host supports a tool allow-list; 0.2.1-rc.3
|
|
987
|
+
setSideToolsHint: 'This switch only works when the host supports a tool allow-list; no 0.2.x host does (verified on 0.2.1-rc.3 and re-checked on rc.5), so the side answerer inherits every tool of the session. To really narrow it, set the preset below (supported since 0.2.1).',
|
|
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
|
|
@@ -2347,10 +2364,20 @@ window.__ModuleLoader__.load({
|
|
|
2347
2364
|
// and lets CardHost show its closed-card face.
|
|
2348
2365
|
const controller = this.controller
|
|
2349
2366
|
if (controller === null || controller === undefined) return false
|
|
2350
|
-
//
|
|
2351
|
-
//
|
|
2352
|
-
//
|
|
2367
|
+
// 归属校验:只有"本卡就是适配器最后打开的那张"才能关。宿主的 page
|
|
2368
|
+
// tab 把 contentId 固定写成 `sidebar://<kind>`(不是卡片 id),所以
|
|
2369
|
+
// 过去用 `contentId === card.id` 判归属永远匹配失败、关闭恒失效
|
|
2370
|
+
// (2.1.5 R2 的回归)。kind 单地址模型下 `cardId` 才是唯一归属信号。
|
|
2371
|
+
// 严格归属(只认 `cardId === card.id`)治好了 2.1.5 R2 的"串卡",
|
|
2372
|
+
// 却把另一种真实场景关在门外:页面重载后宿主会从布局里恢复出这张
|
|
2373
|
+
// tab,而 `cardId` 是内存态、必然是 null —— 此时没有卡片认领它,
|
|
2374
|
+
// 删除历史条目也永远关不掉,就是专家复现的"幽灵面板"。
|
|
2375
|
+
// 规则改为:**已知别的卡拥有它才拒绝**;无主时按 kind + 会话定位。
|
|
2353
2376
|
if (this.cardId !== null && this.cardId !== card.id) return false
|
|
2377
|
+
// 卡片自己的会话优先:用户切走会话后关闭 A 的 tab,仍应落在 A。
|
|
2378
|
+
const ownerSession = typeof card.sessionId === 'string' && card.sessionId !== ''
|
|
2379
|
+
? card.sessionId
|
|
2380
|
+
: store.state.sessionId
|
|
2354
2381
|
let rows = []
|
|
2355
2382
|
try {
|
|
2356
2383
|
rows = typeof controller.openTabs?.getSnapshot === 'function'
|
|
@@ -2360,18 +2387,11 @@ window.__ModuleLoader__.load({
|
|
|
2360
2387
|
const row = Array.isArray(rows)
|
|
2361
2388
|
? rows.find(entry => entry?.kind === CARD_KIND
|
|
2362
2389
|
&& typeof entry?.tabId === 'string' && entry.tabId !== ''
|
|
2363
|
-
//
|
|
2364
|
-
//
|
|
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))
|
|
2390
|
+
// 该 kind 只有一个 page 地址(不是 multiple),所以 kind +
|
|
2391
|
+
// 会话就唯一定位本卡那张 tab;归属由上面的 adapter.cardId 保证。
|
|
2372
2392
|
&& (typeof entry?.sessionId !== 'string'
|
|
2373
|
-
||
|
|
2374
|
-
|| entry.sessionId ===
|
|
2393
|
+
|| ownerSession === null
|
|
2394
|
+
|| entry.sessionId === ownerSession))
|
|
2375
2395
|
: undefined
|
|
2376
2396
|
if (row === undefined) return false
|
|
2377
2397
|
try {
|
|
@@ -2397,6 +2417,14 @@ window.__ModuleLoader__.load({
|
|
|
2397
2417
|
error: null,
|
|
2398
2418
|
dispose: null,
|
|
2399
2419
|
features: [],
|
|
2420
|
+
/**
|
|
2421
|
+
* The card whose open last took the single better tab.
|
|
2422
|
+
*
|
|
2423
|
+
* 0.28.x hosts every better tab in the native rail under one page
|
|
2424
|
+
* address per kind, so only the newest open owns it. Closing a card
|
|
2425
|
+
* that is not this owner must not close someone else's tab.
|
|
2426
|
+
*/
|
|
2427
|
+
cardId: null,
|
|
2400
2428
|
/**
|
|
2401
2429
|
* Why this adapter is (not) usable, in the settings page's own words.
|
|
2402
2430
|
* `null` means "usable"; a string explains the refusal.
|
|
@@ -2421,20 +2449,58 @@ window.__ModuleLoader__.load({
|
|
|
2421
2449
|
},
|
|
2422
2450
|
open(card) {
|
|
2423
2451
|
if (!this.available() || typeof this.service.openTab !== 'function') return false
|
|
2452
|
+
// 0.28.x 把 better 的每个描述符镜像成原生右栏类型,而该类型没有
|
|
2453
|
+
// `multiple`:同一 kind 在一个会话里只有一个 page 地址。再开一张卡
|
|
2454
|
+
// 会把这个地址导航到新卡,上一张既不在栏里也没有回到浮层 —— 和
|
|
2455
|
+
// native 适配器 2.1.5 R1 修的是同一个缺陷。这里同样先把同会话的
|
|
2456
|
+
// 其它 better 卡片迁回浮层。
|
|
2457
|
+
const targetSession = card.sessionId ?? store.state.sessionId ?? null
|
|
2458
|
+
for (const other of store.state.cards) {
|
|
2459
|
+
if (other.id === card.id || other.surface !== 'better-sidebar') continue
|
|
2460
|
+
const otherSession = typeof other.sessionId === 'string' && other.sessionId !== ''
|
|
2461
|
+
? other.sessionId
|
|
2462
|
+
: null
|
|
2463
|
+
if (targetSession !== null && otherSession !== null && otherSession !== targetSession) continue
|
|
2464
|
+
patchCard(other.id, { surface: 'flow', open: true })
|
|
2465
|
+
}
|
|
2424
2466
|
const tabId = `${CARD_KIND_BETTER}:${card.id}`
|
|
2425
2467
|
// `meta` is the transport for the card id (feature `tabMeta`), and
|
|
2426
2468
|
// `dedupeKey` on our descriptor is what collapses repeat opens onto
|
|
2427
|
-
// the same tab; both are part of the stable consumer contract
|
|
2428
|
-
// (`lib/types/client/service.d.ts`, unchanged across 0.22.0 → 0.22.1).
|
|
2469
|
+
// the same tab; both are part of the stable consumer contract.
|
|
2429
2470
|
this.service.openTab(
|
|
2430
2471
|
{ type: CARD_KIND_BETTER, id: tabId, title: card.question.slice(0, 32), meta: { cardId: card.id } },
|
|
2431
|
-
|
|
2472
|
+
typeof targetSession === 'string' && targetSession !== '' ? { sessionId: targetSession } : undefined,
|
|
2432
2473
|
)
|
|
2474
|
+
this.cardId = card.id
|
|
2433
2475
|
return true
|
|
2434
2476
|
},
|
|
2435
2477
|
close(card) {
|
|
2436
2478
|
if (!this.available() || typeof this.service.closeTab !== 'function') return false
|
|
2437
|
-
|
|
2479
|
+
// 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab。
|
|
2480
|
+
// 同 native.close:无主(cardId 为 null,页面重载后)也要放行,
|
|
2481
|
+
// 否则在历史管理器里删掉一条 better 卡,栏里那张 tab 永远留空。
|
|
2482
|
+
if (this.cardId !== null && this.cardId !== card.id) return false
|
|
2483
|
+
const sessionId = typeof card.sessionId === 'string' && card.sessionId !== ''
|
|
2484
|
+
? card.sessionId
|
|
2485
|
+
: (store.state.sessionId ?? undefined)
|
|
2486
|
+
const scope = typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : undefined
|
|
2487
|
+
// 0.28.x 的原生 tab id 由宿主 mint(tab1…),不是我们传入的 seed id;
|
|
2488
|
+
// CardHost 挂载时把真实 id 记进 embeddedTabIds。
|
|
2489
|
+
const mountedId = embeddedTabIds.get(card.id)
|
|
2490
|
+
if (typeof mountedId === 'string' && mountedId !== '') {
|
|
2491
|
+
this.service.closeTab(mountedId, scope)
|
|
2492
|
+
this.cardId = null
|
|
2493
|
+
return true
|
|
2494
|
+
}
|
|
2495
|
+
// 卡片还没挂载过(或已从 store 移除):按 kind + 会话在栏的清单里
|
|
2496
|
+
// 找到唯一那张 tab,经原生控制器关闭。
|
|
2497
|
+
if (closeRailRow(CARD_KIND_BETTER, sessionId)) {
|
|
2498
|
+
this.cardId = null
|
|
2499
|
+
return true
|
|
2500
|
+
}
|
|
2501
|
+
// 0.19 之前的 bottom workbench:seed id 就是 tab id。
|
|
2502
|
+
this.service.closeTab(`${CARD_KIND_BETTER}:${card.id}`, scope)
|
|
2503
|
+
this.cardId = null
|
|
2438
2504
|
return true
|
|
2439
2505
|
},
|
|
2440
2506
|
},
|
|
@@ -2520,6 +2586,17 @@ window.__ModuleLoader__.load({
|
|
|
2520
2586
|
*/
|
|
2521
2587
|
let renderedCardId = null
|
|
2522
2588
|
|
|
2589
|
+
/**
|
|
2590
|
+
* Host tab ids for cards mounted by an external embedder, keyed by card id.
|
|
2591
|
+
*
|
|
2592
|
+
* better-sidebar's descriptor component receives the host `SidebarTab`, whose
|
|
2593
|
+
* `id` is the tab id the native rail minted (`tab1`, …) — NOT the internal
|
|
2594
|
+
* `sidecard-ask-better:<cardId>` seed id. Since 0.28.x every better-sidebar
|
|
2595
|
+
* open lands in DSH's native rail, so `closeTab(seed.id)` is a strict no-op.
|
|
2596
|
+
* The body records the real id here and `surfaces.better.close` uses it.
|
|
2597
|
+
*/
|
|
2598
|
+
const embeddedTabIds = new Map()
|
|
2599
|
+
|
|
2523
2600
|
/**
|
|
2524
2601
|
* Pick the surface for a new card.
|
|
2525
2602
|
* @param {string} wanted - `auto` | adapter kind.
|
|
@@ -5980,6 +6057,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5980
6057
|
truncated: card.truncated === true,
|
|
5981
6058
|
droppedChars: Number(card.droppedChars ?? 0),
|
|
5982
6059
|
createdAt: Number.isFinite(card.createdAt) ? card.createdAt : Date.now(),
|
|
6060
|
+
// 卡片归属的会话,随历史一起持久化;旧条目没有该字段时按 null 读。
|
|
6061
|
+
sessionId: typeof card.sessionId === 'string' && card.sessionId !== '' ? card.sessionId : null,
|
|
5983
6062
|
provider: typeof card.provider === 'string' ? card.provider : '',
|
|
5984
6063
|
elapsedMs: Number.isFinite(card.elapsedMs) ? card.elapsedMs : null,
|
|
5985
6064
|
// 持久化的历史轮次与上下文预算同一个上限(HISTORY_TURNS):
|
|
@@ -6073,6 +6152,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6073
6152
|
provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
|
|
6074
6153
|
elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
|
|
6075
6154
|
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
|
|
6155
|
+
// 刷新后仍要能把追问/改投送回原来的会话;旧条目没有该字段。
|
|
6156
|
+
sessionId: typeof entry.sessionId === 'string' && entry.sessionId !== '' ? entry.sessionId : null,
|
|
6076
6157
|
// P2-5b: replayed versions stay switchable after a reload.
|
|
6077
6158
|
versions: replayVersions(entry.versions),
|
|
6078
6159
|
viewingVersion: null,
|
|
@@ -6123,6 +6204,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6123
6204
|
provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
|
|
6124
6205
|
elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
|
|
6125
6206
|
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
|
|
6207
|
+
// 旧历史没有这个字段:留 null,调用方回退到当前会话。
|
|
6208
|
+
sessionId: typeof entry.sessionId === 'string' && entry.sessionId !== '' ? entry.sessionId : null,
|
|
6126
6209
|
notified: true,
|
|
6127
6210
|
versions: replayVersions(entry.versions),
|
|
6128
6211
|
viewingVersion: null,
|
|
@@ -6136,6 +6219,82 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6136
6219
|
: [],
|
|
6137
6220
|
})
|
|
6138
6221
|
|
|
6222
|
+
/**
|
|
6223
|
+
* Close the one rail tab a kind owns in one session.
|
|
6224
|
+
*
|
|
6225
|
+
* The rail keeps one page tab per kind (no `multiple`), and its
|
|
6226
|
+
* `contentId` is `sidebar://<kind>` rather than any card id. Both
|
|
6227
|
+
* external surfaces therefore locate a tab by kind + session, never by
|
|
6228
|
+
* the id a body was opened with.
|
|
6229
|
+
* @param {string} kind - the tab kind registered with the rail.
|
|
6230
|
+
* @param {string|null|undefined} sessionId - the owning session, when known.
|
|
6231
|
+
* @returns {boolean} whether a tab was found and the rail accepted the close.
|
|
6232
|
+
*/
|
|
6233
|
+
function closeRailRow(kind, sessionId) {
|
|
6234
|
+
const controller = surfaces.native === null ? null : surfaces.native.controller
|
|
6235
|
+
if (controller === null || controller === undefined) return false
|
|
6236
|
+
let rows = []
|
|
6237
|
+
try {
|
|
6238
|
+
rows = typeof controller.openTabs?.getSnapshot === 'function'
|
|
6239
|
+
? controller.openTabs.getSnapshot()
|
|
6240
|
+
: []
|
|
6241
|
+
} catch { return false }
|
|
6242
|
+
if (!Array.isArray(rows)) return false
|
|
6243
|
+
const candidates = rows.filter(entry => entry?.kind === kind
|
|
6244
|
+
&& typeof entry?.tabId === 'string' && entry.tabId !== '')
|
|
6245
|
+
// 会话已知时按会话匹配;会话未知(旧历史没有 sessionId)时只有
|
|
6246
|
+
// 全进程恰好一张该 kind 的 tab 才敢关,否则可能误关别的会话。
|
|
6247
|
+
const row = typeof sessionId === 'string' && sessionId !== ''
|
|
6248
|
+
? candidates.find(entry => typeof entry?.sessionId !== 'string'
|
|
6249
|
+
|| entry.sessionId === sessionId)
|
|
6250
|
+
: (candidates.length === 1 ? candidates[0] : undefined)
|
|
6251
|
+
if (row === undefined) return false
|
|
6252
|
+
try {
|
|
6253
|
+
if (typeof controller.closeIn === 'function' && typeof row.sessionId === 'string') {
|
|
6254
|
+
controller.closeIn(row.sessionId, row.tabId)
|
|
6255
|
+
} else if (typeof controller.close === 'function') {
|
|
6256
|
+
controller.close(row.tabId)
|
|
6257
|
+
} else {
|
|
6258
|
+
return false
|
|
6259
|
+
}
|
|
6260
|
+
return true
|
|
6261
|
+
} catch {
|
|
6262
|
+
return false
|
|
6263
|
+
}
|
|
6264
|
+
}
|
|
6265
|
+
|
|
6266
|
+
/**
|
|
6267
|
+
* Release a better-sidebar tab that no longer has a live card body.
|
|
6268
|
+
*
|
|
6269
|
+
* The descriptor component records the host's real tab id on mount;
|
|
6270
|
+
* without that (a tab restored from a previous page load, or a delete
|
|
6271
|
+
* before the body mounted) the rail inventory locates it by kind.
|
|
6272
|
+
* @param {string} id - the card id whose tab should go away.
|
|
6273
|
+
* @param {string|null|undefined} sessionId - the card's own session.
|
|
6274
|
+
*/
|
|
6275
|
+
function releaseBetterTab(id, sessionId) {
|
|
6276
|
+
const better = surfaces.better
|
|
6277
|
+
if (better === null || better === undefined) return
|
|
6278
|
+
if (better.service === null || typeof better.service?.closeTab !== 'function') return
|
|
6279
|
+
// 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab;释放一张
|
|
6280
|
+
// 不是当前所有者的历史条目,会把现在真正在栏里的卡片连带关掉。
|
|
6281
|
+
// 无主(null)时也放行:理由见 better.close。
|
|
6282
|
+
if (better.cardId !== null && better.cardId !== id) return
|
|
6283
|
+
const scope = typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : undefined
|
|
6284
|
+
const mountedId = embeddedTabIds.get(id)
|
|
6285
|
+
if (typeof mountedId === 'string' && mountedId !== '') {
|
|
6286
|
+
try { better.service.closeTab(mountedId, scope) } catch { /* the host may already be gone */ }
|
|
6287
|
+
better.cardId = null
|
|
6288
|
+
return
|
|
6289
|
+
}
|
|
6290
|
+
if (closeRailRow(CARD_KIND_BETTER, sessionId)) {
|
|
6291
|
+
better.cardId = null
|
|
6292
|
+
return
|
|
6293
|
+
}
|
|
6294
|
+
try { better.service.closeTab(`${CARD_KIND_BETTER}:${id}`, scope) } catch { /* legacy no-op */ }
|
|
6295
|
+
better.cardId = null
|
|
6296
|
+
}
|
|
6297
|
+
|
|
6139
6298
|
/**
|
|
6140
6299
|
* Close the right-rail tab when it is showing this card.
|
|
6141
6300
|
*
|
|
@@ -6145,17 +6304,50 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6145
6304
|
* no longer exists, until the user closed it by hand. Only the tab this
|
|
6146
6305
|
* adapter actually opened is touched; `close` re-checks ownership.
|
|
6147
6306
|
* @param {string} id - the card id that is going away.
|
|
6307
|
+
* @param {string|null|undefined} sessionId - the card's own session.
|
|
6148
6308
|
*/
|
|
6149
|
-
function releaseNativeTab(id) {
|
|
6309
|
+
function releaseNativeTab(id, sessionId) {
|
|
6150
6310
|
const native = surfaces.native
|
|
6151
6311
|
if (native === null || native === undefined) return
|
|
6152
6312
|
if (native.controller === null || native.controller === undefined) return
|
|
6153
|
-
|
|
6313
|
+
// 无主(null)时也放行:理由见 native.close。
|
|
6314
|
+
if (native.cardId !== null && native.cardId !== id) return
|
|
6154
6315
|
try {
|
|
6155
|
-
native.close({ id })
|
|
6316
|
+
native.close({ id, sessionId })
|
|
6156
6317
|
} catch { /* the rail owns its tab's lifetime */ }
|
|
6157
6318
|
}
|
|
6158
6319
|
|
|
6320
|
+
/**
|
|
6321
|
+
* Close rail tabs that no live card can claim.
|
|
6322
|
+
*
|
|
6323
|
+
* The host persists the rail layout, so after a page reload our tab is
|
|
6324
|
+
* restored — but "which card owns it" (`surfaces.native.cardId` /
|
|
6325
|
+
* `surfaces.better.cardId`) is process memory, and cards themselves are
|
|
6326
|
+
* not persisted. The result is a panel that renders "card closed" and
|
|
6327
|
+
* that the old build could not even close (expert A1). When no card of
|
|
6328
|
+
* this page owns the address, the tab is provably orphaned: close it.
|
|
6329
|
+
*/
|
|
6330
|
+
function sweepOrphanRailTabs() {
|
|
6331
|
+
const native = surfaces.native
|
|
6332
|
+
if (native !== null && native !== undefined && native.cardId === null
|
|
6333
|
+
&& native.controller !== null && native.controller !== undefined) {
|
|
6334
|
+
let rows = []
|
|
6335
|
+
try {
|
|
6336
|
+
rows = typeof native.controller.openTabs?.getSnapshot === 'function'
|
|
6337
|
+
? native.controller.openTabs.getSnapshot()
|
|
6338
|
+
: []
|
|
6339
|
+
} catch { rows = [] }
|
|
6340
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
6341
|
+
if (row?.kind !== CARD_KIND) continue
|
|
6342
|
+
if (typeof row.sessionId !== 'string' || typeof row.tabId !== 'string') continue
|
|
6343
|
+
try { native.controller.closeIn(row.sessionId, row.tabId) } catch { /* rail owns it */ }
|
|
6344
|
+
}
|
|
6345
|
+
}
|
|
6346
|
+
// better 栏刻意不在这里扫:插件的 tab 只有在宿主真的 mount 它时才
|
|
6347
|
+
// 存在,而 mount 是“当前页面有卡片”的证明;多窗口下另一个窗口的
|
|
6348
|
+
// 活跃卡片与本页共享同一个 session,盲扫会替它关掉 tab。
|
|
6349
|
+
}
|
|
6350
|
+
|
|
6159
6351
|
const actions = {
|
|
6160
6352
|
openPopover(candidate) {
|
|
6161
6353
|
// P2-2: the composer must be remembered BEFORE the popover's
|
|
@@ -6191,12 +6383,21 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6191
6383
|
},
|
|
6192
6384
|
/** Delete one entry from durable history (and its live card). */
|
|
6193
6385
|
deleteHistoryCard(id) {
|
|
6386
|
+
const entry = store.state.history.find(item => item.id === id)
|
|
6194
6387
|
const next = store.state.history
|
|
6195
6388
|
.filter(item => item.id !== id)
|
|
6196
6389
|
.slice(0, historyCap())
|
|
6197
6390
|
// A deleted entry must not keep a right-rail tab alive: the tab is
|
|
6198
6391
|
// not part of the store, so nothing else would ever close it.
|
|
6199
|
-
|
|
6392
|
+
// The live card (when the manager is deleting one) carries the
|
|
6393
|
+
// session the entry itself may predate.
|
|
6394
|
+
const live = entry === undefined
|
|
6395
|
+
? store.state.cards.find(card => card.id === id)
|
|
6396
|
+
: undefined
|
|
6397
|
+
if (entry !== undefined || live !== undefined) {
|
|
6398
|
+
releaseBetterTab(id, entry?.sessionId ?? live?.sessionId)
|
|
6399
|
+
}
|
|
6400
|
+
releaseNativeTab(id, entry?.sessionId ?? live?.sessionId)
|
|
6200
6401
|
writePersisted(next)
|
|
6201
6402
|
store.set({
|
|
6202
6403
|
history: next,
|
|
@@ -6224,7 +6425,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6224
6425
|
const controller = controllers.get(id)
|
|
6225
6426
|
if (controller !== undefined) {
|
|
6226
6427
|
controller.abort()
|
|
6227
|
-
void postJson('cancel', { id, sessionId:
|
|
6428
|
+
void postJson('cancel', { id, sessionId: cardSessionId(card, ctx) }).catch(() => {})
|
|
6228
6429
|
}
|
|
6229
6430
|
controllers.delete(id)
|
|
6230
6431
|
pendingDeltas.delete(id)
|
|
@@ -6244,7 +6445,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6244
6445
|
// A card restored from durable history has no live `surface`, so the
|
|
6245
6446
|
// branch above cannot see its tab; this checks the adapter's own
|
|
6246
6447
|
// address instead and is a no-op when nothing is on the rail.
|
|
6247
|
-
releaseNativeTab(id)
|
|
6448
|
+
releaseNativeTab(id, card.sessionId)
|
|
6248
6449
|
const next = store.state.history
|
|
6249
6450
|
.filter(item => item.id !== id)
|
|
6250
6451
|
.slice(0, historyCap())
|
|
@@ -6278,7 +6479,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6278
6479
|
const railCard = surfaces.native?.cardId
|
|
6279
6480
|
if (typeof railCard === 'string' && railCard !== ''
|
|
6280
6481
|
&& !store.state.cards.some(card => card.id === railCard)) {
|
|
6281
|
-
|
|
6482
|
+
const railSession = store.state.history.find(item => item.id === railCard)?.sessionId
|
|
6483
|
+
releaseNativeTab(railCard, railSession)
|
|
6484
|
+
}
|
|
6485
|
+
// better 承载面同理:历史条目被清空后,只为「没有活跃卡片」的
|
|
6486
|
+
// 条目释放它可能留下的栏 tab。
|
|
6487
|
+
for (const entry of store.state.history) {
|
|
6488
|
+
if (store.state.cards.some(card => card.id === entry.id)) continue
|
|
6489
|
+
releaseBetterTab(entry.id, entry.sessionId)
|
|
6282
6490
|
}
|
|
6283
6491
|
try {
|
|
6284
6492
|
for (const key of [PERSIST_KEY, PERSIST_V2_KEY, PERSIST_LEGACY_KEY]) {
|
|
@@ -6303,6 +6511,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6303
6511
|
droppedChars: cut.droppedChars,
|
|
6304
6512
|
zone: candidate.zone,
|
|
6305
6513
|
carrier: payload.carrier,
|
|
6514
|
+
// 发起会话:卡片可能活过会话切换,停止/改投/追问必须回到它。
|
|
6515
|
+
sessionId: currentSessionId(ctx),
|
|
6306
6516
|
status: 'pending',
|
|
6307
6517
|
text: '',
|
|
6308
6518
|
reasoning: '',
|
|
@@ -6330,7 +6540,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6330
6540
|
try {
|
|
6331
6541
|
const result = await askInMainConversation({
|
|
6332
6542
|
ctx,
|
|
6333
|
-
sessionId:
|
|
6543
|
+
sessionId: cardSessionId(card, ctx),
|
|
6334
6544
|
selection: candidate.text,
|
|
6335
6545
|
question: payload.question,
|
|
6336
6546
|
zone: candidate.zone,
|
|
@@ -6384,6 +6594,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6384
6594
|
if (renderedCardId === card.id) return
|
|
6385
6595
|
store.set({ surface: 'flow', surfaceNote: t('surfaceUnproven') })
|
|
6386
6596
|
patchCard(card.id, { surface: 'flow' })
|
|
6597
|
+
// 卡已经回到浮层,栏里那张 tab 就没有内容了;不关掉会留下
|
|
6598
|
+
// 一块"已移到浮层"的空面板,用户还得自己找它(专家 E)。
|
|
6599
|
+
try { surfaces.native.close(card) } catch { /* rail owns it */ }
|
|
6600
|
+
try { surfaces.better.close(card) } catch { /* host may be gone */ }
|
|
6387
6601
|
toast(t('surfaceUnproven'))
|
|
6388
6602
|
scheduleReport(0)
|
|
6389
6603
|
}, 600)
|
|
@@ -6517,7 +6731,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6517
6731
|
else {
|
|
6518
6732
|
void askInMainConversation({
|
|
6519
6733
|
ctx,
|
|
6520
|
-
sessionId:
|
|
6734
|
+
sessionId: cardSessionId(next, ctx),
|
|
6521
6735
|
selection: next.selection,
|
|
6522
6736
|
question: text,
|
|
6523
6737
|
zone: next.zone,
|
|
@@ -6585,7 +6799,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6585
6799
|
// so POST /cancel too. Fire-and-forget on purpose — the card is
|
|
6586
6800
|
// already stopped locally, and a dead host must not turn the
|
|
6587
6801
|
// Stop button into a failure.
|
|
6588
|
-
void postJson('cancel', { id: card.id, sessionId:
|
|
6802
|
+
void postJson('cancel', { id: card.id, sessionId: cardSessionId(card, ctx) }).catch(() => {})
|
|
6589
6803
|
}
|
|
6590
6804
|
patchCard(card.id, { status: 'stopped' })
|
|
6591
6805
|
},
|
|
@@ -6593,7 +6807,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6593
6807
|
try {
|
|
6594
6808
|
const result = await askInMainConversation({
|
|
6595
6809
|
ctx,
|
|
6596
|
-
sessionId:
|
|
6810
|
+
sessionId: cardSessionId(card, ctx),
|
|
6597
6811
|
selection: card.selection,
|
|
6598
6812
|
question: card.question,
|
|
6599
6813
|
zone: card.zone,
|
|
@@ -6634,7 +6848,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6634
6848
|
controller.abort()
|
|
6635
6849
|
// Same host-side release as the Stop button: closing a card
|
|
6636
6850
|
// mid-stream must not leave the child running server-side.
|
|
6637
|
-
void postJson('cancel', { id: card.id, sessionId:
|
|
6851
|
+
void postJson('cancel', { id: card.id, sessionId: cardSessionId(card, ctx) }).catch(() => {})
|
|
6638
6852
|
}
|
|
6639
6853
|
controllers.delete(card.id)
|
|
6640
6854
|
// A closed card's buffered-but-unflushed deltas belong to nobody:
|
|
@@ -6767,6 +6981,13 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6767
6981
|
const runSideCard = async (cardId) => {
|
|
6768
6982
|
const card = store.state.cards.find(item => item.id === cardId)
|
|
6769
6983
|
if (card === undefined) return
|
|
6984
|
+
// 追问/重试沿用卡片自己的会话,而不是用户此刻切到的会话。
|
|
6985
|
+
const ownerSession = typeof card.sessionId === 'string' && card.sessionId !== ''
|
|
6986
|
+
? card.sessionId
|
|
6987
|
+
: currentSessionId(ctx)
|
|
6988
|
+
if (typeof ownerSession === 'string' && ownerSession !== '' && card.sessionId !== ownerSession) {
|
|
6989
|
+
patchCard(cardId, { sessionId: ownerSession })
|
|
6990
|
+
}
|
|
6770
6991
|
const controller = new AbortController()
|
|
6771
6992
|
controllers.set(cardId, controller)
|
|
6772
6993
|
// #9: wall time from the (re)start of THIS attempt to its terminal
|
|
@@ -6788,7 +7009,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6788
7009
|
selection: card.selected,
|
|
6789
7010
|
zone: card.zone,
|
|
6790
7011
|
carrier: 'side',
|
|
6791
|
-
sessionId:
|
|
7012
|
+
sessionId: ownerSession,
|
|
6792
7013
|
history: card.history.slice(-HISTORY_TURNS).map(turn => ({
|
|
6793
7014
|
question: String(turn?.question ?? '').slice(0, HISTORY_TURN_Q_CHARS),
|
|
6794
7015
|
answer: String(turn?.answer ?? '').slice(0, HISTORY_TURN_A_CHARS),
|
|
@@ -7209,6 +7430,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7209
7430
|
if (registry === null || registry === undefined) return
|
|
7210
7431
|
surfaces.native.registry = registry
|
|
7211
7432
|
surfaces.native.controller = controller ?? null
|
|
7433
|
+
// 布局恢复出来的 tab 没有卡片认领,注册完立刻清一次。
|
|
7434
|
+
try { sweepOrphanRailTabs() } catch { /* best effort */ }
|
|
7212
7435
|
// Register the tab TYPE first, then its body/title slots — and
|
|
7213
7436
|
// roll the type back if a slot refuses to register.
|
|
7214
7437
|
//
|
|
@@ -7241,7 +7464,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7241
7464
|
slotDisposers.push(ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
|
7242
7465
|
name: 'sidebar.right.pane.tab',
|
|
7243
7466
|
key: CARD_KIND,
|
|
7244
|
-
}, props => h(CardHost, { props, actions }))))
|
|
7467
|
+
}, props => h(CardHost, { props, actions, surface: 'native-rightbar' }))))
|
|
7245
7468
|
slotDisposers.push(ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
|
7246
7469
|
name: 'sidebar.right.pane.tab.title',
|
|
7247
7470
|
key: CARD_KIND,
|
|
@@ -7317,9 +7540,12 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7317
7540
|
component: props => h(CardHost, {
|
|
7318
7541
|
props: { tab: props?.tab, sessionId: props?.scope?.sessionId },
|
|
7319
7542
|
actions,
|
|
7543
|
+
surface: 'better-sidebar',
|
|
7320
7544
|
}),
|
|
7321
7545
|
})
|
|
7322
7546
|
surfaces.better.service = service
|
|
7547
|
+
// 同 native:注册完清掉无主的 better 栏 tab。
|
|
7548
|
+
try { sweepOrphanRailTabs() } catch { /* best effort */ }
|
|
7323
7549
|
surfaces.better.dispose = typeof off === 'function' ? off : null
|
|
7324
7550
|
disposers.push(() => {
|
|
7325
7551
|
try { surfaces.better.dispose?.() } catch { /* already disposed */ }
|
|
@@ -7348,20 +7574,29 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7348
7574
|
const navigation = props?.tab?.navigation ?? props?.navigation
|
|
7349
7575
|
const fromParams = navigation?.params?.cardId
|
|
7350
7576
|
if (typeof fromParams === 'string' && fromParams !== '') return fromParams
|
|
7577
|
+
// 回退只对"宿主不给 tab 对象"的内置右栏成立(它本来就这么设计:
|
|
7578
|
+
// 导航参数不进 tab 记录)。better-sidebar 一定给 tab;它的 meta 里
|
|
7579
|
+
// 没有 cardId 时,这张 tab 不是卡片的,拿原生适配器的地址顶替会
|
|
7580
|
+
// 渲染出**另一张卡**(专家 A1-host 的跨面误伤)。
|
|
7581
|
+
if (props?.tab !== undefined && props.tab !== null) return undefined
|
|
7351
7582
|
const recorded = surfaces.native.cardId
|
|
7352
7583
|
return typeof recorded === 'string' && recorded !== '' ? recorded : undefined
|
|
7353
7584
|
}
|
|
7354
7585
|
|
|
7355
7586
|
/** Embedded card body used by both external hosts. */
|
|
7356
|
-
function CardHost({ props }) {
|
|
7587
|
+
function CardHost({ props, surface }) {
|
|
7357
7588
|
const state = useStoreState()
|
|
7358
7589
|
const cardId = hostCardIdOf(props)
|
|
7359
7590
|
const card = state.cards.find(item => item.id === cardId)
|
|
7591
|
+
// The embedder's REAL tab id (better-sidebar 0.28.x mints it and
|
|
7592
|
+
// ignores the seed id we passed), recorded for close/delete.
|
|
7593
|
+
const hostTabId = typeof props?.tab?.id === 'string' && props.tab.id !== '' ? props.tab.id : null
|
|
7360
7594
|
// The proof hook MUST run before any early return (hook order), and
|
|
7361
7595
|
// it is what lets `submit` detect a host that never rendered us.
|
|
7362
7596
|
React.useEffect(() => {
|
|
7363
7597
|
if (typeof cardId !== 'string' || cardId === '') return undefined
|
|
7364
7598
|
renderedCardId = cardId
|
|
7599
|
+
if (hostTabId !== null) embeddedTabIds.set(cardId, hostTabId)
|
|
7365
7600
|
// Mounting settles the render proof, even when this host mounted
|
|
7366
7601
|
// late (a lazy tab panel slower than the 600ms timer) — the proof
|
|
7367
7602
|
// must never fire after the card is demonstrably on screen.
|
|
@@ -7372,8 +7607,11 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7372
7607
|
}
|
|
7373
7608
|
return () => {
|
|
7374
7609
|
if (renderedCardId === cardId) renderedCardId = null
|
|
7610
|
+
if (hostTabId !== null && embeddedTabIds.get(cardId) === hostTabId) {
|
|
7611
|
+
embeddedTabIds.delete(cardId)
|
|
7612
|
+
}
|
|
7375
7613
|
}
|
|
7376
|
-
}, [cardId])
|
|
7614
|
+
}, [cardId, hostTabId])
|
|
7377
7615
|
if (card === undefined) {
|
|
7378
7616
|
return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '' },
|
|
7379
7617
|
h(Styles, null),
|
|
@@ -7394,7 +7632,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7394
7632
|
h(Styles, null),
|
|
7395
7633
|
h(MemoAnswerCard, {
|
|
7396
7634
|
card,
|
|
7397
|
-
|
|
7635
|
+
// 标签说的是"这张卡现在挂在哪个承载面",与用户选的全局
|
|
7636
|
+
// 偏好无关:两个宿主可能同时存在(原生栏 + better 镜像),
|
|
7637
|
+
// 读 state.surface 会把 better 栏里的卡标成"原生右栏"(专家 A4)。
|
|
7638
|
+
surfaceLabel: (surface ?? state.surface) === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
|
|
7398
7639
|
onCopy: element => actions.copy(card, element),
|
|
7399
7640
|
onFollowUp: text => actions.followUp(card, text),
|
|
7400
7641
|
onRetry: () => actions.retry(card),
|
|
@@ -7418,6 +7659,35 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7418
7659
|
console.warn(`[${PLUGIN_ID}] card history restore failed:`, error)
|
|
7419
7660
|
}
|
|
7420
7661
|
|
|
7662
|
+
// The adapters register asynchronously (ctx.inject callbacks), so the
|
|
7663
|
+
// sweep above may run before the rail data exists; one delayed retry
|
|
7664
|
+
// covers a layout that is restored after this line.
|
|
7665
|
+
const orphanSweepTimer = setTimeout(() => {
|
|
7666
|
+
try { sweepOrphanRailTabs() } catch { /* best effort */ }
|
|
7667
|
+
}, 900)
|
|
7668
|
+
disposers.push(() => clearTimeout(orphanSweepTimer))
|
|
7669
|
+
|
|
7670
|
+
// Cross-tab history sync. Two pages (desktop window + browser) each
|
|
7671
|
+
// hold their own store and write the WHOLE array, so the last writer
|
|
7672
|
+
// wins: A deletes an entry, B still has it in memory, B's next write
|
|
7673
|
+
// resurrects it (expert MULTITAB). Adopting the on-disk envelope
|
|
7674
|
+
// whenever another page writes makes B's next sync start from the
|
|
7675
|
+
// authoritative set instead of its stale memory.
|
|
7676
|
+
let storageListener = null
|
|
7677
|
+
try {
|
|
7678
|
+
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
7679
|
+
storageListener = event => {
|
|
7680
|
+
if (event !== null && event !== undefined && typeof event.key === 'string'
|
|
7681
|
+
&& event.key !== PERSIST_KEY) return
|
|
7682
|
+
try { store.set({ history: readPersisted() }) } catch { /* read defensively */ }
|
|
7683
|
+
}
|
|
7684
|
+
window.addEventListener('storage', storageListener)
|
|
7685
|
+
disposers.push(() => {
|
|
7686
|
+
try { window.removeEventListener('storage', storageListener) } catch { /* gone */ }
|
|
7687
|
+
})
|
|
7688
|
+
}
|
|
7689
|
+
} catch { /* a host without storage events simply has no cross-tab sync */ }
|
|
7690
|
+
|
|
7421
7691
|
// §4.5 family singleton: register this copy, (re)elect on every join
|
|
7422
7692
|
// or leave, and gate the interaction surface on the verdict.
|
|
7423
7693
|
const family = familyTable(window)
|
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.7'
|
|
49
49
|
|
|
50
50
|
/** Route prefix of the plugin's own API. */
|
|
51
51
|
export const ROUTE_PREFIX = '/sidecard-ask/api'
|
|
@@ -2172,10 +2172,12 @@ function createSideEngine(ctx, configOf) {
|
|
|
2172
2172
|
const record = runs.get(id)
|
|
2173
2173
|
if (record === undefined) return false
|
|
2174
2174
|
const owner = record.sessionId
|
|
2175
|
-
if (typeof owner === 'string' && owner !== ''
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2175
|
+
if (typeof owner === 'string' && owner !== '') {
|
|
2176
|
+
// 归属判定必须是"两边都在且相等":只要这个 run 知道自己在哪个会话,
|
|
2177
|
+
// 没带 sessionId(或带错)的调用方一律拒绝 —— 否则任何拿到 run id 的
|
|
2178
|
+
// 页面都能中止别的会话的子代理(专家 CANCEL)。老客户端一直带
|
|
2179
|
+
// sessionId,因此这条收紧不会打断既有调用方。
|
|
2180
|
+
if (typeof sessionId !== 'string' || sessionId === '' || owner !== sessionId) return false
|
|
2179
2181
|
}
|
|
2180
2182
|
record.abort()
|
|
2181
2183
|
return true
|
|
@@ -2752,15 +2754,27 @@ export function apply(ctx, patchConfig) {
|
|
|
2752
2754
|
const authRejection = req => {
|
|
2753
2755
|
const connection = connectionService()
|
|
2754
2756
|
if (connection === undefined) {
|
|
2757
|
+
// 专家 AUTH-EDGE:这是 WEB 端的正常形态(宿主没把 connection 服务
|
|
2758
|
+
// 暴露给插件),但它同时意味着"没有宿主级认证"—— 单条 info 等于把
|
|
2759
|
+
// 风险静音,改成 warn,且每个进程仍只记一次。
|
|
2755
2760
|
if (!authGateNoted) {
|
|
2756
2761
|
authGateNoted = true
|
|
2757
|
-
ctx.logger?.
|
|
2762
|
+
ctx.logger?.warn?.('[sidecard-ask] 未挂载 connection 服务:API 仅保留 loopback/Origin 围栏,无宿主级认证')
|
|
2758
2763
|
}
|
|
2759
2764
|
return undefined
|
|
2760
2765
|
}
|
|
2761
2766
|
try {
|
|
2762
2767
|
const rejection = connection.requestRejection(req)
|
|
2763
|
-
|
|
2768
|
+
if (rejection === 401 || rejection === 403) return rejection
|
|
2769
|
+
// 旧实现把"任何非 401/403 的值"都当放行,包括对象/字符串这类仲裁器
|
|
2770
|
+
// 可能用来表示"拒绝"的返回值(专家 AUTH-EDGE)。契约未公开,所以
|
|
2771
|
+
// 只在明确表示放行时放行:undefined/null/false/200;其余按未认证
|
|
2772
|
+
// 处理并留一条 warn,万一是契约误判能立刻被看见。
|
|
2773
|
+
if (rejection === undefined || rejection === null || rejection === false || rejection === 200) {
|
|
2774
|
+
return undefined
|
|
2775
|
+
}
|
|
2776
|
+
ctx.logger?.warn?.('[sidecard-ask] requestRejection 返回了意外的值,按未认证处理:', rejection)
|
|
2777
|
+
return 403
|
|
2764
2778
|
} catch (error) {
|
|
2765
2779
|
// Fail closed: an unreadable auth service must not widen the surface.
|
|
2766
2780
|
ctx.logger?.warn?.('[sidecard-ask] 鉴权校验异常,按未认证处理:', error?.message ?? error)
|