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/LICENSE +21 -21
- package/README.md +81 -81
- package/app.js +1694 -1140
- package/client.js +241 -217
- package/cordis.patch.yml +13 -13
- package/deepseek-mark.svg +3 -3
- package/docs/architecture.md +104 -104
- package/docs/development.md +137 -137
- package/docs/en/README.md +161 -161
- package/docs/zh-CN/README.md +154 -154
- package/index.js +799 -772
- package/package.json +45 -44
- package/styles.css +420 -310
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
24
|
-
return
|
|
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
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function
|
|
91
|
-
if (window.parent
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function
|
|
132
|
-
state.
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return state.dshWorkspaces.find(workspace => workspace.id
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
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.
|
|
315
|
+
state.quickPhraseEditorOpen = false
|
|
316
|
+
state.draft = { kind: 'continue', parentId: parent.id, anchorId, text, sending: false }
|
|
272
317
|
render()
|
|
273
|
-
window.setTimeout(
|
|
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 =
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
const
|
|
517
|
-
if (
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
if (
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
608
|
-
const
|
|
609
|
-
const
|
|
610
|
-
const
|
|
611
|
-
const
|
|
612
|
-
const
|
|
613
|
-
const
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
const
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
childrenByParent.
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
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 ${
|
|
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
|
|
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}"
|
|
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
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
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
|
|
793
|
-
const
|
|
794
|
-
if (
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
state.
|
|
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
|
-
|
|
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
|
|
861
|
-
const thread =
|
|
862
|
-
if (thread ===
|
|
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
|
-
|
|
865
|
-
|
|
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
|
|
869
|
-
const
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
|
950
|
-
const
|
|
951
|
-
if (
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
const
|
|
968
|
-
|
|
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
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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)
|
|
1061
|
-
|
|
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 === '
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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 === '
|
|
1080
|
-
if (button.dataset.action === '
|
|
1081
|
-
if (button.dataset.action === '
|
|
1082
|
-
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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 === '
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1165
|
-
if (data.type === 'synapse:
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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 (
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
if (
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
if (
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
|
|
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)
|