From ae673ef0c1685812b4175d1a7293149bef5c204d Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Mon, 7 Sep 2026 13:26:36 -0700 Subject: [PATCH] refactor: extract ai job controller --- .../scripts/smoke-clips-first-lifecycle.mjs | 46 +-- .../scripts/smoke-errors-accessibility.mjs | 9 +- frontend/src/components/AIPanel.tsx | 271 ++---------------- .../src/features/ai/useAIJobController.ts | 269 +++++++++++++++++ 4 files changed, 331 insertions(+), 264 deletions(-) create mode 100644 frontend/src/features/ai/useAIJobController.ts diff --git a/frontend/scripts/smoke-clips-first-lifecycle.mjs b/frontend/scripts/smoke-clips-first-lifecycle.mjs index c3d1625..731c969 100644 --- a/frontend/scripts/smoke-clips-first-lifecycle.mjs +++ b/frontend/scripts/smoke-clips-first-lifecycle.mjs @@ -12,6 +12,7 @@ const readSource = (relativePath) => readFileSync(resolve(__dirname, relativePat const appSource = readSource('../src/App.tsx'); const panelSource = readSource('../src/components/AIPanel.tsx'); const aiStoreSource = readSource('../src/store/aiStore.ts'); +const aiJobControllerSource = readSource('../src/features/ai/useAIJobController.ts'); assert.match(appSource, /const currentWorkflowIntent: WorkflowIntent = editorWorkflow === 'short' \? 'short' : 'full-video'/); assert.match(appSource, /onClick=\{\(\) => void handleOpenFile\(currentWorkflowIntent\)\}/); @@ -46,28 +47,37 @@ assert.match(aiStoreSource, /clipReviewDecisions: \{\}/); assert.match(aiStoreSource, /providers: \{/); assert.match(panelSource, /if \(!isCurrentClipWorkspace\(\)\) return;\s+setEditPlanResult\(data\);/); assert.match(panelSource, /if \(!isCurrentClipWorkspace\(\)\) return;\s+setFillerResult\(data\);/); -assert.match(panelSource, /type AIJobRunContext = \{[\s\S]*workspaceEpoch: number/); -assert.match(panelSource, /const activeAIJobRunRef = useRef\(null\)/); -assert.match(panelSource, /Another AI action is still running/); -assert.match(panelSource, /const isCurrentAIJobRun = useCallback/); -assert.match(panelSource, /let jobRes: Response;[\s\S]*jobRes = await fetch\(`\$\{backendUrl\}\/jobs\/\$\{jobId\}`\)[\s\S]*Connection interrupted; checking AI job status[\s\S]*continue;/); -assert.match(panelSource, /if \(!jobRes\.ok\) \{[\s\S]*jobRes\.status === 404[\s\S]*AI job status unavailable; retrying[\s\S]*continue;/); -assert.match(panelSource, /let job: AIJob;[\s\S]*job = \(await jobRes\.json\(\)\) as AIJob[\s\S]*AI job status was unreadable; retrying[\s\S]*continue;/); -assert.match(panelSource, /setActiveAIJob\(\{ \.\.\.job, \.\.\.context, workspaceEpoch: run\.workspaceEpoch, runId: run\.id \}\)/); -assert.match(panelSource, /workspaceEpoch: run\.workspaceEpoch/); -assert.match(panelSource, /runId: run\.id/); -assert.match(panelSource, /current && current\.workspaceEpoch !== clipWorkspaceEpoch \? null : current/); -const cancelAIJobBody = panelSource.match(/const cancelAIJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || ''; +assert.match(panelSource, /useAIJobController\(\{ backendUrl \}\)/); +assert.doesNotMatch(panelSource, /activeAIJobRunRef|const pollAIJob|const beginAIJobRun|const finishAIJobRun|const isCurrentAIJobRun/); +assert.match(aiJobControllerSource, /type AIJobRunContext = \{[\s\S]*workspaceEpoch: number/); +assert.match(aiJobControllerSource, /const activeAIJobRunRef = useRef\(null\)/); +assert.match(aiJobControllerSource, /Another AI action is still running/); +assert.match(aiJobControllerSource, /const isCurrentAIJobRun = useCallback/); +assert.match(aiJobControllerSource, /let jobRes: Response;[\s\S]*jobRes = await fetch\(`\$\{backendUrl\}\/jobs\/\$\{jobId\}`\)[\s\S]*Connection interrupted; checking AI job status[\s\S]*continue;/); +assert.match(aiJobControllerSource, /if \(!jobRes\.ok\) \{[\s\S]*jobRes\.status === 404[\s\S]*AI job status unavailable; retrying[\s\S]*continue;/); +assert.match(aiJobControllerSource, /let job: AIJob;[\s\S]*job = \(await jobRes\.json\(\)\) as AIJob[\s\S]*AI job status was unreadable; retrying[\s\S]*continue;/); +assert.match(aiJobControllerSource, /setActiveAIJob\(\{ \.\.\.job, \.\.\.context, workspaceEpoch: run\.workspaceEpoch, runId: run\.id \}\)/); +assert.match(aiJobControllerSource, /workspaceEpoch: run\.workspaceEpoch/); +assert.match(aiJobControllerSource, /runId: run\.id/); +assert.match(aiJobControllerSource, /current && current\.workspaceEpoch !== clipWorkspaceEpoch \? null : current/); +assert.doesNotMatch(aiJobControllerSource, /defaultProvider|providers|setFillerResult|setEditPlanResult|applyClipDiscoveryResult|mergeGeneratedPublishingCopy|setCreatorNotice|getCreatorErrorPresentation/); +const cancelAIJobBody = aiJobControllerSource.match(/const cancelAIJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || ''; assert.match(cancelAIJobBody, /const run = activeAIJobRunRef\.current/); assert.match(cancelAIJobBody, /if \(job\.workspaceEpoch !== run\.workspaceEpoch \|\| job\.runId !== run\.id \|\| !isCurrentAIJobRun\(run\)\) return/); assert.match(cancelAIJobBody, /await fetch\([\s\S]*if \(!isCurrentAIJobRun\(run\)\) return/); assert.doesNotMatch(cancelAIJobBody, /setProcessing\(false/); -const retryAIJobBody = panelSource.match(/const retryAIJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || ''; -assert.match(retryAIJobBody, /const sourceJob = activeAIJob/); -assert.match(retryAIJobBody, /sourceJob\.workspaceEpoch !== useAIStore\.getState\(\)\.clipWorkspaceEpoch/); -assert.match(retryAIJobBody, /run = beginAIJobRun\(\)/); -assert.match(retryAIJobBody, /pollAIJob\(jobId, sourceJob\.label, context, run\)/); -assert.match(retryAIJobBody, /if \(!isCurrentAIJobRun\(run\)\) return/); +const retryTransportBody = aiJobControllerSource.match(/const retryAIJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || ''; +assert.match(retryTransportBody, /const sourceJob = activeAIJob/); +assert.match(retryTransportBody, /sourceJob\.workspaceEpoch !== useAIStore\.getState\(\)\.clipWorkspaceEpoch/); +assert.match(retryTransportBody, /const run = beginAIJobRun\(\)/); +assert.match(retryTransportBody, /pollAIJob\(jobId, sourceJob\.label, context, run\)/); +assert.match(retryTransportBody, /if \(!isCurrentAIJobRun\(run\)\) return null/); +const retryDomainBody = panelSource.match(/const retryAIJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || ''; +assert.match(retryDomainBody, /const retryResult = await retryAIJobTransport\(\)/); +assert.match(retryDomainBody, /retriedJob\.workspaceEpoch !== useAIStore\.getState\(\)\.clipWorkspaceEpoch/); +assert.match(retryDomainBody, /retriedJob\.kind === 'ai:filler-removal'/); +assert.match(retryDomainBody, /applyClipDiscoveryResult\(result as ClipDiscoveryResult, 'suggested_clip_retry'\)/); +assert.match(retryDomainBody, /mergeGeneratedPublishingCopy\(currentDraft, metadata\)/); const openFileBody = appSource.match(/const handleOpenFile = async \(intent: WorkflowIntent = 'full-video'\) => \{([\s\S]*?)\n\s*\};\s*const handleBrowserFileChange/)?.[1] || ''; const browserChangeBody = appSource.match(/const handleBrowserFileChange = async \(e: React\.ChangeEvent\) => \{([\s\S]*?)\n\s*\};\s*const handleBrowserDrop/)?.[1] || ''; diff --git a/frontend/scripts/smoke-errors-accessibility.mjs b/frontend/scripts/smoke-errors-accessibility.mjs index 7a35f03..e0afd62 100644 --- a/frontend/scripts/smoke-errors-accessibility.mjs +++ b/frontend/scripts/smoke-errors-accessibility.mjs @@ -26,6 +26,7 @@ const transcript = readSource('../src/components/TranscriptEditor.tsx'); const status = readSource('../src/components/TranscriptionStatus.tsx'); const waveform = readSource('../src/components/WaveformTimeline.tsx'); const ai = readSource('../src/components/AIPanel.tsx'); +const aiJobController = readSource('../src/features/ai/useAIJobController.ts'); const dialog = readSource('../src/components/CreatorDialog.tsx'); const notice = readSource('../src/components/CreatorNotice.tsx'); const shortcuts = readSource('../src/hooks/useKeyboardShortcuts.ts'); @@ -82,13 +83,13 @@ assert.match(waveform, /aria-label=\{followPlayhead \? 'Stop following playhead' assert.match(waveform, /aria-label="Zoom out"/); assert.match(waveform, /aria-label="Zoom in"/); -assert.match(ai, /\/jobs\/\$\{sourceJob\.id\}\/retry/); +assert.match(aiJobController, /\/jobs\/\$\{sourceJob\.id\}\/retry/); assert.match(ai, /ai:clip-metadata/); assert.match(ai, /appendDiscoveredClipDrafts/); assert.match(ai, /setCreatorNotice/); -assert.match(ai, /setCreatorNotice\(null\);\s*setProcessing\(true, processingMessage\);\s*try \{\s*const startRes/); -assert.match(ai, /setCreatorNotice\(null\);\s*setProcessing\(true, `Retrying \$\{sourceJob\.label\}/); -assert.match(ai, /Another AI action is still running/); +assert.match(ai, /setCreatorNotice\(null\);\s*return startAIJobTransport\(path, body, fallbackMessage, context, processingMessage\)/); +assert.match(ai, /setCreatorNotice\(null\);\s*try \{\s*const retryResult = await retryAIJobTransport\(\)/); +assert.match(aiJobController, /Another AI action is still running/); assert.match(ai, /const canRetry = !busy && \(job\.status === 'failed' \|\| job\.status === 'canceled'\)/); const generatePublishingCopyBody = ai.match(/const generatePublishingCopy = useCallback\(([\s\S]*?)\n \);\n\n const retryAIJob/)?.[1] || ''; assert.match(generatePublishingCopyBody, /getCreatorErrorPresentation\('ai-action', err\)/); diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index f6b822d..c61f526 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -52,36 +52,14 @@ import { getPathDirectory, } from '../features/clips/clipExportFiles'; import type { BackgroundCapabilities } from '../features/clips/types'; +import { + useAIJobController, + type ActiveAIJob, + type AIJobContext, +} from '../features/ai/useAIJobController'; type FillerQueueFilter = 'all' | 'unreviewed' | 'safe' | 'review' | 'low' | 'accepted' | 'rejected'; -type AIJob = { - id: string; - kind: string; - status: 'queued' | 'running' | 'canceling' | 'succeeded' | 'failed' | 'canceled'; - progress: number; - message: string; - logs?: Array<{ time: string; message: string }>; - result?: T; - error?: string; -}; - -type AIJobContext = { - label: string; - draftId?: string; - inputFingerprint?: string; -}; - -type AIJobRunContext = { - id: number; - workspaceEpoch: number; -}; - -type ActiveAIJob = AIJob & AIJobContext & { - workspaceEpoch: number; - runId: number; -}; - type ClipDiscoveryResult = { clips?: ClipSuggestion[]; requestedCount?: number; @@ -150,7 +128,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { setClipSuggestions, setClipDrafts, setClipReviewDecisions, - setProcessing, } = useAIStore(); const [activeTab, setActiveTab] = useState<'edit' | 'filler' | 'clips'>(mode === 'clips' ? 'clips' : 'edit'); @@ -160,11 +137,17 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { ); const [fillerQueueFilter, setFillerQueueFilter] = useState('all'); const [fillerReasonFilter, setFillerReasonFilter] = useState('all'); - const [activeAIJob, setActiveAIJob] = useState(null); const [backgroundCapabilities, setBackgroundCapabilities] = useState(null); const [activeClipDraftId, setActiveClipDraftId] = useState(null); const [activeClipPreviewKey, setActiveClipPreviewKey] = useState(null); const [creatorNotice, setCreatorNotice] = useState(null); + const { + activeAIJob, + cancelAIJob, + clearActiveAIJob, + retryAIJob: retryAIJobTransport, + startAIJob: startAIJobTransport, + } = useAIJobController({ backendUrl }); const { batchExportProgress, cancelDraftExport, @@ -184,41 +167,12 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { updateClipExportDirectory, } = useClipExportController({ setCreatorNotice }); const knownClipDraftIdsRef = useRef(new Set(clipDrafts.map((draft) => draft.id))); - const aiJobRunEpochRef = useRef(0); - const activeAIJobRunRef = useRef(null); const secondaryToolsVisible = mode === 'clips' && (showSecondaryTools || activeTab === 'edit' || activeTab === 'filler'); const isCurrentClipWorkspace = useCallback( () => useAIStore.getState().clipWorkspaceEpoch === clipWorkspaceEpoch, [clipWorkspaceEpoch], ); - const isCurrentAIJobRun = useCallback((run: AIJobRunContext) => { - const activeRun = activeAIJobRunRef.current; - return ( - activeRun?.id === run.id && - activeRun.workspaceEpoch === run.workspaceEpoch && - useAIStore.getState().clipWorkspaceEpoch === run.workspaceEpoch - ); - }, []); - const beginAIJobRun = useCallback(() => { - const workspaceEpoch = useAIStore.getState().clipWorkspaceEpoch; - const activeRun = activeAIJobRunRef.current; - if (activeRun?.workspaceEpoch === workspaceEpoch) { - throw new Error('Another AI action is still running. Wait for it to finish or cancel it first.'); - } - const run = { - id: aiJobRunEpochRef.current + 1, - workspaceEpoch, - }; - aiJobRunEpochRef.current = run.id; - activeAIJobRunRef.current = run; - return run; - }, []); - const finishAIJobRun = useCallback((run: AIJobRunContext) => { - if (activeAIJobRunRef.current?.id === run.id) { - activeAIJobRunRef.current = null; - } - }, []); const activeReviewPreviewKey = isPlaying && previewRangeEnd !== null ? activeClipPreviewKey : null; @@ -228,15 +182,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { setShowSecondaryTools(false); }, [mode]); - useEffect(() => { - setActiveAIJob((current) => - current && current.workspaceEpoch !== clipWorkspaceEpoch ? null : current, - ); - if (activeAIJobRunRef.current?.workspaceEpoch !== clipWorkspaceEpoch) { - activeAIJobRunRef.current = null; - } - }, [clipWorkspaceEpoch]); - useEffect(() => { if (mode === 'clips') return () => clearClipPresentationPreview(); clearClipPresentationPreview(); @@ -443,67 +388,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { return turns; }, [words]); - const pollAIJob = useCallback( - async (jobId: string, fallbackMessage: string, context: AIJobContext, run: AIJobRunContext) => { - for (;;) { - await new Promise((resolve) => window.setTimeout(resolve, 700)); - if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); - let jobRes: Response; - try { - jobRes = await fetch(`${backendUrl}/jobs/${jobId}`); - } catch { - if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); - setActiveAIJob((current) => - current?.runId === run.id - ? { ...current, message: 'Connection interrupted; checking AI job status...' } - : current, - ); - setProcessing(true, 'Connection interrupted; checking AI job status...'); - continue; - } - if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); - if (!jobRes.ok) { - if (jobRes.status === 404) throw new Error(`${fallbackMessage} job is no longer available`); - setActiveAIJob((current) => - current?.runId === run.id - ? { ...current, message: 'AI job status unavailable; retrying...' } - : current, - ); - setProcessing(true, 'AI job status unavailable; retrying...'); - continue; - } - let job: AIJob; - try { - job = (await jobRes.json()) as AIJob; - } catch { - if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); - setActiveAIJob((current) => - current?.runId === run.id - ? { ...current, message: 'AI job status was unreadable; retrying...' } - : current, - ); - setProcessing(true, 'AI job status was unreadable; retrying...'); - continue; - } - if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); - setActiveAIJob({ ...job, ...context, workspaceEpoch: run.workspaceEpoch, runId: run.id }); - setProcessing( - job.status === 'queued' || job.status === 'running' || job.status === 'canceling', - job.message || fallbackMessage, - ); - - if (job.status === 'succeeded') { - if (!job.result) throw new Error(`${fallbackMessage} finished without a result`); - return job.result; - } - if (job.status === 'failed' || job.status === 'canceled') { - throw new Error(job.error || job.message || `${fallbackMessage} ${job.status}`); - } - } - }, - [backendUrl, isCurrentAIJobRun, setProcessing], - ); - const startAIJob = useCallback( async ( path: string, @@ -512,49 +396,10 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { context?: Partial, processingMessage = fallbackMessage, ) => { - const run = beginAIJobRun(); setCreatorNotice(null); - setProcessing(true, processingMessage); - try { - const startRes = await fetch(`${backendUrl}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was starting.'); - if (!startRes.ok) { - const errorData = await startRes.json().catch(() => null); - if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was starting.'); - throw new Error(errorData?.detail || `${fallbackMessage} start failed`); - } - - const { job_id: jobId } = await startRes.json(); - if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was starting.'); - const jobContext = { - label: context?.label || fallbackMessage, - draftId: context?.draftId, - inputFingerprint: context?.inputFingerprint, - }; - setActiveAIJob({ - id: jobId, - kind: path.replace('/jobs/', ''), - status: 'queued', - progress: 0, - message: 'Queued', - logs: [], - ...jobContext, - workspaceEpoch: run.workspaceEpoch, - runId: run.id, - }); - return await pollAIJob(jobId, fallbackMessage, jobContext, run); - } finally { - if (isCurrentAIJobRun(run)) { - finishAIJobRun(run); - setProcessing(false); - } - } + return startAIJobTransport(path, body, fallbackMessage, context, processingMessage); }, - [backendUrl, beginAIJobRun, finishAIJobRun, isCurrentAIJobRun, pollAIJob, setProcessing], + [startAIJobTransport], ); const createEditPlan = useCallback(async () => { @@ -696,27 +541,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { } }, [isCurrentClipWorkspace, setClipDrafts, setClipReviewDecisions, setClipStage, setClipSuggestions]); - const cancelAIJob = useCallback(async () => { - const job = activeAIJob; - const run = activeAIJobRunRef.current; - if (!job || !run || !['queued', 'running'].includes(job.status)) return; - if (job.workspaceEpoch !== run.workspaceEpoch || job.runId !== run.id || !isCurrentAIJobRun(run)) return; - const res = await fetch(`${backendUrl}/jobs/${job.id}/cancel`, { method: 'POST' }); - if (!isCurrentAIJobRun(run)) return; - if (res.ok) { - const canceledJob = (await res.json()) as AIJob; - if (!isCurrentAIJobRun(run)) return; - setActiveAIJob({ - ...canceledJob, - label: job.label, - draftId: job.draftId, - inputFingerprint: job.inputFingerprint, - workspaceEpoch: job.workspaceEpoch, - runId: job.runId, - }); - } - }, [activeAIJob, backendUrl, isCurrentAIJobRun]); - const detectFillers = useCallback(async () => { if (words.length === 0) return; try { @@ -1163,7 +987,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const sourceJob = activeAIJob; if (!sourceJob || !['failed', 'canceled'].includes(sourceJob.status)) return; if (sourceJob.workspaceEpoch !== useAIStore.getState().clipWorkspaceEpoch) { - setActiveAIJob(null); + clearActiveAIJob(); return; } if (sourceJob.kind === 'ai:clip-metadata') { @@ -1176,85 +1000,48 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { !sourceJob.inputFingerprint || getClipMetadataInputFingerprint(currentDraft, currentWords) !== sourceJob.inputFingerprint ) { - setActiveAIJob(null); + clearActiveAIJob(); return; } } - let run: AIJobRunContext; - try { - run = beginAIJobRun(); - } catch (err) { - setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) }); - return; - } setCreatorNotice(null); - setProcessing(true, `Retrying ${sourceJob.label}...`); try { - const retryRes = await fetch(`${backendUrl}/jobs/${sourceJob.id}/retry`, { method: 'POST' }); - if (!isCurrentAIJobRun(run)) return; - if (!retryRes.ok) throw new Error(`Retry failed: ${retryRes.statusText}`); - const { job_id: jobId } = await retryRes.json(); - if (!isCurrentAIJobRun(run)) return; - const context = { - label: sourceJob.label, - draftId: sourceJob.draftId, - inputFingerprint: sourceJob.inputFingerprint, - }; - setActiveAIJob({ - id: jobId, - kind: sourceJob.kind, - status: 'queued', - progress: 0, - message: 'Queued', - logs: [], - ...context, - workspaceEpoch: run.workspaceEpoch, - runId: run.id, - }); - const result = await pollAIJob(jobId, sourceJob.label, context, run); - if (!isCurrentAIJobRun(run)) return; + const retryResult = await retryAIJobTransport(); + if (!retryResult) return; + const { sourceJob: retriedJob, result } = retryResult; + if (retriedJob.workspaceEpoch !== useAIStore.getState().clipWorkspaceEpoch) return; - if (sourceJob.kind === 'ai:filler-removal') { + if (retriedJob.kind === 'ai:filler-removal') { setFillerResult(result as FillerWordResult); - } else if (sourceJob.kind === 'ai:create-clip') { + } else if (retriedJob.kind === 'ai:create-clip') { applyClipDiscoveryResult(result as ClipDiscoveryResult, 'suggested_clip_retry'); - } else if (sourceJob.kind === 'ai:edit-plan') { + } else if (retriedJob.kind === 'ai:edit-plan') { setEditPlanResult(result as EditPlanResult); - } else if (sourceJob.kind === 'ai:clip-metadata' && sourceJob.draftId) { + } else if (retriedJob.kind === 'ai:clip-metadata' && retriedJob.draftId) { const metadata = result as ClipMetadataResult; - const currentDraft = useAIStore.getState().clipDrafts.find((item) => item.id === sourceJob.draftId); + const currentDraft = useAIStore.getState().clipDrafts.find((item) => item.id === retriedJob.draftId); const currentWords = useEditorStore.getState().words; if ( !currentDraft || - !sourceJob.inputFingerprint || - getClipMetadataInputFingerprint(currentDraft, currentWords) !== sourceJob.inputFingerprint + !retriedJob.inputFingerprint || + getClipMetadataInputFingerprint(currentDraft, currentWords) !== retriedJob.inputFingerprint ) return; const patch = mergeGeneratedPublishingCopy(currentDraft, metadata); if (!patch) throw new Error('Publishing copy generation returned no usable copy.'); updateClipDraft(currentDraft.id, patch); } } catch (err) { - if (!isCurrentAIJobRun(run)) return; console.error(err); setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) }); - } finally { - if (isCurrentAIJobRun(run)) { - finishAIJobRun(run); - setProcessing(false); - } } }, [ activeAIJob, applyClipDiscoveryResult, - backendUrl, - beginAIJobRun, - finishAIJobRun, - isCurrentAIJobRun, - pollAIJob, + clearActiveAIJob, + retryAIJobTransport, setEditPlanResult, setFillerResult, - setProcessing, updateClipDraft, ]); diff --git a/frontend/src/features/ai/useAIJobController.ts b/frontend/src/features/ai/useAIJobController.ts new file mode 100644 index 0000000..aa690e9 --- /dev/null +++ b/frontend/src/features/ai/useAIJobController.ts @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useAIStore } from '../../store/aiStore'; + +export type AIJob = { + id: string; + kind: string; + status: 'queued' | 'running' | 'canceling' | 'succeeded' | 'failed' | 'canceled'; + progress: number; + message: string; + logs?: Array<{ time: string; message: string }>; + result?: T; + error?: string; +}; + +export type AIJobContext = { + label: string; + draftId?: string; + inputFingerprint?: string; +}; + +type AIJobRunContext = { + id: number; + workspaceEpoch: number; +}; + +export type ActiveAIJob = AIJob & AIJobContext & { + workspaceEpoch: number; + runId: number; +}; + +type UseAIJobControllerOptions = { + backendUrl: string; +}; + +export function useAIJobController({ backendUrl }: UseAIJobControllerOptions) { + const clipWorkspaceEpoch = useAIStore((state) => state.clipWorkspaceEpoch); + const setProcessing = useAIStore((state) => state.setProcessing); + const [activeAIJob, setActiveAIJob] = useState(null); + const aiJobRunEpochRef = useRef(0); + const activeAIJobRunRef = useRef(null); + + const isCurrentAIJobRun = useCallback((run: AIJobRunContext) => { + const activeRun = activeAIJobRunRef.current; + return ( + activeRun?.id === run.id && + activeRun.workspaceEpoch === run.workspaceEpoch && + useAIStore.getState().clipWorkspaceEpoch === run.workspaceEpoch + ); + }, []); + + const beginAIJobRun = useCallback(() => { + const workspaceEpoch = useAIStore.getState().clipWorkspaceEpoch; + const activeRun = activeAIJobRunRef.current; + if (activeRun?.workspaceEpoch === workspaceEpoch) { + throw new Error('Another AI action is still running. Wait for it to finish or cancel it first.'); + } + const run = { + id: aiJobRunEpochRef.current + 1, + workspaceEpoch, + }; + aiJobRunEpochRef.current = run.id; + activeAIJobRunRef.current = run; + return run; + }, []); + + const finishAIJobRun = useCallback((run: AIJobRunContext) => { + if (activeAIJobRunRef.current?.id === run.id) { + activeAIJobRunRef.current = null; + } + }, []); + + useEffect(() => { + setActiveAIJob((current) => + current && current.workspaceEpoch !== clipWorkspaceEpoch ? null : current, + ); + if (activeAIJobRunRef.current?.workspaceEpoch !== clipWorkspaceEpoch) { + activeAIJobRunRef.current = null; + } + }, [clipWorkspaceEpoch]); + + const pollAIJob = useCallback( + async (jobId: string, fallbackMessage: string, context: AIJobContext, run: AIJobRunContext) => { + for (;;) { + await new Promise((resolve) => window.setTimeout(resolve, 700)); + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); + let jobRes: Response; + try { + jobRes = await fetch(`${backendUrl}/jobs/${jobId}`); + } catch { + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); + setActiveAIJob((current) => + current?.runId === run.id + ? { ...current, message: 'Connection interrupted; checking AI job status...' } + : current, + ); + setProcessing(true, 'Connection interrupted; checking AI job status...'); + continue; + } + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); + if (!jobRes.ok) { + if (jobRes.status === 404) throw new Error(`${fallbackMessage} job is no longer available`); + setActiveAIJob((current) => + current?.runId === run.id + ? { ...current, message: 'AI job status unavailable; retrying...' } + : current, + ); + setProcessing(true, 'AI job status unavailable; retrying...'); + continue; + } + let job: AIJob; + try { + job = (await jobRes.json()) as AIJob; + } catch { + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); + setActiveAIJob((current) => + current?.runId === run.id + ? { ...current, message: 'AI job status was unreadable; retrying...' } + : current, + ); + setProcessing(true, 'AI job status was unreadable; retrying...'); + continue; + } + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); + setActiveAIJob({ ...job, ...context, workspaceEpoch: run.workspaceEpoch, runId: run.id }); + setProcessing( + job.status === 'queued' || job.status === 'running' || job.status === 'canceling', + job.message || fallbackMessage, + ); + + if (job.status === 'succeeded') { + if (!job.result) throw new Error(`${fallbackMessage} finished without a result`); + return job.result; + } + if (job.status === 'failed' || job.status === 'canceled') { + throw new Error(job.error || job.message || `${fallbackMessage} ${job.status}`); + } + } + }, + [backendUrl, isCurrentAIJobRun, setProcessing], + ); + + const startAIJob = useCallback( + async ( + path: string, + body: unknown, + fallbackMessage: string, + context?: Partial, + processingMessage = fallbackMessage, + ) => { + const run = beginAIJobRun(); + setProcessing(true, processingMessage); + try { + const startRes = await fetch(`${backendUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was starting.'); + if (!startRes.ok) { + const errorData = await startRes.json().catch(() => null); + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was starting.'); + throw new Error(errorData?.detail || `${fallbackMessage} start failed`); + } + + const { job_id: jobId } = await startRes.json(); + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was starting.'); + const jobContext = { + label: context?.label || fallbackMessage, + draftId: context?.draftId, + inputFingerprint: context?.inputFingerprint, + }; + setActiveAIJob({ + id: jobId, + kind: path.replace('/jobs/', ''), + status: 'queued', + progress: 0, + message: 'Queued', + logs: [], + ...jobContext, + workspaceEpoch: run.workspaceEpoch, + runId: run.id, + }); + return await pollAIJob(jobId, fallbackMessage, jobContext, run); + } finally { + if (isCurrentAIJobRun(run)) { + finishAIJobRun(run); + setProcessing(false); + } + } + }, + [backendUrl, beginAIJobRun, finishAIJobRun, isCurrentAIJobRun, pollAIJob, setProcessing], + ); + + const cancelAIJob = useCallback(async () => { + const job = activeAIJob; + const run = activeAIJobRunRef.current; + if (!job || !run || !['queued', 'running'].includes(job.status)) return; + if (job.workspaceEpoch !== run.workspaceEpoch || job.runId !== run.id || !isCurrentAIJobRun(run)) return; + const res = await fetch(`${backendUrl}/jobs/${job.id}/cancel`, { method: 'POST' }); + if (!isCurrentAIJobRun(run)) return; + if (res.ok) { + const canceledJob = (await res.json()) as AIJob; + if (!isCurrentAIJobRun(run)) return; + setActiveAIJob({ + ...canceledJob, + label: job.label, + draftId: job.draftId, + inputFingerprint: job.inputFingerprint, + workspaceEpoch: job.workspaceEpoch, + runId: job.runId, + }); + } + }, [activeAIJob, backendUrl, isCurrentAIJobRun]); + + const retryAIJob = useCallback(async () => { + const sourceJob = activeAIJob; + if (!sourceJob || !['failed', 'canceled'].includes(sourceJob.status)) return null; + if (sourceJob.workspaceEpoch !== useAIStore.getState().clipWorkspaceEpoch) { + setActiveAIJob(null); + return null; + } + + const run = beginAIJobRun(); + setProcessing(true, `Retrying ${sourceJob.label}...`); + try { + const retryRes = await fetch(`${backendUrl}/jobs/${sourceJob.id}/retry`, { method: 'POST' }); + if (!isCurrentAIJobRun(run)) return null; + if (!retryRes.ok) throw new Error(`Retry failed: ${retryRes.statusText}`); + const { job_id: jobId } = await retryRes.json(); + if (!isCurrentAIJobRun(run)) return null; + const context = { + label: sourceJob.label, + draftId: sourceJob.draftId, + inputFingerprint: sourceJob.inputFingerprint, + }; + setActiveAIJob({ + id: jobId, + kind: sourceJob.kind, + status: 'queued', + progress: 0, + message: 'Queued', + logs: [], + ...context, + workspaceEpoch: run.workspaceEpoch, + runId: run.id, + }); + const result = await pollAIJob(jobId, sourceJob.label, context, run); + if (!isCurrentAIJobRun(run)) return null; + return { sourceJob, result }; + } finally { + if (isCurrentAIJobRun(run)) { + finishAIJobRun(run); + setProcessing(false); + } + } + }, [activeAIJob, backendUrl, beginAIJobRun, finishAIJobRun, isCurrentAIJobRun, pollAIJob, setProcessing]); + + const clearActiveAIJob = useCallback(() => { + setActiveAIJob(null); + }, []); + + return { + activeAIJob, + cancelAIJob, + clearActiveAIJob, + retryAIJob, + startAIJob, + }; +}