@ash-qw/dsh-theme-prts 1.3.1 → 1.4.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 +21 -0
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/THIRD_PARTY_NOTICES.zh-CN.md +7 -0
- package/lib/client.js +1010 -31
- package/package.json +3 -2
- package/src/client/facility-geometry.js +14 -10
- package/src/client/facility-vector.js +1 -1
- package/src/client/index.js +3 -1
- package/src/client/preferences.js +18 -1
- package/src/client/preview-markdown.js +44 -0
- package/src/client/session-summary-preview.js +599 -0
- package/src/client/sidebar-control-adapter.js +149 -14
- package/src/client/theme-settings-workbench.js +43 -2
- package/src/styles/facility-vector.css +96 -3
- package/src/styles/prts.css +3 -9
- package/src/styles/refinement.css +10 -10
- package/src/styles/theme-settings-workbench.css +27 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { createPreviewContentCache } from './preview-markdown.js'
|
|
2
|
+
export function canAnswerPreview(pending) {
|
|
3
|
+
if (pending?.kind === 'approval') return pending.answerable === true && typeof pending.answer === 'function'
|
|
4
|
+
const question = pending?.questions?.[0]
|
|
5
|
+
return pending?.kind === 'question' && pending.questions.length === 1
|
|
6
|
+
&& typeof pending.answer === 'function' && typeof question?.id === 'string'
|
|
7
|
+
&& !question.intent && !question.detail && question.options?.length > 0
|
|
8
|
+
&& !pending.getSnapshot?.().closed
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Pending choices are separate from assistant prose in DSH.
|
|
12
|
+
export function pendingQuestionPreview(pending) {
|
|
13
|
+
const questions = pending.questions ?? []
|
|
14
|
+
const text = questions.slice(0, 2).map(item => {
|
|
15
|
+
const question = (item.question ?? '').slice(0, 80)
|
|
16
|
+
const options = (item.options ?? []).slice(0, 4).map(option => option.label).filter(Boolean).join(' / ')
|
|
17
|
+
return [question, options].filter(Boolean).join(':')
|
|
18
|
+
}).join(';')
|
|
19
|
+
return text ? text.slice(0, 240) : '有待回答的问题,请打开会话查看'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Read the host's assembled Chat view rather than interpreting raw stream events.
|
|
23
|
+
export function latestAssistantReply(snapshot) {
|
|
24
|
+
const order = snapshot?.order ?? []
|
|
25
|
+
const latestTurn = snapshot?.timeline?.turnOrder?.at(-1)
|
|
26
|
+
for (let i = order.length - 1; i >= 0; i--) {
|
|
27
|
+
const node = snapshot.nodes?.get?.(order[i])
|
|
28
|
+
// Never search past a new user input, even before turn/start arrives.
|
|
29
|
+
if (['user', 'steering', 'turn-trigger'].includes(node?.kind)) return ''
|
|
30
|
+
if (latestTurn !== undefined && node?.location?.turn?.turn !== latestTurn) continue
|
|
31
|
+
if (node?.kind !== 'assistant-step' || node.visibility === 'hidden') continue
|
|
32
|
+
let text = ''
|
|
33
|
+
for (const block of node.data?.blocks ?? []) {
|
|
34
|
+
if (block.kind === 'text') text += block.text ?? ''
|
|
35
|
+
}
|
|
36
|
+
text = text.trim()
|
|
37
|
+
if (text) return text
|
|
38
|
+
}
|
|
39
|
+
return ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function completedPreviewReplies(snapshot) {
|
|
43
|
+
const replies = []
|
|
44
|
+
const latestTurn = snapshot?.timeline?.turnOrder?.at(-1)
|
|
45
|
+
const order = snapshot?.order ?? []
|
|
46
|
+
for (let i = order.length - 1; i >= 0; i--) {
|
|
47
|
+
const key = order[i]
|
|
48
|
+
const node = snapshot.nodes?.get?.(key)
|
|
49
|
+
if (['user', 'steering', 'turn-trigger'].includes(node?.kind)) break
|
|
50
|
+
if (latestTurn !== undefined && node?.location?.turn?.turn !== latestTurn) continue
|
|
51
|
+
if (node?.kind !== 'assistant-step' || node.visibility === 'hidden' || node.data?.status !== 'settled') continue
|
|
52
|
+
const text = (node.data.blocks ?? []).filter(block => block.kind === 'text').map(block => block.text ?? '').join('').trim()
|
|
53
|
+
if (text) replies.push({key, text})
|
|
54
|
+
}
|
|
55
|
+
return replies.reverse()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Ignore streaming text: only settled prose and turn boundaries affect this list.
|
|
59
|
+
// Cache immutable host data; mutable test/older-host data is read on each check.
|
|
60
|
+
export function createPreviewReplySelector() {
|
|
61
|
+
const textCache = new WeakMap()
|
|
62
|
+
let previous = [], previousTurn, previousBoundary
|
|
63
|
+
return (snapshot, limit = 5) => {
|
|
64
|
+
const order = snapshot?.order ?? []
|
|
65
|
+
const turn = snapshot?.timeline?.turnOrder?.at(-1)
|
|
66
|
+
const replies = []
|
|
67
|
+
let boundary
|
|
68
|
+
for (let i = order.length - 1; i >= 0; i--) {
|
|
69
|
+
const key = order[i], node = snapshot.nodes?.get?.(key)
|
|
70
|
+
if (['user', 'steering', 'turn-trigger'].includes(node?.kind)) { boundary = key; break }
|
|
71
|
+
if (turn !== undefined && node?.location?.turn?.turn !== turn) continue
|
|
72
|
+
if (node?.kind !== 'assistant-step' || node.visibility === 'hidden' || node.data?.status !== 'settled') continue
|
|
73
|
+
let text = textCache.get(node.data)
|
|
74
|
+
if (text === undefined) {
|
|
75
|
+
text = (node.data.blocks ?? []).filter(block => block.kind === 'text').map(block => block.text ?? '').join('').trim()
|
|
76
|
+
if (Object.isFrozen(node.data)) textCache.set(node.data, text)
|
|
77
|
+
}
|
|
78
|
+
if (text) replies.push({key, text})
|
|
79
|
+
}
|
|
80
|
+
replies.reverse()
|
|
81
|
+
const selected = (limit > 0 ? replies.slice(-limit) : replies)
|
|
82
|
+
.map((reply, index) => ({...reply, ordinal: replies.length - (limit > 0 ? Math.min(limit, replies.length) : replies.length) + index + 1}))
|
|
83
|
+
if (turn === previousTurn && boundary === previousBoundary && selected.length === previous.length && selected.every((reply, i) => reply.key === previous[i].key && reply.text === previous[i].text && reply.ordinal === previous[i].ordinal)) return previous
|
|
84
|
+
previousTurn = turn
|
|
85
|
+
previousBoundary = boundary
|
|
86
|
+
return previous = selected
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function previewStatusFields(status) {
|
|
91
|
+
const pending = status?.pendingInteraction
|
|
92
|
+
return [status?.running, status?.completionUnread, pending, pending?.answer,
|
|
93
|
+
JSON.stringify([pending?.kind, pending?.answerable, pending?.getSnapshot?.().closed,
|
|
94
|
+
pending?.questions, pending?.toolName, pending?.reason, pending?.displayReason])]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createSessionSummaryPreview({ document, window, sessions, getSessions, getUiConversation, getSessionStatus, openSession, getPreviewPreferences, onPreviewClosed, onAnswerSubmitted }) {
|
|
98
|
+
let active
|
|
99
|
+
function follows(state) {
|
|
100
|
+
const preferences = getPreviewPreferences?.() ?? {}
|
|
101
|
+
return state === 'warning' ? preferences.previewFollowWarning === true : preferences.previewFollowOngoing !== false
|
|
102
|
+
}
|
|
103
|
+
// Shared across running, pending and completed previews. Active ownership is
|
|
104
|
+
// outside the LRU so it can never be evicted by an idle timeout.
|
|
105
|
+
const warm = new Map()
|
|
106
|
+
const cacheLimit = 5
|
|
107
|
+
function releaseWarm(id) {
|
|
108
|
+
const entry = warm.get(id)
|
|
109
|
+
if (!entry) return
|
|
110
|
+
warm.delete(id)
|
|
111
|
+
window.clearTimeout(entry.timer)
|
|
112
|
+
entry.reference.release()
|
|
113
|
+
}
|
|
114
|
+
function clearWarm() {
|
|
115
|
+
for (const id of warm.keys()) releaseWarm(id)
|
|
116
|
+
}
|
|
117
|
+
function trimWarm(limit) {
|
|
118
|
+
while (warm.size > limit) releaseWarm(warm.keys().next().value)
|
|
119
|
+
}
|
|
120
|
+
function stop(force = true, keepWarm = false) {
|
|
121
|
+
if (!force) return
|
|
122
|
+
if (!keepWarm) clearWarm()
|
|
123
|
+
const old = active
|
|
124
|
+
active = undefined
|
|
125
|
+
if (!old) return
|
|
126
|
+
window.clearTimeout(old.leaveTimer)
|
|
127
|
+
window.clearTimeout(old.delay)
|
|
128
|
+
window.clearTimeout(old.refresh)
|
|
129
|
+
old.unsubscribe?.()
|
|
130
|
+
old.unsubscribeStatus?.()
|
|
131
|
+
if (keepWarm && old.reference && old.source) {
|
|
132
|
+
releaseWarm(old.id)
|
|
133
|
+
const entry = { reference: old.reference, source: old.source, contentCache: old.contentCache }
|
|
134
|
+
entry.timer = window.setTimeout(() => {
|
|
135
|
+
if (warm.get(old.id) === entry) releaseWarm(old.id)
|
|
136
|
+
}, 30000)
|
|
137
|
+
warm.set(old.id, entry)
|
|
138
|
+
trimWarm(cacheLimit)
|
|
139
|
+
} else old.reference?.release()
|
|
140
|
+
old.panel.hidePopover?.()
|
|
141
|
+
old.panel.hidden = true
|
|
142
|
+
old.panel.removeAttribute('data-prts-preview-editing')
|
|
143
|
+
old.panel.setAttribute('role', 'note')
|
|
144
|
+
old.cleanupEditor?.()
|
|
145
|
+
old.cleanupHover?.()
|
|
146
|
+
old.detail?.hidePopover?.()
|
|
147
|
+
old.detail?.remove()
|
|
148
|
+
old.group?.removeAttribute('data-prts-preview-open')
|
|
149
|
+
old.anchor?.removeAttribute('data-prts-preview-active')
|
|
150
|
+
if (old.detached) {
|
|
151
|
+
old.panel.remove()
|
|
152
|
+
if (old.originalParent?.isConnected) old.originalParent.append(old.panel)
|
|
153
|
+
}
|
|
154
|
+
onPreviewClosed?.()
|
|
155
|
+
}
|
|
156
|
+
function show(id, panel, title, anchor) {
|
|
157
|
+
if (active?.id === id) {
|
|
158
|
+
window.clearTimeout(active.leaveTimer)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
stop(true, true)
|
|
162
|
+
const cached = warm.get(id)
|
|
163
|
+
if (cached) { window.clearTimeout(cached.timer); warm.delete(id) }
|
|
164
|
+
// Reserve one slot for the active preview, including a pending acquisition.
|
|
165
|
+
trimWarm(cacheLimit - 1)
|
|
166
|
+
const current = { id, panel, anchor, originalParent: panel.parentElement,
|
|
167
|
+
group: anchor?.closest('[data-prts-session-summary-group]') }
|
|
168
|
+
current.group?.setAttribute('data-prts-preview-open', '')
|
|
169
|
+
anchor?.setAttribute('data-prts-preview-active', '')
|
|
170
|
+
active = current
|
|
171
|
+
current.contentCache = cached?.contentCache ?? createPreviewContentCache(panel.ownerDocument)
|
|
172
|
+
if (cached) { current.reference = cached.reference; current.source = cached.source }
|
|
173
|
+
const keep = () => window.clearTimeout(current.leaveTimer)
|
|
174
|
+
const onDoubleClick = event => {
|
|
175
|
+
if (active !== current || event.button !== 0 || getPreviewPreferences?.().previewDoubleClick === false) return
|
|
176
|
+
if (event.target.closest?.('button, a, input, textarea, select, label, summary, [role="button"], [role="link"], [contenteditable]:not([contenteditable="false"])')) return
|
|
177
|
+
event.preventDefault()
|
|
178
|
+
event.stopPropagation()
|
|
179
|
+
stop()
|
|
180
|
+
openSession?.(id)
|
|
181
|
+
}
|
|
182
|
+
const dismissSticky = event => {
|
|
183
|
+
if (!current.sticky) return
|
|
184
|
+
if (event.type === 'keydown') {
|
|
185
|
+
if (event.key !== 'Escape') return
|
|
186
|
+
event.preventDefault()
|
|
187
|
+
} else if (panel.contains(event.target) || current.anchor?.contains(event.target)) return
|
|
188
|
+
stop()
|
|
189
|
+
}
|
|
190
|
+
panel.ownerDocument.addEventListener('pointerdown', dismissSticky)
|
|
191
|
+
panel.ownerDocument.addEventListener('keydown', dismissSticky)
|
|
192
|
+
panel.addEventListener('dblclick', onDoubleClick)
|
|
193
|
+
panel.addEventListener('pointerenter', keep)
|
|
194
|
+
panel.addEventListener('pointerleave', leave)
|
|
195
|
+
current.cleanupHover = () => {
|
|
196
|
+
panel.ownerDocument.removeEventListener('pointerdown', dismissSticky)
|
|
197
|
+
panel.ownerDocument.removeEventListener('keydown', dismissSticky)
|
|
198
|
+
panel.removeEventListener('dblclick', onDoubleClick)
|
|
199
|
+
panel.removeEventListener('pointerenter', keep)
|
|
200
|
+
panel.removeEventListener('pointerleave', leave)
|
|
201
|
+
}
|
|
202
|
+
panel.removeAttribute('data-prts-preview-editing')
|
|
203
|
+
panel.setAttribute('role', 'note')
|
|
204
|
+
panel.hidden = false
|
|
205
|
+
if (anchor) {
|
|
206
|
+
const list = anchor.closest('[data-prts-session-summary-popover]').getBoundingClientRect()
|
|
207
|
+
const row = anchor.getBoundingClientRect()
|
|
208
|
+
panel.style.left = (list.right + 10) + 'px'
|
|
209
|
+
panel.style.top = Math.max(8, Math.min(row.top, window.innerHeight - 230)) + 'px'
|
|
210
|
+
}
|
|
211
|
+
panel.ownerDocument.body.append(panel)
|
|
212
|
+
current.detached = true
|
|
213
|
+
panel.showPopover?.()
|
|
214
|
+
const label = panel.querySelector('strong')
|
|
215
|
+
let sessionTitle = panel.querySelector('[data-prts-preview-session-title]')
|
|
216
|
+
if (!sessionTitle) {
|
|
217
|
+
sessionTitle = panel.ownerDocument.createElement('div')
|
|
218
|
+
sessionTitle.setAttribute('data-prts-preview-session-title', '')
|
|
219
|
+
panel.prepend(sessionTitle)
|
|
220
|
+
}
|
|
221
|
+
sessionTitle.textContent = title
|
|
222
|
+
sessionTitle.title = title
|
|
223
|
+
let body = panel.querySelector('[data-prts-preview-body]')
|
|
224
|
+
if (!body) {
|
|
225
|
+
body = panel.ownerDocument.createElement('div')
|
|
226
|
+
body.setAttribute('data-prts-preview-body', '')
|
|
227
|
+
panel.querySelector('p').replaceWith(body)
|
|
228
|
+
}
|
|
229
|
+
const choices = panel.ownerDocument.createElement('div')
|
|
230
|
+
choices.setAttribute('data-prts-preview-choices', '')
|
|
231
|
+
panel.querySelector('[data-prts-preview-choices]')?.remove()
|
|
232
|
+
panel.append(choices)
|
|
233
|
+
panel.querySelector('[data-prts-preview-actions]')?.remove()
|
|
234
|
+
const actions = panel.ownerDocument.createElement('div')
|
|
235
|
+
actions.setAttribute('data-prts-preview-actions', '')
|
|
236
|
+
panel.append(actions)
|
|
237
|
+
const pendingNow = () => getSessionStatus?.()?.getSnapshot().get(id)?.pendingInteraction
|
|
238
|
+
const button = (text, action) => {
|
|
239
|
+
const el = panel.ownerDocument.createElement('button')
|
|
240
|
+
el.type = 'button'
|
|
241
|
+
el.textContent = text
|
|
242
|
+
el.addEventListener('click', action)
|
|
243
|
+
return el
|
|
244
|
+
}
|
|
245
|
+
const closeSticky = button('关闭预览', () => stop())
|
|
246
|
+
const enter = button('进入会话', () => { stop(); openSession?.(id) })
|
|
247
|
+
const enableAnswer = () => {
|
|
248
|
+
const pending = pendingNow()
|
|
249
|
+
if (!canAnswerPreview(pending)) return
|
|
250
|
+
current.editor = true
|
|
251
|
+
// Detach from the hover menu before promoting again to the top layer.
|
|
252
|
+
panel.setAttribute('data-prts-preview-editing', '')
|
|
253
|
+
panel.setAttribute('role', 'dialog')
|
|
254
|
+
|
|
255
|
+
const approval = pending.kind === 'approval'
|
|
256
|
+
const question = approval ? {
|
|
257
|
+
options: [{label:'仅允许本次'}, {label:'拒绝'}]
|
|
258
|
+
} : pending.questions[0]
|
|
259
|
+
const submitLabel = approval ? '提交审批' : '提交回答'
|
|
260
|
+
const selected = new Set()
|
|
261
|
+
let busy = false
|
|
262
|
+
const message = panel.ownerDocument.createElement('small')
|
|
263
|
+
message.setAttribute('role', 'status')
|
|
264
|
+
const submit = button(submitLabel, async () => {
|
|
265
|
+
if (busy || !selected.size) return
|
|
266
|
+
if (pendingNow() !== pending || pending.getSnapshot?.().closed || (approval && !pending.answerable)) {
|
|
267
|
+
message.textContent = '问题状态已变化,请重新打开预览。'
|
|
268
|
+
submit.disabled = true
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
busy = true
|
|
272
|
+
submit.disabled = true
|
|
273
|
+
submit.textContent = '正在提交…'
|
|
274
|
+
try {
|
|
275
|
+
const channel = pending.getSnapshot?.().channel
|
|
276
|
+
await pending.answer(approval
|
|
277
|
+
? selected.has('仅允许本次') ? 'allowed-once' : 'rejected'
|
|
278
|
+
: {answers:[{id:question.id,selected:question.options.map(o=>o.label).filter(value=>selected.has(value))}]})
|
|
279
|
+
if (active === current && !follows('warning')) {
|
|
280
|
+
stop(true, true)
|
|
281
|
+
onAnswerSubmitted?.('warning')
|
|
282
|
+
} else if (active === current) {
|
|
283
|
+
current.sticky = true
|
|
284
|
+
keep()
|
|
285
|
+
current.answeredPending = pending
|
|
286
|
+
current.cleanupEditor?.()
|
|
287
|
+
current.cleanupEditor = undefined
|
|
288
|
+
current.validate = undefined
|
|
289
|
+
current.editor = false
|
|
290
|
+
panel.removeAttribute('data-prts-preview-editing')
|
|
291
|
+
panel.setAttribute('role', 'note')
|
|
292
|
+
render()
|
|
293
|
+
}
|
|
294
|
+
if (channel === 'rpc') await pending.dismiss?.()
|
|
295
|
+
} catch {
|
|
296
|
+
if (active !== current) return
|
|
297
|
+
busy = false
|
|
298
|
+
submit.disabled = false
|
|
299
|
+
submit.textContent = submitLabel
|
|
300
|
+
message.textContent = '提交失败,请重试或进入会话处理。'
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
submit.disabled = true
|
|
304
|
+
choices.replaceChildren(...question.options.map(option => {
|
|
305
|
+
const choice = button(option.label, () => {
|
|
306
|
+
if (busy) return
|
|
307
|
+
if (question.multiSelect) {
|
|
308
|
+
if (selected.has(option.label)) selected.delete(option.label)
|
|
309
|
+
else selected.add(option.label)
|
|
310
|
+
} else {
|
|
311
|
+
selected.clear()
|
|
312
|
+
selected.add(option.label)
|
|
313
|
+
}
|
|
314
|
+
pending.engage?.()
|
|
315
|
+
for (const el of choices.children) el.setAttribute('aria-pressed', String(selected.has(el.dataset.value)))
|
|
316
|
+
submit.disabled = !selected.size
|
|
317
|
+
})
|
|
318
|
+
choice.dataset.value = option.label
|
|
319
|
+
choice.setAttribute('aria-pressed','false')
|
|
320
|
+
if (option.description) {
|
|
321
|
+
const detail = panel.ownerDocument.createElement('small')
|
|
322
|
+
detail.textContent = option.description
|
|
323
|
+
choice.append(detail)
|
|
324
|
+
}
|
|
325
|
+
return choice
|
|
326
|
+
}))
|
|
327
|
+
choices.hidden = false
|
|
328
|
+
actions.replaceChildren(button('取消', () => stop()), enter, submit, message)
|
|
329
|
+
const onKey = event => { if (event.key === 'Escape') { event.preventDefault(); stop() } }
|
|
330
|
+
panel.addEventListener('keydown', onKey)
|
|
331
|
+
const onEnter = () => pending.holdFocus?.()
|
|
332
|
+
const onOutside = event => { if (!panel.contains(event.target) && !anchor?.contains(event.target)) stop() }
|
|
333
|
+
panel.addEventListener('pointerenter', onEnter)
|
|
334
|
+
panel.ownerDocument.addEventListener('pointerdown', onOutside)
|
|
335
|
+
current.cleanupEditor = () => {
|
|
336
|
+
pending.releaseFocus?.()
|
|
337
|
+
panel.removeEventListener('keydown', onKey)
|
|
338
|
+
panel.removeEventListener('pointerenter', onEnter)
|
|
339
|
+
panel.ownerDocument.removeEventListener('pointerdown', onOutside)
|
|
340
|
+
}
|
|
341
|
+
current.validate = () => {
|
|
342
|
+
if (!busy && (pendingNow() !== pending || (approval && !pending.answerable))) {
|
|
343
|
+
message.textContent = '问题已更新或结束,请进入会话查看。'
|
|
344
|
+
submit.disabled = true
|
|
345
|
+
for (const el of choices.children) el.disabled = true
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
let lastChoices = ''
|
|
350
|
+
let source = cached?.source
|
|
351
|
+
let previousText
|
|
352
|
+
let previousQuestion
|
|
353
|
+
let historyKeys = []
|
|
354
|
+
const selectReplies = createPreviewReplySelector()
|
|
355
|
+
const replyLimit = () => getPreviewPreferences?.().previewReplyLimit ?? 5
|
|
356
|
+
let detailKey
|
|
357
|
+
let detailEntry
|
|
358
|
+
const hideDetail = () => {
|
|
359
|
+
current.detail?.hidePopover?.()
|
|
360
|
+
if (current.detail) current.detail.hidden = true
|
|
361
|
+
detailKey = undefined
|
|
362
|
+
detailEntry?.removeAttribute('data-prts-reply-active')
|
|
363
|
+
detailEntry = undefined
|
|
364
|
+
}
|
|
365
|
+
const showDetail = (reply, index, item) => {
|
|
366
|
+
keep()
|
|
367
|
+
if (detailKey === reply.key && current.detail && !current.detail.hidden) return
|
|
368
|
+
if (!current.detail) {
|
|
369
|
+
const detail = panel.ownerDocument.createElement('section')
|
|
370
|
+
detail.setAttribute('data-prts-summary-reply', '')
|
|
371
|
+
detail.setAttribute('data-prts-reply-detail', '')
|
|
372
|
+
detail.setAttribute('popover', 'manual')
|
|
373
|
+
detail.setAttribute('role', 'note')
|
|
374
|
+
const heading = panel.ownerDocument.createElement('strong')
|
|
375
|
+
const content = panel.ownerDocument.createElement('div')
|
|
376
|
+
content.setAttribute('data-prts-preview-body', '')
|
|
377
|
+
detail.append(heading, content)
|
|
378
|
+
panel.append(detail)
|
|
379
|
+
current.detail = detail
|
|
380
|
+
}
|
|
381
|
+
const detail = current.detail
|
|
382
|
+
detailKey = reply.key
|
|
383
|
+
detailEntry?.removeAttribute('data-prts-reply-active')
|
|
384
|
+
detailEntry = item
|
|
385
|
+
item?.setAttribute('data-prts-reply-active', '')
|
|
386
|
+
detail.querySelector('strong').textContent = '回复 ' + (reply.ordinal ?? index + 1)
|
|
387
|
+
const content = detail.querySelector('[data-prts-preview-body]')
|
|
388
|
+
content.innerHTML = current.contentCache(reply.text).html
|
|
389
|
+
content.scrollTop = 0
|
|
390
|
+
detail.hidden = false
|
|
391
|
+
const rect = panel.getBoundingClientRect()
|
|
392
|
+
const width = Math.min(340, window.innerWidth - 16)
|
|
393
|
+
detail.style.width = width + 'px'
|
|
394
|
+
const beside = rect.right + 10 + width <= window.innerWidth - 8
|
|
395
|
+
detail.style.left = (beside ? rect.right + 10 : Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))) + 'px'
|
|
396
|
+
detail.style.top = Math.max(8, Math.min(beside ? rect.top : rect.bottom + 8, window.innerHeight - 240)) + 'px'
|
|
397
|
+
detail.showPopover?.()
|
|
398
|
+
}
|
|
399
|
+
const render = () => {
|
|
400
|
+
if (active !== current) return
|
|
401
|
+
if (current.editor) { current.validate?.(); return }
|
|
402
|
+
const status = getSessionStatus?.()?.getSnapshot().get(id)
|
|
403
|
+
const pending = status?.pendingInteraction === current.answeredPending
|
|
404
|
+
? undefined : status?.pendingInteraction
|
|
405
|
+
const approval = pending?.kind === 'approval'
|
|
406
|
+
const question = approval || pending?.kind === 'question' || pending?.kind === 'plan-review'
|
|
407
|
+
const state = question ? 'warning' : status?.running || (current.answeredPending && status?.pendingInteraction === current.answeredPending) ? 'ongoing'
|
|
408
|
+
: status?.completionUnread || status?.running === false ? 'done' : current.group?.getAttribute('data-prts-session-summary-group') || ''
|
|
409
|
+
if (current.state && current.state !== state && !follows(current.state)) return
|
|
410
|
+
if (current.state && current.state !== state) {
|
|
411
|
+
current.sticky = true
|
|
412
|
+
keep()
|
|
413
|
+
}
|
|
414
|
+
current.state = state
|
|
415
|
+
panel.setAttribute('data-prts-preview-state', state)
|
|
416
|
+
label.textContent = approval ? '等待审批 · 仅本次授权' : question ? '等待你的选择' : state === 'ongoing' ? '运行中 · 已完成正文' : state === 'done' ? '已完成 · 最后回复' : '最新 AI 回复'
|
|
417
|
+
panel.toggleAttribute('data-prts-preview-question', question)
|
|
418
|
+
if (question) {
|
|
419
|
+
if (!actions.contains(enter)) actions.replaceChildren(enter)
|
|
420
|
+
} else if (current.sticky) {
|
|
421
|
+
if (!actions.contains(closeSticky)) actions.replaceChildren(closeSticky)
|
|
422
|
+
} else actions.replaceChildren()
|
|
423
|
+
const first = question ? pending.questions?.[0] : undefined
|
|
424
|
+
const labels = (first?.options ?? []).slice(0, 3).map(option => option.label)
|
|
425
|
+
const remaining = Math.max(0, (first?.options?.length ?? 0) - 3)
|
|
426
|
+
if (remaining) labels.push('另有 ' + remaining + ' 个选项')
|
|
427
|
+
if ((pending?.questions?.length ?? 0) > 1) labels.push('共 ' + pending.questions.length + ' 个问题')
|
|
428
|
+
const signature = JSON.stringify(labels)
|
|
429
|
+
if (signature !== lastChoices) {
|
|
430
|
+
lastChoices = signature
|
|
431
|
+
choices.replaceChildren(...labels.map(text => {
|
|
432
|
+
const option = panel.ownerDocument.createElement('span')
|
|
433
|
+
option.textContent = text
|
|
434
|
+
return option
|
|
435
|
+
}))
|
|
436
|
+
}
|
|
437
|
+
choices.hidden = !labels.length
|
|
438
|
+
panel.setAttribute('aria-label', title + ':' + label.textContent)
|
|
439
|
+
if (state === 'ongoing' && source) {
|
|
440
|
+
const replies = selectReplies(source.getSnapshot(), replyLimit())
|
|
441
|
+
if (replies === historyKeys) return
|
|
442
|
+
panel.toggleAttribute('data-prts-preview-empty', !replies.length)
|
|
443
|
+
const previousTop = body.scrollTop
|
|
444
|
+
const scrollAnchor = previousTop > 0 ? [...body.children].find(entry => entry.getBoundingClientRect().bottom > body.getBoundingClientRect().top) : undefined
|
|
445
|
+
const anchorTop = scrollAnchor?.getBoundingClientRect().top
|
|
446
|
+
const retained = new Map(historyKeys.map(reply => [reply.key, reply]))
|
|
447
|
+
for (const old of historyKeys) {
|
|
448
|
+
const replacement = replies.find(reply => reply.key === old.key)
|
|
449
|
+
if (!replacement || replacement.text !== old.text || replacement.ordinal !== old.ordinal) {
|
|
450
|
+
body.querySelectorAll('[data-prts-preview-message]').forEach(entry => {
|
|
451
|
+
if (entry.dataset.replyKey === old.key) entry.remove()
|
|
452
|
+
})
|
|
453
|
+
retained.delete(old.key)
|
|
454
|
+
if (detailKey === old.key) hideDetail()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (!historyKeys.length) body.replaceChildren()
|
|
458
|
+
if (!replies.length) body.textContent = '正在处理,等待本轮正文输出完成…'
|
|
459
|
+
for (let index = 0; index < replies.length; index++) {
|
|
460
|
+
const reply = replies[index]
|
|
461
|
+
if (retained.has(reply.key)) continue
|
|
462
|
+
const entry = panel.ownerDocument.createElement('article')
|
|
463
|
+
entry.setAttribute('data-prts-preview-message', '')
|
|
464
|
+
entry.dataset.replyKey = reply.key
|
|
465
|
+
const item = panel.ownerDocument.createElement('button')
|
|
466
|
+
item.type = 'button'
|
|
467
|
+
item.setAttribute('data-prts-reply-entry', '')
|
|
468
|
+
const heading = panel.ownerDocument.createElement('small')
|
|
469
|
+
heading.textContent = '回复 ' + reply.ordinal
|
|
470
|
+
const summary = panel.ownerDocument.createElement('span')
|
|
471
|
+
summary.textContent = current.contentCache(reply.text).summary
|
|
472
|
+
item.append(heading, summary)
|
|
473
|
+
item.addEventListener('pointermove', () => showDetail(reply, index, item))
|
|
474
|
+
item.addEventListener('focus', () => showDetail(reply, index, item))
|
|
475
|
+
item.addEventListener('click', () => showDetail(reply, index, item))
|
|
476
|
+
entry.append(item)
|
|
477
|
+
body.prepend(entry)
|
|
478
|
+
}
|
|
479
|
+
// Reorder only when necessary, preserving retained DOM and open details.
|
|
480
|
+
const entries = new Map([...body.children].map(entry => [entry.dataset.replyKey, entry]))
|
|
481
|
+
for (let i = replies.length - 1, position = 0; i >= 0; i--, position++) {
|
|
482
|
+
const entry = entries.get(replies[i].key)
|
|
483
|
+
if (body.children[position] !== entry) body.insertBefore(entry, body.children[position] ?? null)
|
|
484
|
+
}
|
|
485
|
+
if (scrollAnchor?.isConnected) body.scrollTop += scrollAnchor.getBoundingClientRect().top - anchorTop
|
|
486
|
+
else if (previousTop === 0) body.scrollTop = 0
|
|
487
|
+
historyKeys = replies
|
|
488
|
+
previousText = undefined
|
|
489
|
+
previousQuestion = undefined
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
hideDetail()
|
|
493
|
+
historyKeys = []
|
|
494
|
+
const excerpt = source ? latestAssistantReply(source.getSnapshot()) : ''
|
|
495
|
+
panel.toggleAttribute('data-prts-preview-empty', !question && !excerpt)
|
|
496
|
+
const text = question
|
|
497
|
+
? approval
|
|
498
|
+
? [pending.toolName, pending.displayReason?.['zh-CN'] || pending.displayReason?.zh || pending.reason || pending.displayReason?.en || '请进入会话查看操作详情'].filter(Boolean).join(':')
|
|
499
|
+
: first?.question || pendingQuestionPreview(pending)
|
|
500
|
+
: source ? excerpt || (state === 'ongoing' ? '正在处理,尚未输出正文…' : '暂无正文回复,可进入会话查看执行记录。') : '正在读取…'
|
|
501
|
+
if (previousText !== text || previousQuestion !== question) {
|
|
502
|
+
const extendsReply = previousQuestion === question && previousText && text.startsWith(previousText)
|
|
503
|
+
if (question) body.textContent = text
|
|
504
|
+
else body.innerHTML = current.contentCache(text).html
|
|
505
|
+
previousText = text
|
|
506
|
+
previousQuestion = question
|
|
507
|
+
if (!extendsReply) body.scrollTop = 0
|
|
508
|
+
}
|
|
509
|
+
if (canAnswerPreview(pending)) enableAnswer()
|
|
510
|
+
}
|
|
511
|
+
let statusFields = previewStatusFields(getSessionStatus?.()?.getSnapshot().get(id))
|
|
512
|
+
current.unsubscribeStatus = getSessionStatus?.()?.subscribe(() => {
|
|
513
|
+
const next = previewStatusFields(getSessionStatus?.()?.getSnapshot().get(id))
|
|
514
|
+
if (next.every((value, index) => value === statusFields[index])) return
|
|
515
|
+
statusFields = next
|
|
516
|
+
render()
|
|
517
|
+
})
|
|
518
|
+
render()
|
|
519
|
+
body.scrollTop = 0
|
|
520
|
+
current.delay = window.setTimeout(async () => {
|
|
521
|
+
try {
|
|
522
|
+
const service = getUiConversation?.()
|
|
523
|
+
const controller = getSessions?.() ?? sessions
|
|
524
|
+
if (!controller?.retain || !service?.binding) throw new Error('unavailable')
|
|
525
|
+
if (!current.reference) current.reference = controller.retain(id, { source: 'prtsSummaryPreview' })
|
|
526
|
+
const binding = await current.reference.ready
|
|
527
|
+
if (active !== current) return
|
|
528
|
+
source ??= service.binding(binding).target('chat')
|
|
529
|
+
current.source = source
|
|
530
|
+
current.unsubscribe = source.subscribe(() => {
|
|
531
|
+
if (active !== current || current.refresh !== undefined) return
|
|
532
|
+
current.refresh = window.setTimeout(() => {
|
|
533
|
+
current.refresh = undefined
|
|
534
|
+
if (current.state === 'ongoing' && !current.editor && selectReplies(source.getSnapshot(), replyLimit()) === historyKeys) return
|
|
535
|
+
render()
|
|
536
|
+
}, 120)
|
|
537
|
+
})
|
|
538
|
+
render()
|
|
539
|
+
} catch {
|
|
540
|
+
if (active !== current) return
|
|
541
|
+
current.unsubscribe?.()
|
|
542
|
+
current.unsubscribe = undefined
|
|
543
|
+
current.reference?.release()
|
|
544
|
+
current.reference = undefined
|
|
545
|
+
render()
|
|
546
|
+
if (body.textContent.trim() === '正在读取…') body.textContent = '暂时无法读取回复,可点击会话查看'
|
|
547
|
+
}
|
|
548
|
+
}, cached ? 0 : 80)
|
|
549
|
+
}
|
|
550
|
+
function leave() {
|
|
551
|
+
if (!active || active.sticky) return
|
|
552
|
+
const current = active
|
|
553
|
+
window.clearTimeout(current.leaveTimer)
|
|
554
|
+
current.leaveTimer = window.setTimeout(() => {
|
|
555
|
+
if (active === current && !current.panel.matches(':hover')) stop(true, true)
|
|
556
|
+
}, 300)
|
|
557
|
+
}
|
|
558
|
+
function reanchor(root) {
|
|
559
|
+
if (!active) return
|
|
560
|
+
const anchor = [...root.querySelectorAll('[data-prts-session-summary-item]')]
|
|
561
|
+
.find(item => item.getAttribute('data-prts-session-summary-item') === active.id)
|
|
562
|
+
active.group?.removeAttribute('data-prts-preview-open')
|
|
563
|
+
active.anchor?.removeAttribute('data-prts-preview-active')
|
|
564
|
+
active.anchor = anchor
|
|
565
|
+
active.group = anchor?.closest('[data-prts-session-summary-group]')
|
|
566
|
+
active.originalParent = undefined
|
|
567
|
+
active.group?.setAttribute('data-prts-preview-open', '')
|
|
568
|
+
anchor?.setAttribute('data-prts-preview-active', '')
|
|
569
|
+
window.clearTimeout(active.leaveTimer)
|
|
570
|
+
}
|
|
571
|
+
function enterState(state) {
|
|
572
|
+
if (active && active.state !== state) stop(true, true)
|
|
573
|
+
}
|
|
574
|
+
function pinnedState() {
|
|
575
|
+
return active && !follows(active.state)
|
|
576
|
+
? { id: active.id, state: active.state } : undefined
|
|
577
|
+
}
|
|
578
|
+
return { show, stop, leave, reanchor, enterState, pinnedState, isOpen: () => Boolean(active) }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function scrollSummaryTitle(button, viewport, text, window) {
|
|
582
|
+
let animation
|
|
583
|
+
const stop = () => { animation?.cancel(); animation = undefined }
|
|
584
|
+
const start = () => {
|
|
585
|
+
stop()
|
|
586
|
+
const distance = text.scrollWidth - viewport.clientWidth
|
|
587
|
+
if (distance <= 8 || !text.animate) return
|
|
588
|
+
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
589
|
+
animation = text.animate([
|
|
590
|
+
{ transform: 'translateX(0)' },
|
|
591
|
+
{ transform: `translateX(-${distance}px)` },
|
|
592
|
+
], { duration: reduced ? 0 : distance / 30 * 1000, fill: 'forwards', easing: 'linear' })
|
|
593
|
+
}
|
|
594
|
+
button.addEventListener('pointerenter', start)
|
|
595
|
+
button.addEventListener('pointerleave', stop)
|
|
596
|
+
button.addEventListener('focus', start)
|
|
597
|
+
button.addEventListener('blur', stop)
|
|
598
|
+
return stop
|
|
599
|
+
}
|