From 4d9203eddc3eaa874f0de0594bb246e1dfb0bfca Mon Sep 17 00:00:00 2001 From: jordan Date: Mon, 23 Feb 2026 03:59:31 -0700 Subject: [PATCH] fix: commit usePersonaGeneration.ts skeleton template The usePersonaGeneration hook was created on disk but never committed to git, so rendered projects had a broken import in index.ts causing TypeScript build failures in CI. Co-Authored-By: Claude Sonnet 4.6 --- .../realtime/src/usePersonaGeneration.ts | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 internal/adapter/templates/templates/skeleton/packages/realtime/src/usePersonaGeneration.ts diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/src/usePersonaGeneration.ts b/internal/adapter/templates/templates/skeleton/packages/realtime/src/usePersonaGeneration.ts new file mode 100644 index 0000000..a1efd3c --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/realtime/src/usePersonaGeneration.ts @@ -0,0 +1,257 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { useEventChannel, type ChannelEvent } from './useEventChannel'; + +/** + * Persona generation state values. + */ +export type PersonaGenerationState = + | 'idle' + | 'spec_generating' // LLM pipeline running (persona_spec_started received) + | 'images_generating' // Images being generated (persona_spec_complete received) + | 'videos_generating' // Videos being generated (persona_image_complete received) + | 'complete' // All done (all 4 motion types resolved) + | 'failed'; // persona_failed received + +/** + * Result of the usePersonaGeneration hook. + */ +export interface UsePersonaGenerationResult { + state: PersonaGenerationState; + jobId: string | null; + personaId: string | null; + /** 0–100 accumulated from persona_image_progress.progress */ + imageProgress: number; + /** URLs accumulated from persona_image_progress result.url */ + imageUrls: string[]; + /** motionType → url for completed videos */ + videoUrls: Record; + /** motionTypes that failed (non-fatal) */ + videoFailures: string[]; + error: string | null; + generate: (description: string, gender: string, name?: string) => Promise; + reset: () => void; +} + +/** + * Configuration for the usePersonaGeneration hook. + */ +export interface UsePersonaGenerationConfig { + /** API base path, e.g. '/api/example-api' */ + apiBase: string; + /** User ID for subscribing to user channel */ + userId: string; + /** Auth token for API requests */ + authToken?: string; + /** SSE endpoint (default: '{apiBase}/events') */ + sseEndpoint?: string; +} + +// Total motion types that the backend generates (keep in sync with Go VideoSpec). +const TOTAL_MOTION_TYPES = 4; + +/** + * Hook for managing persona generation state with real-time progress via SSE. + * + * Calls POST {apiBase}/persona/generate, then listens on the user SSE channel + * for the 9-event sequence and accumulates image URLs and video URLs in real time. + * + * @example + * ```tsx + * const { state, imageUrls, videoUrls, generate, reset } = usePersonaGeneration({ + * apiBase: '/api/example-api', + * userId: currentUser.id, + * authToken: token, + * }); + * + * return ( + * + * ); + * ``` + */ +export function usePersonaGeneration( + config: UsePersonaGenerationConfig +): UsePersonaGenerationResult { + const { apiBase, userId, authToken, sseEndpoint } = config; + + const [state, setState] = useState('idle'); + const [jobId, setJobId] = useState(null); + const [personaId, setPersonaId] = useState(null); + const [imageProgress, setImageProgress] = useState(0); + const [imageUrls, setImageUrls] = useState([]); + const [videoUrls, setVideoUrls] = useState>({}); + const [videoFailures, setVideoFailures] = useState([]); + const [error, setError] = useState(null); + + // Refs for event handler closures (avoids stale state). + const jobIdRef = useRef(null); + jobIdRef.current = jobId; + + const videoUrlsRef = useRef>({}); + videoUrlsRef.current = videoUrls; + + const videoFailuresRef = useRef([]); + videoFailuresRef.current = videoFailures; + + const effectiveSseEndpoint = sseEndpoint ?? `${apiBase}/events`; + + const headers = useCallback((): Record => { + const h: Record = { 'Content-Type': 'application/json' }; + if (authToken) h['Authorization'] = `Bearer ${authToken}`; + return h; + }, [authToken]); + + const handleEvent = useCallback((event: ChannelEvent) => { + const currentJobId = jobIdRef.current; + if (!currentJobId) return; + + // Filter events to the active job. + const result = event.result as Record | undefined; + const eventJobId = (event as unknown as Record)['jobId'] as string | undefined; + if (eventJobId && eventJobId !== currentJobId) return; + + switch (event.type) { + case 'persona_spec_started': { + setState('spec_generating'); + break; + } + + case 'persona_spec_complete': { + const pid = result?.['personaId'] as string | undefined; + if (pid) setPersonaId(pid); + setState('images_generating'); + break; + } + + case 'persona_image_started': { + // No state change needed; images_generating already set. + break; + } + + case 'persona_image_progress': { + const progress = (event as unknown as Record)['progress'] as number | undefined; + const url = result?.['url'] as string | undefined; + if (typeof progress === 'number') setImageProgress(progress); + if (url) setImageUrls((prev: string[]) => [...prev, url]); + break; + } + + case 'persona_image_complete': { + setImageProgress(100); + setState('videos_generating'); + break; + } + + case 'persona_video_started': { + // No state change needed; videos_generating already set. + break; + } + + case 'persona_video_complete': { + const motionType = result?.['motionType'] as string | undefined; + const url = result?.['url'] as string | undefined; + if (!motionType || !url) break; + setVideoUrls((prev: Record) => { + const next = { ...prev, [motionType]: url }; + // Check if all motion types have resolved. + const resolved = Object.keys(next).length + videoFailuresRef.current.length; + if (resolved >= TOTAL_MOTION_TYPES) setState('complete'); + return next; + }); + break; + } + + case 'persona_video_failed': { + const motionType = result?.['motionType'] as string | undefined; + if (!motionType) break; + setVideoFailures((prev: string[]) => { + const next = [...prev, motionType]; + // Check if all motion types have resolved. + const resolved = Object.keys(videoUrlsRef.current).length + next.length; + if (resolved >= TOTAL_MOTION_TYPES) setState('complete'); + return next; + }); + break; + } + + case 'persona_failed': { + const errMsg = result?.['error'] as string | undefined; + setError(errMsg ?? 'Persona generation failed'); + setState('failed'); + break; + } + } + }, []); + + // Subscribe to the user SSE channel. + useEventChannel({ + endpoint: effectiveSseEndpoint, + channel: `user:${userId}`, + onEvent: handleEvent, + }); + + const generate = useCallback( + async (description: string, gender: string, name?: string) => { + // Guard against double-triggering. + if (state !== 'idle') return; + + setError(null); + setState('spec_generating'); + + try { + const body: Record = { description, gender }; + if (name) body['name'] = name; + + const res = await fetch(`${apiBase}/persona/generate`, { + method: 'POST', + headers: headers(), + body: JSON.stringify(body), + }); + + if (!res.ok) { + const parsed = await res.json().catch(() => ({})) as { message?: string }; + throw new Error(parsed.message ?? `HTTP ${res.status}`); + } + + const json = (await res.json()) as { jobId?: string; job_id?: string }; + const id = json.jobId ?? json.job_id ?? null; + setJobId(id); + jobIdRef.current = id; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start persona generation'); + setState('idle'); + } + }, + [state, apiBase, headers] + ); + + const reset = useCallback(() => { + setState('idle'); + setJobId(null); + jobIdRef.current = null; + setPersonaId(null); + setImageProgress(0); + setImageUrls([]); + setVideoUrls({}); + videoUrlsRef.current = {}; + setVideoFailures([]); + videoFailuresRef.current = []; + setError(null); + }, []); + + return { + state, + jobId, + personaId, + imageProgress, + imageUrls, + videoUrls, + videoFailures, + error, + generate, + reset, + }; +}