mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
MAJOR: Added a GUI app for simulations
This commit is contained in:
6
apps/gui/next-env.d.ts
vendored
Normal file
6
apps/gui/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
30
apps/gui/next.config.ts
Normal file
30
apps/gui/next.config.ts
Normal file
@@ -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;
|
||||
32
apps/gui/package.json
Normal file
32
apps/gui/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
221
apps/gui/src/app/config/page.tsx
Normal file
221
apps/gui/src/app/config/page.tsx
Normal file
@@ -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<ConfigStatus | null>(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 (
|
||||
<div className="config-page">
|
||||
<h1>Configuration</h1>
|
||||
|
||||
{loading && <p>Loading configuration...</p>}
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{config && !loading && (
|
||||
<>
|
||||
<section className="config-section">
|
||||
<h2>LLM Provider</h2>
|
||||
<div className="config-row">
|
||||
<span className="config-label">Provider</span>
|
||||
<span className="config-value">Google Gemini</span>
|
||||
</div>
|
||||
<div className="config-row">
|
||||
<span className="config-label">Model</span>
|
||||
<span className="config-value">
|
||||
<code>{config.model}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div className="config-row">
|
||||
<span className="config-label">API Key</span>
|
||||
<span
|
||||
className={
|
||||
config.apiKeySet
|
||||
? "config-value status-ok"
|
||||
: "config-value status-error"
|
||||
}
|
||||
>
|
||||
{config.apiKeySet
|
||||
? `✓ Set (${config.apiKeyPreview})`
|
||||
: "✗ NOT SET"}
|
||||
</span>
|
||||
</div>
|
||||
{!config.apiKeySet && (
|
||||
<div className="config-hint">
|
||||
Add <code>GOOGLE_API_KEY=your_key</code> to the{" "}
|
||||
<code>.env</code> file in the project root, then restart the
|
||||
server.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h2>Available Scenarios</h2>
|
||||
{config.availableScenarios.length === 0 ? (
|
||||
<p className="config-hint">
|
||||
No scenarios found in{" "}
|
||||
<code>content/demo/scenarios/</code>.
|
||||
</p>
|
||||
) : (
|
||||
<table className="scenario-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.availableScenarios.map((s) => (
|
||||
<tr key={s.path}>
|
||||
<td>{s.name}</td>
|
||||
<td>
|
||||
<code>{s.path}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h2>Engine Packages</h2>
|
||||
<p className="config-hint">
|
||||
All <code>@omnia/*</code> workspace packages are consumed via{" "}
|
||||
<code>transpilePackages</code> in <code>next.config.ts</code>.
|
||||
The native <code>better-sqlite3</code> module is externalized via{" "}
|
||||
<code>serverExternalPackages</code>.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.config-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
.config-page h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.config-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.config-section h2 {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.config-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.config-label {
|
||||
color: #555;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.config-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.status-ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
.status-error {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
.config-hint {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
color: #92400e;
|
||||
}
|
||||
.config-hint code {
|
||||
background: rgba(0,0,0,0.06);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.error-banner {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.scenario-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.scenario-table th {
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
.scenario-table td {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.scenario-table code {
|
||||
font-size: 0.8125rem;
|
||||
color: #2563eb;
|
||||
}
|
||||
code {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
apps/gui/src/app/globals.css
Normal file
18
apps/gui/src/app/globals.css
Normal file
@@ -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;
|
||||
}
|
||||
19
apps/gui/src/app/layout.tsx
Normal file
19
apps/gui/src/app/layout.tsx
Normal file
@@ -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 (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<NavBar />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
63
apps/gui/src/app/page.tsx
Normal file
63
apps/gui/src/app/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="home">
|
||||
<h1>Omnia GUI</h1>
|
||||
<p className="subtitle">
|
||||
Configuration and gameplay interface for the Omnia simulation engine.
|
||||
</p>
|
||||
<div className="home-links">
|
||||
<Link href="/play" className="home-card">
|
||||
<h2>Play</h2>
|
||||
<p>Start a simulation and interact with NPCs</p>
|
||||
</Link>
|
||||
<Link href="/config" className="home-card">
|
||||
<h2>Config</h2>
|
||||
<p>Check environment, API keys, and available scenarios</p>
|
||||
</Link>
|
||||
</div>
|
||||
<style>{`
|
||||
.home {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
.home h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #555;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.home-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.home-card {
|
||||
flex: 1;
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.home-card:hover {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
.home-card h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.home-card p {
|
||||
font-size: 0.875rem;
|
||||
color: #555;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
231
apps/gui/src/app/play/actions.ts
Normal file
231
apps/gui/src/app/play/actions.ts
Normal file
@@ -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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
5
apps/gui/src/app/play/page.tsx
Normal file
5
apps/gui/src/app/play/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PlayView } from "@/components/play/PlayView";
|
||||
|
||||
export default function PlayPage() {
|
||||
return <PlayView />;
|
||||
}
|
||||
69
apps/gui/src/components/nav/NavBar.tsx
Normal file
69
apps/gui/src/components/nav/NavBar.tsx
Normal file
@@ -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 (
|
||||
<nav className="navbar">
|
||||
<Link href="/" className="nav-brand">
|
||||
Omnia
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={pathname === link.href ? "nav-link active" : "nav-link"}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<style>{`
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
.nav-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: #111;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nav-link {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111;
|
||||
}
|
||||
.nav-link.active {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
1100
apps/gui/src/components/play/PlayView.tsx
Normal file
1100
apps/gui/src/components/play/PlayView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
62
apps/gui/src/lib/simulation-types.ts
Normal file
62
apps/gui/src/lib/simulation-types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export interface IntentInfo {
|
||||
type: string;
|
||||
description: string;
|
||||
targetIds: string[];
|
||||
isValid?: boolean;
|
||||
reason?: string;
|
||||
minutesToAdvance?: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
turn: number;
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
narrativeProse: string;
|
||||
intents: IntentInfo[];
|
||||
timestamp: string;
|
||||
rawPrompt?: {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
};
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
decoderPrompt?: {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
};
|
||||
decoderUsage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EntityInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isPlayer: boolean;
|
||||
}
|
||||
|
||||
export interface WaitingContext {
|
||||
entityId: string;
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
}
|
||||
|
||||
export interface SimSnapshot {
|
||||
id: string;
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
entities: EntityInfo[];
|
||||
log: LogEntry[];
|
||||
entityIndex: number;
|
||||
waitingEntity?: WaitingContext;
|
||||
error?: string;
|
||||
}
|
||||
751
apps/gui/src/lib/simulation.ts
Normal file
751
apps/gui/src/lib/simulation.ts
Normal file
@@ -0,0 +1,751 @@
|
||||
import dotenv from "dotenv";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { SQLiteRepository } from "@omnia/core";
|
||||
|
||||
// Load .env from monorepo root or apps/gui/
|
||||
const cwd = process.cwd();
|
||||
const envCandidates = [
|
||||
path.resolve(cwd, ".env"),
|
||||
path.resolve(cwd, "../../.env"),
|
||||
];
|
||||
for (const c of envCandidates) {
|
||||
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
|
||||
dotenv.config({ path: c });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
import { BufferRepository } from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import {
|
||||
ActorAgent,
|
||||
ActorPromptBuilder,
|
||||
IActorProseGenerator,
|
||||
buildBufferEntryForIntent,
|
||||
} from "@omnia/actor";
|
||||
import { GeminiProvider } from "@omnia/llm";
|
||||
import { ScenarioLoader } from "@omnia/scenario";
|
||||
|
||||
import type {
|
||||
IntentInfo,
|
||||
LogEntry,
|
||||
EntityInfo,
|
||||
WaitingContext,
|
||||
SimSnapshot,
|
||||
} from "./simulation-types.js";
|
||||
|
||||
export type { SimSnapshot, EntityInfo, LogEntry, IntentInfo, WaitingContext };
|
||||
|
||||
class FixedProseGenerator implements IActorProseGenerator {
|
||||
constructor(private prose: string) {}
|
||||
|
||||
async generate(
|
||||
entityId: string,
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
): Promise<string> {
|
||||
void entityId;
|
||||
void systemPrompt;
|
||||
void userContext;
|
||||
return this.prose;
|
||||
}
|
||||
}
|
||||
|
||||
interface SavedState {
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
entities: EntityInfo[];
|
||||
playerEntityId: string | undefined;
|
||||
entityIndex: number;
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
error?: string;
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
log: LogEntry[];
|
||||
}
|
||||
|
||||
function loadSessionState(db: Database.Database, id: string): SavedState | null {
|
||||
try {
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS gui_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
state_json TEXT
|
||||
)
|
||||
`).run();
|
||||
const row = db.prepare(`SELECT state_json FROM gui_meta WHERE id = ?`).get(id) as { state_json: string } | undefined;
|
||||
return row ? (JSON.parse(row.state_json) as SavedState) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface SimSession {
|
||||
db: Database.Database;
|
||||
dbPath: string;
|
||||
coreRepo: SQLiteRepository;
|
||||
bufferRepo: BufferRepository;
|
||||
worldInstanceId: string;
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
entities: EntityInfo[];
|
||||
playerEntityId: string | undefined;
|
||||
entityIndex: number;
|
||||
llmProvider: GeminiProvider;
|
||||
architect: Architect;
|
||||
aliasGenerator: AliasDeltaGenerator;
|
||||
log: LogEntry[];
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
error?: string;
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
}
|
||||
|
||||
class SimulationManager {
|
||||
private sessions = new Map<string, SimSession>();
|
||||
|
||||
async create(
|
||||
scenarioPath: string,
|
||||
playEntityName?: string,
|
||||
): Promise<SimSnapshot> {
|
||||
const apiKey = process.env.GOOGLE_API_KEY;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
id: "",
|
||||
status: "error",
|
||||
turn: 0,
|
||||
maxTurns: 20,
|
||||
scenarioName: "",
|
||||
scenarioDescription: "",
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
error: "GOOGLE_API_KEY is not set. Add it to your .env file.",
|
||||
};
|
||||
}
|
||||
|
||||
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
|
||||
const id = `sim-${Date.now()}`;
|
||||
|
||||
const dbDir = path.resolve(process.cwd(), "data");
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
const dbPath = path.join(dbDir, `${id}.db`);
|
||||
const db = new Database(dbPath);
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const loader = new ScenarioLoader(coreRepo, bufferRepo);
|
||||
|
||||
const worldInstanceId = id;
|
||||
await loader.initializeWorld(scenarioJson, worldInstanceId);
|
||||
|
||||
const worldState = coreRepo.loadWorldState(worldInstanceId);
|
||||
if (!worldState) {
|
||||
db.close();
|
||||
return {
|
||||
id: "",
|
||||
status: "error",
|
||||
turn: 0,
|
||||
maxTurns: 20,
|
||||
scenarioName: "",
|
||||
scenarioDescription: "",
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
error: "Failed to load world state after initialization.",
|
||||
};
|
||||
}
|
||||
|
||||
const rawEntities = Array.from(worldState.entities.values());
|
||||
const entityInfos: EntityInfo[] = rawEntities.map((e) => ({
|
||||
id: e.id,
|
||||
name: (e.attributes.get("name")?.getValue() as string) || e.id,
|
||||
isPlayer: false,
|
||||
}));
|
||||
|
||||
let playerEntityId: string | undefined;
|
||||
if (playEntityName) {
|
||||
let matched = worldState.getEntity(playEntityName);
|
||||
if (!matched) {
|
||||
for (const ent of rawEntities) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue() as
|
||||
| string
|
||||
| undefined;
|
||||
if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
|
||||
matched = ent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const ent of rawEntities) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue() as
|
||||
| string
|
||||
| undefined;
|
||||
if (
|
||||
nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
|
||||
ent.id.toLowerCase().includes(playEntityName.toLowerCase())
|
||||
) {
|
||||
matched = ent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
playerEntityId = matched.id;
|
||||
const info = entityInfos.find((e) => e.id === matched.id);
|
||||
if (info) info.isPlayer = true;
|
||||
}
|
||||
}
|
||||
|
||||
const llmProvider = new GeminiProvider(apiKey);
|
||||
const architect = new Architect(llmProvider, coreRepo);
|
||||
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
|
||||
|
||||
const session: SimSession = {
|
||||
db,
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
worldInstanceId,
|
||||
scenarioName: scenarioJson.name,
|
||||
scenarioDescription: scenarioJson.description,
|
||||
turn: 1,
|
||||
maxTurns: 20,
|
||||
entities: entityInfos,
|
||||
playerEntityId,
|
||||
entityIndex: 0,
|
||||
llmProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: [],
|
||||
status: "running",
|
||||
aliasDoneForTurn: false,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
async step(id: string): Promise<SimSnapshot | null> {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
if (session.status !== "running") return this.snapshot(session);
|
||||
|
||||
try {
|
||||
if (session.turn > session.maxTurns) {
|
||||
session.status = "done";
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
|
||||
await this.runAliasResolution(session);
|
||||
session.aliasDoneForTurn = true;
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
if (session.entityIndex >= session.entities.length) {
|
||||
session.turn++;
|
||||
session.entityIndex = 0;
|
||||
session.aliasDoneForTurn = false;
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
const info = session.entities[session.entityIndex];
|
||||
|
||||
if (info.isPlayer) {
|
||||
await this.preparePlayerTurn(session, info);
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
await this.processNpcTurn(session, info);
|
||||
session.entityIndex++;
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
async submitPlayerAction(
|
||||
id: string,
|
||||
prose: string,
|
||||
): Promise<SimSnapshot | null> {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
if (session.status !== "waiting_player") return this.snapshot(session);
|
||||
if (!session.waitingEntity) return this.snapshot(session);
|
||||
|
||||
const ctx = session.waitingEntity;
|
||||
session.waitingEntity = undefined;
|
||||
session.status = "running";
|
||||
|
||||
try {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(ctx.entityId);
|
||||
if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
|
||||
|
||||
const playerActor = new ActorAgent(
|
||||
session.llmProvider,
|
||||
session.bufferRepo,
|
||||
20,
|
||||
new FixedProseGenerator(prose),
|
||||
);
|
||||
|
||||
const startCallIdx = session.llmProvider.lastCalls.length;
|
||||
const result = await playerActor.act(worldState, entity);
|
||||
const endCallIdx = session.llmProvider.lastCalls.length;
|
||||
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
entityId: ctx.entityId,
|
||||
entityName: ctx.name,
|
||||
narrativeProse: result.narrativeProse,
|
||||
intents: [],
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
rawPrompt: {
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
userContext: ctx.userContext,
|
||||
},
|
||||
};
|
||||
|
||||
if (endCallIdx > startCallIdx) {
|
||||
const call = session.llmProvider.lastCalls[startCallIdx];
|
||||
entry.decoderPrompt = {
|
||||
systemPrompt: call.systemPrompt,
|
||||
userContext: call.userContext,
|
||||
};
|
||||
entry.decoderUsage = call.usage;
|
||||
}
|
||||
|
||||
for (const intent of result.intents.intents) {
|
||||
const outcome = await session.architect.processIntent(
|
||||
worldState,
|
||||
intent,
|
||||
);
|
||||
const ts = worldState.clock.get().toISOString();
|
||||
|
||||
entry.intents.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
|
||||
});
|
||||
|
||||
const actorEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
ts,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
actorEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
session.bufferRepo.save(actorEntry);
|
||||
|
||||
if (
|
||||
entity.locationId &&
|
||||
(intent.type === "dialogue" || intent.type === "action")
|
||||
) {
|
||||
for (const [, other] of worldState.entities) {
|
||||
if (
|
||||
other.id !== ctx.entityId &&
|
||||
other.locationId === entity.locationId
|
||||
) {
|
||||
const observerEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
ts,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
observerEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
session.bufferRepo.save({
|
||||
...observerEntry,
|
||||
ownerId: other.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.log.push(entry);
|
||||
session.coreRepo.saveWorldState(worldState);
|
||||
session.entityIndex++;
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
private async preparePlayerTurn(
|
||||
session: SimSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const promptBuilder = new ActorPromptBuilder(session.bufferRepo, 20);
|
||||
const { systemPrompt, userContext } = promptBuilder.build(
|
||||
worldState,
|
||||
entity,
|
||||
);
|
||||
|
||||
session.waitingEntity = {
|
||||
entityId: info.id,
|
||||
name: info.name,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
};
|
||||
session.status = "waiting_player";
|
||||
}
|
||||
|
||||
private async processNpcTurn(
|
||||
session: SimSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const actor = new ActorAgent(session.llmProvider, session.bufferRepo, 20);
|
||||
const startCallIdx = session.llmProvider.lastCalls.length;
|
||||
const result = await actor.act(worldState, entity);
|
||||
const endCallIdx = session.llmProvider.lastCalls.length;
|
||||
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
entityId: info.id,
|
||||
entityName: info.name,
|
||||
narrativeProse: result.narrativeProse,
|
||||
intents: [],
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
};
|
||||
|
||||
if (endCallIdx - startCallIdx >= 2) {
|
||||
const actorCall = session.llmProvider.lastCalls[startCallIdx];
|
||||
const decoderCall = session.llmProvider.lastCalls[startCallIdx + 1];
|
||||
|
||||
entry.rawPrompt = {
|
||||
systemPrompt: actorCall.systemPrompt,
|
||||
userContext: actorCall.userContext,
|
||||
};
|
||||
entry.usage = actorCall.usage;
|
||||
|
||||
entry.decoderPrompt = {
|
||||
systemPrompt: decoderCall.systemPrompt,
|
||||
userContext: decoderCall.userContext,
|
||||
};
|
||||
entry.decoderUsage = decoderCall.usage;
|
||||
} else if (endCallIdx - startCallIdx === 1) {
|
||||
const call = session.llmProvider.lastCalls[startCallIdx];
|
||||
entry.rawPrompt = {
|
||||
systemPrompt: call.systemPrompt,
|
||||
userContext: call.userContext,
|
||||
};
|
||||
entry.usage = call.usage;
|
||||
}
|
||||
|
||||
for (const intent of result.intents.intents) {
|
||||
const outcome = await session.architect.processIntent(
|
||||
worldState,
|
||||
intent,
|
||||
);
|
||||
const ts = worldState.clock.get().toISOString();
|
||||
|
||||
entry.intents.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
|
||||
});
|
||||
|
||||
const actorEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
ts,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
actorEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
session.bufferRepo.save(actorEntry);
|
||||
|
||||
if (
|
||||
entity.locationId &&
|
||||
(intent.type === "dialogue" || intent.type === "action")
|
||||
) {
|
||||
for (const [, other] of worldState.entities) {
|
||||
if (
|
||||
other.id !== info.id &&
|
||||
other.locationId === entity.locationId
|
||||
) {
|
||||
const observerEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
ts,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
observerEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.log.push(entry);
|
||||
session.coreRepo.saveWorldState(worldState);
|
||||
}
|
||||
|
||||
private async runAliasResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entities = Array.from(worldState.entities.values());
|
||||
for (const viewer of entities) {
|
||||
if (!viewer.locationId) continue;
|
||||
for (const target of entities) {
|
||||
if (viewer.id === target.id) continue;
|
||||
if (
|
||||
target.locationId === viewer.locationId &&
|
||||
!viewer.aliases.has(target.id)
|
||||
) {
|
||||
const alias = await session.aliasGenerator.generate(viewer, target);
|
||||
viewer.aliases.set(target.id, alias);
|
||||
session.coreRepo.saveEntity(viewer, worldState.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) {
|
||||
session.db.close();
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
deleteSession(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) {
|
||||
session.db.close();
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
const dbDir = path.resolve(process.cwd(), "data");
|
||||
const dbPath = path.join(dbDir, `${id}.db`);
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete session file ${dbPath}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async load(id: string): Promise<SimSnapshot | null> {
|
||||
const active = this.sessions.get(id);
|
||||
if (active) {
|
||||
return this.snapshot(active);
|
||||
}
|
||||
|
||||
const dbDir = path.resolve(process.cwd(), "data");
|
||||
const dbPath = path.join(dbDir, `${id}.db`);
|
||||
if (!fs.existsSync(dbPath)) return null;
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
const state = loadSessionState(db, id);
|
||||
if (!state) {
|
||||
db.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiKey = process.env.GOOGLE_API_KEY;
|
||||
if (!apiKey) {
|
||||
db.close();
|
||||
throw new Error("GOOGLE_API_KEY is not set.");
|
||||
}
|
||||
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const llmProvider = new GeminiProvider(apiKey);
|
||||
const architect = new Architect(llmProvider, coreRepo);
|
||||
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
|
||||
|
||||
const session: SimSession = {
|
||||
db,
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
worldInstanceId: id,
|
||||
scenarioName: state.scenarioName,
|
||||
scenarioDescription: state.scenarioDescription,
|
||||
turn: state.turn,
|
||||
maxTurns: state.maxTurns,
|
||||
entities: state.entities || [],
|
||||
playerEntityId: state.playerEntityId,
|
||||
entityIndex: state.entityIndex,
|
||||
llmProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: state.log || [],
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
waitingEntity: state.waitingEntity,
|
||||
aliasDoneForTurn: state.aliasDoneForTurn || false,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
return this.snapshot(session);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load session ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
listSavedSessions(): SimSnapshot[] {
|
||||
const dbDir = path.resolve(process.cwd(), "data");
|
||||
if (!fs.existsSync(dbDir)) return [];
|
||||
|
||||
const snapshots: SimSnapshot[] = [];
|
||||
const files = fs.readdirSync(dbDir).filter(f => f.startsWith("sim-") && f.endsWith(".db"));
|
||||
|
||||
for (const file of files) {
|
||||
const id = file.replace(".db", "");
|
||||
const dbPath = path.join(dbDir, file);
|
||||
|
||||
const active = this.sessions.get(id);
|
||||
if (active) {
|
||||
snapshots.push(this.snapshot(active));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
const state = loadSessionState(db, id);
|
||||
db.close();
|
||||
|
||||
if (state) {
|
||||
snapshots.push({
|
||||
id,
|
||||
status: state.status,
|
||||
turn: state.turn,
|
||||
maxTurns: state.maxTurns,
|
||||
scenarioName: state.scenarioName,
|
||||
scenarioDescription: state.scenarioDescription,
|
||||
entities: state.entities || [],
|
||||
log: state.log || [],
|
||||
entityIndex: state.entityIndex,
|
||||
waitingEntity: state.waitingEntity,
|
||||
error: state.error,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
|
||||
return snapshots.sort((a, b) => {
|
||||
const tsA = parseInt(a.id.replace("sim-", ""), 10) || 0;
|
||||
const tsB = parseInt(b.id.replace("sim-", ""), 10) || 0;
|
||||
return tsB - tsA;
|
||||
});
|
||||
}
|
||||
|
||||
private save(session: SimSession): void {
|
||||
const state: SavedState = {
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
entities: session.entities,
|
||||
playerEntityId: session.playerEntityId,
|
||||
entityIndex: session.entityIndex,
|
||||
status: session.status,
|
||||
error: session.error,
|
||||
waitingEntity: session.waitingEntity,
|
||||
aliasDoneForTurn: session.aliasDoneForTurn,
|
||||
log: session.log,
|
||||
};
|
||||
|
||||
session.db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS gui_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
state_json TEXT
|
||||
)
|
||||
`).run();
|
||||
|
||||
session.db.prepare(`
|
||||
INSERT INTO gui_meta (id, state_json)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json
|
||||
`).run(session.worldInstanceId, JSON.stringify(state));
|
||||
}
|
||||
|
||||
getSnapshot(id: string): SimSnapshot | null {
|
||||
const session = this.sessions.get(id);
|
||||
return session ? this.snapshot(session) : null;
|
||||
}
|
||||
|
||||
private snapshot(session: SimSession): SimSnapshot {
|
||||
return {
|
||||
id: session.worldInstanceId,
|
||||
status: session.status,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
entities: session.entities,
|
||||
log: session.log,
|
||||
entityIndex: session.entityIndex,
|
||||
waitingEntity: session.waitingEntity,
|
||||
error: session.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const simulationManager = new SimulationManager();
|
||||
41
apps/gui/tsconfig.json
Normal file
41
apps/gui/tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user