dsh-sidecard-ask 2.0.2 → 2.1.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,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0
4
+
5
+ **主题:跨端核查后的性能与可观测性收口——流式热路径从「每次 delta 全树重渲染」改成「只重渲染变化的卡」,
6
+ 新增加载预算与活动卡上限,补上一个不免鉴权的探活口,并修掉部署前缀缺少尾斜杠时的基址丢失。**
7
+
8
+ 排查依据见 `CROSS-END-AUDIT-2.0.2.md`(网页端 dsh 0.2.1-rc.2 × 桌面端 dsh-desktop 0.2.0-rc.2 /
9
+ Electron 43.3.0)。本版不含破坏性变更,配置项与接口签名不变。
10
+
11
+ ### 性能
12
+
13
+ - **流式热路径只重渲染变化的卡片**。`useStoreState()` 订阅整个 store,而 `FloatingLayer` 每次渲染都给
14
+ `AnswerCard` 新建 16 个箭头函数,于是任何一次 `store.set` 都会让所有卡片的 props 全部不等——
15
+ `React.memo` 无从下手。现在:
16
+ - `AnswerCard` 经 `React.memo` 包装(对没有 `memo` 的 React 自动退化为原组件);
17
+ - 新增 `useCardHandlers(actions)`:每个卡片 id 只建一次回调包,回调通过 ref 读当时的卡片
18
+ (否则"复制"会复制到两个 delta 之前的文本),因此未变化的卡片 props 引用稳定、整棵子树跳过。
19
+ - **一次 flush 只提交一次状态**。`flushStreamBuffers` 原来对正文和思考各调用一次
20
+ `appendCardText`/`appendCardReasoning`,每次都是一次 `store.set`;3 路并发时理论上 120 次全树渲染/秒。
21
+ 现在两个缓冲合并成一个 `cards` 补丁、一次提交(两个旧 helper 随之删除)。
22
+ - **流式节奏按文本长度自适应**:答案超过 32 K 字符时刷新间隔 50 ms → 100 ms
23
+ (实测 64 K 答案的 `renderRichText` 为 1.2–2.0 ms/帧,翻倍间隔即减半工作量,肉眼无差)。
24
+ - **右栏观察器不再被 class/style 吵醒**。面板测量的 `MutationObserver` 原来把 `class`、`style`
25
+ 放进 `attributeFilter`,聊天区的 hover/滚动/徽标切换每次都会触发一次 rAF 内的
26
+ `getBoundingClientRect`(强制布局);现在只监听 `data-sidebar-right-open` 与
27
+ `data-dockkit-pane-active` 两个真正表示"右栏几何变了"的属性。
28
+ - **活动卡片上限 20**。此前 `store.state.cards` 无上限,长会话会把每条答案的 React 子树一直挂着。
29
+ 超过上限时最旧的**已完成**卡片隐藏为 `open:false`(数据保留、正文卸载、历史可恢复),
30
+ 正在流式的卡片永不隐藏。
31
+ - **冷启动回放预算 200 K 字符**。回放原来把所有历史卡片以 `open:true` 恢复,仅受卡数(默认 10)
32
+ 与单字段 64 K 限制,最坏要挂 64 万字符的 markdown;现在最新一张必定展开,其余按累计字符预算展开,
33
+ 超预算的以折叠态回来。
34
+
35
+ ### 修复
36
+
37
+ - **部署前缀不带尾斜杠时基址丢失**。`new URL('.', 'https://host/dsh')` 会把 `dsh` 当文件名,
38
+ 于是 `API` 退化成 `/sidecard-ask/api`,前缀部署下全部 404。现在首次 404 会回退到根绝对路径重试一次
39
+ (401/403 都**不**改基址:那是鉴权/围栏的答案,不是"路径不对")。正常部署(文档根或带尾斜杠)
40
+ 取值与旧值完全相同,不产生额外请求。
41
+
42
+ ### 可观测性
43
+
44
+ - **新增 `GET /sidecard-ask/api/ping`**:唯一免宿主会话的接口,只回 `{version, at}`。
45
+ 2.0.2 的会话门把脚本/CI 的探活路径全挡住了(连 README 里的 `Invoke-WebRequest` 都变成 401),
46
+ 这条补回"插件是否加载"的判别能力,同时不重新打开"无 cookie 就能 spawn 子代理"的口子。
47
+ 它仍受 loopback/Origin 围栏保护,且**不咨询** `connection` 服务——会话服务坏掉时探活照常工作。
48
+ - 路由分发顺序随之调整:路径解析提前到会话门之前(`ping` 在此之前应答),未知路径仍然对所有动词
49
+ 回同一个 404。
50
+
51
+ ### 文档
52
+
53
+ - README §10.1 的自测配方改为"先 `/ping` 判存活,其余接口需会话"(旧配方在 2.0.2 之后恒 401)。
54
+ - 新增 README §5.4:2026-10-22 桌面端复核(宿主版本落差、Chromium 150、scheme 特权、请求转发、
55
+ 权限、AUMID、加速键、产物缓存)。
56
+ - README §14 补 12–15 四条限制(`/ping` 的边界、活动卡上限、回放预算、前缀重试)。
57
+ - 更正 2.0.2 的一处结论:「桌面端在本机冷启动即崩」**2026-10-22 未能复现**(冷启动成功、宿主在 19387
58
+ 正常服务),改为"与安装包/启动方式相关,需按版本复验"。
59
+
60
+ ### 测试
61
+
62
+ - `verify` 57、`contract` **1408**(原 1377)、`float-placement` **83**(原 82)、`smoke` **271**(原 262),
63
+ 合计 **1819 通过,0 失败**。
64
+ - 新增覆盖:`apiBaseAfter` 的四种状态码(404 改基址、401/403 不改、根绝对不循环)、
65
+ `capLiveCards` 的隐藏规则(只藏最旧、跳过流式卡片、已隐藏不占配额、不改入参)、
66
+ `replayCardOpen` 的预算边界、`React.memo` 与稳定回调包的接线、一次 flush 只有一次 `store.set`、
67
+ `attributeFilter` 不再含 `class`、`/ping` 免鉴权与 405、`/ping` 不咨询 `connection`、
68
+ 其余接口仍 fail-closed、未知路径仍 404。
69
+
3
70
  ## 2.0.2
