dsh-synapse 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -52
- package/app.js +617 -63
- package/client.js +147 -28
- package/docs/architecture.md +23 -0
- package/docs/development.md +1 -0
- package/docs/en/README.md +1 -1
- package/docs/images/native-webui.jpg +0 -0
- package/docs/images/synapse-map.jpg +0 -0
- package/docs/zh-CN/README.md +1 -1
- package/index.js +63 -29
- package/package.json +5 -2
- package/styles.css +119 -9
- package/docs/images/native-webui.png +0 -0
- package/docs/images/synapse-ui.png +0 -0
package/client.js
CHANGED
|
@@ -2,12 +2,18 @@ window.__ModuleLoader__.load({
|
|
|
2
2
|
id: 'dsh-synapse',
|
|
3
3
|
factory: () => {
|
|
4
4
|
const module = { exports: {} }
|
|
5
|
+
// Consumer identity for owned Session references. The Client records
|
|
6
|
+
// positive reference counts per source; nothing else reads this string.
|
|
7
|
+
const SESSION_SOURCE = 'synapse'
|
|
8
|
+
// Shown when a Session is gone or no longer reachable from this browser.
|
|
9
|
+
const NO_SESSION = '关联的 DSH 会话已不可用'
|
|
5
10
|
const currentSession = ctx => {
|
|
6
11
|
const snapshot = ctx.sessions.list.getSnapshot()
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
// 0.1.7 removed `list.current`: the Main view's own reference count is the
|
|
13
|
+
// Client's current-Session signal. `ui-workspace` derives the highlighted
|
|
14
|
+
// Session row from the same fact, so both stay in step by construction.
|
|
15
|
+
const session = Object.values(snapshot.byId).find(row => (row.retainedBy?.mainView ?? 0) > 0)
|
|
16
|
+
return session === undefined ? null : { id: session.id, title: session.displayTitle, cwd: session.cwd ?? null }
|
|
11
17
|
}
|
|
12
18
|
const sessionSnapshot = ctx => {
|
|
13
19
|
const snapshot = ctx.sessions.list.getSnapshot()
|
|
@@ -26,14 +32,25 @@ window.__ModuleLoader__.load({
|
|
|
26
32
|
]
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
module.exports.inject = ['sessions', 'workspaces']
|
|
35
|
+
module.exports.inject = ['sessions', 'workspaces', 'uiWorkspace', 'uiConversation']
|
|
30
36
|
module.exports.apply = ctx => {
|
|
31
37
|
const prompt = async (sessionId, text) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
// 0.1.7 owns Session lifetime explicitly: `scope()` only borrows an
|
|
39
|
+
// already-retained generation, so sending needs a reference of our own.
|
|
40
|
+
let reference
|
|
41
|
+
try {
|
|
42
|
+
reference = ctx.sessions.retain(sessionId, { source: SESSION_SOURCE })
|
|
43
|
+
await reference.ready
|
|
44
|
+
} catch {
|
|
45
|
+
reference?.release()
|
|
46
|
+
throw new Error(NO_SESSION)
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const result = await reference.binding.session.prompt([{ type: 'text', text }], 'queue')
|
|
50
|
+
if (!result.ok) throw new Error(result.error?.message ?? 'DSH 未接受这条消息')
|
|
51
|
+
} finally {
|
|
52
|
+
reference.release()
|
|
53
|
+
}
|
|
37
54
|
}
|
|
38
55
|
const style = document.createElement('style')
|
|
39
56
|
style.textContent = '.dsh-synapse-switch{position:fixed;z-index:80;top:12px;left:50%;display:flex;gap:2px;transform:translateX(-50%);border:1px solid #d1d5db;border-radius:999px;background:rgba(255,255,255,.96);padding:3px;backdrop-filter:blur(10px)}.dsh-synapse-switch button{height:28px;border:0;border-radius:999px;background:transparent;padding:0 11px;color:#6b7280;font:600 12px Inter,system-ui,sans-serif;cursor:pointer;white-space:nowrap}.dsh-synapse-switch button:hover{background:#f3f4f6;color:#111827}.dsh-synapse-switch button.active{background:#111827;color:#fff}.dsh-synapse-switch button:focus-visible{outline:2px solid #111827;outline-offset:2px}.dsh-synapse-overlay{position:fixed;z-index:100;inset:0;background:#f5f7fa}.dsh-synapse-overlay.is-opening{visibility:hidden}.dsh-synapse-overlay[hidden]{display:none}.dsh-synapse-overlay iframe{display:block;width:100%;height:100%;border:0}'
|
|
@@ -64,25 +81,91 @@ window.__ModuleLoader__.load({
|
|
|
64
81
|
const send = (type, payload) => { frame.contentWindow?.postMessage({ source: 'dsh-synapse', type, ...payload }, location.origin) }
|
|
65
82
|
let syncQueued = false
|
|
66
83
|
let knownSessionIds = new Set()
|
|
67
|
-
|
|
84
|
+
// One live record per catalogued Session. The pre-0.1.7 Client minted a
|
|
85
|
+
// Session scope on demand for any listed id, so live state was readable
|
|
86
|
+
// for every card; 0.1.7 mints none, so a Chat view is readable only while
|
|
87
|
+
// something holds a reference. A card whose Session is not observable
|
|
88
|
+
// still reports its `running` flag from the list row, because only the
|
|
89
|
+
// streaming text needs the view.
|
|
90
|
+
const liveSessions = new Map()
|
|
91
|
+
// References this plugin owns, so it can read a running Session's Chat
|
|
92
|
+
// view without depending on another surface retaining it.
|
|
93
|
+
const ownReferences = new Map()
|
|
94
|
+
|
|
95
|
+
const publishLiveSession = id => {
|
|
96
|
+
if (overlay.hidden) return
|
|
97
|
+
const text = liveSessions.get(id)?.chat?.getSnapshot()?.legacy.partial?.blocks
|
|
98
|
+
.filter(block => block.kind === 'text').map(block => block.text).join('\n') ?? ''
|
|
99
|
+
const running = ctx.sessions.list.getSnapshot().byId[id]?.running === true
|
|
100
|
+
send('synapse:live-reply', { sessionId: id, running, text })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const detachLiveSession = record => {
|
|
104
|
+
for (const unsubscribe of record.unsubscribers) unsubscribe()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A running Session is the only kind that produces live text, so it is the
|
|
108
|
+
// only kind worth opening a reference — and its history read — for.
|
|
109
|
+
const syncObservedSessions = () => {
|
|
110
|
+
const rows = ctx.sessions.list.getSnapshot()
|
|
111
|
+
for (const id of rows.ids) {
|
|
112
|
+
const held = ownReferences.has(id)
|
|
113
|
+
const wanted = rows.byId[id]?.running === true && ctx.sessions.binding(id) === undefined
|
|
114
|
+
if (wanted && !held) {
|
|
115
|
+
try {
|
|
116
|
+
const reference = ctx.sessions.retain(id, { source: SESSION_SOURCE })
|
|
117
|
+
void reference.ready.catch(() => {})
|
|
118
|
+
ownReferences.set(id, reference)
|
|
119
|
+
} catch { /* the Session is not retainable right now */ }
|
|
120
|
+
} else if (!wanted && held) {
|
|
121
|
+
ownReferences.get(id).release()
|
|
122
|
+
ownReferences.delete(id)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const [id, reference] of ownReferences) {
|
|
126
|
+
if (rows.ids.includes(id)) continue
|
|
127
|
+
reference.release()
|
|
128
|
+
ownReferences.delete(id)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
68
132
|
const syncLiveSessions = () => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
send('synapse:live-reply', { sessionId: id, running: state.running, text })
|
|
133
|
+
syncObservedSessions()
|
|
134
|
+
const ids = ctx.sessions.list.getSnapshot().ids
|
|
135
|
+
for (const id of ids) {
|
|
136
|
+
const binding = ctx.sessions.binding(id)
|
|
137
|
+
let record = liveSessions.get(id)
|
|
138
|
+
// Rebuild when the observable source appears or disappears, and when a
|
|
139
|
+
// same-id generation replaced the borrowed binding.
|
|
140
|
+
if (record !== undefined && record.binding !== binding) {
|
|
141
|
+
detachLiveSession(record)
|
|
142
|
+
record = undefined
|
|
80
143
|
}
|
|
81
|
-
|
|
82
|
-
|
|
144
|
+
if (record === undefined) {
|
|
145
|
+
let chat
|
|
146
|
+
if (binding !== undefined) {
|
|
147
|
+
try { chat = ctx.uiConversation.binding(id).target('chat') } catch { chat = undefined }
|
|
148
|
+
}
|
|
149
|
+
liveSessions.set(id, {
|
|
150
|
+
binding,
|
|
151
|
+
chat,
|
|
152
|
+
// `target('chat')` is the only reader that activates the view
|
|
153
|
+
// target: its snapshot stays undefined until something subscribes.
|
|
154
|
+
unsubscribers: chat === undefined
|
|
155
|
+
? []
|
|
156
|
+
: [chat.subscribe(() => publishLiveSession(id)), binding.session.subscribe(() => publishLiveSession(id))],
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const [id, record] of liveSessions) {
|
|
161
|
+
if (ids.includes(id)) continue
|
|
162
|
+
detachLiveSession(record)
|
|
163
|
+
liveSessions.delete(id)
|
|
83
164
|
}
|
|
84
|
-
for (const [id, unsubscribe] of liveUnsubscribers) if (!snapshot.ids.includes(id)) { unsubscribe(); liveUnsubscribers.delete(id) }
|
|
85
165
|
}
|
|
166
|
+
// Always runs after `syncLiveSessions`, so a newly attached Session gets
|
|
167
|
+
// its first report here rather than publishing twice on the same sync.
|
|
168
|
+
const publishLiveSessions = () => { for (const id of liveSessions.keys()) publishLiveSession(id) }
|
|
86
169
|
const syncSessions = () => {
|
|
87
170
|
if (syncQueued) return
|
|
88
171
|
syncQueued = true
|
|
@@ -102,6 +185,7 @@ window.__ModuleLoader__.load({
|
|
|
102
185
|
const syncCurrentSession = () => {
|
|
103
186
|
syncSessions()
|
|
104
187
|
syncLiveSessions()
|
|
188
|
+
publishLiveSessions()
|
|
105
189
|
syncTheme()
|
|
106
190
|
if (!overlay.hidden) {
|
|
107
191
|
send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
|
|
@@ -144,14 +228,46 @@ window.__ModuleLoader__.load({
|
|
|
144
228
|
return send('synapse:current-session', { session: currentSession(ctx) })
|
|
145
229
|
}
|
|
146
230
|
if (event.data.type === 'synapse:open-session') {
|
|
147
|
-
|
|
231
|
+
// 0.1.7 moved navigation out of the Session Controller: selecting a
|
|
232
|
+
// Session and showing its Conversation is one Workspace UI action.
|
|
233
|
+
try { ctx.uiWorkspace.openSession(event.data.sessionId); close() } catch { send('synapse:bridge-error', { message: NO_SESSION }) }
|
|
234
|
+
// Best-effort anchor to the requested turn: chat nodes expose their
|
|
235
|
+
// source event seq (anchorSeq) and render with data-chat-anchor-key,
|
|
236
|
+
// so resolve seq -> node key -> scroll once the view materializes.
|
|
237
|
+
const seq = event.data.seq
|
|
238
|
+
if (Number.isInteger(seq)) {
|
|
239
|
+
const tryScroll = attempt => {
|
|
240
|
+
const record = liveSessions.get(event.data.sessionId)
|
|
241
|
+
if (record === undefined) {
|
|
242
|
+
if (attempt < 3) window.setTimeout(() => tryScroll(attempt + 1), 500)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
let key = undefined
|
|
246
|
+
for (const node of record.chat.getSnapshot()?.nodes.values() ?? []) {
|
|
247
|
+
if (node.anchorSeq === seq) { key = node.key; break }
|
|
248
|
+
}
|
|
249
|
+
if (key !== undefined) {
|
|
250
|
+
const row = document.querySelector(`[data-chat-anchor-key="${CSS.escape(key)}"]`)
|
|
251
|
+
if (row instanceof HTMLElement) row.scrollIntoView({ block: 'start' })
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
// The requested turn may sit outside the loaded window; the jump
|
|
255
|
+
// loader pages history back through the seq, then the retry reads
|
|
256
|
+
// the rebuilt node set.
|
|
257
|
+
if (attempt === 0 && typeof record.session.loadThrough === 'function') {
|
|
258
|
+
void record.session.loadThrough(seq).catch(() => {})
|
|
259
|
+
}
|
|
260
|
+
if (attempt < 3) window.setTimeout(() => tryScroll(attempt + 1), 500)
|
|
261
|
+
}
|
|
262
|
+
window.setTimeout(() => tryScroll(0), 300)
|
|
263
|
+
}
|
|
148
264
|
return
|
|
149
265
|
}
|
|
150
266
|
if (event.data.type === 'synapse:activate-session') {
|
|
151
267
|
// Bidirectional current-session sync: switch DSH's current session
|
|
152
268
|
// without closing the map; the sessions-list subscription re-sends
|
|
153
269
|
// synapse:current-session so the map follows the new highlight.
|
|
154
|
-
try { ctx.
|
|
270
|
+
try { ctx.uiWorkspace.openSession(event.data.sessionId) } catch { send('synapse:bridge-error', { message: NO_SESSION }) }
|
|
155
271
|
return
|
|
156
272
|
}
|
|
157
273
|
if (event.data.type === 'synapse:fork-session') {
|
|
@@ -207,7 +323,10 @@ window.__ModuleLoader__.load({
|
|
|
207
323
|
themeObserver?.disconnect()
|
|
208
324
|
unsubscribeSessions()
|
|
209
325
|
unsubscribeWorkspaces()
|
|
210
|
-
for (const
|
|
326
|
+
for (const record of liveSessions.values()) detachLiveSession(record)
|
|
327
|
+
liveSessions.clear()
|
|
328
|
+
for (const reference of ownReferences.values()) reference.release()
|
|
329
|
+
ownReferences.clear()
|
|
211
330
|
host.remove()
|
|
212
331
|
style.remove()
|
|
213
332
|
}, 'synapse: web workspace switch')
|
package/docs/architecture.md
CHANGED
|
@@ -28,6 +28,29 @@ DSH session logs remain the source of truth for conversation content and lifecyc
|
|
|
28
28
|
|
|
29
29
|
Synapse projects committed DSH events into cards and sends user actions back through the native DSH session bridge.
|
|
30
30
|
|
|
31
|
+
## Harness compatibility
|
|
32
|
+
|
|
33
|
+
This revision is built and verified against DeepSeek Harness `0.1.7-rc.2`. Both halves call Harness APIs directly, so a Harness release that changes them requires a matching plugin release.
|
|
34
|
+
|
|
35
|
+
Host half (`index.js`) calls:
|
|
36
|
+
|
|
37
|
+
- `ctx.webServer.register({ kind, path, handler })`, the route registry of `@deepseek-ai/dsh-host-webserver`;
|
|
38
|
+
- `ctx.sessions.list()`, the live `Session` objects of `@deepseek-ai/dsh-session`;
|
|
39
|
+
- the untagged (global) `session/created` and `session/event` listeners;
|
|
40
|
+
- `session.snapshotEvents()` for a projection replay, `session.inheritedEventCount` for the durable fork cut, and `session.header.cwd` for workspace grouping.
|
|
41
|
+
|
|
42
|
+
Client half (`client.js`), through `dsh.client.inject` in `package.json`:
|
|
43
|
+
|
|
44
|
+
- `sessions.list`, `sessions.binding`, `sessions.create`, `sessions.fork` from `@deepseek-ai/dsh-api-session-controller`, plus `sessions.retain`/`release` for two purposes: owning a reference across a prompt round-trip, and owning one for every *running* Session whose live reply the map mirrors, because no Harness release mints a Session scope implicitly any more and an unowned Session has no readable Chat view;
|
|
45
|
+
- `workspaces.list` from `@deepseek-ai/dsh-api-workspace-controller`;
|
|
46
|
+
- `uiWorkspace.openSession` from `@deepseek-ai/dsh-client-ui-workspace` for selecting a Session;
|
|
47
|
+
- `uiConversation.binding(sessionId).target('chat')` from `@deepseek-ai/dsh-client-ui-conversation` for the streaming reply and the turn anchor index, because the Client builds a view snapshot only after something subscribes to its target. A Session the browser does not observe still reports its `running` flag, which the list row carries.
|
|
48
|
+
|
|
49
|
+
Two Harness dependencies are worth re-checking on every upgrade:
|
|
50
|
+
|
|
51
|
+
- `snapshotEvents()` is marked deprecated: Harness is moving complete session logs out of memory. Rebuilding this canvas from history needs the full sequence, so the call remains, and it needs a storage-backed successor (an asynchronous paged read) before Harness removes it.
|
|
52
|
+
- The browser half must acquire its own `SessionReference` before sending a prompt; borrowing an Agent scope no longer opens one.
|
|
53
|
+
|
|
31
54
|
## Canvas metadata
|
|
32
55
|
|
|
33
56
|
By default, Synapse stores canvas metadata at:
|
package/docs/development.md
CHANGED
|
@@ -5,6 +5,7 @@ This document covers local validation, package inspection, GitHub Actions, and n
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
7
|
- Node.js `>= 22.19.0`
|
|
8
|
+
- DeepSeek Harness `>= 0.1.7-rc.2` for integration runs; the test suite itself installs no Harness
|
|
8
9
|
- pnpm through Corepack
|
|
9
10
|
- A checkout of this repository
|
|
10
11
|
|
package/docs/en/README.md
CHANGED
|
@@ -6,7 +6,7 @@ The plugin does not replace DSH models, tools, sessions, permissions, or the Web
|
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
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
11
|
- The `web` profile; other profiles are not currently supported.
|
|
12
12
|
|
|
Binary file
|
|
Binary file
|
package/docs/zh-CN/README.md
CHANGED
package/index.js
CHANGED
|
@@ -200,7 +200,11 @@ export class WorkspaceStore {
|
|
|
200
200
|
if (this.state.hiddenSessionIds.includes(session.id)) return null
|
|
201
201
|
const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
|
|
202
202
|
const thread = this.dshThread(workspace, session)
|
|
203
|
-
|
|
203
|
+
// `snapshotEvents()` replaces the removed `session.events` getter. It is
|
|
204
|
+
// marked deprecated because DSH is moving complete logs out of memory, but
|
|
205
|
+
// rebuilding this canvas from history needs the full sequence and the
|
|
206
|
+
// reader is the only complete-log access a plugin still has.
|
|
207
|
+
for (const event of session.snapshotEvents()) {
|
|
204
208
|
if (event.seq >= replayFrom) this.projectEventInto(workspace, thread, event)
|
|
205
209
|
}
|
|
206
210
|
return structuredClone(thread)
|
|
@@ -380,9 +384,9 @@ export class WorkspaceStore {
|
|
|
380
384
|
thread.title = title
|
|
381
385
|
thread.dshSessionTitle = title
|
|
382
386
|
}
|
|
383
|
-
// `
|
|
384
|
-
//
|
|
385
|
-
const seedLength = session.
|
|
387
|
+
// `inheritedEventCount` is DSH's durable fork cut, the successor of the
|
|
388
|
+
// removed `header.seedLength`. It survives restore, unlike `firstLiveSeq`.
|
|
389
|
+
const seedLength = session.inheritedEventCount
|
|
386
390
|
if (Number.isSafeInteger(seedLength) && seedLength >= 0) thread.sourceSeedLength = seedLength
|
|
387
391
|
return thread
|
|
388
392
|
}
|
|
@@ -395,7 +399,7 @@ export class WorkspaceStore {
|
|
|
395
399
|
title: typeof session.title === 'string' && session.title.trim() !== '' ? session.title.slice(0, MAX_TITLE_LENGTH) : (parent === undefined ? 'DSH 会话' : `${parent.title} 分支`),
|
|
396
400
|
parentId: parent?.id ?? null,
|
|
397
401
|
sourceParentSessionId: parentSessionId,
|
|
398
|
-
sourceSeedLength: Number.isSafeInteger(session.
|
|
402
|
+
sourceSeedLength: Number.isSafeInteger(session.inheritedEventCount) && session.inheritedEventCount >= 0 ? session.inheritedEventCount : null,
|
|
399
403
|
dshSessionId: session.id,
|
|
400
404
|
dshSessionTitle: typeof session.title === 'string' ? session.title.slice(0, MAX_TITLE_LENGTH) : null,
|
|
401
405
|
color: TOPIC_COLORS[workspace.threads.length % TOPIC_COLORS.length],
|
|
@@ -406,6 +410,7 @@ export class WorkspaceStore {
|
|
|
406
410
|
createdAt: now,
|
|
407
411
|
updatedAt: now,
|
|
408
412
|
messages: [],
|
|
413
|
+
pendingProcess: [],
|
|
409
414
|
}
|
|
410
415
|
workspace.threads.push(thread)
|
|
411
416
|
// A child may arrive before its parent during startup replay. Repair that
|
|
@@ -439,10 +444,11 @@ export class WorkspaceStore {
|
|
|
439
444
|
kind: projection.kind,
|
|
440
445
|
sourceSeq: event.seq,
|
|
441
446
|
at,
|
|
442
|
-
...(projection.kind === 'assistant'
|
|
443
|
-
? { turn: event.data
|
|
447
|
+
...(projection.kind === 'assistant' || projection.kind === 'error'
|
|
448
|
+
? { turn: event.data?.turn, step: event.data?.step, process: [] }
|
|
444
449
|
: {}),
|
|
445
450
|
}
|
|
451
|
+
this.attachPendingProcess(thread, message)
|
|
446
452
|
thread.messages.push(message)
|
|
447
453
|
thread.updatedAt = at
|
|
448
454
|
workspace.updatedAt = at
|
|
@@ -455,31 +461,31 @@ export class WorkspaceStore {
|
|
|
455
461
|
/**
|
|
456
462
|
* Fold one tool call or result into the assistant message of its own
|
|
457
463
|
* turn/step, keyed by `callId`, so a tool invocation never becomes a
|
|
458
|
-
* separate canvas card.
|
|
459
|
-
*
|
|
464
|
+
* separate canvas card. If a tool result arrives before its associated
|
|
465
|
+
* assistant/error message, retain it on the thread until that turn appears.
|
|
460
466
|
*/
|
|
461
467
|
foldToolProcess(thread, event) {
|
|
462
468
|
const at = new Date(event.time).toISOString()
|
|
469
|
+
const data = event.data ?? {}
|
|
463
470
|
const target = [...thread.messages].reverse().find(message =>
|
|
464
|
-
message.kind === 'assistant'
|
|
465
|
-
&& (message.turn ===
|
|
471
|
+
(message.kind === 'assistant' || message.kind === 'error')
|
|
472
|
+
&& (message.turn === data.turn && message.step === data.step
|
|
466
473
|
|| message.turn === undefined && message.step === undefined))
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
const callId = String(event.type === 'tool/call' ? event.data.callId : event.data.message?.source?.callId ?? '')
|
|
474
|
+
const process = target === undefined ? (thread.pendingProcess ??= []) : (target.process ??= [])
|
|
475
|
+
const callId = String(event.type === 'tool/call' ? data.callId : data.message?.source?.callId ?? '')
|
|
470
476
|
const entry = process.find(item => item.callId === callId)
|
|
471
477
|
if (event.type === 'tool/call') {
|
|
472
478
|
if (entry === undefined) {
|
|
473
|
-
process.push({ callId,
|
|
479
|
+
process.push({ callId, turn: data.turn, step: data.step, name: data.name, arguments: data.arguments, result: null, error: null })
|
|
474
480
|
} else {
|
|
475
|
-
entry.name =
|
|
476
|
-
entry.arguments =
|
|
481
|
+
entry.name = data.name
|
|
482
|
+
entry.arguments = data.arguments
|
|
477
483
|
}
|
|
478
484
|
} else {
|
|
479
|
-
const outcome = contentText(
|
|
480
|
-
const error =
|
|
485
|
+
const outcome = contentText(data.message?.content)
|
|
486
|
+
const error = errorText(data.error)
|
|
481
487
|
if (entry === undefined) {
|
|
482
|
-
process.push({ callId, name: '工具调用', arguments: null, result: outcome, error })
|
|
488
|
+
process.push({ callId, turn: data.turn, step: data.step, name: '工具调用', arguments: null, result: outcome, error })
|
|
483
489
|
} else {
|
|
484
490
|
entry.result = outcome
|
|
485
491
|
entry.error = error
|
|
@@ -488,6 +494,14 @@ export class WorkspaceStore {
|
|
|
488
494
|
thread.updatedAt = at
|
|
489
495
|
}
|
|
490
496
|
|
|
497
|
+
attachPendingProcess(thread, message) {
|
|
498
|
+
if (!Array.isArray(thread.pendingProcess) || thread.pendingProcess.length === 0 || !Array.isArray(message.process)) return
|
|
499
|
+
const matching = thread.pendingProcess.filter(entry => entry.turn === message.turn && entry.step === message.step)
|
|
500
|
+
if (matching.length === 0) return
|
|
501
|
+
message.process.push(...matching.map(({ turn, step, ...entry }) => entry))
|
|
502
|
+
thread.pendingProcess = thread.pendingProcess.filter(entry => entry.turn !== message.turn || entry.step !== message.step)
|
|
503
|
+
}
|
|
504
|
+
|
|
491
505
|
thread({ title, parentId, dshSessionId, dshSessionTitle, position, color, now, order }) {
|
|
492
506
|
return {
|
|
493
507
|
id: randomUUID(),
|
|
@@ -500,6 +514,7 @@ export class WorkspaceStore {
|
|
|
500
514
|
createdAt: now,
|
|
501
515
|
updatedAt: now,
|
|
502
516
|
messages: [],
|
|
517
|
+
pendingProcess: [],
|
|
503
518
|
}
|
|
504
519
|
}
|
|
505
520
|
|
|
@@ -528,7 +543,7 @@ function normalizeState(value) {
|
|
|
528
543
|
migrated = true
|
|
529
544
|
const notes = Array.isArray(thread.notes) ? thread.notes : []
|
|
530
545
|
const { notes: _notes, ...rest } = thread
|
|
531
|
-
return { ...rest, messages: notes }
|
|
546
|
+
return { ...rest, messages: notes, pendingProcess: [] }
|
|
532
547
|
}) : [],
|
|
533
548
|
}))
|
|
534
549
|
state = { ...value, version: value.version, hiddenSessionIds, workspaces }
|
|
@@ -638,16 +653,32 @@ function projectableEvent(event) {
|
|
|
638
653
|
return isRuntimeContextText(text) ? null : noteProjection('user', text)
|
|
639
654
|
}
|
|
640
655
|
case 'assistant/message':
|
|
641
|
-
return noteProjection('assistant', contentText(event.data
|
|
656
|
+
return noteProjection('assistant', contentText(event.data?.message?.content))
|
|
642
657
|
case 'todo/write':
|
|
643
|
-
return noteProjection('todo', event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n'))
|
|
644
|
-
case 'turn/end':
|
|
645
|
-
|
|
646
|
-
|
|
658
|
+
return noteProjection('todo', Array.isArray(event.data?.todos) ? event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n') : '')
|
|
659
|
+
case 'turn/end': {
|
|
660
|
+
const reason = event.data?.reason
|
|
661
|
+
if (reason?.kind === 'error') return noteProjection('error', errorText(reason.error) ?? '本轮执行失败')
|
|
662
|
+
if (reason?.kind === 'cancelled' || reason?.kind === 'canceled' || reason?.kind === 'aborted') return noteProjection('error', '本轮已取消')
|
|
647
663
|
return null
|
|
664
|
+
}
|
|
665
|
+
default:
|
|
666
|
+
return /(?:error|failed|failure|cancel(?:led)?|abort)/i.test(event.type)
|
|
667
|
+
? noteProjection('error', errorText(event.data?.error ?? event.data?.reason ?? event.data) ?? 'Harness 运行失败')
|
|
668
|
+
: null
|
|
648
669
|
}
|
|
649
670
|
}
|
|
650
671
|
|
|
672
|
+
function errorText(value) {
|
|
673
|
+
if (typeof value === 'string') return value.trim() || null
|
|
674
|
+
if (value === null || value === undefined || typeof value !== 'object') return null
|
|
675
|
+
const name = typeof value.name === 'string' && value.name.trim() !== '' ? value.name.trim() : ''
|
|
676
|
+
const code = typeof value.code === 'string' && value.code.trim() !== '' ? value.code.trim() : ''
|
|
677
|
+
const message = typeof value.message === 'string' && value.message.trim() !== '' ? value.message.trim() : ''
|
|
678
|
+
if (message !== '') return [name, code].filter(Boolean).concat(message).join(': ')
|
|
679
|
+
return [name, code].filter(Boolean).join(': ') || null
|
|
680
|
+
}
|
|
681
|
+
|
|
651
682
|
function noteProjection(kind, text) {
|
|
652
683
|
const normalized = text.trim()
|
|
653
684
|
if (normalized === '') return null
|
|
@@ -679,7 +710,9 @@ function titleFromText(text) {
|
|
|
679
710
|
}
|
|
680
711
|
|
|
681
712
|
function sessionCwd(session) {
|
|
682
|
-
|
|
713
|
+
// The Session header defines `cwd` as its only working-directory carrier; the
|
|
714
|
+
// old `header.meta.cwd` fallback never matched a real header field.
|
|
715
|
+
const cwd = session.header?.cwd
|
|
683
716
|
return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : '未指定工作目录'
|
|
684
717
|
}
|
|
685
718
|
|
|
@@ -726,8 +759,9 @@ export function apply(ctx, config) {
|
|
|
726
759
|
}
|
|
727
760
|
const replaySession = session => {
|
|
728
761
|
// 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
|
-
|
|
762
|
+
// history through the parent node, so only project the child's live tail:
|
|
763
|
+
// `inheritedEventCount` is 0 without a fork and the durable cut with one.
|
|
764
|
+
const replayFrom = session.inheritedEventCount ?? 0
|
|
731
765
|
void store.projectSession(session, replayFrom, projectionWorkspaceTitle).catch(reportProjectionFailure)
|
|
732
766
|
}
|
|
733
767
|
// Buffer live events per session and flush them in one write per microtask,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-synapse",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "A visual, non-linear conversation workspace plugin for DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
"client": {
|
|
33
33
|
"platform": "web",
|
|
34
34
|
"inject": [
|
|
35
|
-
"@deepseek-ai/dsh-
|
|
35
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
36
|
+
"@deepseek-ai/dsh-api-workspace-controller",
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-workspace"
|
|
36
39
|
],
|
|
37
40
|
"immediately": false
|
|
38
41
|
}
|