dsh-dock 0.9.9 → 0.10.1

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.
@@ -1,17 +1,17 @@
1
- // dsh-dock · 功能模块【任务动画】· 宿主半部(v0.5.0,参照 @wycto/dsh-task-pulse lib/index.js 会话级追踪,同作者 MIT)
1
+ // dsh-dock · 功能模块【任务动画】· 宿主半部
2
2
  //
3
- // 职责:会话级任务追踪(开始/结束/回合/步骤/工具/Token/结束原因)+ RPC + 配置持久化。
4
- // 不做动画(那是浏览器半部的事),也不做钉钉推送(dock 版走浏览器通知,见 view.jsx)。
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 —— 增量更新配置字段并持久化到 settings(dsh-dock 命名空间 animation 段)
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 / notifyEnabled / notifyOnComplete /
12
- // notifyOnError / notifyOnConfirm / notifyStayMs / systemNotify
13
- // 动画与通知是两个独立开关,可只开其一;全部经 settings 持久化,重启后恢复。
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
- // ===== 会话级任务追踪(agent/status 驱动开始/结束,session/event 补统计) =====
70
- const activeSessions = new Map() // sessionId -> 追踪记录
71
- const completedSessions = [] // 最近完成(新→旧)
72
- const MAX_COMPLETED = 30
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, 'notify=' + cfg.notifyEnabled)
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