dsh-synapse 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/app.js +1252 -0
- package/client.js +217 -0
- package/cordis.patch.yml +13 -0
- package/deepseek-mark.svg +3 -0
- package/docs/architecture.md +104 -0
- package/docs/development.md +137 -0
- package/docs/en/README.md +161 -0
- package/docs/images/native-webui.png +0 -0
- package/docs/images/synapse-ui.png +0 -0
- package/docs/zh-CN/README.md +154 -0
- package/index.js +802 -0
- package/package.json +44 -0
- package/styles.css +337 -0
package/index.js
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const name = 'synapse'
|
|
6
|
+
export const inject = ['webServer', 'sessions']
|
|
7
|
+
|
|
8
|
+
const MAX_BODY_BYTES = 32 * 1024
|
|
9
|
+
const MAX_TITLE_LENGTH = 120
|
|
10
|
+
const MAX_NOTE_LENGTH = 4_000
|
|
11
|
+
// Projected message text cap: longer replies truncate with a marker pointing
|
|
12
|
+
// at the detail view instead of silently cutting mid-sentence.
|
|
13
|
+
const MAX_PROJECTION_LENGTH = 8_000
|
|
14
|
+
const PROJECTION_TRUNCATED_SUFFIX = '\n——…(详情查看全文)'
|
|
15
|
+
const TOPIC_COLORS = ['#0f766e', '#2563eb', '#be123c', '#7c3aed', '#b45309']
|
|
16
|
+
const LOCK_STALE_MS = 60_000
|
|
17
|
+
// Deferred (event-projection) writes coalesce into one save per window, so a
|
|
18
|
+
// burst of session events costs a single full-state write instead of one per
|
|
19
|
+
// event (issue #13: per-event saves pinned the main thread at ~90% CPU).
|
|
20
|
+
const SAVE_DEBOUNCE_MS = 800
|
|
21
|
+
|
|
22
|
+
/** JSON persistence for the Synapse workspace graph. */
|
|
23
|
+
export class WorkspaceStore {
|
|
24
|
+
constructor(dataFile) {
|
|
25
|
+
if (typeof dataFile !== 'string' || dataFile.length === 0) throw new Error('synapse: config.dataFile must be a non-empty path')
|
|
26
|
+
this.dataFile = dataFile
|
|
27
|
+
this.state = undefined
|
|
28
|
+
this.serial = Promise.resolve()
|
|
29
|
+
this.ready = this.load()
|
|
30
|
+
this.lastKnownMtime = null
|
|
31
|
+
this.externalModWarned = false
|
|
32
|
+
this.lockWarned = false
|
|
33
|
+
this.dirty = false
|
|
34
|
+
this.flushTimer = null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async list() {
|
|
38
|
+
await this.ready
|
|
39
|
+
return this.state.workspaces.map(workspace => this.summary(workspace))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async get(workspaceId) {
|
|
43
|
+
await this.ready
|
|
44
|
+
const workspace = this.workspace(workspaceId)
|
|
45
|
+
return structuredClone(workspace)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async create(title) {
|
|
49
|
+
return this.mutate(() => {
|
|
50
|
+
const now = new Date().toISOString()
|
|
51
|
+
const workspace = { id: randomUUID(), title: requiredText(title, MAX_TITLE_LENGTH, 'title'), createdAt: now, updatedAt: now, threads: [] }
|
|
52
|
+
this.state.workspaces.unshift(workspace)
|
|
53
|
+
return this.summary(workspace)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async createThread(workspaceId, input) {
|
|
58
|
+
return this.mutate(() => {
|
|
59
|
+
const workspace = this.workspace(workspaceId)
|
|
60
|
+
const now = new Date().toISOString()
|
|
61
|
+
const thread = this.thread({
|
|
62
|
+
title: input?.title,
|
|
63
|
+
parentId: input?.parentId,
|
|
64
|
+
dshSessionId: input?.dshSessionId,
|
|
65
|
+
dshSessionTitle: input?.dshSessionTitle,
|
|
66
|
+
position: input?.position,
|
|
67
|
+
color: input?.color,
|
|
68
|
+
now,
|
|
69
|
+
order: workspace.threads.length,
|
|
70
|
+
})
|
|
71
|
+
if (thread.parentId !== null && !workspace.threads.some(item => item.id === thread.parentId)) throw new InputError('分支来源不存在')
|
|
72
|
+
workspace.threads.push(thread)
|
|
73
|
+
workspace.updatedAt = now
|
|
74
|
+
return structuredClone(thread)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async branch(threadId, input) {
|
|
79
|
+
return this.mutate(() => {
|
|
80
|
+
const { workspace, thread: parent } = this.locateThread(threadId)
|
|
81
|
+
const now = new Date().toISOString()
|
|
82
|
+
const sessionId = typeof input?.dshSessionId === 'string' && input.dshSessionId.length > 0 ? input.dshSessionId : null
|
|
83
|
+
// A DSH fork emits session/created while the browser receives its fork
|
|
84
|
+
// response. Either path may win the race, but both must resolve to one node.
|
|
85
|
+
if (sessionId !== null) {
|
|
86
|
+
const existing = workspace.threads.find(item => item.dshSessionId === sessionId)
|
|
87
|
+
if (existing !== undefined) {
|
|
88
|
+
existing.parentId ??= parent.id
|
|
89
|
+
if (typeof input?.title === 'string' && input.title.trim() !== '') existing.title = requiredText(input.title, MAX_TITLE_LENGTH, 'title')
|
|
90
|
+
if (typeof input?.dshSessionTitle === 'string') existing.dshSessionTitle = input.dshSessionTitle.slice(0, MAX_TITLE_LENGTH)
|
|
91
|
+
existing.updatedAt = now
|
|
92
|
+
workspace.updatedAt = now
|
|
93
|
+
return structuredClone(existing)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const siblings = workspace.threads.filter(item => item.parentId === parent.id)
|
|
97
|
+
const thread = this.thread({
|
|
98
|
+
title: input?.title,
|
|
99
|
+
parentId: parent.id,
|
|
100
|
+
dshSessionId: input?.dshSessionId,
|
|
101
|
+
dshSessionTitle: input?.dshSessionTitle,
|
|
102
|
+
position: input?.position ?? { x: parent.position.x + 420, y: parent.position.y + siblings.length * 248 },
|
|
103
|
+
color: input?.color ?? parent.color,
|
|
104
|
+
now,
|
|
105
|
+
order: workspace.threads.length,
|
|
106
|
+
})
|
|
107
|
+
workspace.threads.push(thread)
|
|
108
|
+
workspace.updatedAt = now
|
|
109
|
+
return structuredClone(thread)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Keep only the canvas graph in Synapse; DSH remains the source of session truth. */
|
|
114
|
+
async syncSessions(sessions, removedSessionIds = []) {
|
|
115
|
+
return this.mutate(() => {
|
|
116
|
+
if (!Array.isArray(sessions)) throw new InputError('sessions 必须是数组')
|
|
117
|
+
if (!Array.isArray(removedSessionIds) || removedSessionIds.some(item => typeof item !== 'string')) throw new InputError('removedSessionIds 必须是字符串数组')
|
|
118
|
+
const blankIds = new Set(sessions.filter(item => item?.blank === true && typeof item.id === 'string').map(item => item.id))
|
|
119
|
+
const removedIds = new Set(removedSessionIds)
|
|
120
|
+
for (const workspace of this.state.workspaces) {
|
|
121
|
+
if (workspace.kind !== 'dsh') continue
|
|
122
|
+
workspace.threads = workspace.threads.filter(thread => !blankIds.has(thread.dshSessionId) && !removedIds.has(thread.dshSessionId))
|
|
123
|
+
}
|
|
124
|
+
this.state.workspaces = this.state.workspaces.filter(workspace => workspace.kind !== 'dsh' || workspace.threads.length > 0)
|
|
125
|
+
for (const item of sessions) {
|
|
126
|
+
if (typeof item?.id !== 'string' || item.id === '' || typeof item.cwd !== 'string' || item.cwd === '') continue
|
|
127
|
+
if (item.blank === true) continue
|
|
128
|
+
// Canvas archiving is persistent UI state. A normal DSH list refresh
|
|
129
|
+
// must not recreate a session that the user deliberately archived.
|
|
130
|
+
if (this.state.hiddenSessionIds.includes(item.id)) continue
|
|
131
|
+
const workspace = this.dshWorkspace(item.cwd, 'DSH 任务')
|
|
132
|
+
const session = { id: item.id, header: { meta: { cwd: item.cwd }, parentSession: typeof item.parentId === 'string' ? item.parentId : undefined }, title: typeof item.title === 'string' ? item.title : undefined, events: [] }
|
|
133
|
+
const thread = this.dshThread(workspace, session)
|
|
134
|
+
if (typeof item.title === 'string' && item.title.trim() !== '') {
|
|
135
|
+
thread.title = item.title.slice(0, MAX_TITLE_LENGTH)
|
|
136
|
+
thread.dshSessionTitle = thread.title
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return this.list()
|
|
140
|
+
}, { deferred: true })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async addMessage(threadId, text) {
|
|
144
|
+
return this.mutate(() => {
|
|
145
|
+
const { workspace, thread } = this.locateThread(threadId)
|
|
146
|
+
const at = new Date().toISOString()
|
|
147
|
+
const message = { id: randomUUID(), text: requiredText(text, MAX_NOTE_LENGTH, 'text'), kind: 'user', at }
|
|
148
|
+
thread.messages.push(message)
|
|
149
|
+
thread.updatedAt = at
|
|
150
|
+
workspace.updatedAt = at
|
|
151
|
+
return structuredClone(thread)
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async updateThread(threadId, input) {
|
|
156
|
+
return this.mutate(() => {
|
|
157
|
+
const { workspace, thread } = this.locateThread(threadId)
|
|
158
|
+
if (input?.title !== undefined) thread.title = requiredText(input.title, MAX_TITLE_LENGTH, 'title')
|
|
159
|
+
if (input?.position !== undefined) thread.position = positionOf(input.position)
|
|
160
|
+
thread.updatedAt = new Date().toISOString()
|
|
161
|
+
workspace.updatedAt = thread.updatedAt
|
|
162
|
+
return structuredClone(thread)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async removeThread(threadId) {
|
|
167
|
+
return this.mutate(() => {
|
|
168
|
+
const { workspace, thread } = this.locateThread(threadId)
|
|
169
|
+
const removal = new Set([thread.id])
|
|
170
|
+
for (let changed = true; changed;) {
|
|
171
|
+
changed = false
|
|
172
|
+
for (const item of workspace.threads) {
|
|
173
|
+
if (item.parentId !== null && removal.has(item.parentId) && !removal.has(item.id)) { removal.add(item.id); changed = true }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const item of workspace.threads) {
|
|
177
|
+
if (removal.has(item.id) && item.dshSessionId !== null && !this.state.hiddenSessionIds.includes(item.dshSessionId)) this.state.hiddenSessionIds.push(item.dshSessionId)
|
|
178
|
+
}
|
|
179
|
+
workspace.threads = workspace.threads.filter(item => !removal.has(item.id))
|
|
180
|
+
workspace.updatedAt = new Date().toISOString()
|
|
181
|
+
if (workspace.threads.length === 0) this.state.workspaces = this.state.workspaces.filter(item => item.id !== workspace.id)
|
|
182
|
+
return { removed: removal.size }
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async clearLegacy(sessions) {
|
|
187
|
+
return this.mutate(() => {
|
|
188
|
+
const hidden = new Set(this.state.hiddenSessionIds)
|
|
189
|
+
for (const workspace of this.state.workspaces) for (const thread of workspace.threads) if (thread.dshSessionId !== null) hidden.add(thread.dshSessionId)
|
|
190
|
+
for (const session of sessions) hidden.add(session.id)
|
|
191
|
+
this.state.hiddenSessionIds = [...hidden]
|
|
192
|
+
this.state.workspaces = []
|
|
193
|
+
return { cleared: true }
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Replay one live DSH session into the dedicated projection workspace. */
|
|
198
|
+
async projectSession(session, replayFrom = 0, workspaceTitle = 'DSH 任务') {
|
|
199
|
+
return this.mutate(() => {
|
|
200
|
+
if (this.state.hiddenSessionIds.includes(session.id)) return null
|
|
201
|
+
const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
|
|
202
|
+
const thread = this.dshThread(workspace, session)
|
|
203
|
+
for (const event of session.events) {
|
|
204
|
+
if (event.seq >= replayFrom) this.projectEventInto(workspace, thread, event)
|
|
205
|
+
}
|
|
206
|
+
return structuredClone(thread)
|
|
207
|
+
}, { deferred: true })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Project one committed DSH session event. Repeated sequence numbers are ignored. */
|
|
211
|
+
async projectEvent(session, event, workspaceTitle = 'DSH 任务') {
|
|
212
|
+
return this.mutate(() => {
|
|
213
|
+
if (this.state.hiddenSessionIds.includes(session.id)) return null
|
|
214
|
+
const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
|
|
215
|
+
const thread = this.dshThread(workspace, session)
|
|
216
|
+
this.projectEventInto(workspace, thread, event)
|
|
217
|
+
return structuredClone(thread)
|
|
218
|
+
}, { deferred: true })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Project a batch of committed events for one session in a single write. */
|
|
222
|
+
async projectEvents(session, events, workspaceTitle = 'DSH 任务') {
|
|
223
|
+
if (events.length === 0) return null
|
|
224
|
+
return this.mutate(() => {
|
|
225
|
+
if (this.state.hiddenSessionIds.includes(session.id)) return null
|
|
226
|
+
const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
|
|
227
|
+
const thread = this.dshThread(workspace, session)
|
|
228
|
+
for (const event of events) this.projectEventInto(workspace, thread, event)
|
|
229
|
+
return structuredClone(thread)
|
|
230
|
+
}, { deferred: true })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async load() {
|
|
234
|
+
await mkdir(dirname(this.dataFile), { recursive: true })
|
|
235
|
+
try {
|
|
236
|
+
const parsed = JSON.parse(await readFile(this.dataFile, 'utf8'))
|
|
237
|
+
const { state, migrated } = normalizeState(parsed)
|
|
238
|
+
this.state = state
|
|
239
|
+
if (migrated) await this.save()
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error?.code !== 'ENOENT') throw new Error(`synapse: cannot read ${this.dataFile}: ${error.message}`)
|
|
242
|
+
this.state = { version: 4, hiddenSessionIds: [], workspaces: [] }
|
|
243
|
+
await this.save()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async mutate(action, { deferred = false } = {}) {
|
|
248
|
+
await this.ready
|
|
249
|
+
const task = this.serial.then(async () => {
|
|
250
|
+
const result = action()
|
|
251
|
+
if (deferred) this.markDirty()
|
|
252
|
+
else await this.save()
|
|
253
|
+
return result
|
|
254
|
+
})
|
|
255
|
+
this.serial = task.catch(() => undefined)
|
|
256
|
+
return task
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Mark the state dirty and schedule one trailing flush for the window. */
|
|
260
|
+
markDirty() {
|
|
261
|
+
this.dirty = true
|
|
262
|
+
if (this.flushTimer !== null) return
|
|
263
|
+
this.flushTimer = setTimeout(() => {
|
|
264
|
+
this.flushTimer = null
|
|
265
|
+
void this.flush()
|
|
266
|
+
}, SAVE_DEBOUNCE_MS)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Persist the current state when dirty, ordered after in-flight mutations. */
|
|
270
|
+
flush() {
|
|
271
|
+
if (!this.dirty) return Promise.resolve()
|
|
272
|
+
this.dirty = false
|
|
273
|
+
const task = this.serial.then(() => this.save())
|
|
274
|
+
this.serial = task.catch(() => undefined)
|
|
275
|
+
return task
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async save() {
|
|
279
|
+
// Two dsh web instances sharing one profile clobber each other's canvas
|
|
280
|
+
// state. Warn loudly instead of silently losing work; a live lock held by
|
|
281
|
+
// another process or a file mtime that moved since our last write both
|
|
282
|
+
// indicate a second writer.
|
|
283
|
+
const before = await this.fileMtime()
|
|
284
|
+
if (this.lastKnownMtime !== null && before !== null && before !== this.lastKnownMtime) {
|
|
285
|
+
this.lastKnownMtime = before
|
|
286
|
+
if (!this.externalModWarned) {
|
|
287
|
+
this.externalModWarned = true
|
|
288
|
+
process.stderr.write('synapse: workspaces.json 已被另一个 dsh web 实例修改,本实例的写入可能覆盖其更改——请只运行一个实例\n')
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
await this.acquireLock()
|
|
292
|
+
try {
|
|
293
|
+
const temporaryFile = `${this.dataFile}.${process.pid}.tmp`
|
|
294
|
+
await writeFile(temporaryFile, `${JSON.stringify(this.state)}\n`, 'utf8')
|
|
295
|
+
await rename(temporaryFile, this.dataFile)
|
|
296
|
+
this.lastKnownMtime = (await stat(this.dataFile)).mtimeMs
|
|
297
|
+
} finally {
|
|
298
|
+
await this.releaseLock()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async fileMtime() {
|
|
303
|
+
try { return (await stat(this.dataFile)).mtimeMs } catch { return null }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Take an exclusive cross-process lock, breaking a stale one; warn when a live process holds it. */
|
|
307
|
+
async acquireLock() {
|
|
308
|
+
const lockFile = `${this.dataFile}.lock`
|
|
309
|
+
if (await this.tryAcquire(lockFile)) return
|
|
310
|
+
if (await this.lockIsStale(lockFile)) {
|
|
311
|
+
await unlink(lockFile).catch(() => {})
|
|
312
|
+
if (await this.tryAcquire(lockFile)) return
|
|
313
|
+
}
|
|
314
|
+
if (!this.lockWarned) {
|
|
315
|
+
this.lockWarned = true
|
|
316
|
+
process.stderr.write('synapse: 另一个 dsh web 实例正在写入 workspaces.json——请只运行一个实例,否则画布数据可能互相覆盖\n')
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async tryAcquire(lockFile) {
|
|
321
|
+
try {
|
|
322
|
+
await writeFile(lockFile, `${process.pid}\n`, { flag: 'wx' })
|
|
323
|
+
return true
|
|
324
|
+
} catch {
|
|
325
|
+
return false
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A lock is stale when its owner PID is gone or the lock file is older than the stale window. */
|
|
330
|
+
async lockIsStale(lockFile) {
|
|
331
|
+
try {
|
|
332
|
+
const [content, stats] = await Promise.all([readFile(lockFile, 'utf8'), stat(lockFile)])
|
|
333
|
+
const tooOld = Date.now() - stats.mtimeMs > LOCK_STALE_MS
|
|
334
|
+
const pid = Number.parseInt(content, 10)
|
|
335
|
+
if (!Number.isInteger(pid)) return tooOld
|
|
336
|
+
if (pid === process.pid) return false
|
|
337
|
+
try {
|
|
338
|
+
process.kill(pid, 0)
|
|
339
|
+
return tooOld
|
|
340
|
+
} catch {
|
|
341
|
+
return true
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
return false
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async releaseLock() {
|
|
349
|
+
await unlink(`${this.dataFile}.lock`).catch(() => {})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
workspace(workspaceId) {
|
|
353
|
+
const workspace = this.state.workspaces.find(item => item.id === workspaceId)
|
|
354
|
+
if (workspace === undefined) throw new NotFoundError('工作空间不存在')
|
|
355
|
+
return workspace
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
locateThread(threadId) {
|
|
359
|
+
for (const workspace of this.state.workspaces) {
|
|
360
|
+
const thread = workspace.threads.find(item => item.id === threadId)
|
|
361
|
+
if (thread !== undefined) return { workspace, thread }
|
|
362
|
+
}
|
|
363
|
+
throw new NotFoundError('节点不存在')
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
dshWorkspace(cwd, fallbackTitle) {
|
|
367
|
+
let workspace = this.state.workspaces.find(item => item.kind === 'dsh' && item.cwd === cwd)
|
|
368
|
+
if (workspace !== undefined) return workspace
|
|
369
|
+
const now = new Date().toISOString()
|
|
370
|
+
workspace = { id: randomUUID(), kind: 'dsh', cwd, title: workspaceTitle(cwd, fallbackTitle), createdAt: now, updatedAt: now, threads: [] }
|
|
371
|
+
this.state.workspaces.unshift(workspace)
|
|
372
|
+
return workspace
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
dshThread(workspace, session) {
|
|
376
|
+
let thread = workspace.threads.find(item => item.dshSessionId === session.id)
|
|
377
|
+
if (thread !== undefined) {
|
|
378
|
+
if (typeof session.title === 'string' && session.title.trim() !== '') {
|
|
379
|
+
const title = session.title.slice(0, MAX_TITLE_LENGTH)
|
|
380
|
+
thread.title = title
|
|
381
|
+
thread.dshSessionTitle = title
|
|
382
|
+
}
|
|
383
|
+
// `seedLength` is DSH's durable fork cut. Keep it even after the
|
|
384
|
+
// session has been restored, when its in-process `firstLiveSeq` moves.
|
|
385
|
+
const seedLength = session.header?.seedLength
|
|
386
|
+
if (Number.isSafeInteger(seedLength) && seedLength >= 0) thread.sourceSeedLength = seedLength
|
|
387
|
+
return thread
|
|
388
|
+
}
|
|
389
|
+
const parentSessionId = typeof session.header?.parentSession === 'string' ? session.header.parentSession : null
|
|
390
|
+
const parent = parentSessionId === null ? undefined : workspace.threads.find(item => item.dshSessionId === parentSessionId)
|
|
391
|
+
const siblings = workspace.threads.filter(item => item.sourceParentSessionId === parentSessionId)
|
|
392
|
+
const now = new Date().toISOString()
|
|
393
|
+
thread = {
|
|
394
|
+
id: randomUUID(),
|
|
395
|
+
title: typeof session.title === 'string' && session.title.trim() !== '' ? session.title.slice(0, MAX_TITLE_LENGTH) : (parent === undefined ? 'DSH 会话' : `${parent.title} 分支`),
|
|
396
|
+
parentId: parent?.id ?? null,
|
|
397
|
+
sourceParentSessionId: parentSessionId,
|
|
398
|
+
sourceSeedLength: Number.isSafeInteger(session.header?.seedLength) && session.header.seedLength >= 0 ? session.header.seedLength : null,
|
|
399
|
+
dshSessionId: session.id,
|
|
400
|
+
dshSessionTitle: typeof session.title === 'string' ? session.title.slice(0, MAX_TITLE_LENGTH) : null,
|
|
401
|
+
color: TOPIC_COLORS[workspace.threads.length % TOPIC_COLORS.length],
|
|
402
|
+
// DSH projection stores only a neutral semantic anchor. The visual map
|
|
403
|
+
// lays out visible cards from the current conversation graph each render,
|
|
404
|
+
// so old/archived session counts must never leak into future coordinates.
|
|
405
|
+
position: parent === undefined ? { x: 86, y: 82 } : { x: parent.position.x + 400, y: parent.position.y },
|
|
406
|
+
createdAt: now,
|
|
407
|
+
updatedAt: now,
|
|
408
|
+
messages: [],
|
|
409
|
+
}
|
|
410
|
+
workspace.threads.push(thread)
|
|
411
|
+
// A child may arrive before its parent during startup replay. Repair that
|
|
412
|
+
// relation when the missing parent later reaches the projection.
|
|
413
|
+
for (const child of workspace.threads) {
|
|
414
|
+
if (child.sourceParentSessionId === session.id && child.parentId === null) child.parentId = thread.id
|
|
415
|
+
}
|
|
416
|
+
workspace.updatedAt = now
|
|
417
|
+
return thread
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
projectEventInto(workspace, thread, event) {
|
|
421
|
+
if (event.type === 'session/title' && typeof event.data?.title === 'string') {
|
|
422
|
+
thread.title = event.data.title.slice(0, MAX_TITLE_LENGTH)
|
|
423
|
+
thread.dshSessionTitle = thread.title
|
|
424
|
+
thread.updatedAt = new Date(event.time).toISOString()
|
|
425
|
+
workspace.updatedAt = thread.updatedAt
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
if (event.type === 'tool/call' || event.type === 'tool/result') {
|
|
429
|
+
this.foldToolProcess(thread, event)
|
|
430
|
+
workspace.updatedAt = thread.updatedAt
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
const projection = projectableEvent(event)
|
|
434
|
+
if (projection === null || thread.messages.some(message => message.sourceSeq === event.seq)) return
|
|
435
|
+
const at = new Date(event.time).toISOString()
|
|
436
|
+
const message = {
|
|
437
|
+
id: randomUUID(),
|
|
438
|
+
text: projection.text,
|
|
439
|
+
kind: projection.kind,
|
|
440
|
+
sourceSeq: event.seq,
|
|
441
|
+
at,
|
|
442
|
+
...(projection.kind === 'assistant'
|
|
443
|
+
? { turn: event.data.turn, step: event.data.step, process: [] }
|
|
444
|
+
: {}),
|
|
445
|
+
}
|
|
446
|
+
thread.messages.push(message)
|
|
447
|
+
thread.updatedAt = at
|
|
448
|
+
workspace.updatedAt = at
|
|
449
|
+
if (thread.dshSessionTitle === null && projection.kind === 'user') {
|
|
450
|
+
thread.title = titleFromText(projection.text)
|
|
451
|
+
thread.dshSessionTitle = thread.title
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Fold one tool call or result into the assistant message of its own
|
|
457
|
+
* turn/step, keyed by `callId`, so a tool invocation never becomes a
|
|
458
|
+
* separate canvas card. A legacy assistant message without `turn`/`step` is
|
|
459
|
+
* the fallback target; an event with no such target is dropped.
|
|
460
|
+
*/
|
|
461
|
+
foldToolProcess(thread, event) {
|
|
462
|
+
const at = new Date(event.time).toISOString()
|
|
463
|
+
const target = [...thread.messages].reverse().find(message =>
|
|
464
|
+
message.kind === 'assistant'
|
|
465
|
+
&& (message.turn === event.data.turn && message.step === event.data.step
|
|
466
|
+
|| message.turn === undefined && message.step === undefined))
|
|
467
|
+
if (target === undefined) return
|
|
468
|
+
const process = target.process ??= []
|
|
469
|
+
const callId = String(event.type === 'tool/call' ? event.data.callId : event.data.message?.source?.callId ?? '')
|
|
470
|
+
const entry = process.find(item => item.callId === callId)
|
|
471
|
+
if (event.type === 'tool/call') {
|
|
472
|
+
if (entry === undefined) {
|
|
473
|
+
process.push({ callId, name: event.data.name, arguments: event.data.arguments, result: null, error: null })
|
|
474
|
+
} else {
|
|
475
|
+
entry.name = event.data.name
|
|
476
|
+
entry.arguments = event.data.arguments
|
|
477
|
+
}
|
|
478
|
+
} else {
|
|
479
|
+
const outcome = contentText(event.data.message?.content)
|
|
480
|
+
const error = event.data.error === undefined ? null : `${event.data.error.name}: ${event.data.error.code}`
|
|
481
|
+
if (entry === undefined) {
|
|
482
|
+
process.push({ callId, name: '工具调用', arguments: null, result: outcome, error })
|
|
483
|
+
} else {
|
|
484
|
+
entry.result = outcome
|
|
485
|
+
entry.error = error
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
thread.updatedAt = at
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
thread({ title, parentId, dshSessionId, dshSessionTitle, position, color, now, order }) {
|
|
492
|
+
return {
|
|
493
|
+
id: randomUUID(),
|
|
494
|
+
title: requiredText(title, MAX_TITLE_LENGTH, 'title'),
|
|
495
|
+
parentId: typeof parentId === 'string' && parentId.length > 0 ? parentId : null,
|
|
496
|
+
dshSessionId: typeof dshSessionId === 'string' && dshSessionId.length > 0 ? dshSessionId : null,
|
|
497
|
+
dshSessionTitle: typeof dshSessionTitle === 'string' ? dshSessionTitle.slice(0, MAX_TITLE_LENGTH) : null,
|
|
498
|
+
color: TOPIC_COLORS.includes(color) ? color : TOPIC_COLORS[order % TOPIC_COLORS.length],
|
|
499
|
+
position: positionOf(position ?? { x: 86 + (order % 3) * 410, y: 82 + Math.floor(order / 3) * 260 }),
|
|
500
|
+
createdAt: now,
|
|
501
|
+
updatedAt: now,
|
|
502
|
+
messages: [],
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
summary(workspace) {
|
|
507
|
+
return { id: workspace.id, kind: workspace.kind ?? 'manual', cwd: workspace.cwd ?? null, title: workspace.title, createdAt: workspace.createdAt, updatedAt: workspace.updatedAt, threadCount: workspace.threads.length }
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
class InputError extends Error {}
|
|
512
|
+
class NotFoundError extends Error {}
|
|
513
|
+
|
|
514
|
+
function normalizeState(value) {
|
|
515
|
+
let migrated = false
|
|
516
|
+
let state
|
|
517
|
+
if ((value?.version === 2 || value?.version === 3 || value?.version === 4) && Array.isArray(value.workspaces)) {
|
|
518
|
+
const hiddenSessionIds = Array.isArray(value.hiddenSessionIds) ? value.hiddenSessionIds.filter(item => typeof item === 'string') : []
|
|
519
|
+
migrated = value.version < 3 || !Array.isArray(value.hiddenSessionIds)
|
|
520
|
+
const workspaces = value.workspaces.map(workspace => ({
|
|
521
|
+
...workspace,
|
|
522
|
+
threads: Array.isArray(workspace.threads) ? workspace.threads.map(thread => {
|
|
523
|
+
if (Array.isArray(thread.messages)) {
|
|
524
|
+
const messages = thread.messages.filter(message => !isRuntimeContextMessage(message))
|
|
525
|
+
if (messages.length !== thread.messages.length) migrated = true
|
|
526
|
+
return { ...thread, messages }
|
|
527
|
+
}
|
|
528
|
+
migrated = true
|
|
529
|
+
const notes = Array.isArray(thread.notes) ? thread.notes : []
|
|
530
|
+
const { notes: _notes, ...rest } = thread
|
|
531
|
+
return { ...rest, messages: notes }
|
|
532
|
+
}) : [],
|
|
533
|
+
}))
|
|
534
|
+
state = { ...value, version: value.version, hiddenSessionIds, workspaces }
|
|
535
|
+
} else if (value?.version === 1 && Array.isArray(value.workspaces)) {
|
|
536
|
+
const now = typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString()
|
|
537
|
+
state = {
|
|
538
|
+
version: 3,
|
|
539
|
+
hiddenSessionIds: [],
|
|
540
|
+
workspaces: value.workspaces.map((workspace, index) => {
|
|
541
|
+
const events = Array.isArray(workspace.events) ? workspace.events : []
|
|
542
|
+
const workspaceNow = typeof workspace.updatedAt === 'string' ? workspace.updatedAt : now
|
|
543
|
+
return {
|
|
544
|
+
id: typeof workspace.id === 'string' ? workspace.id : randomUUID(),
|
|
545
|
+
title: typeof workspace.title === 'string' && workspace.title.trim() ? workspace.title : '未命名工作空间',
|
|
546
|
+
createdAt: typeof workspace.createdAt === 'string' ? workspace.createdAt : workspaceNow,
|
|
547
|
+
updatedAt: workspaceNow,
|
|
548
|
+
threads: events.length === 0 ? [] : [{
|
|
549
|
+
id: randomUUID(), title: workspace.title || '历史记录', parentId: null, dshSessionId: null, dshSessionTitle: null,
|
|
550
|
+
color: TOPIC_COLORS[index % TOPIC_COLORS.length], position: { x: 86, y: 82 }, createdAt: workspaceNow, updatedAt: workspaceNow,
|
|
551
|
+
messages: events.map(event => ({ id: typeof event.id === 'string' ? event.id : randomUUID(), text: String(event.text ?? ''), at: typeof event.at === 'string' ? event.at : workspaceNow })),
|
|
552
|
+
}],
|
|
553
|
+
}
|
|
554
|
+
}),
|
|
555
|
+
}
|
|
556
|
+
migrated = true
|
|
557
|
+
} else {
|
|
558
|
+
throw new Error('expected Synapse data version 1, 2, 3, or 4')
|
|
559
|
+
}
|
|
560
|
+
if (state.version !== 4) {
|
|
561
|
+
if (foldLegacyToolCards(state.workspaces)) migrated = true
|
|
562
|
+
state.version = 4
|
|
563
|
+
migrated = true
|
|
564
|
+
}
|
|
565
|
+
return { state, migrated }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Fold v3-era standalone tool cards (kinds `tool` / `tool-result`) into the
|
|
570
|
+
* preceding assistant message's `process` list, pairing each call with the
|
|
571
|
+
* result that follows it in order, so every tool invocation lives in one
|
|
572
|
+
* home: the assistant turn card.
|
|
573
|
+
*/
|
|
574
|
+
function foldLegacyToolCards(workspaces) {
|
|
575
|
+
let changed = false
|
|
576
|
+
for (const workspace of workspaces) {
|
|
577
|
+
for (const thread of workspace.threads ?? []) {
|
|
578
|
+
if (!Array.isArray(thread.messages)) continue
|
|
579
|
+
const folded = []
|
|
580
|
+
let assistant = null
|
|
581
|
+
let pending = []
|
|
582
|
+
for (const message of thread.messages) {
|
|
583
|
+
if (message.kind === 'assistant') {
|
|
584
|
+
assistant = message
|
|
585
|
+
assistant.process ??= []
|
|
586
|
+
pending = []
|
|
587
|
+
folded.push(message)
|
|
588
|
+
continue
|
|
589
|
+
}
|
|
590
|
+
if (message.kind !== 'tool' && message.kind !== 'tool-result') {
|
|
591
|
+
folded.push(message)
|
|
592
|
+
continue
|
|
593
|
+
}
|
|
594
|
+
if (assistant === null) {
|
|
595
|
+
folded.push(message)
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
598
|
+
changed = true
|
|
599
|
+
if (message.kind === 'tool') {
|
|
600
|
+
const [name = '工具调用', ...argumentLines] = message.text.split('\n')
|
|
601
|
+
const entry = { callId: `legacy-${assistant.process.length}`, name, arguments: argumentLines.join('\n'), result: null, error: null }
|
|
602
|
+
pending.push(entry)
|
|
603
|
+
assistant.process.push(entry)
|
|
604
|
+
} else {
|
|
605
|
+
const entry = pending.shift() ?? (() => {
|
|
606
|
+
const orphan = { callId: `legacy-orphan-${assistant.process.length}`, name: '工具调用', arguments: null, result: null, error: null }
|
|
607
|
+
assistant.process.push(orphan)
|
|
608
|
+
return orphan
|
|
609
|
+
})()
|
|
610
|
+
entry.result = message.text
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
thread.messages = folded
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return changed
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function positionOf(value) {
|
|
620
|
+
const x = Number(value?.x)
|
|
621
|
+
const y = Number(value?.y)
|
|
622
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new InputError('position 必须包含有效坐标')
|
|
623
|
+
return { x: Math.round(Math.max(-2000, Math.min(5000, x))), y: Math.round(Math.max(-2000, Math.min(5000, y))) }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function requiredText(value, maxLength, field) {
|
|
627
|
+
if (typeof value !== 'string') throw new InputError(`${field} 必须是文本`)
|
|
628
|
+
const text = value.trim()
|
|
629
|
+
if (text.length === 0) throw new InputError(`${field} 不能为空`)
|
|
630
|
+
if (text.length > maxLength) throw new InputError(`${field} 超过长度限制`)
|
|
631
|
+
return text
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function projectableEvent(event) {
|
|
635
|
+
switch (event.type) {
|
|
636
|
+
case 'user/message': {
|
|
637
|
+
const text = contentText(event.data.content)
|
|
638
|
+
return isRuntimeContextText(text) ? null : noteProjection('user', text)
|
|
639
|
+
}
|
|
640
|
+
case 'assistant/message':
|
|
641
|
+
return noteProjection('assistant', contentText(event.data.message.content))
|
|
642
|
+
case 'todo/write':
|
|
643
|
+
return noteProjection('todo', event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n'))
|
|
644
|
+
case 'turn/end':
|
|
645
|
+
return event.data.reason.kind === 'error' ? noteProjection('error', event.data.reason.error.message) : null
|
|
646
|
+
default:
|
|
647
|
+
return null
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function noteProjection(kind, text) {
|
|
652
|
+
const normalized = text.trim()
|
|
653
|
+
if (normalized === '') return null
|
|
654
|
+
if (normalized.length <= MAX_PROJECTION_LENGTH) return { kind, text: normalized }
|
|
655
|
+
return { kind, text: `${normalized.slice(0, MAX_PROJECTION_LENGTH)}${PROJECTION_TRUNCATED_SUFFIX}` }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function isRuntimeContextText(text) {
|
|
659
|
+
return typeof text === 'string' && text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function isRuntimeContextMessage(message) {
|
|
663
|
+
return message?.kind === 'user' && isRuntimeContextText(message.text)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function contentText(content) {
|
|
667
|
+
if (!Array.isArray(content)) return ''
|
|
668
|
+
return content.flatMap(block => {
|
|
669
|
+
if (block?.type === 'text') return [block.text]
|
|
670
|
+
if (block?.type === 'tool-call') return [block.name, block.arguments]
|
|
671
|
+
if (block?.type === 'tool-result') return contentText(block.content)
|
|
672
|
+
return []
|
|
673
|
+
}).filter(value => typeof value === 'string' && value.trim() !== '').join('\n')
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function titleFromText(text) {
|
|
677
|
+
const line = text.replaceAll(/\s+/g, ' ').trim()
|
|
678
|
+
return (line.length > 42 ? `${line.slice(0, 42)}...` : line) || 'DSH 会话'
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function sessionCwd(session) {
|
|
682
|
+
const cwd = session.header?.meta?.cwd ?? session.header?.cwd
|
|
683
|
+
return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : '未指定工作目录'
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function workspaceTitle(cwd, fallbackTitle) {
|
|
687
|
+
if (cwd === '未指定工作目录') return fallbackTitle
|
|
688
|
+
const segment = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1)
|
|
689
|
+
return segment && segment.trim() !== '' ? segment : fallbackTitle
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function readJson(req) {
|
|
693
|
+
const chunks = []
|
|
694
|
+
let length = 0
|
|
695
|
+
for await (const chunk of req) {
|
|
696
|
+
length += chunk.length
|
|
697
|
+
if (length > MAX_BODY_BYTES) throw new InputError('请求内容过大')
|
|
698
|
+
chunks.push(chunk)
|
|
699
|
+
}
|
|
700
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new InputError('请求不是有效 JSON') }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function sendJson(res, status, body) {
|
|
704
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
705
|
+
res.end(JSON.stringify(body))
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function sendFile(res, contentType, body) {
|
|
709
|
+
res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' })
|
|
710
|
+
res.end(body)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function page() {
|
|
714
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Synapse for DSH</title><link rel="stylesheet" href="/synapse/styles.css"></head><body><div id="app"></div><script src="/synapse/app.js"></script></body></html>`
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Mount Synapse routes on the existing DSH Web Server. */
|
|
718
|
+
export function apply(ctx, config) {
|
|
719
|
+
const store = new WorkspaceStore(config?.dataFile)
|
|
720
|
+
const autoProjection = config?.autoProjection !== false
|
|
721
|
+
const projectionWorkspaceTitle = typeof config?.projectionWorkspaceTitle === 'string' && config.projectionWorkspaceTitle.trim() !== ''
|
|
722
|
+
? config.projectionWorkspaceTitle.trim().slice(0, MAX_TITLE_LENGTH)
|
|
723
|
+
: 'DSH 任务'
|
|
724
|
+
const reportProjectionFailure = error => {
|
|
725
|
+
ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
|
|
726
|
+
}
|
|
727
|
+
const replaySession = session => {
|
|
728
|
+
// Forks inherit their parent's log. The canvas already represents that
|
|
729
|
+
// history through the parent node, so only project the child's live tail.
|
|
730
|
+
const replayFrom = session.header?.parentSession === undefined ? 0 : session.firstLiveSeq
|
|
731
|
+
void store.projectSession(session, replayFrom, projectionWorkspaceTitle).catch(reportProjectionFailure)
|
|
732
|
+
}
|
|
733
|
+
// Buffer live events per session and flush them in one write per microtask,
|
|
734
|
+
// so a burst of turn events coalesces into a single save instead of N.
|
|
735
|
+
const projectionQueue = []
|
|
736
|
+
let projectionScheduled = false
|
|
737
|
+
const enqueueProjection = (session, event) => {
|
|
738
|
+
projectionQueue.push({ session, event })
|
|
739
|
+
if (projectionScheduled) return
|
|
740
|
+
projectionScheduled = true
|
|
741
|
+
queueMicrotask(() => {
|
|
742
|
+
projectionScheduled = false
|
|
743
|
+
const batch = projectionQueue.splice(0)
|
|
744
|
+
const bySession = new Map()
|
|
745
|
+
for (const item of batch) {
|
|
746
|
+
const entry = bySession.get(item.session.id)
|
|
747
|
+
if (entry === undefined) bySession.set(item.session.id, [item.session, [item.event]])
|
|
748
|
+
else entry[1].push(item.event)
|
|
749
|
+
}
|
|
750
|
+
for (const [sessionId, [session, events]] of bySession) {
|
|
751
|
+
void store.projectEvents(session, events, projectionWorkspaceTitle).catch(reportProjectionFailure)
|
|
752
|
+
}
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
if (autoProjection) {
|
|
756
|
+
ctx.on('session/created', replaySession)
|
|
757
|
+
ctx.on('session/event', enqueueProjection)
|
|
758
|
+
for (const session of ctx.sessions.list()) replaySession(session)
|
|
759
|
+
}
|
|
760
|
+
// The DSH /api browser-trust fence does not cover /synapse routes, so this
|
|
761
|
+
// handler checks the Host header itself: localhost is allowed by default and
|
|
762
|
+
// additional authorities opt in through config.trustedHosts (mirrors the
|
|
763
|
+
// fence's DNS-rebinding defense).
|
|
764
|
+
const trustedHosts = new Set(['localhost', '127.0.0.1', ...[...(config?.trustedHosts ?? [])].map(host => String(host).trim().toLowerCase()).filter(Boolean)])
|
|
765
|
+
const api = async (req, res) => {
|
|
766
|
+
try {
|
|
767
|
+
const hostname = (typeof req.headers.host === 'string' ? req.headers.host : '').replace(/:\d+$/, '').toLowerCase()
|
|
768
|
+
if (!trustedHosts.has(hostname)) return sendJson(res, 403, { error: '不被信任的 Host' })
|
|
769
|
+
const path = new URL(req.url ?? '/', 'http://dsh.local').pathname
|
|
770
|
+
if (path === '/synapse/api/reset' && req.method === 'POST') return sendJson(res, 200, await store.clearLegacy(ctx.sessions.list()))
|
|
771
|
+
if (path === '/synapse/api/workspaces') {
|
|
772
|
+
if (req.method === 'GET') return sendJson(res, 200, { workspaces: await store.list() })
|
|
773
|
+
if (req.method === 'POST') return sendJson(res, 201, { workspace: await store.create((await readJson(req)).title) })
|
|
774
|
+
}
|
|
775
|
+
const workspace = /^\/synapse\/api\/workspaces\/([0-9a-f-]+)$/i.exec(path)
|
|
776
|
+
if (workspace !== null) {
|
|
777
|
+
if (req.method === 'GET') return sendJson(res, 200, { workspace: await store.get(workspace[1]) })
|
|
778
|
+
if (req.method === 'POST') return sendJson(res, 201, { thread: await store.createThread(workspace[1], await readJson(req)) })
|
|
779
|
+
}
|
|
780
|
+
const branch = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/branch$/i.exec(path)
|
|
781
|
+
if (branch !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.branch(branch[1], await readJson(req)) })
|
|
782
|
+
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) }) }
|
|
783
|
+
const messages = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/messages$/i.exec(path)
|
|
784
|
+
if (messages !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.addMessage(messages[1], (await readJson(req)).text) })
|
|
785
|
+
const thread = /^\/synapse\/api\/threads\/([0-9a-f-]+)$/i.exec(path)
|
|
786
|
+
if (thread !== null && req.method === 'PATCH') return sendJson(res, 200, { thread: await store.updateThread(thread[1], await readJson(req)) })
|
|
787
|
+
if (thread !== null && req.method === 'DELETE') return sendJson(res, 200, await store.removeThread(thread[1]))
|
|
788
|
+
return sendJson(res, 404, { error: '接口不存在' })
|
|
789
|
+
} catch (error) {
|
|
790
|
+
if (error instanceof InputError) return sendJson(res, 400, { error: error.message })
|
|
791
|
+
if (error instanceof NotFoundError) return sendJson(res, 404, { error: error.message })
|
|
792
|
+
ctx.logger.error(error instanceof Error ? error : new Error(String(error)))
|
|
793
|
+
return sendJson(res, 500, { error: 'Synapse 数据暂时不可用' })
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse', handler: (_req, res) => { res.writeHead(302, { location: '/synapse/' }); res.end() } }), 'synapse: redirect')
|
|
797
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/', handler: (_req, res) => { sendFile(res, 'text/html; charset=utf-8', page()) } }), 'synapse: page')
|
|
798
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/app.js', handler: async (_req, res) => { sendFile(res, 'text/javascript; charset=utf-8', await readFile(new URL('./app.js', import.meta.url), 'utf8')) } }), 'synapse: app')
|
|
799
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/styles.css', handler: async (_req, res) => { sendFile(res, 'text/css; charset=utf-8', await readFile(new URL('./styles.css', import.meta.url), 'utf8')) } }), 'synapse: styles')
|
|
800
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/deepseek-mark.svg', handler: async (_req, res) => { sendFile(res, 'image/svg+xml', await readFile(new URL('./deepseek-mark.svg', import.meta.url), 'utf8')) } }), 'synapse: DeepSeek mark')
|
|
801
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/synapse/api', handler: api }), 'synapse: api')
|
|
802
|
+
}
|