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