dsh-sidecard-ask 1.2.0 → 1.3.0

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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+
5
+ **主题:把"客户端半的状态"变成宿主可读——补上导致前两次缺陷排查困难的观测盲点。**
6
+
7
+ - 新增 **`POST /sidecard-ask/api/diagnose`**:客户端半在启动、槽位落点变化、承载面回退时把一份受限摘要
8
+ (客户端版本、当前承载面、区域锚点是否可用、三个槽位的真实落点、两个适配器的可用性与失败原因、
9
+ 侧边卡片插件的版本与能力列表)上报给宿主;`GET /state` 的 **`value.client`** 即可读到。
10
+ 宿主对上报做**白名单 + 截断**(未知键丢弃、字符串 ≤200 字符、能力列表 ≤40 项、槽位 ≤8 项)。
11
+ - 客户端版本改为单一常量 `CLIENT_VERSION`:自检上报与 `api.version` 同源,报告不会声称一个页面其实没在跑的版本。
12
+ - 此前两次客户端静默失效(原生 tab 类型因 kind 冲突从未注册;客户端产物是上一代)都只能靠人工翻阅
13
+ 槽位清单才发现,这条通道把它们变成一条命令就能查到的事实。
14
+
15
+ **同时复核 1.2.1 的修复在运行实例上生效**:`sidebar.right.pane.tab` 的占用者现在**同时**包含
16
+ `sidecard-ask:card`(本插件原生右侧栏)与 `dsh-better-sidebar:sidecard-ask:card:workbench`(better-sidebar 镜像),
17
+ 两者均 active——kind 冲突已消除。
18
+
19
+ 自测 295 → **322 项**(verify 53 / contract 135 / smoke 134),新增用例覆盖上报的接收、白名单裁剪、
20
+ 截断、非法体拒绝,以及客户端上报体的构造。
21
+
22
+ ## 1.2.1
23
+
24
+ **主题:核对 DSH 0.2.0-rc.1 的适配性,并修掉由此暴露的一个真实缺陷。**
25
+
26
+ 核对(本机运行中的就是 0.2.0-rc.1,`@deepseek-ai/dsh` 的 `next` 通道):
27
+
28
+ - 包内容层面:`node tools/compat-probe.mjs 0.1.7-rc.2 0.2.0-rc.1` → 16 项能力**逐项一致**,没有任何依赖被移除或改名;
29
+ - 运行实例上:宿主半 `v1.2.0` 正常(`sideEngine`、provider `spawn,fork`、`capabilities.parent` 均可用);
30
+ 客户端半在 `shell.overlay` / `conversation.input.right` 都已注册;`standardProps` 仍含 `sessionId` 与 `inputActions`;
31
+ 真机端到端作答 `start→delta×31→status→done`(1.36 s)通过。新增的 `sessions.fork`/`uiWorkspace.forkSession`
32
+ 可选参数 `onCreated` 是纯增量,不影响既有调用。
33
+
34
+ **修掉的缺陷**:`sidebar.right.pane.tab` 的运行清单里只有 `dsh-better-sidebar:sidecard-ask:card`,
35
+ 没有本插件原生右侧栏的 `sidecard-ask:card`——两个适配器用了同一个 tab kind,而 better-sidebar 会把每个 tab 描述符
36
+ 镜像注册进**同一个**原生注册表(band `extension`),该注册表对同 band 同 kind 的二次注册抛错,后注册的一方静默失败。
37
+
38
+ - better-sidebar 承载面改用独立类型 `sidecard-ask:card:workbench`(`CARD_KIND_BETTER`),两端不再重叠;
39
+ - `contract-test` 新增"surface kinds must not collide"一节:**在测试里模拟该注册表的重复 kind 抛错规则**
40
+ 与 better-sidebar 的镜像注册行为,若两者再共用 kind,测试立即失败;
41
+ - 设置页自检行改为同时报告**两个适配器**(原先原生适配器的错误在界面上不可见,只能靠翻槽位清单发现)。
42
+
43
+ 自测 289 → **295 项**(verify 53 / contract 124 / smoke 118)。
44
+
3
45
  ## 1.2.0
4
46
 
5
47
  **主题:核对并加固与 dsh-better-sidebar 0.22.1 的适配。**
