dsh-sidecard-ask 1.2.1 → 1.3.1

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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.1
4
+
5
+ **主题:让"两半代际不一致"成为一个被处理的状态,而不是噪声。**
6
+
7
+ 这个插件的宿主半只在**重启 DSH** 时更新,客户端半在**页面加载**时更新,所以"新客户端 + 旧宿主"是结构性常态
8
+ (当前本机就是这个状态:页面已跑到 1.3.x,宿主仍是重启前的 1.2.0)。
9
+
10
+ - 客户端在自检上报被宿主以 `not-found`(该路由还不存在,旧宿主)、`bad-response`(SPA 兜底返回 HTML)
11
+ 或 `unreachable` 拒绝时,**记住"此宿主不支持自检"并停止重试**,只打一条 info 说明"重启 DSH 后生效";
12
+ 其它错误(例如配置类真实错误)仍按 warning 记录,不会错误地关闭通道。
13
+ - 新增纯函数 `isDiagnosticsUnsupported(error)` 并纳入契约测试(5 条断言覆盖各错误码)。
14
+
15
+ 自测 322 → **327 项**(verify 53 / contract 140 / smoke 134)。
16
+
17
+ ## 1.3.0
18
+
19
+ **主题:把"客户端半的状态"变成宿主可读——补上导致前两次缺陷排查困难的观测盲点。**
20
+
21
+ - 新增 **`POST /sidecard-ask/api/diagnose`**:客户端半在启动、槽位落点变化、承载面回退时把一份受限摘要
22
+ (客户端版本、当前承载面、区域锚点是否可用、三个槽位的真实落点、两个适配器的可用性与失败原因、
23
+ 侧边卡片插件的版本与能力列表)上报给宿主;`GET /state` 的 **`value.client`** 即可读到。
24
+ 宿主对上报做**白名单 + 截断**(未知键丢弃、字符串 ≤200 字符、能力列表 ≤40 项、槽位 ≤8 项)。
25
+ - 客户端版本改为单一常量 `CLIENT_VERSION`:自检上报与 `api.version` 同源,报告不会声称一个页面其实没在跑的版本。
26
+ - 此前两次客户端静默失效(原生 tab 类型因 kind 冲突从未注册;客户端产物是上一代)都只能靠人工翻阅
27
+ 槽位清单才发现,这条通道把它们变成一条命令就能查到的事实。
28
+
29
+ **同时复核 1.2.1 的修复在运行实例上生效**:`sidebar.right.pane.tab` 的占用者现在**同时**包含
30
+ `sidecard-ask:card`(本插件原生右侧栏)与 `dsh-better-sidebar:sidecard-ask:card:workbench`(better-sidebar 镜像),
31
+ 两者均 active——kind 冲突已消除。
32
+
33
+ 自测 295 → **322 项**(verify 53 / contract 135 / smoke 134),新增用例覆盖上报的接收、白名单裁剪、
34
+ 截断、非法体拒绝,以及客户端上报体的构造。
35
+
3
36
  ## 1.2.1
4
37
 
5
38
  **主题:核对 DSH 0.2.0-rc.1 的适配性,并修掉由此暴露的一个真实缺陷。**
package/README.md CHANGED
@@ -212,6 +212,15 @@ better-sidebar 会把收到的每个 tab 描述符**镜像注册进同一个原
212
212
  2. `contract-test` **模拟该注册表的"重复 kind 抛错"规则**,此类冲突今后会直接让测试失败;
213
213
  3. 设置页自检行改为**同时报告两个适配器**的状态(此前原生适配器的错误压根看不到)。
214
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
+
215
224
  ---
216
225
 
217
226
  ## 六、未知 DSH API:占位接口与替换方式
@@ -237,8 +246,13 @@ ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => { ... })
237
246
  - **占位 1(草稿降级)**:`ctx.get('conversation').input.for(scope)` → `{ state.getSnapshot().draft, setDraft(text) }`。
238
247
  这是**未出现在服务目录里**的接口(属 harness 内部形态),所以只作为第二顺位降级;搜索 `PLACEHOLDER: composer-draft`。
239
248
  - **占位 2(最后兜底)**:前两者都不可用时,插件抛出可读错误并提示用户复制文本,搜索 `PLACEHOLDER: clipboard-fallback`。
240
- - **线协议**:客户端与宿主半之间是插件**自有的** `/sidecard-ask/api`(`GET /state`、`POST /ask|cancel|config|reset`),
249
+ - **线协议**:客户端与宿主半之间是插件**自有的** `/sidecard-ask/api`(`GET /state`、`POST /ask|cancel|config|reset|diagnose`),
241
250
  不依赖任何 harness 内部 RPC;若未来 harness 提供正式的同进程 RPC,替换点就是 `client.js` §3 的 `postJson/streamAsk`。
