dsh-synapse 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.js +117 -5
- package/client.js +13 -4
- package/package.json +1 -1
package/app.js
CHANGED
|
@@ -268,6 +268,24 @@ function openNewSession() {
|
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
async function archiveThread(thread) {
|
|
271
|
+
// A DSH-backed thread is archived in DSH itself, so the sidebar and this map
|
|
272
|
+
// share one archive set (issue #21 / #19). Keeping a second local delete is
|
|
273
|
+
// what made the map claim "archived" while DSH still listed the session.
|
|
274
|
+
if (thread.dshSessionId !== null) {
|
|
275
|
+
if (!window.confirm(`在 DSH 中归档「${thread.title}」及其分支?DSH 侧会话也会被归档。`)) return
|
|
276
|
+
try {
|
|
277
|
+
await dshRpc('synapse:archive-session', { sessionId: thread.dshSessionId })
|
|
278
|
+
} catch (error) {
|
|
279
|
+
setError(error instanceof Error ? error.message : '归档失败')
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
state.archivedSessionIds.add(thread.dshSessionId)
|
|
283
|
+
state.activeId = state.activeId === thread.id ? (state.workspace?.threads.find(item => item.id !== thread.id)?.id ?? null) : state.activeId
|
|
284
|
+
if (state.inspectorCardId !== null && state.canvasCardsById?.get(state.inspectorCardId)?.dshThreadId === thread.id) state.inspectorCardId = null
|
|
285
|
+
render()
|
|
286
|
+
await refreshSummaries()
|
|
287
|
+
return
|
|
288
|
+
}
|
|
271
289
|
if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
|
|
272
290
|
await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
|
|
273
291
|
state.historyBySession.delete(thread.dshSessionId)
|
|
@@ -303,8 +321,6 @@ async function archiveThread(thread) {
|
|
|
303
321
|
} else {
|
|
304
322
|
state.activeId = null
|
|
305
323
|
}
|
|
306
|
-
// Archive in DSH as well, so both sides agree on one archive set (#21 / #19).
|
|
307
|
-
if (thread.dshSessionId !== null) post('synapse:archive-session', { sessionId: thread.dshSessionId })
|
|
308
324
|
await refreshSummaries()
|
|
309
325
|
}
|
|
310
326
|
|
|
@@ -1252,7 +1268,7 @@ function render() {
|
|
|
1252
1268
|
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>` : ''
|
|
1253
1269
|
const detailAvailable = currentThread() !== null
|
|
1254
1270
|
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>`
|
|
1255
|
-
app
|
|
1271
|
+
morphInto(app, `<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">${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>`)
|
|
1256
1272
|
installDragging()
|
|
1257
1273
|
cacheCardConnectors()
|
|
1258
1274
|
// The initial camera from renderCanvas is inset (viewport not laid out yet);
|
|
@@ -1400,7 +1416,100 @@ function applyCanvasTransform() {
|
|
|
1400
1416
|
if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
|
|
1401
1417
|
}
|
|
1402
1418
|
|
|
1419
|
+
// --- DOM morphing ---------------------------------------------------------
|
|
1420
|
+
// The canvas re-renders on every projection tick and on every streamed chunk.
|
|
1421
|
+
// Assigning `app.innerHTML` destroys the exact node the user is pressing, and a
|
|
1422
|
+
// click is only dispatched when mousedown and mouseup share a target, so a
|
|
1423
|
+
// rebuild in between silently swallows the click (W3C uievents #141). Morph the
|
|
1424
|
+
// new markup into the live tree instead: matching nodes are patched in place and
|
|
1425
|
+
// whatever sits under the pointer stays alive.
|
|
1426
|
+
|
|
1427
|
+
const MORPH_KEY_ATTRIBUTES = ['data-morph-key', 'data-card-id', 'data-thread', 'id']
|
|
1428
|
+
|
|
1429
|
+
function morphKeyOf(node) {
|
|
1430
|
+
if (!(node instanceof Element)) return null
|
|
1431
|
+
for (const attribute of MORPH_KEY_ATTRIBUTES) {
|
|
1432
|
+
const value = node.getAttribute(attribute)
|
|
1433
|
+
if (value !== null && value !== '') return `${node.tagName}|${attribute}|${value}`
|
|
1434
|
+
}
|
|
1435
|
+
return null
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** An element that owns the caret keeps its children untouched. */
|
|
1439
|
+
function morphHoldsCaret(node) {
|
|
1440
|
+
if (!(node instanceof Element)) return false
|
|
1441
|
+
if (node === document.activeElement) return true
|
|
1442
|
+
const selection = typeof window.getSelection === 'function' ? window.getSelection() : null
|
|
1443
|
+
if (selection === null || selection.rangeCount === 0) return false
|
|
1444
|
+
const anchor = selection.anchorNode
|
|
1445
|
+
return anchor !== null && node.contains(anchor)
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function morphNode(from, to) {
|
|
1449
|
+
if (from.nodeType !== to.nodeType) return replaceMorphed(from, to)
|
|
1450
|
+
if (from.nodeType === Node.TEXT_NODE || from.nodeType === Node.COMMENT_NODE) {
|
|
1451
|
+
if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue
|
|
1452
|
+
return from
|
|
1453
|
+
}
|
|
1454
|
+
if (!(from instanceof Element) || !(to instanceof Element) || from.tagName !== to.tagName) {
|
|
1455
|
+
return replaceMorphed(from, to)
|
|
1456
|
+
}
|
|
1457
|
+
if (morphHoldsCaret(from)) return from
|
|
1458
|
+
for (const attribute of [...from.attributes]) {
|
|
1459
|
+
if (!to.hasAttribute(attribute.name)) from.removeAttribute(attribute.name)
|
|
1460
|
+
}
|
|
1461
|
+
for (const attribute of [...to.attributes]) {
|
|
1462
|
+
if (from.getAttribute(attribute.name) !== attribute.value) from.setAttribute(attribute.name, attribute.value)
|
|
1463
|
+
}
|
|
1464
|
+
morphChildren(from, to)
|
|
1465
|
+
return from
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/** Swap one node for a copy of another and hand back the node now in the tree. */
|
|
1469
|
+
function replaceMorphed(from, to) {
|
|
1470
|
+
const next = to.cloneNode(true)
|
|
1471
|
+
from.replaceWith(next)
|
|
1472
|
+
return next
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function morphChildren(from, to) {
|
|
1476
|
+
const keyed = new Map()
|
|
1477
|
+
for (const child of [...from.childNodes]) {
|
|
1478
|
+
const key = morphKeyOf(child)
|
|
1479
|
+
if (key !== null && !keyed.has(key)) keyed.set(key, child)
|
|
1480
|
+
}
|
|
1481
|
+
const matched = new Set()
|
|
1482
|
+
let cursor = from.firstChild
|
|
1483
|
+
for (const next of [...to.childNodes]) {
|
|
1484
|
+
const key = morphKeyOf(next)
|
|
1485
|
+
let match = key === null ? null : keyed.get(key) ?? null
|
|
1486
|
+
if (match === null && cursor !== null && morphKeyOf(cursor) === null) match = cursor
|
|
1487
|
+
if (match === null) {
|
|
1488
|
+
const inserted = next.cloneNode(true)
|
|
1489
|
+
from.insertBefore(inserted, cursor)
|
|
1490
|
+
matched.add(inserted)
|
|
1491
|
+
continue
|
|
1492
|
+
}
|
|
1493
|
+
if (match !== cursor) from.insertBefore(match, cursor)
|
|
1494
|
+
matched.add(morphNode(match, next))
|
|
1495
|
+
cursor = match.nextSibling
|
|
1496
|
+
}
|
|
1497
|
+
for (const child of [...from.childNodes]) {
|
|
1498
|
+
if (!matched.has(child)) from.removeChild(child)
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** Patch `container`'s children to match `html` without discarding live nodes. */
|
|
1503
|
+
function morphInto(container, html) {
|
|
1504
|
+
const template = document.createElement('template')
|
|
1505
|
+
template.innerHTML = html
|
|
1506
|
+
morphChildren(container, template.content)
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1403
1509
|
function bindDragHandle(handle) {
|
|
1510
|
+
// Cards now survive a render, so bind each drag handle once.
|
|
1511
|
+
if (handle.dataset.dragBound === '1') return
|
|
1512
|
+
handle.dataset.dragBound = '1'
|
|
1404
1513
|
handle.addEventListener('pointerdown', event => {
|
|
1405
1514
|
const cardId = event.currentTarget.dataset.dragCard
|
|
1406
1515
|
const card = event.currentTarget.closest('.thread-card')
|
|
@@ -1872,6 +1981,7 @@ window.addEventListener('message', event => {
|
|
|
1872
1981
|
}
|
|
1873
1982
|
}
|
|
1874
1983
|
if (data.type === 'synapse:forked-session' || data.type === 'synapse:created-session' || data.type === 'synapse:message-sent') settleRpc(data.requestId, data.session ?? data)
|
|
1984
|
+
if (data.type === 'synapse:archived-session') settleRpc(data.requestId, data)
|
|
1875
1985
|
if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
|
|
1876
1986
|
})
|
|
1877
1987
|
|
|
@@ -1908,9 +2018,11 @@ function applyLiveReplyToCard(sessionId) {
|
|
|
1908
2018
|
const answer = card.querySelector('.thread-answer')
|
|
1909
2019
|
if (!(answer instanceof HTMLElement)) return
|
|
1910
2020
|
const text = live.text
|
|
1911
|
-
answer
|
|
2021
|
+
// Morph instead of replacing the subtree: the answer keeps its nodes, so a
|
|
2022
|
+
// pointer that went down inside it still resolves to a click on release.
|
|
2023
|
+
morphInto(answer, text.trim() === ''
|
|
1912
2024
|
? '<p class="thread-answer-pending">正在回复</p>'
|
|
1913
|
-
: `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`
|
|
2025
|
+
: `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`)
|
|
1914
2026
|
}
|
|
1915
2027
|
function scheduleLiveRender() {
|
|
1916
2028
|
if (liveRenderTimer !== 0 || !canReplaceView()) return
|
package/client.js
CHANGED
|
@@ -316,14 +316,23 @@ window.__ModuleLoader__.load({
|
|
|
316
316
|
}
|
|
317
317
|
if (event.data.type === 'synapse:archive-session') {
|
|
318
318
|
// One archive set only: archive through the Workspace action so DSH's
|
|
319
|
-
// sidebar and this map agree, then
|
|
319
|
+
// sidebar and this map agree, then acknowledge the request so the map
|
|
320
|
+
// can hide the card instead of silently diverging (#21 / #19).
|
|
320
321
|
const sessionId = event.data.sessionId
|
|
322
|
+
const requestId = event.data.requestId
|
|
321
323
|
if (typeof sessionId !== 'string' || sessionId === '') return
|
|
322
|
-
if (typeof ctx.workspaces.archiveSession !== 'function')
|
|
324
|
+
if (typeof ctx.workspaces.archiveSession !== 'function') {
|
|
325
|
+
return send('synapse:bridge-error', { requestId, message: '当前 DSH 版本不支持归档接口' })
|
|
326
|
+
}
|
|
323
327
|
Promise.resolve()
|
|
324
328
|
.then(() => ctx.workspaces.archiveSession(sessionId))
|
|
325
|
-
.then(() =>
|
|
326
|
-
|
|
329
|
+
.then(() => {
|
|
330
|
+
send('synapse:archived-session', { requestId, sessionId })
|
|
331
|
+
send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) })
|
|
332
|
+
})
|
|
333
|
+
.catch(error => {
|
|
334
|
+
send('synapse:bridge-error', { requestId, message: `DSH 归档失败:${error instanceof Error ? error.message : String(error)}` })
|
|
335
|
+
})
|
|
327
336
|
return
|
|
328
337
|
}
|
|
329
338
|
if (event.data.type === 'synapse:fork-session') {
|