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