返回 DeepSeek-Reasonix
runtimeNotifications.ts
根目录 / desktop / frontend / src / lib / runtimeNotifications.ts
1 import { runtimeStateStore, type RuntimeSession } from "./runtimeStateStore";
2 import { attentionChimeEventKey, clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent, type AttentionChimeEvent } from "./sound";
3 import type { Translator } from "./i18n";
4 import type { ToastContextValue } from "./toast";
5
6 export type NotificationOperation = { event: AttentionChimeEvent & { err?: string } } | { resetTabId?: string };
7 type NotificationPorts = { activeTabId: string | undefined; t: Translator; showToast: ToastContextValue["showToast"] };
8
9 /** One owner deduplicates view events and authoritative background snapshots. */
10 export function createRuntimeNotifications(readPorts: () => NotificationPorts | undefined) {
11 const seen = new Set<string>();
12 // Live requests cannot be evicted with historical replay keys. Replace this
13 // set on each trusted snapshot so resolved requests do not accumulate.
14 let pendingSeen = new Set<string>();
15 const handleAttention = (event: AttentionChimeEvent, source?: RuntimeSession) => {
16 const ports = readPorts();
17 const snapshot = runtimeStateStore.getSnapshot();
18 const turnId = event.ask?.turnId || event.approval?.turnId || event.turnId;
19 const candidates = snapshot?.sessions.filter(item => (!event.hostId || (item.hostId || "local") === event.hostId)
20 && (!turnId || item.state.turnId === turnId)) ?? [];
21 const session = source ?? candidates.find(item => item.tabId === event.tabId)
22 ?? (turnId && candidates.length === 1 ? candidates[0] : undefined);
23 event = { ...event, hostId: session?.hostId || session?.state.hostId || event.hostId };
24 const key = attentionChimeEventKey(event);
25 if (!ports || !key || pendingSeen.has(key) || !shouldPlayAttentionChimeForEvent(event, seen)) return;
26 playAttentionChime();
27 const { activeTabId, t, showToast } = ports;
28 const background = session ? !session.open || session.tabId !== activeTabId : Boolean(event.tabId && event.tabId !== activeTabId);
29 if (!background) return;
30 const topic = session ? snapshot?.topics.find(item => item.scope === session.scope
31 && (session.scope !== "project" || (item.workspaceRoot ?? "") === session.workspaceRoot)
32 && (session.sessionId ? (item.node.session?.sessionId || item.node.remoteSession?.sessionId) === session.sessionId
33 && (item.node.session?.hostId || item.node.remoteSession?.hostId || "local") === (session.hostId || "local")
34 : session.sessionPath && item.node.sessionPath === session.sessionPath
35 && (item.node.remoteSession?.hostId || item.node.session?.hostId || "local") === (session.hostId || "local"))) : undefined;
36 const child = session?.sessionPath ? topic?.node.children?.find(node => node.sessionPath === session.sessionPath) : undefined;
37 const title = child?.label || topic?.node.label || t("runtime.otherConversation");
38 showToast(t(event.kind === "ask_request" ? "runtime.backgroundQuestion" : "runtime.backgroundApproval", { title }), "info", { durationMs: 8000 });
39 };
40 const handleSnapshot = () => {
41 if (runtimeStateStore.getFailed()) return;
42 const nextPending = new Set<string>();
43 const visit = (event: AttentionChimeEvent, session: RuntimeSession) => {
44 event = { ...event, hostId: session.hostId || session.state.hostId };
45 const key = attentionChimeEventKey(event);
46 if (!key || nextPending.has(key)) return;
47 if (session.freshness !== "synced") {
48 // Disconnects retain known prompts but cannot announce unseen ones.
49 if (pendingSeen.has(key)) nextPending.add(key);
50 return;
51 }
52 handleAttention(event, session);
53 nextPending.add(key);
54 };
55 for (const session of runtimeStateStore.getSnapshot()?.sessions ?? []) {
56 if (!session.state.pendingPrompt) continue;
57 for (const prompt of session.state.pendingInteractions ?? []) {
58 const identity = { id: prompt.requestId, turnId: prompt.turnId || session.state.turnId };
59 // Plan/recovery decisions use the same approval card/event path.
60 if (prompt.kind === "ask") visit({ kind: "ask_request", tabId: session.tabId, ask: identity }, session);
61 else if (["approval", "plan", "recovery"].includes(prompt.kind)) {
62 visit({ kind: "approval_request", tabId: session.tabId, approval: identity }, session);
63 }
64 }
65 }
66 pendingSeen = nextPending;
67 };
68 let stop: (() => void) | undefined;
69 return {
70 accept(operation: NotificationOperation) {
71 if (!("event" in operation)) {
72 clearAttentionChimeKeys(seen, operation.resetTabId);
73 clearAttentionChimeKeys(pendingSeen, operation.resetTabId);
74 } else if (operation.event.kind === "turn_done") {
75 if (readPorts() && !operation.event.err) playSuccessChime();
76 } else handleAttention(operation.event);
77 },
78 start() { stop = runtimeStateStore.subscribe(handleSnapshot); handleSnapshot(); },
79 dispose() { stop?.(); },
80 };
81 }
82
82 lines TYPESCRIPT