4
71
 
5
72
  **主题:跨端(网页端 / 桌面端)排查后的六个修复 —— 插件 API 补上宿主会话鉴权、诊断上报不再互相覆盖、prompt 预算真正守住 60k、SMTP 密码状态自洽、未知接口状态码对称、用户层配置按 profile 分文件。**
package/README.md CHANGED
@@ -486,6 +486,25 @@ dsh-better-sidebar 为此专门带了一条防御(`html[data-platform] body>[d
486
486
  `Alt+Q` 是否会撞上桌面 App 的菜单加速键(Electron 的菜单加速键由菜单先处理,页面的 keydown 可能收不到;
487
487
  若冲突,在设置页改成 `Ctrl+Alt+Q` 之类即可);Electron 下 `navigator.clipboard` 的写入权限(已有"选中文本"降级兜底)。
488
488
 
489
+ ### 5.4 2026-10-22 复核(v2.1.0 / 桌面 0.2.0-rc.2)
490
+
491
+ 上一轮之后桌面端与网页端的形态都变过,本轮把能静态取证的部分重新核了一遍,**完整报告见
492
+ [`CROSS-END-AUDIT-2.0.2.md`](CROSS-END-AUDIT-2.0.2.md)**(含实测数字与未验证清单)。要点:
493
+
494
+ | 维度 | 事实 | 对插件意味着什么 |
495
+ |---|---|---|
496
+ | 宿主版本落差 | 桌面 `dsh-desktop 0.2.0-rc.2`(包内 `dsh` 同版本);网页 **`0.2.1-rc.2`** | 适配矩阵要按两端分别列,不能写成"DSH 0.2.x" |
497
+ | 渲染内核 | Electron **43.3.0 = Chromium 150.0.7871.212**(`~/.dsh/electron/version` 亦为 43.3.0);网页端取决于访问者浏览器(本机对照 Edge/Chromium 155) | `renderRichText` 在 150 与 155 上实测同量级(8 k 0.2–0.3 ms,64 k 1.2–2.0 ms) |
498
+ | 自定义 scheme | `dsh-app://app/` 特权:`standard/secure/supportFetchAPI/corsEnabled/stream/codeCache` | `fetch` + 流式读取可用,不需要 EventSource |
499
+ | 请求转发 | `forwardWebRequest`:只放行 `origin=dsh-app://app`,删 `host/origin/cookie/sec-fetch-site` 后注入宿主 cookie,`duplex:"half"` | 2.0.2 起的会话门在桌面端靠这条链路通过;插件侧 `origin` 缺失时被判为可信(`isTrustedRequest`) |
500
+ | 权限 | 主窗口 `setPermissionRequestHandler`:非 `media` 一律放行;登录窗/侧栏 webview 一律拒绝并拦下载 | 剪贴板与通知**有**权限;导出下载走主窗口,不受拦截 |
501
+ | 通知 | 全包 **无 `setAppUserModelId`** | Windows toast 可能不弹;`notifyOnDone` 默认关,打开后仍以卡片状态为兜底 |
502
+ | 菜单加速键 | 全包 `Alt+Q` **0 命中** | 外壳不抢默认快捷键(输入法/其它程序仍可能抢) |
503
+ | 插件产物缓存 | `/plugins/*` 被壳改写为 `cache-control: no-store` | 每次加载重新拉 349 KB(localhost,传输≈0,代价在解析) |
504
+
505
+ > 另:2.0.2 CHANGELOG 里"桌面端在本机冷启动即崩"的结论,2026-10-22 **未能复现**——本次冷启动成功、
506
+ > 宿主在 19387 正常服务。该条已按"与安装包/启动方式相关,需按版本复验"处理,不再当作既定事实。
507
+
489
508
  ---
490
509
 
491
510
  ## 六、未知 DSH API:占位接口与替换方式
@@ -816,11 +835,21 @@ node tools/compat-probe.mjs 0.1.7-rc.2 # 也可只探某个版本
816
835
 
817
836
  ### 10.1 对已安装实例做真实联调(本机实测通过)
818
837
 
838
+ > **2.1.0 起除 `/ping` 外的每个接口都走宿主会话门。** 不带浏览器登录 cookie 的脚本调用会拿到
839
+ > `401 {"error":{"code":"unauthorized"}}`,这表示"没登录",**不表示插件没起来**——先用第 0 步区分。
840
+ > 页面内自检(设置 → 侧边卡片追问 → 运行自检)读的是同一份 `/state`,不需要任何脚本。
841
+
819
842
  ```powershell
820
- # 1) 宿主接口与能力(archived 父会话检测也在这里)
821
- (Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state -UseBasicParsing).Content
843
+ # 0) 探活:唯一免鉴权的接口,只回插件版本与宿主时钟
844
+ (Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/ping -UseBasicParsing).Content
845
+ # → {"ok":true,"value":{"version":"2.1.0","at":1792...}} 200 = 插件宿主半已加载
846
+
847
+ # 1) 宿主接口与能力(archived 父会话检测也在这里)——需要会话
848
+ # 在本机浏览器已登录的前提下复用会话:先 -WebSession 登录,或把浏览器的 Cookie 头带进来
849
+ (Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state `
850
+ -Headers @{ Cookie = $cookie } -UseBasicParsing).Content
822
851
 
823
- # 2) 端到端流式作答:把 sessionId 换成当前会话 id($env:DSH_SESSION_ID)
852
+ # 2) 端到端流式作答:把 sessionId 换成当前会话 id($env:DSH_SESSION_ID);同样需要会话
824
853
  # 期望事件序列:start → reasoning*/delta* → status → done(done.text 是真实答案)
