dsh-dock 0.19.0 → 0.21.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.
- package/README.md +21 -20
- package/client.js +1426 -931
- package/features/animation/view.jsx +26 -12
- package/features/balance/view.js +8 -6
- package/features/mobile/host.js +18 -311
- package/features/mobile/view.js +295 -480
- package/features/mobile-relay/host.js +3 -4
- package/features/mobile-relay/view.jsx +1 -2
- package/features/notify/host.js +52 -16
- package/features/notify/view.jsx +884 -377
- package/features/runstate/host.js +1 -11
- package/features/runstate/view.jsx +32 -15
- package/features/tokenlog/view.jsx +8 -3
- package/index.js +1 -1
- package/package.json +2 -2
- package/screenshots/dock-notify.png +0 -0
- package/src/client.jsx +36 -6
- package/src/host-core.js +8 -1
- package/src/shared.js +86 -0
- package/src/task-track.js +87 -0
package/features/notify/view.jsx
CHANGED
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
// 1. View —— 功能坞面板页:通知事件 / 呈现方式 / 钉钉推送 / 飞书推送 四组设置;
|
|
5
5
|
// 2. HomeStat —— 首页总揽概要(当前通知事件与推送通道);
|
|
6
6
|
// 3. Overlay —— 全局浮层(shell.overlay 常驻,功能启用即挂载):轮询 Host 状态,
|
|
7
|
-
//
|
|
7
|
+
// 任务结束弹通知卡片(可选提示音与浏览器系统通知),工具等批准 / 提问等回答时弹常驻提醒卡,
|
|
8
8
|
// 编程阶段长时间无活动弹「卡住」提醒卡(写明主/子任务、停滞时长、最后动作)。
|
|
9
|
+
// 卡住判定的口径统一在 src/shared.js 的 taskStalled:等人介入、命令还在跑、
|
|
10
|
+
// 正在压缩上下文都不算卡住(那都是「在干活」),只有真的安静下来才报。
|
|
9
11
|
// 所有卡片受「页内卡片」总开关与各类型开关控制(见 View 的通知事件区)。
|
|
10
12
|
// 结束卡常驻到「看过」(点卡直达会话 / 点进会话 / 手动 ✕),≥3 张叠放收纳。
|
|
11
13
|
//
|
|
12
14
|
// 与【任务动画】【运行状态】完全独立:动效在 features/animation/、任务状态一览在
|
|
13
15
|
// features/runstate/,本模块只管通知(页内卡片 / 提示音 / 系统通知 / 钉钉飞书群机器人推送)。
|
|
14
16
|
// 模块启停 = 功能坞里的开关(独立菜单项),本页不再有第二个总开关。
|
|
17
|
+
// 语音播报是双层开关:服务端 voiceNotify(settings,跨浏览器)+ 本机开关(localStorage,
|
|
18
|
+
// 当前浏览器),两者都开才播报——团队共用一个 dsh 实例时可按浏览器各自静音。
|
|
15
19
|
//
|
|
16
20
|
// Host 通信:fetch('dsh-dock/notify/<method>')(见 features/notify/host.js)。
|
|
17
21
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
22
|
+
import { taskWaiting, taskApprovals, taskQuestions, taskStalled, taskActivityAt } from "../../src/shared.js";
|
|
18
23
|
|
|
19
24
|
// ---------- Host RPC 桥接 ----------
|
|
20
25
|
function rpcCall(method, args) {
|
|
@@ -160,6 +165,159 @@ function utterCtor() {
|
|
|
160
165
|
export function voiceSupported() {
|
|
161
166
|
return synthOf() !== null && utterCtor() !== null;
|
|
162
167
|
}
|
|
168
|
+
// ---------- 本机播报开关(每浏览器独立,存 localStorage) ----------
|
|
169
|
+
// 服务端 voiceNotify 是总开关(跨浏览器/跨设备,宿主 settings 持久化);本机开关是
|
|
170
|
+
// 「这台浏览器要不要出声」的第二层开关——团队共用一个 dsh 实例时,有人想听、有人嫌吵。
|
|
171
|
+
// 两个都开才播报;默认开(保持服务端总开关打开后的既有行为),用户可在本机关闭本机开关。
|
|
172
|
+
const VOICE_CLIENT_KEY = "dsh-dock/notify/voice-client/v1";
|
|
173
|
+
export function voiceClientEnabled() {
|
|
174
|
+
try {
|
|
175
|
+
if (typeof localStorage === "undefined") return true;
|
|
176
|
+
return localStorage.getItem(VOICE_CLIENT_KEY) !== "0";
|
|
177
|
+
} catch { return true; }
|
|
178
|
+
}
|
|
179
|
+
function setVoiceClientEnabled(on) {
|
|
180
|
+
try { if (typeof localStorage !== "undefined") localStorage.setItem(VOICE_CLIENT_KEY, on ? "1" : "0"); } catch { /* 持久化失败仅在本次会话生效 */ }
|
|
181
|
+
}
|
|
182
|
+
/** 真正允许自动播报:服务端总开关 && 本机开关。所有自动播报统一走这里判断。 */
|
|
183
|
+
export function voiceAllowed(cfg) {
|
|
184
|
+
return !!cfg && cfg.voiceNotify === true && voiceClientEnabled();
|
|
185
|
+
}
|
|
186
|
+
/** 本机开关的 React 状态:读写 localStorage,并跟随其它标签页的 storage 事件。 */
|
|
187
|
+
function useVoiceClientEnabled() {
|
|
188
|
+
const [on, setOn] = useState(() => voiceClientEnabled());
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
const sync = () => setOn(voiceClientEnabled());
|
|
191
|
+
if (typeof window === "undefined" || typeof window.addEventListener !== "function") return undefined;
|
|
192
|
+
window.addEventListener("storage", sync);
|
|
193
|
+
return () => { if (typeof window.removeEventListener === "function") window.removeEventListener("storage", sync); };
|
|
194
|
+
}, []);
|
|
195
|
+
const set = useCallback((next) => {
|
|
196
|
+
const v = next !== false;
|
|
197
|
+
setVoiceClientEnabled(v);
|
|
198
|
+
setOn(v);
|
|
199
|
+
// 关掉本机播报时立刻收声:正在念的那一句不该再继续打扰。
|
|
200
|
+
if (!v) { const s = synthOf(); if (s) { try { s.cancel(); } catch { /* ignore */ } } }
|
|
201
|
+
}, []);
|
|
202
|
+
return [on, set];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------- 通知配置来源:服务端配置 / 本机配置 ----------
|
|
206
|
+
// 服务端配置存在宿主 settings(跨浏览器共享);本机配置只存当前浏览器 localStorage,
|
|
207
|
+
// 只影响本浏览器内的页面卡片/提示音/系统通知/语音播报。群机器人推送与语音服务端总开关
|
|
208
|
+
// 由宿主执行,始终使用服务端配置。
|
|
209
|
+
const CONFIG_SOURCE_KEY = "dsh-dock/notify/config-source/v1";
|
|
210
|
+
const CLIENT_CONFIG_KEY = "dsh-dock/notify/client-config/v1";
|
|
211
|
+
const CLIENT_CONFIG_FIELDS = [
|
|
212
|
+
"notifyOnComplete", "notifyOnError", "notifyOnConfirm", "notifyOnStuck",
|
|
213
|
+
"notifyCards", "cardMode", "notifyStayMs",
|
|
214
|
+
"soundNotify", "soundEffect", "systemNotify",
|
|
215
|
+
"voiceMode", "voiceOnComplete", "voiceOnError", "voiceOnConfirm", "voiceOnStuck",
|
|
216
|
+
"voiceRate", "voiceVolume", "voiceLang", "voiceURI",
|
|
217
|
+
];
|
|
218
|
+
/** 当前浏览器选择的配置来源:'server'(默认)或 'client'。 */
|
|
219
|
+
export function notifyConfigSource() {
|
|
220
|
+
try {
|
|
221
|
+
if (typeof localStorage === "undefined") return "server";
|
|
222
|
+
return localStorage.getItem(CONFIG_SOURCE_KEY) === "client" ? "client" : "server";
|
|
223
|
+
} catch { return "server"; }
|
|
224
|
+
}
|
|
225
|
+
function setNotifyConfigSource(source) {
|
|
226
|
+
try { if (typeof localStorage !== "undefined") localStorage.setItem(CONFIG_SOURCE_KEY, source === "client" ? "client" : "server"); } catch { /* 持久化失败仅本次会话生效 */ }
|
|
227
|
+
}
|
|
228
|
+
/** 本机通知配置(只在 source=client 时参与生效;未设置过返回 null)。 */
|
|
229
|
+
export function readClientNotifyConfig() {
|
|
230
|
+
try {
|
|
231
|
+
if (typeof localStorage === "undefined") return null;
|
|
232
|
+
const raw = localStorage.getItem(CLIENT_CONFIG_KEY);
|
|
233
|
+
const obj = raw ? JSON.parse(raw) : null;
|
|
234
|
+
return obj && typeof obj === "object" && !Array.isArray(obj) ? obj : null;
|
|
235
|
+
} catch { return null; }
|
|
236
|
+
}
|
|
237
|
+
function writeClientNotifyConfig(cfg) {
|
|
238
|
+
try { if (typeof localStorage !== "undefined") localStorage.setItem(CLIENT_CONFIG_KEY, JSON.stringify(cfg || {})); } catch { /* 持久化失败仅本次会话生效 */ }
|
|
239
|
+
}
|
|
240
|
+
/** 从服务端配置里摘出「本机配置」需要接管的浏览器侧字段。
|
|
241
|
+
* ⚠️ eventMatrix 只带浏览器侧渠道(card / sound / system / voice):钉钉/飞书由宿主执行,
|
|
242
|
+
* 写进本机配置既不生效也会误导——本机模式下这两个渠道在矩阵里是只读的。 */
|
|
243
|
+
export function pickClientConfig(serverCfg) {
|
|
244
|
+
const src = serverCfg || {};
|
|
245
|
+
const out = {};
|
|
246
|
+
for (const key of CLIENT_CONFIG_FIELDS) {
|
|
247
|
+
if (src[key] !== undefined) out[key] = src[key];
|
|
248
|
+
}
|
|
249
|
+
const matrix = {};
|
|
250
|
+
const m = src.eventMatrix;
|
|
251
|
+
if (m && typeof m === "object") {
|
|
252
|
+
for (const [key, value] of Object.entries(m)) {
|
|
253
|
+
if (typeof value === "boolean" && /^(card|sound|system|voice):/.test(key)) matrix[key] = value;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
out.eventMatrix = matrix;
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
/** 把本机配置覆盖到服务端配置上(服务端唯一事实字段保留服务端值)。
|
|
260
|
+
* eventMatrix 逐键合并:本机只覆盖 card/sound/system/voice,dingtalk/feishu 始终用服务端的。 */
|
|
261
|
+
export function mergeClientNotifyConfig(serverCfg, localCfg) {
|
|
262
|
+
const server = serverCfg || {};
|
|
263
|
+
const cfg = Object.assign({}, server, localCfg || {});
|
|
264
|
+
// 服务端唯一事实:群机器人推送与语音总开关由宿主执行/控制,不能被本机配置覆盖。
|
|
265
|
+
for (const key of ["dingtalkEnabled", "dingtalkWebhook", "feishuEnabled", "feishuWebhook", "pushMode"]) {
|
|
266
|
+
cfg[key] = server[key];
|
|
267
|
+
}
|
|
268
|
+
cfg.voiceNotify = server.voiceNotify === true;
|
|
269
|
+
const matrix = Object.assign({}, server.eventMatrix || {});
|
|
270
|
+
const localMatrix = (localCfg && localCfg.eventMatrix) || {};
|
|
271
|
+
for (const [key, value] of Object.entries(localMatrix)) {
|
|
272
|
+
if (typeof value === "boolean" && /^(card|sound|system|voice):/.test(key)) matrix[key] = value;
|
|
273
|
+
}
|
|
274
|
+
cfg.eventMatrix = matrix;
|
|
275
|
+
return cfg;
|
|
276
|
+
}
|
|
277
|
+
export function effectiveNotifyConfig(serverCfg) {
|
|
278
|
+
const server = serverCfg || {};
|
|
279
|
+
if (notifyConfigSource() !== "client") return server;
|
|
280
|
+
const local = readClientNotifyConfig();
|
|
281
|
+
if (!local) return server;
|
|
282
|
+
return mergeClientNotifyConfig(server, local);
|
|
283
|
+
}
|
|
284
|
+
/** 本机配置来源的 React 状态。 */
|
|
285
|
+
function useNotifyConfigSource() {
|
|
286
|
+
const [source, setSource] = useState(() => notifyConfigSource());
|
|
287
|
+
const set = useCallback((next) => {
|
|
288
|
+
const value = next === "client" ? "client" : "server";
|
|
289
|
+
setNotifyConfigSource(value);
|
|
290
|
+
setSource(value);
|
|
291
|
+
}, []);
|
|
292
|
+
return [source, set];
|
|
293
|
+
}
|
|
294
|
+
// ---------- 通知事件矩阵:事件 × 渠道 ----------
|
|
295
|
+
// 键名形如 "card:complete" / "sound:error" / "voice:stuck"。未设置过的键回退到旧字段:
|
|
296
|
+
// 卡片/提示音/系统通知/群推送跟随 notifyOn*;语音跟随 voiceOn*——升级不改变既有行为。
|
|
297
|
+
const EVENT_CONFIG_KEY = {
|
|
298
|
+
complete: "notifyOnComplete",
|
|
299
|
+
error: "notifyOnError",
|
|
300
|
+
confirm: "notifyOnConfirm",
|
|
301
|
+
stuck: "notifyOnStuck",
|
|
302
|
+
};
|
|
303
|
+
const VOICE_EVENT_KEY = {
|
|
304
|
+
complete: "voiceOnComplete",
|
|
305
|
+
error: "voiceOnError",
|
|
306
|
+
confirm: "voiceOnConfirm",
|
|
307
|
+
stuck: "voiceOnStuck",
|
|
308
|
+
};
|
|
309
|
+
export function eventEnabled(cfg, channel, event) {
|
|
310
|
+
const key = channel + ":" + event;
|
|
311
|
+
const matrix = cfg && cfg.eventMatrix;
|
|
312
|
+
if (matrix && typeof matrix === "object" && typeof matrix[key] === "boolean") return matrix[key];
|
|
313
|
+
if (channel === "voice") return !cfg || cfg[VOICE_EVENT_KEY[event]] !== false;
|
|
314
|
+
return !cfg || cfg[EVENT_CONFIG_KEY[event]] !== false;
|
|
315
|
+
}
|
|
316
|
+
export function withEventMatrix(cfg, channel, event, on) {
|
|
317
|
+
const matrix = Object.assign({}, (cfg && cfg.eventMatrix) || {});
|
|
318
|
+
matrix[channel + ":" + event] = !!on;
|
|
319
|
+
return matrix;
|
|
320
|
+
}
|
|
163
321
|
/** 本机音色表(可能为空:Chrome 首次调用是异步加载,靠 voiceschanged 补)。 */
|
|
164
322
|
export function localVoices() {
|
|
165
323
|
const s = synthOf();
|
|
@@ -212,7 +370,7 @@ function useLocalVoices() {
|
|
|
212
370
|
}
|
|
213
371
|
let voiceEpoch = 0;
|
|
214
372
|
/**
|
|
215
|
-
*
|
|
373
|
+
* 播一句话(幂等键防重)。调用方已用 voiceAllowed 判过服务端总开关 + 本机开关;这里读
|
|
216
374
|
* voiceRate/voiceVolume/voiceLang/voiceURI,voiceURI 在本机不存在时按语言回落,
|
|
217
375
|
* lang 空时中文文本优先挑中文音色。
|
|
218
376
|
* @returns 是否真的开始播(不支持/文本为空返回 false)
|
|
@@ -334,6 +492,31 @@ export function confirmBroadcastText(task, approval, cfg) {
|
|
|
334
492
|
parts.push("正在等待你批准" + (scene ? "执行" + scene : "") + ",工具是" + tool + ",请回到会话处理。");
|
|
335
493
|
return parts.join("");
|
|
336
494
|
}
|
|
495
|
+
/**
|
|
496
|
+
* 需回答播报文案(ask_user_question 挂起,等人选答案)。
|
|
497
|
+
* 与「需授权」是两件事:批准是"要不要执行",提问是"要选哪个答案"——播报不能混说。
|
|
498
|
+
* 简洁口径只说「任务需要回答」;适中点名任务与问题;详细再补"共几问"与回到会话的叮嘱。
|
|
499
|
+
*/
|
|
500
|
+
export function questionBroadcastText(task, question, cfg) {
|
|
501
|
+
const mode = voiceTone(cfg);
|
|
502
|
+
if (mode === "brief") return "任务需要你回答";
|
|
503
|
+
const who = task && task.title ? "任务「" + truncate(task.title, 30) + "」" : "任务";
|
|
504
|
+
const head = (question && question.header) || "";
|
|
505
|
+
const body = (question && question.question) || "";
|
|
506
|
+
const what = body ? truncate(body.replace(/[#*_`>\[\]()!]/g, ""), 40) : "";
|
|
507
|
+
if (mode === "medium") {
|
|
508
|
+
// 适中口径:点名任务 + 问的是什么(问题正文优先,没有正文退回标题);句号统一补
|
|
509
|
+
const tail = what ? ":" + what : (head ? ":" + head : "");
|
|
510
|
+
return who + "在等你回答" + tail + "。";
|
|
511
|
+
}
|
|
512
|
+
const parts = [];
|
|
513
|
+
if (task && task.title) parts.push("任务:" + truncate(task.title, 30) + "。");
|
|
514
|
+
parts.push(what ? "正在等你回答问题:" + what + "。" : "正在等你回答问题。");
|
|
515
|
+
const count = (question && question.count) || 0;
|
|
516
|
+
if (count > 1) parts.push("共 " + count + " 个问题。");
|
|
517
|
+
parts.push("请回到会话选择答案。");
|
|
518
|
+
return parts.join("");
|
|
519
|
+
}
|
|
337
520
|
/**
|
|
338
521
|
* 卡住播报文案:必须回答「哪里卡住了」——主任务还是子任务、哪个会话、停了多久、
|
|
339
522
|
* 最后在干什么。子任务静默会把「主任务明明在跑却突然播报」放大成困惑(2026-10-30
|
|
@@ -358,8 +541,14 @@ export function stuckBroadcastText(task, cfg) {
|
|
|
358
541
|
return parts.join("");
|
|
359
542
|
}
|
|
360
543
|
|
|
361
|
-
/** 卡住判定阈值:与【任务动画】同款——phase=code
|
|
544
|
+
/** 卡住判定阈值:与【任务动画】同款——phase=code 且这么久没动静才视为卡住(正常长任务不误报)。
|
|
545
|
+
* 「没动静」由 src/shared.js 的 taskStalled 定义:等人介入、命令在跑、正在压缩上下文都算在干活。 */
|
|
362
546
|
const STUCK_MS = 18000;
|
|
547
|
+
/** 读取卡住阈值(宿主可下发 stuckMs 覆盖,缺字段回退 STUCK_MS;播报与判定共用同一口径)。 */
|
|
548
|
+
function stuckMsOf(cfg) {
|
|
549
|
+
const v = cfg && Number(cfg.stuckMs);
|
|
550
|
+
return Number.isFinite(v) && v > 0 ? v : STUCK_MS;
|
|
551
|
+
}
|
|
363
552
|
|
|
364
553
|
// ---------- 共享快照:浮层(轮询) / 面板页 / 首页概要共用一份数据 ----------
|
|
365
554
|
const notifyStore = {
|
|
@@ -373,6 +562,12 @@ const notifyStore = {
|
|
|
373
562
|
this.emit();
|
|
374
563
|
}
|
|
375
564
|
},
|
|
565
|
+
// 本机配置/配置来源变化:状态对象换一个浅拷贝,让订阅方(尤其浮层的 effect)重算。
|
|
566
|
+
touch() {
|
|
567
|
+
if (!this.snap.status) return;
|
|
568
|
+
this.snap = Object.assign({}, this.snap, { status: Object.assign({}, this.snap.status) });
|
|
569
|
+
this.emit();
|
|
570
|
+
},
|
|
376
571
|
refresh() {
|
|
377
572
|
if (this.snap.loading) return Promise.resolve();
|
|
378
573
|
this.snap = Object.assign({}, this.snap, { loading: true });
|
|
@@ -533,13 +728,14 @@ export function NotifyOverlay(props) {
|
|
|
533
728
|
const [stackHover, setStackHover] = useState(false); // 卡片栈被悬停(叠放时整体展开)
|
|
534
729
|
const prevActiveRef = useRef(null);
|
|
535
730
|
const remindedApprovalsRef = useRef(new Map()); // sessionId -> 提醒过的 approvalId 集合
|
|
731
|
+
const remindedQuestionsRef = useRef(new Map()); // sessionId -> 提醒过的提问 callId 集合
|
|
536
732
|
const toastSeqRef = useRef(0);
|
|
537
733
|
|
|
538
734
|
const pushToast = useCallback((toast) => {
|
|
539
735
|
// 「页内卡片」总开关(设置面板里关掉 → 右侧所有卡片都不再弹出)。只管卡片:
|
|
540
736
|
// 语音播报 / 提示音 / 系统通知 / 群推送各有独立开关,与它正交。
|
|
541
737
|
const st = notifyStore.snap.status;
|
|
542
|
-
const cfg = st
|
|
738
|
+
const cfg = st ? effectiveNotifyConfig(st.config) : null;
|
|
543
739
|
if (cfg && cfg.notifyCards === false) return;
|
|
544
740
|
// 上限 12:结束卡现在常驻到看过才收,4 张容易见底;多了靠叠放收纳而不是丢弃。
|
|
545
741
|
setToasts((prev) => prev.concat([toast]).slice(-12));
|
|
@@ -548,7 +744,7 @@ export function NotifyOverlay(props) {
|
|
|
548
744
|
// 总开关中途关闭:已挂着的卡片也一并收掉(否则「设置里关了、右边还挂着」像开关失灵)。
|
|
549
745
|
// 返回原数组避免空转重渲染。
|
|
550
746
|
useEffect(() => {
|
|
551
|
-
const cfg = snap.status
|
|
747
|
+
const cfg = snap.status ? effectiveNotifyConfig(snap.status.config) : null;
|
|
552
748
|
if (cfg && cfg.notifyCards === false) setToasts((prev) => (prev.length ? [] : prev));
|
|
553
749
|
}, [snap.status]);
|
|
554
750
|
|
|
@@ -580,7 +776,7 @@ export function NotifyOverlay(props) {
|
|
|
580
776
|
for (const task of prev) {
|
|
581
777
|
if (currIds.has(task.sessionId)) continue;
|
|
582
778
|
const record = (st.recent || []).find((r) => r.sessionId === task.sessionId) || null;
|
|
583
|
-
handleTaskEnd(task, record, st.config || {});
|
|
779
|
+
handleTaskEnd(task, record, effectiveNotifyConfig(st.config || {}));
|
|
584
780
|
}
|
|
585
781
|
}
|
|
586
782
|
prevActiveRef.current = st.active.slice();
|
|
@@ -592,25 +788,26 @@ export function NotifyOverlay(props) {
|
|
|
592
788
|
useEffect(() => {
|
|
593
789
|
const st = snap.status;
|
|
594
790
|
if (!st || !st.active) return;
|
|
595
|
-
const cfg = st.config || {};
|
|
791
|
+
const cfg = effectiveNotifyConfig(st.config || {});
|
|
596
792
|
const activeIds = new Set(st.active.map((x) => x.sessionId));
|
|
597
793
|
for (const [sid, seen] of remindedApprovalsRef.current) {
|
|
598
794
|
if (!activeIds.has(sid)) remindedApprovalsRef.current.delete(sid);
|
|
599
795
|
}
|
|
600
796
|
for (const task of st.active) {
|
|
601
|
-
const approvals =
|
|
797
|
+
const approvals = taskApprovals(task);
|
|
602
798
|
if (approvals.length === 0) continue;
|
|
603
799
|
let seen = remindedApprovalsRef.current.get(task.sessionId);
|
|
604
800
|
if (!seen) { seen = new Set(); remindedApprovalsRef.current.set(task.sessionId, seen); }
|
|
605
801
|
const fresh = approvals.filter((a) => a && !seen.has(a.id));
|
|
606
802
|
if (fresh.length === 0) continue;
|
|
607
803
|
for (const a of fresh) seen.add(a.id);
|
|
608
|
-
//
|
|
609
|
-
|
|
804
|
+
// 语音播报走自己的开关(在卡片开关判定**之前**:关掉卡片提醒不等于关掉耳朵)。
|
|
805
|
+
// 服务端总开关 + 本机开关都开才出声(团队共用一个实例时各自可静音)。
|
|
806
|
+
if (voiceAllowed(cfg) && eventEnabled(cfg, "voice", "confirm")) {
|
|
610
807
|
speakVoice(confirmBroadcastText(task, fresh[0], cfg), cfg);
|
|
611
808
|
}
|
|
612
809
|
// 提醒开关在"收到新确认项"时检查,保证开关中途打开对下一项生效
|
|
613
|
-
if (cfg
|
|
810
|
+
if (!eventEnabled(cfg, "card", "confirm")) continue;
|
|
614
811
|
const toolName = fresh[0].toolName || "";
|
|
615
812
|
const scene = toolLabel(toolName);
|
|
616
813
|
// 卡片正文按「卡片口径」三挡裁剪(title 行永远在,卡片标题不受口径影响)
|
|
@@ -637,8 +834,8 @@ export function NotifyOverlay(props) {
|
|
|
637
834
|
// 下面的对账 effect 据此自动收起卡片(处理完就不该再占着右上角)。
|
|
638
835
|
approvalKeys: fresh.filter((a) => a && a.id).map((a) => task.sessionId + "|" + a.id),
|
|
639
836
|
});
|
|
640
|
-
if (cfg.soundNotify !== false) playAskSound(cfg.soundEffect);
|
|
641
|
-
if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
|
|
837
|
+
if (cfg.soundNotify !== false && eventEnabled(cfg, "sound", "confirm")) playAskSound(cfg.soundEffect);
|
|
838
|
+
if (cfg.systemNotify && eventEnabled(cfg, "system", "confirm") && typeof document !== "undefined" && document.hidden
|
|
642
839
|
&& typeof Notification !== "undefined" && Notification.permission === "granted") {
|
|
643
840
|
try {
|
|
644
841
|
new Notification("dsh 任务需要确认", {
|
|
@@ -650,22 +847,87 @@ export function NotifyOverlay(props) {
|
|
|
650
847
|
}
|
|
651
848
|
}, [snap.status]);
|
|
652
849
|
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
850
|
+
// ===== 需回答检测:会话调 ask_user_question 后挂起等人选答案时通知 =====
|
|
851
|
+
// 与「需确认」分开成一段:来源不同(tool/call → tool/result,而非 approval/asked →
|
|
852
|
+
// decided),文案也不同("要你选答案"不是"要你批准执行")。但语义同属「任务停在原地等人」,
|
|
853
|
+
// 因此卡住判定把它们一并排除(见下方 stuck 检测的 taskWaiting 判断)。
|
|
854
|
+
// 真实事故(2026-11-01 用户反馈):提问挂起期间会话零事件,18 秒后被判成「主任务卡住了」,
|
|
855
|
+
// 界面一边显示提问表单、一边报卡住——错的不是检测阈值,而是"等人"没被算作一种合法停点。
|
|
856
|
+
useEffect(() => {
|
|
857
|
+
const st = snap.status;
|
|
858
|
+
if (!st || !st.active) return;
|
|
859
|
+
const cfg = effectiveNotifyConfig(st.config || {});
|
|
860
|
+
const activeIds = new Set(st.active.map((x) => x.sessionId));
|
|
861
|
+
for (const [sid, seen] of remindedQuestionsRef.current) {
|
|
862
|
+
if (!activeIds.has(sid)) remindedQuestionsRef.current.delete(sid);
|
|
863
|
+
}
|
|
864
|
+
for (const task of st.active) {
|
|
865
|
+
const questions = taskQuestions(task);
|
|
866
|
+
if (questions.length === 0) continue;
|
|
867
|
+
let seen = remindedQuestionsRef.current.get(task.sessionId);
|
|
868
|
+
if (!seen) { seen = new Set(); remindedQuestionsRef.current.set(task.sessionId, seen); }
|
|
869
|
+
const fresh = questions.filter((q) => q && q.id && !seen.has(q.id));
|
|
870
|
+
if (fresh.length === 0) continue;
|
|
871
|
+
for (const q of fresh) seen.add(q.id);
|
|
872
|
+
// 语音播报走自己的开关(与卡片开关正交:关卡片不等于关掉耳朵)。
|
|
873
|
+
// 服务端总开关 + 本机开关都开才出声。
|
|
874
|
+
if (voiceAllowed(cfg) && eventEnabled(cfg, "voice", "confirm")) {
|
|
875
|
+
speakVoice(questionBroadcastText(task, fresh[0], cfg), cfg);
|
|
876
|
+
}
|
|
877
|
+
// 提醒开关在"收到新提问"时检查,保证开关中途打开对下一问生效
|
|
878
|
+
if (!eventEnabled(cfg, "card", "confirm")) continue;
|
|
879
|
+
const tone = cardTone(cfg);
|
|
880
|
+
const head = fresh[0].header || "";
|
|
881
|
+
const body = fresh[0].question || "";
|
|
882
|
+
const lines = ["任务:" + (task.title || "(无标题)")];
|
|
883
|
+
if (body) lines.push((head ? head + ":" : "问题:") + truncate(body, 140));
|
|
884
|
+
else if (head) lines.push("问题:" + head);
|
|
885
|
+
const count = (fresh[0].count || 0);
|
|
886
|
+
if (count > 1) lines.push(tone === "brief" ? "共 " + count + " 个问题" : "共 " + count + " 个问题等你选择");
|
|
887
|
+
if (tone === "detailed") lines.push("请在会话里选好答案后继续");
|
|
888
|
+
pushToast({
|
|
889
|
+
id: ++toastSeqRef.current,
|
|
890
|
+
kind: "confirm",
|
|
891
|
+
title: "任务需要你回答",
|
|
892
|
+
body: lines.join("\n"),
|
|
893
|
+
// 点卡片直达该会话去选答案(与需确认卡同哲学:问题没答完卡片就该在)
|
|
894
|
+
sessionId: task.sessionId || "",
|
|
895
|
+
sessionTitle: task.title || "",
|
|
896
|
+
// 与这批提问绑定:回答落地后宿主把它们移出 questions,下面的对账 effect 据此收起
|
|
897
|
+
questionKeys: fresh.map((q) => task.sessionId + "|" + q.id),
|
|
898
|
+
});
|
|
899
|
+
if (cfg.soundNotify !== false && eventEnabled(cfg, "sound", "confirm")) playAskSound(cfg.soundEffect);
|
|
900
|
+
if (cfg.systemNotify && eventEnabled(cfg, "system", "confirm") && typeof document !== "undefined" && document.hidden
|
|
901
|
+
&& typeof Notification !== "undefined" && Notification.permission === "granted") {
|
|
902
|
+
try {
|
|
903
|
+
new Notification("dsh 任务需要你回答", {
|
|
904
|
+
body: (task.title || "(无标题)") + " · " + (head || "有一问在等你选择"),
|
|
905
|
+
tag: "dsh-dock-question-" + task.sessionId,
|
|
906
|
+
});
|
|
907
|
+
} catch { /* 系统通知失败不影响页面内通知 */ }
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}, [snap.status]);
|
|
911
|
+
|
|
912
|
+
// 等人介入的项消失(approval/decided 移出 approvals,或回答落地移出 questions)
|
|
913
|
+
// → 对应提醒卡自动收起。只标记 closing 走淡出,不直接删:与手动点 ✕ 的退场一致。
|
|
914
|
+
// 卡片绑定的 keys 全部不在仍在等待的集合里才收;空 keys(老数据)不动。
|
|
915
|
+
// 两类卡共用这段对账:kind=confirm 的卡片要么绑 approvalKeys、要么绑 questionKeys。
|
|
656
916
|
useEffect(() => {
|
|
657
917
|
const st = snap.status;
|
|
658
918
|
if (!st || !st.active) return;
|
|
659
919
|
const live = new Set();
|
|
660
920
|
for (const task of st.active) {
|
|
661
|
-
const
|
|
662
|
-
for (const
|
|
921
|
+
for (const a of taskApprovals(task)) if (a && a.id) live.add(task.sessionId + "|" + a.id);
|
|
922
|
+
for (const q of taskQuestions(task)) if (q && q.id) live.add(task.sessionId + "|" + q.id);
|
|
663
923
|
}
|
|
664
924
|
setToasts((prev) => {
|
|
665
925
|
let changed = false;
|
|
666
926
|
const next = prev.map((x) => {
|
|
667
927
|
if (x.kind !== "confirm" || x.closing) return x;
|
|
668
|
-
const keys =
|
|
928
|
+
const keys = []
|
|
929
|
+
.concat(Array.isArray(x.approvalKeys) ? x.approvalKeys : [])
|
|
930
|
+
.concat(Array.isArray(x.questionKeys) ? x.questionKeys : []);
|
|
669
931
|
if (keys.length === 0 || keys.some((k) => live.has(k))) return x;
|
|
670
932
|
changed = true;
|
|
671
933
|
return Object.assign({}, x, { closing: true });
|
|
@@ -711,7 +973,7 @@ export function NotifyOverlay(props) {
|
|
|
711
973
|
useEffect(() => {
|
|
712
974
|
const st = snap.status;
|
|
713
975
|
if (!st || !st.active) return;
|
|
714
|
-
const cfg = st.config || {};
|
|
976
|
+
const cfg = effectiveNotifyConfig(st.config || {});
|
|
715
977
|
const now = st.now || Date.now();
|
|
716
978
|
const activeIds = new Set(st.active.map((x) => x.sessionId));
|
|
717
979
|
for (const sid of Array.from(stuckSeenRef.current.keys())) {
|
|
@@ -726,24 +988,31 @@ export function NotifyOverlay(props) {
|
|
|
726
988
|
if (t.isSubtask && t.parentSessionId) parentsWaitingOnSubtask.add(t.parentSessionId);
|
|
727
989
|
}
|
|
728
990
|
for (const task of st.active) {
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
991
|
+
// 停在原地等人的会话不算卡住——那是「需确认 / 需回答」提醒的职责,别重复轰炸。
|
|
992
|
+
// 两类都要排除:① 工具等批准(approvals);② 提问等回答(questions)。
|
|
993
|
+
// ②是 2026-11-01 用户实测的误报来源:会话调 ask_user_question 后挂起等人选答案,
|
|
994
|
+
// 其间会话一条事件都没有,18 秒安静被当成停滞,播报「主任务卡住了」并弹卡——
|
|
995
|
+
// 界面上明明是在等用户回答(截图为证),报「卡住」完全是错的口径。
|
|
996
|
+
if (taskWaiting(task)) { stuckSeenRef.current.delete(task.sessionId); continue; }
|
|
732
997
|
if (task.phase !== "code") { continue; }
|
|
733
998
|
// 该会话有活跃子任务在跑 → 它在等子任务,不算卡住(否则主任务进行中会误报卡住)
|
|
734
999
|
if (parentsWaitingOnSubtask.has(task.sessionId)) continue;
|
|
735
|
-
|
|
736
|
-
|
|
1000
|
+
// 卡住判定统一走 src/shared.js 的 taskStalled:它已经排除「有在飞的工具调用」与
|
|
1001
|
+
// 「正在压缩上下文」这两种**正在干活**的安静。2026-11-02 用户实测:25 分钟里 18 次
|
|
1002
|
+
// 误报全落在 compaction 窗口内(最长 96.7 秒),界面在刷「上下文已压缩」却报卡住。
|
|
1003
|
+
// 停点指纹改用同一个活动表达式(taskActivityAt),对账 effect 必须一致,否则卡片闪。
|
|
1004
|
+
const lastAct = taskActivityAt(task);
|
|
1005
|
+
const stuckMs = taskStalled(task, now, stuckMsOf(cfg));
|
|
1006
|
+
if (!stuckMs) continue;
|
|
737
1007
|
const prev = stuckSeenRef.current.get(task.sessionId);
|
|
738
1008
|
if (prev === lastAct) continue; // 同一停点已报过
|
|
739
1009
|
stuckSeenRef.current.set(task.sessionId, lastAct);
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (cfg.voiceNotify === true && cfg.voiceOnStuck !== false) {
|
|
1010
|
+
// —— 语音:说清主/子任务、标题、停滞时长与最后动作(服务端总开关 + 本机开关)——
|
|
1011
|
+
if (voiceAllowed(cfg) && eventEnabled(cfg, "voice", "stuck")) {
|
|
743
1012
|
speakVoice(stuckBroadcastText(task, Object.assign({}, cfg, { stuckMs: stuckMs })), cfg);
|
|
744
1013
|
}
|
|
745
1014
|
// —— 卡片:只受「卡住提醒」与「页内卡片」总开关控制(总开关在 pushToast 里把关)——
|
|
746
|
-
if (cfg
|
|
1015
|
+
if (!eventEnabled(cfg, "card", "stuck")) continue;
|
|
747
1016
|
const label = task.isSubtask ? "子任务" : "主任务";
|
|
748
1017
|
const title = task.title || "(无标题)";
|
|
749
1018
|
const tool = task.lastToolName || "";
|
|
@@ -771,8 +1040,10 @@ export function NotifyOverlay(props) {
|
|
|
771
1040
|
}, [snap.status]);
|
|
772
1041
|
|
|
773
1042
|
// 卡住卡的自动收起(与需确认卡同哲学:不设倒计时,状态没变就一直挂着):
|
|
774
|
-
//
|
|
775
|
-
//
|
|
1043
|
+
// 恢复活动 / 离开活跃列表(结束或取消)/ 转为等人介入(需确认卡或需回答卡接管)
|
|
1044
|
+
// → 标记 closing 走同一套 240ms 淡出。只标记不直删,与手动 ✕ 退场一致。
|
|
1045
|
+
// 「恢复活动」的判据必须与卡住判定处**完全一致**(都走 taskActivityAt),否则同一停点
|
|
1046
|
+
// 两处算出的指纹不同,卡片会反复收起又弹出。
|
|
776
1047
|
useEffect(() => {
|
|
777
1048
|
const st = snap.status;
|
|
778
1049
|
if (!st || !st.active) return;
|
|
@@ -783,8 +1054,8 @@ export function NotifyOverlay(props) {
|
|
|
783
1054
|
if (x.kind !== "stuck" || x.closing) return x;
|
|
784
1055
|
const task = byId.get(x.sessionId);
|
|
785
1056
|
const stillStalled = !!task
|
|
786
|
-
&& (task
|
|
787
|
-
&& (
|
|
1057
|
+
&& taskActivityAt(task) === x.stuckAt
|
|
1058
|
+
&& !taskWaiting(task);
|
|
788
1059
|
if (stillStalled) return x;
|
|
789
1060
|
changed = true;
|
|
790
1061
|
return Object.assign({}, x, { closing: true });
|
|
@@ -797,57 +1068,60 @@ export function NotifyOverlay(props) {
|
|
|
797
1068
|
const reason = (record && record.endReason) || "";
|
|
798
1069
|
const success = isSuccessReason(reason);
|
|
799
1070
|
const info = endInfo(reason);
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1071
|
+
const event = success ? "complete" : "error";
|
|
1072
|
+
const cardWanted = eventEnabled(cfg, "card", event);
|
|
1073
|
+
const soundWanted = cfg.soundNotify !== false && eventEnabled(cfg, "sound", event);
|
|
1074
|
+
const systemWanted = !!cfg.systemNotify && eventEnabled(cfg, "system", event);
|
|
1075
|
+
// 语音播报与卡片/提示音/系统通知完全正交:各自的事件矩阵与总开关独立判断。
|
|
1076
|
+
if (voiceAllowed(cfg) && eventEnabled(cfg, "voice", event)) {
|
|
805
1077
|
speakVoice(endBroadcastText(success, record, task, cfg), cfg);
|
|
806
1078
|
}
|
|
807
|
-
if (wanted === false) return;
|
|
808
1079
|
const models = record && record.models && record.models.length ? record.models.join(", ")
|
|
809
1080
|
: (task.models && task.models.length ? task.models.join(", ") : "未知模型");
|
|
810
1081
|
const provider = (record && record.provider) || task.provider || "";
|
|
811
1082
|
const startTs = (record && record.startTime) || task.startTime;
|
|
812
1083
|
const endTs = (record && record.endTime) || Date.now();
|
|
813
|
-
// 卡片正文按「卡片口径」三挡裁剪(cfg.cardMode,缺省 detailed =历史内容):
|
|
814
|
-
// 简洁=任务+耗时;适中=加模型与起止时间;详细=再加回合/步骤、Token、摘要或错误。
|
|
815
|
-
const tone = cardTone(cfg);
|
|
816
1084
|
const durText = fmtDur((record && record.duration) || (endTs - startTs));
|
|
817
|
-
const lines = ["任务:" + ((record && record.title) || task.title || "(无标题)")];
|
|
818
|
-
if (tone !== "brief") lines.push("模型:" + models + (provider ? "(" + provider + ")" : ""));
|
|
819
|
-
lines.push(tone === "brief"
|
|
820
|
-
? "耗时:" + durText
|
|
821
|
-
: "耗时:" + durText + "(" + fmtTime(startTs) + " → " + fmtTime(endTs) + ")");
|
|
822
|
-
if (tone === "detailed") {
|
|
823
|
-
lines.push("回合 " + ((record && record.turns) || task.turns || 0)
|
|
824
|
-
+ " · 步骤 " + ((record && record.steps) || task.steps || 0)
|
|
825
|
-
+ (((record && record.toolCalls) || task.toolCalls) ? " · 工具 " + ((record && record.toolCalls) || task.toolCalls) + " 次" : ""));
|
|
826
|
-
lines.push("Token:输入 " + fmtNum((record && record.inputTokens) || task.inputTokens)
|
|
827
|
-
+ " / 输出 " + fmtNum((record && record.outputTokens) || task.outputTokens));
|
|
828
|
-
if (record && record.lastText) lines.push("摘要:" + truncate(record.lastText, 140));
|
|
829
|
-
if (!success && record && record.errorMessage) lines.push("错误:" + truncate(record.errorMessage, 120));
|
|
830
|
-
}
|
|
831
1085
|
const title = success ? "任务完成" : "任务" + info.label;
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1086
|
+
const showTitle = (record && record.title) || task.title || "(无标题)";
|
|
1087
|
+
if (cardWanted) {
|
|
1088
|
+
// 卡片正文按「卡片口径」三挡裁剪(cfg.cardMode,缺省 detailed =历史内容):
|
|
1089
|
+
// 简洁=任务+耗时;适中=加模型与起止时间;详细=再加回合/步骤、Token、摘要或错误。
|
|
1090
|
+
const tone = cardTone(cfg);
|
|
1091
|
+
const lines = ["任务:" + showTitle];
|
|
1092
|
+
if (tone !== "brief") lines.push("模型:" + models + (provider ? "(" + provider + ")" : ""));
|
|
1093
|
+
lines.push(tone === "brief"
|
|
1094
|
+
? "耗时:" + durText
|
|
1095
|
+
: "耗时:" + durText + "(" + fmtTime(startTs) + " → " + fmtTime(endTs) + ")");
|
|
1096
|
+
if (tone === "detailed") {
|
|
1097
|
+
lines.push("回合 " + ((record && record.turns) || task.turns || 0)
|
|
1098
|
+
+ " · 步骤 " + ((record && record.steps) || task.steps || 0)
|
|
1099
|
+
+ (((record && record.toolCalls) || task.toolCalls) ? " · 工具 " + ((record && record.toolCalls) || task.toolCalls) + " 次" : ""));
|
|
1100
|
+
lines.push("Token:输入 " + fmtNum((record && record.inputTokens) || task.inputTokens)
|
|
1101
|
+
+ " / 输出 " + fmtNum((record && record.outputTokens) || task.outputTokens));
|
|
1102
|
+
if (record && record.lastText) lines.push("摘要:" + truncate(record.lastText, 140));
|
|
1103
|
+
if (!success && record && record.errorMessage) lines.push("错误:" + truncate(record.errorMessage, 120));
|
|
1104
|
+
}
|
|
1105
|
+
pushToast({
|
|
1106
|
+
id: ++toastSeqRef.current,
|
|
1107
|
+
kind: success ? "success" : (info.cls === "err" ? "error" : "warn"),
|
|
1108
|
+
title,
|
|
1109
|
+
body: lines.join("\n"),
|
|
1110
|
+
// 绑定会话:点卡片直达该会话;用户点进这个会话(侧栏选中)也视为
|
|
1111
|
+
// 看到了结果,巡检 effect 据此自动收起卡片。没看过就一直挂着。
|
|
1112
|
+
// sessionId 是权威标识(跳转/对账都用它),标题只作旧宿主回退。
|
|
1113
|
+
sessionId: task.sessionId || "",
|
|
1114
|
+
sessionTitle: showTitle,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
843
1117
|
// 提示音:任务结束时播放(完成/异常配套音);与系统通知独立开关
|
|
844
|
-
if (
|
|
1118
|
+
if (soundWanted) playDoneSound(success, cfg.soundEffect);
|
|
845
1119
|
// 系统通知:仅页面处于后台时推送,避免前台重复打扰
|
|
846
|
-
if (
|
|
1120
|
+
if (systemWanted && typeof document !== "undefined" && document.hidden
|
|
847
1121
|
&& typeof Notification !== "undefined" && Notification.permission === "granted") {
|
|
848
1122
|
try {
|
|
849
1123
|
new Notification("dsh " + title, {
|
|
850
|
-
body:
|
|
1124
|
+
body: showTitle + " · " + durText,
|
|
851
1125
|
tag: "dsh-dock-notify-" + task.sessionId,
|
|
852
1126
|
});
|
|
853
1127
|
} catch { /* 系统通知失败不影响页面内通知 */ }
|
|
@@ -873,10 +1147,116 @@ export function NotifyOverlay(props) {
|
|
|
873
1147
|
}
|
|
874
1148
|
|
|
875
1149
|
// ---------- 面板页 ----------
|
|
1150
|
+
// 通知事件矩阵:纵向是事件,横向是渠道。每个格子对应 eventMatrix 里的一个布尔键,
|
|
1151
|
+
// 未设置过的键回退到旧字段(见 eventEnabled),保证老配置升级后行为不变。
|
|
1152
|
+
const MATRIX_COLUMNS = [
|
|
1153
|
+
{ id: "card", label: "页内卡片" },
|
|
1154
|
+
{ id: "sound", label: "提示音" },
|
|
1155
|
+
{ id: "system", label: "系统通知" },
|
|
1156
|
+
{ id: "voice", label: "语音播报" },
|
|
1157
|
+
{ id: "dingtalk", label: "钉钉" },
|
|
1158
|
+
{ id: "feishu", label: "飞书" },
|
|
1159
|
+
];
|
|
1160
|
+
const MATRIX_EVENTS = [
|
|
1161
|
+
{ id: "complete", label: "完成", desc: "任务正常结束" },
|
|
1162
|
+
{ id: "error", label: "异常", desc: "出错 / 中止 / 受阻 / 达上限" },
|
|
1163
|
+
{ id: "confirm", label: "需确认 / 需回答", desc: "工具等批准、提问等回答" },
|
|
1164
|
+
{ id: "stuck", label: "卡住", desc: "编程阶段长时间无活动" },
|
|
1165
|
+
];
|
|
1166
|
+
// 渠道支持哪些事件:提示音/系统通知没有「卡住」,群机器人只在任务结束时推送。
|
|
1167
|
+
const MATRIX_SUPPORT = {
|
|
1168
|
+
card: ["complete", "error", "confirm", "stuck"],
|
|
1169
|
+
sound: ["complete", "error", "confirm"],
|
|
1170
|
+
system: ["complete", "error", "confirm"],
|
|
1171
|
+
voice: ["complete", "error", "confirm", "stuck"],
|
|
1172
|
+
dingtalk: ["complete", "error"],
|
|
1173
|
+
feishu: ["complete", "error"],
|
|
1174
|
+
};
|
|
1175
|
+
const MODE_OPTIONS = [["brief", "简洁"], ["medium", "适中"], ["detailed", "详细"]];
|
|
1176
|
+
|
|
1177
|
+
function ModePicker(props) {
|
|
1178
|
+
return (
|
|
1179
|
+
<span className="dknt-modes">
|
|
1180
|
+
{MODE_OPTIONS.map(([value, label]) => (
|
|
1181
|
+
<button type="button" key={value}
|
|
1182
|
+
className={"dknt-miniswitch" + (props.value === value ? " on" : "")}
|
|
1183
|
+
title={props.title ? props.title + "(" + label + ")" : label}
|
|
1184
|
+
onClick={() => props.onChange(value)}>{label}</button>
|
|
1185
|
+
))}
|
|
1186
|
+
</span>
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** 渠道总开关状态(矩阵表头的小圆点用)。 */
|
|
1191
|
+
function channelMasterOn(cfg, channel, voiceClientOn) {
|
|
1192
|
+
if (!cfg) return false;
|
|
1193
|
+
if (channel === "card") return cfg.notifyCards !== false;
|
|
1194
|
+
if (channel === "sound") return cfg.soundNotify !== false;
|
|
1195
|
+
if (channel === "system") return !!cfg.systemNotify;
|
|
1196
|
+
if (channel === "voice") return cfg.voiceNotify === true && voiceClientOn;
|
|
1197
|
+
if (channel === "dingtalk") return !!cfg.dingtalkEnabled;
|
|
1198
|
+
if (channel === "feishu") return !!cfg.feishuEnabled;
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function EventMatrix(props) {
|
|
1203
|
+
const { cfg, source, voiceClientOn, patch } = props;
|
|
1204
|
+
const toggle = (channel, event, on) => patch({ eventMatrix: withEventMatrix(cfg, channel, event, on) });
|
|
1205
|
+
return (
|
|
1206
|
+
<div className="dknt-matrix-wrap">
|
|
1207
|
+
<div className="dknt-matrix">
|
|
1208
|
+
<div className="dknt-matrix-row dknt-matrix-head">
|
|
1209
|
+
<span className="dknt-matrix-event">通知事件</span>
|
|
1210
|
+
{MATRIX_COLUMNS.map((column) => (
|
|
1211
|
+
<span key={column.id} className={"dknt-matrix-col" + (channelMasterOn(cfg, column.id, voiceClientOn) ? " on" : "")}>
|
|
1212
|
+
{column.label}
|
|
1213
|
+
<em>{channelMasterOn(cfg, column.id, voiceClientOn) ? "开" : "关"}</em>
|
|
1214
|
+
</span>
|
|
1215
|
+
))}
|
|
1216
|
+
</div>
|
|
1217
|
+
{MATRIX_EVENTS.map((ev) => (
|
|
1218
|
+
<div key={ev.id} className="dknt-matrix-row">
|
|
1219
|
+
<span className="dknt-matrix-event">
|
|
1220
|
+
<b>{ev.label}</b>
|
|
1221
|
+
<small>{ev.desc}</small>
|
|
1222
|
+
</span>
|
|
1223
|
+
{MATRIX_COLUMNS.map((column) => {
|
|
1224
|
+
const supported = MATRIX_SUPPORT[column.id].indexOf(ev.id) !== -1;
|
|
1225
|
+
const serverOnly = source === "client" && (column.id === "dingtalk" || column.id === "feishu");
|
|
1226
|
+
if (!supported) return <span key={column.id} className="dknt-cell dknt-cell-na" title="该渠道不支持此事件">—</span>;
|
|
1227
|
+
const on = eventEnabled(cfg, column.id, ev.id);
|
|
1228
|
+
return (
|
|
1229
|
+
<button type="button" key={column.id}
|
|
1230
|
+
className={"dknt-cell" + (on ? " on" : "") + (serverOnly ? " disabled" : "")}
|
|
1231
|
+
role="checkbox" aria-checked={on}
|
|
1232
|
+
disabled={serverOnly}
|
|
1233
|
+
title={serverOnly ? "群机器人推送由服务端执行,请切换到「服务端配置」修改" : on ? "取消该事件通过此渠道提醒" : "让该事件通过此渠道提醒"}
|
|
1234
|
+
onClick={() => { if (!serverOnly) toggle(column.id, ev.id, !on); }}>
|
|
1235
|
+
{on ? "✓" : ""}
|
|
1236
|
+
</button>
|
|
1237
|
+
);
|
|
1238
|
+
})}
|
|
1239
|
+
</div>
|
|
1240
|
+
))}
|
|
1241
|
+
</div>
|
|
1242
|
+
<div className="dknt-matrix-legend">
|
|
1243
|
+
<span><i className="dknt-dot on" /> 已勾选</span>
|
|
1244
|
+
<span><i className="dknt-dot" /> 未勾选</span>
|
|
1245
|
+
<span>「—」= 该渠道不支持此事件</span>
|
|
1246
|
+
{source === "client" ? <span>钉钉 / 飞书为服务端推送,需切回「服务端配置」修改</span> : null}
|
|
1247
|
+
</div>
|
|
1248
|
+
</div>
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
876
1252
|
function NotifyView(props) {
|
|
877
1253
|
const snap = useNotify();
|
|
878
1254
|
const voices = useLocalVoices(); // 音色下拉用(本机 speechSynthesis 音色表)
|
|
879
|
-
const [
|
|
1255
|
+
const [voiceClientOn, setVoiceClientOn] = useVoiceClientEnabled(); // 本机播报开关(localStorage)
|
|
1256
|
+
const [source, setSource] = useNotifyConfigSource(); // 服务端 / 本机
|
|
1257
|
+
const [tab, setTab] = useState("events"); // events | channels | robots
|
|
1258
|
+
const [clientCfg, setClientCfg] = useState(() => readClientNotifyConfig());
|
|
1259
|
+
const [serverDraft, setServerDraft] = useState(null);
|
|
880
1260
|
const [saveErr, setSaveErr] = useState("");
|
|
881
1261
|
const [testing, setTesting] = useState(false);
|
|
882
1262
|
const [testState, setTestState] = useState(null); // { ok, msg } 钉钉测试结果
|
|
@@ -886,24 +1266,42 @@ function NotifyView(props) {
|
|
|
886
1266
|
const pendingSavesRef = useRef(0); // 进行中的保存(轮询回包不覆盖乐观值)
|
|
887
1267
|
const editingWebhookRef = useRef(false); // 钉钉 Webhook 输入中(轮询不覆盖草稿)
|
|
888
1268
|
const editingFeishuRef = useRef(false); // 飞书 Webhook 输入中(轮询不覆盖草稿)
|
|
889
|
-
|
|
1269
|
+
const serverCfg = snap.status && snap.status.config;
|
|
1270
|
+
// 服务端配置(含保存回包)同步到本地编辑态;保存中/输入中不覆盖
|
|
890
1271
|
useEffect(() => {
|
|
891
|
-
|
|
892
|
-
if (!c) return;
|
|
1272
|
+
if (!serverCfg) return;
|
|
893
1273
|
if (pendingSavesRef.current > 0 || editingWebhookRef.current || editingFeishuRef.current) return;
|
|
894
|
-
if (
|
|
895
|
-
cfgRef.current =
|
|
896
|
-
|
|
1274
|
+
if (serverCfg !== cfgRef.current) {
|
|
1275
|
+
cfgRef.current = serverCfg;
|
|
1276
|
+
setServerDraft(Object.assign({}, serverCfg));
|
|
897
1277
|
}
|
|
898
1278
|
}, [snap.status]);
|
|
1279
|
+
// 切到本机配置时,第一次从服务端配置复制一份作为起点
|
|
1280
|
+
useEffect(() => {
|
|
1281
|
+
if (source !== "client" || clientCfg || !serverCfg) return;
|
|
1282
|
+
const clone = pickClientConfig(serverCfg);
|
|
1283
|
+
writeClientNotifyConfig(clone);
|
|
1284
|
+
setClientCfg(clone);
|
|
1285
|
+
}, [source, clientCfg, serverCfg]);
|
|
899
1286
|
// 面板打开时兜底拉一次(浮层通常已在轮询)
|
|
900
1287
|
useEffect(() => {
|
|
901
1288
|
if (!notifyStore.snap.status) notifyStore.refresh();
|
|
902
1289
|
}, []);
|
|
903
|
-
|
|
1290
|
+
const cfg = source === "client"
|
|
1291
|
+
? (clientCfg ? mergeClientNotifyConfig(serverCfg, clientCfg) : null)
|
|
1292
|
+
: (serverDraft || serverCfg);
|
|
1293
|
+
// 乐观更新 + 立即持久化:服务端模式写 settings,本机模式只写 localStorage。
|
|
904
1294
|
const patch = (p) => {
|
|
905
1295
|
setSaveErr("");
|
|
906
|
-
|
|
1296
|
+
if (source === "client") {
|
|
1297
|
+
const base = clientCfg || (serverCfg ? pickClientConfig(serverCfg) : {});
|
|
1298
|
+
const next = Object.assign({}, base, p);
|
|
1299
|
+
writeClientNotifyConfig(next);
|
|
1300
|
+
setClientCfg(next);
|
|
1301
|
+
notifyStore.touch();
|
|
1302
|
+
return Promise.resolve();
|
|
1303
|
+
}
|
|
1304
|
+
setServerDraft(Object.assign({}, cfg, p));
|
|
907
1305
|
pendingSavesRef.current++;
|
|
908
1306
|
return rpcCall("config", p)
|
|
909
1307
|
.then((d) => {
|
|
@@ -915,6 +1313,22 @@ function NotifyView(props) {
|
|
|
915
1313
|
setSaveErr("保存失败:" + ((e && e.message) || String(e)));
|
|
916
1314
|
});
|
|
917
1315
|
};
|
|
1316
|
+
const chooseSource = (next) => {
|
|
1317
|
+
if (next === "client" && !clientCfg && serverCfg) {
|
|
1318
|
+
const clone = pickClientConfig(serverCfg);
|
|
1319
|
+
writeClientNotifyConfig(clone);
|
|
1320
|
+
setClientCfg(clone);
|
|
1321
|
+
}
|
|
1322
|
+
setSource(next);
|
|
1323
|
+
notifyStore.touch();
|
|
1324
|
+
};
|
|
1325
|
+
const copyFromServer = () => {
|
|
1326
|
+
if (!serverCfg) return;
|
|
1327
|
+
const clone = pickClientConfig(serverCfg);
|
|
1328
|
+
writeClientNotifyConfig(clone);
|
|
1329
|
+
setClientCfg(clone);
|
|
1330
|
+
notifyStore.touch();
|
|
1331
|
+
};
|
|
918
1332
|
// Webhook 草稿保存:清编辑态后立即对齐一次(同步 effect 在编辑期被跳过)
|
|
919
1333
|
const saveWebhook = async () => {
|
|
920
1334
|
if (!editingWebhookRef.current) return;
|
|
@@ -924,7 +1338,7 @@ function NotifyView(props) {
|
|
|
924
1338
|
const c = notifyStore.snap.status && notifyStore.snap.status.config;
|
|
925
1339
|
if (c && c !== cfgRef.current) {
|
|
926
1340
|
cfgRef.current = c;
|
|
927
|
-
|
|
1341
|
+
setServerDraft(Object.assign({}, c));
|
|
928
1342
|
}
|
|
929
1343
|
};
|
|
930
1344
|
const saveFeishuWebhook = async () => {
|
|
@@ -935,7 +1349,7 @@ function NotifyView(props) {
|
|
|
935
1349
|
const c = notifyStore.snap.status && notifyStore.snap.status.config;
|
|
936
1350
|
if (c && c !== cfgRef.current) {
|
|
937
1351
|
cfgRef.current = c;
|
|
938
|
-
|
|
1352
|
+
setServerDraft(Object.assign({}, c));
|
|
939
1353
|
}
|
|
940
1354
|
};
|
|
941
1355
|
// 钉钉测试:草稿未保存先保存,再发测试消息
|
|
@@ -950,7 +1364,7 @@ function NotifyView(props) {
|
|
|
950
1364
|
notifyStore.applyConfig(d && d.config);
|
|
951
1365
|
editingWebhookRef.current = false;
|
|
952
1366
|
cfgRef.current = (d && d.config) || cfgRef.current;
|
|
953
|
-
|
|
1367
|
+
setServerDraft(Object.assign({}, cfg, { dingtalkWebhook: hook }));
|
|
954
1368
|
}
|
|
955
1369
|
const r = await rpcCall("test");
|
|
956
1370
|
setTestState(r && r.sent
|
|
@@ -974,7 +1388,7 @@ function NotifyView(props) {
|
|
|
974
1388
|
notifyStore.applyConfig(d && d.config);
|
|
975
1389
|
editingFeishuRef.current = false;
|
|
976
1390
|
cfgRef.current = (d && d.config) || cfgRef.current;
|
|
977
|
-
|
|
1391
|
+
setServerDraft(Object.assign({}, cfg, { feishuWebhook: hook }));
|
|
978
1392
|
}
|
|
979
1393
|
const r = await rpcCall("test", { target: "feishu" });
|
|
980
1394
|
setFeishuTestState(r && r.sent
|
|
@@ -1012,313 +1426,356 @@ function NotifyView(props) {
|
|
|
1012
1426
|
rows.push(<div key="load" className="dknt-note">{snap.error ? "状态不可用:" + snap.error : (snap.loading ? "正在拉取通知状态…" : "等待通知状态")}</div>);
|
|
1013
1427
|
} else {
|
|
1014
1428
|
rows.push(
|
|
1015
|
-
<div key="
|
|
1016
|
-
<div className="dknt-
|
|
1017
|
-
<span className="dknt-
|
|
1018
|
-
<span className="dknt-
|
|
1429
|
+
<div key="source" className="dknt-source">
|
|
1430
|
+
<div className="dknt-source-info">
|
|
1431
|
+
<span className="dknt-source-title">配置来源</span>
|
|
1432
|
+
<span className="dknt-source-sub">{source === "server"
|
|
1433
|
+
? "设置保存在服务端,所有浏览器/设备共享"
|
|
1434
|
+
: "设置只保存在当前浏览器,不影响其他人"}</span>
|
|
1019
1435
|
</div>
|
|
1020
|
-
<div className="dknt-
|
|
1021
|
-
<
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
{cfg.notifyCards !== false ? "开" : "关"}
|
|
1026
|
-
</button>
|
|
1027
|
-
<span className="dknt-row-sub">右侧通知卡片总开关:关闭后下面各类卡片(完成 / 异常 / 需确认 / 卡住)一律不弹,已挂着的一并收起;语音播报、提示音、系统通知、钉钉飞书推送不受影响</span>
|
|
1028
|
-
</div>
|
|
1029
|
-
<div className="dknt-row">
|
|
1030
|
-
<span className="dknt-row-label">完成通知</span>
|
|
1031
|
-
<button type="button" className={"dknt-miniswitch" + (cfg.notifyOnComplete ? " on" : "")}
|
|
1032
|
-
onClick={() => patch({ notifyOnComplete: !cfg.notifyOnComplete })}>
|
|
1033
|
-
{cfg.notifyOnComplete ? "开" : "关"}
|
|
1034
|
-
</button>
|
|
1035
|
-
<span className="dknt-row-sub">任务正常完成时通知</span>
|
|
1036
|
-
</div>
|
|
1037
|
-
<div className="dknt-row">
|
|
1038
|
-
<span className="dknt-row-label">异常通知</span>
|
|
1039
|
-
<button type="button" className={"dknt-miniswitch" + (cfg.notifyOnError ? " on" : "")}
|
|
1040
|
-
onClick={() => patch({ notifyOnError: !cfg.notifyOnError })}>
|
|
1041
|
-
{cfg.notifyOnError ? "开" : "关"}
|
|
1042
|
-
</button>
|
|
1043
|
-
<span className="dknt-row-sub">出错 / 中止 / 达输出上限时通知</span>
|
|
1044
|
-
</div>
|
|
1045
|
-
<div className="dknt-row">
|
|
1046
|
-
<span className="dknt-row-label">需确认提醒</span>
|
|
1047
|
-
<button type="button" className={"dknt-miniswitch" + (cfg.notifyOnConfirm !== false ? " on" : "")}
|
|
1048
|
-
onClick={() => patch({ notifyOnConfirm: cfg.notifyOnConfirm === false })}>
|
|
1049
|
-
{cfg.notifyOnConfirm !== false ? "开" : "关"}
|
|
1050
|
-
</button>
|
|
1051
|
-
<span className="dknt-row-sub">工具等待你批准时弹卡片并响确认音;审批处理完自动收起,没处理完就一直显示(点卡片直达会话)</span>
|
|
1052
|
-
</div>
|
|
1053
|
-
<div className="dknt-row">
|
|
1054
|
-
<span className="dknt-row-label">卡住提醒</span>
|
|
1055
|
-
<button type="button" className={"dknt-miniswitch" + (cfg.notifyOnStuck !== false ? " on" : "")}
|
|
1056
|
-
onClick={() => patch({ notifyOnStuck: cfg.notifyOnStuck === false })}>
|
|
1057
|
-
{cfg.notifyOnStuck !== false ? "开" : "关"}
|
|
1058
|
-
</button>
|
|
1059
|
-
<span className="dknt-row-sub">编程阶段 18 秒没有动静时弹卡片,写明是主任务还是子任务、停滞了多久、最后在执行什么;恢复活动 / 任务结束自动收起,同一停点只弹一次(语音播报走「语音播报」区的卡住开关,互不干扰)</span>
|
|
1060
|
-
</div>
|
|
1436
|
+
<div className="dknt-seg" role="group" aria-label="通知配置来源">
|
|
1437
|
+
<button type="button" className={"dknt-seg-btn" + (source === "server" ? " on" : "")}
|
|
1438
|
+
aria-pressed={source === "server"} onClick={() => chooseSource("server")}>服务端配置</button>
|
|
1439
|
+
<button type="button" className={"dknt-seg-btn" + (source === "client" ? " on" : "")}
|
|
1440
|
+
aria-pressed={source === "client"} onClick={() => chooseSource("client")}>本机配置</button>
|
|
1061
1441
|
</div>
|
|
1442
|
+
{source === "client" ? <button type="button" className="dknt-btn" onClick={copyFromServer}>从服务端复制</button> : null}
|
|
1062
1443
|
</div>,
|
|
1063
|
-
<div key="
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
<
|
|
1070
|
-
|
|
1071
|
-
{
|
|
1072
|
-
|
|
1073
|
-
className={"dknt-miniswitch" + (cardTone(cfg) === v ? " on" : "")}
|
|
1074
|
-
style={{ marginRight: 6 }}
|
|
1075
|
-
title={"右侧通知卡片正文按" + label + "档显示"}
|
|
1076
|
-
onClick={() => patch({ cardMode: v })}>{label}</button>
|
|
1077
|
-
))}
|
|
1078
|
-
<span className="dknt-row-sub">右侧卡片正文写多少(与各类型开关、语音播报互不影响):简洁=任务与耗时 / 谁卡住;适中=加模型、起止时间、停滞时长;详细=全部(回合步骤、Token、摘要或错误、最后动作)</span>
|
|
1079
|
-
</div>
|
|
1080
|
-
<div className="dknt-row">
|
|
1081
|
-
<span className="dknt-row-label">卡片收纳</span>
|
|
1082
|
-
<span className="dknt-row-sub">所有卡片都不超时自动消失:结束卡片没看过就一直挂着(后台跑完的切回来也在),点卡片直达对应会话,或点进该会话,卡片自动收起;需确认卡片挂到审批处理完;卡住卡片挂到恢复活动。3 张及以上自动叠放成标题条,鼠标移上去整体展开;要整体不弹看上面「页内卡片」总开关</span>
|
|
1083
|
-
</div>
|
|
1084
|
-
<div className="dknt-row">
|
|
1085
|
-
<span className="dknt-row-label">系统通知</span>
|
|
1086
|
-
<button type="button" className={"dknt-miniswitch" + (cfg.systemNotify ? " on" : "")}
|
|
1087
|
-
onClick={() => enableSystemNotify(!cfg.systemNotify)}>
|
|
1088
|
-
{cfg.systemNotify ? "开" : "关"}
|
|
1089
|
-
</button>
|
|
1090
|
-
<span className="dknt-row-sub">{permNote}</span>
|
|
1091
|
-
</div>
|
|
1092
|
-
<div className="dknt-row">
|
|
1093
|
-
<span className="dknt-row-label">提示音</span>
|
|
1094
|
-
<button type="button" className={"dknt-miniswitch" + (cfg.soundNotify !== false ? " on" : "")}
|
|
1095
|
-
onClick={() => patch({ soundNotify: cfg.soundNotify === false })}>
|
|
1096
|
-
{cfg.soundNotify !== false ? "开" : "关"}
|
|
1097
|
-
</button>
|
|
1098
|
-
<span className="dknt-row-sub">任务结束/需确认时播放(试听为先播完成音、后播异常音)</span>
|
|
1099
|
-
</div>
|
|
1100
|
-
{cfg.soundNotify !== false ? (
|
|
1101
|
-
<div className="dknt-sounds">
|
|
1102
|
-
{Object.keys(SOUND_LIBRARY).map((key) => (
|
|
1103
|
-
<button type="button" key={key}
|
|
1104
|
-
className={"dknt-sound" + (cfg.soundEffect === key ? " on" : "")}
|
|
1105
|
-
onClick={() => patch({ soundEffect: key })}>
|
|
1106
|
-
<span className="dknt-sound-name">
|
|
1107
|
-
{SOUND_LIBRARY[key].name}
|
|
1108
|
-
{cfg.soundEffect === key ? <span className="dknt-sound-cur">✓</span> : null}
|
|
1109
|
-
</span>
|
|
1110
|
-
<span className="dknt-sound-play" role="button" tabIndex={0}
|
|
1111
|
-
title={"试听 " + SOUND_LIBRARY[key].name}
|
|
1112
|
-
onClick={(e) => { e.stopPropagation(); previewSound(key); }}
|
|
1113
|
-
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); previewSound(key); } }}>▶</span>
|
|
1114
|
-
</button>
|
|
1115
|
-
))}
|
|
1116
|
-
</div>
|
|
1117
|
-
) : null}
|
|
1118
|
-
</div>
|
|
1444
|
+
<div key="tabs" className="dknt-tabs" role="tablist" aria-label="任务通知设置">
|
|
1445
|
+
{[
|
|
1446
|
+
["events", "按事件提醒"],
|
|
1447
|
+
["channels", "按渠道设置"],
|
|
1448
|
+
["robots", "群机器人"],
|
|
1449
|
+
].map(([id, label]) => (
|
|
1450
|
+
<button type="button" key={id} role="tab" aria-selected={tab === id}
|
|
1451
|
+
className={"dknt-tab" + (tab === id ? " on" : "")}
|
|
1452
|
+
onClick={() => setTab(id)}>{label}</button>
|
|
1453
|
+
))}
|
|
1119
1454
|
</div>,
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
<
|
|
1125
|
-
<span className=
|
|
1126
|
-
<
|
|
1127
|
-
role="switch" aria-checked={cfg.voiceNotify} aria-label="开关语音播报"
|
|
1128
|
-
title={cfg.voiceNotify ? "关闭语音播报" : "开启语音播报"}
|
|
1129
|
-
onClick={() => patch({ voiceNotify: !cfg.voiceNotify })} />
|
|
1130
|
-
</span>
|
|
1131
|
-
</div>
|
|
1132
|
-
{cfg.voiceNotify ? <div className="dknt-rows-narrow">
|
|
1133
|
-
<div className="dknt-row">
|
|
1134
|
-
<span className="dknt-row-label">播报口径</span>
|
|
1135
|
-
{[["brief", "简洁"], ["medium", "适中"], ["detailed", "详细"]].map(([v, label]) => (
|
|
1136
|
-
<button type="button" key={v}
|
|
1137
|
-
className={"dknt-miniswitch" + (voiceTone(cfg) === v ? " on" : "")}
|
|
1138
|
-
style={{ marginRight: 6 }}
|
|
1139
|
-
title={"语音播报按" + label + "档说"}
|
|
1140
|
-
onClick={() => patch({ voiceMode: v })}>{label}</button>
|
|
1141
|
-
))}
|
|
1142
|
-
<span className="dknt-row-sub">简洁:一句「任务完成 / 任务停止了 / 任务需要授权 / 主任务(或子任务)·标题 卡住了」;适中:点名任务,结束带原因与耗时、卡住带停滞时长;详细:结束带回合统计与回复摘要,需授权带工具名,卡住带最后动作</span>
|
|
1143
|
-
</div>
|
|
1144
|
-
<div className="dknt-row">
|
|
1145
|
-
<span className="dknt-row-label">播报时刻</span>
|
|
1146
|
-
<span className="dknt-row-sub" style={{ minWidth: 0 }}>
|
|
1147
|
-
{[
|
|
1148
|
-
["voiceOnComplete", "完成", cfg.voiceOnComplete !== false],
|
|
1149
|
-
["voiceOnError", "停止", cfg.voiceOnError !== false],
|
|
1150
|
-
["voiceOnConfirm", "需授权", cfg.voiceOnConfirm !== false],
|
|
1151
|
-
["voiceOnStuck", "卡住", cfg.voiceOnStuck !== false],
|
|
1152
|
-
].map(([key, label, on]) => (
|
|
1153
|
-
<button type="button" key={key} className={"dknt-miniswitch" + (on ? " on" : "")}
|
|
1154
|
-
style={{ marginRight: 6 }}
|
|
1155
|
-
title={key === "voiceOnStuck" ? "编程阶段 18 秒没有动静时播报一次(恢复后可再报)" : ""}
|
|
1156
|
-
onClick={() => patch({ [key]: !on })}>{label}{on ? "·开" : "·关"}</button>
|
|
1157
|
-
))}
|
|
1158
|
-
</span>
|
|
1159
|
-
</div>
|
|
1160
|
-
<div className="dknt-row">
|
|
1161
|
-
<span className="dknt-row-label">语速</span>
|
|
1162
|
-
<input type="range" min="0.5" max="2" step="0.05" value={String(cfg.voiceRate || 1)}
|
|
1163
|
-
aria-label="播报语速" style={{ width: 120, accentColor: "var(--dk-accent)" }}
|
|
1164
|
-
onChange={(e) => patch({ voiceRate: Number(e.target.value) || 1 })} />
|
|
1165
|
-
<span className="dknt-row-sub">{Number(cfg.voiceRate || 1).toFixed(2)}×(播报比正文朗读略快更利落)</span>
|
|
1455
|
+
);
|
|
1456
|
+
if (tab === "events") {
|
|
1457
|
+
rows.push(
|
|
1458
|
+
<div key="events" className="dknt-sec">
|
|
1459
|
+
<div className="dknt-sec-head">
|
|
1460
|
+
<span className="dknt-sec-title">通知事件</span>
|
|
1461
|
+
<span className="dknt-sec-sub">纵向是通知事件、横向是通知渠道;勾选表示「这个事件用这个渠道提醒」。渠道的总开关和详细设置在同页「按渠道设置」。</span>
|
|
1166
1462
|
</div>
|
|
1167
|
-
<
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1463
|
+
<EventMatrix cfg={cfg} source={source} voiceClientOn={voiceClientOn} patch={patch} />
|
|
1464
|
+
<div className="dknt-note">「需确认 / 需回答」包含工具等待批准与会话提问等待回答两种「停在原地等人」;「卡住」只在编程阶段长时间没有动静时触发,等批准、等回答、命令在跑、正在压缩上下文都不算卡住。</div>
|
|
1465
|
+
</div>
|
|
1466
|
+
);
|
|
1467
|
+
} else if (tab === "channels") {
|
|
1468
|
+
rows.push(
|
|
1469
|
+
<div key="channels" className="dknt-channels">
|
|
1470
|
+
<div className="dknt-channel">
|
|
1471
|
+
<div className="dknt-channel-head">
|
|
1472
|
+
<div className="dknt-channel-title-wrap">
|
|
1473
|
+
<span className="dknt-channel-title">页内卡片</span>
|
|
1474
|
+
<span className="dknt-channel-sub">右侧浮层通知卡;点卡片直达对应会话</span>
|
|
1475
|
+
</div>
|
|
1476
|
+
<button type="button" className={"dock-sw" + (cfg.notifyCards !== false ? " on" : "")}
|
|
1477
|
+
role="switch" aria-checked={cfg.notifyCards !== false} aria-label="开关页内卡片"
|
|
1478
|
+
onClick={() => patch({ notifyCards: cfg.notifyCards === false })} />
|
|
1479
|
+
</div>
|
|
1480
|
+
<div className="dknt-channel-body">
|
|
1481
|
+
<div className="dknt-row">
|
|
1482
|
+
<span className="dknt-row-label">正文口径</span>
|
|
1483
|
+
<ModePicker value={cardTone(cfg)} title="页内卡片正文" onChange={(v) => patch({ cardMode: v })} />
|
|
1484
|
+
<span className="dknt-row-sub">简洁=任务与耗时 / 谁卡住;适中=加模型、起止时间、停滞时长;详细=全部(回合步骤、Token、摘要或错误、最后动作)</span>
|
|
1485
|
+
</div>
|
|
1486
|
+
<div className="dknt-row">
|
|
1487
|
+
<span className="dknt-row-label">卡片收纳</span>
|
|
1488
|
+
<span className="dknt-row-sub">所有卡片都不超时自动消失:结束卡没看过就一直挂着,点卡片或点进会话后收起;需确认卡挂到处理完;卡住卡挂到恢复活动。3 张及以上自动叠放成标题条,鼠标移上去整体展开。</span>
|
|
1489
|
+
</div>
|
|
1490
|
+
<div className="dknt-row">
|
|
1491
|
+
<span className="dknt-row-label">事件范围</span>
|
|
1492
|
+
<span className="dknt-row-sub">在「按事件提醒」的页内卡片列里勾选。</span>
|
|
1493
|
+
</div>
|
|
1494
|
+
</div>
|
|
1173
1495
|
</div>
|
|
1174
|
-
<div className="dknt-
|
|
1175
|
-
<
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
{(
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1496
|
+
<div className="dknt-channel">
|
|
1497
|
+
<div className="dknt-channel-head">
|
|
1498
|
+
<div className="dknt-channel-title-wrap">
|
|
1499
|
+
<span className="dknt-channel-title">提示音</span>
|
|
1500
|
+
<span className="dknt-channel-sub">任务结束 / 需确认时播放,6 种音效可选</span>
|
|
1501
|
+
</div>
|
|
1502
|
+
<button type="button" className={"dock-sw" + (cfg.soundNotify !== false ? " on" : "")}
|
|
1503
|
+
role="switch" aria-checked={cfg.soundNotify !== false} aria-label="开关提示音"
|
|
1504
|
+
onClick={() => patch({ soundNotify: cfg.soundNotify === false })} />
|
|
1505
|
+
</div>
|
|
1506
|
+
{cfg.soundNotify !== false ? (
|
|
1507
|
+
<div className="dknt-channel-body">
|
|
1508
|
+
<div className="dknt-sounds">
|
|
1509
|
+
{Object.keys(SOUND_LIBRARY).map((key) => (
|
|
1510
|
+
<button type="button" key={key}
|
|
1511
|
+
className={"dknt-sound" + (cfg.soundEffect === key ? " on" : "")}
|
|
1512
|
+
onClick={() => patch({ soundEffect: key })}>
|
|
1513
|
+
<span className="dknt-sound-name">
|
|
1514
|
+
{SOUND_LIBRARY[key].name}
|
|
1515
|
+
{cfg.soundEffect === key ? <span className="dknt-sound-cur">✓</span> : null}
|
|
1516
|
+
</span>
|
|
1517
|
+
<span className="dknt-sound-play" role="button" tabIndex={0}
|
|
1518
|
+
title={"试听 " + SOUND_LIBRARY[key].name}
|
|
1519
|
+
onClick={(e) => { e.stopPropagation(); previewSound(key); }}
|
|
1520
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); previewSound(key); } }}>▶</span>
|
|
1521
|
+
</button>
|
|
1522
|
+
))}
|
|
1523
|
+
</div>
|
|
1524
|
+
<div className="dknt-row">
|
|
1525
|
+
<span className="dknt-row-label">事件范围</span>
|
|
1526
|
+
<span className="dknt-row-sub">在「按事件提醒」的提示音列里勾选(支持完成 / 异常 / 需确认;卡住没有提示音)。</span>
|
|
1527
|
+
</div>
|
|
1528
|
+
</div>
|
|
1529
|
+
) : <div className="dknt-channel-off">未开启——任务事件不播放提示音。</div>}
|
|
1204
1530
|
</div>
|
|
1205
|
-
<div className="dknt-
|
|
1206
|
-
<
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1531
|
+
<div className="dknt-channel">
|
|
1532
|
+
<div className="dknt-channel-head">
|
|
1533
|
+
<div className="dknt-channel-title-wrap">
|
|
1534
|
+
<span className="dknt-channel-title">系统通知</span>
|
|
1535
|
+
<span className="dknt-channel-sub">浏览器系统通知,仅页面在后台时推送</span>
|
|
1536
|
+
</div>
|
|
1537
|
+
<button type="button" className={"dock-sw" + (cfg.systemNotify ? " on" : "")}
|
|
1538
|
+
role="switch" aria-checked={cfg.systemNotify} aria-label="开关系统通知"
|
|
1539
|
+
onClick={() => enableSystemNotify(!cfg.systemNotify)} />
|
|
1540
|
+
</div>
|
|
1541
|
+
<div className="dknt-channel-body">
|
|
1542
|
+
<div className="dknt-row">
|
|
1543
|
+
<span className="dknt-row-label">权限</span>
|
|
1544
|
+
<span className="dknt-row-sub">{permNote}</span>
|
|
1545
|
+
</div>
|
|
1546
|
+
<div className="dknt-row">
|
|
1547
|
+
<span className="dknt-row-label">事件范围</span>
|
|
1548
|
+
<span className="dknt-row-sub">在「按事件提醒」的系统通知列里勾选(支持完成 / 异常 / 需确认;卡住没有系统通知)。</span>
|
|
1549
|
+
</div>
|
|
1550
|
+
</div>
|
|
1220
1551
|
</div>
|
|
1221
|
-
<div className="dknt-
|
|
1222
|
-
<
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
<
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1552
|
+
<div className="dknt-channel">
|
|
1553
|
+
<div className="dknt-channel-head">
|
|
1554
|
+
<div className="dknt-channel-title-wrap">
|
|
1555
|
+
<span className="dknt-channel-title">语音播报</span>
|
|
1556
|
+
<span className="dknt-channel-sub">用浏览器语音(TTS)说一句话;服务端总开关 + 本机开关都开才播报</span>
|
|
1557
|
+
</div>
|
|
1558
|
+
<span className="dknt-sec-sw">
|
|
1559
|
+
<span className={"dknt-sec-swlabel" + (cfg.voiceNotify ? " on" : "")}>{cfg.voiceNotify ? "服务端:已开启" : "服务端:已关闭"}</span>
|
|
1560
|
+
<button type="button" className={"dock-sw" + (cfg.voiceNotify ? " on" : "")}
|
|
1561
|
+
role="switch" aria-checked={cfg.voiceNotify} aria-label="开关语音播报(服务端总开关)"
|
|
1562
|
+
disabled={source === "client"}
|
|
1563
|
+
title={source === "client" ? "服务端总开关需切换到「服务端配置」修改" : (cfg.voiceNotify ? "关闭语音播报(服务端总开关)" : "开启语音播报(服务端总开关)")}
|
|
1564
|
+
onClick={() => { if (source !== "client") patch({ voiceNotify: !cfg.voiceNotify }); }} />
|
|
1565
|
+
</span>
|
|
1566
|
+
</div>
|
|
1567
|
+
<div className="dknt-channel-body">
|
|
1568
|
+
<div className="dknt-row">
|
|
1569
|
+
<span className="dknt-row-label">本机播报</span>
|
|
1570
|
+
<button type="button" className={"dknt-miniswitch" + (voiceClientOn ? " on" : "")}
|
|
1571
|
+
role="switch" aria-checked={voiceClientOn} aria-label="开关本机语音播报"
|
|
1572
|
+
title={voiceClientOn ? "关闭本机语音播报" : "开启本机语音播报"}
|
|
1573
|
+
onClick={() => setVoiceClientOn(!voiceClientOn)}>{voiceClientOn ? "开" : "关"}</button>
|
|
1574
|
+
<span className="dknt-row-sub">{voiceClientOn
|
|
1575
|
+
? "本浏览器会播报(设置保存在当前浏览器;服务端总开关关闭时不生效)"
|
|
1576
|
+
: "本浏览器不播报(设置保存在当前浏览器,不影响其他浏览器)"}</span>
|
|
1577
|
+
</div>
|
|
1578
|
+
{cfg.voiceNotify ? (
|
|
1579
|
+
<>
|
|
1580
|
+
<div className="dknt-row">
|
|
1581
|
+
<span className="dknt-row-label">播报口径</span>
|
|
1582
|
+
<ModePicker value={voiceTone(cfg)} title="语音播报" onChange={(v) => patch({ voiceMode: v })} />
|
|
1583
|
+
<span className="dknt-row-sub">简洁:一句「任务完成 / 任务停止了 / 任务需要授权 / 主任务(或子任务)·标题 卡住了」;适中:点名任务,结束带原因与耗时、卡住带停滞时长;详细:结束带回合统计与回复摘要,需授权带工具名,卡住带最后动作。</span>
|
|
1584
|
+
</div>
|
|
1585
|
+
<div className="dknt-row">
|
|
1586
|
+
<span className="dknt-row-label">播报事件</span>
|
|
1587
|
+
<span className="dknt-row-sub">在「按事件提醒」的语音播报列里勾选(支持完成 / 异常 / 需确认 / 卡住)。</span>
|
|
1588
|
+
</div>
|
|
1589
|
+
<div className="dknt-row">
|
|
1590
|
+
<span className="dknt-row-label">语速</span>
|
|
1591
|
+
<input type="range" min="0.5" max="2" step="0.05" value={String(cfg.voiceRate || 1)}
|
|
1592
|
+
aria-label="播报语速" style={{ width: 120, accentColor: "var(--dk-accent)" }}
|
|
1593
|
+
onChange={(e) => patch({ voiceRate: Number(e.target.value) || 1 })} />
|
|
1594
|
+
<span className="dknt-row-sub">{Number(cfg.voiceRate || 1).toFixed(2)}×(播报比正文朗读略快更利落)</span>
|
|
1595
|
+
</div>
|
|
1596
|
+
<div className="dknt-row">
|
|
1597
|
+
<span className="dknt-row-label">音量</span>
|
|
1598
|
+
<input type="range" min="0" max="1" step="0.05" value={String(cfg.voiceVolume == null ? 1 : cfg.voiceVolume)}
|
|
1599
|
+
aria-label="播报音量" style={{ width: 120, accentColor: "var(--dk-accent)" }}
|
|
1600
|
+
onChange={(e) => patch({ voiceVolume: Number(e.target.value) })} />
|
|
1601
|
+
<span className="dknt-row-sub">{Math.round((cfg.voiceVolume == null ? 1 : cfg.voiceVolume) * 100)}%</span>
|
|
1602
|
+
</div>
|
|
1603
|
+
<div className="dknt-row">
|
|
1604
|
+
<span className="dknt-row-label">音色</span>
|
|
1605
|
+
<select className="dknt-input" style={{ maxWidth: 260 }} value={cfg.voiceURI || ""}
|
|
1606
|
+
aria-label="播报音色"
|
|
1607
|
+
onChange={(e) => patch({ voiceURI: e.target.value })}>
|
|
1608
|
+
<option value="">自动(按语言挑)</option>
|
|
1609
|
+
{(() => {
|
|
1610
|
+
// 与【语音朗读】同款分组:中文在前,其余按语言名排序,长列表也能找
|
|
1611
|
+
const zh = [], other = [];
|
|
1612
|
+
for (const v of voices) {
|
|
1613
|
+
if (!v || !v.voiceURI) continue;
|
|
1614
|
+
if (/^zh/i.test(String(v.lang || ""))) zh.push(v); else other.push(v);
|
|
1615
|
+
}
|
|
1616
|
+
const groups = [];
|
|
1617
|
+
if (zh.length) groups.push(["中文音色", zh]);
|
|
1618
|
+
if (other.length) groups.push(["其他音色", other]);
|
|
1619
|
+
return groups.map(([gname, vs]) => (
|
|
1620
|
+
<optgroup key={gname} label={gname}>
|
|
1621
|
+
{vs.map((v) => (
|
|
1622
|
+
<option key={v.voiceURI} value={v.voiceURI}>
|
|
1623
|
+
{(v.name || v.voiceURI) + "(" + (v.lang || "?") + ")"}
|
|
1624
|
+
</option>
|
|
1625
|
+
))}
|
|
1626
|
+
</optgroup>
|
|
1627
|
+
));
|
|
1628
|
+
})()}
|
|
1629
|
+
</select>
|
|
1630
|
+
<span className="dknt-row-sub">{voices.length
|
|
1631
|
+
? "音色是本机浏览器给的,换设备可能要重选;本机找不到所选音色时自动按语言回落"
|
|
1632
|
+
: "本机暂未读到音色(系统语音未装/未启用),将按语言自动挑"}</span>
|
|
1633
|
+
</div>
|
|
1634
|
+
<div className="dknt-row">
|
|
1635
|
+
<span className="dknt-row-label">试听</span>
|
|
1636
|
+
<button type="button" className="dknt-btn" disabled={!voiceSupported()}
|
|
1637
|
+
onClick={() => {
|
|
1638
|
+
const preview = Object.assign({}, cfg, { voiceNotify: true });
|
|
1639
|
+
speakVoice(endBroadcastText(true, { title: "示例任务", duration: 125000, turns: 3, steps: 9, toolCalls: 4, lastText: "好的,三份文档都已更新完毕。" }, null, preview), preview);
|
|
1640
|
+
}}>播一句</button>
|
|
1641
|
+
<button type="button" className="dknt-btn" disabled={!voiceSupported()}
|
|
1642
|
+
onClick={() => {
|
|
1643
|
+
const preview = Object.assign({}, cfg, { voiceNotify: true });
|
|
1644
|
+
speakVoice(confirmBroadcastText({ title: "示例任务" }, { toolName: "pwsh" }, preview), preview);
|
|
1645
|
+
}}>需授权</button>
|
|
1646
|
+
<span className="dknt-row-sub">{voiceSupported()
|
|
1647
|
+
? "浏览器语音合成(与【语音朗读】同一套声音,独立开关;打断式——播报信时正在朗读的正文会让位)"
|
|
1648
|
+
: "本浏览器不支持语音合成(speechSynthesis 不可用)"}</span>
|
|
1649
|
+
</div>
|
|
1650
|
+
<div className="dknt-row">
|
|
1651
|
+
<span className="dknt-row-label">语言</span>
|
|
1652
|
+
<select className="dknt-input" style={{ maxWidth: 180 }} value={cfg.voiceLang || ""}
|
|
1653
|
+
aria-label="播报语言"
|
|
1654
|
+
onChange={(e) => patch({ voiceLang: e.target.value })}>
|
|
1655
|
+
<option value="">自动(中文优先)</option>
|
|
1656
|
+
{["zh-CN", "zh-TW", "en-US", "en-GB", "ja-JP", "ko-KR"].map((l) => (
|
|
1657
|
+
<option key={l} value={l}>{l}</option>
|
|
1658
|
+
))}
|
|
1659
|
+
</select>
|
|
1660
|
+
<span className="dknt-row-sub">未指定音色时按语言自动挑;已选音色的语言会覆盖这里</span>
|
|
1661
|
+
</div>
|
|
1662
|
+
</>
|
|
1663
|
+
) : <div className="dknt-channel-off">服务端总开关未开启——任务事件不播报语音;上面的「本机播报」只决定本浏览器是否出声。</div>}
|
|
1664
|
+
</div>
|
|
1232
1665
|
</div>
|
|
1233
|
-
</div> : <div className="dknt-note">未开启——任务事件不播报语音(提示音、系统通知不受影响)。</div>}
|
|
1234
|
-
</div>,
|
|
1235
|
-
<div key="dingtalk" className="dknt-sec">
|
|
1236
|
-
<div className="dknt-sec-head">
|
|
1237
|
-
<span className="dknt-sec-title">钉钉推送</span>
|
|
1238
|
-
<span className="dknt-sec-sub">任务结束推送到钉钉群机器人(宿主直发,浏览器关着也能推;事件跟随上方完成/异常开关)</span>
|
|
1239
|
-
<span className="dknt-sec-sw">
|
|
1240
|
-
<span className={"dknt-sec-swlabel" + (cfg.dingtalkEnabled ? " on" : "")}>{cfg.dingtalkEnabled ? "已开启" : "已关闭"}</span>
|
|
1241
|
-
<button type="button" className={"dock-sw" + (cfg.dingtalkEnabled ? " on" : "")}
|
|
1242
|
-
role="switch" aria-checked={cfg.dingtalkEnabled} aria-label="开关钉钉推送"
|
|
1243
|
-
title={cfg.dingtalkEnabled ? "关闭钉钉推送" : "开启钉钉推送"}
|
|
1244
|
-
onClick={() => patch({ dingtalkEnabled: !cfg.dingtalkEnabled })} />
|
|
1245
|
-
</span>
|
|
1246
1666
|
</div>
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1667
|
+
);
|
|
1668
|
+
} else {
|
|
1669
|
+
rows.push(
|
|
1670
|
+
<div key="robots" className="dknt-channels">
|
|
1671
|
+
{source === "client" ? <div className="dknt-note dknt-note-box">群机器人推送由服务端执行,始终使用服务端配置;请切换到「服务端配置」修改。</div> : null}
|
|
1672
|
+
<div className="dknt-channel">
|
|
1673
|
+
<div className="dknt-channel-head">
|
|
1674
|
+
<div className="dknt-channel-title-wrap">
|
|
1675
|
+
<span className="dknt-channel-title">钉钉推送</span>
|
|
1676
|
+
<span className="dknt-channel-sub">任务结束推送到钉钉群机器人(宿主直发,浏览器关着也能推)</span>
|
|
1677
|
+
</div>
|
|
1678
|
+
<span className="dknt-sec-sw">
|
|
1679
|
+
<span className={"dknt-sec-swlabel" + (cfg.dingtalkEnabled ? " on" : "")}>{cfg.dingtalkEnabled ? "已开启" : "已关闭"}</span>
|
|
1680
|
+
<button type="button" className={"dock-sw" + (cfg.dingtalkEnabled ? " on" : "")}
|
|
1681
|
+
role="switch" aria-checked={cfg.dingtalkEnabled} aria-label="开关钉钉推送"
|
|
1682
|
+
disabled={source === "client"}
|
|
1683
|
+
onClick={() => { if (source !== "client") patch({ dingtalkEnabled: !cfg.dingtalkEnabled }); }} />
|
|
1684
|
+
</span>
|
|
1685
|
+
</div>
|
|
1686
|
+
{source === "client" ? <div className="dknt-channel-off">本机配置模式不修改群机器人;切回服务端配置后可编辑 Webhook。</div>
|
|
1687
|
+
: !cfg.dingtalkEnabled ? <div className="dknt-channel-off">未开启——任务结束不推送钉钉。</div> : (
|
|
1688
|
+
<div className="dknt-channel-body">
|
|
1689
|
+
<div className="dknt-row">
|
|
1690
|
+
<span className="dknt-row-label">消息口径</span>
|
|
1691
|
+
<ModePicker value={cfg.pushMode || "detailed"} title="钉钉消息" onChange={(v) => patch({ pushMode: v })} />
|
|
1692
|
+
<span className="dknt-row-sub">简洁=任务与结果;适中=加模型/耗时;详细=再加回合、Token 与摘要。</span>
|
|
1693
|
+
</div>
|
|
1694
|
+
<div className="dknt-row dknt-row-webhook">
|
|
1695
|
+
<span className="dknt-row-label">Webhook</span>
|
|
1696
|
+
<input type="text" className="dknt-input" spellCheck={false}
|
|
1697
|
+
value={cfg.dingtalkWebhook || ""}
|
|
1698
|
+
placeholder="https://oapi.dingtalk.com/robot/send?access_token=…"
|
|
1699
|
+
onChange={(e) => {
|
|
1700
|
+
editingWebhookRef.current = true;
|
|
1701
|
+
setServerDraft(Object.assign({}, cfg, { dingtalkWebhook: e.target.value }));
|
|
1702
|
+
}} />
|
|
1703
|
+
<button type="button" className="dknt-btn" disabled={!editingWebhookRef.current}
|
|
1704
|
+
onClick={saveWebhook}>
|
|
1705
|
+
{editingWebhookRef.current ? "保存" : "已保存"}
|
|
1706
|
+
</button>
|
|
1707
|
+
</div>
|
|
1708
|
+
<div className="dknt-row">
|
|
1709
|
+
<span className="dknt-row-label">连通测试</span>
|
|
1710
|
+
<button type="button" className="dknt-btn" disabled={testing} onClick={runDingtalkTest}>
|
|
1711
|
+
{testing ? "发送中…" : "发送测试消息"}
|
|
1712
|
+
</button>
|
|
1713
|
+
{testState ? <span className={"dknt-row-sub" + (testState.ok ? " dknt-ok" : " dknt-err")}>
|
|
1714
|
+
{testState.ok ? "✓ " : "✗ "}{testState.msg}
|
|
1715
|
+
</span> : <span className="dknt-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
|
|
1716
|
+
</div>
|
|
1717
|
+
<div className="dknt-note">机器人创建:钉钉群 → 设置 → 智能群助手 → 添加机器人 → 自定义(Webhook),安全设置选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
|
|
1718
|
+
</div>
|
|
1719
|
+
)}
|
|
1261
1720
|
</div>
|
|
1262
|
-
<div className="dknt-
|
|
1263
|
-
<
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1721
|
+
<div className="dknt-channel">
|
|
1722
|
+
<div className="dknt-channel-head">
|
|
1723
|
+
<div className="dknt-channel-title-wrap">
|
|
1724
|
+
<span className="dknt-channel-title">飞书推送</span>
|
|
1725
|
+
<span className="dknt-channel-sub">任务结束以卡片形式推送到飞书群机器人(宿主直发)</span>
|
|
1726
|
+
</div>
|
|
1727
|
+
<span className="dknt-sec-sw">
|
|
1728
|
+
<span className={"dknt-sec-swlabel" + (cfg.feishuEnabled ? " on" : "")}>{cfg.feishuEnabled ? "已开启" : "已关闭"}</span>
|
|
1729
|
+
<button type="button" className={"dock-sw" + (cfg.feishuEnabled ? " on" : "")}
|
|
1730
|
+
role="switch" aria-checked={cfg.feishuEnabled} aria-label="开关飞书推送"
|
|
1731
|
+
disabled={source === "client"}
|
|
1732
|
+
onClick={() => { if (source !== "client") patch({ feishuEnabled: !cfg.feishuEnabled }); }} />
|
|
1733
|
+
</span>
|
|
1734
|
+
</div>
|
|
1735
|
+
{source === "client" ? <div className="dknt-channel-off">本机配置模式不修改群机器人;切回服务端配置后可编辑 Webhook。</div>
|
|
1736
|
+
: !cfg.feishuEnabled ? <div className="dknt-channel-off">未开启——任务结束不推送飞书。</div> : (
|
|
1737
|
+
<div className="dknt-channel-body">
|
|
1738
|
+
<div className="dknt-row">
|
|
1739
|
+
<span className="dknt-row-label">消息口径</span>
|
|
1740
|
+
<ModePicker value={cfg.pushMode || "detailed"} title="飞书消息" onChange={(v) => patch({ pushMode: v })} />
|
|
1741
|
+
<span className="dknt-row-sub">简洁=任务与结果;适中=加模型/耗时;详细=再加回合、Token 与摘要。</span>
|
|
1742
|
+
</div>
|
|
1743
|
+
<div className="dknt-row dknt-row-webhook">
|
|
1744
|
+
<span className="dknt-row-label">Webhook</span>
|
|
1745
|
+
<input type="text" className="dknt-input" spellCheck={false}
|
|
1746
|
+
value={cfg.feishuWebhook || ""}
|
|
1747
|
+
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/…"
|
|
1748
|
+
onChange={(e) => {
|
|
1749
|
+
editingFeishuRef.current = true;
|
|
1750
|
+
setServerDraft(Object.assign({}, cfg, { feishuWebhook: e.target.value }));
|
|
1751
|
+
}} />
|
|
1752
|
+
<button type="button" className="dknt-btn" disabled={!editingFeishuRef.current}
|
|
1753
|
+
onClick={saveFeishuWebhook}>
|
|
1754
|
+
{editingFeishuRef.current ? "保存" : "已保存"}
|
|
1755
|
+
</button>
|
|
1756
|
+
</div>
|
|
1757
|
+
<div className="dknt-row">
|
|
1758
|
+
<span className="dknt-row-label">连通测试</span>
|
|
1759
|
+
<button type="button" className="dknt-btn" disabled={feishuTesting} onClick={runFeishuTest}>
|
|
1760
|
+
{feishuTesting ? "发送中…" : "发送测试消息"}
|
|
1761
|
+
</button>
|
|
1762
|
+
{feishuTestState ? <span className={"dknt-row-sub" + (feishuTestState.ok ? " dknt-ok" : " dknt-err")}>
|
|
1763
|
+
{feishuTestState.ok ? "✓ " : "✗ "}{feishuTestState.msg}
|
|
1764
|
+
</span> : <span className="dknt-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
|
|
1765
|
+
</div>
|
|
1766
|
+
<div className="dknt-note">机器人创建:飞书群 → 设置 → 群机器人 → 添加机器人 → 自定义机器人(获取 Webhook 地址);安全设置如选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
|
|
1767
|
+
</div>
|
|
1768
|
+
)}
|
|
1270
1769
|
</div>
|
|
1271
|
-
<div className="dknt-note">机器人创建:钉钉群 → 设置 → 智能群助手 → 添加机器人 → 自定义(Webhook),
|
|
1272
|
-
安全设置选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
|
|
1273
|
-
</div> : <div className="dknt-note">未开启——任务结束不推送钉钉。</div>}
|
|
1274
|
-
</div>,
|
|
1275
|
-
<div key="feishu" className="dknt-sec">
|
|
1276
|
-
<div className="dknt-sec-head">
|
|
1277
|
-
<span className="dknt-sec-title">飞书推送</span>
|
|
1278
|
-
<span className="dknt-sec-sub">任务结束推送到飞书群机器人(宿主直发,浏览器关着也能推;事件跟随上方完成/异常开关)</span>
|
|
1279
|
-
<span className="dknt-sec-sw">
|
|
1280
|
-
<span className={"dknt-sec-swlabel" + (cfg.feishuEnabled ? " on" : "")}>{cfg.feishuEnabled ? "已开启" : "已关闭"}</span>
|
|
1281
|
-
<button type="button" className={"dock-sw" + (cfg.feishuEnabled ? " on" : "")}
|
|
1282
|
-
role="switch" aria-checked={cfg.feishuEnabled} aria-label="开关飞书推送"
|
|
1283
|
-
title={cfg.feishuEnabled ? "关闭飞书推送" : "开启飞书推送"}
|
|
1284
|
-
onClick={() => patch({ feishuEnabled: !cfg.feishuEnabled })} />
|
|
1285
|
-
</span>
|
|
1286
1770
|
</div>
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
<span className="dknt-row-label">Webhook</span>
|
|
1290
|
-
<input type="text" className="dknt-input" spellCheck={false}
|
|
1291
|
-
value={cfg.feishuWebhook || ""}
|
|
1292
|
-
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/…"
|
|
1293
|
-
onChange={(e) => {
|
|
1294
|
-
editingFeishuRef.current = true;
|
|
1295
|
-
setCfg(Object.assign({}, cfg, { feishuWebhook: e.target.value }));
|
|
1296
|
-
}} />
|
|
1297
|
-
<button type="button" className="dknt-btn" disabled={!editingFeishuRef.current}
|
|
1298
|
-
onClick={saveFeishuWebhook}>
|
|
1299
|
-
{editingFeishuRef.current ? "保存" : "已保存"}
|
|
1300
|
-
</button>
|
|
1301
|
-
</div>
|
|
1302
|
-
<div className="dknt-row">
|
|
1303
|
-
<span className="dknt-row-label">连通测试</span>
|
|
1304
|
-
<button type="button" className="dknt-btn" disabled={feishuTesting} onClick={runFeishuTest}>
|
|
1305
|
-
{feishuTesting ? "发送中…" : "发送测试消息"}
|
|
1306
|
-
</button>
|
|
1307
|
-
{feishuTestState ? <span className={"dknt-row-sub" + (feishuTestState.ok ? " dknt-ok" : " dknt-err")}>
|
|
1308
|
-
{feishuTestState.ok ? "✓ " : "✗ "}{feishuTestState.msg}
|
|
1309
|
-
</span> : <span className="dknt-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
|
|
1310
|
-
</div>
|
|
1311
|
-
<div className="dknt-note">机器人创建:飞书群 → 设置 → 群机器人 → 添加机器人 → 自定义机器人(获取 Webhook 地址);
|
|
1312
|
-
安全设置如选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
|
|
1313
|
-
</div> : <div className="dknt-note">未开启——任务结束不推送飞书。</div>}
|
|
1314
|
-
</div>
|
|
1315
|
-
);
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1316
1773
|
}
|
|
1317
1774
|
if (saveErr) rows.push(<div key="err" className="dknt-note dknt-err">{saveErr}</div>);
|
|
1318
|
-
// 任务运行状态(进行中 / 最近完成 /
|
|
1775
|
+
// 任务运行状态(进行中 / 最近完成 / 等人介入)已独立成左侧「运行状态」菜单项,这里只留一句指路
|
|
1319
1776
|
rows.push(
|
|
1320
1777
|
<div key="hint" className="dknt-note">
|
|
1321
|
-
|
|
1778
|
+
任务运行状态(进行中任务、等人介入(等批准 / 等回答)与最近完成)已独立成左侧「运行状态」菜单;本页只负责结束与需确认 / 需回答时的提醒。
|
|
1322
1779
|
</div>
|
|
1323
1780
|
);
|
|
1324
1781
|
return <div className="dknt-root">{rows}</div>;
|
|
@@ -1329,11 +1786,19 @@ function NotifyView(props) {
|
|
|
1329
1786
|
// 通知事件 + 提醒方式 + 已开启的推送通道,与面板页开关一一对应。
|
|
1330
1787
|
function NotifyStat(props) {
|
|
1331
1788
|
const snap = useNotify(props && props.ctx);
|
|
1332
|
-
const cfg = snap.status
|
|
1789
|
+
const cfg = snap.status ? effectiveNotifyConfig(snap.status.config) : null;
|
|
1333
1790
|
if (snap.error) return <span className="dknt-err">通知状态不可用(宿主需重启加载通知路由)</span>;
|
|
1334
1791
|
if (!cfg) return <span>等待通知配置…</span>;
|
|
1335
|
-
const
|
|
1336
|
-
const
|
|
1792
|
+
const anyChannel = (event) => MATRIX_COLUMNS.some((column) => eventEnabled(cfg, column.id, event));
|
|
1793
|
+
const events = [
|
|
1794
|
+
anyChannel("complete") ? "完成" : "",
|
|
1795
|
+
anyChannel("error") ? "异常" : "",
|
|
1796
|
+
anyChannel("confirm") ? "需确认/需回答" : "",
|
|
1797
|
+
anyChannel("stuck") ? "卡住" : "",
|
|
1798
|
+
].filter(Boolean);
|
|
1799
|
+
const voiceMode = cfg.voiceMode === "detailed" ? "·详" : cfg.voiceMode === "medium" ? "·适" : "·简";
|
|
1800
|
+
const voice = !cfg.voiceNotify ? "" : (voiceClientEnabled() ? "语音播报" + voiceMode : "语音播报·本机已关");
|
|
1801
|
+
const extras = [cfg.soundNotify !== false ? "提示音" : "", cfg.systemNotify ? "系统通知" : "", voice].filter(Boolean);
|
|
1337
1802
|
const push = [cfg.dingtalkEnabled ? "钉钉" : "", cfg.feishuEnabled ? "飞书" : ""].filter(Boolean);
|
|
1338
1803
|
return <span>{(events.length ? "通知:" + events.join("/") : "通知事件全关")
|
|
1339
1804
|
+ (extras.length ? " · " + extras.join("/") : "")
|
|
@@ -1384,6 +1849,48 @@ const css = [
|
|
|
1384
1849
|
".dknt-row-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
|
|
1385
1850
|
".dknt-miniswitch{cursor:pointer;flex:none;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 12px;font-family:inherit;font-size:11px;line-height:18px;}",
|
|
1386
1851
|
".dknt-miniswitch.on{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
|
|
1852
|
+
// 配置来源与页签
|
|
1853
|
+
".dknt-source{display:flex;align-items:center;gap:10px;flex-wrap:wrap;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:10px 12px;}",
|
|
1854
|
+
".dknt-source-info{display:flex;flex-direction:column;gap:2px;flex:1;min-width:180px;}",
|
|
1855
|
+
".dknt-source-title{font-weight:600;font-size:13px;color:var(--dsw-alias-label-primary);}",
|
|
1856
|
+
".dknt-source-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
|
|
1857
|
+
".dknt-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;overflow:hidden;flex:none;}",
|
|
1858
|
+
".dknt-seg-btn{cursor:pointer;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:12px;padding:5px 12px;}",
|
|
1859
|
+
".dknt-seg-btn + .dknt-seg-btn{border-left:1px solid var(--dsw-alias-border-l2);}",
|
|
1860
|
+
".dknt-seg-btn.on{background:var(--dk-accent-soft);color:var(--dk-accent);font-weight:600;}",
|
|
1861
|
+
".dknt-tabs{display:flex;gap:4px;border-bottom:1px solid var(--dsw-alias-border-l1);padding:0 2px;}",
|
|
1862
|
+
".dknt-tab{cursor:pointer;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:13px;padding:7px 12px;border-bottom:2px solid transparent;margin-bottom:-1px;}",
|
|
1863
|
+
".dknt-tab.on{color:var(--dk-accent);border-bottom-color:var(--dk-accent);font-weight:600;}",
|
|
1864
|
+
// 事件 × 渠道矩阵
|
|
1865
|
+
".dknt-matrix-wrap{overflow-x:auto;}",
|
|
1866
|
+
".dknt-matrix{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden;min-width:620px;}",
|
|
1867
|
+
".dknt-matrix-row{display:grid;grid-template-columns:minmax(150px,1.4fr) repeat(6,minmax(56px,1fr));align-items:center;}",
|
|
1868
|
+
".dknt-matrix-row:nth-child(even){background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 55%,transparent);}",
|
|
1869
|
+
".dknt-matrix-head{background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1);}",
|
|
1870
|
+
".dknt-matrix-col{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);text-align:center;padding:7px 4px;display:flex;flex-direction:column;align-items:center;gap:1px;}",
|
|
1871
|
+
".dknt-matrix-col em{font-style:normal;font-size:10px;font-weight:400;color:var(--dsw-alias-label-tertiary);}",
|
|
1872
|
+
".dknt-matrix-col.on em{color:var(--dsw-alias-state-success-primary);}",
|
|
1873
|
+
".dknt-matrix-event{display:flex;flex-direction:column;gap:1px;padding:8px 10px;font-size:12px;color:var(--dsw-alias-label-primary);}",
|
|
1874
|
+
".dknt-matrix-event small{font-size:10px;color:var(--dsw-alias-label-tertiary);}",
|
|
1875
|
+
".dknt-cell{justify-self:center;cursor:pointer;width:24px;height:24px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:transparent;font-family:inherit;font-size:12px;line-height:1;display:inline-flex;align-items:center;justify-content:center;margin:5px 0;}",
|
|
1876
|
+
".dknt-cell.on{border-color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 14%,transparent);color:var(--dsw-alias-state-success-primary);}",
|
|
1877
|
+
".dknt-cell-na{cursor:default;border-style:dashed;color:var(--dsw-alias-label-tertiary);}",
|
|
1878
|
+
".dknt-cell.disabled{opacity:.4;cursor:not-allowed;}",
|
|
1879
|
+
".dknt-matrix-legend{display:flex;gap:12px;flex-wrap:wrap;padding-top:6px;font-size:10px;color:var(--dsw-alias-label-tertiary);}",
|
|
1880
|
+
".dknt-matrix-legend span{display:inline-flex;align-items:center;gap:4px;}",
|
|
1881
|
+
".dknt-dot{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-label-tertiary);display:inline-block;}",
|
|
1882
|
+
".dknt-dot.on{background:var(--dsw-alias-state-success-primary);}",
|
|
1883
|
+
// 渠道卡片
|
|
1884
|
+
".dknt-channels{display:flex;flex-direction:column;gap:10px;}",
|
|
1885
|
+
".dknt-channel{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;}",
|
|
1886
|
+
".dknt-channel-head{display:flex;align-items:center;gap:10px;padding:10px 12px;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 45%,transparent);border-bottom:1px solid var(--dsw-alias-border-l1);}",
|
|
1887
|
+
".dknt-channel-title-wrap{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;}",
|
|
1888
|
+
".dknt-channel-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);}",
|
|
1889
|
+
".dknt-channel-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
|
|
1890
|
+
".dknt-channel-body{display:flex;flex-direction:column;gap:8px;padding:10px 12px;}",
|
|
1891
|
+
".dknt-channel-off{padding:10px 12px;font-size:12px;color:var(--dsw-alias-label-tertiary);}",
|
|
1892
|
+
".dknt-modes{display:inline-flex;gap:4px;flex:none;}",
|
|
1893
|
+
".dknt-note-box{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1);}",
|
|
1387
1894
|
// 音效选择卡(名称 + 播放键;选中态描边)
|
|
1388
1895
|
".dknt-sounds{display:flex;gap:6px;flex-wrap:wrap;}",
|
|
1389
1896
|
".dknt-sound{flex:1;min-width:104px;display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;font-family:inherit;transition:border-color .15s var(--ds-ease-in-out);}",
|
|
@@ -1407,7 +1914,7 @@ export const feature = {
|
|
|
1407
1914
|
name: "任务通知",
|
|
1408
1915
|
order: 140,
|
|
1409
1916
|
accent: "#f59e0b",
|
|
1410
|
-
description: "
|
|
1917
|
+
description: "任务完成/异常/需确认/需回答/卡住通知:服务端或本机配置、事件×渠道勾选、页内卡片/提示音/系统通知/语音播报/钉钉飞书推送(各自独立开关)",
|
|
1411
1918
|
css,
|
|
1412
1919
|
View: NotifyView,
|
|
1413
1920
|
HomeStat: NotifyStat,
|