dsh-dock 0.9.9 → 0.10.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/README.md +56 -31
- package/client.js +1818 -1460
- package/features/animation/host.js +16 -557
- package/features/animation/view.jsx +57 -645
- package/features/modelconfig/host.js +4 -1
- package/features/notify/host.js +374 -0
- package/features/notify/view.jsx +751 -0
- package/features/runstate/host.js +64 -0
- package/features/runstate/view.jsx +269 -0
- package/index.js +25 -5
- package/package.json +4 -3
- package/src/client.jsx +5 -3
- package/src/host-core.js +74 -3
- package/src/shared.js +25 -8
- package/src/task-track.js +359 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// dsh-dock · 宿主共享内核:会话级任务追踪(【任务动画】【任务通知】【运行状态】共用一份数据)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成模块:动画、通知与运行状态都需要「任务什么时候开始/结束、现在在干什么」,
|
|
4
|
+
// 但三者是各自独立开关的功能模块(可只开其一,也可各自提取成独立包发布)。
|
|
5
|
+
// 追踪逻辑放这里、用引用计数共享一份实例:三个功能都开也只有一份追踪(会话事件只订阅一次),
|
|
6
|
+
// 全都关则立刻退订,不留下常驻开销。
|
|
7
|
+
//
|
|
8
|
+
// 用法(features/<id>/host.js):
|
|
9
|
+
// const lease = acquireTaskTracker(ctx)
|
|
10
|
+
// disposers.push(() => lease.release())
|
|
11
|
+
// lease.tracker.snapshot(Date.now()) // → { now, active: [...], recent: [...] }
|
|
12
|
+
// const off = lease.tracker.onFinish((rec) => { ... }) // 任务结束回调(群机器人推送用)
|
|
13
|
+
//
|
|
14
|
+
// 数据形状(active/recent)由客户端各模块消费,字段改动要同时看:
|
|
15
|
+
// features/runstate/view.jsx、features/animation/view.jsx、features/notify/view.jsx
|
|
16
|
+
|
|
17
|
+
/** 最近完成记录保留条数(新→旧)。 */
|
|
18
|
+
const MAX_COMPLETED = 30
|
|
19
|
+
|
|
20
|
+
/** 当前共享实例(引用计数 > 0 时存在)。 */
|
|
21
|
+
let shared = null
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 取得共享追踪器租约。返回 { tracker, release }:
|
|
25
|
+
* 每个使用方在 setup 时 acquire、在 dispose 时 release(必须成对,否则追踪不会退订)。
|
|
26
|
+
*/
|
|
27
|
+
export function acquireTaskTracker(ctx) {
|
|
28
|
+
if (!shared) shared = { refs: 0, tracker: createTracker(ctx) }
|
|
29
|
+
const slot = shared
|
|
30
|
+
slot.refs += 1
|
|
31
|
+
let released = false
|
|
32
|
+
return {
|
|
33
|
+
tracker: slot.tracker,
|
|
34
|
+
release() {
|
|
35
|
+
if (released) return
|
|
36
|
+
released = true
|
|
37
|
+
slot.refs -= 1
|
|
38
|
+
if (slot.refs > 0) return
|
|
39
|
+
if (shared === slot) shared = null
|
|
40
|
+
try { slot.tracker.dispose() } catch { /* 退订失败不影响功能停用 */ }
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createTracker(ctx) {
|
|
46
|
+
// ===== 会话级任务追踪(agent/status 驱动开始/结束,session/event 补统计) =====
|
|
47
|
+
const activeSessions = new Map() // sessionId -> 追踪记录
|
|
48
|
+
const completedSessions = [] // 最近完成(新→旧)
|
|
49
|
+
const finishListeners = new Set()
|
|
50
|
+
const disposers = []
|
|
51
|
+
|
|
52
|
+
function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0 }
|
|
53
|
+
|
|
54
|
+
function extractText(content) {
|
|
55
|
+
if (!Array.isArray(content)) return ''
|
|
56
|
+
const parts = []
|
|
57
|
+
for (const block of content) {
|
|
58
|
+
if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
59
|
+
}
|
|
60
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function truncate(str, max = 160) {
|
|
64
|
+
if (!str) return ''
|
|
65
|
+
return String(str).length > max ? String(str).slice(0, max) + '…' : str
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 工具名 → 阶段:检索类工具 = search,其余 = code
|
|
69
|
+
function phaseOfToolName(name) {
|
|
70
|
+
const n = String(name || '').toLowerCase()
|
|
71
|
+
if (/web|search|fetch|grep|glob|find|read|ls$|^ls|tree|locate/.test(n)) return 'search'
|
|
72
|
+
return 'code'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 会话标题:官方标题服务,取不到回退首条用户输入(firstPrompt)
|
|
76
|
+
async function getSessionTitle(sessionId, sessionQuery) {
|
|
77
|
+
try {
|
|
78
|
+
if (!sessionQuery) return ''
|
|
79
|
+
if (typeof sessionQuery.readTitle === 'function') {
|
|
80
|
+
const snap = await sessionQuery.readTitle(sessionId)
|
|
81
|
+
if (snap && snap.title) return snap.title
|
|
82
|
+
}
|
|
83
|
+
if (typeof sessionQuery.readSession === 'function') {
|
|
84
|
+
const snap = await sessionQuery.readSession(sessionId)
|
|
85
|
+
if (snap && snap.header && snap.header.title) return snap.header.title
|
|
86
|
+
}
|
|
87
|
+
} catch { /* 标题取不到就用 firstPrompt 兜底 */ }
|
|
88
|
+
return ''
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function addActiveSession(sid, sessionQuery, extra = {}) {
|
|
92
|
+
if (activeSessions.has(sid)) return activeSessions.get(sid)
|
|
93
|
+
console.log('[dsh-dock] task-track: task started', sid.substring(0, 8))
|
|
94
|
+
const record = {
|
|
95
|
+
startTime: Date.now(),
|
|
96
|
+
title: '',
|
|
97
|
+
turns: 0,
|
|
98
|
+
steps: 0,
|
|
99
|
+
toolCalls: 0,
|
|
100
|
+
models: new Set(),
|
|
101
|
+
provider: '',
|
|
102
|
+
inputTokens: 0,
|
|
103
|
+
outputTokens: 0,
|
|
104
|
+
totalTokens: 0,
|
|
105
|
+
reasoningTokens: 0,
|
|
106
|
+
cacheReadTokens: 0,
|
|
107
|
+
endReason: '',
|
|
108
|
+
errorMessage: '',
|
|
109
|
+
lastText: '',
|
|
110
|
+
firstPrompt: '',
|
|
111
|
+
// 工具等待用户确认(dsh 会话流 approval/asked):浏览器侧据此弹"需确认"通知
|
|
112
|
+
approvals: new Map(), // approvalId -> { toolName, reason, askedAt }
|
|
113
|
+
phase: 'think',
|
|
114
|
+
phaseAt: Date.now(),
|
|
115
|
+
lastActivityAt: Date.now(),
|
|
116
|
+
// assistant/chunk 是实时流;用轻量活动计数给浏览器侧映射动作速度,
|
|
117
|
+
// 不等最终 usage 落盘,避免任务正在输出时机器人仍慢速。
|
|
118
|
+
motionTicks: 0,
|
|
119
|
+
lastMotionAt: Date.now(),
|
|
120
|
+
...extra,
|
|
121
|
+
}
|
|
122
|
+
activeSessions.set(sid, record)
|
|
123
|
+
getSessionTitle(sid, sessionQuery).then((title) => {
|
|
124
|
+
const s = activeSessions.get(sid)
|
|
125
|
+
if (s) s.title = title
|
|
126
|
+
})
|
|
127
|
+
return record
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 任务结束:归档 + 通知订阅者(浏览器侧通知/群机器人推送都由各自消费方决定怎么用)
|
|
131
|
+
function finishSession(sid) {
|
|
132
|
+
const session = activeSessions.get(sid)
|
|
133
|
+
if (!session) return
|
|
134
|
+
activeSessions.delete(sid)
|
|
135
|
+
const endTime = Date.now()
|
|
136
|
+
const duration = endTime - session.startTime
|
|
137
|
+
console.log('[dsh-dock] task-track: task finished', {
|
|
138
|
+
sid: sid.substring(0, 8),
|
|
139
|
+
duration,
|
|
140
|
+
endReason: session.endReason || 'completed',
|
|
141
|
+
})
|
|
142
|
+
const record = {
|
|
143
|
+
sessionId: sid,
|
|
144
|
+
title: session.title || truncate(session.firstPrompt, 60) || '(无标题)',
|
|
145
|
+
duration,
|
|
146
|
+
turns: session.turns,
|
|
147
|
+
steps: session.steps,
|
|
148
|
+
toolCalls: session.toolCalls,
|
|
149
|
+
models: [...session.models],
|
|
150
|
+
provider: session.provider,
|
|
151
|
+
inputTokens: session.inputTokens,
|
|
152
|
+
outputTokens: session.outputTokens,
|
|
153
|
+
totalTokens: session.totalTokens,
|
|
154
|
+
reasoningTokens: session.reasoningTokens,
|
|
155
|
+
cacheReadTokens: session.cacheReadTokens,
|
|
156
|
+
endReason: session.endReason,
|
|
157
|
+
errorMessage: session.errorMessage,
|
|
158
|
+
lastText: session.lastText,
|
|
159
|
+
startTime: session.startTime,
|
|
160
|
+
endTime,
|
|
161
|
+
}
|
|
162
|
+
completedSessions.unshift(record)
|
|
163
|
+
if (completedSessions.length > MAX_COMPLETED) completedSessions.pop()
|
|
164
|
+
for (const fn of finishListeners) {
|
|
165
|
+
try { fn(record) } catch (e) {
|
|
166
|
+
console.error('[dsh-dock] task-track finish listener error:', e && e.message)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function handleSessionEvent(sessionQuery, session, event) {
|
|
172
|
+
const sid = typeof session === 'object' && session ? (session.id || '') : String(session || '')
|
|
173
|
+
if (!sid || !event || !event.type) return
|
|
174
|
+
|
|
175
|
+
if (event.type === 'step/start') {
|
|
176
|
+
const s = addActiveSession(sid, sessionQuery, { startTime: event.time || Date.now() })
|
|
177
|
+
s.steps++
|
|
178
|
+
s.phase = 'think' // 新步骤:模型正在推理下一步
|
|
179
|
+
s.phaseAt = event.time || Date.now()
|
|
180
|
+
s.lastActivityAt = s.phaseAt
|
|
181
|
+
s.motionTicks += 2
|
|
182
|
+
s.lastMotionAt = s.phaseAt
|
|
183
|
+
if (event.data && event.data.turn !== undefined) {
|
|
184
|
+
s.turns = Math.max(s.turns, num(event.data.turn) + 1)
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
const s = activeSessions.get(sid)
|
|
189
|
+
if (!s) return
|
|
190
|
+
const now = event.time || Date.now()
|
|
191
|
+
// 流级阶段:chunk 高频到达(每 token 一条),只做最廉价的阶段切换
|
|
192
|
+
if (event.type === 'assistant/chunk') {
|
|
193
|
+
const chunk = event.data && event.data.chunk
|
|
194
|
+
const ctype = chunk && chunk.type
|
|
195
|
+
if (ctype === 'reasoning-delta') {
|
|
196
|
+
s.phase = 'think'
|
|
197
|
+
s.phaseAt = now
|
|
198
|
+
} else if (ctype === 'text-delta') {
|
|
199
|
+
s.phase = 'write'
|
|
200
|
+
s.phaseAt = now
|
|
201
|
+
}
|
|
202
|
+
// 不同适配器的 delta 字段名并不完全一致。按可见文本量估算,
|
|
203
|
+
// 无文本时每个 chunk 也记一个脉冲,保证推理流同样有动作反馈。
|
|
204
|
+
if (ctype === 'reasoning-delta' || ctype === 'text-delta') {
|
|
205
|
+
const delta = chunk && (chunk.text || chunk.delta || chunk.content)
|
|
206
|
+
const units = typeof delta === 'string' ? Math.max(1, Math.ceil(delta.length / 24)) : 1
|
|
207
|
+
s.motionTicks += units
|
|
208
|
+
s.lastMotionAt = now
|
|
209
|
+
}
|
|
210
|
+
s.lastActivityAt = now
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
if (event.type === 'turn/end' && event.data && event.data.reason) {
|
|
214
|
+
const reason = event.data.reason
|
|
215
|
+
s.endReason = (reason.kind || 'completed')
|
|
216
|
+
if (reason.kind === 'error' && reason.error) {
|
|
217
|
+
const msg = (reason.error && (reason.error.message || reason.error.code)) || ''
|
|
218
|
+
s.errorMessage = typeof msg === 'string' ? msg : String(msg)
|
|
219
|
+
}
|
|
220
|
+
} else if (event.type === 'approval/asked' && event.data) {
|
|
221
|
+
// 工具等待用户确认/批准:挂在会话记录上,/status 下发给浏览器弹"需确认"通知
|
|
222
|
+
const id = typeof event.data.id === 'string' ? event.data.id : ''
|
|
223
|
+
if (id) {
|
|
224
|
+
s.approvals.set(id, {
|
|
225
|
+
toolName: typeof event.data.toolName === 'string' ? event.data.toolName : '',
|
|
226
|
+
reason: typeof event.data.reason === 'string' ? truncate(event.data.reason, 200) : '',
|
|
227
|
+
askedAt: event.time || Date.now(),
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
s.lastActivityAt = now
|
|
231
|
+
} else if (event.type === 'approval/decided' && event.data) {
|
|
232
|
+
s.approvals.delete(typeof event.data.id === 'string' ? event.data.id : '')
|
|
233
|
+
} else if (event.type === 'tool/call') {
|
|
234
|
+
s.toolCalls++
|
|
235
|
+
s.phase = phaseOfToolName(event.data && event.data.name)
|
|
236
|
+
s.phaseAt = now
|
|
237
|
+
// 工具启动是稀疏事件,给一个更明显的短促动作,检索/写文件也不会显得停住。
|
|
238
|
+
s.motionTicks += s.phase === 'search' ? 7 : 9
|
|
239
|
+
s.lastMotionAt = now
|
|
240
|
+
} else if (event.type === 'user/message') {
|
|
241
|
+
if (!s.firstPrompt && event.data && event.data.content) {
|
|
242
|
+
const text = extractText(event.data.content)
|
|
243
|
+
if (text) s.firstPrompt = text
|
|
244
|
+
}
|
|
245
|
+
} else if (event.type === 'assistant/message' && event.data) {
|
|
246
|
+
const usage = event.data.usage
|
|
247
|
+
if (usage) {
|
|
248
|
+
s.inputTokens += num(usage.inputTokens)
|
|
249
|
+
s.outputTokens += num(usage.outputTokens)
|
|
250
|
+
s.totalTokens += num(usage.inputTokens) + num(usage.outputTokens)
|
|
251
|
+
s.reasoningTokens += num(usage.reasoningTokens)
|
|
252
|
+
s.cacheReadTokens += num(usage.cacheReadTokens)
|
|
253
|
+
}
|
|
254
|
+
if (event.data.message && event.data.message.content) {
|
|
255
|
+
const text = extractText(event.data.message.content)
|
|
256
|
+
if (text) s.lastText = text
|
|
257
|
+
}
|
|
258
|
+
// 完整消息落盘后不改阶段:下一步要么 tool/call(code/search)要么收尾,
|
|
259
|
+
// 维持 chunk 推导出的最后状态,避免"输完还显示思考中"的回跳
|
|
260
|
+
} else if (event.type === 'request/context' && event.data) {
|
|
261
|
+
if (event.data.provider) s.provider = event.data.provider
|
|
262
|
+
if (event.data.model) s.models.add(event.data.model)
|
|
263
|
+
} else if (event.type === 'request/header' && event.data && event.data.header && event.data.header.config) {
|
|
264
|
+
const c = event.data.header.config
|
|
265
|
+
if (c.provider) s.provider = c.provider
|
|
266
|
+
if (c.model) s.models.add(c.model)
|
|
267
|
+
}
|
|
268
|
+
if (event.type !== 'request/header' && event.type !== 'request/context') s.lastActivityAt = now
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function handleAgentStatus(sessionQuery, agent, status) {
|
|
272
|
+
const sid = agent && agent.id ? String(agent.id) : ''
|
|
273
|
+
if (!sid) return
|
|
274
|
+
if (status === 'running') {
|
|
275
|
+
const record = addActiveSession(sid, sessionQuery)
|
|
276
|
+
if (agent.options) {
|
|
277
|
+
if (agent.options.model) record.models.add(agent.options.model)
|
|
278
|
+
if (agent.options.provider) record.provider = agent.options.provider
|
|
279
|
+
}
|
|
280
|
+
} else if (status === 'idle') {
|
|
281
|
+
finishSession(sid)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 不能在 setup 时软获取 sessionQuery(可能早于服务激活,见 tokenlog 模块同款注释),用 inject 等就绪
|
|
286
|
+
disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
|
|
287
|
+
const sessionQuery = sqCtx.sessionQuery
|
|
288
|
+
disposers.push(sqCtx.on('session/event', (session, event) => {
|
|
289
|
+
try {
|
|
290
|
+
handleSessionEvent(sessionQuery, session, event)
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error('[dsh-dock] task-track handle event error', err)
|
|
293
|
+
}
|
|
294
|
+
}))
|
|
295
|
+
disposers.push(sqCtx.on('agent/status', (payload) => {
|
|
296
|
+
try {
|
|
297
|
+
handleAgentStatus(sessionQuery, payload && payload.agent, payload && payload.status)
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error('[dsh-dock] task-track handle agent/status error', err)
|
|
300
|
+
}
|
|
301
|
+
}))
|
|
302
|
+
// agent 销毁兜底归档(会话直接关闭时也产出完成记录)
|
|
303
|
+
disposers.push(sqCtx.on('agent/disposed', (payload) => {
|
|
304
|
+
try {
|
|
305
|
+
const sid = payload && payload.agent && payload.agent.id ? String(payload.agent.id) : ''
|
|
306
|
+
if (sid) finishSession(sid)
|
|
307
|
+
} catch (err) {
|
|
308
|
+
console.error('[dsh-dock] task-track handle agent/disposed error', err)
|
|
309
|
+
}
|
|
310
|
+
}))
|
|
311
|
+
}))
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
/** 当前快照(活跃任务 + 最近完成),供各功能模块的 /status 路由下发。 */
|
|
315
|
+
snapshot(now) {
|
|
316
|
+
const at = typeof now === 'number' ? now : Date.now()
|
|
317
|
+
const active = []
|
|
318
|
+
for (const [sid, s] of activeSessions) {
|
|
319
|
+
active.push({
|
|
320
|
+
sessionId: sid,
|
|
321
|
+
title: s.title || truncate(s.firstPrompt, 60) || '(无标题)',
|
|
322
|
+
startTime: s.startTime,
|
|
323
|
+
elapsed: at - s.startTime,
|
|
324
|
+
turns: s.turns,
|
|
325
|
+
steps: s.steps,
|
|
326
|
+
toolCalls: s.toolCalls,
|
|
327
|
+
models: [...s.models],
|
|
328
|
+
provider: s.provider,
|
|
329
|
+
inputTokens: s.inputTokens,
|
|
330
|
+
outputTokens: s.outputTokens,
|
|
331
|
+
totalTokens: s.totalTokens,
|
|
332
|
+
phase: s.phase,
|
|
333
|
+
phaseAt: s.phaseAt,
|
|
334
|
+
lastActivityAt: s.lastActivityAt,
|
|
335
|
+
motionTicks: s.motionTicks,
|
|
336
|
+
lastMotionAt: s.lastMotionAt,
|
|
337
|
+
approvals: [...s.approvals.entries()].map(([id, v]) => ({ id, ...v })),
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
return { now: at, active, recent: completedSessions.slice(0, 20) }
|
|
341
|
+
},
|
|
342
|
+
/** 任务结束订阅(返回退订函数);异常不会打断其他订阅者。 */
|
|
343
|
+
onFinish(fn) {
|
|
344
|
+
if (typeof fn !== 'function') return () => {}
|
|
345
|
+
finishListeners.add(fn)
|
|
346
|
+
return () => { finishListeners.delete(fn) }
|
|
347
|
+
},
|
|
348
|
+
/** 退订会话事件(引用计数归零时由 acquireTaskTracker 调用)。 */
|
|
349
|
+
dispose() {
|
|
350
|
+
finishListeners.clear()
|
|
351
|
+
activeSessions.clear()
|
|
352
|
+
completedSessions.length = 0
|
|
353
|
+
while (disposers.length > 0) {
|
|
354
|
+
const fn = disposers.pop()
|
|
355
|
+
try { if (typeof fn === 'function') fn() } catch { /* 退订失败不阻断 */ }
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
}
|