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/apps/gui/next-env.d.ts b/apps/gui/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/apps/gui/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 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..bc9e1a4 --- /dev/null +++ b/apps/gui/src/app/config/page.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getConfigStatus } from "@/app/play/actions"; + +interface ConfigStatus { + apiKeySet: boolean; + apiKeyPreview: string; + model: string; + availableScenarios: { path: string; name: string }[]; +} + +export default function ConfigPage() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(""); + try { + const result = await getConfigStatus(); + if (!cancelled) { + setConfig(result); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + load(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+

Configuration

+ + {loading &&

Loading configuration...

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

LLM Provider

+
+ Provider + Google Gemini +
+
+ Model + + {config.model} + +
+
+ API Key + + {config.apiKeySet + ? `✓ Set (${config.apiKeyPreview})` + : "✗ NOT SET"} + +
+ {!config.apiKeySet && ( +
+ Add GOOGLE_API_KEY=your_key to the{" "} + .env file in the project root, then restart the + server. +
+ )} +
+ +
+

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..c0a0a7a --- /dev/null +++ b/apps/gui/src/app/play/actions.ts @@ -0,0 +1,231 @@ +"use server"; + +import path from "path"; +import fs from "fs"; +import { simulationManager } from "@/lib/simulation"; +import type { SimSnapshot } from "@/lib/simulation"; + +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; +}): 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, + ); + + 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), + }; + } +} 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..6563892 --- /dev/null +++ b/apps/gui/src/components/play/PlayView.tsx @@ -0,0 +1,1100 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + startSimulation, + stepSimulation, + submitPlayerAction, + listSavedSimulations, + resumeSimulation, + getConfigStatus, + getScenarioEntities, + deleteSimulation, +} from "@/app/play/actions"; +import type { SimSnapshot } from "@/lib/simulation-types"; + +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(""); + + // Load scenarios on mount + useEffect(() => { + async function loadScenarios() { + try { + const configStatus = await getConfigStatus(); + setScenarios(configStatus.availableScenarios); + if (configStatus.availableScenarios.length > 0) { + setSelectedScenario(configStatus.availableScenarios[0].path); + } + } catch { + // ignore + } + } + loadScenarios(); + }, []); + + // 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, + }); + + 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}
+                
+
+ +
+