dsh-dock 0.4.1 → 0.5.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.
@@ -0,0 +1,516 @@
1
+ // dsh-dock · 功能模块【任务动画】· 宿主半部(v0.5.0,参照 @wycto/dsh-task-pulse lib/index.js 会话级追踪,同作者 MIT)
2
+ //
3
+ // 职责:会话级任务追踪(开始/结束/回合/步骤/工具/Token/结束原因)+ RPC + 配置持久化。
4
+ // 不做动画(那是浏览器半部的事),也不做钉钉推送(dock 版走浏览器通知,见 view.jsx)。
5
+ //
6
+ // RPC(webServer HTTP 路由,前缀 /dsh-dock/animation/):
7
+ // POST /status —— 活跃任务 + 最近完成 + 配置(客户端据此渲染动画/通知)
8
+ // POST /config —— 增量更新配置字段并持久化到 settings(dsh-dock 命名空间 animation 段)
9
+ //
10
+ // 配置模型(schemastery schema 见 src/host-core.js DockConfig.animation):
11
+ // animationEnabled / effectMode / notifyEnabled / notifyOnComplete /
12
+ // notifyOnError / notifyStayMs / systemNotify
13
+ // 动画与通知是两个独立开关,可只开其一;全部经 settings 持久化,重启后恢复。
14
+ import { DOCK_NS, ANIMATION_MODES, SOUND_EFFECTS, sendJson, readBody } from '../../src/host-core.js'
15
+
16
+ // 默认配置(schema 默认值一致;settings.get 未挂载时的兜底)
17
+ function defaultConfig() {
18
+ return {
19
+ animationEnabled: true,
20
+ effectMode: 'flow',
21
+ robotScale: 1.35,
22
+ notifyEnabled: true,
23
+ notifyOnComplete: true,
24
+ notifyOnError: true,
25
+ notifyStayMs: 8000,
26
+ systemNotify: false,
27
+ soundNotify: true,
28
+ soundEffect: 'chime',
29
+ dingtalkEnabled: false,
30
+ dingtalkWebhook: '',
31
+ }
32
+ }
33
+
34
+ // 读 settings 里的 animation 配置(resolved 值已含 schema 默认),异常时回退默认
35
+ function readConfig(ctx) {
36
+ const cfg = defaultConfig()
37
+ try {
38
+ const settings = ctx.get('settings')
39
+ const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : null
40
+ const a = v && typeof v === 'object' && v.animation && typeof v.animation === 'object' ? v.animation : null
41
+ if (a) {
42
+ for (const key of Object.keys(cfg)) {
43
+ if (a[key] !== undefined) cfg[key] = a[key]
44
+ }
45
+ if (!ANIMATION_MODES.includes(cfg.effectMode)) cfg.effectMode = 'flow'
46
+ if (!SOUND_EFFECTS.includes(cfg.soundEffect)) cfg.soundEffect = 'chime'
47
+ }
48
+ } catch { /* settings 未挂载,用默认 */ }
49
+ return cfg
50
+ }
51
+
52
+ export const feature = {
53
+ id: 'animation',
54
+ name: '任务动画',
55
+ description: '任务运行动画与完成通知(动画/通知独立开关,配置持久化)',
56
+ defaultEnabled: true,
57
+ setup(ctx) {
58
+ const disposers = []
59
+ const dispose = () => {
60
+ while (disposers.length > 0) {
61
+ const fn = disposers.pop()
62
+ try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
63
+ }
64
+ }
65
+
66
+ // ===== 会话级任务追踪(agent/status 驱动开始/结束,session/event 补统计) =====
67
+ const activeSessions = new Map() // sessionId -> 追踪记录
68
+ const completedSessions = [] // 最近完成(新→旧)
69
+ const MAX_COMPLETED = 30
70
+
71
+ function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0 }
72
+
73
+ function extractText(content) {
74
+ if (!Array.isArray(content)) return ''
75
+ const parts = []
76
+ for (const block of content) {
77
+ if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
78
+ }
79
+ return parts.join(' ').replace(/\s+/g, ' ').trim()
80
+ }
81
+
82
+ function truncate(str, max = 160) {
83
+ if (!str) return ''
84
+ return String(str).length > max ? String(str).slice(0, max) + '…' : str
85
+ }
86
+
87
+ // ===== 任务阶段追踪(供桌面机器人等动效感知当前在干嘛) =====
88
+ // 流级实时同步(assistant/chunk,每 token 一条):
89
+ // reasoning-delta = 思考流 → think;text-delta = 正文输出流 → write
90
+ // 事件级:step/start = 思考;tool/call 按工具名分类:检索类 = search,其余 = code。
91
+ function phaseOfToolName(name) {
92
+ const n = String(name || '').toLowerCase()
93
+ if (/web|search|fetch|grep|glob|find|read|ls$|^ls|tree|locate/.test(n)) return 'search'
94
+ return 'code'
95
+ }
96
+
97
+ // 会话标题:官方标题服务,取不到回退首条用户输入(firstPrompt)
98
+ async function getSessionTitle(sessionId, sessionQuery) {
99
+ try {
100
+ if (!sessionQuery) return ''
101
+ if (typeof sessionQuery.readTitle === 'function') {
102
+ const snap = await sessionQuery.readTitle(sessionId)
103
+ if (snap && snap.title) return snap.title
104
+ }
105
+ if (typeof sessionQuery.readSession === 'function') {
106
+ const snap = await sessionQuery.readSession(sessionId)
107
+ if (snap && snap.header && snap.header.title) return snap.header.title
108
+ }
109
+ } catch { /* 标题取不到就用 firstPrompt 兜底 */ }
110
+ return ''
111
+ }
112
+
113
+ function addActiveSession(sid, sessionQuery, extra = {}) {
114
+ if (activeSessions.has(sid)) return activeSessions.get(sid)
115
+ console.log('[dsh-dock] animation: task started', sid.substring(0, 8))
116
+ const record = {
117
+ startTime: Date.now(),
118
+ title: '',
119
+ turns: 0,
120
+ steps: 0,
121
+ toolCalls: 0,
122
+ models: new Set(),
123
+ provider: '',
124
+ inputTokens: 0,
125
+ outputTokens: 0,
126
+ totalTokens: 0,
127
+ reasoningTokens: 0,
128
+ cacheReadTokens: 0,
129
+ endReason: '',
130
+ errorMessage: '',
131
+ lastText: '',
132
+ firstPrompt: '',
133
+ phase: 'think',
134
+ phaseAt: Date.now(),
135
+ lastActivityAt: Date.now(),
136
+ ...extra,
137
+ }
138
+ activeSessions.set(sid, record)
139
+ getSessionTitle(sid, sessionQuery).then((title) => {
140
+ const s = activeSessions.get(sid)
141
+ if (s) s.title = title
142
+ })
143
+ return record
144
+ }
145
+
146
+ // 任务结束:归档 + 供客户端通知(通知由浏览器侧 diff 触发,这里只出数据)
147
+ function finishSession(sid) {
148
+ const session = activeSessions.get(sid)
149
+ if (!session) return
150
+ activeSessions.delete(sid)
151
+ const endTime = Date.now()
152
+ const duration = endTime - session.startTime
153
+ console.log('[dsh-dock] animation: task finished', {
154
+ sid: sid.substring(0, 8),
155
+ duration,
156
+ endReason: session.endReason || 'completed',
157
+ })
158
+ const record = {
159
+ sessionId: sid,
160
+ title: session.title || truncate(session.firstPrompt, 60) || '(无标题)',
161
+ duration,
162
+ turns: session.turns,
163
+ steps: session.steps,
164
+ toolCalls: session.toolCalls,
165
+ models: [...session.models],
166
+ provider: session.provider,
167
+ inputTokens: session.inputTokens,
168
+ outputTokens: session.outputTokens,
169
+ totalTokens: session.totalTokens,
170
+ reasoningTokens: session.reasoningTokens,
171
+ cacheReadTokens: session.cacheReadTokens,
172
+ endReason: session.endReason,
173
+ errorMessage: session.errorMessage,
174
+ lastText: session.lastText,
175
+ startTime: session.startTime,
176
+ endTime,
177
+ }
178
+ completedSessions.unshift(record)
179
+ if (completedSessions.length > MAX_COMPLETED) completedSessions.pop()
180
+ // 钉钉群机器人推送(宿主侧直发,异步不阻塞;事件筛选跟随 notifyOnComplete/notifyOnError)
181
+ pushDingtalkIfNeeded(record)
182
+ }
183
+
184
+ // ===== 钉钉群机器人推送(参照 @wycto/dsh-task-pulse 同款 markdown 消息) =====
185
+ const END_REASON_LABELS = {
186
+ completed: '✅ 完成',
187
+ aborted: '⛔ 已中止',
188
+ blocked: '🚧 受阻',
189
+ error: '❌ 出错',
190
+ 'max-tokens': '⏹ 达到输出上限',
191
+ interrupted: '⚡ 中断',
192
+ }
193
+
194
+ function fmtDurCn(ms) {
195
+ const s = Math.max(0, Math.round((ms || 0) / 1000))
196
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60
197
+ if (h > 0) return `${h}小时${m}分${sec}秒`
198
+ if (m > 0) return `${m}分${sec}秒`
199
+ return `${sec}秒`
200
+ }
201
+ function pad2(n) { return String(n).padStart(2, '0') }
202
+ function fmtClockOf(ts) {
203
+ const d = new Date(ts)
204
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
205
+ }
206
+ function fmtDateClockOf(ts) {
207
+ const d = new Date(ts)
208
+ return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${fmtClockOf(ts)}`
209
+ }
210
+
211
+ function buildDingtalkMessage(t) {
212
+ const success = !t.endReason || t.endReason === 'completed'
213
+ const reasonLabel = END_REASON_LABELS[t.endReason] || '✅ 完成'
214
+ const models = t.models.length > 0 ? t.models.join(', ') : '未知'
215
+ const lines = []
216
+ lines.push(`### ${success ? '✅' : '📢'} dsh 任务${success ? '完成' : '结束'}`)
217
+ lines.push('')
218
+ lines.push(`**任务**: ${t.title}`)
219
+ lines.push(`**模型**: ${models}${t.provider ? `(${t.provider})` : ''}`)
220
+ lines.push(`**耗时**: ${fmtDurCn(t.duration)}(${fmtClockOf(t.startTime)} → ${fmtClockOf(t.endTime)})`)
221
+ lines.push(`**回合**: ${t.turns} · 步骤 ${t.steps}${t.toolCalls ? ` · 工具 ${t.toolCalls} 次` : ''}`)
222
+ lines.push(`**Token**: 输入 ${t.inputTokens.toLocaleString()} / 输出 ${t.outputTokens.toLocaleString()} / 总计 ${t.totalTokens.toLocaleString()}`)
223
+ lines.push(`**结果**: ${reasonLabel}${t.errorMessage ? `:${truncate(t.errorMessage, 120)}` : ''}`)
224
+ if (t.lastText) {
225
+ lines.push('')
226
+ lines.push(`> ${truncate(t.lastText, 200)}`)
227
+ }
228
+ lines.push('')
229
+ lines.push('---')
230
+ lines.push(`*由 dsh-dock 任务动画发送 · ${fmtDateClockOf(t.endTime)}*`)
231
+ return {
232
+ title: `dsh 任务${success ? '完成' : '结束'}:${truncate(t.title, 40)}`,
233
+ text: lines.join('\n'),
234
+ }
235
+ }
236
+
237
+ // 发送钉钉 markdown 消息;成功 = HTTP 200 且业务码 errcode === 0
238
+ async function sendDingtalk(webhook, title, text) {
239
+ try {
240
+ const res = await fetch(webhook, {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json' },
243
+ body: JSON.stringify({ msgtype: 'markdown', markdown: { title, text } }),
244
+ })
245
+ const raw = await res.text()
246
+ let data = null
247
+ try { data = JSON.parse(raw) } catch { /* 非 JSON 响应体 */ }
248
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status} ${truncate(raw, 120)}` }
249
+ if (data && typeof data.errcode === 'number' && data.errcode !== 0) {
250
+ return { ok: false, error: `钉钉 errcode ${data.errcode}:${data.errmsg || ''}` }
251
+ }
252
+ return { ok: true }
253
+ } catch (e) {
254
+ return { ok: false, error: (e && e.message) || String(e) }
255
+ }
256
+ }
257
+
258
+ async function pushDingtalkIfNeeded(record) {
259
+ try {
260
+ const cfg = readConfig(ctx)
261
+ if (!cfg.dingtalkEnabled || !cfg.dingtalkWebhook) return
262
+ const success = !record.endReason || record.endReason === 'completed'
263
+ if (success ? !cfg.notifyOnComplete : !cfg.notifyOnError) return
264
+ const { title, text } = buildDingtalkMessage(record)
265
+ const r = await sendDingtalk(cfg.dingtalkWebhook, title, text)
266
+ if (r.ok) console.log('[dsh-dock] animation dingtalk pushed:', title)
267
+ else console.error('[dsh-dock] animation dingtalk push failed:', r.error)
268
+ } catch (e) {
269
+ console.error('[dsh-dock] animation dingtalk push error:', e && e.message)
270
+ }
271
+ }
272
+
273
+ function handleSessionEvent(sessionQuery, session, event) {
274
+ const sid = typeof session === 'object' && session ? (session.id || '') : String(session || '')
275
+ if (!sid || !event || !event.type) return
276
+
277
+ if (event.type === 'step/start') {
278
+ const s = addActiveSession(sid, sessionQuery, { startTime: event.time || Date.now() })
279
+ s.steps++
280
+ s.phase = 'think' // 新步骤:模型正在推理下一步
281
+ s.phaseAt = event.time || Date.now()
282
+ s.lastActivityAt = s.phaseAt
283
+ if (event.data && event.data.turn !== undefined) {
284
+ s.turns = Math.max(s.turns, num(event.data.turn) + 1)
285
+ }
286
+ return
287
+ }
288
+ const s = activeSessions.get(sid)
289
+ if (!s) return
290
+ const now = event.time || Date.now()
291
+ // 流级阶段:chunk 高频到达(每 token 一条),只做最廉价的阶段切换
292
+ if (event.type === 'assistant/chunk') {
293
+ const ctype = event.data && event.data.chunk && event.data.chunk.type
294
+ if (ctype === 'reasoning-delta') {
295
+ s.phase = 'think'
296
+ s.phaseAt = now
297
+ } else if (ctype === 'text-delta') {
298
+ s.phase = 'write'
299
+ s.phaseAt = now
300
+ }
301
+ s.lastActivityAt = now
302
+ return
303
+ }
304
+ if (event.type === 'turn/end' && event.data && event.data.reason) {
305
+ const reason = event.data.reason
306
+ s.endReason = (reason.kind || 'completed')
307
+ if (reason.kind === 'error' && reason.error) {
308
+ const msg = (reason.error && (reason.error.message || reason.error.code)) || ''
309
+ s.errorMessage = typeof msg === 'string' ? msg : String(msg)
310
+ }
311
+ } else if (event.type === 'tool/call') {
312
+ s.toolCalls++
313
+ s.phase = phaseOfToolName(event.data && event.data.name)
314
+ s.phaseAt = now
315
+ } else if (event.type === 'user/message') {
316
+ if (!s.firstPrompt && event.data && event.data.content) {
317
+ const text = extractText(event.data.content)
318
+ if (text) s.firstPrompt = text
319
+ }
320
+ } else if (event.type === 'assistant/message' && event.data) {
321
+ const usage = event.data.usage
322
+ if (usage) {
323
+ s.inputTokens += num(usage.inputTokens)
324
+ s.outputTokens += num(usage.outputTokens)
325
+ s.totalTokens += num(usage.inputTokens) + num(usage.outputTokens)
326
+ s.reasoningTokens += num(usage.reasoningTokens)
327
+ s.cacheReadTokens += num(usage.cacheReadTokens)
328
+ }
329
+ if (event.data.message && event.data.message.content) {
330
+ const text = extractText(event.data.message.content)
331
+ if (text) s.lastText = text
332
+ }
333
+ // 完整消息落盘后不改阶段:下一步要么 tool/call(code/search)要么收尾,
334
+ // 维持 chunk 推导出的最后状态,避免"输完还显示思考中"的回跳
335
+ } else if (event.type === 'request/context' && event.data) {
336
+ if (event.data.provider) s.provider = event.data.provider
337
+ if (event.data.model) s.models.add(event.data.model)
338
+ } else if (event.type === 'request/header' && event.data && event.data.header && event.data.header.config) {
339
+ const c = event.data.header.config
340
+ if (c.provider) s.provider = c.provider
341
+ if (c.model) s.models.add(c.model)
342
+ }
343
+ if (event.type !== 'request/header' && event.type !== 'request/context') s.lastActivityAt = now
344
+ }
345
+
346
+ function handleAgentStatus(sessionQuery, agent, status) {
347
+ const sid = agent && agent.id ? String(agent.id) : ''
348
+ if (!sid) return
349
+ if (status === 'running') {
350
+ const record = addActiveSession(sid, sessionQuery)
351
+ if (agent.options) {
352
+ if (agent.options.model) record.models.add(agent.options.model)
353
+ if (agent.options.provider) record.provider = agent.options.provider
354
+ }
355
+ } else if (status === 'idle') {
356
+ finishSession(sid)
357
+ }
358
+ }
359
+
360
+ // 不能在 setup 时软获取 sessionQuery(可能早于服务激活,见 tokenlog 模块同款注释),用 inject 等就绪
361
+ disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
362
+ const sessionQuery = sqCtx.sessionQuery
363
+ disposers.push(sqCtx.on('session/event', (session, event) => {
364
+ try {
365
+ handleSessionEvent(sessionQuery, session, event)
366
+ } catch (err) {
367
+ console.error('[dsh-dock] animation handle event error', err)
368
+ }
369
+ }))
370
+ disposers.push(sqCtx.on('agent/status', (payload) => {
371
+ try {
372
+ handleAgentStatus(sessionQuery, payload && payload.agent, payload && payload.status)
373
+ } catch (err) {
374
+ console.error('[dsh-dock] animation handle agent/status error', err)
375
+ }
376
+ }))
377
+ // agent 销毁兜底归档(会话直接关闭时也产出完成记录)
378
+ disposers.push(sqCtx.on('agent/disposed', (payload) => {
379
+ try {
380
+ const sid = payload && payload.agent && payload.agent.id ? String(payload.agent.id) : ''
381
+ if (sid) finishSession(sid)
382
+ } catch (err) {
383
+ console.error('[dsh-dock] animation handle agent/disposed error', err)
384
+ }
385
+ }))
386
+ }))
387
+
388
+ // ===== RPC 路由 =====
389
+ disposers.push(ctx.inject(['webServer'], (wsCtx) => {
390
+ wsCtx.effect(() => wsCtx.webServer.register({
391
+ kind: 'prefix',
392
+ path: '/dsh-dock/animation',
393
+ async handler(req, res) {
394
+ try {
395
+ const url = new URL(req.url || '/', 'http://dsh.internal')
396
+ const method = url.pathname.replace(/^\/dsh-dock\/animation\/?/, '').split('/')[0] || ''
397
+ const payload = await readBody(req)
398
+
399
+ if (method === 'status') {
400
+ const now = Date.now()
401
+ const active = []
402
+ for (const [sid, s] of activeSessions) {
403
+ active.push({
404
+ sessionId: sid,
405
+ title: s.title || truncate(s.firstPrompt, 60) || '(无标题)',
406
+ startTime: s.startTime,
407
+ elapsed: now - s.startTime,
408
+ turns: s.turns,
409
+ steps: s.steps,
410
+ toolCalls: s.toolCalls,
411
+ models: [...s.models],
412
+ provider: s.provider,
413
+ inputTokens: s.inputTokens,
414
+ outputTokens: s.outputTokens,
415
+ totalTokens: s.totalTokens,
416
+ phase: s.phase,
417
+ phaseAt: s.phaseAt,
418
+ lastActivityAt: s.lastActivityAt,
419
+ })
420
+ }
421
+ return sendJson(res, 200, {
422
+ ok: true,
423
+ data: {
424
+ now,
425
+ active,
426
+ recent: completedSessions.slice(0, 20),
427
+ config: readConfig(ctx),
428
+ },
429
+ })
430
+ }
431
+
432
+ if (method === 'config') {
433
+ // 增量合并:只接受已知字段,类型不符忽略;整体写回 animation 段
434
+ const cfg = readConfig(ctx)
435
+ const p = payload || {}
436
+ if (typeof p.animationEnabled === 'boolean') cfg.animationEnabled = p.animationEnabled
437
+ if (typeof p.effectMode === 'string' && ANIMATION_MODES.includes(p.effectMode)) cfg.effectMode = p.effectMode
438
+ if (typeof p.robotScale === 'number' && Number.isFinite(p.robotScale)) {
439
+ cfg.robotScale = Math.max(0.85, Math.min(2.2, Math.round(p.robotScale * 100) / 100))
440
+ }
441
+ if (typeof p.notifyEnabled === 'boolean') cfg.notifyEnabled = p.notifyEnabled
442
+ if (typeof p.notifyOnComplete === 'boolean') cfg.notifyOnComplete = p.notifyOnComplete
443
+ if (typeof p.notifyOnError === 'boolean') cfg.notifyOnError = p.notifyOnError
444
+ if (typeof p.notifyStayMs === 'number' && Number.isFinite(p.notifyStayMs)) {
445
+ cfg.notifyStayMs = Math.max(0, Math.min(600000, Math.round(p.notifyStayMs)))
446
+ }
447
+ if (typeof p.systemNotify === 'boolean') cfg.systemNotify = p.systemNotify
448
+ if (typeof p.soundNotify === 'boolean') cfg.soundNotify = p.soundNotify
449
+ if (typeof p.soundEffect === 'string' && SOUND_EFFECTS.includes(p.soundEffect)) cfg.soundEffect = p.soundEffect
450
+ if (typeof p.dingtalkEnabled === 'boolean') cfg.dingtalkEnabled = p.dingtalkEnabled
451
+ if (typeof p.dingtalkWebhook === 'string') {
452
+ const hook = p.dingtalkWebhook.trim()
453
+ if (hook && !/^https?:\/\//i.test(hook)) {
454
+ const err = new Error('钉钉 Webhook 地址需以 http(s):// 开头')
455
+ err.statusCode = 400
456
+ throw err
457
+ }
458
+ cfg.dingtalkWebhook = hook
459
+ }
460
+
461
+ const settings = ctx.get('settings')
462
+ if (!settings || typeof settings.mutate !== 'function') {
463
+ throw new Error('settings 服务不可用,配置无法持久化')
464
+ }
465
+ try {
466
+ await settings.mutate(DOCK_NS, [{ op: 'set', path: ['animation'], value: cfg }])
467
+ } catch (e) {
468
+ const err = new Error('保存配置被拒绝:' + ((e && e.message) || String(e)))
469
+ err.statusCode = 400
470
+ throw err
471
+ }
472
+ console.log('[dsh-dock] animation config saved:', cfg.effectMode, 'anim=' + cfg.animationEnabled, 'notify=' + cfg.notifyEnabled)
473
+ return sendJson(res, 200, { ok: true, data: { config: cfg, savedAt: Date.now() } })
474
+ }
475
+
476
+ // 钉钉连通性测试:用当前已保存的 Webhook 发一条测试消息
477
+ if (method === 'test') {
478
+ const cfgNow = readConfig(ctx)
479
+ if (!cfgNow.dingtalkWebhook) {
480
+ const err = new Error('请先填写并保存钉钉 Webhook 地址')
481
+ err.statusCode = 400
482
+ throw err
483
+ }
484
+ const text = [
485
+ '### 🧪 dsh 任务动画测试',
486
+ '',
487
+ '这是一条来自 dsh-dock 任务动画的测试消息。',
488
+ '',
489
+ `**时间**: ${fmtDateClockOf(Date.now())}`,
490
+ '',
491
+ '> 看到这条消息说明钉钉 Webhook 配置正确。',
492
+ '',
493
+ '---',
494
+ '*由 dsh-dock 任务动画发送*',
495
+ ].join('\n')
496
+ const r = await sendDingtalk(cfgNow.dingtalkWebhook, 'dsh 任务动画测试', text)
497
+ if (!r.ok) console.error('[dsh-dock] animation dingtalk test failed:', r.error)
498
+ return sendJson(res, 200, { ok: true, data: { sent: r.ok, error: r.ok ? '' : r.error } })
499
+ }
500
+
501
+ return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
502
+ } catch (e) {
503
+ const status = e && e.statusCode ? e.statusCode : 500
504
+ console.error('[dsh-dock] animation HTTP error:', status, e && e.message)
505
+ return sendJson(res, status, {
506
+ ok: false,
507
+ error: { code: status >= 500 ? 'internal' : 'bad-request', message: (e && e.message) || String(e) },
508
+ })
509
+ }
510
+ },
511
+ }), 'dsh-dock animation: /dsh-dock/animation HTTP route')
512
+ }))
513
+
514
+ return dispose
515
+ },
516
+ }