dsh-quote-annotate-pro 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,1275 @@
1
+ // dsh-quote-annotate-pro —— Client 半区(浏览器)
2
+ // Fork of dsh-quote-annotate v0.1.1 (MIT, wangwei-wade) —— 增强:精确锚点 / 可观测性
3
+ // 上游 bundle 形态保留:window.__ModuleLoader__ 工厂式 CJS
4
+ window.__ModuleLoader__.load({
5
+ id: 'dsh-quote-annotate-pro',
6
+ factory: (require) => {
7
+ var module = { exports: {} }
8
+ var exports = module.exports
9
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
10
+ let React = require('react')
11
+
12
+ // ============ 诊断通道(开发期;探测不到本地接收端就本次运行静默) ============
13
+ // 想彻底关闭:把 DIAG_ENABLED 改成 false(或注释掉 sendReport 的 fetch)。
14
+ const DIAG_ENABLED = true
15
+ const DIAG_URL = 'http://127.0.0.1:47821/dsh-quote-pro'
16
+ let diagReady = null // null=探测中 true=可用 false=不可用
17
+ const diagQueue = []
18
+ function sendReport(text) {
19
+ try {
20
+ if (typeof fetch === 'function') {
21
+ Promise.resolve(fetch(DIAG_URL, { method: 'POST', body: text, keepalive: true })).catch(() => {})
22
+ }
23
+ } catch (e) { /* ignore */ }
24
+ }
25
+ function report(tag, detail) {
26
+ try {
27
+ let text
28
+ if (detail === undefined) text = tag
29
+ else if (typeof detail === 'string') text = tag + ' ' + detail
30
+ else {
31
+ try { text = tag + ' ' + JSON.stringify(detail) } catch (e) { text = tag + ' <unserializable>' }
32
+ }
33
+ console.log('[quote-pro]', text)
34
+ if (!DIAG_ENABLED || diagReady === false) return
35
+ if (diagReady === null) {
36
+ diagQueue.push(text)
37
+ if (diagQueue.length > 60) diagQueue.shift()
38
+ return
39
+ }
40
+ sendReport(text)
41
+ } catch (e) { /* diagnostics must never throw */ }
42
+ }
43
+ if (DIAG_ENABLED && typeof fetch === 'function') {
44
+ try {
45
+ fetch(DIAG_URL + '/ping', { method: 'POST', body: 'ping' }).then(() => {
46
+ diagReady = true
47
+ const pending = diagQueue.splice(0)
48
+ for (const text of pending) sendReport(text)
49
+ }).catch(() => { diagReady = false })
50
+ } catch (e) { diagReady = false }
51
+ } else {
52
+ diagReady = false
53
+ }
54
+ report('module:materialized', { id: 'dsh-quote-annotate-pro' })
55
+
56
+ // ============ 引用锚点文案:让模型知道"这指的是哪一处" ============
57
+ // role/kind 取自 DOM 的 data-chat-flow-kind,turn 取自 data-chat-turn(与 TurnLocation.turn 同源,0 基)
58
+ function roleText(role, kind) {
59
+ const k = role || kind
60
+ if (k === 'user') return '你的消息'
61
+ if (k === 'assistant-step' || k === 'assistant') return 'DSH 回复'
62
+ if (k === 'tool-call') return '工具调用'
63
+ if (k === 'steering') return '插话'
64
+ if (k === 'turn-tail') return '回合结算'
65
+ if (k === 'question-reply') return '你的答复'
66
+ if (k === 'command' || k === 'command-input') return '命令'
67
+ return k ? '消息(' + k + ')' : ''
68
+ }
69
+ function anchorLabelOf(item) {
70
+ const parts = []
71
+ if (item && typeof item.turn === 'number' && Number.isFinite(item.turn)) parts.push('第' + (item.turn + 1) + '轮')
72
+ const role = roleText(item && item.role, item && item.kind)
73
+ if (role !== '') parts.push(role)
74
+ return parts.join(' · ')
75
+ }
76
+ /** 引用块首行:模型据此确定"哪一轮、谁说的",多轮对话里不再靠猜。 */
77
+ function buildQuoteHeader(item) {
78
+ const label = anchorLabelOf(item)
79
+ return label === '' ? '' : '【引用 · ' + label + '】'
80
+ }
81
+
82
+ // ============ 锚点持久化:刷新后 chip 仍能跳回原文 ============
83
+ // key: dsh-quote-pro:ref:<sessionId> -> { <ref>: { anchorKey, body, label, at } }
84
+ const STORE_PREFIX = 'dsh-quote-pro:ref:'
85
+ function loadRefMap(sessionId) {
86
+ try {
87
+ const raw = localStorage.getItem(STORE_PREFIX + sessionId)
88
+ if (!raw) return null
89
+ const parsed = JSON.parse(raw)
90
+ return parsed && typeof parsed === 'object' ? parsed : null
91
+ } catch (e) { return null }
92
+ }
93
+ function saveRef(sessionId, ref, info) {
94
+ try {
95
+ const map = loadRefMap(sessionId) || {}
96
+ map[ref] = { anchorKey: info.anchorKey || null, body: info.body || '', label: info.label || '', at: Date.now() }
97
+ const keys = Object.keys(map)
98
+ if (keys.length > 60) {
99
+ keys.sort((a, b) => (map[a].at || 0) - (map[b].at || 0))
100
+ for (const k of keys.slice(0, keys.length - 60)) delete map[k]
101
+ }
102
+ localStorage.setItem(STORE_PREFIX + sessionId, JSON.stringify(map))
103
+ } catch (e) { /* 存储不可用不影响主流程 */ }
104
+ }
105
+ function hydrateRefs(sessionId) {
106
+ const map = loadRefMap(sessionId)
107
+ if (!map) return 0
108
+ let n = 0
109
+ for (const ref of Object.keys(map)) {
110
+ if (refTargets.has(ref)) continue
111
+ const info = map[ref] || {}
112
+ refTargets.set(ref, { sessionId, anchorKey: info.anchorKey || null, body: info.body || '', label: info.label || '', restored: true })
113
+ n += 1
114
+ }
115
+ if (n > 0) report('refs:hydrated', { sessionId: String(sessionId).slice(0, 8), count: n })
116
+ return n
117
+ }
118
+ // 每次页面运行使用独立前缀,避免刷新后新 ref 与历史 ref 撞号
119
+ const REF_RUN = Math.random().toString(36).slice(2, 6)
120
+
121
+
122
+ // ============ chip 插入:走官方 session 事件(本版本 shell.insertReference 由它驱动) ============
123
+ // 注意:这里必须直接对传入的 Cordis 上下文调用 .emit —— 写成 ctx.ctx 会触发
124
+ // 「cannot get property "ctx" without inject」守卫错误(本插件未声明该 inject)。
125
+ function emitInsertReference(emitCtx, input, before, reference) {
126
+ if (!emitCtx || typeof emitCtx.emit !== 'function') return false
127
+ if (!before || before.draftRev === undefined) return false
128
+ try {
129
+ emitCtx.emit('slash/input-insert-reference', {
130
+ reference,
131
+ span: { draftRev: before.draftRev, start: (before.draft || '').length, end: (before.draft || '').length },
132
+ })
133
+ } catch (e) {
134
+ report('emit:throw', String((e && e.stack) || (e && e.message) || e))
135
+ return false
136
+ }
137
+ const after = input.state.getSnapshot()
138
+ return after.draftRev !== before.draftRev
139
+ }
140
+
141
+ // ============ 模块级状态 ============
142
+ let rootCtx = null
143
+ let currentSessionId = null
144
+ let refSeq = 0
145
+ let pipelineAvailable = null // null=未知 false=不可用 true=可用
146
+ // ref -> { sessionId, anchorKey, body, label } 用于点击 chip 跳回原文 / 悬停显示原文
147
+ const refTargets = new Map()
148
+ // ref -> { body, clipboardText } 提交时经 codec 序列化
149
+ const refCodecs = new Map()
150
+
151
+ // 页面内反馈 toast(点击 chip 时显示结果)
152
+ const toastStore = {
153
+ text: '',
154
+ pos: null, // { left, top }
155
+ ok: false,
156
+ listeners: new Set(),
157
+ subscribe(fn) {
158
+ this.listeners.add(fn)
159
+ return () => { this.listeners.delete(fn) }
160
+ },
161
+ emit() {
162
+ this.listeners.forEach((fn) => fn())
163
+ },
164
+ show(text, pos, ok) {
165
+ this.text = text
166
+ this.pos = pos
167
+ this.ok = !!ok
168
+ this.emit()
169
+ },
170
+ hide() {
171
+ if (!this.text) return
172
+ this.text = ''
173
+ this.pos = null
174
+ this.emit()
175
+ },
176
+ }
177
+
178
+ function useToast() {
179
+ const [, force] = React.useState(0)
180
+ React.useEffect(() => toastStore.subscribe(() => force((v) => v + 1)), [])
181
+ return toastStore
182
+ }
183
+
184
+ // ============ 选区浮动小气泡 store ============
185
+ const selectionStore = {
186
+ visible: false,
187
+ text: '',
188
+ rect: null,
189
+ anchorKey: null,
190
+ listeners: new Set(),
191
+ subscribe(fn) {
192
+ this.listeners.add(fn)
193
+ return () => { this.listeners.delete(fn) }
194
+ },
195
+ emit() {
196
+ this.listeners.forEach((fn) => fn())
197
+ },
198
+ show(info) {
199
+ this.visible = true
200
+ this.text = info.text
201
+ this.rect = info.rect
202
+ this.anchorKey = info.anchorKey || null
203
+ // 位置信息必须一起存:否则 stage() 读到的 turn/kind 恒为 null,引用块首行就空了
204
+ this.turn = typeof info.turn === 'number' && Number.isFinite(info.turn) ? info.turn : null
205
+ this.kind = info.kind || null
206
+ this.emit()
207
+ },
208
+ hide() {
209
+ if (!this.visible) return
210
+ this.visible = false
211
+ this.emit()
212
+ },
213
+ }
214
+
215
+ function useSelection() {
216
+ const [, force] = React.useState(0)
217
+ React.useEffect(() => selectionStore.subscribe(() => force((v) => v + 1)), [])
218
+ return selectionStore
219
+ }
220
+
221
+ // ============ 悬浮批注编辑框 store(单条,锚定在对应文字附近) ============
222
+ const editorStore = {
223
+ item: null, // { sessionId, label, text, anchorKey }
224
+ rect: null,
225
+ listeners: new Set(),
226
+ subscribe(fn) {
227
+ this.listeners.add(fn)
228
+ return () => { this.listeners.delete(fn) }
229
+ },
230
+ emit() {
231
+ this.listeners.forEach((fn) => fn())
232
+ },
233
+ open(item, rect) {
234
+ this.item = item
235
+ this.rect = rect
236
+ this.emit()
237
+ },
238
+ close() {
239
+ if (!this.item) return
240
+ this.item = null
241
+ this.rect = null
242
+ this.emit()
243
+ },
244
+ }
245
+
246
+ function useEditor() {
247
+ const [, force] = React.useState(0)
248
+ React.useEffect(() => editorStore.subscribe(() => force((v) => v + 1)), [])
249
+ return editorStore
250
+ }
251
+
252
+ // ============ 引用悬停提示 store ============
253
+ const tooltipStore = {
254
+ visible: false,
255
+ text: '',
256
+ label: '',
257
+ rect: null,
258
+ listeners: new Set(),
259
+ subscribe(fn) {
260
+ this.listeners.add(fn)
261
+ return () => { this.listeners.delete(fn) }
262
+ },
263
+ emit() {
264
+ this.listeners.forEach((fn) => fn())
265
+ },
266
+ show(info) {
267
+ this.visible = true
268
+ this.text = info.text
269
+ this.label = info.label
270
+ this.rect = info.rect
271
+ this.emit()
272
+ },
273
+ hide() {
274
+ if (!this.visible) return
275
+ this.visible = false
276
+ this.emit()
277
+ },
278
+ }
279
+
280
+ function useTooltip() {
281
+ const [, force] = React.useState(0)
282
+ React.useEffect(() => tooltipStore.subscribe(() => force((v) => v + 1)), [])
283
+ return tooltipStore
284
+ }
285
+
286
+ // ============ 样式注入(静态包环境:直接操作 document) ============
287
+ function insertStyleSheet(css) {
288
+ const tag = document.createElement('style')
289
+ tag.textContent = css
290
+ document.head.appendChild(tag)
291
+ return () => tag.remove()
292
+ }
293
+
294
+ // ============ 文本工具 ============
295
+ function textBlocksToText(blocks, kindField, typeValue) {
296
+ const parts = []
297
+ if (Array.isArray(blocks)) {
298
+ for (const b of blocks) {
299
+ if (b && b[kindField] === typeValue && typeof b.text === 'string' && b.text.trim() !== '') {
300
+ parts.push(b.text.trim())
301
+ }
302
+ }
303
+ }
304
+ return parts.join('\n\n')
305
+ }
306
+
307
+ function clipText(text, max) {
308
+ if (text.length <= max) return text
309
+ return text.slice(0, max) + '\n…(已截断)'
310
+ }
311
+
312
+ const MAX_QUOTE = 1500
313
+ const MAX_SELECTION = 2000
314
+
315
+ // 从 Chat 快照中提取某一轮的用户提问文本与最终 AI 回答文本。
316
+ // 本版本(0.2.0-rc.1)的真实形状:
317
+ // snapshot.nodes 是 Map(不是数组)、身份字段是 anchorSeq(不是 seq)、
318
+ // user 文本在 node.data.content[{type:'text'}]、assistant-step 在 node.data.blocks[{kind:'text'}]。
319
+ function extractTurnTexts(chat, turnLoc) {
320
+ const empty = { userText: '', assistantText: '' }
321
+ try {
322
+ if (!chat || !turnLoc || typeof turnLoc.turn !== 'number') return empty
323
+ const turn = turnLoc.turn
324
+ const endSeq = turnLoc.end ? turnLoc.end.seq : Number.POSITIVE_INFINITY
325
+ let prevEnd = 0
326
+ if (chat.turnEnds && typeof chat.turnEnds.forEach === 'function') {
327
+ chat.turnEnds.forEach((seq, t) => {
328
+ if (typeof t === 'number' && typeof seq === 'number' && t < turn && seq > prevEnd) prevEnd = seq
329
+ })
330
+ }
331
+ const nodes = chat.nodes
332
+ if (!nodes) return empty
333
+ const userParts = []
334
+ let assistantText = ''
335
+ let assistantSeq = -1
336
+ const visit = (node) => {
337
+ if (!node || typeof node.anchorSeq !== 'number') return
338
+ if (node.anchorSeq <= prevEnd || node.anchorSeq > endSeq) return
339
+ const data = node.data || {}
340
+ if (node.kind === 'user' || node.kind === 'steering') {
341
+ const text = textBlocksToText(data.content, 'type', 'text')
342
+ if (text !== '') userParts.push(text)
343
+ } else if (node.kind === 'assistant-step') {
344
+ if (typeof data.turn === 'number' && data.turn !== turn) return
345
+ if (node.anchorSeq > assistantSeq) {
346
+ const text = textBlocksToText(data.blocks, 'kind', 'text')
347
+ if (text !== '') {
348
+ assistantSeq = node.anchorSeq
349
+ assistantText = text
350
+ }
351
+ }
352
+ }
353
+ }
354
+ const keys = chat.locations && typeof chat.locations.getTurn === 'function' ? chat.locations.getTurn(turn) : null
355
+ if (Array.isArray(keys) && typeof nodes.get === 'function') {
356
+ for (const key of keys) visit(nodes.get(key))
357
+ } else if (typeof nodes.forEach === 'function') {
358
+ nodes.forEach(visit)
359
+ }
360
+ return { userText: userParts.join('\n\n'), assistantText }
361
+ } catch (e) {
362
+ report('turnTail:extract-throw', String((e && e.message) || e))
363
+ return empty
364
+ }
365
+ }
366
+
367
+ // 解析某一轮里指定类型消息行的 DOM 锚点 key(= ChatNode.key,即 data-chat-anchor-key)
368
+ function anchorKeyFor(chat, turn, kind, closingSeq) {
369
+ try {
370
+ if (!chat || !chat.locations || !chat.nodes) return null
371
+ const keys = chat.locations.getTurn(turn) || []
372
+ let fallback = null
373
+ let prefer = null
374
+ for (const key of keys) {
375
+ const node = chat.nodes.get(key)
376
+ if (!node || node.kind !== kind) continue
377
+ fallback = key
378
+ if (kind === 'user') return key
379
+ if (kind === 'assistant-step') {
380
+ const finalSeq = node.data && node.data.finalNode ? node.data.finalNode.seq : undefined
381
+ if (typeof closingSeq === 'number' && finalSeq === closingSeq) prefer = key
382
+ }
383
+ }
384
+ return prefer || fallback
385
+ } catch (e) {
386
+ return null
387
+ }
388
+ }
389
+
390
+ // ============ 跳转与提示(模块级:dock 导航条与旧 chip 处理共用) ============
391
+ let notifyTimer = null
392
+ function notify(text, pos, ok) {
393
+ try {
394
+ report('toast', (ok ? 'ok | ' : 'err | ') + text)
395
+ toastStore.show(text, pos, ok)
396
+ if (notifyTimer) clearTimeout(notifyTimer)
397
+ notifyTimer = setTimeout(() => toastStore.hide(), 2600)
398
+ } catch (e) { /* ignore */ }
399
+ }
400
+ /** 跳到某个锚点行并高亮它。 */
401
+ function jumpToAnchorKey(anchorKey, pos) {
402
+ if (!anchorKey) return false
403
+ let row = null
404
+ for (const r of document.querySelectorAll('[data-chat-anchor-key]')) {
405
+ if (r.getAttribute('data-chat-anchor-key') === anchorKey) { row = r; break }
406
+ }
407
+ if (!row) {
408
+ notify('跳转失败:原文不在当前页面(可能切了会话或还没加载)', pos, false)
409
+ report('jump:miss', { anchorKey })
410
+ return false
411
+ }
412
+ row.scrollIntoView({ block: 'center', behavior: 'smooth' })
413
+ row.classList.add('dshq-flash')
414
+ setTimeout(() => row.classList.remove('dshq-flash'), 1600)
415
+ notify('已跳转到原文 ✓', pos, true)
416
+ report('jump:ok', { anchorKey })
417
+ return true
418
+ }
419
+ /** 按 ref 跳回原文:优先用锚点键,退化到按引用正文反查。 */
420
+ function jumpToRef(ref, pos) {
421
+ const info = refTargets.get(ref)
422
+ if (!info) {
423
+ notify('跳转失败:找不到该引用(可能已发送或已删除)', pos, false)
424
+ return false
425
+ }
426
+ let anchorKey = info.anchorKey || null
427
+ if (!anchorKey && info.body) {
428
+ const probe = String(info.body)
429
+ .split('\n')
430
+ .map((line) => line.replace(/^【引用[^\n]*】\s*/, '').replace(/^> ?/, ''))
431
+ .join('\n')
432
+ .trim()
433
+ .slice(0, 24)
434
+ for (const r of document.querySelectorAll('[data-chat-anchor-key]')) {
435
+ if (probe && r.textContent && r.textContent.indexOf(probe) !== -1) {
436
+ anchorKey = r.getAttribute('data-chat-anchor-key')
437
+ break
438
+ }
439
+ }
440
+ }
441
+ if (!anchorKey) {
442
+ notify('跳转失败:没有定位键(选中时未识别到消息行)', pos, false)
443
+ return false
444
+ }
445
+ return jumpToAnchorKey(anchorKey, pos)
446
+ }
447
+
448
+ /**
449
+ * 按 chip 的 title(= 我们插入时写入的 label)反查 ref。
450
+ * 本版本 chip 没有 data-* 属性,但 ReferenceChip 会把 label 渲染成 title,
451
+ * 因此 title 是最可靠的 DOM 侧标识,不必按 CSS 类猜顺序。
452
+ */
453
+ function refByLabel(label) {
454
+ if (typeof label !== 'string' || label === '') return null
455
+ const wanted = label.trim()
456
+ for (const [ref, info] of refTargets) {
457
+ if (info && typeof info.label === 'string' && info.label.trim() === wanted) return ref
458
+ }
459
+ // 退化:只按 `引用#N` 前缀匹配(label 可能被截断显示)
460
+ const short = wanted.split('·')[0].trim()
461
+ if (short.startsWith('引用#')) {
462
+ for (const [ref, info] of refTargets) {
463
+ if (info && typeof info.label === 'string' && info.label.trim().startsWith(short)) return ref
464
+ }
465
+ }
466
+ return null
467
+ }
468
+
469
+ // 按 occurrenceId 解析引用(chip 点击跳转 / 悬停提示共用)
470
+ function resolveOccurrence(occId) {
471
+ try {
472
+ const conversation = rootCtx ? rootCtx.get('conversation') : undefined
473
+ const sessions = rootCtx ? rootCtx.get('sessions') : undefined
474
+ if (!conversation || !conversation.input || !sessions) return null
475
+ const trySession = (sessionId) => {
476
+ if (!sessionId) return null
477
+ const binding = sessions.binding(sessionId)
478
+ if (!binding) return null
479
+ const input = conversation.input.for(binding.ctx)
480
+ const occurrences = input.state.getSnapshot().occurrences || []
481
+ const found = occurrences.find((o) => o.occurrenceId === occId) || null
482
+ // 刷新后 ref → 锚点映射从 localStorage 复原(上游此映射仅存内存,刷新即失效)
483
+ if (found && found.source === 'quote-ref' && !refTargets.has(found.ref)) hydrateRefs(sessionId)
484
+ return found
485
+ }
486
+ let occ = trySession(currentSessionId)
487
+ if (!occ) {
488
+ for (const refKey of refTargets.keys()) {
489
+ const info = refTargets.get(refKey)
490
+ if (!info || !info.sessionId) continue
491
+ occ = trySession(info.sessionId)
492
+ if (occ) break
493
+ }
494
+ }
495
+ if (!occ || occ.source !== 'quote-ref') return null
496
+ return { occ, info: refTargets.get(occ.ref) }
497
+ } catch (e) {
498
+ return null
499
+ }
500
+ }
501
+
502
+ // ============ 链条目选择器:仅在已结束且无交付文件的回合挂载 ============
503
+ function selectTurnQuote(owner) {
504
+ if (!owner || !owner.turn || !owner.turn.end) return null
505
+ const delivered = owner.turn.data ? owner.turn.data.get('deliverables') : undefined
506
+ if (delivered && Array.isArray(delivered.produced) && delivered.produced.length > 0) return null
507
+ return { turn: owner.turn.turn }
508
+ }
509
+
510
+ function rectOf(e) {
511
+ try {
512
+ const r = e.currentTarget.getBoundingClientRect()
513
+ return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
514
+ } catch (err) {
515
+ return null
516
+ }
517
+ }
518
+
519
+ // ============ 组件一:回合尾部的引用按钮(点击直接打开悬浮编辑框) ============
520
+ const TurnQuoteActions = (props) => {
521
+ // 本版本该轮文本在 Chat 快照里(useChat),不在 session 快照里;
522
+ // 且 nodes 是 Map、身份字段是 anchorSeq —— 上游按数组 + seq 读,永远取空,所以按钮不出现。
523
+ const chat = typeof props.useChat === 'function' ? props.useChat((s) => s) : undefined
524
+ const texts = React.useMemo(() => extractTurnTexts(chat, props.turn), [chat, props.turn])
525
+ if (props.turn && !turnTailReported.has(props.turn.turn)) {
526
+ turnTailReported.add(props.turn.turn)
527
+ try {
528
+ report('turnTail:render', {
529
+ turn: props.turn.turn,
530
+ hasChat: !!chat,
531
+ nodeCount: chat && chat.nodes && typeof chat.nodes.size === 'number' ? chat.nodes.size : null,
532
+ userChars: texts.userText.length,
533
+ assistantChars: texts.assistantText.length,
534
+ userAnchor: anchorKeyFor(chat, props.turn.turn, 'user', props.seq) || null,
535
+ assistantAnchor: anchorKeyFor(chat, props.turn.turn, 'assistant-step', props.seq) || null,
536
+ })
537
+ } catch (e) { /* ignore */ }
538
+ }
539
+ const open = (e, role, label, text) => {
540
+ noteSession(props.sessionId, 'turnTail')
541
+ const anchorKey = anchorKeyFor(chat, props.turn.turn, role, props.seq)
542
+ editorStore.open({ sessionId: props.sessionId, label, text, anchorKey, turn: props.turn.turn, kind: role === 'user' ? 'user' : 'assistant-step' }, rectOf(e))
543
+ }
544
+ const buttons = []
545
+ if (texts.userText) {
546
+ buttons.push(React.createElement('button', {
547
+ key: 'user',
548
+ type: 'button',
549
+ className: 'dshq-btn',
550
+ onClick: (e) => open(e, 'user', '引用提问 · 第 ' + (props.turn.turn + 1) + ' 轮', clipText(texts.userText, MAX_QUOTE)),
551
+ }, '引用提问'))
552
+ }
553
+ if (texts.assistantText) {
554
+ buttons.push(React.createElement('button', {
555
+ key: 'assistant',
556
+ type: 'button',
557
+ className: 'dshq-btn',
558
+ onClick: (e) => open(e, 'assistant', '引用回答 · 第 ' + (props.turn.turn + 1) + ' 轮', clipText(texts.assistantText, MAX_QUOTE)),
559
+ }, '引用回答'))
560
+ }
561
+ if (buttons.length === 0) return null
562
+ return React.createElement('div', { className: 'dshq-tail' }, buttons)
563
+ }
564
+
565
+ // ============ 当前会话解析 ============
566
+ // 重要:本版本(0.1.6+)的会话列表快照没有 current 字段,读 s.current 恒为 undefined。
567
+ // 可靠来源是「会话作用域槽位」回填的 sessionId(root 作用域的 shell.overlay 拿不到它),
568
+ // 其次才是右侧栏 mounted 席位。
569
+ let toolbarPropsReported = false
570
+ let insertProbeDone = false
571
+ const turnTailReported = new Set()
572
+ /**
573
+ * 零副作用插入探针:用过期 draftRev 发一次 insert-reference 事件。
574
+ * shell.insertReference 会先做 CAS(span.draftRev !== rev → return false),
575
+ * 因此不会改动草稿,但能验证"上下文可用 + 监听器会被派发"这条通路。
576
+ */
577
+ function probeInsertPath(sessionId) {
578
+ if (insertProbeDone) return
579
+ insertProbeDone = true
580
+ try {
581
+ const conversation = rootCtx ? rootCtx.get('conversation') : undefined
582
+ const sessions = rootCtx ? rootCtx.get('sessions') : undefined
583
+ const binding = sessions ? sessions.binding(sessionId) : undefined
584
+ if (!conversation || !conversation.input || !binding) {
585
+ report('probe:emit-skip', 'no conversation/binding')
586
+ return
587
+ }
588
+ const input = conversation.input.for(binding.ctx)
589
+ let emitCtx = binding.ctx
590
+ let isScope = false
591
+ try {
592
+ if (typeof sessions.scope === 'function') {
593
+ const scoped = sessions.scope(sessionId)
594
+ if (scoped) { emitCtx = scoped; isScope = true }
595
+ }
596
+ } catch (e) { report('probe:scope-throw', String((e && e.message) || e)) }
597
+ emitCtx.emit('slash/input-insert-reference', {
598
+ reference: { source: 'quote-ref', ref: 'probe-noop', label: 'probe', clipboardText: 'probe' },
599
+ span: { draftRev: -1, start: 0, end: 0 },
600
+ })
601
+ report('probe:emit-ok', { viaScope: isScope, draftRev: input.state.getSnapshot().draftRev })
602
+ } catch (e) {
603
+ report('probe:emit-throw', String((e && e.message) || e))
604
+ }
605
+ }
606
+ function noteSession(id, via) {
607
+ if (typeof id !== 'string' || id === '') return null
608
+ if (currentSessionId !== id) {
609
+ currentSessionId = id
610
+ report('session:resolved', { id: id.slice(0, 8), via })
611
+ setTimeout(() => { try { probeInsertPath(id) } catch (e) { /* ignore */ } }, 0)
612
+ }
613
+ return id
614
+ }
615
+ function resolveSessionId() {
616
+ if (typeof currentSessionId === 'string' && currentSessionId !== '') return currentSessionId
617
+ try {
618
+ const sr = rootCtx ? rootCtx.get('sidebarRight') : undefined
619
+ const snap = sr && sr.mounted && typeof sr.mounted.getSnapshot === 'function' ? sr.mounted.getSnapshot() : undefined
620
+ if (noteSession(snap, 'sidebarRight.mounted')) return currentSessionId
621
+ } catch (e) {
622
+ report('session:sidebarRight-throw', String((e && e.message) || e))
623
+ }
624
+ return currentSessionId || null
625
+ }
626
+
627
+ // 会话作用域探针:注册进 conversation.input.dock —— 它在屏即代表该会话正在被查看
628
+ const SessionProbe = (props) => {
629
+ try { noteSession(props && props.sessionId, 'dock-probe') } catch (e) { /* ignore */ }
630
+ return null
631
+ }
632
+
633
+ // ============ 组件二:鼠标选区浮动小气泡(点击「批注」打开悬浮编辑框) ============
634
+ const SelectionToolbar = (props) => {
635
+ const sel = useSelection()
636
+ // 本版本会话列表快照没有 current 字段(读取恒为 undefined),
637
+ // 所以这里只把 props 里可能存在的 sessionId 当线索,真正的来源是会话作用域探针。
638
+ if (!toolbarPropsReported) {
639
+ toolbarPropsReported = true
640
+ try {
641
+ report('env:overlay-props', {
642
+ keys: Object.keys(props || {}),
643
+ hasSessionId: typeof (props && props.sessionId) === 'string',
644
+ hasUseSession: typeof (props && props.useSession) === 'function',
645
+ hasUseSessions: typeof (props && props.useSessions) === 'function',
646
+ })
647
+ } catch (e) { /* ignore */ }
648
+ }
649
+ if (typeof props.sessionId === 'string' && props.sessionId !== '') currentSessionId = props.sessionId
650
+ if (!sel.visible || !sel.rect) return null
651
+ const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
652
+ // 气泡宽度随文字增长:估算 = 每字约13px + 按钮与留白约76px,封顶384px
653
+ const est = Math.min(384, Math.max(120, sel.text.length * 13 + 76))
654
+ const center = sel.rect.left + (sel.rect.right - sel.rect.left) / 2
655
+ const left = Math.max(12, Math.min(center - est / 2, vw - est - 12))
656
+ const top = sel.rect.top - 42 >= 8 ? sel.rect.top - 42 : sel.rect.bottom + 8
657
+ const finish = () => {
658
+ try {
659
+ const s = window.getSelection()
660
+ if (s && s.removeAllRanges) s.removeAllRanges()
661
+ } catch (e) { /* ignore */ }
662
+ selectionStore.hide()
663
+ }
664
+ const stage = () => {
665
+ // 关键修复:不再要求"已解析到会话"才打开编辑框;解析不到也照常进入编辑,
666
+ // 真正的会话在插入时再解析,避免整条链路因为拿不到 sessionId 而静默失效。
667
+ const sid = resolveSessionId()
668
+ const anchorLabel = anchorLabelOf({ turn: sel.turn, kind: sel.kind })
669
+ report('selection:stage', {
670
+ anchorKey: sel.anchorKey || null,
671
+ turn: typeof sel.turn === 'number' ? sel.turn : null,
672
+ kind: sel.kind || null,
673
+ sessionId: sid ? String(sid).slice(0, 8) : null,
674
+ })
675
+ editorStore.open({
676
+ sessionId: sid || null,
677
+ label: anchorLabel === '' ? '选中文字' : '选中 · ' + anchorLabel,
678
+ text: clipText(sel.text, MAX_SELECTION),
679
+ anchorKey: sel.anchorKey,
680
+ turn: sel.turn,
681
+ kind: sel.kind,
682
+ }, sel.rect)
683
+ finish()
684
+ }
685
+ return React.createElement('div', { className: 'dshq-toolbar', style: { left, top } },
686
+ React.createElement('span', { className: 'dshq-toolbar-preview' }, sel.text),
687
+ React.createElement('button', {
688
+ type: 'button',
689
+ className: 'dshq-btn dshq-primary dshq-bubble-cta',
690
+ onMouseDown: (e) => e.preventDefault(),
691
+ onClick: stage,
692
+ }, '批注'),
693
+ React.createElement('button', {
694
+ type: 'button',
695
+ className: 'dshq-btn dshq-close',
696
+ onMouseDown: (e) => e.preventDefault(),
697
+ onClick: finish,
698
+ }, '✕'),
699
+ )
700
+ }
701
+
702
+ // ============ 组件三:悬浮批注编辑框(评论输入框元素永久驻留,复用时输入法上下文不重建) ============
703
+ const AnnotationEditor = () => {
704
+ const state = useEditor()
705
+ const inputRef = React.useRef(null)
706
+ const [comment, setComment] = React.useState('')
707
+ const [status, setStatus] = React.useState(null)
708
+ React.useEffect(() => {
709
+ setComment('')
710
+ setStatus(null)
711
+ if (state.item && inputRef.current) {
712
+ // 同一元素重复聚焦:输入法记住的语言模式得以保留
713
+ inputRef.current.focus()
714
+ }
715
+ }, [state.item])
716
+ const open = !!(state.item && state.rect)
717
+ const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
718
+ const vh = typeof window !== 'undefined' ? window.innerHeight : 768
719
+ const item = state.item
720
+ let left = -9999
721
+ let top = -9999
722
+ let w = 200
723
+ if (open) {
724
+ // 宽度:默认约 10 字(120px),随评论输入变宽,封顶 340px
725
+ const quoteW = Math.min(220, item.text.length * 12 + 16)
726
+ const inputW = Math.min(300, Math.max(140, comment.length * 13 + 44))
727
+ w = Math.max(120, Math.min(340, Math.max(quoteW, inputW)))
728
+ const center = state.rect.left + (state.rect.right - state.rect.left) / 2
729
+ left = Math.max(12, Math.min(center - w / 2, vw - w - 12))
730
+ const estH = 200
731
+ top = state.rect.bottom + 8 + estH <= vh - 8 ? state.rect.bottom + 8 : Math.max(8, state.rect.top - estH - 8)
732
+ }
733
+ const insert = () => {
734
+ if (!open || !item) return
735
+ try {
736
+ const c = (comment || '').trim()
737
+ const header = buildQuoteHeader(item)
738
+ const quoted = item.text.split('\n').map((line) => '> ' + line).join('\n')
739
+ const quoteBody = header === '' ? quoted : header + '\n' + quoted
740
+ const conversation = rootCtx ? rootCtx.get('conversation') : undefined
741
+ const sessions = rootCtx ? rootCtx.get('sessions') : undefined
742
+ if (!conversation || !conversation.input || !sessions) {
743
+ setStatus({ ok: false, text: '写入输入框失败:conversation/sessions 服务不可用' })
744
+ return
745
+ }
746
+ const sessionId = item.sessionId || resolveSessionId()
747
+ if (!sessionId) {
748
+ report('insert:no-session', {})
749
+ setStatus({ ok: false, text: '写入失败:拿不到当前会话,请在对话区里点一下再重试' })
750
+ return
751
+ }
752
+ const binding = sessions.binding(sessionId)
753
+ if (!binding) {
754
+ report('insert:no-binding', { sessionId: String(sessionId).slice(0, 8) })
755
+ setStatus({ ok: false, text: '写入输入框失败:会话不可用' })
756
+ return
757
+ }
758
+ const input = conversation.input.for(binding.ctx)
759
+ const st = input.state.getSnapshot()
760
+ const base = (st.draft || '').replace(/[\s\u00A0]+$/, '')
761
+ const commentTail = c === '' ? '' : '\n\n' + c
762
+ const ref = 'q' + REF_RUN + (++refSeq)
763
+ const anchorLabel = anchorLabelOf(item)
764
+ const chipLabel = '引用#' + refSeq + (anchorLabel === '' ? '' : ' · ' + anchorLabel)
765
+ refTargets.set(ref, { sessionId, anchorKey: item.anchorKey || null, body: quoteBody, label: chipLabel })
766
+ refCodecs.set(ref, { body: quoteBody, clipboardText: quoteBody })
767
+ saveRef(sessionId, ref, { anchorKey: item.anchorKey || null, body: quoteBody, label: chipLabel })
768
+ while (refCodecs.size > 200) {
769
+ const oldest = refCodecs.keys().next().value
770
+ refCodecs.delete(oldest)
771
+ refTargets.delete(oldest)
772
+ }
773
+ // 事件派发目标:优先用 sessions.scope()(社区插件在本版本已验证的路径),退回 binding.ctx
774
+ let emitCtx = binding.ctx
775
+ try {
776
+ if (typeof sessions.scope === 'function') {
777
+ const scoped = sessions.scope(sessionId)
778
+ if (scoped) emitCtx = scoped
779
+ }
780
+ } catch (e) { /* 退回 binding.ctx */ }
781
+ // 在草稿末尾插入引用 chip(官方事件;输入机自行追加占位符 + 空格)
782
+ const applied = emitInsertReference(emitCtx, input, st, {
783
+ source: 'quote-ref',
784
+ ref,
785
+ label: chipLabel,
786
+ clipboardText: quoteBody,
787
+ })
788
+ if (applied) {
789
+ // 把评论追加到 chip 之后
790
+ if (commentTail !== '') {
791
+ const st3 = input.state.getSnapshot()
792
+ input.setDraft(st3.draft.replace(/[ \t]+$/, '') + commentTail)
793
+ }
794
+ report('insert:chip', { ref, anchorKey: item.anchorKey || null, header, chars: item.text.length })
795
+ setStatus({
796
+ ok: true,
797
+ text: '已插入引用 chip ✓(' + (item.anchorKey ? '点击 chip 可跳回原文' : '未定位到消息行,点击可能无法跳转') + ')',
798
+ })
799
+ editorStore.close()
800
+ return
801
+ }
802
+ // 未生效:回退纯文本(内容不丢)
803
+ refTargets.delete(ref)
804
+ refCodecs.delete(ref)
805
+ input.setDraft(base === '' ? quoteBody + commentTail : base + '\n\n' + quoteBody + commentTail)
806
+ report('insert:fallback-plaintext', { ref, header })
807
+ setStatus({ ok: false, text: 'chip 插入未生效,已回退为纯文本引用' })
808
+ editorStore.close()
809
+ } catch (e) {
810
+ setStatus({ ok: false, text: '插入失败:' + (e && e.message ? e.message : String(e)) })
811
+ report('insert:throw', String((e && e.stack) || (e && e.message) || e))
812
+ console.error('[quote-pro] 插入失败', e)
813
+ }
814
+ }
815
+ const hint = pipelineAvailable === false ? '引用锚点管线不可用,将插入纯文本引用' : '插入后输入框下方会出现引用条,点它可跳回原文'
816
+ return React.createElement('div', {
817
+ className: open ? 'dshq-editor' : 'dshq-editor dshq-editor-hidden',
818
+ style: { left, top, width: w },
819
+ },
820
+ React.createElement('div', { className: 'dshq-editor-label' }, open ? item.label : ''),
821
+ React.createElement('div', { className: 'dshq-editor-quote' }, open ? item.text : ''),
822
+ React.createElement('input', {
823
+ ref: inputRef,
824
+ className: 'dshq-editor-input',
825
+ value: comment,
826
+ placeholder: '输入评论…',
827
+ onChange: (e) => setComment(e.target.value),
828
+ onKeyDown: (e) => {
829
+ if (e.key === 'Enter') insert()
830
+ if (e.key === 'Escape') editorStore.close()
831
+ },
832
+ }),
833
+ status && open ? React.createElement('div', { className: status.ok ? 'dshq-editor-status dshq-ok' : 'dshq-editor-status dshq-err' }, status.text) : (open ? React.createElement('div', { className: 'dshq-editor-hint' }, hint) : null),
834
+ React.createElement('div', { className: 'dshq-editor-actions' },
835
+ React.createElement('button', { type: 'button', className: 'dshq-btn dshq-primary', onClick: insert }, '插入输入框'),
836
+ React.createElement('button', { type: 'button', className: 'dshq-btn', onClick: () => editorStore.close() }, '✕'),
837
+ ),
838
+ )
839
+ }
840
+
841
+ // ============ 组件四:引用悬停提示(显示当时选中的文字) ============
842
+ const QuoteTooltip = () => {
843
+ const t = useTooltip()
844
+ if (!t.visible || !t.rect) return null
845
+ const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
846
+ const vh = typeof window !== 'undefined' ? window.innerHeight : 768
847
+ const w = Math.min(340, Math.max(180, Math.min(t.text.length, 60) * 11 + 48))
848
+ const estH = Math.min(200, 56 + Math.ceil(Math.min(t.text.length, 400) / 22) * 15)
849
+ const center = t.rect.left + (t.rect.right - t.rect.left) / 2
850
+ const left = Math.max(8, Math.min(center - w / 2, vw - w - 8))
851
+ let top = t.rect.top - estH - 8
852
+ if (top < 8) top = Math.min(vh - estH - 8, t.rect.bottom + 8)
853
+ return React.createElement('div', { className: 'dshq-tip', style: { left, top, width: w } },
854
+ React.createElement('div', { className: 'dshq-tip-label' }, t.label),
855
+ React.createElement('div', { className: 'dshq-tip-quote' }, t.text),
856
+ )
857
+ }
858
+
859
+ // ============ 组件五:点击 chip 的反馈 toast(shell.overlay 条目) ============
860
+ const FeedbackToast = () => {
861
+ const t = useToast()
862
+ if (!t.text || !t.pos) return null
863
+ const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
864
+ const left = Math.max(12, Math.min(t.pos.left - 80, vw - 180))
865
+ return React.createElement('div', {
866
+ className: t.ok ? 'dshq-toast dshq-toast-ok' : 'dshq-toast dshq-toast-err',
867
+ style: { left, top: t.pos.top + 18 },
868
+ }, t.text)
869
+ }
870
+
871
+ // ============ 样式(主题令牌自适应明暗) ============
872
+ // ============ 组件六:输入框下方的引用导航条 ============
873
+ // 本版本输入框里的引用 chip 没有 data 属性(只有 CSS 类),DOM 无法反查 occurrence;
874
+ // 因此导航条直接读官方输入状态的 occurrences(source === 'quote-ref')来渲染,
875
+ // 点击即可跳回原文 —— 这是「多轮对话里精准定位」的可靠入口。
876
+ const QuoteDockChip = ({ item }) => {
877
+ const onJump = (e) => {
878
+ try {
879
+ const r = e.currentTarget.getBoundingClientRect()
880
+ report('dock:click', { ref: String(item.ref), anchorKey: item.anchorKey || null })
881
+ jumpToRef(item.ref, { left: r.left + 16, top: r.top })
882
+ } catch (err) { /* ignore */ }
883
+ }
884
+ const onOver = (e) => {
885
+ try {
886
+ const info = refTargets.get(item.ref)
887
+ if (!info || !info.body) return
888
+ const r = e.currentTarget.getBoundingClientRect()
889
+ tooltipStore.show({
890
+ text: String(info.body).replace(/^> ?/gm, '').trim(),
891
+ label: info.label || '引用',
892
+ rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
893
+ })
894
+ } catch (err) { /* ignore */ }
895
+ }
896
+ const onOut = () => { try { tooltipStore.hide() } catch (err) { /* ignore */ } }
897
+ return React.createElement('button', {
898
+ type: 'button',
899
+ className: 'dshq-dock-chip',
900
+ onClick: onJump,
901
+ onMouseEnter: onOver,
902
+ onMouseLeave: onOut,
903
+ title: '点击跳回原文,悬停查看原文',
904
+ },
905
+ React.createElement('span', { className: 'dshq-dock-chip-icon' }, '❝'),
906
+ React.createElement('span', { className: 'dshq-dock-chip-label' }, item.label || ('引用#' + item.ref)),
907
+ )
908
+ }
909
+
910
+ const QuoteDock = (props) => {
911
+ const sessionId = props && typeof props.sessionId === 'string' && props.sessionId !== '' ? props.sessionId : null
912
+ const [rows, setRows] = React.useState([])
913
+ React.useEffect(() => {
914
+ if (!sessionId) { setRows([]); return undefined }
915
+ let input = null
916
+ try {
917
+ const conversation = rootCtx ? rootCtx.get('conversation') : undefined
918
+ const sessions = rootCtx ? rootCtx.get('sessions') : undefined
919
+ const binding = sessions ? sessions.binding(sessionId) : undefined
920
+ input = conversation && conversation.input && binding ? conversation.input.for(binding.ctx) : null
921
+ } catch (e) {
922
+ report('dock:input-throw', String((e && e.message) || e))
923
+ }
924
+ if (!input || !input.state) return undefined
925
+ const read = () => {
926
+ try {
927
+ const snap = input.state.getSnapshot()
928
+ const occurrences = (snap && snap.occurrences) || []
929
+ const next = []
930
+ for (const occ of occurrences) {
931
+ if (!occ || occ.source !== 'quote-ref') continue
932
+ const info = refTargets.get(occ.ref)
933
+ next.push({ ref: occ.ref, label: (info && info.label) || occ.label || ('引用#' + occ.ref), anchorKey: (info && info.anchorKey) || null })
934
+ }
935
+ setRows(next)
936
+ if (next.length > 0) report('dock:rows', { count: next.length })
937
+ } catch (e) { /* ignore */ }
938
+ }
939
+ read()
940
+ let off
941
+ let timer = null
942
+ try {
943
+ if (typeof input.state.subscribe === 'function') off = input.state.subscribe(read)
944
+ else timer = setInterval(read, 800)
945
+ } catch (e) { timer = setInterval(read, 800) }
946
+ return () => {
947
+ try { if (typeof off === 'function') off() } catch (e) { /* ignore */ }
948
+ if (timer !== null) clearInterval(timer)
949
+ }
950
+ }, [sessionId])
951
+ if (!sessionId || rows.length === 0) return null
952
+ const children = [React.createElement('span', { className: 'dshq-dock-title', key: '__t' }, '引用')]
953
+ for (const row of rows) children.push(React.createElement(QuoteDockChip, { key: row.ref, item: row }))
954
+ return React.createElement('div', { className: 'dshq-dock' }, children)
955
+ }
956
+
957
+ const css = `
958
+ .dshq-tail{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap;margin-left:2px}
959
+ .dshq-btn{appearance:none;background:transparent;border:1px solid transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:1.4;padding:2px 8px;border-radius:6px;cursor:pointer;transition:background .12s ease,color .12s ease}
960
+ .dshq-btn:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
961
+ .dshq-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:#fff}
962
+ .dshq-primary:hover{background:var(--dsw-alias-brand-primary);color:#fff;opacity:.9}
963
+ .dshq-toolbar{position:fixed;display:flex;align-items:center;gap:4px;min-width:10em;width:max-content;max-width:min(32em,calc(100vw - 24px));font-size:12px;padding:3px 7px;border-radius:999px;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 4px 14px rgba(0,0,0,.14);pointer-events:auto;box-sizing:border-box}
964
+ .dshq-toolbar-preview{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;color:var(--dsw-alias-label-secondary)}
965
+ .dshq-bubble-cta{flex:none;padding:1px 8px;border-radius:999px}
966
+ .dshq-close{flex:none;padding:1px 5px;opacity:.7}
967
+ .dshq-editor{position:fixed;display:flex;flex-direction:column;gap:5px;box-sizing:border-box;padding:8px 10px;border-radius:10px;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 8px 24px rgba(0,0,0,.18);pointer-events:auto;font-size:12px}
968
+ .dshq-editor-hidden{opacity:0;pointer-events:none}
969
+ .dshq-editor-label{font-size:11px;color:var(--dsw-alias-label-secondary)}
970
+ .dshq-editor-quote{margin:0;padding:3px 8px;border-left:3px solid var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;max-height:96px;overflow:auto}
971
+ .dshq-editor-input{width:100%;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;padding:4px 8px}
972
+ .dshq-editor-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
973
+ .dshq-editor-hint{font-size:11px;color:var(--dsw-alias-label-secondary);opacity:.8}
974
+ .dshq-editor-status{font-size:11px;line-height:1.5}
975
+ .dshq-editor-status.dshq-ok{color:var(--dsw-alias-state-success-primary)}
976
+ .dshq-editor-status.dshq-err{color:var(--dsw-alias-state-error-primary)}
977
+ .dshq-editor-actions{display:flex;justify-content:flex-end;gap:6px}
978
+ .dshq-tip{position:fixed;display:flex;flex-direction:column;gap:4px;box-sizing:border-box;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 6px 18px rgba(0,0,0,.18);pointer-events:none;z-index:2147483000;font-size:12px}
979
+ .dshq-tip-label{font-size:11px;color:var(--dsw-alias-label-secondary)}
980
+ .dshq-tip-quote{margin:0;padding:3px 8px;border-left:3px solid var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;max-height:160px;overflow:auto;font-size:11px}
981
+ .dshq-flash{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:4px;border-radius:8px}
982
+ .dshq-toast{position:fixed;max-width:240px;padding:5px 9px;border-radius:8px;font-size:11px;line-height:1.5;box-shadow:0 4px 14px rgba(0,0,0,.18);pointer-events:none;z-index:2147483000}
983
+ .dshq-toast-ok{background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-state-success-primary);color:var(--dsw-alias-label-primary)}
984
+ .dshq-toast-err{background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
985
+ /* 根因:textarea(透明文字、absolute、inset:0)渲染在 backdrop 之后,整层盖住 chip。
986
+ 给 chip 提升 z-index 使其浮到 textarea 之上,点击才能落到 chip 上。 */
987
+ [data-occurrence]{pointer-events:auto;cursor:pointer;position:relative;z-index:1}
988
+ /* 引用导航条:输入框下方,点击跳回原文 */
989
+ .dshq-dock{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 6px;font-size:11px;color:var(--dsw-alias-label-secondary)}
990
+ .dshq-dock-title{opacity:.65;user-select:none}
991
+ .dshq-dock-chip{appearance:none;display:inline-flex;align-items:center;gap:4px;max-width:280px;padding:2px 9px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;line-height:18px;cursor:pointer}
992
+ .dshq-dock-chip:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
993
+ .dshq-dock-chip-icon{opacity:.55}
994
+ .dshq-dock-chip-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
995
+ `
996
+
997
+ // ============ 插件主体 ============
998
+ function apply(ctx) {
999
+ rootCtx = ctx
1000
+ report('apply:start')
1001
+ try {
1002
+ const names = ['slots', 'inputTriggers', 'conversation', 'sessions', 'sidebarRight', 'uiSession', 'layout', 'uiWorkspace', 'workspaces', 'chat']
1003
+ const have = {}
1004
+ for (const n of names) {
1005
+ try { have[n] = ctx.get(n) !== undefined } catch (e) { have[n] = 'throw' }
1006
+ }
1007
+ report('env:services', have)
1008
+ } catch (e) { /* ignore */ }
1009
+ let slots
1010
+ try { slots = ctx.get('slots') } catch (e) { report('apply:slots-throw', String((e && e.message) || e)) }
1011
+ report('apply:slots', slots === undefined ? 'undefined' : typeof slots)
1012
+ if (slots === undefined) return
1013
+ try {
1014
+ ctx.effect(() => insertStyleSheet(css), 'dsh-quote-annotate-pro: styles')
1015
+ } catch (e) {
1016
+ report('apply:styles-throw', String((e && e.stack) || (e && e.message) || e))
1017
+ }
1018
+ // 注册引用 source(提交时把占位符序列化为引用正文;空候选分组不渲染,不污染 @ 菜单)
1019
+ let inputTriggers
1020
+ try { inputTriggers = ctx.get('inputTriggers') } catch (e) { report('apply:inputTriggers-throw', String((e && e.message) || e)) }
1021
+ pipelineAvailable = !!(inputTriggers && typeof inputTriggers.registerSource === 'function')
1022
+ report('apply:pipeline', {
1023
+ available: pipelineAvailable,
1024
+ hasInsertReference: !!(inputTriggers && typeof inputTriggers.insertReference === 'function'),
1025
+ })
1026
+ if (pipelineAvailable) {
1027
+ try {
1028
+ ctx.effect(() => inputTriggers.registerSource({
1029
+ trigger: '@',
1030
+ name: 'quote-ref',
1031
+ order: 100,
1032
+ candidates: async () => [],
1033
+ onPick: () => undefined,
1034
+ codec: {
1035
+ clipboardText: (ref) => (refCodecs.get(ref) ? refCodecs.get(ref).clipboardText : ''),
1036
+ serialize: (ref) => Promise.resolve(refCodecs.get(ref) ? refCodecs.get(ref).body : ''),
1037
+ },
1038
+ }), 'dsh-quote-annotate-pro: quote-ref source')
1039
+ report('register:quote-ref-source', 'ok')
1040
+ } catch (e) {
1041
+ pipelineAvailable = false
1042
+ report('register:quote-ref-source', 'throw ' + String((e && e.stack) || (e && e.message) || e))
1043
+ }
1044
+ }
1045
+ // 全局监听(选区气泡 / chip 点击跳转 / chip 悬停提示;ctx.effect 保证卸载时全部清理)
1046
+ try {
1047
+ ctx.effect(() => {
1048
+ let raf = 0
1049
+ let toastTimer = null
1050
+ let hideTimer = null
1051
+ const onKey = (e) => {
1052
+ if (e.key === 'Escape') {
1053
+ selectionStore.hide()
1054
+ toastStore.hide()
1055
+ tooltipStore.hide()
1056
+ }
1057
+ }
1058
+ const showToast = (text, pos, ok) => notify(text, pos, ok)
1059
+ const cancelHide = () => {
1060
+ if (hideTimer) {
1061
+ clearTimeout(hideTimer)
1062
+ hideTimer = null
1063
+ }
1064
+ }
1065
+ const scheduleHide = () => {
1066
+ if (hideTimer) return
1067
+ hideTimer = setTimeout(() => {
1068
+ hideTimer = null
1069
+ selectionStore.hide()
1070
+ }, 150)
1071
+ }
1072
+ const onScroll = () => {
1073
+ schedule()
1074
+ tooltipStore.hide()
1075
+ }
1076
+ // 用 pointerdown 捕获(比 click 更早,且不受 click 阶段任何 stopPropagation 影响)
1077
+ const onChipPress = (e) => {
1078
+ try {
1079
+ tooltipStore.hide()
1080
+ const pos = { left: e.clientX, top: e.clientY }
1081
+ const el = e.target && e.target.closest ? e.target.closest('[data-occurrence]') : null
1082
+ if (el) {
1083
+ const occId = Number(el.getAttribute('data-occurrence'))
1084
+ if (!Number.isFinite(occId)) return
1085
+ const found = resolveOccurrence(occId)
1086
+ if (!found) {
1087
+ notify('跳转失败:找不到该引用(可能已发送或被删除)', pos, false)
1088
+ return
1089
+ }
1090
+ report('chip:click', { via: 'occurrence', ref: String(found.occ.ref) })
1091
+ jumpToRef(found.occ.ref, pos)
1092
+ return
1093
+ }
1094
+ // 本版本输入框 chip 无 data-occurrence,但带 title=<label>:按 title 反查并跳转
1095
+ const titled = e.target && e.target.closest ? e.target.closest('[title]') : null
1096
+ if (!titled) return
1097
+ const title = titled.getAttribute('title')
1098
+ const ref = refByLabel(title)
1099
+ if (!ref) return
1100
+ report('chip:click', { via: 'title', ref: String(ref), title: String(title).slice(0, 40) })
1101
+ jumpToRef(ref, pos)
1102
+ } catch (err) {
1103
+ notify('跳转出错:' + (err && err.message ? err.message : String(err)), { left: e.clientX, top: e.clientY }, false)
1104
+ report('chip:click-throw', String((err && err.message) || err))
1105
+ }
1106
+ }
1107
+ // 悬停 chip:显示当时选中的文字
1108
+ const onChipOver = (e) => {
1109
+ try {
1110
+ const el = e.target && e.target.closest ? e.target.closest('[data-occurrence]') : null
1111
+ if (!el) {
1112
+ tooltipStore.hide()
1113
+ return
1114
+ }
1115
+ const occId = Number(el.getAttribute('data-occurrence'))
1116
+ if (!Number.isFinite(occId)) {
1117
+ tooltipStore.hide()
1118
+ return
1119
+ }
1120
+ const found = resolveOccurrence(occId)
1121
+ if (!found || !found.info || !found.info.body) {
1122
+ tooltipStore.hide()
1123
+ return
1124
+ }
1125
+ const r = el.getBoundingClientRect()
1126
+ tooltipStore.show({
1127
+ text: (found.info.body || '').replace(/^> /gm, '').trim(),
1128
+ label: found.info.label || '引用',
1129
+ rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
1130
+ })
1131
+ } catch (err) { /* ignore */ }
1132
+ }
1133
+ const onChipOut = (e) => {
1134
+ try {
1135
+ const el = e.target && e.target.closest ? e.target.closest('[data-occurrence]') : null
1136
+ if (!el) return
1137
+ const next = e.relatedTarget
1138
+ if (next && next.closest && next.closest('[data-occurrence]')) return
1139
+ tooltipStore.hide()
1140
+ } catch (err) { /* ignore */ }
1141
+ }
1142
+ const compute = () => {
1143
+ raf = 0
1144
+ try {
1145
+ const sel = window.getSelection()
1146
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
1147
+ // 选区消失:延迟隐藏,避免流式渲染抖动导致气泡闪没
1148
+ scheduleHide()
1149
+ return
1150
+ }
1151
+ const text = (sel.toString() || '').trim()
1152
+ if (!text) {
1153
+ scheduleHide()
1154
+ return
1155
+ }
1156
+ const range = sel.getRangeAt(0)
1157
+ const node = range.commonAncestorContainer
1158
+ const host = node && node.nodeType === 1 ? node : (node ? node.parentElement : null)
1159
+ if (host && host.closest('textarea, input, [contenteditable="true"]')) {
1160
+ scheduleHide()
1161
+ return
1162
+ }
1163
+ const rect = range.getBoundingClientRect()
1164
+ if (!rect || (rect.width === 0 && rect.height === 0)) {
1165
+ scheduleHide()
1166
+ return
1167
+ }
1168
+ // 选区滚出视口(自动滚动场景)才隐藏;仍在视口内则跟随重定位
1169
+ const vh = typeof window !== 'undefined' ? window.innerHeight : 768
1170
+ if (rect.bottom < -8 || rect.top > vh + 8) {
1171
+ scheduleHide()
1172
+ return
1173
+ }
1174
+ cancelHide()
1175
+ const anchor = sel.anchorNode && sel.anchorNode.nodeType === 1 ? sel.anchorNode : (sel.anchorNode ? sel.anchorNode.parentElement : null)
1176
+ let anchorKey = null
1177
+ let turn
1178
+ let kind = null
1179
+ if (anchor) {
1180
+ const row = anchor.closest('[data-chat-anchor-key]')
1181
+ if (row) {
1182
+ anchorKey = row.getAttribute('data-chat-anchor-key')
1183
+ // 位置信息:轮次 + 该行种类(用于给模型标注"这是哪一处")
1184
+ const rawTurn = row.getAttribute('data-chat-turn')
1185
+ if (rawTurn !== null && rawTurn !== '' && Number.isFinite(Number(rawTurn))) turn = Number(rawTurn)
1186
+ kind = row.getAttribute('data-chat-flow-kind') || null
1187
+ }
1188
+ }
1189
+ report('selection:show', { chars: text.length, anchorKey, turn, kind })
1190
+ selectionStore.show({
1191
+ text: clipText(text, MAX_SELECTION),
1192
+ rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
1193
+ anchorKey,
1194
+ turn,
1195
+ kind,
1196
+ })
1197
+ } catch (e) {
1198
+ scheduleHide()
1199
+ }
1200
+ }
1201
+ const schedule = () => {
1202
+ if (!raf) raf = requestAnimationFrame(compute)
1203
+ }
1204
+ document.addEventListener('selectionchange', schedule)
1205
+ // 滚动/缩放不再盲目隐藏:重新计算选区位置,跟随重定位(执行任务自动滚动时气泡不消失)
1206
+ document.addEventListener('scroll', onScroll, true)
1207
+ window.addEventListener('resize', onScroll)
1208
+ document.addEventListener('keydown', onKey)
1209
+ document.addEventListener('pointerdown', onChipPress, true)
1210
+ document.addEventListener('pointerover', onChipOver, true)
1211
+ document.addEventListener('pointerout', onChipOut, true)
1212
+ return () => {
1213
+ document.removeEventListener('selectionchange', schedule)
1214
+ document.removeEventListener('scroll', onScroll, true)
1215
+ window.removeEventListener('resize', onScroll)
1216
+ document.removeEventListener('keydown', onKey)
1217
+ document.removeEventListener('pointerdown', onChipPress, true)
1218
+ document.removeEventListener('pointerover', onChipOver, true)
1219
+ document.removeEventListener('pointerout', onChipOut, true)
1220
+ if (raf) cancelAnimationFrame(raf)
1221
+ if (hideTimer) clearTimeout(hideTimer)
1222
+ if (toastTimer) clearTimeout(toastTimer)
1223
+ }
1224
+ }, 'dsh-quote-annotate-pro: global listeners')
1225
+ } catch (e) {
1226
+ report('apply:listeners-throw', String((e && e.stack) || (e && e.message) || e))
1227
+ }
1228
+ const tryRegister = (tag, fn) => {
1229
+ try { fn(); report('register:ok', tag); return true } catch (e) {
1230
+ report('register:throw', tag + ' :: ' + String((e && e.stack) || (e && e.message) || e))
1231
+ return false
1232
+ }
1233
+ }
1234
+ // 注意:conversation.chat.turnTail 自 0.1.6-alpha.2 起是 list 槽(不是 chain),
1235
+ // 注册参数必须是 { name, id, order };上游用的 { select, priority } 在本版本会抛错,
1236
+ // 且它排在 4 个 overlay 注册之前 —— 一旦抛错就导致整个插件"静默失效"。
1237
+ tryRegister('dock:session-probe', () => slots.inject('conversation.input.dock', () => slots.register(
1238
+ { name: 'conversation.input.dock', id: 'quote-pro-session-probe', order: 1 },
1239
+ SessionProbe,
1240
+ )))
1241
+ tryRegister('dock:quote-nav', () => slots.inject('conversation.input.dock', () => slots.register(
1242
+ { name: 'conversation.input.dock', id: 'quote-pro-quote-dock', order: 2 },
1243
+ QuoteDock,
1244
+ )))
1245
+ tryRegister('turnTail', () => slots.inject('conversation.chat.turnTail', () => slots.register(
1246
+ { name: 'conversation.chat.turnTail', id: 'quote-turn-actions', order: 10 },
1247
+ TurnQuoteActions,
1248
+ )))
1249
+ tryRegister('overlay:toolbar', () => slots.inject('shell.overlay', () => slots.register(
1250
+ { name: 'shell.overlay', id: 'quote-pro-selection-toolbar', order: 100 },
1251
+ SelectionToolbar,
1252
+ )))
1253
+ tryRegister('overlay:editor', () => slots.inject('shell.overlay', () => slots.register(
1254
+ { name: 'shell.overlay', id: 'quote-pro-annotation-editor', order: 101 },
1255
+ AnnotationEditor,
1256
+ )))
1257
+ tryRegister('overlay:tooltip', () => slots.inject('shell.overlay', () => slots.register(
1258
+ { name: 'shell.overlay', id: 'quote-pro-tooltip', order: 103 },
1259
+ QuoteTooltip,
1260
+ )))
1261
+ tryRegister('overlay:toast', () => slots.inject('shell.overlay', () => slots.register(
1262
+ { name: 'shell.overlay', id: 'quote-pro-feedback-toast', order: 102 },
1263
+ FeedbackToast,
1264
+ )))
1265
+ report('apply:done', { pipelineAvailable })
1266
+ }
1267
+
1268
+ // Cordis 客户端插件:声明依赖让 apply 在 slots 服务就绪后才执行
1269
+ // (上游缺少这一行,slots 未就绪时 apply 第 534 行静默 return → 全插件失效)
1270
+ exports.name = 'dsh-quote-annotate-pro'
1271
+ exports.inject = ['slots']
1272
+ exports.apply = apply
1273
+ return module.exports
1274
+ },
1275
+ })