fix: commit usePersonaGeneration.ts skeleton template
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
3247ce3ca0
commit
4d9203eddc
@ -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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user