dsh-synapse 0.4.5 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -3
- package/app.js +133 -11
- package/client.js +21 -2
- package/docs/architecture.md +9 -5
- package/docs/en/README.md +22 -4
- package/docs/zh-CN/README.md +22 -4
- package/index.js +192 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,11 +22,24 @@
|
|
|
22
22
|
|
|
23
23
|
## 快速安装
|
|
24
24
|
|
|
25
|
-
需要 DeepSeek Harness `>= 0.1.7-rc.2`(本插件依赖该版本的 Host 会话与 Client
|
|
25
|
+
需要 DeepSeek Harness `>= 0.1.7-rc.2`(本插件依赖该版本的 Host 会话与 Client 服务接口)和 Node.js `>= 22.19.0`。
|
|
26
26
|
|
|
27
27
|
> **兼容性提示:** 本版本包含破坏性变更,仅支持 DeepSeek Harness `>= 0.1.7-rc.2`,不再兼容更早版本。
|
|
28
28
|
|
|
29
|
+
**先确认插件要装进哪一套 profile**。DSH 按 profile 分套安装插件,桌面 App 和命令行各用一套,互不影响:
|
|
30
|
+
|
|
31
|
+
| 你平时怎么用 DSH | 要装的 profile | 安装命令 |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| 桌面 App(DeepSeek Harness 客户端) | `desktop` | `corepack pnpm dsh plugin --profile desktop add dsh-synapse` |
|
|
34
|
+
| 命令行 `dsh web` / 浏览器访问 | `web` | `corepack pnpm dsh plugin --profile web add dsh-synapse` |
|
|
35
|
+
| 两种都用 | 两套各装一次 | 两条命令都执行 |
|
|
36
|
+
|
|
37
|
+
- 从 App 内的插件页面安装时,App 会自动装进它自己用的那套,不用管 profile。
|
|
38
|
+
- **自检口诀:装完顶部看不到「会话地图」开关,就是装到另一套去了**,换成另一条命令再装一次即可。
|
|
39
|
+
- 桌面 App 装完需要**重启 App**(Host 侧插件在启动时加载);命令行装的,重启 `dsh web` 并刷新页面。
|
|
40
|
+
|
|
29
41
|
```powershell
|
|
42
|
+
# 命令行方式:装好后启动
|
|
30
43
|
corepack pnpm dsh plugin --profile web add dsh-synapse
|
|
31
44
|
corepack pnpm dsh web
|
|
32
45
|
```
|
|
@@ -60,9 +73,9 @@ corepack pnpm dsh web
|
|
|
60
73
|
## 与 DSH 的边界
|
|
61
74
|
|
|
62
75
|
- DSH session log 保存真实会话内容;Synapse 只投影已提交的事件。
|
|
63
|
-
- Synapse
|
|
76
|
+
- Synapse 只保存画布布局和每轮对话的短预览,消息正文始终从 DSH 会话记录按需读取;`$DSH_HOME/synapse/` 删除后不会丢会话,只会丢布局。
|
|
64
77
|
- 插件不修改 prompt、模型请求、工具 schema、provider 路由或可复用 KV-cache 前缀。
|
|
65
|
-
- 内置 patch
|
|
78
|
+
- 内置 patch 由 profile 的 bundle 列表引入,桌面 App(`desktop`)与命令行(`web`)都适用。
|
|
66
79
|
|
|
67
80
|
## 开发
|
|
68
81
|
|
package/app.js
CHANGED
|
@@ -57,6 +57,10 @@ const state = {
|
|
|
57
57
|
summaries: [], workspace: null, activeId: null, selectedCardId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
|
|
58
58
|
dshWorkspaces: [], selectedDshWorkspaceId: null,
|
|
59
59
|
historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
|
|
60
|
+
// Message bodies are not part of the workspace payload; they are fetched per
|
|
61
|
+
// screenful and cached here (issue #42).
|
|
62
|
+
bodies: new Map(), bodyRequests: new Set(), bodyFailures: new Set(), bodiesLoaded: new Set(),
|
|
63
|
+
archivedSessionIds: new Set(),
|
|
60
64
|
draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards), quickPhrases: savedQuickPhrases, quickPhraseEditorOpen: false,
|
|
61
65
|
dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 }, mapCardSessionSwitches: new Set(),
|
|
62
66
|
expandedMessageIds: new Set(),
|
|
@@ -299,6 +303,8 @@ async function archiveThread(thread) {
|
|
|
299
303
|
} else {
|
|
300
304
|
state.activeId = null
|
|
301
305
|
}
|
|
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 })
|
|
302
308
|
await refreshSummaries()
|
|
303
309
|
}
|
|
304
310
|
|
|
@@ -392,10 +398,25 @@ async function submitDraft() {
|
|
|
392
398
|
}
|
|
393
399
|
|
|
394
400
|
function threadsById() { return new Map((state.workspace?.threads ?? []).map(thread => [thread.id, thread])) }
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A session archived in DSH must not keep drawing cards here: the canvas and
|
|
404
|
+
* the sidebar both read this filtered list (issue #21 / #19). Threads without a
|
|
405
|
+
* DSH session (canvas-only notes) are never hidden by DSH's state.
|
|
406
|
+
*/
|
|
407
|
+
function visibleThreads(threads) {
|
|
408
|
+
if (state.archivedSessionIds.size === 0) return threads
|
|
409
|
+
return threads.filter(thread => thread.dshSessionId === null || !state.archivedSessionIds.has(thread.dshSessionId))
|
|
410
|
+
}
|
|
395
411
|
function persistedMessagesFor(thread) { return state.historyBySession.get(thread.dshSessionId) ?? thread.messages ?? [] }
|
|
396
412
|
|
|
397
413
|
function pendingUserIndex(messages, pending) {
|
|
398
|
-
|
|
414
|
+
// The stored copy may be a preview, so match by full length and by the
|
|
415
|
+
// preview prefix instead of by exact text equality.
|
|
416
|
+
return messages.findLastIndex(message => message.kind === 'user'
|
|
417
|
+
&& new Date(message.at).getTime() >= pending.at - 2_000
|
|
418
|
+
&& (message.text === pending.text
|
|
419
|
+
|| (typeof message.text === 'string' && pending.text.startsWith(message.text.replace(/…$/, '')))))
|
|
399
420
|
}
|
|
400
421
|
|
|
401
422
|
function settlePendingReply(thread, messages) {
|
|
@@ -411,7 +432,15 @@ function messagesFor(thread) {
|
|
|
411
432
|
// A runtime-context snapshot is internal DSH state, never a user turn.
|
|
412
433
|
// Filter here as well as during persistence so existing saved workspaces
|
|
413
434
|
// immediately render one question and its answer as one card.
|
|
414
|
-
const
|
|
435
|
+
const stored = persistedMessagesFor(thread).filter(message => !(message.kind === 'user' && typeof message.text === 'string' && message.text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')))
|
|
436
|
+
// Swap in hydrated bodies where present: the stored text is only a preview.
|
|
437
|
+
const bodies = state.bodies.get(thread.id)
|
|
438
|
+
const messages = bodies === undefined || bodies.size === 0
|
|
439
|
+
? stored
|
|
440
|
+
: stored.map(message => {
|
|
441
|
+
const body = bodies.get(message.sourceSeq)
|
|
442
|
+
return body === undefined ? message : { ...message, text: body.text, process: Array.isArray(body.process) ? body.process : message.process, truncated: body.truncated === true, hydrated: true }
|
|
443
|
+
})
|
|
415
444
|
const pending = state.pendingReplies.get(thread.dshSessionId)
|
|
416
445
|
if (pending === undefined) return messages
|
|
417
446
|
if (settlePendingReply(thread, messages)) {
|
|
@@ -700,6 +729,7 @@ function conversationCards(threads) {
|
|
|
700
729
|
position,
|
|
701
730
|
positionLocked,
|
|
702
731
|
question: question.text,
|
|
732
|
+
questionMessage: question,
|
|
703
733
|
answer,
|
|
704
734
|
error,
|
|
705
735
|
processCount,
|
|
@@ -1034,7 +1064,7 @@ function syncCanvasViewport() {
|
|
|
1034
1064
|
}
|
|
1035
1065
|
|
|
1036
1066
|
function renderCanvas() {
|
|
1037
|
-
const threads = state.workspace?.threads ?? []
|
|
1067
|
+
const threads = visibleThreads(state.workspace?.threads ?? [])
|
|
1038
1068
|
if (threads.length === 0 && state.draft?.kind !== 'new') return `<section class="empty-canvas"><strong>当前工作目录还没有 DSH 对话。</strong><p>点击新会话,在画布中输入第一条消息。</p><div><button class="primary" type="button" data-action="create-session">新建会话</button></div></section>`
|
|
1039
1069
|
const allCards = conversationCards(threads)
|
|
1040
1070
|
const graph = conversationGraphView(allCards)
|
|
@@ -1086,16 +1116,35 @@ function threadMessage(thread, message) {
|
|
|
1086
1116
|
return `<article class="message message-${message.kind}${message.pending ? ' message-pending' : ''}${collapsible ? ' message-collapsible' : ''}${expanded ? ' expanded' : ''}" data-message-seq="${Number.isInteger(message.sourceSeq) ? message.sourceSeq : ''}"><header>${avatar}<span class="message-role">${label}</span><time>${formatTime(message.at)}</time>${branch}${fold}</header><div class="message-body">${body}</div></article>`
|
|
1087
1117
|
}
|
|
1088
1118
|
|
|
1119
|
+
/**
|
|
1120
|
+
* Stored process entries carry a `status` (`pending` / `done` / `failed`);
|
|
1121
|
+
* records folded in from a v3-era message still carry `error` / `result`. Read
|
|
1122
|
+
* both shapes, otherwise a finished tool renders as a failure only because the
|
|
1123
|
+
* newer shape has no `error` key at all (`undefined !== null`).
|
|
1124
|
+
*/
|
|
1125
|
+
function processState(entry) {
|
|
1126
|
+
if (entry.status === 'failed') return 'failed'
|
|
1127
|
+
if (entry.status === 'pending') return 'pending'
|
|
1128
|
+
if (entry.status === 'done') return 'done'
|
|
1129
|
+
if (typeof entry.error === 'string') return entry.error === '' ? (entry.result === null || entry.result === undefined ? 'pending' : 'done') : 'failed'
|
|
1130
|
+
if (entry.error !== null && entry.error !== undefined) return 'failed'
|
|
1131
|
+
return entry.result === null || entry.result === undefined ? 'pending' : 'done'
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1089
1134
|
function processRecords(process, messageId) {
|
|
1090
1135
|
const key = `${messageId}:process`
|
|
1091
1136
|
const expanded = state.expandedMessageIds.has(key)
|
|
1092
1137
|
const entries = process.map((entry, index) => {
|
|
1093
1138
|
const entryKey = `${key}:${index}`
|
|
1094
1139
|
const entryExpanded = state.expandedMessageIds.has(entryKey)
|
|
1095
|
-
const
|
|
1096
|
-
const
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1140
|
+
const state_ = processState(entry)
|
|
1141
|
+
const status = state_ === 'failed' ? '失败' : state_ === 'pending' ? '等待结果' : '完成'
|
|
1142
|
+
const statusClass = state_ === 'failed' ? ' process-status-error' : state_ === 'pending' ? ' process-status-pending' : ' process-status-done'
|
|
1143
|
+
const argumentsHtml = typeof entry.arguments === 'string' && entry.arguments !== '' ? `<pre class="process-args">${escapeHtml(entry.arguments)}</pre>` : ''
|
|
1144
|
+
const outcomeHtml = state_ === 'failed' && typeof entry.error === 'string' && entry.error !== ''
|
|
1145
|
+
? `<pre class="process-error">${escapeHtml(entry.error)}</pre>`
|
|
1146
|
+
: typeof entry.result === 'string' && entry.result !== '' ? `<pre class="process-result">${escapeHtml(entry.result)}</pre>` : ''
|
|
1147
|
+
return `<div class="process-entry${entryExpanded ? ' expanded' : ''}"><button class="process-entry-fold" data-action="toggle-message" data-message="${escapeHtml(entryKey)}"><span class="process-entry-name">${escapeHtml(entry.name)}</span><span class="process-status${statusClass}">${status}</span></button>${entryExpanded ? `<div class="process-entry-body">${argumentsHtml}${outcomeHtml}</div>` : ''}</div>`
|
|
1099
1148
|
}).join('')
|
|
1100
1149
|
return `<section class="process-records${expanded ? ' expanded' : ''}"><button class="process-records-fold" data-action="toggle-message" data-message="${escapeHtml(key)}"><span>${expanded ? '收起过程记录' : '过程记录'}</span><span class="process-count">${process.length}</span></button>${expanded ? entries : ''}</section>`
|
|
1101
1150
|
}
|
|
@@ -1127,13 +1176,15 @@ function inspectorProcessEntries(messages) {
|
|
|
1127
1176
|
continue
|
|
1128
1177
|
}
|
|
1129
1178
|
if (message.kind === 'tool') {
|
|
1130
|
-
entries.push({ name: processSummary(message.text), arguments: message.text,
|
|
1179
|
+
entries.push({ name: processSummary(message.text), arguments: message.text, status: 'pending' })
|
|
1131
1180
|
continue
|
|
1132
1181
|
}
|
|
1133
1182
|
if (message.kind === 'tool-result') {
|
|
1134
1183
|
const previous = entries.at(-1)
|
|
1135
|
-
if (previous !== undefined && previous
|
|
1136
|
-
|
|
1184
|
+
if (previous !== undefined && processState(previous) === 'pending') {
|
|
1185
|
+
previous.status = 'done'
|
|
1186
|
+
previous.result = message.text
|
|
1187
|
+
} else entries.push({ name: '工具结果', arguments: null, status: 'done', result: message.text })
|
|
1137
1188
|
}
|
|
1138
1189
|
}
|
|
1139
1190
|
return entries
|
|
@@ -1194,7 +1245,7 @@ function render() {
|
|
|
1194
1245
|
}
|
|
1195
1246
|
}
|
|
1196
1247
|
const workspace = state.workspace
|
|
1197
|
-
const threads = workspace?.threads ?? []
|
|
1248
|
+
const threads = visibleThreads(workspace?.threads ?? [])
|
|
1198
1249
|
const view = state.mode === 'thread' ? renderThread() : renderCanvas()
|
|
1199
1250
|
const choices = workspaceChoices()
|
|
1200
1251
|
const selectedWorkspaceId = state.selectedDshWorkspaceId ?? workspace?.id
|
|
@@ -1239,6 +1290,74 @@ function render() {
|
|
|
1239
1290
|
if (target instanceof HTMLElement) target.scrollIntoView({ block: 'start' })
|
|
1240
1291
|
})
|
|
1241
1292
|
}
|
|
1293
|
+
// Card faces and the detail view only hold previews: pull the real bodies for
|
|
1294
|
+
// whatever is on screen right now (issue #42).
|
|
1295
|
+
void hydrateBodies()
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Ask the Host for the bodies of the messages currently on screen. The
|
|
1300
|
+
* workspace payload carries previews only, so each newly mounted card and each
|
|
1301
|
+
* opened detail view hydrates from the DSH session log.
|
|
1302
|
+
*/
|
|
1303
|
+
async function hydrateBodies() {
|
|
1304
|
+
const wanted = new Map()
|
|
1305
|
+
const add = (threadId, message) => {
|
|
1306
|
+
if (threadId === undefined || message === null || message === undefined) return
|
|
1307
|
+
if (!Number.isInteger(message.sourceSeq)) return
|
|
1308
|
+
if (state.bodies.get(threadId)?.has(message.sourceSeq) === true) return
|
|
1309
|
+
const key = `${threadId}:${message.sourceSeq}`
|
|
1310
|
+
if (state.bodyRequests.has(key) || state.bodyFailures.has(key)) return
|
|
1311
|
+
const seqs = wanted.get(threadId) ?? new Set()
|
|
1312
|
+
seqs.add(message.sourceSeq)
|
|
1313
|
+
wanted.set(threadId, seqs)
|
|
1314
|
+
}
|
|
1315
|
+
const addCard = card => {
|
|
1316
|
+
if (card === undefined) return
|
|
1317
|
+
add(card.dshThreadId, card.questionMessage)
|
|
1318
|
+
add(card.dshThreadId, card.answer)
|
|
1319
|
+
add(card.dshThreadId, card.error)
|
|
1320
|
+
}
|
|
1321
|
+
if (state.mode === 'thread' && state.activeId !== null) {
|
|
1322
|
+
const thread = currentThread()
|
|
1323
|
+
if (thread !== null && !state.bodiesLoaded.has(thread.id)) {
|
|
1324
|
+
for (const message of persistedMessagesFor(thread)) add(thread.id, message)
|
|
1325
|
+
}
|
|
1326
|
+
} else if (state.mode === 'canvas') {
|
|
1327
|
+
for (const cardId of state.mountedCardIds) addCard(state.canvasCardsById?.get(cardId))
|
|
1328
|
+
}
|
|
1329
|
+
if (state.inspectorCardId !== null) addCard(state.canvasCardsById?.get(state.inspectorCardId))
|
|
1330
|
+
for (const [threadId, seqs] of wanted) void fetchBodies(threadId, [...seqs])
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
async function fetchBodies(threadId, seqs) {
|
|
1334
|
+
if (seqs.length === 0) return
|
|
1335
|
+
const keys = seqs.map(seq => `${threadId}:${seq}`)
|
|
1336
|
+
for (const key of keys) state.bodyRequests.add(key)
|
|
1337
|
+
try {
|
|
1338
|
+
const body = await api(`/synapse/api/threads/${threadId}/full?seqs=${seqs.join(',')}`)
|
|
1339
|
+
const cached = state.bodies.get(threadId) ?? new Map()
|
|
1340
|
+
for (const [seq, payload] of Object.entries(body.messages ?? {})) {
|
|
1341
|
+
if (payload === null || typeof payload !== 'object') continue
|
|
1342
|
+
cached.set(Number(seq), payload)
|
|
1343
|
+
}
|
|
1344
|
+
state.bodies.set(threadId, cached)
|
|
1345
|
+
if (state.mode === 'thread' && state.activeId === threadId) state.bodiesLoaded.add(threadId)
|
|
1346
|
+
if (body.unavailable === true) {
|
|
1347
|
+
// Archived or already removed from DSH: keep the previews, stop asking.
|
|
1348
|
+
for (const message of persistedMessagesFor(currentThread() ?? { messages: [] })) {
|
|
1349
|
+
if (Number.isInteger(message.sourceSeq)) state.bodyFailures.add(`${threadId}:${message.sourceSeq}`)
|
|
1350
|
+
}
|
|
1351
|
+
state.bodiesLoaded.add(threadId)
|
|
1352
|
+
}
|
|
1353
|
+
if (state.mode === 'thread' || state.inspectorCardId !== null) render()
|
|
1354
|
+
else if (canReplaceView()) render()
|
|
1355
|
+
else deferCanvasRefresh(400)
|
|
1356
|
+
} catch {
|
|
1357
|
+
for (const key of keys) state.bodyFailures.add(key)
|
|
1358
|
+
} finally {
|
|
1359
|
+
for (const key of keys) state.bodyRequests.delete(key)
|
|
1360
|
+
}
|
|
1242
1361
|
}
|
|
1243
1362
|
|
|
1244
1363
|
function renderPreservingDetailScroll() {
|
|
@@ -1699,6 +1818,9 @@ window.addEventListener('message', event => {
|
|
|
1699
1818
|
}
|
|
1700
1819
|
if (data.type === 'synapse:workspaces') {
|
|
1701
1820
|
state.dshWorkspaces = Array.isArray(data.workspaces) ? data.workspaces.filter(workspace => typeof workspace?.id === 'string' && typeof workspace.title === 'string' && Array.isArray(workspace.sessionIds)) : []
|
|
1821
|
+
// DSH owns the archive set; mirror it so an archived session stops drawing
|
|
1822
|
+
// cards here too (issue #21 / #19).
|
|
1823
|
+
state.archivedSessionIds = new Set(Array.isArray(data.archivedSessionIds) ? data.archivedSessionIds.filter(id => typeof id === 'string') : [])
|
|
1702
1824
|
const current = currentDshWorkspace()
|
|
1703
1825
|
if (current !== undefined && current.id !== state.selectedDshWorkspaceId) void openDshWorkspace(current.id).catch(setError)
|
|
1704
1826
|
else if (state.selectedDshWorkspaceId !== null) void openDshWorkspace(state.selectedDshWorkspaceId).catch(setError)
|
package/client.js
CHANGED
|
@@ -31,6 +31,13 @@ window.__ModuleLoader__.load({
|
|
|
31
31
|
{ id: 'dsh-ungrouped', title: '未分组', path: null, sessionIds: sessions.ids.filter(id => !accounted.has(id)) },
|
|
32
32
|
]
|
|
33
33
|
}
|
|
34
|
+
// DSH owns which Sessions are archived. The map mirrors that set so an
|
|
35
|
+
// archived Session stops drawing cards, and archives through the same
|
|
36
|
+
// Workspace action instead of keeping a second hidden list (#21 / #19).
|
|
37
|
+
const archivedSessionIds = ctx => {
|
|
38
|
+
const snapshot = ctx.workspaces.list.getSnapshot()
|
|
39
|
+
return Array.isArray(snapshot.archivedSessionIds) ? snapshot.archivedSessionIds.map(String) : []
|
|
40
|
+
}
|
|
34
41
|
|
|
35
42
|
module.exports.inject = ['sessions', 'workspaces', 'uiWorkspace', 'uiConversation']
|
|
36
43
|
module.exports.apply = ctx => {
|
|
@@ -225,7 +232,7 @@ window.__ModuleLoader__.load({
|
|
|
225
232
|
publishLiveSessions()
|
|
226
233
|
syncTheme()
|
|
227
234
|
if (!overlay.hidden) {
|
|
228
|
-
send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
|
|
235
|
+
send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) })
|
|
229
236
|
send('synapse:current-session', { session: currentSession(ctx) })
|
|
230
237
|
}
|
|
231
238
|
}
|
|
@@ -261,7 +268,7 @@ window.__ModuleLoader__.load({
|
|
|
261
268
|
if (event.data.type === 'synapse:close') return close()
|
|
262
269
|
if (event.data.type === 'synapse:map-ready') return showMapOverlay()
|
|
263
270
|
if (event.data.type === 'synapse:request-current') {
|
|
264
|
-
send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
|
|
271
|
+
send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) })
|
|
265
272
|
return send('synapse:current-session', { session: currentSession(ctx) })
|
|
266
273
|
}
|
|
267
274
|
if (event.data.type === 'synapse:open-session') {
|
|
@@ -307,6 +314,18 @@ window.__ModuleLoader__.load({
|
|
|
307
314
|
try { ctx.uiWorkspace.openSession(event.data.sessionId) } catch { send('synapse:bridge-error', { message: NO_SESSION }) }
|
|
308
315
|
return
|
|
309
316
|
}
|
|
317
|
+
if (event.data.type === 'synapse:archive-session') {
|
|
318
|
+
// One archive set only: archive through the Workspace action so DSH's
|
|
319
|
+
// sidebar and this map agree, then report the refreshed set.
|
|
320
|
+
const sessionId = event.data.sessionId
|
|
321
|
+
if (typeof sessionId !== 'string' || sessionId === '') return
|
|
322
|
+
if (typeof ctx.workspaces.archiveSession !== 'function') return send('synapse:bridge-error', { message: '当前 DSH 版本不支持归档接口' })
|
|
323
|
+
Promise.resolve()
|
|
324
|
+
.then(() => ctx.workspaces.archiveSession(sessionId))
|
|
325
|
+
.then(() => send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) }))
|
|
326
|
+
.catch(() => send('synapse:bridge-error', { message: 'DSH 归档失败,请稍后重试' }))
|
|
327
|
+
return
|
|
328
|
+
}
|
|
310
329
|
if (event.data.type === 'synapse:fork-session') {
|
|
311
330
|
const atSeq = Number.isInteger(event.data.atSeq) ? event.data.atSeq : undefined
|
|
312
331
|
ctx.sessions.fork({ sessionId: event.data.sessionId, atSeq, increaseTitle: true }).then(id => {
|
package/docs/architecture.md
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
`dsh-synapse` is a presentation and organization layer for DeepSeek Harness conversations. It turns existing DSH sessions, turns, and forks into a visual map without replacing the systems that own those conversations.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Profile integration
|
|
8
8
|
|
|
9
|
-
The package contributes `cordis.patch.yml`, which inserts the `dsh-synapse` service into
|
|
9
|
+
The package contributes `cordis.patch.yml`, which inserts the `dsh-synapse` service into whichever DSH profile lists the package as a bundle — `desktop` for the desktop app, `web` for the `dsh web` command. It reuses the existing DSH Web server and client runtime.
|
|
10
10
|
|
|
11
11
|
The plugin:
|
|
12
12
|
|
|
13
13
|
- does not start a second HTTP server;
|
|
14
14
|
- does not create a second model or agent runtime;
|
|
15
15
|
- does not replace DSH authentication or permission checks;
|
|
16
|
-
- does not
|
|
16
|
+
- stays inert in a profile that does not list it, so installing it into the wrong profile only means the map never appears (see the installation table in the user guide).
|
|
17
17
|
|
|
18
18
|
## Conversation ownership
|
|
19
19
|
|
|
@@ -78,10 +78,14 @@ Each user question becomes a conversation card. The following assistant messages
|
|
|
78
78
|
|
|
79
79
|
Only a turn a person typed becomes a card. The harness also writes its own `user/message` rows — the runtime-context snapshot, the skill catalogue `<system-reminder>` — and those carry a structured `data.source.kind`; injected kinds (`skill-catalog`, plus the runtime snapshot's text marker) are not projected, so they neither occupy a card nor steal the answer from the real question. Existing projections are pruned the same way when the store loads.
|
|
80
80
|
|
|
81
|
-
Projected
|
|
81
|
+
Projected messages keep only a preview (280 characters), not the body. The body already lives in the DSH session log, and a workspace file full of copies is what made one workspace reach several megabytes and stall the canvas (issue #42). The canvas asks the Host for the bodies of the cards it actually mounts (`GET /synapse/api/threads/:id/full`), and the detail view asks for a whole thread at once; when a session is archived or gone from DSH the previews stay and the card is marked unavailable instead of rendering blank.
|
|
82
|
+
|
|
83
|
+
Projected card text is capped at 8000 characters on the detail fetch, while the preview stays short.
|
|
82
84
|
|
|
83
85
|
Projection writes are coalesced during event bursts, and live updates reuse cached Markdown and patch the active card instead of rebuilding the complete canvas. Card coordinates remain visual metadata only and never determine conversation lineage.
|
|
84
86
|
|
|
87
|
+
Archiving is owned by DSH. The map mirrors `archivedSessionIds` from the Workspace snapshot to stop drawing cards for archived sessions, and its own archive action calls the Workspace `archiveSession` so both sides share one archive set (issue #21 / #19).
|
|
88
|
+
|
|
85
89
|
## Tool process folding
|
|
86
90
|
|
|
87
91
|
Live events pair tool calls and results by `callId` and render them inside the related assistant reply instead of as standalone conversation cards.
|
|
@@ -115,7 +119,7 @@ As a result, the plugin has no direct model-experience effect and does not inval
|
|
|
115
119
|
|
|
116
120
|
## Operational limitations
|
|
117
121
|
|
|
118
|
-
-
|
|
122
|
+
- The bundled patch applies to any profile that lists the package as a bundle (`desktop`, `web`); a profile that does not list it stays untouched.
|
|
119
123
|
- Canvas metadata and session content have different owners and backup requirements.
|
|
120
124
|
- A single shared metadata file is not a multi-writer database.
|
|
121
125
|
- Browser state can be cleared independently from DSH Home data.
|
package/docs/en/README.md
CHANGED
|
@@ -8,13 +8,31 @@ The plugin does not replace DSH models, tools, sessions, permissions, or the Web
|
|
|
8
8
|
|
|
9
9
|
- DeepSeek Harness `>= 0.1.7-rc.2`; this revision targets that release's Host session and Client service interfaces.
|
|
10
10
|
- Node.js `>= 22.19.0`.
|
|
11
|
-
- The `web`
|
|
11
|
+
- The right profile: the desktop app uses `desktop`, the `dsh web` command uses `web` (see below).
|
|
12
|
+
|
|
13
|
+
## Which profile to install into
|
|
14
|
+
|
|
15
|
+
DSH installs plugins per profile. Each profile keeps its own plugin list and `node_modules`, so the desktop app and the command line do not affect each other.
|
|
16
|
+
|
|
17
|
+
| How you run DSH | Profile to use |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Desktop app (DeepSeek Harness client) | `desktop` |
|
|
20
|
+
| `dsh web` on the command line | `web` |
|
|
21
|
+
| Both | Install once per profile |
|
|
22
|
+
|
|
23
|
+
- Installing from the app's plugin page already targets the profile the app uses, so you do not pick one there.
|
|
24
|
+
- **Quick self-check: if the "会话地图 / Session map" switch never appears, the plugin went into the other profile** — run the other command and install again.
|
|
25
|
+
- A desktop install takes effect after **restarting the app**; a command-line install takes effect after restarting `dsh web` and refreshing the page.
|
|
12
26
|
|
|
13
27
|
## Installation
|
|
14
28
|
|
|
15
29
|
### Install from npm
|
|
16
30
|
|
|
17
31
|
```powershell
|
|
32
|
+
# Desktop app
|
|
33
|
+
corepack pnpm dsh plugin --profile desktop add dsh-synapse
|
|
34
|
+
|
|
35
|
+
# Command line (dsh web)
|
|
18
36
|
corepack pnpm dsh plugin --profile web add dsh-synapse
|
|
19
37
|
```
|
|
20
38
|
|
|
@@ -30,7 +48,7 @@ GitHub installs run the package `prepare` script, which validates JavaScript syn
|
|
|
30
48
|
|
|
31
49
|
### pnpm 10+ allowBuilds
|
|
32
50
|
|
|
33
|
-
pnpm 10 and later may block build scripts for Git dependencies by default. If installation is blocked, copy the **complete key printed by pnpm** into the
|
|
51
|
+
pnpm 10 and later may block build scripts for Git dependencies by default. If installation is blocked, copy the **complete key printed by pnpm** into the matching profile's `pnpm-workspace.yaml`:
|
|
34
52
|
|
|
35
53
|
```yaml
|
|
36
54
|
allowBuilds:
|
|
@@ -45,7 +63,7 @@ Use the complete key containing the tarball URL and commit, not the bare package
|
|
|
45
63
|
corepack pnpm dsh plugin --profile web add link:E:\path\to\dsh-synapse
|
|
46
64
|
```
|
|
47
65
|
|
|
48
|
-
The `link:` form references the local checkout directly and is recommended for development. In a normal run, restart `dsh web` and refresh the page after editing the plugin.
|
|
66
|
+
The `link:` form references the local checkout directly and is recommended for development. In a normal run, restart the host (the desktop app or `dsh web`) and refresh the page after editing the plugin.
|
|
49
67
|
|
|
50
68
|
## Start DSH Web
|
|
51
69
|
|
|
@@ -150,7 +168,7 @@ Does not invalidate. The plugin never changes request headers, system prompts, o
|
|
|
150
168
|
|
|
151
169
|
## Known limitations
|
|
152
170
|
|
|
153
|
-
-
|
|
171
|
+
- Both the `desktop` (desktop app) and `web` (`dsh web`) profiles are supported; any other profile has to add the package to its own bundle list. Installing into the wrong profile does not error — the Session map switch simply never appears.
|
|
154
172
|
- Two DSH Web instances sharing one profile write the same `workspaces.json`. A cross-process write lock and external-modification warnings exist, but last-writer-wins replacement remains possible; run one instance.
|
|
155
173
|
- During v3 migration, legacy tool cards pair each call with the next result by order. Live events pair by `callId`.
|
|
156
174
|
|
package/docs/zh-CN/README.md
CHANGED
|
@@ -8,13 +8,31 @@
|
|
|
8
8
|
|
|
9
9
|
- DeepSeek Harness `>= 0.1.7-rc.2`(本插件依赖该版本的 Host 会话与 Client 服务接口)。
|
|
10
10
|
- Node.js `>= 22.19.0`。
|
|
11
|
-
-
|
|
11
|
+
- 选对 profile:桌面 App 用 `desktop`,命令行 `dsh web` 用 `web`(见下节)。
|
|
12
|
+
|
|
13
|
+
## 装到哪一套 profile
|
|
14
|
+
|
|
15
|
+
DSH 按 profile 分套安装插件:每套 profile 有自己的插件清单和 `node_modules`,桌面 App 和命令行各用一套,互不影响。
|
|
16
|
+
|
|
17
|
+
| 你平时怎么用 DSH | 要装的 profile |
|
|
18
|
+
|---|---|
|
|
19
|
+
| 桌面 App(DeepSeek Harness 客户端) | `desktop` |
|
|
20
|
+
| 命令行 `dsh web` / 浏览器访问 | `web` |
|
|
21
|
+
| 两种都用 | 两套各装一次 |
|
|
22
|
+
|
|
23
|
+
- 从 App 内的插件页面安装时,App 会自动装进它自己用的那套,无需关心 profile。
|
|
24
|
+
- **自检口诀:装完顶部看不到「会话地图」开关,就是装到另一套去了**,换成另一条命令再装一次即可。
|
|
25
|
+
- 桌面 App 装完需要**重启 App**;命令行装的,重启 `dsh web` 并刷新页面。
|
|
12
26
|
|
|
13
27
|
## 安装
|
|
14
28
|
|
|
15
29
|
### 从 npm 安装
|
|
16
30
|
|
|
17
31
|
```powershell
|
|
32
|
+
# 桌面 App
|
|
33
|
+
corepack pnpm dsh plugin --profile desktop add dsh-synapse
|
|
34
|
+
|
|
35
|
+
# 命令行 dsh web
|
|
18
36
|
corepack pnpm dsh plugin --profile web add dsh-synapse
|
|
19
37
|
```
|
|
20
38
|
|
|
@@ -30,7 +48,7 @@ GitHub 安装会运行本包的 `prepare` 脚本,通过 `node --check` 验证
|
|
|
30
48
|
|
|
31
49
|
### pnpm 10+ 的 allowBuilds
|
|
32
50
|
|
|
33
|
-
pnpm 10 及之后版本默认可能阻止 Git 依赖执行构建脚本。如果安装被拦截,请将 pnpm
|
|
51
|
+
pnpm 10 及之后版本默认可能阻止 Git 依赖执行构建脚本。如果安装被拦截,请将 pnpm 输出的**完整键**复制到对应 profile 的 `pnpm-workspace.yaml`:
|
|
34
52
|
|
|
35
53
|
```yaml
|
|
36
54
|
allowBuilds:
|
|
@@ -45,7 +63,7 @@ allowBuilds:
|
|
|
45
63
|
corepack pnpm dsh plugin --profile web add link:E:\path\to\dsh-synapse
|
|
46
64
|
```
|
|
47
65
|
|
|
48
|
-
`link:` 会直接引用本地 checkout
|
|
66
|
+
`link:` 会直接引用本地 checkout,适合开发和调试。普通运行模式下修改代码后,建议重启宿主(桌面 App 或 `dsh web`)并刷新页面。
|
|
49
67
|
|
|
50
68
|
## 启动
|
|
51
69
|
|
|
@@ -143,7 +161,7 @@ $DSH_HOME/synapse/
|
|
|
143
161
|
|
|
144
162
|
## 已知限制
|
|
145
163
|
|
|
146
|
-
-
|
|
164
|
+
- 支持 `desktop`(桌面 App)与 `web`(命令行 `dsh web`)两套 profile;其他自定义 profile 需要自己把插件加入 bundle 列表。装错 profile 时插件不会报错,只是顶部不会出现「会话地图」。
|
|
147
165
|
- 两个 DSH Web 实例共享同一个 profile 时会写入同一个 `workspaces.json`。虽然存在跨进程写锁和外部修改警告,仍可能出现最后写入覆盖,请只运行一个实例。
|
|
148
166
|
- v3 数据迁移时,旧工具卡片按顺序将每次调用与下一条结果配对;实时事件使用 `callId`。
|
|
149
167
|
|
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
3
|
import { dirname } from 'node:path'
|
|
4
4
|
|
|
@@ -10,8 +10,16 @@ const MAX_TITLE_LENGTH = 120
|
|
|
10
10
|
const MAX_NOTE_LENGTH = 4_000
|
|
11
11
|
// Projected message text cap: longer replies truncate with a marker pointing
|
|
12
12
|
// at the detail view instead of silently cutting mid-sentence.
|
|
13
|
-
|
|
13
|
+
// Projected messages keep only a short preview: the body already lives in the
|
|
14
|
+
// DSH session log, and a canvas full of copies is what made one workspace file
|
|
15
|
+
// grow to megabytes (issue #42). Bodies are streamed back on demand by
|
|
16
|
+
// `/synapse/api/threads/:id/full`.
|
|
17
|
+
const EXCERPT_CHARS = 280
|
|
18
|
+
// On-demand bodies are capped so a single enormous tool result cannot stall the
|
|
19
|
+
// browser while its card is rendered.
|
|
20
|
+
const MAX_BODY_CHARS = 200_000
|
|
14
21
|
const PROJECTION_TRUNCATED_SUFFIX = '\n——…(详情查看全文)'
|
|
22
|
+
const BODY_TRUNCATED_SUFFIX = '\n——…(正文过长,其余请在 DSH 中查看)'
|
|
15
23
|
const TOPIC_COLORS = ['#0f766e', '#2563eb', '#be123c', '#7c3aed', '#b45309']
|
|
16
24
|
// The harness writes `user/message` rows of its own (runtime snapshots, skill
|
|
17
25
|
// catalogues). A canvas card must stand for a turn a person actually typed, so
|
|
@@ -55,6 +63,20 @@ export class WorkspaceStore {
|
|
|
55
63
|
return structuredClone(workspace)
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Read-only lookup used by on-demand body loading: returns the live thread
|
|
68
|
+
* (no clone) together with its workspace, or null when the id is unknown.
|
|
69
|
+
*/
|
|
70
|
+
async locateThread(threadId) {
|
|
71
|
+
await this.ready
|
|
72
|
+
for (const workspace of this.state.workspaces) {
|
|
73
|
+
for (const thread of workspace.threads ?? []) {
|
|
74
|
+
if (thread.id === threadId) return { workspace, thread }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
async create(title) {
|
|
59
81
|
return this.mutate(() => {
|
|
60
82
|
const now = new Date().toISOString()
|
|
@@ -250,10 +272,15 @@ export class WorkspaceStore {
|
|
|
250
272
|
const parsed = JSON.parse(await readFile(this.dataFile, 'utf8'))
|
|
251
273
|
const { state, migrated } = normalizeState(parsed)
|
|
252
274
|
this.state = state
|
|
253
|
-
if (migrated)
|
|
275
|
+
if (migrated) {
|
|
276
|
+
// Dropping stored bodies is the one migration that cannot be undone by
|
|
277
|
+
// re-projection alone, so keep the pre-v5 file next to the new one.
|
|
278
|
+
if (Number(parsed?.version) < 5) await copyFile(this.dataFile, `${this.dataFile}.v4-backup`).catch(() => {})
|
|
279
|
+
await this.save()
|
|
280
|
+
}
|
|
254
281
|
} catch (error) {
|
|
255
282
|
if (error?.code !== 'ENOENT') throw new Error(`synapse: cannot read ${this.dataFile}: ${error.message}`)
|
|
256
|
-
this.state = { version:
|
|
283
|
+
this.state = { version: 5, hiddenSessionIds: [], workspaces: [] }
|
|
257
284
|
await this.save()
|
|
258
285
|
}
|
|
259
286
|
}
|
|
@@ -451,6 +478,8 @@ export class WorkspaceStore {
|
|
|
451
478
|
const message = {
|
|
452
479
|
id: randomUUID(),
|
|
453
480
|
text: projection.text,
|
|
481
|
+
length: projection.length,
|
|
482
|
+
truncated: projection.truncated,
|
|
454
483
|
kind: projection.kind,
|
|
455
484
|
sourceSeq: event.seq,
|
|
456
485
|
at,
|
|
@@ -484,21 +513,21 @@ export class WorkspaceStore {
|
|
|
484
513
|
const process = target === undefined ? (thread.pendingProcess ??= []) : (target.process ??= [])
|
|
485
514
|
const callId = String(event.type === 'tool/call' ? data.callId : data.message?.source?.callId ?? '')
|
|
486
515
|
const entry = process.find(item => item.callId === callId)
|
|
516
|
+
// Process records keep only identity and status: tool arguments and results
|
|
517
|
+
// are the bulk of the old file (issue #42) and are reloaded on demand.
|
|
487
518
|
if (event.type === 'tool/call') {
|
|
488
519
|
if (entry === undefined) {
|
|
489
|
-
process.push({ callId, turn: data.turn, step: data.step, name: data.name,
|
|
520
|
+
process.push({ callId, turn: data.turn, step: data.step, name: data.name, status: 'pending' })
|
|
490
521
|
} else {
|
|
491
522
|
entry.name = data.name
|
|
492
|
-
entry.arguments = data.arguments
|
|
493
523
|
}
|
|
494
524
|
} else {
|
|
495
|
-
const
|
|
496
|
-
const error = errorText(data.error)
|
|
525
|
+
const failed = errorText(data.error) !== null
|
|
497
526
|
if (entry === undefined) {
|
|
498
|
-
process.push({ callId, turn: data.turn, step: data.step, name: '工具调用',
|
|
527
|
+
process.push({ callId, turn: data.turn, step: data.step, name: '工具调用', status: failed ? 'failed' : 'done' })
|
|
499
528
|
} else {
|
|
500
|
-
entry.
|
|
501
|
-
entry.
|
|
529
|
+
entry.status = failed ? 'failed' : 'done'
|
|
530
|
+
if (typeof entry.name !== 'string' || entry.name === '') entry.name = '工具调用'
|
|
502
531
|
}
|
|
503
532
|
}
|
|
504
533
|
thread.updatedAt = at
|
|
@@ -539,7 +568,7 @@ class NotFoundError extends Error {}
|
|
|
539
568
|
function normalizeState(value) {
|
|
540
569
|
let migrated = false
|
|
541
570
|
let state
|
|
542
|
-
if ((value?.version === 2 || value?.version === 3 || value?.version === 4) && Array.isArray(value.workspaces)) {
|
|
571
|
+
if ((value?.version === 2 || value?.version === 3 || value?.version === 4 || value?.version === 5) && Array.isArray(value.workspaces)) {
|
|
543
572
|
const hiddenSessionIds = Array.isArray(value.hiddenSessionIds) ? value.hiddenSessionIds.filter(item => typeof item === 'string') : []
|
|
544
573
|
migrated = value.version < 3 || !Array.isArray(value.hiddenSessionIds)
|
|
545
574
|
const workspaces = value.workspaces.map(workspace => ({
|
|
@@ -580,16 +609,54 @@ function normalizeState(value) {
|
|
|
580
609
|
}
|
|
581
610
|
migrated = true
|
|
582
611
|
} else {
|
|
583
|
-
throw new Error('expected Synapse data version 1, 2, 3, or
|
|
612
|
+
throw new Error('expected Synapse data version 1, 2, 3, 4, or 5')
|
|
584
613
|
}
|
|
585
|
-
if (state.version
|
|
614
|
+
if (state.version < 4) {
|
|
586
615
|
if (foldLegacyToolCards(state.workspaces)) migrated = true
|
|
587
616
|
state.version = 4
|
|
588
617
|
migrated = true
|
|
589
618
|
}
|
|
619
|
+
// v5 drops message bodies and tool payloads from the workspace file: they are
|
|
620
|
+
// re-read from the DSH session log on demand. Existing bodies become previews.
|
|
621
|
+
if (state.version < 5) {
|
|
622
|
+
for (const workspace of state.workspaces) {
|
|
623
|
+
for (const thread of workspace.threads ?? []) {
|
|
624
|
+
if (Array.isArray(thread.messages)) thread.messages = thread.messages.map(toStoredMessage)
|
|
625
|
+
if (Array.isArray(thread.pendingProcess)) thread.pendingProcess = thread.pendingProcess.map(toStoredProcess)
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
state.version = 5
|
|
629
|
+
migrated = true
|
|
630
|
+
}
|
|
590
631
|
return { state, migrated }
|
|
591
632
|
}
|
|
592
633
|
|
|
634
|
+
/** v4 message → v5 message: keep identity and a preview, drop the body. */
|
|
635
|
+
function toStoredMessage(message) {
|
|
636
|
+
const normalized = typeof message?.text === 'string' ? message.text.trim() : ''
|
|
637
|
+
const text = normalized === '' ? '' : excerptOf(normalized)
|
|
638
|
+
const process = Array.isArray(message?.process) ? message.process.map(toStoredProcess) : message?.process
|
|
639
|
+
return {
|
|
640
|
+
...message,
|
|
641
|
+
text,
|
|
642
|
+
length: normalized.length,
|
|
643
|
+
truncated: text.length < normalized.length,
|
|
644
|
+
...(process === undefined ? {} : { process }),
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** v4 process record → v5 record: keep identity and status, drop the payload. */
|
|
649
|
+
function toStoredProcess(entry) {
|
|
650
|
+
if (entry === null || typeof entry !== 'object') return entry
|
|
651
|
+
const { arguments: _arguments, result, error, ...rest } = entry
|
|
652
|
+
const status = error !== null && error !== undefined && error !== ''
|
|
653
|
+
? 'failed'
|
|
654
|
+
: result === null || result === undefined
|
|
655
|
+
? 'pending'
|
|
656
|
+
: 'done'
|
|
657
|
+
return { ...rest, status: typeof entry.status === 'string' ? entry.status : status }
|
|
658
|
+
}
|
|
659
|
+
|
|
593
660
|
/**
|
|
594
661
|
* Fold v3-era standalone tool cards (kinds `tool` / `tool-result`) into the
|
|
595
662
|
* preceding assistant message's `process` list, pairing each call with the
|
|
@@ -656,30 +723,88 @@ function requiredText(value, maxLength, field) {
|
|
|
656
723
|
return text
|
|
657
724
|
}
|
|
658
725
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
666
|
-
case 'assistant/message':
|
|
667
|
-
return noteProjection('assistant', contentText(event.data?.message?.content))
|
|
668
|
-
case 'todo/write':
|
|
669
|
-
return noteProjection('todo', Array.isArray(event.data?.todos) ? event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n') : '')
|
|
726
|
+
/** Event → canvas message kind, or null when the event is not a message. */
|
|
727
|
+
function eventKind(event) {
|
|
728
|
+
switch (event?.type) {
|
|
729
|
+
case 'user/message': return 'user'
|
|
730
|
+
case 'assistant/message': return 'assistant'
|
|
731
|
+
case 'todo/write': return 'todo'
|
|
670
732
|
case 'turn/end': {
|
|
671
733
|
const reason = event.data?.reason
|
|
672
|
-
if (reason?.kind === 'error') return
|
|
673
|
-
if (reason?.kind === 'cancelled' || reason?.kind === 'canceled' || reason?.kind === 'aborted') return
|
|
734
|
+
if (reason?.kind === 'error') return 'error'
|
|
735
|
+
if (reason?.kind === 'cancelled' || reason?.kind === 'canceled' || reason?.kind === 'aborted') return 'error'
|
|
674
736
|
return null
|
|
675
737
|
}
|
|
676
738
|
default:
|
|
677
|
-
return /(?:error|failed|failure|cancel(?:led)?|abort)/i.test(event
|
|
678
|
-
? noteProjection('error', errorText(event.data?.error ?? event.data?.reason ?? event.data) ?? 'Harness 运行失败')
|
|
679
|
-
: null
|
|
739
|
+
return /(?:error|failed|failure|cancel(?:led)?|abort)/i.test(event?.type ?? '') ? 'error' : null
|
|
680
740
|
}
|
|
681
741
|
}
|
|
682
742
|
|
|
743
|
+
/**
|
|
744
|
+
* Full body of one event. Shared by the projection (which stores a preview) and
|
|
745
|
+
* the on-demand loader (which returns the whole text), so both read the same
|
|
746
|
+
* fields instead of keeping two parsers in step.
|
|
747
|
+
*/
|
|
748
|
+
function eventBody(event) {
|
|
749
|
+
switch (event?.type) {
|
|
750
|
+
case 'user/message': return contentText(event.data?.content)
|
|
751
|
+
case 'assistant/message': return contentText(event.data?.message?.content)
|
|
752
|
+
case 'todo/write': return Array.isArray(event.data?.todos) ? event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n') : ''
|
|
753
|
+
case 'turn/end': {
|
|
754
|
+
const reason = event.data?.reason
|
|
755
|
+
if (reason?.kind === 'error') return errorText(reason.error) ?? '本轮执行失败'
|
|
756
|
+
return '本轮已取消'
|
|
757
|
+
}
|
|
758
|
+
default:
|
|
759
|
+
return eventKind(event) === 'error' ? errorText(event.data?.error ?? event.data?.reason ?? event.data) ?? 'Harness 运行失败' : ''
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function projectableEvent(event) {
|
|
764
|
+
const kind = eventKind(event)
|
|
765
|
+
if (kind === null) return null
|
|
766
|
+
const text = eventBody(event)
|
|
767
|
+
if (kind === 'user' && (isInjectedUserSource(event.data?.source) || isRuntimeContextText(text))) return null
|
|
768
|
+
return noteProjection(kind, text)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Full bodies for the requested stored messages, assembled from one session's
|
|
773
|
+
* event log. Exported so the on-demand loader is testable without a Host.
|
|
774
|
+
* `seqs === null` returns every projected message of the thread.
|
|
775
|
+
*/
|
|
776
|
+
export function bodiesFromEvents(thread, events, seqs) {
|
|
777
|
+
const wanted = seqs === null ? null : new Set(seqs)
|
|
778
|
+
const messages = {}
|
|
779
|
+
const eventsBySeq = new Map(events.map(event => [event?.seq, event]))
|
|
780
|
+
const calls = new Map()
|
|
781
|
+
for (const event of events) {
|
|
782
|
+
if (event?.type !== 'tool/call' && event?.type !== 'tool/result') continue
|
|
783
|
+
const callId = String(event.type === 'tool/call' ? event.data?.callId : event.data?.message?.source?.callId ?? '')
|
|
784
|
+
if (callId === '') continue
|
|
785
|
+
const record = calls.get(callId) ?? {}
|
|
786
|
+
if (event.type === 'tool/call') {
|
|
787
|
+
record.arguments = typeof event.data?.arguments === 'string' ? event.data.arguments : null
|
|
788
|
+
} else {
|
|
789
|
+
record.result = contentText(event.data?.message?.content) || null
|
|
790
|
+
record.error = errorText(event.data?.error)
|
|
791
|
+
}
|
|
792
|
+
calls.set(callId, record)
|
|
793
|
+
}
|
|
794
|
+
for (const message of thread.messages ?? []) {
|
|
795
|
+
if (wanted !== null && !wanted.has(message.sourceSeq)) continue
|
|
796
|
+
const event = eventsBySeq.get(message.sourceSeq)
|
|
797
|
+
if (event === undefined) continue
|
|
798
|
+
const body = eventBody(event).trim()
|
|
799
|
+
const capped = body.length > MAX_BODY_CHARS ? `${body.slice(0, MAX_BODY_CHARS)}${BODY_TRUNCATED_SUFFIX}` : body
|
|
800
|
+
const process = Array.isArray(message.process)
|
|
801
|
+
? message.process.map(entry => ({ ...entry, ...(calls.get(String(entry.callId)) ?? {}) }))
|
|
802
|
+
: []
|
|
803
|
+
messages[String(message.sourceSeq)] = { text: capped, truncated: capped.length < body.length, process }
|
|
804
|
+
}
|
|
805
|
+
return messages
|
|
806
|
+
}
|
|
807
|
+
|
|
683
808
|
function errorText(value) {
|
|
684
809
|
if (typeof value === 'string') return value.trim() || null
|
|
685
810
|
if (value === null || value === undefined || typeof value !== 'object') return null
|
|
@@ -693,8 +818,14 @@ function errorText(value) {
|
|
|
693
818
|
function noteProjection(kind, text) {
|
|
694
819
|
const normalized = text.trim()
|
|
695
820
|
if (normalized === '') return null
|
|
696
|
-
|
|
697
|
-
return { kind, text:
|
|
821
|
+
const excerpt = excerptOf(normalized)
|
|
822
|
+
return { kind, text: excerpt, length: normalized.length, truncated: excerpt.length < normalized.length }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Short preview kept in the workspace file, with an ellipsis when cut. */
|
|
826
|
+
function excerptOf(text) {
|
|
827
|
+
if (text.length <= EXCERPT_CHARS) return text
|
|
828
|
+
return `${text.slice(0, EXCERPT_CHARS).trimEnd()}…`
|
|
698
829
|
}
|
|
699
830
|
|
|
700
831
|
function isRuntimeContextText(text) {
|
|
@@ -825,6 +956,27 @@ export function apply(ctx, config) {
|
|
|
825
956
|
ctx.on('session/event', enqueueProjection)
|
|
826
957
|
for (const session of ctx.sessions.list()) replaySession(session)
|
|
827
958
|
}
|
|
959
|
+
// Bodies live in the session log, not in the workspace file (issue #42). The
|
|
960
|
+
// canvas pulls them for the cards it actually renders; the detail view pulls a
|
|
961
|
+
// whole thread at once. An archived or already-removed session cannot be
|
|
962
|
+
// re-read, so the caller is told instead of receiving silently empty text.
|
|
963
|
+
const threadBodies = async (threadId, seqs) => {
|
|
964
|
+
const located = await store.locateThread(threadId)
|
|
965
|
+
if (located === null) throw new NotFoundError('会话不存在')
|
|
966
|
+
const { thread } = located
|
|
967
|
+
const wanted = seqs === null ? null : new Set(seqs)
|
|
968
|
+
if (thread.dshSessionId === null) {
|
|
969
|
+
const messages = {}
|
|
970
|
+
for (const message of thread.messages ?? []) {
|
|
971
|
+
if (wanted !== null && !wanted.has(message.sourceSeq)) continue
|
|
972
|
+
messages[String(message.sourceSeq)] = { text: message.text, truncated: message.truncated === true, process: message.process ?? [], local: true }
|
|
973
|
+
}
|
|
974
|
+
return { messages }
|
|
975
|
+
}
|
|
976
|
+
const session = ctx.sessions.list().find(candidate => candidate.id === thread.dshSessionId)
|
|
977
|
+
if (session === undefined) return { messages: {}, unavailable: true }
|
|
978
|
+
return { messages: bodiesFromEvents(thread, session.snapshotEvents(), seqs) }
|
|
979
|
+
}
|
|
828
980
|
// The DSH /api browser-trust fence does not cover /synapse routes, so this
|
|
829
981
|
// handler checks the Host header itself: localhost is allowed by default and
|
|
830
982
|
// additional authorities opt in through config.trustedHosts (mirrors the
|
|
@@ -850,6 +1002,14 @@ export function apply(ctx, config) {
|
|
|
850
1002
|
if (path === '/synapse/api/sessions/sync' && req.method === 'POST') { const body = await readJson(req); return sendJson(res, 200, { workspaces: await store.syncSessions(body.sessions, body.removedSessionIds) }) }
|
|
851
1003
|
const messages = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/messages$/i.exec(path)
|
|
852
1004
|
if (messages !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.addMessage(messages[1], (await readJson(req)).text) })
|
|
1005
|
+
const full = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/full$/i.exec(path)
|
|
1006
|
+
if (full !== null && req.method === 'GET') {
|
|
1007
|
+
const raw = new URL(req.url ?? '/', 'http://dsh.local').searchParams.get('seqs')
|
|
1008
|
+
const seqs = raw === null || raw.trim() === ''
|
|
1009
|
+
? null
|
|
1010
|
+
: raw.split(',').map(value => Number.parseInt(value, 10)).filter(value => Number.isInteger(value) && value >= 0)
|
|
1011
|
+
return sendJson(res, 200, await threadBodies(full[1], seqs))
|
|
1012
|
+
}
|
|
853
1013
|
const thread = /^\/synapse\/api\/threads\/([0-9a-f-]+)$/i.exec(path)
|
|
854
1014
|
if (thread !== null && req.method === 'PATCH') return sendJson(res, 200, { thread: await store.updateThread(thread[1], await readJson(req)) })
|
|
855
1015
|
if (thread !== null && req.method === 'DELETE') return sendJson(res, 200, await store.removeThread(thread[1]))
|