package/README.md CHANGED
@@ -183,6 +183,44 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
183
183
 
184
184
  > 注意:`version` 是加载到浏览器里的那份模块报告的版本。页面刷新后即可用它确认"跑的是不是 0.22.1"。
185
185
 
186
+ ### 5.2 与 DSH 0.2.0-rc.1 的适配核对(2026-09-29)
187
+
188
+ 本机实际运行的就是 **0.2.0-rc.1**(`@deepseek-ai/dsh` 的 `next` 通道;`latest` 仍是 0.1.7-rc.2)。核对分两层:
189
+
190
+ **① 包内容层面**(`node tools/compat-probe.mjs 0.1.7-rc.2 0.2.0-rc.1`):
191
+ 16 项能力**逐项与 0.1.7-rc.2 完全一致**——`shell.overlay`、`conversation.input.right`、`settings.section`、
192
+ `data-slot` 锚点、原生右侧栏、`sessions.using/retain`、归档门、`agent/assistant-stream`、子代理、`tools.schemas`、
193
+ `webServer` 路由……全部 ✅。
194
+
195
+ **② 在运行中的实例上实测**:
196
+
197
+ | 检查 | 结果 |
198
+ |---|---|
199
+ | 宿主半是否加载 | ✅ `/sidecard-ask/api/state` 返回 `dsh-sidecard-ask v1.2.0`,`sideEngine=true`(provider `spawn,fork`)、`capabilities.parent` 正常 |
200
+ | 客户端半是否注册 | ✅ `shell.overlay` 有 `sidecard-ask`(order 45、active)、`conversation.input.right` 有 `sidecard-ask-session-probe`、`settings.section` 有设置页 |
201
+ | 依赖的槽位与标准 prop | ✅ `conversation.input.right` 仍是 list/session,`standardProps` 仍含 **`sessionId`** 与 **`inputActions`** |
202
+ | 依赖的客户端服务 | ✅ `sessions`(`retain`/`using`/`scope`)、`slots`、`locale`、`layout`、`theme` 全在;`sessions.fork`/`uiWorkspace.forkSession` 只是**新增**了可选 `onCreated`,不影响既有调用 |
203
+ | 端到端作答 | ✅ 真机 `start→delta×31→status→done`(1.36 s,`streamSource=frames`),只读工具白名单照常生效 |
204
+
205
+ **③ 由此发现并修掉的真实缺陷(1.2.1)**:运行中的 `sidebar.right.pane.tab` 清单里只有
206
+ `dsh-better-sidebar:sidecard-ask:card`,**没有我的原生 `sidecard-ask:card`**。原因是两个适配器用了**同一个 tab kind**:
207
+ better-sidebar 会把收到的每个 tab 描述符**镜像注册进同一个原生注册表**(`sidebarRightTabs.register`,band `extension`),
208
+ 而该注册表对*同 band 同 kind 的二次注册直接抛错*(源码注释:*"Everything else colliding on a kind throws"*)——
209
+ 后注册的那一方就此失败,而且**在界面上不可见**。修法:
210
+
211
+ 1. better-sidebar 承载面改用独立类型 **`sidecard-ask:card:workbench`**,两端 kind 不再重叠;
212
+ 2. `contract-test` **模拟该注册表的"重复 kind 抛错"规则**,此类冲突今后会直接让测试失败;
213
+ 3. 设置页自检行改为**同时报告两个适配器**的状态(此前原生适配器的错误压根看不到)。
214
+
215
+ **④ 修复后的运行实例复核(2026-09-29,1.3.0)**:`sidebar.right.pane.tab` 的占用者现在**同时**包含
216
+
217
+ | key | 来源 |
218
+ |---|---|
219
+ | `sidecard-ask:card` | 本插件的**原生右侧栏**承载面(1.2.1 之前完全缺失) |
220
+ | `dsh-better-sidebar:sidecard-ask:card:workbench` | 经 dsh-better-sidebar 镜像注册的承载面(独立 kind) |
221
+
222
+ 两者共存、均 `active: true` —— 即"同 kind 冲突"已被消除,`auto` 默认走原生右侧栏,强制 `sideSurface: better-sidebar` 仍可用。
223
+
186
224
  ---
187
225
 
