返回 JoyAI-Echo
useWorkplace.ts
根目录 / echo_longvideo / Director_Agent / webui / src / hooks / useWorkplace.ts
1 import { useCallback, useEffect, useRef, useState } from "react";
2
3 import {
4 acceptAllShots as acceptAllShotsApi,
5 acceptShot,
6 approveMemoryReview as approveMemoryReviewApi,
7 reselectMemoryReview as reselectMemoryReviewApi,
8 selectMemoryReviewFrame as selectMemoryReviewFrameApi,
9 selectMemoryReviewMode as selectMemoryReviewModeApi,
10 ApiError,
11 abortGeneration as abortGenerationApi,
12 confirmStory as confirmStoryApi,
13 deleteShot as deleteShotApi,
14 fetchWorkplace,
15 startGeneration as startGenerationApi,
16 startAutoGenerate as startAutoGenerateApi,
17 generateAll as generateAllApi,
18 generateShot as generateShotApi,
19 setShotContinuousMode as setShotContinuousModeApi,
20 continuousGenerateShot as continuousGenerateShotApi,
21 mergeShotUp as mergeShotUpApi,
22 mockAcceptAllShots,
23 mockDeleteShot,
24 reviseShot,
25 saveShotPrompt as saveShotPromptApi,
26 updateShotDuration as updateShotDurationApi,
27 splitShot as splitShotApi,
28 startMerge as startMergeApi,
29 regenerate as regenerateApi,
30 updateEchoLike as updateEchoLikeApi,
31 recordEchoDownloadPrompt as recordEchoDownloadPromptApi,
32 type SplitShotPayload,
33 } from "@/lib/api";
34 import { webuiChatIdFromKey } from "@/lib/session-key";
35 import type {
36 ConnectionStatus,
37 MemoryAssetUpload,
38 ShotMemoryAssetCreate,
39 MemoryReview,
40 MemorySlotReference,
41 StoryProfile,
42 WorkplaceData,
43 } from "@/lib/types";
44 import { useClient } from "@/providers/ClientProvider";
45
46 const POLL_MS = 4_000;
47
48 const USE_MOCK_DELETE_SHOT = false;
49
50 const USE_MOCK_ACCEPT_ALL = false;
51
52 /** Tracks which async workflow action is in flight for smarter busy clearing. */
53 type WorkflowAction = "confirm_story" | "start_merge";
54
55 function shouldClearWorkflowBusy(
56 startedStage: string | null | undefined,
57 next: WorkplaceData,
58 action: WorkflowAction | null,
59 ): boolean {
60 if (action === "confirm_story") {
61 // confirm-story may land on story_confirmed (01) until shot_count is locked.
62 return (
63 next.stage === "shot_planning" ||
64 next.stage === "story_confirmed" ||
65 (next.shots?.length ?? 0) > 0
66 );
67 }
68 return next.stage !== startedStage;
69 }
70
71 /** Return shape of {@link useWorkplace}; shared by WorkplaceProvider context. */
72 export type UseWorkplaceResult = {
73 workplace: WorkplaceData | null;
74 loading: boolean;
75 error: string | null;
76 mutatingShotId: number | null;
77 workflowBusy: boolean;
78 splitMergeBusy: boolean;
79 acceptAllBusy: boolean;
80 memoryReviewBusy: {
81 shotId: number;
82 action: "approve" | "reselect" | "manual_select" | "select_mode";
83 } | null;
84 memoryWorkspaceBusy: boolean;
85 refresh: () => Promise<void>;
86 accept: (shotId: number) => Promise<void>;
87 acceptAll: () => Promise<void>;
88 approveMemoryReview: (
89 review: MemoryReview,
90 retainedMemoryIds: string[],
91 ) => Promise<void>;
92 reselectMemoryReview: (
93 review: MemoryReview,
94 memoryId?: string,
95 ) => Promise<void>;
96 selectMemoryReviewFrame: (
97 review: MemoryReview,
98 memoryId: string,
99 timestampSec: number,
100 ) => Promise<void>;
101 selectMemoryReviewMode: (
102 review: MemoryReview,
103 mode: "manual" | "vlm",
104 ) => Promise<void>;
105 saveMemoryAsset: (asset: MemoryAssetUpload) => Promise<void>;
106 createShotMemoryAsset: (
107 shotId: number,
108 asset: ShotMemoryAssetCreate,
109 ) => Promise<void>;
110 deleteMemoryAsset: (assetId: string) => Promise<void>;
111 saveShotMemorySlots: (
112 shotId: number,
113 slots: MemorySlotReference[],
114 ) => Promise<void>;
115 revise: (shotId: number, feedback: string) => Promise<void>;
116 confirmStory: (storyMd?: string) => Promise<void>;
117 saveStory: (storyMd: string) => Promise<void>;
118 saveStoryProfile: (profile: StoryProfile) => Promise<void>;
119 startGeneration: () => Promise<void>;
120 startAutoGenerate: () => Promise<void>;
121 abortGeneration: () => Promise<void>;
122 generateAll: () => Promise<void>;
123 startMerge: () => Promise<void>;
124 regenerate: () => Promise<void>;
125 splitShot: (shotId: number, payload: SplitShotPayload) => Promise<void>;
126 mergeShotUp: (shotId: number, mergedText?: string) => Promise<void>;
127 deleteShot: (shotId: number) => Promise<void>;
128 generateOneShot: (
129 shotId: number,
130 referenceImage?: {
131 url: string;
132 name?: string;
133 width?: number;
134 height?: number;
135 } | null,
136 ) => Promise<void>;
137 setContinuousMode: (shotId: number, enabled: boolean) => Promise<void>;
138 continuousGenerateOneShot: (shotId: number) => Promise<void>;
139 saveShotPrompt: (shotId: number, summary: string) => Promise<void>;
140 updateShotDuration: (shotId: number, durationSec: number) => Promise<void>;
141 updateEchoLike: (action: 1 | 2) => Promise<void>;
142 recordEchoDownloadPrompt: () => Promise<void>;
143 promptOverrides: Record<number, string>;
144 };
145
146 export function useWorkplace(sessionKey: string | null): UseWorkplaceResult {
147 const { token, client } = useClient();
148 const tokenRef = useRef(token);
149 tokenRef.current = token;
150 const [workplace, setWorkplace] = useState<WorkplaceData | null>(null);
151 const [loading, setLoading] = useState(false);
152 const [error, setError] = useState<string | null>(null);
153 const [mutatingShotId, setMutatingShotId] = useState<number | null>(null);
154 const [promptOverrides, setPromptOverrides] = useState<
155 Record<number, string>
156 >({});
157 const [workflowBusy, setWorkflowBusy] = useState(false);
158 const [splitMergeBusy, setSplitMergeBusy] = useState(false);
159 const [acceptAllBusy, setAcceptAllBusy] = useState(false);
160 const [memoryReviewBusy, setMemoryReviewBusy] = useState<{
161 shotId: number;
162 action: "approve" | "reselect" | "manual_select" | "select_mode";
163 } | null>(null);
164 const [memoryWorkspaceBusy, setMemoryWorkspaceBusy] = useState(false);
165 const workflowStageRef = useRef<string | null | undefined>(null);
166 const workflowActionRef = useRef<WorkflowAction | null>(null);
167 const prevSessionKeyRef = useRef<string | null>(null);
168 const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(
169 client.status,
170 );
171
172 const refresh = useCallback(async () => {
173 if (!sessionKey) {
174 setWorkplace(null);
175 setError(null);
176 return;
177 }
178 setLoading(true);
179 try {
180 // Read token from ref so auth refresh does not recreate ``refresh`` and
181 // retrigger the session effect (which clears workplace and flashes overlay).
182 const next = await fetchWorkplace(tokenRef.current, sessionKey);
183 setWorkplace(next);
184 setError(null);
185 } catch (err) {
186 setError((err as Error).message);
187 } finally {
188 setLoading(false);
189 }
190 }, [sessionKey]);
191
192 const applyWorkplaceUpdate = useCallback(
193 (next: WorkplaceData) => {
194 setWorkplace(next);
195 setError(null);
196 if (
197 workflowBusy &&
198 workflowStageRef.current !== undefined &&
199 shouldClearWorkflowBusy(
200 workflowStageRef.current,
201 next,
202 workflowActionRef.current,
203 )
204 ) {
205 setWorkflowBusy(false);
206 workflowStageRef.current = undefined;
207 workflowActionRef.current = null;
208 }
209 },
210 [workflowBusy],
211 );
212
213 useEffect(() => client.onStatus(setConnectionStatus), [client]);
214
215 useEffect(() => {
216 const sessionChanged = prevSessionKeyRef.current !== sessionKey;
217 prevSessionKeyRef.current = sessionKey;
218 if (!sessionKey) {
219 setWorkplace(null);
220 setError(null);
221 setWorkflowBusy(false);
222 setPromptOverrides({});
223 workflowStageRef.current = undefined;
224 workflowActionRef.current = null;
225 setLoading(false);
226 return;
227 }
228 // Clear only when the chat changes. Reconnect/status flaps must keep the
229 // current workplace mounted so step-4 video does not remount and autoPlay.
230 if (sessionChanged) {
231 setWorkplace(null);
232 setError(null);
233 setLoading(true);
234 }
235 if (connectionStatus !== "open") return;
236 void refresh();
237 const id = window.setInterval(() => {
238 void refresh();
239 }, POLL_MS);
240 return () => window.clearInterval(id);
241 }, [connectionStatus, refresh, sessionKey]);
242
243 useEffect(() => {
244 if (!sessionKey) return;
245 const chatId = webuiChatIdFromKey(sessionKey);
246 if (!chatId) return;
247 return client.onChat(chatId, (event) => {
248 if (event.event !== "workplace_updated") return;
249 if (event.workplace) {
250 applyWorkplaceUpdate(event.workplace);
251 return;
252 }
253 void refresh();
254 });
255 }, [applyWorkplaceUpdate, client, refresh, sessionKey]);
256
257 const beginWorkflow = useCallback(
258 (
259 currentStage: string | null | undefined,
260 action: WorkflowAction | null = null,
261 ) => {
262 workflowStageRef.current = currentStage ?? null;
263 workflowActionRef.current = action;
264 setWorkflowBusy(true);
265 },
266 [],
267 );
268
269 const confirmStory = useCallback(
270 async (storyMd?: string) => {
271 if (!sessionKey) return;
272 beginWorkflow(workplace?.stage, "confirm_story");
273 try {
274 const result = await confirmStoryApi(token, sessionKey, storyMd);
275 applyWorkplaceUpdate(result.workplace);
276 } catch (err) {
277 setWorkflowBusy(false);
278 workflowStageRef.current = undefined;
279 workflowActionRef.current = null;
280 setError((err as Error).message);
281 }
282 },
283 [applyWorkplaceUpdate, beginWorkflow, sessionKey, token, workplace?.stage],
284 );
285
286 const saveStory = useCallback(
287 async (storyMd: string) => {
288 if (!sessionKey) return;
289 const chatId = webuiChatIdFromKey(sessionKey);
290 try {
291 const { workplace } = await client.saveWorkplaceStory(chatId, storyMd);
292 applyWorkplaceUpdate(workplace);
293 } catch (err) {
294 setError((err as Error).message);
295 throw err;
296 }
297 },
298 [applyWorkplaceUpdate, client, sessionKey],
299 );
300
301 const saveStoryProfile = useCallback(
302 async (profile: StoryProfile) => {
303 if (!sessionKey) return;
304 const chatId = webuiChatIdFromKey(sessionKey);
305 try {
306 const { workplace } = await client.saveWorkplaceStoryProfile(
307 chatId,
308 profile,
309 );
310 applyWorkplaceUpdate(workplace);
311 } catch (err) {
312 setError((err as Error).message);
313 throw err;
314 }
315 },
316 [applyWorkplaceUpdate, client, sessionKey],
317 );
318
319 const startGeneration = useCallback(async () => {
320 if (!sessionKey) return;
321 beginWorkflow(workplace?.stage);
322 try {
323 const result = await startGenerationApi(token, sessionKey);
324 applyWorkplaceUpdate(result.workplace);
325 // generate-all submits jobs synchronously; no agent stage transition to wait on.
326 setWorkflowBusy(false);
327 workflowStageRef.current = undefined;
328 workflowActionRef.current = null;
329 } catch (err) {
330 setWorkflowBusy(false);
331 workflowStageRef.current = undefined;
332 workflowActionRef.current = null;
333 setError((err as Error).message);
334 }
335 }, [
336 applyWorkplaceUpdate,
337 beginWorkflow,
338 sessionKey,
339 token,
340 workplace?.stage,
341 ]);
342
343 const startAutoGenerate = useCallback(async () => {
344 if (!sessionKey) return;
345 beginWorkflow(workplace?.stage);
346 try {
347 const result = await startAutoGenerateApi(token, sessionKey);
348 applyWorkplaceUpdate(result.workplace);
349 setWorkflowBusy(false);
350 workflowStageRef.current = undefined;
351 workflowActionRef.current = null;
352 } catch (err) {
353 setWorkflowBusy(false);
354 workflowStageRef.current = undefined;
355 workflowActionRef.current = null;
356 setError((err as Error).message);
357 }
358 }, [
359 applyWorkplaceUpdate,
360 beginWorkflow,
361 sessionKey,
362 token,
363 workplace?.stage,
364 ]);
365
366 /** Roll stuck shot_generating prep back to shot_planning so the user can retry. */
367 const abortGeneration = useCallback(async () => {
368 if (!sessionKey) return;
369 try {
370 const result = await abortGenerationApi(token, sessionKey);
371 applyWorkplaceUpdate(result.workplace);
372 setWorkflowBusy(false);
373 workflowStageRef.current = undefined;
374 workflowActionRef.current = null;
375 } catch (err) {
376 setError((err as Error).message);
377 throw err;
378 }
379 }, [applyWorkplaceUpdate, sessionKey, token]);
380
381 /** 批量提交可生成镜头的 Echo 任务(FramesPanel「全部生成」)。 */
382 const generateAll = useCallback(async () => {
383 if (!sessionKey) return;
384 if (
385 workplace?.session_key &&
386 workplace.session_key !== sessionKey
387 ) {
388 await refresh();
389 return;
390 }
391 try {
392 const result = await generateAllApi(token, sessionKey);
393 if (result.workplace.session_key !== sessionKey) {
394 await refresh();
395 return;
396 }
397 applyWorkplaceUpdate(result.workplace);
398 } catch (err) {
399 setError((err as Error).message);
400 throw err;
401 }
402 }, [applyWorkplaceUpdate, refresh, sessionKey, token, workplace?.session_key]);
403
404 const startMerge = useCallback(async () => {
405 if (!sessionKey) return;
406 beginWorkflow(workplace?.stage, "start_merge");
407 try {
408 const result = await startMergeApi(token, sessionKey);
409 applyWorkplaceUpdate(result.workplace);
410 } catch (err) {
411 setWorkflowBusy(false);
412 workflowStageRef.current = undefined;
413 workflowActionRef.current = null;
414 setError((err as Error).message);
415 }
416 }, [
417 applyWorkplaceUpdate,
418 beginWorkflow,
419 sessionKey,
420 token,
421 workplace?.stage,
422 ]);
423
424 /** 成片后回到分镜编辑;与 startMerge(重新触发合成)职责不同。 */
425 const regenerate = useCallback(async () => {
426 if (!sessionKey) return;
427 try {
428 const result = await regenerateApi(token, sessionKey);
429 applyWorkplaceUpdate(result.workplace);
430 } catch (err) {
431 setError((err as Error).message);
432 throw err;
433 }
434 }, [applyWorkplaceUpdate, sessionKey, token]);
435
436 const splitShot = useCallback(
437 async (shotId: number, payload: SplitShotPayload) => {
438 if (!sessionKey) return;
439 setSplitMergeBusy(true);
440 try {
441 const result = await splitShotApi(token, sessionKey, shotId, payload);
442 applyWorkplaceUpdate(result.workplace);
443 } catch (err) {
444 setError((err as Error).message);
445 } finally {
446 setSplitMergeBusy(false);
447 }
448 },
449 [applyWorkplaceUpdate, sessionKey, token],
450 );
451
452 const mergeShotUp = useCallback(
453 async (shotId: number, mergedText?: string) => {
454 if (!sessionKey) return;
455 setSplitMergeBusy(true);
456 try {
457 const result = await mergeShotUpApi(
458 token,
459 sessionKey,
460 shotId,
461 mergedText,
462 );
463 applyWorkplaceUpdate(result.workplace);
464 } catch (err) {
465 setError((err as Error).message);
466 } finally {
467 setSplitMergeBusy(false);
468 }
469 },
470 [applyWorkplaceUpdate, sessionKey, token],
471 );
472
473 const deleteShot = useCallback(
474 async (shotId: number) => {
475 if (!sessionKey || !workplace) return;
476 setSplitMergeBusy(true);
477 try {
478 const result = USE_MOCK_DELETE_SHOT
479 ? mockDeleteShot(workplace, shotId)
480 : await deleteShotApi(token, sessionKey, shotId);
481 applyWorkplaceUpdate(result.workplace);
482 } catch (err) {
483 setError((err as Error).message);
484 } finally {
485 setSplitMergeBusy(false);
486 }
487 },
488 [applyWorkplaceUpdate, sessionKey, token, workplace],
489 );
490
491 const accept = useCallback(
492 async (shotId: number) => {
493 if (!sessionKey) return;
494 setMutatingShotId(shotId);
495 try {
496 const result = await acceptShot(token, sessionKey, shotId);
497 applyWorkplaceUpdate(result.workplace);
498 } catch (err) {
499 setError((err as Error).message);
500 } finally {
501 setMutatingShotId(null);
502 }
503 },
504 [applyWorkplaceUpdate, sessionKey, token],
505 );
506
507 /** 批量接收已生成分镜(FramesPanel「全部接收」)。 */
508 const acceptAll = useCallback(async () => {
509 if (!sessionKey || !workplace) return;
510 setAcceptAllBusy(true);
511 try {
512 const result = USE_MOCK_ACCEPT_ALL
513 ? mockAcceptAllShots(workplace)
514 : await acceptAllShotsApi(token, sessionKey);
515 applyWorkplaceUpdate(result.workplace);
516 } catch (err) {
517 setError((err as Error).message);
518 } finally {
519 setAcceptAllBusy(false);
520 }
521 }, [applyWorkplaceUpdate, sessionKey, token, workplace]);
522
523 const approveMemoryReview = useCallback(
524 async (review: MemoryReview, retainedMemoryIds: string[]) => {
525 if (!sessionKey) return;
526 setMemoryReviewBusy({ shotId: review.shot_id, action: "approve" });
527 try {
528 const result = await approveMemoryReviewApi(
529 token,
530 sessionKey,
531 review.shot_id,
532 {
533 review_id: review.review_id,
534 attempt: review.attempt,
535 retained_memory_ids: retainedMemoryIds,
536 },
537 );
538 applyWorkplaceUpdate(result.workplace);
539 } catch (err) {
540 setError((err as Error).message);
541 throw err;
542 } finally {
543 setMemoryReviewBusy(null);
544 }
545 },
546 [applyWorkplaceUpdate, sessionKey, token],
547 );
548
549 const reselectMemoryReview = useCallback(
550 async (review: MemoryReview, memoryId?: string) => {
551 if (!sessionKey) return;
552 setMemoryReviewBusy({ shotId: review.shot_id, action: "reselect" });
553 try {
554 const result = await reselectMemoryReviewApi(
555 token,
556 sessionKey,
557 review.shot_id,
558 {
559 review_id: review.review_id,
560 attempt: review.attempt,
561 memory_id: memoryId,
562 },
563 );
564 applyWorkplaceUpdate(result.workplace);
565 } catch (err) {
566 setError((err as Error).message);
567 throw err;
568 } finally {
569 setMemoryReviewBusy(null);
570 }
571 },
572 [applyWorkplaceUpdate, sessionKey, token],
573 );
574
575 const selectMemoryReviewFrame = useCallback(
576 async (review: MemoryReview, memoryId: string, timestampSec: number) => {
577 if (!sessionKey) return;
578 setMemoryReviewBusy({ shotId: review.shot_id, action: "manual_select" });
579 try {
580 const result = await selectMemoryReviewFrameApi(
581 token,
582 sessionKey,
583 review.shot_id,
584 {
585 review_id: review.review_id,
586 attempt: review.attempt,
587 memory_id: memoryId,
588 timestamp_sec: timestampSec,
589 },
590 );
591 applyWorkplaceUpdate(result.workplace);
592 } catch (err) {
593 setError((err as Error).message);
594 throw err;
595 } finally {
596 setMemoryReviewBusy(null);
597 }
598 },
599 [applyWorkplaceUpdate, sessionKey, token],
600 );
601
602 const selectMemoryReviewMode = useCallback(
603 async (review: MemoryReview, mode: "manual" | "vlm") => {
604 if (!sessionKey) return;
605 setMemoryReviewBusy({ shotId: review.shot_id, action: "select_mode" });
606 try {
607 const result = await selectMemoryReviewModeApi(
608 token,
609 sessionKey,
610 review.shot_id,
611 {
612 review_id: review.review_id,
613 attempt: review.attempt,
614 selection_mode: mode,
615 },
616 );
617 applyWorkplaceUpdate(result.workplace);
618 } catch (err) {
619 setError((err as Error).message);
620 throw err;
621 } finally {
622 setMemoryReviewBusy(null);
623 }
624 },
625 [applyWorkplaceUpdate, sessionKey, token],
626 );
627
628 const saveMemoryAsset = useCallback(
629 async (asset: MemoryAssetUpload) => {
630 if (!sessionKey) return;
631 setMemoryWorkspaceBusy(true);
632 try {
633 const chatId = webuiChatIdFromKey(sessionKey);
634 const result = await client.saveWorkplaceMemoryAsset(chatId, asset);
635 applyWorkplaceUpdate(result.workplace);
636 } catch (err) {
637 setError((err as Error).message);
638 throw err;
639 } finally {
640 setMemoryWorkspaceBusy(false);
641 }
642 },
643 [applyWorkplaceUpdate, client, sessionKey],
644 );
645
646 const createShotMemoryAsset = useCallback(
647 async (shotId: number, asset: ShotMemoryAssetCreate) => {
648 if (!sessionKey) return;
649 setMemoryWorkspaceBusy(true);
650 try {
651 const chatId = webuiChatIdFromKey(sessionKey);
652 const result = await client.createWorkplaceShotMemoryAsset(
653 chatId,
654 shotId,
655 asset,
656 );
657 applyWorkplaceUpdate(result.workplace);
658 } catch (err) {
659 setError((err as Error).message);
660 throw err;
661 } finally {
662 setMemoryWorkspaceBusy(false);
663 }
664 },
665 [applyWorkplaceUpdate, client, sessionKey],
666 );
667
668 const deleteMemoryAsset = useCallback(
669 async (assetId: string) => {
670 if (!sessionKey) return;
671 setMemoryWorkspaceBusy(true);
672 try {
673 const chatId = webuiChatIdFromKey(sessionKey);
674 const result = await client.deleteWorkplaceMemoryAsset(chatId, assetId);
675 applyWorkplaceUpdate(result.workplace);
676 } catch (err) {
677 setError((err as Error).message);
678 throw err;
679 } finally {
680 setMemoryWorkspaceBusy(false);
681 }
682 },
683 [applyWorkplaceUpdate, client, sessionKey],
684 );
685
686 const saveShotMemorySlots = useCallback(
687 async (shotId: number, slots: MemorySlotReference[]) => {
688 if (!sessionKey) return;
689 setMemoryWorkspaceBusy(true);
690 try {
691 const chatId = webuiChatIdFromKey(sessionKey);
692 const result = await client.saveWorkplaceShotMemorySlots(
693 chatId,
694 shotId,
695 slots,
696 );
697 applyWorkplaceUpdate(result.workplace);
698 } catch (err) {
699 setError((err as Error).message);
700 throw err;
701 } finally {
702 setMemoryWorkspaceBusy(false);
703 }
704 },
705 [applyWorkplaceUpdate, client, sessionKey],
706 );
707
708 const revise = useCallback(
709 async (shotId: number, feedback: string) => {
710 if (!sessionKey) return;
711 setMutatingShotId(shotId);
712 try {
713 const result = await reviseShot(token, sessionKey, shotId, feedback);
714 applyWorkplaceUpdate(result.workplace);
715 } catch (err) {
716 setError((err as Error).message);
717 } finally {
718 setMutatingShotId(null);
719 }
720 },
721 [applyWorkplaceUpdate, sessionKey, token],
722 );
723
724 const generateOneShot = useCallback(
725 async (
726 shotId: number,
727 referenceImage?: {
728 url: string;
729 name?: string;
730 width?: number;
731 height?: number;
732 } | null,
733 ) => {
734 if (!sessionKey) return;
735 setMutatingShotId(shotId);
736 try {
737 const result = await generateShotApi(
738 token,
739 sessionKey,
740 shotId,
741 referenceImage,
742 );
743 applyWorkplaceUpdate(result.workplace);
744 } catch (err) {
745 setError((err as Error).message);
746 } finally {
747 setMutatingShotId(null);
748 }
749 },
750 [applyWorkplaceUpdate, sessionKey, token],
751 );
752
753 const setContinuousMode = useCallback(
754 async (shotId: number, enabled: boolean) => {
755 if (!sessionKey) return;
756 try {
757 const result = await setShotContinuousModeApi(
758 token,
759 sessionKey,
760 shotId,
761 enabled,
762 );
763 applyWorkplaceUpdate(result.workplace);
764 } catch (err) {
765 setError((err as Error).message);
766 }
767 },
768 [applyWorkplaceUpdate, sessionKey, token],
769 );
770
771 const continuousGenerateOneShot = useCallback(
772 async (shotId: number) => {
773 if (!sessionKey) return;
774 setMutatingShotId(shotId);
775 try {
776 const result = await continuousGenerateShotApi(
777 token,
778 sessionKey,
779 shotId,
780 );
781 applyWorkplaceUpdate(result.workplace);
782 } catch (err) {
783 setError((err as Error).message);
784 } finally {
785 setMutatingShotId(null);
786 }
787 },
788 [applyWorkplaceUpdate, sessionKey, token],
789 );
790
791 const saveShotPrompt = useCallback(
792 async (shotId: number, summary: string) => {
793 if (!sessionKey) return;
794 try {
795 const result = await saveShotPromptApi(
796 token,
797 sessionKey,
798 shotId,
799 summary,
800 );
801 applyWorkplaceUpdate(result.workplace);
802 setPromptOverrides((prev) => {
803 if (!(shotId in prev)) return prev;
804 const next = { ...prev };
805 delete next[shotId];
806 return next;
807 });
808 } catch (err) {
809 if (err instanceof ApiError && err.status === 404) {
810 console.warn(
811 "saveShotPrompt: backend not ready (404), using local override",
812 );
813 setPromptOverrides((prev) => ({ ...prev, [shotId]: summary }));
814 return;
815 }
816 setError((err as Error).message);
817 throw err;
818 }
819 },
820 [applyWorkplaceUpdate, sessionKey, token],
821 );
822
823 const updateShotDuration = useCallback(
824 async (shotId: number, durationSec: number) => {
825 if (!sessionKey) return;
826 try {
827 const result = await updateShotDurationApi(
828 token,
829 sessionKey,
830 shotId,
831 durationSec,
832 );
833 applyWorkplaceUpdate(result.workplace);
834 } catch (err) {
835 setError((err as Error).message);
836 }
837 },
838 [applyWorkplaceUpdate, sessionKey, token],
839 );
840
841 const updateEchoLike = useCallback(
842 async (action: 1 | 2) => {
843 if (!sessionKey) return;
844 try {
845 const result = await updateEchoLikeApi(token, sessionKey, action);
846 applyWorkplaceUpdate(result.workplace);
847 } catch (err) {
848 setError((err as Error).message);
849 throw err;
850 }
851 },
852 [applyWorkplaceUpdate, sessionKey, token],
853 );
854
855 const recordEchoDownloadPrompt = useCallback(async () => {
856 if (!sessionKey) return;
857 try {
858 const result = await recordEchoDownloadPromptApi(token, sessionKey);
859 applyWorkplaceUpdate(result.workplace);
860 } catch (err) {
861 console.warn("failed to record prompt download", err);
862 }
863 }, [applyWorkplaceUpdate, sessionKey, token]);
864
865 return {
866 workplace,
867 loading,
868 error,
869 mutatingShotId,
870 workflowBusy,
871 splitMergeBusy,
872 acceptAllBusy,
873 memoryReviewBusy,
874 memoryWorkspaceBusy,
875 refresh,
876 accept,
877 acceptAll,
878 approveMemoryReview,
879 reselectMemoryReview,
880 selectMemoryReviewFrame,
881 selectMemoryReviewMode,
882 saveMemoryAsset,
883 createShotMemoryAsset,
884 deleteMemoryAsset,
885 saveShotMemorySlots,
886 revise,
887 confirmStory,
888 saveStory,
889 saveStoryProfile,
890 startGeneration,
891 startAutoGenerate,
892 abortGeneration,
893 generateAll,
894 startMerge,
895 regenerate,
896 splitShot,
897 mergeShotUp,
898 deleteShot,
899 generateOneShot,
900 setContinuousMode,
901 continuousGenerateOneShot,
902 saveShotPrompt,
903 updateShotDuration,
904 updateEchoLike,
905 recordEchoDownloadPrompt,
906 promptOverrides,
907 };
908 }
909
909 lines TYPESCRIPT