251
+ - **`POST /diagnose`(客户端自检上报)**:宿主**看不到浏览器里的客户端半**,所以客户端在启动、槽位落点变化、
252
+ 承载面回退时把自己的一份受限摘要(版本、承载面、区域锚点、槽位落点、两个适配器的可用性与原因、侧边卡片插件版本与能力)
253
+ POST 给宿主;随后 `GET /state` 的 `value.client` 就能读到它。宿主侧对上报做**白名单 + 长度截断**(未知键丢弃、
254
+ 字符串截断到 200 字符、能力列表最多 40 项),所以页面即使被注入垃圾也不会污染状态面。
255
+ 这条通道是在两次"客户端静默失效只能靠人工翻槽位清单才发现"之后补上的。
242
256
 
243
257
  > 约定:所有占位点都写成 `PLACEHOLDER: <名字>` 注释 + 可运行的降级路径,替换时只需改该函数的实现,调用方(卡片、设置页)无需改动。
244
258
 
@@ -382,7 +396,7 @@ node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失
382
396
  ```
383
397
 
384
398
  三个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
385
- 当前规模:verify 53 项 + contract 118 项 + smoke 118 项 = **289 项全部通过**。
399
+ 当前规模:verify 53 项 + contract 140 项 + smoke 134 项 = **327 项全部通过**。
386
400
 
387
401
  ### 10.2 版本能力探测(§7 矩阵的来源)
388
402
 
@@ -409,8 +423,34 @@ $tmp = Join-Path $env:TEMP 'probe.json'
409
423
  [System.IO.File]::WriteAllText($tmp, $body, (New-Object System.Text.UTF8Encoding($false)))
410
424
  (Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/ask -Method POST `
411
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
412
430
  ```
413
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
+ }
445
+ ```
446
+
447
+ `native.reason` / `better.reason` 会写明**为什么**某个承载面不可用(例如 `no-tab-meta` 或注册表的报错原文)——
448
+ 1.2.1 修掉的那个 tab kind 冲突正是靠这类信息才定位到的。
449
+
450
+ > **两半代际不一致是常态**:宿主半只在重启 DSH 时更新,客户端半在页面加载时更新。
451
+ > 所以"新客户端 + 旧宿主"很常见——此时 `/diagnose` 还不存在,客户端会**识别这一点、只提示一次并停止重试**
452
+ > (`not-found` / SPA 兜底 HTML / 不可达三类失败),不会每次加载都刷警告。
453
+
414
454
  **2026-09-28 在 DSH 0.1.7-rc.2 上的实测结果**(`provider=spawn`、`sideTools=readonly`):
415
455
  `start x1 → reasoning x181 → delta x90 → status x1 → done x1`,耗时 2.6 s,`done.text` 为真实模型答案。
416
456
  这条记录同时说明:子代理启动、只读工具白名单(与真实工具表求交后)、流式帧桥接、SSE 线协议、运行结束后的 `dispose()` 都是**在真实宿主上跑通的**,
