dsh-sidecard-ask 2.1.6 → 2.1.8
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 +76 -0
- package/NODE-HOST-CONTRACT.md +392 -0
- package/client.js +98 -12
- package/index.js +21 -7
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.8
|
|
4
|
+
|
|
5
|
+
**主题:交付《非 Node 宿主契约》文档,无代码改动。**
|
|
6
|
+
|
|
7
|
+
本轮没有功能与缺陷修复,发布物只多一份文档(`NODE-HOST-CONTRACT.md`,392 行),
|
|
8
|
+
内容是从本插件 2.1.7 的真实调用点反推出来的宿主契约面:需要宿主实现什么、
|
|
9
|
+
语义边界在哪、少了会怎样、如何验收。写它的直接起因是「用 Go 写一个 DSH 宿主」
|
|
10
|
+
这个需求——结论是纯 Go 宿主无法复用本插件代码,必须先把契约对齐再谈实现。
|
|
11
|
+
|
|
12
|
+
### 新增
|
|
13
|
+
|
|
14
|
+
- **`NODE-HOST-CONTRACT.md`**(已加入 `files`,随包发布)。分 14 节:
|
|
15
|
+
- 硬依赖与软依赖分层表(L1–L11):只有「运行时 + `(req,res)` 语义」与
|
|
16
|
+
「前端模块加载器 + 具名槽位」是硬的,其余全部可降级。
|
|
17
|
+
- 插件真正触碰的 `req`/`res` 成员**全量 13 个**、`ctx.*` **全量 7 个**——
|
|
18
|
+
照这张表实现即可,不必复刻 Node 的 HTTP 类。
|
|
19
|
+
- 8 个 API 方法的动词/鉴权/语义表;`ping` 是唯一免鉴权的动词。
|
|
20
|
+
- `connection.requestRejection` 的 6 种返回值语义(含 fail-closed 与
|
|
21
|
+
「服务缺失即放行」这条最容易做空的路径)。
|
|
22
|
+
- SSE 帧格式、15 s 心跳、`res.write` 必须即时刷出的理由。
|
|
23
|
+
- 两代子代理 API 形状、`delivery:'caller'` 的产品语义、工具白名单的交集规则。
|
|
24
|
+
- 路径与原子写(`$DSH_HOME` 空白算未设置、profile slug 正则、0700/0600/rename)。
|
|
25
|
+
- 前端契约:`__ModuleLoader__`、`/plugins` 与 rev、槽位回退链、右栏 DOM 标记、
|
|
26
|
+
localStorage 键与预算。
|
|
27
|
+
- **V1–V8 验收清单**(可直接 curl)与 **10 条反模式**。
|
|
28
|
+
|
|
29
|
+
### 说明
|
|
30
|
+
|
|
31
|
+
- 代码、API、行为与 2.1.7 完全一致;本版仅为把文档纳入发布物并让仓库与 npm 对齐。
|
|
32
|
+
- 文档里凡未经实测的推理都显式标注为 `[推断]`,不当作契约引用。
|
|
33
|
+
|
|
34
|
+
## 2.1.7
|
|
35
|
+
|
|
36
|
+
**主题:修复「技术方案评审团」在 0.2.1-rc.5(桌面 Electron 44.7.0 / 网页端同源)上复核出的 8 条缺陷。**
|
|
37
|
+
|
|
38
|
+
### 修复
|
|
39
|
+
|
|
40
|
+
- **A1(中)页面重载后右侧栏留下关不掉的「幽灵面板」**。宿主会持久化栏布局,重载
|
|
41
|
+
后把插件的 tab 还原出来;而「哪张卡拥有它」(`surfaces.native.cardId`)是进程内存,
|
|
42
|
+
卡片本身也不持久化。旧归属门是「`cardId !== card.id` 就拒绝」,于是没有卡片认领、
|
|
43
|
+
也永远关不掉,面板长期渲染「该卡片已关闭」。现在归属门改为**已知别的卡拥有才拒绝,
|
|
44
|
+
无主时按 kind + 会话定位**;并在注册完成时、以及 900 ms 后各清扫一次无主的同 kind
|
|
45
|
+
tab(绝不碰其它插件的 kinds)。
|
|
46
|
+
- **A2(中)删除历史条目时孤儿 tab 不关**。`deleteRailTab` 与 `surfaces.better.close`
|
|
47
|
+
在 `cardId === null` 时直接 return,删除后栏里留一块空面板。改为无主放行后,删除
|
|
48
|
+
路径能真正清掉那张 tab。
|
|
49
|
+
- **A4(低)承载面标签标错宿主**。`CardHost` 过去读全局 `state.surface`;原生栏与
|
|
50
|
+
better 镜像同时存在时,better 栏里的卡会被标成「原生右栏」。两个宿主槽现在都显式
|
|
51
|
+
传自己的 `surface`,标签优先采用它。
|
|
52
|
+
- **A8(低)设置页文案仍写死 `0.2.1-rc.3`**。只读开关与 preset 提示改为「0.2.x 宿主
|
|
53
|
+
均不支持(`0.2.1-rc.3` 实测、`rc.5` 复核)」。
|
|
54
|
+
- **CANCEL(中)`/cancel` 接受不带 sessionId 的调用**。run 有归属会话时,旧逻辑只在
|
|
55
|
+
「两边都非空且不等」才拒绝,于是不带 sessionId 的调用能中止任意会话的子代理。现在
|
|
56
|
+
只要 run 知道归属,就要求调用方给出**相等**的 sessionId,否则拒绝;老客户端一直带
|
|
57
|
+
sessionId,不受影响。
|
|
58
|
+
- **AUTH-EDGE(低)`requestRejection` 的意外返回值被当成放行**。旧实现把任何非
|
|
59
|
+
401/403 的值都当通过。现在只有 `undefined / null / false / 200` 算放行,其余按未
|
|
60
|
+
认证(403)处理并留一条 warn;`connection` 服务缺失时的日志由 info 改为 warn
|
|
61
|
+
(网页端就是这个形态,等于「没有宿主级认证」,不该静音)。
|
|
62
|
+
- **MULTITAB(低)跨页历史被「最后写者」复活**。桌面窗口与浏览器各持一份 store、每次
|
|
63
|
+
写整个数组:A 删掉一条后,B 的下一次同步会把它写回来。现在监听 `storage` 事件,
|
|
64
|
+
别的页面写盘后立即采用磁盘上的权威集合。
|
|
65
|
+
- **承载面未证实的空面板(低)**。卡被判定「承载面未证实」迁回浮层时,栏里那张 tab
|
|
66
|
+
没有关闭,留下「已移到浮层」的空面板。迁移时同时请求两个宿主关掉自己的 tab。
|
|
67
|
+
|
|
68
|
+
### 评估后不改
|
|
69
|
+
|
|
70
|
+
- **run 没有归属会话时 `/cancel` 仍允许取消**。这类 run(宿主没给 sessionId、脚本
|
|
71
|
+
直调)若一并拒绝,就只能等 120 s 超时才结束;归属会话已知时已经收紧,故保留。
|
|
72
|
+
|
|
73
|
+
### 验证
|
|
74
|
+
|
|
75
|
+
`verify 57 / contract 1487 / float-placement 100 / smoke 283 = 1927 全绿`。本轮净增
|
|
76
|
+
16 条断言:无主 tab 在注册时即被清扫且不碰其它 kind、删除历史条目时无主 tab 仍被
|
|
77
|
+
关闭、承载面标签跟随宿主而非全局偏好、`/cancel` 拒绝错会话与缺会话。
|
|
78
|
+
|
|
3
79
|
## 2.1.6
|
|
4
80
|
|
|
5
81
|
**主题:修复桌面端 0.2.1-rc.3 / 网页端 0.2.1-rc.4 与 0.2.2-alpha.2 上的 4 个承载面与会话归属缺陷。**
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# NODE-HOST-CONTRACT — 非 Node 宿主必须实现的契约面
|
|
2
|
+
|
|
3
|
+
> **这份文档是什么**:`dsh-sidecard-ask` 2.1.7 在真实运行中**实际依赖**的宿主能力清单,按层拆开,逐条给出调用点、语义、缺失后果与验收方法。
|
|
4
|
+
>
|
|
5
|
+
> **不是什么**:不是 DSH 官方规范,也不是"全部插件"的契约。它由**本插件源码的真实调用点反推**得出,凡是"宿主侧参照实现"的内容都标注了出处(rc.5 解包源码),凡是**推断**而非实测的都显式标注。想用 Go 之类非 Node 运行时写一个宿主,这份就是最小的对表依据。
|
|
6
|
+
>
|
|
7
|
+
> 标注约定:`[实测]` = 本机 rc.5 上跑过/抓过;`[源码]` = 读插件源码得到;`[对照]` = 读宿主 rc.5 源码得到;`[推断]` = 未实测的推理,不可当契约用。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## §0 结论先行
|
|
12
|
+
|
|
13
|
+
1. **这个插件不是"可选 Node"**:它 import 了 `node:fs` / `node:os` / `node:path` / `node:net` / `node:tls`,用了全局 `Buffer` 与 `process.env`,并且把 Node 的 `(req, res)` 双流对象**直接当成契约**(`req.on('data'|'end'|'error')`、`res.write` 分块刷出 SSE)。**换宿主 = 换运行时语义,不是换 HTTP 框架。**
|
|
14
|
+
2. **硬依赖只有两条链路**:①`webServer` 注册路由 + 这套 req/res 语义;②前端 `window.__ModuleLoader__` + React + 具名槽位。其余(`subagents` / `tools` / `connection` / `sidebarRightTabs` / `sidebarRight` / `betterSidebar`)**全是软依赖**:拿不到就降级,插件照跑。
|
|
15
|
+
3. **最容易被忽略的一条**:`Buffer` 与 `Buffer.byteLength` 被用于响应体长度与请求体拼接(`index.js:1478`、`index.js:1409`)。任何宿主必须提供 UTF-8 字节语义的等价物,否则 `content-length` 一算错就会挂住连接。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## §1 契约分层总表
|
|
20
|
+
|
|
21
|
+
| 层 | 名称 | 插件是否要 | 缺失后果 |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| L1 | JS 运行时 + Node 内建模块 | **硬** | 模块加载即失败,整个插件不启动 |
|
|
24
|
+
| L2 | HTTP 服务与 `(req,res)` 语义 | **硬** | 8 个 API 全部 404/挂死,后端功能全灭 |
|
|
25
|
+
| L3 | 插件上下文(生命周期/服务定位) | **硬** | 无 `inject` 就无法注册路由 |
|
|
26
|
+
| L4 | 鉴权仲裁 `connection.requestRejection` | 软 | 降级为"仅 loopback/Origin 围栏"(会 warn) |
|
|
27
|
+
| L5 | 子代理 `subagents` | 软 | 生成类功能不可用,`/state` 报 `NO_START_CAPABILITIES` |
|
|
28
|
+
| L6 | 工具注册表 `tools` | 软 | 只读工具白名单失效,子代理继承全量工具 |
|
|
29
|
+
| L7 | 文件系统(配置持久化) | 软 | 配置不落盘,每次重启回默认 |
|
|
30
|
+
| L8 | 出站 `net`/`tls`(SMTP 反馈) | 软 | 反馈只落本地日志 |
|
|
31
|
+
| L9 | 前端模块加载器 `/plugins` + `__ModuleLoader__` | 软(无则纯 API) | 浏览器里没有任何界面 |
|
|
32
|
+
| L10 | 具名槽位 `slots` | 软 | 界面无处挂载(前端空跑) |
|
|
33
|
+
| L11 | 右侧栏/第三方栏服务 | 软 | 右栏承载面不可用,退回浮层 |
|
|
34
|
+
|
|
35
|
+
**一句话取舍**:想先跑通"能生成"的最小宿主,只做 **L1+L2+L3+L5**;想跑通"有界面",还得加 **L9+L10**。
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## §2 R1 · 模块与会话身份
|
|
40
|
+
|
|
41
|
+
`[源码]`
|
|
42
|
+
|
|
43
|
+
- 插件是 **ESM**(`package.json` `"type": "module"`),入口 `index.js` 导出四个具名符号:`name`、`inject`、`PLUGIN_VERSION`,以及默认的宿主注入函数(`export function inject` 同名常量与运行时函数在 DSH 里由 loader 解析)。
|
|
44
|
+
- 硬依赖声明:**只有** `webServer`
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
export const inject = ['webServer'] // index.js:45
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
宿主必须能表达"我需要 webServer 服务"并在其可用后再调用插件入口;其余服务通过 `ctx.get()` 探测。
|
|
51
|
+
- 版本门在 `package.json`:`engines: { node: ">=20", dsh: ">=0.1.2-rc.1 <0.3.0" }`。宿主侧语义 `[对照]`:`semver.satisfies(runtimeVersion, range, { includePrerelease: true })` —— **预发布版本必须参与比较**(rc.3/rc.5 都是预发布)。自己实现门禁时若丢掉 `includePrerelease`,rc 版会被判为不满足。
|
|
52
|
+
- `PLUGIN_VERSION` 必须能被前端读到:客户端校验它等于自身 `CLIENT_VERSION`(`test/verify.mjs` 也在仓库侧校验三处一致)。
|
|
53
|
+
|
|
54
|
+
**替代要求**:Go 宿主若用嵌入式 JS 引擎,至少要提供 ESM `import` + 具名导出语义;CommonJS 转换会打断 `node:` 前缀解析与顶层 `export const`。
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## §3 R2 · 插件上下文子集(这是全部)
|
|
59
|
+
|
|
60
|
+
`[实测]` 对插件源码里 `ctx.*` 的成员访问做全量统计,**只有 7 个成员**:
|
|
61
|
+
|
|
62
|
+
| 成员 | 次数 | 语义要求 |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `ctx.logger` | 20 | 至少 `info` / `warn` 两个方法,可带多参 |
|
|
65
|
+
| `ctx.get(name)` | 11 | 按名取服务,**未注册返回 `undefined`**(不能抛) |
|
|
66
|
+
| `ctx.effect(fn)` | 4 | 调用 `fn`,其返回值为 disposer,插件卸载/重载时回调;`fn` 抛错按单个效果失败处理 |
|
|
67
|
+
| `ctx.on(event, handler)` | 2 | 事件订阅,返回 disposer |
|
|
68
|
+
| `ctx.webServer` | 2 | 见 §4 |
|
|
69
|
+
| `ctx.subagents` | 2 | 见 §8 |
|
|
70
|
+
| `ctx.tools` | 1 | 见 §9 |
|
|
71
|
+
|
|
72
|
+
前端另有 `ctx.inject` / `ctx.slots`(见 §12):
|
|
73
|
+
|
|
74
|
+
| 成员 | 语义要求 |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `ctx.inject(['a','b'], cb)` | 所列服务**全部**就绪后调用 `cb(injectedCtx)`;返回 disposer |
|
|
77
|
+
| `ctx.inject('name', cb)` | 单服务版本 |
|
|
78
|
+
| `ctx.slots.inject(name, cb)` | 槽位存在后注册,返回 disposer |
|
|
79
|
+
| `ctx.slots.register(options, component)` | 向槽位注册,返回 disposer |
|
|
80
|
+
|
|
81
|
+
`[推断]` **disposer 必须幂等且不能抛**:插件在卸载路径上会连续调用多个 disposer,其中一个抛错会掩盖后续释放(代码里到处是 `try { dispose() } catch {}`,说明开发者认为它可能抛)。
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## §4 R3 · HTTP 路由与 `(req,res)` 语义
|
|
86
|
+
|
|
87
|
+
### 4.1 注册形状
|
|
88
|
+
|
|
89
|
+
`[源码]` `index.js:2873` 起(prefix 分支在 `index.js:2876`):
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
ctx.webServer.register({ kind: 'prefix', path: '/sidecard-ask/api', handler })
|
|
93
|
+
// 若宿主拒绝 prefix,则退化为逐方法精确路由:
|
|
94
|
+
ctx.webServer.register({ kind: 'exact', path: '/sidecard-ask/api/ping', handler })
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- `kind: 'prefix'` 是首选;`register` **抛错**即触发退化路径,所以宿主若不支持 prefix,必须**同步抛错**,不能静默注册成功。
|
|
98
|
+
- `register` 必须返回 disposer(插件用 `ctx.effect` 包裹)。
|
|
99
|
+
- handler 签名 `(req, res)`,由宿主在**每个请求**上调用;同一 handler 要能并发处理多个请求(插件自身用 `await` 读体,不阻塞其它连接)。
|
|
100
|
+
|
|
101
|
+
### 4.2 插件真正触碰的 req/res 成员(全量)
|
|
102
|
+
|
|
103
|
+
`[实测]` 静态统计 + 人工核对,**就是这些,多一个都不需要**:
|
|
104
|
+
|
|
105
|
+
| 对象 | 成员 | 必须满足的语义 |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `req` | `.method` | 大写 HTTP 动词字符串 |
|
|
108
|
+
| `req` | `.url` | 至少 `pathname[+query]`;插件用 `new URL(url, 'http://dsh.internal').pathname` 解析 |
|
|
109
|
+
| `req` | `.headers` | 小写键的普通对象;取值可能是 `string` 或 `string[]`(代码按 `string` 判型) |
|
|
110
|
+
| `req` | `.on(event, cb)` | 必须支持 `'data'`(chunk 须有 `.length` 与可 `Buffer.concat` 的字节序)、`'end'`、`'error'` |
|
|
111
|
+
| `req` | `.removeListener` | 可为 `undefined` 的判断已被代码兼容(`typeof === 'function'` 才调) |
|
|
112
|
+
| `req` | `.destroy()` | 用于超限时中止读体 |
|
|
113
|
+
| `res` | `.writeHead(status, headers)` | 设置状态行与响应头 |
|
|
114
|
+
| `res` | `.write(string)` | **必须能被多次调用且每次立即刷给客户端**(SSE 靠它) |
|
|
115
|
+
| `res` | `.end([body])` | 结束响应;可无参数 |
|
|
116
|
+
| `res` | `.headersSent` | 布尔 |
|
|
117
|
+
| `res` | `.writableEnded` | 布尔 |
|
|
118
|
+
| `res` | `.destroyed` | 布尔 |
|
|
119
|
+
| `res` | `.on('close', cb)` | 只需 `'close``(`index.js:2628` 用它判断浏览器是否离开) |
|
|
120
|
+
|
|
121
|
+
**关键点**:`res.write` 的**即时刷出**是 SSE 的全部基础。任何把响应体缓存在内存里最后一次性写出的宿主,会让流式卡片"整段答案在结束时一次性出现",还可能导致心跳失效、被中间层判死。
|
|
122
|
+
|
|
123
|
+
### 4.3 请求体读取
|
|
124
|
+
|
|
125
|
+
`[源码]` `index.js:1447` `readJsonBody`:
|
|
126
|
+
|
|
127
|
+
- 流式累积 chunk,累计超过 **`1 << 20` 字节(1 MiB)** 立即 `req.destroy()` 并抛 `{code:'too-large'}` → 客户端收 400。
|
|
128
|
+
- **空体返回 `null`**(不是 `{}`,也不是错误)。
|
|
129
|
+
- 非 JSON 抛 `{code:'bad-request'}`。
|
|
130
|
+
- 不依赖 `content-type`,不依赖 `content-length`。
|
|
131
|
+
|
|
132
|
+
### 4.4 响应与错误形状
|
|
133
|
+
|
|
134
|
+
`[源码]` `writeJson`(`index.js:1413`)设置的三个头是硬要求:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
content-type: application/json; charset=utf-8
|
|
138
|
+
cache-control: no-store
|
|
139
|
+
content-length: Buffer.byteLength(body) // UTF-8 字节数,不是字符串长度
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
成功:`{"ok":true,"value":<任意>}`;失败:`{"ok":false,"error":{"code":"<token>","message":"<中文可读>"}}`(`writeError`,`index.js:1436`)。
|
|
143
|
+
|
|
144
|
+
`[实测]` 线上真实响应(未登录访问):
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{"ok":false,"error":{"code":"unauthorized","message":"需要先通过 DSH 登录(宿主会话未认证)"}}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## §5 R4 · API 方法表与鉴权闸门
|
|
153
|
+
|
|
154
|
+
### 5.1 方法表
|
|
155
|
+
|
|
156
|
+
`[源码]` `index.js:2728`:
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
const API_METHODS = ['ping', 'state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
| 方法 | 动词 | 过鉴权闸门 | 说明 |
|
|
163
|
+
|---|---|---|---|
|
|
164
|
+
| `ping` | GET / HEAD | **否**(唯一豁免) | 只回 `{version, at}`,用于探活 |
|
|
165
|
+
| `state` | GET / POST | 是 | 返回能力、SMTP 解析、provenance 等全量诊断面 |
|
|
166
|
+
| `ask` | POST | 是 | 起一次子代理并开 SSE |
|
|
167
|
+
| `cancel` | POST | 是 | 取消某次运行(带归属校验) |
|
|
168
|
+
| `config` | POST | 是 | 读写配置 |
|
|
169
|
+
| `reset` | POST | 是 | 重置 |
|
|
170
|
+
| `diagnose` | POST | 是 | 客户端诊断上报 |
|
|
171
|
+
| `feedback` | POST | 是 | SMTP 反馈 |
|
|
172
|
+
|
|
173
|
+
未在表内的方法名一律 404(**动词错误不改变 404**:方法是先于动词判定的)。
|
|
174
|
+
|
|
175
|
+
### 5.2 两道闸门,顺序不可换
|
|
176
|
+
|
|
177
|
+
`[源码]` `routeHandler`(`index.js:2786`)顺序是:**信任围栏 → 方法名校验 → ping → 鉴权 → 业务**。
|
|
178
|
+
|
|
179
|
+
**闸门 1 · 信任围栏** `isTrustedRequest(req, trustedHosts)`(`index.js:1374`):
|
|
180
|
+
|
|
181
|
+
1. 必须有 `host` 头,且能构成合法 authority,否则拒。
|
|
182
|
+
2. `trustedHosts` 里带端口 → 比 `host:port` 全等;不带端口 → 只比 hostname。
|
|
183
|
+
3. hostname 既非 loopback 又不在 `trustedHosts` → 拒。
|
|
184
|
+
- loopback 判定(`isLoopbackHostname`,`index.js:1360`):`localhost`、`[::1]`、`127.x.y.z`(四段、每段 1–3 位数字且 ≤255)。**不含 `::ffff:127.0.0.1`、不含 `0.0.0.0`**。
|
|
185
|
+
4. `sec-fetch-site: cross-site` → 拒。
|
|
186
|
+
5. 有 `origin` → 必须与 `host` 的**完整 authority** 相同(端口不同即不同源);无 `origin` → 放行。
|
|
187
|
+
|
|
188
|
+
**闸门 2 · 会话认证** `authRejection(req)`(`index.js:2754`)—— 返回值语义是这份契约里最需要精确复制的一段:
|
|
189
|
+
|
|
190
|
+
| `connection.requestRejection(req)` 返回 | 插件行为 |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `401` | 响应 401 `unauthorized` |
|
|
193
|
+
| `403` | 响应 403 `forbidden` |
|
|
194
|
+
| `undefined` / `null` / `false` / `200` | **放行** |
|
|
195
|
+
| 其它任意值(对象/字符串/…) | 按拒处理 → 403,并 warn |
|
|
196
|
+
| 抛异常 | **fail-closed** → 401 |
|
|
197
|
+
| 服务不存在(`ctx.get('connection')` 为 `undefined`) | **放行**,只记一条 warn(每个进程一次) |
|
|
198
|
+
|
|
199
|
+
`[设计]` 最后一行是"WEB 端正常形态":宿主没把 `connection` 暴露给插件时,loopback+Origin 围栏就是全部模型。**做 Go 宿主时若实现了会话却忘了暴露该服务,等于无认证**——这是本契约最容易踩空的一条。
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## §6 R5 · SSE 契约
|
|
204
|
+
|
|
205
|
+
`[源码]` `openSse`(`index.js:1503`):
|
|
206
|
+
|
|
207
|
+
响应头:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
content-type: text/event-stream; charset=utf-8
|
|
211
|
+
cache-control: no-cache, no-transform
|
|
212
|
+
connection: keep-alive
|
|
213
|
+
x-accel-buffering: no
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
帧格式(严格):
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
event: <name>\n
|
|
220
|
+
data: <JSON>\n
|
|
221
|
+
\n
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
心跳:每 **15 000 ms** 写一次注释帧 `: keep-alive\n\n`,定时器置 `unref()`(不阻止进程退出)。
|
|
225
|
+
结束语义:`send()` 在 `closed` 后静默丢弃;`end()` **幂等**且必须清掉心跳定时器;`closed` 为真当且仅当内部已关或 `res.writableEnded === true`。
|
|
226
|
+
|
|
227
|
+
**宿主侧要求**:`res.write` 必须是"写进 socket 并 flush",且客户端的半关闭要能被 `res.on('close')` 感知(否则心跳会对着已死连接写一整晚)。
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## §7 R6 · 子代理(两代 API 并存)
|
|
232
|
+
|
|
233
|
+
`[源码]` `index.js:1616` `subagentsService()` / `usesActivationApi()` / `startSubagent()`:
|
|
234
|
+
|
|
235
|
+
- 服务被认为"可启动"的条件:`typeof service.start === 'function' || typeof service.startActivation === 'function'`。
|
|
236
|
+
- **旧面(≤0.2.1-alpha.1)**:`service.start(provider, request)` → 返回 `{ id, localAgent?, result: Promise, dispose }`。
|
|
237
|
+
- **新面(0.2.1-alpha.2+)**:`service.startActivation(spec)`,`spec = { provider, label, delivery: 'caller', signal, request }`,返回 `{ childId, result, dispose }`。
|
|
238
|
+
- `request` 是同一个 `SubagentStartRequest` 形状,插件会带 `prompt`、`parent`,并按能力追加 `persona` / `toolFilter` / **`agentPreset`**。
|
|
239
|
+
- `delivery: 'caller'` 的语义是"子代理不进父会话的可见对话"——**这是产品语义,不是优化项**;宿主把它实现成默认归档进父会话,卡片就会污染主对话。
|
|
240
|
+
- 还需要 `service.list()` 返回 provider 名数组(拿不到就视为空)。
|
|
241
|
+
- 能力探测:`service.capabilities?.(provider)` 用于判断 `persona` / `toolFilter` 是否被支持;不支持时插件**如实上报** `toolFilter:'unsupported'` 而不是谎报 `'preset'`(`index.js:2004`)。
|
|
242
|
+
|
|
243
|
+
**事件契约**:
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
ctx.on('agent/assistant-stream', payload => …) // index.js:1587
|
|
247
|
+
ctx.on('session/event', (session, event) => …) // index.js:1597(event.type === 'assistant/chunk')
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`[推断]` 插件用这两种事件把子代理的输出帧桥接到 SSE;宿主若不发这两类事件,`ask` 只会返回一个空流(不会 500)。做 Go 宿主时**先实现 `startActivation` + 一条能推 chunk 的事件通道**即可让生成链路活起来。
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## §8 R7 · 工具白名单
|
|
255
|
+
|
|
256
|
+
`[源码]` `readOnlyToolFilter(ctx)`(`index.js:1337`):
|
|
257
|
+
|
|
258
|
+
- `ctx.get('tools')`;无服务或 `typeof tools.schemas !== 'function'` → 返回 `undefined`(= 不限制,子代理继承全量工具)。
|
|
259
|
+
- `tools.schemas()` 抛错 → `undefined`。
|
|
260
|
+
- 交集为空 → `undefined`(**不能**返回空数组:`tools.restrict({allow: []})` 会让宿主直接拒绝启动)。
|
|
261
|
+
- 返回 `{allow: string[]}`,**每个名字都必须是注册表认识的名字**,否则 `restrict()` 会拒绝整次启动。
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## §9 R8 · 文件系统与路径
|
|
266
|
+
|
|
267
|
+
`[源码]` `index.js:1296` 起:
|
|
268
|
+
|
|
269
|
+
- `configDir()` = `${$DSH_HOME || ~/.dsh}/sidecard-ask`;**`$DSH_HOME` 为空白算未设置**;绝不使用 `process.cwd()`。
|
|
270
|
+
- `configFilePath()` = 在 `$DSH_PROFILE` 匹配 `/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/` 时用 `config.<profile>.json`,否则退回共享的 `config.json`(profile 非法值**不得**进入路径)。
|
|
271
|
+
- 写入:`mkdirSync(dir,{recursive:true,mode:0700})` → 写 `<path>.tmp`(`mode: 0600`)→ `renameSync` 原子替换 → 失败时删临时文件并返回 `{ok:false,error}`。
|
|
272
|
+
- 读取:文件不存在(`ENOENT`)按"未配置"处理;其它错误记 warn 并按未配置处理;损坏 JSON 同。
|
|
273
|
+
|
|
274
|
+
**宿主要求**:`rename` 必须同分区原子;权限位在 Windows 上是 no-op(插件已在注释里声明这一点,不是 bug)。
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## §10 R9 · 出站 SMTP
|
|
279
|
+
|
|
280
|
+
`[源码]`:`net.connect`(明文 25 / STARTTLS 587 的裸连接)+ `tls.connect`(隐式 TLS 465,带 `servername` 与 `rejectUnauthorized`)。
|
|
281
|
+
|
|
282
|
+
`[声明]` 插件自带 SMTP 客户端在 **STARTTLS 协商阶段是明文**的(平台限制,已在 README/CHANGELOG 记为已知限制)。做 Go 宿主时可以直接忽略这一层——反馈功能是纯可选。
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## §11 R10 · 前端契约
|
|
287
|
+
|
|
288
|
+
`[源码]` 客户端是**单文件 UMD-ish 模块**,不是 ESM:
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
window.__ModuleLoader__.load({ id: 'dsh-sidecard-ask', factory(require) { const React = require('react'); … } }) // client.js:27
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### 11.1 宿主必须提供的浏览器侧能力
|
|
295
|
+
|
|
296
|
+
| 能力 | 要求 |
|
|
297
|
+
|---|---|
|
|
298
|
+
| `/plugins/<pkg>/client.js?rev=<12 hex>` | 能被 `<script>` 加载;`[对照]` rev = `sha1(mtime,ctime,size)` 取前 12 位十六进制 |
|
|
299
|
+
| `window.__ModuleLoader__` | 提供 `.load({id, factory})`,并在 `factory` 内提供 `require('react')` |
|
|
300
|
+
| React | 需可 `createElement`;插件不依赖具体版本特性 |
|
|
301
|
+
| 具名槽位 | `slots.inject(name)/(register)`,见下表 |
|
|
302
|
+
|
|
303
|
+
### 11.2 槽位与回退顺序
|
|
304
|
+
|
|
305
|
+
`[源码]` `client.js:7360` 起,逐级回退(第一个存在的即用):
|
|
306
|
+
|
|
307
|
+
| 用途 | 回退链 |
|
|
308
|
+
|---|---|
|
|
309
|
+
| 浮层 | `shell.overlay` → `conversation.input.dock` → `conversation.composer.dock` |
|
|
310
|
+
| 设置页 | `settings.section` → `settings.plugins.tab` |
|
|
311
|
+
| 会话 id 采集 | `conversation.input.right` → `conversation.input.left` → `conversation.composer.dock` → `conversation.input.dock` |
|
|
312
|
+
| 右栏卡片体 | `sidebar.right.pane.tab`(+ `.title`),key = `CARD_KIND` |
|
|
313
|
+
|
|
314
|
+
### 11.3 右栏 DOM 标记(无公开契约,靠实测)
|
|
315
|
+
|
|
316
|
+
`client.js:449` `RAIL_SELECTORS`:
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
[data-dsa-rail-host] // 插件自己的节点,最可靠
|
|
320
|
+
[data-sidebar-right-panel][data-sidebar-right-open] [data-dockkit-pane]
|
|
321
|
+
[data-sidebar-right-panel][data-sidebar-right-open]
|
|
322
|
+
[data-sidebar-right-session][data-sidebar-right-open]
|
|
323
|
+
[data-dockkit-pane] / [data-dockkit-column]
|
|
324
|
+
[data-slot^="sidebar.right"]
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**注意**:折叠态面板会滑出视口,插件另有一次"矩形是否在屏内"的判定,所以宿主不能只靠 `data-*-open`。
|
|
328
|
+
|
|
329
|
+
### 11.4 右栏服务形状
|
|
330
|
+
|
|
331
|
+
`[源码]` + `[对照]` rc.5:
|
|
332
|
+
|
|
333
|
+
```js
|
|
334
|
+
sidebarRightTabs.register({ id, kind, title: () => string, guide: [{ id, order, title, description }] }) → disposer
|
|
335
|
+
sidebarRight.openTab(kind, { params }) // [对照] 记录里 contentId = pageAddress(kind) = `sidebar://<kind>`
|
|
336
|
+
sidebarRight.openTabs.getSnapshot() → [{ id, kind, contentId, title, sessionId? }]
|
|
337
|
+
sidebarRight.closeIn(sessionId, tabId)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
`[对照]` 关键事实:**tab 记录只有 `{id, kind, contentId, title}`**,没有 `params`、没有 `meta`;插件的 `cardId` 只能靠适配器里的内存字段 + `contentId` 归属推导。第三方 tab body **拿不到 tab 对象**。
|
|
341
|
+
|
|
342
|
+
### 11.5 localStorage 键
|
|
343
|
+
|
|
344
|
+
`client.js:5905`:`dsh-sidecard-ask:cards:v3` / `:v2` / `:v1`(v3 起带 `writtenAt`,用于区分"我们写空过"与"从未写过")。预算:单字段 65 536 字符、总 2 000 000 字符、最多 10 张卡。宿主若替换存储后端,必须保留 `storage` 事件的跨窗口通知语义。
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## §12 验收清单(Go 宿主可直接照跑)
|
|
349
|
+
|
|
350
|
+
前置:宿主已加载插件,`BASE=http://127.0.0.1:<port>/sidecard-ask/api`。
|
|
351
|
+
|
|
352
|
+
| # | 动作 | 期望 |
|
|
353
|
+
|---|---|---|
|
|
354
|
+
| V1 | `curl -s $BASE/ping` | `{"ok":true,"value":{"version":"2.1.7","at":<ms>}}`,**无 Cookie 也应 200** |
|
|
355
|
+
| V2 | 未登录 `curl -s -X POST $BASE/config -d '{}'` | 401 + `error.code === "unauthorized"`(若你的宿主无会话体系,此条应 200——**这一条决定你是否真的有认证**) |
|
|
356
|
+
| V3 | `curl -s $BASE/nope` | 404 + `error.code === "not-found"`;`POST /ping` → 405 |
|
|
357
|
+
| V4 | 伪造 `Host: evil.com` 请求 | 403 `forbidden` |
|
|
358
|
+
| V5 | 带 `Origin: http://127.0.0.1:<other-port>` | 403(端口不同即不同源) |
|
|
359
|
+
| V6 | `POST $BASE/ask`(`range` 流) | 先收到响应头 `text/event-stream`,**首帧 < 1s 到达**,15s 内有 `: keep-alive`;客户端断开后宿主无残留定时器 |
|
|
360
|
+
| V7 | `POST $BASE/ask` 发 1.5 MiB body | 400 `too-large`,连接不挂死 |
|
|
361
|
+
| V8 | 浏览器打开右栏 | 插件 tab 出现在 guide 列表;打开后 `[data-dsa-rail-host]` 内出现卡片体;关 tab 后同 kind 的 tab 记录消失(`openTabs.getSnapshot()` 里不再有 `contentId === 'sidebar://<kind>'`) |
|
|
362
|
+
|
|
363
|
+
`[建议]` 把仓库里的 `test/harness.mjs`(假宿主)当作契约夹具的**参照实现**:它把本文档 §3–§8 的每一层都实现了一遍,一个 Go 宿主若能通过同样的断言序列,基本就对齐了。
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## §13 反模式清单(踩了就中招)
|
|
368
|
+
|
|
369
|
+
1. **把 `res.write` 做成"攒着最后一起发"** → SSE 变成一次性快照,卡片空白到结束。
|
|
370
|
+
2. **`register` 不支持 `prefix` 却静默成功** → 所有方法名解析不到,全 404。
|
|
371
|
+
3. **`ctx.get()` 对未注册服务抛错** → 插件在探测阶段直接崩,而它预期的是 `undefined`。
|
|
372
|
+
4. **暴露了会话体系却没给 `connection` 服务** → 插件按"无认证"放行,等于敞开。
|
|
373
|
+
5. **`openTab` 的 `contentId` 不按 `sidebar://<kind>`** → 插件无法判断某 tab 是否属于自己,会去关别人的 tab(2.1.6 已修过一次这类 bug)。
|
|
374
|
+
6. **`tools.restrict({allow: []})` 返回成功但实际不做限制** → 只读承诺失效,用户以为安全实则全量工具。
|
|
375
|
+
7. **`$DSH_HOME` 为空串时当成有效路径** → 配置写到 cwd,出现"两份配置"。
|
|
376
|
+
8. **`engines.dsh` 比较漏 `includePrerelease`** → rc 版本被误判不满足,插件根本不加载。
|
|
377
|
+
9. **`Buffer.byteLength` 换成 `string.length`** → 中文响应体 `content-length` 偏小,连接被截断。
|
|
378
|
+
10. **`requestRejection` 返回对象表示拒绝** → 插件按契约只认 `401/403` 数字,其它一律 403;若你的"放行"用对象表示,会被当成拒绝。
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## §14 最小可行宿主(如果真要写 Go 宿主)
|
|
383
|
+
|
|
384
|
+
`[推断]` 以下不是已验证方案,只是这份契约推出来的结论:
|
|
385
|
+
|
|
386
|
+
- **最省力的路**:Go 进程内嵌 `libnode`(CGO)跑本插件的 JS,Go 只负责 HTTP/文件/界面。这样 L1–L8 全部由 Node 承担,你要写的只是 L2 的 `(req,res)` 适配与 L9–L11。
|
|
387
|
+
- **纯 Go 的路**:必须自带 JS 引擎并支持 ESM(goja 不支持 ESM,需要先打包成 CJS 并注入 `node:` 垫片),且要自己实现 `Buffer`、`(req,res)` 双流、SSE flush。工作量≈重写宿主,插件本体 **0 行复用**。
|
|
388
|
+
- **无论哪条路**:先用 §12 的 V1–V8 当门禁,再谈界面。
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
*本文档由 `dsh-sidecard-ask` 2.1.7 源码反推,宿主侧参照实现为 DSH 0.2.1-rc.5(桌面端 AppImage 内 `app.asar` 解包的 `@deepseek-ai/dsh-*` 包)。凡标注 `[推断]` 的段落未经实测,不得当作契约引用。*
|
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.8'
|
|
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',
|
|
@@ -2368,7 +2368,12 @@ window.__ModuleLoader__.load({
|
|
|
2368
2368
|
// tab 把 contentId 固定写成 `sidebar://<kind>`(不是卡片 id),所以
|
|
2369
2369
|
// 过去用 `contentId === card.id` 判归属永远匹配失败、关闭恒失效
|
|
2370
2370
|
// (2.1.5 R2 的回归)。kind 单地址模型下 `cardId` 才是唯一归属信号。
|
|
2371
|
-
|
|
2371
|
+
// 严格归属(只认 `cardId === card.id`)治好了 2.1.5 R2 的"串卡",
|
|
2372
|
+
// 却把另一种真实场景关在门外:页面重载后宿主会从布局里恢复出这张
|
|
2373
|
+
// tab,而 `cardId` 是内存态、必然是 null —— 此时没有卡片认领它,
|
|
2374
|
+
// 删除历史条目也永远关不掉,就是专家复现的"幽灵面板"。
|
|
2375
|
+
// 规则改为:**已知别的卡拥有它才拒绝**;无主时按 kind + 会话定位。
|
|
2376
|
+
if (this.cardId !== null && this.cardId !== card.id) return false
|
|
2372
2377
|
// 卡片自己的会话优先:用户切走会话后关闭 A 的 tab,仍应落在 A。
|
|
2373
2378
|
const ownerSession = typeof card.sessionId === 'string' && card.sessionId !== ''
|
|
2374
2379
|
? card.sessionId
|
|
@@ -2472,7 +2477,9 @@ window.__ModuleLoader__.load({
|
|
|
2472
2477
|
close(card) {
|
|
2473
2478
|
if (!this.available() || typeof this.service.closeTab !== 'function') return false
|
|
2474
2479
|
// 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab。
|
|
2475
|
-
|
|
2480
|
+
// 同 native.close:无主(cardId 为 null,页面重载后)也要放行,
|
|
2481
|
+
// 否则在历史管理器里删掉一条 better 卡,栏里那张 tab 永远留空。
|
|
2482
|
+
if (this.cardId !== null && this.cardId !== card.id) return false
|
|
2476
2483
|
const sessionId = typeof card.sessionId === 'string' && card.sessionId !== ''
|
|
2477
2484
|
? card.sessionId
|
|
2478
2485
|
: (store.state.sessionId ?? undefined)
|
|
@@ -6271,7 +6278,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6271
6278
|
if (better.service === null || typeof better.service?.closeTab !== 'function') return
|
|
6272
6279
|
// 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab;释放一张
|
|
6273
6280
|
// 不是当前所有者的历史条目,会把现在真正在栏里的卡片连带关掉。
|
|
6274
|
-
|
|
6281
|
+
// 无主(null)时也放行:理由见 better.close。
|
|
6282
|
+
if (better.cardId !== null && better.cardId !== id) return
|
|
6275
6283
|
const scope = typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : undefined
|
|
6276
6284
|
const mountedId = embeddedTabIds.get(id)
|
|
6277
6285
|
if (typeof mountedId === 'string' && mountedId !== '') {
|
|
@@ -6302,12 +6310,44 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6302
6310
|
const native = surfaces.native
|
|
6303
6311
|
if (native === null || native === undefined) return
|
|
6304
6312
|
if (native.controller === null || native.controller === undefined) return
|
|
6305
|
-
|
|
6313
|
+
// 无主(null)时也放行:理由见 native.close。
|
|
6314
|
+
if (native.cardId !== null && native.cardId !== id) return
|
|
6306
6315
|
try {
|
|
6307
6316
|
native.close({ id, sessionId })
|
|
6308
6317
|
} catch { /* the rail owns its tab's lifetime */ }
|
|
6309
6318
|
}
|
|
6310
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
|
+
|
|
6311
6351
|
const actions = {
|
|
6312
6352
|
openPopover(candidate) {
|
|
6313
6353
|
// P2-2: the composer must be remembered BEFORE the popover's
|
|
@@ -6554,6 +6594,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6554
6594
|
if (renderedCardId === card.id) return
|
|
6555
6595
|
store.set({ surface: 'flow', surfaceNote: t('surfaceUnproven') })
|
|
6556
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 */ }
|
|
6557
6601
|
toast(t('surfaceUnproven'))
|
|
6558
6602
|
scheduleReport(0)
|
|
6559
6603
|
}, 600)
|
|
@@ -7386,6 +7430,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7386
7430
|
if (registry === null || registry === undefined) return
|
|
7387
7431
|
surfaces.native.registry = registry
|
|
7388
7432
|
surfaces.native.controller = controller ?? null
|
|
7433
|
+
// 布局恢复出来的 tab 没有卡片认领,注册完立刻清一次。
|
|
7434
|
+
try { sweepOrphanRailTabs() } catch { /* best effort */ }
|
|
7389
7435
|
// Register the tab TYPE first, then its body/title slots — and
|
|
7390
7436
|
// roll the type back if a slot refuses to register.
|
|
7391
7437
|
//
|
|
@@ -7418,7 +7464,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7418
7464
|
slotDisposers.push(ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
|
7419
7465
|
name: 'sidebar.right.pane.tab',
|
|
7420
7466
|
key: CARD_KIND,
|
|
7421
|
-
}, props => h(CardHost, { props, actions }))))
|
|
7467
|
+
}, props => h(CardHost, { props, actions, surface: 'native-rightbar' }))))
|
|
7422
7468
|
slotDisposers.push(ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
|
7423
7469
|
name: 'sidebar.right.pane.tab.title',
|
|
7424
7470
|
key: CARD_KIND,
|
|
@@ -7494,9 +7540,12 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7494
7540
|
component: props => h(CardHost, {
|
|
7495
7541
|
props: { tab: props?.tab, sessionId: props?.scope?.sessionId },
|
|
7496
7542
|
actions,
|
|
7543
|
+
surface: 'better-sidebar',
|
|
7497
7544
|
}),
|
|
7498
7545
|
})
|
|
7499
7546
|
surfaces.better.service = service
|
|
7547
|
+
// 同 native:注册完清掉无主的 better 栏 tab。
|
|
7548
|
+
try { sweepOrphanRailTabs() } catch { /* best effort */ }
|
|
7500
7549
|
surfaces.better.dispose = typeof off === 'function' ? off : null
|
|
7501
7550
|
disposers.push(() => {
|
|
7502
7551
|
try { surfaces.better.dispose?.() } catch { /* already disposed */ }
|
|
@@ -7525,12 +7574,17 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7525
7574
|
const navigation = props?.tab?.navigation ?? props?.navigation
|
|
7526
7575
|
const fromParams = navigation?.params?.cardId
|
|
7527
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
|
|
7528
7582
|
const recorded = surfaces.native.cardId
|
|
7529
7583
|
return typeof recorded === 'string' && recorded !== '' ? recorded : undefined
|
|
7530
7584
|
}
|
|
7531
7585
|
|
|
7532
7586
|
/** Embedded card body used by both external hosts. */
|
|
7533
|
-
function CardHost({ props }) {
|
|
7587
|
+
function CardHost({ props, surface }) {
|
|
7534
7588
|
const state = useStoreState()
|
|
7535
7589
|
const cardId = hostCardIdOf(props)
|
|
7536
7590
|
const card = state.cards.find(item => item.id === cardId)
|
|
@@ -7578,7 +7632,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7578
7632
|
h(Styles, null),
|
|
7579
7633
|
h(MemoAnswerCard, {
|
|
7580
7634
|
card,
|
|
7581
|
-
|
|
7635
|
+
// 标签说的是"这张卡现在挂在哪个承载面",与用户选的全局
|
|
7636
|
+
// 偏好无关:两个宿主可能同时存在(原生栏 + better 镜像),
|
|
7637
|
+
// 读 state.surface 会把 better 栏里的卡标成"原生右栏"(专家 A4)。
|
|
7638
|
+
surfaceLabel: (surface ?? state.surface) === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
|
|
7582
7639
|
onCopy: element => actions.copy(card, element),
|
|
7583
7640
|
onFollowUp: text => actions.followUp(card, text),
|
|
7584
7641
|
onRetry: () => actions.retry(card),
|
|
@@ -7602,6 +7659,35 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7602
7659
|
console.warn(`[${PLUGIN_ID}] card history restore failed:`, error)
|
|
7603
7660
|
}
|
|
7604
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
|
+
|
|
7605
7691
|
// §4.5 family singleton: register this copy, (re)elect on every join
|
|
7606
7692
|
// or leave, and gate the interaction surface on the verdict.
|
|
7607
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.8'
|
|
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)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-sidecard-ask",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.8",
|
|
4
4
|
"description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"icon.svg",
|
|
22
22
|
"README.md",
|
|
23
23
|
"CHANGELOG.md",
|
|
24
|
-
"LICENSE"
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"NODE-HOST-CONTRACT.md"
|
|
25
26
|
],
|
|
26
27
|
"engines": {
|
|
27
28
|
"node": ">=20",
|