dsh-sidecard-ask 2.1.2 → 2.1.4
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 +78 -0
- package/README.md +23 -5
- package/client.js +162 -21
- package/index.js +25 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.4
|
|
4
|
+
|
|
5
|
+
**主题:把「技术方案评审团」查出的 11 条缺陷一次修完。四套脚本全绿,新增 21 条回归断言。**
|
|
6
|
+
排查依据:Software Architect / Application Security Engineer / Reality Checker 三方评审 + 源码复核 + 实测复现。
|
|
7
|
+
|
|
8
|
+
### 修复
|
|
9
|
+
|
|
10
|
+
- **B1(阻断,静默数据丢失)关闭的卡片在下次答题完成时被抹掉**。`syncPersisted()` 只从
|
|
11
|
+
「活跃卡片」重建整个持久化集合再整键覆盖,而 `close()` 只把卡片移出活跃区、durable 快照
|
|
12
|
+
仍留在 `store.state.history` 里,于是关掉的那张被静默删除(`contract-test` 原先把破坏后的
|
|
13
|
+
结果当成了期望值)。现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目,按 id 去重(活跃方
|
|
14
|
+
优先)后按 newest-first 截到 cap。删除路径本来就先改 `store.history` 再写盘,并集不会把
|
|
15
|
+
它们删掉的条目复活。
|
|
16
|
+
- **B2(高,虚假安全保证)`agentPreset` 在 0.2.1-alpha.2+ 的 activation 面没被转发**。
|
|
17
|
+
`startSubagent` 只在旧 `start(provider, request)` 面转发 `agentPreset`,新面的 `request`
|
|
18
|
+
漏了它 → 子代理拿默认全套工具,而 start 帧仍按配置上报 `toolFilter: 'preset'`,卡片于是
|
|
19
|
+
对用户谎报「工具集由 preset 决定」。现在两个面都转发。
|
|
20
|
+
- **B3(中高,降级链断裂)Rung 1 `sessions.using()` 没有 try/catch**。它一抛(会话归档、
|
|
21
|
+
形状漂移,或它内部的 `no-session-face`),整条「改发到主对话」就断在第一档,后面
|
|
22
|
+
`retain` / `inputActions` / `draft` 三档全部不可达。现在与 Rung 2 对称降级,
|
|
23
|
+
`prompt-rejected` 仍照旧抛给调用方(那是用户可见的拒绝,不是通道缺失)。
|
|
24
|
+
- **B4(中,原生栏串卡)第二张卡开进原生右栏时第一张没有迁回浮层**。它只是被盖住——卡片
|
|
25
|
+
仍标 `native-rightbar`、store 里被过滤掉,流式输出继续往看不见的卡片里写,且 rail 上那张
|
|
26
|
+
永远关不掉。现在 `open()` 先把栏里其它卡片迁回 `flow`;`close()` 增加归属校验(栏被另一
|
|
27
|
+
张卡占用时不替它关)与 `contentId` 行匹配(多行时关「属于这张卡」的那行);`openTab`
|
|
28
|
+
抛错时回滚 `cardId`,避免 CardHost 去渲染一张根本没有 tab 的卡。
|
|
29
|
+
- **B5(中低)`/cancel` 把跨会话的全局 run id 列表回给页面**。`runs` 是跨会话的全局表,
|
|
30
|
+
回 `active` 等于泄露别的会话正在追问的卡片 id(`cancel` 本身也不校验归属),而 Client
|
|
31
|
+
从不消费它。现在只回 `{ cancelled }`。
|
|
32
|
+
- **B6(中低,迁移被永久挡住)空的 v3 信封会阻断 v1/v2 迁移**。`raw !== null` 就直接
|
|
33
|
+
return,损坏的 JSON 或旧版本留下的空信封同样挡住。现在写盘时带 `writtenAt` 标记,只有
|
|
34
|
+
「本版本自己写的空信封」才算权威(用户确实清空了历史,绝不从 v2/v1 复活),没有该标记的
|
|
35
|
+
空信封与损坏 JSON 继续向下探测。
|
|
36
|
+
- **B7(低,卸载泄漏)`dispose()` 只清了 `streamListeners`,漏了 `chunkListeners`**。第二个
|
|
37
|
+
流来源的订阅者会留下仍被 `session/event` 桥持有的闭包(它捕获整个 engine 状态)。
|
|
38
|
+
- **B9(低,死重量)持久化写死 `slice(-12)`,而 `followUp` 只发 6 轮**。改成
|
|
39
|
+
`HISTORY_TURNS`,与上下文预算同一个上限。
|
|
40
|
+
- **B10(低,死代码)`let finished = false` 只赋值不读取**,删除。
|
|
41
|
+
- **B11(低,可诊断性)信任围栏的 403 只说 `forbidden`**。现在说明「Host 不在信任范围 +
|
|
42
|
+
怎么放行(127.0.0.1 或 `trustedHosts`)」。有意**不**自动信任绑定地址:插件读不到宿主的
|
|
43
|
+
bind 配置,猜一个就等于自己拆掉 DNS-rebinding 防线。
|
|
44
|
+
- **B8(条件性,平台限制)配置里的 SMTP 密码以明文落在 `~/.dsh/` 下的用户层 JSON**。
|
|
45
|
+
写盘已带 `mode: 0o600`(POSIX 生效,Windows 忽略),代码注释与 README 均已注明;
|
|
46
|
+
无进程内可做的进一步缓解,**保留为已文档化的限制**,建议优先用环境变量。
|
|
47
|
+
|
|
48
|
+
### 验证
|
|
49
|
+
|
|
50
|
+
`node test/verify.mjs` / `contract-test.mjs` / `float-placement.mjs` / `smoke-test.mjs`:
|
|
51
|
+
**57 / 1446 / 100 / 277 全绿(1880 条,0 失败)**。相对修复前 1859 条新增 21 条回归断言,
|
|
52
|
+
逐条对应上面的缺陷:关卡片后历史仍在(contract)、activation 面转发 preset 且无能力时
|
|
53
|
+
不带该字段(smoke)、`using()` 抛错降级而 `prompt-rejected` 仍抛(contract)、空/损坏 v3
|
|
54
|
+
信封不挡迁移且自有空信封不被复活(contract)、原生栏单地址与关 tab 归属(contract)、
|
|
55
|
+
`/cancel` 不泄露 `active`(smoke)。
|
|
56
|
+
|
|
57
|
+
## 2.1.3
|
|
58
|
+
|
|
59
|
+
**主题:折叠态胶囊不再压在聊天输入框上(2026-10-25 实拍报告)。**
|
|
60
|
+
|
|
61
|
+
### 修复
|
|
62
|
+
|
|
63
|
+
- **胶囊盖住输入框**。浮层固定在 `bottom:16px`,而输入框就在会话列底部,折叠态胶囊
|
|
64
|
+
正好落在输入框上。新增纯函数
|
|
65
|
+
`floatBottom({ frameHeight, composerTop, fallback, limit })`:测量 `[data-composer-card]`
|
|
66
|
+
的顶边,把浮层抬到输入框上方 8px(CSS 变量 `--dsa-gap-bottom`)。两道保险——输入框
|
|
67
|
+
顶边落在窗口上半部(空会话的居中输入框)时不抬;结果上限 240px,避免超高输入框把
|
|
68
|
+
浮层推到会话正文上。
|
|
69
|
+
- **胶囊改为按内容收缩**。浮层本就贴着右边缘(`right:8px`),「再往右」没有余量,所以
|
|
70
|
+
改为缩窄:内容自适应、上限 240px、容器右对齐,左边缘随之右退。内边距 8/12 → 6/10、
|
|
71
|
+
行高 18 → 16,问题文本由 `flex:1` 改为 `flex:0 1 auto`(只在真放不下时才截断)。
|
|
72
|
+
取舍:抬到输入框上方后它会压住最后一行消息,所以必须足够窄小。
|
|
73
|
+
- 新增 `node.dataset.layerBottom`(与 `layerWidth` 并列),用户反馈可以直接报出浮层
|
|
74
|
+
离底多少。
|
|
75
|
+
|
|
76
|
+
### 验证
|
|
77
|
+
|
|
78
|
+
`test/float-placement.mjs` 新增 `floatBottom` 数值用例与源码钩子;四套脚本
|
|
79
|
+
100 / 1427 / 57 / 271 全绿。
|
|
80
|
+
|
|
3
81
|
## 2.1.2
|
|
4
82
|
|
|
5
83
|
**主题:按 DSH 0.2.1-rc.3 的实测契约修复——原生右栏卡片、语言跟随、只读工具收窄、可信主机围栏。
|
package/README.md
CHANGED
|
@@ -196,7 +196,7 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
|
|
|
196
196
|
|
|
197
197
|
非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。
|
|
198
198
|
|
|
199
|
-
### 4.1 内置浮层的位置规则(`floatPlacement`)
|
|
199
|
+
### 4.1 内置浮层的位置规则(`floatPlacement` / `floatBottom`)
|
|
200
200
|
|
|
201
201
|
用户实测报告过一个问题:浮层是 `position:fixed; right:16px; width:min(400px,92vw)` 的实心卡片。
|
|
202
202
|
实测那一次(1400px 宽的窗口)对话区右边界在 x≈971,而浮层左边界在 x≈858,**压住正文约 113px**,
|
|
@@ -218,7 +218,23 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
|
|
|
218
218
|
- 浮层**只使用对话区右侧的预留带**(右栏也在这条带里),窗口越窄它越窄,而不是越靠左。
|
|
219
219
|
- `capped: true` 表示「上限没用满,是被可用带宽压住的」——这是正常的自适应,不是错误。
|
|
220
220
|
- 挂载时、窗口 resize 时、右栏开合时(`ResizeObserver`)都会重算;流式刷新只重渲染,不动几何。
|
|
221
|
-
- 浮层根节点带 `data-layer-width
|
|
221
|
+
- 浮层根节点带 `data-layer-width`(实际宽度)、`data-layer-bottom`(距窗口底的距离)与
|
|
222
|
+
`data-dsa-float-mode`:报告问题时可直接读这三个值。
|
|
223
|
+
|
|
224
|
+
**垂直位置(2.1.3)**:宽度不是唯一的问题——浮层原本固定在 `bottom:16px`,而输入框就在会话列底部,
|
|
225
|
+
折叠态胶囊必然压在输入框上(2026-10-25 实拍报告「挡住聊天框了」)。垂直位置由纯函数 `floatBottom` 决定:
|
|
226
|
+
|
|
227
|
+
```js
|
|
228
|
+
// floatBottom({ frameHeight, composerTop = null, fallback = 16, limit = 240 })
|
|
229
|
+
if (composerTop 未知 || 帧高为 0) return 16 // 测不到就保持旧锚点
|
|
230
|
+
if (composerTop < 帧高 / 2) return 16 // 居中输入框(空会话)不是底栏,不抬
|
|
231
|
+
bottom = clamp(帧高 - composerTop + 8, 16, 240) // 抬到输入框上方 8px,上限 240px
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`composerTop` 取自 DSH 的 `[data-composer-card]`(输入框那张卡片的顶边),该节点也进了
|
|
235
|
+
`ResizeObserver`——输入多行时卡片会往上长,浮层跟着重算,不会重新沉回输入框上。
|
|
236
|
+
取舍要讲清楚:抬起来之后它**改压最后一两行消息**,所以胶囊同时改成按内容收缩(≤240px、右对齐),
|
|
237
|
+
尽量把覆盖面积压到最小。
|
|
222
238
|
|
|
223
239
|
### 4.2 右侧栏打开时「占满它」(`dockRail`,1.8.3)
|
|
224
240
|
|
|
@@ -782,10 +798,12 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
|
|
|
782
798
|
展开后标题栏右侧有**收起为胶囊(⇥)**与**删除这条追问对话(×)**——× 会把该卡片从浮层与追问历史里
|
|
783
799
|
一并移除,**无法撤销**(有内容时会先弹插件内确认层;点浮层底部的「关闭」则只把它摘离浮层、历史仍保留在
|
|
784
800
|
设置页的历史管理器里)。2.0.0 起不再有「隐藏整个浮层 / 只留一个计数圆点」这套形态。
|
|
785
|
-
用 F12 选中浮层根节点(`[data-dsa-root]
|
|
801
|
+
用 F12 选中浮层根节点(`[data-dsa-root]`)读三个值自检:
|
|
786
802
|
- `data-dsa-float-mode` 应为 `capsule` / `full`(铺满右栏时为 `dock`);
|
|
787
|
-
- `data-layer-width` 应等于 `min(floatMaxWidth, 对话区右边界到帧右边缘的可用带宽)
|
|
788
|
-
|
|
803
|
+
- `data-layer-width` 应等于 `min(floatMaxWidth, 对话区右边界到帧右边缘的可用带宽)`;
|
|
804
|
+
- `data-layer-bottom` 应等于 `帧高 - 输入框(`[data-composer-card]`)顶边 + 8`(空会话/测不到时为 16)。
|
|
805
|
+
**关键判据:胶囊/卡片的左边缘不得越过对话区(`[data-slot^="conversation"]`)的右边界,
|
|
806
|
+
下边缘不得落在输入框上**。
|
|
789
807
|
把窗口从 1920px 拖到 900px、再开关一次右侧栏,浮层应随之变窄而不是往左压住正文;
|
|
790
808
|
只有窗口窄到可用带宽不足 240px 时才允许压入(此时是设计内的降级,不是 bug)。
|
|
791
809
|
9. **占满右侧栏(1.8.3,`dockRail`)**:先把右侧栏打开(任意面板,例如「团队任务板」),再追问一次 →
|
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.4'
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
123
|
* Tab kind served by the DSH native right rail (also its implementation id).
|
|
@@ -364,6 +364,17 @@ window.__ModuleLoader__.load({
|
|
|
364
364
|
const FLOAT_GAP_SIDEBAR = 10
|
|
365
365
|
const FLOAT_MIN_WIDTH = 240
|
|
366
366
|
const FLOAT_MAX_WIDTH_LIMIT = 560
|
|
367
|
+
/**
|
|
368
|
+
* Vertical clearance (2.1.3). The layer used to be pinned `bottom:16px`,
|
|
369
|
+
* i.e. straight on top of the composer — which is what the 2026-10-25
|
|
370
|
+
* report ("挡住聊天框了") caught: the capsule covered the input box. The
|
|
371
|
+
* layer now measures the composer and sits just above it.
|
|
372
|
+
*/
|
|
373
|
+
const FLOAT_GAP_BOTTOM = 16
|
|
374
|
+
const FLOAT_GAP_COMPOSER = 8
|
|
375
|
+
const FLOAT_BOTTOM_LIMIT = 240
|
|
376
|
+
/** The capsule is a status pill, not a card: it sizes to its content. */
|
|
377
|
+
const CAPSULE_MAX_WIDTH = 240
|
|
367
378
|
|
|
368
379
|
/**
|
|
369
380
|
* The floating layer's width for one frame.
|
|
@@ -394,6 +405,40 @@ window.__ModuleLoader__.load({
|
|
|
394
405
|
return { width, avail, capped: width < cap, bleeds: lane - left > 1 }
|
|
395
406
|
}
|
|
396
407
|
|
|
408
|
+
/**
|
|
409
|
+
* How far above the frame's bottom edge the floating layer sits (2.1.3).
|
|
410
|
+
*
|
|
411
|
+
* Pure, like {@link floatPlacement}, and mirrored by the tests.
|
|
412
|
+
* `composerTop` is the composer card's top edge, in frame coordinates. The
|
|
413
|
+
* composer is part of the conversation column, so the old fixed
|
|
414
|
+
* `bottom:16px` parked the layer straight on top of the input box; the
|
|
415
|
+
* layer is lifted until its own bottom edge clears the composer by
|
|
416
|
+
* `FLOAT_GAP_COMPOSER`.
|
|
417
|
+
*
|
|
418
|
+
* Two guards keep that lift honest: a composer whose top sits in the frame's
|
|
419
|
+
* upper half is not a bottom bar (the empty-session composer is vertically
|
|
420
|
+
* centred) and is ignored, and the result is clamped to `limit` so an
|
|
421
|
+
* unusually tall composer cannot push the layer over the conversation.
|
|
422
|
+
*
|
|
423
|
+
* @param {object} input - `frameHeight`, `composerTop`, `fallback`, `limit`.
|
|
424
|
+
* @returns {number} Bottom offset in px, never below `fallback`.
|
|
425
|
+
*/
|
|
426
|
+
function floatBottom({
|
|
427
|
+
frameHeight,
|
|
428
|
+
composerTop = null,
|
|
429
|
+
fallback = FLOAT_GAP_BOTTOM,
|
|
430
|
+
limit = FLOAT_BOTTOM_LIMIT,
|
|
431
|
+
}) {
|
|
432
|
+
const base = Number.isFinite(fallback) ? Math.max(0, Math.round(fallback)) : FLOAT_GAP_BOTTOM
|
|
433
|
+
const frame = Number.isFinite(frameHeight) ? frameHeight : 0
|
|
434
|
+
if (frame <= 0 || !Number.isFinite(composerTop)) return base
|
|
435
|
+
// A centred composer is not a bottom bar; leave the layer where it was.
|
|
436
|
+
if (composerTop < frame / 2) return base
|
|
437
|
+
const clear = Math.round(frame - composerTop + FLOAT_GAP_COMPOSER)
|
|
438
|
+
const cap = Number.isFinite(limit) ? Math.max(base, limit) : FLOAT_BOTTOM_LIMIT
|
|
439
|
+
return Math.max(base, Math.min(clear, cap))
|
|
440
|
+
}
|
|
441
|
+
|
|
397
442
|
const DOCK_MIN_WIDTH = 280
|
|
398
443
|
const DOCK_MIN_HEIGHT = 240
|
|
399
444
|
|
|
@@ -2133,16 +2178,26 @@ window.__ModuleLoader__.load({
|
|
|
2133
2178
|
}
|
|
2134
2179
|
|
|
2135
2180
|
// Rung 1 — `using()`: the documented helper, present from 0.1.6-alpha.2.
|
|
2181
|
+
// 与 Rung 2 必须对称地包 try/catch:`using()` 本身(或它内部的
|
|
2182
|
+
// `no-session-face` 抛出)一失败就该降级到 retain → inputActions →
|
|
2183
|
+
// draft,而不是让整个"改发到主对话"断在这里 —— 后两档存在的理由
|
|
2184
|
+
// 正是这种情况。`prompt-rejected` 仍然照旧抛给调用方(那是用户可见的
|
|
2185
|
+
// 拒绝,不是通道缺失)。
|
|
2136
2186
|
if (sessionId !== null && typeof sessions?.using === 'function') {
|
|
2137
|
-
|
|
2138
|
-
const
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2187
|
+
try {
|
|
2188
|
+
const result = await sessions.using(sessionId, { source: 'gateway' }, async (reference) => {
|
|
2189
|
+
const face = reference?.binding?.session
|
|
2190
|
+
if (face === undefined || typeof face.prompt !== 'function') {
|
|
2191
|
+
throw Object.assign(new Error('session face has no prompt()'), { code: 'no-session-face' })
|
|
2192
|
+
}
|
|
2193
|
+
return face.prompt(parts, 'queue')
|
|
2194
|
+
})
|
|
2195
|
+
accepted(result)
|
|
2196
|
+
return { mode: 'session', via: 'sessions.using' }
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
if (error?.code === 'prompt-rejected') throw error
|
|
2199
|
+
console.warn(`[${PLUGIN_ID}] sessions.using 提交失败,尝试下一级:`, error)
|
|
2200
|
+
}
|
|
2146
2201
|
}
|
|
2147
2202
|
|
|
2148
2203
|
// Rung 2 — `retain()` + explicit release: the same operation spelled out,
|
|
@@ -2262,8 +2317,22 @@ window.__ModuleLoader__.load({
|
|
|
2262
2317
|
cardId: null,
|
|
2263
2318
|
open(card) {
|
|
2264
2319
|
if (!this.available() || typeof this.controller.openTab !== 'function') return false
|
|
2320
|
+
// 同一个 kind 在原生栏里只有一个地址(见 cardId 的注释):先把栏里
|
|
2321
|
+
// 其它卡片迁回浮层,否则它们既不在栏里也不在浮层上,流式输出还会
|
|
2322
|
+
// 继续往一张看不见的卡片里写。
|
|
2323
|
+
for (const other of store.state.cards) {
|
|
2324
|
+
if (other.id !== card.id && other.surface === 'native-rightbar') {
|
|
2325
|
+
patchCard(other.id, { surface: 'flow' })
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2265
2328
|
this.cardId = card.id
|
|
2266
|
-
|
|
2329
|
+
try {
|
|
2330
|
+
this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
|
|
2331
|
+
} catch (error) {
|
|
2332
|
+
// 开栏失败就把地址回滚,免得 CardHost 去渲染一张根本没有 tab 的卡。
|
|
2333
|
+
this.cardId = null
|
|
2334
|
+
throw error
|
|
2335
|
+
}
|
|
2267
2336
|
return true
|
|
2268
2337
|
},
|
|
2269
2338
|
close(card) {
|
|
@@ -2274,6 +2343,10 @@ window.__ModuleLoader__.load({
|
|
|
2274
2343
|
// and lets CardHost show its closed-card face.
|
|
2275
2344
|
const controller = this.controller
|
|
2276
2345
|
if (controller === null || controller === undefined) return false
|
|
2346
|
+
// 归属校验:栏里那条同 kind 的 tab 若正被另一张卡片占用,就不能替它
|
|
2347
|
+
// 关掉(否则关 A 会把 B 的 tab 一起关掉)。cardId === null 是"清理
|
|
2348
|
+
// 残留 tab"的路径,此时按 kind 关是对的。
|
|
2349
|
+
if (this.cardId !== null && this.cardId !== card.id) return false
|
|
2277
2350
|
let rows = []
|
|
2278
2351
|
try {
|
|
2279
2352
|
rows = typeof controller.openTabs?.getSnapshot === 'function'
|
|
@@ -2283,6 +2356,12 @@ window.__ModuleLoader__.load({
|
|
|
2283
2356
|
const row = Array.isArray(rows)
|
|
2284
2357
|
? rows.find(entry => entry?.kind === CARD_KIND
|
|
2285
2358
|
&& typeof entry?.tabId === 'string' && entry.tabId !== ''
|
|
2359
|
+
// 单地址模型下这里只该有一行;万一宿主保留了多行,必须关"属于这张
|
|
2360
|
+
// 卡片"的那一行(openTab 把 cardId 写进 params → contentId),
|
|
2361
|
+
// 否则会替另一张卡片关掉它的 tab。
|
|
2362
|
+
&& (typeof entry?.contentId !== 'string'
|
|
2363
|
+
|| entry.contentId === ''
|
|
2364
|
+
|| entry.contentId === card.id)
|
|
2286
2365
|
&& (typeof entry?.sessionId !== 'string'
|
|
2287
2366
|
|| store.state.sessionId === null
|
|
2288
2367
|
|| entry.sessionId === store.state.sessionId))
|
|
@@ -2654,9 +2733,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
2654
2733
|
\`floatPlacement\` through --dsa-float-w, so it grows left only as far as the
|
|
2655
2734
|
conversation column allows. pointer-events stays off on the container so the
|
|
2656
2735
|
empty area around the pill never eats a click meant for the conversation. */
|
|
2657
|
-
.dsa-float{pointer-events:none;position:fixed;right:var(--dsa-gap-right, 8px);bottom:16px;
|
|
2736
|
+
.dsa-float{pointer-events:none;position:fixed;right:var(--dsa-gap-right, 8px);bottom:var(--dsa-gap-bottom, 16px);
|
|
2658
2737
|
display:flex;flex-direction:column;gap:8px;width:var(--dsa-float-w, 340px);max-width:92vw}
|
|
2659
2738
|
.dsa-float > *{pointer-events:auto}
|
|
2739
|
+
/* Capsule mode (2.1.3): the layer is a status pill, not a card. It sizes to its
|
|
2740
|
+
own content and stays right-aligned, so shrinking it is what pulls its left
|
|
2741
|
+
edge back over the conversation — the layer is already flush with the frame's
|
|
2742
|
+
right edge, so it has nowhere further right to go. */
|
|
2743
|
+
.dsa-float[data-dsa-float-mode="capsule"]{width:auto;align-items:flex-end;
|
|
2744
|
+
max-width:min(var(--dsa-float-w, 340px), var(--dsa-capsule-max, 240px), 92vw)}
|
|
2660
2745
|
/* Docked presentation (1.8.3): the same container, but pinned to the right
|
|
2661
2746
|
rail's measured rectangle (inline left/top/width/height) instead of the
|
|
2662
2747
|
frame's bottom-right corner. Right/bottom must be released, otherwise the
|
|
@@ -2671,8 +2756,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
2671
2756
|
box-shadow:none;background:transparent;padding:10px 12px 8px}
|
|
2672
2757
|
.dsa-float[data-dsa-float-mode="dock"] .dsa-answer{max-height:none;flex:1 1 auto;min-height:0;overflow:auto}
|
|
2673
2758
|
.dsa-stack{display:flex;flex-direction:column;gap:8px;max-height:52vh;overflow:auto}
|
|
2674
|
-
.dsa-capsule{display:flex;align-items:center;gap:
|
|
2675
|
-
padding:
|
|
2759
|
+
.dsa-capsule{display:flex;align-items:center;gap:6px;width:auto;max-width:100%;box-sizing:border-box;text-align:left;
|
|
2760
|
+
padding:6px 10px;border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:16px;
|
|
2676
2761
|
border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
2677
2762
|
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
2678
2763
|
box-shadow:0 2px 10px rgba(0,0,0,.14)}
|
|
@@ -2685,7 +2770,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
2685
2770
|
.dsa-capsule-dot[data-status="error"]{background:var(--dsw-alias-state-error-primary, #c0392b)}
|
|
2686
2771
|
.dsa-capsule-dot[data-status="stopped"]{background:var(--dsw-alias-state-warn-primary, #b8860b)}
|
|
2687
2772
|
.dsa-capsule-label{font-weight:600;white-space:nowrap}
|
|
2688
|
-
.dsa-capsule-q{min-width:0;flex:1;color:var(--dsw-alias-label-secondary, #666);
|
|
2773
|
+
.dsa-capsule-q{min-width:0;flex:0 1 auto;color:var(--dsw-alias-label-secondary, #666);
|
|
2689
2774
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
2690
2775
|
.dsa-capsule-chev{flex:none;color:var(--dsw-alias-label-secondary, #888)}
|
|
2691
2776
|
/* Delete confirmation (2.0.0). Fixed, not inline: a card docked into the right
|
|
@@ -4707,7 +4792,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4707
4792
|
// Slot subtrees only: `.rightbar` and `[data-dsh-panel-host]` were
|
|
4708
4793
|
// dropped in 2.1.2 — no supported build renders either marker, so they
|
|
4709
4794
|
// were two dead branches in a selector that runs on every resize.
|
|
4710
|
-
|
|
4795
|
+
// 2.1.3: the composer card joins the list so a multi-line draft (which
|
|
4796
|
+
// grows the box upward) re-measures the layer's vertical clearance
|
|
4797
|
+
// instead of letting the pill sink back onto the input.
|
|
4798
|
+
const selector = '[data-slot^="conversation"], [data-slot^="sidebar.right"], [data-composer-card]'
|
|
4711
4799
|
const observed = new Set()
|
|
4712
4800
|
const all = () => {
|
|
4713
4801
|
try {
|
|
@@ -4743,11 +4831,25 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4743
4831
|
const railBox = rail === null ? null : rail.box
|
|
4744
4832
|
const laneRight = laneRect === null ? rootWidth : laneRect.right
|
|
4745
4833
|
const railLeft = railBox === null ? null : railBox.left
|
|
4834
|
+
// 2.1.3: the composer occupies the bottom of the conversation column,
|
|
4835
|
+
// so the layer's old fixed `bottom:16px` landed straight on top of the
|
|
4836
|
+
// input box. Measure the composer card (the box the user types in) and
|
|
4837
|
+
// lift the layer clear of it; `floatBottom` owns the guards.
|
|
4838
|
+
const composerEl = one('[data-slot^="conversation"] [data-composer-card]')
|
|
4839
|
+
?? one('[data-composer-card]')
|
|
4840
|
+
const composerRect = measure(composerEl)
|
|
4841
|
+
const gapBottom = floatBottom({
|
|
4842
|
+
frameHeight: rootHeight,
|
|
4843
|
+
composerTop: composerRect === null ? null : composerRect.top,
|
|
4844
|
+
})
|
|
4845
|
+
node.style.setProperty('--dsa-gap-bottom', `${gapBottom}px`)
|
|
4746
4846
|
const placement = floatPlacement({ frameWidth: rootWidth, laneRight, railLeft, maxWidth })
|
|
4747
4847
|
node.style.setProperty('--dsa-float-w', `${placement.width}px`)
|
|
4748
4848
|
node.style.setProperty('--dsa-gap-right', `${FLOAT_GAP_RIGHT}px`)
|
|
4849
|
+
node.style.setProperty('--dsa-capsule-max', `${CAPSULE_MAX_WIDTH}px`)
|
|
4749
4850
|
// Readable placement, so a user report can say WHERE the layer sat.
|
|
4750
4851
|
node.dataset.layerWidth = String(placement.width)
|
|
4852
|
+
node.dataset.layerBottom = String(gapBottom)
|
|
4751
4853
|
|
|
4752
4854
|
// Docked presentation: the card fills the rail page's body instead of
|
|
4753
4855
|
// floating over it. Only measured in `full` mode — a capsule/launcher
|
|
@@ -5773,13 +5875,32 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5773
5875
|
} catch { return [] }
|
|
5774
5876
|
}
|
|
5775
5877
|
|
|
5878
|
+
/** 是否是「本版本自己写下的空信封」(带 writtenAt 标记)。 */
|
|
5879
|
+
function isOwnedEmptyEnvelope(raw) {
|
|
5880
|
+
try {
|
|
5881
|
+
const value = JSON.parse(raw)
|
|
5882
|
+
return value !== null
|
|
5883
|
+
&& typeof value === 'object'
|
|
5884
|
+
&& value.version === 3
|
|
5885
|
+
&& Array.isArray(value.cards)
|
|
5886
|
+
&& Number.isFinite(value.writtenAt)
|
|
5887
|
+
} catch {
|
|
5888
|
+
return false
|
|
5889
|
+
}
|
|
5890
|
+
}
|
|
5891
|
+
|
|
5776
5892
|
function readPersisted() {
|
|
5777
5893
|
let raw = null
|
|
5778
5894
|
try {
|
|
5779
5895
|
raw = window.localStorage?.getItem(PERSIST_KEY)
|
|
5780
5896
|
} catch { return [] }
|
|
5781
5897
|
const v3 = parseEnvelope(raw, 3).slice(0, 50)
|
|
5782
|
-
if (v3.length > 0
|
|
5898
|
+
if (v3.length > 0) return v3
|
|
5899
|
+
// 空的 v3 信封只有在「本版本自己写的」时候才算权威:它带 writtenAt,
|
|
5900
|
+
// 说明用户确实把历史清空了,此时绝不能回落到 v2/v1 把旧记录复活。
|
|
5901
|
+
// 而没有 writtenAt 的空信封(旧版本写的 / 迁移中途留下的)或损坏的
|
|
5902
|
+
// JSON 必须继续向下探测 —— 否则 v1、v2 的数据会被永久挡住。
|
|
5903
|
+
if (raw !== null && raw !== undefined && isOwnedEmptyEnvelope(raw)) return v3
|
|
5783
5904
|
// v2 → v3: v2 entries have no `versions`, so the field is seeded empty.
|
|
5784
5905
|
let v2raw = null
|
|
5785
5906
|
try {
|
|
@@ -5813,7 +5934,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5813
5934
|
let lastError = null
|
|
5814
5935
|
for (;;) {
|
|
5815
5936
|
try {
|
|
5816
|
-
window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining }))
|
|
5937
|
+
window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining, writtenAt: Date.now() }))
|
|
5817
5938
|
return remaining
|
|
5818
5939
|
} catch (error) {
|
|
5819
5940
|
lastError = error
|
|
@@ -5839,7 +5960,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5839
5960
|
.filter(card => card.carrier === 'side'
|
|
5840
5961
|
&& (card.status === 'done' || card.status === 'stopped')
|
|
5841
5962
|
&& card.text !== '')
|
|
5842
|
-
.slice(0, cap)
|
|
5843
5963
|
.map(card => ({
|
|
5844
5964
|
id: card.id,
|
|
5845
5965
|
question: card.question,
|
|
@@ -5855,7 +5975,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5855
5975
|
createdAt: Number.isFinite(card.createdAt) ? card.createdAt : Date.now(),
|
|
5856
5976
|
provider: typeof card.provider === 'string' ? card.provider : '',
|
|
5857
5977
|
elapsedMs: Number.isFinite(card.elapsedMs) ? card.elapsedMs : null,
|
|
5858
|
-
|
|
5978
|
+
// 持久化的历史轮次与上下文预算同一个上限(HISTORY_TURNS):
|
|
5979
|
+
// 这里曾经写死 `slice(-12)`,而 followUp 只发 6 轮,多出来的
|
|
5980
|
+
// 6 轮永远不会被读,是纯死重量。
|
|
5981
|
+
history: Array.isArray(card.history) ? card.history.slice(-HISTORY_TURNS) : [],
|
|
5859
5982
|
// P2-2: kept so a replayed rewrite card still offers "insert".
|
|
5860
5983
|
actionId: typeof card.actionId === 'string' ? card.actionId.slice(0, 60) : null,
|
|
5861
5984
|
// P2-5b: bounded, text-capped older answers. `question` is short
|
|
@@ -5879,7 +6002,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5879
6002
|
store.set({ history: [] })
|
|
5880
6003
|
return
|
|
5881
6004
|
}
|
|
5882
|
-
|
|
6005
|
+
// #6(2026-10-26 修复):关闭一张卡片只把它从「活跃区」移除,
|
|
6006
|
+
// durable 快照仍然留在 store.history 里。这里过去只从活跃卡片
|
|
6007
|
+
// 重建整个集合再整键覆盖,于是「关掉的那张」会在下一次答题完成
|
|
6008
|
+
// 时被静默抹掉(contract-test 把破坏后的结果当成了期望)。
|
|
6009
|
+
// 现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目(按 id 去重,
|
|
6010
|
+
// 活跃方优先),再按 newest-first 截到 cap。
|
|
6011
|
+
// 删除路径(deleteCard / deleteHistoryCard / 改投主对话 /
|
|
6012
|
+
// clearHistory)都是先改 store.history 再写盘,所以并集不会把它们
|
|
6013
|
+
// 删掉的条目复活。
|
|
6014
|
+
const liveIds = new Set(finished.map(entry => entry.id))
|
|
6015
|
+
const kept = store.state.history.filter(entry => entry !== null
|
|
6016
|
+
&& typeof entry === 'object'
|
|
6017
|
+
&& typeof entry.id === 'string'
|
|
6018
|
+
&& !liveIds.has(entry.id))
|
|
6019
|
+
const merged = [...finished, ...kept]
|
|
6020
|
+
.sort((left, right) => (Number(right.createdAt) || 0) - (Number(left.createdAt) || 0))
|
|
6021
|
+
.slice(0, cap)
|
|
6022
|
+
const persisted = writePersisted(merged)
|
|
5883
6023
|
store.set({ history: persisted })
|
|
5884
6024
|
}
|
|
5885
6025
|
|
|
@@ -7429,6 +7569,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
7429
7569
|
renderRichText,
|
|
7430
7570
|
pickSurface,
|
|
7431
7571
|
floatPlacement,
|
|
7572
|
+
floatBottom,
|
|
7432
7573
|
railDock,
|
|
7433
7574
|
compareVersions,
|
|
7434
7575
|
electFamilyLeader,
|
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.4'
|
|
49
49
|
|
|
50
50
|
/** Route prefix of the plugin's own API. */
|
|
51
51
|
export const ROUTE_PREFIX = '/sidecard-ask/api'
|
|
@@ -1541,7 +1541,7 @@ function openSse(res) {
|
|
|
1541
1541
|
* Build the engine that owns every side-card run.
|
|
1542
1542
|
* @param {import('@deepseek-ai/cordis').Context} ctx - host plugin context.
|
|
1543
1543
|
* @param {() => Record<string, unknown>} configOf - live config reader.
|
|
1544
|
-
* @returns {{ask: Function, cancel: Function,
|
|
1544
|
+
* @returns {{ask: Function, cancel: Function, dispose: Function, capabilities: Function}}
|
|
1545
1545
|
*/
|
|
1546
1546
|
function createSideEngine(ctx, configOf) {
|
|
1547
1547
|
/** id → run record. */
|
|
@@ -1630,6 +1630,11 @@ function createSideEngine(ctx, configOf) {
|
|
|
1630
1630
|
parent: fields.parent,
|
|
1631
1631
|
...(fields.persona === undefined ? {} : { persona: fields.persona }),
|
|
1632
1632
|
...(fields.toolFilter === undefined ? {} : { toolFilter: fields.toolFilter }),
|
|
1633
|
+
// 0.2.1-alpha.2+ 的 `request` 与旧 `start(provider, request)` 是同一个
|
|
1634
|
+
// SubagentStartRequest 形状,`agentPreset` 必须一起转发。漏掉这一项
|
|
1635
|
+
// 会让子代理拿到默认全套工具,而 start 帧仍按配置上报
|
|
1636
|
+
// `toolFilter: 'preset'`,卡片于是对用户谎报"工具集由 preset 决定"。
|
|
1637
|
+
...(fields.agentPreset === undefined ? {} : { agentPreset: fields.agentPreset }),
|
|
1633
1638
|
},
|
|
1634
1639
|
})
|
|
1635
1640
|
return {
|
|
@@ -1893,7 +1898,6 @@ function createSideEngine(ctx, configOf) {
|
|
|
1893
1898
|
/** Which source produced deltas: process-local frames or durable chunks. */
|
|
1894
1899
|
let framesSeen = false
|
|
1895
1900
|
let chunksSeen = false
|
|
1896
|
-
let finished = false
|
|
1897
1901
|
|
|
1898
1902
|
/** Detach this run's stream listener (frames are per attempt). */
|
|
1899
1903
|
let detach = () => {}
|
|
@@ -2078,7 +2082,6 @@ function createSideEngine(ctx, configOf) {
|
|
|
2078
2082
|
})
|
|
2079
2083
|
return
|
|
2080
2084
|
}
|
|
2081
|
-
finished = true
|
|
2082
2085
|
sink.send('done', {
|
|
2083
2086
|
id,
|
|
2084
2087
|
text,
|
|
@@ -2140,9 +2143,12 @@ function createSideEngine(ctx, configOf) {
|
|
|
2140
2143
|
}
|
|
2141
2144
|
runs.clear()
|
|
2142
2145
|
streamListeners.clear()
|
|
2146
|
+
// 第二个流来源的订阅者也要清:只清 streamListeners 会在插件卸载后
|
|
2147
|
+
// 留下仍被 `session/event` 桥持有的闭包(它捕获了整个 engine 状态)。
|
|
2148
|
+
chunkListeners.clear()
|
|
2143
2149
|
}
|
|
2144
2150
|
|
|
2145
|
-
return { ask, cancel, dispose, capabilities
|
|
2151
|
+
return { ask, cancel, dispose, capabilities }
|
|
2146
2152
|
}
|
|
2147
2153
|
|
|
2148
2154
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -2594,7 +2600,10 @@ export function apply(ctx, patchConfig) {
|
|
|
2594
2600
|
writeError(res, 400, 'bad-request', '缺少 id')
|
|
2595
2601
|
return
|
|
2596
2602
|
}
|
|
2597
|
-
|
|
2603
|
+
// 只回本次取消的结果:`runs` 是跨会话的全局表,把它的 id 列表回给
|
|
2604
|
+
// 任意可信页面等于泄露别的会话正在追问的卡片 id(而 cancel 本身也不
|
|
2605
|
+
// 校验归属),Client 侧也没有任何地方消费这个字段。
|
|
2606
|
+
writeOk(res, { cancelled: engine.cancel(id) })
|
|
2598
2607
|
}
|
|
2599
2608
|
|
|
2600
2609
|
/** `/config` — merge a partial config into the user layer and persist it. */
|
|
@@ -2707,7 +2716,16 @@ export function apply(ctx, patchConfig) {
|
|
|
2707
2716
|
/** One request → one response; the dispatcher owns path parsing and the fence. */
|
|
2708
2717
|
const routeHandler = async (req, res) => {
|
|
2709
2718
|
if (!isTrustedRequest(req, trustedHostsOf())) {
|
|
2710
|
-
|
|
2719
|
+
// 说明"为什么被拒 + 怎么放行":这条 403 只会出现在 DSH 被绑到非回环
|
|
2720
|
+
// 地址、或经反向代理访问时(Host 既不是 loopback,也不在 trustedHosts
|
|
2721
|
+
// 里)。有意不自动信任绑定地址:插件读不到宿主的 bind 配置,猜一个就等于
|
|
2722
|
+
// 自己把 DNS-rebinding 防线拆掉。
|
|
2723
|
+
writeError(
|
|
2724
|
+
res,
|
|
2725
|
+
403,
|
|
2726
|
+
'forbidden',
|
|
2727
|
+
'Host 不在信任范围:请用 http://127.0.0.1:<端口> 访问,或把该地址写进 trustedHosts 配置',
|
|
2728
|
+
)
|
|
2711
2729
|
return
|
|
2712
2730
|
}
|
|
2713
2731
|
// Path parsing runs BEFORE the session gate now, because `ping` must be
|