package/client.js CHANGED
@@ -42,6 +42,13 @@ window.__ModuleLoader__.load({
42
42
  settings: 'sidecard-ask-settings',
43
43
  sessionProbe: 'sidecard-ask-session-probe',
44
44
  }
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.1'
51
+
45
52
  /**
46
53
  * Tab kind served by the DSH native right rail (also its implementation id).
47
54
  */
@@ -941,6 +948,58 @@ window.__ModuleLoader__.load({
941
948
  */
942
949
  let capturedComposerActions = null
943
950
 
951
+ /**
952
+ * Whether `POST /diagnose` is worth calling on this host generation.
953
+ *
954
+ * The two halves update independently — the Host half only on a DSH
955
+ * restart, the Client half on a page load — so a page can easily run a
956
+ * NEWER Client against an older Host that has no `/diagnose` route. The
957
+ * first refusal turns the channel off for this page instead of retrying
958
+ * (and warning) on every report.
959
+ *
960
+ * @param {unknown} error - the thrown wire error.
961
+ * @returns {boolean} true when the failure means "this host lacks the route".
962
+ */
963
+ function isDiagnosticsUnsupported(error) {
964
+ const code = error?.code
965
+ return code === 'not-found' || code === 'bad-response' || code === 'unreachable'
966
+ }
967
+
968
+ /**
969
+ * What this Client half wants the Host to know about its own state.
970
+ *
971
+ * The Host cannot inspect a browser-side plugin, so this report is what
972
+ * makes a silent CLIENT-side failure visible from outside the page
973
+ * (`GET /sidecard-ask/api/state` → `value.client`). It was added after two
974
+ * such failures were only diagnosable by hand-inspecting the live slot
975
+ * inventory: a native tab type that never registered, and a client build
976
+ * that was still the previous generation.
977
+ *
978
+ * @param {object} state - `store.state`.
979
+ * @returns {object} the report body (allow-listed again on the Host).
980
+ */
981
+ function buildClientReport(state) {
982
+ return {
983
+ // Read from the module's own version constant so the report can never
984
+ // claim a generation the browser is not actually running.
985
+ version: CLIENT_VERSION,
986
+ surface: state.surface,
987
+ zoneAnchors: state.zoneAnchors,
988
+ sessionKnown: typeof state.sessionId === 'string' && state.sessionId !== '',
989
+ slots: { ...state.slots },
990
+ native: {
991
+ available: surfaces.native.available(),
992
+ ...(surfaces.native.error === null ? {} : { reason: String(surfaces.native.error) }),
993
+ },
994
+ better: {
995
+ available: surfaces.better.available(),
996
+ ...(surfaces.better.version === null ? {} : { version: surfaces.better.version }),
997
+ ...(surfaces.better.reason === null ? {} : { reason: String(surfaces.better.reason) }),
998
+ features: surfaces.better.features,
999
+ },
1000
+ }
1001
+ }
1002
+
944
1003
  /**
945
1004
  * The card id an external host is CURRENTLY rendering. `CardHost` sets it
946
1005
  * on mount and clears it on unmount, which is what turns "the adapter
@@ -1743,6 +1802,7 @@ window.__ModuleLoader__.load({
1743
1802
  if (!opened) {
1744
1803
  store.set({ surface: 'flow' })
1745
1804
  patchCard(card.id, { surface: 'flow' })
1805
+ scheduleReport(0)
1746
1806
  } else {
1747
1807
  // Render proof: an adapter that accepts the open but never
1748
1808
  // mounts our body would leave an empty tab. Unless the card is
@@ -1757,6 +1817,7 @@ window.__ModuleLoader__.load({
1757
1817
  store.set({ surface: 'flow', surfaceNote: t('surfaceUnproven') })
1758
1818
  patchCard(card.id, { surface: 'flow' })
1759
1819
  toast(t('surfaceUnproven'))
1820
+ scheduleReport(0)
1760
1821
  }, 600)
1761
1822
  pendingProofs.add(proof)
1762
1823
  }
@@ -2118,10 +2179,35 @@ window.__ModuleLoader__.load({
2118
2179
  }
2119
2180
  }
2120
2181
 
2182
+ /**
2183
+ * Debounced self-report to the Host. Diagnostics are best-effort: a
2184
+ * failure is logged and never surfaces as a user-visible error, and a
2185
+ * host too old to have the route is remembered so the channel is not
2186
+ * retried for the rest of the page's life.
2187
+ */
2188
+ let reportTimer = null
2189
+ let diagnosticsSupported = true
2190
+ const scheduleReport = (delay = 600) => {
2191
+ if (!diagnosticsSupported) return
2192
+ if (reportTimer !== null) clearTimeout(reportTimer)
2193
+ reportTimer = setTimeout(() => {
2194
+ reportTimer = null
2195
+ void postJson('diagnose', buildClientReport(store.state)).catch((error) => {
2196
+ if (isDiagnosticsUnsupported(error)) {
2197
+ diagnosticsSupported = false
2198
+ console.info(`[${PLUGIN_ID}] 宿主半尚不支持自检上报(宿主半比客户端旧,重启 DSH 后生效)`)
2199
+ return
2200
+ }
2201
+ console.warn(`[${PLUGIN_ID}] 自检上报失败(不影响功能):`, error?.message ?? error)
2202
+ })
2203
+ }, delay)
2204
+ }
2205
+
2121
2206
  /** Record which rung a registration actually landed on. */
2122
2207
  const noteSlot = (name, key) => {
2123
2208
  const slots = { ...(store.state.slots ?? {}), [name]: key }
2124
2209
  store.set({ slots })
2210
+ scheduleReport()
2125
2211
  }
2126
2212
 
2127
2213
  disposers.push(firstLiveSlot(
@@ -2340,12 +2426,16 @@ window.__ModuleLoader__.load({
2340
2426
 
2341
2427
  // ── boot ─────────────────────────────────────────────────────────
2342
2428
  void refreshState().catch(() => { /* the settings page shows the failure */ })
2429
+ // One report after the slot ladders have had time to settle, so the
2430
+ // Host ends up holding the CLIENT's real registration state.
2431
+ scheduleReport(2500)
2343
2432
  disposers.push(() => {
2344
2433
  for (const controller of controllers.values()) controller.abort()
2345
2434
  controllers.clear()
2346
2435
  for (const proof of pendingProofs) clearTimeout(proof)
2347
2436
  pendingProofs.clear()
2348
2437
  if (toastTimer !== null) clearTimeout(toastTimer)
2438
+ if (reportTimer !== null) clearTimeout(reportTimer)
2349
2439
  store.set({
2350
2440
  trigger: null,
2351
2441
  popover: null,
@@ -2373,7 +2463,7 @@ window.__ModuleLoader__.load({
2373
2463
  * `window` and exercises these without a browser.
2374
2464
  */
2375
2465
  api: Object.freeze({
2376
- version: '1.2.1',
2466
+ version: CLIENT_VERSION,
2377
2467
  /** Read-only state accessor for diagnostics and the test harness. */
2378
2468
  snapshot: () => store.state,
2379
2469
  pure: Object.freeze({
@@ -2391,6 +2481,8 @@ window.__ModuleLoader__.load({
2391
2481
  selectionSignature,
2392
2482
  composeMainPrompt,
2393
2483
  askInMainConversation,
2484
+ buildClientReport,
2485
+ isDiagnosticsUnsupported,
2394
2486
  renderRichText,
2395
2487
  pickSurface,
2396
2488
  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.1'
45
+ export const PLUGIN_VERSION = '1.3.1'
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.1",
3
+ "version": "1.3.1",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",