/** Drives an LLM-backed episode: starts a session, then loops `llm-step` * calls (run / pause / step-once / end) and accumulates `LlmTurn` records. * Render-agnostic; consumed by `RunWithLlmPane`. */ import { useCallback, useEffect, useRef, useState } from "react"; import { InteractiveApiError, type InteractiveClient, type LlmModelInfo, type LlmStepResponse, type SystemDescriptor, } from "@/lib/interactiveClient"; import { InteractiveClient as DefaultClient } from "@/lib/interactiveClient"; import type { LlmConnection } from "@/lib/llmPresets"; import { OLLAMA_OPENAI_BASE_URL } from "@/lib/llmPresets"; import type { PhysiXAction, PhysiXObservation, TrajectorySample, } from "@/types/physix"; export type RunnerStatus = | "idle" | "starting" | "running" | "paused" | "ended" | "error"; export interface LlmTurn { turn: number; action: PhysiXAction; observation: PhysiXObservation; predictedTrajectory: TrajectorySample[]; rawCompletion: string; latencyS: number; model: string; } export interface LlmEpisodeRunnerState { status: RunnerStatus; errorMessage: string | null; systems: SystemDescriptor[] | null; /** Locally-pulled Ollama model tags (`null` = still loading). */ models: LlmModelInfo[] | null; /** Set when the server couldn't talk to Ollama; UI surfaces a hint. */ modelsError: string | null; /** Resolved system_id of the active episode (server-decided if user passed none). */ systemId: string | null; sessionId: string | null; /** Reset observation; trajectory + hint live here for the whole episode. */ initialObservation: PhysiXObservation | null; turns: LlmTurn[]; maxTurns: number; } export interface LlmEpisodeRunnerControls { refreshCatalogue: () => Promise; refreshModels: () => Promise; start: (options: { systemId?: string | undefined; seed?: number | undefined; maxTurns?: number | undefined; connection: LlmConnection; temperature?: number | undefined; }) => Promise; /** Pause an autoplaying loop without ending the session. */ pause: () => void; /** Resume the loop from where it stopped. */ resume: () => Promise; /** Run one turn manually (also works while paused). */ stepOnce: () => Promise; /** End the session and clear local state. */ end: () => Promise; resetError: () => void; } const INITIAL_STATE: LlmEpisodeRunnerState = { status: "idle", errorMessage: null, systems: null, models: null, modelsError: null, systemId: null, sessionId: null, initialObservation: null, turns: [], maxTurns: 0, }; interface RunnerSettings { connection: LlmConnection; temperature: number; } const DEFAULT_SETTINGS: RunnerSettings = { connection: { endpointId: "ollama", baseUrl: OLLAMA_OPENAI_BASE_URL, model: "qwen2.5:3b-instruct", apiKey: "", }, temperature: 0.7, }; export function useLlmEpisodeRunner( clientOverride?: InteractiveClient, ): LlmEpisodeRunnerState & LlmEpisodeRunnerControls { const clientRef = useRef( clientOverride ?? new DefaultClient(), ); const [state, setState] = useState(INITIAL_STATE); const sessionIdRef = useRef(null); const settingsRef = useRef(DEFAULT_SETTINGS); // Keeps autoplay loops idempotent: when the user pauses or the episode // ends, we flip this so any in-flight chained call stops requesting more. const stopRef = useRef(false); // ---- Catalogue (mount + refresh) --------------------------------------- const refreshCatalogue = useCallback(async () => { try { const systems = await clientRef.current.listSystems(); setState((prev) => ({ ...prev, systems, errorMessage: null })); } catch (error) { setState((prev) => ({ ...prev, status: "error", errorMessage: extractMessage(error), })); } }, []); // Fetched separately from the system catalogue: a missing Ollama daemon // shouldn't blank out the system selector, and a missing systems catalogue // shouldn't blank out the model selector. const refreshModels = useCallback(async () => { try { const response = await clientRef.current.listModels(); setState((prev) => ({ ...prev, models: response.models, modelsError: response.error ?? null, })); } catch (error) { setState((prev) => ({ ...prev, models: [], modelsError: extractMessage(error), })); } }, []); useEffect(() => { void refreshCatalogue(); void refreshModels(); }, [refreshCatalogue, refreshModels]); // Best-effort cleanup if the user closes the tab mid-session. useEffect(() => { const handler = () => { const sessionId = sessionIdRef.current; if (!sessionId) return; const client = clientRef.current as unknown as { baseUrl: string }; const url = `${client.baseUrl}/interactive/sessions/${encodeURIComponent(sessionId)}`; try { navigator.sendBeacon?.(url); } catch { /* ignore */ } }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, []); // ---- Helpers ----------------------------------------------------------- const recordTurn = useCallback((response: LlmStepResponse) => { const turnRecord: LlmTurn = { turn: response.observation.turn, action: response.action, observation: response.observation, predictedTrajectory: response.predicted_trajectory, rawCompletion: response.raw_completion, latencyS: response.latency_s, model: response.model, }; setState((prev) => ({ ...prev, turns: [...prev.turns, turnRecord], })); return turnRecord; }, []); const callLlmStepOnce = useCallback(async (): Promise => { const sessionId = sessionIdRef.current; if (!sessionId) return null; const { connection, temperature } = settingsRef.current; try { const response = await clientRef.current.llmStep(sessionId, { base_url: connection.baseUrl, model: connection.model, api_key: connection.apiKey || undefined, temperature, }); return recordTurn(response); } catch (error) { setState((prev) => ({ ...prev, status: "error", errorMessage: extractMessage(error), })); return null; } }, [recordTurn]); const runUntilDone = useCallback(async () => { setState((prev) => ({ ...prev, status: "running", errorMessage: null })); stopRef.current = false; while (!stopRef.current) { const turn = await callLlmStepOnce(); if (turn === null) return; if (turn.observation.done) { stopRef.current = true; setState((prev) => ({ ...prev, status: "ended" })); return; } } // We exited the loop because the user paused. setState((prev) => prev.status === "running" ? { ...prev, status: "paused" } : prev, ); }, [callLlmStepOnce]); // ---- Controls ---------------------------------------------------------- const start = useCallback( async (options: { systemId?: string | undefined; seed?: number | undefined; maxTurns?: number | undefined; connection: LlmConnection; temperature?: number | undefined; }) => { // Tear down any prior session. const prior = sessionIdRef.current; if (prior) { sessionIdRef.current = null; try { await clientRef.current.endSession(prior); } catch { /* best-effort */ } } stopRef.current = true; settingsRef.current = { connection: options.connection, temperature: options.temperature ?? DEFAULT_SETTINGS.temperature, }; setState((prev) => ({ ...prev, status: "starting", errorMessage: null, sessionId: null, turns: [], initialObservation: null, })); try { const response = await clientRef.current.startSession({ system_id: options.systemId, seed: options.seed, max_turns: options.maxTurns, }); sessionIdRef.current = response.session_id; setState((prev) => ({ ...prev, status: "paused", // ready to run; caller decides when to start the loop systemId: response.system.system_id, sessionId: response.session_id, initialObservation: response.observation, maxTurns: response.max_turns, turns: [], })); // Kick off the run-to-done loop. Caller can pause at any time. void runUntilDone(); } catch (error) { setState((prev) => ({ ...prev, status: "error", errorMessage: extractMessage(error), })); } }, [runUntilDone], ); const pause = useCallback(() => { stopRef.current = true; setState((prev) => prev.status === "running" ? { ...prev, status: "paused" } : prev, ); }, []); const resume = useCallback(async () => { if (!sessionIdRef.current) return; if (stopRef.current === false) return; // already running void runUntilDone(); }, [runUntilDone]); const stepOnce = useCallback(async () => { if (!sessionIdRef.current) return; stopRef.current = true; // make sure no loop is queueing setState((prev) => ({ ...prev, status: "running", errorMessage: null })); const turn = await callLlmStepOnce(); if (turn === null) return; setState((prev) => ({ ...prev, status: turn.observation.done ? "ended" : "paused", })); }, [callLlmStepOnce]); const end = useCallback(async () => { stopRef.current = true; const sessionId = sessionIdRef.current; sessionIdRef.current = null; if (sessionId) { try { await clientRef.current.endSession(sessionId); } catch { /* best-effort */ } } setState((prev) => ({ ...prev, status: "idle", sessionId: null, turns: [], initialObservation: null, })); }, []); const resetError = useCallback(() => { setState((prev) => ({ ...prev, errorMessage: null })); }, []); return { ...state, refreshCatalogue, refreshModels, start, pause, resume, stepOnce, end, resetError, }; } function extractMessage(error: unknown): string { if (error instanceof InteractiveApiError) return error.detail; if (error instanceof Error) return error.message; return "Unknown error"; }