dsh-sidecard-ask 1.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 +79 -0
- package/LICENSE +21 -0
- package/README.md +439 -0
- package/client.js +2268 -0
- package/cordis.patch.yml +38 -0
- package/icon.svg +9 -0
- package/index.js +1242 -0
- package/locale/en.json +15 -0
- package/locale/zh.json +15 -0
- package/package.json +66 -0
package/client.js
ADDED
|
@@ -0,0 +1,2268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-sidecard-ask — Client half (`dsh.client.platform = web`).
|
|
3
|
+
*
|
|
4
|
+
* Browser artifact: registers one lazy factory whose id equals the package
|
|
5
|
+
* name. React comes from the browser module table (`require('react')`); this
|
|
6
|
+
* file imports NOTHING else, and it is plain JavaScript on purpose — no build
|
|
7
|
+
* step, no bundler, no client package.
|
|
8
|
+
*
|
|
9
|
+
* Layout of this file:
|
|
10
|
+
* §1 constants, defaults, dictionaries
|
|
11
|
+
* §2 i18n (own dictionary + optional `locale` service)
|
|
12
|
+
* §3 wire client (JSON + SSE against the Host half)
|
|
13
|
+
* §4 module store (one observable state for every surface)
|
|
14
|
+
* §5 selection engine (read, classify, place, dedupe)
|
|
15
|
+
* §6 carriers (main conversation / independent side card)
|
|
16
|
+
* §7 side-card surfaces (native right sidebar → better-sidebar → own flow)
|
|
17
|
+
* §8 view helpers (markdown-lite renderer, styles, small controls)
|
|
18
|
+
* §9 components (overlay root, cards, settings page, session probe)
|
|
19
|
+
* §10 plugin face (apply + registrations)
|
|
20
|
+
*
|
|
21
|
+
* Test seam: the returned module carries `api.pure`, a frozen bag of the pure
|
|
22
|
+
* helpers above. The DSH client runtime only reads `inject`/`apply`; the test
|
|
23
|
+
* harness in `test/` loads this file with a synthetic `window` to exercise
|
|
24
|
+
* them without a browser.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
window.__ModuleLoader__.load({
|
|
28
|
+
id: 'dsh-sidecard-ask',
|
|
29
|
+
factory(require) {
|
|
30
|
+
const React = require('react')
|
|
31
|
+
const h = React.createElement
|
|
32
|
+
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
34
|
+
// §1 constants and defaults
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const PLUGIN_ID = 'dsh-sidecard-ask'
|
|
38
|
+
const API = '/sidecard-ask/api'
|
|
39
|
+
/** Slot ids contributed by this plugin (also the overlay entry ids). */
|
|
40
|
+
const IDS = {
|
|
41
|
+
overlay: 'sidecard-ask',
|
|
42
|
+
settings: 'sidecard-ask-settings',
|
|
43
|
+
sessionProbe: 'sidecard-ask-session-probe',
|
|
44
|
+
}
|
|
45
|
+
/** Side-card tab type registered in a side-card host. */
|
|
46
|
+
const CARD_KIND = 'sidecard-ask:card'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
|
|
50
|
+
* before `/state` answers (first paint) and when the Host is unreachable;
|
|
51
|
+
* `test/contract-test.mjs` asserts this mirror stays identical to the
|
|
52
|
+
* Host's own defaults.
|
|
53
|
+
*/
|
|
54
|
+
const CLIENT_DEFAULTS = {
|
|
55
|
+
trigger: 'selection',
|
|
56
|
+
defaultCarrier: 'side',
|
|
57
|
+
sideSurface: 'auto',
|
|
58
|
+
maxChars: 4000,
|
|
59
|
+
shortcut: 'Alt+Q',
|
|
60
|
+
captureZones: 'auto',
|
|
61
|
+
showInUnclassified: true,
|
|
62
|
+
minChars: 2,
|
|
63
|
+
maxConcurrentAsks: 3,
|
|
64
|
+
sideTools: 'readonly',
|
|
65
|
+
sideTimeoutMs: 180000,
|
|
66
|
+
sideProvider: 'auto',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Zone classification: slot-key patterns the harness itself renders. */
|
|
70
|
+
const ZONE_PATTERNS = {
|
|
71
|
+
chat: [/^conversation\b/],
|
|
72
|
+
task: [/\btask/i, /\btodo/i, /\bschedule/i, /\bteam/i, /\bjob/i, /\bplan/i, /^rightbar\b/, /^sidebar\.right\b/],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
76
|
+
// §2 i18n
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const DICT = {
|
|
80
|
+
zh: {
|
|
81
|
+
triggerLabel: '追问选中内容',
|
|
82
|
+
triggerHint: '点击用 AI 追问这段文字',
|
|
83
|
+
askTitle: '追问选中内容',
|
|
84
|
+
askPlaceholder: '想就这段内容问什么?(Enter 发送,Shift+Enter 换行)',
|
|
85
|
+
askSubmit: '提问',
|
|
86
|
+
askCancel: '取消',
|
|
87
|
+
askShortcutHint: '快捷键 {key} 可随时唤起',
|
|
88
|
+
carrier: '作答位置',
|
|
89
|
+
carrierMain: '主对话',
|
|
90
|
+
carrierSide: '侧边卡片',
|
|
91
|
+
carrierMainHint: '在主对话里提问,答案随对话正常流式呈现',
|
|
92
|
+
carrierSideHint: '独立子代理作答,不打扰主对话',
|
|
93
|
+
zoneChat: '聊天区',
|
|
94
|
+
zoneTask: '任务区',
|
|
95
|
+
zoneOther: '其它区域',
|
|
96
|
+
zoneCross: '跨区选择',
|
|
97
|
+
chars: '{n} 字',
|
|
98
|
+
truncatedBadge: '已截断 {n} 字',
|
|
99
|
+
selectionEmpty: '没有选中文本',
|
|
100
|
+
selectionTooShort: '选中文本太短,未触发追问',
|
|
101
|
+
answerTitle: '追问',
|
|
102
|
+
answerStreaming: '作答中…',
|
|
103
|
+
answerDone: '已完成',
|
|
104
|
+
answerStopped: '已停止',
|
|
105
|
+
answerNonStreaming: '当前 DSH 版本未提供流式帧,答案一次性返回',
|
|
106
|
+
answerEmpty: '(没有内容)',
|
|
107
|
+
copy: '复制',
|
|
108
|
+
copied: '已复制',
|
|
109
|
+
copySelectedHint: '已选中答案文本,请按 Ctrl+C 复制',
|
|
110
|
+
copyFailed: '复制失败,请手动选择',
|
|
111
|
+
close: '关闭',
|
|
112
|
+
followUp: '继续追问',
|
|
113
|
+
followUpPlaceholder: '继续问下去…',
|
|
114
|
+
retry: '重试',
|
|
115
|
+
sendToMain: '改到主对话',
|
|
116
|
+
cancelAnswer: '停止作答',
|
|
117
|
+
mainSent: '已发送到主对话',
|
|
118
|
+
mainSentHint: '答案会出现在主对话里,随对话正常流式输出。',
|
|
119
|
+
draftInserted: '已填入输入框',
|
|
120
|
+
draftInsertedHint: '主对话发送接口不可用,已把追问内容放进输入框,请按 Enter 发送。',
|
|
121
|
+
noMainCarrier: '主对话发送接口不可用:既不能提交消息,也不能写入输入框',
|
|
122
|
+
errorTitle: '作答失败',
|
|
123
|
+
errorNoSideEngine: '这台 DSH 组合没有可用的子代理,无法在侧边卡片作答;可以改到主对话提问。',
|
|
124
|
+
surfaceFlow: '浮层卡片',
|
|
125
|
+
surfaceNative: '右侧栏卡片',
|
|
126
|
+
surfaceBetter: '侧边卡片插件',
|
|
127
|
+
surfaceFellBack: '请求的侧边面不可用,已回退到{name}',
|
|
128
|
+
surfaceUnproven: '目标承载面未真正渲染该卡片,已回退到内置浮层卡片',
|
|
129
|
+
settingsTitle: '划词追问',
|
|
130
|
+
settingsDesc: '选中聊天或任务里的文本,就地追问。',
|
|
131
|
+
setTrigger: '触发方式',
|
|
132
|
+
setTriggerSelection: '选中即出现按钮',
|
|
133
|
+
setTriggerShortcut: '仅快捷键',
|
|
134
|
+
setTriggerBoth: '按钮 + 快捷键',
|
|
135
|
+
setCarrier: '默认作答位置',
|
|
136
|
+
setSurface: '侧边卡片承载面',
|
|
137
|
+
setSurfaceAuto: '自动选择',
|
|
138
|
+
setSurfaceFlow: '内置浮层卡片',
|
|
139
|
+
setMaxChars: '最大字符数',
|
|
140
|
+
setMaxCharsHint: '超过部分截断并在提问中标注',
|
|
141
|
+
setShortcut: '快捷键',
|
|
142
|
+
setZones: '捕获区域',
|
|
143
|
+
setZonesAuto: '全部区域',
|
|
144
|
+
setZonesChat: '仅聊天区',
|
|
145
|
+
setZonesTask: '仅任务区',
|
|
146
|
+
setZonesBoth: '聊天区 + 任务区',
|
|
147
|
+
setUnclassified: '未归类区域也触发',
|
|
148
|
+
setMinChars: '最小触发字符数',
|
|
149
|
+
setConcurrency: '并发作答上限',
|
|
150
|
+
setSideTools: '侧边作答工具权限',
|
|
151
|
+
setSideToolsReadonly: '只读(推荐)',
|
|
152
|
+
setSideToolsInherit: '继承当前会话',
|
|
153
|
+
save: '保存',
|
|
154
|
+
saving: '保存中…',
|
|
155
|
+
saved: '已保存',
|
|
156
|
+
saveFailed: '保存失败:{msg}',
|
|
157
|
+
reset: '重置为 patch 配置',
|
|
158
|
+
resetDone: '已重置',
|
|
159
|
+
diagnostics: '运行自检',
|
|
160
|
+
diagTitle: '能力自检',
|
|
161
|
+
diagHost: '宿主接口',
|
|
162
|
+
diagSide: '侧边作答引擎',
|
|
163
|
+
diagProviders: '子代理 provider',
|
|
164
|
+
diagAgents: '活动会话代理',
|
|
165
|
+
diagSurface: '当前侧边承载面',
|
|
166
|
+
diagSession: '已识别会话',
|
|
167
|
+
diagZones: '区域锚点(data-slot)',
|
|
168
|
+
diagZonesMissing: '缺失,区域过滤已停用',
|
|
169
|
+
diagSlots: '槽位落点',
|
|
170
|
+
toolGuardUnavailable: '该 provider 不支持工具白名单,本次作答继承了当前会话的工具',
|
|
171
|
+
toolGuardInherited: '按配置继承当前会话的工具',
|
|
172
|
+
diagUnreachable: '宿主接口不可达:{msg}',
|
|
173
|
+
yes: '可用',
|
|
174
|
+
no: '不可用',
|
|
175
|
+
none: '无',
|
|
176
|
+
unsupportedTrigger: '无法识别该快捷键,请用形如 Alt+Q / Ctrl+Shift+K 的写法',
|
|
177
|
+
surfaceUnavailable: '所选侧边承载面不可用',
|
|
178
|
+
},
|
|
179
|
+
en: {
|
|
180
|
+
triggerLabel: 'Ask about selection',
|
|
181
|
+
triggerHint: 'Ask AI about this text',
|
|
182
|
+
askTitle: 'Ask about the selection',
|
|
183
|
+
askPlaceholder: 'What do you want to ask? (Enter sends, Shift+Enter breaks a line)',
|
|
184
|
+
askSubmit: 'Ask',
|
|
185
|
+
askCancel: 'Cancel',
|
|
186
|
+
askShortcutHint: 'Press {key} any time',
|
|
187
|
+
carrier: 'Answer in',
|
|
188
|
+
carrierMain: 'Main chat',
|
|
189
|
+
carrierSide: 'Side card',
|
|
190
|
+
carrierMainHint: 'Ask in the main conversation; the answer streams there as usual',
|
|
191
|
+
carrierSideHint: 'A separate child agent answers, leaving the main chat untouched',
|
|
192
|
+
zoneChat: 'Chat',
|
|
193
|
+
zoneTask: 'Tasks',
|
|
194
|
+
zoneOther: 'Elsewhere',
|
|
195
|
+
zoneCross: 'Cross-region',
|
|
196
|
+
chars: '{n} chars',
|
|
197
|
+
truncatedBadge: 'truncated {n} chars',
|
|
198
|
+
selectionEmpty: 'Nothing selected',
|
|
199
|
+
selectionTooShort: 'Selection too short to trigger',
|
|
200
|
+
answerTitle: 'Follow-up',
|
|
201
|
+
answerStreaming: 'Answering…',
|
|
202
|
+
answerDone: 'Done',
|
|
203
|
+
answerStopped: 'Stopped',
|
|
204
|
+
answerNonStreaming: 'This DSH build publishes no stream frames; the answer arrived at once',
|
|
205
|
+
answerEmpty: '(empty)',
|
|
206
|
+
copy: 'Copy',
|
|
207
|
+
copied: 'Copied',
|
|
208
|
+
copySelectedHint: 'Answer selected — press Ctrl+C to copy',
|
|
209
|
+
copyFailed: 'Copy failed — select the text manually',
|
|
210
|
+
close: 'Close',
|
|
211
|
+
followUp: 'Follow up',
|
|
212
|
+
followUpPlaceholder: 'Ask again…',
|
|
213
|
+
retry: 'Retry',
|
|
214
|
+
sendToMain: 'Ask in main chat',
|
|
215
|
+
cancelAnswer: 'Stop',
|
|
216
|
+
mainSent: 'Sent to the main chat',
|
|
217
|
+
mainSentHint: 'The answer appears in the main conversation and streams there.',
|
|
218
|
+
draftInserted: 'Inserted into the composer',
|
|
219
|
+
draftInsertedHint: 'The main-chat send API is unavailable, so the follow-up was placed in the composer — press Enter.',
|
|
220
|
+
noMainCarrier: 'The main-chat send API is unavailable: neither submitting a message nor writing the composer draft worked',
|
|
221
|
+
errorTitle: 'Answer failed',
|
|
222
|
+
errorNoSideEngine: 'This DSH composition has no usable subagent, so the side card cannot answer; ask in the main chat instead.',
|
|
223
|
+
surfaceFlow: 'Floating card',
|
|
224
|
+
surfaceNative: 'Right rail',
|
|
225
|
+
surfaceBetter: 'Sidebar plugin',
|
|
226
|
+
surfaceFellBack: 'The requested surface is unavailable; fell back to {name}',
|
|
227
|
+
surfaceUnproven: 'The target surface never rendered the card; fell back to the built-in floating card',
|
|
228
|
+
settingsTitle: 'Selection Ask',
|
|
229
|
+
settingsDesc: 'Select text in chat or tasks and ask about it in place.',
|
|
230
|
+
setTrigger: 'Trigger',
|
|
231
|
+
setTriggerSelection: 'Button on selection',
|
|
232
|
+
setTriggerShortcut: 'Shortcut only',
|
|
233
|
+
setTriggerBoth: 'Button + shortcut',
|
|
234
|
+
setCarrier: 'Default answer carrier',
|
|
235
|
+
setSurface: 'Side-card surface',
|
|
236
|
+
setSurfaceAuto: 'Automatic',
|
|
237
|
+
setSurfaceFlow: 'Built-in floating card',
|
|
238
|
+
setMaxChars: 'Max characters',
|
|
239
|
+
setMaxCharsHint: 'Longer selections are truncated and marked in the question',
|
|
240
|
+
setShortcut: 'Shortcut',
|
|
241
|
+
setZones: 'Capture regions',
|
|
242
|
+
setZonesAuto: 'Every region',
|
|
243
|
+
setZonesChat: 'Chat only',
|
|
244
|
+
setZonesTask: 'Tasks only',
|
|
245
|
+
setZonesBoth: 'Chat + tasks',
|
|
246
|
+
setUnclassified: 'Also trigger outside known regions',
|
|
247
|
+
setMinChars: 'Minimum selection length',
|
|
248
|
+
setConcurrency: 'Max concurrent answers',
|
|
249
|
+
setSideTools: 'Side answerer tool access',
|
|
250
|
+
setSideToolsReadonly: 'Read-only (recommended)',
|
|
251
|
+
setSideToolsInherit: 'Inherit from the session',
|
|
252
|
+
save: 'Save',
|
|
253
|
+
saving: 'Saving…',
|
|
254
|
+
saved: 'Saved',
|
|
255
|
+
saveFailed: 'Save failed: {msg}',
|
|
256
|
+
reset: 'Reset to patch config',
|
|
257
|
+
resetDone: 'Reset',
|
|
258
|
+
diagnostics: 'Run diagnostics',
|
|
259
|
+
diagTitle: 'Capability check',
|
|
260
|
+
diagHost: 'Host API',
|
|
261
|
+
diagSide: 'Side answer engine',
|
|
262
|
+
diagProviders: 'Subagent providers',
|
|
263
|
+
diagAgents: 'Live session agents',
|
|
264
|
+
diagSurface: 'Current side surface',
|
|
265
|
+
diagSession: 'Session detected',
|
|
266
|
+
diagZones: 'Zone anchors (data-slot)',
|
|
267
|
+
diagZonesMissing: 'missing — region filtering disabled',
|
|
268
|
+
diagSlots: 'Slot landing',
|
|
269
|
+
toolGuardUnavailable: 'This provider supports no tool allow-list, so the answer inherited the session tools',
|
|
270
|
+
toolGuardInherited: 'Inherits the session tools, as configured',
|
|
271
|
+
diagUnreachable: 'Host API unreachable: {msg}',
|
|
272
|
+
yes: 'yes',
|
|
273
|
+
no: 'no',
|
|
274
|
+
none: 'none',
|
|
275
|
+
unsupportedTrigger: 'Unrecognized shortcut — use a form like Alt+Q / Ctrl+Shift+K',
|
|
276
|
+
surfaceUnavailable: 'The chosen side surface is unavailable',
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The active language id, kept in step with the harness locale service. */
|
|
281
|
+
let lang = (() => {
|
|
282
|
+
const raw = typeof navigator !== 'undefined' && typeof navigator.language === 'string'
|
|
283
|
+
? navigator.language.toLowerCase()
|
|
284
|
+
: 'zh'
|
|
285
|
+
return raw.startsWith('zh') ? 'zh' : 'en'
|
|
286
|
+
})()
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Translate one key with `{name}` interpolation.
|
|
290
|
+
* @param {string} key - dictionary key.
|
|
291
|
+
* @param {Record<string, unknown>} [vars] - interpolation values.
|
|
292
|
+
*/
|
|
293
|
+
function t(key, vars) {
|
|
294
|
+
const table = DICT[lang] ?? DICT.zh
|
|
295
|
+
let text = table[key] ?? DICT.zh[key] ?? key
|
|
296
|
+
if (vars !== undefined) {
|
|
297
|
+
for (const [name, value] of Object.entries(vars)) text = text.split(`{${name}}`).join(String(value))
|
|
298
|
+
}
|
|
299
|
+
return text
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
303
|
+
// §3 wire client
|
|
304
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* POST JSON to the plugin's Host API.
|
|
308
|
+
* @returns {Promise<unknown>} the `value` field of an `{ok:true}` answer.
|
|
309
|
+
* @throws {Error} with a `code` for every `{ok:false}` or transport failure.
|
|
310
|
+
*/
|
|
311
|
+
async function postJson(method, payload) {
|
|
312
|
+
let response
|
|
313
|
+
try {
|
|
314
|
+
response = await fetch(`${API}/${method}`, {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: { 'content-type': 'application/json' },
|
|
317
|
+
body: JSON.stringify(payload ?? {}),
|
|
318
|
+
})
|
|
319
|
+
} catch (error) {
|
|
320
|
+
throw Object.assign(new Error(String(error?.message ?? error)), { code: 'unreachable' })
|
|
321
|
+
}
|
|
322
|
+
let body
|
|
323
|
+
try {
|
|
324
|
+
body = await response.json()
|
|
325
|
+
} catch {
|
|
326
|
+
throw Object.assign(new Error(`HTTP ${response.status}`), { code: 'bad-response' })
|
|
327
|
+
}
|
|
328
|
+
if (body?.ok === true) return body.value
|
|
329
|
+
const error = new Error(body?.error?.message ?? `HTTP ${response.status}`)
|
|
330
|
+
error.code = body?.error?.code ?? 'bad-response'
|
|
331
|
+
throw error
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** GET the plugin state (`/state` also answers POST, GET keeps it cacheable-free). */
|
|
335
|
+
async function getState() {
|
|
336
|
+
const response = await fetch(`${API}/state`, { method: 'GET' })
|
|
337
|
+
const body = await response.json()
|
|
338
|
+
if (body?.ok !== true) throw Object.assign(new Error('state failed'), { code: 'bad-response' })
|
|
339
|
+
return body.value
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Open the SSE ask stream and drive callbacks per event.
|
|
344
|
+
* Split out from the React layer so a card owns no transport details.
|
|
345
|
+
* @param {object} payload - the ask request.
|
|
346
|
+
* @param {{onStart: Function, onDelta: Function, onReasoning: Function,
|
|
347
|
+
* onDone: Function, onError: Function, signal: AbortSignal}} handlers
|
|
348
|
+
*/
|
|
349
|
+
async function streamAsk(payload, handlers) {
|
|
350
|
+
let response
|
|
351
|
+
try {
|
|
352
|
+
response = await fetch(`${API}/ask`, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
355
|
+
body: JSON.stringify(payload),
|
|
356
|
+
signal: handlers.signal,
|
|
357
|
+
})
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (handlers.signal.aborted) return
|
|
360
|
+
handlers.onError({ code: 'unreachable', message: String(error?.message ?? error) })
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
if (!response.ok || response.body === null) {
|
|
364
|
+
handlers.onError({ code: `http-${response.status}`, message: `HTTP ${response.status}` })
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
const reader = response.body.getReader()
|
|
368
|
+
const decoder = new TextDecoder()
|
|
369
|
+
let buffer = ''
|
|
370
|
+
let settled = false
|
|
371
|
+
try {
|
|
372
|
+
for (;;) {
|
|
373
|
+
const { value, done } = await reader.read()
|
|
374
|
+
if (done) break
|
|
375
|
+
buffer += decoder.decode(value, { stream: true })
|
|
376
|
+
let cut = buffer.indexOf('\n\n')
|
|
377
|
+
while (cut !== -1) {
|
|
378
|
+
const block = buffer.slice(0, cut)
|
|
379
|
+
buffer = buffer.slice(cut + 2)
|
|
380
|
+
cut = buffer.indexOf('\n\n')
|
|
381
|
+
const parsed = parseSseBlock(block)
|
|
382
|
+
if (parsed === null) continue
|
|
383
|
+
const { event, data } = parsed
|
|
384
|
+
if (event === 'start') handlers.onStart(data)
|
|
385
|
+
else if (event === 'delta') handlers.onDelta(data)
|
|
386
|
+
else if (event === 'reasoning') handlers.onReasoning(data)
|
|
387
|
+
else if (event === 'status') handlers.onStatus?.(data)
|
|
388
|
+
else if (event === 'done') { settled = true; handlers.onDone(data) }
|
|
389
|
+
else if (event === 'error') { settled = true; handlers.onError(data) }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (!handlers.signal.aborted && !settled) {
|
|
394
|
+
handlers.onError({ code: 'stream-broken', message: String(error?.message ?? error) })
|
|
395
|
+
}
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
if (!settled && !handlers.signal.aborted) {
|
|
399
|
+
handlers.onError({ code: 'stream-ended', message: 'stream ended without a terminal event' })
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Parse one raw SSE block into `{event, data}`.
|
|
405
|
+
* @returns {{event: string, data: unknown}|null} null when unusable.
|
|
406
|
+
*/
|
|
407
|
+
function parseSseBlock(block) {
|
|
408
|
+
let event = 'message'
|
|
409
|
+
const dataLines = []
|
|
410
|
+
for (const line of block.split('\n')) {
|
|
411
|
+
if (line.startsWith(':')) continue
|
|
412
|
+
if (line.startsWith('event:')) event = line.slice(6).trim()
|
|
413
|
+
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
|
|
414
|
+
}
|
|
415
|
+
if (dataLines.length === 0) return null
|
|
416
|
+
try {
|
|
417
|
+
return { event, data: JSON.parse(dataLines.join('\n')) }
|
|
418
|
+
} catch {
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
424
|
+
// §4 module store
|
|
425
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
/** The single observable state every surface reads. */
|
|
428
|
+
const store = {
|
|
429
|
+
state: {
|
|
430
|
+
config: { ...CLIENT_DEFAULTS },
|
|
431
|
+
problems: [],
|
|
432
|
+
capabilities: null,
|
|
433
|
+
provenance: null,
|
|
434
|
+
hostReachable: null,
|
|
435
|
+
surface: 'flow',
|
|
436
|
+
surfaceNote: null,
|
|
437
|
+
sessionId: null,
|
|
438
|
+
trigger: null,
|
|
439
|
+
popover: null,
|
|
440
|
+
cards: [],
|
|
441
|
+
toast: null,
|
|
442
|
+
/** Whether the harness renders the `data-slot` zone anchors (null = unknown yet). */
|
|
443
|
+
zoneAnchors: null,
|
|
444
|
+
/** Which ladder rung each registration actually landed on. */
|
|
445
|
+
slots: {},
|
|
446
|
+
revision: 0,
|
|
447
|
+
},
|
|
448
|
+
listeners: new Set(),
|
|
449
|
+
get() { return this.state },
|
|
450
|
+
set(patch) {
|
|
451
|
+
this.state = { ...this.state, ...patch, revision: this.state.revision + 1 }
|
|
452
|
+
for (const listener of [...this.listeners]) {
|
|
453
|
+
try {
|
|
454
|
+
listener()
|
|
455
|
+
} catch (error) {
|
|
456
|
+
console.error(`[${PLUGIN_ID}] store listener failed:`, error)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
subscribe(listener) {
|
|
461
|
+
this.listeners.add(listener)
|
|
462
|
+
return () => { this.listeners.delete(listener) }
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Subscribe a component to the whole store (small state, one revision). */
|
|
467
|
+
function useStoreState() {
|
|
468
|
+
const [, bump] = React.useReducer(count => count + 1, 0)
|
|
469
|
+
React.useEffect(() => store.subscribe(bump), [])
|
|
470
|
+
return store.state
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Replace one card by id (immutably; a no-op when it is gone). */
|
|
474
|
+
function patchCard(id, patch) {
|
|
475
|
+
const cards = store.state.cards.map(card => (card.id === id ? { ...card, ...patch } : card))
|
|
476
|
+
store.set({ cards })
|
|
477
|
+
return cards.find(card => card.id === id)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Append text to one card's streaming answer. */
|
|
481
|
+
function appendCardText(id, delta) {
|
|
482
|
+
const cards = store.state.cards.map(card => (
|
|
483
|
+
card.id === id ? { ...card, text: card.text + delta } : card
|
|
484
|
+
))
|
|
485
|
+
store.set({ cards })
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Append reasoning text to one card. */
|
|
489
|
+
function appendCardReasoning(id, delta) {
|
|
490
|
+
const cards = store.state.cards.map(card => (
|
|
491
|
+
card.id === id ? { ...card, reasoning: card.reasoning + delta } : card
|
|
492
|
+
))
|
|
493
|
+
store.set({ cards })
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Show a transient toast line. */
|
|
497
|
+
let toastTimer = null
|
|
498
|
+
function toast(message) {
|
|
499
|
+
store.set({ toast: message })
|
|
500
|
+
if (toastTimer !== null) clearTimeout(toastTimer)
|
|
501
|
+
toastTimer = setTimeout(() => { store.set({ toast: null }) }, 2600)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
505
|
+
// §5 selection engine
|
|
506
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
/** Element for a DOM node (the node itself when it is an element). */
|
|
509
|
+
function elementOf(node) {
|
|
510
|
+
if (node === null || node === undefined) return null
|
|
511
|
+
return node.nodeType === 1 ? node : node.parentElement ?? null
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Whether the node lives inside an editable field (composer, rename box…). */
|
|
515
|
+
function isEditable(node) {
|
|
516
|
+
const element = elementOf(node)
|
|
517
|
+
if (element === null) return false
|
|
518
|
+
const tag = element.tagName
|
|
519
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
520
|
+
return element.closest('[contenteditable=""],[contenteditable="true"]') !== null
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Whether the node belongs to this plugin's own UI. */
|
|
524
|
+
function isOurUi(node) {
|
|
525
|
+
const element = elementOf(node)
|
|
526
|
+
return element !== null && element.closest('[data-dsa-root]') !== null
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The `data-slot` chain above a node. The harness slot renderer stamps
|
|
531
|
+
* every outlet with `data-slot="<slot key>"`, so this is the sanctioned
|
|
532
|
+
* way to tell *where* a selection lives without reading any plugin's
|
|
533
|
+
* markup.
|
|
534
|
+
* @param {Node} node - selection anchor or focus node.
|
|
535
|
+
* @returns {string[]} slot keys, nearest first.
|
|
536
|
+
*/
|
|
537
|
+
function slotPathOf(node) {
|
|
538
|
+
const keys = []
|
|
539
|
+
let element = elementOf(node)
|
|
540
|
+
while (element !== null) {
|
|
541
|
+
const key = element.getAttribute?.('data-slot')
|
|
542
|
+
if (typeof key === 'string' && key !== '') keys.push(key)
|
|
543
|
+
element = element.parentElement
|
|
544
|
+
}
|
|
545
|
+
return keys
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Classify a selection by its slot chain.
|
|
550
|
+
* @param {string[]} slotPath - nearest-first slot keys.
|
|
551
|
+
* @returns {'chat'|'task'|'other'} the zone.
|
|
552
|
+
*/
|
|
553
|
+
function classifyZone(slotPath) {
|
|
554
|
+
for (const key of slotPath) {
|
|
555
|
+
if (ZONE_PATTERNS.chat.some(pattern => pattern.test(key))) return 'chat'
|
|
556
|
+
if (ZONE_PATTERNS.task.some(pattern => pattern.test(key))) return 'task'
|
|
557
|
+
}
|
|
558
|
+
return 'other'
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The rect a trigger should hang off, tolerating multi-block selections. */
|
|
562
|
+
function rectOf(range) {
|
|
563
|
+
let rect = range.getBoundingClientRect()
|
|
564
|
+
const usable = candidate => candidate !== undefined && (candidate.width > 0 || candidate.height > 0)
|
|
565
|
+
if (!usable(rect)) {
|
|
566
|
+
const rects = typeof range.getClientRects === 'function' ? range.getClientRects() : []
|
|
567
|
+
rect = rects.length > 0 ? rects[rects.length - 1] : rect
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
left: rect.left,
|
|
571
|
+
top: rect.top,
|
|
572
|
+
right: rect.right,
|
|
573
|
+
bottom: rect.bottom,
|
|
574
|
+
width: rect.width,
|
|
575
|
+
height: rect.height,
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Read the current DOM selection into a trigger candidate.
|
|
581
|
+
* @returns {{text: string, rect: object, zone: string, cross: boolean,
|
|
582
|
+
* slotPath: string[], kind: 'text'} | null} null when nothing usable.
|
|
583
|
+
*/
|
|
584
|
+
function readSelection() {
|
|
585
|
+
if (typeof window === 'undefined' || typeof window.getSelection !== 'function') return null
|
|
586
|
+
const selection = window.getSelection()
|
|
587
|
+
if (selection === null || selection.rangeCount === 0 || selection.isCollapsed === true) return null
|
|
588
|
+
const text = selection.toString()
|
|
589
|
+
if (typeof text !== 'string' || text.trim() === '') return null
|
|
590
|
+
const range = selection.getRangeAt(0)
|
|
591
|
+
const anchorNode = selection.anchorNode
|
|
592
|
+
const focusNode = selection.focusNode ?? anchorNode
|
|
593
|
+
if (isOurUi(anchorNode) || isOurUi(focusNode)) return null
|
|
594
|
+
if (isEditable(anchorNode) || isEditable(focusNode)) return null
|
|
595
|
+
const anchorPath = slotPathOf(anchorNode)
|
|
596
|
+
const focusPath = slotPathOf(focusNode)
|
|
597
|
+
const anchorZone = classifyZone(anchorPath)
|
|
598
|
+
const focusZone = classifyZone(focusPath)
|
|
599
|
+
return {
|
|
600
|
+
text,
|
|
601
|
+
rect: rectOf(range),
|
|
602
|
+
zone: anchorZone,
|
|
603
|
+
cross: anchorZone !== focusZone,
|
|
604
|
+
slotPath: anchorPath,
|
|
605
|
+
kind: 'text',
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Whether a candidate may raise the in-place trigger button.
|
|
611
|
+
* @param {object|null} candidate - a `readSelection()` result.
|
|
612
|
+
* @param {object} config - the effective config.
|
|
613
|
+
* @param {{anchors?: boolean}} [options] - `anchors: false` means this DSH
|
|
614
|
+
* build renders no `data-slot` marker, so the selection cannot be placed
|
|
615
|
+
* in a region at all; the zone filter is then skipped instead of
|
|
616
|
+
* silently suppressing every trigger (the settings page reports why).
|
|
617
|
+
* @returns {{ok: boolean, reason?: string, zoneFiltering?: string}} the decision.
|
|
618
|
+
*/
|
|
619
|
+
function shouldOffer(candidate, config, options) {
|
|
620
|
+
if (candidate === null) return { ok: false, reason: 'empty' }
|
|
621
|
+
if (config.trigger === 'shortcut') return { ok: false, reason: 'shortcut-only' }
|
|
622
|
+
if (candidate.text.trim().length < config.minChars) return { ok: false, reason: 'too-short' }
|
|
623
|
+
if (options?.anchors === false) return { ok: true, zoneFiltering: 'unavailable' }
|
|
624
|
+
const zones = config.captureZones
|
|
625
|
+
const zone = candidate.zone
|
|
626
|
+
if (zones === 'chat' && zone !== 'chat') return { ok: false, reason: 'zone' }
|
|
627
|
+
if (zones === 'task' && zone !== 'task') return { ok: false, reason: 'zone' }
|
|
628
|
+
if ((zones === 'chat+task' || zones === 'auto') && zone === 'other' && config.showInUnclassified !== true) {
|
|
629
|
+
return { ok: false, reason: 'zone' }
|
|
630
|
+
}
|
|
631
|
+
return { ok: true }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* A cheap identity for a selection, used to suppress duplicate triggers
|
|
636
|
+
* for the same text at (nearly) the same place.
|
|
637
|
+
*/
|
|
638
|
+
function selectionSignature(candidate) {
|
|
639
|
+
const rect = candidate.rect ?? { left: 0, top: 0 }
|
|
640
|
+
return `${candidate.text.length}:${Math.round(rect.left)}:${Math.round(rect.top)}:${candidate.text.slice(0, 24)}`
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Truncate an over-long selection for an outgoing prompt, keeping head and
|
|
645
|
+
* tail. Mirrors the Host's `truncateSelection` (asserted by contract test).
|
|
646
|
+
* @returns {{text: string, truncated: boolean, droppedChars: number}}
|
|
647
|
+
*/
|
|
648
|
+
function truncateSelection(text, maxChars) {
|
|
649
|
+
const source = typeof text === 'string' ? text : ''
|
|
650
|
+
if (source.length <= maxChars) return { text: source, truncated: false, droppedChars: 0 }
|
|
651
|
+
const head = Math.max(1, Math.ceil(maxChars * 0.7))
|
|
652
|
+
const tail = Math.max(0, maxChars - head)
|
|
653
|
+
const dropped = source.length - head - tail
|
|
654
|
+
return {
|
|
655
|
+
text: `${source.slice(0, head)}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? source.slice(source.length - tail) : ''}`,
|
|
656
|
+
truncated: true,
|
|
657
|
+
droppedChars: dropped,
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Parse a `Ctrl+Shift+K`-style accelerator into matcher facts. */
|
|
662
|
+
function parseShortcut(accelerator) {
|
|
663
|
+
if (typeof accelerator !== 'string') return null
|
|
664
|
+
const trimmed = accelerator.trim()
|
|
665
|
+
// A trailing separator ("Alt+") names a modifier as the key: refuse it.
|
|
666
|
+
if (trimmed === '' || trimmed.endsWith('+')) return null
|
|
667
|
+
const parts = trimmed.split('+').map(part => part.trim().toLowerCase()).filter(Boolean)
|
|
668
|
+
const key = parts.pop()
|
|
669
|
+
if (key === undefined || key === '') return null
|
|
670
|
+
const facts = { key, ctrl: false, alt: false, shift: false, meta: false }
|
|
671
|
+
for (const part of parts) {
|
|
672
|
+
if (part === 'ctrl' || part === 'control') facts.ctrl = true
|
|
673
|
+
else if (part === 'alt' || part === 'option') facts.alt = true
|
|
674
|
+
else if (part === 'shift') facts.shift = true
|
|
675
|
+
else if (part === 'meta' || part === 'cmd' || part === 'command' || part === 'win') facts.meta = true
|
|
676
|
+
else return null
|
|
677
|
+
}
|
|
678
|
+
// A bare modifier is not an accelerator either.
|
|
679
|
+
if (['ctrl', 'control', 'alt', 'option', 'shift', 'meta', 'cmd', 'command', 'win'].includes(facts.key)) return null
|
|
680
|
+
return facts
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Whether a keyboard event matches a parsed accelerator. */
|
|
684
|
+
function matchesShortcut(event, facts) {
|
|
685
|
+
if (facts === null) return false
|
|
686
|
+
const key = String(event.key ?? '').toLowerCase()
|
|
687
|
+
if (key !== facts.key) return false
|
|
688
|
+
return event.ctrlKey === facts.ctrl
|
|
689
|
+
&& event.altKey === facts.alt
|
|
690
|
+
&& event.shiftKey === facts.shift
|
|
691
|
+
&& event.metaKey === facts.meta
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
695
|
+
// §6 carriers
|
|
696
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
697
|
+
|
|
698
|
+
/** Compose the prompt sent to the main conversation. */
|
|
699
|
+
function composeMainPrompt(selection, question, zone, maxChars) {
|
|
700
|
+
const cut = truncateSelection(selection, maxChars)
|
|
701
|
+
const quoted = cut.text.split('\n').map(line => `> ${line}`).join('\n')
|
|
702
|
+
const zoneLabel = t(zone === 'chat' ? 'zoneChat' : zone === 'task' ? 'zoneTask' : 'zoneOther')
|
|
703
|
+
const head = `${quoted}\n`
|
|
704
|
+
const mark = cut.truncated ? `\n(选中文本已截断,省略约 ${cut.droppedChars} 字)` : ''
|
|
705
|
+
return `${head}${mark}\n【来源:${zoneLabel}】\n${question}`
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Send one question into the main conversation.
|
|
710
|
+
*
|
|
711
|
+
* Four rungs, strongest first, each probed at call time — the submit API
|
|
712
|
+
* moved twice inside the supported range (`sessions.retain` before
|
|
713
|
+
* 0.1.6-alpha.2, `sessions.using` from then on), so a version table would
|
|
714
|
+
* rot. Every rung reports which one won, and the caller renders that.
|
|
715
|
+
*
|
|
716
|
+
* @returns {Promise<{mode: 'session'|'draft', via: string}>}
|
|
717
|
+
*/
|
|
718
|
+
async function askInMainConversation(input) {
|
|
719
|
+
const text = composeMainPrompt(input.selection, input.question, input.zone, input.maxChars)
|
|
720
|
+
const sessions = input.ctx.get('sessions')
|
|
721
|
+
const sessionId = input.sessionId ?? currentSessionId(input.ctx)
|
|
722
|
+
const parts = [{ type: 'text', text }]
|
|
723
|
+
const accepted = (result) => {
|
|
724
|
+
if (result !== undefined && result !== null && result.ok === false) {
|
|
725
|
+
throw Object.assign(new Error(result.error?.message ?? 'prompt rejected'), { code: 'prompt-rejected' })
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Rung 1 — `using()`: the documented helper, present from 0.1.6-alpha.2.
|
|
730
|
+
if (sessionId !== null && typeof sessions?.using === 'function') {
|
|
731
|
+
const result = await sessions.using(sessionId, { source: 'gateway' }, async (reference) => {
|
|
732
|
+
const face = reference?.binding?.session
|
|
733
|
+
if (face === undefined || typeof face.prompt !== 'function') {
|
|
734
|
+
throw Object.assign(new Error('session face has no prompt()'), { code: 'no-session-face' })
|
|
735
|
+
}
|
|
736
|
+
return face.prompt(parts, 'queue')
|
|
737
|
+
})
|
|
738
|
+
accepted(result)
|
|
739
|
+
return { mode: 'session', via: 'sessions.using' }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Rung 2 — `retain()` + explicit release: the same operation spelled out,
|
|
743
|
+
// and the only session-face submit path on 0.1.2-rc.1 … 0.1.6-alpha.1.
|
|
744
|
+
let scope
|
|
745
|
+
if (sessionId !== null && typeof sessions?.retain === 'function') {
|
|
746
|
+
const reference = sessions.retain(sessionId, { source: 'gateway' })
|
|
747
|
+
try {
|
|
748
|
+
if (reference?.ready !== undefined) await reference.ready
|
|
749
|
+
const face = reference?.binding?.session
|
|
750
|
+
if (face !== undefined && typeof face.prompt === 'function') {
|
|
751
|
+
accepted(await face.prompt(parts, 'queue'))
|
|
752
|
+
return { mode: 'session', via: 'sessions.retain' }
|
|
753
|
+
}
|
|
754
|
+
} catch (error) {
|
|
755
|
+
if (error?.code === 'prompt-rejected') throw error
|
|
756
|
+
console.warn(`[${PLUGIN_ID}] sessions.retain 提交失败,尝试下一级:`, error)
|
|
757
|
+
} finally {
|
|
758
|
+
try {
|
|
759
|
+
reference?.release?.()
|
|
760
|
+
} catch { /* already released */ }
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Rung 3 — the composer's own action face. A session-scoped slot
|
|
765
|
+
// component receives `inputActions` as a standard prop (the composer tool
|
|
766
|
+
// row is where this plugin collects the session id), so the harness
|
|
767
|
+
// itself hands us the sanctioned draft+submit pair.
|
|
768
|
+
const captured = composerFace()
|
|
769
|
+
if (captured !== null) {
|
|
770
|
+
const { actions } = captured
|
|
771
|
+
try {
|
|
772
|
+
if (typeof actions.setDraft === 'function') actions.setDraft(text)
|
|
773
|
+
const submit = [actions.submit, actions.send, actions.submitPrompt]
|
|
774
|
+
.find(candidate => typeof candidate === 'function')
|
|
775
|
+
if (submit !== undefined) {
|
|
776
|
+
await submit.call(actions)
|
|
777
|
+
return { mode: 'session', via: 'inputActions.submit' }
|
|
778
|
+
}
|
|
779
|
+
} catch (error) {
|
|
780
|
+
console.warn(`[${PLUGIN_ID}] inputActions 提交失败,回退到写入输入框:`, error)
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Rung 4 — write the draft and let the user press Enter.
|
|
785
|
+
//
|
|
786
|
+
// PLACEHOLDER: composer-draft — `ctx.get('conversation').input.for(scope)`
|
|
787
|
+
// is the harness's own composer draft face; it is not in the published
|
|
788
|
+
// client service catalog, so it is last and probed defensively (the
|
|
789
|
+
// supported replacement is `inputActions` above; see README §6).
|
|
790
|
+
const conversation = input.ctx.get('conversation')
|
|
791
|
+
if (scope === undefined && sessions !== undefined && typeof sessions.scope === 'function' && sessionId !== null) {
|
|
792
|
+
scope = sessions.scope(sessionId)
|
|
793
|
+
}
|
|
794
|
+
if (conversation?.input?.for !== undefined && scope !== undefined) {
|
|
795
|
+
const reactor = conversation.input.for(scope)
|
|
796
|
+
const draft = reactor.state?.getSnapshot?.().draft ?? ''
|
|
797
|
+
reactor.setDraft(draft.trim() === '' ? text : `${draft}\n\n${text}`)
|
|
798
|
+
return { mode: 'draft', via: 'composer.draft' }
|
|
799
|
+
}
|
|
800
|
+
// PLACEHOLDER: clipboard-fallback — nothing could carry the question.
|
|
801
|
+
// The caller renders this message on the card; replace this throw when a
|
|
802
|
+
// supported send API appears (see README §6).
|
|
803
|
+
throw Object.assign(new Error(t('noMainCarrier')), { code: 'no-main-carrier' })
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Best-effort active session id from every sanctioned source. */
|
|
807
|
+
function currentSessionId(ctx) {
|
|
808
|
+
const captured = store.state.sessionId
|
|
809
|
+
if (typeof captured === 'string' && captured !== '') return captured
|
|
810
|
+
try {
|
|
811
|
+
const better = ctx.get('betterSidebar')
|
|
812
|
+
const id = better?.getSnapshot?.()?.sessionId
|
|
813
|
+
if (typeof id === 'string' && id !== '') return id
|
|
814
|
+
} catch { /* the service is optional */ }
|
|
815
|
+
try {
|
|
816
|
+
const mounted = ctx.get('sidebarRight')?.mounted
|
|
817
|
+
const id = typeof mounted?.getSnapshot === 'function' ? mounted.getSnapshot() : undefined
|
|
818
|
+
if (typeof id === 'string' && id !== '') return id
|
|
819
|
+
} catch { /* the service is optional */ }
|
|
820
|
+
return null
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
824
|
+
// §7 side-card surfaces
|
|
825
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Adapter registry. Each adapter answers two questions — "can you host a
|
|
829
|
+
* card now?" and "put this card there" — so `auto` never has to guess.
|
|
830
|
+
* Order matters: the native right rail is the shipped surface, the
|
|
831
|
+
* better-sidebar plugin is an optional third party, and the built-in flow
|
|
832
|
+
* card is the guaranteed fallback that needs nothing at all.
|
|
833
|
+
*/
|
|
834
|
+
const surfaces = {
|
|
835
|
+
native: {
|
|
836
|
+
kind: 'native-rightbar',
|
|
837
|
+
label: () => t('surfaceNative'),
|
|
838
|
+
registry: null,
|
|
839
|
+
controller: null,
|
|
840
|
+
error: null,
|
|
841
|
+
dispose: null,
|
|
842
|
+
available() {
|
|
843
|
+
return this.registry !== null && this.controller !== null && this.error === null
|
|
844
|
+
},
|
|
845
|
+
open(card) {
|
|
846
|
+
if (!this.available() || typeof this.controller.openTab !== 'function') return false
|
|
847
|
+
this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
|
|
848
|
+
return true
|
|
849
|
+
},
|
|
850
|
+
close() {
|
|
851
|
+
// The right rail owns its tab lifecycle; the store entry is enough.
|
|
852
|
+
return true
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
better: {
|
|
856
|
+
kind: 'better-sidebar',
|
|
857
|
+
label: () => t('surfaceBetter'),
|
|
858
|
+
service: null,
|
|
859
|
+
error: null,
|
|
860
|
+
dispose: null,
|
|
861
|
+
features: [],
|
|
862
|
+
available() {
|
|
863
|
+
return this.service !== null && this.error === null
|
|
864
|
+
},
|
|
865
|
+
open(card) {
|
|
866
|
+
if (!this.available() || typeof this.service.openTab !== 'function') return false
|
|
867
|
+
const tabId = `${CARD_KIND}:${card.id}`
|
|
868
|
+
this.service.openTab(
|
|
869
|
+
{ type: CARD_KIND, id: tabId, title: card.question.slice(0, 32), meta: { cardId: card.id } },
|
|
870
|
+
store.state.sessionId === null ? undefined : { sessionId: store.state.sessionId },
|
|
871
|
+
)
|
|
872
|
+
return true
|
|
873
|
+
},
|
|
874
|
+
close(card) {
|
|
875
|
+
if (!this.available() || typeof this.service.closeTab !== 'function') return false
|
|
876
|
+
this.service.closeTab(`${CARD_KIND}:${card.id}`)
|
|
877
|
+
return true
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
flow: {
|
|
881
|
+
kind: 'flow',
|
|
882
|
+
label: () => t('surfaceFlow'),
|
|
883
|
+
available() { return true },
|
|
884
|
+
open() { return true },
|
|
885
|
+
close() { return true },
|
|
886
|
+
},
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* The composer action face captured from a session-scoped slot entry
|
|
891
|
+
* (`inputActions`), or null before any such entry has rendered.
|
|
892
|
+
*/
|
|
893
|
+
let capturedComposerActions = null
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* The card id an external host is CURRENTLY rendering. `CardHost` sets it
|
|
897
|
+
* on mount and clears it on unmount, which is what turns "the adapter
|
|
898
|
+
* accepted the open" into "the user can actually see the card": a host
|
|
899
|
+
* that swallows the open (kind mismatch, collapsed panel, an older plugin
|
|
900
|
+
* version) is detected and the card falls back to the flow stack.
|
|
901
|
+
*/
|
|
902
|
+
let renderedCardId = null
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Pick the surface for a new card.
|
|
906
|
+
* @param {string} wanted - `auto` | adapter kind.
|
|
907
|
+
* @returns {{adapter: object, fellBack: boolean}}
|
|
908
|
+
*/
|
|
909
|
+
function pickSurface(wanted) {
|
|
910
|
+
const order = [surfaces.native, surfaces.better, surfaces.flow]
|
|
911
|
+
if (wanted === 'auto' || wanted === undefined) {
|
|
912
|
+
for (const adapter of order) if (adapter.available()) return { adapter, fellBack: false }
|
|
913
|
+
return { adapter: surfaces.flow, fellBack: false }
|
|
914
|
+
}
|
|
915
|
+
const match = order.find(adapter => adapter.kind === wanted || adapter === surfaces[wanted])
|
|
916
|
+
if (match === undefined) return { adapter: surfaces.flow, fellBack: true }
|
|
917
|
+
if (!match.available()) return { adapter: surfaces.flow, fellBack: true }
|
|
918
|
+
return { adapter: match, fellBack: false }
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
922
|
+
// §8 view helpers
|
|
923
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
924
|
+
|
|
925
|
+
/** Theme-token-only stylesheet. Every class is prefixed `dsa-`. */
|
|
926
|
+
const CSS_TEXT = `
|
|
927
|
+
.dsa-layer{pointer-events:none;position:relative;z-index:60}
|
|
928
|
+
.dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
|
|
929
|
+
padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
930
|
+
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
931
|
+
font-size:12px;line-height:16px;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.18);font-family:inherit}
|
|
932
|
+
.dsa-trigger:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
933
|
+
.dsa-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
|
|
934
|
+
.dsa-pop{pointer-events:auto;position:fixed;width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;
|
|
935
|
+
padding:12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
936
|
+
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
937
|
+
box-shadow:0 10px 30px rgba(0,0,0,.24);font-size:13px;line-height:19px}
|
|
938
|
+
.dsa-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
939
|
+
.dsa-title{font-weight:600;font-size:13px}
|
|
940
|
+
.dsa-quote{margin:0;padding:6px 8px;max-height:96px;overflow:auto;white-space:pre-wrap;word-break:break-word;
|
|
941
|
+
border-left:3px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));border-radius:4px;
|
|
942
|
+
background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.08));color:var(--dsw-alias-label-secondary, #444);
|
|
943
|
+
font-size:12px;line-height:17px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
944
|
+
.dsa-badge{padding:1px 6px;border-radius:999px;font-size:11px;line-height:16px;
|
|
945
|
+
border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));color:var(--dsw-alias-label-secondary, #555)}
|
|
946
|
+
.dsa-badge-warn{border-color:var(--dsw-alias-state-warn-primary, #b8860b);color:var(--dsw-alias-state-warn-primary, #b8860b)}
|
|
947
|
+
.dsa-textarea{width:100%;box-sizing:border-box;min-height:64px;max-height:180px;resize:vertical;padding:8px;
|
|
948
|
+
border-radius:8px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
|
|
949
|
+
background:var(--dsw-alias-bg-layer-1, transparent);color:inherit;font:inherit;font-size:13px}
|
|
950
|
+
.dsa-textarea:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
951
|
+
.dsa-btn{pointer-events:auto;padding:4px 10px;border-radius:8px;cursor:pointer;font:inherit;font-size:12px;
|
|
952
|
+
border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
953
|
+
background:var(--dsw-alias-bg-layer-1, transparent);color:var(--dsw-alias-label-primary, #111)}
|
|
954
|
+
.dsa-btn:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
955
|
+
.dsa-btn-primary{background:var(--dsw-alias-brand-primary, #4c8dff);border-color:var(--dsw-alias-brand-primary, #4c8dff);
|
|
956
|
+
color:#fff;font-weight:500}
|
|
957
|
+
.dsa-btn:disabled{opacity:.55;cursor:default}
|
|
958
|
+
.dsa-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px;overflow:hidden}
|
|
959
|
+
.dsa-seg button{padding:3px 10px;border:none;background:transparent;color:var(--dsw-alias-label-secondary, #555);
|
|
960
|
+
font:inherit;font-size:12px;cursor:pointer}
|
|
961
|
+
.dsa-seg button[data-on="true"]{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.14));
|
|
962
|
+
color:var(--dsw-alias-label-primary, #111);font-weight:500}
|
|
963
|
+
.dsa-card{pointer-events:auto;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:12px;
|
|
964
|
+
border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
965
|
+
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
966
|
+
box-shadow:0 10px 30px rgba(0,0,0,.22);font-size:13px;line-height:19px}
|
|
967
|
+
.dsa-stack{pointer-events:none;position:fixed;right:16px;bottom:16px;display:flex;flex-direction:column;gap:8px;
|
|
968
|
+
width:min(400px, 92vw);max-height:70vh;overflow:auto}
|
|
969
|
+
.dsa-answer{max-height:44vh;overflow:auto;word-break:break-word}
|
|
970
|
+
.dsa-answer .dsa-p{margin:0 0 6px}
|
|
971
|
+
.dsa-answer .dsa-ul{margin:0 0 6px;padding-left:18px}
|
|
972
|
+
.dsa-answer .dsa-h{font-weight:600;margin:0 0 6px}
|
|
973
|
+
.dsa-answer .dsa-gap{height:4px}
|
|
974
|
+
.dsa-pre{margin:0 0 6px;padding:8px;border-radius:8px;overflow:auto;
|
|
975
|
+
background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-size:12px;line-height:17px;
|
|
976
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
977
|
+
.dsa-code{padding:0 4px;border-radius:4px;background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));
|
|
978
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
|
|
979
|
+
.dsa-muted{color:var(--dsw-alias-label-secondary, #666);font-size:11px;line-height:16px}
|
|
980
|
+
.dsa-error{color:var(--dsw-alias-state-error-primary, #c0392b);font-size:12px}
|
|
981
|
+
.dsa-toast{pointer-events:auto;position:fixed;left:50%;transform:translateX(-50%);bottom:24px;padding:6px 12px;
|
|
982
|
+
border-radius:999px;font-size:12px;background:var(--dsw-alias-bg-overlay, #222);
|
|
983
|
+
color:var(--dsw-alias-label-primary, #eee);border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3))}
|
|
984
|
+
.dsa-settings{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary, #111)}
|
|
985
|
+
.dsa-field{display:flex;flex-direction:column;gap:4px}
|
|
986
|
+
.dsa-field > label{font-size:12px;color:var(--dsw-alias-label-secondary, #555)}
|
|
987
|
+
.dsa-input{padding:5px 8px;border-radius:8px;font:inherit;font-size:13px;box-sizing:border-box;
|
|
988
|
+
border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
|
|
989
|
+
background:var(--dsw-alias-bg-layer-1, transparent);color:inherit}
|
|
990
|
+
.dsa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
|
|
991
|
+
.dsa-check{display:flex;align-items:center;gap:6px;font-size:12px}
|
|
992
|
+
.dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
|
|
993
|
+
color:var(--dsw-alias-label-secondary, #555)}
|
|
994
|
+
`
|
|
995
|
+
|
|
996
|
+
/** Render the stylesheet as a React element (removed with its component). */
|
|
997
|
+
function Styles() {
|
|
998
|
+
return h('style', { 'data-dsa-style': '' }, CSS_TEXT)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** Inline markdown-lite: `code` and **bold** only — never innerHTML. */
|
|
1002
|
+
function inlineNodes(text, key) {
|
|
1003
|
+
const out = []
|
|
1004
|
+
const pattern = /(`[^`]+`|\*\*[^*]+\*\*)/g
|
|
1005
|
+
let last = 0
|
|
1006
|
+
let match
|
|
1007
|
+
let index = 0
|
|
1008
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
1009
|
+
if (match.index > last) out.push(text.slice(last, match.index))
|
|
1010
|
+
const token = match[0]
|
|
1011
|
+
if (token.startsWith('`')) {
|
|
1012
|
+
out.push(h('code', { key: `c${key}-${index++}`, className: 'dsa-code' }, token.slice(1, -1)))
|
|
1013
|
+
} else {
|
|
1014
|
+
out.push(h('strong', { key: `b${key}-${index++}` }, token.slice(2, -2)))
|
|
1015
|
+
}
|
|
1016
|
+
last = match.index + token.length
|
|
1017
|
+
}
|
|
1018
|
+
if (last < text.length) out.push(text.slice(last))
|
|
1019
|
+
return out
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Render answer text as React nodes: fenced code, lists, headings,
|
|
1024
|
+
* paragraphs. No HTML injection anywhere, so model output cannot become
|
|
1025
|
+
* markup.
|
|
1026
|
+
*/
|
|
1027
|
+
function renderRichText(text) {
|
|
1028
|
+
const lines = String(text ?? '').split('\n')
|
|
1029
|
+
const nodes = []
|
|
1030
|
+
let i = 0
|
|
1031
|
+
let key = 0
|
|
1032
|
+
const isBullet = line => /^\s*([-*+]|\d+\.)\s+/.test(line)
|
|
1033
|
+
const isHeading = line => /^#{1,6}\s+/.test(line)
|
|
1034
|
+
while (i < lines.length) {
|
|
1035
|
+
const line = lines[i]
|
|
1036
|
+
if (/^```/.test(line)) {
|
|
1037
|
+
const body = []
|
|
1038
|
+
i += 1
|
|
1039
|
+
while (i < lines.length && !/^```/.test(lines[i])) {
|
|
1040
|
+
body.push(lines[i])
|
|
1041
|
+
i += 1
|
|
1042
|
+
}
|
|
1043
|
+
i += 1
|
|
1044
|
+
nodes.push(h('pre', { key: `f${key++}`, className: 'dsa-pre' }, h('code', null, body.join('\n'))))
|
|
1045
|
+
continue
|
|
1046
|
+
}
|
|
1047
|
+
if (isBullet(line)) {
|
|
1048
|
+
const items = []
|
|
1049
|
+
while (i < lines.length && isBullet(lines[i])) {
|
|
1050
|
+
items.push(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, ''))
|
|
1051
|
+
i += 1
|
|
1052
|
+
}
|
|
1053
|
+
nodes.push(h('ul', { key: `u${key++}`, className: 'dsa-ul' },
|
|
1054
|
+
items.map((item, index) => h('li', { key: index }, inlineNodes(item, `${key}-${index}`)))))
|
|
1055
|
+
continue
|
|
1056
|
+
}
|
|
1057
|
+
if (isHeading(line)) {
|
|
1058
|
+
nodes.push(h('div', { key: `h${key}`, className: 'dsa-h' }, inlineNodes(line.replace(/^#{1,6}\s+/, ''), key)))
|
|
1059
|
+
key += 1
|
|
1060
|
+
i += 1
|
|
1061
|
+
continue
|
|
1062
|
+
}
|
|
1063
|
+
if (line.trim() === '') {
|
|
1064
|
+
nodes.push(h('div', { key: `g${key++}`, className: 'dsa-gap' }))
|
|
1065
|
+
i += 1
|
|
1066
|
+
continue
|
|
1067
|
+
}
|
|
1068
|
+
const paragraph = []
|
|
1069
|
+
while (
|
|
1070
|
+
i < lines.length
|
|
1071
|
+
&& lines[i].trim() !== ''
|
|
1072
|
+
&& !/^```/.test(lines[i])
|
|
1073
|
+
&& !isBullet(lines[i])
|
|
1074
|
+
&& !isHeading(lines[i])
|
|
1075
|
+
) {
|
|
1076
|
+
paragraph.push(lines[i])
|
|
1077
|
+
i += 1
|
|
1078
|
+
}
|
|
1079
|
+
nodes.push(h('p', { key: `p${key}`, className: 'dsa-p' }, inlineNodes(paragraph.join('\n'), key)))
|
|
1080
|
+
key += 1
|
|
1081
|
+
}
|
|
1082
|
+
return nodes
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Clamp a fixed-position box into the viewport. */
|
|
1086
|
+
function clampBox(rect, width, height, prefer = 'below') {
|
|
1087
|
+
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
|
|
1088
|
+
const vh = typeof window !== 'undefined' ? window.innerHeight : 768
|
|
1089
|
+
let left = rect.right + 8
|
|
1090
|
+
if (left + width > vw - 8) left = Math.max(8, Math.min(vw - width - 8, rect.left - 8 - width))
|
|
1091
|
+
let top = prefer === 'below' ? rect.bottom + 6 : rect.top - height - 6
|
|
1092
|
+
if (top + height > vh - 8) top = Math.max(8, vh - height - 8)
|
|
1093
|
+
if (top < 8) top = 8
|
|
1094
|
+
return { left: Math.round(left), top: Math.round(top) }
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Copy text to the clipboard.
|
|
1099
|
+
*
|
|
1100
|
+
* Only the async Clipboard API is used: this plugin never inserts an
|
|
1101
|
+
* element into `document.body` (a hidden textarea is how the legacy
|
|
1102
|
+
* `execCommand('copy')` path is usually built, and it would put DOM outside
|
|
1103
|
+
* the component tree). When the Clipboard API is unavailable, the rendered
|
|
1104
|
+
* answer node is SELECTED instead, so the user can finish with Ctrl+C.
|
|
1105
|
+
*
|
|
1106
|
+
* @param {string} text - the text to copy.
|
|
1107
|
+
* @param {Element|null} [fallbackElement] - the rendered node to select.
|
|
1108
|
+
* @returns {Promise<'copied'|'selected'|'failed'>}
|
|
1109
|
+
*/
|
|
1110
|
+
async function copyText(text, fallbackElement) {
|
|
1111
|
+
try {
|
|
1112
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
1113
|
+
await navigator.clipboard.writeText(text)
|
|
1114
|
+
return 'copied'
|
|
1115
|
+
}
|
|
1116
|
+
} catch { /* fall through to the selection fallback */ }
|
|
1117
|
+
try {
|
|
1118
|
+
if (fallbackElement !== null && fallbackElement !== undefined && typeof window !== 'undefined') {
|
|
1119
|
+
const range = document.createRange()
|
|
1120
|
+
range.selectNodeContents(fallbackElement)
|
|
1121
|
+
const selection = window.getSelection()
|
|
1122
|
+
selection.removeAllRanges()
|
|
1123
|
+
selection.addRange(range)
|
|
1124
|
+
return 'selected'
|
|
1125
|
+
}
|
|
1126
|
+
} catch { /* report the failure below */ }
|
|
1127
|
+
return 'failed'
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** The zone badge label for a candidate. */
|
|
1131
|
+
function zoneLabel(candidate) {
|
|
1132
|
+
if (candidate.cross) return t('zoneCross')
|
|
1133
|
+
if (candidate.zone === 'chat') return t('zoneChat')
|
|
1134
|
+
if (candidate.zone === 'task') return t('zoneTask')
|
|
1135
|
+
return t('zoneOther')
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1139
|
+
// §9 components
|
|
1140
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1141
|
+
|
|
1142
|
+
/** The in-place trigger button floating at the selection. */
|
|
1143
|
+
function TriggerButton({ candidate, onOpen }) {
|
|
1144
|
+
const box = clampBox(candidate.rect, 132, 30, 'below')
|
|
1145
|
+
return h('button', {
|
|
1146
|
+
type: 'button',
|
|
1147
|
+
className: 'dsa-trigger',
|
|
1148
|
+
style: { left: `${box.left}px`, top: `${box.top}px` },
|
|
1149
|
+
title: t('triggerHint'),
|
|
1150
|
+
'aria-label': `${t('triggerLabel')}:${zoneLabel(candidate)}`,
|
|
1151
|
+
onMouseDown: (event) => { event.preventDefault() },
|
|
1152
|
+
onClick: (event) => {
|
|
1153
|
+
event.preventDefault()
|
|
1154
|
+
event.stopPropagation()
|
|
1155
|
+
onOpen()
|
|
1156
|
+
},
|
|
1157
|
+
}, h('span', { 'aria-hidden': true }, '💬'), h('span', null, t('triggerLabel')))
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** The question popover. */
|
|
1161
|
+
function AskPopover({ candidate, config, onClose, onSubmit }) {
|
|
1162
|
+
const [question, setQuestion] = React.useState('')
|
|
1163
|
+
const [carrier, setCarrier] = React.useState(config.defaultCarrier)
|
|
1164
|
+
const [busy, setBusy] = React.useState(false)
|
|
1165
|
+
const areaRef = React.useRef(null)
|
|
1166
|
+
const cut = truncateSelection(candidate.text, config.maxChars)
|
|
1167
|
+
const box = clampBox(candidate.rect, 420, 260, 'below')
|
|
1168
|
+
|
|
1169
|
+
React.useEffect(() => {
|
|
1170
|
+
const area = areaRef.current
|
|
1171
|
+
if (area !== null) area.focus()
|
|
1172
|
+
}, [])
|
|
1173
|
+
|
|
1174
|
+
const submit = async () => {
|
|
1175
|
+
const text = question.trim()
|
|
1176
|
+
if (text === '' || busy) return
|
|
1177
|
+
setBusy(true)
|
|
1178
|
+
try {
|
|
1179
|
+
await onSubmit({ question: text, carrier })
|
|
1180
|
+
} finally {
|
|
1181
|
+
setBusy(false)
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
return h('div', {
|
|
1186
|
+
className: 'dsa-pop',
|
|
1187
|
+
style: { left: `${box.left}px`, top: `${box.top}px` },
|
|
1188
|
+
role: 'dialog',
|
|
1189
|
+
'aria-label': t('askTitle'),
|
|
1190
|
+
onMouseDown: event => event.stopPropagation(),
|
|
1191
|
+
onKeyDown: (event) => {
|
|
1192
|
+
if (event.key === 'Escape') {
|
|
1193
|
+
event.stopPropagation()
|
|
1194
|
+
onClose()
|
|
1195
|
+
return
|
|
1196
|
+
}
|
|
1197
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
|
1198
|
+
event.preventDefault()
|
|
1199
|
+
void submit()
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
},
|
|
1203
|
+
h('div', { className: 'dsa-row' },
|
|
1204
|
+
h('span', { className: 'dsa-title' }, t('askTitle')),
|
|
1205
|
+
h('span', { className: 'dsa-badge' }, zoneLabel(candidate)),
|
|
1206
|
+
h('span', { className: 'dsa-badge' }, t('chars', { n: candidate.text.length })),
|
|
1207
|
+
cut.truncated
|
|
1208
|
+
? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: cut.droppedChars }))
|
|
1209
|
+
: null),
|
|
1210
|
+
h('pre', { className: 'dsa-quote' }, candidate.text.length > 400 ? `${candidate.text.slice(0, 400)}…` : candidate.text),
|
|
1211
|
+
h('textarea', {
|
|
1212
|
+
ref: areaRef,
|
|
1213
|
+
className: 'dsa-textarea',
|
|
1214
|
+
value: question,
|
|
1215
|
+
placeholder: t('askPlaceholder'),
|
|
1216
|
+
onChange: event => setQuestion(event.target.value),
|
|
1217
|
+
}),
|
|
1218
|
+
h('div', { className: 'dsa-row' },
|
|
1219
|
+
h('span', { className: 'dsa-muted' }, t('carrier')),
|
|
1220
|
+
h('div', { className: 'dsa-seg' },
|
|
1221
|
+
h('button', {
|
|
1222
|
+
type: 'button',
|
|
1223
|
+
'data-on': String(carrier === 'main'),
|
|
1224
|
+
title: t('carrierMainHint'),
|
|
1225
|
+
onClick: () => setCarrier('main'),
|
|
1226
|
+
}, t('carrierMain')),
|
|
1227
|
+
h('button', {
|
|
1228
|
+
type: 'button',
|
|
1229
|
+
'data-on': String(carrier === 'side'),
|
|
1230
|
+
title: t('carrierSideHint'),
|
|
1231
|
+
onClick: () => setCarrier('side'),
|
|
1232
|
+
}, t('carrierSide')))),
|
|
1233
|
+
h('div', { className: 'dsa-row', style: { justifyContent: 'flex-end' } },
|
|
1234
|
+
h('span', { className: 'dsa-muted', style: { marginRight: 'auto' } },
|
|
1235
|
+
t('askShortcutHint', { key: config.shortcut })),
|
|
1236
|
+
h('button', { type: 'button', className: 'dsa-btn', onClick: onClose }, t('askCancel')),
|
|
1237
|
+
h('button', {
|
|
1238
|
+
type: 'button',
|
|
1239
|
+
className: 'dsa-btn dsa-btn-primary',
|
|
1240
|
+
disabled: busy || question.trim() === '',
|
|
1241
|
+
onClick: () => { void submit() },
|
|
1242
|
+
}, t('askSubmit'))))
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** The streamed answer card body, shared by every surface. */
|
|
1246
|
+
function AnswerBody({ card, containerRef }) {
|
|
1247
|
+
if (card.error !== null) {
|
|
1248
|
+
const engineMissing = card.error.code === 'no-side-engine' || card.error.code === 'no-parent'
|
|
1249
|
+
return h('div', { className: 'dsa-error' },
|
|
1250
|
+
h('div', { className: 'dsa-title' }, t('errorTitle')),
|
|
1251
|
+
h('div', null, card.error.message ?? String(card.error.code ?? '')),
|
|
1252
|
+
engineMissing ? h('div', { className: 'dsa-muted' }, t('errorNoSideEngine')) : null)
|
|
1253
|
+
}
|
|
1254
|
+
if (card.text === '' && card.status === 'streaming') {
|
|
1255
|
+
return h('div', { className: 'dsa-muted' }, t('answerStreaming'))
|
|
1256
|
+
}
|
|
1257
|
+
if (card.text === '' && card.status === 'done') {
|
|
1258
|
+
return h('div', { className: 'dsa-muted' }, t('answerEmpty'))
|
|
1259
|
+
}
|
|
1260
|
+
return h('div', { className: 'dsa-answer', ref: containerRef }, renderRichText(card.text))
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** Card footer: status, copy, follow-up, retry, close. */
|
|
1264
|
+
function CardActions({ card, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain }) {
|
|
1265
|
+
const terminal = card.status === 'done' || card.status === 'error' || card.status === 'stopped'
|
|
1266
|
+
return h('div', { className: 'dsa-row' },
|
|
1267
|
+
h('span', { className: 'dsa-muted' },
|
|
1268
|
+
card.status === 'streaming' ? t('answerStreaming')
|
|
1269
|
+
: card.status === 'done' ? (card.streaming === false ? t('answerNonStreaming') : t('answerDone'))
|
|
1270
|
+
: card.status === 'stopped' ? t('answerStopped') : t('answerTitle')),
|
|
1271
|
+
card.status === 'streaming'
|
|
1272
|
+
? h('button', { type: 'button', className: 'dsa-btn', onClick: onCancel }, t('cancelAnswer'))
|
|
1273
|
+
: null,
|
|
1274
|
+
terminal && card.status !== 'done'
|
|
1275
|
+
? h('button', { type: 'button', className: 'dsa-btn', onClick: onRetry }, t('retry'))
|
|
1276
|
+
: null,
|
|
1277
|
+
card.status === 'error' && card.carrier === 'side'
|
|
1278
|
+
? h('button', { type: 'button', className: 'dsa-btn', onClick: onSendToMain }, t('sendToMain'))
|
|
1279
|
+
: null,
|
|
1280
|
+
terminal && card.text !== ''
|
|
1281
|
+
? h('button', { type: 'button', className: 'dsa-btn', onClick: onCopy }, t('copy'))
|
|
1282
|
+
: null,
|
|
1283
|
+
terminal
|
|
1284
|
+
? h('button', { type: 'button', className: 'dsa-btn', onClick: onFollowUp }, t('followUp'))
|
|
1285
|
+
: null,
|
|
1286
|
+
h('button', {
|
|
1287
|
+
type: 'button',
|
|
1288
|
+
className: 'dsa-btn',
|
|
1289
|
+
style: { marginLeft: 'auto' },
|
|
1290
|
+
onClick: onClose,
|
|
1291
|
+
}, t('close')))
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** One answer card; `embedded` is true inside a host's tab. */
|
|
1295
|
+
function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain }) {
|
|
1296
|
+
const [followOpen, setFollowOpen] = React.useState(false)
|
|
1297
|
+
const [draft, setDraft] = React.useState('')
|
|
1298
|
+
const answerRef = React.useRef(null)
|
|
1299
|
+
const config = store.state.config
|
|
1300
|
+
return h('section', { className: 'dsa-card', 'aria-label': t('answerTitle') },
|
|
1301
|
+
h('div', { className: 'dsa-row' },
|
|
1302
|
+
h('span', { className: 'dsa-title' }, t('answerTitle')),
|
|
1303
|
+
h('span', { className: 'dsa-badge' }, zoneLabel(card)),
|
|
1304
|
+
surfaceLabel !== null ? h('span', { className: 'dsa-badge' }, surfaceLabel) : null,
|
|
1305
|
+
card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null),
|
|
1306
|
+
h('pre', { className: 'dsa-quote' }, card.question),
|
|
1307
|
+
h(AnswerBody, { card, containerRef: answerRef }),
|
|
1308
|
+
card.toolFilter === 'unsupported'
|
|
1309
|
+
? h('div', { className: 'dsa-muted' }, t('toolGuardUnavailable'))
|
|
1310
|
+
: (card.toolFilter === 'inherit' ? h('div', { className: 'dsa-muted' }, t('toolGuardInherited')) : null),
|
|
1311
|
+
card.carrier === 'main' && card.status === 'done'
|
|
1312
|
+
? h('div', { className: 'dsa-muted' }, t('mainSentHint'))
|
|
1313
|
+
: null,
|
|
1314
|
+
followOpen
|
|
1315
|
+
? h('div', { className: 'dsa-field' },
|
|
1316
|
+
h('textarea', {
|
|
1317
|
+
className: 'dsa-textarea',
|
|
1318
|
+
value: draft,
|
|
1319
|
+
placeholder: t('followUpPlaceholder'),
|
|
1320
|
+
autoFocus: true,
|
|
1321
|
+
onChange: event => setDraft(event.target.value),
|
|
1322
|
+
onKeyDown: (event) => {
|
|
1323
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
|
1324
|
+
event.preventDefault()
|
|
1325
|
+
const text = draft.trim()
|
|
1326
|
+
if (text === '') return
|
|
1327
|
+
setDraft('')
|
|
1328
|
+
setFollowOpen(false)
|
|
1329
|
+
onFollowUp(text)
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1332
|
+
}))
|
|
1333
|
+
: null,
|
|
1334
|
+
h(CardActions, {
|
|
1335
|
+
card,
|
|
1336
|
+
onCopy: () => onCopy(answerRef.current),
|
|
1337
|
+
onRetry,
|
|
1338
|
+
onCancel,
|
|
1339
|
+
onSendToMain,
|
|
1340
|
+
onFollowUp: () => setFollowOpen(open => !open),
|
|
1341
|
+
onClose,
|
|
1342
|
+
}),
|
|
1343
|
+
surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null)
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/** The flow surface: a fixed stack of cards at the frame's bottom-right. */
|
|
1347
|
+
function FlowStack({ cards, actions }) {
|
|
1348
|
+
const visible = cards.filter(card => card.surface === 'flow' && card.open)
|
|
1349
|
+
if (visible.length === 0) return null
|
|
1350
|
+
return h('div', { className: 'dsa-stack', 'data-dsa-root': '' },
|
|
1351
|
+
visible.map(card => h(AnswerCard, {
|
|
1352
|
+
key: card.id,
|
|
1353
|
+
card,
|
|
1354
|
+
surfaceLabel: null,
|
|
1355
|
+
onCopy: element => actions.copy(card, element),
|
|
1356
|
+
onFollowUp: text => actions.followUp(card, text),
|
|
1357
|
+
onRetry: () => actions.retry(card),
|
|
1358
|
+
onCancel: () => actions.cancel(card),
|
|
1359
|
+
onSendToMain: () => actions.sendToMain(card),
|
|
1360
|
+
onClose: () => actions.close(card),
|
|
1361
|
+
})))
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** The `shell.overlay` entry: styles + trigger + popover + cards + toast. */
|
|
1365
|
+
function OverlayRoot({ actions }) {
|
|
1366
|
+
const state = useStoreState()
|
|
1367
|
+
const config = state.config
|
|
1368
|
+
const candidate = state.trigger
|
|
1369
|
+
const popover = state.popover
|
|
1370
|
+
return h('div', { className: 'dsa-layer', 'data-dsa-root': '' },
|
|
1371
|
+
h(Styles, null),
|
|
1372
|
+
candidate !== null && popover === null && config.trigger !== 'shortcut'
|
|
1373
|
+
? h(TriggerButton, { candidate, onOpen: () => actions.openPopover(candidate) })
|
|
1374
|
+
: null,
|
|
1375
|
+
popover !== null
|
|
1376
|
+
? h(AskPopover, {
|
|
1377
|
+
candidate: popover.candidate,
|
|
1378
|
+
config,
|
|
1379
|
+
onClose: () => actions.closePopover(),
|
|
1380
|
+
onSubmit: payload => actions.submit(popover.candidate, payload),
|
|
1381
|
+
})
|
|
1382
|
+
: null,
|
|
1383
|
+
h(FlowStack, { cards: state.cards, actions }),
|
|
1384
|
+
state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/** The settings page (`settings.section`). */
|
|
1388
|
+
function SettingsSection({ actions }) {
|
|
1389
|
+
const state = useStoreState()
|
|
1390
|
+
const [draft, setDraft] = React.useState(state.config)
|
|
1391
|
+
const [status, setStatus] = React.useState('idle')
|
|
1392
|
+
const [diag, setDiag] = React.useState(null)
|
|
1393
|
+
const config = state.config
|
|
1394
|
+
|
|
1395
|
+
React.useEffect(() => { setDraft(config) }, [config])
|
|
1396
|
+
|
|
1397
|
+
const field = (label, control, hint) => h('div', { className: 'dsa-field' },
|
|
1398
|
+
h('label', null, label),
|
|
1399
|
+
control,
|
|
1400
|
+
hint !== undefined ? h('span', { className: 'dsa-muted' }, hint) : null)
|
|
1401
|
+
|
|
1402
|
+
const select = (key, options) => h('select', {
|
|
1403
|
+
className: 'dsa-input',
|
|
1404
|
+
value: String(draft[key]),
|
|
1405
|
+
onChange: event => setDraft({ ...draft, [key]: event.target.value }),
|
|
1406
|
+
}, options.map(([value, label]) => h('option', { key: value, value }, label)))
|
|
1407
|
+
|
|
1408
|
+
const number = (key, min, max) => h('input', {
|
|
1409
|
+
className: 'dsa-input',
|
|
1410
|
+
type: 'number',
|
|
1411
|
+
min,
|
|
1412
|
+
max,
|
|
1413
|
+
value: String(draft[key]),
|
|
1414
|
+
onChange: event => setDraft({ ...draft, [key]: Number(event.target.value) }),
|
|
1415
|
+
})
|
|
1416
|
+
|
|
1417
|
+
const save = async () => {
|
|
1418
|
+
if (parseShortcut(draft.shortcut) === null) {
|
|
1419
|
+
setStatus(`error:${t('unsupportedTrigger')}`)
|
|
1420
|
+
return
|
|
1421
|
+
}
|
|
1422
|
+
setStatus('saving')
|
|
1423
|
+
try {
|
|
1424
|
+
await actions.saveConfig(draft)
|
|
1425
|
+
setStatus('saved')
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
setStatus(`error:${t('saveFailed', { msg: error?.message ?? error })}`)
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
|
|
1432
|
+
h(Styles, null),
|
|
1433
|
+
h('div', null,
|
|
1434
|
+
h('div', { className: 'dsa-title' }, t('settingsTitle')),
|
|
1435
|
+
h('div', { className: 'dsa-muted' }, t('settingsDesc'))),
|
|
1436
|
+
h('div', { className: 'dsa-grid' },
|
|
1437
|
+
field(t('setTrigger'), select('trigger', [
|
|
1438
|
+
['selection', t('setTriggerSelection')],
|
|
1439
|
+
['shortcut', t('setTriggerShortcut')],
|
|
1440
|
+
['both', t('setTriggerBoth')],
|
|
1441
|
+
])),
|
|
1442
|
+
field(t('setCarrier'), select('defaultCarrier', [
|
|
1443
|
+
['side', t('carrierSide')],
|
|
1444
|
+
['main', t('carrierMain')],
|
|
1445
|
+
])),
|
|
1446
|
+
field(t('setSurface'), select('sideSurface', [
|
|
1447
|
+
['auto', t('setSurfaceAuto')],
|
|
1448
|
+
['flow', t('setSurfaceFlow')],
|
|
1449
|
+
['native-rightbar', t('surfaceNative')],
|
|
1450
|
+
['better-sidebar', t('surfaceBetter')],
|
|
1451
|
+
])),
|
|
1452
|
+
field(t('setZones'), select('captureZones', [
|
|
1453
|
+
['auto', t('setZonesAuto')],
|
|
1454
|
+
['chat', t('setZonesChat')],
|
|
1455
|
+
['task', t('setZonesTask')],
|
|
1456
|
+
['chat+task', t('setZonesBoth')],
|
|
1457
|
+
])),
|
|
1458
|
+
field(t('setMaxChars'), number('maxChars', 200, 60000), t('setMaxCharsHint')),
|
|
1459
|
+
field(t('setMinChars'), number('minChars', 0, 200)),
|
|
1460
|
+
field(t('setConcurrency'), number('maxConcurrentAsks', 1, 12)),
|
|
1461
|
+
field(t('setShortcut'), h('input', {
|
|
1462
|
+
className: 'dsa-input',
|
|
1463
|
+
value: String(draft.shortcut),
|
|
1464
|
+
onChange: event => setDraft({ ...draft, shortcut: event.target.value }),
|
|
1465
|
+
})),
|
|
1466
|
+
field(t('setSideTools'), select('sideTools', [
|
|
1467
|
+
['readonly', t('setSideToolsReadonly')],
|
|
1468
|
+
['inherit', t('setSideToolsInherit')],
|
|
1469
|
+
]))),
|
|
1470
|
+
h('label', { className: 'dsa-check' },
|
|
1471
|
+
h('input', {
|
|
1472
|
+
type: 'checkbox',
|
|
1473
|
+
checked: draft.showInUnclassified === true,
|
|
1474
|
+
onChange: event => setDraft({ ...draft, showInUnclassified: event.target.checked }),
|
|
1475
|
+
}),
|
|
1476
|
+
t('setUnclassified')),
|
|
1477
|
+
h('div', { className: 'dsa-row' },
|
|
1478
|
+
h('button', {
|
|
1479
|
+
type: 'button',
|
|
1480
|
+
className: 'dsa-btn dsa-btn-primary',
|
|
1481
|
+
disabled: status === 'saving',
|
|
1482
|
+
onClick: () => { void save() },
|
|
1483
|
+
}, status === 'saving' ? t('saving') : t('save')),
|
|
1484
|
+
h('button', {
|
|
1485
|
+
type: 'button',
|
|
1486
|
+
className: 'dsa-btn',
|
|
1487
|
+
onClick: async () => {
|
|
1488
|
+
try {
|
|
1489
|
+
await actions.resetConfig()
|
|
1490
|
+
setStatus('saved')
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
setStatus(`error:${String(error?.message ?? error)}`)
|
|
1493
|
+
}
|
|
1494
|
+
},
|
|
1495
|
+
}, t('reset')),
|
|
1496
|
+
h('button', {
|
|
1497
|
+
type: 'button',
|
|
1498
|
+
className: 'dsa-btn',
|
|
1499
|
+
onClick: async () => {
|
|
1500
|
+
try {
|
|
1501
|
+
const value = await actions.diagnose()
|
|
1502
|
+
setDiag(value)
|
|
1503
|
+
} catch (error) {
|
|
1504
|
+
setDiag({ unreachable: String(error?.message ?? error) })
|
|
1505
|
+
}
|
|
1506
|
+
},
|
|
1507
|
+
}, t('diagnostics')),
|
|
1508
|
+
h('span', { className: 'dsa-muted' },
|
|
1509
|
+
status === 'saved' ? t('saved')
|
|
1510
|
+
: status === 'idle' ? ''
|
|
1511
|
+
: status === 'saving' ? t('saving') : status.replace(/^error:/, ''))),
|
|
1512
|
+
diag !== null
|
|
1513
|
+
? h('div', { className: 'dsa-diag' },
|
|
1514
|
+
h('div', { className: 'dsa-title' }, t('diagTitle')),
|
|
1515
|
+
diag.unreachable !== undefined
|
|
1516
|
+
? h('div', { className: 'dsa-error' }, t('diagUnreachable', { msg: diag.unreachable }))
|
|
1517
|
+
: [
|
|
1518
|
+
h('div', { key: 'host' }, `${t('diagHost')}: ${t('yes')} · v${diag.version}`),
|
|
1519
|
+
h('div', { key: 'side' }, `${t('diagSide')}: ${diag.capabilities?.sideEngine ? t('yes') : t('no')}`),
|
|
1520
|
+
h('div', { key: 'prov' }, `${t('diagProviders')}: ${(diag.capabilities?.sideProviders ?? []).join(', ') || t('none')}`),
|
|
1521
|
+
h('div', { key: 'agents' }, `${t('diagAgents')}: ${diag.capabilities?.liveAgents ?? 0}`),
|
|
1522
|
+
h('div', { key: 'surface' }, `${t('diagSurface')}: ${state.surface}`),
|
|
1523
|
+
h('div', { key: 'session' }, `${t('diagSession')}: ${state.sessionId ?? t('none')}`),
|
|
1524
|
+
h('div', { key: 'zones' }, `${t('diagZones')}: ${state.zoneAnchors === false ? t('diagZonesMissing') : t('yes')}`),
|
|
1525
|
+
h('div', { key: 'slots' }, `${t('diagSlots')}: ${Object.entries(state.slots).map(([k, v]) => `${k}=${v}`).join(' · ')}`),
|
|
1526
|
+
h('div', { key: 'path' }, `${t('settingsTitle')} → ${diag.provenance?.persistedPath ?? ''}`),
|
|
1527
|
+
])
|
|
1528
|
+
: null)
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** Session probe: an invisible entry in the composer tool row. */
|
|
1532
|
+
function SessionProbe(props) {
|
|
1533
|
+
const sessionId = props?.sessionId
|
|
1534
|
+
// `inputActions` is a STANDARD prop of every session-scoped slot entry
|
|
1535
|
+
// (the slot catalog lists it beside `sessionId`), so the composer's own
|
|
1536
|
+
// action face is captured here rather than guessed at from a service.
|
|
1537
|
+
const actions = props?.inputActions
|
|
1538
|
+
React.useEffect(() => {
|
|
1539
|
+
if (typeof sessionId === 'string' && sessionId !== '' && store.state.sessionId !== sessionId) {
|
|
1540
|
+
store.set({ sessionId })
|
|
1541
|
+
}
|
|
1542
|
+
if (actions !== undefined && actions !== null) capturedComposerActions = actions
|
|
1543
|
+
}, [sessionId, actions])
|
|
1544
|
+
return null
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/** The composer action face captured from a session-scoped slot entry. */
|
|
1548
|
+
function composerFace() {
|
|
1549
|
+
return capturedComposerActions === null ? null : { actions: capturedComposerActions }
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1553
|
+
// §10 plugin face
|
|
1554
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1555
|
+
|
|
1556
|
+
return {
|
|
1557
|
+
inject: ['slots'],
|
|
1558
|
+
apply(ctx) {
|
|
1559
|
+
/** Everything this activation must undo. */
|
|
1560
|
+
const disposers = []
|
|
1561
|
+
const controllers = new Map()
|
|
1562
|
+
/** Pending surface render-proof timers (cleared on unload). */
|
|
1563
|
+
const pendingProofs = new Set()
|
|
1564
|
+
let lastSignature = null
|
|
1565
|
+
let lastAt = 0
|
|
1566
|
+
let pendingTimer = null
|
|
1567
|
+
let nextCardId = 1
|
|
1568
|
+
|
|
1569
|
+
// ── configuration ────────────────────────────────────────────────
|
|
1570
|
+
const refreshState = async () => {
|
|
1571
|
+
try {
|
|
1572
|
+
const value = await getState()
|
|
1573
|
+
store.set({
|
|
1574
|
+
config: { ...CLIENT_DEFAULTS, ...(value.config ?? {}) },
|
|
1575
|
+
problems: value.problems ?? [],
|
|
1576
|
+
capabilities: value.capabilities ?? null,
|
|
1577
|
+
provenance: value.provenance ?? null,
|
|
1578
|
+
hostReachable: true,
|
|
1579
|
+
})
|
|
1580
|
+
return value
|
|
1581
|
+
} catch (error) {
|
|
1582
|
+
store.set({ hostReachable: false })
|
|
1583
|
+
console.error(`[${PLUGIN_ID}] /state failed:`, error)
|
|
1584
|
+
throw error
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const actions = {
|
|
1589
|
+
openPopover(candidate) {
|
|
1590
|
+
store.set({ popover: { candidate } })
|
|
1591
|
+
},
|
|
1592
|
+
closePopover() {
|
|
1593
|
+
store.set({ popover: null })
|
|
1594
|
+
},
|
|
1595
|
+
async submit(candidate, payload) {
|
|
1596
|
+
store.set({ popover: null, trigger: null })
|
|
1597
|
+
const config = store.state.config
|
|
1598
|
+
const cut = truncateSelection(candidate.text, config.maxChars)
|
|
1599
|
+
const card = {
|
|
1600
|
+
id: `card-${Date.now()}-${nextCardId++}`,
|
|
1601
|
+
question: payload.question,
|
|
1602
|
+
selection: candidate.text,
|
|
1603
|
+
selected: cut.text,
|
|
1604
|
+
truncated: cut.truncated,
|
|
1605
|
+
droppedChars: cut.droppedChars,
|
|
1606
|
+
zone: candidate.zone,
|
|
1607
|
+
cross: candidate.cross,
|
|
1608
|
+
carrier: payload.carrier,
|
|
1609
|
+
status: 'pending',
|
|
1610
|
+
text: '',
|
|
1611
|
+
reasoning: '',
|
|
1612
|
+
streaming: null,
|
|
1613
|
+
error: null,
|
|
1614
|
+
surface: 'flow',
|
|
1615
|
+
open: true,
|
|
1616
|
+
history: [],
|
|
1617
|
+
}
|
|
1618
|
+
if (payload.carrier === 'main') {
|
|
1619
|
+
store.set({ cards: [card, ...store.state.cards] })
|
|
1620
|
+
try {
|
|
1621
|
+
const result = await askInMainConversation({
|
|
1622
|
+
ctx,
|
|
1623
|
+
sessionId: currentSessionId(ctx),
|
|
1624
|
+
selection: candidate.text,
|
|
1625
|
+
question: payload.question,
|
|
1626
|
+
zone: candidate.zone,
|
|
1627
|
+
maxChars: config.maxChars,
|
|
1628
|
+
})
|
|
1629
|
+
patchCard(card.id, {
|
|
1630
|
+
status: 'done',
|
|
1631
|
+
text: result.mode === 'draft' ? t('draftInsertedHint') : t('mainSentHint'),
|
|
1632
|
+
streaming: null,
|
|
1633
|
+
})
|
|
1634
|
+
toast(result.mode === 'draft' ? t('draftInserted') : t('mainSent'))
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
patchCard(card.id, {
|
|
1637
|
+
status: 'error',
|
|
1638
|
+
error: { code: error?.code ?? 'main-failed', message: String(error?.message ?? error), retryable: true },
|
|
1639
|
+
})
|
|
1640
|
+
}
|
|
1641
|
+
return
|
|
1642
|
+
}
|
|
1643
|
+
const { adapter, fellBack } = pickSurface(config.sideSurface)
|
|
1644
|
+
card.surface = adapter.kind
|
|
1645
|
+
store.set({ cards: [card, ...store.state.cards], surface: adapter.kind })
|
|
1646
|
+
if (fellBack) {
|
|
1647
|
+
store.set({ surfaceNote: t('surfaceFellBack', { name: adapter.label() }) })
|
|
1648
|
+
toast(t('surfaceUnavailable'))
|
|
1649
|
+
}
|
|
1650
|
+
if (adapter.kind !== 'flow') {
|
|
1651
|
+
let opened = false
|
|
1652
|
+
try {
|
|
1653
|
+
opened = adapter.open(card) === true
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
console.error(`[${PLUGIN_ID}] surface open failed:`, error)
|
|
1656
|
+
opened = false
|
|
1657
|
+
}
|
|
1658
|
+
if (!opened) {
|
|
1659
|
+
store.set({ surface: 'flow' })
|
|
1660
|
+
patchCard(card.id, { surface: 'flow' })
|
|
1661
|
+
} else {
|
|
1662
|
+
// Render proof: an adapter that accepts the open but never
|
|
1663
|
+
// mounts our body would leave an empty tab. Unless the card is
|
|
1664
|
+
// on screen shortly after, move it to the flow stack — the
|
|
1665
|
+
// card's data lives in the store, so the running stream simply
|
|
1666
|
+
// continues in the other surface.
|
|
1667
|
+
const proof = setTimeout(() => {
|
|
1668
|
+
pendingProofs.delete(proof)
|
|
1669
|
+
const live = store.state.cards.find(item => item.id === card.id)
|
|
1670
|
+
if (live === undefined || live.surface === 'flow') return
|
|
1671
|
+
if (renderedCardId === card.id) return
|
|
1672
|
+
store.set({ surface: 'flow', surfaceNote: t('surfaceUnproven') })
|
|
1673
|
+
patchCard(card.id, { surface: 'flow' })
|
|
1674
|
+
toast(t('surfaceUnproven'))
|
|
1675
|
+
}, 600)
|
|
1676
|
+
pendingProofs.add(proof)
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
void runSideCard(card.id)
|
|
1680
|
+
},
|
|
1681
|
+
copy(card, element) {
|
|
1682
|
+
void copyText(card.text, element).then((result) => {
|
|
1683
|
+
toast(result === 'copied' ? t('copied') : result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
|
|
1684
|
+
})
|
|
1685
|
+
},
|
|
1686
|
+
followUp(card, text) {
|
|
1687
|
+
const history = [...card.history, { question: card.question, answer: card.text }]
|
|
1688
|
+
const next = patchCard(card.id, {
|
|
1689
|
+
question: text,
|
|
1690
|
+
history,
|
|
1691
|
+
text: '',
|
|
1692
|
+
reasoning: '',
|
|
1693
|
+
status: 'pending',
|
|
1694
|
+
error: null,
|
|
1695
|
+
streaming: null,
|
|
1696
|
+
})
|
|
1697
|
+
if (next === undefined) return
|
|
1698
|
+
if (next.carrier === 'side') void runSideCard(card.id)
|
|
1699
|
+
else {
|
|
1700
|
+
void askInMainConversation({
|
|
1701
|
+
ctx,
|
|
1702
|
+
sessionId: currentSessionId(ctx),
|
|
1703
|
+
selection: next.selection,
|
|
1704
|
+
question: text,
|
|
1705
|
+
zone: next.zone,
|
|
1706
|
+
maxChars: store.state.config.maxChars,
|
|
1707
|
+
}).then(() => {
|
|
1708
|
+
patchCard(card.id, { status: 'done', text: t('mainSentHint') })
|
|
1709
|
+
}).catch((error) => {
|
|
1710
|
+
patchCard(card.id, {
|
|
1711
|
+
status: 'error',
|
|
1712
|
+
error: { code: error?.code ?? 'main-failed', message: String(error?.message ?? error), retryable: true },
|
|
1713
|
+
})
|
|
1714
|
+
})
|
|
1715
|
+
}
|
|
1716
|
+
},
|
|
1717
|
+
retry(card) {
|
|
1718
|
+
patchCard(card.id, { status: 'pending', text: '', reasoning: '', error: null, streaming: null })
|
|
1719
|
+
if (card.carrier === 'side') void runSideCard(card.id)
|
|
1720
|
+
},
|
|
1721
|
+
cancel(card) {
|
|
1722
|
+
const controller = controllers.get(card.id)
|
|
1723
|
+
if (controller !== undefined) controller.abort()
|
|
1724
|
+
patchCard(card.id, { status: 'stopped' })
|
|
1725
|
+
},
|
|
1726
|
+
async sendToMain(card) {
|
|
1727
|
+
try {
|
|
1728
|
+
const result = await askInMainConversation({
|
|
1729
|
+
ctx,
|
|
1730
|
+
sessionId: currentSessionId(ctx),
|
|
1731
|
+
selection: card.selection,
|
|
1732
|
+
question: card.question,
|
|
1733
|
+
zone: card.zone,
|
|
1734
|
+
maxChars: store.state.config.maxChars,
|
|
1735
|
+
})
|
|
1736
|
+
patchCard(card.id, {
|
|
1737
|
+
carrier: 'main',
|
|
1738
|
+
status: 'done',
|
|
1739
|
+
text: result.mode === 'draft' ? t('draftInsertedHint') : t('mainSentHint'),
|
|
1740
|
+
error: null,
|
|
1741
|
+
streaming: null,
|
|
1742
|
+
})
|
|
1743
|
+
toast(result.mode === 'draft' ? t('draftInserted') : t('mainSent'))
|
|
1744
|
+
} catch (error) {
|
|
1745
|
+
patchCard(card.id, {
|
|
1746
|
+
status: 'error',
|
|
1747
|
+
error: { code: error?.code ?? 'main-failed', message: String(error?.message ?? error), retryable: true },
|
|
1748
|
+
})
|
|
1749
|
+
}
|
|
1750
|
+
},
|
|
1751
|
+
close(card) {
|
|
1752
|
+
controllers.get(card.id)?.abort()
|
|
1753
|
+
controllers.delete(card.id)
|
|
1754
|
+
if (card.surface === 'better-sidebar') {
|
|
1755
|
+
try {
|
|
1756
|
+
surfaces.better.close(card)
|
|
1757
|
+
} catch { /* the host may already be gone */ }
|
|
1758
|
+
}
|
|
1759
|
+
store.set({ cards: store.state.cards.filter(item => item.id !== card.id) })
|
|
1760
|
+
},
|
|
1761
|
+
async saveConfig(patch) {
|
|
1762
|
+
const value = await postJson('config', patch)
|
|
1763
|
+
store.set({
|
|
1764
|
+
config: { ...CLIENT_DEFAULTS, ...(value.config ?? {}) },
|
|
1765
|
+
problems: value.problems ?? [],
|
|
1766
|
+
provenance: value.provenance ?? null,
|
|
1767
|
+
})
|
|
1768
|
+
return value
|
|
1769
|
+
},
|
|
1770
|
+
async resetConfig() {
|
|
1771
|
+
const value = await postJson('reset', {})
|
|
1772
|
+
store.set({
|
|
1773
|
+
config: { ...CLIENT_DEFAULTS, ...(value.config ?? {}) },
|
|
1774
|
+
problems: value.problems ?? [],
|
|
1775
|
+
provenance: value.provenance ?? null,
|
|
1776
|
+
})
|
|
1777
|
+
return value
|
|
1778
|
+
},
|
|
1779
|
+
async diagnose() {
|
|
1780
|
+
return await getState()
|
|
1781
|
+
},
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/** Stream one side answer into its card. */
|
|
1785
|
+
const runSideCard = async (cardId) => {
|
|
1786
|
+
const card = store.state.cards.find(item => item.id === cardId)
|
|
1787
|
+
if (card === undefined) return
|
|
1788
|
+
const controller = new AbortController()
|
|
1789
|
+
controllers.set(cardId, controller)
|
|
1790
|
+
patchCard(cardId, { status: 'streaming', text: '', reasoning: '', error: null })
|
|
1791
|
+
await streamAsk({
|
|
1792
|
+
id: cardId,
|
|
1793
|
+
question: card.question,
|
|
1794
|
+
selection: card.selected,
|
|
1795
|
+
zone: card.zone,
|
|
1796
|
+
cross: card.cross,
|
|
1797
|
+
carrier: 'side',
|
|
1798
|
+
sessionId: currentSessionId(ctx),
|
|
1799
|
+
history: card.history.slice(-6),
|
|
1800
|
+
}, {
|
|
1801
|
+
signal: controller.signal,
|
|
1802
|
+
onStart: (data) => {
|
|
1803
|
+
patchCard(cardId, {
|
|
1804
|
+
truncated: data?.truncated === true || card.truncated,
|
|
1805
|
+
droppedChars: data?.droppedChars ?? card.droppedChars,
|
|
1806
|
+
// Reported by the host so the card can be honest about the
|
|
1807
|
+
// read-only guard when the provider cannot take a tool filter.
|
|
1808
|
+
toolFilter: typeof data?.toolFilter === 'string' ? data.toolFilter : null,
|
|
1809
|
+
})
|
|
1810
|
+
},
|
|
1811
|
+
onDelta: data => appendCardText(cardId, String(data?.text ?? '')),
|
|
1812
|
+
onReasoning: data => appendCardReasoning(cardId, String(data?.text ?? '')),
|
|
1813
|
+
onDone: (data) => {
|
|
1814
|
+
const current = store.state.cards.find(item => item.id === cardId)
|
|
1815
|
+
patchCard(cardId, {
|
|
1816
|
+
status: data?.aborted === true ? 'stopped' : 'done',
|
|
1817
|
+
streaming: data?.streaming === true,
|
|
1818
|
+
text: typeof data?.text === 'string' && data.text !== '' ? data.text : (current?.text ?? ''),
|
|
1819
|
+
})
|
|
1820
|
+
controllers.delete(cardId)
|
|
1821
|
+
},
|
|
1822
|
+
onError: (error) => {
|
|
1823
|
+
patchCard(cardId, { status: 'error', error })
|
|
1824
|
+
controllers.delete(cardId)
|
|
1825
|
+
},
|
|
1826
|
+
})
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// ── selection listeners ──────────────────────────────────────────
|
|
1830
|
+
const evaluate = () => {
|
|
1831
|
+
pendingTimer = null
|
|
1832
|
+
const config = store.state.config
|
|
1833
|
+
const candidate = readSelection()
|
|
1834
|
+
// Zone anchors: the harness stamps every slot outlet with
|
|
1835
|
+
// `data-slot="<key>"`. A build that renders no such marker (or a
|
|
1836
|
+
// selection made outside the frame's own tree) leaves the slot path
|
|
1837
|
+
// empty, and then region filtering is impossible — reported once and
|
|
1838
|
+
// relaxed rather than silently swallowing every trigger.
|
|
1839
|
+
if (candidate !== null) {
|
|
1840
|
+
const anchored = candidate.slotPath.length > 0
|
|
1841
|
+
if (store.state.zoneAnchors !== anchored) {
|
|
1842
|
+
store.set({ zoneAnchors: anchored })
|
|
1843
|
+
if (!anchored) {
|
|
1844
|
+
console.info(`[${PLUGIN_ID}] 该版本没有 data-slot 锚点,区域过滤不可用,已按"全部区域"工作`)
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
const decision = shouldOffer(candidate, config, { anchors: store.state.zoneAnchors !== false })
|
|
1849
|
+
if (!decision.ok) {
|
|
1850
|
+
lastSignature = null
|
|
1851
|
+
if (store.state.trigger !== null || store.state.popover !== null) {
|
|
1852
|
+
store.set({ trigger: null })
|
|
1853
|
+
}
|
|
1854
|
+
return
|
|
1855
|
+
}
|
|
1856
|
+
const signature = selectionSignature(candidate)
|
|
1857
|
+
const now = Date.now()
|
|
1858
|
+
if (signature === lastSignature && now - lastAt < 400) return
|
|
1859
|
+
lastSignature = signature
|
|
1860
|
+
lastAt = now
|
|
1861
|
+
store.set({ trigger: candidate })
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
const schedule = () => {
|
|
1865
|
+
if (pendingTimer !== null) clearTimeout(pendingTimer)
|
|
1866
|
+
pendingTimer = setTimeout(evaluate, 120)
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
const onDocumentDown = (event) => {
|
|
1870
|
+
const element = elementOf(event.target)
|
|
1871
|
+
if (element !== null && element.closest('[data-dsa-root]') !== null) return
|
|
1872
|
+
if (store.state.popover !== null) store.set({ popover: null })
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
const onKeyDown = (event) => {
|
|
1876
|
+
const config = store.state.config
|
|
1877
|
+
const facts = parseShortcut(config.shortcut)
|
|
1878
|
+
if (matchesShortcut(event, facts)) {
|
|
1879
|
+
const candidate = readSelection() ?? store.state.trigger
|
|
1880
|
+
if (candidate !== null) {
|
|
1881
|
+
event.preventDefault()
|
|
1882
|
+
actions.openPopover(candidate)
|
|
1883
|
+
return
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
if (event.key === 'Escape') {
|
|
1887
|
+
if (store.state.popover !== null) store.set({ popover: null })
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
const onScroll = () => {
|
|
1892
|
+
if (store.state.popover === null && store.state.trigger !== null) store.set({ trigger: null })
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
if (typeof document !== 'undefined') {
|
|
1896
|
+
document.addEventListener('selectionchange', schedule)
|
|
1897
|
+
document.addEventListener('pointerup', schedule, true)
|
|
1898
|
+
document.addEventListener('keyup', schedule, true)
|
|
1899
|
+
document.addEventListener('mousedown', onDocumentDown, true)
|
|
1900
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
1901
|
+
window.addEventListener('scroll', onScroll, true)
|
|
1902
|
+
window.addEventListener('resize', onScroll, true)
|
|
1903
|
+
disposers.push(() => {
|
|
1904
|
+
document.removeEventListener('selectionchange', schedule)
|
|
1905
|
+
document.removeEventListener('pointerup', schedule, true)
|
|
1906
|
+
document.removeEventListener('keyup', schedule, true)
|
|
1907
|
+
document.removeEventListener('mousedown', onDocumentDown, true)
|
|
1908
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
1909
|
+
window.removeEventListener('scroll', onScroll, true)
|
|
1910
|
+
window.removeEventListener('resize', onScroll, true)
|
|
1911
|
+
if (pendingTimer !== null) clearTimeout(pendingTimer)
|
|
1912
|
+
})
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// ── locale ───────────────────────────────────────────────────────
|
|
1916
|
+
try {
|
|
1917
|
+
const locale = ctx.get('locale')
|
|
1918
|
+
if (locale !== undefined && typeof locale.subscribe === 'function') {
|
|
1919
|
+
const read = () => {
|
|
1920
|
+
let id = null
|
|
1921
|
+
try {
|
|
1922
|
+
const snapshot = typeof locale.getSnapshot === 'function' ? locale.getSnapshot() : null
|
|
1923
|
+
id = snapshot?.locale ?? snapshot?.id ?? (typeof locale.getLocale === 'function' ? locale.getLocale()?.locale : null)
|
|
1924
|
+
} catch { /* keep the previous language */ }
|
|
1925
|
+
if (typeof id === 'string' && id !== '') {
|
|
1926
|
+
const next = id.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
1927
|
+
if (next !== lang) {
|
|
1928
|
+
lang = next
|
|
1929
|
+
store.set({})
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
read()
|
|
1934
|
+
const off = locale.subscribe(read)
|
|
1935
|
+
if (typeof off === 'function') disposers.push(off)
|
|
1936
|
+
}
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
console.error(`[${PLUGIN_ID}] locale probe failed:`, error)
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// ── slot registrations ───────────────────────────────────────────
|
|
1942
|
+
//
|
|
1943
|
+
// A slot key is a version-scoped contract: `shell.overlay` has been the
|
|
1944
|
+
// frame-wide floating layer for the whole supported range, but nothing
|
|
1945
|
+
// guarantees it in a build we have not seen. Each registration below
|
|
1946
|
+
// therefore walks a LADDER of equivalent slots and keeps the best one
|
|
1947
|
+
// that is actually declared: a later rung is dropped the moment an
|
|
1948
|
+
// earlier one goes live, so the UI never doubles up.
|
|
1949
|
+
//
|
|
1950
|
+
// Every rung is a LIST slot on purpose — registering into a `single`
|
|
1951
|
+
// slot would REPLACE shipped UI (the catalog marks those
|
|
1952
|
+
// `shadows-shipped-ui`).
|
|
1953
|
+
const SLOT_PROBE_MS = 1500
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Register one entry into the first ladder rung that accepts it.
|
|
1957
|
+
* @param {string[]} candidates - slot keys, best first.
|
|
1958
|
+
* @param {(slotKey: string) => {options: object, component: Function}} build
|
|
1959
|
+
* @param {(from: string, to: string) => void} [onFallback] - reported when
|
|
1960
|
+
* a rung is skipped (the settings page shows the outcome).
|
|
1961
|
+
* @returns {() => void} disposer for every rung and timer.
|
|
1962
|
+
*/
|
|
1963
|
+
const firstLiveSlot = (candidates, build, onFallback) => {
|
|
1964
|
+
const injected = []
|
|
1965
|
+
const timers = new Set()
|
|
1966
|
+
const registrations = new Map()
|
|
1967
|
+
let activeIndex = Number.POSITIVE_INFINITY
|
|
1968
|
+
let disposed = false
|
|
1969
|
+
|
|
1970
|
+
const takeOver = (index, disposeRegistration) => {
|
|
1971
|
+
if (disposed || index >= activeIndex) {
|
|
1972
|
+
// A better rung is already live: this one is surplus.
|
|
1973
|
+
if (index >= activeIndex) {
|
|
1974
|
+
try {
|
|
1975
|
+
disposeRegistration()
|
|
1976
|
+
} catch { /* already gone */ }
|
|
1977
|
+
}
|
|
1978
|
+
return
|
|
1979
|
+
}
|
|
1980
|
+
// This rung is better than whatever a previous fallback installed.
|
|
1981
|
+
for (const [otherIndex, entry] of registrations) {
|
|
1982
|
+
if (otherIndex <= index) continue
|
|
1983
|
+
try {
|
|
1984
|
+
entry.dispose()
|
|
1985
|
+
} catch { /* already gone */ }
|
|
1986
|
+
registrations.delete(otherIndex)
|
|
1987
|
+
}
|
|
1988
|
+
activeIndex = index
|
|
1989
|
+
registrations.set(index, { dispose: disposeRegistration })
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
const attempt = (index) => {
|
|
1993
|
+
if (disposed || index >= candidates.length) return
|
|
1994
|
+
const key = candidates[index]
|
|
1995
|
+
try {
|
|
1996
|
+
injected.push(ctx.slots.inject(key, () => {
|
|
1997
|
+
const built = build(key)
|
|
1998
|
+
const disposeRegistration = ctx.slots.register(built.options, built.component)
|
|
1999
|
+
takeOver(index, disposeRegistration)
|
|
2000
|
+
return disposeRegistration
|
|
2001
|
+
}))
|
|
2002
|
+
} catch (error) {
|
|
2003
|
+
console.error(`[${PLUGIN_ID}] slot inject failed for "${key}":`, error)
|
|
2004
|
+
}
|
|
2005
|
+
const timer = setTimeout(() => {
|
|
2006
|
+
timers.delete(timer)
|
|
2007
|
+
if (disposed) return
|
|
2008
|
+
const next = candidates[index + 1]
|
|
2009
|
+
if (next === undefined) return
|
|
2010
|
+
if (activeIndex <= index) return
|
|
2011
|
+
onFallback?.(key, next)
|
|
2012
|
+
attempt(index + 1)
|
|
2013
|
+
}, SLOT_PROBE_MS)
|
|
2014
|
+
timers.add(timer)
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
attempt(0)
|
|
2018
|
+
return () => {
|
|
2019
|
+
disposed = true
|
|
2020
|
+
for (const timer of timers) clearTimeout(timer)
|
|
2021
|
+
timers.clear()
|
|
2022
|
+
for (const entry of registrations.values()) {
|
|
2023
|
+
try {
|
|
2024
|
+
entry.dispose()
|
|
2025
|
+
} catch { /* already gone */ }
|
|
2026
|
+
}
|
|
2027
|
+
registrations.clear()
|
|
2028
|
+
for (const dispose of injected) {
|
|
2029
|
+
try {
|
|
2030
|
+
dispose()
|
|
2031
|
+
} catch { /* already gone */ }
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/** Record which rung a registration actually landed on. */
|
|
2037
|
+
const noteSlot = (name, key) => {
|
|
2038
|
+
const slots = { ...(store.state.slots ?? {}), [name]: key }
|
|
2039
|
+
store.set({ slots })
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
disposers.push(firstLiveSlot(
|
|
2043
|
+
['shell.overlay', 'conversation.input.dock', 'conversation.composer.dock'],
|
|
2044
|
+
slotKey => ({
|
|
2045
|
+
options: { name: slotKey, id: IDS.overlay, order: 45, label: () => t('triggerLabel') },
|
|
2046
|
+
component: () => h(OverlayRoot, { actions }),
|
|
2047
|
+
}),
|
|
2048
|
+
(from, to) => {
|
|
2049
|
+
console.info(`[${PLUGIN_ID}] ${from} 不可用,浮层改挂 ${to}`)
|
|
2050
|
+
noteSlot('overlay', to)
|
|
2051
|
+
},
|
|
2052
|
+
))
|
|
2053
|
+
noteSlot('overlay', 'shell.overlay')
|
|
2054
|
+
|
|
2055
|
+
disposers.push(firstLiveSlot(
|
|
2056
|
+
['settings.section', 'settings.plugins.tab'],
|
|
2057
|
+
slotKey => ({
|
|
2058
|
+
options: { name: slotKey, id: IDS.settings, order: 60, label: () => t('settingsTitle') },
|
|
2059
|
+
component: () => h(SettingsSection, { actions }),
|
|
2060
|
+
}),
|
|
2061
|
+
(from, to) => {
|
|
2062
|
+
console.info(`[${PLUGIN_ID}] ${from} 不可用,设置页改挂 ${to}`)
|
|
2063
|
+
noteSlot('settings', to)
|
|
2064
|
+
},
|
|
2065
|
+
))
|
|
2066
|
+
noteSlot('settings', 'settings.section')
|
|
2067
|
+
|
|
2068
|
+
disposers.push(firstLiveSlot(
|
|
2069
|
+
['conversation.input.right', 'conversation.input.left', 'conversation.composer.dock', 'conversation.input.dock'],
|
|
2070
|
+
slotKey => ({
|
|
2071
|
+
options: { name: slotKey, id: IDS.sessionProbe, order: 90 },
|
|
2072
|
+
component: SessionProbe,
|
|
2073
|
+
}),
|
|
2074
|
+
(from, to) => {
|
|
2075
|
+
console.info(`[${PLUGIN_ID}] ${from} 不可用,会话 id 采集改挂 ${to}`)
|
|
2076
|
+
noteSlot('sessionProbe', to)
|
|
2077
|
+
},
|
|
2078
|
+
))
|
|
2079
|
+
noteSlot('sessionProbe', 'conversation.input.right')
|
|
2080
|
+
|
|
2081
|
+
// ── side-card surfaces ───────────────────────────────────────────
|
|
2082
|
+
// The native right rail: its registry accepts tab types and its
|
|
2083
|
+
// controller opens them by kind. Every call is defensive — an older
|
|
2084
|
+
// harness simply leaves `native` unavailable and `auto` falls through.
|
|
2085
|
+
//
|
|
2086
|
+
// PLACEHOLDER: native-rightbar — the call shapes below were taken from
|
|
2087
|
+
// the native surface's own consumer (`sidebarRightTabs.register` with a
|
|
2088
|
+
// `guide[]` entry, `sidebarRight.openTab(kind, { params })`, and the
|
|
2089
|
+
// keyed `sidebar.right.pane.tab` slot with `inject: sessionId => …`).
|
|
2090
|
+
// They are NOT verified on this machine (no right-rail composition),
|
|
2091
|
+
// so the whole block is capability-probed and try/caught: a mismatch
|
|
2092
|
+
// degrades to the next surface and logs, never breaks the plugin.
|
|
2093
|
+
// See README §6 for the replacement checklist.
|
|
2094
|
+
try {
|
|
2095
|
+
ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => {
|
|
2096
|
+
try {
|
|
2097
|
+
const registry = injected.get('sidebarRightTabs')
|
|
2098
|
+
const controller = injected.get('sidebarRight')
|
|
2099
|
+
if (registry === null || registry === undefined) return
|
|
2100
|
+
surfaces.native.registry = registry
|
|
2101
|
+
surfaces.native.controller = controller ?? null
|
|
2102
|
+
if (typeof registry.register === 'function') {
|
|
2103
|
+
const off = registry.register({
|
|
2104
|
+
id: CARD_KIND,
|
|
2105
|
+
kind: CARD_KIND,
|
|
2106
|
+
title: () => t('answerTitle'),
|
|
2107
|
+
guide: [{
|
|
2108
|
+
id: CARD_KIND,
|
|
2109
|
+
order: 120,
|
|
2110
|
+
title: () => t('settingsTitle'),
|
|
2111
|
+
description: () => t('settingsDesc'),
|
|
2112
|
+
}],
|
|
2113
|
+
})
|
|
2114
|
+
surfaces.native.dispose = typeof off === 'function' ? off : null
|
|
2115
|
+
}
|
|
2116
|
+
const offBody = ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
|
2117
|
+
name: 'sidebar.right.pane.tab',
|
|
2118
|
+
key: CARD_KIND,
|
|
2119
|
+
inject: sessionId => ({ sessionId }),
|
|
2120
|
+
}, (props) => h(CardHost, { props, actions })))
|
|
2121
|
+
const offTitle = ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
|
2122
|
+
name: 'sidebar.right.pane.tab.title',
|
|
2123
|
+
key: CARD_KIND,
|
|
2124
|
+
}, () => h('span', null, t('answerTitle'))))
|
|
2125
|
+
disposers.push(() => {
|
|
2126
|
+
try { offBody() } catch { /* already disposed */ }
|
|
2127
|
+
try { offTitle() } catch { /* already disposed */ }
|
|
2128
|
+
try { surfaces.native.dispose?.() } catch { /* already disposed */ }
|
|
2129
|
+
})
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
surfaces.native.error = String(error?.message ?? error)
|
|
2132
|
+
console.error(`[${PLUGIN_ID}] native right-rail adapter unavailable:`, error)
|
|
2133
|
+
}
|
|
2134
|
+
})
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
surfaces.native.error = String(error?.message ?? error)
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// The dsh-better-sidebar plugin, when installed: its client service
|
|
2140
|
+
// registers a tab type and opens one per card. `features.tabMeta`
|
|
2141
|
+
// gates the meta-carried card id, so an older version of that plugin
|
|
2142
|
+
// degrades to the built-in flow card instead of showing an empty tab.
|
|
2143
|
+
try {
|
|
2144
|
+
ctx.inject(['betterSidebar'], (injected) => {
|
|
2145
|
+
try {
|
|
2146
|
+
const service = injected.get('betterSidebar')
|
|
2147
|
+
if (service === null || service === undefined) return
|
|
2148
|
+
const features = Array.isArray(service.features) ? service.features : []
|
|
2149
|
+
surfaces.better.features = features
|
|
2150
|
+
if (typeof service.registerTab !== 'function') return
|
|
2151
|
+
const off = service.registerTab({
|
|
2152
|
+
id: CARD_KIND,
|
|
2153
|
+
title: () => t('answerTitle'),
|
|
2154
|
+
description: () => t('settingsDesc'),
|
|
2155
|
+
order: 120,
|
|
2156
|
+
dedupeKey: tab => (tab?.meta?.cardId !== undefined ? String(tab.meta.cardId) : undefined),
|
|
2157
|
+
component: props => h(CardHost, {
|
|
2158
|
+
props: { tab: props?.tab, sessionId: props?.scope?.sessionId },
|
|
2159
|
+
actions,
|
|
2160
|
+
}),
|
|
2161
|
+
})
|
|
2162
|
+
surfaces.better.service = service
|
|
2163
|
+
surfaces.better.dispose = typeof off === 'function' ? off : null
|
|
2164
|
+
disposers.push(() => {
|
|
2165
|
+
try { surfaces.better.dispose?.() } catch { /* already disposed */ }
|
|
2166
|
+
})
|
|
2167
|
+
} catch (error) {
|
|
2168
|
+
surfaces.better.error = String(error?.message ?? error)
|
|
2169
|
+
console.error(`[${PLUGIN_ID}] better-sidebar adapter unavailable:`, error)
|
|
2170
|
+
}
|
|
2171
|
+
})
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
surfaces.better.error = String(error?.message ?? error)
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/** Embedded card body used by both external hosts. */
|
|
2177
|
+
function CardHost({ props }) {
|
|
2178
|
+
const state = useStoreState()
|
|
2179
|
+
const cardId = props?.tab?.meta?.cardId
|
|
2180
|
+
const card = state.cards.find(item => item.id === cardId)
|
|
2181
|
+
// The proof hook MUST run before any early return (hook order), and
|
|
2182
|
+
// it is what lets `submit` detect a host that never rendered us.
|
|
2183
|
+
React.useEffect(() => {
|
|
2184
|
+
if (typeof cardId !== 'string' || cardId === '') return undefined
|
|
2185
|
+
renderedCardId = cardId
|
|
2186
|
+
return () => {
|
|
2187
|
+
if (renderedCardId === cardId) renderedCardId = null
|
|
2188
|
+
}
|
|
2189
|
+
}, [cardId])
|
|
2190
|
+
if (card === undefined) {
|
|
2191
|
+
return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
|
|
2192
|
+
h(Styles, null),
|
|
2193
|
+
h('div', { className: 'dsa-muted' }, t('answerEmpty')))
|
|
2194
|
+
}
|
|
2195
|
+
return h('div', { className: 'dsa-settings', 'data-dsa-root': '', style: { padding: '10px' } },
|
|
2196
|
+
h(Styles, null),
|
|
2197
|
+
h(AnswerCard, {
|
|
2198
|
+
card,
|
|
2199
|
+
surfaceLabel: state.surface === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
|
|
2200
|
+
onCopy: element => actions.copy(card, element),
|
|
2201
|
+
onFollowUp: text => actions.followUp(card, text),
|
|
2202
|
+
onRetry: () => actions.retry(card),
|
|
2203
|
+
onCancel: () => actions.cancel(card),
|
|
2204
|
+
onSendToMain: () => { void actions.sendToMain(card) },
|
|
2205
|
+
onClose: () => actions.close(card),
|
|
2206
|
+
}))
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// ── boot ─────────────────────────────────────────────────────────
|
|
2210
|
+
void refreshState().catch(() => { /* the settings page shows the failure */ })
|
|
2211
|
+
disposers.push(() => {
|
|
2212
|
+
for (const controller of controllers.values()) controller.abort()
|
|
2213
|
+
controllers.clear()
|
|
2214
|
+
for (const proof of pendingProofs) clearTimeout(proof)
|
|
2215
|
+
pendingProofs.clear()
|
|
2216
|
+
if (toastTimer !== null) clearTimeout(toastTimer)
|
|
2217
|
+
store.set({
|
|
2218
|
+
trigger: null,
|
|
2219
|
+
popover: null,
|
|
2220
|
+
cards: [],
|
|
2221
|
+
toast: null,
|
|
2222
|
+
sessionId: null,
|
|
2223
|
+
surface: 'flow',
|
|
2224
|
+
})
|
|
2225
|
+
})
|
|
2226
|
+
|
|
2227
|
+
ctx.effect(() => () => {
|
|
2228
|
+
for (const dispose of disposers.splice(0)) {
|
|
2229
|
+
try {
|
|
2230
|
+
dispose()
|
|
2231
|
+
} catch (error) {
|
|
2232
|
+
console.error(`[${PLUGIN_ID}] cleanup failed:`, error)
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
}, 'sidecard-ask: client activation')
|
|
2236
|
+
},
|
|
2237
|
+
|
|
2238
|
+
/**
|
|
2239
|
+
* Test seam: pure helpers, frozen. The DSH client runtime only reads
|
|
2240
|
+
* `inject`/`apply`; `test/*.mjs` loads this file with a synthetic
|
|
2241
|
+
* `window` and exercises these without a browser.
|
|
2242
|
+
*/
|
|
2243
|
+
api: Object.freeze({
|
|
2244
|
+
version: '1.1.0',
|
|
2245
|
+
/** Read-only state accessor for diagnostics and the test harness. */
|
|
2246
|
+
snapshot: () => store.state,
|
|
2247
|
+
pure: Object.freeze({
|
|
2248
|
+
CLIENT_DEFAULTS,
|
|
2249
|
+
IDS,
|
|
2250
|
+
CARD_KIND,
|
|
2251
|
+
parseSseBlock,
|
|
2252
|
+
truncateSelection,
|
|
2253
|
+
parseShortcut,
|
|
2254
|
+
matchesShortcut,
|
|
2255
|
+
classifyZone,
|
|
2256
|
+
slotPathOf,
|
|
2257
|
+
shouldOffer,
|
|
2258
|
+
selectionSignature,
|
|
2259
|
+
composeMainPrompt,
|
|
2260
|
+
askInMainConversation,
|
|
2261
|
+
renderRichText,
|
|
2262
|
+
pickSurface,
|
|
2263
|
+
dict: DICT,
|
|
2264
|
+
}),
|
|
2265
|
+
}),
|
|
2266
|
+
}
|
|
2267
|
+
},
|
|
2268
|
+
})
|