返回 JoyAI-Echo
useShotGenerationAlerts.ts
根目录 / echo_longvideo / Director_Agent / webui / src / hooks / useShotGenerationAlerts.ts
1 import { useCallback, useEffect, useRef, useState } from "react";
2
3 import type { GenerationAlertVariant } from "@/components/ui/GenerationAlertDialog";
4 import { GENERATION_BUSY_ALERT_ENABLED } from "@/config/features";
5 import type { WorkplaceData } from "@/lib/types";
6
7 export const GENERATION_CONGESTED_MS = 60_000;
8
9 const DONE_STATUSES = new Set(["generated", "review_pass", "approved"]);
10
11 function hasVersionId(shot: { version_id?: string }): boolean {
12 return Boolean(shot.version_id?.trim());
13 }
14
15 function isQueuedSubmitted(shot: {
16 status: string;
17 version_id?: string;
18 }): boolean {
19 return shot.status === "queued" && hasVersionId(shot);
20 }
21
22 function isShotDone(shot: {
23 status: string;
24 video?: { url?: string } | null;
25 has_video?: boolean;
26 }): boolean {
27 if (!DONE_STATUSES.has(shot.status)) return false;
28 return Boolean(shot.video?.url || shot.has_video);
29 }
30
31 export type ShotGenerationAlerts = {
32 congestedOpen: boolean;
33 errorOpen: boolean;
34 /** Which dialog to show when either is open; error takes precedence. */
35 activeVariant: GenerationAlertVariant | null;
36 dismissCongested: () => void;
37 dismissError: () => void;
38 dismissActive: () => void;
39 };
40
41 /**
42 * Watches workplace shots for R2V-submitted queued jobs and generation errors.
43 * - queued + version_id for ≥1min → congested alert (once per queued cycle)
44 * - status === "error" (or workplace.stage === "failed") → error alert
45 */
46 export function useShotGenerationAlerts(
47 workplace: WorkplaceData | null,
48 sessionKey: string | null,
49 ): ShotGenerationAlerts {
50 const [congestedOpen, setCongestedOpen] = useState(false);
51 const [errorOpen, setErrorOpen] = useState(false);
52
53 /** shot_id → local Date.now() when queued+version_id was first seen */
54 const queuedSinceRef = useRef<Map<number, number>>(new Map());
55 /** shot_ids that already showed congested for the current queued cycle */
56 const congestedShownRef = useRef<Set<number>>(new Set());
57 /** shot_ids that already showed error for the current error cycle */
58 const errorShownRef = useRef<Set<number>>(new Set());
59 const stageFailedShownRef = useRef(false);
60 const timersRef = useRef<Map<number, number>>(new Map());
61
62 const clearTimer = useCallback((shotId: number) => {
63 const id = timersRef.current.get(shotId);
64 if (id != null) {
65 window.clearTimeout(id);
66 timersRef.current.delete(shotId);
67 }
68 }, []);
69
70 const clearAllTimers = useCallback(() => {
71 for (const id of timersRef.current.values()) {
72 window.clearTimeout(id);
73 }
74 timersRef.current.clear();
75 }, []);
76
77 const resetAll = useCallback(() => {
78 clearAllTimers();
79 queuedSinceRef.current.clear();
80 congestedShownRef.current.clear();
81 errorShownRef.current.clear();
82 stageFailedShownRef.current = false;
83 setCongestedOpen(false);
84 setErrorOpen(false);
85 }, [clearAllTimers]);
86
87 useEffect(() => {
88 resetAll();
89 }, [sessionKey, resetAll]);
90
91 useEffect(() => {
92 if (!workplace || !sessionKey) return;
93
94 const shots = workplace.shots ?? [];
95 const activeQueuedIds = new Set<number>();
96
97 for (const shot of shots) {
98 const shotId = shot.shot_id;
99
100 if (shot.status === "error") {
101 clearTimer(shotId);
102 queuedSinceRef.current.delete(shotId);
103 congestedShownRef.current.delete(shotId);
104 if (!errorShownRef.current.has(shotId)) {
105 errorShownRef.current.add(shotId);
106 setErrorOpen(true);
107 setCongestedOpen(false);
108 }
109 continue;
110 }
111
112 // Left error state → allow future error alerts for this shot.
113 errorShownRef.current.delete(shotId);
114
115 if (isShotDone(shot) || shot.status !== "queued") {
116 clearTimer(shotId);
117 queuedSinceRef.current.delete(shotId);
118 congestedShownRef.current.delete(shotId);
119 if (isShotDone(shot)) {
120 setCongestedOpen(false);
121 }
122 continue;
123 }
124
125 if (!GENERATION_BUSY_ALERT_ENABLED || !isQueuedSubmitted(shot)) {
126 // queued without version_id yet (e.g. still recaptioning) — wait.
127 continue;
128 }
129
130 activeQueuedIds.add(shotId);
131
132 if (!queuedSinceRef.current.has(shotId)) {
133 queuedSinceRef.current.set(shotId, Date.now());
134 }
135
136 if (congestedShownRef.current.has(shotId)) continue;
137 if (timersRef.current.has(shotId)) continue;
138
139 const since = queuedSinceRef.current.get(shotId)!;
140 const remaining = Math.max(0, GENERATION_CONGESTED_MS - (Date.now() - since));
141
142 const timerId = window.setTimeout(() => {
143 timersRef.current.delete(shotId);
144 // Re-check: only fire if still tracked as queued submitted.
145 if (!queuedSinceRef.current.has(shotId)) return;
146 if (congestedShownRef.current.has(shotId)) return;
147 congestedShownRef.current.add(shotId);
148 setCongestedOpen(true);
149 }, remaining);
150
151 timersRef.current.set(shotId, timerId);
152 }
153
154 // Drop timers for shots no longer in the workplace list.
155 for (const shotId of [...timersRef.current.keys()]) {
156 if (!activeQueuedIds.has(shotId) && !shots.some((s) => s.shot_id === shotId)) {
157 clearTimer(shotId);
158 queuedSinceRef.current.delete(shotId);
159 }
160 }
161
162 if (workplace.stage === "failed") {
163 if (!stageFailedShownRef.current) {
164 stageFailedShownRef.current = true;
165 setErrorOpen(true);
166 setCongestedOpen(false);
167 }
168 } else {
169 stageFailedShownRef.current = false;
170 }
171 }, [workplace, sessionKey, clearTimer]);
172
173 useEffect(() => {
174 return () => {
175 clearAllTimers();
176 };
177 }, [clearAllTimers]);
178
179 const dismissCongested = useCallback(() => {
180 setCongestedOpen(false);
181 }, []);
182
183 const dismissError = useCallback(() => {
184 setErrorOpen(false);
185 }, []);
186
187 const dismissActive = useCallback(() => {
188 if (errorOpen) {
189 setErrorOpen(false);
190 return;
191 }
192 setCongestedOpen(false);
193 }, [errorOpen]);
194
195 const activeVariant: GenerationAlertVariant | null = errorOpen
196 ? "error"
197 : congestedOpen
198 ? "congested"
199 : null;
200
201 return {
202 congestedOpen,
203 errorOpen,
204 activeVariant,
205 dismissCongested,
206 dismissError,
207 dismissActive,
208 };
209 }
210
210 lines TYPESCRIPT