dsh-sidecard-ask 1.3.2 → 1.5.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 +146 -0
- package/README.md +82 -7
- package/client.js +790 -61
- package/index.js +52 -15
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
|
|
|
47
47
|
* the Host and the module's `api.version` both read it, so a report can
|
|
48
48
|
* never claim a generation the browser is not actually running.
|
|
49
49
|
*/
|
|
50
|
-
const CLIENT_VERSION = '1.
|
|
50
|
+
const CLIENT_VERSION = '1.5.0'
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Tab kind served by the DSH native right rail (also its implementation id).
|
|
@@ -70,6 +70,13 @@ window.__ModuleLoader__.load({
|
|
|
70
70
|
*/
|
|
71
71
|
const CARD_KIND_BETTER = 'sidecard-ask:card:workbench'
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* How many earlier turns of a card ride the next ask payload. Mirrors the
|
|
75
|
+
* Host's own cap (contract-asserted): the Host trims to the same number,
|
|
76
|
+
* so sending more would only be dead wire weight.
|
|
77
|
+
*/
|
|
78
|
+
const HISTORY_TURNS = 6
|
|
79
|
+
|
|
73
80
|
/**
|
|
74
81
|
* Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
|
|
75
82
|
* before `/state` answers (first paint) and when the Host is unreachable;
|
|
@@ -89,6 +96,8 @@ window.__ModuleLoader__.load({
|
|
|
89
96
|
sideTools: 'readonly',
|
|
90
97
|
sideTimeoutMs: 180000,
|
|
91
98
|
sideProvider: 'auto',
|
|
99
|
+
floatMode: 'capsule',
|
|
100
|
+
floatMaxWidth: 340,
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
/** Zone classification: slot-key patterns the harness itself renders. */
|
|
@@ -97,6 +106,54 @@ window.__ModuleLoader__.load({
|
|
|
97
106
|
task: [/\btask/i, /\btodo/i, /\bschedule/i, /\bteam/i, /\bjob/i, /\bplan/i, /^rightbar\b/, /^sidebar\.right\b/],
|
|
98
107
|
}
|
|
99
108
|
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
110
|
+
// §1b floating-layer (A) geometry
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Placement rules for the floating layer, mirrored by
|
|
115
|
+
* `test/float-placement.mjs`.
|
|
116
|
+
*
|
|
117
|
+
* The layer is anchored to the FRAME's right edge, so it can only ever
|
|
118
|
+
* grow LEFT into the conversation column. Everything here exists to stop
|
|
119
|
+
* that: the layer may use the strip to the right of the conversation
|
|
120
|
+
* column's right boundary (where the right rail lives), but it never
|
|
121
|
+
* crosses that boundary while there is at least `minWidth` of room.
|
|
122
|
+
*/
|
|
123
|
+
const FLOAT_GAP_RIGHT = 8
|
|
124
|
+
const FLOAT_GAP_SIDEBAR = 10
|
|
125
|
+
const FLOAT_MIN_WIDTH = 240
|
|
126
|
+
const FLOAT_MAX_WIDTH_LIMIT = 560
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The floating layer's width for one frame.
|
|
130
|
+
*
|
|
131
|
+
* Pure (no DOM) so both the runtime and the tests can call it:
|
|
132
|
+
* `avail` is the distance from the conversation column's right boundary to
|
|
133
|
+
* the frame's right edge, minus the right rail when one is present. The
|
|
134
|
+
* boundary is treated as un-crossable while `avail >= minWidth`; below that
|
|
135
|
+
* the layer gives up and clamps to `minWidth` (a pill narrower than that is
|
|
136
|
+
* unusable), which is the only case where it may overlap the column.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} input - `frameWidth`, `laneRight`, `railLeft`, `maxWidth`.
|
|
139
|
+
* @returns {{width: number, avail: number, capped: boolean, bleeds: boolean}}
|
|
140
|
+
*/
|
|
141
|
+
function floatPlacement({ frameWidth, laneRight, railLeft = null, maxWidth = CLIENT_DEFAULTS.floatMaxWidth }) {
|
|
142
|
+
const frame = Number.isFinite(frameWidth) ? frameWidth : 0
|
|
143
|
+
const lane = Number.isFinite(laneRight) ? laneRight : 0
|
|
144
|
+
const cap = Math.min(
|
|
145
|
+
FLOAT_MAX_WIDTH_LIMIT,
|
|
146
|
+
Math.max(FLOAT_MIN_WIDTH, Number.isFinite(maxWidth) ? maxWidth : CLIENT_DEFAULTS.floatMaxWidth),
|
|
147
|
+
)
|
|
148
|
+
let avail = frame - FLOAT_GAP_RIGHT - lane
|
|
149
|
+
if (Number.isFinite(railLeft)) avail = Math.min(avail, frame - railLeft - FLOAT_GAP_SIDEBAR)
|
|
150
|
+
avail = Math.max(FLOAT_MIN_WIDTH, Math.floor(avail))
|
|
151
|
+
const width = Math.min(cap, avail)
|
|
152
|
+
// Left edge of a right-anchored layer of this width.
|
|
153
|
+
const left = frame - FLOAT_GAP_RIGHT - width
|
|
154
|
+
return { width, avail, capped: width < cap, bleeds: lane - left > 1 }
|
|
155
|
+
}
|
|
156
|
+
|
|
100
157
|
// ─────────────────────────────────────────────────────────────────────
|
|
101
158
|
// §2 i18n
|
|
102
159
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -129,6 +186,9 @@ window.__ModuleLoader__.load({
|
|
|
129
186
|
answerStopped: '已停止',
|
|
130
187
|
answerNonStreaming: '当前 DSH 版本未提供流式帧,答案一次性返回',
|
|
131
188
|
answerEmpty: '(没有内容)',
|
|
189
|
+
cardClosed: '该卡片已关闭',
|
|
190
|
+
cardMovedToFlow: '该卡片已移至浮层显示',
|
|
191
|
+
reasoningTitle: '思考过程',
|
|
132
192
|
copy: '复制',
|
|
133
193
|
copied: '已复制',
|
|
134
194
|
copySelectedHint: '已选中答案文本,请按 Ctrl+C 复制',
|
|
@@ -146,6 +206,18 @@ window.__ModuleLoader__.load({
|
|
|
146
206
|
noMainCarrier: '主对话发送接口不可用:既不能提交消息,也不能写入输入框',
|
|
147
207
|
errorTitle: '作答失败',
|
|
148
208
|
errorNoSideEngine: '这台 DSH 组合没有可用的子代理,无法在侧边卡片作答;可以改到主对话提问。',
|
|
209
|
+
errorTooLarge: '请求体过大',
|
|
210
|
+
errorBadRequest: '请求无效(问题或选中文本为空)',
|
|
211
|
+
errorUnloaded: '插件正在卸载,无法受理新的追问',
|
|
212
|
+
errorDuplicate: '该追问已在处理中,请等它完成',
|
|
213
|
+
errorBusy: '并发追问已达上限({n}),请稍后再试',
|
|
214
|
+
errorNoSideEngineBody: '这台 DSH 组合没有可用的子代理 provider',
|
|
215
|
+
errorNoParentBody: '当前没有活动的会话代理,无法发起独立作答',
|
|
216
|
+
errorAborted: '作答被取消或超时',
|
|
217
|
+
errorUnreachableBody: '宿主接口不可达',
|
|
218
|
+
errorBadResponseBody: '宿主返回了无法解析的响应',
|
|
219
|
+
errorStreamBrokenBody: '流式连接中断',
|
|
220
|
+
errorStreamEndedBody: '流式连接意外结束',
|
|
149
221
|
surfaceFlow: '浮层卡片',
|
|
150
222
|
surfaceNative: '右侧栏卡片',
|
|
151
223
|
surfaceBetter: '侧边卡片插件',
|
|
@@ -163,6 +235,20 @@ window.__ModuleLoader__.load({
|
|
|
163
235
|
setSurfaceFlow: '内置浮层卡片',
|
|
164
236
|
setMaxChars: '最大字符数',
|
|
165
237
|
setMaxCharsHint: '超过部分截断并在提问中标注',
|
|
238
|
+
setFloatMode: '浮层默认形态',
|
|
239
|
+
setFloatModeCapsule: '胶囊(不遮挡对话,可随时展开)',
|
|
240
|
+
setFloatModeFull: '直接展开成卡片',
|
|
241
|
+
setFloatModeOff: '关闭浮层(只留小圆点)',
|
|
242
|
+
setFloatModeHint: '浮层贴右侧边缘,只在空间不足时才可能压到对话区',
|
|
243
|
+
setFloatMaxWidth: '浮层宽度上限(px)',
|
|
244
|
+
setFloatMaxWidthHint: '空间不足时自动收窄,不会硬顶',
|
|
245
|
+
floatExpand: '展开侧边卡片',
|
|
246
|
+
floatCollapse: '收起为胶囊(不遮挡对话)',
|
|
247
|
+
floatHide: '隐藏浮层(只留小圆点)',
|
|
248
|
+
floatRestore: '恢复浮层',
|
|
249
|
+
floatCount: '{n} 条追问回答',
|
|
250
|
+
floatStreaming: '正在作答…',
|
|
251
|
+
floatDone: '已完成 {n} 条',
|
|
166
252
|
setShortcut: '快捷键',
|
|
167
253
|
setZones: '捕获区域',
|
|
168
254
|
setZonesAuto: '全部区域',
|
|
@@ -175,6 +261,10 @@ window.__ModuleLoader__.load({
|
|
|
175
261
|
setSideTools: '侧边作答工具权限',
|
|
176
262
|
setSideToolsReadonly: '只读(推荐)',
|
|
177
263
|
setSideToolsInherit: '继承当前会话',
|
|
264
|
+
setSideTimeout: '侧边作答超时(毫秒)',
|
|
265
|
+
setSideTimeoutHint: '超过该时间未完成即停止作答',
|
|
266
|
+
setSideProvider: '子代理 provider',
|
|
267
|
+
setSideProviderHint: 'auto = 自动挑选已注册的 provider',
|
|
178
268
|
save: '保存',
|
|
179
269
|
saving: '保存中…',
|
|
180
270
|
saved: '已保存',
|
|
@@ -232,6 +322,9 @@ window.__ModuleLoader__.load({
|
|
|
232
322
|
answerStopped: 'Stopped',
|
|
233
323
|
answerNonStreaming: 'This DSH build publishes no stream frames; the answer arrived at once',
|
|
234
324
|
answerEmpty: '(empty)',
|
|
325
|
+
cardClosed: 'This card was closed',
|
|
326
|
+
cardMovedToFlow: 'This card moved to the floating stack',
|
|
327
|
+
reasoningTitle: 'Reasoning',
|
|
235
328
|
copy: 'Copy',
|
|
236
329
|
copied: 'Copied',
|
|
237
330
|
copySelectedHint: 'Answer selected — press Ctrl+C to copy',
|
|
@@ -249,6 +342,18 @@ window.__ModuleLoader__.load({
|
|
|
249
342
|
noMainCarrier: 'The main-chat send API is unavailable: neither submitting a message nor writing the composer draft worked',
|
|
250
343
|
errorTitle: 'Answer failed',
|
|
251
344
|
errorNoSideEngine: 'This DSH composition has no usable subagent, so the side card cannot answer; ask in the main chat instead.',
|
|
345
|
+
errorTooLarge: 'The request body is too large',
|
|
346
|
+
errorBadRequest: 'Invalid request (empty question or empty selection)',
|
|
347
|
+
errorUnloaded: 'The plugin is unloading and cannot take new follow-ups',
|
|
348
|
+
errorDuplicate: 'This follow-up is already being answered; wait for it to finish',
|
|
349
|
+
errorBusy: 'Too many concurrent answers (limit {n}); try again shortly',
|
|
350
|
+
errorNoSideEngineBody: 'This DSH composition has no usable subagent provider',
|
|
351
|
+
errorNoParentBody: 'No active session agent to answer independently',
|
|
352
|
+
errorAborted: 'The answer was cancelled or timed out',
|
|
353
|
+
errorUnreachableBody: 'The host API is unreachable',
|
|
354
|
+
errorBadResponseBody: 'The host returned an unreadable response',
|
|
355
|
+
errorStreamBrokenBody: 'The stream connection broke',
|
|
356
|
+
errorStreamEndedBody: 'The stream ended without a terminal event',
|
|
252
357
|
surfaceFlow: 'Floating card',
|
|
253
358
|
surfaceNative: 'Right rail',
|
|
254
359
|
surfaceBetter: 'Sidebar plugin',
|
|
@@ -266,6 +371,20 @@ window.__ModuleLoader__.load({
|
|
|
266
371
|
setSurfaceFlow: 'Built-in floating card',
|
|
267
372
|
setMaxChars: 'Max characters',
|
|
268
373
|
setMaxCharsHint: 'Longer selections are truncated and marked in the question',
|
|
374
|
+
setFloatMode: 'Floating layer default',
|
|
375
|
+
setFloatModeCapsule: 'Capsule (never covers the conversation)',
|
|
376
|
+
setFloatModeFull: 'Expanded card stack',
|
|
377
|
+
setFloatModeOff: 'Hidden (small launcher only)',
|
|
378
|
+
setFloatModeHint: 'The layer is anchored to the right edge and only overlaps on very narrow frames',
|
|
379
|
+
setFloatMaxWidth: 'Floating layer max width (px)',
|
|
380
|
+
setFloatMaxWidthHint: 'Narrows automatically when there is not enough room',
|
|
381
|
+
floatExpand: 'Expand the side cards',
|
|
382
|
+
floatCollapse: 'Collapse to a capsule (stops covering the conversation)',
|
|
383
|
+
floatHide: 'Hide the layer (leaves a small launcher)',
|
|
384
|
+
floatRestore: 'Restore the floating layer',
|
|
385
|
+
floatCount: '{n} follow-up answers',
|
|
386
|
+
floatStreaming: 'Answering…',
|
|
387
|
+
floatDone: '{n} done',
|
|
269
388
|
setShortcut: 'Shortcut',
|
|
270
389
|
setZones: 'Capture regions',
|
|
271
390
|
setZonesAuto: 'Every region',
|
|
@@ -278,6 +397,10 @@ window.__ModuleLoader__.load({
|
|
|
278
397
|
setSideTools: 'Side answerer tool access',
|
|
279
398
|
setSideToolsReadonly: 'Read-only (recommended)',
|
|
280
399
|
setSideToolsInherit: 'Inherit from the session',
|
|
400
|
+
setSideTimeout: 'Side answer timeout (ms)',
|
|
401
|
+
setSideTimeoutHint: 'The answer stops once this time is exceeded',
|
|
402
|
+
setSideProvider: 'Subagent provider',
|
|
403
|
+
setSideProviderHint: 'auto picks a registered provider automatically',
|
|
281
404
|
save: 'Save',
|
|
282
405
|
saving: 'Saving…',
|
|
283
406
|
saved: 'Saved',
|
|
@@ -310,13 +433,37 @@ window.__ModuleLoader__.load({
|
|
|
310
433
|
},
|
|
311
434
|
}
|
|
312
435
|
|
|
313
|
-
/**
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
436
|
+
/**
|
|
437
|
+
* Pick a dictionary id from locale candidates, best first. A candidate
|
|
438
|
+
* naming neither zh nor en is skipped, so a lesser preference can still
|
|
439
|
+
* win — `navigator.languages` is an ordered preference list, and a user
|
|
440
|
+
* whose first choice the UI cannot serve should get their second, not a
|
|
441
|
+
* hard fallback. This is the ONE place locale strings become a dictionary
|
|
442
|
+
* id; the harness locale subscription below feeds the same helper.
|
|
443
|
+
* @param {unknown[]} candidates locale strings, best first.
|
|
444
|
+
* @param {'zh'|'en'} fallback applied only when no candidate matches.
|
|
445
|
+
* @returns {'zh'|'en'} the dictionary id.
|
|
446
|
+
*/
|
|
447
|
+
function resolveLanguage(candidates, fallback) {
|
|
448
|
+
for (const candidate of candidates) {
|
|
449
|
+
if (typeof candidate !== 'string' || candidate === '') continue
|
|
450
|
+
const lower = candidate.toLowerCase()
|
|
451
|
+
if (lower.startsWith('zh')) return 'zh'
|
|
452
|
+
if (lower.startsWith('en')) return 'en'
|
|
453
|
+
}
|
|
454
|
+
return fallback
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The active language id, kept in step with the harness locale service.
|
|
459
|
+
* When the browser lists a language the UI does not carry, the next
|
|
460
|
+
* preference decides; only a list with no zh/en entry at all falls back.
|
|
461
|
+
*/
|
|
462
|
+
const initialLocales = [
|
|
463
|
+
...(Array.isArray(navigator?.languages) ? [...navigator.languages] : []),
|
|
464
|
+
...(typeof navigator?.language === 'string' ? [navigator.language] : []),
|
|
465
|
+
]
|
|
466
|
+
let lang = resolveLanguage(initialLocales, initialLocales.length > 0 ? 'en' : 'zh')
|
|
320
467
|
|
|
321
468
|
/**
|
|
322
469
|
* Translate one key with `{name}` interpolation.
|
|
@@ -332,6 +479,37 @@ window.__ModuleLoader__.load({
|
|
|
332
479
|
return text
|
|
333
480
|
}
|
|
334
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Wire-error codes whose meaning is fully known Client-side, so an English
|
|
484
|
+
* interface never has to show the Host's Chinese message line. Codes that
|
|
485
|
+
* carry a live diagnostic in their message (engine-error, blocked-step,
|
|
486
|
+
* http-*, internal, main-failed) are deliberately absent: the Host message
|
|
487
|
+
* is the only place that reason exists, and replacing it would lose it.
|
|
488
|
+
* `vars` receives the whole error when the copy needs a live value.
|
|
489
|
+
*/
|
|
490
|
+
const ERROR_CODES = {
|
|
491
|
+
'too-large': { key: 'errorTooLarge' },
|
|
492
|
+
'bad-request': { key: 'errorBadRequest' },
|
|
493
|
+
unloaded: { key: 'errorUnloaded' },
|
|
494
|
+
duplicate: { key: 'errorDuplicate' },
|
|
495
|
+
busy: { key: 'errorBusy', vars: () => ({ n: store?.state?.config?.maxConcurrentAsks ?? 0 }) },
|
|
496
|
+
'no-side-engine': { key: 'errorNoSideEngineBody' },
|
|
497
|
+
'no-parent': { key: 'errorNoParentBody' },
|
|
498
|
+
aborted: { key: 'errorAborted' },
|
|
499
|
+
unreachable: { key: 'errorUnreachableBody' },
|
|
500
|
+
'bad-response': { key: 'errorBadResponseBody' },
|
|
501
|
+
'stream-broken': { key: 'errorStreamBrokenBody' },
|
|
502
|
+
'stream-ended': { key: 'errorStreamEndedBody' },
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** One localized line for a wire error; unknown codes keep the Host text. */
|
|
506
|
+
function localizeError(error) {
|
|
507
|
+
if (error === null || error === undefined) return ''
|
|
508
|
+
const entry = ERROR_CODES[error?.code]
|
|
509
|
+
if (entry === undefined) return error.message ?? String(error?.code ?? error)
|
|
510
|
+
return t(entry.key, entry.vars?.(error))
|
|
511
|
+
}
|
|
512
|
+
|
|
335
513
|
// ─────────────────────────────────────────────────────────────────────
|
|
336
514
|
// §3 wire client
|
|
337
515
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -467,6 +645,11 @@ window.__ModuleLoader__.load({
|
|
|
467
645
|
hostReachable: null,
|
|
468
646
|
surface: 'flow',
|
|
469
647
|
surfaceNote: null,
|
|
648
|
+
/**
|
|
649
|
+
* In-session override of `config.floatMode`, set by the layer's own
|
|
650
|
+
* collapse/hide buttons. `null` = follow the configured default.
|
|
651
|
+
*/
|
|
652
|
+
floatMode: null,
|
|
470
653
|
sessionId: null,
|
|
471
654
|
trigger: null,
|
|
472
655
|
popover: null,
|
|
@@ -503,6 +686,18 @@ window.__ModuleLoader__.load({
|
|
|
503
686
|
return store.state
|
|
504
687
|
}
|
|
505
688
|
|
|
689
|
+
/**
|
|
690
|
+
* In-session override of the configured floating-layer shape. Deliberately
|
|
691
|
+
* NOT persisted: the settings page owns the durable default, and a reload
|
|
692
|
+
* should return to it (a pill hidden by accident must not stay hidden
|
|
693
|
+
* across sessions).
|
|
694
|
+
* @param {string} mode - `capsule` | `full` | `off`.
|
|
695
|
+
*/
|
|
696
|
+
function setFloatMode(mode) {
|
|
697
|
+
if (mode !== 'capsule' && mode !== 'full' && mode !== 'off') return
|
|
698
|
+
store.set({ floatMode: mode })
|
|
699
|
+
}
|
|
700
|
+
|
|
506
701
|
/** Replace one card by id (immutably; a no-op when it is gone). */
|
|
507
702
|
function patchCard(id, patch) {
|
|
508
703
|
const cards = store.state.cards.map(card => (card.id === id ? { ...card, ...patch } : card))
|
|
@@ -652,7 +847,7 @@ window.__ModuleLoader__.load({
|
|
|
652
847
|
function shouldOffer(candidate, config, options) {
|
|
653
848
|
if (candidate === null) return { ok: false, reason: 'empty' }
|
|
654
849
|
if (config.trigger === 'shortcut') return { ok: false, reason: 'shortcut-only' }
|
|
655
|
-
if (candidate.text.trim()
|
|
850
|
+
if (codePointLength(candidate.text.trim()) < config.minChars) return { ok: false, reason: 'too-short' }
|
|
656
851
|
if (options?.anchors === false) return { ok: true, zoneFiltering: 'unavailable' }
|
|
657
852
|
const zones = config.captureZones
|
|
658
853
|
const zone = candidate.zone
|
|
@@ -675,22 +870,29 @@ window.__ModuleLoader__.load({
|
|
|
675
870
|
|
|
676
871
|
/**
|
|
677
872
|
* Truncate an over-long selection for an outgoing prompt, keeping head and
|
|
678
|
-
* tail. Mirrors the Host's `truncateSelection` (asserted by contract test)
|
|
873
|
+
* tail. Mirrors the Host's `truncateSelection` (asserted by contract test):
|
|
874
|
+
* code points, not UTF-16 units, so a surrogate pair is never split.
|
|
679
875
|
* @returns {{text: string, truncated: boolean, droppedChars: number}}
|
|
680
876
|
*/
|
|
681
877
|
function truncateSelection(text, maxChars) {
|
|
682
878
|
const source = typeof text === 'string' ? text : ''
|
|
683
|
-
|
|
879
|
+
const points = Array.from(source)
|
|
880
|
+
if (points.length <= maxChars) return { text: source, truncated: false, droppedChars: 0 }
|
|
684
881
|
const head = Math.max(1, Math.ceil(maxChars * 0.7))
|
|
685
882
|
const tail = Math.max(0, maxChars - head)
|
|
686
|
-
const dropped =
|
|
883
|
+
const dropped = points.length - head - tail
|
|
687
884
|
return {
|
|
688
|
-
text: `${
|
|
885
|
+
text: `${points.slice(0, head).join('')}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? points.slice(points.length - tail).join('') : ''}`,
|
|
689
886
|
truncated: true,
|
|
690
887
|
droppedChars: dropped,
|
|
691
888
|
}
|
|
692
889
|
}
|
|
693
890
|
|
|
891
|
+
/** Character count a Chinese user would expect: one glyph = one char. */
|
|
892
|
+
function codePointLength(text) {
|
|
893
|
+
return Array.from(String(text ?? '')).length
|
|
894
|
+
}
|
|
895
|
+
|
|
694
896
|
/** Parse a `Ctrl+Shift+K`-style accelerator into matcher facts. */
|
|
695
897
|
function parseShortcut(accelerator) {
|
|
696
898
|
if (typeof accelerator !== 'string') return null
|
|
@@ -880,9 +1082,20 @@ window.__ModuleLoader__.load({
|
|
|
880
1082
|
this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
|
|
881
1083
|
return true
|
|
882
1084
|
},
|
|
883
|
-
close() {
|
|
884
|
-
// The right rail owns its tab lifecycle
|
|
885
|
-
|
|
1085
|
+
close(card) {
|
|
1086
|
+
// The right rail owns its tab lifecycle, so closing never blocks on
|
|
1087
|
+
// it — but since 0.2.0 the rail controller exposes `closeTab` with
|
|
1088
|
+
// the same shape `openTab` has (verified only for `openTab` on this
|
|
1089
|
+
// machine; see README §6). Probe defensively: a mismatching rail
|
|
1090
|
+
// simply keeps the tab, and CardHost then shows the closed-card note.
|
|
1091
|
+
if (typeof this.controller?.closeTab !== 'function') return false
|
|
1092
|
+
try {
|
|
1093
|
+
this.controller.closeTab(CARD_KIND, { params: { cardId: card.id } })
|
|
1094
|
+
return true
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
console.warn(`[${PLUGIN_ID}] native closeTab rejected the close:`, error)
|
|
1097
|
+
return false
|
|
1098
|
+
}
|
|
886
1099
|
},
|
|
887
1100
|
},
|
|
888
1101
|
better: {
|
|
@@ -1042,12 +1255,13 @@ window.__ModuleLoader__.load({
|
|
|
1042
1255
|
(\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
|
|
1043
1256
|
plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
|
|
1044
1257
|
Every surface we draw declares it. The property is inert in a browser. */
|
|
1045
|
-
.dsa-layer,.dsa-trigger,.dsa-pop,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
|
|
1258
|
+
.dsa-layer,.dsa-trigger,.dsa-pop,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
|
|
1046
1259
|
.dsa-layer{pointer-events:none;position:relative;z-index:60}
|
|
1047
1260
|
.dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
|
|
1048
1261
|
padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
1049
1262
|
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
1050
|
-
font-size:12px;line-height:16px;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.18);
|
|
1263
|
+
font-size:12px;line-height:16px;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.18);
|
|
1264
|
+
font-family:inherit;white-space:nowrap}
|
|
1051
1265
|
.dsa-trigger:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
1052
1266
|
.dsa-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
|
|
1053
1267
|
.dsa-pop{pointer-events:auto;position:fixed;width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;
|
|
@@ -1074,6 +1288,9 @@ window.__ModuleLoader__.load({
|
|
|
1074
1288
|
.dsa-btn-primary{background:var(--dsw-alias-brand-primary, #4c8dff);border-color:var(--dsw-alias-brand-primary, #4c8dff);
|
|
1075
1289
|
color:#fff;font-weight:500}
|
|
1076
1290
|
.dsa-btn:disabled{opacity:.55;cursor:default}
|
|
1291
|
+
/* Icon-only variant (the floating layer's collapse/hide controls): square and
|
|
1292
|
+
quiet so the card title bar stays one line and the title keeps the weight. */
|
|
1293
|
+
.dsa-btn-icon{padding:4px 7px;line-height:1;font-size:13px;color:var(--dsw-alias-label-secondary, #666)}
|
|
1077
1294
|
.dsa-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px;overflow:hidden}
|
|
1078
1295
|
.dsa-seg button{padding:3px 10px;border:none;background:transparent;color:var(--dsw-alias-label-secondary, #555);
|
|
1079
1296
|
font:inherit;font-size:12px;cursor:pointer}
|
|
@@ -1082,14 +1299,55 @@ window.__ModuleLoader__.load({
|
|
|
1082
1299
|
.dsa-card{pointer-events:auto;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:12px;
|
|
1083
1300
|
border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
1084
1301
|
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
1085
|
-
box-shadow:0
|
|
1086
|
-
.
|
|
1087
|
-
|
|
1302
|
+
box-shadow:0 4px 16px rgba(0,0,0,.10);font-size:13px;line-height:19px}
|
|
1303
|
+
/* The floating layer (A). Anchored to the frame's RIGHT edge and sized by
|
|
1304
|
+
\`floatPlacement\` through --dsa-float-w, so it grows left only as far as the
|
|
1305
|
+
conversation column allows. pointer-events stays off on the container so the
|
|
1306
|
+
empty area around the pill never eats a click meant for the conversation. */
|
|
1307
|
+
.dsa-float{pointer-events:none;position:fixed;right:var(--dsa-gap-right, 8px);bottom:16px;
|
|
1308
|
+
display:flex;flex-direction:column;gap:8px;width:var(--dsa-float-w, 340px);max-width:92vw}
|
|
1309
|
+
.dsa-float > *{pointer-events:auto}
|
|
1310
|
+
.dsa-stack{display:flex;flex-direction:column;gap:8px;max-height:52vh;overflow:auto}
|
|
1311
|
+
.dsa-capsule{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;text-align:left;
|
|
1312
|
+
padding:8px 12px;border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
|
|
1313
|
+
border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
1314
|
+
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
1315
|
+
box-shadow:0 2px 10px rgba(0,0,0,.14)}
|
|
1316
|
+
.dsa-capsule:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
1317
|
+
.dsa-capsule:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
|
|
1318
|
+
.dsa-capsule-dot{flex:none;width:8px;height:8px;border-radius:50%;
|
|
1319
|
+
background:var(--dsw-alias-state-idle-primary, #8a8f98)}
|
|
1320
|
+
.dsa-capsule-dot[data-status="streaming"]{background:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
1321
|
+
.dsa-capsule-dot[data-status="done"]{background:var(--dsw-alias-state-success-primary, #2e9e5b)}
|
|
1322
|
+
.dsa-capsule-dot[data-status="error"]{background:var(--dsw-alias-state-error-primary, #c0392b)}
|
|
1323
|
+
.dsa-capsule-dot[data-status="stopped"]{background:var(--dsw-alias-state-warn-primary, #b8860b)}
|
|
1324
|
+
.dsa-capsule-label{font-weight:600;white-space:nowrap}
|
|
1325
|
+
.dsa-capsule-q{min-width:0;flex:1;color:var(--dsw-alias-label-secondary, #666);
|
|
1326
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1327
|
+
.dsa-capsule-chev{flex:none;color:var(--dsw-alias-label-secondary, #888)}
|
|
1328
|
+
.dsa-launcher{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;padding:6px 11px;
|
|
1329
|
+
border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
|
|
1330
|
+
border:1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,.45));
|
|
1331
|
+
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-secondary, #666)}
|
|
1332
|
+
.dsa-launcher:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
1088
1333
|
.dsa-answer{max-height:44vh;overflow:auto;word-break:break-word}
|
|
1089
1334
|
.dsa-answer .dsa-p{margin:0 0 6px}
|
|
1090
1335
|
.dsa-answer .dsa-ul{margin:0 0 6px;padding-left:18px}
|
|
1091
1336
|
.dsa-answer .dsa-h{font-weight:600;margin:0 0 6px}
|
|
1092
1337
|
.dsa-answer .dsa-gap{height:4px}
|
|
1338
|
+
.dsa-details summary{cursor:pointer;user-select:none;font-size:12px;line-height:18px;
|
|
1339
|
+
color:var(--dsw-alias-label-secondary, #666)}
|
|
1340
|
+
.dsa-reasoning{max-height:24vh;overflow:auto;margin:4px 0 8px;padding:6px 8px;word-break:break-word;
|
|
1341
|
+
border-left:3px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));border-radius:4px;
|
|
1342
|
+
background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.08));font-size:12px;line-height:17px}
|
|
1343
|
+
.dsa-a{color:var(--dsw-alias-brand-primary, #0066cc);text-decoration:underline;word-break:break-all}
|
|
1344
|
+
.dsa-quote{margin:0 0 6px;padding:6px 10px;border-left:3px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
1345
|
+
border-radius:4px;background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.08));
|
|
1346
|
+
font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary, #555)}
|
|
1347
|
+
.dsa-table{border-collapse:collapse;margin:0 0 6px;font-size:12px;max-width:100%}
|
|
1348
|
+
.dsa-table th,.dsa-table td{border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
1349
|
+
padding:4px 8px;text-align:left;word-break:break-word;vertical-align:top}
|
|
1350
|
+
.dsa-table th{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-weight:600}
|
|
1093
1351
|
.dsa-pre{margin:0 0 6px;padding:8px;border-radius:8px;overflow:auto;
|
|
1094
1352
|
background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-size:12px;line-height:17px;
|
|
1095
1353
|
font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
@@ -1117,10 +1375,10 @@ window.__ModuleLoader__.load({
|
|
|
1117
1375
|
return h('style', { 'data-dsa-style': '' }, CSS_TEXT)
|
|
1118
1376
|
}
|
|
1119
1377
|
|
|
1120
|
-
/** Inline markdown-lite: `code
|
|
1378
|
+
/** Inline markdown-lite: `code`, **bold**, *italic*, [label](http url) — never innerHTML. */
|
|
1121
1379
|
function inlineNodes(text, key) {
|
|
1122
1380
|
const out = []
|
|
1123
|
-
const pattern = /(`[^`]+`|\*\*[^*]
|
|
1381
|
+
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\(https?:\/\/[^\s)]+\))/g
|
|
1124
1382
|
let last = 0
|
|
1125
1383
|
let match
|
|
1126
1384
|
let index = 0
|
|
@@ -1129,8 +1387,23 @@ window.__ModuleLoader__.load({
|
|
|
1129
1387
|
const token = match[0]
|
|
1130
1388
|
if (token.startsWith('`')) {
|
|
1131
1389
|
out.push(h('code', { key: `c${key}-${index++}`, className: 'dsa-code' }, token.slice(1, -1)))
|
|
1132
|
-
} else {
|
|
1390
|
+
} else if (token.startsWith('**')) {
|
|
1133
1391
|
out.push(h('strong', { key: `b${key}-${index++}` }, token.slice(2, -2)))
|
|
1392
|
+
} else if (token.startsWith('*')) {
|
|
1393
|
+
out.push(h('em', { key: `i${key}-${index++}` }, token.slice(1, -1)))
|
|
1394
|
+
} else {
|
|
1395
|
+
// [label](url). The protocol allow-list (http/https only) is the
|
|
1396
|
+
// whole security story: a model answer must not be able to make a
|
|
1397
|
+
// `javascript:` or `data:` URL clickable. The label stays plain
|
|
1398
|
+
// text — no nested syntax inside a link label.
|
|
1399
|
+
const split = token.indexOf('](')
|
|
1400
|
+
out.push(h('a', {
|
|
1401
|
+
key: `a${key}-${index++}`,
|
|
1402
|
+
className: 'dsa-a',
|
|
1403
|
+
href: token.slice(split + 2, -1),
|
|
1404
|
+
target: '_blank',
|
|
1405
|
+
rel: 'noopener noreferrer',
|
|
1406
|
+
}, token.slice(1, split)))
|
|
1134
1407
|
}
|
|
1135
1408
|
last = match.index + token.length
|
|
1136
1409
|
}
|
|
@@ -1140,8 +1413,8 @@ window.__ModuleLoader__.load({
|
|
|
1140
1413
|
|
|
1141
1414
|
/**
|
|
1142
1415
|
* Render answer text as React nodes: fenced code, lists, headings,
|
|
1143
|
-
* paragraphs. No HTML injection anywhere, so model
|
|
1144
|
-
* markup.
|
|
1416
|
+
* blockquotes, tables, paragraphs. No HTML injection anywhere, so model
|
|
1417
|
+
* output cannot become markup.
|
|
1145
1418
|
*/
|
|
1146
1419
|
function renderRichText(text) {
|
|
1147
1420
|
const lines = String(text ?? '').split('\n')
|
|
@@ -1150,6 +1423,15 @@ window.__ModuleLoader__.load({
|
|
|
1150
1423
|
let key = 0
|
|
1151
1424
|
const isBullet = line => /^\s*([-*+]|\d+\.)\s+/.test(line)
|
|
1152
1425
|
const isHeading = line => /^#{1,6}\s+/.test(line)
|
|
1426
|
+
const isQuote = line => /^>\s?/.test(line)
|
|
1427
|
+
// A GFM table separator: `|---|---|`, `---|:---:`, whole row nothing
|
|
1428
|
+
// but pipes, dashes, colons and spaces.
|
|
1429
|
+
const isTableSplit = line => /^\s*\|?[\s:|-]*\|[\s:|-]*$/.test(line)
|
|
1430
|
+
const tableCells = rowText => rowText
|
|
1431
|
+
.replace(/^\s*\|/, '')
|
|
1432
|
+
.replace(/\|\s*$/, '')
|
|
1433
|
+
.split('|')
|
|
1434
|
+
.map(cell => cell.trim())
|
|
1153
1435
|
while (i < lines.length) {
|
|
1154
1436
|
const line = lines[i]
|
|
1155
1437
|
if (/^```/.test(line)) {
|
|
@@ -1163,6 +1445,22 @@ window.__ModuleLoader__.load({
|
|
|
1163
1445
|
nodes.push(h('pre', { key: `f${key++}`, className: 'dsa-pre' }, h('code', null, body.join('\n'))))
|
|
1164
1446
|
continue
|
|
1165
1447
|
}
|
|
1448
|
+
if (line.includes('|') && i + 1 < lines.length && isTableSplit(lines[i + 1])) {
|
|
1449
|
+
const header = tableCells(line)
|
|
1450
|
+
i += 2
|
|
1451
|
+
const rows = []
|
|
1452
|
+
while (i < lines.length && lines[i].trim() !== '' && lines[i].includes('|')) {
|
|
1453
|
+
rows.push(tableCells(lines[i]))
|
|
1454
|
+
i += 1
|
|
1455
|
+
}
|
|
1456
|
+
nodes.push(h('table', { key: `t${key}`, className: 'dsa-table' },
|
|
1457
|
+
h('thead', null, h('tr', null,
|
|
1458
|
+
header.map((cell, index) => h('th', { key: `th${key}-${index}` }, inlineNodes(cell, `${key}h-${index}`))))),
|
|
1459
|
+
h('tbody', null, rows.map((row, rowIndex) => h('tr', { key: `tr${key}-${rowIndex}` },
|
|
1460
|
+
row.map((cell, index) => h('td', { key: `td${key}-${rowIndex}-${index}` }, inlineNodes(cell, `${key}-${rowIndex}-${index}`))))))))
|
|
1461
|
+
key += 1
|
|
1462
|
+
continue
|
|
1463
|
+
}
|
|
1166
1464
|
if (isBullet(line)) {
|
|
1167
1465
|
const items = []
|
|
1168
1466
|
while (i < lines.length && isBullet(lines[i])) {
|
|
@@ -1179,6 +1477,17 @@ window.__ModuleLoader__.load({
|
|
|
1179
1477
|
i += 1
|
|
1180
1478
|
continue
|
|
1181
1479
|
}
|
|
1480
|
+
if (isQuote(line)) {
|
|
1481
|
+
const quoted = []
|
|
1482
|
+
while (i < lines.length && isQuote(lines[i])) {
|
|
1483
|
+
quoted.push(lines[i].replace(/^>\s?/, ''))
|
|
1484
|
+
i += 1
|
|
1485
|
+
}
|
|
1486
|
+
nodes.push(h('blockquote', { key: `q${key}`, className: 'dsa-quote' },
|
|
1487
|
+
quoted.map((row, index) => h('div', { key: `ql${key}-${index}` }, inlineNodes(row, `${key}-${index}`)))))
|
|
1488
|
+
key += 1
|
|
1489
|
+
continue
|
|
1490
|
+
}
|
|
1182
1491
|
if (line.trim() === '') {
|
|
1183
1492
|
nodes.push(h('div', { key: `g${key++}`, className: 'dsa-gap' }))
|
|
1184
1493
|
i += 1
|
|
@@ -1191,6 +1500,7 @@ window.__ModuleLoader__.load({
|
|
|
1191
1500
|
&& !/^```/.test(lines[i])
|
|
1192
1501
|
&& !isBullet(lines[i])
|
|
1193
1502
|
&& !isHeading(lines[i])
|
|
1503
|
+
&& !isQuote(lines[i])
|
|
1194
1504
|
) {
|
|
1195
1505
|
paragraph.push(lines[i])
|
|
1196
1506
|
i += 1
|
|
@@ -1201,6 +1511,26 @@ window.__ModuleLoader__.load({
|
|
|
1201
1511
|
return nodes
|
|
1202
1512
|
}
|
|
1203
1513
|
|
|
1514
|
+
/**
|
|
1515
|
+
* Rough advance width of a 12px label, in CSS pixels: full-width for CJK
|
|
1516
|
+
* ideographs and fullwidth forms, ~0.55em for everything else. The zh
|
|
1517
|
+
* trigger label ("追问选中内容") estimates ~72px of text while the en one
|
|
1518
|
+
* ("Ask about selection") estimates ~125px — exactly the difference a
|
|
1519
|
+
* hard-coded clamp width tuned for zh would miss.
|
|
1520
|
+
*/
|
|
1521
|
+
function estimateLabelWidth(text, fontSize = 12) {
|
|
1522
|
+
let units = 0
|
|
1523
|
+
for (const char of typeof text === 'string' ? text : '') {
|
|
1524
|
+
const code = char.codePointAt(0)
|
|
1525
|
+
const fullWidth = (code >= 0x2e80 && code <= 0x9fff)
|
|
1526
|
+
|| (code >= 0xf900 && code <= 0xfaff)
|
|
1527
|
+
|| (code >= 0xff00 && code <= 0xffef)
|
|
1528
|
+
|| (code >= 0x30000 && code <= 0x3134f)
|
|
1529
|
+
units += fullWidth ? 1 : 0.55
|
|
1530
|
+
}
|
|
1531
|
+
return units * fontSize
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1204
1534
|
/** Clamp a fixed-position box into the viewport. */
|
|
1205
1535
|
function clampBox(rect, width, height, prefer = 'below') {
|
|
1206
1536
|
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
|
|
@@ -1260,7 +1590,10 @@ window.__ModuleLoader__.load({
|
|
|
1260
1590
|
|
|
1261
1591
|
/** The in-place trigger button floating at the selection. */
|
|
1262
1592
|
function TriggerButton({ candidate, onOpen }) {
|
|
1263
|
-
|
|
1593
|
+
// The clamp width follows the live label: icon + gap + padding + border
|
|
1594
|
+
// (~38px of chrome) plus a safety margin, over the estimated label width.
|
|
1595
|
+
const width = Math.ceil(estimateLabelWidth(t('triggerLabel')) + 62)
|
|
1596
|
+
const box = clampBox(candidate.rect, width, 30, 'below')
|
|
1264
1597
|
return h('button', {
|
|
1265
1598
|
type: 'button',
|
|
1266
1599
|
className: 'dsa-trigger',
|
|
@@ -1322,11 +1655,14 @@ window.__ModuleLoader__.load({
|
|
|
1322
1655
|
h('div', { className: 'dsa-row' },
|
|
1323
1656
|
h('span', { className: 'dsa-title' }, t('askTitle')),
|
|
1324
1657
|
h('span', { className: 'dsa-badge' }, zoneLabel(candidate)),
|
|
1325
|
-
h('span', { className: 'dsa-badge' }, t('chars', { n: candidate.text
|
|
1658
|
+
h('span', { className: 'dsa-badge' }, t('chars', { n: codePointLength(candidate.text) })),
|
|
1326
1659
|
cut.truncated
|
|
1327
1660
|
? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: cut.droppedChars }))
|
|
1328
1661
|
: null),
|
|
1329
|
-
h('pre', { className: 'dsa-quote' },
|
|
1662
|
+
h('pre', { className: 'dsa-quote' },
|
|
1663
|
+
codePointLength(candidate.text) > 400
|
|
1664
|
+
? `${Array.from(candidate.text).slice(0, 400).join('')}…`
|
|
1665
|
+
: candidate.text),
|
|
1330
1666
|
h('textarea', {
|
|
1331
1667
|
ref: areaRef,
|
|
1332
1668
|
className: 'dsa-textarea',
|
|
@@ -1363,20 +1699,42 @@ window.__ModuleLoader__.load({
|
|
|
1363
1699
|
|
|
1364
1700
|
/** The streamed answer card body, shared by every surface. */
|
|
1365
1701
|
function AnswerBody({ card, containerRef }) {
|
|
1702
|
+
// The reasoning stream is collapsible on purpose: it is diagnostic
|
|
1703
|
+
// context, not the answer, and a long chain-of-thought would otherwise
|
|
1704
|
+
// bury the answer the user asked for. Collapsed by default, open on
|
|
1705
|
+
// demand — the text inside still streams live while it is open.
|
|
1706
|
+
const reasoning = typeof card.reasoning === 'string' && card.reasoning !== ''
|
|
1707
|
+
? h('details', { className: 'dsa-details' },
|
|
1708
|
+
h('summary', null, t('reasoningTitle')),
|
|
1709
|
+
h('div', { className: 'dsa-reasoning' }, renderRichText(card.reasoning)))
|
|
1710
|
+
: null
|
|
1711
|
+
// Follow the stream only while the reader sits at the bottom; scrolling
|
|
1712
|
+
// up to reread an earlier paragraph must win over the auto-scroll. The
|
|
1713
|
+
// 48px slack keeps single-line deltas from losing the tail.
|
|
1714
|
+
React.useEffect(() => {
|
|
1715
|
+
if (card.status !== 'streaming') return undefined
|
|
1716
|
+
const element = containerRef?.current
|
|
1717
|
+
if (element === null || element === undefined) return undefined
|
|
1718
|
+
if (element.scrollHeight - element.scrollTop - element.clientHeight <= 48) {
|
|
1719
|
+
element.scrollTop = element.scrollHeight
|
|
1720
|
+
}
|
|
1721
|
+
return undefined
|
|
1722
|
+
}, [card.text, card.reasoning, card.status, containerRef])
|
|
1366
1723
|
if (card.error !== null) {
|
|
1367
1724
|
const engineMissing = card.error.code === 'no-side-engine' || card.error.code === 'no-parent'
|
|
1368
1725
|
return h('div', { className: 'dsa-error' },
|
|
1369
1726
|
h('div', { className: 'dsa-title' }, t('errorTitle')),
|
|
1370
|
-
h('div', null,
|
|
1727
|
+
h('div', null, localizeError(card.error)),
|
|
1371
1728
|
engineMissing ? h('div', { className: 'dsa-muted' }, t('errorNoSideEngine')) : null)
|
|
1372
1729
|
}
|
|
1373
1730
|
if (card.text === '' && card.status === 'streaming') {
|
|
1374
|
-
return h('div', { className: 'dsa-muted' }, t('answerStreaming'))
|
|
1731
|
+
return h('div', null, reasoning, h('div', { className: 'dsa-muted' }, t('answerStreaming')))
|
|
1375
1732
|
}
|
|
1376
1733
|
if (card.text === '' && card.status === 'done') {
|
|
1377
|
-
return h('div', { className: 'dsa-muted' }, t('answerEmpty'))
|
|
1734
|
+
return h('div', null, reasoning, h('div', { className: 'dsa-muted' }, t('answerEmpty')))
|
|
1378
1735
|
}
|
|
1379
|
-
return h('div', { className: 'dsa-answer', ref: containerRef },
|
|
1736
|
+
return h('div', { className: 'dsa-answer', ref: containerRef },
|
|
1737
|
+
reasoning, renderRichText(card.text))
|
|
1380
1738
|
}
|
|
1381
1739
|
|
|
1382
1740
|
/** Card footer: status, copy, follow-up, retry, close. */
|
|
@@ -1410,18 +1768,40 @@ window.__ModuleLoader__.load({
|
|
|
1410
1768
|
}, t('close')))
|
|
1411
1769
|
}
|
|
1412
1770
|
|
|
1413
|
-
/**
|
|
1414
|
-
|
|
1771
|
+
/**
|
|
1772
|
+
* One answer card; `embedded` is true inside a host's tab.
|
|
1773
|
+
*
|
|
1774
|
+
* `onFloatMode` is passed ONLY by the floating layer: it renders the
|
|
1775
|
+
* collapse/hide controls that let the user get the layer out of the way
|
|
1776
|
+
* (the layer is the only surface that can cover the conversation). A host
|
|
1777
|
+
* tab that embeds the same card passes no callback and gets no buttons.
|
|
1778
|
+
*/
|
|
1779
|
+
function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain, onFloatMode }) {
|
|
1415
1780
|
const [followOpen, setFollowOpen] = React.useState(false)
|
|
1416
1781
|
const [draft, setDraft] = React.useState('')
|
|
1417
1782
|
const answerRef = React.useRef(null)
|
|
1418
1783
|
const config = store.state.config
|
|
1784
|
+
// Icon-only, so the header row keeps one line: the full labels
|
|
1785
|
+
// ("收起为胶囊(不遮挡对话)") are 12+ characters each and would wrap the
|
|
1786
|
+
// title bar. The words stay on `title`/`aria-label`.
|
|
1787
|
+
const floatControl = (mode, labelKey, glyph) => h('button', {
|
|
1788
|
+
type: 'button',
|
|
1789
|
+
className: 'dsa-btn dsa-btn-icon',
|
|
1790
|
+
title: t(labelKey),
|
|
1791
|
+
'aria-label': t(labelKey),
|
|
1792
|
+
onClick: () => onFloatMode(mode),
|
|
1793
|
+
}, glyph)
|
|
1419
1794
|
return h('section', { className: 'dsa-card', 'aria-label': t('answerTitle') },
|
|
1420
1795
|
h('div', { className: 'dsa-row' },
|
|
1421
1796
|
h('span', { className: 'dsa-title' }, t('answerTitle')),
|
|
1422
1797
|
h('span', { className: 'dsa-badge' }, zoneLabel(card)),
|
|
1423
1798
|
surfaceLabel !== null ? h('span', { className: 'dsa-badge' }, surfaceLabel) : null,
|
|
1424
|
-
card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null
|
|
1799
|
+
card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null,
|
|
1800
|
+
typeof onFloatMode === 'function'
|
|
1801
|
+
? h('span', { className: 'dsa-row', style: { marginLeft: 'auto' } },
|
|
1802
|
+
floatControl('capsule', 'floatCollapse', '⇥'),
|
|
1803
|
+
floatControl('off', 'floatHide', '×'))
|
|
1804
|
+
: null),
|
|
1425
1805
|
h('pre', { className: 'dsa-quote' }, card.question),
|
|
1426
1806
|
h(AnswerBody, { card, containerRef: answerRef }),
|
|
1427
1807
|
card.toolFilter === 'unsupported'
|
|
@@ -1462,11 +1842,124 @@ window.__ModuleLoader__.load({
|
|
|
1462
1842
|
surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null)
|
|
1463
1843
|
}
|
|
1464
1844
|
|
|
1465
|
-
/**
|
|
1466
|
-
|
|
1845
|
+
/**
|
|
1846
|
+
* The flow surface, i.e. the floating layer (A).
|
|
1847
|
+
*
|
|
1848
|
+
* Three shapes, all sharing one right-anchored container:
|
|
1849
|
+
* - `capsule`: a single ~34px pill (status + truncated question). The
|
|
1850
|
+
* default, because a one-line pill cannot bury the conversation.
|
|
1851
|
+
* - `full`: the classic card stack, collapsed on demand.
|
|
1852
|
+
* - `off`: only a small launcher, so nothing covers the page until asked.
|
|
1853
|
+
*
|
|
1854
|
+
* The width comes from {@link floatPlacement}, so the layer stops growing
|
|
1855
|
+
* left as soon as it would cross the conversation column's right boundary.
|
|
1856
|
+
*/
|
|
1857
|
+
function FloatingLayer({ cards, actions }) {
|
|
1858
|
+
const containerRef = React.useRef(null)
|
|
1859
|
+
const state = useStoreState()
|
|
1467
1860
|
const visible = cards.filter(card => card.surface === 'flow' && card.open)
|
|
1468
|
-
|
|
1469
|
-
|
|
1861
|
+
const total = visible.length
|
|
1862
|
+
const mode = state.floatMode ?? state.config.floatMode ?? CLIENT_DEFAULTS.floatMode
|
|
1863
|
+
const maxWidth = state.config.floatMaxWidth ?? CLIENT_DEFAULTS.floatMaxWidth
|
|
1864
|
+
|
|
1865
|
+
// Measure the conversation column + right rail, then pin the layer's
|
|
1866
|
+
// width to what is left. Recomputed on frame resize and on rail toggles
|
|
1867
|
+
// (ResizeObserver), not once at mount. Every probe is optional: the test
|
|
1868
|
+
// harness's synthetic DOM has no `querySelectorAll`, and an older WebView
|
|
1869
|
+
// may have no `ResizeObserver` — neither may break the layer.
|
|
1870
|
+
React.useEffect(() => {
|
|
1871
|
+
const node = containerRef.current
|
|
1872
|
+
if (node === null || node === undefined) return undefined
|
|
1873
|
+
const selector = '[data-slot^="conversation"], .rightbar, [data-slot^="sidebar.right"], [data-dsh-panel-host]'
|
|
1874
|
+
const all = () => {
|
|
1875
|
+
try {
|
|
1876
|
+
return typeof document.querySelectorAll === 'function'
|
|
1877
|
+
? [...document.querySelectorAll(selector)]
|
|
1878
|
+
: []
|
|
1879
|
+
} catch { return [] }
|
|
1880
|
+
}
|
|
1881
|
+
const one = (query) => {
|
|
1882
|
+
try { return typeof document.querySelector === 'function' ? document.querySelector(query) : null } catch { return null }
|
|
1883
|
+
}
|
|
1884
|
+
let frame = 0
|
|
1885
|
+
const apply = () => {
|
|
1886
|
+
if (typeof node.style?.setProperty !== 'function') return
|
|
1887
|
+
const lane = one('[data-slot^="conversation"]')
|
|
1888
|
+
const rail = one('.rightbar, [data-slot^="sidebar.right"], [data-dsh-panel-host]')
|
|
1889
|
+
const rootWidth = document.documentElement?.clientWidth ?? window.innerWidth
|
|
1890
|
+
const laneRight = lane === null
|
|
1891
|
+
? rootWidth
|
|
1892
|
+
: lane.getBoundingClientRect().right
|
|
1893
|
+
const railLeft = rail === null ? null : rail.getBoundingClientRect().left
|
|
1894
|
+
const placement = floatPlacement({ frameWidth: rootWidth, laneRight, railLeft, maxWidth })
|
|
1895
|
+
node.style.setProperty('--dsa-float-w', `${placement.width}px`)
|
|
1896
|
+
node.style.setProperty('--dsa-gap-right', `${FLOAT_GAP_RIGHT}px`)
|
|
1897
|
+
// Readable placement, so a user report can say WHERE the layer sat.
|
|
1898
|
+
node.dataset.layerWidth = String(placement.width)
|
|
1899
|
+
}
|
|
1900
|
+
const schedule = () => {
|
|
1901
|
+
if (typeof window.requestAnimationFrame !== 'function') { apply(); return }
|
|
1902
|
+
if (frame !== 0) return
|
|
1903
|
+
frame = window.requestAnimationFrame(() => {
|
|
1904
|
+
frame = 0
|
|
1905
|
+
apply()
|
|
1906
|
+
})
|
|
1907
|
+
}
|
|
1908
|
+
apply()
|
|
1909
|
+
let observer = null
|
|
1910
|
+
if (typeof window.ResizeObserver === 'function') {
|
|
1911
|
+
observer = new window.ResizeObserver(schedule)
|
|
1912
|
+
for (const target of all()) {
|
|
1913
|
+
try { observer.observe(target) } catch { /* not an element we can watch */ }
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
if (typeof window.addEventListener === 'function') window.addEventListener('resize', schedule)
|
|
1917
|
+
return () => {
|
|
1918
|
+
if (typeof window.removeEventListener === 'function') window.removeEventListener('resize', schedule)
|
|
1919
|
+
if (observer !== null) { try { observer.disconnect() } catch { /* already gone */ } }
|
|
1920
|
+
if (frame !== 0 && typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(frame)
|
|
1921
|
+
}
|
|
1922
|
+
}, [maxWidth, total, mode])
|
|
1923
|
+
|
|
1924
|
+
if (total === 0) return null
|
|
1925
|
+
|
|
1926
|
+
const dot = h('span', {
|
|
1927
|
+
className: 'dsa-capsule-dot',
|
|
1928
|
+
'data-status': visible.some(card => card.status === 'streaming') ? 'streaming' : visible[0].status,
|
|
1929
|
+
})
|
|
1930
|
+
const label = visible.some(card => card.status === 'streaming')
|
|
1931
|
+
? t('floatStreaming')
|
|
1932
|
+
: t('floatDone', { n: total })
|
|
1933
|
+
const firstQuestion = visible[0].question
|
|
1934
|
+
|
|
1935
|
+
const container = children => h('div', {
|
|
1936
|
+
className: 'dsa-float',
|
|
1937
|
+
'data-dsa-root': '',
|
|
1938
|
+
ref: containerRef,
|
|
1939
|
+
'data-dsa-float-mode': mode,
|
|
1940
|
+
}, children)
|
|
1941
|
+
|
|
1942
|
+
if (mode === 'off') {
|
|
1943
|
+
return container(h('button', {
|
|
1944
|
+
type: 'button',
|
|
1945
|
+
className: 'dsa-launcher',
|
|
1946
|
+
title: t('floatRestore'),
|
|
1947
|
+
onClick: () => setFloatMode('full'),
|
|
1948
|
+
}, dot, t('floatCount', { n: total })))
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
if (mode === 'capsule') {
|
|
1952
|
+
return container(h('button', {
|
|
1953
|
+
type: 'button',
|
|
1954
|
+
className: 'dsa-capsule',
|
|
1955
|
+
title: t('floatExpand'),
|
|
1956
|
+
onClick: () => setFloatMode('full'),
|
|
1957
|
+
}, dot, h('span', { className: 'dsa-capsule-label' }, label),
|
|
1958
|
+
h('span', { className: 'dsa-capsule-q' }, firstQuestion),
|
|
1959
|
+
h('span', { className: 'dsa-capsule-chev' }, '›')))
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
return container(h('div', { className: 'dsa-stack' },
|
|
1470
1963
|
visible.map(card => h(AnswerCard, {
|
|
1471
1964
|
key: card.id,
|
|
1472
1965
|
card,
|
|
@@ -1477,7 +1970,8 @@ window.__ModuleLoader__.load({
|
|
|
1477
1970
|
onCancel: () => actions.cancel(card),
|
|
1478
1971
|
onSendToMain: () => actions.sendToMain(card),
|
|
1479
1972
|
onClose: () => actions.close(card),
|
|
1480
|
-
|
|
1973
|
+
onFloatMode: setFloatMode,
|
|
1974
|
+
}))))
|
|
1481
1975
|
}
|
|
1482
1976
|
|
|
1483
1977
|
/** The `shell.overlay` entry: styles + trigger + popover + cards + toast. */
|
|
@@ -1499,7 +1993,7 @@ window.__ModuleLoader__.load({
|
|
|
1499
1993
|
onSubmit: payload => actions.submit(popover.candidate, payload),
|
|
1500
1994
|
})
|
|
1501
1995
|
: null,
|
|
1502
|
-
h(
|
|
1996
|
+
h(FloatingLayer, { cards: state.cards, actions }),
|
|
1503
1997
|
state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
|
|
1504
1998
|
}
|
|
1505
1999
|
|
|
@@ -1533,6 +2027,25 @@ window.__ModuleLoader__.load({
|
|
|
1533
2027
|
onChange: event => setDraft({ ...draft, [key]: Number(event.target.value) }),
|
|
1534
2028
|
})
|
|
1535
2029
|
|
|
2030
|
+
/**
|
|
2031
|
+
* Provider picker: `auto` plus whatever `/state` reported live. The
|
|
2032
|
+
* current draft value is always offered, so a stale saved name or an
|
|
2033
|
+
* unreachable host never blanks the select.
|
|
2034
|
+
*/
|
|
2035
|
+
const providerSelect = () => {
|
|
2036
|
+
const reported = Array.isArray(state.capabilities?.sideProviders)
|
|
2037
|
+
? state.capabilities.sideProviders.filter(name => typeof name === 'string' && name !== '')
|
|
2038
|
+
: []
|
|
2039
|
+
const known = ['auto', ...reported]
|
|
2040
|
+
const value = String(draft.sideProvider)
|
|
2041
|
+
const options = known.includes(value) ? known : [...known, value]
|
|
2042
|
+
return h('select', {
|
|
2043
|
+
className: 'dsa-input',
|
|
2044
|
+
value,
|
|
2045
|
+
onChange: event => setDraft({ ...draft, sideProvider: event.target.value }),
|
|
2046
|
+
}, options.map(name => h('option', { key: name, value: name }, name)))
|
|
2047
|
+
}
|
|
2048
|
+
|
|
1536
2049
|
const save = async () => {
|
|
1537
2050
|
if (parseShortcut(draft.shortcut) === null) {
|
|
1538
2051
|
setStatus(`error:${t('unsupportedTrigger')}`)
|
|
@@ -1575,6 +2088,12 @@ window.__ModuleLoader__.load({
|
|
|
1575
2088
|
['chat+task', t('setZonesBoth')],
|
|
1576
2089
|
])),
|
|
1577
2090
|
field(t('setMaxChars'), number('maxChars', 200, 60000), t('setMaxCharsHint')),
|
|
2091
|
+
field(t('setFloatMode'), select('floatMode', [
|
|
2092
|
+
['capsule', t('setFloatModeCapsule')],
|
|
2093
|
+
['full', t('setFloatModeFull')],
|
|
2094
|
+
['off', t('setFloatModeOff')],
|
|
2095
|
+
]), t('setFloatModeHint')),
|
|
2096
|
+
field(t('setFloatMaxWidth'), number('floatMaxWidth', 240, 560), t('setFloatMaxWidthHint')),
|
|
1578
2097
|
field(t('setMinChars'), number('minChars', 0, 200)),
|
|
1579
2098
|
field(t('setConcurrency'), number('maxConcurrentAsks', 1, 12)),
|
|
1580
2099
|
field(t('setShortcut'), h('input', {
|
|
@@ -1585,7 +2104,9 @@ window.__ModuleLoader__.load({
|
|
|
1585
2104
|
field(t('setSideTools'), select('sideTools', [
|
|
1586
2105
|
['readonly', t('setSideToolsReadonly')],
|
|
1587
2106
|
['inherit', t('setSideToolsInherit')],
|
|
1588
|
-
]))
|
|
2107
|
+
])),
|
|
2108
|
+
field(t('setSideTimeout'), number('sideTimeoutMs', 5000, 3600000), t('setSideTimeoutHint')),
|
|
2109
|
+
field(t('setSideProvider'), providerSelect(), t('setSideProviderHint'))),
|
|
1589
2110
|
h('label', { className: 'dsa-check' },
|
|
1590
2111
|
h('input', {
|
|
1591
2112
|
type: 'checkbox',
|
|
@@ -1715,7 +2236,7 @@ window.__ModuleLoader__.load({
|
|
|
1715
2236
|
const disposers = []
|
|
1716
2237
|
const controllers = new Map()
|
|
1717
2238
|
/** Pending surface render-proof timers (cleared on unload). */
|
|
1718
|
-
const pendingProofs = new
|
|
2239
|
+
const pendingProofs = new Map()
|
|
1719
2240
|
let lastSignature = null
|
|
1720
2241
|
let lastAt = 0
|
|
1721
2242
|
let pendingTimer = null
|
|
@@ -1740,6 +2261,96 @@ window.__ModuleLoader__.load({
|
|
|
1740
2261
|
}
|
|
1741
2262
|
}
|
|
1742
2263
|
|
|
2264
|
+
// ── card persistence ────────────────────────────────────────────
|
|
2265
|
+
// A page reload used to lose every finished answer: the store is
|
|
2266
|
+
// memory-only and the reload drops the SSE link mid-stream. Finished
|
|
2267
|
+
// cards are therefore mirrored into `localStorage` (same data, no
|
|
2268
|
+
// server round-trip) and replayed on boot. Everything here is optional
|
|
2269
|
+
// by design — a private-mode window (throws on setItem), a full quota,
|
|
2270
|
+
// or a tampered payload must degrade to "no history", never break boot.
|
|
2271
|
+
const PERSIST_KEY = 'dsh-sidecard-ask:cards:v1'
|
|
2272
|
+
const PERSIST_MAX_CARDS = 10
|
|
2273
|
+
const PERSIST_TEXT_LIMIT = 65536
|
|
2274
|
+
|
|
2275
|
+
function readPersisted() {
|
|
2276
|
+
let raw = null
|
|
2277
|
+
try {
|
|
2278
|
+
raw = window.localStorage?.getItem(PERSIST_KEY)
|
|
2279
|
+
} catch { return [] }
|
|
2280
|
+
if (raw === null || raw === undefined) return []
|
|
2281
|
+
try {
|
|
2282
|
+
const value = JSON.parse(raw)
|
|
2283
|
+
if (value?.version !== 1 || !Array.isArray(value.cards)) return []
|
|
2284
|
+
return value.cards
|
|
2285
|
+
.filter(entry => typeof entry?.id === 'string' && entry.id !== '' && typeof entry.question === 'string')
|
|
2286
|
+
.slice(0, PERSIST_MAX_CARDS)
|
|
2287
|
+
} catch { return [] }
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
function writePersisted(cards) {
|
|
2291
|
+
try {
|
|
2292
|
+
window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 1, cards }))
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
// Quota or privacy mode — the cards stay on screen, just not durable.
|
|
2295
|
+
console.warn(`[${PLUGIN_ID}] card history not persisted:`, error)
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
/** Recompute the durable set from the live store and write it. */
|
|
2300
|
+
const syncPersisted = () => {
|
|
2301
|
+
const finished = store.state.cards
|
|
2302
|
+
.filter(card => card.carrier === 'side'
|
|
2303
|
+
&& (card.status === 'done' || card.status === 'stopped')
|
|
2304
|
+
&& card.text !== '')
|
|
2305
|
+
.slice(0, PERSIST_MAX_CARDS)
|
|
2306
|
+
.map(card => ({
|
|
2307
|
+
id: card.id,
|
|
2308
|
+
question: card.question,
|
|
2309
|
+
selected: String(card.selected ?? '').slice(0, PERSIST_TEXT_LIMIT),
|
|
2310
|
+
selection: String(card.selection ?? '').slice(0, PERSIST_TEXT_LIMIT),
|
|
2311
|
+
text: String(card.text ?? '').slice(0, PERSIST_TEXT_LIMIT),
|
|
2312
|
+
reasoning: String(card.reasoning ?? '').slice(0, PERSIST_TEXT_LIMIT),
|
|
2313
|
+
status: card.status === 'stopped' ? 'stopped' : 'done',
|
|
2314
|
+
carrier: 'side',
|
|
2315
|
+
zone: card.zone,
|
|
2316
|
+
truncated: card.truncated === true,
|
|
2317
|
+
droppedChars: Number(card.droppedChars ?? 0),
|
|
2318
|
+
history: Array.isArray(card.history) ? card.history.slice(-12) : [],
|
|
2319
|
+
}))
|
|
2320
|
+
writePersisted(finished)
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/** Replay finished cards from a previous page load into the store. */
|
|
2324
|
+
const restorePersisted = () => {
|
|
2325
|
+
const saved = readPersisted()
|
|
2326
|
+
if (saved.length === 0) return
|
|
2327
|
+
const replayed = saved.map(entry => ({
|
|
2328
|
+
id: entry.id,
|
|
2329
|
+
question: entry.question,
|
|
2330
|
+
selected: String(entry.selected ?? ''),
|
|
2331
|
+
selection: String(entry.selection ?? ''),
|
|
2332
|
+
text: String(entry.text ?? ''),
|
|
2333
|
+
reasoning: String(entry.reasoning ?? ''),
|
|
2334
|
+
status: entry.status === 'stopped' ? 'stopped' : 'done',
|
|
2335
|
+
carrier: 'side',
|
|
2336
|
+
zone: entry.zone,
|
|
2337
|
+
truncated: entry.truncated === true,
|
|
2338
|
+
droppedChars: Number(entry.droppedChars ?? 0),
|
|
2339
|
+
streaming: null,
|
|
2340
|
+
error: null,
|
|
2341
|
+
// A native/better tab cannot survive a reload, so history always
|
|
2342
|
+
// comes back on the flow stack where it is visible immediately.
|
|
2343
|
+
surface: 'flow',
|
|
2344
|
+
open: true,
|
|
2345
|
+
history: Array.isArray(entry.history)
|
|
2346
|
+
? entry.history
|
|
2347
|
+
.filter(turn => turn !== null && typeof turn === 'object')
|
|
2348
|
+
.map(turn => ({ question: String(turn.question ?? ''), answer: String(turn.answer ?? '') }))
|
|
2349
|
+
: [],
|
|
2350
|
+
}))
|
|
2351
|
+
store.set({ cards: [...replayed, ...store.state.cards] })
|
|
2352
|
+
}
|
|
2353
|
+
|
|
1743
2354
|
const actions = {
|
|
1744
2355
|
openPopover(candidate) {
|
|
1745
2356
|
store.set({ popover: { candidate } })
|
|
@@ -1759,7 +2370,6 @@ window.__ModuleLoader__.load({
|
|
|
1759
2370
|
truncated: cut.truncated,
|
|
1760
2371
|
droppedChars: cut.droppedChars,
|
|
1761
2372
|
zone: candidate.zone,
|
|
1762
|
-
cross: candidate.cross,
|
|
1763
2373
|
carrier: payload.carrier,
|
|
1764
2374
|
status: 'pending',
|
|
1765
2375
|
text: '',
|
|
@@ -1817,11 +2427,13 @@ window.__ModuleLoader__.load({
|
|
|
1817
2427
|
} else {
|
|
1818
2428
|
// Render proof: an adapter that accepts the open but never
|
|
1819
2429
|
// mounts our body would leave an empty tab. Unless the card is
|
|
1820
|
-
//
|
|
1821
|
-
// card's data lives in the
|
|
1822
|
-
// continues
|
|
2430
|
+
// mounted by then (CardHost cancels its own proof on mount),
|
|
2431
|
+
// move it to the flow stack — the card's data lives in the
|
|
2432
|
+
// store, so the running stream simply continues on the other
|
|
2433
|
+
// surface. A proof that fires before a lazy host mounts is
|
|
2434
|
+
// what the "moved to the flow stack" body below covers.
|
|
1823
2435
|
const proof = setTimeout(() => {
|
|
1824
|
-
pendingProofs.delete(
|
|
2436
|
+
pendingProofs.delete(card.id)
|
|
1825
2437
|
const live = store.state.cards.find(item => item.id === card.id)
|
|
1826
2438
|
if (live === undefined || live.surface === 'flow') return
|
|
1827
2439
|
if (renderedCardId === card.id) return
|
|
@@ -1830,7 +2442,7 @@ window.__ModuleLoader__.load({
|
|
|
1830
2442
|
toast(t('surfaceUnproven'))
|
|
1831
2443
|
scheduleReport(0)
|
|
1832
2444
|
}, 600)
|
|
1833
|
-
pendingProofs.
|
|
2445
|
+
pendingProofs.set(card.id, proof)
|
|
1834
2446
|
}
|
|
1835
2447
|
}
|
|
1836
2448
|
void runSideCard(card.id)
|
|
@@ -1877,7 +2489,15 @@ window.__ModuleLoader__.load({
|
|
|
1877
2489
|
},
|
|
1878
2490
|
cancel(card) {
|
|
1879
2491
|
const controller = controllers.get(card.id)
|
|
1880
|
-
if (controller !== undefined)
|
|
2492
|
+
if (controller !== undefined) {
|
|
2493
|
+
controller.abort()
|
|
2494
|
+
// The local abort stops only the browser's stream: the Host's
|
|
2495
|
+
// child run would keep burning until its timeout unless told,
|
|
2496
|
+
// so POST /cancel too. Fire-and-forget on purpose — the card is
|
|
2497
|
+
// already stopped locally, and a dead host must not turn the
|
|
2498
|
+
// Stop button into a failure.
|
|
2499
|
+
void postJson('cancel', { id: card.id }).catch(() => {})
|
|
2500
|
+
}
|
|
1881
2501
|
patchCard(card.id, { status: 'stopped' })
|
|
1882
2502
|
},
|
|
1883
2503
|
async sendToMain(card) {
|
|
@@ -1906,14 +2526,33 @@ window.__ModuleLoader__.load({
|
|
|
1906
2526
|
}
|
|
1907
2527
|
},
|
|
1908
2528
|
close(card) {
|
|
1909
|
-
controllers.get(card.id)
|
|
2529
|
+
const controller = controllers.get(card.id)
|
|
2530
|
+
if (controller !== undefined) {
|
|
2531
|
+
controller.abort()
|
|
2532
|
+
// Same host-side release as the Stop button: closing a card
|
|
2533
|
+
// mid-stream must not leave the child running server-side.
|
|
2534
|
+
void postJson('cancel', { id: card.id }).catch(() => {})
|
|
2535
|
+
}
|
|
1910
2536
|
controllers.delete(card.id)
|
|
2537
|
+
// A closed card's buffered-but-unflushed deltas belong to nobody:
|
|
2538
|
+
// drop them here so the next tick's flush cannot touch them.
|
|
2539
|
+
pendingDeltas.delete(card.id)
|
|
2540
|
+
pendingReasonings.delete(card.id)
|
|
2541
|
+
// Every surface gets a chance to close its own container: an
|
|
2542
|
+
// orphaned native tab is indistinguishable from a stuck card to
|
|
2543
|
+
// the user, so closing the card must close the tab too (the rail
|
|
2544
|
+
// silently ignores a close it cannot perform).
|
|
1911
2545
|
if (card.surface === 'better-sidebar') {
|
|
1912
2546
|
try {
|
|
1913
2547
|
surfaces.better.close(card)
|
|
1914
2548
|
} catch { /* the host may already be gone */ }
|
|
2549
|
+
} else if (card.surface === 'native-rightbar') {
|
|
2550
|
+
try {
|
|
2551
|
+
surfaces.native.close(card)
|
|
2552
|
+
} catch { /* the right rail owns its lifecycle */ }
|
|
1915
2553
|
}
|
|
1916
2554
|
store.set({ cards: store.state.cards.filter(item => item.id !== card.id) })
|
|
2555
|
+
syncPersisted()
|
|
1917
2556
|
},
|
|
1918
2557
|
async saveConfig(patch) {
|
|
1919
2558
|
const value = await postJson('config', patch)
|
|
@@ -1938,6 +2577,44 @@ window.__ModuleLoader__.load({
|
|
|
1938
2577
|
},
|
|
1939
2578
|
}
|
|
1940
2579
|
|
|
2580
|
+
/**
|
|
2581
|
+
* Streaming deltas coalesce into one store commit per tick. Every
|
|
2582
|
+
* commit re-renders every card and re-parses the whole answer, so a
|
|
2583
|
+
* fast stream on a long answer was quadratic work. A terminal event
|
|
2584
|
+
* (or cancel, or close) flushes the buffer first, so no streamed text
|
|
2585
|
+
* is ever lost on the way into the store.
|
|
2586
|
+
*/
|
|
2587
|
+
const pendingDeltas = new Map()
|
|
2588
|
+
const pendingReasonings = new Map()
|
|
2589
|
+
let deltaFlushTimer = null
|
|
2590
|
+
|
|
2591
|
+
const flushStreamBuffers = () => {
|
|
2592
|
+
if (deltaFlushTimer !== null) {
|
|
2593
|
+
clearTimeout(deltaFlushTimer)
|
|
2594
|
+
deltaFlushTimer = null
|
|
2595
|
+
}
|
|
2596
|
+
if (pendingDeltas.size > 0) {
|
|
2597
|
+
for (const [cardId, chunk] of [...pendingDeltas.entries()]) appendCardText(cardId, chunk)
|
|
2598
|
+
pendingDeltas.clear()
|
|
2599
|
+
}
|
|
2600
|
+
if (pendingReasonings.size > 0) {
|
|
2601
|
+
for (const [cardId, chunk] of [...pendingReasonings.entries()]) appendCardReasoning(cardId, chunk)
|
|
2602
|
+
pendingReasonings.clear()
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
const bufferStreamDelta = (cardId, chunk) => {
|
|
2607
|
+
if (chunk === '') return
|
|
2608
|
+
pendingDeltas.set(cardId, (pendingDeltas.get(cardId) ?? '') + chunk)
|
|
2609
|
+
if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, 50)
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
const bufferStreamReasoning = (cardId, chunk) => {
|
|
2613
|
+
if (chunk === '') return
|
|
2614
|
+
pendingReasonings.set(cardId, (pendingReasonings.get(cardId) ?? '') + chunk)
|
|
2615
|
+
if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, 50)
|
|
2616
|
+
}
|
|
2617
|
+
|
|
1941
2618
|
/** Stream one side answer into its card. */
|
|
1942
2619
|
const runSideCard = async (cardId) => {
|
|
1943
2620
|
const card = store.state.cards.find(item => item.id === cardId)
|
|
@@ -1950,10 +2627,9 @@ window.__ModuleLoader__.load({
|
|
|
1950
2627
|
question: card.question,
|
|
1951
2628
|
selection: card.selected,
|
|
1952
2629
|
zone: card.zone,
|
|
1953
|
-
cross: card.cross,
|
|
1954
2630
|
carrier: 'side',
|
|
1955
2631
|
sessionId: currentSessionId(ctx),
|
|
1956
|
-
history: card.history.slice(-
|
|
2632
|
+
history: card.history.slice(-HISTORY_TURNS),
|
|
1957
2633
|
}, {
|
|
1958
2634
|
signal: controller.signal,
|
|
1959
2635
|
onStart: (data) => {
|
|
@@ -1965,18 +2641,27 @@ window.__ModuleLoader__.load({
|
|
|
1965
2641
|
toolFilter: typeof data?.toolFilter === 'string' ? data.toolFilter : null,
|
|
1966
2642
|
})
|
|
1967
2643
|
},
|
|
1968
|
-
onDelta: data =>
|
|
1969
|
-
onReasoning: data =>
|
|
2644
|
+
onDelta: data => bufferStreamDelta(cardId, String(data?.text ?? '')),
|
|
2645
|
+
onReasoning: data => bufferStreamReasoning(cardId, String(data?.text ?? '')),
|
|
1970
2646
|
onDone: (data) => {
|
|
2647
|
+
// A locally cancelled stream can still see its terminal frame
|
|
2648
|
+
// (a harness stream ignores the abort signal; a real host may
|
|
2649
|
+
// race the abort): the card's local "stopped" verdict wins.
|
|
2650
|
+
if (controller.signal.aborted) return
|
|
2651
|
+
flushStreamBuffers()
|
|
1971
2652
|
const current = store.state.cards.find(item => item.id === cardId)
|
|
1972
2653
|
patchCard(cardId, {
|
|
1973
2654
|
status: data?.aborted === true ? 'stopped' : 'done',
|
|
1974
2655
|
streaming: data?.streaming === true,
|
|
1975
2656
|
text: typeof data?.text === 'string' && data.text !== '' ? data.text : (current?.text ?? ''),
|
|
2657
|
+
reasoning: typeof data?.reasoning === 'string' ? data.reasoning : (current?.reasoning ?? ''),
|
|
1976
2658
|
})
|
|
1977
2659
|
controllers.delete(cardId)
|
|
2660
|
+
syncPersisted()
|
|
1978
2661
|
},
|
|
1979
2662
|
onError: (error) => {
|
|
2663
|
+
if (controller.signal.aborted) return
|
|
2664
|
+
flushStreamBuffers()
|
|
1980
2665
|
patchCard(cardId, { status: 'error', error })
|
|
1981
2666
|
controllers.delete(cardId)
|
|
1982
2667
|
},
|
|
@@ -2080,7 +2765,10 @@ window.__ModuleLoader__.load({
|
|
|
2080
2765
|
id = snapshot?.locale ?? snapshot?.id ?? (typeof locale.getLocale === 'function' ? locale.getLocale()?.locale : null)
|
|
2081
2766
|
} catch { /* keep the previous language */ }
|
|
2082
2767
|
if (typeof id === 'string' && id !== '') {
|
|
2083
|
-
|
|
2768
|
+
// The service names ONE locale (no preference list behind it),
|
|
2769
|
+
// so an id the UI cannot serve falls back to en — same rule the
|
|
2770
|
+
// initial resolution applies to a preference list with no hit.
|
|
2771
|
+
const next = resolveLanguage([id], 'en')
|
|
2084
2772
|
if (next !== lang) {
|
|
2085
2773
|
lang = next
|
|
2086
2774
|
store.set({})
|
|
@@ -2274,7 +2962,7 @@ window.__ModuleLoader__.load({
|
|
|
2274
2962
|
// degrades to the next surface and logs, never breaks the plugin.
|
|
2275
2963
|
// See README §6 for the replacement checklist.
|
|
2276
2964
|
try {
|
|
2277
|
-
ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => {
|
|
2965
|
+
const uninject = ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => {
|
|
2278
2966
|
try {
|
|
2279
2967
|
const registry = injected.get('sidebarRightTabs')
|
|
2280
2968
|
const controller = injected.get('sidebarRight')
|
|
@@ -2346,6 +3034,9 @@ window.__ModuleLoader__.load({
|
|
|
2346
3034
|
console.error(`[${PLUGIN_ID}] native right-rail adapter unavailable:`, error)
|
|
2347
3035
|
}
|
|
2348
3036
|
})
|
|
3037
|
+
// ctx.inject's own reverse-registration must unwind with the plugin
|
|
3038
|
+
// too, or the host keeps routing injections into a disposed fiber.
|
|
3039
|
+
if (typeof uninject === 'function') disposers.push(uninject)
|
|
2349
3040
|
} catch (error) {
|
|
2350
3041
|
surfaces.native.error = String(error?.message ?? error)
|
|
2351
3042
|
}
|
|
@@ -2361,7 +3052,7 @@ window.__ModuleLoader__.load({
|
|
|
2361
3052
|
// ("Features are never removed"), so the adapter gates on `tabMeta`
|
|
2362
3053
|
// instead of on a version number.
|
|
2363
3054
|
try {
|
|
2364
|
-
ctx.inject(['betterSidebar'], (injected) => {
|
|
3055
|
+
const uninject = ctx.inject(['betterSidebar'], (injected) => {
|
|
2365
3056
|
try {
|
|
2366
3057
|
const service = injected.get('betterSidebar')
|
|
2367
3058
|
if (service === null || service === undefined) return
|
|
@@ -2398,6 +3089,7 @@ window.__ModuleLoader__.load({
|
|
|
2398
3089
|
console.error(`[${PLUGIN_ID}] better-sidebar adapter unavailable:`, error)
|
|
2399
3090
|
}
|
|
2400
3091
|
})
|
|
3092
|
+
if (typeof uninject === 'function') disposers.push(uninject)
|
|
2401
3093
|
} catch (error) {
|
|
2402
3094
|
surfaces.better.error = String(error?.message ?? error)
|
|
2403
3095
|
}
|
|
@@ -2412,6 +3104,14 @@ window.__ModuleLoader__.load({
|
|
|
2412
3104
|
React.useEffect(() => {
|
|
2413
3105
|
if (typeof cardId !== 'string' || cardId === '') return undefined
|
|
2414
3106
|
renderedCardId = cardId
|
|
3107
|
+
// Mounting settles the render proof, even when this host mounted
|
|
3108
|
+
// late (a lazy tab panel slower than the 600ms timer) — the proof
|
|
3109
|
+
// must never fire after the card is demonstrably on screen.
|
|
3110
|
+
const proof = pendingProofs.get(cardId)
|
|
3111
|
+
if (proof !== undefined) {
|
|
3112
|
+
clearTimeout(proof)
|
|
3113
|
+
pendingProofs.delete(cardId)
|
|
3114
|
+
}
|
|
2415
3115
|
return () => {
|
|
2416
3116
|
if (renderedCardId === cardId) renderedCardId = null
|
|
2417
3117
|
}
|
|
@@ -2419,7 +3119,18 @@ window.__ModuleLoader__.load({
|
|
|
2419
3119
|
if (card === undefined) {
|
|
2420
3120
|
return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
|
|
2421
3121
|
h(Styles, null),
|
|
2422
|
-
h('div', { className: 'dsa-muted' }, t('
|
|
3122
|
+
h('div', { className: 'dsa-muted' }, t('cardClosed')))
|
|
3123
|
+
}
|
|
3124
|
+
if (card.surface === 'flow') {
|
|
3125
|
+
// The proof already moved this card to the flow stack and this
|
|
3126
|
+
// host mounted afterwards. Rendering the answer again here would
|
|
3127
|
+
// show it in two places, so close the tab we can and leave the
|
|
3128
|
+
// note for a rail that keeps it.
|
|
3129
|
+
try { surfaces.native.close(card) } catch { /* the rail owns its lifecycle */ }
|
|
3130
|
+
try { surfaces.better.close(card) } catch { /* the host may already be gone */ }
|
|
3131
|
+
return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
|
|
3132
|
+
h(Styles, null),
|
|
3133
|
+
h('div', { className: 'dsa-muted' }, t('cardMovedToFlow')))
|
|
2423
3134
|
}
|
|
2424
3135
|
return h('div', { className: 'dsa-settings', 'data-dsa-root': '', style: { padding: '10px' } },
|
|
2425
3136
|
h(Styles, null),
|
|
@@ -2436,6 +3147,12 @@ window.__ModuleLoader__.load({
|
|
|
2436
3147
|
}
|
|
2437
3148
|
|
|
2438
3149
|
// ── boot ─────────────────────────────────────────────────────────
|
|
3150
|
+
try {
|
|
3151
|
+
restorePersisted()
|
|
3152
|
+
} catch (error) {
|
|
3153
|
+
// Corrupt-but-parseable history must never block startup.
|
|
3154
|
+
console.warn(`[${PLUGIN_ID}] card history restore failed:`, error)
|
|
3155
|
+
}
|
|
2439
3156
|
void refreshState().catch(() => { /* the settings page shows the failure */ })
|
|
2440
3157
|
// One report after the slot ladders have had time to settle, so the
|
|
2441
3158
|
// Host ends up holding the CLIENT's real registration state.
|
|
@@ -2443,7 +3160,13 @@ window.__ModuleLoader__.load({
|
|
|
2443
3160
|
disposers.push(() => {
|
|
2444
3161
|
for (const controller of controllers.values()) controller.abort()
|
|
2445
3162
|
controllers.clear()
|
|
2446
|
-
|
|
3163
|
+
pendingDeltas.clear()
|
|
3164
|
+
pendingReasonings.clear()
|
|
3165
|
+
if (deltaFlushTimer !== null) {
|
|
3166
|
+
clearTimeout(deltaFlushTimer)
|
|
3167
|
+
deltaFlushTimer = null
|
|
3168
|
+
}
|
|
3169
|
+
for (const proof of pendingProofs.values()) clearTimeout(proof)
|
|
2447
3170
|
pendingProofs.clear()
|
|
2448
3171
|
if (toastTimer !== null) clearTimeout(toastTimer)
|
|
2449
3172
|
if (reportTimer !== null) clearTimeout(reportTimer)
|
|
@@ -2482,6 +3205,8 @@ window.__ModuleLoader__.load({
|
|
|
2482
3205
|
IDS,
|
|
2483
3206
|
CARD_KIND,
|
|
2484
3207
|
CARD_KIND_BETTER,
|
|
3208
|
+
HISTORY_TURNS,
|
|
3209
|
+
estimateLabelWidth,
|
|
2485
3210
|
parseSseBlock,
|
|
2486
3211
|
truncateSelection,
|
|
2487
3212
|
parseShortcut,
|
|
@@ -2496,6 +3221,10 @@ window.__ModuleLoader__.load({
|
|
|
2496
3221
|
isDiagnosticsUnsupported,
|
|
2497
3222
|
renderRichText,
|
|
2498
3223
|
pickSurface,
|
|
3224
|
+
floatPlacement,
|
|
3225
|
+
localizeError,
|
|
3226
|
+
errorCodes: ERROR_CODES,
|
|
3227
|
+
resolveLanguage,
|
|
2499
3228
|
dict: DICT,
|
|
2500
3229
|
}),
|
|
2501
3230
|
}),
|