dsh-dock 0.9.6 → 0.9.7
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/LICENSE +18 -18
- package/README.md +325 -325
- package/client.js +42 -8
- package/cordis.patch.yml +2 -2
- package/features/animation/host.js +660 -660
- package/features/animation/view.jsx +2247 -2247
- package/features/balance/host.js +494 -494
- package/features/balance/view.js +351 -351
- package/features/games/games/breakout.jsx +227 -227
- package/features/games/games/gomoku.jsx +244 -244
- package/features/games/games/racer.jsx +169 -169
- package/features/games/games/shared.jsx +118 -118
- package/features/games/games/snake.jsx +144 -144
- package/features/games/games/sokoban.jsx +180 -180
- package/features/games/games/tank.jsx +128 -128
- package/features/games/games/tetris.jsx +308 -308
- package/features/games/games/xiangqi.jsx +277 -277
- package/features/games/tank-core.js +152 -152
- package/features/games/view.jsx +639 -639
- package/features/games/xiangqi-core.js +299 -299
- package/features/heartbeat/view.js +35 -35
- package/features/mobile-relay/gateway.js +472 -472
- package/features/mobile-relay/host.js +570 -569
- package/features/mobile-relay/view.jsx +300 -300
- package/features/modelconfig/host.js +403 -403
- package/features/modelconfig/view.js +490 -490
- package/features/theme/view.js +61 -61
- package/features/tokenlog/host.js +678 -678
- package/features/tokenlog/view.jsx +561 -561
- package/features/visionproxy/host.js +315 -315
- package/index.js +175 -118
- package/package.json +59 -59
- package/src/client.jsx +668 -668
- package/src/host-core.js +98 -95
- package/src/shared.js +167 -130
|
@@ -1,660 +1,660 @@
|
|
|
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 / notifyOnConfirm / 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
|
-
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
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// 读 settings 里的 animation 配置(resolved 值已含 schema 默认),异常时回退默认
|
|
38
|
-
function readConfig(ctx) {
|
|
39
|
-
const cfg = defaultConfig()
|
|
40
|
-
try {
|
|
41
|
-
const settings = ctx.get('settings')
|
|
42
|
-
const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : null
|
|
43
|
-
const a = v && typeof v === 'object' && v.animation && typeof v.animation === 'object' ? v.animation : null
|
|
44
|
-
if (a) {
|
|
45
|
-
for (const key of Object.keys(cfg)) {
|
|
46
|
-
if (a[key] !== undefined) cfg[key] = a[key]
|
|
47
|
-
}
|
|
48
|
-
if (!ANIMATION_MODES.includes(cfg.effectMode)) cfg.effectMode = 'flow'
|
|
49
|
-
if (!SOUND_EFFECTS.includes(cfg.soundEffect)) cfg.soundEffect = 'chime'
|
|
50
|
-
}
|
|
51
|
-
} catch { /* settings 未挂载,用默认 */ }
|
|
52
|
-
return cfg
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export const feature = {
|
|
56
|
-
id: 'animation',
|
|
57
|
-
name: '任务动画',
|
|
58
|
-
description: '任务运行动画与完成通知(动画/通知独立开关,配置持久化)',
|
|
59
|
-
defaultEnabled: false,
|
|
60
|
-
setup(ctx) {
|
|
61
|
-
const disposers = []
|
|
62
|
-
const dispose = () => {
|
|
63
|
-
while (disposers.length > 0) {
|
|
64
|
-
const fn = disposers.pop()
|
|
65
|
-
try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
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
|
-
}))
|
|
491
|
-
|
|
492
|
-
// ===== RPC 路由 =====
|
|
493
|
-
disposers.push(ctx.inject(['webServer'], (wsCtx) => {
|
|
494
|
-
wsCtx.effect(() => wsCtx.webServer.register({
|
|
495
|
-
kind: 'prefix',
|
|
496
|
-
path: '/dsh-dock/animation',
|
|
497
|
-
async handler(req, res) {
|
|
498
|
-
try {
|
|
499
|
-
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
500
|
-
const method = url.pathname.replace(/^\/dsh-dock\/animation\/?/, '').split('/')[0] || ''
|
|
501
|
-
const payload = await readBody(req)
|
|
502
|
-
|
|
503
|
-
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
|
-
return sendJson(res, 200, {
|
|
529
|
-
ok: true,
|
|
530
|
-
data: {
|
|
531
|
-
now,
|
|
532
|
-
active,
|
|
533
|
-
recent: completedSessions.slice(0, 20),
|
|
534
|
-
config: readConfig(ctx),
|
|
535
|
-
},
|
|
536
|
-
})
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
if (method === 'config') {
|
|
540
|
-
// 增量合并:只接受已知字段,类型不符忽略;整体写回 animation 段
|
|
541
|
-
const cfg = readConfig(ctx)
|
|
542
|
-
const p = payload || {}
|
|
543
|
-
if (typeof p.animationEnabled === 'boolean') cfg.animationEnabled = p.animationEnabled
|
|
544
|
-
if (typeof p.effectMode === 'string' && ANIMATION_MODES.includes(p.effectMode)) cfg.effectMode = p.effectMode
|
|
545
|
-
if (typeof p.robotScale === 'number' && Number.isFinite(p.robotScale)) {
|
|
546
|
-
cfg.robotScale = Math.max(0.85, Math.min(2.2, Math.round(p.robotScale * 100) / 100))
|
|
547
|
-
}
|
|
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
|
-
|
|
579
|
-
const settings = ctx.get('settings')
|
|
580
|
-
if (!settings || typeof settings.mutate !== 'function') {
|
|
581
|
-
throw new Error('settings 服务不可用,配置无法持久化')
|
|
582
|
-
}
|
|
583
|
-
try {
|
|
584
|
-
await settings.mutate(DOCK_NS, [{ op: 'set', path: ['animation'], value: cfg }])
|
|
585
|
-
} catch (e) {
|
|
586
|
-
const err = new Error('保存配置被拒绝:' + ((e && e.message) || String(e)))
|
|
587
|
-
err.statusCode = 400
|
|
588
|
-
throw err
|
|
589
|
-
}
|
|
590
|
-
console.log('[dsh-dock] animation config saved:', cfg.effectMode, 'anim=' + cfg.animationEnabled, 'notify=' + cfg.notifyEnabled)
|
|
591
|
-
return sendJson(res, 200, { ok: true, data: { config: cfg, savedAt: Date.now() } })
|
|
592
|
-
}
|
|
593
|
-
|
|
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
|
-
return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
|
|
646
|
-
} catch (e) {
|
|
647
|
-
const status = e && e.statusCode ? e.statusCode : 500
|
|
648
|
-
console.error('[dsh-dock] animation HTTP error:', status, e && e.message)
|
|
649
|
-
return sendJson(res, status, {
|
|
650
|
-
ok: false,
|
|
651
|
-
error: { code: status >= 500 ? 'internal' : 'bad-request', message: (e && e.message) || String(e) },
|
|
652
|
-
})
|
|
653
|
-
}
|
|
654
|
-
},
|
|
655
|
-
}), 'dsh-dock animation: /dsh-dock/animation HTTP route')
|
|
656
|
-
}))
|
|
657
|
-
|
|
658
|
-
return dispose
|
|
659
|
-
},
|
|
660
|
-
}
|
|
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 / notifyOnConfirm / 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
|
+
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
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 读 settings 里的 animation 配置(resolved 值已含 schema 默认),异常时回退默认
|
|
38
|
+
function readConfig(ctx) {
|
|
39
|
+
const cfg = defaultConfig()
|
|
40
|
+
try {
|
|
41
|
+
const settings = ctx.get('settings')
|
|
42
|
+
const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : null
|
|
43
|
+
const a = v && typeof v === 'object' && v.animation && typeof v.animation === 'object' ? v.animation : null
|
|
44
|
+
if (a) {
|
|
45
|
+
for (const key of Object.keys(cfg)) {
|
|
46
|
+
if (a[key] !== undefined) cfg[key] = a[key]
|
|
47
|
+
}
|
|
48
|
+
if (!ANIMATION_MODES.includes(cfg.effectMode)) cfg.effectMode = 'flow'
|
|
49
|
+
if (!SOUND_EFFECTS.includes(cfg.soundEffect)) cfg.soundEffect = 'chime'
|
|
50
|
+
}
|
|
51
|
+
} catch { /* settings 未挂载,用默认 */ }
|
|
52
|
+
return cfg
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const feature = {
|
|
56
|
+
id: 'animation',
|
|
57
|
+
name: '任务动画',
|
|
58
|
+
description: '任务运行动画与完成通知(动画/通知独立开关,配置持久化)',
|
|
59
|
+
defaultEnabled: false,
|
|
60
|
+
setup(ctx) {
|
|
61
|
+
const disposers = []
|
|
62
|
+
const dispose = () => {
|
|
63
|
+
while (disposers.length > 0) {
|
|
64
|
+
const fn = disposers.pop()
|
|
65
|
+
try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
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
|
+
}))
|
|
491
|
+
|
|
492
|
+
// ===== RPC 路由 =====
|
|
493
|
+
disposers.push(ctx.inject(['webServer'], (wsCtx) => {
|
|
494
|
+
wsCtx.effect(() => wsCtx.webServer.register({
|
|
495
|
+
kind: 'prefix',
|
|
496
|
+
path: '/dsh-dock/animation',
|
|
497
|
+
async handler(req, res) {
|
|
498
|
+
try {
|
|
499
|
+
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
500
|
+
const method = url.pathname.replace(/^\/dsh-dock\/animation\/?/, '').split('/')[0] || ''
|
|
501
|
+
const payload = await readBody(req)
|
|
502
|
+
|
|
503
|
+
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
|
+
return sendJson(res, 200, {
|
|
529
|
+
ok: true,
|
|
530
|
+
data: {
|
|
531
|
+
now,
|
|
532
|
+
active,
|
|
533
|
+
recent: completedSessions.slice(0, 20),
|
|
534
|
+
config: readConfig(ctx),
|
|
535
|
+
},
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (method === 'config') {
|
|
540
|
+
// 增量合并:只接受已知字段,类型不符忽略;整体写回 animation 段
|
|
541
|
+
const cfg = readConfig(ctx)
|
|
542
|
+
const p = payload || {}
|
|
543
|
+
if (typeof p.animationEnabled === 'boolean') cfg.animationEnabled = p.animationEnabled
|
|
544
|
+
if (typeof p.effectMode === 'string' && ANIMATION_MODES.includes(p.effectMode)) cfg.effectMode = p.effectMode
|
|
545
|
+
if (typeof p.robotScale === 'number' && Number.isFinite(p.robotScale)) {
|
|
546
|
+
cfg.robotScale = Math.max(0.85, Math.min(2.2, Math.round(p.robotScale * 100) / 100))
|
|
547
|
+
}
|
|
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
|
+
|
|
579
|
+
const settings = ctx.get('settings')
|
|
580
|
+
if (!settings || typeof settings.mutate !== 'function') {
|
|
581
|
+
throw new Error('settings 服务不可用,配置无法持久化')
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
await settings.mutate(DOCK_NS, [{ op: 'set', path: ['animation'], value: cfg }])
|
|
585
|
+
} catch (e) {
|
|
586
|
+
const err = new Error('保存配置被拒绝:' + ((e && e.message) || String(e)))
|
|
587
|
+
err.statusCode = 400
|
|
588
|
+
throw err
|
|
589
|
+
}
|
|
590
|
+
console.log('[dsh-dock] animation config saved:', cfg.effectMode, 'anim=' + cfg.animationEnabled, 'notify=' + cfg.notifyEnabled)
|
|
591
|
+
return sendJson(res, 200, { ok: true, data: { config: cfg, savedAt: Date.now() } })
|
|
592
|
+
}
|
|
593
|
+
|
|
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
|
+
return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
|
|
646
|
+
} catch (e) {
|
|
647
|
+
const status = e && e.statusCode ? e.statusCode : 500
|
|
648
|
+
console.error('[dsh-dock] animation HTTP error:', status, e && e.message)
|
|
649
|
+
return sendJson(res, status, {
|
|
650
|
+
ok: false,
|
|
651
|
+
error: { code: status >= 500 ? 'internal' : 'bad-request', message: (e && e.message) || String(e) },
|
|
652
|
+
})
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
}), 'dsh-dock animation: /dsh-dock/animation HTTP route')
|
|
656
|
+
}))
|
|
657
|
+
|
|
658
|
+
return dispose
|
|
659
|
+
},
|
|
660
|
+
}
|