825
854
  $body = @{ id='probe'; question='用一句话说明这句话在说什么。';
826
855
  selection='槽位出口会带上 data-slot 标记。'; zone='chat';
@@ -828,11 +857,13 @@ $body = @{ id='probe'; question='用一句话说明这句话在说什么。';
828
857
  $tmp = Join-Path $env:TEMP 'probe.json'
829
858
  [System.IO.File]::WriteAllText($tmp, $body, (New-Object System.Text.UTF8Encoding($false)))
830
859
  (Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/ask -Method POST `
860
+ -Headers @{ Cookie = $cookie } `
831
861
  -ContentType 'application/json; charset=utf-8' -InFile $tmp -TimeoutSec 240 -UseBasicParsing).Content
832
862
 
833
863
  # 3) 客户端半的真实状态(浏览器跑的那份代码/槽位落点/两个适配器)
834
864
  # 页面加载后 GET /state,看 value.client —— 这是唯一能从页面外读到客户端状态的通道
835
- ((Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state -UseBasicParsing).Content | ConvertFrom-Json).value.client
865
+ ((Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state `
866
+ -Headers @{ Cookie = $cookie } -UseBasicParsing).Content | ConvertFrom-Json).value.client
836
867
  ```
837
868
 
838
869
  **客户端状态样例**(1.3.0 起):
@@ -986,4 +1017,14 @@ npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>" # 旧名
986
1017
  自己的数据);**唯一的例外是「清空全部历史」**——显式清空会同时删除 v3/v2/v1 三代键(1.8.1)。
987
1018
  这条例外是必需的:只删当前键会让 `readPersisted()` 的 v3→v2→v1 回落把刚清掉的历史从旧键读回来,
988
1019
  刷新一次就复活(1.8.1 复现并修复)。
1020
+ 12. **接口鉴权(2.0.2 起,2.1.0 补 `/ping`)**:除 `/ping` 外的每个接口都要宿主会话;从页面外读
1021
+ `/state` 会得到 `401 unauthorized`。`/ping` 是唯一免鉴权的探活口,只回 `{version, at}`——
1022
+ 不要往里加字段,它存在的理由是"能给监控用",不是"能省登录"。
1023
+ 13. **活动卡片上限 20(2.1.0)**:超过后最旧的**已完成**卡片自动隐藏(`open:false`,数据仍在,
1024
+ 历史里可恢复),正在流式的卡片永不隐藏。这不是删除,但会"少一张卡片在浮层里"。
1025
+ 14. **冷启动回放预算 200 K 字符(2.1.0)**:最新一张必定展开,其余按累计字符预算展开;
1026
+ 超预算的旧卡片以折叠态回来,点开才解析。改 `historyMaxCards` 不改变这个预算。
1027
+ 15. **部署前缀不带尾斜杠时(2.1.0)**:`API` 基址会先按 `document.baseURI` 猜一次,若得到 404
1028
+ 则回退到根绝对路径重试一次。这是唯一安全的判别方式(401/403 都不能用来改基址),
1029
+ 代价是每次页面加载最多多一次 404 往返——正常部署下不会触发。
989
1030
 
package/client.js CHANGED
@@ -62,6 +62,50 @@ window.__ModuleLoader__.load({
62
62
  return '/sidecard-ask/api'
63
63
  }
64
64
  })()
65
+
66
+ /** Root-absolute base — the retry target when the prefix guess misses. */
67
+ const API_ROOT = '/sidecard-ask/api'
68
+
69
+ /**
70
+ * The base actually used from now on.
71
+ *
72
+ * `new URL('.', base)` treats the last path segment as a FILE name, so a
73
+ * deployment whose public URL has no trailing slash (`https://host/dsh`)
74
+ * resolves to the document root and loses the prefix. There is no way to
75
+ * tell `/dsh` (directory) from `/dsh` (file) without asking the server, so
76
+ * the first `404` retries once against the root-absolute base: 404 is the
77
+ * only unambiguous signal here — auth answers `401`, a foreign origin
78
+ * `403`, and `404` means the route is not mounted at this path at all.
79
+ */
80
+ let apiBase = API
81
+
82
+ /**
83
+ * The base to use after one response.
84
+ * @param {number} status - HTTP status of the attempt.
85
+ * @param {string} current - the base that produced it.
86
+ * @returns {string} the base for the next attempt (unchanged unless 404).
87
+ */
88
+ function apiBaseAfter(status, current) {
89
+ return status === 404 && current !== API_ROOT ? API_ROOT : current
90
+ }
91
+
92
+ /**
93
+ * `fetch` against the plugin API, retrying once on a 404 with the other
94
+ * base. A 404 body is never consumed here: the caller's own error path
95
+ * reads it (or ignores it), exactly as before.
96
+ * @param {string} path - `/state` or `/ask`, appended to the base.
97
+ * @param {RequestInit} [init] - fetch options (body must be replayable).
98
+ * @returns {Promise<Response>} the response of whichever attempt answered.
99
+ */
100
+ async function fetchApi(path, init) {
101
+ const attempted = apiBase
102
+ let response = await fetch(`${attempted}${path}`, init)
103
+ if (response.status === 404 && attempted !== API_ROOT) {
104
+ apiBase = apiBaseAfter(response.status, attempted)
105
+ response = await fetch(`${apiBase}${path}`, init)
106
+ }
107
+ return response
108
+ }
65
109
  /** Slot ids contributed by this plugin (also the overlay entry ids). */
66
110
  const IDS = {
67
111
  overlay: 'sidecard-ask',
@@ -73,7 +117,7 @@ window.__ModuleLoader__.load({
73
117
  * the Host and the module's `api.version` both read it, so a report can
74
118
  * never claim a generation the browser is not actually running.
75
119
  */
76
- const CLIENT_VERSION = '2.0.2'
120
+ const CLIENT_VERSION = '2.1.0'
77
121
 
78
122
  /**
79
123
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -109,6 +153,35 @@ window.__ModuleLoader__.load({
109
153
  const HISTORY_TURN_Q_CHARS = 500
110
154
  const HISTORY_TURN_A_CHARS = 4000
111
155
 
156
+ /**
157
+ * Streaming flush cadence.
158
+ *
159
+ * 50 ms is the cadence that reads as "live"; a 32 k+ answer costs 1.2–2.0 ms
160
+ * of markdown parsing per frame (measured, both Chromium 150 and 155), so
161
+ * past that length the cadence doubles — the eye cannot tell, and the work
162
+ * halves.
163
+ */
164
+ const STREAM_FLUSH_MS = 50
165
+ const STREAM_FLUSH_SLOW_MS = 100
166
+ const STREAM_SLOW_CHARS = 32_000
167
+
168
+ /**
169
+ * How many cards may stay mounted at once. Past this the oldest finished
170
+ * card is hidden (not deleted): its body unmounts, its data stays, and the
171
+ * durable history still restores it.
172
+ */
173
+ const MAX_LIVE_CARDS = 20
174
+
175
+ /**
176
+ * How many characters of history a page load may re-render unfolded.
177
+ *
178
+ * Replay used to restore every saved card `open:true`, bounded only by the
179
+ * card count (default 10) and a 64 k per-field limit — a worst case of
180
+ * ~640 k characters of markdown parsed and mounted before the page is
181
+ * usable. Past this budget the older cards come back collapsed.
182
+ */
183
+ const REPLAY_OPEN_BUDGET_CHARS = 200_000
184
+
112
185
  /**
113
186
  * Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
114
187
  * before `/state` answers (first paint) and when the Host is unreachable;
@@ -1121,7 +1194,7 @@ window.__ModuleLoader__.load({
1121
1194
  async function postJson(method, payload) {
1122
1195
  let response
1123
1196
  try {
1124
- response = await fetch(`${API}/${method}`, {
1197
+ response = await fetchApi(`/${method}`, {
1125
1198
  method: 'POST',
1126
1199
  headers: { 'content-type': 'application/json' },
1127
1200
  body: JSON.stringify(payload ?? {}),
@@ -1145,7 +1218,7 @@ window.__ModuleLoader__.load({
1145
1218
  async function getState() {
1146
1219
  let response
1147
1220
  try {
1148
- response = await fetch(`${API}/state`, { method: 'GET' })
1221
+ response = await fetchApi('/state', { method: 'GET' })
1149
1222
  } catch (error) {
1150
1223
  throw Object.assign(new Error(String(error?.message ?? error)), { code: 'unreachable' })
1151
1224
  }
@@ -1173,7 +1246,7 @@ window.__ModuleLoader__.load({
1173
1246
  async function streamAsk(payload, handlers) {
1174
1247
  let response
1175
1248
  try {
1176
- response = await fetch(`${API}/ask`, {
1249
+ response = await fetchApi('/ask', {
1177
1250
  method: 'POST',
1178
1251
  headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
1179
1252
  body: JSON.stringify(payload),
@@ -1323,6 +1396,57 @@ window.__ModuleLoader__.load({
1323
1396
  return store.state
1324
1397
  }
1325
1398
 
1399
+ /**
1400
+ * Per-card handler bundle with a STABLE identity.
1401
+ *
1402
+ * `store.set` bumps a revision on every streamed delta, and the floating
1403
+ * layer re-renders on each one. Handing `AnswerCard` a fresh arrow per
1404
+ * render makes every prop compare unequal, so `React.memo` could never bail
1405
+ * out. The bundle is therefore created once per card id and reads the card
1406
+ * through a ref at call time — a closure must never capture a stale card,
1407
+ * or "copy" would copy the text from two deltas ago.
1408
+ * @param {object} actions - the plugin's action table (stable for the
1409
+ * lifetime of `apply`).
1410
+ * @returns {(card: object) => object} handler bundle for one card.
1411
+ */
1412
+ function useCardHandlers(actions) {
1413
+ const latest = React.useRef(new Map())
1414
+ const cache = React.useRef(new Map())
1415
+ return React.useCallback((card) => {
1416
+ if (card === null || card === undefined) return null
1417
+ latest.current.set(card.id, card)
1418
+ let bundle = cache.current.get(card.id)
1419
+ if (bundle === undefined) {
1420
+ // The maps are keyed by card id and live for the whole session, so a
1421
+ // long session would keep one tiny bundle per card ever shown. Past a
1422
+ // generous ceiling both are dropped: the live cards simply get a
1423
+ // fresh (still stable) bundle and re-render once.
1424
+ if (cache.current.size > 64) {
1425
+ cache.current.clear()
1426
+ latest.current.clear()
1427
+ }
1428
+ const current = () => latest.current.get(card.id) ?? card
1429
+ bundle = {
1430
+ onCopy: element => actions.copy(current(), element),
1431
+ onFollowUp: text => actions.followUp(current(), text),
1432
+ onRetry: () => actions.retry(current()),
1433
+ onRetryEdit: text => actions.retryEdit(current(), text),
1434
+ onCancel: () => actions.cancel(current()),
1435
+ onSendToMain: () => { void actions.sendToMain(current()) },
1436
+ onClose: () => actions.close(current()),
1437
+ onLocate: target => actions.locateCard(target ?? current()),
1438
+ onExport: target => actions.exportCard(target ?? current()),
1439
+ onInsert: target => actions.insertAnswer(target ?? current()),
1440
+ onReplace: target => actions.insertAnswer(target ?? current(), { replace: true }),
1441
+ onViewVersion: index => actions.viewVersion(current(), index),
1442
+ onDelete: () => actions.deleteCard(current()),
1443
+ }
1444
+ cache.current.set(card.id, bundle)
1445
+ }
1446
+ return bundle
1447
+ }, [actions])
1448
+ }
1449
+
1326
1450
  // ─────────────────────────────────────────────────────────────────────
1327
1451
  // §4.5 family singleton
1328
1452
  //
@@ -1430,20 +1554,63 @@ window.__ModuleLoader__.load({
1430
1554
  return cards.find(card => card.id === id)
1431
1555
  }
1432
1556
 
1433
- /** Append text to one card's streaming answer. */
1434
- function appendCardText(id, delta) {
1435
- const cards = store.state.cards.map(card => (
1436
- card.id === id ? { ...card, text: card.text + delta } : card
1437
- ))
1438
- store.set({ cards })
1557
+ /**
1558
+ * Hide the oldest finished cards once a stack exceeds its cap.
1559
+ *
1560
+ * Pure so the rule can be asserted without a renderer: the newest card and
1561
+ * anything still streaming are never hidden, `open:false` cards are skipped
1562
+ * (already hidden), and the input array is not mutated.
1563
+ * @param {object[]} cards - newest first.
1564
+ * @param {number} [cap] - how many may stay visible.
1565
+ * @param {string|null} [keptId] - id that must survive this pass.
1566
+ * @returns {object[]} the same list, with overflow entries closed.
1567
+ */
1568
+ function capLiveCards(cards, cap = MAX_LIVE_CARDS, keptId = null) {
1569
+ if (!Array.isArray(cards) || cards.length <= cap) return cards
1570
+ const next = [...cards]
1571
+ let excess = next.length - cap
1572
+ for (let index = next.length - 1; index >= 0 && excess > 0; index -= 1) {
1573
+ const item = next[index]
1574
+ if (item === null || item === undefined) continue
1575
+ if (item.id === keptId || item.open === false) continue
1576
+ if (item.status === 'pending' || item.status === 'streaming') continue
1577
+ next[index] = { ...item, open: false }
1578
+ excess -= 1
1579
+ }
1580
+ return next
1439
1581
  }
1440
1582
 
1441
- /** Append reasoning text to one card. */
1442
- function appendCardReasoning(id, delta) {
1443
- const cards = store.state.cards.map(card => (
1444
- card.id === id ? { ...card, reasoning: card.reasoning + delta } : card
1445
- ))
1446
- store.set({ cards })
1583
+ /**
1584
+ * Whether replayed card `index` comes back unfolded.
1585
+ *
1586
+ * The newest replay always does — a page load that hides everything would
1587
+ * look like data loss — and the rest only while the running total stays
1588
+ * inside the budget.
1589
+ * @param {number} index - 0 = newest.
1590
+ * @param {number} replayedChars - characters replayed so far, inclusive.
1591
+ * @returns {boolean} true when the card should mount unfolded.
1592
+ */
1593
+ function replayCardOpen(index, replayedChars) {
1594
+ return index === 0 || replayedChars <= REPLAY_OPEN_BUDGET_CHARS
1595
+ }
1596
+
1597
+ /**
1598
+ * Prepend one card, hiding the oldest FINISHED card once the stack grows
1599
+ * past the live cap.
1600
+ *
1601
+ * Cards used to accumulate for the whole session: nothing capped
1602
+ * `store.state.cards`, so a long session kept every answer mounted (each
1603
+ * one a full React subtree). Past the cap the oldest finished card is
1604
+ * hidden — `open:false` keeps its data, unmounts its body, and the durable
1605
+ * history still offers it back. A card that is still streaming is never
1606
+ * hidden: that would orphan its stream.
1607
+ * @param {object} card - the card to put on top.
1608
+ * @param {object} [extra] - additional store fields to commit in the same
1609
+ * update (the carrier's surface label, for instance).
1610
+ */
1611
+ function pushCard(card, extra = null) {
1612
+ const cards = capLiveCards([card, ...store.state.cards], MAX_LIVE_CARDS, card.id)
1613
+ store.set(extra === null ? { cards } : { ...extra, cards })
1447
1614
  }
1448
1615
 
1449
1616
  /** Show a transient toast line. */
@@ -4431,6 +4598,19 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4431
4598
  : null)
4432
4599
  }
4433
4600
 
4601
+ /**
4602
+ * Memoised {@link AnswerCard}.
4603
+ *
4604
+ * Only the card that actually changed gets a new `card` object
4605
+ * (`patchCard` / the stream flush keep every other card by reference), and
4606
+ * the handler bundle from {@link useCardHandlers} is stable per id — so a
4607
+ * delta on card A no longer re-renders cards B..N. `React.memo` is probed
4608
+ * because the test double (and any trimmed-down React a host might hand a
4609
+ * plugin) does not have to implement it; without it this is the same
4610
+ * component, just un-memoised.
4611
+ */
4612
+ const MemoAnswerCard = typeof React.memo === 'function' ? React.memo(AnswerCard) : AnswerCard
4613
+
4434
4614
  /**
4435
4615
  * The flow surface, i.e. the floating layer (A).
4436
4616
  *
@@ -4450,6 +4630,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4450
4630
  const containerRef = React.useRef(null)
4451
4631
  const dockRef = React.useRef(null)
4452
4632
  const state = useStoreState()
4633
+ const handlersFor = useCardHandlers(actions)
4453
4634
  const [dock, setDock] = React.useState(null)
4454
4635
  const visible = cards.filter(card => card.surface === 'flow' && card.open)
4455
4636
  const total = visible.length
@@ -4563,16 +4744,20 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4563
4744
  }
4564
4745
  if (typeof window.addEventListener === 'function') window.addEventListener('resize', schedule)
4565
4746
  // Opening/closing the rail changes no size this effect already watches,
4566
- // so the DOM itself has to be the trigger. Attributes only (no
4567
- // childList): streaming tokens rewrite text nodes continuously and must
4568
- // not wake the layer on every frame.
4747
+ // so the DOM itself has to be the trigger. The two data attributes are
4748
+ // the only signals that MEAN "the rail geometry changed". `class` and
4749
+ // `style` used to be in this list too, and a chat pane rewrites both
4750
+ // constantly (hover, scroll state, streaming badges) — every one of
4751
+ // those woke this observer and forced a layout read on the next frame.
4752
+ // Streaming tokens rewrite text nodes, which `childList`-free observing
4753
+ // already ignores.
4569
4754
  if (typeof window.MutationObserver === 'function' && document.body !== null && document.body !== undefined) {
4570
4755
  try {
4571
4756
  mutation = new window.MutationObserver(schedule)
4572
4757
  mutation.observe(document.body, {
4573
4758
  attributes: true,
4574
4759
  subtree: true,
4575
- attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active', 'style', 'class'],
4760
+ attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active'],
4576
4761
  })
4577
4762
  } catch { mutation = null }
4578
4763
  }
@@ -4625,24 +4810,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4625
4810
  : null),
4626
4811
  }, children)
4627
4812
 
4628
- const cardNodes = visible.map(card => h(AnswerCard, {
4813
+ const cardNodes = visible.map(card => h(MemoAnswerCard, {
4629
4814
  key: card.id,
4630
4815
  card,
4631
4816
  surfaceLabel: null,
4632
- onCopy: element => actions.copy(card, element),
4633
- onFollowUp: text => actions.followUp(card, text),
4634
- onRetry: () => actions.retry(card),
4635
- onRetryEdit: text => actions.retryEdit(card, text),
4636
- onCancel: () => actions.cancel(card),
4637
- onSendToMain: () => actions.sendToMain(card),
4638
- onClose: () => actions.close(card),
4639
- onLocate: target => actions.locateCard(target ?? card),
4640
- onExport: target => actions.exportCard(target ?? card),
4641
- onInsert: target => actions.insertAnswer(target ?? card),
4642
- onReplace: target => actions.insertAnswer(target ?? card, { replace: true }),
4643
- onViewVersion: index => actions.viewVersion(card, index),
4644
4817
  onFloatMode: setFloatMode,
4645
- onDelete: () => actions.deleteCard(card),
4818
+ // Stable per card id (see useCardHandlers), which is what lets
4819
+ // React.memo skip every card that did not change in this tick.
4820
+ ...handlersFor(card),
4646
4821
  }))
4647
4822
 
4648
4823
  if (mode === 'capsule') {
@@ -5646,37 +5821,47 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5646
5821
  if (cap > 0 && (currentRaw === null || currentRaw === undefined)) {
5647
5822
  writePersisted(saved)
5648
5823
  }
5649
- const replayed = saved.map(entry => ({
5650
- id: entry.id,
5651
- question: entry.question,
5652
- selected: String(entry.selected ?? ''),
5653
- selection: String(entry.selection ?? ''),
5654
- text: String(entry.text ?? ''),
5655
- reasoning: String(entry.reasoning ?? ''),
5656
- status: entry.status === 'stopped' ? 'stopped' : 'done',
5657
- carrier: 'side',
5658
- zone: entry.zone,
5659
- truncated: entry.truncated === true,
5660
- droppedChars: Number(entry.droppedChars ?? 0),
5661
- streaming: null,
5662
- error: null,
5663
- provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
5664
- elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
5665
- createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
5666
- // P2-5b: replayed versions stay switchable after a reload.
5667
- versions: replayVersions(entry.versions),
5668
- viewingVersion: null,
5669
- actionId: typeof entry.actionId === 'string' && entry.actionId !== '' ? entry.actionId : null,
5670
- // A native/better tab cannot survive a reload, so history always
5671
- // comes back on the flow stack where it is visible immediately.
5672
- surface: 'flow',
5673
- open: true,
5674
- history: Array.isArray(entry.history)
5675
- ? entry.history
5676
- .filter(turn => turn !== null && typeof turn === 'object')
5677
- .map(turn => ({ question: String(turn.question ?? ''), answer: String(turn.answer ?? '') }))
5678
- : [],
5679
- }))
5824
+ // Replay budget: the newest card always comes back unfolded, and the
5825
+ // rest stay unfolded only while the running total of replayed
5826
+ // characters stays under the budget. A page load used to mount up to
5827
+ // 10 × 64 k characters of markdown before it was usable; past the
5828
+ // budget the older cards come back collapsed and are parsed when the
5829
+ // user opens them.
5830
+ let replayedChars = 0
5831
+ const replayed = saved.map((entry, index) => {
5832
+ replayedChars += String(entry.text ?? '').length + String(entry.reasoning ?? '').length
5833
+ return {
5834
+ id: entry.id,
5835
+ question: entry.question,
5836
+ selected: String(entry.selected ?? ''),
5837
+ selection: String(entry.selection ?? ''),
5838
+ text: String(entry.text ?? ''),
5839
+ reasoning: String(entry.reasoning ?? ''),
5840
+ status: entry.status === 'stopped' ? 'stopped' : 'done',
5841
+ carrier: 'side',
5842
+ zone: entry.zone,
5843
+ truncated: entry.truncated === true,
5844
+ droppedChars: Number(entry.droppedChars ?? 0),
5845
+ streaming: null,
5846
+ error: null,
5847
+ provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
5848
+ elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
5849
+ createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
5850
+ // P2-5b: replayed versions stay switchable after a reload.
5851
+ versions: replayVersions(entry.versions),
5852
+ viewingVersion: null,
5853
+ actionId: typeof entry.actionId === 'string' && entry.actionId !== '' ? entry.actionId : null,
5854
+ // A native/better tab cannot survive a reload, so history always
5855
+ // comes back on the flow stack where it is visible immediately.
5856
+ surface: 'flow',
5857
+ open: replayCardOpen(index, replayedChars),
5858
+ history: Array.isArray(entry.history)
5859
+ ? entry.history
5860
+ .filter(turn => turn !== null && typeof turn === 'object')
5861
+ .map(turn => ({ question: String(turn.question ?? ''), answer: String(turn.answer ?? '') }))
5862
+ : [],
5863
+ }
5864
+ })
5680
5865
  store.set({ cards: [...replayed, ...store.state.cards] })
5681
5866
  }
5682
5867
 
@@ -5750,7 +5935,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5750
5935
  if (store.state.cards.some(card => card.id === id)) {
5751
5936
  patchCard(id, { surface: 'flow', open: true })
5752
5937
  } else {
5753
- store.set({ cards: [historyEntryToCard(entry), ...store.state.cards] })
5938
+ pushCard(historyEntryToCard(entry))
5754
5939
  }
5755
5940
  // A collapsed (capsule) layer would swallow the restored card;
5756
5941
  // this is a session-only override, exactly like clicking expand.
@@ -5879,7 +6064,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5879
6064
  actionId: payload?.actionId ?? null,
5880
6065
  }
5881
6066
  if (payload.carrier === 'main') {
5882
- store.set({ cards: [card, ...store.state.cards] })
6067
+ pushCard(card)
5883
6068
  try {
5884
6069
  const result = await askInMainConversation({
5885
6070
  ctx,
@@ -5905,7 +6090,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5905
6090
  }
5906
6091
  const { adapter, fellBack } = pickSurface(config.sideSurface)
5907
6092
  card.surface = adapter.kind
5908
- store.set({ cards: [card, ...store.state.cards], surface: adapter.kind })
6093
+ pushCard(card, { surface: adapter.kind })
5909
6094
  if (fellBack) {
5910
6095
  store.set({ surfaceNote: t('surfaceFellBack', { name: adapter.label() }) })
5911
6096
  toast(t('surfaceUnavailable'))
@@ -6241,31 +6426,65 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6241
6426
  const pendingReasonings = new Map()
6242
6427
  let deltaFlushTimer = null
6243
6428
 
6429
+ /**
6430
+ * Commit every buffered chunk for this tick as ONE store update.
6431
+ *
6432
+ * The previous shape published text and reasoning separately, so a
6433
+ * single 50 ms tick could bump the store twice per streaming card —
6434
+ * and every bump re-renders the whole overlay. With the concurrency
6435
+ * cap at 3 that is up to 120 tree renders per second for two string
6436
+ * appends. Cards that saw no chunk keep their identity, so a memoised
6437
+ * card is not touched at all.
6438
+ */
6244
6439
  const flushStreamBuffers = () => {
6245
6440
  if (deltaFlushTimer !== null) {
6246
6441
  clearTimeout(deltaFlushTimer)
6247
6442
  deltaFlushTimer = null
6248
6443
  }
6249
- if (pendingDeltas.size > 0) {
6250
- for (const [cardId, chunk] of [...pendingDeltas.entries()]) appendCardText(cardId, chunk)
6251
- pendingDeltas.clear()
6252
- }
6253
- if (pendingReasonings.size > 0) {
6254
- for (const [cardId, chunk] of [...pendingReasonings.entries()]) appendCardReasoning(cardId, chunk)
6255
- pendingReasonings.clear()
6444
+ if (pendingDeltas.size === 0 && pendingReasonings.size === 0) return
6445
+ const cards = store.state.cards.map((card) => {
6446
+ const text = pendingDeltas.get(card.id)
6447
+ const reasoning = pendingReasonings.get(card.id)
6448
+ if (text === undefined && reasoning === undefined) return card
6449
+ const next = { ...card }
6450
+ if (text !== undefined) next.text = `${card.text}${text}`
6451
+ if (reasoning !== undefined) next.reasoning = `${card.reasoning}${reasoning}`
6452
+ return next
6453
+ })
6454
+ pendingDeltas.clear()
6455
+ pendingReasonings.clear()
6456
+ store.set({ cards })
6457
+ }
6458
+
6459
+ /**
6460
+ * Milliseconds to wait before the next flush.
6461
+ *
6462
+ * 50 ms is the "feels live" cadence, but the cost of one frame grows
6463
+ * with the answer: `renderRichText` measured 0.2–0.3 ms at 8 k
6464
+ * characters and 1.2–2.0 ms at 64 k. Past 32 k a 100 ms cadence is
6465
+ * indistinguishable to the eye and halves the work.
6466
+ * @returns {number} the delay for the next flush tick.
6467
+ */
6468
+ const streamFlushDelay = () => {
6469
+ let longest = 0
6470
+ for (const card of store.state.cards) {
6471
+ if (card.status === 'streaming' && typeof card.text === 'string' && card.text.length > longest) {
6472
+ longest = card.text.length
6473
+ }
6256
6474
  }
6475
+ return longest > STREAM_SLOW_CHARS ? STREAM_FLUSH_SLOW_MS : STREAM_FLUSH_MS
6257
6476
  }
6258
6477
 
6259
6478
  const bufferStreamDelta = (cardId, chunk) => {
6260
6479
  if (chunk === '') return
6261
6480
  pendingDeltas.set(cardId, (pendingDeltas.get(cardId) ?? '') + chunk)
6262
- if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, 50)
6481
+ if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, streamFlushDelay())
6263
6482
  }
6264
6483
 
6265
6484
  const bufferStreamReasoning = (cardId, chunk) => {
6266
6485
  if (chunk === '') return
6267
6486
  pendingReasonings.set(cardId, (pendingReasonings.get(cardId) ?? '') + chunk)
6268
- if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, 50)
6487
+ if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, streamFlushDelay())
6269
6488
  }
6270
6489
 
6271
6490
  /** Stream one side answer into its card. */
@@ -6868,7 +7087,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6868
7087
  }
6869
7088
  return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '', style: { padding: '10px' } },
6870
7089
  h(Styles, null),
6871
- h(AnswerCard, {
7090
+ h(MemoAnswerCard, {
6872
7091
  card,
6873
7092
  surfaceLabel: state.surface === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
6874
7093
  onCopy: element => actions.copy(card, element),
@@ -7043,6 +7262,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7043
7262
  CARD_KIND,
7044
7263
  CARD_KIND_BETTER,
7045
7264
  HISTORY_TURNS,
7265
+ API_ROOT,
7266
+ apiBaseAfter,
7267
+ MAX_LIVE_CARDS,
7268
+ REPLAY_OPEN_BUDGET_CHARS,
7269
+ STREAM_FLUSH_MS,
7270
+ STREAM_FLUSH_SLOW_MS,
7271
+ STREAM_SLOW_CHARS,
7272
+ capLiveCards,
7273
+ replayCardOpen,
7046
7274
  estimateLabelWidth,
7047
7275
  parseSseBlock,
7048
7276
  truncateSelection,
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.0.2'
48
+ export const PLUGIN_VERSION = '2.1.0'
49
49
 
50
50
  /** Route prefix of the plugin's own API. */
51
51
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -2591,8 +2591,13 @@ export function apply(ctx, patchConfig) {
2591
2591
  /**
2592
2592
  * The HTTP methods the plugin serves; kept in one place because the route
2593
2593
  * registration has two shapes (see below).
2594
+ *
2595
+ * `ping` is the one method that answers WITHOUT the browser session: it is
2596
+ * the only way a script, a CI job or a monitor can tell "the plugin is up"
2597
+ * apart from "the Host is up" once the session gate is in front of
2598
+ * everything else. It exposes nothing but the version and the Host clock.
2594
2599
  */
2595
- const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
2600
+ const API_METHODS = ['ping', 'state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
2596
2601
 
2597
2602
  /**
2598
2603
  * The Host's own browser-session gate, borrowed for the plugin's routes.
@@ -2643,16 +2648,11 @@ export function apply(ctx, patchConfig) {
2643
2648
  writeError(res, 403, 'forbidden', 'forbidden')
2644
2649
  return
2645
2650
  }
2646
- const unauthorized = authRejection(req)
2647
- if (unauthorized !== undefined) {
2648
- writeError(
2649
- res,
2650
- unauthorized,
2651
- unauthorized === 401 ? 'unauthorized' : 'forbidden',
2652
- unauthorized === 401 ? '需要先通过 DSH 登录(宿主会话未认证)' : '请求来源不受信任',
2653
- )
2654
- return
2655
- }
2651
+ // Path parsing runs BEFORE the session gate now, because `ping` must be
2652
+ // answerable without a browser session. The order is deliberate: the fence
2653
+ // still rejects a foreign origin first, the method name is validated
2654
+ // before anything is dispatched, and `ping` is the ONLY verb that skips the
2655
+ // gate — everything else falls through to it immediately below.
2656
2656
  const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
2657
2657
  const method = pathname.startsWith(`${ROUTE_PREFIX}/`)
2658
2658
  ? pathname.slice(ROUTE_PREFIX.length + 1)
@@ -2663,6 +2663,24 @@ export function apply(ctx, patchConfig) {
2663
2663
  writeError(res, 404, 'not-found', `未知接口 "${method}"`)
2664
2664
  return
2665
2665
  }
2666
+ if (method === 'ping') {
2667
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
2668
+ writeError(res, 405, 'method-error', 'method not allowed')
2669
+ return
2670
+ }
2671
+ writeOk(res, { version: PLUGIN_VERSION, at: Date.now() })
2672
+ return
2673
+ }
2674
+ const unauthorized = authRejection(req)
2675
+ if (unauthorized !== undefined) {
2676
+ writeError(
2677
+ res,
2678
+ unauthorized,
2679
+ unauthorized === 401 ? 'unauthorized' : 'forbidden',
2680
+ unauthorized === 401 ? '需要先通过 DSH 登录(宿主会话未认证)' : '请求来源不受信任',
2681
+ )
2682
+ return
2683
+ }
2666
2684
  // Everything that can throw runs inside ONE try: `/state` assembles the
2667
2685
  // whole diagnostic surface (capabilities, SMTP resolution, provenance), and
2668
2686
  // it used to sit outside this block, so a single bad config value answered
package/package.json CHANGED
@@ -1,69 +1,69 @@
1
- {
2
- "name": "dsh-sidecard-ask",
3
- "version": "2.0.2",
4
- "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
- "type": "module",
6
- "main": "index.js",
7
- "scripts": {
8
- "test": "node test/verify.mjs && node test/contract-test.mjs && node test/float-placement.mjs && node test/smoke-test.mjs"
9
- },
10
- "exports": {
11
- ".": "./index.js",
12
- "./client": "./client.js",
13
- "./package.json": "./package.json",
14
- "./locale/*.json": "./locale/*.json"
15
- },
16
- "files": [
17
- "index.js",
18
- "client.js",
19
- "cordis.patch.yml",
20
- "locale/*.json",
21
- "icon.svg",
22
- "README.md",
23
- "CHANGELOG.md",
24
- "LICENSE"
25
- ],
26
- "engines": {
27
- "node": ">=20"
28
- },
29
- "license": "MIT",
30
- "keywords": [
31
- "dsh",
32
- "dsh-plugin",
33
- "deepseek-harness",
34
- "selection",
35
- "划词",
36
- "选中追问",
37
- "ask",
38
- "side-card",
39
- "侧边卡片"
40
- ],
41
- "repository": {
42
- "type": "git",
43
- "url": "git+https://github.com/HERO476/dsh-sidecard-ask.git"
44
- },
45
- "homepage": "https://github.com/HERO476/dsh-sidecard-ask#readme",
46
- "bugs": {
47
- "url": "https://github.com/HERO476/dsh-sidecard-ask/issues"
48
- },
49
- "icon": "./icon.svg",
50
- "meta": {
51
- "title": "侧边卡片追问",
52
- "description": "选中聊天或任务里的文本就地追问;答案由独立子代理在侧边卡片里流式呈现(也可落回主对话)。"
53
- },
54
- "dsh": {
55
- "manifestVersion": 1,
56
- "bundle": {
57
- "patch": "./cordis.patch.yml"
58
- },
59
- "client": {
60
- "platform": "web",
61
- "immediately": true,
62
- "inject": [
63
- "@deepseek-ai/dsh-client-locale",
64
- "@deepseek-ai/dsh-client-ui-slots",
65
- "@deepseek-ai/dsh-client-ui-conversation"
66
- ]
67
- }
68
- }
69
- }
1
+ {
2
+ "name": "dsh-sidecard-ask",
3
+ "version": "2.1.0",
4
+ "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "test": "node test/verify.mjs && node test/contract-test.mjs && node test/float-placement.mjs && node test/smoke-test.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./index.js",
12
+ "./client": "./client.js",
13
+ "./package.json": "./package.json",
14
+ "./locale/*.json": "./locale/*.json"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "client.js",
19
+ "cordis.patch.yml",
20
+ "locale/*.json",
21
+ "icon.svg",
22
+ "README.md",
23
+ "CHANGELOG.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "license": "MIT",
30
+ "keywords": [
31
+ "dsh",
32
+ "dsh-plugin",
33
+ "deepseek-harness",
34
+ "selection",
35
+ "划词",
36
+ "选中追问",
37
+ "ask",
38
+ "side-card",
39
+ "侧边卡片"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/HERO476/dsh-sidecard-ask.git"
44
+ },
45
+ "homepage": "https://github.com/HERO476/dsh-sidecard-ask#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/HERO476/dsh-sidecard-ask/issues"
48
+ },
49
+ "icon": "./icon.svg",
50
+ "meta": {
51
+ "title": "侧边卡片追问",
52
+ "description": "选中聊天或任务里的文本就地追问;答案由独立子代理在侧边卡片里流式呈现(也可落回主对话)。"
53
+ },
54
+ "dsh": {
55
+ "manifestVersion": 1,
56
+ "bundle": {
57
+ "patch": "./cordis.patch.yml"
58
+ },
59
+ "client": {
60
+ "platform": "web",
61
+ "immediately": true,
62
+ "inject": [
63
+ "@deepseek-ai/dsh-client-locale",
64
+ "@deepseek-ai/dsh-client-ui-slots",
65
+ "@deepseek-ai/dsh-client-ui-conversation"
66
+ ]
67
+ }
68
+ }
69
+ }