diff --git a/.gitignore b/.gitignore index 9f970eb..c2abf00 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,12 @@ Thumbs.db # Database omnia.db +*.db +*.db-journal +*.db-wal +*.db-shm +**/data/*.db +data/ # Environment Files .env diff --git a/README.md b/README.md index 64c4f77..efd2484 100644 --- a/README.md +++ b/README.md @@ -136,11 +136,11 @@ omnia/ memory/ verbatim buffer; later the vector archive, dossier, and affect vectors spatial/ location and POI graph, portal-based perception llm/ ILLMProvider interface plus Gemini and deterministic mock implementations + scenario/ scenario JSON schema and loader (JSON → SQLite) + apps/ + cli/ the playable loop (human or LLM actors, --scenario / --play flags) content/ - scenario-core/ scenario JSON schema and loader (JSON → SQLite) - scenario-builder/ Next.js web UI for authoring worlds demo/ bundled scenarios (talking-room) - cli/ the playable loop (human or LLM actors, --scenario / --play flags) tests/ integration/ cross-package tests against a mocked LLM evals/ deliberate real-API evaluation runs diff --git a/content/scenario-builder/next-env.d.ts b/apps/gui/next-env.d.ts similarity index 100% rename from content/scenario-builder/next-env.d.ts rename to apps/gui/next-env.d.ts diff --git a/apps/gui/next.config.ts b/apps/gui/next.config.ts new file mode 100644 index 0000000..0de3a19 --- /dev/null +++ b/apps/gui/next.config.ts @@ -0,0 +1,30 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: [ + "@omnia/core", + "@omnia/llm", + "@omnia/intent", + "@omnia/architect", + "@omnia/actor", + "@omnia/memory", + "@omnia/spatial", + "@omnia/scenario", + ], + serverExternalPackages: ["better-sqlite3"], + allowedDevOrigins: ["192.168.0.18", "localhost", "127.0.0.1"], + experimental: { + serverActions: { + allowedOrigins: [ + "192.168.0.18:3000", + "192.168.0.18:3001", + "192.168.0.18:3002", + "localhost:3000", + "localhost:3001", + "localhost:3002", + ], + }, + }, +}; + +export default nextConfig; diff --git a/apps/gui/package.json b/apps/gui/package.json new file mode 100644 index 0000000..e92d50f --- /dev/null +++ b/apps/gui/package.json @@ -0,0 +1,32 @@ +{ + "name": "@omnia/gui", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@omnia/core": "workspace:*", + "@omnia/llm": "workspace:*", + "@omnia/intent": "workspace:*", + "@omnia/architect": "workspace:*", + "@omnia/actor": "workspace:*", + "@omnia/memory": "workspace:*", + "@omnia/spatial": "workspace:*", + "@omnia/scenario": "workspace:*", + "dotenv": "^17.4.2", + "next": "^16.2.10", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^6.0.3" + } +} diff --git a/apps/gui/src/app/config/page.tsx b/apps/gui/src/app/config/page.tsx new file mode 100644 index 0000000..879d696 --- /dev/null +++ b/apps/gui/src/app/config/page.tsx @@ -0,0 +1,777 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { + getConfigStatus, + listProviderInstances, + createProviderInstance, + deleteProviderInstance, + setActiveProviderInstance, + getProviderMappings, + setProviderMapping, + updateProviderInstance, +} from "@/app/play/actions"; +import type { LLMProviderInstance } from "@omnia/llm"; + +interface ConfigStatus { + apiKeySet: boolean; + apiKeyPreview: string; + model: string; + availableScenarios: { path: string; name: string }[]; +} + +export default function ConfigPage() { + const [config, setConfig] = useState(null); + const [instances, setInstances] = useState([]); + const [mappings, setMappings] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const [selectedInstanceId, setSelectedInstanceId] = useState("new"); + const [editName, setEditName] = useState(""); + const [editProvider, setEditProvider] = useState("google-genai"); + const [editKey, setEditKey] = useState(""); + const [editModel, setEditModel] = useState("gemini-2.5-flash"); + const [editIsActive, setEditIsActive] = useState(false); + + useEffect(() => { + if (selectedInstanceId === "new") { + setEditName(""); + setEditProvider("google-genai"); + setEditKey(""); + setEditModel("gemini-2.5-flash"); + setEditIsActive(false); + } else { + const inst = instances.find((i) => i.id === selectedInstanceId); + if (inst) { + setEditName(inst.name); + setEditProvider(inst.providerName); + setEditKey(""); + setEditModel(inst.modelName || "gemini-2.5-flash"); + setEditIsActive(inst.isActive); + } + } + }, [selectedInstanceId, instances]); + + const loadInstances = useCallback(async () => { + try { + const list = await listProviderInstances(); + setInstances(list); + } catch { + // ignore + } + }, []); + + const loadMappings = useCallback(async () => { + try { + const maps = await getProviderMappings(); + setMappings(maps); + } catch { + // ignore + } + }, []); + + const loadAll = useCallback(async () => { + setLoading(true); + setError(""); + try { + const result = await getConfigStatus(); + setConfig(result); + await loadInstances(); + await loadMappings(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [loadInstances, loadMappings]); + + useEffect(() => { + loadAll(); + }, [loadAll]); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editName.trim()) { + setError("Name is required."); + return; + } + + try { + setLoading(true); + setError(""); + + if (selectedInstanceId === "new") { + if (!editKey.trim()) { + setError("API Key is required for new instances."); + setLoading(false); + return; + } + const created = await createProviderInstance(editName, editProvider, editKey, editModel || undefined); + if (editIsActive) { + await setActiveProviderInstance(created.id); + } + setSelectedInstanceId(created.id); + } else { + await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined, editModel || undefined); + if (editIsActive) { + await setActiveProviderInstance(selectedInstanceId); + } + } + + await loadInstances(); + await loadMappings(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + if (selectedInstanceId === "new") return; + if (!confirm("Are you sure you want to delete this provider instance?")) return; + + try { + setLoading(true); + setError(""); + await deleteProviderInstance(selectedInstanceId); + setSelectedInstanceId("new"); + await loadInstances(); + await loadMappings(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + const handleUpdateMapping = async (task: string, providerInstanceId: string) => { + try { + setLoading(true); + await setProviderMapping(task, providerInstanceId); + await loadMappings(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + return ( +
+

