dsh-dock 0.9.9 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -356,7 +356,10 @@ async function writeModelConfig(ctx, body) {
356
356
 
357
357
  /** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
358
358
  export const feature = {
359
- id: 'models',
359
+ // id 必须与客户端视图描述符(view.js 的 feature.id)一致:面板开关 / 补推同步都按它
360
+ // POST /dsh-dock/features,历史上这里叫 'models' 而客户端叫 'modelconfig',
361
+ // 开关永远被宿主当「未知功能」拒掉,目录接口随之 404(迁移见 host-core 的 migrateModelsFeatureId)。
362
+ id: 'modelconfig',
360
363
  name: '模型设置',
361
364
  description: '模型目录读写:编辑输入类型与思考强度,写回官方配置热生效',
362
365
  defaultEnabled: false,
@@ -0,0 +1,374 @@
1
+ // dsh-dock · 功能模块【任务通知】· 宿主半部
2
+ //
3
+ // 职责:通知配置持久化 + 群机器人推送(钉钉/飞书,宿主侧直发——浏览器关着也能推)。
4
+ // 会话级任务追踪在 src/task-track.js(与【任务动画】共享一份数据);
5
+ // 页内卡片 / 提示音 / 浏览器系统通知在浏览器半部(features/notify/view.jsx)。
6
+ //
7
+ // RPC(webServer HTTP 路由,前缀 /dsh-dock/notify/):
8
+ // POST /status —— 活跃任务 + 最近完成 + 通知配置(客户端据此弹卡片、响提示音)
9
+ // POST /config —— 增量更新通知配置字段并持久化到 settings(dsh-dock 命名空间 notify 段)
10
+ // POST /test —— 群机器人连通性测试(payload.target: 'dingtalk'(默认)| 'feishu')
11
+ //
12
+ // 配置模型(schemastery schema 见 src/host-core.js DockConfig.notify):
13
+ // notifyOnComplete / notifyOnError / notifyOnConfirm / notifyStayMs /
14
+ // systemNotify / soundNotify / soundEffect / dingtalkEnabled / dingtalkWebhook /
15
+ // feishuEnabled / feishuWebhook
16
+ // 模块启停由功能坞的 features.notify 开关负责(独立菜单项),本模块不再有第二个总开关。
17
+ import { DOCK_NS, SOUND_EFFECTS, sendJson, readBody } from '../../src/host-core.js'
18
+ import { acquireTaskTracker } from '../../src/task-track.js'
19
+
20
+ // 默认配置(schema 默认值一致;settings.get 未挂载时的兜底)
21
+ function defaultConfig() {
22
+ return {
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
+ // 迁移标记(schema 默认 false):settings 不可用时的兜底取 true——迁移由
35
+ // src/host-core.js migrateNotifyConfig 统一负责,这里只是原样带回,不影响通知行为。
36
+ migratedFromAnimation: true,
37
+ }
38
+ }
39
+
40
+ // 读 settings 里的 notify 配置(resolved 值已含 schema 默认),异常时回退默认
41
+ function readConfig(ctx) {
42
+ const cfg = defaultConfig()
43
+ try {
44
+ const settings = ctx.get('settings')
45
+ const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : null
46
+ const n = v && typeof v === 'object' && v.notify && typeof v.notify === 'object' ? v.notify : null
47
+ if (n) {
48
+ for (const key of Object.keys(cfg)) {
49
+ if (n[key] !== undefined) cfg[key] = n[key]
50
+ }
51
+ if (!SOUND_EFFECTS.includes(cfg.soundEffect)) cfg.soundEffect = 'chime'
52
+ }
53
+ } catch { /* settings 未挂载,用默认 */ }
54
+ return cfg
55
+ }
56
+
57
+ function truncate(str, max = 160) {
58
+ if (!str) return ''
59
+ return String(str).length > max ? String(str).slice(0, max) + '…' : str
60
+ }
61
+
62
+ function fmtDurCn(ms) {
63
+ const s = Math.max(0, Math.round((ms || 0) / 1000))
64
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60
65
+ if (h > 0) return `${h}小时${m}分${sec}秒`
66
+ if (m > 0) return `${m}分${sec}秒`
67
+ return `${sec}秒`
68
+ }
69
+ function pad2(n) { return String(n).padStart(2, '0') }
70
+ function fmtClockOf(ts) {
71
+ const d = new Date(ts)
72
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
73
+ }
74
+ function fmtDateClockOf(ts) {
75
+ const d = new Date(ts)
76
+ return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${fmtClockOf(ts)}`
77
+ }
78
+
79
+ const END_REASON_LABELS = {
80
+ completed: '✅ 完成',
81
+ aborted: '⛔ 已中止',
82
+ blocked: '🚧 受阻',
83
+ error: '❌ 出错',
84
+ 'max-tokens': '⏹ 达到输出上限',
85
+ interrupted: '⚡ 中断',
86
+ }
87
+
88
+ // 钉钉与飞书共用的消息正文(各端再按各自 markdown 方言拼接)
89
+ function buildMessageLines(t) {
90
+ const success = !t.endReason || t.endReason === 'completed'
91
+ const reasonLabel = END_REASON_LABELS[t.endReason] || '✅ 完成'
92
+ const models = t.models.length > 0 ? t.models.join(', ') : '未知'
93
+ const lines = []
94
+ lines.push(`### ${success ? '✅' : '📢'} dsh 任务${success ? '完成' : '结束'}`)
95
+ lines.push(`**任务**: ${t.title}`)
96
+ lines.push(`**模型**: ${models}${t.provider ? `(${t.provider})` : ''}`)
97
+ lines.push(`**耗时**: ${fmtDurCn(t.duration)}(${fmtClockOf(t.startTime)} → ${fmtClockOf(t.endTime)})`)
98
+ lines.push(`**回合**: ${t.turns} · 步骤 ${t.steps}${t.toolCalls ? ` · 工具 ${t.toolCalls} 次` : ''}`)
99
+ lines.push(`**Token**: 输入 ${t.inputTokens.toLocaleString()} / 输出 ${t.outputTokens.toLocaleString()} / 总计 ${t.totalTokens.toLocaleString()}`)
100
+ lines.push(`**结果**: ${reasonLabel}${t.errorMessage ? `:${truncate(t.errorMessage, 120)}` : ''}`)
101
+ if (t.lastText) {
102
+ lines.push(`> ${truncate(t.lastText, 200)}`)
103
+ }
104
+ lines.push('---')
105
+ lines.push(`*由 dsh-dock 任务通知发送 · ${fmtDateClockOf(t.endTime)}*`)
106
+ return {
107
+ title: `dsh 任务${success ? '完成' : '结束'}:${truncate(t.title, 40)}`,
108
+ success,
109
+ lines,
110
+ }
111
+ }
112
+
113
+ function buildDingtalkMessage(t) {
114
+ const { title, lines } = buildMessageLines(t)
115
+ return {
116
+ title,
117
+ // 钉钉 markdown 不渲染单个 \n,行之间必须用空行分隔才能换行
118
+ text: lines.join('\n\n'),
119
+ }
120
+ }
121
+
122
+ // 飞书 interactive 卡片:标题进 header,正文走 lark_md(支持 **加粗** 与 \n 换行,不支持 ### 标题)
123
+ function buildFeishuMessage(t) {
124
+ const { title, success, lines } = buildMessageLines(t)
125
+ const body = lines.filter((l) => !l.startsWith('### ')).join('\n')
126
+ return {
127
+ title,
128
+ card: {
129
+ config: { wide_screen_mode: true },
130
+ header: {
131
+ template: success ? 'green' : 'orange',
132
+ title: { tag: 'plain_text', content: title },
133
+ },
134
+ elements: [
135
+ { tag: 'div', text: { tag: 'lark_md', content: body } },
136
+ ],
137
+ },
138
+ }
139
+ }
140
+
141
+ // 发送飞书 interactive 卡片;成功 = HTTP 200 且业务码 code/StatusCode === 0
142
+ async function sendFeishu(webhook, title, card) {
143
+ try {
144
+ const res = await fetch(webhook, {
145
+ method: 'POST',
146
+ headers: { 'Content-Type': 'application/json' },
147
+ body: JSON.stringify({ msg_type: 'interactive', card }),
148
+ })
149
+ const raw = await res.text()
150
+ let data = null
151
+ try { data = JSON.parse(raw) } catch { /* 非 JSON 响应体 */ }
152
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status} ${truncate(raw, 120)}` }
153
+ // 新版返回 { code, msg },旧版返回 { StatusCode, StatusMessage }
154
+ const code = data && typeof data === 'object'
155
+ ? (typeof data.code === 'number' ? data.code : data.StatusCode)
156
+ : undefined
157
+ if (typeof code === 'number' && code !== 0) {
158
+ return { ok: false, error: `飞书 code ${code}:${data.msg || data.StatusMessage || ''}` }
159
+ }
160
+ return { ok: true }
161
+ } catch (e) {
162
+ return { ok: false, error: (e && e.message) || String(e) }
163
+ }
164
+ }
165
+
166
+ // 发送钉钉 markdown 消息;成功 = HTTP 200 且业务码 errcode === 0
167
+ async function sendDingtalk(webhook, title, text) {
168
+ try {
169
+ const res = await fetch(webhook, {
170
+ method: 'POST',
171
+ headers: { 'Content-Type': 'application/json' },
172
+ body: JSON.stringify({ msgtype: 'markdown', markdown: { title, text } }),
173
+ })
174
+ const raw = await res.text()
175
+ let data = null
176
+ try { data = JSON.parse(raw) } catch { /* 非 JSON 响应体 */ }
177
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status} ${truncate(raw, 120)}` }
178
+ if (data && typeof data.errcode === 'number' && data.errcode !== 0) {
179
+ return { ok: false, error: `钉钉 errcode ${data.errcode}:${data.errmsg || ''}` }
180
+ }
181
+ return { ok: true }
182
+ } catch (e) {
183
+ return { ok: false, error: (e && e.message) || String(e) }
184
+ }
185
+ }
186
+
187
+ export const feature = {
188
+ id: 'notify',
189
+ name: '任务通知',
190
+ description: '任务完成/异常/需确认通知:页内卡片、提示音、系统通知、钉钉/飞书推送(独立开关)',
191
+ defaultEnabled: false,
192
+ setup(ctx) {
193
+ const disposers = []
194
+ const dispose = () => {
195
+ while (disposers.length > 0) {
196
+ const fn = disposers.pop()
197
+ try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
198
+ }
199
+ }
200
+
201
+ // 会话级任务追踪(与【任务动画】共享一份)
202
+ const lease = acquireTaskTracker(ctx)
203
+ disposers.push(() => lease.release())
204
+ const tracker = lease.tracker
205
+
206
+ // ===== 群机器人推送(宿主侧直发,异步不阻塞;事件筛选跟随 notifyOnComplete/notifyOnError) =====
207
+ async function pushDingtalkIfNeeded(record) {
208
+ try {
209
+ const cfg = readConfig(ctx)
210
+ if (!cfg.dingtalkEnabled || !cfg.dingtalkWebhook) return
211
+ const success = !record.endReason || record.endReason === 'completed'
212
+ if (success ? !cfg.notifyOnComplete : !cfg.notifyOnError) return
213
+ const { title, text } = buildDingtalkMessage(record)
214
+ const r = await sendDingtalk(cfg.dingtalkWebhook, title, text)
215
+ if (r.ok) console.log('[dsh-dock] notify dingtalk pushed:', title)
216
+ else console.error('[dsh-dock] notify dingtalk push failed:', r.error)
217
+ } catch (e) {
218
+ console.error('[dsh-dock] notify dingtalk push error:', e && e.message)
219
+ }
220
+ }
221
+
222
+ async function pushFeishuIfNeeded(record) {
223
+ try {
224
+ const cfg = readConfig(ctx)
225
+ if (!cfg.feishuEnabled || !cfg.feishuWebhook) return
226
+ const success = !record.endReason || record.endReason === 'completed'
227
+ if (success ? !cfg.notifyOnComplete : !cfg.notifyOnError) return
228
+ const { title, card } = buildFeishuMessage(record)
229
+ const r = await sendFeishu(cfg.feishuWebhook, title, card)
230
+ if (r.ok) console.log('[dsh-dock] notify feishu pushed:', title)
231
+ else console.error('[dsh-dock] notify feishu push failed:', r.error)
232
+ } catch (e) {
233
+ console.error('[dsh-dock] notify feishu push error:', e && e.message)
234
+ }
235
+ }
236
+
237
+ disposers.push(tracker.onFinish((record) => {
238
+ pushDingtalkIfNeeded(record)
239
+ pushFeishuIfNeeded(record)
240
+ }))
241
+
242
+ // ===== RPC 路由 =====
243
+ disposers.push(ctx.inject(['webServer'], (wsCtx) => {
244
+ wsCtx.effect(() => wsCtx.webServer.register({
245
+ kind: 'prefix',
246
+ path: '/dsh-dock/notify',
247
+ async handler(req, res) {
248
+ try {
249
+ const url = new URL(req.url || '/', 'http://dsh.internal')
250
+ const method = url.pathname.replace(/^\/dsh-dock\/notify\/?/, '').split('/')[0] || ''
251
+ const payload = await readBody(req)
252
+
253
+ if (method === 'status') {
254
+ return sendJson(res, 200, {
255
+ ok: true,
256
+ data: Object.assign({}, tracker.snapshot(Date.now()), { config: readConfig(ctx) }),
257
+ })
258
+ }
259
+
260
+ if (method === 'config') {
261
+ // 增量合并:只接受已知字段,类型不符忽略;整体写回 notify 段
262
+ const cfg = readConfig(ctx)
263
+ const p = payload || {}
264
+ if (typeof p.notifyOnComplete === 'boolean') cfg.notifyOnComplete = p.notifyOnComplete
265
+ if (typeof p.notifyOnError === 'boolean') cfg.notifyOnError = p.notifyOnError
266
+ if (typeof p.notifyOnConfirm === 'boolean') cfg.notifyOnConfirm = p.notifyOnConfirm
267
+ if (typeof p.notifyStayMs === 'number' && Number.isFinite(p.notifyStayMs)) {
268
+ cfg.notifyStayMs = Math.max(0, Math.min(600000, Math.round(p.notifyStayMs)))
269
+ }
270
+ if (typeof p.systemNotify === 'boolean') cfg.systemNotify = p.systemNotify
271
+ if (typeof p.soundNotify === 'boolean') cfg.soundNotify = p.soundNotify
272
+ if (typeof p.soundEffect === 'string' && SOUND_EFFECTS.includes(p.soundEffect)) cfg.soundEffect = p.soundEffect
273
+ if (typeof p.dingtalkEnabled === 'boolean') cfg.dingtalkEnabled = p.dingtalkEnabled
274
+ if (typeof p.dingtalkWebhook === 'string') {
275
+ const hook = p.dingtalkWebhook.trim()
276
+ if (hook && !/^https?:\/\//i.test(hook)) {
277
+ const err = new Error('钉钉 Webhook 地址需以 http(s):// 开头')
278
+ err.statusCode = 400
279
+ throw err
280
+ }
281
+ cfg.dingtalkWebhook = hook
282
+ }
283
+ if (typeof p.feishuEnabled === 'boolean') cfg.feishuEnabled = p.feishuEnabled
284
+ if (typeof p.feishuWebhook === 'string') {
285
+ const hook = p.feishuWebhook.trim()
286
+ if (hook && !/^https?:\/\//i.test(hook)) {
287
+ const err = new Error('飞书 Webhook 地址需以 http(s):// 开头')
288
+ err.statusCode = 400
289
+ throw err
290
+ }
291
+ cfg.feishuWebhook = hook
292
+ }
293
+
294
+ const settings = ctx.get('settings')
295
+ if (!settings || typeof settings.mutate !== 'function') {
296
+ throw new Error('settings 服务不可用,配置无法持久化')
297
+ }
298
+ try {
299
+ await settings.mutate(DOCK_NS, [{ op: 'set', path: ['notify'], value: cfg }])
300
+ } catch (e) {
301
+ const err = new Error('保存配置被拒绝:' + ((e && e.message) || String(e)))
302
+ err.statusCode = 400
303
+ throw err
304
+ }
305
+ console.log('[dsh-dock] notify config saved:', 'dingtalk=' + cfg.dingtalkEnabled, 'feishu=' + cfg.feishuEnabled)
306
+ return sendJson(res, 200, { ok: true, data: { config: cfg, savedAt: Date.now() } })
307
+ }
308
+
309
+ // 机器人连通性测试:payload.target 指定 'dingtalk'(默认)或 'feishu',用当前已保存的 Webhook 发一条测试消息
310
+ if (method === 'test') {
311
+ const target = (payload && payload.target) === 'feishu' ? 'feishu' : 'dingtalk'
312
+ const cfgNow = readConfig(ctx)
313
+ const webhook = target === 'feishu' ? cfgNow.feishuWebhook : cfgNow.dingtalkWebhook
314
+ if (!webhook) {
315
+ const err = new Error(`请先填写并保存${target === 'feishu' ? '飞书' : '钉钉'} Webhook 地址`)
316
+ err.statusCode = 400
317
+ throw err
318
+ }
319
+ let r
320
+ if (target === 'feishu') {
321
+ const text = [
322
+ '**这是一条来自 dsh-dock 任务通知的测试消息。**',
323
+ '',
324
+ `**时间**: ${fmtDateClockOf(Date.now())}`,
325
+ '',
326
+ '看到这条消息说明飞书 Webhook 配置正确。',
327
+ '',
328
+ '---',
329
+ '*由 dsh-dock 任务通知发送*',
330
+ ].join('\n')
331
+ const card = {
332
+ config: { wide_screen_mode: true },
333
+ header: {
334
+ template: 'blue',
335
+ title: { tag: 'plain_text', content: '🧪 dsh 任务通知测试' },
336
+ },
337
+ elements: [{ tag: 'div', text: { tag: 'lark_md', content: text } }],
338
+ }
339
+ r = await sendFeishu(webhook, 'dsh 任务通知测试', card)
340
+ } else {
341
+ const text = [
342
+ '### 🧪 dsh 任务通知测试',
343
+ '',
344
+ '这是一条来自 dsh-dock 任务通知的测试消息。',
345
+ '',
346
+ `**时间**: ${fmtDateClockOf(Date.now())}`,
347
+ '',
348
+ '> 看到这条消息说明钉钉 Webhook 配置正确。',
349
+ '',
350
+ '---',
351
+ '*由 dsh-dock 任务通知发送*',
352
+ ].join('\n')
353
+ r = await sendDingtalk(webhook, 'dsh 任务通知测试', text)
354
+ }
355
+ if (!r.ok) console.error(`[dsh-dock] notify ${target} test failed:`, r.error)
356
+ return sendJson(res, 200, { ok: true, data: { sent: r.ok, error: r.ok ? '' : r.error } })
357
+ }
358
+
359
+ return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
360
+ } catch (e) {
361
+ const status = e && e.statusCode ? e.statusCode : 500
362
+ console.error('[dsh-dock] notify HTTP error:', status, e && e.message)
363
+ return sendJson(res, status, {
364
+ ok: false,
365
+ error: { code: status >= 500 ? 'internal' : 'bad-request', message: (e && e.message) || String(e) },
366
+ })
367
+ }
368
+ },
369
+ }), 'dsh-dock notify: /dsh-dock/notify HTTP route')
370
+ }))
371
+
372
+ return dispose
373
+ },
374
+ }