188
226
  ## 六、未知 DSH API:占位接口与替换方式
@@ -208,8 +246,13 @@ ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => { ... })
208
246
  - **占位 1(草稿降级)**:`ctx.get('conversation').input.for(scope)` → `{ state.getSnapshot().draft, setDraft(text) }`。
209
247
  这是**未出现在服务目录里**的接口(属 harness 内部形态),所以只作为第二顺位降级;搜索 `PLACEHOLDER: composer-draft`。
210
248
  - **占位 2(最后兜底)**:前两者都不可用时,插件抛出可读错误并提示用户复制文本,搜索 `PLACEHOLDER: clipboard-fallback`。
211
- - **线协议**:客户端与宿主半之间是插件**自有的** `/sidecard-ask/api`(`GET /state`、`POST /ask|cancel|config|reset`),
249
+ - **线协议**:客户端与宿主半之间是插件**自有的** `/sidecard-ask/api`(`GET /state`、`POST /ask|cancel|config|reset|diagnose`),
212
250
  不依赖任何 harness 内部 RPC;若未来 harness 提供正式的同进程 RPC,替换点就是 `client.js` §3 的 `postJson/streamAsk`。
251
+ - **`POST /diagnose`(客户端自检上报)**:宿主**看不到浏览器里的客户端半**,所以客户端在启动、槽位落点变化、
252
+ 承载面回退时把自己的一份受限摘要(版本、承载面、区域锚点、槽位落点、两个适配器的可用性与原因、侧边卡片插件版本与能力)
253
+ POST 给宿主;随后 `GET /state` 的 `value.client` 就能读到它。宿主侧对上报做**白名单 + 长度截断**(未知键丢弃、
254
+ 字符串截断到 200 字符、能力列表最多 40 项),所以页面即使被注入垃圾也不会污染状态面。
255
+ 这条通道是在两次"客户端静默失效只能靠人工翻槽位清单才发现"之后补上的。
213
256
 
214
257
  > 约定:所有占位点都写成 `PLACEHOLDER: <名字>` 注释 + 可运行的降级路径,替换时只需改该函数的实现,调用方(卡片、设置页)无需改动。
215
258
 
@@ -353,7 +396,7 @@ node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失
353
396
  ```
354
397
 
355
398
  三个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
356
- 当前规模:verify 53 项 + contract 105 项 + smoke 118 项 = **276 项全部通过**。
399
+ 当前规模:verify 53 项 + contract 135 项 + smoke 134 项 = **322 项全部通过**。
357
400
 
358
401
  ### 10.2 版本能力探测(§7 矩阵的来源)
359
402
 
@@ -380,8 +423,30 @@ $tmp = Join-Path $env:TEMP 'probe.json'
380
423
  [System.IO.File]::WriteAllText($tmp, $body, (New-Object System.Text.UTF8Encoding($false)))
381
424
  (Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/ask -Method POST `
382
425
  -ContentType 'application/json; charset=utf-8' -InFile $tmp -TimeoutSec 240 -UseBasicParsing).Content
