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
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
// dsh-dock · 功能模块【任务动画】·
|
|
1
|
+
// dsh-dock · 功能模块【任务动画】· 宿主半部
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// 职责:动画配置持久化(动效开关 / 模式 / 桌面伙伴大小)+ 状态路由。
|
|
4
|
+
// 会话级任务追踪在 src/task-track.js(与【任务通知】【运行状态】共用一份数据);
|
|
5
|
+
// 通知在 features/notify/、运行状态在 features/runstate/,三个功能各自独立启停。
|
|
5
6
|
//
|
|
6
7
|
// RPC(webServer HTTP 路由,前缀 /dsh-dock/animation/):
|
|
7
|
-
// POST /status —— 活跃任务 + 最近完成 +
|
|
8
|
-
// POST /config ——
|
|
8
|
+
// POST /status —— 活跃任务 + 最近完成 + 动画配置(客户端据此渲染动效)
|
|
9
|
+
// POST /config —— 增量更新动画配置字段并持久化到 settings(dsh-dock 命名空间 animation 段)
|
|
9
10
|
//
|
|
10
11
|
// 配置模型(schemastery schema 见 src/host-core.js DockConfig.animation):
|
|
11
|
-
// animationEnabled / effectMode /
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import { DOCK_NS, ANIMATION_MODES, SOUND_EFFECTS, sendJson, readBody } from '../../src/host-core.js'
|
|
12
|
+
// animationEnabled / effectMode / robotScale
|
|
13
|
+
import { DOCK_NS, ANIMATION_MODES, sendJson, readBody } from '../../src/host-core.js'
|
|
14
|
+
import { acquireTaskTracker } from '../../src/task-track.js'
|
|
15
15
|
|
|
16
16
|
// 默认配置(schema 默认值一致;settings.get 未挂载时的兜底)
|
|
17
17
|
function defaultConfig() {
|
|
@@ -19,18 +19,6 @@ function defaultConfig() {
|
|
|
19
19
|
animationEnabled: true,
|
|
20
20
|
effectMode: 'flow',
|
|
21
21
|
robotScale: 1.35,
|
|
22
|
-
notifyEnabled: true,
|
|
23
|
-
notifyOnComplete: true,
|
|
24
|
-
notifyOnError: true,
|
|
25
|
-
notifyOnConfirm: true,
|
|
26
|
-
notifyStayMs: 8000,
|
|
27
|
-
systemNotify: false,
|
|
28
|
-
soundNotify: true,
|
|
29
|
-
soundEffect: 'chime',
|
|
30
|
-
dingtalkEnabled: false,
|
|
31
|
-
dingtalkWebhook: '',
|
|
32
|
-
feishuEnabled: false,
|
|
33
|
-
feishuWebhook: '',
|
|
34
22
|
}
|
|
35
23
|
}
|
|
36
24
|
|
|
@@ -46,7 +34,6 @@ function readConfig(ctx) {
|
|
|
46
34
|
if (a[key] !== undefined) cfg[key] = a[key]
|
|
47
35
|
}
|
|
48
36
|
if (!ANIMATION_MODES.includes(cfg.effectMode)) cfg.effectMode = 'flow'
|
|
49
|
-
if (!SOUND_EFFECTS.includes(cfg.soundEffect)) cfg.soundEffect = 'chime'
|
|
50
37
|
}
|
|
51
38
|
} catch { /* settings 未挂载,用默认 */ }
|
|
52
39
|
return cfg
|
|
@@ -55,7 +42,7 @@ function readConfig(ctx) {
|
|
|
55
42
|
export const feature = {
|
|
56
43
|
id: 'animation',
|
|
57
44
|
name: '任务动画',
|
|
58
|
-
description: '
|
|
45
|
+
description: '19 种任务运行动画(速度随任务活动联动,配置持久化;通知见「任务通知」,任务状态见「运行状态」)',
|
|
59
46
|
defaultEnabled: false,
|
|
60
47
|
setup(ctx) {
|
|
61
48
|
const disposers = []
|
|
@@ -66,428 +53,10 @@ export const feature = {
|
|
|
66
53
|
}
|
|
67
54
|
}
|
|
68
55
|
|
|
69
|
-
//
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0 }
|
|
75
|
-
|
|
76
|
-
function extractText(content) {
|
|
77
|
-
if (!Array.isArray(content)) return ''
|
|
78
|
-
const parts = []
|
|
79
|
-
for (const block of content) {
|
|
80
|
-
if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
81
|
-
}
|
|
82
|
-
return parts.join(' ').replace(/\s+/g, ' ').trim()
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function truncate(str, max = 160) {
|
|
86
|
-
if (!str) return ''
|
|
87
|
-
return String(str).length > max ? String(str).slice(0, max) + '…' : str
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ===== 任务阶段追踪(供桌面机器人等动效感知当前在干嘛) =====
|
|
91
|
-
// 流级实时同步(assistant/chunk,每 token 一条):
|
|
92
|
-
// reasoning-delta = 思考流 → think;text-delta = 正文输出流 → write
|
|
93
|
-
// 事件级:step/start = 思考;tool/call 按工具名分类:检索类 = search,其余 = code。
|
|
94
|
-
function phaseOfToolName(name) {
|
|
95
|
-
const n = String(name || '').toLowerCase()
|
|
96
|
-
if (/web|search|fetch|grep|glob|find|read|ls$|^ls|tree|locate/.test(n)) return 'search'
|
|
97
|
-
return 'code'
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 会话标题:官方标题服务,取不到回退首条用户输入(firstPrompt)
|
|
101
|
-
async function getSessionTitle(sessionId, sessionQuery) {
|
|
102
|
-
try {
|
|
103
|
-
if (!sessionQuery) return ''
|
|
104
|
-
if (typeof sessionQuery.readTitle === 'function') {
|
|
105
|
-
const snap = await sessionQuery.readTitle(sessionId)
|
|
106
|
-
if (snap && snap.title) return snap.title
|
|
107
|
-
}
|
|
108
|
-
if (typeof sessionQuery.readSession === 'function') {
|
|
109
|
-
const snap = await sessionQuery.readSession(sessionId)
|
|
110
|
-
if (snap && snap.header && snap.header.title) return snap.header.title
|
|
111
|
-
}
|
|
112
|
-
} catch { /* 标题取不到就用 firstPrompt 兜底 */ }
|
|
113
|
-
return ''
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function addActiveSession(sid, sessionQuery, extra = {}) {
|
|
117
|
-
if (activeSessions.has(sid)) return activeSessions.get(sid)
|
|
118
|
-
console.log('[dsh-dock] animation: task started', sid.substring(0, 8))
|
|
119
|
-
const record = {
|
|
120
|
-
startTime: Date.now(),
|
|
121
|
-
title: '',
|
|
122
|
-
turns: 0,
|
|
123
|
-
steps: 0,
|
|
124
|
-
toolCalls: 0,
|
|
125
|
-
models: new Set(),
|
|
126
|
-
provider: '',
|
|
127
|
-
inputTokens: 0,
|
|
128
|
-
outputTokens: 0,
|
|
129
|
-
totalTokens: 0,
|
|
130
|
-
reasoningTokens: 0,
|
|
131
|
-
cacheReadTokens: 0,
|
|
132
|
-
endReason: '',
|
|
133
|
-
errorMessage: '',
|
|
134
|
-
lastText: '',
|
|
135
|
-
firstPrompt: '',
|
|
136
|
-
// 工具等待用户确认(dsh 会话流 approval/asked):浏览器侧据此弹"需确认"通知
|
|
137
|
-
approvals: new Map(), // approvalId -> { toolName, reason, askedAt }
|
|
138
|
-
phase: 'think',
|
|
139
|
-
phaseAt: Date.now(),
|
|
140
|
-
lastActivityAt: Date.now(),
|
|
141
|
-
// assistant/chunk 是实时流;用轻量活动计数给浏览器侧映射动作速度,
|
|
142
|
-
// 不等最终 usage 落盘,避免任务正在输出时机器人仍慢速。
|
|
143
|
-
motionTicks: 0,
|
|
144
|
-
lastMotionAt: Date.now(),
|
|
145
|
-
...extra,
|
|
146
|
-
}
|
|
147
|
-
activeSessions.set(sid, record)
|
|
148
|
-
getSessionTitle(sid, sessionQuery).then((title) => {
|
|
149
|
-
const s = activeSessions.get(sid)
|
|
150
|
-
if (s) s.title = title
|
|
151
|
-
})
|
|
152
|
-
return record
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// 任务结束:归档 + 供客户端通知(通知由浏览器侧 diff 触发,这里只出数据)
|
|
156
|
-
function finishSession(sid) {
|
|
157
|
-
const session = activeSessions.get(sid)
|
|
158
|
-
if (!session) return
|
|
159
|
-
activeSessions.delete(sid)
|
|
160
|
-
const endTime = Date.now()
|
|
161
|
-
const duration = endTime - session.startTime
|
|
162
|
-
console.log('[dsh-dock] animation: task finished', {
|
|
163
|
-
sid: sid.substring(0, 8),
|
|
164
|
-
duration,
|
|
165
|
-
endReason: session.endReason || 'completed',
|
|
166
|
-
})
|
|
167
|
-
const record = {
|
|
168
|
-
sessionId: sid,
|
|
169
|
-
title: session.title || truncate(session.firstPrompt, 60) || '(无标题)',
|
|
170
|
-
duration,
|
|
171
|
-
turns: session.turns,
|
|
172
|
-
steps: session.steps,
|
|
173
|
-
toolCalls: session.toolCalls,
|
|
174
|
-
models: [...session.models],
|
|
175
|
-
provider: session.provider,
|
|
176
|
-
inputTokens: session.inputTokens,
|
|
177
|
-
outputTokens: session.outputTokens,
|
|
178
|
-
totalTokens: session.totalTokens,
|
|
179
|
-
reasoningTokens: session.reasoningTokens,
|
|
180
|
-
cacheReadTokens: session.cacheReadTokens,
|
|
181
|
-
endReason: session.endReason,
|
|
182
|
-
errorMessage: session.errorMessage,
|
|
183
|
-
lastText: session.lastText,
|
|
184
|
-
startTime: session.startTime,
|
|
185
|
-
endTime,
|
|
186
|
-
}
|
|
187
|
-
completedSessions.unshift(record)
|
|
188
|
-
if (completedSessions.length > MAX_COMPLETED) completedSessions.pop()
|
|
189
|
-
// 钉钉/飞书群机器人推送(宿主侧直发,异步不阻塞;事件筛选跟随 notifyOnComplete/notifyOnError)
|
|
190
|
-
pushDingtalkIfNeeded(record)
|
|
191
|
-
pushFeishuIfNeeded(record)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ===== 钉钉群机器人推送(参照 @wycto/dsh-task-pulse 同款 markdown 消息) =====
|
|
195
|
-
const END_REASON_LABELS = {
|
|
196
|
-
completed: '✅ 完成',
|
|
197
|
-
aborted: '⛔ 已中止',
|
|
198
|
-
blocked: '🚧 受阻',
|
|
199
|
-
error: '❌ 出错',
|
|
200
|
-
'max-tokens': '⏹ 达到输出上限',
|
|
201
|
-
interrupted: '⚡ 中断',
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function fmtDurCn(ms) {
|
|
205
|
-
const s = Math.max(0, Math.round((ms || 0) / 1000))
|
|
206
|
-
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60
|
|
207
|
-
if (h > 0) return `${h}小时${m}分${sec}秒`
|
|
208
|
-
if (m > 0) return `${m}分${sec}秒`
|
|
209
|
-
return `${sec}秒`
|
|
210
|
-
}
|
|
211
|
-
function pad2(n) { return String(n).padStart(2, '0') }
|
|
212
|
-
function fmtClockOf(ts) {
|
|
213
|
-
const d = new Date(ts)
|
|
214
|
-
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
|
|
215
|
-
}
|
|
216
|
-
function fmtDateClockOf(ts) {
|
|
217
|
-
const d = new Date(ts)
|
|
218
|
-
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${fmtClockOf(ts)}`
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// 钉钉与飞书共用的消息正文(各端再按各自 markdown 方言拼接)
|
|
222
|
-
function buildMessageLines(t) {
|
|
223
|
-
const success = !t.endReason || t.endReason === 'completed'
|
|
224
|
-
const reasonLabel = END_REASON_LABELS[t.endReason] || '✅ 完成'
|
|
225
|
-
const models = t.models.length > 0 ? t.models.join(', ') : '未知'
|
|
226
|
-
const lines = []
|
|
227
|
-
lines.push(`### ${success ? '✅' : '📢'} dsh 任务${success ? '完成' : '结束'}`)
|
|
228
|
-
lines.push(`**任务**: ${t.title}`)
|
|
229
|
-
lines.push(`**模型**: ${models}${t.provider ? `(${t.provider})` : ''}`)
|
|
230
|
-
lines.push(`**耗时**: ${fmtDurCn(t.duration)}(${fmtClockOf(t.startTime)} → ${fmtClockOf(t.endTime)})`)
|
|
231
|
-
lines.push(`**回合**: ${t.turns} · 步骤 ${t.steps}${t.toolCalls ? ` · 工具 ${t.toolCalls} 次` : ''}`)
|
|
232
|
-
lines.push(`**Token**: 输入 ${t.inputTokens.toLocaleString()} / 输出 ${t.outputTokens.toLocaleString()} / 总计 ${t.totalTokens.toLocaleString()}`)
|
|
233
|
-
lines.push(`**结果**: ${reasonLabel}${t.errorMessage ? `:${truncate(t.errorMessage, 120)}` : ''}`)
|
|
234
|
-
if (t.lastText) {
|
|
235
|
-
lines.push(`> ${truncate(t.lastText, 200)}`)
|
|
236
|
-
}
|
|
237
|
-
lines.push('---')
|
|
238
|
-
lines.push(`*由 dsh-dock 任务动画发送 · ${fmtDateClockOf(t.endTime)}*`)
|
|
239
|
-
return {
|
|
240
|
-
title: `dsh 任务${success ? '完成' : '结束'}:${truncate(t.title, 40)}`,
|
|
241
|
-
success,
|
|
242
|
-
lines,
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function buildDingtalkMessage(t) {
|
|
247
|
-
const { title, lines } = buildMessageLines(t)
|
|
248
|
-
return {
|
|
249
|
-
title,
|
|
250
|
-
// 钉钉 markdown 不渲染单个 \n,行之间必须用空行分隔才能换行
|
|
251
|
-
text: lines.join('\n\n'),
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// 飞书 interactive 卡片:标题进 header,正文走 lark_md(支持 **加粗** 与 \n 换行,不支持 ### 标题)
|
|
256
|
-
function buildFeishuMessage(t) {
|
|
257
|
-
const { title, success, lines } = buildMessageLines(t)
|
|
258
|
-
const body = lines.filter((l) => !l.startsWith('### ')).join('\n')
|
|
259
|
-
return {
|
|
260
|
-
title,
|
|
261
|
-
card: {
|
|
262
|
-
config: { wide_screen_mode: true },
|
|
263
|
-
header: {
|
|
264
|
-
template: success ? 'green' : 'orange',
|
|
265
|
-
title: { tag: 'plain_text', content: title },
|
|
266
|
-
},
|
|
267
|
-
elements: [
|
|
268
|
-
{ tag: 'div', text: { tag: 'lark_md', content: body } },
|
|
269
|
-
],
|
|
270
|
-
},
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// 发送飞书 interactive 卡片;成功 = HTTP 200 且业务码 code/StatusCode === 0
|
|
275
|
-
async function sendFeishu(webhook, title, card) {
|
|
276
|
-
try {
|
|
277
|
-
const res = await fetch(webhook, {
|
|
278
|
-
method: 'POST',
|
|
279
|
-
headers: { 'Content-Type': 'application/json' },
|
|
280
|
-
body: JSON.stringify({ msg_type: 'interactive', card }),
|
|
281
|
-
})
|
|
282
|
-
const raw = await res.text()
|
|
283
|
-
let data = null
|
|
284
|
-
try { data = JSON.parse(raw) } catch { /* 非 JSON 响应体 */ }
|
|
285
|
-
if (!res.ok) return { ok: false, error: `HTTP ${res.status} ${truncate(raw, 120)}` }
|
|
286
|
-
// 新版返回 { code, msg },旧版返回 { StatusCode, StatusMessage }
|
|
287
|
-
const code = data && typeof data === 'object'
|
|
288
|
-
? (typeof data.code === 'number' ? data.code : data.StatusCode)
|
|
289
|
-
: undefined
|
|
290
|
-
if (typeof code === 'number' && code !== 0) {
|
|
291
|
-
return { ok: false, error: `飞书 code ${code}:${data.msg || data.StatusMessage || ''}` }
|
|
292
|
-
}
|
|
293
|
-
return { ok: true }
|
|
294
|
-
} catch (e) {
|
|
295
|
-
return { ok: false, error: (e && e.message) || String(e) }
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// 发送钉钉 markdown 消息;成功 = HTTP 200 且业务码 errcode === 0
|
|
300
|
-
async function sendDingtalk(webhook, title, text) {
|
|
301
|
-
try {
|
|
302
|
-
const res = await fetch(webhook, {
|
|
303
|
-
method: 'POST',
|
|
304
|
-
headers: { 'Content-Type': 'application/json' },
|
|
305
|
-
body: JSON.stringify({ msgtype: 'markdown', markdown: { title, text } }),
|
|
306
|
-
})
|
|
307
|
-
const raw = await res.text()
|
|
308
|
-
let data = null
|
|
309
|
-
try { data = JSON.parse(raw) } catch { /* 非 JSON 响应体 */ }
|
|
310
|
-
if (!res.ok) return { ok: false, error: `HTTP ${res.status} ${truncate(raw, 120)}` }
|
|
311
|
-
if (data && typeof data.errcode === 'number' && data.errcode !== 0) {
|
|
312
|
-
return { ok: false, error: `钉钉 errcode ${data.errcode}:${data.errmsg || ''}` }
|
|
313
|
-
}
|
|
314
|
-
return { ok: true }
|
|
315
|
-
} catch (e) {
|
|
316
|
-
return { ok: false, error: (e && e.message) || String(e) }
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
async function pushDingtalkIfNeeded(record) {
|
|
321
|
-
try {
|
|
322
|
-
const cfg = readConfig(ctx)
|
|
323
|
-
if (!cfg.dingtalkEnabled || !cfg.dingtalkWebhook) return
|
|
324
|
-
const success = !record.endReason || record.endReason === 'completed'
|
|
325
|
-
if (success ? !cfg.notifyOnComplete : !cfg.notifyOnError) return
|
|
326
|
-
const { title, text } = buildDingtalkMessage(record)
|
|
327
|
-
const r = await sendDingtalk(cfg.dingtalkWebhook, title, text)
|
|
328
|
-
if (r.ok) console.log('[dsh-dock] animation dingtalk pushed:', title)
|
|
329
|
-
else console.error('[dsh-dock] animation dingtalk push failed:', r.error)
|
|
330
|
-
} catch (e) {
|
|
331
|
-
console.error('[dsh-dock] animation dingtalk push error:', e && e.message)
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
async function pushFeishuIfNeeded(record) {
|
|
336
|
-
try {
|
|
337
|
-
const cfg = readConfig(ctx)
|
|
338
|
-
if (!cfg.feishuEnabled || !cfg.feishuWebhook) return
|
|
339
|
-
const success = !record.endReason || record.endReason === 'completed'
|
|
340
|
-
if (success ? !cfg.notifyOnComplete : !cfg.notifyOnError) return
|
|
341
|
-
const { title, card } = buildFeishuMessage(record)
|
|
342
|
-
const r = await sendFeishu(cfg.feishuWebhook, title, card)
|
|
343
|
-
if (r.ok) console.log('[dsh-dock] animation feishu pushed:', title)
|
|
344
|
-
else console.error('[dsh-dock] animation feishu push failed:', r.error)
|
|
345
|
-
} catch (e) {
|
|
346
|
-
console.error('[dsh-dock] animation feishu push error:', e && e.message)
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function handleSessionEvent(sessionQuery, session, event) {
|
|
351
|
-
const sid = typeof session === 'object' && session ? (session.id || '') : String(session || '')
|
|
352
|
-
if (!sid || !event || !event.type) return
|
|
353
|
-
|
|
354
|
-
if (event.type === 'step/start') {
|
|
355
|
-
const s = addActiveSession(sid, sessionQuery, { startTime: event.time || Date.now() })
|
|
356
|
-
s.steps++
|
|
357
|
-
s.phase = 'think' // 新步骤:模型正在推理下一步
|
|
358
|
-
s.phaseAt = event.time || Date.now()
|
|
359
|
-
s.lastActivityAt = s.phaseAt
|
|
360
|
-
s.motionTicks += 2
|
|
361
|
-
s.lastMotionAt = s.phaseAt
|
|
362
|
-
if (event.data && event.data.turn !== undefined) {
|
|
363
|
-
s.turns = Math.max(s.turns, num(event.data.turn) + 1)
|
|
364
|
-
}
|
|
365
|
-
return
|
|
366
|
-
}
|
|
367
|
-
const s = activeSessions.get(sid)
|
|
368
|
-
if (!s) return
|
|
369
|
-
const now = event.time || Date.now()
|
|
370
|
-
// 流级阶段:chunk 高频到达(每 token 一条),只做最廉价的阶段切换
|
|
371
|
-
if (event.type === 'assistant/chunk') {
|
|
372
|
-
const chunk = event.data && event.data.chunk
|
|
373
|
-
const ctype = chunk && chunk.type
|
|
374
|
-
if (ctype === 'reasoning-delta') {
|
|
375
|
-
s.phase = 'think'
|
|
376
|
-
s.phaseAt = now
|
|
377
|
-
} else if (ctype === 'text-delta') {
|
|
378
|
-
s.phase = 'write'
|
|
379
|
-
s.phaseAt = now
|
|
380
|
-
}
|
|
381
|
-
// 不同适配器的 delta 字段名并不完全一致。按可见文本量估算,
|
|
382
|
-
// 无文本时每个 chunk 也记一个脉冲,保证推理流同样有动作反馈。
|
|
383
|
-
if (ctype === 'reasoning-delta' || ctype === 'text-delta') {
|
|
384
|
-
const delta = chunk && (chunk.text || chunk.delta || chunk.content)
|
|
385
|
-
const units = typeof delta === 'string' ? Math.max(1, Math.ceil(delta.length / 24)) : 1
|
|
386
|
-
s.motionTicks += units
|
|
387
|
-
s.lastMotionAt = now
|
|
388
|
-
}
|
|
389
|
-
s.lastActivityAt = now
|
|
390
|
-
return
|
|
391
|
-
}
|
|
392
|
-
if (event.type === 'turn/end' && event.data && event.data.reason) {
|
|
393
|
-
const reason = event.data.reason
|
|
394
|
-
s.endReason = (reason.kind || 'completed')
|
|
395
|
-
if (reason.kind === 'error' && reason.error) {
|
|
396
|
-
const msg = (reason.error && (reason.error.message || reason.error.code)) || ''
|
|
397
|
-
s.errorMessage = typeof msg === 'string' ? msg : String(msg)
|
|
398
|
-
}
|
|
399
|
-
} else if (event.type === 'approval/asked' && event.data) {
|
|
400
|
-
// 工具等待用户确认/批准:挂在会话记录上,/status 下发给浏览器弹"需确认"通知
|
|
401
|
-
const id = typeof event.data.id === 'string' ? event.data.id : ''
|
|
402
|
-
if (id) {
|
|
403
|
-
s.approvals.set(id, {
|
|
404
|
-
toolName: typeof event.data.toolName === 'string' ? event.data.toolName : '',
|
|
405
|
-
reason: typeof event.data.reason === 'string' ? truncate(event.data.reason, 200) : '',
|
|
406
|
-
askedAt: event.time || Date.now(),
|
|
407
|
-
})
|
|
408
|
-
}
|
|
409
|
-
s.lastActivityAt = now
|
|
410
|
-
} else if (event.type === 'approval/decided' && event.data) {
|
|
411
|
-
s.approvals.delete(typeof event.data.id === 'string' ? event.data.id : '')
|
|
412
|
-
} else if (event.type === 'tool/call') {
|
|
413
|
-
s.toolCalls++
|
|
414
|
-
s.phase = phaseOfToolName(event.data && event.data.name)
|
|
415
|
-
s.phaseAt = now
|
|
416
|
-
// 工具启动是稀疏事件,给一个更明显的短促动作,检索/写文件也不会显得停住。
|
|
417
|
-
s.motionTicks += s.phase === 'search' ? 7 : 9
|
|
418
|
-
s.lastMotionAt = now
|
|
419
|
-
} else if (event.type === 'user/message') {
|
|
420
|
-
if (!s.firstPrompt && event.data && event.data.content) {
|
|
421
|
-
const text = extractText(event.data.content)
|
|
422
|
-
if (text) s.firstPrompt = text
|
|
423
|
-
}
|
|
424
|
-
} else if (event.type === 'assistant/message' && event.data) {
|
|
425
|
-
const usage = event.data.usage
|
|
426
|
-
if (usage) {
|
|
427
|
-
s.inputTokens += num(usage.inputTokens)
|
|
428
|
-
s.outputTokens += num(usage.outputTokens)
|
|
429
|
-
s.totalTokens += num(usage.inputTokens) + num(usage.outputTokens)
|
|
430
|
-
s.reasoningTokens += num(usage.reasoningTokens)
|
|
431
|
-
s.cacheReadTokens += num(usage.cacheReadTokens)
|
|
432
|
-
}
|
|
433
|
-
if (event.data.message && event.data.message.content) {
|
|
434
|
-
const text = extractText(event.data.message.content)
|
|
435
|
-
if (text) s.lastText = text
|
|
436
|
-
}
|
|
437
|
-
// 完整消息落盘后不改阶段:下一步要么 tool/call(code/search)要么收尾,
|
|
438
|
-
// 维持 chunk 推导出的最后状态,避免"输完还显示思考中"的回跳
|
|
439
|
-
} else if (event.type === 'request/context' && event.data) {
|
|
440
|
-
if (event.data.provider) s.provider = event.data.provider
|
|
441
|
-
if (event.data.model) s.models.add(event.data.model)
|
|
442
|
-
} else if (event.type === 'request/header' && event.data && event.data.header && event.data.header.config) {
|
|
443
|
-
const c = event.data.header.config
|
|
444
|
-
if (c.provider) s.provider = c.provider
|
|
445
|
-
if (c.model) s.models.add(c.model)
|
|
446
|
-
}
|
|
447
|
-
if (event.type !== 'request/header' && event.type !== 'request/context') s.lastActivityAt = now
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function handleAgentStatus(sessionQuery, agent, status) {
|
|
451
|
-
const sid = agent && agent.id ? String(agent.id) : ''
|
|
452
|
-
if (!sid) return
|
|
453
|
-
if (status === 'running') {
|
|
454
|
-
const record = addActiveSession(sid, sessionQuery)
|
|
455
|
-
if (agent.options) {
|
|
456
|
-
if (agent.options.model) record.models.add(agent.options.model)
|
|
457
|
-
if (agent.options.provider) record.provider = agent.options.provider
|
|
458
|
-
}
|
|
459
|
-
} else if (status === 'idle') {
|
|
460
|
-
finishSession(sid)
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// 不能在 setup 时软获取 sessionQuery(可能早于服务激活,见 tokenlog 模块同款注释),用 inject 等就绪
|
|
465
|
-
disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
|
|
466
|
-
const sessionQuery = sqCtx.sessionQuery
|
|
467
|
-
disposers.push(sqCtx.on('session/event', (session, event) => {
|
|
468
|
-
try {
|
|
469
|
-
handleSessionEvent(sessionQuery, session, event)
|
|
470
|
-
} catch (err) {
|
|
471
|
-
console.error('[dsh-dock] animation handle event error', err)
|
|
472
|
-
}
|
|
473
|
-
}))
|
|
474
|
-
disposers.push(sqCtx.on('agent/status', (payload) => {
|
|
475
|
-
try {
|
|
476
|
-
handleAgentStatus(sessionQuery, payload && payload.agent, payload && payload.status)
|
|
477
|
-
} catch (err) {
|
|
478
|
-
console.error('[dsh-dock] animation handle agent/status error', err)
|
|
479
|
-
}
|
|
480
|
-
}))
|
|
481
|
-
// agent 销毁兜底归档(会话直接关闭时也产出完成记录)
|
|
482
|
-
disposers.push(sqCtx.on('agent/disposed', (payload) => {
|
|
483
|
-
try {
|
|
484
|
-
const sid = payload && payload.agent && payload.agent.id ? String(payload.agent.id) : ''
|
|
485
|
-
if (sid) finishSession(sid)
|
|
486
|
-
} catch (err) {
|
|
487
|
-
console.error('[dsh-dock] animation handle agent/disposed error', err)
|
|
488
|
-
}
|
|
489
|
-
}))
|
|
490
|
-
}))
|
|
56
|
+
// 会话级任务追踪(与【任务通知】共享一份;两个功能都停用后自动退订会话事件)
|
|
57
|
+
const lease = acquireTaskTracker(ctx)
|
|
58
|
+
disposers.push(() => lease.release())
|
|
59
|
+
const tracker = lease.tracker
|
|
491
60
|
|
|
492
61
|
// ===== RPC 路由 =====
|
|
493
62
|
disposers.push(ctx.inject(['webServer'], (wsCtx) => {
|
|
@@ -501,38 +70,9 @@ export const feature = {
|
|
|
501
70
|
const payload = await readBody(req)
|
|
502
71
|
|
|
503
72
|
if (method === 'status') {
|
|
504
|
-
const now = Date.now()
|
|
505
|
-
const active = []
|
|
506
|
-
for (const [sid, s] of activeSessions) {
|
|
507
|
-
active.push({
|
|
508
|
-
sessionId: sid,
|
|
509
|
-
title: s.title || truncate(s.firstPrompt, 60) || '(无标题)',
|
|
510
|
-
startTime: s.startTime,
|
|
511
|
-
elapsed: now - s.startTime,
|
|
512
|
-
turns: s.turns,
|
|
513
|
-
steps: s.steps,
|
|
514
|
-
toolCalls: s.toolCalls,
|
|
515
|
-
models: [...s.models],
|
|
516
|
-
provider: s.provider,
|
|
517
|
-
inputTokens: s.inputTokens,
|
|
518
|
-
outputTokens: s.outputTokens,
|
|
519
|
-
totalTokens: s.totalTokens,
|
|
520
|
-
phase: s.phase,
|
|
521
|
-
phaseAt: s.phaseAt,
|
|
522
|
-
lastActivityAt: s.lastActivityAt,
|
|
523
|
-
motionTicks: s.motionTicks,
|
|
524
|
-
lastMotionAt: s.lastMotionAt,
|
|
525
|
-
approvals: [...s.approvals.entries()].map(([id, v]) => ({ id, ...v })),
|
|
526
|
-
})
|
|
527
|
-
}
|
|
528
73
|
return sendJson(res, 200, {
|
|
529
74
|
ok: true,
|
|
530
|
-
data: {
|
|
531
|
-
now,
|
|
532
|
-
active,
|
|
533
|
-
recent: completedSessions.slice(0, 20),
|
|
534
|
-
config: readConfig(ctx),
|
|
535
|
-
},
|
|
75
|
+
data: Object.assign({}, tracker.snapshot(Date.now()), { config: readConfig(ctx) }),
|
|
536
76
|
})
|
|
537
77
|
}
|
|
538
78
|
|
|
@@ -545,36 +85,6 @@ export const feature = {
|
|
|
545
85
|
if (typeof p.robotScale === 'number' && Number.isFinite(p.robotScale)) {
|
|
546
86
|
cfg.robotScale = Math.max(0.85, Math.min(2.2, Math.round(p.robotScale * 100) / 100))
|
|
547
87
|
}
|
|
548
|
-
if (typeof p.notifyEnabled === 'boolean') cfg.notifyEnabled = p.notifyEnabled
|
|
549
|
-
if (typeof p.notifyOnComplete === 'boolean') cfg.notifyOnComplete = p.notifyOnComplete
|
|
550
|
-
if (typeof p.notifyOnError === 'boolean') cfg.notifyOnError = p.notifyOnError
|
|
551
|
-
if (typeof p.notifyOnConfirm === 'boolean') cfg.notifyOnConfirm = p.notifyOnConfirm
|
|
552
|
-
if (typeof p.notifyStayMs === 'number' && Number.isFinite(p.notifyStayMs)) {
|
|
553
|
-
cfg.notifyStayMs = Math.max(0, Math.min(600000, Math.round(p.notifyStayMs)))
|
|
554
|
-
}
|
|
555
|
-
if (typeof p.systemNotify === 'boolean') cfg.systemNotify = p.systemNotify
|
|
556
|
-
if (typeof p.soundNotify === 'boolean') cfg.soundNotify = p.soundNotify
|
|
557
|
-
if (typeof p.soundEffect === 'string' && SOUND_EFFECTS.includes(p.soundEffect)) cfg.soundEffect = p.soundEffect
|
|
558
|
-
if (typeof p.dingtalkEnabled === 'boolean') cfg.dingtalkEnabled = p.dingtalkEnabled
|
|
559
|
-
if (typeof p.dingtalkWebhook === 'string') {
|
|
560
|
-
const hook = p.dingtalkWebhook.trim()
|
|
561
|
-
if (hook && !/^https?:\/\//i.test(hook)) {
|
|
562
|
-
const err = new Error('钉钉 Webhook 地址需以 http(s):// 开头')
|
|
563
|
-
err.statusCode = 400
|
|
564
|
-
throw err
|
|
565
|
-
}
|
|
566
|
-
cfg.dingtalkWebhook = hook
|
|
567
|
-
}
|
|
568
|
-
if (typeof p.feishuEnabled === 'boolean') cfg.feishuEnabled = p.feishuEnabled
|
|
569
|
-
if (typeof p.feishuWebhook === 'string') {
|
|
570
|
-
const hook = p.feishuWebhook.trim()
|
|
571
|
-
if (hook && !/^https?:\/\//i.test(hook)) {
|
|
572
|
-
const err = new Error('飞书 Webhook 地址需以 http(s):// 开头')
|
|
573
|
-
err.statusCode = 400
|
|
574
|
-
throw err
|
|
575
|
-
}
|
|
576
|
-
cfg.feishuWebhook = hook
|
|
577
|
-
}
|
|
578
88
|
|
|
579
89
|
const settings = ctx.get('settings')
|
|
580
90
|
if (!settings || typeof settings.mutate !== 'function') {
|
|
@@ -587,61 +97,10 @@ export const feature = {
|
|
|
587
97
|
err.statusCode = 400
|
|
588
98
|
throw err
|
|
589
99
|
}
|
|
590
|
-
console.log('[dsh-dock] animation config saved:', cfg.effectMode, 'anim=' + cfg.animationEnabled
|
|
100
|
+
console.log('[dsh-dock] animation config saved:', cfg.effectMode, 'anim=' + cfg.animationEnabled)
|
|
591
101
|
return sendJson(res, 200, { ok: true, data: { config: cfg, savedAt: Date.now() } })
|
|
592
102
|
}
|
|
593
103
|
|
|
594
|
-
// 机器人连通性测试:payload.target 指定 'dingtalk'(默认)或 'feishu',用当前已保存的 Webhook 发一条测试消息
|
|
595
|
-
if (method === 'test') {
|
|
596
|
-
const target = (payload && payload.target) === 'feishu' ? 'feishu' : 'dingtalk'
|
|
597
|
-
const cfgNow = readConfig(ctx)
|
|
598
|
-
const webhook = target === 'feishu' ? cfgNow.feishuWebhook : cfgNow.dingtalkWebhook
|
|
599
|
-
if (!webhook) {
|
|
600
|
-
const err = new Error(`请先填写并保存${target === 'feishu' ? '飞书' : '钉钉'} Webhook 地址`)
|
|
601
|
-
err.statusCode = 400
|
|
602
|
-
throw err
|
|
603
|
-
}
|
|
604
|
-
const nameCn = target === 'feishu' ? '飞书' : '钉钉'
|
|
605
|
-
let r
|
|
606
|
-
if (target === 'feishu') {
|
|
607
|
-
const text = [
|
|
608
|
-
'**这是一条来自 dsh-dock 任务动画的测试消息。**',
|
|
609
|
-
'',
|
|
610
|
-
`**时间**: ${fmtDateClockOf(Date.now())}`,
|
|
611
|
-
'',
|
|
612
|
-
'看到这条消息说明飞书 Webhook 配置正确。',
|
|
613
|
-
'',
|
|
614
|
-
'---',
|
|
615
|
-
'*由 dsh-dock 任务动画发送*',
|
|
616
|
-
].join('\n')
|
|
617
|
-
const card = {
|
|
618
|
-
config: { wide_screen_mode: true },
|
|
619
|
-
header: {
|
|
620
|
-
template: 'blue',
|
|
621
|
-
title: { tag: 'plain_text', content: '🧪 dsh 任务动画测试' },
|
|
622
|
-
},
|
|
623
|
-
elements: [{ tag: 'div', text: { tag: 'lark_md', content: text } }],
|
|
624
|
-
}
|
|
625
|
-
r = await sendFeishu(webhook, 'dsh 任务动画测试', card)
|
|
626
|
-
} else {
|
|
627
|
-
const text = [
|
|
628
|
-
'### 🧪 dsh 任务动画测试',
|
|
629
|
-
'',
|
|
630
|
-
'这是一条来自 dsh-dock 任务动画的测试消息。',
|
|
631
|
-
'',
|
|
632
|
-
`**时间**: ${fmtDateClockOf(Date.now())}`,
|
|
633
|
-
'',
|
|
634
|
-
'> 看到这条消息说明钉钉 Webhook 配置正确。',
|
|
635
|
-
'',
|
|
636
|
-
'---',
|
|
637
|
-
'*由 dsh-dock 任务动画发送*',
|
|
638
|
-
].join('\n')
|
|
639
|
-
r = await sendDingtalk(webhook, 'dsh 任务动画测试', text)
|
|
640
|
-
}
|
|
641
|
-
if (!r.ok) console.error(`[dsh-dock] animation ${target} test failed:`, r.error)
|
|
642
|
-
return sendJson(res, 200, { ok: true, data: { sent: r.ok, error: r.ok ? '' : r.error } })
|
|
643
|
-
}
|
|
644
|
-
|
|
645
104
|
return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
|
|
646
105
|
} catch (e) {
|
|
647
106
|
const status = e && e.statusCode ? e.statusCode : 500
|