dsh-synapse 0.4.0 → 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/README.md +50 -52
- package/app.js +617 -63
- package/client.js +147 -28
- package/docs/architecture.md +23 -0
- package/docs/development.md +1 -0
- package/docs/en/README.md +1 -1
- package/docs/images/native-webui.jpg +0 -0
- package/docs/images/synapse-map.jpg +0 -0
- package/docs/zh-CN/README.md +1 -1
- package/index.js +63 -29
- package/package.json +5 -2
- package/styles.css +119 -9
- package/docs/images/native-webui.png +0 -0
- package/docs/images/synapse-ui.png +0 -0
package/app.js
CHANGED
|
@@ -3,6 +3,26 @@ if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
|
|
|
3
3
|
const LEGACY_CARD_POSITIONS_KEY = 'dsh-synapse:card-positions'
|
|
4
4
|
const CARD_POSITIONS_KEY = 'dsh-synapse:card-positions:v3'
|
|
5
5
|
const COLLAPSED_CARDS_KEY = 'dsh-synapse:collapsed-cards:v1'
|
|
6
|
+
const QUICK_PHRASES_KEY = 'dsh-synapse:quick-phrases:v1'
|
|
7
|
+
const DEFAULT_QUICK_PHRASES = ['展开说明', '举例', '通俗易懂', '对比解释']
|
|
8
|
+
const MAX_QUICK_PHRASES = 12
|
|
9
|
+
const MAX_QUICK_PHRASE_LENGTH = 16
|
|
10
|
+
function normalizeQuickPhrases(value) {
|
|
11
|
+
if (!Array.isArray(value)) return []
|
|
12
|
+
const phrases = []
|
|
13
|
+
for (const item of value) {
|
|
14
|
+
const phrase = typeof item === 'string' ? item.trim().slice(0, MAX_QUICK_PHRASE_LENGTH) : ''
|
|
15
|
+
if (phrase !== '' && !phrases.includes(phrase)) phrases.push(phrase)
|
|
16
|
+
if (phrases.length === MAX_QUICK_PHRASES) break
|
|
17
|
+
}
|
|
18
|
+
return phrases
|
|
19
|
+
}
|
|
20
|
+
const savedQuickPhrases = (() => {
|
|
21
|
+
try {
|
|
22
|
+
const stored = localStorage.getItem(QUICK_PHRASES_KEY)
|
|
23
|
+
return stored === null ? DEFAULT_QUICK_PHRASES : normalizeQuickPhrases(JSON.parse(stored))
|
|
24
|
+
} catch { return DEFAULT_QUICK_PHRASES }
|
|
25
|
+
})()
|
|
6
26
|
const savedBranchAnchors = (() => {
|
|
7
27
|
try {
|
|
8
28
|
const value = JSON.parse(localStorage.getItem('dsh-synapse:branch-anchors') ?? '[]')
|
|
@@ -29,13 +49,20 @@ const CARD_HEIGHT = 276
|
|
|
29
49
|
const CARD_GAP_Y = 42
|
|
30
50
|
const CAMERA_INSET_X = 56
|
|
31
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
|
|
32
56
|
const state = {
|
|
33
|
-
summaries: [], workspace: null, activeId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
|
|
57
|
+
summaries: [], workspace: null, activeId: null, selectedCardId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
|
|
34
58
|
dshWorkspaces: [], selectedDshWorkspaceId: null,
|
|
35
59
|
historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
|
|
36
|
-
draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards),
|
|
37
|
-
dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 },
|
|
60
|
+
draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards), quickPhrases: savedQuickPhrases, quickPhraseEditorOpen: false,
|
|
61
|
+
dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 }, mapCardSessionSwitches: new Set(),
|
|
38
62
|
expandedMessageIds: new Set(),
|
|
63
|
+
canvasCards: undefined, canvasCardsById: undefined, canvasGraph: undefined, mountedCardIds: new Set(), canvasNeedsCenter: false,
|
|
64
|
+
detailScrollByThread: new Map(), detailThreadId: null, detailTargetCardId: null,
|
|
65
|
+
inspectorCardId: null, inspectorOpening: false, inspectorScrollByCard: new Map(),
|
|
39
66
|
}
|
|
40
67
|
|
|
41
68
|
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]))
|
|
@@ -56,6 +83,10 @@ function persistCollapsedCards() {
|
|
|
56
83
|
try { localStorage.setItem(COLLAPSED_CARDS_KEY, JSON.stringify([...state.collapsedCardIds])) } catch { /* Private browsing may disable local storage. */ }
|
|
57
84
|
}
|
|
58
85
|
|
|
86
|
+
function persistQuickPhrases() {
|
|
87
|
+
try { localStorage.setItem(QUICK_PHRASES_KEY, JSON.stringify(state.quickPhrases)) } catch { /* Private browsing may disable local storage. */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
59
90
|
function rememberCardPosition(cardId, position, aliases = []) {
|
|
60
91
|
state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
|
|
61
92
|
for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
|
|
@@ -158,7 +189,7 @@ async function threadsForDshWorkspace(workspace) {
|
|
|
158
189
|
return projections.flatMap(projection => projection.workspace.threads.filter(thread => requested.has(thread.dshSessionId)))
|
|
159
190
|
}
|
|
160
191
|
|
|
161
|
-
async function openDshWorkspace(id, { renderAfter = true } = {}) {
|
|
192
|
+
async function openDshWorkspace(id, { renderAfter = true, preserveCanvasCamera = false } = {}) {
|
|
162
193
|
const workspace = state.dshWorkspaces.find(item => item.id === id)
|
|
163
194
|
if (workspace === undefined) return false
|
|
164
195
|
const load = ++state.workspaceLoad
|
|
@@ -166,7 +197,7 @@ async function openDshWorkspace(id, { renderAfter = true } = {}) {
|
|
|
166
197
|
const threads = await threadsForDshWorkspace(workspace)
|
|
167
198
|
if (load !== state.workspaceLoad) return true
|
|
168
199
|
const nextWorkspaceId = `dsh:${workspace.id}`
|
|
169
|
-
if (state.workspace?.id !== nextWorkspaceId) resetCanvasCamera()
|
|
200
|
+
if (state.workspace?.id !== nextWorkspaceId && !preserveCanvasCamera) resetCanvasCamera()
|
|
170
201
|
state.workspace = { id: nextWorkspaceId, title: workspace.title, cwd: workspace.path, threads }
|
|
171
202
|
const currentThread = currentDshThread(state.workspace.threads)
|
|
172
203
|
state.activeId = currentThread?.id ?? (state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null)
|
|
@@ -177,10 +208,10 @@ async function openDshWorkspace(id, { renderAfter = true } = {}) {
|
|
|
177
208
|
return true
|
|
178
209
|
}
|
|
179
210
|
|
|
180
|
-
async function openCurrentWorkspace() {
|
|
211
|
+
async function openCurrentWorkspace({ preserveCanvasCamera = false } = {}) {
|
|
181
212
|
const workspace = currentDshWorkspace()
|
|
182
213
|
if (workspace === undefined || workspace.id === state.selectedDshWorkspaceId) return false
|
|
183
|
-
return openDshWorkspace(workspace.id)
|
|
214
|
+
return openDshWorkspace(workspace.id, { preserveCanvasCamera })
|
|
184
215
|
}
|
|
185
216
|
|
|
186
217
|
async function refreshSummaries({ renderAfter = true } = {}) {
|
|
@@ -221,6 +252,10 @@ function openNewSession() {
|
|
|
221
252
|
if (state.draft !== null) return
|
|
222
253
|
state.mode = 'canvas'
|
|
223
254
|
state.activeId = null
|
|
255
|
+
state.selectedCardId = null
|
|
256
|
+
state.inspectorCardId = null
|
|
257
|
+
state.inspectorOpening = false
|
|
258
|
+
state.quickPhraseEditorOpen = false
|
|
224
259
|
state.draft = { kind: 'new', text: '', sending: false }
|
|
225
260
|
state.error = ''
|
|
226
261
|
resetCanvasCamera()
|
|
@@ -232,6 +267,8 @@ async function archiveThread(thread) {
|
|
|
232
267
|
if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
|
|
233
268
|
await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
|
|
234
269
|
state.historyBySession.delete(thread.dshSessionId)
|
|
270
|
+
state.detailScrollByThread.delete(thread.id)
|
|
271
|
+
state.detailTargetCardId = state.detailThreadId === thread.id ? null : state.detailTargetCardId
|
|
235
272
|
if (state.workspace !== null) {
|
|
236
273
|
const removed = new Set([thread.id])
|
|
237
274
|
for (let changed = true; changed;) {
|
|
@@ -265,17 +302,26 @@ async function archiveThread(thread) {
|
|
|
265
302
|
await refreshSummaries()
|
|
266
303
|
}
|
|
267
304
|
|
|
268
|
-
function
|
|
305
|
+
function focusDraftInput() {
|
|
306
|
+
const input = document.querySelector('[data-draft] textarea')
|
|
307
|
+
if (!(input instanceof HTMLTextAreaElement)) return
|
|
308
|
+
input.focus()
|
|
309
|
+
input.setSelectionRange(input.value.length, input.value.length)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function openContinue(parent, anchorId = undefined, text = '') {
|
|
269
313
|
if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
|
|
270
314
|
state.activeId = parent.id
|
|
271
|
-
state.
|
|
315
|
+
state.quickPhraseEditorOpen = false
|
|
316
|
+
state.draft = { kind: 'continue', parentId: parent.id, anchorId, text, sending: false }
|
|
272
317
|
render()
|
|
273
|
-
window.setTimeout(
|
|
318
|
+
window.setTimeout(focusDraftInput, 0)
|
|
274
319
|
}
|
|
275
320
|
|
|
276
321
|
function openBranch(parent, atSeq = undefined, anchorId = undefined) {
|
|
277
322
|
if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
|
|
278
323
|
state.activeId = parent.id
|
|
324
|
+
state.quickPhraseEditorOpen = false
|
|
279
325
|
state.draft = { kind: 'branch', parentId: parent.id, atSeq, anchorId, text: '', sending: false }
|
|
280
326
|
render()
|
|
281
327
|
window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
|
|
@@ -453,7 +499,7 @@ function markdownBlock(text) {
|
|
|
453
499
|
// rebuild; cache the rendered HTML by input text so stable answers are never
|
|
454
500
|
// re-parsed. Bounded: streaming partial texts churn keys, so evict oldest.
|
|
455
501
|
const markdownCache = new Map()
|
|
456
|
-
const MARKDOWN_CACHE_LIMIT =
|
|
502
|
+
const MARKDOWN_CACHE_LIMIT = 5000
|
|
457
503
|
function renderMarkdown(text) {
|
|
458
504
|
const key = String(text)
|
|
459
505
|
const cached = markdownCache.get(key)
|
|
@@ -504,25 +550,49 @@ function selectorValue(value) {
|
|
|
504
550
|
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
505
551
|
}
|
|
506
552
|
|
|
507
|
-
|
|
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()
|
|
508
558
|
const viewport = document.querySelector('.canvas-viewport')
|
|
509
559
|
if (!(viewport instanceof HTMLElement)) return
|
|
510
|
-
const
|
|
511
|
-
|
|
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) {
|
|
512
578
|
const fromId = path.getAttribute('data-from')
|
|
513
579
|
const toId = path.getAttribute('data-to')
|
|
514
580
|
if (fromId === null || toId === null) continue
|
|
515
|
-
const fromCard =
|
|
516
|
-
const toCard =
|
|
517
|
-
if (
|
|
518
|
-
|
|
519
|
-
|
|
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))
|
|
520
587
|
}
|
|
521
588
|
}
|
|
522
589
|
|
|
523
590
|
function initialCanvasCamera(cards) {
|
|
524
591
|
const draft = state.draft?.kind === 'new' ? { id: 'draft:new', position: { x: 86, y: 82 } } : draftPlacement(cards)
|
|
525
|
-
|
|
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)
|
|
526
596
|
const focus = draft ?? active ?? cards[0]
|
|
527
597
|
const position = focus?.position
|
|
528
598
|
if (position === undefined) return { x: 0, y: 0 }
|
|
@@ -598,12 +668,18 @@ function conversationCards(threads) {
|
|
|
598
668
|
const question = messages[messageIndex]
|
|
599
669
|
if (question.kind !== 'user') continue
|
|
600
670
|
const replies = []
|
|
671
|
+
const errors = []
|
|
672
|
+
let processCount = 0
|
|
601
673
|
for (let replyIndex = messageIndex + 1; replyIndex < messages.length; replyIndex++) {
|
|
602
674
|
const reply = messages[replyIndex]
|
|
603
675
|
if (reply.kind === 'user') break
|
|
604
676
|
if (reply.kind === 'assistant') replies.push(reply)
|
|
677
|
+
if (reply.kind === 'error') errors.push(reply)
|
|
678
|
+
if (Array.isArray(reply.process)) processCount += reply.process.length
|
|
679
|
+
else if (reply.kind === 'tool') processCount += 1
|
|
605
680
|
}
|
|
606
681
|
const answer = replies.at(-1) ?? null
|
|
682
|
+
const error = errors.at(-1) ?? null
|
|
607
683
|
const turnIndex = turns.length
|
|
608
684
|
const id = `${thread.id}:turn:${question.sourceSeq ?? messageIndex}`
|
|
609
685
|
const previous = turns.at(-1)
|
|
@@ -625,6 +701,8 @@ function conversationCards(threads) {
|
|
|
625
701
|
positionLocked,
|
|
626
702
|
question: question.text,
|
|
627
703
|
answer,
|
|
704
|
+
error,
|
|
705
|
+
processCount,
|
|
628
706
|
})
|
|
629
707
|
}
|
|
630
708
|
const liveReply = state.liveReplies.get(thread.dshSessionId)
|
|
@@ -649,6 +727,8 @@ function conversationCards(threads) {
|
|
|
649
727
|
positionLocked,
|
|
650
728
|
question: thread.dshSessionTitle ?? thread.title,
|
|
651
729
|
answer: null,
|
|
730
|
+
error: null,
|
|
731
|
+
processCount: 0,
|
|
652
732
|
})
|
|
653
733
|
}
|
|
654
734
|
turns.at(-1).canContinue = true
|
|
@@ -704,17 +784,60 @@ function conversationGraphView(cards, collapsedCardIds = state.collapsedCardIds)
|
|
|
704
784
|
// contains a cycle where two collapsed nodes otherwise hide each other.
|
|
705
785
|
for (const rootId of collapsedCardIds) hiddenIds.delete(rootId)
|
|
706
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.
|
|
707
794
|
const descendantCounts = new Map()
|
|
795
|
+
const inStack = new Set()
|
|
708
796
|
for (const card of cards) {
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
+
}
|
|
716
840
|
}
|
|
717
|
-
descendantCounts.set(card.id, visited.size - 1)
|
|
718
841
|
}
|
|
719
842
|
|
|
720
843
|
return {
|
|
@@ -744,7 +867,8 @@ function canvasConnectors(cards) {
|
|
|
744
867
|
const links = cards.map(card => {
|
|
745
868
|
const parent = card.parentId === null ? null : index.get(card.parentId)
|
|
746
869
|
if (parent === undefined || parent === null) return ''
|
|
747
|
-
|
|
870
|
+
const active = card.dshThreadId === state.activeId && parent.dshThreadId === state.activeId ? ' active-connector' : ''
|
|
871
|
+
return `<path class="${active.trim()}" data-from="${escapeHtml(parent.id)}" data-to="${escapeHtml(card.id)}" d="${connectorPath(parent.position, card.position)}"></path>`
|
|
748
872
|
})
|
|
749
873
|
const placement = draftPlacement(cards)
|
|
750
874
|
if (placement !== null) {
|
|
@@ -754,7 +878,7 @@ function canvasConnectors(cards) {
|
|
|
754
878
|
}
|
|
755
879
|
|
|
756
880
|
function conversationCard(card, graph) {
|
|
757
|
-
const
|
|
881
|
+
const selected = card.id === state.selectedCardId ? 'selected' : ''
|
|
758
882
|
const source = card.parentId === null ? 'DSH 会话' : card.turnIndex === 0 ? 'DSH 分支' : '追问'
|
|
759
883
|
const continueButton = card.canContinue === true
|
|
760
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>`
|
|
@@ -764,13 +888,13 @@ function conversationCard(card, graph) {
|
|
|
764
888
|
const foldLabel = collapsed ? '展开后续对话' : '折叠后续对话'
|
|
765
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>`
|
|
766
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>`
|
|
767
|
-
return `<article class="thread-card ${
|
|
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">
|
|
768
892
|
<button class="node-handle" data-drag-card="${card.id}" aria-label="拖动 ${escapeHtml(card.question)}" title="拖动卡片"></button>
|
|
769
893
|
${continueButton}${foldButton}${branchButton}
|
|
770
|
-
<div class="thread-card-head"><span class="topic-dot"></span><button class="thread-title" data-action="show-thread" data-thread="${card.dshThreadId}" title="查看完整会话:${escapeHtml(card.question)}">${escapeHtml(card.question)}</button></div>
|
|
771
|
-
<div class="thread-meta"><span>${source}</span><span>第 ${card.turnIndex + 1} 轮</span
|
|
772
|
-
<div class="thread-answer">${card.answer === null ? '<p class="thread-answer-empty">等待助手回复</p>' : card.answer.pending && card.answer.text === '' ? '<p class="thread-answer-pending">正在回复</p>' : `${renderMarkdown(card.answer.text)}${card.answer.pending ? '<p class="thread-answer-pending">正在回复</p>' : ''}`}</div>
|
|
773
|
-
<footer><button data-action="show-thread" data-thread="${card.dshThreadId}">详情</button><button data-action="open-dsh" data-thread="${card.dshThreadId}"
|
|
894
|
+
<div class="thread-card-head"><span class="topic-dot"></span><button class="thread-title" data-action="show-thread" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" title="查看完整会话:${escapeHtml(card.question)}">${escapeHtml(card.question)}</button></div>
|
|
895
|
+
<div class="thread-meta"><span>${source}</span><span>第 ${card.turnIndex + 1} 轮</span>${card.error === null ? '' : '<span class="card-error-status">失败</span>'}${card.processCount > 0 ? `<span class="card-process-count">工具 ${card.processCount}</span>` : ''}</div>
|
|
896
|
+
<div class="thread-answer">${card.answer === null ? (card.error === null ? '<p class="thread-answer-empty">等待助手回复</p>' : '') : card.answer.pending && card.answer.text === '' ? '<p class="thread-answer-pending">正在回复</p>' : `${renderMarkdown(card.answer.text)}${card.answer.pending ? '<p class="thread-answer-pending">正在回复</p>' : ''}`}${card.error === null ? '' : `<p class="thread-answer-error" title="${escapeHtml(card.error.text)}">本轮失败:${escapeHtml(card.error.text)}</p>`}</div>
|
|
897
|
+
<footer><button data-action="show-thread" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" title="查看完整会话" aria-label="查看完整会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2 8.5 8 2.5l6 6V13.5a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5Z"/><path d="M6.2 14v-3.6a1.8 1.8 0 0 1 3.6 0V14" /></svg>详情</button><button data-action="open-dsh" data-thread="${card.dshThreadId}" data-seq="${Number.isInteger(card.sourceSeq) ? card.sourceSeq : ''}" title="在 DSH 中打开" aria-label="在 DSH 中打开"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3.5H4.5A1.5 1.5 0 0 0 3 5v6.5A1.5 1.5 0 0 0 4.5 13H11a1.5 1.5 0 0 0 1.5-1.5V9"/><path d="M9.5 3.5h3v3M12.4 3.6 7.5 8.5"/></svg>DSH</button><button data-action="archive-thread" data-thread="${card.dshThreadId}" title="归档此会话" aria-label="归档此会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 5h11M5.5 7v5.5a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V7"/><path d="M4 5 5 2.8a.7.7 0 0 1 .6-.4h4.8a.7.7 0 0 1 .6.4L12 5M6 9.5h4"/></svg>归档</button></footer>
|
|
774
898
|
</article>`
|
|
775
899
|
}
|
|
776
900
|
|
|
@@ -779,6 +903,60 @@ function draftActions(draft) {
|
|
|
779
903
|
return `<div class="draft-actions"><button type="button" data-action="cancel-draft" ${disabled} aria-label="取消" title="取消"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button><button class="primary" type="submit" ${disabled} aria-label="发送" title="发送"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12.5v-9M4.5 7 8 3.5 11.5 7"/></svg></button></div>`
|
|
780
904
|
}
|
|
781
905
|
|
|
906
|
+
function quickPhraseEditor(draft) {
|
|
907
|
+
const disabled = draft.sending ? 'disabled' : ''
|
|
908
|
+
const phrases = state.quickPhrases.map((phrase, index) => `<div class="draft-quick-phrase-editor-row"><input data-quick-phrase-index="${index}" maxlength="${MAX_QUICK_PHRASE_LENGTH}" value="${escapeHtml(phrase)}" aria-label="快捷词 ${index + 1}" ${disabled}><button type="button" data-action="remove-quick-phrase" data-quick-phrase-index="${index}" aria-label="删除 ${escapeHtml(phrase)}" title="删除" ${disabled}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button></div>`).join('')
|
|
909
|
+
return `<section class="draft-quick-editor" aria-label="编辑快捷词"><div class="draft-quick-editor-list">${phrases}</div><div class="draft-quick-phrase-add"><input maxlength="${MAX_QUICK_PHRASE_LENGTH}" placeholder="添加快捷词" aria-label="添加快捷词" ${disabled}><button class="primary" type="button" data-action="add-quick-phrase" aria-label="添加快捷词" title="添加快捷词" ${disabled}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div><button class="draft-quick-editor-close" type="button" data-action="close-quick-phrase-editor" ${disabled}>完成</button></section>`
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function draftQuickPhrases(draft) {
|
|
913
|
+
const disabled = draft.sending ? 'disabled' : ''
|
|
914
|
+
if (state.quickPhraseEditorOpen) return quickPhraseEditor(draft)
|
|
915
|
+
const phrases = state.quickPhrases.map(phrase => `<button class="draft-quick-phrase" type="button" data-action="insert-quick-phrase" data-quick-phrase="${escapeHtml(phrase)}" ${disabled}>${escapeHtml(phrase)}</button>`).join('')
|
|
916
|
+
return `<div class="draft-quick-phrases" aria-label="常用补充词">${phrases}<button class="draft-quick-phrase-add-button" type="button" data-action="open-quick-phrase-editor" aria-label="管理快捷词" title="管理快捷词" ${disabled}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div>`
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function insertQuickPhrase(phrase) {
|
|
920
|
+
const input = document.querySelector('[data-draft] textarea')
|
|
921
|
+
if (!(input instanceof HTMLTextAreaElement) || state.draft === null) return
|
|
922
|
+
const start = input.selectionStart
|
|
923
|
+
const end = input.selectionEnd
|
|
924
|
+
const prefix = input.value.slice(0, start)
|
|
925
|
+
const suffix = input.value.slice(end)
|
|
926
|
+
const separator = prefix !== '' && !prefix.endsWith('\n') ? '\n' : ''
|
|
927
|
+
const text = `${prefix}${separator}${phrase}${suffix}`
|
|
928
|
+
if (text.length > input.maxLength) return setError('追问内容不能超过 4000 个字符')
|
|
929
|
+
const caret = prefix.length + separator.length + phrase.length
|
|
930
|
+
input.value = text
|
|
931
|
+
state.draft.text = text
|
|
932
|
+
input.focus()
|
|
933
|
+
input.setSelectionRange(caret, caret)
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function addQuickPhrase(value) {
|
|
937
|
+
const phrase = value.trim().slice(0, MAX_QUICK_PHRASE_LENGTH)
|
|
938
|
+
if (phrase === '') return false
|
|
939
|
+
if (state.quickPhrases.includes(phrase)) return setError('这个快捷词已经存在')
|
|
940
|
+
if (state.quickPhrases.length >= MAX_QUICK_PHRASES) return setError(`最多保留 ${MAX_QUICK_PHRASES} 个快捷词`)
|
|
941
|
+
state.quickPhrases.push(phrase)
|
|
942
|
+
persistQuickPhrases()
|
|
943
|
+
return true
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function updateQuickPhrase(index, value) {
|
|
947
|
+
if (!Number.isInteger(index) || index < 0 || index >= state.quickPhrases.length) return
|
|
948
|
+
const phrase = value.trim().slice(0, MAX_QUICK_PHRASE_LENGTH)
|
|
949
|
+
if (phrase === '') {
|
|
950
|
+
state.quickPhrases.splice(index, 1)
|
|
951
|
+
} else if (state.quickPhrases.some((item, itemIndex) => itemIndex !== index && item === phrase)) {
|
|
952
|
+
return setError('这个快捷词已经存在')
|
|
953
|
+
} else {
|
|
954
|
+
state.quickPhrases[index] = phrase
|
|
955
|
+
}
|
|
956
|
+
persistQuickPhrases()
|
|
957
|
+
render()
|
|
958
|
+
}
|
|
959
|
+
|
|
782
960
|
function draftPlacement(cards) {
|
|
783
961
|
const draft = state.draft
|
|
784
962
|
if (draft === null || draft.kind === 'new') return null
|
|
@@ -800,21 +978,86 @@ function draftCard(cards) {
|
|
|
800
978
|
const continuing = draft.kind === 'continue'
|
|
801
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">
|
|
802
980
|
<div class="thread-card-head"><span class="topic-dot"></span><strong>${continuing ? '新的追问' : '新的分支'}</strong></div>
|
|
803
|
-
<form class="draft-branch-form" data-draft
|
|
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>
|
|
804
982
|
</article>`
|
|
805
983
|
}
|
|
806
984
|
|
|
985
|
+
function selectionFollowupButton() {
|
|
986
|
+
return `<button class="selection-followup" type="button" data-action="follow-selection" hidden aria-label="基于所选内容创建追问" title="基于所选内容追问"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M3 3.5h10v6.25H7.2L4 12.5V9.75H3Z"/><path d="M8 4.9v3.4M6.3 6.6h3.4"/></svg><span>追问</span></button>`
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Cards are mounted into the DOM only when they intersect the viewport
|
|
990
|
+
// (inflated by VIEWPORT_MARGIN) in world coordinates. The camera transform is
|
|
991
|
+
// translate(camera) scale(zoom), so screen = world * zoom + camera.
|
|
992
|
+
function visibleCardIds(cards) {
|
|
993
|
+
const viewport = document.querySelector('.canvas-viewport')
|
|
994
|
+
if (!(viewport instanceof HTMLElement)) return new Set(cards.map(card => card.id))
|
|
995
|
+
const bounds = viewport.getBoundingClientRect()
|
|
996
|
+
const left = (-state.canvasCamera.x - VIEWPORT_MARGIN) / state.zoom
|
|
997
|
+
const right = (bounds.width - state.canvasCamera.x + VIEWPORT_MARGIN) / state.zoom
|
|
998
|
+
const top = (-state.canvasCamera.y - VIEWPORT_MARGIN) / state.zoom
|
|
999
|
+
const bottom = (bounds.height - state.canvasCamera.y + VIEWPORT_MARGIN) / state.zoom
|
|
1000
|
+
const visible = new Set()
|
|
1001
|
+
for (const card of cards) {
|
|
1002
|
+
const { x, y } = card.position
|
|
1003
|
+
if (x + CARD_WIDTH < left || x > right || y + CARD_HEIGHT < top || y > bottom) continue
|
|
1004
|
+
visible.add(card.id)
|
|
1005
|
+
}
|
|
1006
|
+
return visible
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Incrementally mount cards entering the viewport and unmount cards leaving
|
|
1010
|
+
// it, without rebuilding the canvas. Called after pan/zoom/focus camera moves.
|
|
1011
|
+
function syncCanvasViewport() {
|
|
1012
|
+
if (state.mode !== 'canvas' || state.canvasCards === undefined) return
|
|
1013
|
+
const layer = document.querySelector('.cards-layer')
|
|
1014
|
+
if (!(layer instanceof HTMLElement)) return
|
|
1015
|
+
const visible = visibleCardIds(state.canvasCards)
|
|
1016
|
+
for (const cardId of [...state.mountedCardIds]) {
|
|
1017
|
+
if (visible.has(cardId)) continue
|
|
1018
|
+
const element = layer.querySelector(`[data-card-id="${selectorValue(cardId)}"]`)
|
|
1019
|
+
if (element instanceof HTMLElement) element.remove()
|
|
1020
|
+
state.mountedCardIds.delete(cardId)
|
|
1021
|
+
}
|
|
1022
|
+
for (const card of state.canvasCards) {
|
|
1023
|
+
if (!visible.has(card.id) || state.mountedCardIds.has(card.id)) continue
|
|
1024
|
+
const wrapper = document.createElement('div')
|
|
1025
|
+
wrapper.innerHTML = conversationCard(card, state.canvasGraph)
|
|
1026
|
+
const element = wrapper.firstElementChild
|
|
1027
|
+
if (element instanceof HTMLElement) {
|
|
1028
|
+
layer.appendChild(element)
|
|
1029
|
+
const handle = element.querySelector('[data-drag-card]')
|
|
1030
|
+
if (handle instanceof HTMLElement) bindDragHandle(handle)
|
|
1031
|
+
}
|
|
1032
|
+
state.mountedCardIds.add(card.id)
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
807
1036
|
function renderCanvas() {
|
|
808
1037
|
const threads = state.workspace?.threads ?? []
|
|
809
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>`
|
|
810
1039
|
const allCards = conversationCards(threads)
|
|
811
1040
|
const graph = conversationGraphView(allCards)
|
|
812
1041
|
const cards = graph.cards
|
|
1042
|
+
state.canvasCards = cards
|
|
1043
|
+
state.canvasCardsById = new Map(cards.map(card => [card.id, card]))
|
|
1044
|
+
state.canvasGraph = graph
|
|
1045
|
+
if (state.inspectorCardId !== null && !state.canvasCardsById.has(state.inspectorCardId)) {
|
|
1046
|
+
state.inspectorCardId = null
|
|
1047
|
+
state.inspectorOpening = false
|
|
1048
|
+
}
|
|
813
1049
|
if (!state.canvasViewInitialized) {
|
|
814
1050
|
state.canvasCamera = initialCanvasCamera(cards)
|
|
815
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
|
|
816
1055
|
}
|
|
817
|
-
|
|
1056
|
+
const visible = visibleCardIds(cards)
|
|
1057
|
+
state.mountedCardIds = new Set(visible)
|
|
1058
|
+
const mounted = cards.filter(card => visible.has(card.id))
|
|
1059
|
+
const inspector = state.inspectorCardId === null ? '' : renderCardInspector(state.canvasCardsById.get(state.inspectorCardId))
|
|
1060
|
+
return `<section class="canvas-view"><div class="canvas-viewport"><div class="canvas-content" style="transform:translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})"><svg class="connectors">${canvasConnectors(cards)}</svg><div class="cards-layer">${mounted.map(card => conversationCard(card, graph)).join('')}${draftCard(cards)}</div></div></div>${inspector}</section>`
|
|
818
1061
|
}
|
|
819
1062
|
|
|
820
1063
|
function isProcessMessage(message) {
|
|
@@ -840,7 +1083,7 @@ function threadMessage(thread, message) {
|
|
|
840
1083
|
const body = message.pending && message.text === '' ? '<p class="message-streaming"><span class="streaming-dot"></span>正在回复</p>'
|
|
841
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)}`
|
|
842
1085
|
const avatar = isUser ? '' : '<span class="message-avatar" aria-hidden="true"></span>'
|
|
843
|
-
return `<article class="message message-${message.kind}${message.pending ? ' message-pending' : ''}${collapsible ? ' message-collapsible' : ''}${expanded ? ' expanded' : ''}"><header>${avatar}<span class="message-role">${label}</span><time>${formatTime(message.at)}</time>${branch}${fold}</header><div class="message-body">${body}</div></article>`
|
|
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>`
|
|
844
1087
|
}
|
|
845
1088
|
|
|
846
1089
|
function processRecords(process, messageId) {
|
|
@@ -857,22 +1100,95 @@ function processRecords(process, messageId) {
|
|
|
857
1100
|
return `<section class="process-records${expanded ? ' expanded' : ''}"><button class="process-records-fold" data-action="toggle-message" data-message="${escapeHtml(key)}"><span>${expanded ? '收起过程记录' : '过程记录'}</span><span class="process-count">${process.length}</span></button>${expanded ? entries : ''}</section>`
|
|
858
1101
|
}
|
|
859
1102
|
|
|
1103
|
+
function messagesForCard(card) {
|
|
1104
|
+
const thread = state.workspace?.threads.find(item => item.id === card.dshThreadId)
|
|
1105
|
+
if (thread === undefined) return { thread: null, messages: [] }
|
|
1106
|
+
const messages = messagesFor(thread)
|
|
1107
|
+
let turnIndex = -1
|
|
1108
|
+
let start = -1
|
|
1109
|
+
for (let index = 0; index < messages.length; index++) {
|
|
1110
|
+
if (messages[index].kind !== 'user') continue
|
|
1111
|
+
turnIndex += 1
|
|
1112
|
+
if (turnIndex === card.turnIndex) {
|
|
1113
|
+
start = index
|
|
1114
|
+
break
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (start === -1) return { thread, messages: [] }
|
|
1118
|
+
const end = messages.findIndex((message, index) => index > start && message.kind === 'user')
|
|
1119
|
+
return { thread, messages: messages.slice(start, end === -1 ? undefined : end) }
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function inspectorProcessEntries(messages) {
|
|
1123
|
+
const entries = []
|
|
1124
|
+
for (const message of messages) {
|
|
1125
|
+
if (Array.isArray(message.process)) {
|
|
1126
|
+
entries.push(...message.process.map(entry => ({ ...entry })))
|
|
1127
|
+
continue
|
|
1128
|
+
}
|
|
1129
|
+
if (message.kind === 'tool') {
|
|
1130
|
+
entries.push({ name: processSummary(message.text), arguments: message.text, result: null, error: null })
|
|
1131
|
+
continue
|
|
1132
|
+
}
|
|
1133
|
+
if (message.kind === 'tool-result') {
|
|
1134
|
+
const previous = entries.at(-1)
|
|
1135
|
+
if (previous !== undefined && previous.result === null && previous.error === null) previous.result = message.text
|
|
1136
|
+
else entries.push({ name: '工具结果', arguments: null, result: message.text, error: null })
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return entries
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function renderCardInspector(card) {
|
|
1143
|
+
if (card === undefined) return ''
|
|
1144
|
+
const { thread, messages } = messagesForCard(card)
|
|
1145
|
+
if (thread === null) return ''
|
|
1146
|
+
const process = inspectorProcessEntries(messages)
|
|
1147
|
+
const answer = card.answer === null
|
|
1148
|
+
? card.error === null ? '<p class="card-inspector-pending">等待助手回复</p>' : ''
|
|
1149
|
+
: `<article class="card-inspector-answer">${renderMarkdown(card.answer.text)}${card.answer.pending ? '<p class="card-inspector-pending">正在回复</p>' : ''}</article>`
|
|
1150
|
+
const error = card.error === null ? '' : `<section class="card-inspector-error" role="alert"><strong>本轮未完成</strong><p>${escapeHtml(card.error.text)}</p></section>`
|
|
1151
|
+
const processRecordsHtml = process.length === 0 ? '' : processRecords(process, `${thread.id}:${card.id}:inspector`)
|
|
1152
|
+
const continueAction = card.canContinue === true ? `<button type="button" data-action="open-continue" data-thread="${thread.id}" data-card="${escapeHtml(card.id)}"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M2.5 3.5h11v7h-6l-3.5 2.5v-2.5h-1.5Z"/><path d="M8 5.5v3M6.5 7h3"/></svg>继续追问</button>` : ''
|
|
1153
|
+
const branch = Number.isInteger(card.answer?.sourceSeq)
|
|
1154
|
+
? `<button type="button" data-action="open-branch" data-thread="${thread.id}" data-card="${escapeHtml(card.id)}" data-seq="${card.answer.sourceSeq}"><svg aria-hidden="true" viewBox="0 0 16 16"><circle cx="4" cy="3.5" r="1.5"/><circle cx="12" cy="3.5" r="1.5"/><circle cx="12" cy="12.5" r="1.5"/><path d="M5.5 3.5h2A2.5 2.5 0 0 1 10 6v5"/></svg>创建分支</button>`
|
|
1155
|
+
: ''
|
|
1156
|
+
const openDshAction = `<button class="primary" type="button" data-action="open-dsh" data-thread="${thread.id}" data-seq="${Number.isInteger(card.answer?.sourceSeq) ? card.answer.sourceSeq : ''}"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M7 3.5H4.5A1.5 1.5 0 0 0 3 5v6.5A1.5 1.5 0 0 0 4.5 13H11a1.5 1.5 0 0 0 1.5-1.5V9"/><path d="M9.5 3.5h3v3M12.4 3.6 7.5 8.5"/></svg>在 DSH 中打开</button>`
|
|
1157
|
+
return `<aside class="card-inspector${state.inspectorOpening ? ' is-opening' : ''}" aria-label="卡片详情" data-inspector-card="${escapeHtml(card.id)}"><header class="card-inspector-head"><div><div class="card-inspector-meta"><span>第 ${card.turnIndex + 1} 轮</span>${card.error === null ? '' : '<span class="card-inspector-error-status">失败</span>'}${process.length > 0 ? `<span>工具 ${process.length}</span>` : ''}</div><h2>${escapeHtml(card.question)}</h2></div><button class="card-inspector-close" type="button" data-action="close-card-inspector" aria-label="关闭卡片详情" title="关闭"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button></header><div class="card-inspector-scroll">${error}${answer}${processRecordsHtml}</div><footer class="card-inspector-actions">${continueAction}${branch}${openDshAction}</footer></aside>`
|
|
1158
|
+
}
|
|
1159
|
+
|
|
860
1160
|
function renderThread() {
|
|
861
1161
|
const thread = currentThread()
|
|
862
1162
|
if (thread === null) return renderCanvas()
|
|
863
1163
|
const messages = messagesFor(thread)
|
|
864
1164
|
const waiting = state.pendingReplies.has(thread.dshSessionId)
|
|
865
|
-
|
|
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>`
|
|
866
1167
|
}
|
|
867
1168
|
|
|
868
1169
|
function render() {
|
|
869
|
-
|
|
870
|
-
|
|
1170
|
+
// Remember the departing thread's scroll position per thread id, so
|
|
1171
|
+
// switching sessions restores each conversation's own place instead of
|
|
1172
|
+
// smearing one session's position onto another.
|
|
1173
|
+
if (state.mode === 'thread' && state.detailThreadId !== null) {
|
|
1174
|
+
const detail = document.querySelector('.detail-scroll')
|
|
1175
|
+
if (detail instanceof HTMLElement) state.detailScrollByThread.set(state.detailThreadId, detail.scrollTop)
|
|
1176
|
+
}
|
|
1177
|
+
if (state.mode === 'canvas' && state.inspectorCardId !== null) {
|
|
1178
|
+
const inspector = document.querySelector('.card-inspector-scroll')
|
|
1179
|
+
if (inspector instanceof HTMLElement) state.inspectorScrollByCard.set(state.inspectorCardId, inspector.scrollTop)
|
|
1180
|
+
}
|
|
1181
|
+
state.detailThreadId = state.mode === 'thread' ? state.activeId : null
|
|
1182
|
+
const detailScrollTop = state.detailThreadId === null ? null : state.detailScrollByThread.get(state.detailThreadId) ?? null
|
|
1183
|
+
const inspectorScrollTop = state.mode === 'canvas' && state.inspectorCardId !== null ? state.inspectorScrollByCard.get(state.inspectorCardId) ?? null : null
|
|
871
1184
|
const cardScrollTops = new Map()
|
|
872
1185
|
if (state.mode === 'canvas') {
|
|
873
1186
|
// Key by the unique card id: every card of a session shares data-thread,
|
|
874
|
-
// so keying on it would clobber sibling cards' scroll positions.
|
|
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.
|
|
875
1190
|
for (const answer of document.querySelectorAll('.thread-card[data-thread] .thread-answer')) {
|
|
1191
|
+
if (answer.scrollHeight <= answer.clientHeight) continue
|
|
876
1192
|
const card = answer.closest('.thread-card')
|
|
877
1193
|
if (card instanceof HTMLElement && typeof card.dataset.cardId === 'string') cardScrollTops.set(card.dataset.cardId, answer.scrollTop)
|
|
878
1194
|
}
|
|
@@ -882,11 +1198,18 @@ function render() {
|
|
|
882
1198
|
const view = state.mode === 'thread' ? renderThread() : renderCanvas()
|
|
883
1199
|
const choices = workspaceChoices()
|
|
884
1200
|
const selectedWorkspaceId = state.selectedDshWorkspaceId ?? workspace?.id
|
|
885
|
-
const canvasControls = state.mode === 'canvas' && (threads.length > 0 || state.draft?.kind === 'new') ? `<div class="canvas-controls"><button data-action="layout"
|
|
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>` : ''
|
|
886
1202
|
const detailAvailable = currentThread() !== null
|
|
887
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>`
|
|
888
|
-
app.innerHTML = `<main class="synapse-shell ${state.sidebarCollapsed ? 'sidebar-collapsed' : ''}"><aside class="sidebar"><div class="sidebar-brand-row"><div class="brand" aria-label="Synapse"><svg class="brand-mark" aria-hidden="true" viewBox="0 0 32 32" fill="none"><path d="M9 10.5 16 7l7 3.5M9 10.5v8L16 22m0-15v15m7-11.5v8L16 22"/><circle cx="9" cy="10" r="2.5"/><circle cx="23" cy="10" r="2.5"/><circle cx="16" cy="23" r="2.5"/></svg><strong>Synapse</strong></div><button class="sidebar-toggle" type="button" data-action="toggle-sidebar" aria-label="${state.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}" title="${state.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}"><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25"/><path d="M6 2v12"/></svg></button></div><button class="new-workspace" type="button" data-action="create-session" ${state.draft !== null ? 'disabled' : ''}><svg class="new-session-icon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 4.75v6.5M4.75 8h6.5"/></svg><span>新会话</span></button><label class="workspace-label"><span>工作区</span><span class="workspace-select"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M2.5 4.75h3l1.2 1.5h6.8v5.5a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z"/></svg><select data-action="select-workspace" aria-label="选择工作区" ${state.draft !== null ? 'disabled' : ''}>${choices.map(item => `<option value="${item.id}" title="${escapeHtml(item.path ?? item.title)}" ${item.id === selectedWorkspaceId ? 'selected' : ''}>${escapeHtml(item.title)}</option>`).join('')}</select></span></label><div class="sidebar-heading"><span>会话</span></div><nav class="thread-tree">${threads.map(thread => `<button class="tree-row ${thread.id === state.activeId ? 'active' : ''}" data-action="select-thread" data-thread="${thread.id}" style="--thread-color:#374151"><span class="tree-dot"></span><span>${escapeHtml(threadListTitle(thread))}</span>${thread.parentId === null ? '' : '<i>分支</i>'}</button>`).join('') || '<p class="tree-empty">暂未同步会话</p>'}</nav></aside><header class="topbar"><div class="view-switch" role="group" aria-label="视图切换"><button data-action="close" type="button" aria-pressed="false">对话</button><button class="active" type="button" aria-pressed="true">会话地图</button></div>${canvasControls}</header><section class="main-stage">${state.error ? `<div class="status-message" role="alert"><span>${escapeHtml(state.error)}</span><button data-action="dismiss-error" aria-label="关闭" title="关闭">×</button></div>` : ''}${canvasTabs}${view}</section></main>`
|
|
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>`
|
|
889
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
|
+
}
|
|
890
1213
|
for (const [cardId, scrollTop] of cardScrollTops) {
|
|
891
1214
|
const answer = app.querySelector(`.thread-card[data-card-id="${CSS.escape(cardId)}"] .thread-answer`)
|
|
892
1215
|
if (answer instanceof HTMLElement) answer.scrollTop = scrollTop
|
|
@@ -895,19 +1218,71 @@ function render() {
|
|
|
895
1218
|
const nextDetail = document.querySelector('.detail-scroll')
|
|
896
1219
|
if (nextDetail instanceof HTMLElement) nextDetail.scrollTop = detailScrollTop
|
|
897
1220
|
})
|
|
1221
|
+
if (inspectorScrollTop !== null) window.requestAnimationFrame(() => {
|
|
1222
|
+
const inspector = document.querySelector('.card-inspector-scroll')
|
|
1223
|
+
if (inspector instanceof HTMLElement) inspector.scrollTop = inspectorScrollTop
|
|
1224
|
+
})
|
|
1225
|
+
if (state.inspectorOpening) window.requestAnimationFrame(() => {
|
|
1226
|
+
document.querySelector('.card-inspector')?.classList.remove('is-opening')
|
|
1227
|
+
state.inspectorOpening = false
|
|
1228
|
+
})
|
|
1229
|
+
// Jump the detail view to the card the user clicked: card ids carry the
|
|
1230
|
+
// source sequence (`<thread>:turn:<seq>`), which matches data-message-seq
|
|
1231
|
+
// anchors on the rendered messages.
|
|
1232
|
+
const targetCardId = state.detailTargetCardId
|
|
1233
|
+
state.detailTargetCardId = null
|
|
1234
|
+
if (targetCardId !== null) {
|
|
1235
|
+
const match = /:turn:(\d+)$/.exec(targetCardId)
|
|
1236
|
+
const seq = match === null ? null : match[1]
|
|
1237
|
+
if (seq !== null) window.requestAnimationFrame(() => {
|
|
1238
|
+
const target = app.querySelector(`[data-message-seq="${CSS.escape(seq)}"]`)
|
|
1239
|
+
if (target instanceof HTMLElement) target.scrollIntoView({ block: 'start' })
|
|
1240
|
+
})
|
|
1241
|
+
}
|
|
898
1242
|
}
|
|
899
1243
|
|
|
900
1244
|
function renderPreservingDetailScroll() {
|
|
901
1245
|
render()
|
|
902
1246
|
}
|
|
903
1247
|
|
|
1248
|
+
let inspectorCloseTimer = 0
|
|
1249
|
+
function openCardInspector(cardId) {
|
|
1250
|
+
if (inspectorCloseTimer !== 0) {
|
|
1251
|
+
window.clearTimeout(inspectorCloseTimer)
|
|
1252
|
+
inspectorCloseTimer = 0
|
|
1253
|
+
}
|
|
1254
|
+
state.inspectorOpening = state.inspectorCardId === null
|
|
1255
|
+
state.inspectorCardId = cardId
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function closeCardInspector({ animate = true } = {}) {
|
|
1259
|
+
if (state.inspectorCardId === null) return
|
|
1260
|
+
if (inspectorCloseTimer !== 0) window.clearTimeout(inspectorCloseTimer)
|
|
1261
|
+
const cardId = state.inspectorCardId
|
|
1262
|
+
const inspector = document.querySelector('.card-inspector')
|
|
1263
|
+
if (!animate || !(inspector instanceof HTMLElement)) {
|
|
1264
|
+
state.inspectorCardId = null
|
|
1265
|
+
state.inspectorOpening = false
|
|
1266
|
+
render()
|
|
1267
|
+
return
|
|
1268
|
+
}
|
|
1269
|
+
inspector.classList.add('is-closing')
|
|
1270
|
+
inspectorCloseTimer = window.setTimeout(() => {
|
|
1271
|
+
inspectorCloseTimer = 0
|
|
1272
|
+
if (state.inspectorCardId !== cardId) return
|
|
1273
|
+
state.inspectorCardId = null
|
|
1274
|
+
state.inspectorOpening = false
|
|
1275
|
+
render()
|
|
1276
|
+
}, 180)
|
|
1277
|
+
}
|
|
1278
|
+
|
|
904
1279
|
function applyCanvasTransform() {
|
|
905
1280
|
const content = document.querySelector('.canvas-content')
|
|
906
1281
|
if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
|
|
907
1282
|
}
|
|
908
1283
|
|
|
909
|
-
function
|
|
910
|
-
|
|
1284
|
+
function bindDragHandle(handle) {
|
|
1285
|
+
handle.addEventListener('pointerdown', event => {
|
|
911
1286
|
const cardId = event.currentTarget.dataset.dragCard
|
|
912
1287
|
const card = event.currentTarget.closest('.thread-card')
|
|
913
1288
|
if (cardId === undefined || !(card instanceof HTMLElement)) return
|
|
@@ -916,25 +1291,39 @@ function installDragging() {
|
|
|
916
1291
|
const aliases = card.dataset.positionKey === undefined ? [] : [card.dataset.positionKey]
|
|
917
1292
|
let position = origin.position
|
|
918
1293
|
let stopped = false
|
|
1294
|
+
let frame = 0
|
|
919
1295
|
state.dragging = true
|
|
920
|
-
|
|
921
|
-
|
|
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
|
|
922
1300
|
state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
|
|
923
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 }
|
|
924
1306
|
card.style.left = `${position.x}px`
|
|
925
1307
|
card.style.top = `${position.y}px`
|
|
926
1308
|
refreshCardConnectors(cardId)
|
|
927
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
|
+
}
|
|
928
1314
|
const stop = () => {
|
|
929
1315
|
if (stopped) return
|
|
930
1316
|
stopped = true
|
|
931
1317
|
document.removeEventListener('pointermove', move)
|
|
932
1318
|
document.removeEventListener('pointerup', stop)
|
|
933
1319
|
document.removeEventListener('pointercancel', stop)
|
|
1320
|
+
if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
|
|
1321
|
+
apply()
|
|
934
1322
|
rememberCardPosition(cardId, position, aliases)
|
|
935
1323
|
state.dragging = false
|
|
936
1324
|
deferCanvasRefresh(120)
|
|
937
|
-
render
|
|
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.
|
|
938
1327
|
}
|
|
939
1328
|
document.addEventListener('pointermove', move)
|
|
940
1329
|
document.addEventListener('pointerup', stop)
|
|
@@ -942,6 +1331,10 @@ function installDragging() {
|
|
|
942
1331
|
})
|
|
943
1332
|
}
|
|
944
1333
|
|
|
1334
|
+
function installDragging() {
|
|
1335
|
+
for (const handle of document.querySelectorAll('[data-drag-card]')) bindDragHandle(handle)
|
|
1336
|
+
}
|
|
1337
|
+
|
|
945
1338
|
function canvasViewport(target) {
|
|
946
1339
|
return target instanceof Element ? target.closest('.canvas-viewport') : null
|
|
947
1340
|
}
|
|
@@ -956,7 +1349,19 @@ function zoomCanvas(viewport, nextZoom, clientX, clientY) {
|
|
|
956
1349
|
const worldY = (localY - state.canvasCamera.y) / state.zoom
|
|
957
1350
|
state.zoom = zoom
|
|
958
1351
|
state.canvasCamera = { x: localX - worldX * zoom, y: localY - worldY * zoom }
|
|
959
|
-
|
|
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
|
+
}
|
|
960
1365
|
const label = document.querySelector('.canvas-controls span')
|
|
961
1366
|
if (label !== null) label.textContent = `${Math.round(state.zoom * 100)}%`
|
|
962
1367
|
}
|
|
@@ -969,18 +1374,75 @@ function zoomCanvasAtCenter(delta) {
|
|
|
969
1374
|
}
|
|
970
1375
|
|
|
971
1376
|
function focusActiveCard() {
|
|
972
|
-
const card = document.querySelector('.thread-card.active') ?? document.querySelector('.thread-card[data-thread]:not(.draft-card)')
|
|
973
1377
|
const viewport = document.querySelector('.canvas-viewport')
|
|
974
|
-
if (!(
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
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
|
|
978
1389
|
const bounds = viewport.getBoundingClientRect()
|
|
979
1390
|
state.canvasCamera = {
|
|
980
1391
|
x: bounds.width / 2 - (left + CARD_WIDTH / 2) * state.zoom,
|
|
981
1392
|
y: bounds.height / 2 - (top + CARD_HEIGHT / 2) * state.zoom,
|
|
982
1393
|
}
|
|
983
1394
|
applyCanvasTransform()
|
|
1395
|
+
syncCanvasViewport()
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
let selectionFollowup = null
|
|
1399
|
+
let selectionFollowupFrame = 0
|
|
1400
|
+
|
|
1401
|
+
function hideSelectionFollowup() {
|
|
1402
|
+
if (selectionFollowupFrame !== 0) {
|
|
1403
|
+
window.cancelAnimationFrame(selectionFollowupFrame)
|
|
1404
|
+
selectionFollowupFrame = 0
|
|
1405
|
+
}
|
|
1406
|
+
selectionFollowup = null
|
|
1407
|
+
const button = app.querySelector('.selection-followup')
|
|
1408
|
+
if (button instanceof HTMLButtonElement) button.hidden = true
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function selectionFollowupTarget(range) {
|
|
1412
|
+
const start = range.startContainer instanceof Element ? range.startContainer : range.startContainer.parentElement
|
|
1413
|
+
const end = range.endContainer instanceof Element ? range.endContainer : range.endContainer.parentElement
|
|
1414
|
+
if (!(start instanceof Element) || !(end instanceof Element)) return null
|
|
1415
|
+
const answer = start.closest('.thread-answer')
|
|
1416
|
+
if (answer instanceof HTMLElement && answer.contains(end)) {
|
|
1417
|
+
const card = answer.closest('.thread-card[data-thread]:not(.draft-card)')
|
|
1418
|
+
if (card instanceof HTMLElement && card.dataset.thread !== undefined) return { threadId: card.dataset.thread }
|
|
1419
|
+
}
|
|
1420
|
+
const messageBody = start.closest('.message-assistant .message-body')
|
|
1421
|
+
const thread = currentThread()
|
|
1422
|
+
if (messageBody instanceof HTMLElement && messageBody.contains(end) && thread !== null) return { threadId: thread.id }
|
|
1423
|
+
return null
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function updateSelectionFollowup() {
|
|
1427
|
+
selectionFollowupFrame = 0
|
|
1428
|
+
const button = app.querySelector('.selection-followup')
|
|
1429
|
+
const selection = window.getSelection()
|
|
1430
|
+
if (!(button instanceof HTMLButtonElement) || state.draft !== null || selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return hideSelectionFollowup()
|
|
1431
|
+
const text = selection.toString().trim()
|
|
1432
|
+
const range = selection.getRangeAt(0)
|
|
1433
|
+
const target = text === '' || text.length > 4000 ? null : selectionFollowupTarget(range)
|
|
1434
|
+
const rect = range.getBoundingClientRect()
|
|
1435
|
+
if (target === null || rect.width === 0 || rect.height === 0) return hideSelectionFollowup()
|
|
1436
|
+
selectionFollowup = { ...target, text }
|
|
1437
|
+
button.dataset.thread = target.threadId
|
|
1438
|
+
button.style.left = `${Math.min(window.innerWidth - 12, Math.max(76, rect.right))}px`
|
|
1439
|
+
button.style.top = `${Math.min(window.innerHeight - 38, Math.max(8, rect.bottom + 8))}px`
|
|
1440
|
+
button.hidden = false
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function queueSelectionFollowup() {
|
|
1444
|
+
if (selectionFollowupFrame !== 0) return
|
|
1445
|
+
selectionFollowupFrame = window.requestAnimationFrame(updateSelectionFollowup)
|
|
984
1446
|
}
|
|
985
1447
|
|
|
986
1448
|
app.addEventListener('pointerdown', event => {
|
|
@@ -988,21 +1450,33 @@ app.addEventListener('pointerdown', event => {
|
|
|
988
1450
|
if (!(viewport instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.thread-card, button, textarea, select')) return
|
|
989
1451
|
event.preventDefault()
|
|
990
1452
|
const origin = { x: event.clientX, y: event.clientY, camera: { ...state.canvasCamera } }
|
|
1453
|
+
let pendingCamera = null
|
|
1454
|
+
let frame = 0
|
|
991
1455
|
state.canvasGesture = true
|
|
992
1456
|
viewport.classList.add('is-panning')
|
|
993
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
|
+
}
|
|
994
1466
|
const move = moveEvent => {
|
|
995
|
-
|
|
1467
|
+
pendingCamera = {
|
|
996
1468
|
x: origin.camera.x + moveEvent.clientX - origin.x,
|
|
997
1469
|
y: origin.camera.y + moveEvent.clientY - origin.y,
|
|
998
1470
|
}
|
|
999
|
-
|
|
1471
|
+
if (frame === 0) frame = window.requestAnimationFrame(apply)
|
|
1000
1472
|
}
|
|
1001
1473
|
const stop = () => {
|
|
1002
1474
|
viewport.classList.remove('is-panning')
|
|
1003
1475
|
document.removeEventListener('pointermove', move)
|
|
1004
1476
|
document.removeEventListener('pointerup', stop)
|
|
1005
1477
|
document.removeEventListener('pointercancel', stop)
|
|
1478
|
+
if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
|
|
1479
|
+
apply()
|
|
1006
1480
|
state.canvasGesture = false
|
|
1007
1481
|
deferCanvasRefresh(120)
|
|
1008
1482
|
}
|
|
@@ -1038,6 +1512,19 @@ app.addEventListener('wheel', event => {
|
|
|
1038
1512
|
// wipe the user's selection.
|
|
1039
1513
|
let pointerDownPosition = null
|
|
1040
1514
|
app.addEventListener('pointerdown', event => { pointerDownPosition = { x: event.clientX, y: event.clientY } })
|
|
1515
|
+
app.addEventListener('pointerdown', event => {
|
|
1516
|
+
const button = event.target instanceof Element ? event.target.closest('.selection-followup') : null
|
|
1517
|
+
if (button instanceof HTMLButtonElement) event.preventDefault()
|
|
1518
|
+
else hideSelectionFollowup()
|
|
1519
|
+
})
|
|
1520
|
+
app.addEventListener('pointerup', queueSelectionFollowup)
|
|
1521
|
+
app.addEventListener('scroll', hideSelectionFollowup, true)
|
|
1522
|
+
document.addEventListener('selectionchange', queueSelectionFollowup)
|
|
1523
|
+
document.addEventListener('keydown', event => {
|
|
1524
|
+
if (event.key !== 'Escape' || state.mode !== 'canvas' || state.inspectorCardId === null) return
|
|
1525
|
+
event.preventDefault()
|
|
1526
|
+
closeCardInspector({ animate: false })
|
|
1527
|
+
})
|
|
1041
1528
|
|
|
1042
1529
|
app.addEventListener('click', async event => {
|
|
1043
1530
|
const button = event.target.closest('[data-action]')
|
|
@@ -1051,23 +1538,60 @@ app.addEventListener('click', async event => {
|
|
|
1051
1538
|
&& Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y) > 4) return
|
|
1052
1539
|
const thread = state.workspace?.threads.find(item => item.id === card.dataset.thread)
|
|
1053
1540
|
if (thread === undefined) return
|
|
1541
|
+
const cardId = card.dataset.cardId
|
|
1542
|
+
if (cardId === undefined) return
|
|
1054
1543
|
state.activeId = thread.id
|
|
1544
|
+
state.selectedCardId = cardId
|
|
1545
|
+
openCardInspector(cardId)
|
|
1055
1546
|
state.error = ''
|
|
1056
1547
|
render()
|
|
1057
1548
|
void loadThreadHistory(thread)
|
|
1058
1549
|
// Bidirectional current-session sync: switch DSH's current session
|
|
1059
1550
|
// without closing the map; the client confirms via synapse:current-session.
|
|
1060
|
-
if (thread.dshSessionId !== null)
|
|
1551
|
+
if (thread.dshSessionId !== null) {
|
|
1552
|
+
if (thread.dshSessionId !== state.currentDsh?.id) state.mapCardSessionSwitches.add(thread.dshSessionId)
|
|
1553
|
+
post('synapse:activate-session', { sessionId: thread.dshSessionId })
|
|
1554
|
+
}
|
|
1061
1555
|
return
|
|
1062
1556
|
}
|
|
1063
1557
|
const thread = state.workspace?.threads.find(item => item.id === button.dataset.thread)
|
|
1064
1558
|
try {
|
|
1559
|
+
if (button.dataset.action === 'follow-selection') {
|
|
1560
|
+
const followup = selectionFollowup
|
|
1561
|
+
hideSelectionFollowup()
|
|
1562
|
+
if (followup !== null && thread !== undefined && thread.id === followup.threadId && state.draft === null) openContinue(thread, undefined, followup.text)
|
|
1563
|
+
return
|
|
1564
|
+
}
|
|
1565
|
+
if (button.dataset.action === 'insert-quick-phrase' && button.dataset.quickPhrase !== undefined) insertQuickPhrase(button.dataset.quickPhrase)
|
|
1566
|
+
if (button.dataset.action === 'open-quick-phrase-editor') { state.quickPhraseEditorOpen = true; render() }
|
|
1567
|
+
if (button.dataset.action === 'close-quick-phrase-editor') { state.quickPhraseEditorOpen = false; render() }
|
|
1568
|
+
if (button.dataset.action === 'add-quick-phrase') {
|
|
1569
|
+
const editor = button.closest('.draft-quick-phrase-add')
|
|
1570
|
+
const input = editor?.querySelector('input')
|
|
1571
|
+
if (input instanceof HTMLInputElement && addQuickPhrase(input.value)) {
|
|
1572
|
+
render()
|
|
1573
|
+
window.setTimeout(() => document.querySelector('.draft-quick-phrase-add input')?.focus(), 0)
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
if (button.dataset.action === 'remove-quick-phrase') {
|
|
1577
|
+
const index = Number(button.dataset.quickPhraseIndex)
|
|
1578
|
+
if (Number.isInteger(index) && index >= 0 && index < state.quickPhrases.length) {
|
|
1579
|
+
state.quickPhrases.splice(index, 1)
|
|
1580
|
+
persistQuickPhrases()
|
|
1581
|
+
render()
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1065
1584
|
if (button.dataset.action === 'close') post('synapse:close')
|
|
1585
|
+
if (button.dataset.action === 'close-card-inspector') { closeCardInspector(); return }
|
|
1066
1586
|
if (button.dataset.action === 'toggle-sidebar') { state.sidebarCollapsed = !state.sidebarCollapsed; render() }
|
|
1067
1587
|
if (button.dataset.action === 'create-session') openNewSession()
|
|
1068
1588
|
if (button.dataset.action === 'open-current' && state.currentDsh !== null) post('synapse:open-session', { sessionId: state.currentDsh.id })
|
|
1069
1589
|
if (button.dataset.action === 'select-thread' && thread !== undefined) {
|
|
1590
|
+
state.mapCardSessionSwitches.clear()
|
|
1070
1591
|
state.activeId = thread.id
|
|
1592
|
+
state.selectedCardId = null
|
|
1593
|
+
state.inspectorCardId = null
|
|
1594
|
+
state.inspectorOpening = false
|
|
1071
1595
|
state.error = ''
|
|
1072
1596
|
if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
|
|
1073
1597
|
render()
|
|
@@ -1076,7 +1600,7 @@ app.addEventListener('click', async event => {
|
|
|
1076
1600
|
// without closing the map; the client confirms via synapse:current-session.
|
|
1077
1601
|
if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
|
|
1078
1602
|
}
|
|
1079
|
-
if (button.dataset.action === 'show-thread' && thread !== undefined) { state.activeId = thread.id; state.mode = 'thread'; render(); void loadThreadHistory(thread) }
|
|
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) }
|
|
1080
1604
|
if (button.dataset.action === 'show-canvas') { state.mode = 'canvas'; render() }
|
|
1081
1605
|
if (button.dataset.action === 'toggle-card-children' && button.dataset.card !== undefined) {
|
|
1082
1606
|
const cardId = button.dataset.card
|
|
@@ -1102,9 +1626,9 @@ app.addEventListener('click', async event => {
|
|
|
1102
1626
|
const fallbackSeq = latestMessage(thread, 'assistant')?.sourceSeq
|
|
1103
1627
|
openBranch(thread, Number.isInteger(requestedSeq) ? requestedSeq : fallbackSeq, button.dataset.card)
|
|
1104
1628
|
}
|
|
1105
|
-
if (button.dataset.action === 'cancel-draft') { state.draft = null; render() }
|
|
1629
|
+
if (button.dataset.action === 'cancel-draft') { state.draft = null; state.quickPhraseEditorOpen = false; render() }
|
|
1106
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() }
|
|
1107
|
-
if (button.dataset.action === 'open-dsh' && thread?.dshSessionId !== null) post('synapse:open-session', { sessionId: thread.dshSessionId })
|
|
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 })
|
|
1108
1632
|
if (button.dataset.action === 'archive-thread' && thread !== undefined) await archiveThread(thread)
|
|
1109
1633
|
if (button.dataset.action === 'zoom-in') zoomCanvasAtCenter(.1)
|
|
1110
1634
|
if (button.dataset.action === 'zoom-out') zoomCanvasAtCenter(-.1)
|
|
@@ -1119,9 +1643,16 @@ app.addEventListener('click', async event => {
|
|
|
1119
1643
|
})
|
|
1120
1644
|
|
|
1121
1645
|
app.addEventListener('change', event => {
|
|
1646
|
+
const quickPhrase = event.target instanceof Element ? event.target.closest('[data-quick-phrase-index]') : null
|
|
1647
|
+
if (quickPhrase instanceof HTMLInputElement) {
|
|
1648
|
+
updateQuickPhrase(Number(quickPhrase.dataset.quickPhraseIndex), quickPhrase.value)
|
|
1649
|
+
return
|
|
1650
|
+
}
|
|
1122
1651
|
const select = event.target.closest('[data-action="select-workspace"]')
|
|
1123
1652
|
if (!(select instanceof HTMLSelectElement)) return
|
|
1124
1653
|
const choice = workspaceChoices().find(item => item.id === select.value)
|
|
1654
|
+
state.inspectorCardId = null
|
|
1655
|
+
state.inspectorOpening = false
|
|
1125
1656
|
if (choice?.source === 'dsh') {
|
|
1126
1657
|
// Map → native sync: switching workspaces moves DSH's current session to
|
|
1127
1658
|
// the workspace's most recently updated session, keeping both sides in step.
|
|
@@ -1154,7 +1685,10 @@ window.addEventListener('message', event => {
|
|
|
1154
1685
|
if (event.origin !== window.location.origin || event.data?.source !== 'dsh-synapse') return
|
|
1155
1686
|
const data = event.data
|
|
1156
1687
|
if (data.type === 'synapse:map-opened') {
|
|
1157
|
-
|
|
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.
|
|
1158
1692
|
state.mode = 'canvas'
|
|
1159
1693
|
render()
|
|
1160
1694
|
window.requestAnimationFrame(() => post('synapse:map-ready'))
|
|
@@ -1172,12 +1706,29 @@ window.addEventListener('message', event => {
|
|
|
1172
1706
|
if (data.type === 'synapse:current-session') {
|
|
1173
1707
|
const previousId = state.currentDsh?.id
|
|
1174
1708
|
state.currentDsh = data.session
|
|
1709
|
+
const preserveCanvasCamera = previousId !== data.session?.id && state.mapCardSessionSwitches.delete(data.session?.id)
|
|
1175
1710
|
const thread = currentDshThread()
|
|
1176
1711
|
if (thread !== undefined) {
|
|
1712
|
+
const preserveSelectedCard = state.activeId === thread.id
|
|
1177
1713
|
state.activeId = thread.id
|
|
1714
|
+
if (!preserveSelectedCard) {
|
|
1715
|
+
state.selectedCardId = null
|
|
1716
|
+
state.inspectorCardId = null
|
|
1717
|
+
state.inspectorOpening = false
|
|
1718
|
+
}
|
|
1178
1719
|
if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
|
|
1179
1720
|
}
|
|
1180
|
-
if (previousId !== data.session?.id)
|
|
1721
|
+
if (previousId !== data.session?.id) {
|
|
1722
|
+
// A real session switch: re-center on the new session's latest turn,
|
|
1723
|
+
// whether it lives in the same workspace (openCurrentWorkspace returns
|
|
1724
|
+
// false) or a different one (it resets the camera itself).
|
|
1725
|
+
void openCurrentWorkspace({ preserveCanvasCamera }).then(opened => {
|
|
1726
|
+
if (!opened && canReplaceView()) {
|
|
1727
|
+
render()
|
|
1728
|
+
if (!preserveCanvasCamera) focusActiveCard()
|
|
1729
|
+
}
|
|
1730
|
+
}).catch(setError)
|
|
1731
|
+
}
|
|
1181
1732
|
else if (canReplaceView()) render()
|
|
1182
1733
|
}
|
|
1183
1734
|
if (data.type === 'synapse:live-reply' && typeof data.sessionId === 'string') {
|
|
@@ -1221,6 +1772,9 @@ function scheduleLiveCardUpdate(sessionId) {
|
|
|
1221
1772
|
}
|
|
1222
1773
|
function applyLiveReplyToCard(sessionId) {
|
|
1223
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
|
|
1224
1778
|
const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId)
|
|
1225
1779
|
if (thread === undefined) return
|
|
1226
1780
|
const live = state.liveReplies.get(sessionId)
|