dsh-dock 0.5.0 → 0.7.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.
@@ -28,6 +28,8 @@ function defaultConfig() {
28
28
  soundEffect: 'chime',
29
29
  dingtalkEnabled: false,
30
30
  dingtalkWebhook: '',
31
+ feishuEnabled: false,
32
+ feishuWebhook: '',
31
33
  }
32
34
  }
33
35
 
@@ -133,6 +135,10 @@ export const feature = {
133
135
  phase: 'think',
134
136
  phaseAt: Date.now(),
135
137
  lastActivityAt: Date.now(),
138
+ // assistant/chunk 是实时流;用轻量活动计数给浏览器侧映射动作速度,
139
+ // 不等最终 usage 落盘,避免任务正在输出时机器人仍慢速。
140
+ motionTicks: 0,
141
+ lastMotionAt: Date.now(),
136
142
  ...extra,
137
143
  }
138
144
  activeSessions.set(sid, record)
@@ -177,8 +183,9 @@ export const feature = {
177
183
  }
178
184
  completedSessions.unshift(record)
179
185
  if (completedSessions.length > MAX_COMPLETED) completedSessions.pop()
180
- // 钉钉群机器人推送(宿主侧直发,异步不阻塞;事件筛选跟随 notifyOnComplete/notifyOnError)
186
+ // 钉钉/飞书群机器人推送(宿主侧直发,异步不阻塞;事件筛选跟随 notifyOnComplete/notifyOnError)
181
187
  pushDingtalkIfNeeded(record)
188
+ pushFeishuIfNeeded(record)
182
189
  }
183
190
 
184
191
  // ===== 钉钉群机器人推送(参照 @wycto/dsh-task-pulse 同款 markdown 消息) =====
