dsh-synapse 0.4.0 → 0.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/app.js CHANGED
@@ -1,1252 +1,1806 @@
1
- const app = document.querySelector('#app')
2
- if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
1
+ const app = document.querySelector('#app')
2
+ if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
3
3
  const LEGACY_CARD_POSITIONS_KEY = 'dsh-synapse:card-positions'
4
4
  const CARD_POSITIONS_KEY = 'dsh-synapse:card-positions:v3'
5
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 = (() => {
6
+ const QUICK_PHRASES_KEY = 'dsh-synapse:quick-phrases:v1'
7
+ const DEFAULT_QUICK_PHRASES = ['展开说明', '举例', '通俗易懂', '对比解释']
8
+ const MAX_QUICK_PHRASES = 12
9
+ const MAX_QUICK_PHRASE_LENGTH = 16
10
+ function normalizeQuickPhrases(value) {
11
+ if (!Array.isArray(value)) return []
12
+ const phrases = []
13
+ for (const item of value) {
14
+ const phrase = typeof item === 'string' ? item.trim().slice(0, MAX_QUICK_PHRASE_LENGTH) : ''
15
+ if (phrase !== '' && !phrases.includes(phrase)) phrases.push(phrase)
16
+ if (phrases.length === MAX_QUICK_PHRASES) break
17
+ }
18
+ return phrases
19
+ }
20
+ const savedQuickPhrases = (() => {
22
21
  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 [] }
22
+ const stored = localStorage.getItem(QUICK_PHRASES_KEY)
23
+ return stored === null ? DEFAULT_QUICK_PHRASES : normalizeQuickPhrases(JSON.parse(stored))
24
+ } catch { return DEFAULT_QUICK_PHRASES }
26
25
  })()
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
-
26
+ const savedBranchAnchors = (() => {
27
+ try {
28
+ const value = JSON.parse(localStorage.getItem('dsh-synapse:branch-anchors') ?? '[]')
29
+ return Array.isArray(value) ? value.filter(item => Array.isArray(item) && typeof item[0] === 'string' && typeof item[1] === 'string') : []
30
+ } catch { return [] }
31
+ })()
32
+ const savedCardPositions = (() => {
33
+ try {
34
+ // Drop formats that were never persisted; the current key stores drags.
35
+ localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
36
+ localStorage.removeItem('dsh-synapse:card-positions:v2')
37
+ const value = JSON.parse(localStorage.getItem(CARD_POSITIONS_KEY) ?? '[]')
38
+ 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)) : []
39
+ } catch { return [] }
40
+ })()
41
+ const savedCollapsedCards = (() => {
42
+ try {
43
+ const value = JSON.parse(localStorage.getItem(COLLAPSED_CARDS_KEY) ?? '[]')
44
+ return Array.isArray(value) ? value.filter(item => typeof item === 'string') : []
45
+ } catch { return [] }
46
+ })()
47
+ const CARD_WIDTH = 310
48
+ const CARD_HEIGHT = 276
49
+ const CARD_GAP_Y = 42
50
+ const CAMERA_INSET_X = 56
51
+ const CAMERA_INSET_Y = 56
52
+ // Cards outside the viewport (plus this world-space margin) are not mounted
53
+ // into the DOM; the margin pre-mounts cards just before they scroll into view
54
+ // so panning never flashes empty space.
55
+ const VIEWPORT_MARGIN = 1400
56
+ const state = {
57
+ summaries: [], workspace: null, activeId: null, selectedCardId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
58
+ dshWorkspaces: [], selectedDshWorkspaceId: null,
59
+ historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
60
+ draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards), quickPhrases: savedQuickPhrases, quickPhraseEditorOpen: false,
61
+ dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 }, mapCardSessionSwitches: new Set(),
62
+ expandedMessageIds: new Set(),
63
+ canvasCards: undefined, canvasCardsById: undefined, canvasGraph: undefined, mountedCardIds: new Set(), canvasNeedsCenter: false,
64
+ detailScrollByThread: new Map(), detailThreadId: null, detailTargetCardId: null,
65
+ inspectorCardId: null, inspectorOpening: false, inspectorScrollByCard: new Map(),
66
+ }
67
+
68
+ const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character]))
69
+ const formatTime = value => new Date(value).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
70
+ const currentThread = () => state.workspace?.threads.find(thread => thread.id === state.activeId) ?? state.workspace?.threads[0] ?? null
71
+ const threadListTitle = thread => thread.dshSessionTitle ?? thread.title ?? questionFor(thread)
72
+
73
+ function rememberBranchAnchor(sessionId, cardId) {
74
+ state.branchAnchors.set(sessionId, cardId)
75
+ try { localStorage.setItem('dsh-synapse:branch-anchors', JSON.stringify([...state.branchAnchors])) } catch { /* Private browsing may disable local storage. */ }
76
+ }
77
+
78
+ function persistCardPositions() {
79
+ try { localStorage.setItem(CARD_POSITIONS_KEY, JSON.stringify([...state.cardPositions])) } catch { /* Private browsing may disable local storage. */ }
80
+ }
81
+
55
82
  function persistCollapsedCards() {
56
83
  try { localStorage.setItem(COLLAPSED_CARDS_KEY, JSON.stringify([...state.collapsedCardIds])) } catch { /* Private browsing may disable local storage. */ }
57
84
  }
58
85
 
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() {
86
+ function persistQuickPhrases() {
87
+ try { localStorage.setItem(QUICK_PHRASES_KEY, JSON.stringify(state.quickPhrases)) } catch { /* Private browsing may disable local storage. */ }
88
+ }
89
+
90
+ function rememberCardPosition(cardId, position, aliases = []) {
91
+ state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
92
+ for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
93
+ persistCardPositions()
94
+ }
95
+
96
+ function resetCardPositions() {
97
+ state.cardPositions.clear()
98
+ persistCardPositions()
99
+ try {
100
+ localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
101
+ localStorage.removeItem('dsh-synapse:card-positions:v2')
102
+ } catch { /* Private browsing may disable local storage. */ }
103
+ }
104
+
105
+ function resetCanvasCamera() {
106
+ state.canvasViewInitialized = false
107
+ state.canvasCamera = { x: 0, y: 0 }
108
+ }
109
+
110
+ async function api(path, options = {}) {
111
+ const response = await fetch(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers ?? {}) } })
112
+ const body = await response.json().catch(() => ({}))
113
+ if (!response.ok) throw new Error(body.error ?? '请求失败')
114
+ return body
115
+ }
116
+
117
+ function post(type, payload = {}) {
118
+ if (window.parent !== window) window.parent.postMessage({ source: 'dsh-synapse', type, ...payload }, window.location.origin)
119
+ }
120
+
121
+ function dshRpc(type, payload = {}) {
122
+ if (window.parent === window) return Promise.reject(new Error('请从 DSH 页面打开 Synapse 后再操作会话'))
123
+ const requestId = crypto.randomUUID()
124
+ post(type, { requestId, ...payload })
125
+ return new Promise((resolve, reject) => {
126
+ const timer = window.setTimeout(() => {
127
+ state.pendingRpc.delete(requestId)
128
+ reject(new Error('DSH 未在规定时间内响应'))
129
+ }, 20_000)
130
+ state.pendingRpc.set(requestId, { resolve, reject, timer })
131
+ })
132
+ }
133
+
134
+ function settleRpc(requestId, value, error) {
135
+ const pending = state.pendingRpc.get(requestId)
136
+ if (pending === undefined) return
137
+ state.pendingRpc.delete(requestId)
138
+ window.clearTimeout(pending.timer)
139
+ if (error === undefined) pending.resolve(value)
140
+ else pending.reject(error instanceof Error ? error : new Error(String(error)))
141
+ }
142
+
143
+ function setError(error = '') { state.error = error instanceof Error ? error.message : error; render() }
144
+
145
+ function messagesFromEvents(events) {
146
+ if (!Array.isArray(events)) return []
147
+ return events.flatMap(event => {
148
+ const content = event?.data?.message?.content ?? event?.data?.content
149
+ const text = Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).filter(Boolean).join('\n') : ''
150
+ 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 }]
151
+ if (event?.type === 'assistant/message' && text) return [{ kind: 'assistant', text, at: event.time, sourceSeq: event.seq }]
152
+ return []
153
+ })
154
+ }
155
+
156
+ async function loadThreadHistory() {}
157
+
158
+ function canReplaceView() {
159
+ return state.draft === null && !state.dragging && !state.canvasGesture && Date.now() >= state.canvasRefreshAfter && !document.activeElement?.matches('textarea')
160
+ }
161
+
162
+ function deferCanvasRefresh(delay = 700) {
163
+ state.canvasRefreshAfter = Math.max(state.canvasRefreshAfter, Date.now() + delay)
164
+ }
165
+
166
+ function currentDshWorkspace() {
167
+ const id = state.currentDsh?.id
168
+ return typeof id === 'string' ? state.dshWorkspaces.find(workspace => workspace.sessionIds.includes(id)) : undefined
169
+ }
170
+
171
+ function selectedDshWorkspace() {
172
+ return state.dshWorkspaces.find(workspace => workspace.id === state.selectedDshWorkspaceId)
173
+ }
174
+
175
+ function currentDshThread(threads = state.workspace?.threads ?? []) {
176
+ const id = state.currentDsh?.id
177
+ return typeof id === 'string' ? threads.find(thread => thread.dshSessionId === id) : undefined
178
+ }
179
+
180
+ function workspaceChoices() {
181
+ if (state.dshWorkspaces.length > 0) return state.dshWorkspaces.map(workspace => ({ ...workspace, source: 'dsh' }))
182
+ return state.summaries.map(workspace => ({ id: workspace.id, title: workspace.title, path: workspace.cwd, sessionIds: [], source: 'projection' }))
183
+ }
184
+
185
+ async function threadsForDshWorkspace(workspace) {
186
+ if (workspace.sessionIds.length === 0) return []
187
+ const requested = new Set(workspace.sessionIds)
188
+ const projections = await Promise.all(state.summaries.map(summary => api(`/synapse/api/workspaces/${summary.id}`)))
189
+ return projections.flatMap(projection => projection.workspace.threads.filter(thread => requested.has(thread.dshSessionId)))
190
+ }
191
+
192
+ async function openDshWorkspace(id, { renderAfter = true, preserveCanvasCamera = false } = {}) {
193
+ const workspace = state.dshWorkspaces.find(item => item.id === id)
194
+ if (workspace === undefined) return false
195
+ const load = ++state.workspaceLoad
196
+ state.selectedDshWorkspaceId = id
197
+ const threads = await threadsForDshWorkspace(workspace)
198
+ if (load !== state.workspaceLoad) return true
199
+ const nextWorkspaceId = `dsh:${workspace.id}`
200
+ if (state.workspace?.id !== nextWorkspaceId && !preserveCanvasCamera) resetCanvasCamera()
201
+ state.workspace = { id: nextWorkspaceId, title: workspace.title, cwd: workspace.path, threads }
202
+ const currentThread = currentDshThread(state.workspace.threads)
203
+ state.activeId = currentThread?.id ?? (state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null)
204
+ if (currentThread !== undefined) revealConversationThread(conversationCards(state.workspace.threads), currentThread.id)
205
+ if (renderAfter && canReplaceView()) render()
206
+ await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
207
+ if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
208
+ return true
209
+ }
210
+
211
+ async function openCurrentWorkspace({ preserveCanvasCamera = false } = {}) {
181
212
  const workspace = currentDshWorkspace()
182
213
  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
-
214
+ return openDshWorkspace(workspace.id, { preserveCanvasCamera })
215
+ }
216
+
217
+ async function refreshSummaries({ renderAfter = true } = {}) {
218
+ const before = JSON.stringify(state.summaries)
219
+ const body = await api('/synapse/api/workspaces')
220
+ state.summaries = body.workspaces
221
+ const changed = before !== JSON.stringify(state.summaries)
222
+ const current = state.workspace?.id
223
+ if (state.selectedDshWorkspaceId === null && current !== null && !state.summaries.some(item => item.id === current)) state.workspace = null
224
+ const selected = selectedDshWorkspace()
225
+ if (selected !== undefined && (changed || state.workspace === null)) await openDshWorkspace(selected.id, { renderAfter })
226
+ else if (state.workspace === null && state.summaries.length > 0) await openWorkspace(state.summaries[0].id)
227
+ else if (renderAfter && changed && canReplaceView()) render()
228
+ return changed
229
+ }
230
+
231
+ async function openWorkspace(id, { renderAfter = true } = {}) {
232
+ const load = ++state.workspaceLoad
233
+ const body = await api(`/synapse/api/workspaces/${id}`)
234
+ if (load !== state.workspaceLoad) return
235
+ if (state.workspace?.id !== body.workspace.id) resetCanvasCamera()
236
+ state.workspace = body.workspace
237
+ state.activeId = state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null
238
+ if (renderAfter && canReplaceView()) render()
239
+ await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
240
+ if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
241
+ }
242
+
243
+ async function refreshProjection() {
244
+ const summariesChanged = await refreshSummaries({ renderAfter: false })
245
+ if (!summariesChanged || state.workspace === null || !canReplaceView()) return summariesChanged
246
+ if (state.selectedDshWorkspaceId !== null) await openDshWorkspace(state.selectedDshWorkspaceId)
247
+ else await openWorkspace(state.workspace.id)
248
+ return true
249
+ }
250
+
220
251
  function openNewSession() {
221
252
  if (state.draft !== null) return
222
253
  state.mode = 'canvas'
223
254
  state.activeId = null
255
+ state.selectedCardId = null
256
+ state.inspectorCardId = null
257
+ state.inspectorOpening = false
258
+ state.quickPhraseEditorOpen = false
224
259
  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) {
260
+ state.error = ''
261
+ resetCanvasCamera()
262
+ render()
263
+ window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
264
+ }
265
+
266
+ async function archiveThread(thread) {
267
+ if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
268
+ await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
269
+ state.historyBySession.delete(thread.dshSessionId)
270
+ state.detailScrollByThread.delete(thread.id)
271
+ state.detailTargetCardId = state.detailThreadId === thread.id ? null : state.detailTargetCardId
272
+ if (state.workspace !== null) {
273
+ const removed = new Set([thread.id])
274
+ for (let changed = true; changed;) {
275
+ changed = false
276
+ for (const item of state.workspace.threads) {
277
+ if (item.parentId !== null && removed.has(item.parentId) && !removed.has(item.id)) {
278
+ removed.add(item.id)
279
+ changed = true
280
+ }
281
+ }
282
+ }
283
+ state.workspace.threads = state.workspace.threads.filter(item => !removed.has(item.id))
284
+ for (const key of [...state.cardPositions.keys()]) {
285
+ if ([...removed].some(id => key.startsWith(`${id}:`))) state.cardPositions.delete(key)
286
+ }
287
+ let collapsedChanged = false
288
+ for (const key of [...state.collapsedCardIds]) {
289
+ if ([...removed].some(id => key.startsWith(`${id}:`))) {
290
+ state.collapsedCardIds.delete(key)
291
+ collapsedChanged = true
292
+ }
293
+ }
294
+ if (collapsedChanged) persistCollapsedCards()
295
+ state.activeId = state.activeId !== null && state.workspace.threads.some(item => item.id === state.activeId)
296
+ ? state.activeId
297
+ : state.workspace.threads[0]?.id ?? null
298
+ render()
299
+ } else {
300
+ state.activeId = null
301
+ }
302
+ await refreshSummaries()
303
+ }
304
+
305
+ function focusDraftInput() {
306
+ const input = document.querySelector('[data-draft] textarea')
307
+ if (!(input instanceof HTMLTextAreaElement)) return
308
+ input.focus()
309
+ input.setSelectionRange(input.value.length, input.value.length)
310
+ }
311
+
312
+ function openContinue(parent, anchorId = undefined, text = '') {
269
313
  if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
270
314
  state.activeId = parent.id
271
- state.draft = { kind: 'continue', parentId: parent.id, anchorId, text: '', sending: false }
315
+ state.quickPhraseEditorOpen = false
316
+ state.draft = { kind: 'continue', parentId: parent.id, anchorId, text, sending: false }
272
317
  render()
273
- window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
318
+ window.setTimeout(focusDraftInput, 0)
274
319
  }