Configuration

+ + {loading &&

Loading configuration...

} + {error &&
{error}
} + + {config && !loading && ( + <> +
+

LLM Provider Instances

+
+ + {/* 30% area */} +
+
+

Instances

+ +
+
+ {instances.length === 0 ? ( +
No instances configured
+ ) : ( + instances.map((inst) => ( +
setSelectedInstanceId(inst.id)} + className={`instance-list-item ${selectedInstanceId === inst.id ? "active" : ""}`} + > +
{inst.name}
+
+ {inst.providerName} + {inst.isActive && Active} +
+
+ )) + )} +
+
+ + {/* 70% area */} +
+
+
+

+ {selectedInstanceId === "new" + ? "Create New Provider Instance" + : `Configure: ${editName}`} +

+ +
+ + setEditName(e.target.value)} + placeholder="e.g. Gemini - Production" + required + /> +
+ +
+ + +
+ +
+ + setEditKey(e.target.value)} + placeholder={ + selectedInstanceId === "new" + ? "AIzaSy..." + : "•••••••• (unchanged)" + } + required={selectedInstanceId === "new"} + /> +
+ +
+ + setEditModel(e.target.value)} + placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro" + /> +
+ +
+ setEditIsActive(e.target.checked)} + /> + +
+
+ +
+
+ {selectedInstanceId !== "new" && ( + + )} +
+
+ +
+
+
+
+ +
+
+ +
+

Task Provider Routing

+