@@ -208,13 +215,13 @@ export const feature = {
208
215
  return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${fmtClockOf(ts)}`
209
216
  }
210
217
 
211
- function buildDingtalkMessage(t) {
218
+ // 钉钉与飞书共用的消息正文(各端再按各自 markdown 方言拼接)
219
+ function buildMessageLines(t) {
212
220
  const success = !t.endReason || t.endReason === 'completed'
213
221
  const reasonLabel = END_REASON_LABELS[t.endReason] || '✅ 完成'
214
222
  const models = t.models.length > 0 ? t.models.join(', ') : '未知'
215
223
  const lines = []
216
224
  lines.push(`### ${success ? '✅' : '📢'} dsh 任务${success ? '完成' : '结束'}`)
217
- lines.push('')
218
225
  lines.push(`**任务**: ${t.title}`)
219
226
  lines.push(`**模型**: ${models}${t.provider ? `(${t.provider})` : ''}`)
220
227
  lines.push(`**耗时**: ${fmtDurCn(t.duration)}(${fmtClockOf(t.startTime)} → ${fmtClockOf(t.endTime)})`)
@@ -222,15 +229,67 @@ export const feature = {
222
229
  lines.push(`**Token**: 输入 ${t.inputTokens.toLocaleString()} / 输出 ${t.outputTokens.toLocaleString()} / 总计 ${t.totalTokens.toLocaleString()}`)
223
230
  lines.push(`**结果**: ${reasonLabel}${t.errorMessage ? `:${truncate(t.errorMessage, 120)}` : ''}`)
224
231
  if (t.lastText) {
225
- lines.push('')
226
232
  lines.push(`> ${truncate(t.lastText, 200)}`)
227
233
  }
228
- lines.push('')
229
234
  lines.push('---')
230
235
  lines.push(`*由 dsh-dock 任务动画发送 · ${fmtDateClockOf(t.endTime)}*`)
231
236
  return {
232
237
  title: `dsh 任务${success ? '完成' : '结束'}:${truncate(t.title, 40)}`,
233
- text: lines.join('\n'),
238
+ success,
239
+ lines,
240
+ }
241
+ }
242
+
243
+ function buildDingtalkMessage(t) {
244
+ const { title, lines } = buildMessageLines(t)
245
+ return {
246
+ title,
247
+ // 钉钉 markdown 不渲染单个 \n,行之间必须用空行分隔才能换行
248
+ text: lines.join('\n\n'),
249
+ }
250
+ }
251
+
252
+ // 飞书 interactive 卡片:标题进 header,正文走 lark_md(支持 **加粗** 与 \n 换行,不支持 ### 标题)
253
+ function buildFeishuMessage(t) {
254
+ const { title, success, lines } = buildMessageLines(t)
255
+ const body = lines.filter((l) => !l.startsWith('### ')).join('\n')
256
+ return {
257
+ title,
258
+ card: {
259
+ config: { wide_screen_mode: true },
260
+ header: {
261
+ template: success ? 'green' : 'orange',
262
+ title: { tag: 'plain_text', content: title },
263
+ },
264
+ elements: [
265
+ { tag: 'div', text: { tag: 'lark_md', content: body } },
266
+ ],
267
+ },
268
+ }
269
+ }
270
+
271
+ // 发送飞书 interactive 卡片;成功 = HTTP 200 且业务码 code/StatusCode === 0
272
+ async function sendFeishu(webhook, title, card) {
273
+ try {
274
+ const res = await fetch(webhook, {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({ msg_type: 'interactive', card }),
278
+ })
279
+ const raw = await res.text()
280
+ let data = null
281
+ try { data = JSON.parse(raw) } catch { /* 非 JSON 响应体 */ }
282
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status} ${truncate(raw, 120)}` }
283
+ // 新版返回 { code, msg },旧版返回 { StatusCode, StatusMessage }
284
+ const code = data && typeof data === 'object'
285
+ ? (typeof data.code === 'number' ? data.code : data.StatusCode)
286
+ : undefined
287
+ if (typeof code === 'number' && code !== 0) {
288
+ return { ok: false, error: `飞书 code ${code}:${data.msg || data.StatusMessage || ''}` }
289
+ }
290
+ return { ok: true }
291
+ } catch (e) {
292
+ return { ok: false, error: (e && e.message) || String(e) }
234
293
  }
235
294
  }
236
295
 
@@ -270,6 +329,21 @@ export const feature = {
270
329
  }
271
330
  }
272
331
 
332
+ async function pushFeishuIfNeeded(record) {
333
+ try {
334
+ const cfg = readConfig(ctx)
335
+ if (!cfg.feishuEnabled || !cfg.feishuWebhook) return
336
+ const success = !record.endReason || record.endReason === 'completed'
337
+ if (success ? !cfg.notifyOnComplete : !cfg.notifyOnError) return
338
+ const { title, card } = buildFeishuMessage(record)
339
+ const r = await sendFeishu(cfg.feishuWebhook, title, card)
340
+ if (r.ok) console.log('[dsh-dock] animation feishu pushed:', title)
341
+ else console.error('[dsh-dock] animation feishu push failed:', r.error)
342
+ } catch (e) {
343
+ console.error('[dsh-dock] animation feishu push error:', e && e.message)
344
+ }
345
+ }
346
+
273
347
  function handleSessionEvent(sessionQuery, session, event) {
274
348
  const sid = typeof session === 'object' && session ? (session.id || '') : String(session || '')
275
349
  if (!sid || !event || !event.type) return
@@ -280,6 +354,8 @@ export const feature = {
280
354
  s.phase = 'think' // 新步骤:模型正在推理下一步
281
355
  s.phaseAt = event.time || Date.now()
282
356
  s.lastActivityAt = s.phaseAt
357
+ s.motionTicks += 2
358
+ s.lastMotionAt = s.phaseAt
283
359
  if (event.data && event.data.turn !== undefined) {
284
360
  s.turns = Math.max(s.turns, num(event.data.turn) + 1)
285
361
  }
@@ -290,7 +366,8 @@ export const feature = {
290
366
  const now = event.time || Date.now()
291
367
  // 流级阶段:chunk 高频到达(每 token 一条),只做最廉价的阶段切换
292
368
  if (event.type === 'assistant/chunk') {
293
- const ctype = event.data && event.data.chunk && event.data.chunk.type
369
+ const chunk = event.data && event.data.chunk
370
+ const ctype = chunk && chunk.type
294
371
  if (ctype === 'reasoning-delta') {
295
372
  s.phase = 'think'
296
373
  s.phaseAt = now
@@ -298,6 +375,14 @@ export const feature = {
298
375
  s.phase = 'write'
299
376
  s.phaseAt = now
300
377
  }
378
+ // 不同适配器的 delta 字段名并不完全一致。按可见文本量估算,
379
+ // 无文本时每个 chunk 也记一个脉冲,保证推理流同样有动作反馈。
380
+ if (ctype === 'reasoning-delta' || ctype === 'text-delta') {
381
+ const delta = chunk && (chunk.text || chunk.delta || chunk.content)
382
+ const units = typeof delta === 'string' ? Math.max(1, Math.ceil(delta.length / 24)) : 1
383
+ s.motionTicks += units
384
+ s.lastMotionAt = now
385
+ }
301
386
  s.lastActivityAt = now
302
387
  return
303
388
  }
@@ -312,6 +397,9 @@ export const feature = {
312
397
  s.toolCalls++
313
398
  s.phase = phaseOfToolName(event.data && event.data.name)
314
399
  s.phaseAt = now
400
+ // 工具启动是稀疏事件,给一个更明显的短促动作,检索/写文件也不会显得停住。
401
+ s.motionTicks += s.phase === 'search' ? 7 : 9
402
+ s.lastMotionAt = now
315
403
  } else if (event.type === 'user/message') {
316
404
  if (!s.firstPrompt && event.data && event.data.content) {
317
405
  const text = extractText(event.data.content)
@@ -416,6 +504,8 @@ export const feature = {
416
504
  phase: s.phase,
417
505
  phaseAt: s.phaseAt,
418
506
  lastActivityAt: s.lastActivityAt,
507
+ motionTicks: s.motionTicks,
508
+ lastMotionAt: s.lastMotionAt,
419
509
  })
420
510
  }
421
511
  return sendJson(res, 200, {
@@ -457,6 +547,16 @@ export const feature = {
457
547
  }
458
548
  cfg.dingtalkWebhook = hook
459
549
  }
550
+ if (typeof p.feishuEnabled === 'boolean') cfg.feishuEnabled = p.feishuEnabled
551
+ if (typeof p.feishuWebhook === 'string') {
552
+ const hook = p.feishuWebhook.trim()
553
+ if (hook && !/^https?:\/\//i.test(hook)) {
554
+ const err = new Error('飞书 Webhook 地址需以 http(s):// 开头')
555
+ err.statusCode = 400
556
+ throw err
557
+ }
558
+ cfg.feishuWebhook = hook
559
+ }
460
560
 
461
561
  const settings = ctx.get('settings')
462
562
  if (!settings || typeof settings.mutate !== 'function') {
@@ -473,28 +573,54 @@ export const feature = {
473
573
  return sendJson(res, 200, { ok: true, data: { config: cfg, savedAt: Date.now() } })
474
574
  }
475
575
 
476
- // 钉钉连通性测试:用当前已保存的 Webhook 发一条测试消息
576
+ // 机器人连通性测试:payload.target 指定 'dingtalk'(默认)或 'feishu',用当前已保存的 Webhook 发一条测试消息
477
577
  if (method === 'test') {
578
+ const target = (payload && payload.target) === 'feishu' ? 'feishu' : 'dingtalk'
478
579
  const cfgNow = readConfig(ctx)
479
- if (!cfgNow.dingtalkWebhook) {
480
- const err = new Error('请先填写并保存钉钉 Webhook 地址')
580
+ const webhook = target === 'feishu' ? cfgNow.feishuWebhook : cfgNow.dingtalkWebhook
581
+ if (!webhook) {
582
+ const err = new Error(`请先填写并保存${target === 'feishu' ? '飞书' : '钉钉'} Webhook 地址`)
481
583
  err.statusCode = 400
482
584
  throw err
483
585
  }
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)
586
+ const nameCn = target === 'feishu' ? '飞书' : '钉钉'
587
+ let r
588
+ if (target === 'feishu') {
589
+ const text = [
590
+ '**这是一条来自 dsh-dock 任务动画的测试消息。**',
591
+ '',
592
+ `**时间**: ${fmtDateClockOf(Date.now())}`,
593
+ '',
594
+ '看到这条消息说明飞书 Webhook 配置正确。',
595
+ '',
596
+ '---',
597
+ '*由 dsh-dock 任务动画发送*',
598
+ ].join('\n')
599
+ const card = {
600
+ config: { wide_screen_mode: true },
601
+ header: {
602
+ template: 'blue',
603
+ title: { tag: 'plain_text', content: '🧪 dsh 任务动画测试' },
604
+ },
605
+ elements: [{ tag: 'div', text: { tag: 'lark_md', content: text } }],
606
+ }
607
+ r = await sendFeishu(webhook, 'dsh 任务动画测试', card)
608
+ } else {
609
+ const text = [
610
+ '### 🧪 dsh 任务动画测试',
611
+ '',
612
+ '这是一条来自 dsh-dock 任务动画的测试消息。',
613
+ '',
614
+ `**时间**: ${fmtDateClockOf(Date.now())}`,
615
+ '',
616
+ '> 看到这条消息说明钉钉 Webhook 配置正确。',
617
+ '',
618
+ '---',
619
+ '*由 dsh-dock 任务动画发送*',
620
+ ].join('\n')
621
+ r = await sendDingtalk(webhook, 'dsh 任务动画测试', text)
622
+ }
623
+ if (!r.ok) console.error(`[dsh-dock] animation ${target} test failed:`, r.error)
498
624
  return sendJson(res, 200, { ok: true, data: { sent: r.ok, error: r.ok ? '' : r.error } })
499
625
  }
500
626