返回 presentation-ai
useAntvInfographicGeneration.ts
根目录 / src / hooks / presentation / infographic / useAntvInfographicGeneration.ts
1 "use client";
2
3 import { useCompletion } from "@ai-sdk/react";
4 import { type PlateEditor } from "platejs/react";
5 import {
6 useCallback,
7 useEffect,
8 useMemo,
9 useRef,
10 useState,
11 type Dispatch,
12 type SetStateAction,
13 } from "react";
14
15 import { type TAntvInfographicElement } from "@/components/notebook/presentation/editor/plugins/antv-infographic-plugin";
16 import { findInfographicEntryById } from "@/hooks/presentation/infographic/findInfographicNode";
17 import {
18 buildInfographicLayoutInstruction,
19 getInfographicOrientationForSlideLayout,
20 } from "@/lib/presentation/infographic-layout";
21 import { useInfographicStreamingState } from "@/states/infographic-streaming-state";
22 import { usePresentationState } from "@/states/presentation-state";
23
24 type GenerationParams = {
25 editor: PlateEditor;
26 element: TAntvInfographicElement;
27 setHasError: Dispatch<SetStateAction<boolean>>;
28 canResumeLoadingGeneration?: boolean;
29 };
30
31 type GenerationTarget = {
32 mode: "prompt" | "text";
33 value: string;
34 };
35
36 type ActiveGenerationRequest = {
37 elementId: string;
38 requestKey: string;
39 mode: GenerationTarget["mode"];
40 };
41
42 const DEFAULT_GENERATION_PROMPT = "Generate an infographic";
43
44 function hashGenerationInput(input: string): string {
45 let hash = 0;
46
47 for (let index = 0; index < input.length; index += 1) {
48 hash = (hash << 5) - hash + input.charCodeAt(index);
49 hash |= 0;
50 }
51
52 return Math.abs(hash).toString(36);
53 }
54
55 function resolveGenerationTarget(
56 element: TAntvInfographicElement,
57 ): GenerationTarget {
58 const sourceText = element.sourceText?.trim();
59
60 if (sourceText) {
61 return { mode: "text", value: sourceText };
62 }
63
64 const generationPrompt = element.generationPrompt?.trim();
65
66 if (generationPrompt) {
67 return { mode: "prompt", value: generationPrompt };
68 }
69
70 return { mode: "prompt", value: DEFAULT_GENERATION_PROMPT };
71 }
72
73 export function useAntvInfographicGeneration({
74 editor,
75 element,
76 setHasError,
77 canResumeLoadingGeneration = false,
78 }: GenerationParams) {
79 const [syntax, setSyntax] = useState<string>("");
80 const isMountedRef = useRef(false);
81 const activeRequestRef = useRef<ActiveGenerationRequest | null>(null);
82 const elementId = typeof element.id === "string" ? element.id : "";
83 const generationTarget = useMemo(
84 () => resolveGenerationTarget(element),
85 [element.generationPrompt, element.sourceText],
86 );
87 const generationRequestKey = useMemo(() => {
88 if (!elementId) {
89 return "";
90 }
91
92 return `${elementId}:${generationTarget.mode}:${
93 element.slideLayoutType ?? "unspecified"
94 }:${hashGenerationInput(generationTarget.value)}`;
95 }, [
96 element.slideLayoutType,
97 elementId,
98 generationTarget.mode,
99 generationTarget.value,
100 ]);
101 const layoutInstruction = useMemo(
102 () => buildInfographicLayoutInstruction(element.slideLayoutType),
103 [element.slideLayoutType],
104 );
105 const requestedOrientation = useMemo(
106 () => getInfographicOrientationForSlideLayout(element.slideLayoutType),
107 [element.slideLayoutType],
108 );
109 const isInfographicComplete = useInfographicStreamingState((state) =>
110 elementId ? state.completedInfographicIds[elementId] === true : false,
111 );
112 const isReadyToGenerate =
113 generationTarget.mode === "text" ||
114 isInfographicComplete ||
115 canResumeLoadingGeneration;
116 const hasStartedRequest = useInfographicStreamingState((state) =>
117 generationRequestKey
118 ? state.startedGenerationRequests[generationRequestKey] === true
119 : false,
120 );
121
122 useEffect(() => {
123 isMountedRef.current = true;
124
125 return () => {
126 isMountedRef.current = false;
127 };
128 }, []);
129
130 const updateInfographicNode = useCallback(
131 (
132 targetElementIds: string[],
133 update: Partial<TAntvInfographicElement>,
134 ): boolean => {
135 const candidateIds = [...new Set(targetElementIds.filter(Boolean))];
136
137 if (candidateIds.length === 0) {
138 return false;
139 }
140
141 for (const candidateId of candidateIds) {
142 const entry = findInfographicEntryById(editor, candidateId);
143 const path = entry?.[1];
144
145 if (!path) {
146 continue;
147 }
148
149 editor.tf.setNodes(update, { at: path });
150
151 // Ensure global state has the updated content for serialization
152 const slideId = editor.id;
153 if (slideId && typeof slideId === "string") {
154 usePresentationState.getState().updateSlide(slideId, {
155 content: editor.children,
156 });
157 }
158
159 return true;
160 }
161
162 return false;
163 },
164 [editor],
165 );
166
167 const handleGenerationFinish = useCallback(
168 (completion: string) => {
169 const activeRequest = activeRequestRef.current;
170
171 if (!activeRequest) {
172 return;
173 }
174
175 if (isMountedRef.current) {
176 setSyntax(completion);
177 setHasError(false);
178 }
179
180 const didUpdateNode = updateInfographicNode(
181 [activeRequest.elementId, elementId],
182 {
183 syntax: completion,
184 isLoading: false,
185 },
186 );
187
188 console.info("[infographic-api] generation response applied", {
189 elementId: activeRequest.elementId,
190 didUpdateNode,
191 requestKey: activeRequest.requestKey,
192 syntaxLength: completion.length,
193 });
194
195 if (!didUpdateNode && isMountedRef.current) {
196 setHasError(true);
197 }
198
199 },
200 [elementId, setHasError, updateInfographicNode],
201 );
202
203 const handleGenerationError = useCallback(() => {
204 const activeRequest = activeRequestRef.current;
205
206 if (!activeRequest) {
207 return;
208 }
209
210 updateInfographicNode([activeRequest.elementId, elementId], {
211 isLoading: false,
212 });
213
214 if (isMountedRef.current) {
215 setHasError(true);
216 }
217
218 }, [elementId, setHasError, updateInfographicNode]);
219
220 const {
221 completion: syntaxFromPrompt,
222 complete: startForPrompt,
223 isLoading: isGeneratingFromPrompt,
224 } = useCompletion({
225 api: "/api/presentation/prompt-to-diagram",
226 id: elementId ? `${elementId}:prompt-to-diagram` : undefined,
227 onFinish(_prompt, completion) {
228 handleGenerationFinish(completion);
229 },
230 onError() {
231 handleGenerationError();
232 },
233 });
234
235 const {
236 completion: syntaxFromText,
237 complete: startForText,
238 isLoading: isGeneratingFromText,
239 } = useCompletion({
240 api: "/api/presentation/text-to-diagram",
241 id: elementId ? `${elementId}:text-to-diagram` : undefined,
242 onFinish(_prompt, completion) {
243 handleGenerationFinish(completion);
244 },
245 onError() {
246 handleGenerationError();
247 },
248 });
249
250 const isGenerating = isGeneratingFromPrompt || isGeneratingFromText;
251
252 useEffect(() => {
253 if (element.isLoading) {
254 setHasError(false);
255 return;
256 }
257
258 activeRequestRef.current = null;
259 }, [element.isLoading, setHasError]);
260
261 useEffect(() => {
262 if (!elementId || !generationRequestKey) {
263 return;
264 }
265
266 if (
267 !isMountedRef.current ||
268 !element.isLoading ||
269 element.syntax.trim().length > 0 ||
270 !isReadyToGenerate ||
271 isGenerating ||
272 hasStartedRequest
273 ) {
274 return;
275 }
276
277 const didStartRequest = useInfographicStreamingState
278 .getState()
279 .tryStartGenerationRequest(generationRequestKey);
280
281 if (!didStartRequest) {
282 return;
283 }
284
285 activeRequestRef.current = {
286 elementId,
287 requestKey: generationRequestKey,
288 mode: generationTarget.mode,
289 };
290 setSyntax("");
291 setHasError(false);
292
293 console.info("[infographic-api] starting generation request", {
294 elementId,
295 inputLength: generationTarget.value.length,
296 mode: generationTarget.mode,
297 requestKey: generationRequestKey,
298 });
299
300 if (generationTarget.mode === "text") {
301 void startForText(generationTarget.value, {
302 body: {
303 slideLayoutType: element.slideLayoutType,
304 requestedOrientation,
305 layoutInstruction,
306 },
307 });
308 return;
309 }
310
311 void startForPrompt(generationTarget.value, {
312 body: {
313 slideLayoutType: element.slideLayoutType,
314 requestedOrientation,
315 layoutInstruction,
316 },
317 });
318 }, [
319 element.isLoading,
320 element.slideLayoutType,
321 element.syntax,
322 elementId,
323 generationRequestKey,
324 generationTarget.mode,
325 generationTarget.value,
326 isGenerating,
327 isReadyToGenerate,
328 layoutInstruction,
329 requestedOrientation,
330 hasStartedRequest,
331 setHasError,
332 startForPrompt,
333 startForText,
334 ]);
335
336 useEffect(() => {
337 if (!isMountedRef.current) {
338 return;
339 }
340
341 if (activeRequestRef.current?.mode === "text") {
342 if (syntaxFromText) {
343 setSyntax(syntaxFromText);
344 }
345
346 return;
347 }
348
349 if (syntaxFromPrompt) {
350 setSyntax(syntaxFromPrompt);
351 }
352 }, [syntaxFromPrompt, syntaxFromText]);
353
354 return { syntax };
355 }
356
356 lines TYPESCRIPT