dsh-sidecard-ask 1.2.1 → 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,24 @@
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
+
3
22
  ## 1.2.1
4
23
 
5
24
  **主题:核对 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 135 项 + smoke 134 项 = **322 项全部通过**。
386
400
 
387
401
  ### 10.2 版本能力探测(§7 矩阵的来源)
388
402
 
@@ -409,8 +423,30 @@ $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
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
+ }
412
445
  ```
413
446
 
447
+ `native.reason` / `better.reason` 会写明**为什么**某个承载面不可用(例如 `no-tab-meta` 或注册表的报错原文)——
448
+ 1.2.1 修掉的那个 tab kind 冲突正是靠这类信息才定位到的。
449
+
414
450
  **2026-09-28 在 DSH 0.1.7-rc.2 上的实测结果**(`provider=spawn`、`sideTools=readonly`):
415
451
  `start x1 → reasoning x181 → delta x90 → status x1 → done x1`,耗时 2.6 s,`done.text` 为真实模型答案。
416
452
  这条记录同时说明:子代理启动、只读工具白名单(与真实工具表求交后)、流式帧桥接、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.0'
51
+
45
52
  /**
46
53
  * Tab kind served by the DSH native right rail (also its implementation id).
47
54
  */
@@ -941,6 +948,41 @@ window.__ModuleLoader__.load({
941
948
  */
942
949
  let capturedComposerActions = null
943
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
+
944
986
  /**
945
987
  * The card id an external host is CURRENTLY rendering. `CardHost` sets it
946
988
  * on mount and clears it on unmount, which is what turns "the adapter
@@ -1743,6 +1785,7 @@ window.__ModuleLoader__.load({
1743
1785
  if (!opened) {
1744
1786
  store.set({ surface: 'flow' })
1745
1787
  patchCard(card.id, { surface: 'flow' })
1788
+ scheduleReport(0)
1746
1789
  } else {
1747
1790
  // Render proof: an adapter that accepts the open but never
1748
1791
  // mounts our body would leave an empty tab. Unless the card is
@@ -1757,6 +1800,7 @@ window.__ModuleLoader__.load({
1757
1800
  store.set({ surface: 'flow', surfaceNote: t('surfaceUnproven') })
1758
1801
  patchCard(card.id, { surface: 'flow' })
1759
1802
  toast(t('surfaceUnproven'))
1803
+ scheduleReport(0)
1760
1804
  }, 600)
1761
1805
  pendingProofs.add(proof)
1762
1806
  }
@@ -2118,10 +2162,26 @@ window.__ModuleLoader__.load({
2118
2162
  }
2119
2163
  }
2120
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
+
2121
2180
  /** Record which rung a registration actually landed on. */
2122
2181
  const noteSlot = (name, key) => {
2123
2182
  const slots = { ...(store.state.slots ?? {}), [name]: key }
2124
2183
  store.set({ slots })
2184
+ scheduleReport()
2125
2185
  }
2126
2186
 
2127
2187
  disposers.push(firstLiveSlot(
@@ -2340,12 +2400,16 @@ window.__ModuleLoader__.load({
2340
2400
 
2341
2401
  // ── boot ─────────────────────────────────────────────────────────
2342
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)
2343
2406
  disposers.push(() => {
2344
2407
  for (const controller of controllers.values()) controller.abort()
2345
2408
  controllers.clear()
2346
2409
  for (const proof of pendingProofs) clearTimeout(proof)
2347
2410
  pendingProofs.clear()
2348
2411
  if (toastTimer !== null) clearTimeout(toastTimer)
2412
+ if (reportTimer !== null) clearTimeout(reportTimer)
2349
2413
  store.set({
2350
2414
  trigger: null,
2351
2415
  popover: null,
@@ -2373,7 +2437,7 @@ window.__ModuleLoader__.load({
2373
2437
  * `window` and exercises these without a browser.
2374
2438
  */
2375
2439
  api: Object.freeze({
2376
- version: '1.2.1',
2440
+ version: CLIENT_VERSION,
2377
2441
  /** Read-only state accessor for diagnostics and the test harness. */
2378
2442
  snapshot: () => store.state,
2379
2443
  pure: Object.freeze({
@@ -2391,6 +2455,7 @@ window.__ModuleLoader__.load({
2391
2455
  selectionSignature,
2392
2456
  composeMainPrompt,
2393
2457
  askInMainConversation,
2458
+ buildClientReport,
2394
2459
  renderRichText,
2395
2460
  pickSurface,
2396
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.1'
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.1",
3
+ "version": "1.3.0",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",