Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/studio/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ export interface PipelineStatus {
completedAt?: number
}

export interface ProofStatus {
label: string
status: "idle" | "running" | "completed" | "failed"
error?: string
startedAt?: number
completedAt?: number
}

export interface RunPipelineOptions {
startPage?: number
endPage?: number
Expand Down Expand Up @@ -116,6 +124,9 @@ export interface PageDetail {
rendering: {
sections: SectionRendering[]
} | null
imageCaptioning: {
captions: Array<{ imageId: string; reasoning: string; caption: string }>
} | null
}

// --- Debug types ---
Expand Down Expand Up @@ -316,6 +327,15 @@ export const api = {
body: JSON.stringify({ config }),
}),

runProof: (label: string, apiKey: string) =>
request<{ status: string; label: string }>(
`/books/${label}/proof/run`,
{ method: "POST", headers: { "X-OpenAI-Key": apiKey } }
),

getProofStatus: (label: string) =>
request<ProofStatus>(`/books/${label}/proof/status`),

acceptStoryboard: (label: string) =>
request<{ version: number; acceptedAt: string }>(
`/books/${label}/accept-storyboard`,
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/components/debug/LlmLogsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const STEPS = [
"image-classification",
"page-sectioning",
"web-rendering",
"image-captioning",
] as const

const STEP_LABELS: Record<StepName, string> = {
Expand All @@ -34,6 +35,7 @@ const STEP_LABELS: Record<StepName, string> = {
"image-classification": "Images",
"page-sectioning": "Sections",
"web-rendering": "Render",
"image-captioning": "Captions",
}

interface LlmLogsTabProps {
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/components/pipeline/StepIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const STEP_LABELS: Record<StepName, string> = {
"image-classification": "Classify Images",
"page-sectioning": "Section Pages",
"web-rendering": "Render Pages",
"image-captioning": "Caption Images",
}

export { STEP_ORDER, STEP_LABELS }
Expand Down
102 changes: 102 additions & 0 deletions apps/studio/src/components/proof/ProofProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { CheckCircle2, XCircle, Play } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { StepIndicator, STEP_LABELS } from "@/components/pipeline/StepIndicator"
import type { PipelineProgress } from "@/hooks/use-pipeline"
import type { StepName } from "@/hooks/use-pipeline"

interface ProofProgressProps {
progress: PipelineProgress
onRun: () => void
isStarting: boolean
hasApiKey: boolean
}

function getStepState(
step: StepName,
progress: PipelineProgress
): "pending" | "active" | "completed" | "error" {
if (progress.completedSteps.has(step)) return "completed"
if (progress.stepProgress.has(step)) return "active"
if (progress.currentStep === step) return "active"
if (progress.error?.startsWith(step)) return "error"
return "pending"
}

const PROOF_STEP: StepName = "image-captioning"

export function ProofProgress({
progress,
onRun,
isStarting,
hasApiKey,
}: ProofProgressProps) {
const { isRunning, isComplete, error } = progress

return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
{isComplete && (
<CheckCircle2 className="h-5 w-5 text-green-600" />
)}
{error && !isRunning && (
<XCircle className="h-5 w-5 text-destructive" />
)}
Proof Phase
</CardTitle>
<CardDescription className="mt-1">
{!isRunning && !isComplete && !error &&
"Generate image captions using LLM analysis."}
{isRunning && "Proof phase is running..."}
{isComplete && "Proof phase completed. Check captions in page detail."}
{error && !isRunning && `Proof failed: ${error}`}
</CardDescription>
</div>
{!isRunning && (
<Button
onClick={onRun}
disabled={isStarting || !hasApiKey}
size="sm"
>
<Play className="mr-2 h-4 w-4" />
{isStarting
? "Starting..."
: isComplete
? "Re-run"
: error
? "Retry"
: "Run Proof"}
</Button>
)}
</div>
</CardHeader>
<CardContent>
{(isRunning || isComplete || error) && (
<div className="mb-4">
<StepIndicator
step={PROOF_STEP}
label={STEP_LABELS[PROOF_STEP]}
state={getStepState(PROOF_STEP, progress)}
progress={progress.stepProgress.get(PROOF_STEP)}
/>
</div>
)}

{!hasApiKey && !isRunning && (
<p className="mt-2 text-xs text-muted-foreground">
Enter your OpenAI API key to run the proof phase.
</p>
)}
</CardContent>
</Card>
)
}
1 change: 1 addition & 0 deletions apps/studio/src/hooks/use-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type StepName =
| "image-classification"
| "page-sectioning"
| "web-rendering"
| "image-captioning"

export interface StepProgress {
step: StepName
Expand Down
208 changes: 208 additions & 0 deletions apps/studio/src/hooks/use-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { useState, useEffect, useCallback, useRef } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api } from "@/api/client"
import type { PipelineProgress, StepProgress, LlmLogSummary, StepName } from "./use-pipeline"

const MAX_LIVE_LOGS = 500

const INITIAL_PROGRESS: PipelineProgress = {
isRunning: false,
isComplete: false,
error: null,
currentStep: null,
completedSteps: new Set(),
stepProgress: new Map(),
liveLlmLogs: [],
}

/**
* Hook to subscribe to real-time proof progress via SSE.
* Same event format as pipeline SSE — reuses PipelineProgress state shape.
*/
export function useProofSSE(label: string, enabled: boolean) {
const [progress, setProgress] = useState<PipelineProgress>(INITIAL_PROGRESS)
const queryClient = useQueryClient()
const eventSourceRef = useRef<EventSource | null>(null)

useEffect(() => {
if (!enabled || !label) {
return
}

// Don't reset progress on reconnect — preserve previously received step data.
// Fresh runs call reset() before enabling SSE, which handles the clean-slate case.
setProgress((prev) => ({
...prev,
isRunning: true,
isComplete: false,
error: null,
}))

const url = `/api/books/${label}/proof/status`
const es = new EventSource(url)
eventSourceRef.current = es

es.addEventListener("progress", (e) => {
const data = JSON.parse(e.data)
setProgress((prev) => {
const next = { ...prev }
const stepProgress = new Map(prev.stepProgress)
const completedSteps = new Set(prev.completedSteps)

if (data.type === "step-start") {
next.currentStep = data.step as StepName
} else if (data.type === "step-progress") {
stepProgress.set(data.step as StepName, {
step: data.step,
page: data.page,
totalPages: data.totalPages,
message: data.message,
} as StepProgress)
next.currentStep = data.step as StepName
} else if (data.type === "step-complete") {
completedSteps.add(data.step as StepName)
stepProgress.delete(data.step as StepName)
} else if (data.type === "step-error") {
next.error = `${data.step}: ${data.error}`
} else if (data.type === "llm-log") {
const entry: LlmLogSummary = {
step: data.step,
itemId: data.itemId,
promptName: data.promptName,
modelId: data.modelId,
cacheHit: data.cacheHit,
durationMs: data.durationMs,
inputTokens: data.inputTokens,
outputTokens: data.outputTokens,
validationErrors: data.validationErrors,
receivedAt: Date.now(),
}
const logs = [...prev.liveLlmLogs, entry]
next.liveLlmLogs = logs.length > MAX_LIVE_LOGS ? logs.slice(-MAX_LIVE_LOGS) : logs
}

next.stepProgress = stepProgress
next.completedSteps = completedSteps
return next
})
})

es.addEventListener("complete", () => {
setProgress((prev) => ({
...prev,
isRunning: false,
isComplete: true,
currentStep: null,
}))
queryClient.invalidateQueries({ queryKey: ["proof-status", label] })
queryClient.invalidateQueries({ queryKey: ["books", label] })
queryClient.invalidateQueries({ queryKey: ["books"] })
queryClient.invalidateQueries({ queryKey: ["books", label, "pages"] })
queryClient.invalidateQueries({ queryKey: ["debug"] })
es.close()
})

es.addEventListener("error", (e) => {
if (es.readyState === EventSource.CLOSED) {
return
}
const messageEvent = e as MessageEvent
if (messageEvent.data) {
try {
const data = JSON.parse(messageEvent.data)
setProgress((prev) => ({
...prev,
isRunning: false,
error: data.error ?? "Proof failed",
}))
} catch {
setProgress((prev) => ({
...prev,
isRunning: false,
error: "Connection lost",
}))
}
es.close()
}
})

const pollInterval = setInterval(async () => {
try {
const status = await api.getProofStatus(label)
if (status.status === "completed") {
setProgress((prev) => {
if (!prev.isRunning) return prev
return {
...prev,
isRunning: false,
isComplete: true,
currentStep: null,
}
})
queryClient.invalidateQueries({ queryKey: ["proof-status", label] })
queryClient.invalidateQueries({ queryKey: ["books", label] })
queryClient.invalidateQueries({ queryKey: ["books"] })
queryClient.invalidateQueries({ queryKey: ["books", label, "pages"] })
queryClient.invalidateQueries({ queryKey: ["debug"] })
es.close()
clearInterval(pollInterval)
} else if (status.status === "failed") {
setProgress((prev) => {
if (!prev.isRunning) return prev
return {
...prev,
isRunning: false,
error: status.error ?? "Proof failed",
}
})
es.close()
clearInterval(pollInterval)
}
} catch {
// Polling failed, will retry next interval
}
}, 10000)

return () => {
es.close()
eventSourceRef.current = null
clearInterval(pollInterval)
}
}, [label, enabled, queryClient])

const reset = useCallback(() => {
setProgress(INITIAL_PROGRESS)
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
}, [])

return { progress, reset }
}

/**
* Hook to start a proof run.
*/
export function useRunProof() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ label, apiKey }: { label: string; apiKey: string }) =>
api.runProof(label, apiKey),
onSuccess: (_data, { label }) => {
queryClient.invalidateQueries({ queryKey: ["proof-status", label] })
},
})
}

/**
* Hook to poll proof status (non-SSE fallback).
*/
export function useProofStatus(label: string) {
return useQuery({
queryKey: ["proof-status", label],
queryFn: () => api.getProofStatus(label),
enabled: !!label,
refetchInterval: false,
})
}
Loading