dsh-dock 0.20.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.
@@ -201,6 +201,123 @@ function useVoiceClientEnabled() {
201
201
  }, []);
202
202
  return [on, set];
203
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
+ }
204
321
  /** 本机音色表(可能为空:Chrome 首次调用是异步加载,靠 voiceschanged 补)。 */
205
322
  export function localVoices() {
206
323
  const s = synthOf();
@@ -445,6 +562,12 @@ const notifyStore = {
445
562
  this.emit();
446
563
  }
447
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
+ },
448
571
  refresh() {
449
572
  if (this.snap.loading) return Promise.resolve();
450
573
  this.snap = Object.assign({}, this.snap, { loading: true });
@@ -612,7 +735,7 @@ export function NotifyOverlay(props) {
612
735
  // 「页内卡片」总开关(设置面板里关掉 → 右侧所有卡片都不再弹出)。只管卡片:
613
736
  // 语音播报 / 提示音 / 系统通知 / 群推送各有独立开关,与它正交。
614
737
  const st = notifyStore.snap.status;
615
- const cfg = st && st.config;
738
+ const cfg = st ? effectiveNotifyConfig(st.config) : null;
616
739
  if (cfg && cfg.notifyCards === false) return;
617
740
  // 上限 12:结束卡现在常驻到看过才收,4 张容易见底;多了靠叠放收纳而不是丢弃。
618
741
  setToasts((prev) => prev.concat([toast]).slice(-12));
@@ -621,7 +744,7 @@ export function NotifyOverlay(props) {
621
744
  // 总开关中途关闭:已挂着的卡片也一并收掉(否则「设置里关了、右边还挂着」像开关失灵)。
622
745
  // 返回原数组避免空转重渲染。
623
746
  useEffect(() => {
624
- const cfg = snap.status && snap.status.config;
747
+ const cfg = snap.status ? effectiveNotifyConfig(snap.status.config) : null;
625
748
  if (cfg && cfg.notifyCards === false) setToasts((prev) => (prev.length ? [] : prev));
626
749
  }, [snap.status]);
627
750
 
@@ -653,7 +776,7 @@ export function NotifyOverlay(props) {
653
776
  for (const task of prev) {
654
777
  if (currIds.has(task.sessionId)) continue;
655
778
  const record = (st.recent || []).find((r) => r.sessionId === task.sessionId) || null;
656
- handleTaskEnd(task, record, st.config || {});
779
+ handleTaskEnd(task, record, effectiveNotifyConfig(st.config || {}));
657
780
  }
658
781
  }
659
782
  prevActiveRef.current = st.active.slice();
@@ -665,7 +788,7 @@ export function NotifyOverlay(props) {
665
788
  useEffect(() => {
666
789
  const st = snap.status;
667
790
  if (!st || !st.active) return;
668
- const cfg = st.config || {};
791
+ const cfg = effectiveNotifyConfig(st.config || {});
669
792
  const activeIds = new Set(st.active.map((x) => x.sessionId));
670
793
  for (const [sid, seen] of remindedApprovalsRef.current) {
671
794
  if (!activeIds.has(sid)) remindedApprovalsRef.current.delete(sid);
@@ -680,11 +803,11 @@ export function NotifyOverlay(props) {
680
803
  for (const a of fresh) seen.add(a.id);
681
804
  // 语音播报走自己的开关(在卡片开关判定**之前**:关掉卡片提醒不等于关掉耳朵)。
682
805
  // 服务端总开关 + 本机开关都开才出声(团队共用一个实例时各自可静音)。
683
- if (voiceAllowed(cfg) && cfg.voiceOnConfirm !== false) {
806
+ if (voiceAllowed(cfg) && eventEnabled(cfg, "voice", "confirm")) {
684
807
  speakVoice(confirmBroadcastText(task, fresh[0], cfg), cfg);
685
808
  }
686
809
  // 提醒开关在"收到新确认项"时检查,保证开关中途打开对下一项生效
687
- if (cfg.notifyOnConfirm === false) continue;
810
+ if (!eventEnabled(cfg, "card", "confirm")) continue;
688
811
  const toolName = fresh[0].toolName || "";
689
812
  const scene = toolLabel(toolName);
690
813
  // 卡片正文按「卡片口径」三挡裁剪(title 行永远在,卡片标题不受口径影响)
@@ -711,8 +834,8 @@ export function NotifyOverlay(props) {
711
834
  // 下面的对账 effect 据此自动收起卡片(处理完就不该再占着右上角)。
712
835
  approvalKeys: fresh.filter((a) => a && a.id).map((a) => task.sessionId + "|" + a.id),
713
836
  });
714
- if (cfg.soundNotify !== false) playAskSound(cfg.soundEffect);
715
- 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
716
839
  && typeof Notification !== "undefined" && Notification.permission === "granted") {
717
840
  try {
718
841
  new Notification("dsh 任务需要确认", {
@@ -733,7 +856,7 @@ export function NotifyOverlay(props) {
733
856
  useEffect(() => {
734
857
  const st = snap.status;
735
858
  if (!st || !st.active) return;
736
- const cfg = st.config || {};
859
+ const cfg = effectiveNotifyConfig(st.config || {});
737
860
  const activeIds = new Set(st.active.map((x) => x.sessionId));
738
861
  for (const [sid, seen] of remindedQuestionsRef.current) {
739
862
  if (!activeIds.has(sid)) remindedQuestionsRef.current.delete(sid);
@@ -748,11 +871,11 @@ export function NotifyOverlay(props) {
748
871
  for (const q of fresh) seen.add(q.id);
749
872
  // 语音播报走自己的开关(与卡片开关正交:关卡片不等于关掉耳朵)。
750
873
  // 服务端总开关 + 本机开关都开才出声。
751
- if (voiceAllowed(cfg) && cfg.voiceOnConfirm !== false) {
874
+ if (voiceAllowed(cfg) && eventEnabled(cfg, "voice", "confirm")) {
752
875
  speakVoice(questionBroadcastText(task, fresh[0], cfg), cfg);
753
876
  }
754
877
  // 提醒开关在"收到新提问"时检查,保证开关中途打开对下一问生效
755
- if (cfg.notifyOnConfirm === false) continue;
878
+ if (!eventEnabled(cfg, "card", "confirm")) continue;
756
879
  const tone = cardTone(cfg);
757
880
  const head = fresh[0].header || "";
758
881
  const body = fresh[0].question || "";
@@ -773,8 +896,8 @@ export function NotifyOverlay(props) {
773
896
  // 与这批提问绑定:回答落地后宿主把它们移出 questions,下面的对账 effect 据此收起
774
897
  questionKeys: fresh.map((q) => task.sessionId + "|" + q.id),
775
898
  });
776
- if (cfg.soundNotify !== false) playAskSound(cfg.soundEffect);
777
- if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
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
778
901
  && typeof Notification !== "undefined" && Notification.permission === "granted") {
779
902
  try {
780
903
  new Notification("dsh 任务需要你回答", {
@@ -850,7 +973,7 @@ export function NotifyOverlay(props) {
850
973
  useEffect(() => {
851
974
  const st = snap.status;
852
975
  if (!st || !st.active) return;
853
- const cfg = st.config || {};
976
+ const cfg = effectiveNotifyConfig(st.config || {});
854
977
  const now = st.now || Date.now();
855
978
  const activeIds = new Set(st.active.map((x) => x.sessionId));
856
979
  for (const sid of Array.from(stuckSeenRef.current.keys())) {
@@ -885,11 +1008,11 @@ export function NotifyOverlay(props) {
885
1008
  if (prev === lastAct) continue; // 同一停点已报过
886
1009
  stuckSeenRef.current.set(task.sessionId, lastAct);
887
1010
  // —— 语音:说清主/子任务、标题、停滞时长与最后动作(服务端总开关 + 本机开关)——
888
- if (voiceAllowed(cfg) && cfg.voiceOnStuck !== false) {
1011
+ if (voiceAllowed(cfg) && eventEnabled(cfg, "voice", "stuck")) {
889
1012
  speakVoice(stuckBroadcastText(task, Object.assign({}, cfg, { stuckMs: stuckMs })), cfg);
890
1013
  }
891
1014
  // —— 卡片:只受「卡住提醒」与「页内卡片」总开关控制(总开关在 pushToast 里把关)——
892
- if (cfg.notifyOnStuck === false) continue;
1015
+ if (!eventEnabled(cfg, "card", "stuck")) continue;
893
1016
  const label = task.isSubtask ? "子任务" : "主任务";
894
1017
  const title = task.title || "(无标题)";
895
1018
  const tool = task.lastToolName || "";
@@ -945,57 +1068,60 @@ export function NotifyOverlay(props) {
945
1068
  const reason = (record && record.endReason) || "";
946
1069
  const success = isSuccessReason(reason);
947
1070
  const info = endInfo(reason);
948
- const wanted = success ? cfg.notifyOnComplete : cfg.notifyOnError;
949
- // 语音播报先走自己的开关(与卡片开关正交:关卡片不关耳朵)。
950
- // 服务端总开关 + 本机开关都开才出声;播报音量归 0 / 浏览器不支持时静默跳过即可。
951
- if (voiceAllowed(cfg)
952
- && (success ? cfg.voiceOnComplete !== false : cfg.voiceOnError !== false)) {
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)) {
953
1077
  speakVoice(endBroadcastText(success, record, task, cfg), cfg);
954
1078
  }
955
- if (wanted === false) return;
956
1079
  const models = record && record.models && record.models.length ? record.models.join(", ")
957
1080
  : (task.models && task.models.length ? task.models.join(", ") : "未知模型");
958
1081
  const provider = (record && record.provider) || task.provider || "";
959
1082
  const startTs = (record && record.startTime) || task.startTime;
960
1083
  const endTs = (record && record.endTime) || Date.now();
961
- // 卡片正文按「卡片口径」三挡裁剪(cfg.cardMode,缺省 detailed =历史内容):
962
- // 简洁=任务+耗时;适中=加模型与起止时间;详细=再加回合/步骤、Token、摘要或错误。
963
- const tone = cardTone(cfg);
964
1084
  const durText = fmtDur((record && record.duration) || (endTs - startTs));
965
- const lines = ["任务:" + ((record && record.title) || task.title || "(无标题)")];
966
- if (tone !== "brief") lines.push("模型:" + models + (provider ? "(" + provider + ")" : ""));
967
- lines.push(tone === "brief"
968
- ? "耗时:" + durText
969
- : "耗时:" + durText + "(" + fmtTime(startTs) + " → " + fmtTime(endTs) + ")");
970
- if (tone === "detailed") {
971
- lines.push("回合 " + ((record && record.turns) || task.turns || 0)
972
- + " · 步骤 " + ((record && record.steps) || task.steps || 0)
973
- + (((record && record.toolCalls) || task.toolCalls) ? " · 工具 " + ((record && record.toolCalls) || task.toolCalls) + " 次" : ""));
974
- lines.push("Token:输入 " + fmtNum((record && record.inputTokens) || task.inputTokens)
975
- + " / 输出 " + fmtNum((record && record.outputTokens) || task.outputTokens));
976
- if (record && record.lastText) lines.push("摘要:" + truncate(record.lastText, 140));
977
- if (!success && record && record.errorMessage) lines.push("错误:" + truncate(record.errorMessage, 120));
978
- }
979
1085
  const title = success ? "任务完成" : "任务" + info.label;
980
- pushToast({
981
- id: ++toastSeqRef.current,
982
- kind: success ? "success" : (info.cls === "err" ? "error" : "warn"),
983
- title,
984
- body: lines.join("\n"),
985
- // 绑定会话:点卡片直达该会话;用户点进这个会话(侧栏选中)也视为
986
- // 看到了结果,巡检 effect 据此自动收起卡片。没看过就一直挂着。
987
- // sessionId 是权威标识(跳转/对账都用它),标题只作旧宿主回退。
988
- sessionId: task.sessionId || "",
989
- sessionTitle: (record && record.title) || task.title || "",
990
- });
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
+ }
991
1117
  // 提示音:任务结束时播放(完成/异常配套音);与系统通知独立开关
992
- if (cfg.soundNotify !== false) playDoneSound(success, cfg.soundEffect);
1118
+ if (soundWanted) playDoneSound(success, cfg.soundEffect);
993
1119
  // 系统通知:仅页面处于后台时推送,避免前台重复打扰
994
- if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
1120
+ if (systemWanted && typeof document !== "undefined" && document.hidden
995
1121
  && typeof Notification !== "undefined" && Notification.permission === "granted") {
996
1122
  try {
997
1123
  new Notification("dsh " + title, {
998
- body: ((record && record.title) || task.title || "(无标题)") + " · " + fmtDur((record && record.duration) || (endTs - startTs)),
1124
+ body: showTitle + " · " + durText,
999
1125
  tag: "dsh-dock-notify-" + task.sessionId,
1000
1126
  });
1001
1127
  } catch { /* 系统通知失败不影响页面内通知 */ }
@@ -1021,11 +1147,116 @@ export function NotifyOverlay(props) {
1021
1147
  }
1022
1148
 
1023
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
+
1024
1252
  function NotifyView(props) {
1025
1253
  const snap = useNotify();
1026
1254
  const voices = useLocalVoices(); // 音色下拉用(本机 speechSynthesis 音色表)
1027
1255
  const [voiceClientOn, setVoiceClientOn] = useVoiceClientEnabled(); // 本机播报开关(localStorage)
1028
- const [cfg, setCfg] = useState(null); // null = 尚未加载
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);
1029
1260
  const [saveErr, setSaveErr] = useState("");
1030
1261
  const [testing, setTesting] = useState(false);
1031
1262
  const [testState, setTestState] = useState(null); // { ok, msg } 钉钉测试结果
@@ -1035,24 +1266,42 @@ function NotifyView(props) {
1035
1266
  const pendingSavesRef = useRef(0); // 进行中的保存(轮询回包不覆盖乐观值)
1036
1267
  const editingWebhookRef = useRef(false); // 钉钉 Webhook 输入中(轮询不覆盖草稿)
1037
1268
  const editingFeishuRef = useRef(false); // 飞书 Webhook 输入中(轮询不覆盖草稿)
1038
- // 拉到新配置(含保存回包)后同步本地编辑态;保存中/输入中不覆盖
1269
+ const serverCfg = snap.status && snap.status.config;
1270
+ // 服务端配置(含保存回包)同步到本地编辑态;保存中/输入中不覆盖
1039
1271
  useEffect(() => {
1040
- const c = snap.status && snap.status.config;
1041
- if (!c) return;
1272
+ if (!serverCfg) return;
1042
1273
  if (pendingSavesRef.current > 0 || editingWebhookRef.current || editingFeishuRef.current) return;
1043
- if (c !== cfgRef.current) {
1044
- cfgRef.current = c;
1045
- setCfg(Object.assign({}, c));
1274
+ if (serverCfg !== cfgRef.current) {
1275
+ cfgRef.current = serverCfg;
1276
+ setServerDraft(Object.assign({}, serverCfg));
1046
1277
  }
1047
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]);
1048
1286
  // 面板打开时兜底拉一次(浮层通常已在轮询)
1049
1287
  useEffect(() => {
1050
1288
  if (!notifyStore.snap.status) notifyStore.refresh();
1051
1289
  }, []);
1052
- // 乐观更新 + 立即持久化(每个开关独立保存)
1290
+ const cfg = source === "client"
1291
+ ? (clientCfg ? mergeClientNotifyConfig(serverCfg, clientCfg) : null)
1292
+ : (serverDraft || serverCfg);
1293
+ // 乐观更新 + 立即持久化:服务端模式写 settings,本机模式只写 localStorage。
1053
1294
  const patch = (p) => {
1054
1295
  setSaveErr("");
1055
- setCfg(Object.assign({}, cfg, p));
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));
1056
1305
  pendingSavesRef.current++;
1057
1306
  return rpcCall("config", p)
1058
1307
  .then((d) => {
@@ -1064,6 +1313,22 @@ function NotifyView(props) {
1064
1313
  setSaveErr("保存失败:" + ((e && e.message) || String(e)));
1065
1314
  });
1066
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
+ };
1067
1332
  // Webhook 草稿保存:清编辑态后立即对齐一次(同步 effect 在编辑期被跳过)
1068
1333
  const saveWebhook = async () => {
1069
1334
  if (!editingWebhookRef.current) return;
@@ -1073,7 +1338,7 @@ function NotifyView(props) {
1073
1338
  const c = notifyStore.snap.status && notifyStore.snap.status.config;
1074
1339
  if (c && c !== cfgRef.current) {
1075
1340
  cfgRef.current = c;
1076
- setCfg(Object.assign({}, c));
1341
+ setServerDraft(Object.assign({}, c));
1077
1342
  }
1078
1343
  };
1079
1344
  const saveFeishuWebhook = async () => {
@@ -1084,7 +1349,7 @@ function NotifyView(props) {
1084
1349
  const c = notifyStore.snap.status && notifyStore.snap.status.config;
1085
1350
  if (c && c !== cfgRef.current) {
1086
1351
  cfgRef.current = c;
1087
- setCfg(Object.assign({}, c));
1352
+ setServerDraft(Object.assign({}, c));
1088
1353
  }
1089
1354
  };
1090
1355
  // 钉钉测试:草稿未保存先保存,再发测试消息
@@ -1099,7 +1364,7 @@ function NotifyView(props) {
1099
1364
  notifyStore.applyConfig(d && d.config);
1100
1365
  editingWebhookRef.current = false;
1101
1366
  cfgRef.current = (d && d.config) || cfgRef.current;
1102
- setCfg(Object.assign({}, cfg, { dingtalkWebhook: hook }));
1367
+ setServerDraft(Object.assign({}, cfg, { dingtalkWebhook: hook }));
1103
1368
  }
1104
1369
  const r = await rpcCall("test");
1105
1370
  setTestState(r && r.sent
@@ -1123,7 +1388,7 @@ function NotifyView(props) {
1123
1388
  notifyStore.applyConfig(d && d.config);
1124
1389
  editingFeishuRef.current = false;
1125
1390
  cfgRef.current = (d && d.config) || cfgRef.current;
1126
- setCfg(Object.assign({}, cfg, { feishuWebhook: hook }));
1391
+ setServerDraft(Object.assign({}, cfg, { feishuWebhook: hook }));
1127
1392
  }
1128
1393
  const r = await rpcCall("test", { target: "feishu" });
1129
1394
  setFeishuTestState(r && r.sent
@@ -1161,317 +1426,350 @@ function NotifyView(props) {
1161
1426
  rows.push(<div key="load" className="dknt-note">{snap.error ? "状态不可用:" + snap.error : (snap.loading ? "正在拉取通知状态…" : "等待通知状态")}</div>);
1162
1427
  } else {
1163
1428
  rows.push(
1164
- <div key="events" className="dknt-sec">
1165
- <div className="dknt-sec-head">
1166
- <span className="dknt-sec-title">通知事件</span>
1167
- <span className="dknt-sec-sub">哪些时刻提醒你;与【任务动画】的动效互不依赖</span>
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>
1168
1435
  </div>
1169
- <div className="dknt-rows-narrow">
1170
- <div className="dknt-row">
1171
- <span className="dknt-row-label">页内卡片</span>
1172
- <button type="button" className={"dknt-miniswitch" + (cfg.notifyCards !== false ? " on" : "")}
1173
- onClick={() => patch({ notifyCards: cfg.notifyCards === false })}>
1174
- {cfg.notifyCards !== false ? "开" : "关"}
1175
- </button>
1176
- <span className="dknt-row-sub">右侧通知卡片总开关:关闭后下面各类卡片(完成 / 异常 / 需确认 / 卡住)一律不弹,已挂着的一并收起;语音播报、提示音、系统通知、钉钉飞书推送不受影响</span>
1177
- </div>
1178
- <div className="dknt-row">
1179
- <span className="dknt-row-label">完成通知</span>
1180
- <button type="button" className={"dknt-miniswitch" + (cfg.notifyOnComplete ? " on" : "")}
1181
- onClick={() => patch({ notifyOnComplete: !cfg.notifyOnComplete })}>
1182
- {cfg.notifyOnComplete ? "开" : "关"}
1183
- </button>
1184
- <span className="dknt-row-sub">任务正常完成时通知</span>
1185
- </div>
1186
- <div className="dknt-row">
1187
- <span className="dknt-row-label">异常通知</span>
1188
- <button type="button" className={"dknt-miniswitch" + (cfg.notifyOnError ? " on" : "")}
1189
- onClick={() => patch({ notifyOnError: !cfg.notifyOnError })}>
1190
- {cfg.notifyOnError ? "开" : "关"}
1191
- </button>
1192
- <span className="dknt-row-sub">出错 / 中止 / 达输出上限时通知</span>
1193
- </div>
1194
- <div className="dknt-row">
1195
- <span className="dknt-row-label">需确认提醒</span>
1196
- <button type="button" className={"dknt-miniswitch" + (cfg.notifyOnConfirm !== false ? " on" : "")}
1197
- onClick={() => patch({ notifyOnConfirm: cfg.notifyOnConfirm === false })}>
1198
- {cfg.notifyOnConfirm !== false ? "开" : "关"}
1199
- </button>
1200
- <span className="dknt-row-sub">工具等你批准、或会话提问等你选答案时弹卡片并响确认音;处理完自动收起,没处理完就一直显示(点卡片直达会话)。这两种「停在原地等人」都不算卡住</span>
1201
- </div>
1202
- <div className="dknt-row">
1203
- <span className="dknt-row-label">卡住提醒</span>
1204
- <button type="button" className={"dknt-miniswitch" + (cfg.notifyOnStuck !== false ? " on" : "")}
1205
- onClick={() => patch({ notifyOnStuck: cfg.notifyOnStuck === false })}>
1206
- {cfg.notifyOnStuck !== false ? "开" : "关"}
1207
- </button>
1208
- <span className="dknt-row-sub">编程阶段长时间没有动静时弹卡片,写明是主任务还是子任务、停滞了多久、最后在执行什么;恢复活动 / 任务结束自动收起,同一停点只弹一次。等批准、等回答、命令还在跑、正在压缩上下文的会话都不算卡住(那些都是在干活,别重复轰炸)(语音播报走「语音播报」区的卡住开关,互不干扰)</span>
1209
- </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>
1210
1441
  </div>
1442
+ {source === "client" ? <button type="button" className="dknt-btn" onClick={copyFromServer}>从服务端复制</button> : null}
1211
1443
  </div>,
1212
- <div key="style" className="dknt-sec">
1213
- <div className="dknt-sec-head">
1214
- <span className="dknt-sec-title">提醒方式</span>
1215
- <span className="dknt-sec-sub">页内卡片、提示音、浏览器系统通知可自由组合</span>
1216
- </div>
1217
- <div className="dknt-rows-narrow">
1218
- <div className="dknt-row">
1219
- <span className="dknt-row-label">卡片口径</span>
1220
- {[["brief", "简洁"], ["medium", "适中"], ["detailed", "详细"]].map(([v, label]) => (
1221
- <button type="button" key={v}
1222
- className={"dknt-miniswitch" + (cardTone(cfg) === v ? " on" : "")}
1223
- style={{ marginRight: 6 }}
1224
- title={"右侧通知卡片正文按" + label + "档显示"}
1225
- onClick={() => patch({ cardMode: v })}>{label}</button>
1226
- ))}
1227
- <span className="dknt-row-sub">右侧卡片正文写多少(与各类型开关、语音播报互不影响):简洁=任务与耗时 / 谁卡住;适中=加模型、起止时间、停滞时长;详细=全部(回合步骤、Token、摘要或错误、最后动作)</span>
1228
- </div>
1229
- <div className="dknt-row">
1230
- <span className="dknt-row-label">卡片收纳</span>
1231
- <span className="dknt-row-sub">所有卡片都不超时自动消失:结束卡片没看过就一直挂着(后台跑完的切回来也在),点卡片直达对应会话,或点进该会话,卡片自动收起;需确认卡片挂到审批处理完;卡住卡片挂到恢复活动。3 张及以上自动叠放成标题条,鼠标移上去整体展开;要整体不弹看上面「页内卡片」总开关</span>
1232
- </div>
1233
- <div className="dknt-row">
1234
- <span className="dknt-row-label">系统通知</span>
1235
- <button type="button" className={"dknt-miniswitch" + (cfg.systemNotify ? " on" : "")}
1236
- onClick={() => enableSystemNotify(!cfg.systemNotify)}>
1237
- {cfg.systemNotify ? "开" : "关"}
1238
- </button>
1239
- <span className="dknt-row-sub">{permNote}</span>
1240
- </div>
1241
- <div className="dknt-row">
1242
- <span className="dknt-row-label">提示音</span>
1243
- <button type="button" className={"dknt-miniswitch" + (cfg.soundNotify !== false ? " on" : "")}
1244
- onClick={() => patch({ soundNotify: cfg.soundNotify === false })}>
1245
- {cfg.soundNotify !== false ? "开" : "关"}
1246
- </button>
1247
- <span className="dknt-row-sub">任务结束/需确认时播放(试听为先播完成音、后播异常音)</span>
1248
- </div>
1249
- {cfg.soundNotify !== false ? (
1250
- <div className="dknt-sounds">
1251
- {Object.keys(SOUND_LIBRARY).map((key) => (
1252
- <button type="button" key={key}
1253
- className={"dknt-sound" + (cfg.soundEffect === key ? " on" : "")}
1254
- onClick={() => patch({ soundEffect: key })}>
1255
- <span className="dknt-sound-name">
1256
- {SOUND_LIBRARY[key].name}
1257
- {cfg.soundEffect === key ? <span className="dknt-sound-cur">✓</span> : null}
1258
- </span>
1259
- <span className="dknt-sound-play" role="button" tabIndex={0}
1260
- title={"试听 " + SOUND_LIBRARY[key].name}
1261
- onClick={(e) => { e.stopPropagation(); previewSound(key); }}
1262
- onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); previewSound(key); } }}>▶</span>
1263
- </button>
1264
- ))}
1265
- </div>
1266
- ) : null}
1267
- </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
+ ))}
1268
1454
  </div>,
1269
- <div key="voice" className="dknt-sec">
1270
- <div className="dknt-sec-head">
1271
- <span className="dknt-sec-title">语音播报</span>
1272
- <span className="dknt-sec-sub">任务完成 / 停止 / 需要授权 / 需回答 / 卡住时用浏览器语音(TTS)说一句话;服务端总开关(跨浏览器)与本机开关(当前浏览器)都开启才播报</span>
1273
- <span className="dknt-sec-sw">
1274
- <span className={"dknt-sec-swlabel" + (cfg.voiceNotify ? " on" : "")}>{cfg.voiceNotify ? "服务端:已开启" : "服务端:已关闭"}</span>
1275
- <button type="button" className={"dock-sw" + (cfg.voiceNotify ? " on" : "")}
1276
- role="switch" aria-checked={cfg.voiceNotify} aria-label="开关语音播报(服务端总开关)"
1277
- title={cfg.voiceNotify ? "关闭语音播报(服务端总开关)" : "开启语音播报(服务端总开关)"}
1278
- onClick={() => patch({ voiceNotify: !cfg.voiceNotify })} />
1279
- </span>
1280
- </div>
1281
- <div className="dknt-row">
1282
- <span className="dknt-row-label">本机播报</span>
1283
- <button type="button" className={"dknt-miniswitch" + (voiceClientOn ? " on" : "")}
1284
- role="switch" aria-checked={voiceClientOn} aria-label="开关本机语音播报"
1285
- title={voiceClientOn ? "关闭本机语音播报" : "开启本机语音播报"}
1286
- onClick={() => setVoiceClientOn(!voiceClientOn)}>{voiceClientOn ? "开" : "关"}</button>
1287
- <span className="dknt-row-sub">{voiceClientOn
1288
- ? "本浏览器会播报(设置保存在当前浏览器;服务端总开关关闭时不生效)"
1289
- : "本浏览器不播报(设置保存在当前浏览器,不影响其他浏览器)"}</span>
1290
- </div>
1291
- {cfg.voiceNotify ? <div className="dknt-rows-narrow">
1292
- <div className="dknt-row">
1293
- <span className="dknt-row-label">播报口径</span>
1294
- {[["brief", "简洁"], ["medium", "适中"], ["detailed", "详细"]].map(([v, label]) => (
1295
- <button type="button" key={v}
1296
- className={"dknt-miniswitch" + (voiceTone(cfg) === v ? " on" : "")}
1297
- style={{ marginRight: 6 }}
1298
- title={"语音播报按" + label + "档说"}
1299
- onClick={() => patch({ voiceMode: v })}>{label}</button>
1300
- ))}
1301
- <span className="dknt-row-sub">简洁:一句「任务完成 / 任务停止了 / 任务需要授权 / 主任务(或子任务)·标题 卡住了」;适中:点名任务,结束带原因与耗时、卡住带停滞时长;详细:结束带回合统计与回复摘要,需授权带工具名,卡住带最后动作</span>
1302
- </div>
1303
- <div className="dknt-row">
1304
- <span className="dknt-row-label">播报时刻</span>
1305
- <span className="dknt-row-sub" style={{ minWidth: 0 }}>
1306
- {[
1307
- ["voiceOnComplete", "完成", cfg.voiceOnComplete !== false],
1308
- ["voiceOnError", "停止", cfg.voiceOnError !== false],
1309
- ["voiceOnConfirm", "需授权/需回答", cfg.voiceOnConfirm !== false],
1310
- ["voiceOnStuck", "卡住", cfg.voiceOnStuck !== false],
1311
- ].map(([key, label, on]) => (
1312
- <button type="button" key={key} className={"dknt-miniswitch" + (on ? " on" : "")}
1313
- style={{ marginRight: 6 }}
1314
- title={key === "voiceOnStuck" ? "编程阶段长时间没有动静时播报一次(恢复后可再报;命令在跑 / 正在压缩上下文不算)" : ""}
1315
- onClick={() => patch({ [key]: !on })}>{label}{on ? "·开" : "·关"}</button>
1316
- ))}
1317
- </span>
1318
- </div>
1319
- <div className="dknt-row">
1320
- <span className="dknt-row-label">语速</span>
1321
- <input type="range" min="0.5" max="2" step="0.05" value={String(cfg.voiceRate || 1)}
1322
- aria-label="播报语速" style={{ width: 120, accentColor: "var(--dk-accent)" }}
1323
- onChange={(e) => patch({ voiceRate: Number(e.target.value) || 1 })} />
1324
- <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>
1325
1462
  </div>
1326
- <div className="dknt-row">
1327
- <span className="dknt-row-label">音量</span>
1328
- <input type="range" min="0" max="1" step="0.05" value={String(cfg.voiceVolume == null ? 1 : cfg.voiceVolume)}
1329
- aria-label="播报音量" style={{ width: 120, accentColor: "var(--dk-accent)" }}
1330
- onChange={(e) => patch({ voiceVolume: Number(e.target.value) })} />
1331
- <span className="dknt-row-sub">{Math.round((cfg.voiceVolume == null ? 1 : cfg.voiceVolume) * 100)}%</span>
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>
1332
1495
  </div>
1333
- <div className="dknt-row">
1334
- <span className="dknt-row-label">音色</span>
1335
- <select className="dknt-input" style={{ maxWidth: 260 }} value={cfg.voiceURI || ""}
1336
- aria-label="播报音色"
1337
- onChange={(e) => patch({ voiceURI: e.target.value })}>
1338
- <option value="">自动(按语言挑)</option>
1339
- {(() => {
1340
- // 与【语音朗读】同款分组:中文在前,其余按语言名排序,长列表也能找
1341
- const zh = [], other = [];
1342
- for (const v of voices) {
1343
- if (!v || !v.voiceURI) continue;
1344
- if (/^zh/i.test(String(v.lang || ""))) zh.push(v); else other.push(v);
1345
- }
1346
- const groups = [];
1347
- if (zh.length) groups.push(["中文音色", zh]);
1348
- if (other.length) groups.push(["其他音色", other]);
1349
- return groups.map(([gname, vs]) => (
1350
- <optgroup key={gname} label={gname}>
1351
- {vs.map((v) => (
1352
- <option key={v.voiceURI} value={v.voiceURI}>
1353
- {(v.name || v.voiceURI) + "(" + (v.lang || "?") + ")"}
1354
- </option>
1355
- ))}
1356
- </optgroup>
1357
- ));
1358
- })()}
1359
- </select>
1360
- <span className="dknt-row-sub">{voices.length
1361
- ? "音色是本机浏览器给的,换设备可能要重选;本机找不到所选音色时自动按语言回落"
1362
- : "本机暂未读到音色(系统语音未装/未启用),将按语言自动挑"}</span>
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>}
1363
1530
  </div>
1364
- <div className="dknt-row">
1365
- <span className="dknt-row-label">试听</span>
1366
- <button type="button" className="dknt-btn" disabled={!voiceSupported()}
1367
- onClick={() => {
1368
- const preview = Object.assign({}, cfg, { voiceNotify: true });
1369
- speakVoice(endBroadcastText(true, { title: "示例任务", duration: 125000, turns: 3, steps: 9, toolCalls: 4, lastText: "好的,三份文档都已更新完毕。" }, null, preview), preview);
1370
- }}>播一句</button>
1371
- <button type="button" className="dknt-btn" disabled={!voiceSupported()}
1372
- onClick={() => {
1373
- const preview = Object.assign({}, cfg, { voiceNotify: true });
1374
- speakVoice(confirmBroadcastText({ title: "示例任务" }, { toolName: "pwsh" }, preview), preview);
1375
- }}>需授权</button>
1376
- <span className="dknt-row-sub">{voiceSupported()
1377
- ? "浏览器语音合成(与【语音朗读】同一套声音,独立开关;打断式——播报信时正在朗读的正文会让位)"
1378
- : "本浏览器不支持语音合成(speechSynthesis 不可用)"}</span>
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>
1379
1551
  </div>
1380
- <div className="dknt-row">
1381
- <span className="dknt-row-label">语言</span>
1382
- <select className="dknt-input" style={{ maxWidth: 180 }} value={cfg.voiceLang || ""}
1383
- aria-label="播报语言"
1384
- onChange={(e) => patch({ voiceLang: e.target.value })}>
1385
- <option value="">自动(中文优先)</option>
1386
- {["zh-CN", "zh-TW", "en-US", "en-GB", "ja-JP", "ko-KR"].map((l) => (
1387
- <option key={l} value={l}>{l}</option>
1388
- ))}
1389
- </select>
1390
- <span className="dknt-row-sub">未指定音色时按语言自动挑;已选音色的语言会覆盖这里</span>
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>
1391
1665
  </div>
1392
- </div> : <div className="dknt-note">服务端总开关未开启——任务事件不播报语音(提示音、系统通知不受影响);上面的「本机播报」只决定本浏览器是否出声。</div>}
1393
- </div>,
1394
- <div key="dingtalk" className="dknt-sec">
1395
- <div className="dknt-sec-head">
1396
- <span className="dknt-sec-title">钉钉推送</span>
1397
- <span className="dknt-sec-sub">任务结束推送到钉钉群机器人(宿主直发,浏览器关着也能推;事件跟随上方完成/异常开关)</span>
1398
- <span className="dknt-sec-sw">
1399
- <span className={"dknt-sec-swlabel" + (cfg.dingtalkEnabled ? " on" : "")}>{cfg.dingtalkEnabled ? "已开启" : "已关闭"}</span>
1400
- <button type="button" className={"dock-sw" + (cfg.dingtalkEnabled ? " on" : "")}
1401
- role="switch" aria-checked={cfg.dingtalkEnabled} aria-label="开关钉钉推送"
1402
- title={cfg.dingtalkEnabled ? "关闭钉钉推送" : "开启钉钉推送"}
1403
- onClick={() => patch({ dingtalkEnabled: !cfg.dingtalkEnabled })} />
1404
- </span>
1405
1666
  </div>
1406
- {cfg.dingtalkEnabled ? <div className="dknt-rows-narrow">
1407
- <div className="dknt-row dknt-row-webhook">
1408
- <span className="dknt-row-label">Webhook</span>
1409
- <input type="text" className="dknt-input" spellCheck={false}
1410
- value={cfg.dingtalkWebhook || ""}
1411
- placeholder="https://oapi.dingtalk.com/robot/send?access_token=…"
1412
- onChange={(e) => {
1413
- editingWebhookRef.current = true;
1414
- setCfg(Object.assign({}, cfg, { dingtalkWebhook: e.target.value }));
1415
- }} />
1416
- <button type="button" className="dknt-btn" disabled={!editingWebhookRef.current}
1417
- onClick={saveWebhook}>
1418
- {editingWebhookRef.current ? "保存" : "已保存"}
1419
- </button>
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
+ )}
1420
1720
  </div>
1421
- <div className="dknt-row">
1422
- <span className="dknt-row-label">连通测试</span>
1423
- <button type="button" className="dknt-btn" disabled={testing} onClick={runDingtalkTest}>
1424
- {testing ? "发送中…" : "发送测试消息"}
1425
- </button>
1426
- {testState ? <span className={"dknt-row-sub" + (testState.ok ? " dknt-ok" : " dknt-err")}>
1427
- {testState.ok ? "✓ " : "✗ "}{testState.msg}
1428
- </span> : <span className="dknt-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
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
+ )}
1429
1769
  </div>
1430
- <div className="dknt-note">机器人创建:钉钉群 → 设置 → 智能群助手 → 添加机器人 → 自定义(Webhook),
1431
- 安全设置选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
1432
- </div> : <div className="dknt-note">未开启——任务结束不推送钉钉。</div>}
1433
- </div>,
1434
- <div key="feishu" className="dknt-sec">
1435
- <div className="dknt-sec-head">
1436
- <span className="dknt-sec-title">飞书推送</span>
1437
- <span className="dknt-sec-sub">任务结束推送到飞书群机器人(宿主直发,浏览器关着也能推;事件跟随上方完成/异常开关)</span>
1438
- <span className="dknt-sec-sw">
1439
- <span className={"dknt-sec-swlabel" + (cfg.feishuEnabled ? " on" : "")}>{cfg.feishuEnabled ? "已开启" : "已关闭"}</span>
1440
- <button type="button" className={"dock-sw" + (cfg.feishuEnabled ? " on" : "")}
1441
- role="switch" aria-checked={cfg.feishuEnabled} aria-label="开关飞书推送"
1442
- title={cfg.feishuEnabled ? "关闭飞书推送" : "开启飞书推送"}
1443
- onClick={() => patch({ feishuEnabled: !cfg.feishuEnabled })} />
1444
- </span>
1445
1770
  </div>
1446
- {cfg.feishuEnabled ? <div className="dknt-rows-narrow">
1447
- <div className="dknt-row dknt-row-webhook">
1448
- <span className="dknt-row-label">Webhook</span>
1449
- <input type="text" className="dknt-input" spellCheck={false}
1450
- value={cfg.feishuWebhook || ""}
1451
- placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/…"
1452
- onChange={(e) => {
1453
- editingFeishuRef.current = true;
1454
- setCfg(Object.assign({}, cfg, { feishuWebhook: e.target.value }));
1455
- }} />
1456
- <button type="button" className="dknt-btn" disabled={!editingFeishuRef.current}
1457
- onClick={saveFeishuWebhook}>
1458
- {editingFeishuRef.current ? "保存" : "已保存"}
1459
- </button>
1460
- </div>
1461
- <div className="dknt-row">
1462
- <span className="dknt-row-label">连通测试</span>
1463
- <button type="button" className="dknt-btn" disabled={feishuTesting} onClick={runFeishuTest}>
1464
- {feishuTesting ? "发送中…" : "发送测试消息"}
1465
- </button>
1466
- {feishuTestState ? <span className={"dknt-row-sub" + (feishuTestState.ok ? " dknt-ok" : " dknt-err")}>
1467
- {feishuTestState.ok ? "✓ " : "✗ "}{feishuTestState.msg}
1468
- </span> : <span className="dknt-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
1469
- </div>
1470
- <div className="dknt-note">机器人创建:飞书群 → 设置 → 群机器人 → 添加机器人 → 自定义机器人(获取 Webhook 地址);
1471
- 安全设置如选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
1472
- </div> : <div className="dknt-note">未开启——任务结束不推送飞书。</div>}
1473
- </div>
1474
- );
1771
+ );
1772
+ }
1475
1773
  }
1476
1774
  if (saveErr) rows.push(<div key="err" className="dknt-note dknt-err">{saveErr}</div>);
1477
1775
  // 任务运行状态(进行中 / 最近完成 / 等人介入)已独立成左侧「运行状态」菜单项,这里只留一句指路
@@ -1488,10 +1786,16 @@ function NotifyView(props) {
1488
1786
  // 通知事件 + 提醒方式 + 已开启的推送通道,与面板页开关一一对应。
1489
1787
  function NotifyStat(props) {
1490
1788
  const snap = useNotify(props && props.ctx);
1491
- const cfg = snap.status && snap.status.config;
1789
+ const cfg = snap.status ? effectiveNotifyConfig(snap.status.config) : null;
1492
1790
  if (snap.error) return <span className="dknt-err">通知状态不可用(宿主需重启加载通知路由)</span>;
1493
1791
  if (!cfg) return <span>等待通知配置…</span>;
1494
- const events = [cfg.notifyOnComplete ? "完成" : "", cfg.notifyOnError ? "异常" : "", cfg.notifyOnConfirm !== false ? "需确认/需回答" : ""].filter(Boolean);
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);
1495
1799
  const voiceMode = cfg.voiceMode === "detailed" ? "·详" : cfg.voiceMode === "medium" ? "·适" : "·简";
1496
1800
  const voice = !cfg.voiceNotify ? "" : (voiceClientEnabled() ? "语音播报" + voiceMode : "语音播报·本机已关");
1497
1801
  const extras = [cfg.soundNotify !== false ? "提示音" : "", cfg.systemNotify ? "系统通知" : "", voice].filter(Boolean);
@@ -1545,6 +1849,48 @@ const css = [
1545
1849
  ".dknt-row-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
1546
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;}",
1547
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);}",
1548
1894
  // 音效选择卡(名称 + 播放键;选中态描边)
1549
1895
  ".dknt-sounds{display:flex;gap:6px;flex-wrap:wrap;}",
1550
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);}",
@@ -1568,7 +1914,7 @@ export const feature = {
1568
1914
  name: "任务通知",
1569
1915
  order: 140,
1570
1916
  accent: "#f59e0b",
1571
- description: "任务完成/异常/需确认通知:页内卡片、提示音、系统通知、钉钉/飞书群机器人推送(独立开关)",
1917
+ description: "任务完成/异常/需确认/需回答/卡住通知:服务端或本机配置、事件×渠道勾选、页内卡片/提示音/系统通知/语音播报/钉钉飞书推送(各自独立开关)",
1572
1918
  css,
1573
1919
  View: NotifyView,
1574
1920
  HomeStat: NotifyStat,