426
+
427
+ # 3) 客户端半的真实状态(浏览器跑的那份代码/槽位落点/两个适配器)
428
+ # 页面加载后 GET /state,看 value.client —— 这是唯一能从页面外读到客户端状态的通道
429
+ ((Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state -UseBasicParsing).Content | ConvertFrom-Json).value.client
430
+ ```
431
+
432
+ **客户端状态样例**(1.3.0 起):
433
+
434
+ ```json
435
+ {
436
+ "at": 1790555771338,
437
+ "version": "1.3.0",
438
+ "surface": "native-rightbar",
439
+ "zoneAnchors": true,
440
+ "sessionKnown": true,
441
+ "slots": { "overlay": "shell.overlay", "settings": "settings.section", "sessionProbe": "conversation.input.right" },
442
+ "native": { "available": true },
443
+ "better": { "available": true, "version": "0.22.1", "features": ["badge", "…", "tabMeta"] }
444
+ }
383
445
  ```
384
446
 
447
+ `native.reason` / `better.reason` 会写明**为什么**某个承载面不可用(例如 `no-tab-meta` 或注册表的报错原文)——
448
+ 1.2.1 修掉的那个 tab kind 冲突正是靠这类信息才定位到的。
449
+
385
450
  **2026-09-28 在 DSH 0.1.7-rc.2 上的实测结果**(`provider=spawn`、`sideTools=readonly`):
386
451
  `start x1 → reasoning x181 → delta x90 → status x1 → done x1`,耗时 2.6 s,`done.text` 为真实模型答案。
387
452
  这条记录同时说明:子代理启动、只读工具白名单(与真实工具表求交后)、流式帧桥接、SSE 线协议、运行结束后的 `dispose()` 都是**在真实宿主上跑通的**,
@@ -471,5 +536,5 @@ npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>" # 旧名
471
536
  本仓库当前文件比运行中的宿主模块新(1.0.1 的「归档父会话不再一刀切 / 持久化 chunk 流式源 / 能力门控 / 路由降级」),
472
537
  **重启后**这些改动才生效;`/state` 的 `version` 与 `capabilities.parent` 字段可以直接确认是否已加载新版。
473
538
  6. **除 0.1.7-rc.2 外,其余版本只做了产物层验证**:见 §7.3——插件在 0.1.2-rc.1 … 0.1.6-alpha.2 上安装启动过**没有**得到验证,
474
- 验证到的是"这些版本缺哪些 API + 插件对每个缺失都有分支",分支本身由 276 项自测覆盖。
539
+ 验证到的是"这些版本缺哪些 API + 插件对每个缺失都有分支",分支本身由 289 项自测覆盖。
475
540
 
package/client.js CHANGED
@@ -42,9 +42,34 @@ window.__ModuleLoader__.load({
42
42
  settings: 'sidecard-ask-settings',
43
43
  sessionProbe: 'sidecard-ask-session-probe',
44
44
  }
45
- /** Side-card tab type registered in a side-card host. */
45
+ /**
46
+ * Version of this Client half. Single source of truth: the self-report to
47
+ * the Host and the module's `api.version` both read it, so a report can
48
+ * never claim a generation the browser is not actually running.
49
+ */
50
+ const CLIENT_VERSION = '1.3.0'
51
+
52
+ /**
53
+ * Tab kind served by the DSH native right rail (also its implementation id).
54
+ */
46
55
  const CARD_KIND = 'sidecard-ask:card'
47
56
 
57
+ /**
58
+ * Tab type served by the dsh-better-sidebar plugin.
59
+ *
60
+ * It MUST differ from {@link CARD_KIND}: better-sidebar mirrors every tab
61
+ * descriptor it is given into the SAME native registry
62
+ * (`sidebarRightTabs.register`, id `dsh-better-sidebar:<id>`, band
63
+ * `extension`), and that registry THROWS when a kind already has a
64
+ * registration in the same band ("Everything else colliding on a kind
65
+ * throws"). Sharing one kind made the two adapters collide: whichever
66
+ * registered second failed — observed live on 0.2.0-rc.1, where the pane
67
+ * listed `dsh-better-sidebar:sidecard-ask:card` and no native
68
+ * `sidecard-ask:card` at all. `test/contract-test.mjs` now emulates that
69
+ * registry rule so the collision cannot come back.
70
+ */
71
+ const CARD_KIND_BETTER = 'sidecard-ask:card:workbench'
72
+
48
73
  /**
49
74
  * Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
50
75
  * before `/state` answers (first paint) and when the Host is unreachable;
@@ -170,6 +195,7 @@ window.__ModuleLoader__.load({
170
195
  diagSidecard: '侧边卡片插件(dsh-better-sidebar)',
171
196
  diagFeatures: '项能力',
172
197
  diagNoTabMeta: '版本过旧:没有 tabMeta 能力,已跳过它改用其它承载面',
198
+ diagNativeKind: 'kind',
173
199
  toolGuardUnavailable: '该 provider 不支持工具白名单,本次作答继承了当前会话的工具',
174
200
  toolGuardInherited: '按配置继承当前会话的工具',
175
201
  diagUnreachable: '宿主接口不可达:{msg}',
@@ -272,6 +298,7 @@ window.__ModuleLoader__.load({
272
298
  diagSidecard: 'Side-card plugin (dsh-better-sidebar)',
273
299
  diagFeatures: 'capabilities',
274
300
  diagNoTabMeta: 'too old: no tabMeta capability, skipped in favour of another surface',
301
+ diagNativeKind: 'kind',
275
302
  toolGuardUnavailable: 'This provider supports no tool allow-list, so the answer inherited the session tools',
276
303
  toolGuardInherited: 'Inherits the session tools, as configured',
277
304
  diagUnreachable: 'Host API unreachable: {msg}',
@@ -889,20 +916,20 @@ window.__ModuleLoader__.load({
889
916
  },
890
917
  open(card) {
891
918
  if (!this.available() || typeof this.service.openTab !== 'function') return false
892
- const tabId = `${CARD_KIND}:${card.id}`
919
+ const tabId = `${CARD_KIND_BETTER}:${card.id}`
893
920
  // `meta` is the transport for the card id (feature `tabMeta`), and
894
921
  // `dedupeKey` on our descriptor is what collapses repeat opens onto
895
922
  // the same tab; both are part of the stable consumer contract
896
923
  // (`lib/types/client/service.d.ts`, unchanged across 0.22.0 → 0.22.1).
897
924
  this.service.openTab(
898
- { type: CARD_KIND, id: tabId, title: card.question.slice(0, 32), meta: { cardId: card.id } },
925
+ { type: CARD_KIND_BETTER, id: tabId, title: card.question.slice(0, 32), meta: { cardId: card.id } },
899
926
  store.state.sessionId === null ? undefined : { sessionId: store.state.sessionId },
900
927
  )
901
928
  return true
902
929
  },
903
930
  close(card) {
904
931
  if (!this.available() || typeof this.service.closeTab !== 'function') return false
905
- this.service.closeTab(`${CARD_KIND}:${card.id}`)
932
+ this.service.closeTab(`${CARD_KIND_BETTER}:${card.id}`)
906
933
  return true
907
934
  },
908
935
  },
@@ -921,6 +948,41 @@ window.__ModuleLoader__.load({
921
948
  */
922
949
  let capturedComposerActions = null
923
950
 
951
+ /**
952
+ * What this Client half wants the Host to know about its own state.
953
+ *
954
+ * The Host cannot inspect a browser-side plugin, so this report is what
955
+ * makes a silent CLIENT-side failure visible from outside the page
956
+ * (`GET /sidecard-ask/api/state` → `value.client`). It was added after two
957
+ * such failures were only diagnosable by hand-inspecting the live slot
958
+ * inventory: a native tab type that never registered, and a client build
959
+ * that was still the previous generation.
960
+ *
961
+ * @param {object} state - `store.state`.
962
+ * @returns {object} the report body (allow-listed again on the Host).
963
+ */
964
+ function buildClientReport(state) {
965
+ return {
966
+ // Read from the module's own version constant so the report can never
967
+ // claim a generation the browser is not actually running.
968
+ version: CLIENT_VERSION,
969
+ surface: state.surface,
970
+ zoneAnchors: state.zoneAnchors,
971
+ sessionKnown: typeof state.sessionId === 'string' && state.sessionId !== '',
972
+ slots: { ...state.slots },
973
+ native: {
974
+ available: surfaces.native.available(),
975
+ ...(surfaces.native.error === null ? {} : { reason: String(surfaces.native.error) }),
976
+ },
977
+ better: {
978
+ available: surfaces.better.available(),
979
+ ...(surfaces.better.version === null ? {} : { version: surfaces.better.version }),
980
+ ...(surfaces.better.reason === null ? {} : { reason: String(surfaces.better.reason) }),
981
+ features: surfaces.better.features,
982
+ },
983
+ }
984
+ }
985
+
924
986
  /**
925
987
  * The card id an external host is CURRENTLY rendering. `CardHost` sets it
926
988
  * on mount and clears it on unmount, which is what turns "the adapter
@@ -1554,7 +1616,7 @@ window.__ModuleLoader__.load({
1554
1616
  h('div', { key: 'slots' }, `${t('diagSlots')}: ${Object.entries(state.slots).map(([k, v]) => `${k}=${v}`).join(' · ')}`),
1555
1617
  // The side-card plugin's own report: which version is loaded and
1556
1618
  // whether it advertises the capability this adapter needs.
1557
- h('div', { key: 'sidecard' }, `${t('diagSidecard')}: ${describeSidecardAdapter()}`),
1619
+ h('div', { key: 'sidecard' }, `${t('diagSidecard')}: ${describeSurfaces()}`),
1558
1620
  h('div', { key: 'path' }, `${t('settingsTitle')} → ${diag.provenance?.persistedPath ?? ''}`),
1559
1621
  ])
1560
1622
  : null)
@@ -1582,10 +1644,22 @@ window.__ModuleLoader__.load({
1582
1644
  }
1583
1645
 
1584
1646
  /**
1585
- * One line describing how the optional side-card plugin resolved, for the
1586
- * settings page's capability check. Reported from the plugin's OWN
1587
- * `version`/`features` fields, so the answer is what is running, not what
1588
- * package.json claims.
1647
+ * One line describing how each optional side-card surface resolved, for the
1648
+ * settings page's capability check. Both adapters are reported, because a
1649
+ * failing NATIVE registration was previously invisible here (it only showed
1650
+ * up as a missing entry in the pane's own slot inventory).
1651
+ * @returns {string} human-readable state.
1652
+ */
1653
+ function describeSurfaces() {
1654
+ const native = surfaces.native.available()
1655
+ ? `${t('yes')} · ${t('diagNativeKind')}=${CARD_KIND}`
1656
+ : `${t('no')}${surfaces.native.error === null ? '' : `(${surfaces.native.error})`}`
1657
+ return `${t('surfaceNative')}: ${native} / ${t('surfaceBetter')}: ${describeSidecardAdapter()}`
1658
+ }
1659
+
1660
+ /**
1661
+ * The optional side-card plugin's own report: which version is loaded and
1662
+ * whether it advertises the capability this adapter needs.
1589
1663
  * @returns {string} human-readable state.
1590
1664
  */
1591
1665
  function describeSidecardAdapter() {
@@ -1711,6 +1785,7 @@ window.__ModuleLoader__.load({
1711
1785
  if (!opened) {
1712
1786
  store.set({ surface: 'flow' })
1713
1787
  patchCard(card.id, { surface: 'flow' })
1788
+ scheduleReport(0)
1714
1789
  } else {
1715
1790
  // Render proof: an adapter that accepts the open but never
1716
1791
  // mounts our body would leave an empty tab. Unless the card is
@@ -1725,6 +1800,7 @@ window.__ModuleLoader__.load({
1725
1800
  store.set({ surface: 'flow', surfaceNote: t('surfaceUnproven') })
1726
1801
  patchCard(card.id, { surface: 'flow' })
1727
1802
  toast(t('surfaceUnproven'))
1803
+ scheduleReport(0)
1728
1804
  }, 600)
1729
1805
  pendingProofs.add(proof)
1730
1806
  }
@@ -2086,10 +2162,26 @@ window.__ModuleLoader__.load({
2086
2162
  }
2087
2163
  }
2088
2164
 
2165
+ /**
2166
+ * Debounced self-report to the Host. Diagnostics are best-effort: a
2167
+ * failure is logged and never surfaces as a user-visible error.
2168
+ */
2169
+ let reportTimer = null
2170
+ const scheduleReport = (delay = 600) => {
2171
+ if (reportTimer !== null) clearTimeout(reportTimer)
2172
+ reportTimer = setTimeout(() => {
2173
+ reportTimer = null
2174
+ void postJson('diagnose', buildClientReport(store.state)).catch((error) => {
2175
+ console.warn(`[${PLUGIN_ID}] 自检上报失败(不影响功能):`, error?.message ?? error)
2176
+ })
2177
+ }, delay)
2178
+ }
2179
+
2089
2180
  /** Record which rung a registration actually landed on. */
2090
2181
  const noteSlot = (name, key) => {
2091
2182
  const slots = { ...(store.state.slots ?? {}), [name]: key }
2092
2183
  store.set({ slots })
2184
+ scheduleReport()
2093
2185
  }
2094
2186
 
2095
2187
  disposers.push(firstLiveSlot(
@@ -2245,7 +2337,11 @@ window.__ModuleLoader__.load({
2245
2337
  return
2246
2338
  }
2247
2339
  const off = service.registerTab({
2248
- id: CARD_KIND,
2340
+ // A DIFFERENT type from the native rail's: better-sidebar mirrors
2341
+ // this descriptor into `sidebarRightTabs` as an `extension`-band
2342
+ // registration, and a second registration of the same kind in
2343
+ // that band throws (see CARD_KIND_BETTER).
2344
+ id: CARD_KIND_BETTER,
2249
2345
  title: () => t('answerTitle'),
2250
2346
  description: () => t('settingsDesc'),
2251
2347
  order: 120,
@@ -2304,12 +2400,16 @@ window.__ModuleLoader__.load({
2304
2400
 
2305
2401
  // ── boot ─────────────────────────────────────────────────────────
2306
2402
  void refreshState().catch(() => { /* the settings page shows the failure */ })
2403
+ // One report after the slot ladders have had time to settle, so the
2404
+ // Host ends up holding the CLIENT's real registration state.
2405
+ scheduleReport(2500)
2307
2406
  disposers.push(() => {
2308
2407
  for (const controller of controllers.values()) controller.abort()
2309
2408
  controllers.clear()
2310
2409
  for (const proof of pendingProofs) clearTimeout(proof)
2311
2410
  pendingProofs.clear()
2312
2411
  if (toastTimer !== null) clearTimeout(toastTimer)
2412
+ if (reportTimer !== null) clearTimeout(reportTimer)
2313
2413
  store.set({
2314
2414
  trigger: null,
2315
2415
  popover: null,
@@ -2337,13 +2437,14 @@ window.__ModuleLoader__.load({
2337
2437
  * `window` and exercises these without a browser.
2338
2438
  */
2339
2439
  api: Object.freeze({
2340
- version: '1.2.0',
2440
+ version: CLIENT_VERSION,
2341
2441
  /** Read-only state accessor for diagnostics and the test harness. */
2342
2442
  snapshot: () => store.state,
2343
2443
  pure: Object.freeze({
2344
2444
  CLIENT_DEFAULTS,
2345
2445
  IDS,
2346
2446
  CARD_KIND,
2447
+ CARD_KIND_BETTER,
2347
2448
  parseSseBlock,
2348
2449
  truncateSelection,
2349
2450
  parseShortcut,
@@ -2354,6 +2455,7 @@ window.__ModuleLoader__.load({
2354
2455
  selectionSignature,
2355
2456
  composeMainPrompt,
2356
2457
  askInMainConversation,
2458
+ buildClientReport,
2357
2459
  renderRichText,
2358
2460
  pickSurface,
2359
2461
  dict: DICT,
package/index.js CHANGED
@@ -42,7 +42,7 @@ export const name = 'dsh-sidecard-ask'
42
42
  export const inject = ['webServer']
43
43
 
44
44
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
45
- export const PLUGIN_VERSION = '1.2.0'
45
+ export const PLUGIN_VERSION = '1.3.0'
46
46
 
47
47
  /** Route prefix of the plugin's own API. */
48
48
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -1047,6 +1047,58 @@ export function apply(ctx, patchConfig) {
1047
1047
  const engine = createSideEngine(ctx, configOf)
1048
1048
  ctx.effect(() => () => { engine.dispose() }, 'sidecard-ask: side engine')
1049
1049
 
1050
+ /**
1051
+ * The Client half's own status report, or null before the first one.
1052
+ *
1053
+ * This exists because the Client runs in a browser this plugin cannot
1054
+ * inspect: without it, a broken CLIENT-side registration (for example two
1055
+ * adapters colliding on one tab kind, or a slot that never went live) is
1056
+ * invisible from the Host — it only shows up as a missing entry deep in a
1057
+ * slot inventory. The Client posts a bounded, allow-listed summary after its
1058
+ * registration ladders settle; `/state` hands it back.
1059
+ */
1060
+ let clientReport = null
1061
+
1062
+ /** Allow-listed, size-bounded view of one Client report. */
1063
+ function sanitizeClientReport(raw) {
1064
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
1065
+ const short = (value, limit = 200) => (typeof value === 'string' ? value.slice(0, limit) : undefined)
1066
+ const flag = value => (typeof value === 'boolean' ? value : undefined)
1067
+ const out = {
1068
+ at: Date.now(),
1069
+ version: short(raw.version, 40),
1070
+ surface: short(raw.surface, 40),
1071
+ zoneAnchors: flag(raw.zoneAnchors),
1072
+ sessionKnown: flag(raw.sessionKnown),
1073
+ slots: {},
1074
+ native: {},
1075
+ better: {},
1076
+ }
1077
+ if (raw.slots !== null && typeof raw.slots === 'object' && !Array.isArray(raw.slots)) {
1078
+ for (const [slot, key] of Object.entries(raw.slots).slice(0, 8)) {
1079
+ const name = short(slot, 40)
1080
+ const value = short(key, 80)
1081
+ if (name !== undefined && value !== undefined) out.slots[name] = value
1082
+ }
1083
+ }
1084
+ for (const [target, source] of [['native', raw.native], ['better', raw.better]]) {
1085
+ if (source === null || typeof source !== 'object' || Array.isArray(source)) continue
1086
+ const available = flag(source.available)
1087
+ if (available !== undefined) out[target].available = available
1088
+ const version = short(source.version, 40)
1089
+ if (version !== undefined) out[target].version = version
1090
+ const reason = short(source.reason ?? source.error, 200)
1091
+ if (reason !== undefined && reason !== '') out[target].reason = reason
1092
+ if (Array.isArray(source.features)) {
1093
+ out[target].features = source.features
1094
+ .filter(feature => typeof feature === 'string')
1095
+ .slice(0, 40)
1096
+ .map(feature => feature.slice(0, 40))
1097
+ }
1098
+ }
1099
+ return out
1100
+ }
1101
+
1050
1102
  /** Whether the request may reach the plugin routes. */
1051
1103
  const trustedHostsOf = () => {
1052
1104
  const runtime = ctx.get('webRuntime')
@@ -1074,9 +1126,35 @@ export function apply(ctx, patchConfig) {
1074
1126
  // Informational: which process-local stream the host bridges.
1075
1127
  streamSource: 'agent/assistant-stream',
1076
1128
  },
1129
+ // The Client half's last self-report (null until it posts one).
1130
+ client: clientReport,
1077
1131
  })
1078
1132
  }
1079
1133
 
1134
+ /**
1135
+ * `/diagnose` — the Client half reports its own registration state.
1136
+ *
1137
+ * A browser-side plugin cannot be inspected from the Host, so this is the
1138
+ * only channel that turns "the card silently fell back" or "the native tab
1139
+ * never registered" into something readable from outside the page.
1140
+ */
1141
+ const handleDiagnose = async (req, res) => {
1142
+ let body
1143
+ try {
1144
+ body = await readJsonBody(req)
1145
+ } catch (error) {
1146
+ writeError(res, 400, toWireError(error, 'bad-request').code, toWireError(error).message)
1147
+ return
1148
+ }
1149
+ const report = sanitizeClientReport(body)
1150
+ if (report === null) {
1151
+ writeError(res, 400, 'bad-request', '诊断上报必须是对象')
1152
+ return
1153
+ }
1154
+ clientReport = report
1155
+ writeOk(res, { accepted: true, at: report.at })
1156
+ }
1157
+
1080
1158
  /** `/ask` — one SSE stream per side answer. */
1081
1159
  const handleAsk = async (req, res) => {
1082
1160
  let body
@@ -1164,7 +1242,7 @@ export function apply(ctx, patchConfig) {
1164
1242
  * The HTTP methods the plugin serves; kept in one place because the route
1165
1243
  * registration has two shapes (see below).
1166
1244
  */
1167
- const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset']
1245
+ const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose']
1168
1246
 
1169
1247
  /** One request → one response; the dispatcher owns path parsing and the fence. */
1170
1248
  const routeHandler = async (req, res) => {
@@ -1197,6 +1275,7 @@ export function apply(ctx, patchConfig) {
1197
1275
  else if (method === 'cancel') await handleCancel(req, res)
1198
1276
  else if (method === 'config') await handleConfig(req, res)
1199
1277
  else if (method === 'reset') handleReset(req, res)
1278
+ else if (method === 'diagnose') await handleDiagnose(req, res)
1200
1279
  else writeError(res, 404, 'not-found', `未知接口 "${method}"`)
1201
1280
  } catch (error) {
1202
1281
  const wire = toWireError(error)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",