+ Configure which LLM Provider Key Instance should handle each specific simulation task. Mappings default to the currently Active instance if not specified. +

+
+ {[ + { key: "actor-prose", label: "Actor Prose Generation", desc: "Generates roleplay/narrative prose for Non-Player Characters." }, + { key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules." }, + { key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC)." }, + { key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock." }, + ].map((task) => ( +
+
+ {task.label} + {task.desc} +
+ +
+ ))} +
+
+ +
+

Environment Variables Default

+
+ Default Model + + {config.model} + +
+
+ Default API Key (.env) + + {config.apiKeySet + ? `✓ Set (${config.apiKeyPreview})` + : "✗ NOT SET"} + +
+
+ +
+

Available Scenarios

+ {config.availableScenarios.length === 0 ? ( +

+ No scenarios found in content/demo/scenarios/. +

+ ) : ( + + + + + + + + + {config.availableScenarios.map((s) => ( + + + + + ))} + +
NamePath
{s.name} + {s.path} +
+ )} +
+ +
+

Engine Packages

+

+ All @omnia/* workspace packages are consumed via{" "} + transpilePackages in next.config.ts. + The native better-sqlite3 module is externalized via{" "} + serverExternalPackages. +

+
+ + )} + + +
+ ); +} diff --git a/apps/gui/src/app/globals.css b/apps/gui/src/app/globals.css new file mode 100644 index 0000000..db28247 --- /dev/null +++ b/apps/gui/src/app/globals.css @@ -0,0 +1,18 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, sans-serif; + color: #111; + background: #fafafa; +} + +body { + min-height: 100dvh; +} diff --git a/apps/gui/src/app/layout.tsx b/apps/gui/src/app/layout.tsx new file mode 100644 index 0000000..32c6d04 --- /dev/null +++ b/apps/gui/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { NavBar } from "@/components/nav/NavBar"; +import "./globals.css"; + +export const metadata = { + title: "Omnia GUI", + description: "Omnia Narrative Simulation Engine — Web Interface", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + ); +} diff --git a/apps/gui/src/app/page.tsx b/apps/gui/src/app/page.tsx new file mode 100644 index 0000000..456eeda --- /dev/null +++ b/apps/gui/src/app/page.tsx @@ -0,0 +1,63 @@ +import Link from "next/link"; + +export default function Home() { + return ( +
+

Omnia GUI

+

+ Configuration and gameplay interface for the Omnia simulation engine. +

+
+ +

Play

+

Start a simulation and interact with NPCs

+ + +

Config

+

Check environment, API keys, and available scenarios

+ +
+ +
+ ); +} diff --git a/apps/gui/src/app/play/actions.ts b/apps/gui/src/app/play/actions.ts new file mode 100644 index 0000000..8f9423e --- /dev/null +++ b/apps/gui/src/app/play/actions.ts @@ -0,0 +1,276 @@ +"use server"; + +import path from "path"; +import fs from "fs"; +import { simulationManager } from "@/lib/simulation"; +import type { SimSnapshot } from "@/lib/simulation"; +import { ProviderManager, LLMProviderInstance } from "@omnia/llm"; + +function resolveScenarioPath(relative: string): string { + const cwd = process.cwd(); + const candidates = [ + path.resolve(cwd, relative), + path.resolve(cwd, "content/demo/scenarios", relative), + path.resolve(cwd, "../../", relative), + path.resolve(cwd, "../../content/demo/scenarios", relative), + ]; + for (const c of candidates) { + try { + if (fs.statSync(c).isFile()) return c; + } catch { + /* not found */ + } + } + return path.resolve(cwd, relative); +} + +type ActionResult = + | { ok: true; snapshot: SimSnapshot } + | { ok: false; error: string }; + +export async function startSimulation(input: { + scenario?: string; + playEntity?: string; + providerInstanceId?: string; +}): Promise { + try { + const scenarioFile = + input.scenario || "content/demo/scenarios/talking-room.json"; + + const resolved = resolveScenarioPath(scenarioFile); + if (!fs.existsSync(resolved)) { + return { ok: false, error: `Scenario file not found: ${scenarioFile}` }; + } + + const snapshot = await simulationManager.create( + resolved, + input.playEntity || undefined, + input.providerInstanceId, + ); + + if (snapshot.status === "error") { + return { ok: false, error: snapshot.error || "Unknown error" }; + } + + return { ok: true, snapshot }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function stepSimulation(input: { + simId: string; +}): Promise { + try { + if (!input.simId) { + return { ok: false, error: "Missing simId" }; + } + + const snapshot = await simulationManager.step(input.simId); + + if (!snapshot) { + return { ok: false, error: "Simulation session not found" }; + } + + if (snapshot.status === "error") { + return { ok: false, error: snapshot.error || "Unknown error" }; + } + + return { ok: true, snapshot }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function submitPlayerAction(input: { + simId: string; + prose: string; +}): Promise { + try { + if (!input.simId || !input.prose.trim()) { + return { ok: false, error: "Missing simId or prose" }; + } + + const snapshot = await simulationManager.submitPlayerAction( + input.simId, + input.prose.trim(), + ); + + if (!snapshot) { + return { ok: false, error: "Simulation session not found" }; + } + + if (snapshot.status === "error") { + return { ok: false, error: snapshot.error || "Unknown error" }; + } + + return { ok: true, snapshot }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function getConfigStatus(): Promise<{ + apiKeySet: boolean; + apiKeyPreview: string; + model: string; + availableScenarios: { path: string; name: string }[]; +}> { + const apiKey = process.env.GOOGLE_API_KEY; + const scenarios: { path: string; name: string }[] = []; + + const cwd = process.cwd(); + const candidates = [ + path.resolve(cwd, "content/demo/scenarios"), + path.resolve(cwd, "../../content/demo/scenarios"), + ]; + let scenariosDir = ""; + for (const c of candidates) { + if (fs.existsSync(c) && fs.statSync(c).isDirectory()) { + scenariosDir = c; + break; + } + } + + if (scenariosDir) { + for (const file of fs.readdirSync(scenariosDir)) { + if (file.endsWith(".json")) { + try { + const fullPath = path.join(scenariosDir, file); + const content = JSON.parse(fs.readFileSync(fullPath, "utf-8")); + scenarios.push({ + path: `content/demo/scenarios/${file}`, + name: content.name || file, + }); + } catch { + /* skip invalid */ + } + } + } + } + + return { + apiKeySet: !!apiKey, + apiKeyPreview: apiKey ? apiKey.substring(0, 10) + "..." : "NOT SET", + model: "gemini-2.5-flash", + availableScenarios: scenarios, + }; +} + +export async function listSavedSimulations(): Promise< + | { ok: true; sessions: SimSnapshot[] } + | { ok: false; error: string } +> { + try { + const sessions = simulationManager.listSavedSessions(); + return { ok: true, sessions }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function resumeSimulation(simId: string): Promise { + try { + const snapshot = await simulationManager.load(simId); + if (!snapshot) { + return { ok: false, error: `Failed to load simulation: ${simId}` }; + } + return { ok: true, snapshot }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function getScenarioEntities(scenarioPath: string): Promise< + | { ok: true; entities: { id: string; name: string }[] } + | { ok: false; error: string } +> { + try { + const resolved = resolveScenarioPath(scenarioPath); + if (!fs.existsSync(resolved)) { + return { ok: false, error: `Scenario file not found: ${scenarioPath}` }; + } + const content = JSON.parse(fs.readFileSync(resolved, "utf-8")); + const entities = (content.entities || []).map((e: { id: string; name?: string }) => ({ + id: e.id, + name: e.name || e.id, + })); + return { ok: true, entities }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function deleteSimulation(simId: string): Promise< + { ok: true } | { ok: false; error: string } +> { + try { + simulationManager.deleteSession(simId); + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function listProviderInstances(): Promise { + return ProviderManager.list(); +} + +export async function createProviderInstance( + name: string, + providerName: string, + apiKey: string, + modelName?: string, +): Promise { + return ProviderManager.create(name, providerName, apiKey, modelName); +} + +export async function deleteProviderInstance(id: string): Promise { + ProviderManager.delete(id); +} + +export async function setActiveProviderInstance(id: string): Promise { + ProviderManager.setActive(id); +} + +export async function updateProviderInstance( + id: string, + name: string, + providerName: string, + apiKey?: string, + modelName?: string, +): Promise { + ProviderManager.update(id, name, providerName, apiKey, modelName); +} + +export async function getProviderMappings(): Promise> { + return ProviderManager.getMappings(); +} + +export async function setProviderMapping( + task: string, + providerInstanceId: string, +): Promise { + ProviderManager.setMapping(task, providerInstanceId); +} diff --git a/apps/gui/src/app/play/page.tsx b/apps/gui/src/app/play/page.tsx new file mode 100644 index 0000000..9e2dab7 --- /dev/null +++ b/apps/gui/src/app/play/page.tsx @@ -0,0 +1,5 @@ +import { PlayView } from "@/components/play/PlayView"; + +export default function PlayPage() { + return ; +} diff --git a/apps/gui/src/components/nav/NavBar.tsx b/apps/gui/src/components/nav/NavBar.tsx new file mode 100644 index 0000000..40741e6 --- /dev/null +++ b/apps/gui/src/components/nav/NavBar.tsx @@ -0,0 +1,69 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const links = [ + { href: "/", label: "Home" }, + { href: "/play", label: "Play" }, + { href: "/config", label: "Config" }, +]; + +export function NavBar() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/apps/gui/src/components/play/PlayView.tsx b/apps/gui/src/components/play/PlayView.tsx new file mode 100644 index 0000000..f1ac424 --- /dev/null +++ b/apps/gui/src/components/play/PlayView.tsx @@ -0,0 +1,1137 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + startSimulation, + stepSimulation, + submitPlayerAction, + listSavedSimulations, + resumeSimulation, + getConfigStatus, + getScenarioEntities, + deleteSimulation, + listProviderInstances, +} from "@/app/play/actions"; +import type { SimSnapshot } from "@/lib/simulation-types"; +import type { LLMProviderInstance } from "@omnia/llm"; + +function IntentTag({ + intent, +}: { + intent: SimSnapshot["log"][number]["intents"][number]; +}) { + const labels: Record = { + monologue: "thought", + dialogue: "dialogue", + action: "action", + }; + + const label = labels[intent.type] || intent.type; + + let outcome = ""; + if (intent.type === "action") { + outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`; + } + + return ( + + [{label}] “{intent.description}”{outcome} + {intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""} + + ); +} + +function PromptModal({ + entry, + onClose, +}: { + entry: SimSnapshot["log"][number]; + onClose: () => void; +}) { + const [activeTab, setActiveTab] = useState<"actor" | "decoder">("actor"); + + useEffect(() => { + if (!entry.rawPrompt && entry.decoderPrompt) { + setActiveTab("decoder"); + } + }, [entry]); + + return ( +
+
e.stopPropagation()}> +
+

Raw Prompts & Token Usage ({entry.entityName})

+ +
+ +
+ + +
+ +
+ {activeTab === "actor" && entry.rawPrompt && ( +
+ {entry.usage ? ( +
+ Token Usage: + Input: {entry.usage.inputTokens} ·{" "} + Output: {entry.usage.outputTokens} ·{" "} + Total: {entry.usage.totalTokens} +
+ ) : ( +
+ No LLM token usage (Player turn used fixed prose). +
+ )} + +
+

System Prompt

+
{entry.rawPrompt.systemPrompt}
+
+ +
+

User Context

+
{entry.rawPrompt.userContext}
+
+
+ )} + + {activeTab === "decoder" && entry.decoderPrompt && ( +
+ {entry.decoderUsage && ( +
+ Token Usage: + Input: {entry.decoderUsage.inputTokens} ·{" "} + Output: {entry.decoderUsage.outputTokens} ·{" "} + Total: {entry.decoderUsage.totalTokens} +
+ )} + +
+

System Prompt

+
{entry.decoderPrompt.systemPrompt}
+
+ +
+

User Context

+
{entry.decoderPrompt.userContext}
+
+
+ )} +
+
+
+ ); +} + +function LogEntryCard({ + entry, + onShowPrompt, +}: { + entry: SimSnapshot["log"][number]; + onShowPrompt: (entry: SimSnapshot["log"][number]) => void; +}) { + const showMenu = !!(entry.rawPrompt || entry.decoderPrompt); + + return ( +
+
+
+ {entry.entityName} + + Turn {entry.turn} ·{" "} + {new Date(entry.timestamp).toLocaleTimeString()} + +
+ {showMenu && ( + + )} +
+
{entry.narrativeProse}
+
+ {entry.intents.map((intent, i) => ( + + ))} +
+
+ ); +} + +export function PlayView() { + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(false); + const [playerInput, setPlayerInput] = useState(""); + const [error, setError] = useState(""); + const [statusText, setStatusText] = useState(""); + const [selectedEntryForModal, setSelectedEntryForModal] = useState(null); + const logEndRef = useRef(null); + const steppingRef = useRef(false); + + const scrollToBottom = useCallback(() => { + setTimeout( + () => logEndRef.current?.scrollIntoView({ behavior: "smooth" }), + 100, + ); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [snapshot, scrollToBottom]); + + const runSteps = useCallback( + async (id: string) => { + if (steppingRef.current) return; + steppingRef.current = true; + setLoading(true); + setError(""); + + try { + let current = snapshot; + while (true) { + const result = await stepSimulation({ simId: id }); + if (!result.ok) { + setError(result.error); + break; + } + current = result.snapshot; + setSnapshot(current); + + if ( + current.status === "waiting_player" || + current.status === "done" || + current.status === "error" + ) { + break; + } + + const entityName = + current.entities[current.entityIndex ?? 0]?.name || ""; + setStatusText( + `Turn ${current.turn} — processing ${entityName || "next step"}...`, + ); + } + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Failed during simulation step.", + ); + } finally { + steppingRef.current = false; + setLoading(false); + setStatusText(""); + } + }, + [snapshot], + ); + + const [savedSessions, setSavedSessions] = useState([]); + + const loadSavedSessions = useCallback(async () => { + try { + const res = await listSavedSimulations(); + if (res.ok) { + setSavedSessions(res.sessions); + } + } catch { + // ignore + } + }, []); + + useEffect(() => { + if (!snapshot) { + loadSavedSessions(); + } + }, [snapshot, loadSavedSessions]); + + const handleResume = async (id: string) => { + setLoading(true); + setError(""); + try { + const res = await resumeSimulation(id); + if (!res.ok) { + setError(res.error); + setLoading(false); + return; + } + setSnapshot(res.snapshot); + if (res.snapshot.status === "running") { + await runSteps(res.snapshot.id); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to resume session."); + setLoading(false); + } + }; + + const handleDelete = async (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (!confirm("Are you sure you want to delete this simulation session?")) return; + setLoading(true); + try { + const res = await deleteSimulation(id); + if (!res.ok) { + setError(res.error); + } else { + await loadSavedSessions(); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete session."); + } finally { + setLoading(false); + } + }; + + const [scenarios, setScenarios] = useState<{ path: string; name: string }[]>([]); + const [selectedScenario, setSelectedScenario] = useState(""); + const [availableEntities, setAvailableEntities] = useState<{ id: string; name: string }[]>([]); + const [selectedEntity, setSelectedEntity] = useState(""); + + const [providerInstances, setProviderInstances] = useState([]); + const [selectedProviderInstance, setSelectedProviderInstance] = useState(""); + + // Load scenarios and provider instances on mount + useEffect(() => { + async function loadScenariosAndProviders() { + try { + const configStatus = await getConfigStatus(); + setScenarios(configStatus.availableScenarios); + if (configStatus.availableScenarios.length > 0) { + setSelectedScenario(configStatus.availableScenarios[0].path); + } + } catch { + // ignore + } + try { + const providersList = await listProviderInstances(); + setProviderInstances(providersList); + const active = providersList.find(p => p.isActive); + if (active) { + setSelectedProviderInstance(active.id); + } else if (providersList.length > 0) { + setSelectedProviderInstance(providersList[0].id); + } + } catch { + // ignore + } + } + loadScenariosAndProviders(); + }, [snapshot]); + + // Fetch entities when selectedScenario changes + useEffect(() => { + if (!selectedScenario) { + setAvailableEntities([]); + setSelectedEntity(""); + return; + } + async function loadEntities() { + try { + const res = await getScenarioEntities(selectedScenario); + if (res.ok) { + setAvailableEntities(res.entities); + if (res.entities.length > 0) { + setSelectedEntity(res.entities[0].id); + } else { + setSelectedEntity(""); + } + } + } catch { + // ignore + } + } + loadEntities(); + }, [selectedScenario]); + + const handleStart = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + + try { + const form = new FormData(e.currentTarget); + const result = await startSimulation({ + scenario: (form.get("scenario") as string) || undefined, + playEntity: (form.get("playEntity") as string) || undefined, + providerInstanceId: selectedProviderInstance || undefined, + }); + + if (!result.ok) { + setError(result.error); + setLoading(false); + return; + } + + setSnapshot(result.snapshot); + + if (result.snapshot.status === "running") { + await runSteps(result.snapshot.id); + } + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Failed to start simulation.", + ); + setLoading(false); + } + }; + + const handleSubmitAction = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!snapshot || !playerInput.trim()) return; + + setLoading(true); + const prose = playerInput.trim(); + setPlayerInput(""); + + try { + const result = await submitPlayerAction({ + simId: snapshot.id, + prose, + }); + + if (!result.ok) { + setError(result.error); + setLoading(false); + return; + } + + setSnapshot(result.snapshot); + + if (result.snapshot.status === "running") { + await runSteps(result.snapshot.id); + } + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Failed to submit action.", + ); + setLoading(false); + } + }; + + const statusMessage = () => { + if (!snapshot) return null; + if (loading && statusText) return statusText; + switch (snapshot.status) { + case "waiting_player": + return `Waiting for your input as "${snapshot.waitingEntity?.name}"...`; + case "done": + return "Simulation complete."; + case "error": + return `Error: ${snapshot.error}`; + default: + return "Simulation running..."; + } + }; + + return ( +
+

Omnia Play

+ + {!snapshot && ( +
+
+

Start New Simulation

+
+ {error &&
{error}
} +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Resume Simulation

+ {savedSessions.length === 0 ? ( +

No saved sessions found. Start a new one!

+ ) : ( +
+ {savedSessions.map((s) => ( +
+
+ {s.scenarioName} + + Turn {s.turn} · {s.entities.length} entities · {s.status} + + + Session ID: {s.id} + +
+
+ + +
+
+ ))} +
+ )} +
+
+ )} + + {snapshot && ( + <> +
+
+

{snapshot.scenarioName}

+ {snapshot.status !== "done" && snapshot.status !== "error" && ( + + )} +
+

{snapshot.scenarioDescription}

+

+ {loading && "⏳ "} + {statusMessage()} +

+
+ +
+ {snapshot.log.map((entry, i) => ( + + ))} + {loading && ( +
+ + {statusText || "Processing..."} +
+ )} +
+
+ + {snapshot.status === "waiting_player" && snapshot.waitingEntity && ( +
+
+ + + Your context as {snapshot.waitingEntity.name} + + +
+                  {snapshot.waitingEntity.userContext}
+                
+
+ +
+