| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { useSyncExternalStore } from "react"; |
| import type { InteractiveApi } from "./api"; |
| import type { AutoGenerateResult } from "./types"; |
|
|
| type GenerateAllResult = AutoGenerateResult; |
|
|
| export type WizardProgressEventType = |
| | "rendering_started" |
| | "rendering_scene" |
| | "scene_rendered" |
| | "scene_skipped" |
| | "scene_render_failed" |
| | "library_build_started" |
| | "library_asset_rendered" |
| | "library_asset_failed" |
| | "library_build_done" |
| | "scene_linked" |
| |
| |
| |
| | "planner_thought" |
| | "workflow_started" |
| | "workflow_completed" |
| | "step_started" |
| | "step_completed" |
| | "step_failed" |
| | "llm_step_started" |
| | "llm_step_completed" |
| | "llm_step_failed"; |
|
|
| export interface ExpertLogEntry { |
| |
| id: number; |
| |
| ts: number; |
| |
| type: string; |
| |
| label: string; |
| |
| summary: string; |
| |
| payload: Record<string, unknown>; |
| |
| kind: "thought" | "step" | "llm" | "phase" | "render"; |
| |
| failed?: boolean; |
| } |
|
|
| export interface WizardProgressState { |
| |
| |
| active: boolean; |
| |
| genStep: number; |
| |
| renderTotal: number; |
| |
| renderDone: number; |
| |
| renderSkipped: number; |
| |
| currentSceneTitle: string; |
| |
| error: string | null; |
| |
| result: GenerateAllResult | null; |
| |
| |
| |
| |
| |
| experienceId: string | null; |
| |
| |
| |
| libraryTotal: number; |
| libraryDone: number; |
| libraryFailed: number; |
| |
| |
| |
| |
| expertLog: ExpertLogEntry[]; |
| } |
|
|
| const MAX_EXPERT_LOG = 200; |
|
|
| const initialState: WizardProgressState = { |
| active: false, |
| genStep: 0, |
| renderTotal: 0, |
| renderDone: 0, |
| renderSkipped: 0, |
| currentSceneTitle: "", |
| error: null, |
| result: null, |
| experienceId: null, |
| libraryTotal: 0, |
| libraryDone: 0, |
| libraryFailed: 0, |
| expertLog: [], |
| }; |
|
|
| let expertLogSeq = 0; |
|
|
| function _classifyEvent(type: string): ExpertLogEntry["kind"] { |
| if (type === "planner_thought") return "thought"; |
| if (type.startsWith("llm_step")) return "llm"; |
| if (type === "workflow_started" || type === "workflow_completed" |
| || type === "step_started" || type === "step_completed" |
| || type === "step_failed") return "step"; |
| if (type.startsWith("rendering_") || type.startsWith("scene_") |
| || type.startsWith("library_")) return "render"; |
| return "phase"; |
| } |
|
|
| function _summarizeEvent( |
| type: string, payload: Record<string, unknown>, |
| ): { label: string; summary: string } { |
| const p = payload || {}; |
| const peek = (k: string): string => { |
| const v = p[k]; |
| return typeof v === "string" ? v : v == null ? "" : String(v); |
| }; |
| switch (type) { |
| case "planner_thought": |
| return { label: peek("label") || "Planner thinking", |
| summary: [peek("path"), peek("error")].filter(Boolean).join(" Β· ") }; |
| case "workflow_started": |
| return { label: `Workflow β ${peek("workflow") || "?"}`, |
| summary: `${(p.step_ids as unknown[] || []).length} step(s)` }; |
| case "workflow_completed": |
| return { label: `Workflow ${peek("ok") === "true" || p.ok ? "complete" : "ended"}`, |
| summary: `${peek("duration_ms") || "?"} ms` }; |
| case "step_started": |
| return { label: `Step β ${peek("step_id") || peek("prompt_id") || "?"}`, |
| summary: `prompt=${peek("prompt_id") || "?"}` }; |
| case "step_completed": |
| return { label: `Step β ${peek("step_id") || "?"}`, |
| summary: peek("preview") || `${peek("duration_ms") || "?"} ms` }; |
| case "step_failed": |
| return { label: `Step β ${peek("step_id") || "?"}`, |
| summary: peek("reason") || "failed" }; |
| case "llm_step_started": |
| return { label: `LLM call β ${peek("step_id") || "?"}`, |
| summary: peek("user_preview") || peek("system_preview") || "" }; |
| case "llm_step_completed": |
| return { label: `LLM β ${peek("step_id") || "?"}`, |
| summary: peek("preview") || `${peek("output_chars") || "?"} chars` }; |
| case "llm_step_failed": |
| return { label: `LLM β ${peek("step_id") || "?"}`, |
| summary: peek("reason") || "failed" }; |
| case "rendering_scene": |
| return { label: `Rendering scene ${peek("index") || ""}/${peek("total") || ""}`, |
| summary: peek("title") }; |
| case "scene_rendered": |
| return { label: "Scene rendered", summary: peek("title") || peek("scene_id") }; |
| case "scene_skipped": |
| return { label: "Scene skipped", summary: peek("title") || peek("reason") || "" }; |
| case "scene_render_failed": |
| return { label: "Scene render failed", |
| summary: peek("reason") || peek("title") }; |
| default: |
| return { label: type, summary: "" }; |
| } |
| } |
|
|
| function _appendExpertLog( |
| type: string, payload: Record<string, unknown>, |
| ): void { |
| const { label, summary } = _summarizeEvent(type, payload); |
| const entry: ExpertLogEntry = { |
| id: ++expertLogSeq, |
| ts: Date.now(), |
| type, |
| label, |
| summary, |
| payload, |
| kind: _classifyEvent(type), |
| failed: type.endsWith("_failed"), |
| }; |
| const next = state.expertLog.length >= MAX_EXPERT_LOG |
| ? [...state.expertLog.slice(-MAX_EXPERT_LOG + 1), entry] |
| : [...state.expertLog, entry]; |
| setState({ expertLog: next }); |
| } |
|
|
| let state: WizardProgressState = { ...initialState }; |
| const subscribers = new Set<() => void>(); |
|
|
| function emit(): void { |
| subscribers.forEach((fn) => { |
| try { fn(); } |
| catch { } |
| }); |
| } |
|
|
| function setState(patch: Partial<WizardProgressState>): void { |
| state = { ...state, ...patch }; |
| emit(); |
| } |
|
|
| function subscribe(fn: () => void): () => void { |
| subscribers.add(fn); |
| return () => { subscribers.delete(fn); }; |
| } |
|
|
| function getSnapshot(): WizardProgressState { |
| return state; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function useWizardProgress(): WizardProgressState { |
| return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); |
| } |
|
|
| |
| |
| |
| |
| |
| const STEP_INDEX_BY_PHASE: Record<string, number> = { |
| started: 0, |
| generating_graph: 1, |
| graph_generated: 2, |
| persisting_nodes: 2, |
| persisting_edges: 2, |
| persisting_actions: 2, |
| seeding_rule: 2, |
| running_qa: 2, |
| qa_done: 2, |
| rendering_started: 3, |
| rendering_scene: 3, |
| scene_rendered: 3, |
| scene_skipped: 3, |
| scene_render_failed: 3, |
| scene_deferred: 3, |
| rendering_done: 3, |
| library_build_started: 3, |
| library_rendering_asset: 3, |
| library_asset_rendered: 3, |
| library_asset_failed: 3, |
| library_build_done: 3, |
| scene_linked: 4, |
| done: 4, |
| result: 4, |
| }; |
|
|
| function _stepIndexForPhase(phase: string): number | null { |
| const idx = STEP_INDEX_BY_PHASE[phase]; |
| return typeof idx === "number" ? idx : null; |
| } |
|
|
| |
|
|
| interface StartGenerationArgs { |
| api: InteractiveApi; |
| payload: Parameters<InteractiveApi["createExperience"]>[0]; |
| onCreated?: (experienceId: string) => void; |
| |
| |
| |
| totalSteps: number; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function startGeneration( |
| args: StartGenerationArgs, |
| ): Promise<GenerateAllResult | null> { |
| expertLogSeq = 0; |
| setState({ |
| ...initialState, |
| active: true, |
| }); |
|
|
| let createdId: string | null = null; |
| try { |
| const created = await args.api.createExperience(args.payload); |
| createdId = created.id; |
| setState({ experienceId: created.id, genStep: 1 }); |
| args.onCreated?.(created.id); |
|
|
| const result = await args.api.generateAllStream(created.id, { |
| onEvent: (ev) => { |
| |
| |
| |
| _appendExpertLog( |
| ev.type, |
| (ev.payload as Record<string, unknown>) || {}, |
| ); |
|
|
| const idx = _stepIndexForPhase(ev.type); |
| if (idx !== null) { |
| setState({ genStep: Math.max(state.genStep, idx) }); |
| } |
|
|
| if (ev.type === "rendering_started") { |
| const total = Number((ev.payload as any)?.total || 0); |
| setState({ renderTotal: total, renderDone: 0, renderSkipped: 0 }); |
| } |
|
|
| if (ev.type === "rendering_scene") { |
| const title = String((ev.payload as any)?.title || ""); |
| if (title) setState({ currentSceneTitle: title }); |
| } |
|
|
| if (ev.type === "scene_rendered") { |
| setState({ renderDone: state.renderDone + 1 }); |
| } |
|
|
| if (ev.type === "scene_skipped" || ev.type === "scene_render_failed") { |
| setState({ |
| renderDone: state.renderDone + 1, |
| renderSkipped: state.renderSkipped + 1, |
| }); |
| } |
|
|
| |
| |
| |
| if (ev.type === "library_build_started") { |
| const total = Number((ev.payload as any)?.total || 0); |
| setState({ libraryTotal: total, libraryDone: 0, libraryFailed: 0 }); |
| } |
| if (ev.type === "library_asset_rendered") { |
| setState({ libraryDone: state.libraryDone + 1 }); |
| } |
| if (ev.type === "library_asset_failed") { |
| setState({ |
| libraryDone: state.libraryDone + 1, |
| libraryFailed: state.libraryFailed + 1, |
| }); |
| } |
| }, |
| }); |
|
|
| setState({ |
| genStep: Math.max(state.genStep, args.totalSteps - 1), |
| result, |
| active: false, |
| }); |
| return result; |
| } catch (err) { |
| const message = |
| err instanceof Error ? err.message : "Generation failed."; |
| setState({ |
| error: message, |
| active: false, |
| experienceId: createdId, |
| }); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function clearGeneration(): void { |
| state = { ...initialState }; |
| emit(); |
| } |
|
|
| |
| |
| |
| |
| export function dismissOverlay(): void { |
| setState({ active: false }); |
| } |
|
|