dsh-synapse 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.js +19 -2
- package/client.js +13 -4
- package/index.js +109 -4
- package/package.json +1 -1
package/app.js
CHANGED
|
@@ -268,6 +268,24 @@ function openNewSession() {
|
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
async function archiveThread(thread) {
|
|
271
|
+
// A DSH-backed thread is archived in DSH itself, so the sidebar and this map
|
|
272
|
+
// share one archive set (issue #21 / #19). Keeping a second local delete is
|
|
273
|
+
// what made the map claim "archived" while DSH still listed the session.
|
|
274
|
+
if (thread.dshSessionId !== null) {
|
|
275
|
+
if (!window.confirm(`在 DSH 中归档「${thread.title}」及其分支?DSH 侧会话也会被归档。`)) return
|
|
276
|
+
try {
|
|
277
|
+
await dshRpc('synapse:archive-session', { sessionId: thread.dshSessionId })
|
|
278
|
+
} catch (error) {
|
|
279
|
+
setError(error instanceof Error ? error.message : '归档失败')
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
state.archivedSessionIds.add(thread.dshSessionId)
|
|
283
|
+
state.activeId = state.activeId === thread.id ? (state.workspace?.threads.find(item => item.id !== thread.id)?.id ?? null) : state.activeId
|
|
284
|
+
if (state.inspectorCardId !== null && state.canvasCardsById?.get(state.inspectorCardId)?.dshThreadId === thread.id) state.inspectorCardId = null
|
|
285
|
+
render()
|
|
286
|
+
await refreshSummaries()
|
|
287
|
+
return
|
|
288
|
+
}
|
|
271
289
|
if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
|
|
272
290
|
await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
|
|
273
291
|
state.historyBySession.delete(thread.dshSessionId)
|
|
@@ -303,8 +321,6 @@ async function archiveThread(thread) {
|
|
|
303
321
|
} else {
|
|
304
322
|
state.activeId = null
|
|
305
323
|
}
|
|
306
|
-
// Archive in DSH as well, so both sides agree on one archive set (#21 / #19).
|
|
307
|
-
if (thread.dshSessionId !== null) post('synapse:archive-session', { sessionId: thread.dshSessionId })
|
|
308
324
|
await refreshSummaries()
|
|
309
325
|
}
|
|
310
326
|
|
|
@@ -1872,6 +1888,7 @@ window.addEventListener('message', event => {
|
|
|
1872
1888
|
}
|
|
1873
1889
|
}
|
|
1874
1890
|
if (data.type === 'synapse:forked-session' || data.type === 'synapse:created-session' || data.type === 'synapse:message-sent') settleRpc(data.requestId, data.session ?? data)
|
|
1891
|
+
if (data.type === 'synapse:archived-session') settleRpc(data.requestId, data)
|
|
1875
1892
|
if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
|
|
1876
1893
|
})
|
|
1877
1894
|
|
package/client.js
CHANGED
|
@@ -316,14 +316,23 @@ window.__ModuleLoader__.load({
|
|
|
316
316
|
}
|
|
317
317
|
if (event.data.type === 'synapse:archive-session') {
|
|
318
318
|
// One archive set only: archive through the Workspace action so DSH's
|
|
319
|
-
// sidebar and this map agree, then
|
|
319
|
+
// sidebar and this map agree, then acknowledge the request so the map
|
|
320
|
+
// can hide the card instead of silently diverging (#21 / #19).
|
|
320
321
|
const sessionId = event.data.sessionId
|
|
322
|
+
const requestId = event.data.requestId
|
|
321
323
|
if (typeof sessionId !== 'string' || sessionId === '') return
|
|
322
|
-
if (typeof ctx.workspaces.archiveSession !== 'function')
|
|
324
|
+
if (typeof ctx.workspaces.archiveSession !== 'function') {
|
|
325
|
+
return send('synapse:bridge-error', { requestId, message: '当前 DSH 版本不支持归档接口' })
|
|
326
|
+
}
|
|
323
327
|
Promise.resolve()
|
|
324
328
|
.then(() => ctx.workspaces.archiveSession(sessionId))
|
|
325
|
-
.then(() =>
|
|
326
|
-
|
|
329
|
+
.then(() => {
|
|
330
|
+
send('synapse:archived-session', { requestId, sessionId })
|
|
331
|
+
send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) })
|
|
332
|
+
})
|
|
333
|
+
.catch(error => {
|
|
334
|
+
send('synapse:bridge-error', { requestId, message: `DSH 归档失败:${error instanceof Error ? error.message : String(error)}` })
|
|
335
|
+
})
|
|
327
336
|
return
|
|
328
337
|
}
|
|
329
338
|
if (event.data.type === 'synapse:fork-session') {
|
package/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { copyFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
|
-
import { dirname } from 'node:path'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
4
5
|
|
|
5
6
|
export const name = 'synapse'
|
|
6
7
|
export const inject = ['webServer', 'sessions']
|
|
@@ -805,6 +806,101 @@ export function bodiesFromEvents(thread, events, seqs) {
|
|
|
805
806
|
return messages
|
|
806
807
|
}
|
|
807
808
|
|
|
809
|
+
// ---------------------------------------------------------------------------
|
|
810
|
+
// Session logs on disk
|
|
811
|
+
//
|
|
812
|
+
// `ctx.sessions` only exposes sessions this process currently holds, which is
|
|
813
|
+
// one or two out of a workspace's history. Everything else is read from the DSH
|
|
814
|
+
// session log: a run of concatenated zstd frames holding JSONL events.
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
const SESSION_LOG_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
818
|
+
const SESSION_LOG_PATTERN = /^session(?:\.[\w-]+)*\.jsonl\.zstd$/
|
|
819
|
+
|
|
820
|
+
/** Decode the concatenated zstd frames of one session log into JSONL text. */
|
|
821
|
+
export function decodeSessionLog(buffer) {
|
|
822
|
+
if (typeof zstdDecompressSync !== 'function') return null
|
|
823
|
+
const offsets = []
|
|
824
|
+
let index = buffer.indexOf(SESSION_LOG_MAGIC, 0)
|
|
825
|
+
while (index !== -1) {
|
|
826
|
+
offsets.push(index)
|
|
827
|
+
index = buffer.indexOf(SESSION_LOG_MAGIC, index + 4)
|
|
828
|
+
}
|
|
829
|
+
if (offsets.length === 0) return null
|
|
830
|
+
const parts = []
|
|
831
|
+
for (let i = 0; i < offsets.length; i += 1) {
|
|
832
|
+
const end = i + 1 < offsets.length ? offsets[i + 1] : buffer.length
|
|
833
|
+
try {
|
|
834
|
+
parts.push(zstdDecompressSync(buffer.subarray(offsets[i], end)))
|
|
835
|
+
} catch {
|
|
836
|
+
// A log that is being appended to can end mid-frame; decode what is whole.
|
|
837
|
+
break
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (parts.length === 0) return null
|
|
841
|
+
return Buffer.concat(parts).toString('utf8')
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Reads session events from `$DSH_HOME/sessions`, caching by file mtime. */
|
|
845
|
+
export class SessionLogReader {
|
|
846
|
+
constructor(directory) {
|
|
847
|
+
this.directory = directory
|
|
848
|
+
this.cache = new Map()
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Events of one session, or null when its log is missing or unreadable. */
|
|
852
|
+
async events(sessionId) {
|
|
853
|
+
if (typeof this.directory !== 'string' || this.directory === '' || typeof sessionId !== 'string' || sessionId === '') return null
|
|
854
|
+
const file = await this.findLog(sessionId)
|
|
855
|
+
if (file === null) {
|
|
856
|
+
this.cache.delete(sessionId)
|
|
857
|
+
return null
|
|
858
|
+
}
|
|
859
|
+
const info = await stat(file).catch(() => null)
|
|
860
|
+
if (info === null) return null
|
|
861
|
+
const cached = this.cache.get(sessionId)
|
|
862
|
+
if (cached !== undefined && cached.mtimeMs === info.mtimeMs && cached.size === info.size) return cached.events
|
|
863
|
+
const text = decodeSessionLog(await readFile(file))
|
|
864
|
+
if (text === null) return null
|
|
865
|
+
const events = []
|
|
866
|
+
for (const line of text.split('\n')) {
|
|
867
|
+
if (line.trim() === '') continue
|
|
868
|
+
try {
|
|
869
|
+
const event = JSON.parse(line)
|
|
870
|
+
if (typeof event?.seq === 'number') events.push(event)
|
|
871
|
+
} catch {
|
|
872
|
+
// Trailing partial line while the session is still being written.
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
// Keep a few decoded logs; one screenful of cards touches few threads.
|
|
876
|
+
if (this.cache.size >= 4) this.cache.delete(this.cache.keys().next().value)
|
|
877
|
+
this.cache.set(sessionId, { mtimeMs: info.mtimeMs, size: info.size, events })
|
|
878
|
+
return events
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async findLog(sessionId) {
|
|
882
|
+
let workspaces
|
|
883
|
+
try {
|
|
884
|
+
workspaces = await readdir(this.directory, { withFileTypes: true })
|
|
885
|
+
} catch {
|
|
886
|
+
return null
|
|
887
|
+
}
|
|
888
|
+
for (const workspace of workspaces) {
|
|
889
|
+
if (!workspace.isDirectory()) continue
|
|
890
|
+
const sessionDirectory = join(this.directory, workspace.name, sessionId)
|
|
891
|
+
let files
|
|
892
|
+
try {
|
|
893
|
+
files = await readdir(sessionDirectory)
|
|
894
|
+
} catch {
|
|
895
|
+
continue
|
|
896
|
+
}
|
|
897
|
+
const log = files.find(name => SESSION_LOG_PATTERN.test(name))
|
|
898
|
+
if (log !== undefined) return join(sessionDirectory, log)
|
|
899
|
+
}
|
|
900
|
+
return null
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
808
904
|
function errorText(value) {
|
|
809
905
|
if (typeof value === 'string') return value.trim() || null
|
|
810
906
|
if (value === null || value === undefined || typeof value !== 'object') return null
|
|
@@ -915,6 +1011,12 @@ function page() {
|
|
|
915
1011
|
/** Mount Synapse routes on the existing DSH Web Server. */
|
|
916
1012
|
export function apply(ctx, config) {
|
|
917
1013
|
const store = new WorkspaceStore(config?.dataFile)
|
|
1014
|
+
// A profile can point the plugin at a different home; by default the session
|
|
1015
|
+
// logs sit beside the workspace file ($DSH_HOME/sessions).
|
|
1016
|
+
const sessionsDirectory = typeof config?.sessionsDir === 'string' && config.sessionsDir !== ''
|
|
1017
|
+
? config.sessionsDir
|
|
1018
|
+
: join(dirname(dirname(config?.dataFile ?? '')), 'sessions')
|
|
1019
|
+
const sessionLog = new SessionLogReader(sessionsDirectory)
|
|
918
1020
|
const autoProjection = config?.autoProjection !== false
|
|
919
1021
|
const projectionWorkspaceTitle = typeof config?.projectionWorkspaceTitle === 'string' && config.projectionWorkspaceTitle.trim() !== ''
|
|
920
1022
|
? config.projectionWorkspaceTitle.trim().slice(0, MAX_TITLE_LENGTH)
|
|
@@ -974,8 +1076,11 @@ export function apply(ctx, config) {
|
|
|
974
1076
|
return { messages }
|
|
975
1077
|
}
|
|
976
1078
|
const session = ctx.sessions.list().find(candidate => candidate.id === thread.dshSessionId)
|
|
977
|
-
if (session
|
|
978
|
-
|
|
1079
|
+
if (session !== undefined) return { messages: bodiesFromEvents(thread, session.snapshotEvents(), seqs) }
|
|
1080
|
+
// Closed session: read its log from disk, which is where the body lives.
|
|
1081
|
+
const events = await sessionLog.events(thread.dshSessionId)
|
|
1082
|
+
if (events === null) return { messages: {}, unavailable: true }
|
|
1083
|
+
return { messages: bodiesFromEvents(thread, events, seqs) }
|
|
979
1084
|
}
|
|
980
1085
|
// The DSH /api browser-trust fence does not cover /synapse routes, so this
|
|
981
1086
|
// handler checks the Host header itself: localhost is allowed by default and
|