275
-
320
+
276
321
  function openBranch(parent, atSeq = undefined, anchorId = undefined) {
277
322
  if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
278
323
  state.activeId = parent.id
324
+ state.quickPhraseEditorOpen = false
279
325
  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++) {
326
+ render()
327
+ window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
328
+ }
329
+
330
+ async function sendMessage(thread, text) {
331
+ if (thread.dshSessionId === null) throw new Error('该节点没有关联的 DSH 会话')
332
+ if (state.pendingReplies.has(thread.dshSessionId)) throw new Error('该会话正在回复,请稍后再发送')
333
+ state.pendingReplies.set(thread.dshSessionId, { text, at: Date.now() })
334
+ state.error = ''
335
+ render()
336
+ try {
337
+ await dshRpc('synapse:send-message', { sessionId: thread.dshSessionId, text })
338
+ void loadThreadHistory(thread)
339
+ } catch (error) {
340
+ state.pendingReplies.delete(thread.dshSessionId)
341
+ render()
342
+ throw error
343
+ }
344
+ }
345
+
346
+ async function submitDraft() {
347
+ const draft = state.draft
348
+ const text = draft?.text.trim()
349
+ if (draft === null || !text) return
350
+ const branchPosition = draft.kind === 'branch' && state.workspace !== null ? draftPlacement(conversationCards(state.workspace.threads))?.position : undefined
351
+ draft.sending = true
352
+ state.error = ''
353
+ render()
354
+ try {
355
+ if (draft.kind === 'new') {
356
+ const session = await dshRpc('synapse:create-session', { workspaceId: state.selectedDshWorkspaceId, cwd: state.currentDsh?.cwd })
357
+ await dshRpc('synapse:send-message', { sessionId: session.id, text })
358
+ state.draft = null
359
+ render()
360
+ window.setTimeout(() => {
361
+ void refreshProjection().catch(() => {})
362
+ }, 150)
363
+ return
364
+ }
365
+ const parent = state.workspace?.threads.find(thread => thread.id === draft.parentId)
366
+ if (parent === undefined) throw new Error('来源会话不存在')
367
+ if (draft.kind === 'continue') {
368
+ state.draft = null
369
+ await sendMessage(parent, text)
370
+ return
371
+ }
372
+ const session = await dshRpc('synapse:fork-session', { sessionId: parent.dshSessionId, atSeq: draft.atSeq })
373
+ if (draft.anchorId !== undefined) rememberBranchAnchor(session.id, draft.anchorId)
374
+ 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 }) })
375
+ 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)
376
+ state.activeId = result.thread.id
377
+ state.draft = null
378
+ state.pendingReplies.set(result.thread.dshSessionId, { text, at: Date.now() })
379
+ render()
380
+ await dshRpc('synapse:send-message', { sessionId: result.thread.dshSessionId, text })
381
+ void loadThreadHistory(result.thread)
382
+ await refreshProjection()
383
+ } catch (error) {
384
+ if (draft.kind === 'branch') {
385
+ state.pendingReplies.delete(state.workspace?.threads.find(thread => thread.id === state.activeId)?.dshSessionId)
386
+ if (state.draft !== null) state.draft = { ...draft, sending: false }
387
+ } else {
388
+ state.draft = { ...draft, sending: false }
389
+ }
390
+ setError(error)
391
+ }
392
+ }
393
+
394
+ function threadsById() { return new Map((state.workspace?.threads ?? []).map(thread => [thread.id, thread])) }
395
+ function persistedMessagesFor(thread) { return state.historyBySession.get(thread.dshSessionId) ?? thread.messages ?? [] }
396
+
397
+ function pendingUserIndex(messages, pending) {
398
+ return messages.findLastIndex(message => message.kind === 'user' && message.text === pending.text && new Date(message.at).getTime() >= pending.at - 2_000)
399
+ }
400
+
401
+ function settlePendingReply(thread, messages) {
402
+ const pending = state.pendingReplies.get(thread.dshSessionId)
403
+ if (pending === undefined) return false
404
+ const userIndex = pendingUserIndex(messages, pending)
405
+ if (userIndex === -1 || !messages.slice(userIndex + 1).some(message => message.kind === 'assistant')) return false
406
+ state.pendingReplies.delete(thread.dshSessionId)
407
+ return true
408
+ }
409
+
410
+ function messagesFor(thread) {
411
+ // A runtime-context snapshot is internal DSH state, never a user turn.
412
+ // Filter here as well as during persistence so existing saved workspaces
413
+ // immediately render one question and its answer as one card.
414
+ 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.')))
415
+ const pending = state.pendingReplies.get(thread.dshSessionId)
416
+ if (pending === undefined) return messages
417
+ if (settlePendingReply(thread, messages)) {
418
+ state.liveReplies.delete(thread.dshSessionId)
419
+ return messages
420
+ }
421
+ const liveReply = state.liveReplies.get(thread.dshSessionId)
422
+ const liveAssistant = liveReply?.running ? { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() } : { kind: 'assistant', text: '', pending: true, at: new Date().toISOString() }
423
+ const userIndex = pendingUserIndex(messages, pending)
424
+ if (userIndex !== -1) return [...messages, liveAssistant]
425
+ return [...messages, { kind: 'user', text: pending.text, pending: true, at: new Date(pending.at).toISOString() }, liveAssistant]
426
+ }
427
+
428
+ function latestMessage(thread, kind) { return [...messagesFor(thread)].reverse().find(message => message.kind === kind) }
429
+ function questionFor(thread) { return latestMessage(thread, 'user')?.text ?? thread.dshSessionTitle ?? '等待用户提问' }
430
+ function answerFor(thread) { return latestMessage(thread, 'assistant') ?? null }
431
+
432
+ function inlineMarkdown(text) {
433
+ return escapeHtml(text)
434
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>')
435
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
436
+ .replace(/~~([^~]+)~~/g, '<s>$1</s>')
437
+ .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
438
+ }
439
+
440
+ const tableCells = line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
441
+
442
+ const isTableDelimiter = line => {
443
+ const cells = tableCells(line)
444
+ return cells.length > 0 && cells.every(cell => /^:?-+:?$/.test(cell))
445
+ }
446
+
447
+ function markdownBlock(text) {
448
+ const lines = text.split('\n')
449
+ const output = []
450
+ for (let index = 0; index < lines.length;) {
451
+ const line = lines[index]
452
+ if (line.trim() === '') { index++; continue }
453
+ const heading = /^(#{1,3})\s+(.+)$/.exec(line)
454
+ if (heading !== null) {
455
+ const level = heading[1].length
456
+ output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`)
457
+ index++
458
+ continue
459
+ }
460
+ const unordered = /^[-*+]\s+(.+)$/.exec(line)
461
+ const ordered = /^\d+[.)]\s+(.+)$/.exec(line)
462
+ if (unordered !== null || ordered !== null) {
463
+ const matcher = unordered === null ? /^\d+[.)]\s+(.+)$/ : /^[-*+]\s+(.+)$/
464
+ const items = []
465
+ while (index < lines.length) {
466
+ const item = matcher.exec(lines[index])
467
+ if (item === null) break
468
+ items.push(`<li>${inlineMarkdown(item[1])}</li>`)
469
+ index++
470
+ }
471
+ output.push(`<${unordered === null ? 'ol' : 'ul'}>${items.join('')}</${unordered === null ? 'ol' : 'ul'}>`)
472
+ continue
473
+ }
474
+ // GFM table: a leading-pipe header row followed by a |-delimiter row,
475
+ // then any number of leading-pipe body rows.
476
+ if (/^\s*\|/.test(line) && index + 1 < lines.length && isTableDelimiter(lines[index + 1])) {
477
+ const header = line
478
+ const body = []
479
+ index += 2
480
+ while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) {
481
+ body.push(lines[index])
482
+ index++
483
+ }
484
+ 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>`)
485
+ continue
486
+ }
487
+ const paragraph = []
488
+ 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++])
489
+ // A marker-only line such as PowerShell's "+ " diagnostic is neither a
490
+ // list item nor paragraph content under the rules above. Consume it so
491
+ // the parser always makes progress.
492
+ if (paragraph.length === 0) paragraph.push(lines[index++])
493
+ output.push(`<p>${paragraph.map(inlineMarkdown).join('<br>')}</p>`)
494
+ }
495
+ return output.join('')
496
+ }
497
+
498
+ // Markdown parsing is pure CPU and repeats for every card on every canvas
499
+ // rebuild; cache the rendered HTML by input text so stable answers are never
500
+ // re-parsed. Bounded: streaming partial texts churn keys, so evict oldest.
501
+ const markdownCache = new Map()
502
+ const MARKDOWN_CACHE_LIMIT = 5000
503
+ function renderMarkdown(text) {
504
+ const key = String(text)
505
+ const cached = markdownCache.get(key)
506
+ if (cached !== undefined) return cached
507
+ const parts = key.split(/```/)
508
+ const rendered = parts.map((part, index) => index % 2 === 1
509
+ ? `<pre><code>${escapeHtml(part.replace(/^\w*\n/, ''))}</code></pre>`
510
+ : markdownBlock(part)).join('')
511
+ if (markdownCache.size >= MARKDOWN_CACHE_LIMIT) markdownCache.delete(markdownCache.keys().next().value)
512
+ markdownCache.set(key, rendered)
513
+ return rendered
514
+ }
515
+
516
+ function overlapsCard(position, other) {
517
+ return position.x < other.x + CARD_WIDTH && position.x + CARD_WIDTH > other.x
518
+ && position.y < other.y + CARD_HEIGHT && position.y + CARD_HEIGHT > other.y
519
+ }
520
+
521
+ function firstAvailableCardPosition(position, occupied) {
522
+ const candidate = { x: Math.round(position.x), y: Math.max(82, Math.round(position.y)) }
523
+ while (true) {
524
+ const collisions = occupied.filter(other => overlapsCard(candidate, other))
525
+ if (collisions.length === 0) return candidate
526
+ candidate.y = Math.max(...collisions.map(other => other.y + CARD_HEIGHT + CARD_GAP_Y))
527
+ }
528
+ }
529
+
530
+ function connectorPath(fromPosition, toPosition) {
531
+ const fromX = fromPosition.x + CARD_WIDTH
532
+ const fromY = fromPosition.y + CARD_HEIGHT / 2
533
+ const toX = toPosition.x
534
+ const toY = toPosition.y + CARD_HEIGHT / 2
535
+ const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
536
+ return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
537
+ }
538
+
539
+ function connectorPathFromElements(fromCard, toCard) {
540
+ const fromX = Number.parseFloat(fromCard.style.left) + CARD_WIDTH
541
+ const fromY = Number.parseFloat(fromCard.style.top) + CARD_HEIGHT / 2
542
+ const toX = Number.parseFloat(toCard.style.left)
543
+ const toY = Number.parseFloat(toCard.style.top) + CARD_HEIGHT / 2
544
+ if (![fromX, fromY, toX, toY].every(Number.isFinite)) return null
545
+ const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
546
+ return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
547
+ }
548
+
549
+ function selectorValue(value) {
550
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
551
+ }
552
+
553
+ // Connector paths are rebuilt together with the canvas DOM; cache the mapping
554
+ // from card id to its incident paths so dragging never scans the whole SVG.
555
+ let connectorPathsByCard = new Map()
556
+ function cacheCardConnectors() {
557
+ connectorPathsByCard = new Map()
558
+ const viewport = document.querySelector('.canvas-viewport')
559
+ if (!(viewport instanceof HTMLElement)) return
560
+ for (const path of viewport.querySelectorAll('.connectors path[data-from]')) {
561
+ const fromId = path.getAttribute('data-from')
562
+ const toId = path.getAttribute('data-to')
563
+ if (fromId === null || toId === null) continue
564
+ for (const id of [fromId, toId]) {
565
+ const paths = connectorPathsByCard.get(id)
566
+ if (paths === undefined) connectorPathsByCard.set(id, new Set([path]))
567
+ else paths.add(path)
568
+ }
569
+ }
570
+ }
571
+
572
+ function refreshCardConnectors(cardId) {
573
+ const paths = connectorPathsByCard.get(cardId)
574
+ if (paths === undefined || paths.size === 0) return
575
+ const byId = state.canvasCardsById
576
+ if (byId === undefined) return
577
+ for (const path of paths) {
578
+ const fromId = path.getAttribute('data-from')
579
+ const toId = path.getAttribute('data-to')
580
+ if (fromId === null || toId === null) continue
581
+ const fromCard = byId.get(fromId)
582
+ const toCard = byId.get(toId)
583
+ if (fromCard === undefined || toCard === undefined) continue
584
+ // Data-driven endpoints: the counterpart card may be unmounted (outside
585
+ // the viewport) but its position is still authoritative.
586
+ path.setAttribute('d', connectorPath(fromCard.position, toCard.position))
587
+ }
588
+ }
589
+
590
+ function initialCanvasCamera(cards) {
591
+ const draft = state.draft?.kind === 'new' ? { id: 'draft:new', position: { x: 86, y: 82 } } : draftPlacement(cards)
592
+ // Focus the active conversation's latest turn, not its first: after many
593
+ // rounds the canvas should open where work is happening, at the newest card.
594
+ const activeCards = state.activeId === null || state.activeId === undefined ? [] : cards.filter(card => card.dshThreadId === state.activeId)
595
+ const active = activeCards.at(-1)
596
+ const focus = draft ?? active ?? cards[0]
597
+ const position = focus?.position
598
+ if (position === undefined) return { x: 0, y: 0 }
599
+ return { x: CAMERA_INSET_X - position.x * state.zoom, y: CAMERA_INSET_Y - position.y * state.zoom }
600
+ }
601
+
602
+ function placeConversationCards(cards) {
603
+ const saved = new Map(cards.flatMap(card => {
604
+ if (card.positionLocked !== true) return []
605
+ const position = state.cardPositions.get(card.id) ?? state.cardPositions.get(card.positionKey)
606
+ return position === undefined ? [] : [[card.id, { x: position.x, y: position.y }]]
607
+ }))
608
+ const occupied = []
609
+ for (const card of cards) {
610
+ const position = saved.get(card.id)
611
+ if (position !== undefined) {
612
+ card.position = position
613
+ continue
614
+ }
615
+ card.position = firstAvailableCardPosition(card.naturalPosition ?? card.position, occupied)
616
+ occupied.push(card.position)
617
+ }
618
+ return cards
619
+ }
620
+
621
+ function layoutConversationGraph(cards, threads) {
622
+ const childrenByThread = new Map()
623
+ for (const thread of threads) {
624
+ if (thread.parentId === null) continue
625
+ const children = childrenByThread.get(thread.parentId) ?? []
626
+ children.push(thread.id)
627
+ childrenByThread.set(thread.parentId, children)
628
+ }
629
+ const laneByThread = new Map()
630
+ const visitThread = threadId => {
631
+ if (laneByThread.has(threadId)) return
632
+ laneByThread.set(threadId, laneByThread.size)
633
+ for (const childId of childrenByThread.get(threadId) ?? []) visitThread(childId)
634
+ }
635
+ for (const thread of threads) if (thread.parentId === null) visitThread(thread.id)
636
+ for (const thread of threads) visitThread(thread.id)
637
+
638
+ const byId = new Map(cards.map(card => [card.id, card]))
639
+ const positioned = new Map()
640
+ const positionFor = (card, visiting = new Set()) => {
641
+ if (positioned.has(card.id)) return positioned.get(card.id)
642
+ if (visiting.has(card.id)) return { x: 86, y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y) }
643
+ visiting.add(card.id)
644
+ const parent = card.parentId === null ? undefined : byId.get(card.parentId)
645
+ const parentPosition = parent === undefined ? undefined : positionFor(parent, visiting)
646
+ const position = {
647
+ x: parentPosition === undefined ? 86 : parentPosition.x + 365,
648
+ y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y),
649
+ }
650
+ visiting.delete(card.id)
651
+ positioned.set(card.id, position)
652
+ return position
653
+ }
654
+ for (const card of cards) {
655
+ card.naturalPosition = positionFor(card)
656
+ if (!card.positionLocked) card.position = card.naturalPosition
657
+ }
658
+ return placeConversationCards(cards)
659
+ }
660
+
661
+ function conversationCards(threads) {
662
+ const cards = []
663
+ const cardsByThread = new Map()
664
+ for (const thread of threads) {
665
+ const messages = messagesFor(thread)
666
+ const turns = []
667
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
598
668
  const question = messages[messageIndex]
599
669
  if (question.kind !== 'user') continue
600
670
  const replies = []
671
+ const errors = []
672
+ let processCount = 0
601
673
  for (let replyIndex = messageIndex + 1; replyIndex < messages.length; replyIndex++) {
602
674
  const reply = messages[replyIndex]
603
675
  if (reply.kind === 'user') break
604
676
  if (reply.kind === 'assistant') replies.push(reply)
677
+ if (reply.kind === 'error') errors.push(reply)
678
+ if (Array.isArray(reply.process)) processCount += reply.process.length
679
+ else if (reply.kind === 'tool') processCount += 1
605
680
  }
606
681
  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,
682
+ const error = errors.at(-1) ?? null
683
+ const turnIndex = turns.length
684
+ const id = `${thread.id}:turn:${question.sourceSeq ?? messageIndex}`
685
+ const previous = turns.at(-1)
686
+ const positionKey = `${thread.id}:turn-index:${turnIndex}`
687
+ const naturalPosition = previous === undefined ? { x: 86, y: 82 } : { x: previous.naturalPosition.x + 365, y: previous.naturalPosition.y }
688
+ const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
689
+ const positionLocked = savedPosition !== undefined
690
+ const position = positionLocked ? savedPosition : naturalPosition
691
+ turns.push({
692
+ id,
693
+ positionKey,
694
+ dshThreadId: thread.id,
695
+ sourceParentId: thread.parentId,
696
+ parentId: null,
697
+ sourceSeq: question.sourceSeq,
698
+ turnIndex,
699
+ naturalPosition,
700
+ position,
701
+ positionLocked,
626
702
  question: question.text,
627
703
  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,
704
+ error,
705
+ processCount,
706
+ })
707
+ }
708
+ const liveReply = state.liveReplies.get(thread.dshSessionId)
709
+ const latestTurn = turns.at(-1)
710
+ 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() }
711
+ if (turns.length === 0) {
712
+ const id = `${thread.id}:turn:empty`
713
+ const positionKey = `${thread.id}:turn-index:0`
714
+ const naturalPosition = { x: 86, y: 82 }
715
+ const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
716
+ const positionLocked = savedPosition !== undefined
717
+ turns.push({
718
+ id,
719
+ positionKey,
720
+ dshThreadId: thread.id,
721
+ sourceParentId: thread.parentId,
722
+ parentId: null,
723
+ sourceSeq: undefined,
724
+ turnIndex: 0,
725
+ naturalPosition,
726
+ position: positionLocked ? savedPosition : naturalPosition,
727
+ positionLocked,
650
728
  question: thread.dshSessionTitle ?? thread.title,
651
729
  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
-
730
+ error: null,
731
+ processCount: 0,
732
+ })
733
+ }
734
+ turns.at(-1).canContinue = true
735
+ cardsByThread.set(thread.id, turns)
736
+ cards.push(...turns)
737
+ }
738
+ for (const card of cards) {
739
+ const siblings = cardsByThread.get(card.dshThreadId)
740
+ if (card.turnIndex > 0) card.parentId = siblings[card.turnIndex - 1].id
741
+ else {
742
+ const parentCards = cardsByThread.get(card.sourceParentId)
743
+ const sourceThread = threads.find(thread => thread.id === card.dshThreadId)
744
+ const firstChildQuestion = siblings?.[0]
745
+ const seedLength = sourceThread?.sourceSeedLength ?? firstChildQuestion?.sourceSeq
746
+ // A fork inherits every parent event before DSH's durable seed boundary.
747
+ // The latest parent question below that boundary is the exact Turn where
748
+ // this child was born. Canvas coordinates never participate in lineage.
749
+ const inheritedTurn = Number.isSafeInteger(seedLength)
750
+ ? parentCards?.filter(candidate => Number.isInteger(candidate.sourceSeq) && candidate.sourceSeq < seedLength).at(-1)
751
+ : undefined
752
+ card.parentId = state.branchAnchors.get(card.dshThreadId) ?? inheritedTurn?.id ?? null
753
+ }
754
+ }
755
+ return layoutConversationGraph(cards, threads)
756
+ }
757
+
758
+ function conversationGraphView(cards, collapsedCardIds = state.collapsedCardIds) {
759
+ const cardIds = new Set(cards.map(card => card.id))
760
+ const childrenByParent = new Map()
761
+ for (const card of cards) {
762
+ if (card.parentId === null || !cardIds.has(card.parentId)) continue
763
+ const children = childrenByParent.get(card.parentId) ?? []
764
+ children.push(card.id)
765
+ childrenByParent.set(card.parentId, children)
766
+ }
767
+
768
+ const hiddenIds = new Set()
769
+ for (const rootId of collapsedCardIds) {
770
+ if (!cardIds.has(rootId)) continue
771
+ const visited = new Set([rootId])
772
+ const visit = parentId => {
773
+ for (const childId of childrenByParent.get(parentId) ?? []) {
774
+ if (visited.has(childId)) continue
775
+ visited.add(childId)
776
+ hiddenIds.add(childId)
777
+ visit(childId)
778
+ }
779
+ }
780
+ visit(rootId)
781
+ }
782
+
783
+ // Persisted collapse roots must remain visible even if malformed metadata
784
+ // contains a cycle where two collapsed nodes otherwise hide each other.
785
+ for (const rootId of collapsedCardIds) hiddenIds.delete(rootId)
786
+
787
+ // Post-order accumulation: each card's descendant count is 1 + the sum of
788
+ // its children's subtree sizes, so the whole graph is O(n) instead of a BFS
789
+ // from every card (O(n²) on deep chains). Malformed parent cycles are
790
+ // detected through the DFS path: every member of a cycle reaches every other
791
+ // member plus the union of their off-cycle subtrees, so when the cycle entry
792
+ // pops last, all members are settled to (cycleSize - 1) + off-cycle total,
793
+ // which matches the per-card BFS' unique-descendant count.
794
+ const descendantCounts = new Map()
795
+ const inStack = new Set()
796
+ for (const card of cards) {
797
+ if (descendantCounts.has(card.id)) continue
798
+ const stack = [{ id: card.id, children: childrenByParent.get(card.id) ?? [], index: 0 }]
799
+ const path = [card.id]
800
+ let cycleEntry = null
801
+ let cycleMembers = null
802
+ let cycleOffCycleTotal = 0
803
+ inStack.add(card.id)
804
+ while (stack.length > 0) {
805
+ const top = stack[stack.length - 1]
806
+ if (top.index < top.children.length) {
807
+ const childId = top.children[top.index++]
808
+ if (descendantCounts.has(childId)) continue
809
+ if (inStack.has(childId)) {
810
+ // Back edge: the nodes from childId up to top.id form a cycle.
811
+ cycleEntry = childId
812
+ cycleMembers = new Set(path.slice(path.indexOf(childId)))
813
+ cycleOffCycleTotal = 0
814
+ continue
815
+ }
816
+ inStack.add(childId)
817
+ path.push(childId)
818
+ stack.push({ id: childId, children: childrenByParent.get(childId) ?? [], index: 0 })
819
+ } else {
820
+ stack.pop()
821
+ path.pop()
822
+ inStack.delete(top.id)
823
+ let count = 0
824
+ for (const childId of top.children) {
825
+ if (cycleMembers !== null && cycleMembers.has(childId)) continue // ring edge; base count added below
826
+ count += 1 + (descendantCounts.get(childId) ?? 0)
827
+ }
828
+ if (cycleMembers !== null && cycleMembers.has(top.id)) cycleOffCycleTotal += count
829
+ if (cycleMembers !== null && top.id === cycleEntry) {
830
+ // All cycle members have popped (the entry pops last in post-order);
831
+ // settle them so ancestors popping next read the final counts.
832
+ const base = cycleMembers.size - 1
833
+ for (const id of cycleMembers) descendantCounts.set(id, base + cycleOffCycleTotal)
834
+ cycleEntry = null
835
+ cycleMembers = null
836
+ } else {
837
+ descendantCounts.set(top.id, count)
838
+ }
839
+ }
840
+ }
841
+ }
842
+
843
+ return {
844
+ cards: cards.filter(card => !hiddenIds.has(card.id)),
845
+ childCounts: new Map(cards.map(card => [card.id, childrenByParent.get(card.id)?.length ?? 0])),
846
+ descendantCounts,
847
+ }
848
+ }
849
+
850
+ function revealConversationThread(cards, threadId) {
851
+ const byId = new Map(cards.map(card => [card.id, card]))
852
+ let changed = false
853
+ for (const target of cards.filter(card => card.dshThreadId === threadId)) {
854
+ const visited = new Set([target.id])
855
+ let parentId = target.parentId
856
+ while (parentId !== null && !visited.has(parentId)) {
857
+ visited.add(parentId)
858
+ if (state.collapsedCardIds.delete(parentId)) changed = true
859
+ parentId = byId.get(parentId)?.parentId ?? null
860
+ }
861
+ }
862
+ if (changed) persistCollapsedCards()
863
+ }
864
+
742
865
  function canvasConnectors(cards) {
743
866
  const index = new Map(cards.map(card => [card.id, card]))
744
867
  const links = cards.map(card => {
745
868
  const parent = card.parentId === null ? null : index.get(card.parentId)
746
869
  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
-
870
+ const active = card.dshThreadId === state.activeId && parent.dshThreadId === state.activeId ? ' active-connector' : ''
871
+ return `<path class="${active.trim()}" data-from="${escapeHtml(parent.id)}" data-to="${escapeHtml(card.id)}" d="${connectorPath(parent.position, card.position)}"></path>`
872
+ })
873
+ const placement = draftPlacement(cards)
874
+ if (placement !== null) {
875
+ links.push(`<path class="draft-connector" data-from="${escapeHtml(placement.parent.id)}" data-to="draft" d="${connectorPath(placement.parent.position, placement.position)}"></path>`)
876
+ }
877
+ return links.join('')
878
+ }
879
+
756
880
  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
-
881
+ const selected = card.id === state.selectedCardId ? 'selected' : ''
882
+ const source = card.parentId === null ? 'DSH 会话' : card.turnIndex === 0 ? 'DSH 分支' : '追问'
883
+ const continueButton = card.canContinue === true
884
+ ? `<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>`
885
+ : ''
886
+ const childCount = graph.childCounts.get(card.id) ?? 0
887
+ const collapsed = state.collapsedCardIds.has(card.id)
888
+ const foldLabel = collapsed ? '展开后续对话' : '折叠后续对话'
889
+ 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>`
890
+ 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>`
891
+ return `<article class="thread-card ${selected}" 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">
892
+ <button class="node-handle" data-drag-card="${card.id}" aria-label="拖动 ${escapeHtml(card.question)}" title="拖动卡片"></button>
893
+ ${continueButton}${foldButton}${branchButton}
894
+ <div class="thread-card-head"><span class="topic-dot"></span><button class="thread-title" data-action="show-thread" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" title="查看完整会话:${escapeHtml(card.question)}">${escapeHtml(card.question)}</button></div>
895
+ <div class="thread-meta"><span>${source}</span><span>第 ${card.turnIndex + 1} 轮</span>${card.error === null ? '' : '<span class="card-error-status">失败</span>'}${card.processCount > 0 ? `<span class="card-process-count">工具 ${card.processCount}</span>` : ''}</div>
896
+ <div class="thread-answer">${card.answer === null ? (card.error === 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>' : ''}`}${card.error === null ? '' : `<p class="thread-answer-error" title="${escapeHtml(card.error.text)}">本轮失败:${escapeHtml(card.error.text)}</p>`}</div>
897
+ <footer><button data-action="show-thread" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" title="查看完整会话" aria-label="查看完整会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2 8.5 8 2.5l6 6V13.5a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5Z"/><path d="M6.2 14v-3.6a1.8 1.8 0 0 1 3.6 0V14" /></svg>详情</button><button data-action="open-dsh" data-thread="${card.dshThreadId}" data-seq="${Number.isInteger(card.sourceSeq) ? card.sourceSeq : ''}" title="在 DSH 中打开" aria-label="在 DSH 中打开"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3.5H4.5A1.5 1.5 0 0 0 3 5v6.5A1.5 1.5 0 0 0 4.5 13H11a1.5 1.5 0 0 0 1.5-1.5V9"/><path d="M9.5 3.5h3v3M12.4 3.6 7.5 8.5"/></svg>DSH</button><button data-action="archive-thread" data-thread="${card.dshThreadId}" title="归档此会话" aria-label="归档此会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 5h11M5.5 7v5.5a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V7"/><path d="M4 5 5 2.8a.7.7 0 0 1 .6-.4h4.8a.7.7 0 0 1 .6.4L12 5M6 9.5h4"/></svg>归档</button></footer>
898
+ </article>`
899
+ }
900
+
777
901
  function draftActions(draft) {
778
902
  const disabled = draft.sending ? 'disabled' : ''
779
903
  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
904
  }
781
905
 
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)) }
906
+ function quickPhraseEditor(draft) {
907
+ const disabled = draft.sending ? 'disabled' : ''
908
+ const phrases = state.quickPhrases.map((phrase, index) => `<div class="draft-quick-phrase-editor-row"><input data-quick-phrase-index="${index}" maxlength="${MAX_QUICK_PHRASE_LENGTH}" value="${escapeHtml(phrase)}" aria-label="快捷词 ${index + 1}" ${disabled}><button type="button" data-action="remove-quick-phrase" data-quick-phrase-index="${index}" aria-label="删除 ${escapeHtml(phrase)}" title="删除" ${disabled}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button></div>`).join('')
909
+ return `<section class="draft-quick-editor" aria-label="编辑快捷词"><div class="draft-quick-editor-list">${phrases}</div><div class="draft-quick-phrase-add"><input maxlength="${MAX_QUICK_PHRASE_LENGTH}" placeholder="添加快捷词" aria-label="添加快捷词" ${disabled}><button class="primary" type="button" data-action="add-quick-phrase" aria-label="添加快捷词" title="添加快捷词" ${disabled}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div><button class="draft-quick-editor-close" type="button" data-action="close-quick-phrase-editor" ${disabled}>完成</button></section>`
790
910
  }
791
911
 
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>`
912
+ function draftQuickPhrases(draft) {
913
+ const disabled = draft.sending ? 'disabled' : ''
914
+ if (state.quickPhraseEditorOpen) return quickPhraseEditor(draft)
915
+ const phrases = state.quickPhrases.map(phrase => `<button class="draft-quick-phrase" type="button" data-action="insert-quick-phrase" data-quick-phrase="${escapeHtml(phrase)}" ${disabled}>${escapeHtml(phrase)}</button>`).join('')
916
+ return `<div class="draft-quick-phrases" aria-label="常用补充词">${phrases}<button class="draft-quick-phrase-add-button" type="button" data-action="open-quick-phrase-editor" aria-label="管理快捷词" title="管理快捷词" ${disabled}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div>`
917
+ }
918
+
919
+ function insertQuickPhrase(phrase) {
920
+ const input = document.querySelector('[data-draft] textarea')
921
+ if (!(input instanceof HTMLTextAreaElement) || state.draft === null) return
922
+ const start = input.selectionStart
923
+ const end = input.selectionEnd
924
+ const prefix = input.value.slice(0, start)
925
+ const suffix = input.value.slice(end)
926
+ const separator = prefix !== '' && !prefix.endsWith('\n') ? '\n' : ''
927
+ const text = `${prefix}${separator}${phrase}${suffix}`
928
+ if (text.length > input.maxLength) return setError('追问内容不能超过 4000 个字符')
929
+ const caret = prefix.length + separator.length + phrase.length
930
+ input.value = text
931
+ state.draft.text = text
932
+ input.focus()
933
+ input.setSelectionRange(caret, caret)
934
+ }
935
+
936
+ function addQuickPhrase(value) {
937
+ const phrase = value.trim().slice(0, MAX_QUICK_PHRASE_LENGTH)
938
+ if (phrase === '') return false
939
+ if (state.quickPhrases.includes(phrase)) return setError('这个快捷词已经存在')
940
+ if (state.quickPhrases.length >= MAX_QUICK_PHRASES) return setError(`最多保留 ${MAX_QUICK_PHRASES} 个快捷词`)
941
+ state.quickPhrases.push(phrase)
942
+ persistQuickPhrases()
943
+ return true
805
944
  }
806
945
 
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
946
+ function updateQuickPhrase(index, value) {
947
+ if (!Number.isInteger(index) || index < 0 || index >= state.quickPhrases.length) return
948
+ const phrase = value.trim().slice(0, MAX_QUICK_PHRASE_LENGTH)
949
+ if (phrase === '') {
950
+ state.quickPhrases.splice(index, 1)
951
+ } else if (state.quickPhrases.some((item, itemIndex) => itemIndex !== index && item === phrase)) {
952
+ return setError('这个快捷词已经存在')
953
+ } else {
954
+ state.quickPhrases[index] = phrase
816
955
  }
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>`
956
+ persistQuickPhrases()
957
+ render()
844
958
  }
845
-
959
+
960
+ function draftPlacement(cards) {
961
+ const draft = state.draft
962
+ if (draft === null || draft.kind === 'new') return null
963
+ const parent = draft.anchorId === undefined
964
+ ? cards.filter(card => card.dshThreadId === draft.parentId).at(-1)
965
+ : cards.find(card => card.id === draft.anchorId)
966
+ if (parent === undefined) return null
967
+ return { parent, position: firstAvailableCardPosition({ x: parent.position.x + 365, y: parent.position.y }, cards.map(card => card.position)) }
968
+ }
969
+
970
+ function draftCard(cards) {
971
+ const draft = state.draft
972
+ 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">
973
+ <div class="thread-card-head"><span class="topic-dot"></span><strong>新会话</strong></div>
974
+ <form class="draft-branch-form" data-draft><textarea maxlength="4000" placeholder="输入第一条消息" ${draft.sending ? 'disabled' : ''}>${escapeHtml(draft.text)}</textarea>${draftActions(draft)}</form>
975
+ </article>`
976
+ const placement = draftPlacement(cards)
977
+ if (draft === null || placement === null) return ''
978
+ const continuing = draft.kind === 'continue'
979
+ 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">
980
+ <div class="thread-card-head"><span class="topic-dot"></span><strong>${continuing ? '新的追问' : '新的分支'}</strong></div>
981
+ <form class="draft-branch-form" data-draft>${draftQuickPhrases(draft)}<textarea maxlength="4000" placeholder="${continuing ? '输入追问' : '输入这个分支的新问题'}" ${draft.sending ? 'disabled' : ''}>${escapeHtml(draft.text)}</textarea>${draftActions(draft)}</form>
982
+ </article>`
983
+ }
984
+
985
+ function selectionFollowupButton() {
986
+ return `<button class="selection-followup" type="button" data-action="follow-selection" hidden aria-label="基于所选内容创建追问" title="基于所选内容追问"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M3 3.5h10v6.25H7.2L4 12.5V9.75H3Z"/><path d="M8 4.9v3.4M6.3 6.6h3.4"/></svg><span>追问</span></button>`
987
+ }
988
+
989
+ // Cards are mounted into the DOM only when they intersect the viewport
990
+ // (inflated by VIEWPORT_MARGIN) in world coordinates. The camera transform is
991
+ // translate(camera) scale(zoom), so screen = world * zoom + camera.
992
+ function visibleCardIds(cards) {
993
+ const viewport = document.querySelector('.canvas-viewport')
994
+ if (!(viewport instanceof HTMLElement)) return new Set(cards.map(card => card.id))
995
+ const bounds = viewport.getBoundingClientRect()
996
+ const left = (-state.canvasCamera.x - VIEWPORT_MARGIN) / state.zoom
997
+ const right = (bounds.width - state.canvasCamera.x + VIEWPORT_MARGIN) / state.zoom
998
+ const top = (-state.canvasCamera.y - VIEWPORT_MARGIN) / state.zoom
999
+ const bottom = (bounds.height - state.canvasCamera.y + VIEWPORT_MARGIN) / state.zoom
1000
+ const visible = new Set()
1001
+ for (const card of cards) {
1002
+ const { x, y } = card.position
1003
+ if (x + CARD_WIDTH < left || x > right || y + CARD_HEIGHT < top || y > bottom) continue
1004
+ visible.add(card.id)
1005
+ }
1006
+ return visible
1007
+ }
1008
+
1009
+ // Incrementally mount cards entering the viewport and unmount cards leaving
1010
+ // it, without rebuilding the canvas. Called after pan/zoom/focus camera moves.
1011
+ function syncCanvasViewport() {
1012
+ if (state.mode !== 'canvas' || state.canvasCards === undefined) return
1013
+ const layer = document.querySelector('.cards-layer')
1014
+ if (!(layer instanceof HTMLElement)) return
1015
+ const visible = visibleCardIds(state.canvasCards)
1016
+ for (const cardId of [...state.mountedCardIds]) {
1017
+ if (visible.has(cardId)) continue
1018
+ const element = layer.querySelector(`[data-card-id="${selectorValue(cardId)}"]`)
1019
+ if (element instanceof HTMLElement) element.remove()
1020
+ state.mountedCardIds.delete(cardId)
1021
+ }
1022
+ for (const card of state.canvasCards) {
1023
+ if (!visible.has(card.id) || state.mountedCardIds.has(card.id)) continue
1024
+ const wrapper = document.createElement('div')
1025
+ wrapper.innerHTML = conversationCard(card, state.canvasGraph)
1026
+ const element = wrapper.firstElementChild
1027
+ if (element instanceof HTMLElement) {
1028
+ layer.appendChild(element)
1029
+ const handle = element.querySelector('[data-drag-card]')
1030
+ if (handle instanceof HTMLElement) bindDragHandle(handle)
1031
+ }
1032
+ state.mountedCardIds.add(card.id)
1033
+ }
1034
+ }
1035
+
1036
+ function renderCanvas() {
1037
+ const threads = state.workspace?.threads ?? []
1038
+ 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>`
1039
+ const allCards = conversationCards(threads)
1040
+ const graph = conversationGraphView(allCards)
1041
+ const cards = graph.cards
1042
+ state.canvasCards = cards
1043
+ state.canvasCardsById = new Map(cards.map(card => [card.id, card]))
1044
+ state.canvasGraph = graph
1045
+ if (state.inspectorCardId !== null && !state.canvasCardsById.has(state.inspectorCardId)) {
1046
+ state.inspectorCardId = null
1047
+ state.inspectorOpening = false
1048
+ }
1049
+ if (!state.canvasViewInitialized) {
1050
+ state.canvasCamera = initialCanvasCamera(cards)
1051
+ state.canvasViewInitialized = true
1052
+ // The viewport is not laid out yet while renderCanvas builds its HTML;
1053
+ // center the focused card once the DOM is mounted (render tail).
1054
+ state.canvasNeedsCenter = true
1055
+ }
1056
+ const visible = visibleCardIds(cards)
1057
+ state.mountedCardIds = new Set(visible)
1058
+ const mounted = cards.filter(card => visible.has(card.id))
1059
+ const inspector = state.inspectorCardId === null ? '' : renderCardInspector(state.canvasCardsById.get(state.inspectorCardId))
1060
+ 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">${mounted.map(card => conversationCard(card, graph)).join('')}${draftCard(cards)}</div></div></div>${inspector}</section>`
1061
+ }
1062
+
1063
+ function isProcessMessage(message) {
1064
+ if (message.kind === 'tool' || message.kind === 'tool-result') return true
1065
+ return message.kind === 'assistant' && /(?:^|\n)\s*(?:bash|pwsh|powershell|web_search|web_fetch|browser|read_file|write_file)\s*\n\s*\{/.test(message.text)
1066
+ }
1067
+
1068
+ function processSummary(text) {
1069
+ return text.replace(/\s+/g, ' ').trim().slice(0, 140) || '工具调用记录'
1070
+ }
1071
+
1072
+ function threadMessage(thread, message) {
1073
+ const isUser = message.kind === 'user'
1074
+ const label = isUser ? '你' : message.kind === 'assistant' ? 'DSH' : message.kind === 'error' ? '错误' : '记录'
1075
+ const branch = message.kind === 'assistant' && Number.isInteger(message.sourceSeq)
1076
+ ? `<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>`
1077
+ : ''
1078
+ const messageId = `${thread.id}:${message.sourceSeq ?? `${message.kind}:${message.at}`}`
1079
+ const collapsible = isProcessMessage(message)
1080
+ const expanded = state.expandedMessageIds.has(messageId)
1081
+ 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>` : ''
1082
+ const process = Array.isArray(message.process) && message.process.length > 0 ? message.process : null
1083
+ const body = message.pending && message.text === '' ? '<p class="message-streaming"><span class="streaming-dot"></span>正在回复</p>'
1084
+ : `${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)}`
1085
+ const avatar = isUser ? '' : '<span class="message-avatar" aria-hidden="true"></span>'
1086
+ return `<article class="message message-${message.kind}${message.pending ? ' message-pending' : ''}${collapsible ? ' message-collapsible' : ''}${expanded ? ' expanded' : ''}" data-message-seq="${Number.isInteger(message.sourceSeq) ? message.sourceSeq : ''}"><header>${avatar}<span class="message-role">${label}</span><time>${formatTime(message.at)}</time>${branch}${fold}</header><div class="message-body">${body}</div></article>`
1087
+ }
1088
+
846
1089
  function processRecords(process, messageId) {
847
1090
  const key = `${messageId}:process`
848
1091
  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('')
1092
+ const entries = process.map((entry, index) => {
1093
+ const entryKey = `${key}:${index}`
1094
+ const entryExpanded = state.expandedMessageIds.has(entryKey)
1095
+ const status = entry.error !== null ? '失败' : entry.result === null ? '等待结果' : '完成'
1096
+ const argumentsHtml = entry.arguments === null || entry.arguments === '' ? '' : `<pre class="process-args">${escapeHtml(entry.arguments)}</pre>`
1097
+ const outcomeHtml = entry.error !== null ? `<pre class="process-error">${escapeHtml(entry.error)}</pre>` : entry.result === null ? '' : `<pre class="process-result">${escapeHtml(entry.result)}</pre>`
1098
+ 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>`
1099
+ }).join('')
857
1100
  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
1101
  }
859
1102
 
860
- function renderThread() {
861
- const thread = currentThread()
862
- if (thread === null) return renderCanvas()
1103
+ function messagesForCard(card) {
1104
+ const thread = state.workspace?.threads.find(item => item.id === card.dshThreadId)
1105
+ if (thread === undefined) return { thread: null, messages: [] }
863
1106
  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>`
1107
+ let turnIndex = -1
1108
+ let start = -1
1109
+ for (let index = 0; index < messages.length; index++) {
1110
+ if (messages[index].kind !== 'user') continue
1111
+ turnIndex += 1
1112
+ if (turnIndex === card.turnIndex) {
1113
+ start = index
1114
+ break
1115
+ }
1116
+ }
1117
+ if (start === -1) return { thread, messages: [] }
1118
+ const end = messages.findIndex((message, index) => index > start && message.kind === 'user')
1119
+ return { thread, messages: messages.slice(start, end === -1 ? undefined : end) }
866
1120
  }
867
1121
 
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)
1122
+ function inspectorProcessEntries(messages) {
1123
+ const entries = []
1124
+ for (const message of messages) {
1125
+ if (Array.isArray(message.process)) {
1126
+ entries.push(...message.process.map(entry => ({ ...entry })))
1127
+ continue
1128
+ }
1129
+ if (message.kind === 'tool') {
1130
+ entries.push({ name: processSummary(message.text), arguments: message.text, result: null, error: null })
1131
+ continue
878
1132
  }
1133
+ if (message.kind === 'tool-result') {
1134
+ const previous = entries.at(-1)
1135
+ if (previous !== undefined && previous.result === null && previous.error === null) previous.result = message.text
1136
+ else entries.push({ name: '工具结果', arguments: null, result: message.text, error: null })
1137
+ }
1138
+ }
1139
+ return entries
1140
+ }
1141
+
1142
+ function renderCardInspector(card) {
1143
+ if (card === undefined) return ''
1144
+ const { thread, messages } = messagesForCard(card)
1145
+ if (thread === null) return ''
1146
+ const process = inspectorProcessEntries(messages)
1147
+ const answer = card.answer === null
1148
+ ? card.error === null ? '<p class="card-inspector-pending">等待助手回复</p>' : ''
1149
+ : `<article class="card-inspector-answer">${renderMarkdown(card.answer.text)}${card.answer.pending ? '<p class="card-inspector-pending">正在回复</p>' : ''}</article>`
1150
+ const error = card.error === null ? '' : `<section class="card-inspector-error" role="alert"><strong>本轮未完成</strong><p>${escapeHtml(card.error.text)}</p></section>`
1151
+ const processRecordsHtml = process.length === 0 ? '' : processRecords(process, `${thread.id}:${card.id}:inspector`)
1152
+ const continueAction = card.canContinue === true ? `<button type="button" data-action="open-continue" data-thread="${thread.id}" data-card="${escapeHtml(card.id)}"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M2.5 3.5h11v7h-6l-3.5 2.5v-2.5h-1.5Z"/><path d="M8 5.5v3M6.5 7h3"/></svg>继续追问</button>` : ''
1153
+ const branch = Number.isInteger(card.answer?.sourceSeq)
1154
+ ? `<button type="button" data-action="open-branch" data-thread="${thread.id}" data-card="${escapeHtml(card.id)}" data-seq="${card.answer.sourceSeq}"><svg aria-hidden="true" viewBox="0 0 16 16"><circle cx="4" cy="3.5" r="1.5"/><circle cx="12" cy="3.5" r="1.5"/><circle cx="12" cy="12.5" r="1.5"/><path d="M5.5 3.5h2A2.5 2.5 0 0 1 10 6v5"/></svg>创建分支</button>`
1155
+ : ''
1156
+ const openDshAction = `<button class="primary" type="button" data-action="open-dsh" data-thread="${thread.id}" data-seq="${Number.isInteger(card.answer?.sourceSeq) ? card.answer.sourceSeq : ''}"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M7 3.5H4.5A1.5 1.5 0 0 0 3 5v6.5A1.5 1.5 0 0 0 4.5 13H11a1.5 1.5 0 0 0 1.5-1.5V9"/><path d="M9.5 3.5h3v3M12.4 3.6 7.5 8.5"/></svg>在 DSH 中打开</button>`
1157
+ return `<aside class="card-inspector${state.inspectorOpening ? ' is-opening' : ''}" aria-label="卡片详情" data-inspector-card="${escapeHtml(card.id)}"><header class="card-inspector-head"><div><div class="card-inspector-meta"><span>第 ${card.turnIndex + 1} 轮</span>${card.error === null ? '' : '<span class="card-inspector-error-status">失败</span>'}${process.length > 0 ? `<span>工具 ${process.length}</span>` : ''}</div><h2>${escapeHtml(card.question)}</h2></div><button class="card-inspector-close" type="button" data-action="close-card-inspector" aria-label="关闭卡片详情" title="关闭"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button></header><div class="card-inspector-scroll">${error}${answer}${processRecordsHtml}</div><footer class="card-inspector-actions">${continueAction}${branch}${openDshAction}</footer></aside>`
1158
+ }
1159
+
1160
+ function renderThread() {
1161
+ const thread = currentThread()
1162
+ if (thread === null) return renderCanvas()
1163
+ const messages = messagesFor(thread)
1164
+ const waiting = state.pendingReplies.has(thread.dshSessionId)
1165
+ const latestAssistantSeq = [...messages].reverse().find(message => Number.isInteger(message.sourceSeq))?.sourceSeq
1166
+ 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}" data-seq="${Number.isInteger(latestAssistantSeq) ? latestAssistantSeq : ''}" 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>`
1167
+ }
1168
+
1169
+ function render() {
1170
+ // Remember the departing thread's scroll position per thread id, so
1171
+ // switching sessions restores each conversation's own place instead of
1172
+ // smearing one session's position onto another.
1173
+ if (state.mode === 'thread' && state.detailThreadId !== null) {
1174
+ const detail = document.querySelector('.detail-scroll')
1175
+ if (detail instanceof HTMLElement) state.detailScrollByThread.set(state.detailThreadId, detail.scrollTop)
879
1176
  }
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
1177
+ if (state.mode === 'canvas' && state.inspectorCardId !== null) {
1178
+ const inspector = document.querySelector('.card-inspector-scroll')
1179
+ if (inspector instanceof HTMLElement) state.inspectorScrollByCard.set(state.inspectorCardId, inspector.scrollTop)
893
1180
  }
1181
+ state.detailThreadId = state.mode === 'thread' ? state.activeId : null
1182
+ const detailScrollTop = state.detailThreadId === null ? null : state.detailScrollByThread.get(state.detailThreadId) ?? null
1183
+ const inspectorScrollTop = state.mode === 'canvas' && state.inspectorCardId !== null ? state.inspectorScrollByCard.get(state.inspectorCardId) ?? null : null
1184
+ const cardScrollTops = new Map()
1185
+ if (state.mode === 'canvas') {
1186
+ // Key by the unique card id: every card of a session shares data-thread,
1187
+ // so keying on it would clobber sibling cards' scroll positions. Only
1188
+ // scrollable answers have a position worth preserving; reading the two
1189
+ // height properties shares the same forced layout as the scrollTop read.
1190
+ for (const answer of document.querySelectorAll('.thread-card[data-thread] .thread-answer')) {
1191
+ if (answer.scrollHeight <= answer.clientHeight) continue
1192
+ const card = answer.closest('.thread-card')
1193
+ if (card instanceof HTMLElement && typeof card.dataset.cardId === 'string') cardScrollTops.set(card.dataset.cardId, answer.scrollTop)
1194
+ }
1195
+ }
1196
+ const workspace = state.workspace
1197
+ const threads = workspace?.threads ?? []
1198
+ const view = state.mode === 'thread' ? renderThread() : renderCanvas()
1199
+ const choices = workspaceChoices()
1200
+ const selectedWorkspaceId = state.selectedDshWorkspaceId ?? workspace?.id
1201
+ const canvasControls = state.mode === 'canvas' && (threads.length > 0 || state.draft?.kind === 'new') ? `<div class="canvas-controls"><button data-action="layout" title="整理节点" aria-label="整理节点"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="9" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="2.5" y="9" width="4.5" height="4.5" rx="1"/><rect x="9" y="9" width="4.5" height="4.5" rx="1"/></svg>整理</button><button data-action="focus-active" title="定位到当前会话" aria-label="定位到当前会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><circle cx="8" cy="8" r="3.2"/><path d="M8 1.5v2.6M8 11.9v2.6M1.5 8h2.6M11.9 8h2.6"/></svg>定位</button><button data-action="zoom-out" aria-label="缩小" title="缩小"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M3.5 8h9"/></svg></button><span>${Math.round(state.zoom * 100)}%</span><button data-action="zoom-in" aria-label="放大" title="放大"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div>` : ''
1202
+ const detailAvailable = currentThread() !== null
1203
+ 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>`
1204
+ 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}${selectionFollowupButton()}</section></main>`
1205
+ installDragging()
1206
+ cacheCardConnectors()
1207
+ // The initial camera from renderCanvas is inset (viewport not laid out yet);
1208
+ // center it on the focused card once the canvas DOM is mounted.
1209
+ if (state.canvasNeedsCenter) {
1210
+ state.canvasNeedsCenter = false
1211
+ window.requestAnimationFrame(() => { if (state.mode === 'canvas') focusActiveCard() })
1212
+ }
1213
+ for (const [cardId, scrollTop] of cardScrollTops) {
1214
+ const answer = app.querySelector(`.thread-card[data-card-id="${CSS.escape(cardId)}"] .thread-answer`)
1215
+ if (answer instanceof HTMLElement) answer.scrollTop = scrollTop
1216
+ }
894
1217
  if (detailScrollTop !== null) window.requestAnimationFrame(() => {
895
1218
  const nextDetail = document.querySelector('.detail-scroll')
896
1219
  if (nextDetail instanceof HTMLElement) nextDetail.scrollTop = detailScrollTop
897
1220
  })
898
- }
899
-
1221
+ if (inspectorScrollTop !== null) window.requestAnimationFrame(() => {
1222
+ const inspector = document.querySelector('.card-inspector-scroll')
1223
+ if (inspector instanceof HTMLElement) inspector.scrollTop = inspectorScrollTop
1224
+ })
1225
+ if (state.inspectorOpening) window.requestAnimationFrame(() => {
1226
+ document.querySelector('.card-inspector')?.classList.remove('is-opening')
1227
+ state.inspectorOpening = false
1228
+ })
1229
+ // Jump the detail view to the card the user clicked: card ids carry the
1230
+ // source sequence (`<thread>:turn:<seq>`), which matches data-message-seq
1231
+ // anchors on the rendered messages.
1232
+ const targetCardId = state.detailTargetCardId
1233
+ state.detailTargetCardId = null
1234
+ if (targetCardId !== null) {
1235
+ const match = /:turn:(\d+)$/.exec(targetCardId)
1236
+ const seq = match === null ? null : match[1]
1237
+ if (seq !== null) window.requestAnimationFrame(() => {
1238
+ const target = app.querySelector(`[data-message-seq="${CSS.escape(seq)}"]`)
1239
+ if (target instanceof HTMLElement) target.scrollIntoView({ block: 'start' })
1240
+ })
1241
+ }
1242
+ }
1243
+
900
1244
  function renderPreservingDetailScroll() {
901
1245
  render()
902
1246
  }
903
1247
 
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
1248
+ let inspectorCloseTimer = 0
1249
+ function openCardInspector(cardId) {
1250
+ if (inspectorCloseTimer !== 0) {
1251
+ window.clearTimeout(inspectorCloseTimer)
1252
+ inspectorCloseTimer = 0
1253
+ }
1254
+ state.inspectorOpening = state.inspectorCardId === null
1255
+ state.inspectorCardId = cardId
1256
+ }
1257
+
1258
+ function closeCardInspector({ animate = true } = {}) {
1259
+ if (state.inspectorCardId === null) return
1260
+ if (inspectorCloseTimer !== 0) window.clearTimeout(inspectorCloseTimer)
1261
+ const cardId = state.inspectorCardId
1262
+ const inspector = document.querySelector('.card-inspector')
1263
+ if (!animate || !(inspector instanceof HTMLElement)) {
1264
+ state.inspectorCardId = null
1265
+ state.inspectorOpening = false
1266
+ render()
1267
+ return
1268
+ }
1269
+ inspector.classList.add('is-closing')
1270
+ inspectorCloseTimer = window.setTimeout(() => {
1271
+ inspectorCloseTimer = 0
1272
+ if (state.inspectorCardId !== cardId) return
1273
+ state.inspectorCardId = null
1274
+ state.inspectorOpening = false
1275
+ render()
1276
+ }, 180)
947
1277
  }
948
1278
 
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)}%`
1279
+ function applyCanvasTransform() {
1280
+ const content = document.querySelector('.canvas-content')
1281
+ if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
1282
+ }
1283
+
1284
+ function bindDragHandle(handle) {
1285
+ handle.addEventListener('pointerdown', event => {
1286
+ const cardId = event.currentTarget.dataset.dragCard
1287
+ const card = event.currentTarget.closest('.thread-card')
1288
+ if (cardId === undefined || !(card instanceof HTMLElement)) return
1289
+ event.preventDefault()
1290
+ const origin = { x: event.clientX, y: event.clientY, position: { x: Number.parseFloat(card.style.left), y: Number.parseFloat(card.style.top) } }
1291
+ const aliases = card.dataset.positionKey === undefined ? [] : [card.dataset.positionKey]
1292
+ let position = origin.position
1293
+ let stopped = false
1294
+ let frame = 0
1295
+ state.dragging = true
1296
+ // Coalesce pointermove updates to one DOM pass per animation frame so a
1297
+ // high report-rate pointer cannot queue a reflow per event.
1298
+ const apply = () => {
1299
+ frame = 0
1300
+ state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
1301
+ for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
1302
+ // Keep the virtualized data object in sync so viewport visibility and
1303
+ // connector paths track the live drag position.
1304
+ const dataCard = state.canvasCardsById?.get(cardId)
1305
+ if (dataCard !== undefined) dataCard.position = { x: position.x, y: position.y }
1306
+ card.style.left = `${position.x}px`
1307
+ card.style.top = `${position.y}px`
1308
+ refreshCardConnectors(cardId)
1309
+ }
1310
+ const move = moveEvent => {
1311
+ position = { x: origin.position.x + (moveEvent.clientX - origin.x) / state.zoom, y: origin.position.y + (moveEvent.clientY - origin.y) / state.zoom }
1312
+ if (frame === 0) frame = window.requestAnimationFrame(apply)
1313
+ }
1314
+ const stop = () => {
1315
+ if (stopped) return
1316
+ stopped = true
1317
+ document.removeEventListener('pointermove', move)
1318
+ document.removeEventListener('pointerup', stop)
1319
+ document.removeEventListener('pointercancel', stop)
1320
+ if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
1321
+ apply()
1322
+ rememberCardPosition(cardId, position, aliases)
1323
+ state.dragging = false
1324
+ deferCanvasRefresh(120)
1325
+ // No full render: only the dragged card's inline position and its
1326
+ // connectors changed; rebuilding the whole canvas on drop is the jank.
1327
+ }
1328
+ document.addEventListener('pointermove', move)
1329
+ document.addEventListener('pointerup', stop)
1330
+ document.addEventListener('pointercancel', stop)
1331
+ })
1332
+ }
1333
+
1334
+ function installDragging() {
1335
+ for (const handle of document.querySelectorAll('[data-drag-card]')) bindDragHandle(handle)
1336
+ }
1337
+
1338
+ function canvasViewport(target) {
1339
+ return target instanceof Element ? target.closest('.canvas-viewport') : null
1340
+ }
1341
+
1342
+ function zoomCanvas(viewport, nextZoom, clientX, clientY) {
1343
+ const zoom = Math.min(4, Math.max(.6, Math.round(nextZoom * 100) / 100))
1344
+ if (zoom === state.zoom) return
1345
+ const bounds = viewport.getBoundingClientRect()
1346
+ const localX = clientX - bounds.left
1347
+ const localY = clientY - bounds.top
1348
+ const worldX = (localX - state.canvasCamera.x) / state.zoom
1349
+ const worldY = (localY - state.canvasCamera.y) / state.zoom
1350
+ state.zoom = zoom
1351
+ state.canvasCamera = { x: localX - worldX * zoom, y: localY - worldY * zoom }
1352
+ const content = viewport.querySelector('.canvas-content')
1353
+ if (content instanceof HTMLElement) {
1354
+ // Drop the composited layer before zooming: a cached will-change raster
1355
+ // would be upscaled instead of re-rasterized, which was the original
1356
+ // zoom-blur bug. will-change re-applies via .is-panning on the next pan.
1357
+ content.style.willChange = 'auto'
1358
+ applyCanvasTransform()
1359
+ syncCanvasViewport()
1360
+ window.requestAnimationFrame(() => { content.style.willChange = '' })
1361
+ } else {
1362
+ applyCanvasTransform()
1363
+ syncCanvasViewport()
1364
+ }
1365
+ const label = document.querySelector('.canvas-controls span')
1366
+ if (label !== null) label.textContent = `${Math.round(state.zoom * 100)}%`
1367
+ }
1368
+
1369
+ function zoomCanvasAtCenter(delta) {
1370
+ const viewport = document.querySelector('.canvas-viewport')
1371
+ if (!(viewport instanceof HTMLElement)) return
1372
+ const bounds = viewport.getBoundingClientRect()
1373
+ zoomCanvas(viewport, state.zoom + delta, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
1374
+ }
1375
+
1376
+ function focusActiveCard() {
1377
+ const viewport = document.querySelector('.canvas-viewport')
1378
+ if (!(viewport instanceof HTMLElement)) return
1379
+ const cards = state.canvasCards
1380
+ if (cards === undefined || cards.length === 0) return
1381
+ // Drafts win over the active conversation's latest turn; fall back to the
1382
+ // first card. Cards may be unmounted (outside the viewport), so the focus
1383
+ // target comes from the data model, never from DOM queries.
1384
+ const draft = state.draft === null ? undefined
1385
+ : state.draft.kind === 'new' ? { position: { x: 86, y: 82 } } : draftPlacement(cards)
1386
+ const activeCards = state.activeId === null || state.activeId === undefined ? [] : cards.filter(card => card.dshThreadId === state.activeId)
1387
+ const card = draft ?? activeCards.at(-1) ?? cards[0]
1388
+ const { x: left, y: top } = card.position
1389
+ const bounds = viewport.getBoundingClientRect()
1390
+ state.canvasCamera = {
1391
+ x: bounds.width / 2 - (left + CARD_WIDTH / 2) * state.zoom,
1392
+ y: bounds.height / 2 - (top + CARD_HEIGHT / 2) * state.zoom,
1393
+ }
1394
+ applyCanvasTransform()
1395
+ syncCanvasViewport()
1396
+ }
1397
+
1398
+ let selectionFollowup = null
1399
+ let selectionFollowupFrame = 0
1400
+
1401
+ function hideSelectionFollowup() {
1402
+ if (selectionFollowupFrame !== 0) {
1403
+ window.cancelAnimationFrame(selectionFollowupFrame)
1404
+ selectionFollowupFrame = 0
1405
+ }
1406
+ selectionFollowup = null
1407
+ const button = app.querySelector('.selection-followup')
1408
+ if (button instanceof HTMLButtonElement) button.hidden = true
1409
+ }
1410
+
1411
+ function selectionFollowupTarget(range) {
1412
+ const start = range.startContainer instanceof Element ? range.startContainer : range.startContainer.parentElement
1413
+ const end = range.endContainer instanceof Element ? range.endContainer : range.endContainer.parentElement
1414
+ if (!(start instanceof Element) || !(end instanceof Element)) return null
1415
+ const answer = start.closest('.thread-answer')
1416
+ if (answer instanceof HTMLElement && answer.contains(end)) {
1417
+ const card = answer.closest('.thread-card[data-thread]:not(.draft-card)')
1418
+ if (card instanceof HTMLElement && card.dataset.thread !== undefined) return { threadId: card.dataset.thread }
1419
+ }
1420
+ const messageBody = start.closest('.message-assistant .message-body')
1421
+ const thread = currentThread()
1422
+ if (messageBody instanceof HTMLElement && messageBody.contains(end) && thread !== null) return { threadId: thread.id }
1423
+ return null
962
1424
  }
963
1425
 
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)
1426
+ function updateSelectionFollowup() {
1427
+ selectionFollowupFrame = 0
1428
+ const button = app.querySelector('.selection-followup')
1429
+ const selection = window.getSelection()
1430
+ if (!(button instanceof HTMLButtonElement) || state.draft !== null || selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return hideSelectionFollowup()
1431
+ const text = selection.toString().trim()
1432
+ const range = selection.getRangeAt(0)
1433
+ const target = text === '' || text.length > 4000 ? null : selectionFollowupTarget(range)
1434
+ const rect = range.getBoundingClientRect()
1435
+ if (target === null || rect.width === 0 || rect.height === 0) return hideSelectionFollowup()
1436
+ selectionFollowup = { ...target, text }
1437
+ button.dataset.thread = target.threadId
1438
+ button.style.left = `${Math.min(window.innerWidth - 12, Math.max(76, rect.right))}px`
1439
+ button.style.top = `${Math.min(window.innerHeight - 38, Math.max(8, rect.bottom + 8))}px`
1440
+ button.hidden = false
969
1441
  }
970
1442
 
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()
1443
+ function queueSelectionFollowup() {
1444
+ if (selectionFollowupFrame !== 0) return
1445
+ selectionFollowupFrame = window.requestAnimationFrame(updateSelectionFollowup)
984
1446
  }
985
1447
 
986
1448
  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.
1449
+ const viewport = canvasViewport(event.target)
1450
+ if (!(viewport instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.thread-card, button, textarea, select')) return
1451
+ event.preventDefault()
1452
+ const origin = { x: event.clientX, y: event.clientY, camera: { ...state.canvasCamera } }
1453
+ let pendingCamera = null
1454
+ let frame = 0
1455
+ state.canvasGesture = true
1456
+ viewport.classList.add('is-panning')
1457
+ viewport.setPointerCapture(event.pointerId)
1458
+ const apply = () => {
1459
+ frame = 0
1460
+ if (pendingCamera === null) return
1461
+ state.canvasCamera = pendingCamera
1462
+ pendingCamera = null
1463
+ applyCanvasTransform()
1464
+ syncCanvasViewport()
1465
+ }
1466
+ const move = moveEvent => {
1467
+ pendingCamera = {
1468
+ x: origin.camera.x + moveEvent.clientX - origin.x,
1469
+ y: origin.camera.y + moveEvent.clientY - origin.y,
1470
+ }
1471
+ if (frame === 0) frame = window.requestAnimationFrame(apply)
1472
+ }
1473
+ const stop = () => {
1474
+ viewport.classList.remove('is-panning')
1475
+ document.removeEventListener('pointermove', move)
1476
+ document.removeEventListener('pointerup', stop)
1477
+ document.removeEventListener('pointercancel', stop)
1478
+ if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
1479
+ apply()
1480
+ state.canvasGesture = false
1481
+ deferCanvasRefresh(120)
1482
+ }
1483
+ document.addEventListener('pointermove', move)
1484
+ document.addEventListener('pointerup', stop)
1485
+ document.addEventListener('pointercancel', stop)
1486
+ })
1487
+
1488
+ app.addEventListener('wheel', event => {
1489
+ const viewport = canvasViewport(event.target)
1490
+ if (!(viewport instanceof HTMLElement)) return
1491
+ const card = event.target instanceof Element ? event.target.closest('.thread-card') : null
1492
+ if (card instanceof HTMLElement) {
1493
+ // Over a card the wheel scrolls that card's own answer with the browser's
1494
+ // native wheel (OS-smooth, never a page jump per notch); the answer's
1495
+ // overscroll-behavior: contain stops the scroll chaining into the canvas.
1496
+ const answer = card.querySelector('.thread-answer')
1497
+ if (answer instanceof HTMLElement && answer.scrollHeight > answer.clientHeight) {
1498
+ deferCanvasRefresh()
1499
+ return
1500
+ }
1501
+ // A card with no scrollable answer swallows the wheel instead of zooming.
1502
+ event.preventDefault()
1503
+ deferCanvasRefresh()
1504
+ return
1505
+ }
1506
+ event.preventDefault()
1507
+ zoomCanvas(viewport, state.zoom + (event.deltaY < 0 ? .05 : -.05), event.clientX, event.clientY)
1508
+ }, { passive: false })
1509
+
1510
+ // Track pointer-down so the card click handler can tell a plain click from a
1511
+ // text-selection or drag gesture; acting on the latter would re-render and
1512
+ // wipe the user's selection.
1039
1513
  let pointerDownPosition = null
1040
1514
  app.addEventListener('pointerdown', event => { pointerDownPosition = { x: event.clientX, y: event.clientY } })
1515
+ app.addEventListener('pointerdown', event => {
1516
+ const button = event.target instanceof Element ? event.target.closest('.selection-followup') : null
1517
+ if (button instanceof HTMLButtonElement) event.preventDefault()
1518
+ else hideSelectionFollowup()
1519
+ })
1520
+ app.addEventListener('pointerup', queueSelectionFollowup)
1521
+ app.addEventListener('scroll', hideSelectionFollowup, true)
1522
+ document.addEventListener('selectionchange', queueSelectionFollowup)
1523
+ document.addEventListener('keydown', event => {
1524
+ if (event.key !== 'Escape' || state.mode !== 'canvas' || state.inspectorCardId === null) return
1525
+ event.preventDefault()
1526
+ closeCardInspector({ animate: false })
1527
+ })
1041
1528
 
1042
1529
  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
1530
+ const button = event.target.closest('[data-action]')
1531
+ if (!(button instanceof HTMLElement)) {
1532
+ const card = event.target instanceof Element ? event.target.closest('.thread-card[data-thread]:not(.draft-card)') : null
1533
+ if (!(card instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.node-handle, textarea, select, form')) return
1534
+ // A double-click selects a word and a drag selects a range; neither is a
1535
+ // select-click, so leave the selection intact instead of re-rendering.
1536
+ if (event.detail > 1) return
1537
+ if (pointerDownPosition !== null
1538
+ && Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y) > 4) return
1052
1539
  const thread = state.workspace?.threads.find(item => item.id === card.dataset.thread)
1053
1540
  if (thread === undefined) return
1541
+ const cardId = card.dataset.cardId
1542
+ if (cardId === undefined) return
1054
1543
  state.activeId = thread.id
1544
+ state.selectedCardId = cardId
1545
+ openCardInspector(cardId)
1055
1546
  state.error = ''
1056
1547
  render()
1057
1548
  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
1549
+ // Bidirectional current-session sync: switch DSH's current session
1550
+ // without closing the map; the client confirms via synapse:current-session.
1551
+ if (thread.dshSessionId !== null) {
1552
+ if (thread.dshSessionId !== state.currentDsh?.id) state.mapCardSessionSwitches.add(thread.dshSessionId)
1553
+ post('synapse:activate-session', { sessionId: thread.dshSessionId })
1554
+ }
1555
+ return
1062
1556
  }
1063
1557
  const thread = state.workspace?.threads.find(item => item.id === button.dataset.thread)
1064
1558
  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 })
1559
+ if (button.dataset.action === 'follow-selection') {
1560
+ const followup = selectionFollowup
1561
+ hideSelectionFollowup()
1562
+ if (followup !== null && thread !== undefined && thread.id === followup.threadId && state.draft === null) openContinue(thread, undefined, followup.text)
1563
+ return
1078
1564
  }
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('当前会话位于这个后续分支中,请先切换会话')
1565
+ if (button.dataset.action === 'insert-quick-phrase' && button.dataset.quickPhrase !== undefined) insertQuickPhrase(button.dataset.quickPhrase)
1566
+ if (button.dataset.action === 'open-quick-phrase-editor') { state.quickPhraseEditorOpen = true; render() }
1567
+ if (button.dataset.action === 'close-quick-phrase-editor') { state.quickPhraseEditorOpen = false; render() }
1568
+ if (button.dataset.action === 'add-quick-phrase') {
1569
+ const editor = button.closest('.draft-quick-phrase-add')
1570
+ const input = editor?.querySelector('input')
1571
+ if (input instanceof HTMLInputElement && addQuickPhrase(input.value)) {
1572
+ render()
1573
+ window.setTimeout(() => document.querySelector('.draft-quick-phrase-add input')?.focus(), 0)
1092
1574
  }
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
1575
  }
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()
1576
+ if (button.dataset.action === 'remove-quick-phrase') {
1577
+ const index = Number(button.dataset.quickPhraseIndex)
1578
+ if (Number.isInteger(index) && index >= 0 && index < state.quickPhrases.length) {
1579
+ state.quickPhrases.splice(index, 1)
1580
+ persistQuickPhrases()
1581
+ render()
1582
+ }
1117
1583
  }
1118
- } catch (error) { setError(error) }
1119
- })
1120
-
1584
+ if (button.dataset.action === 'close') post('synapse:close')
1585
+ if (button.dataset.action === 'close-card-inspector') { closeCardInspector(); return }
1586
+ if (button.dataset.action === 'toggle-sidebar') { state.sidebarCollapsed = !state.sidebarCollapsed; render() }
1587
+ if (button.dataset.action === 'create-session') openNewSession()
1588
+ if (button.dataset.action === 'open-current' && state.currentDsh !== null) post('synapse:open-session', { sessionId: state.currentDsh.id })
1589
+ if (button.dataset.action === 'select-thread' && thread !== undefined) {
1590
+ state.mapCardSessionSwitches.clear()
1591
+ state.activeId = thread.id
1592
+ state.selectedCardId = null
1593
+ state.inspectorCardId = null
1594
+ state.inspectorOpening = false
1595
+ state.error = ''
1596
+ if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
1597
+ render()
1598
+ void loadThreadHistory(thread)
1599
+ // Bidirectional current-session sync: switch DSH's current session
1600
+ // without closing the map; the client confirms via synapse:current-session.
1601
+ if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
1602
+ }
1603
+ if (button.dataset.action === 'show-thread' && thread !== undefined) { state.activeId = thread.id; state.mode = 'thread'; state.detailTargetCardId = button.dataset.card ?? null; render(); void loadThreadHistory(thread) }
1604
+ if (button.dataset.action === 'show-canvas') { state.mode = 'canvas'; render() }
1605
+ if (button.dataset.action === 'toggle-card-children' && button.dataset.card !== undefined) {
1606
+ const cardId = button.dataset.card
1607
+ const collapsing = !state.collapsedCardIds.has(cardId)
1608
+ if (collapsing && state.workspace !== null) {
1609
+ const allCards = conversationCards(state.workspace.threads)
1610
+ const nextCollapsed = new Set(state.collapsedCardIds).add(cardId)
1611
+ const visibleCards = conversationGraphView(allCards, nextCollapsed).cards
1612
+ const visibleIds = new Set(visibleCards.map(card => card.id))
1613
+ const draftParentId = draftPlacement(allCards)?.parent.id
1614
+ if (draftParentId !== undefined && !visibleIds.has(draftParentId)) return setError('请先完成或取消正在编辑的追问或分支')
1615
+ if (state.activeId !== null && !visibleCards.some(card => card.dshThreadId === state.activeId)) return setError('当前会话位于这个后续分支中,请先切换会话')
1616
+ }
1617
+ collapsing ? state.collapsedCardIds.add(cardId) : state.collapsedCardIds.delete(cardId)
1618
+ persistCollapsedCards()
1619
+ render()
1620
+ window.setTimeout(() => document.querySelector(`[data-action="toggle-card-children"][data-card="${selectorValue(cardId)}"]`)?.focus(), 0)
1621
+ }
1622
+ if (button.dataset.action === 'open-continue' && thread !== undefined) openContinue(thread, button.dataset.card)
1623
+ if (button.dataset.action === 'open-branch' && thread !== undefined) {
1624
+ const requestedSeq = Number(button.dataset.seq)
1625
+ if (button.dataset.card !== undefined && !Number.isInteger(requestedSeq)) return setError('请等待这张卡片的最终回答后再创建分支')
1626
+ const fallbackSeq = latestMessage(thread, 'assistant')?.sourceSeq
1627
+ openBranch(thread, Number.isInteger(requestedSeq) ? requestedSeq : fallbackSeq, button.dataset.card)
1628
+ }
1629
+ if (button.dataset.action === 'cancel-draft') { state.draft = null; state.quickPhraseEditorOpen = false; render() }
1630
+ 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() }
1631
+ if (button.dataset.action === 'open-dsh' && thread?.dshSessionId !== null) post('synapse:open-session', { sessionId: thread.dshSessionId, seq: Number.isInteger(Number(button.dataset.seq)) ? Number(button.dataset.seq) : undefined })
1632
+ if (button.dataset.action === 'archive-thread' && thread !== undefined) await archiveThread(thread)
1633
+ if (button.dataset.action === 'zoom-in') zoomCanvasAtCenter(.1)
1634
+ if (button.dataset.action === 'zoom-out') zoomCanvasAtCenter(-.1)
1635
+ if (button.dataset.action === 'focus-active') focusActiveCard()
1636
+ if (button.dataset.action === 'dismiss-error') { state.error = ''; render() }
1637
+ if (button.dataset.action === 'layout' && state.workspace !== null) {
1638
+ resetCardPositions()
1639
+ resetCanvasCamera()
1640
+ render()
1641
+ }
1642
+ } catch (error) { setError(error) }
1643
+ })
1644
+
1121
1645
  app.addEventListener('change', event => {
1646
+ const quickPhrase = event.target instanceof Element ? event.target.closest('[data-quick-phrase-index]') : null
1647
+ if (quickPhrase instanceof HTMLInputElement) {
1648
+ updateQuickPhrase(Number(quickPhrase.dataset.quickPhraseIndex), quickPhrase.value)
1649
+ return
1650
+ }
1122
1651
  const select = event.target.closest('[data-action="select-workspace"]')
1123
1652
  if (!(select instanceof HTMLSelectElement)) return
1124
1653
  const choice = workspaceChoices().find(item => item.id === select.value)
1654
+ state.inspectorCardId = null
1655
+ state.inspectorOpening = false
1125
1656
  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
- }
1657
+ // Map → native sync: switching workspaces moves DSH's current session to
1658
+ // the workspace's most recently updated session, keeping both sides in step.
1659
+ void openDshWorkspace(choice.id).then(opened => {
1660
+ if (!opened) return
1661
+ const threads = state.workspace?.threads ?? []
1662
+ const latest = threads
1663
+ .filter(thread => thread.dshSessionId !== null)
1664
+ .sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))[0]
1665
+ const sessionId = latest?.dshSessionId ?? choice.sessionIds[0]
1666
+ if (sessionId !== undefined) post('synapse:activate-session', { sessionId })
1667
+ }).catch(setError)
1668
+ } else if (choice !== undefined) { state.selectedDshWorkspaceId = null; void openWorkspace(choice.id).catch(setError) }
1669
+ })
1670
+ app.addEventListener('input', event => { const input = event.target; if (input instanceof HTMLTextAreaElement && input.closest('[data-draft]') && state.draft !== null) state.draft.text = input.value })
1671
+ app.addEventListener('submit', event => {
1672
+ const form = event.target
1673
+ if (!(form instanceof HTMLFormElement)) return
1674
+ if (form.matches('[data-draft]')) { event.preventDefault(); void submitDraft(); return }
1675
+ const thread = state.workspace?.threads.find(item => item.id === form.dataset.compose)
1676
+ const input = form.querySelector('textarea')
1677
+ if (thread === undefined || !(input instanceof HTMLTextAreaElement) || input.value.trim() === '') return
1678
+ event.preventDefault()
1679
+ const text = input.value.trim()
1680
+ input.value = ''
1681
+ void sendMessage(thread, text).catch(setError)
1682
+ })
1683
+
1684
+ window.addEventListener('message', event => {
1685
+ if (event.origin !== window.location.origin || event.data?.source !== 'dsh-synapse') return
1686
+ const data = event.data
1687
+ if (data.type === 'synapse:map-opened') {
1688
+ // Do NOT reset the camera here: toggling dialog<->map for the same
1689
+ // session must keep the user's viewport. A fresh canvas (canvasView
1690
+ // not initialized) still centers via renderCanvas; a real session switch
1691
+ // re-centers in the current-session handler below.
1692
+ state.mode = 'canvas'
1693
+ render()
1694
+ window.requestAnimationFrame(() => post('synapse:map-ready'))
1695
+ }
1696
+ if (data.type === 'synapse:theme') {
1697
+ document.documentElement.dataset.theme = data.dark === true ? 'dark' : 'light'
1698
+ }
1699
+ if (data.type === 'synapse:workspaces') {
1700
+ state.dshWorkspaces = Array.isArray(data.workspaces) ? data.workspaces.filter(workspace => typeof workspace?.id === 'string' && typeof workspace.title === 'string' && Array.isArray(workspace.sessionIds)) : []
1701
+ const current = currentDshWorkspace()
1702
+ if (current !== undefined && current.id !== state.selectedDshWorkspaceId) void openDshWorkspace(current.id).catch(setError)
1703
+ else if (state.selectedDshWorkspaceId !== null) void openDshWorkspace(state.selectedDshWorkspaceId).catch(setError)
1704
+ else if (canReplaceView()) render()
1705
+ }
1172
1706
  if (data.type === 'synapse:current-session') {
1173
1707
  const previousId = state.currentDsh?.id
1174
1708
  state.currentDsh = data.session
1709
+ const preserveCanvasCamera = previousId !== data.session?.id && state.mapCardSessionSwitches.delete(data.session?.id)
1175
1710
  const thread = currentDshThread()
1176
1711
  if (thread !== undefined) {
1712
+ const preserveSelectedCard = state.activeId === thread.id
1177
1713
  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()
1714
+ if (!preserveSelectedCard) {
1715
+ state.selectedCardId = null
1716
+ state.inspectorCardId = null
1717
+ state.inspectorOpening = false
1197
1718
  }
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)
1719
+ if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
1720
+ }
1721
+ if (previousId !== data.session?.id) {
1722
+ // A real session switch: re-center on the new session's latest turn,
1723
+ // whether it lives in the same workspace (openCurrentWorkspace returns
1724
+ // false) or a different one (it resets the camera itself).
1725
+ void openCurrentWorkspace({ preserveCanvasCamera }).then(opened => {
1726
+ if (!opened && canReplaceView()) {
1727
+ render()
1728
+ if (!preserveCanvasCamera) focusActiveCard()
1729
+ }
1730
+ }).catch(setError)
1731
+ }
1732
+ else if (canReplaceView()) render()
1733
+ }
1734
+ if (data.type === 'synapse:live-reply' && typeof data.sessionId === 'string') {
1735
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === data.sessionId)
1736
+ if (thread !== undefined) {
1737
+ if (data.running === true) {
1738
+ state.liveReplies.set(data.sessionId, { running: true, text: typeof data.text === 'string' ? data.text : '' })
1739
+ // Streaming: patch the live card's answer in place instead of
1740
+ // rebuilding the whole canvas on every chunk; a full render reconciles
1741
+ // at stream end. The detail view is single-thread, so keep its cheap
1742
+ // throttled full render.
1743
+ if (state.mode === 'canvas') scheduleLiveCardUpdate(data.sessionId)
1744
+ else if (canReplaceView()) scheduleLiveRender()
1745
+ } else {
1746
+ state.liveReplies.delete(data.sessionId)
1747
+ if (canReplaceView() || state.pendingReplies.has(data.sessionId)) renderPreservingDetailScroll()
1748
+ }
1749
+ }
1750
+ }
1751
+ if (data.type === 'synapse:forked-session' || data.type === 'synapse:created-session' || data.type === 'synapse:message-sent') settleRpc(data.requestId, data.session ?? data)
1752
+ if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
1753
+ })
1754
+
1755
+ post('synapse:request-current')
1756
+ refreshSummaries().catch(setError)
1757
+ let polling = false
1758
+ let liveRenderTimer = 0
1759
+ let liveCardFrame = 0
1760
+ let liveCardSessionId = null
1761
+ function scheduleLiveCardUpdate(sessionId) {
1762
+ // Coalesce streaming chunks to one DOM patch per animation frame.
1763
+ liveCardSessionId = sessionId
1764
+ if (liveCardFrame !== 0) return
1765
+ liveCardFrame = window.requestAnimationFrame(() => {
1766
+ liveCardFrame = 0
1767
+ if (liveCardSessionId === null) return
1768
+ const id = liveCardSessionId
1769
+ liveCardSessionId = null
1770
+ applyLiveReplyToCard(id)
1771
+ })
1772
+ }
1773
+ function applyLiveReplyToCard(sessionId) {
1774
+ if (state.mode !== 'canvas') return
1775
+ // Never patch cards mid-gesture: the reflow would compete with the drag or
1776
+ // pan frame; the next live-reply chunk re-applies after the gesture ends.
1777
+ if (state.dragging || state.canvasGesture) return
1778
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId)
1779
+ if (thread === undefined) return
1780
+ const live = state.liveReplies.get(sessionId)
1781
+ if (live?.running !== true) return
1782
+ const cards = app.querySelectorAll(`.thread-card[data-thread="${CSS.escape(thread.id)}"]`)
1783
+ const card = cards[cards.length - 1]
1784
+ if (!(card instanceof HTMLElement)) return
1785
+ const answer = card.querySelector('.thread-answer')
1786
+ if (!(answer instanceof HTMLElement)) return
1787
+ const text = live.text
1788
+ answer.innerHTML = text.trim() === ''
1789
+ ? '<p class="thread-answer-pending">正在回复</p>'
1790
+ : `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`
1791
+ }
1792
+ function scheduleLiveRender() {
1793
+ if (liveRenderTimer !== 0 || !canReplaceView()) return
1794
+ liveRenderTimer = window.setTimeout(() => {
1795
+ liveRenderTimer = 0
1796
+ if (canReplaceView()) renderPreservingDetailScroll()
1797
+ }, 120)
1798
+ }
1799
+ async function pollProjection() {
1800
+ if (polling || document.hidden || !canReplaceView()) return
1801
+ polling = true
1802
+ try {
1803
+ await refreshProjection()
1804
+ } finally { polling = false }
1805
+ }
1806
+ window.setInterval(() => { void pollProjection() }, 1_000)