fix: commit usePersonaGeneration.ts skeleton template
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-23 03:59:31 -07:00
parent 3247ce3ca0
commit 4d9203eddc

View File

@ -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;
/** 0100 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<string, string>;
/** motionTypes that failed (non-fatal) */
videoFailures: string[];
error: string | null;
generate: (description: string, gender: string, name?: string) => Promise<void>;
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 (
* <button onClick={() => generate('athletic woman, 30s', 'female', 'Aria')}>
* Generate Persona
* </button>
* );
* ```
*/
export function usePersonaGeneration(
config: UsePersonaGenerationConfig
): UsePersonaGenerationResult {
const { apiBase, userId, authToken, sseEndpoint } = config;
const [state, setState] = useState<PersonaGenerationState>('idle');
const [jobId, setJobId] = useState<string | null>(null);
const [personaId, setPersonaId] = useState<string | null>(null);
const [imageProgress, setImageProgress] = useState(0);
const [imageUrls, setImageUrls] = useState<string[]>([]);
const [videoUrls, setVideoUrls] = useState<Record<string, string>>({});
const [videoFailures, setVideoFailures] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
// Refs for event handler closures (avoids stale state).
const jobIdRef = useRef<string | null>(null);
jobIdRef.current = jobId;
const videoUrlsRef = useRef<Record<string, string>>({});
videoUrlsRef.current = videoUrls;
const videoFailuresRef = useRef<string[]>([]);
videoFailuresRef.current = videoFailures;
const effectiveSseEndpoint = sseEndpoint ?? `${apiBase}/events`;
const headers = useCallback((): Record<string, string> => {
const h: Record<string, string> = { '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<string, unknown> | undefined;
const eventJobId = (event as unknown as Record<string, unknown>)['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<string, unknown>)['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<string, string>) => {
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<string, string> = { 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,
};
}