dsh-synapse 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/app.js +1252 -0
- package/client.js +217 -0
- package/cordis.patch.yml +13 -0
- package/deepseek-mark.svg +3 -0
- package/docs/architecture.md +104 -0
- package/docs/development.md +137 -0
- package/docs/en/README.md +161 -0
- package/docs/images/native-webui.png +0 -0
- package/docs/images/synapse-ui.png +0 -0
- package/docs/zh-CN/README.md +154 -0
- package/index.js +802 -0
- package/package.json +44 -0
- package/styles.css +337 -0
package/app.js
ADDED
|
@@ -0,0 +1,1252 @@
|
|
|
1
|
+
const app = document.querySelector('#app')
|
|
2
|
+
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
|
|
3
|
+
const LEGACY_CARD_POSITIONS_KEY = 'dsh-synapse:card-positions'
|
|
4
|
+
const CARD_POSITIONS_KEY = 'dsh-synapse:card-positions:v3'
|
|
5
|
+
const COLLAPSED_CARDS_KEY = 'dsh-synapse:collapsed-cards:v1'
|
|
6
|
+
const savedBranchAnchors = (() => {
|
|
7
|
+
try {
|
|
8
|
+
const value = JSON.parse(localStorage.getItem('dsh-synapse:branch-anchors') ?? '[]')
|
|
9
|
+
return Array.isArray(value) ? value.filter(item => Array.isArray(item) && typeof item[0] === 'string' && typeof item[1] === 'string') : []
|
|
10
|
+
} catch { return [] }
|
|
11
|
+
})()
|
|
12
|
+
const savedCardPositions = (() => {
|
|
13
|
+
try {
|
|
14
|
+
// Drop formats that were never persisted; the current key stores drags.
|
|
15
|
+
localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
|
|
16
|
+
localStorage.removeItem('dsh-synapse:card-positions:v2')
|
|
17
|
+
const value = JSON.parse(localStorage.getItem(CARD_POSITIONS_KEY) ?? '[]')
|
|
18
|
+
return Array.isArray(value) ? value.filter(item => Array.isArray(item) && typeof item[0] === 'string' && item[1] !== null && Number.isFinite(item[1].x) && Number.isFinite(item[1].y)) : []
|
|
19
|
+
} catch { return [] }
|
|
20
|
+
})()
|
|
21
|
+
const savedCollapsedCards = (() => {
|
|
22
|
+
try {
|
|
23
|
+
const value = JSON.parse(localStorage.getItem(COLLAPSED_CARDS_KEY) ?? '[]')
|
|
24
|
+
return Array.isArray(value) ? value.filter(item => typeof item === 'string') : []
|
|
25
|
+
} catch { return [] }
|
|
26
|
+
})()
|
|
27
|
+
const CARD_WIDTH = 310
|
|
28
|
+
const CARD_HEIGHT = 276
|
|
29
|
+
const CARD_GAP_Y = 42
|
|
30
|
+
const CAMERA_INSET_X = 56
|
|
31
|
+
const CAMERA_INSET_Y = 56
|
|
32
|
+
const state = {
|
|
33
|
+
summaries: [], workspace: null, activeId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
|
|
34
|
+
dshWorkspaces: [], selectedDshWorkspaceId: null,
|
|
35
|
+
historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
|
|
36
|
+
draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards),
|
|
37
|
+
dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 },
|
|
38
|
+
expandedMessageIds: new Set(),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]))
|
|
42
|
+
const formatTime = value => new Date(value).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
43
|
+
const currentThread = () => state.workspace?.threads.find(thread => thread.id === state.activeId) ?? state.workspace?.threads[0] ?? null
|
|
44
|
+
const threadListTitle = thread => thread.dshSessionTitle ?? thread.title ?? questionFor(thread)
|
|
45
|
+
|
|
46
|
+
function rememberBranchAnchor(sessionId, cardId) {
|
|
47
|
+
state.branchAnchors.set(sessionId, cardId)
|
|
48
|
+
try { localStorage.setItem('dsh-synapse:branch-anchors', JSON.stringify([...state.branchAnchors])) } catch { /* Private browsing may disable local storage. */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function persistCardPositions() {
|
|
52
|
+
try { localStorage.setItem(CARD_POSITIONS_KEY, JSON.stringify([...state.cardPositions])) } catch { /* Private browsing may disable local storage. */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function persistCollapsedCards() {
|
|
56
|
+
try { localStorage.setItem(COLLAPSED_CARDS_KEY, JSON.stringify([...state.collapsedCardIds])) } catch { /* Private browsing may disable local storage. */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rememberCardPosition(cardId, position, aliases = []) {
|
|
60
|
+
state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
|
|
61
|
+
for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
|
|
62
|
+
persistCardPositions()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resetCardPositions() {
|
|
66
|
+
state.cardPositions.clear()
|
|
67
|
+
persistCardPositions()
|
|
68
|
+
try {
|
|
69
|
+
localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
|
|
70
|
+
localStorage.removeItem('dsh-synapse:card-positions:v2')
|
|
71
|
+
} catch { /* Private browsing may disable local storage. */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resetCanvasCamera() {
|
|
75
|
+
state.canvasViewInitialized = false
|
|
76
|
+
state.canvasCamera = { x: 0, y: 0 }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function api(path, options = {}) {
|
|
80
|
+
const response = await fetch(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers ?? {}) } })
|
|
81
|
+
const body = await response.json().catch(() => ({}))
|
|
82
|
+
if (!response.ok) throw new Error(body.error ?? '请求失败')
|
|
83
|
+
return body
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function post(type, payload = {}) {
|
|
87
|
+
if (window.parent !== window) window.parent.postMessage({ source: 'dsh-synapse', type, ...payload }, window.location.origin)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dshRpc(type, payload = {}) {
|
|
91
|
+
if (window.parent === window) return Promise.reject(new Error('请从 DSH 页面打开 Synapse 后再操作会话'))
|
|
92
|
+
const requestId = crypto.randomUUID()
|
|
93
|
+
post(type, { requestId, ...payload })
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const timer = window.setTimeout(() => {
|
|
96
|
+
state.pendingRpc.delete(requestId)
|
|
97
|
+
reject(new Error('DSH 未在规定时间内响应'))
|
|
98
|
+
}, 20_000)
|
|
99
|
+
state.pendingRpc.set(requestId, { resolve, reject, timer })
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function settleRpc(requestId, value, error) {
|
|
104
|
+
const pending = state.pendingRpc.get(requestId)
|
|
105
|
+
if (pending === undefined) return
|
|
106
|
+
state.pendingRpc.delete(requestId)
|
|
107
|
+
window.clearTimeout(pending.timer)
|
|
108
|
+
if (error === undefined) pending.resolve(value)
|
|
109
|
+
else pending.reject(error instanceof Error ? error : new Error(String(error)))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function setError(error = '') { state.error = error instanceof Error ? error.message : error; render() }
|
|
113
|
+
|
|
114
|
+
function messagesFromEvents(events) {
|
|
115
|
+
if (!Array.isArray(events)) return []
|
|
116
|
+
return events.flatMap(event => {
|
|
117
|
+
const content = event?.data?.message?.content ?? event?.data?.content
|
|
118
|
+
const text = Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).filter(Boolean).join('\n') : ''
|
|
119
|
+
if (event?.type === 'user/message' && text && !text.startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')) return [{ kind: 'user', text, at: event.time, sourceSeq: event.seq }]
|
|
120
|
+
if (event?.type === 'assistant/message' && text) return [{ kind: 'assistant', text, at: event.time, sourceSeq: event.seq }]
|
|
121
|
+
return []
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function loadThreadHistory() {}
|
|
126
|
+
|
|
127
|
+
function canReplaceView() {
|
|
128
|
+
return state.draft === null && !state.dragging && !state.canvasGesture && Date.now() >= state.canvasRefreshAfter && !document.activeElement?.matches('textarea')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function deferCanvasRefresh(delay = 700) {
|
|
132
|
+
state.canvasRefreshAfter = Math.max(state.canvasRefreshAfter, Date.now() + delay)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function currentDshWorkspace() {
|
|
136
|
+
const id = state.currentDsh?.id
|
|
137
|
+
return typeof id === 'string' ? state.dshWorkspaces.find(workspace => workspace.sessionIds.includes(id)) : undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function selectedDshWorkspace() {
|
|
141
|
+
return state.dshWorkspaces.find(workspace => workspace.id === state.selectedDshWorkspaceId)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function currentDshThread(threads = state.workspace?.threads ?? []) {
|
|
145
|
+
const id = state.currentDsh?.id
|
|
146
|
+
return typeof id === 'string' ? threads.find(thread => thread.dshSessionId === id) : undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function workspaceChoices() {
|
|
150
|
+
if (state.dshWorkspaces.length > 0) return state.dshWorkspaces.map(workspace => ({ ...workspace, source: 'dsh' }))
|
|
151
|
+
return state.summaries.map(workspace => ({ id: workspace.id, title: workspace.title, path: workspace.cwd, sessionIds: [], source: 'projection' }))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function threadsForDshWorkspace(workspace) {
|
|
155
|
+
if (workspace.sessionIds.length === 0) return []
|
|
156
|
+
const requested = new Set(workspace.sessionIds)
|
|
157
|
+
const projections = await Promise.all(state.summaries.map(summary => api(`/synapse/api/workspaces/${summary.id}`)))
|
|
158
|
+
return projections.flatMap(projection => projection.workspace.threads.filter(thread => requested.has(thread.dshSessionId)))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function openDshWorkspace(id, { renderAfter = true } = {}) {
|
|
162
|
+
const workspace = state.dshWorkspaces.find(item => item.id === id)
|
|
163
|
+
if (workspace === undefined) return false
|
|
164
|
+
const load = ++state.workspaceLoad
|
|
165
|
+
state.selectedDshWorkspaceId = id
|
|
166
|
+
const threads = await threadsForDshWorkspace(workspace)
|
|
167
|
+
if (load !== state.workspaceLoad) return true
|
|
168
|
+
const nextWorkspaceId = `dsh:${workspace.id}`
|
|
169
|
+
if (state.workspace?.id !== nextWorkspaceId) resetCanvasCamera()
|
|
170
|
+
state.workspace = { id: nextWorkspaceId, title: workspace.title, cwd: workspace.path, threads }
|
|
171
|
+
const currentThread = currentDshThread(state.workspace.threads)
|
|
172
|
+
state.activeId = currentThread?.id ?? (state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null)
|
|
173
|
+
if (currentThread !== undefined) revealConversationThread(conversationCards(state.workspace.threads), currentThread.id)
|
|
174
|
+
if (renderAfter && canReplaceView()) render()
|
|
175
|
+
await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
|
|
176
|
+
if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function openCurrentWorkspace() {
|
|
181
|
+
const workspace = currentDshWorkspace()
|
|
182
|
+
if (workspace === undefined || workspace.id === state.selectedDshWorkspaceId) return false
|
|
183
|
+
return openDshWorkspace(workspace.id)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function refreshSummaries({ renderAfter = true } = {}) {
|
|
187
|
+
const before = JSON.stringify(state.summaries)
|
|
188
|
+
const body = await api('/synapse/api/workspaces')
|
|
189
|
+
state.summaries = body.workspaces
|
|
190
|
+
const changed = before !== JSON.stringify(state.summaries)
|
|
191
|
+
const current = state.workspace?.id
|
|
192
|
+
if (state.selectedDshWorkspaceId === null && current !== null && !state.summaries.some(item => item.id === current)) state.workspace = null
|
|
193
|
+
const selected = selectedDshWorkspace()
|
|
194
|
+
if (selected !== undefined && (changed || state.workspace === null)) await openDshWorkspace(selected.id, { renderAfter })
|
|
195
|
+
else if (state.workspace === null && state.summaries.length > 0) await openWorkspace(state.summaries[0].id)
|
|
196
|
+
else if (renderAfter && changed && canReplaceView()) render()
|
|
197
|
+
return changed
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function openWorkspace(id, { renderAfter = true } = {}) {
|
|
201
|
+
const load = ++state.workspaceLoad
|
|
202
|
+
const body = await api(`/synapse/api/workspaces/${id}`)
|
|
203
|
+
if (load !== state.workspaceLoad) return
|
|
204
|
+
if (state.workspace?.id !== body.workspace.id) resetCanvasCamera()
|
|
205
|
+
state.workspace = body.workspace
|
|
206
|
+
state.activeId = state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null
|
|
207
|
+
if (renderAfter && canReplaceView()) render()
|
|
208
|
+
await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
|
|
209
|
+
if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function refreshProjection() {
|
|
213
|
+
const summariesChanged = await refreshSummaries({ renderAfter: false })
|
|
214
|
+
if (!summariesChanged || state.workspace === null || !canReplaceView()) return summariesChanged
|
|
215
|
+
if (state.selectedDshWorkspaceId !== null) await openDshWorkspace(state.selectedDshWorkspaceId)
|
|
216
|
+
else await openWorkspace(state.workspace.id)
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function openNewSession() {
|
|
221
|
+
if (state.draft !== null) return
|
|
222
|
+
state.mode = 'canvas'
|
|
223
|
+
state.activeId = null
|
|
224
|
+
state.draft = { kind: 'new', text: '', sending: false }
|
|
225
|
+
state.error = ''
|
|
226
|
+
resetCanvasCamera()
|
|
227
|
+
render()
|
|
228
|
+
window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function archiveThread(thread) {
|
|
232
|
+
if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
|
|
233
|
+
await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
|
|
234
|
+
state.historyBySession.delete(thread.dshSessionId)
|
|
235
|
+
if (state.workspace !== null) {
|
|
236
|
+
const removed = new Set([thread.id])
|
|
237
|
+
for (let changed = true; changed;) {
|
|
238
|
+
changed = false
|
|
239
|
+
for (const item of state.workspace.threads) {
|
|
240
|
+
if (item.parentId !== null && removed.has(item.parentId) && !removed.has(item.id)) {
|
|
241
|
+
removed.add(item.id)
|
|
242
|
+
changed = true
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
state.workspace.threads = state.workspace.threads.filter(item => !removed.has(item.id))
|
|
247
|
+
for (const key of [...state.cardPositions.keys()]) {
|
|
248
|
+
if ([...removed].some(id => key.startsWith(`${id}:`))) state.cardPositions.delete(key)
|
|
249
|
+
}
|
|
250
|
+
let collapsedChanged = false
|
|
251
|
+
for (const key of [...state.collapsedCardIds]) {
|
|
252
|
+
if ([...removed].some(id => key.startsWith(`${id}:`))) {
|
|
253
|
+
state.collapsedCardIds.delete(key)
|
|
254
|
+
collapsedChanged = true
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (collapsedChanged) persistCollapsedCards()
|
|
258
|
+
state.activeId = state.activeId !== null && state.workspace.threads.some(item => item.id === state.activeId)
|
|
259
|
+
? state.activeId
|
|
260
|
+
: state.workspace.threads[0]?.id ?? null
|
|
261
|
+
render()
|
|
262
|
+
} else {
|
|
263
|
+
state.activeId = null
|
|
264
|
+
}
|
|
265
|
+
await refreshSummaries()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function openContinue(parent, anchorId = undefined) {
|
|
269
|
+
if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
|
|
270
|
+
state.activeId = parent.id
|
|
271
|
+
state.draft = { kind: 'continue', parentId: parent.id, anchorId, text: '', sending: false }
|
|
272
|
+
render()
|
|
273
|
+
window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function openBranch(parent, atSeq = undefined, anchorId = undefined) {
|
|
277
|
+
if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
|
|
278
|
+
state.activeId = parent.id
|
|
279
|
+
state.draft = { kind: 'branch', parentId: parent.id, atSeq, anchorId, text: '', sending: false }
|
|
280
|
+
render()
|
|
281
|
+
window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function sendMessage(thread, text) {
|
|
285
|
+
if (thread.dshSessionId === null) throw new Error('该节点没有关联的 DSH 会话')
|
|
286
|
+
if (state.pendingReplies.has(thread.dshSessionId)) throw new Error('该会话正在回复,请稍后再发送')
|
|
287
|
+
state.pendingReplies.set(thread.dshSessionId, { text, at: Date.now() })
|
|
288
|
+
state.error = ''
|
|
289
|
+
render()
|
|
290
|
+
try {
|
|
291
|
+
await dshRpc('synapse:send-message', { sessionId: thread.dshSessionId, text })
|
|
292
|
+
void loadThreadHistory(thread)
|
|
293
|
+
} catch (error) {
|
|
294
|
+
state.pendingReplies.delete(thread.dshSessionId)
|
|
295
|
+
render()
|
|
296
|
+
throw error
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function submitDraft() {
|
|
301
|
+
const draft = state.draft
|
|
302
|
+
const text = draft?.text.trim()
|
|
303
|
+
if (draft === null || !text) return
|
|
304
|
+
const branchPosition = draft.kind === 'branch' && state.workspace !== null ? draftPlacement(conversationCards(state.workspace.threads))?.position : undefined
|
|
305
|
+
draft.sending = true
|
|
306
|
+
state.error = ''
|
|
307
|
+
render()
|
|
308
|
+
try {
|
|
309
|
+
if (draft.kind === 'new') {
|
|
310
|
+
const session = await dshRpc('synapse:create-session', { workspaceId: state.selectedDshWorkspaceId, cwd: state.currentDsh?.cwd })
|
|
311
|
+
await dshRpc('synapse:send-message', { sessionId: session.id, text })
|
|
312
|
+
state.draft = null
|
|
313
|
+
render()
|
|
314
|
+
window.setTimeout(() => {
|
|
315
|
+
void refreshProjection().catch(() => {})
|
|
316
|
+
}, 150)
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
const parent = state.workspace?.threads.find(thread => thread.id === draft.parentId)
|
|
320
|
+
if (parent === undefined) throw new Error('来源会话不存在')
|
|
321
|
+
if (draft.kind === 'continue') {
|
|
322
|
+
state.draft = null
|
|
323
|
+
await sendMessage(parent, text)
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
const session = await dshRpc('synapse:fork-session', { sessionId: parent.dshSessionId, atSeq: draft.atSeq })
|
|
327
|
+
if (draft.anchorId !== undefined) rememberBranchAnchor(session.id, draft.anchorId)
|
|
328
|
+
const result = await api(`/synapse/api/threads/${parent.id}/branch`, { method: 'POST', body: JSON.stringify({ title: text.slice(0, 42), dshSessionId: session.id, dshSessionTitle: session.title, position: branchPosition }) })
|
|
329
|
+
if (state.workspace !== null && !state.workspace.threads.some(thread => thread.id === result.thread.id || thread.dshSessionId === result.thread.dshSessionId)) state.workspace.threads.push(result.thread)
|
|
330
|
+
state.activeId = result.thread.id
|
|
331
|
+
state.draft = null
|
|
332
|
+
state.pendingReplies.set(result.thread.dshSessionId, { text, at: Date.now() })
|
|
333
|
+
render()
|
|
334
|
+
await dshRpc('synapse:send-message', { sessionId: result.thread.dshSessionId, text })
|
|
335
|
+
void loadThreadHistory(result.thread)
|
|
336
|
+
await refreshProjection()
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (draft.kind === 'branch') {
|
|
339
|
+
state.pendingReplies.delete(state.workspace?.threads.find(thread => thread.id === state.activeId)?.dshSessionId)
|
|
340
|
+
if (state.draft !== null) state.draft = { ...draft, sending: false }
|
|
341
|
+
} else {
|
|
342
|
+
state.draft = { ...draft, sending: false }
|
|
343
|
+
}
|
|
344
|
+
setError(error)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function threadsById() { return new Map((state.workspace?.threads ?? []).map(thread => [thread.id, thread])) }
|
|
349
|
+
function persistedMessagesFor(thread) { return state.historyBySession.get(thread.dshSessionId) ?? thread.messages ?? [] }
|
|
350
|
+
|
|
351
|
+
function pendingUserIndex(messages, pending) {
|
|
352
|
+
return messages.findLastIndex(message => message.kind === 'user' && message.text === pending.text && new Date(message.at).getTime() >= pending.at - 2_000)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function settlePendingReply(thread, messages) {
|
|
356
|
+
const pending = state.pendingReplies.get(thread.dshSessionId)
|
|
357
|
+
if (pending === undefined) return false
|
|
358
|
+
const userIndex = pendingUserIndex(messages, pending)
|
|
359
|
+
if (userIndex === -1 || !messages.slice(userIndex + 1).some(message => message.kind === 'assistant')) return false
|
|
360
|
+
state.pendingReplies.delete(thread.dshSessionId)
|
|
361
|
+
return true
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function messagesFor(thread) {
|
|
365
|
+
// A runtime-context snapshot is internal DSH state, never a user turn.
|
|
366
|
+
// Filter here as well as during persistence so existing saved workspaces
|
|
367
|
+
// immediately render one question and its answer as one card.
|
|
368
|
+
const messages = persistedMessagesFor(thread).filter(message => !(message.kind === 'user' && typeof message.text === 'string' && message.text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')))
|
|
369
|
+
const pending = state.pendingReplies.get(thread.dshSessionId)
|
|
370
|
+
if (pending === undefined) return messages
|
|
371
|
+
if (settlePendingReply(thread, messages)) {
|
|
372
|
+
state.liveReplies.delete(thread.dshSessionId)
|
|
373
|
+
return messages
|
|
374
|
+
}
|
|
375
|
+
const liveReply = state.liveReplies.get(thread.dshSessionId)
|
|
376
|
+
const liveAssistant = liveReply?.running ? { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() } : { kind: 'assistant', text: '', pending: true, at: new Date().toISOString() }
|
|
377
|
+
const userIndex = pendingUserIndex(messages, pending)
|
|
378
|
+
if (userIndex !== -1) return [...messages, liveAssistant]
|
|
379
|
+
return [...messages, { kind: 'user', text: pending.text, pending: true, at: new Date(pending.at).toISOString() }, liveAssistant]
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function latestMessage(thread, kind) { return [...messagesFor(thread)].reverse().find(message => message.kind === kind) }
|
|
383
|
+
function questionFor(thread) { return latestMessage(thread, 'user')?.text ?? thread.dshSessionTitle ?? '等待用户提问' }
|
|
384
|
+
function answerFor(thread) { return latestMessage(thread, 'assistant') ?? null }
|
|
385
|
+
|
|
386
|
+
function inlineMarkdown(text) {
|
|
387
|
+
return escapeHtml(text)
|
|
388
|
+
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
|
389
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
390
|
+
.replace(/~~([^~]+)~~/g, '<s>$1</s>')
|
|
391
|
+
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const tableCells = line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
|
|
395
|
+
|
|
396
|
+
const isTableDelimiter = line => {
|
|
397
|
+
const cells = tableCells(line)
|
|
398
|
+
return cells.length > 0 && cells.every(cell => /^:?-+:?$/.test(cell))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function markdownBlock(text) {
|
|
402
|
+
const lines = text.split('\n')
|
|
403
|
+
const output = []
|
|
404
|
+
for (let index = 0; index < lines.length;) {
|
|
405
|
+
const line = lines[index]
|
|
406
|
+
if (line.trim() === '') { index++; continue }
|
|
407
|
+
const heading = /^(#{1,3})\s+(.+)$/.exec(line)
|
|
408
|
+
if (heading !== null) {
|
|
409
|
+
const level = heading[1].length
|
|
410
|
+
output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`)
|
|
411
|
+
index++
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
const unordered = /^[-*+]\s+(.+)$/.exec(line)
|
|
415
|
+
const ordered = /^\d+[.)]\s+(.+)$/.exec(line)
|
|
416
|
+
if (unordered !== null || ordered !== null) {
|
|
417
|
+
const matcher = unordered === null ? /^\d+[.)]\s+(.+)$/ : /^[-*+]\s+(.+)$/
|
|
418
|
+
const items = []
|
|
419
|
+
while (index < lines.length) {
|
|
420
|
+
const item = matcher.exec(lines[index])
|
|
421
|
+
if (item === null) break
|
|
422
|
+
items.push(`<li>${inlineMarkdown(item[1])}</li>`)
|
|
423
|
+
index++
|
|
424
|
+
}
|
|
425
|
+
output.push(`<${unordered === null ? 'ol' : 'ul'}>${items.join('')}</${unordered === null ? 'ol' : 'ul'}>`)
|
|
426
|
+
continue
|
|
427
|
+
}
|
|
428
|
+
// GFM table: a leading-pipe header row followed by a |-delimiter row,
|
|
429
|
+
// then any number of leading-pipe body rows.
|
|
430
|
+
if (/^\s*\|/.test(line) && index + 1 < lines.length && isTableDelimiter(lines[index + 1])) {
|
|
431
|
+
const header = line
|
|
432
|
+
const body = []
|
|
433
|
+
index += 2
|
|
434
|
+
while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) {
|
|
435
|
+
body.push(lines[index])
|
|
436
|
+
index++
|
|
437
|
+
}
|
|
438
|
+
output.push(`<table><thead><tr>${tableCells(header).map(cell => `<th>${inlineMarkdown(cell)}</th>`).join('')}</tr></thead><tbody>${body.map(row => `<tr>${tableCells(row).map(cell => `<td>${inlineMarkdown(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table>`)
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
const paragraph = []
|
|
442
|
+
while (index < lines.length && lines[index].trim() !== '' && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index]) && !/^\d+[.)]\s+/.test(lines[index])) paragraph.push(lines[index++])
|
|
443
|
+
// A marker-only line such as PowerShell's "+ " diagnostic is neither a
|
|
444
|
+
// list item nor paragraph content under the rules above. Consume it so
|
|
445
|
+
// the parser always makes progress.
|
|
446
|
+
if (paragraph.length === 0) paragraph.push(lines[index++])
|
|
447
|
+
output.push(`<p>${paragraph.map(inlineMarkdown).join('<br>')}</p>`)
|
|
448
|
+
}
|
|
449
|
+
return output.join('')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Markdown parsing is pure CPU and repeats for every card on every canvas
|
|
453
|
+
// rebuild; cache the rendered HTML by input text so stable answers are never
|
|
454
|
+
// re-parsed. Bounded: streaming partial texts churn keys, so evict oldest.
|
|
455
|
+
const markdownCache = new Map()
|
|
456
|
+
const MARKDOWN_CACHE_LIMIT = 500
|
|
457
|
+
function renderMarkdown(text) {
|
|
458
|
+
const key = String(text)
|
|
459
|
+
const cached = markdownCache.get(key)
|
|
460
|
+
if (cached !== undefined) return cached
|
|
461
|
+
const parts = key.split(/```/)
|
|
462
|
+
const rendered = parts.map((part, index) => index % 2 === 1
|
|
463
|
+
? `<pre><code>${escapeHtml(part.replace(/^\w*\n/, ''))}</code></pre>`
|
|
464
|
+
: markdownBlock(part)).join('')
|
|
465
|
+
if (markdownCache.size >= MARKDOWN_CACHE_LIMIT) markdownCache.delete(markdownCache.keys().next().value)
|
|
466
|
+
markdownCache.set(key, rendered)
|
|
467
|
+
return rendered
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function overlapsCard(position, other) {
|
|
471
|
+
return position.x < other.x + CARD_WIDTH && position.x + CARD_WIDTH > other.x
|
|
472
|
+
&& position.y < other.y + CARD_HEIGHT && position.y + CARD_HEIGHT > other.y
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function firstAvailableCardPosition(position, occupied) {
|
|
476
|
+
const candidate = { x: Math.round(position.x), y: Math.max(82, Math.round(position.y)) }
|
|
477
|
+
while (true) {
|
|
478
|
+
const collisions = occupied.filter(other => overlapsCard(candidate, other))
|
|
479
|
+
if (collisions.length === 0) return candidate
|
|
480
|
+
candidate.y = Math.max(...collisions.map(other => other.y + CARD_HEIGHT + CARD_GAP_Y))
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function connectorPath(fromPosition, toPosition) {
|
|
485
|
+
const fromX = fromPosition.x + CARD_WIDTH
|
|
486
|
+
const fromY = fromPosition.y + CARD_HEIGHT / 2
|
|
487
|
+
const toX = toPosition.x
|
|
488
|
+
const toY = toPosition.y + CARD_HEIGHT / 2
|
|
489
|
+
const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
|
|
490
|
+
return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function connectorPathFromElements(fromCard, toCard) {
|
|
494
|
+
const fromX = Number.parseFloat(fromCard.style.left) + CARD_WIDTH
|
|
495
|
+
const fromY = Number.parseFloat(fromCard.style.top) + CARD_HEIGHT / 2
|
|
496
|
+
const toX = Number.parseFloat(toCard.style.left)
|
|
497
|
+
const toY = Number.parseFloat(toCard.style.top) + CARD_HEIGHT / 2
|
|
498
|
+
if (![fromX, fromY, toX, toY].every(Number.isFinite)) return null
|
|
499
|
+
const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
|
|
500
|
+
return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function selectorValue(value) {
|
|
504
|
+
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function refreshCardConnectors(cardId) {
|
|
508
|
+
const viewport = document.querySelector('.canvas-viewport')
|
|
509
|
+
if (!(viewport instanceof HTMLElement)) return
|
|
510
|
+
const id = selectorValue(cardId)
|
|
511
|
+
for (const path of viewport.querySelectorAll(`.connectors path[data-from="${id}"], .connectors path[data-to="${id}"]`)) {
|
|
512
|
+
const fromId = path.getAttribute('data-from')
|
|
513
|
+
const toId = path.getAttribute('data-to')
|
|
514
|
+
if (fromId === null || toId === null) continue
|
|
515
|
+
const fromCard = viewport.querySelector(`[data-card-id="${selectorValue(fromId)}"]`)
|
|
516
|
+
const toCard = viewport.querySelector(`[data-card-id="${selectorValue(toId)}"]`)
|
|
517
|
+
if (!(fromCard instanceof HTMLElement) || !(toCard instanceof HTMLElement)) continue
|
|
518
|
+
const nextPath = connectorPathFromElements(fromCard, toCard)
|
|
519
|
+
if (nextPath !== null) path.setAttribute('d', nextPath)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function initialCanvasCamera(cards) {
|
|
524
|
+
const draft = state.draft?.kind === 'new' ? { id: 'draft:new', position: { x: 86, y: 82 } } : draftPlacement(cards)
|
|
525
|
+
const active = state.activeId === null ? undefined : cards.find(card => card.dshThreadId === state.activeId)
|
|
526
|
+
const focus = draft ?? active ?? cards[0]
|
|
527
|
+
const position = focus?.position
|
|
528
|
+
if (position === undefined) return { x: 0, y: 0 }
|
|
529
|
+
return { x: CAMERA_INSET_X - position.x * state.zoom, y: CAMERA_INSET_Y - position.y * state.zoom }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function placeConversationCards(cards) {
|
|
533
|
+
const saved = new Map(cards.flatMap(card => {
|
|
534
|
+
if (card.positionLocked !== true) return []
|
|
535
|
+
const position = state.cardPositions.get(card.id) ?? state.cardPositions.get(card.positionKey)
|
|
536
|
+
return position === undefined ? [] : [[card.id, { x: position.x, y: position.y }]]
|
|
537
|
+
}))
|
|
538
|
+
const occupied = []
|
|
539
|
+
for (const card of cards) {
|
|
540
|
+
const position = saved.get(card.id)
|
|
541
|
+
if (position !== undefined) {
|
|
542
|
+
card.position = position
|
|
543
|
+
continue
|
|
544
|
+
}
|
|
545
|
+
card.position = firstAvailableCardPosition(card.naturalPosition ?? card.position, occupied)
|
|
546
|
+
occupied.push(card.position)
|
|
547
|
+
}
|
|
548
|
+
return cards
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function layoutConversationGraph(cards, threads) {
|
|
552
|
+
const childrenByThread = new Map()
|
|
553
|
+
for (const thread of threads) {
|
|
554
|
+
if (thread.parentId === null) continue
|
|
555
|
+
const children = childrenByThread.get(thread.parentId) ?? []
|
|
556
|
+
children.push(thread.id)
|
|
557
|
+
childrenByThread.set(thread.parentId, children)
|
|
558
|
+
}
|
|
559
|
+
const laneByThread = new Map()
|
|
560
|
+
const visitThread = threadId => {
|
|
561
|
+
if (laneByThread.has(threadId)) return
|
|
562
|
+
laneByThread.set(threadId, laneByThread.size)
|
|
563
|
+
for (const childId of childrenByThread.get(threadId) ?? []) visitThread(childId)
|
|
564
|
+
}
|
|
565
|
+
for (const thread of threads) if (thread.parentId === null) visitThread(thread.id)
|
|
566
|
+
for (const thread of threads) visitThread(thread.id)
|
|
567
|
+
|
|
568
|
+
const byId = new Map(cards.map(card => [card.id, card]))
|
|
569
|
+
const positioned = new Map()
|
|
570
|
+
const positionFor = (card, visiting = new Set()) => {
|
|
571
|
+
if (positioned.has(card.id)) return positioned.get(card.id)
|
|
572
|
+
if (visiting.has(card.id)) return { x: 86, y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y) }
|
|
573
|
+
visiting.add(card.id)
|
|
574
|
+
const parent = card.parentId === null ? undefined : byId.get(card.parentId)
|
|
575
|
+
const parentPosition = parent === undefined ? undefined : positionFor(parent, visiting)
|
|
576
|
+
const position = {
|
|
577
|
+
x: parentPosition === undefined ? 86 : parentPosition.x + 365,
|
|
578
|
+
y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y),
|
|
579
|
+
}
|
|
580
|
+
visiting.delete(card.id)
|
|
581
|
+
positioned.set(card.id, position)
|
|
582
|
+
return position
|
|
583
|
+
}
|
|
584
|
+
for (const card of cards) {
|
|
585
|
+
card.naturalPosition = positionFor(card)
|
|
586
|
+
if (!card.positionLocked) card.position = card.naturalPosition
|
|
587
|
+
}
|
|
588
|
+
return placeConversationCards(cards)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function conversationCards(threads) {
|
|
592
|
+
const cards = []
|
|
593
|
+
const cardsByThread = new Map()
|
|
594
|
+
for (const thread of threads) {
|
|
595
|
+
const messages = messagesFor(thread)
|
|
596
|
+
const turns = []
|
|
597
|
+
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
|
598
|
+
const question = messages[messageIndex]
|
|
599
|
+
if (question.kind !== 'user') continue
|
|
600
|
+
const replies = []
|
|
601
|
+
for (let replyIndex = messageIndex + 1; replyIndex < messages.length; replyIndex++) {
|
|
602
|
+
const reply = messages[replyIndex]
|
|
603
|
+
if (reply.kind === 'user') break
|
|
604
|
+
if (reply.kind === 'assistant') replies.push(reply)
|
|
605
|
+
}
|
|
606
|
+
const answer = replies.at(-1) ?? null
|
|
607
|
+
const turnIndex = turns.length
|
|
608
|
+
const id = `${thread.id}:turn:${question.sourceSeq ?? messageIndex}`
|
|
609
|
+
const previous = turns.at(-1)
|
|
610
|
+
const positionKey = `${thread.id}:turn-index:${turnIndex}`
|
|
611
|
+
const naturalPosition = previous === undefined ? { x: 86, y: 82 } : { x: previous.naturalPosition.x + 365, y: previous.naturalPosition.y }
|
|
612
|
+
const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
|
|
613
|
+
const positionLocked = savedPosition !== undefined
|
|
614
|
+
const position = positionLocked ? savedPosition : naturalPosition
|
|
615
|
+
turns.push({
|
|
616
|
+
id,
|
|
617
|
+
positionKey,
|
|
618
|
+
dshThreadId: thread.id,
|
|
619
|
+
sourceParentId: thread.parentId,
|
|
620
|
+
parentId: null,
|
|
621
|
+
sourceSeq: question.sourceSeq,
|
|
622
|
+
turnIndex,
|
|
623
|
+
naturalPosition,
|
|
624
|
+
position,
|
|
625
|
+
positionLocked,
|
|
626
|
+
question: question.text,
|
|
627
|
+
answer,
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
const liveReply = state.liveReplies.get(thread.dshSessionId)
|
|
631
|
+
const latestTurn = turns.at(-1)
|
|
632
|
+
if (liveReply?.running && latestTurn !== undefined && (latestTurn.answer === null || latestTurn.answer.pending === true)) latestTurn.answer = { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() }
|
|
633
|
+
if (turns.length === 0) {
|
|
634
|
+
const id = `${thread.id}:turn:empty`
|
|
635
|
+
const positionKey = `${thread.id}:turn-index:0`
|
|
636
|
+
const naturalPosition = { x: 86, y: 82 }
|
|
637
|
+
const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
|
|
638
|
+
const positionLocked = savedPosition !== undefined
|
|
639
|
+
turns.push({
|
|
640
|
+
id,
|
|
641
|
+
positionKey,
|
|
642
|
+
dshThreadId: thread.id,
|
|
643
|
+
sourceParentId: thread.parentId,
|
|
644
|
+
parentId: null,
|
|
645
|
+
sourceSeq: undefined,
|
|
646
|
+
turnIndex: 0,
|
|
647
|
+
naturalPosition,
|
|
648
|
+
position: positionLocked ? savedPosition : naturalPosition,
|
|
649
|
+
positionLocked,
|
|
650
|
+
question: thread.dshSessionTitle ?? thread.title,
|
|
651
|
+
answer: null,
|
|
652
|
+
})
|
|
653
|
+
}
|
|
654
|
+
turns.at(-1).canContinue = true
|
|
655
|
+
cardsByThread.set(thread.id, turns)
|
|
656
|
+
cards.push(...turns)
|
|
657
|
+
}
|
|
658
|
+
for (const card of cards) {
|
|
659
|
+
const siblings = cardsByThread.get(card.dshThreadId)
|
|
660
|
+
if (card.turnIndex > 0) card.parentId = siblings[card.turnIndex - 1].id
|
|
661
|
+
else {
|
|
662
|
+
const parentCards = cardsByThread.get(card.sourceParentId)
|
|
663
|
+
const sourceThread = threads.find(thread => thread.id === card.dshThreadId)
|
|
664
|
+
const firstChildQuestion = siblings?.[0]
|
|
665
|
+
const seedLength = sourceThread?.sourceSeedLength ?? firstChildQuestion?.sourceSeq
|
|
666
|
+
// A fork inherits every parent event before DSH's durable seed boundary.
|
|
667
|
+
// The latest parent question below that boundary is the exact Turn where
|
|
668
|
+
// this child was born. Canvas coordinates never participate in lineage.
|
|
669
|
+
const inheritedTurn = Number.isSafeInteger(seedLength)
|
|
670
|
+
? parentCards?.filter(candidate => Number.isInteger(candidate.sourceSeq) && candidate.sourceSeq < seedLength).at(-1)
|
|
671
|
+
: undefined
|
|
672
|
+
card.parentId = state.branchAnchors.get(card.dshThreadId) ?? inheritedTurn?.id ?? null
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return layoutConversationGraph(cards, threads)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function conversationGraphView(cards, collapsedCardIds = state.collapsedCardIds) {
|
|
679
|
+
const cardIds = new Set(cards.map(card => card.id))
|
|
680
|
+
const childrenByParent = new Map()
|
|
681
|
+
for (const card of cards) {
|
|
682
|
+
if (card.parentId === null || !cardIds.has(card.parentId)) continue
|
|
683
|
+
const children = childrenByParent.get(card.parentId) ?? []
|
|
684
|
+
children.push(card.id)
|
|
685
|
+
childrenByParent.set(card.parentId, children)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const hiddenIds = new Set()
|
|
689
|
+
for (const rootId of collapsedCardIds) {
|
|
690
|
+
if (!cardIds.has(rootId)) continue
|
|
691
|
+
const visited = new Set([rootId])
|
|
692
|
+
const visit = parentId => {
|
|
693
|
+
for (const childId of childrenByParent.get(parentId) ?? []) {
|
|
694
|
+
if (visited.has(childId)) continue
|
|
695
|
+
visited.add(childId)
|
|
696
|
+
hiddenIds.add(childId)
|
|
697
|
+
visit(childId)
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
visit(rootId)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Persisted collapse roots must remain visible even if malformed metadata
|
|
704
|
+
// contains a cycle where two collapsed nodes otherwise hide each other.
|
|
705
|
+
for (const rootId of collapsedCardIds) hiddenIds.delete(rootId)
|
|
706
|
+
|
|
707
|
+
const descendantCounts = new Map()
|
|
708
|
+
for (const card of cards) {
|
|
709
|
+
const visited = new Set([card.id])
|
|
710
|
+
const pending = [...(childrenByParent.get(card.id) ?? [])]
|
|
711
|
+
while (pending.length > 0) {
|
|
712
|
+
const descendantId = pending.pop()
|
|
713
|
+
if (visited.has(descendantId)) continue
|
|
714
|
+
visited.add(descendantId)
|
|
715
|
+
pending.push(...(childrenByParent.get(descendantId) ?? []))
|
|
716
|
+
}
|
|
717
|
+
descendantCounts.set(card.id, visited.size - 1)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
cards: cards.filter(card => !hiddenIds.has(card.id)),
|
|
722
|
+
childCounts: new Map(cards.map(card => [card.id, childrenByParent.get(card.id)?.length ?? 0])),
|
|
723
|
+
descendantCounts,
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function revealConversationThread(cards, threadId) {
|
|
728
|
+
const byId = new Map(cards.map(card => [card.id, card]))
|
|
729
|
+
let changed = false
|
|
730
|
+
for (const target of cards.filter(card => card.dshThreadId === threadId)) {
|
|
731
|
+
const visited = new Set([target.id])
|
|
732
|
+
let parentId = target.parentId
|
|
733
|
+
while (parentId !== null && !visited.has(parentId)) {
|
|
734
|
+
visited.add(parentId)
|
|
735
|
+
if (state.collapsedCardIds.delete(parentId)) changed = true
|
|
736
|
+
parentId = byId.get(parentId)?.parentId ?? null
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if (changed) persistCollapsedCards()
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function canvasConnectors(cards) {
|
|
743
|
+
const index = new Map(cards.map(card => [card.id, card]))
|
|
744
|
+
const links = cards.map(card => {
|
|
745
|
+
const parent = card.parentId === null ? null : index.get(card.parentId)
|
|
746
|
+
if (parent === undefined || parent === null) return ''
|
|
747
|
+
return `<path data-from="${escapeHtml(parent.id)}" data-to="${escapeHtml(card.id)}" d="${connectorPath(parent.position, card.position)}"></path>`
|
|
748
|
+
})
|
|
749
|
+
const placement = draftPlacement(cards)
|
|
750
|
+
if (placement !== null) {
|
|
751
|
+
links.push(`<path class="draft-connector" data-from="${escapeHtml(placement.parent.id)}" data-to="draft" d="${connectorPath(placement.parent.position, placement.position)}"></path>`)
|
|
752
|
+
}
|
|
753
|
+
return links.join('')
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function conversationCard(card, graph) {
|
|
757
|
+
const active = card.dshThreadId === state.activeId ? 'active' : ''
|
|
758
|
+
const source = card.parentId === null ? 'DSH 会话' : card.turnIndex === 0 ? 'DSH 分支' : '追问'
|
|
759
|
+
const continueButton = card.canContinue === true
|
|
760
|
+
? `<button class="graph-continue-button" data-action="open-continue" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" aria-label="添加追问" title="添加追问"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M8 3.5v9M3.5 8h9"/></svg></button>`
|
|
761
|
+
: ''
|
|
762
|
+
const childCount = graph.childCounts.get(card.id) ?? 0
|
|
763
|
+
const collapsed = state.collapsedCardIds.has(card.id)
|
|
764
|
+
const foldLabel = collapsed ? '展开后续对话' : '折叠后续对话'
|
|
765
|
+
const foldButton = childCount === 0 || card.canContinue === true ? '' : `<button class="graph-fold-button${collapsed ? ' collapsed' : ''}" data-action="toggle-card-children" data-card="${escapeHtml(card.id)}" aria-expanded="${collapsed ? 'false' : 'true'}" aria-label="${foldLabel}" title="${foldLabel}"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M3.5 8h9"/>${collapsed ? '<path d="M8 3.5v9"/>' : ''}</svg></button>`
|
|
766
|
+
const branchButton = childCount === 0 || card.canContinue === true || !Number.isInteger(card.answer?.sourceSeq) ? '' : `<button class="graph-branch-button" data-action="open-branch" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" data-seq="${card.answer.sourceSeq}" aria-label="在新对话中分支" title="在新对话中分支"><svg aria-hidden="true" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.0762 1.37207C14.0846 1.37228 14.9021 2.19077 14.9023 3.19922C14.9022 4.20772 14.0847 5.02518 13.0762 5.02539C12.2967 5.02539 11.6325 4.53691 11.3701 3.84961H4.35547C4.79397 4.26458 5.15861 4.7644 5.41699 5.33496L7.10645 9.06738C7.88526 10.7875 9.55104 11.9228 11.4189 12.0371C11.7085 11.4109 12.3411 10.9756 13.0762 10.9756C14.0843 10.9759 14.9023 11.7936 14.9023 12.8018C14.9023 13.81 14.0843 14.6277 13.0762 14.6279C12.2534 14.6279 11.5574 14.0832 11.3291 13.335C8.9868 13.1879 6.89981 11.7612 5.92285 9.60352L4.23242 5.87109C3.67503 4.64033 2.44878 3.84961 1.09766 3.84961V2.54883C1.10665 2.54883 1.11601 2.54975 1.125 2.5498L11.3701 2.54883C11.6326 1.86151 12.2969 1.37207 13.0762 1.37207ZM13.0762 12.2764C12.7858 12.2764 12.5508 12.5114 12.5508 12.8018C12.5508 13.0921 12.7858 13.3281 13.0762 13.3281C13.3664 13.3279 13.6025 13.092 13.6025 12.8018C13.6025 12.5115 13.3664 12.2766 13.0762 12.2764ZM13.0762 2.67285C12.7855 2.67285 12.55 2.90861 12.5498 3.19922C12.5499 3.48987 12.7855 3.72559 13.0762 3.72559C13.3667 3.72538 13.6024 3.48975 13.6025 3.19922C13.6023 2.90874 13.3666 2.67306 13.0762 2.67285Z" fill="currentColor"/></svg></button>`
|
|
767
|
+
return `<article class="thread-card ${active}" data-card-id="${escapeHtml(card.id)}" data-position-key="${escapeHtml(card.positionKey)}" data-thread="${card.dshThreadId}" style="left:${card.position.x}px;top:${card.position.y}px;--thread-color:#3478f6">
|
|
768
|
+
<button class="node-handle" data-drag-card="${card.id}" aria-label="拖动 ${escapeHtml(card.question)}" title="拖动卡片"></button>
|
|
769
|
+
${continueButton}${foldButton}${branchButton}
|
|
770
|
+
<div class="thread-card-head"><span class="topic-dot"></span><button class="thread-title" data-action="show-thread" data-thread="${card.dshThreadId}" title="查看完整会话:${escapeHtml(card.question)}">${escapeHtml(card.question)}</button></div>
|
|
771
|
+
<div class="thread-meta"><span>${source}</span><span>第 ${card.turnIndex + 1} 轮</span></div>
|
|
772
|
+
<div class="thread-answer">${card.answer === null ? '<p class="thread-answer-empty">等待助手回复</p>' : card.answer.pending && card.answer.text === '' ? '<p class="thread-answer-pending">正在回复</p>' : `${renderMarkdown(card.answer.text)}${card.answer.pending ? '<p class="thread-answer-pending">正在回复</p>' : ''}`}</div>
|
|
773
|
+
<footer><button data-action="show-thread" data-thread="${card.dshThreadId}">详情</button><button data-action="open-dsh" data-thread="${card.dshThreadId}">打开 DSH</button><button data-action="archive-thread" data-thread="${card.dshThreadId}">归档</button></footer>
|
|
774
|
+
</article>`
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function draftActions(draft) {
|
|
778
|
+
const disabled = draft.sending ? 'disabled' : ''
|
|
779
|
+
return `<div class="draft-actions"><button type="button" data-action="cancel-draft" ${disabled} aria-label="取消" title="取消"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button><button class="primary" type="submit" ${disabled} aria-label="发送" title="发送"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12.5v-9M4.5 7 8 3.5 11.5 7"/></svg></button></div>`
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function draftPlacement(cards) {
|
|
783
|
+
const draft = state.draft
|
|
784
|
+
if (draft === null || draft.kind === 'new') return null
|
|
785
|
+
const parent = draft.anchorId === undefined
|
|
786
|
+
? cards.filter(card => card.dshThreadId === draft.parentId).at(-1)
|
|
787
|
+
: cards.find(card => card.id === draft.anchorId)
|
|
788
|
+
if (parent === undefined) return null
|
|
789
|
+
return { parent, position: firstAvailableCardPosition({ x: parent.position.x + 365, y: parent.position.y }, cards.map(card => card.position)) }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function draftCard(cards) {
|
|
793
|
+
const draft = state.draft
|
|
794
|
+
if (draft?.kind === 'new') return `<article class="thread-card draft-card first-session-card" data-card-id="draft" style="left:86px;top:82px;--thread-color:#3478f6">
|
|
795
|
+
<div class="thread-card-head"><span class="topic-dot"></span><strong>新会话</strong></div>
|
|
796
|
+
<form class="draft-branch-form" data-draft><textarea maxlength="4000" placeholder="输入第一条消息" ${draft.sending ? 'disabled' : ''}>${escapeHtml(draft.text)}</textarea>${draftActions(draft)}</form>
|
|
797
|
+
</article>`
|
|
798
|
+
const placement = draftPlacement(cards)
|
|
799
|
+
if (draft === null || placement === null) return ''
|
|
800
|
+
const continuing = draft.kind === 'continue'
|
|
801
|
+
return `<article class="thread-card draft-card" data-card-id="draft" style="left:${placement.position.x}px;top:${placement.position.y}px;--thread-color:#3478f6">
|
|
802
|
+
<div class="thread-card-head"><span class="topic-dot"></span><strong>${continuing ? '新的追问' : '新的分支'}</strong></div>
|
|
803
|
+
<form class="draft-branch-form" data-draft><textarea maxlength="4000" placeholder="${continuing ? '输入追问' : '输入这个分支的新问题'}" ${draft.sending ? 'disabled' : ''}>${escapeHtml(draft.text)}</textarea>${draftActions(draft)}</form>
|
|
804
|
+
</article>`
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function renderCanvas() {
|
|
808
|
+
const threads = state.workspace?.threads ?? []
|
|
809
|
+
if (threads.length === 0 && state.draft?.kind !== 'new') return `<section class="empty-canvas"><strong>当前工作目录还没有 DSH 对话。</strong><p>点击新会话,在画布中输入第一条消息。</p><div><button class="primary" type="button" data-action="create-session">新建会话</button></div></section>`
|
|
810
|
+
const allCards = conversationCards(threads)
|
|
811
|
+
const graph = conversationGraphView(allCards)
|
|
812
|
+
const cards = graph.cards
|
|
813
|
+
if (!state.canvasViewInitialized) {
|
|
814
|
+
state.canvasCamera = initialCanvasCamera(cards)
|
|
815
|
+
state.canvasViewInitialized = true
|
|
816
|
+
}
|
|
817
|
+
return `<section class="canvas-view"><div class="canvas-viewport"><div class="canvas-content" style="transform:translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})"><svg class="connectors">${canvasConnectors(cards)}</svg><div class="cards-layer">${cards.map(card => conversationCard(card, graph)).join('')}${draftCard(cards)}</div></div></div></section>`
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function isProcessMessage(message) {
|
|
821
|
+
if (message.kind === 'tool' || message.kind === 'tool-result') return true
|
|
822
|
+
return message.kind === 'assistant' && /(?:^|\n)\s*(?:bash|pwsh|powershell|web_search|web_fetch|browser|read_file|write_file)\s*\n\s*\{/.test(message.text)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function processSummary(text) {
|
|
826
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, 140) || '工具调用记录'
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function threadMessage(thread, message) {
|
|
830
|
+
const isUser = message.kind === 'user'
|
|
831
|
+
const label = isUser ? '你' : message.kind === 'assistant' ? 'DSH' : message.kind === 'error' ? '错误' : '记录'
|
|
832
|
+
const branch = message.kind === 'assistant' && Number.isInteger(message.sourceSeq)
|
|
833
|
+
? `<button class="message-branch" data-action="open-branch" data-thread="${thread.id}" data-seq="${message.sourceSeq}" title="从此回答创建分支"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M4.5 3v6a2.5 2.5 0 0 0 2.5 2.5H12"/><circle cx="4.5" cy="3" r="1.5"/><circle cx="11.5" cy="12" r="1.5"/></svg>分支</button>`
|
|
834
|
+
: ''
|
|
835
|
+
const messageId = `${thread.id}:${message.sourceSeq ?? `${message.kind}:${message.at}`}`
|
|
836
|
+
const collapsible = isProcessMessage(message)
|
|
837
|
+
const expanded = state.expandedMessageIds.has(messageId)
|
|
838
|
+
const fold = collapsible ? `<button class="message-fold" data-action="toggle-message" data-message="${escapeHtml(messageId)}" aria-label="${expanded ? '收起过程记录' : '展开过程记录'}" title="${expanded ? '收起' : '展开'}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3.5 4.5 4.5L6 12.5"/></svg></button>` : ''
|
|
839
|
+
const process = Array.isArray(message.process) && message.process.length > 0 ? message.process : null
|
|
840
|
+
const body = message.pending && message.text === '' ? '<p class="message-streaming"><span class="streaming-dot"></span>正在回复</p>'
|
|
841
|
+
: `${collapsible && !expanded ? `<p class="message-summary">${escapeHtml(processSummary(message.text))}</p>` : renderMarkdown(message.text)}${message.pending ? '<p class="message-streaming"><span class="streaming-dot"></span>正在回复</p>' : ''}${process === null ? '' : processRecords(process, messageId)}`
|
|
842
|
+
const avatar = isUser ? '' : '<span class="message-avatar" aria-hidden="true"></span>'
|
|
843
|
+
return `<article class="message message-${message.kind}${message.pending ? ' message-pending' : ''}${collapsible ? ' message-collapsible' : ''}${expanded ? ' expanded' : ''}"><header>${avatar}<span class="message-role">${label}</span><time>${formatTime(message.at)}</time>${branch}${fold}</header><div class="message-body">${body}</div></article>`
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function processRecords(process, messageId) {
|
|
847
|
+
const key = `${messageId}:process`
|
|
848
|
+
const expanded = state.expandedMessageIds.has(key)
|
|
849
|
+
const entries = process.map((entry, index) => {
|
|
850
|
+
const entryKey = `${key}:${index}`
|
|
851
|
+
const entryExpanded = state.expandedMessageIds.has(entryKey)
|
|
852
|
+
const status = entry.error !== null ? '失败' : entry.result === null ? '等待结果' : '完成'
|
|
853
|
+
const argumentsHtml = entry.arguments === null || entry.arguments === '' ? '' : `<pre class="process-args">${escapeHtml(entry.arguments)}</pre>`
|
|
854
|
+
const outcomeHtml = entry.error !== null ? `<pre class="process-error">${escapeHtml(entry.error)}</pre>` : entry.result === null ? '' : `<pre class="process-result">${escapeHtml(entry.result)}</pre>`
|
|
855
|
+
return `<div class="process-entry${entryExpanded ? ' expanded' : ''}"><button class="process-entry-fold" data-action="toggle-message" data-message="${escapeHtml(entryKey)}"><span class="process-entry-name">${escapeHtml(entry.name)}</span><span class="process-status${entry.error !== null ? ' process-status-error' : entry.result === null ? ' process-status-pending' : ' process-status-done'}">${status}</span></button>${entryExpanded ? `<div class="process-entry-body">${argumentsHtml}${outcomeHtml}</div>` : ''}</div>`
|
|
856
|
+
}).join('')
|
|
857
|
+
return `<section class="process-records${expanded ? ' expanded' : ''}"><button class="process-records-fold" data-action="toggle-message" data-message="${escapeHtml(key)}"><span>${expanded ? '收起过程记录' : '过程记录'}</span><span class="process-count">${process.length}</span></button>${expanded ? entries : ''}</section>`
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function renderThread() {
|
|
861
|
+
const thread = currentThread()
|
|
862
|
+
if (thread === null) return renderCanvas()
|
|
863
|
+
const messages = messagesFor(thread)
|
|
864
|
+
const waiting = state.pendingReplies.has(thread.dshSessionId)
|
|
865
|
+
return `<section class="detail-view"><header class="detail-head"><div class="detail-head-title"><div class="detail-head-meta"><span class="detail-badge">${thread.parentId === null ? '会话' : '分支'}</span>${thread.dshSessionTitle ?? thread.title ? `<span class="detail-subtitle">${escapeHtml(thread.dshSessionTitle ?? thread.title)}</span>` : ''}</div><h1>${escapeHtml(questionFor(thread))}</h1></div><div class="detail-head-actions"><button data-action="open-dsh" data-thread="${thread.id}" title="在原生对话中打开此会话">在 DSH 中打开</button><button data-action="open-branch" data-thread="${thread.id}" title="基于最新回答创建分支">创建分支</button><button class="primary" data-action="show-canvas">返回画布</button></div></header><div class="detail-scroll">${messages.map(message => threadMessage(thread, message)).join('') || '<div class="note-empty">等待这条会话的第一条消息。</div>'}</div><form class="message-composer" data-compose="${thread.id}"><textarea maxlength="4000" placeholder="继续当前会话…" ${waiting ? 'disabled' : ''}></textarea><button class="primary" type="submit" ${waiting ? 'disabled' : ''}>${waiting ? '等待回复' : '发送'}</button></form></section>`
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function render() {
|
|
869
|
+
const detail = state.mode === 'thread' ? document.querySelector('.detail-scroll') : null
|
|
870
|
+
const detailScrollTop = detail instanceof HTMLElement ? detail.scrollTop : null
|
|
871
|
+
const cardScrollTops = new Map()
|
|
872
|
+
if (state.mode === 'canvas') {
|
|
873
|
+
// Key by the unique card id: every card of a session shares data-thread,
|
|
874
|
+
// so keying on it would clobber sibling cards' scroll positions.
|
|
875
|
+
for (const answer of document.querySelectorAll('.thread-card[data-thread] .thread-answer')) {
|
|
876
|
+
const card = answer.closest('.thread-card')
|
|
877
|
+
if (card instanceof HTMLElement && typeof card.dataset.cardId === 'string') cardScrollTops.set(card.dataset.cardId, answer.scrollTop)
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const workspace = state.workspace
|
|
881
|
+
const threads = workspace?.threads ?? []
|
|
882
|
+
const view = state.mode === 'thread' ? renderThread() : renderCanvas()
|
|
883
|
+
const choices = workspaceChoices()
|
|
884
|
+
const selectedWorkspaceId = state.selectedDshWorkspaceId ?? workspace?.id
|
|
885
|
+
const canvasControls = state.mode === 'canvas' && (threads.length > 0 || state.draft?.kind === 'new') ? `<div class="canvas-controls"><button data-action="layout">整理节点</button><button data-action="focus-active" title="定位到当前会话">定位</button><button data-action="zoom-out" aria-label="缩小">-</button><span>${Math.round(state.zoom * 100)}%</span><button data-action="zoom-in" aria-label="放大">+</button></div>` : ''
|
|
886
|
+
const detailAvailable = currentThread() !== null
|
|
887
|
+
const canvasTabs = `<nav class="canvas-tabs" aria-label="会话地图视图"><button class="${state.mode === 'canvas' ? 'active' : ''}" data-action="show-canvas">地图</button><button class="${state.mode === 'thread' ? 'active' : ''}" data-action="show-thread" data-thread="${state.activeId ?? ''}" ${detailAvailable ? '' : 'disabled'}>详情</button></nav>`
|
|
888
|
+
app.innerHTML = `<main class="synapse-shell ${state.sidebarCollapsed ? 'sidebar-collapsed' : ''}"><aside class="sidebar"><div class="sidebar-brand-row"><div class="brand" aria-label="Synapse"><svg class="brand-mark" aria-hidden="true" viewBox="0 0 32 32" fill="none"><path d="M9 10.5 16 7l7 3.5M9 10.5v8L16 22m0-15v15m7-11.5v8L16 22"/><circle cx="9" cy="10" r="2.5"/><circle cx="23" cy="10" r="2.5"/><circle cx="16" cy="23" r="2.5"/></svg><strong>Synapse</strong></div><button class="sidebar-toggle" type="button" data-action="toggle-sidebar" aria-label="${state.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}" title="${state.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}"><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25"/><path d="M6 2v12"/></svg></button></div><button class="new-workspace" type="button" data-action="create-session" ${state.draft !== null ? 'disabled' : ''}><svg class="new-session-icon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 4.75v6.5M4.75 8h6.5"/></svg><span>新会话</span></button><label class="workspace-label"><span>工作区</span><span class="workspace-select"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M2.5 4.75h3l1.2 1.5h6.8v5.5a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z"/></svg><select data-action="select-workspace" aria-label="选择工作区" ${state.draft !== null ? 'disabled' : ''}>${choices.map(item => `<option value="${item.id}" title="${escapeHtml(item.path ?? item.title)}" ${item.id === selectedWorkspaceId ? 'selected' : ''}>${escapeHtml(item.title)}</option>`).join('')}</select></span></label><div class="sidebar-heading"><span>会话</span></div><nav class="thread-tree">${threads.map(thread => `<button class="tree-row ${thread.id === state.activeId ? 'active' : ''}" data-action="select-thread" data-thread="${thread.id}" style="--thread-color:#374151"><span class="tree-dot"></span><span>${escapeHtml(threadListTitle(thread))}</span>${thread.parentId === null ? '' : '<i>分支</i>'}</button>`).join('') || '<p class="tree-empty">暂未同步会话</p>'}</nav></aside><header class="topbar"><div class="view-switch" role="group" aria-label="视图切换"><button data-action="close" type="button" aria-pressed="false">对话</button><button class="active" type="button" aria-pressed="true">会话地图</button></div>${canvasControls}</header><section class="main-stage">${state.error ? `<div class="status-message" role="alert"><span>${escapeHtml(state.error)}</span><button data-action="dismiss-error" aria-label="关闭" title="关闭">×</button></div>` : ''}${canvasTabs}${view}</section></main>`
|
|
889
|
+
installDragging()
|
|
890
|
+
for (const [cardId, scrollTop] of cardScrollTops) {
|
|
891
|
+
const answer = app.querySelector(`.thread-card[data-card-id="${CSS.escape(cardId)}"] .thread-answer`)
|
|
892
|
+
if (answer instanceof HTMLElement) answer.scrollTop = scrollTop
|
|
893
|
+
}
|
|
894
|
+
if (detailScrollTop !== null) window.requestAnimationFrame(() => {
|
|
895
|
+
const nextDetail = document.querySelector('.detail-scroll')
|
|
896
|
+
if (nextDetail instanceof HTMLElement) nextDetail.scrollTop = detailScrollTop
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function renderPreservingDetailScroll() {
|
|
901
|
+
render()
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function applyCanvasTransform() {
|
|
905
|
+
const content = document.querySelector('.canvas-content')
|
|
906
|
+
if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function installDragging() {
|
|
910
|
+
for (const handle of document.querySelectorAll('[data-drag-card]')) handle.addEventListener('pointerdown', event => {
|
|
911
|
+
const cardId = event.currentTarget.dataset.dragCard
|
|
912
|
+
const card = event.currentTarget.closest('.thread-card')
|
|
913
|
+
if (cardId === undefined || !(card instanceof HTMLElement)) return
|
|
914
|
+
event.preventDefault()
|
|
915
|
+
const origin = { x: event.clientX, y: event.clientY, position: { x: Number.parseFloat(card.style.left), y: Number.parseFloat(card.style.top) } }
|
|
916
|
+
const aliases = card.dataset.positionKey === undefined ? [] : [card.dataset.positionKey]
|
|
917
|
+
let position = origin.position
|
|
918
|
+
let stopped = false
|
|
919
|
+
state.dragging = true
|
|
920
|
+
const move = moveEvent => {
|
|
921
|
+
position = { x: origin.position.x + (moveEvent.clientX - origin.x) / state.zoom, y: origin.position.y + (moveEvent.clientY - origin.y) / state.zoom }
|
|
922
|
+
state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
|
|
923
|
+
for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
|
|
924
|
+
card.style.left = `${position.x}px`
|
|
925
|
+
card.style.top = `${position.y}px`
|
|
926
|
+
refreshCardConnectors(cardId)
|
|
927
|
+
}
|
|
928
|
+
const stop = () => {
|
|
929
|
+
if (stopped) return
|
|
930
|
+
stopped = true
|
|
931
|
+
document.removeEventListener('pointermove', move)
|
|
932
|
+
document.removeEventListener('pointerup', stop)
|
|
933
|
+
document.removeEventListener('pointercancel', stop)
|
|
934
|
+
rememberCardPosition(cardId, position, aliases)
|
|
935
|
+
state.dragging = false
|
|
936
|
+
deferCanvasRefresh(120)
|
|
937
|
+
render()
|
|
938
|
+
}
|
|
939
|
+
document.addEventListener('pointermove', move)
|
|
940
|
+
document.addEventListener('pointerup', stop)
|
|
941
|
+
document.addEventListener('pointercancel', stop)
|
|
942
|
+
})
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function canvasViewport(target) {
|
|
946
|
+
return target instanceof Element ? target.closest('.canvas-viewport') : null
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function zoomCanvas(viewport, nextZoom, clientX, clientY) {
|
|
950
|
+
const zoom = Math.min(4, Math.max(.6, Math.round(nextZoom * 100) / 100))
|
|
951
|
+
if (zoom === state.zoom) return
|
|
952
|
+
const bounds = viewport.getBoundingClientRect()
|
|
953
|
+
const localX = clientX - bounds.left
|
|
954
|
+
const localY = clientY - bounds.top
|
|
955
|
+
const worldX = (localX - state.canvasCamera.x) / state.zoom
|
|
956
|
+
const worldY = (localY - state.canvasCamera.y) / state.zoom
|
|
957
|
+
state.zoom = zoom
|
|
958
|
+
state.canvasCamera = { x: localX - worldX * zoom, y: localY - worldY * zoom }
|
|
959
|
+
applyCanvasTransform()
|
|
960
|
+
const label = document.querySelector('.canvas-controls span')
|
|
961
|
+
if (label !== null) label.textContent = `${Math.round(state.zoom * 100)}%`
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function zoomCanvasAtCenter(delta) {
|
|
965
|
+
const viewport = document.querySelector('.canvas-viewport')
|
|
966
|
+
if (!(viewport instanceof HTMLElement)) return
|
|
967
|
+
const bounds = viewport.getBoundingClientRect()
|
|
968
|
+
zoomCanvas(viewport, state.zoom + delta, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function focusActiveCard() {
|
|
972
|
+
const card = document.querySelector('.thread-card.active') ?? document.querySelector('.thread-card[data-thread]:not(.draft-card)')
|
|
973
|
+
const viewport = document.querySelector('.canvas-viewport')
|
|
974
|
+
if (!(card instanceof HTMLElement) || !(viewport instanceof HTMLElement)) return
|
|
975
|
+
const left = Number.parseFloat(card.style.left)
|
|
976
|
+
const top = Number.parseFloat(card.style.top)
|
|
977
|
+
if (!Number.isFinite(left) || !Number.isFinite(top)) return
|
|
978
|
+
const bounds = viewport.getBoundingClientRect()
|
|
979
|
+
state.canvasCamera = {
|
|
980
|
+
x: bounds.width / 2 - (left + CARD_WIDTH / 2) * state.zoom,
|
|
981
|
+
y: bounds.height / 2 - (top + CARD_HEIGHT / 2) * state.zoom,
|
|
982
|
+
}
|
|
983
|
+
applyCanvasTransform()
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
app.addEventListener('pointerdown', event => {
|
|
987
|
+
const viewport = canvasViewport(event.target)
|
|
988
|
+
if (!(viewport instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.thread-card, button, textarea, select')) return
|
|
989
|
+
event.preventDefault()
|
|
990
|
+
const origin = { x: event.clientX, y: event.clientY, camera: { ...state.canvasCamera } }
|
|
991
|
+
state.canvasGesture = true
|
|
992
|
+
viewport.classList.add('is-panning')
|
|
993
|
+
viewport.setPointerCapture(event.pointerId)
|
|
994
|
+
const move = moveEvent => {
|
|
995
|
+
state.canvasCamera = {
|
|
996
|
+
x: origin.camera.x + moveEvent.clientX - origin.x,
|
|
997
|
+
y: origin.camera.y + moveEvent.clientY - origin.y,
|
|
998
|
+
}
|
|
999
|
+
applyCanvasTransform()
|
|
1000
|
+
}
|
|
1001
|
+
const stop = () => {
|
|
1002
|
+
viewport.classList.remove('is-panning')
|
|
1003
|
+
document.removeEventListener('pointermove', move)
|
|
1004
|
+
document.removeEventListener('pointerup', stop)
|
|
1005
|
+
document.removeEventListener('pointercancel', stop)
|
|
1006
|
+
state.canvasGesture = false
|
|
1007
|
+
deferCanvasRefresh(120)
|
|
1008
|
+
}
|
|
1009
|
+
document.addEventListener('pointermove', move)
|
|
1010
|
+
document.addEventListener('pointerup', stop)
|
|
1011
|
+
document.addEventListener('pointercancel', stop)
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
app.addEventListener('wheel', event => {
|
|
1015
|
+
const viewport = canvasViewport(event.target)
|
|
1016
|
+
if (!(viewport instanceof HTMLElement)) return
|
|
1017
|
+
const card = event.target instanceof Element ? event.target.closest('.thread-card') : null
|
|
1018
|
+
if (card instanceof HTMLElement) {
|
|
1019
|
+
// Over a card the wheel scrolls that card's own answer with the browser's
|
|
1020
|
+
// native wheel (OS-smooth, never a page jump per notch); the answer's
|
|
1021
|
+
// overscroll-behavior: contain stops the scroll chaining into the canvas.
|
|
1022
|
+
const answer = card.querySelector('.thread-answer')
|
|
1023
|
+
if (answer instanceof HTMLElement && answer.scrollHeight > answer.clientHeight) {
|
|
1024
|
+
deferCanvasRefresh()
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
1027
|
+
// A card with no scrollable answer swallows the wheel instead of zooming.
|
|
1028
|
+
event.preventDefault()
|
|
1029
|
+
deferCanvasRefresh()
|
|
1030
|
+
return
|
|
1031
|
+
}
|
|
1032
|
+
event.preventDefault()
|
|
1033
|
+
zoomCanvas(viewport, state.zoom + (event.deltaY < 0 ? .05 : -.05), event.clientX, event.clientY)
|
|
1034
|
+
}, { passive: false })
|
|
1035
|
+
|
|
1036
|
+
// Track pointer-down so the card click handler can tell a plain click from a
|
|
1037
|
+
// text-selection or drag gesture; acting on the latter would re-render and
|
|
1038
|
+
// wipe the user's selection.
|
|
1039
|
+
let pointerDownPosition = null
|
|
1040
|
+
app.addEventListener('pointerdown', event => { pointerDownPosition = { x: event.clientX, y: event.clientY } })
|
|
1041
|
+
|
|
1042
|
+
app.addEventListener('click', async event => {
|
|
1043
|
+
const button = event.target.closest('[data-action]')
|
|
1044
|
+
if (!(button instanceof HTMLElement)) {
|
|
1045
|
+
const card = event.target instanceof Element ? event.target.closest('.thread-card[data-thread]:not(.draft-card)') : null
|
|
1046
|
+
if (!(card instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.node-handle, textarea, select, form')) return
|
|
1047
|
+
// A double-click selects a word and a drag selects a range; neither is a
|
|
1048
|
+
// select-click, so leave the selection intact instead of re-rendering.
|
|
1049
|
+
if (event.detail > 1) return
|
|
1050
|
+
if (pointerDownPosition !== null
|
|
1051
|
+
&& Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y) > 4) return
|
|
1052
|
+
const thread = state.workspace?.threads.find(item => item.id === card.dataset.thread)
|
|
1053
|
+
if (thread === undefined) return
|
|
1054
|
+
state.activeId = thread.id
|
|
1055
|
+
state.error = ''
|
|
1056
|
+
render()
|
|
1057
|
+
void loadThreadHistory(thread)
|
|
1058
|
+
// Bidirectional current-session sync: switch DSH's current session
|
|
1059
|
+
// without closing the map; the client confirms via synapse:current-session.
|
|
1060
|
+
if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
const thread = state.workspace?.threads.find(item => item.id === button.dataset.thread)
|
|
1064
|
+
try {
|
|
1065
|
+
if (button.dataset.action === 'close') post('synapse:close')
|
|
1066
|
+
if (button.dataset.action === 'toggle-sidebar') { state.sidebarCollapsed = !state.sidebarCollapsed; render() }
|
|
1067
|
+
if (button.dataset.action === 'create-session') openNewSession()
|
|
1068
|
+
if (button.dataset.action === 'open-current' && state.currentDsh !== null) post('synapse:open-session', { sessionId: state.currentDsh.id })
|
|
1069
|
+
if (button.dataset.action === 'select-thread' && thread !== undefined) {
|
|
1070
|
+
state.activeId = thread.id
|
|
1071
|
+
state.error = ''
|
|
1072
|
+
if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
|
|
1073
|
+
render()
|
|
1074
|
+
void loadThreadHistory(thread)
|
|
1075
|
+
// Bidirectional current-session sync: switch DSH's current session
|
|
1076
|
+
// without closing the map; the client confirms via synapse:current-session.
|
|
1077
|
+
if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
|
|
1078
|
+
}
|
|
1079
|
+
if (button.dataset.action === 'show-thread' && thread !== undefined) { state.activeId = thread.id; state.mode = 'thread'; render(); void loadThreadHistory(thread) }
|
|
1080
|
+
if (button.dataset.action === 'show-canvas') { state.mode = 'canvas'; render() }
|
|
1081
|
+
if (button.dataset.action === 'toggle-card-children' && button.dataset.card !== undefined) {
|
|
1082
|
+
const cardId = button.dataset.card
|
|
1083
|
+
const collapsing = !state.collapsedCardIds.has(cardId)
|
|
1084
|
+
if (collapsing && state.workspace !== null) {
|
|
1085
|
+
const allCards = conversationCards(state.workspace.threads)
|
|
1086
|
+
const nextCollapsed = new Set(state.collapsedCardIds).add(cardId)
|
|
1087
|
+
const visibleCards = conversationGraphView(allCards, nextCollapsed).cards
|
|
1088
|
+
const visibleIds = new Set(visibleCards.map(card => card.id))
|
|
1089
|
+
const draftParentId = draftPlacement(allCards)?.parent.id
|
|
1090
|
+
if (draftParentId !== undefined && !visibleIds.has(draftParentId)) return setError('请先完成或取消正在编辑的追问或分支')
|
|
1091
|
+
if (state.activeId !== null && !visibleCards.some(card => card.dshThreadId === state.activeId)) return setError('当前会话位于这个后续分支中,请先切换会话')
|
|
1092
|
+
}
|
|
1093
|
+
collapsing ? state.collapsedCardIds.add(cardId) : state.collapsedCardIds.delete(cardId)
|
|
1094
|
+
persistCollapsedCards()
|
|
1095
|
+
render()
|
|
1096
|
+
window.setTimeout(() => document.querySelector(`[data-action="toggle-card-children"][data-card="${selectorValue(cardId)}"]`)?.focus(), 0)
|
|
1097
|
+
}
|
|
1098
|
+
if (button.dataset.action === 'open-continue' && thread !== undefined) openContinue(thread, button.dataset.card)
|
|
1099
|
+
if (button.dataset.action === 'open-branch' && thread !== undefined) {
|
|
1100
|
+
const requestedSeq = Number(button.dataset.seq)
|
|
1101
|
+
if (button.dataset.card !== undefined && !Number.isInteger(requestedSeq)) return setError('请等待这张卡片的最终回答后再创建分支')
|
|
1102
|
+
const fallbackSeq = latestMessage(thread, 'assistant')?.sourceSeq
|
|
1103
|
+
openBranch(thread, Number.isInteger(requestedSeq) ? requestedSeq : fallbackSeq, button.dataset.card)
|
|
1104
|
+
}
|
|
1105
|
+
if (button.dataset.action === 'cancel-draft') { state.draft = null; render() }
|
|
1106
|
+
if (button.dataset.action === 'toggle-message' && button.dataset.message !== undefined) { state.expandedMessageIds.has(button.dataset.message) ? state.expandedMessageIds.delete(button.dataset.message) : state.expandedMessageIds.add(button.dataset.message); renderPreservingDetailScroll() }
|
|
1107
|
+
if (button.dataset.action === 'open-dsh' && thread?.dshSessionId !== null) post('synapse:open-session', { sessionId: thread.dshSessionId })
|
|
1108
|
+
if (button.dataset.action === 'archive-thread' && thread !== undefined) await archiveThread(thread)
|
|
1109
|
+
if (button.dataset.action === 'zoom-in') zoomCanvasAtCenter(.1)
|
|
1110
|
+
if (button.dataset.action === 'zoom-out') zoomCanvasAtCenter(-.1)
|
|
1111
|
+
if (button.dataset.action === 'focus-active') focusActiveCard()
|
|
1112
|
+
if (button.dataset.action === 'dismiss-error') { state.error = ''; render() }
|
|
1113
|
+
if (button.dataset.action === 'layout' && state.workspace !== null) {
|
|
1114
|
+
resetCardPositions()
|
|
1115
|
+
resetCanvasCamera()
|
|
1116
|
+
render()
|
|
1117
|
+
}
|
|
1118
|
+
} catch (error) { setError(error) }
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
app.addEventListener('change', event => {
|
|
1122
|
+
const select = event.target.closest('[data-action="select-workspace"]')
|
|
1123
|
+
if (!(select instanceof HTMLSelectElement)) return
|
|
1124
|
+
const choice = workspaceChoices().find(item => item.id === select.value)
|
|
1125
|
+
if (choice?.source === 'dsh') {
|
|
1126
|
+
// Map → native sync: switching workspaces moves DSH's current session to
|
|
1127
|
+
// the workspace's most recently updated session, keeping both sides in step.
|
|
1128
|
+
void openDshWorkspace(choice.id).then(opened => {
|
|
1129
|
+
if (!opened) return
|
|
1130
|
+
const threads = state.workspace?.threads ?? []
|
|
1131
|
+
const latest = threads
|
|
1132
|
+
.filter(thread => thread.dshSessionId !== null)
|
|
1133
|
+
.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))[0]
|
|
1134
|
+
const sessionId = latest?.dshSessionId ?? choice.sessionIds[0]
|
|
1135
|
+
if (sessionId !== undefined) post('synapse:activate-session', { sessionId })
|
|
1136
|
+
}).catch(setError)
|
|
1137
|
+
} else if (choice !== undefined) { state.selectedDshWorkspaceId = null; void openWorkspace(choice.id).catch(setError) }
|
|
1138
|
+
})
|
|
1139
|
+
app.addEventListener('input', event => { const input = event.target; if (input instanceof HTMLTextAreaElement && input.closest('[data-draft]') && state.draft !== null) state.draft.text = input.value })
|
|
1140
|
+
app.addEventListener('submit', event => {
|
|
1141
|
+
const form = event.target
|
|
1142
|
+
if (!(form instanceof HTMLFormElement)) return
|
|
1143
|
+
if (form.matches('[data-draft]')) { event.preventDefault(); void submitDraft(); return }
|
|
1144
|
+
const thread = state.workspace?.threads.find(item => item.id === form.dataset.compose)
|
|
1145
|
+
const input = form.querySelector('textarea')
|
|
1146
|
+
if (thread === undefined || !(input instanceof HTMLTextAreaElement) || input.value.trim() === '') return
|
|
1147
|
+
event.preventDefault()
|
|
1148
|
+
const text = input.value.trim()
|
|
1149
|
+
input.value = ''
|
|
1150
|
+
void sendMessage(thread, text).catch(setError)
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
window.addEventListener('message', event => {
|
|
1154
|
+
if (event.origin !== window.location.origin || event.data?.source !== 'dsh-synapse') return
|
|
1155
|
+
const data = event.data
|
|
1156
|
+
if (data.type === 'synapse:map-opened') {
|
|
1157
|
+
resetCanvasCamera()
|
|
1158
|
+
state.mode = 'canvas'
|
|
1159
|
+
render()
|
|
1160
|
+
window.requestAnimationFrame(() => post('synapse:map-ready'))
|
|
1161
|
+
}
|
|
1162
|
+
if (data.type === 'synapse:theme') {
|
|
1163
|
+
document.documentElement.dataset.theme = data.dark === true ? 'dark' : 'light'
|
|
1164
|
+
}
|
|
1165
|
+
if (data.type === 'synapse:workspaces') {
|
|
1166
|
+
state.dshWorkspaces = Array.isArray(data.workspaces) ? data.workspaces.filter(workspace => typeof workspace?.id === 'string' && typeof workspace.title === 'string' && Array.isArray(workspace.sessionIds)) : []
|
|
1167
|
+
const current = currentDshWorkspace()
|
|
1168
|
+
if (current !== undefined && current.id !== state.selectedDshWorkspaceId) void openDshWorkspace(current.id).catch(setError)
|
|
1169
|
+
else if (state.selectedDshWorkspaceId !== null) void openDshWorkspace(state.selectedDshWorkspaceId).catch(setError)
|
|
1170
|
+
else if (canReplaceView()) render()
|
|
1171
|
+
}
|
|
1172
|
+
if (data.type === 'synapse:current-session') {
|
|
1173
|
+
const previousId = state.currentDsh?.id
|
|
1174
|
+
state.currentDsh = data.session
|
|
1175
|
+
const thread = currentDshThread()
|
|
1176
|
+
if (thread !== undefined) {
|
|
1177
|
+
state.activeId = thread.id
|
|
1178
|
+
if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
|
|
1179
|
+
}
|
|
1180
|
+
if (previousId !== data.session?.id) void openCurrentWorkspace().then(opened => { if (!opened && canReplaceView()) render() }).catch(setError)
|
|
1181
|
+
else if (canReplaceView()) render()
|
|
1182
|
+
}
|
|
1183
|
+
if (data.type === 'synapse:live-reply' && typeof data.sessionId === 'string') {
|
|
1184
|
+
const thread = state.workspace?.threads.find(item => item.dshSessionId === data.sessionId)
|
|
1185
|
+
if (thread !== undefined) {
|
|
1186
|
+
if (data.running === true) {
|
|
1187
|
+
state.liveReplies.set(data.sessionId, { running: true, text: typeof data.text === 'string' ? data.text : '' })
|
|
1188
|
+
// Streaming: patch the live card's answer in place instead of
|
|
1189
|
+
// rebuilding the whole canvas on every chunk; a full render reconciles
|
|
1190
|
+
// at stream end. The detail view is single-thread, so keep its cheap
|
|
1191
|
+
// throttled full render.
|
|
1192
|
+
if (state.mode === 'canvas') scheduleLiveCardUpdate(data.sessionId)
|
|
1193
|
+
else if (canReplaceView()) scheduleLiveRender()
|
|
1194
|
+
} else {
|
|
1195
|
+
state.liveReplies.delete(data.sessionId)
|
|
1196
|
+
if (canReplaceView() || state.pendingReplies.has(data.sessionId)) renderPreservingDetailScroll()
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (data.type === 'synapse:forked-session' || data.type === 'synapse:created-session' || data.type === 'synapse:message-sent') settleRpc(data.requestId, data.session ?? data)
|
|
1201
|
+
if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
post('synapse:request-current')
|
|
1205
|
+
refreshSummaries().catch(setError)
|
|
1206
|
+
let polling = false
|
|
1207
|
+
let liveRenderTimer = 0
|
|
1208
|
+
let liveCardFrame = 0
|
|
1209
|
+
let liveCardSessionId = null
|
|
1210
|
+
function scheduleLiveCardUpdate(sessionId) {
|
|
1211
|
+
// Coalesce streaming chunks to one DOM patch per animation frame.
|
|
1212
|
+
liveCardSessionId = sessionId
|
|
1213
|
+
if (liveCardFrame !== 0) return
|
|
1214
|
+
liveCardFrame = window.requestAnimationFrame(() => {
|
|
1215
|
+
liveCardFrame = 0
|
|
1216
|
+
if (liveCardSessionId === null) return
|
|
1217
|
+
const id = liveCardSessionId
|
|
1218
|
+
liveCardSessionId = null
|
|
1219
|
+
applyLiveReplyToCard(id)
|
|
1220
|
+
})
|
|
1221
|
+
}
|
|
1222
|
+
function applyLiveReplyToCard(sessionId) {
|
|
1223
|
+
if (state.mode !== 'canvas') return
|
|
1224
|
+
const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId)
|
|
1225
|
+
if (thread === undefined) return
|
|
1226
|
+
const live = state.liveReplies.get(sessionId)
|
|
1227
|
+
if (live?.running !== true) return
|
|
1228
|
+
const cards = app.querySelectorAll(`.thread-card[data-thread="${CSS.escape(thread.id)}"]`)
|
|
1229
|
+
const card = cards[cards.length - 1]
|
|
1230
|
+
if (!(card instanceof HTMLElement)) return
|
|
1231
|
+
const answer = card.querySelector('.thread-answer')
|
|
1232
|
+
if (!(answer instanceof HTMLElement)) return
|
|
1233
|
+
const text = live.text
|
|
1234
|
+
answer.innerHTML = text.trim() === ''
|
|
1235
|
+
? '<p class="thread-answer-pending">正在回复</p>'
|
|
1236
|
+
: `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`
|
|
1237
|
+
}
|
|
1238
|
+
function scheduleLiveRender() {
|
|
1239
|
+
if (liveRenderTimer !== 0 || !canReplaceView()) return
|
|
1240
|
+
liveRenderTimer = window.setTimeout(() => {
|
|
1241
|
+
liveRenderTimer = 0
|
|
1242
|
+
if (canReplaceView()) renderPreservingDetailScroll()
|
|
1243
|
+
}, 120)
|
|
1244
|
+
}
|
|
1245
|
+
async function pollProjection() {
|
|
1246
|
+
if (polling || document.hidden || !canReplaceView()) return
|
|
1247
|
+
polling = true
|
|
1248
|
+
try {
|
|
1249
|
+
await refreshProjection()
|
|
1250
|
+
} finally { polling = false }
|
|
1251
|
+
}
|
|
1252
|
+
window.setInterval(() => { void pollProjection() }, 1_000)
|