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/CHANGELOG.md +43 -0
- package/LICENSE +22 -0
- package/README.en.md +85 -0
- package/README.md +158 -2
- package/SUMMARY.md +143 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1275 -0
- package/lib/index.js +6 -0
- package/package.json +51 -4
- package/tools/check-manifest.mjs +61 -0
- package/tools/diag-sink.mjs +56 -0
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
|
+
})
|