dsh-synapse 0.4.1 → 0.4.2
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 +79 -81
- package/app.js +1344 -1344
- package/client.js +336 -241
- package/cordis.patch.yml +13 -13
- package/deepseek-mark.svg +3 -3
- package/docs/architecture.md +127 -104
- package/docs/development.md +138 -137
- package/docs/en/README.md +161 -161
- package/docs/images/native-webui.jpg +0 -0
- package/docs/images/synapse-map.jpg +0 -0
- package/docs/zh-CN/README.md +154 -154
- package/index.js +761 -754
- package/package.json +47 -45
- package/styles.css +317 -317
- package/docs/images/native-webui.png +0 -0
- package/docs/images/synapse-ui.png +0 -0
package/app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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'
|
|
@@ -23,62 +23,62 @@ const savedQuickPhrases = (() => {
|
|
|
23
23
|
return stored === null ? DEFAULT_QUICK_PHRASES : normalizeQuickPhrases(JSON.parse(stored))
|
|
24
24
|
} catch { return DEFAULT_QUICK_PHRASES }
|
|
25
25
|
})()
|
|
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 = {
|
|
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
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(),
|
|
58
|
+
dshWorkspaces: [], selectedDshWorkspaceId: null,
|
|
59
|
+
historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
|
|
60
60
|
draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards), quickPhrases: savedQuickPhrases, quickPhraseEditorOpen: false,
|
|
61
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,
|
|
62
|
+
expandedMessageIds: new Set(),
|
|
63
|
+
canvasCards: undefined, canvasCardsById: undefined, canvasGraph: undefined, mountedCardIds: new Set(), canvasNeedsCenter: false,
|
|
64
64
|
detailScrollByThread: new Map(), detailThreadId: null, detailTargetCardId: null,
|
|
65
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
|
-
|
|
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
|
+
|
|
82
82
|
function persistCollapsedCards() {
|
|
83
83
|
try { localStorage.setItem(COLLAPSED_CARDS_KEY, JSON.stringify([...state.collapsedCardIds])) } catch { /* Private browsing may disable local storage. */ }
|
|
84
84
|
}
|
|
@@ -86,168 +86,168 @@ function persistCollapsedCards() {
|
|
|
86
86
|
function persistQuickPhrases() {
|
|
87
87
|
try { localStorage.setItem(QUICK_PHRASES_KEY, JSON.stringify(state.quickPhrases)) } catch { /* Private browsing may disable local storage. */ }
|
|
88
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
|
-
|
|
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
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}`
|
|
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
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
|
-
|
|
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
211
|
async function openCurrentWorkspace({ preserveCanvasCamera = false } = {}) {
|
|
212
212
|
const workspace = currentDshWorkspace()
|
|
213
213
|
if (workspace === undefined || workspace.id === state.selectedDshWorkspaceId) return false
|
|
214
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
|
-
|
|
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
|
+
|
|
251
251
|
function openNewSession() {
|
|
252
252
|
if (state.draft !== null) return
|
|
253
253
|
state.mode = 'canvas'
|
|
@@ -257,51 +257,51 @@ function openNewSession() {
|
|
|
257
257
|
state.inspectorOpening = false
|
|
258
258
|
state.quickPhraseEditorOpen = false
|
|
259
259
|
state.draft = { kind: 'new', text: '', sending: false }
|
|
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
|
-
|
|
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
305
|
function focusDraftInput() {
|
|
306
306
|
const input = document.querySelector('[data-draft] textarea')
|
|
307
307
|
if (!(input instanceof HTMLTextAreaElement)) return
|
|
@@ -317,354 +317,354 @@ function openContinue(parent, anchorId = undefined, text = '') {
|
|
|
317
317
|
render()
|
|
318
318
|
window.setTimeout(focusDraftInput, 0)
|
|
319
319
|
}
|
|
320
|
-
|
|
320
|
+
|
|
321
321
|
function openBranch(parent, atSeq = undefined, anchorId = undefined) {
|
|
322
322
|
if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
|
|
323
323
|
state.activeId = parent.id
|
|
324
324
|
state.quickPhraseEditorOpen = false
|
|
325
325
|
state.draft = { kind: 'branch', parentId: parent.id, atSeq, anchorId, text: '', sending: false }
|
|
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++) {
|
|
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++) {
|
|
668
668
|
const question = messages[messageIndex]
|
|
669
669
|
if (question.kind !== 'user') continue
|
|
670
670
|
const replies = []
|
|
@@ -680,188 +680,188 @@ function conversationCards(threads) {
|
|
|
680
680
|
}
|
|
681
681
|
const answer = replies.at(-1) ?? null
|
|
682
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,
|
|
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,
|
|
702
702
|
question: question.text,
|
|
703
703
|
answer,
|
|
704
704
|
error,
|
|
705
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,
|
|
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,
|
|
728
728
|
question: thread.dshSessionTitle ?? thread.title,
|
|
729
729
|
answer: null,
|
|
730
730
|
error: null,
|
|
731
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
|
-
|
|
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
|
+
|
|
865
865
|
function canvasConnectors(cards) {
|
|
866
866
|
const index = new Map(cards.map(card => [card.id, card]))
|
|
867
867
|
const links = cards.map(card => {
|
|
@@ -869,35 +869,35 @@ function canvasConnectors(cards) {
|
|
|
869
869
|
if (parent === undefined || parent === null) return ''
|
|
870
870
|
const active = card.dshThreadId === state.activeId && parent.dshThreadId === state.activeId ? ' active-connector' : ''
|
|
871
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
|
-
|
|
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
|
+
|
|
880
880
|
function conversationCard(card, graph) {
|
|
881
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>`
|
|
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
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>
|
|
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
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
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
|
-
|
|
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
|
+
|
|
901
901
|
function draftActions(draft) {
|
|
902
902
|
const disabled = draft.sending ? 'disabled' : ''
|
|
903
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>`
|
|
@@ -956,89 +956,89 @@ function updateQuickPhrase(index, value) {
|
|
|
956
956
|
persistQuickPhrases()
|
|
957
957
|
render()
|
|
958
958
|
}
|
|
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
|
-
|
|
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
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>
|
|
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
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>`
|
|
982
|
+
</article>`
|
|
983
983
|
}
|
|
984
984
|
|
|
985
985
|
function selectionFollowupButton() {
|
|
986
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
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
|
|
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
1042
|
state.canvasCards = cards
|
|
1043
1043
|
state.canvasCardsById = new Map(cards.map(card => [card.id, card]))
|
|
1044
1044
|
state.canvasGraph = graph
|
|
@@ -1046,57 +1046,57 @@ function renderCanvas() {
|
|
|
1046
1046
|
state.inspectorCardId = null
|
|
1047
1047
|
state.inspectorOpening = false
|
|
1048
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
|
-
}
|
|
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
1056
|
const visible = visibleCardIds(cards)
|
|
1057
1057
|
state.mountedCardIds = new Set(visible)
|
|
1058
1058
|
const mounted = cards.filter(card => visible.has(card.id))
|
|
1059
1059
|
const inspector = state.inspectorCardId === null ? '' : renderCardInspector(state.canvasCardsById.get(state.inspectorCardId))
|
|
1060
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
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
|
-
|
|
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
|
+
|
|
1089
1089
|
function processRecords(process, messageId) {
|
|
1090
1090
|
const key = `${messageId}:process`
|
|
1091
1091
|
const expanded = state.expandedMessageIds.has(key)
|
|
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('')
|
|
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('')
|
|
1100
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>`
|
|
1101
1101
|
}
|
|
1102
1102
|
|
|
@@ -1158,18 +1158,18 @@ function renderCardInspector(card) {
|
|
|
1158
1158
|
}
|
|
1159
1159
|
|
|
1160
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.
|
|
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
1173
|
if (state.mode === 'thread' && state.detailThreadId !== null) {
|
|
1174
1174
|
const detail = document.querySelector('.detail-scroll')
|
|
1175
1175
|
if (detail instanceof HTMLElement) state.detailScrollByThread.set(state.detailThreadId, detail.scrollTop)
|
|
@@ -1181,39 +1181,39 @@ function render() {
|
|
|
1181
1181
|
state.detailThreadId = state.mode === 'thread' ? state.activeId : null
|
|
1182
1182
|
const detailScrollTop = state.detailThreadId === null ? null : state.detailScrollByThread.get(state.detailThreadId) ?? null
|
|
1183
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>`
|
|
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
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
|
-
}
|
|
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
|
+
}
|
|
1217
1217
|
if (detailScrollTop !== null) window.requestAnimationFrame(() => {
|
|
1218
1218
|
const nextDetail = document.querySelector('.detail-scroll')
|
|
1219
1219
|
if (nextDetail instanceof HTMLElement) nextDetail.scrollTop = detailScrollTop
|
|
@@ -1226,21 +1226,21 @@ function render() {
|
|
|
1226
1226
|
document.querySelector('.card-inspector')?.classList.remove('is-opening')
|
|
1227
1227
|
state.inspectorOpening = false
|
|
1228
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
|
-
|
|
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
|
+
|
|
1244
1244
|
function renderPreservingDetailScroll() {
|
|
1245
1245
|
render()
|
|
1246
1246
|
}
|
|
@@ -1277,121 +1277,121 @@ function closeCardInspector({ animate = true } = {}) {
|
|
|
1277
1277
|
}
|
|
1278
1278
|
|
|
1279
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
|
-
|
|
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
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()
|
|
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
1395
|
syncCanvasViewport()
|
|
1396
1396
|
}
|
|
1397
1397
|
|
|
@@ -1446,70 +1446,70 @@ function queueSelectionFollowup() {
|
|
|
1446
1446
|
}
|
|
1447
1447
|
|
|
1448
1448
|
app.addEventListener('pointerdown', event => {
|
|
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.
|
|
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.
|
|
1513
1513
|
let pointerDownPosition = null
|
|
1514
1514
|
app.addEventListener('pointerdown', event => { pointerDownPosition = { x: event.clientX, y: event.clientY } })
|
|
1515
1515
|
app.addEventListener('pointerdown', event => {
|
|
@@ -1527,15 +1527,15 @@ document.addEventListener('keydown', event => {
|
|
|
1527
1527
|
})
|
|
1528
1528
|
|
|
1529
1529
|
app.addEventListener('click', async event => {
|
|
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
|
|
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
|
|
1539
1539
|
const thread = state.workspace?.threads.find(item => item.id === card.dataset.thread)
|
|
1540
1540
|
if (thread === undefined) return
|
|
1541
1541
|
const cardId = card.dataset.cardId
|
|
@@ -1546,13 +1546,13 @@ app.addEventListener('click', async event => {
|
|
|
1546
1546
|
state.error = ''
|
|
1547
1547
|
render()
|
|
1548
1548
|
void loadThreadHistory(thread)
|
|
1549
|
-
// Bidirectional current-session sync: switch DSH's current session
|
|
1550
|
-
// without closing the map; the client confirms via synapse:current-session.
|
|
1549
|
+
// Bidirectional current-session sync: switch DSH's current session
|
|
1550
|
+
// without closing the map; the client confirms via synapse:current-session.
|
|
1551
1551
|
if (thread.dshSessionId !== null) {
|
|
1552
1552
|
if (thread.dshSessionId !== state.currentDsh?.id) state.mapCardSessionSwitches.add(thread.dshSessionId)
|
|
1553
1553
|
post('synapse:activate-session', { sessionId: thread.dshSessionId })
|
|
1554
1554
|
}
|
|
1555
|
-
return
|
|
1555
|
+
return
|
|
1556
1556
|
}
|
|
1557
1557
|
const thread = state.workspace?.threads.find(item => item.id === button.dataset.thread)
|
|
1558
1558
|
try {
|
|
@@ -1583,9 +1583,9 @@ app.addEventListener('click', async event => {
|
|
|
1583
1583
|
}
|
|
1584
1584
|
if (button.dataset.action === 'close') post('synapse:close')
|
|
1585
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 })
|
|
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
1589
|
if (button.dataset.action === 'select-thread' && thread !== undefined) {
|
|
1590
1590
|
state.mapCardSessionSwitches.clear()
|
|
1591
1591
|
state.activeId = thread.id
|
|
@@ -1593,55 +1593,55 @@ app.addEventListener('click', async event => {
|
|
|
1593
1593
|
state.inspectorCardId = null
|
|
1594
1594
|
state.inspectorOpening = false
|
|
1595
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
|
-
}
|
|
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
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
|
-
|
|
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
|
+
|
|
1645
1645
|
app.addEventListener('change', event => {
|
|
1646
1646
|
const quickPhrase = event.target instanceof Element ? event.target.closest('[data-quick-phrase-index]') : null
|
|
1647
1647
|
if (quickPhrase instanceof HTMLInputElement) {
|
|
@@ -1654,55 +1654,55 @@ app.addEventListener('change', event => {
|
|
|
1654
1654
|
state.inspectorCardId = null
|
|
1655
1655
|
state.inspectorOpening = false
|
|
1656
1656
|
if (choice?.source === 'dsh') {
|
|
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
|
-
}
|
|
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
|
+
}
|
|
1706
1706
|
if (data.type === 'synapse:current-session') {
|
|
1707
1707
|
const previousId = state.currentDsh?.id
|
|
1708
1708
|
state.currentDsh = data.session
|
|
@@ -1717,90 +1717,90 @@ window.addEventListener('message', event => {
|
|
|
1717
1717
|
state.inspectorOpening = false
|
|
1718
1718
|
}
|
|
1719
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).
|
|
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
1725
|
void openCurrentWorkspace({ preserveCanvasCamera }).then(opened => {
|
|
1726
1726
|
if (!opened && canReplaceView()) {
|
|
1727
1727
|
render()
|
|
1728
1728
|
if (!preserveCanvasCamera) focusActiveCard()
|
|
1729
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)
|
|
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)
|