mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 12:02:49 +05:30
Compare commits
1 Commits
cc9d0006e5
...
feat/openr
| Author | SHA1 | Date | |
|---|---|---|---|
| 4339a8b4b5 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,12 +46,6 @@ Thumbs.db
|
||||
|
||||
# Database
|
||||
omnia.db
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
**/data/*.db
|
||||
data/
|
||||
|
||||
# Environment Files
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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;
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,777 +0,0 @@
|
||||
"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<ConfigStatus | null>(null);
|
||||
const [instances, setInstances] = useState<LLMProviderInstance[]>([]);
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState<string | "new">("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 (
|
||||
<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 Instances</h2>
|
||||
<div className="provider-split-container">
|
||||
|
||||
{/* 30% area */}
|
||||
<div className="provider-list-pane">
|
||||
<div className="pane-header">
|
||||
<h3>Instances</h3>
|
||||
<button
|
||||
onClick={() => setSelectedInstanceId("new")}
|
||||
className="btn-add-inst"
|
||||
type="button"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="pane-list">
|
||||
{instances.length === 0 ? (
|
||||
<div className="no-instances-msg">No instances configured</div>
|
||||
) : (
|
||||
instances.map((inst) => (
|
||||
<div
|
||||
key={inst.id}
|
||||
onClick={() => setSelectedInstanceId(inst.id)}
|
||||
className={`instance-list-item ${selectedInstanceId === inst.id ? "active" : ""}`}
|
||||
>
|
||||
<div className="item-name">{inst.name}</div>
|
||||
<div className="item-meta">
|
||||
<span>{inst.providerName}</span>
|
||||
{inst.isActive && <span className="active-pill">Active</span>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 70% area */}
|
||||
<div className="provider-form-pane">
|
||||
<form onSubmit={handleSave} className="provider-config-form">
|
||||
<div className="form-scroll-content">
|
||||
<h3>
|
||||
{selectedInstanceId === "new"
|
||||
? "Create New Provider Instance"
|
||||
: `Configure: ${editName}`}
|
||||
</h3>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="formName">Friendly Name</label>
|
||||
<input
|
||||
id="formName"
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="e.g. Gemini - Production"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="formProvider">Provider Type</label>
|
||||
<select
|
||||
id="formProvider"
|
||||
value={editProvider}
|
||||
onChange={(e) => setEditProvider(e.target.value)}
|
||||
>
|
||||
<option value="google-genai">Google Gemini (Gemini-2.5-flash)</option>
|
||||
<option value="mock">Mock LLM Provider</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="formKey">API Key</label>
|
||||
<input
|
||||
id="formKey"
|
||||
type="password"
|
||||
value={editKey}
|
||||
onChange={(e) => setEditKey(e.target.value)}
|
||||
placeholder={
|
||||
selectedInstanceId === "new"
|
||||
? "AIzaSy..."
|
||||
: "•••••••• (unchanged)"
|
||||
}
|
||||
required={selectedInstanceId === "new"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="formModel">Model Name</label>
|
||||
<input
|
||||
id="formModel"
|
||||
type="text"
|
||||
value={editModel}
|
||||
onChange={(e) => setEditModel(e.target.value)}
|
||||
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<input
|
||||
id="formActive"
|
||||
type="checkbox"
|
||||
checked={editIsActive}
|
||||
onChange={(e) => setEditIsActive(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="formActive">Set as Active Instance</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions-bar">
|
||||
<div className="action-left">
|
||||
{selectedInstanceId !== "new" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="btn-delete-pane"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="action-right">
|
||||
<button type="submit" disabled={loading} className="btn-save-pane">
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h2>Task Provider Routing</h2>
|
||||
<p className="config-hint" style={{ background: "#eff6ff", border: "1px solid #bfdbfe", color: "#1e3a8a", margin: "1rem 0" }}>
|
||||
Configure which LLM Provider Key Instance should handle each specific simulation task. Mappings default to the currently <strong>Active</strong> instance if not specified.
|
||||
</p>
|
||||
<div className="mappings-grid">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div key={task.key} className="mapping-card">
|
||||
<div className="mapping-info">
|
||||
<strong>{task.label}</strong>
|
||||
<span className="text-gray" style={{ fontSize: "0.75rem", marginTop: "0.125rem" }}>{task.desc}</span>
|
||||
</div>
|
||||
<select
|
||||
value={mappings[task.key] || ""}
|
||||
onChange={(e) => handleUpdateMapping(task.key, e.target.value)}
|
||||
>
|
||||
<option value="">-- Use Active Key (Default) --</option>
|
||||
{instances.map((inst) => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} ({inst.providerName}){inst.isActive ? " [Active]" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h2>Environment Variables Default</h2>
|
||||
<div className="config-row">
|
||||
<span className="config-label">Default Model</span>
|
||||
<span className="config-value">
|
||||
<code>{config.model}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div className="config-row">
|
||||
<span className="config-label">Default API Key (.env)</span>
|
||||
<span
|
||||
className={
|
||||
config.apiKeySet
|
||||
? "config-value status-ok"
|
||||
: "config-value status-error"
|
||||
}
|
||||
>
|
||||
{config.apiKeySet
|
||||
? `✓ Set (${config.apiKeyPreview})`
|
||||
: "✗ NOT SET"}
|
||||
</span>
|
||||
</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;
|
||||
}
|
||||
|
||||
/* Pill and List Styles */
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-pill.active {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.status-pill.inactive {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-sm:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-sm.delete-btn {
|
||||
background: #ef4444;
|
||||
}
|
||||
.btn-sm.delete-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
/* Split container */
|
||||
.provider-split-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
margin-top: 1rem;
|
||||
min-height: 400px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.provider-split-container {
|
||||
grid-template-columns: 30% 70%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 30% List Pane */
|
||||
.provider-list-pane {
|
||||
border-right: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.pane-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.pane-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
.btn-add-inst {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-add-inst:hover {
|
||||
background: #059669;
|
||||
}
|
||||
.pane-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.no-instances-msg {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.instance-list-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-left 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.instance-list-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.instance-list-item.active {
|
||||
background: #eff6ff;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: #111;
|
||||
}
|
||||
.item-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.active-pill {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* 70% Form Pane */
|
||||
.provider-form-pane {
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.provider-config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.form-scroll-content {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.form-scroll-content h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.form-group label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"],
|
||||
.form-group select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.checkbox-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.checkbox-group label {
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-group input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Action bar */
|
||||
.form-actions-bar {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-delete-pane {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-delete-pane:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
.btn-save-pane {
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-save-pane:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Task Provider Routing Styles */
|
||||
.mappings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.mappings-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
.mapping-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.mapping-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.mapping-info strong {
|
||||
font-size: 0.875rem;
|
||||
color: #111;
|
||||
}
|
||||
.mapping-card select {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
.text-gray {
|
||||
color: #6b7280;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
*,
|
||||
*::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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
"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<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,
|
||||
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<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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProviderInstances(): Promise<LLMProviderInstance[]> {
|
||||
return ProviderManager.list();
|
||||
}
|
||||
|
||||
export async function createProviderInstance(
|
||||
name: string,
|
||||
providerName: string,
|
||||
apiKey: string,
|
||||
modelName?: string,
|
||||
): Promise<LLMProviderInstance> {
|
||||
return ProviderManager.create(name, providerName, apiKey, modelName);
|
||||
}
|
||||
|
||||
export async function deleteProviderInstance(id: string): Promise<void> {
|
||||
ProviderManager.delete(id);
|
||||
}
|
||||
|
||||
export async function setActiveProviderInstance(id: string): Promise<void> {
|
||||
ProviderManager.setActive(id);
|
||||
}
|
||||
|
||||
export async function updateProviderInstance(
|
||||
id: string,
|
||||
name: string,
|
||||
providerName: string,
|
||||
apiKey?: string,
|
||||
modelName?: string,
|
||||
): Promise<void> {
|
||||
ProviderManager.update(id, name, providerName, apiKey, modelName);
|
||||
}
|
||||
|
||||
export async function getProviderMappings(): Promise<Record<string, string>> {
|
||||
return ProviderManager.getMappings();
|
||||
}
|
||||
|
||||
export async function setProviderMapping(
|
||||
task: string,
|
||||
providerInstanceId: string,
|
||||
): Promise<void> {
|
||||
ProviderManager.setMapping(task, providerInstanceId);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PlayView } from "@/components/play/PlayView";
|
||||
|
||||
export default function PlayPage() {
|
||||
return <PlayView />;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,828 +0,0 @@
|
||||
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, ILLMProvider, MockLLMProvider, ProviderManager } 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[];
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
actorProvider: ILLMProvider;
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
architect: Architect;
|
||||
aliasGenerator: AliasDeltaGenerator;
|
||||
log: LogEntry[];
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
error?: string;
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
|
||||
class SimulationManager {
|
||||
private sessions = new Map<string, SimSession>();
|
||||
|
||||
async create(
|
||||
scenarioPath: string,
|
||||
playEntityName?: string,
|
||||
providerInstanceId?: string,
|
||||
): Promise<SimSnapshot> {
|
||||
let activeInstance = providerInstanceId
|
||||
? ProviderManager.list().find((p) => p.id === providerInstanceId)
|
||||
: ProviderManager.getActive();
|
||||
|
||||
if (!activeInstance) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
activeInstance = ProviderManager.create("Default (Env)", "google-genai", envKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeInstance) {
|
||||
return {
|
||||
id: "",
|
||||
status: "error",
|
||||
turn: 0,
|
||||
maxTurns: 20,
|
||||
scenarioName: "",
|
||||
scenarioDescription: "",
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
error: "No active LLM Provider Instance found. Please configure a key in Settings first.",
|
||||
};
|
||||
}
|
||||
|
||||
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 list = ProviderManager.list();
|
||||
const active = ProviderManager.getActive() || activeInstance;
|
||||
const mappings = ProviderManager.getMappings();
|
||||
|
||||
const resolveProviderForTask = (task: string): ILLMProvider => {
|
||||
const mappedId = mappings[task];
|
||||
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
|
||||
if (!inst) {
|
||||
inst = active;
|
||||
}
|
||||
|
||||
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
|
||||
const providerName = inst ? inst.providerName : "google-genai";
|
||||
|
||||
if (providerName === "google-genai") {
|
||||
return new GeminiProvider(key);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
};
|
||||
|
||||
const actorProvider = resolveProviderForTask("actor-prose");
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
const decoderProvider = resolveProviderForTask("intent-decoder");
|
||||
const timedeltaProvider = resolveProviderForTask("timedelta");
|
||||
|
||||
const architect = new Architect(
|
||||
{ validator: validatorProvider, timedelta: timedeltaProvider },
|
||||
coreRepo,
|
||||
);
|
||||
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
|
||||
|
||||
const session: SimSession = {
|
||||
db,
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
worldInstanceId,
|
||||
scenarioName: scenarioJson.name,
|
||||
scenarioDescription: scenarioJson.description,
|
||||
turn: 1,
|
||||
maxTurns: 20,
|
||||
entities: entityInfos,
|
||||
playerEntityId,
|
||||
entityIndex: 0,
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: [],
|
||||
status: "running",
|
||||
aliasDoneForTurn: false,
|
||||
providerMappings: mappings,
|
||||
};
|
||||
|
||||
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(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
20,
|
||||
new FixedProseGenerator(prose),
|
||||
);
|
||||
|
||||
const result = await playerActor.act(worldState, entity);
|
||||
|
||||
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 (session.decoderProvider.lastCalls && session.decoderProvider.lastCalls.length > 0) {
|
||||
const call = session.decoderProvider.lastCalls[session.decoderProvider.lastCalls.length - 1];
|
||||
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(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
20,
|
||||
);
|
||||
const result = await actor.act(worldState, entity);
|
||||
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
entityId: info.id,
|
||||
entityName: info.name,
|
||||
narrativeProse: result.narrativeProse,
|
||||
intents: [],
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
};
|
||||
|
||||
if (session.actorProvider.lastCalls && session.actorProvider.lastCalls.length > 0) {
|
||||
const actorCall = session.actorProvider.lastCalls[session.actorProvider.lastCalls.length - 1];
|
||||
entry.rawPrompt = {
|
||||
systemPrompt: actorCall.systemPrompt,
|
||||
userContext: actorCall.userContext,
|
||||
};
|
||||
entry.usage = actorCall.usage;
|
||||
}
|
||||
|
||||
if (session.decoderProvider.lastCalls && session.decoderProvider.lastCalls.length > 0) {
|
||||
const decoderCall = session.decoderProvider.lastCalls[session.decoderProvider.lastCalls.length - 1];
|
||||
entry.decoderPrompt = {
|
||||
systemPrompt: decoderCall.systemPrompt,
|
||||
userContext: decoderCall.userContext,
|
||||
};
|
||||
entry.decoderUsage = decoderCall.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 list = ProviderManager.list();
|
||||
const active = ProviderManager.getActive();
|
||||
const mappings = state.providerMappings || {};
|
||||
|
||||
const resolveProviderForTask = (task: string): ILLMProvider => {
|
||||
const mappedId = mappings[task];
|
||||
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
|
||||
if (!inst) {
|
||||
inst = active;
|
||||
}
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
inst = ProviderManager.create("Default (Env)", "google-genai", envKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
throw new Error(`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`);
|
||||
}
|
||||
|
||||
if (inst.providerName === "google-genai") {
|
||||
return new GeminiProvider(inst.apiKey);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
};
|
||||
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
|
||||
const actorProvider = resolveProviderForTask("actor-prose");
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
const decoderProvider = resolveProviderForTask("intent-decoder");
|
||||
const timedeltaProvider = resolveProviderForTask("timedelta");
|
||||
|
||||
const architect = new Architect(
|
||||
{ validator: validatorProvider, timedelta: timedeltaProvider },
|
||||
coreRepo,
|
||||
);
|
||||
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
|
||||
|
||||
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,
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: state.log || [],
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
waitingEntity: state.waitingEntity,
|
||||
aliasDoneForTurn: state.aliasDoneForTurn || false,
|
||||
providerMappings: mappings,
|
||||
};
|
||||
|
||||
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,
|
||||
providerMappings: session.providerMappings,
|
||||
};
|
||||
|
||||
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();
|
||||
21
cli/package.json
Normal file
21
cli/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@omnia/cli",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/spatial": "workspace:*",
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/architect": "workspace:*",
|
||||
"@omnia/actor": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/scenario-core": "workspace:*",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"dotenv": "^17.4.2"
|
||||
}
|
||||
}
|
||||
426
cli/src/index.ts
Normal file
426
cli/src/index.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import readline from "readline";
|
||||
import Database from "better-sqlite3";
|
||||
import { WorldState, SQLiteRepository } from "@omnia/core";
|
||||
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-core";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
class CLIProseGenerator implements IActorProseGenerator {
|
||||
async generate(
|
||||
entityId: string,
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
): Promise<string> {
|
||||
console.log(
|
||||
"\n================================================================================",
|
||||
);
|
||||
console.log(`YOUR TURN: Playing as character "${entityId}"`);
|
||||
console.log(
|
||||
"================================================================================",
|
||||
);
|
||||
console.log(userContext);
|
||||
console.log(
|
||||
"================================================================================",
|
||||
);
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
"\nDescribe what your character does, says, or thinks (or type 'exit' to quit):\n> ",
|
||||
(answer) => {
|
||||
rl.close();
|
||||
const trimmed = answer.trim();
|
||||
if (trimmed.toLowerCase() === "exit") {
|
||||
console.log("\nExiting simulation. Goodbye!");
|
||||
process.exit(0);
|
||||
}
|
||||
resolve(trimmed);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for co-located entities who do not have subjective aliases for each other,
|
||||
* and calls the AliasDeltaGenerator to synthesize names based on visible attributes.
|
||||
*/
|
||||
async function runAliasResolution(
|
||||
worldState: WorldState,
|
||||
aliasGenerator: AliasDeltaGenerator,
|
||||
coreRepo: SQLiteRepository,
|
||||
): Promise<void> {
|
||||
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) {
|
||||
if (!viewer.aliases.has(target.id)) {
|
||||
const alias = await aliasGenerator.generate(viewer, target);
|
||||
viewer.aliases.set(target.id, alias);
|
||||
console.log(
|
||||
`\n[Alias Resolved] "${viewer.id}" sees "${target.id}" -> alias: "${alias}"`,
|
||||
);
|
||||
// Save the viewer state with the new alias
|
||||
coreRepo.saveEntity(viewer, worldState.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const logFileIndex = args.indexOf("--log-file");
|
||||
let logStream: fs.WriteStream | undefined;
|
||||
if (logFileIndex !== -1 && args[logFileIndex + 1]) {
|
||||
const logFilePath = path.resolve(args[logFileIndex + 1]);
|
||||
logStream = fs.createWriteStream(logFilePath, {
|
||||
flags: "w",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
// Monkeypatch console.log
|
||||
const originalLog = console.log;
|
||||
console.log = (...messageArgs: unknown[]) => {
|
||||
originalLog(...messageArgs);
|
||||
const text =
|
||||
messageArgs
|
||||
.map((arg) => {
|
||||
if (typeof arg === "object" && arg !== null) {
|
||||
try {
|
||||
return JSON.stringify(arg, null, 2);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
})
|
||||
.join(" ") + "\n";
|
||||
logStream?.write(text);
|
||||
};
|
||||
|
||||
// Monkeypatch console.error
|
||||
const originalError = console.error;
|
||||
console.error = (...messageArgs: unknown[]) => {
|
||||
originalError(...messageArgs);
|
||||
const text =
|
||||
messageArgs
|
||||
.map((arg) => {
|
||||
if (typeof arg === "object" && arg !== null) {
|
||||
try {
|
||||
return JSON.stringify(arg, null, 2);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
})
|
||||
.join(" ") + "\n";
|
||||
logStream?.write("[ERROR] " + text);
|
||||
};
|
||||
|
||||
process.on("exit", () => {
|
||||
logStream?.end();
|
||||
});
|
||||
}
|
||||
const scenarioArgIndex = args.indexOf("--scenario");
|
||||
const scenarioPath =
|
||||
scenarioArgIndex !== -1
|
||||
? args[scenarioArgIndex + 1]
|
||||
: "content/demo/scenarios/talking-room.json";
|
||||
|
||||
const playArgIndex = args.indexOf("--play");
|
||||
const playEntityId = playArgIndex !== -1 ? args[playArgIndex + 1] : undefined;
|
||||
|
||||
const dbPath = path.resolve("./omnia.db");
|
||||
console.log(`Initializing SQLite database at: ${dbPath}`);
|
||||
|
||||
const db = new Database(dbPath);
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const loader = new ScenarioLoader(coreRepo, bufferRepo);
|
||||
|
||||
// 1. Read Scenario JSON file
|
||||
if (!fs.existsSync(scenarioPath)) {
|
||||
console.error(
|
||||
`Error: Scenario template file not found at: ${scenarioPath}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Loading scenario template from: ${scenarioPath}`);
|
||||
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
|
||||
|
||||
// 2. Initialize World Instance
|
||||
const worldInstanceId = `run-${Date.now()}`;
|
||||
console.log(`Initializing live world instance: ${worldInstanceId}`);
|
||||
await loader.initializeWorld(scenarioJson, worldInstanceId);
|
||||
|
||||
// Load the running world state
|
||||
const worldState = coreRepo.loadWorldState(worldInstanceId);
|
||||
if (!worldState) {
|
||||
console.error(
|
||||
`Error: Failed to load initialized world state: ${worldInstanceId}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Ensure API Key exists if we are running LLMs
|
||||
const apiKey = process.env.GOOGLE_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("Error: GOOGLE_API_KEY environment variable is missing.");
|
||||
console.error(
|
||||
"Please provide it in your .env file to enable LLM generators and decoders.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const llmProvider = new GeminiProvider(apiKey);
|
||||
const architect = new Architect(llmProvider, coreRepo);
|
||||
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
|
||||
|
||||
console.log(
|
||||
"\n================================================================================",
|
||||
);
|
||||
console.log(`SIMULATION STARTED: "${scenarioJson.name}"`);
|
||||
console.log(`Description: ${scenarioJson.description}`);
|
||||
if (playEntityId) {
|
||||
console.log(`Player Role: Controlling entity "${playEntityId}"`);
|
||||
} else {
|
||||
console.log("Player Role: Observing fully autonomous NPC run");
|
||||
}
|
||||
console.log(
|
||||
"================================================================================",
|
||||
);
|
||||
|
||||
const isVerbose = args.includes("--verbose");
|
||||
|
||||
let turnCount = 1;
|
||||
const maxTurns = 20; // safe loop breaker
|
||||
|
||||
while (turnCount <= maxTurns) {
|
||||
console.log(
|
||||
`\n\n--- TURN ${turnCount} (World Time: ${worldState.clock.get().toISOString()}) ---`,
|
||||
);
|
||||
|
||||
// Reload world state from database to ensure fresh DB sync
|
||||
const currentWorldState = coreRepo.loadWorldState(worldInstanceId);
|
||||
if (!currentWorldState) {
|
||||
console.error("Error: Synced world state lost.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Auto-resolve aliases for co-located entities who don't know each other yet
|
||||
await runAliasResolution(currentWorldState, aliasGenerator, coreRepo);
|
||||
|
||||
const entities = Array.from(currentWorldState.entities.values());
|
||||
|
||||
for (const entity of entities) {
|
||||
// 1. Determine the ActorAgent generator: CLI input for player, LLM for NPCs
|
||||
const isPlayer = playEntityId && entity.id === playEntityId;
|
||||
const generator = isPlayer ? new CLIProseGenerator() : undefined;
|
||||
|
||||
const agent = new ActorAgent(llmProvider, bufferRepo, 20, generator);
|
||||
|
||||
// Verbose mode: Output the generated prompt builder context before generation
|
||||
if (isVerbose) {
|
||||
const promptBuilder = new ActorPromptBuilder(bufferRepo, 20);
|
||||
const { systemPrompt, userContext } = promptBuilder.build(
|
||||
currentWorldState,
|
||||
entity,
|
||||
);
|
||||
console.log(`\n[VERBOSE] Assembled Prompts for "${entity.id}":`);
|
||||
console.log("\n--- SYSTEM PROMPT ---");
|
||||
console.log(systemPrompt);
|
||||
console.log("\n--- USER CONTEXT ---");
|
||||
console.log(userContext);
|
||||
console.log("\n--- CONTEXT BREAKDOWN ---");
|
||||
|
||||
const userSections = userContext.split("\n\n");
|
||||
const momentSection =
|
||||
userSections.find((s) => s.startsWith("=== CURRENT MOMENT ===")) ||
|
||||
"";
|
||||
const worldSection =
|
||||
userSections.find((s) =>
|
||||
s.startsWith("=== THE WORLD AS YOU PERCEIVE IT ==="),
|
||||
) || "";
|
||||
const memorySection =
|
||||
userSections.find((s) =>
|
||||
s.startsWith("=== YOUR RECENT MEMORY ==="),
|
||||
) || "";
|
||||
|
||||
const systemChars = systemPrompt.length;
|
||||
const momentChars = momentSection.length;
|
||||
const worldChars = worldSection.length;
|
||||
const memoryChars = memorySection.length;
|
||||
const totalChars = systemChars + userContext.length;
|
||||
|
||||
const estTokens = (chars: number) => Math.ceil(chars / 4);
|
||||
|
||||
console.log(
|
||||
` ├─ System Instructions: ${systemChars.toLocaleString()} chars (~${estTokens(systemChars)} tokens)`,
|
||||
);
|
||||
console.log(
|
||||
` ├─ Current Moment Context: ${momentChars.toLocaleString()} chars (~${estTokens(momentChars)} tokens)`,
|
||||
);
|
||||
console.log(
|
||||
` ├─ World Perception: ${worldChars.toLocaleString()} chars (~${estTokens(worldChars)} tokens)`,
|
||||
);
|
||||
console.log(
|
||||
` ├─ Recent Memory Buffer: ${memoryChars.toLocaleString()} chars (~${estTokens(memoryChars)} tokens)`,
|
||||
);
|
||||
console.log(
|
||||
` └─ TOTAL ESTIMATED INPUT: ${totalChars.toLocaleString()} chars (~${estTokens(totalChars)} tokens)`,
|
||||
);
|
||||
console.log(
|
||||
"--------------------------------------------------------------------------------",
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPlayer) {
|
||||
console.log(`\n[${entity.id}] is thinking...`);
|
||||
}
|
||||
|
||||
// 2. Execute character turn
|
||||
const turnResult = await agent.act(currentWorldState, entity);
|
||||
|
||||
if (!isPlayer) {
|
||||
console.log(`\n[${entity.id}]: ${turnResult.narrativeProse}`);
|
||||
} else {
|
||||
console.log(`\n[You]: ${turnResult.narrativeProse}`);
|
||||
}
|
||||
|
||||
// Verbose mode: Output decoded intent structures
|
||||
if (isVerbose) {
|
||||
console.log(`\n[VERBOSE] Decoded Intents from Prose:`);
|
||||
console.log(JSON.stringify(turnResult.intents.intents, null, 2));
|
||||
console.log(
|
||||
"--------------------------------------------------------------------------------",
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Process each generated intent sequence through physics and memory
|
||||
for (const intent of turnResult.intents.intents) {
|
||||
const outcome = await architect.processIntent(
|
||||
currentWorldState,
|
||||
intent,
|
||||
);
|
||||
const timestamp = currentWorldState.clock.get().toISOString();
|
||||
|
||||
// Verbose mode: Output architect evaluation
|
||||
if (isVerbose) {
|
||||
console.log(`\n[VERBOSE] Architect Intent Processing:`);
|
||||
console.log(` Type: ${intent.type}`);
|
||||
console.log(` Description: "${intent.description}"`);
|
||||
if (intent.type === "monologue") {
|
||||
console.log(" Validation: Bypassed (monologue)");
|
||||
} else {
|
||||
console.log(
|
||||
` Validation Result: isValid = ${outcome.isValid}, reason = "${outcome.reason}"`,
|
||||
);
|
||||
if (outcome.timeDelta) {
|
||||
console.log(
|
||||
` Clock Delta: +${outcome.timeDelta.minutesToAdvance} min (${outcome.timeDelta.explanation})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
"--------------------------------------------------------------------------------",
|
||||
);
|
||||
}
|
||||
|
||||
// Save actor's subjective memory
|
||||
const actorEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
timestamp,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
actorEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
bufferRepo.save(actorEntry);
|
||||
|
||||
// Propagate public memories (dialogue/actions) to co-located observers
|
||||
if (
|
||||
entity.locationId &&
|
||||
(intent.type === "dialogue" || intent.type === "action")
|
||||
) {
|
||||
for (const other of currentWorldState.entities.values()) {
|
||||
if (
|
||||
other.id !== entity.id &&
|
||||
other.locationId === entity.locationId
|
||||
) {
|
||||
const observerEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
timestamp,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
observerEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
bufferRepo.save({
|
||||
...observerEntry,
|
||||
ownerId: other.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print formatted logs
|
||||
if (intent.type === "monologue") {
|
||||
if (isPlayer) {
|
||||
console.log(` (Thought processed: "${intent.description}")`);
|
||||
}
|
||||
} else if (intent.type === "dialogue") {
|
||||
console.log(
|
||||
` (Dialogue spoken: spoken to ${intent.targetIds.join(", ") || "someone"})`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` (Action result: ${outcome.isValid ? "Success" : `Failed - ${outcome.reason}`})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Save synced world state to repository
|
||||
coreRepo.saveWorldState(currentWorldState);
|
||||
}
|
||||
|
||||
turnCount++;
|
||||
}
|
||||
|
||||
console.log("\nSimulation execution limit reached. Goodbye!");
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Simulation run aborted due to error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
18
cli/tsconfig.json
Normal file
18
cli/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../packages/core" },
|
||||
{ "path": "../packages/spatial" },
|
||||
{ "path": "../packages/memory" },
|
||||
{ "path": "../packages/intent" },
|
||||
{ "path": "../packages/architect" },
|
||||
{ "path": "../packages/actor" },
|
||||
{ "path": "../packages/llm" },
|
||||
{ "path": "../content/scenario-core" }
|
||||
]
|
||||
}
|
||||
18
content/scenario-builder/eslint.config.mjs
Normal file
18
content/scenario-builder/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
33
content/scenario-builder/next.config.ts
Normal file
33
content/scenario-builder/next.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { networkInterfaces } from "os";
|
||||
|
||||
const getLocalIPs = () => {
|
||||
const ips: string[] = ["localhost", "127.0.0.1"];
|
||||
const nets = networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name] || []) {
|
||||
if (net.family === "IPv4" && !net.internal) {
|
||||
ips.push(net.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
};
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: getLocalIPs(),
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
headers: [
|
||||
{ key: "Access-Control-Allow-Origin", value: "*" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST,PUT,DELETE,OPTIONS" },
|
||||
{ key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
27
content/scenario-builder/package.json
Normal file
27
content/scenario-builder/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "scenario-builder",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"@omnia/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
content/scenario-builder/postcss.config.mjs
Normal file
7
content/scenario-builder/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
content/scenario-builder/public/file.svg
Normal file
1
content/scenario-builder/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
content/scenario-builder/public/globe.svg
Normal file
1
content/scenario-builder/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
content/scenario-builder/public/next.svg
Normal file
1
content/scenario-builder/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
content/scenario-builder/public/vercel.svg
Normal file
1
content/scenario-builder/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
content/scenario-builder/public/window.svg
Normal file
1
content/scenario-builder/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
125
content/scenario-builder/src/app/api/world/route.ts
Normal file
125
content/scenario-builder/src/app/api/world/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Database from "better-sqlite3";
|
||||
import { WorldState, Entity, SQLiteRepository, AttributeVisibility } from "@omnia/core";
|
||||
import path from "path";
|
||||
|
||||
const DB_PATH = path.resolve("/home/sortedcord/Projects/omnia_umbrella/omnia/omnia.db");
|
||||
|
||||
function getRepo() {
|
||||
const db = new Database(DB_PATH);
|
||||
return { repo: new SQLiteRepository(db), db };
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
||||
const { repo, db } = getRepo();
|
||||
|
||||
try {
|
||||
if (id) {
|
||||
const world = repo.loadWorldState(id);
|
||||
if (!world) {
|
||||
return NextResponse.json({ error: `World with ID ${id} not found` }, { status: 404 });
|
||||
}
|
||||
|
||||
// Serialize world
|
||||
const serialized = {
|
||||
id: world.id,
|
||||
attributes: Array.from(world.attributes.values()).map(attr => ({
|
||||
name: attr.name,
|
||||
value: attr.getValue(),
|
||||
visibility: attr.getVisibility(),
|
||||
allowedEntities: Array.from(attr.getAllowedEntities())
|
||||
})),
|
||||
entities: Array.from(world.entities.values()).map(entity => ({
|
||||
id: entity.id,
|
||||
attributes: Array.from(entity.attributes.values()).map(attr => ({
|
||||
name: attr.name,
|
||||
value: attr.getValue(),
|
||||
visibility: attr.getVisibility(),
|
||||
allowedEntities: Array.from(attr.getAllowedEntities())
|
||||
}))
|
||||
}))
|
||||
};
|
||||
return NextResponse.json(serialized);
|
||||
} else {
|
||||
// List all worlds
|
||||
const rows = db.prepare("SELECT id FROM objects WHERE type = 'world'").all() as { id: string }[];
|
||||
const worlds = [];
|
||||
for (const row of rows) {
|
||||
const world = repo.loadWorldState(row.id);
|
||||
if (world) {
|
||||
const nameAttr = world.attributes.get("name")?.getValue() || "Unnamed World";
|
||||
worlds.push({
|
||||
id: world.id,
|
||||
name: nameAttr
|
||||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ worlds });
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("API GET Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Internal Server Error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const payload = await request.json();
|
||||
if (!payload.id) {
|
||||
return NextResponse.json({ error: "World ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { repo, db } = getRepo();
|
||||
|
||||
try {
|
||||
const world = new WorldState(payload.id);
|
||||
|
||||
// Add attributes to world
|
||||
if (payload.attributes && Array.isArray(payload.attributes)) {
|
||||
for (const attr of payload.attributes) {
|
||||
world.addAttribute(
|
||||
attr.name,
|
||||
attr.value,
|
||||
attr.visibility || AttributeVisibility.PUBLIC,
|
||||
attr.allowedEntities ? new Set(attr.allowedEntities) : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add entities to world
|
||||
if (payload.entities && Array.isArray(payload.entities)) {
|
||||
for (const ent of payload.entities) {
|
||||
const entity = new Entity(ent.id);
|
||||
if (ent.attributes && Array.isArray(ent.attributes)) {
|
||||
for (const attr of ent.attributes) {
|
||||
entity.addAttribute(
|
||||
attr.name,
|
||||
attr.value,
|
||||
attr.visibility || AttributeVisibility.PRIVATE,
|
||||
attr.allowedEntities ? new Set(attr.allowedEntities) : null
|
||||
);
|
||||
}
|
||||
}
|
||||
world.addEntity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
repo.saveWorldState(world);
|
||||
return NextResponse.json({ success: true, worldId: world.id });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("API POST Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Internal Server Error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
BIN
content/scenario-builder/src/app/favicon.ico
Normal file
BIN
content/scenario-builder/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
6
content/scenario-builder/src/app/globals.css
Normal file
6
content/scenario-builder/src/app/globals.css
Normal file
@@ -0,0 +1,6 @@
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
33
content/scenario-builder/src/app/layout.tsx
Normal file
33
content/scenario-builder/src/app/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
586
content/scenario-builder/src/app/page.tsx
Normal file
586
content/scenario-builder/src/app/page.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
function generateUUID(): string {
|
||||
if (typeof window !== "undefined" && window.crypto && window.crypto.randomUUID) {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
interface Attribute {
|
||||
name: string;
|
||||
value: string;
|
||||
visibility: "PUBLIC" | "PRIVATE";
|
||||
allowedEntities: string[];
|
||||
}
|
||||
|
||||
interface Entity {
|
||||
id: string;
|
||||
attributes: Attribute[];
|
||||
}
|
||||
|
||||
interface WorldData {
|
||||
id: string;
|
||||
attributes: Attribute[];
|
||||
entities: Entity[];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [worldsList, setWorldsList] = useState<{ id: string; name: string }[]>([]);
|
||||
const [selectedWorldId, setSelectedWorldId] = useState<string>("");
|
||||
const [worldData, setWorldData] = useState<WorldData | null>(null);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
// Temp state for adding new attributes / entities
|
||||
const [newWorldAttribute, setNewWorldAttribute] = useState<{ name: string; value: string; visibility: "PUBLIC" | "PRIVATE" }>({ name: "", value: "", visibility: "PUBLIC" });
|
||||
const [newEntityAttribute, setNewEntityAttribute] = useState<Record<string, { name: string; value: string; visibility: "PUBLIC" | "PRIVATE" }>>({});
|
||||
|
||||
const fetchWorlds = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/world");
|
||||
if (!res.ok) throw new Error("Failed to fetch worlds");
|
||||
const data = await res.json();
|
||||
setWorldsList(data.worlds || []);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to load worlds list";
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchWorlds();
|
||||
}, []);
|
||||
|
||||
const handleCreateNewWorld = () => {
|
||||
const newId = generateUUID();
|
||||
setWorldData({
|
||||
id: newId,
|
||||
attributes: [{ name: "name", value: "New World", visibility: "PUBLIC", allowedEntities: [] }],
|
||||
entities: []
|
||||
});
|
||||
setSelectedWorldId("");
|
||||
setStatus("Created new world locally. Don't forget to save!");
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleLoadWorld = async (id: string) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setStatus(`Loading world ${id}...`);
|
||||
setError("");
|
||||
const res = await fetch(`/api/world?id=${id}`);
|
||||
if (!res.ok) throw new Error("Failed to load world data");
|
||||
const data = await res.json();
|
||||
setWorldData(data);
|
||||
setSelectedWorldId(id);
|
||||
setStatus(`Loaded world successfully!`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to load world";
|
||||
setError(msg);
|
||||
setStatus("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveWorld = async () => {
|
||||
if (!worldData) return;
|
||||
try {
|
||||
setStatus("Saving world to database...");
|
||||
setError("");
|
||||
const res = await fetch("/api/world", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(worldData)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to save world");
|
||||
|
||||
setStatus("World saved successfully!");
|
||||
fetchWorlds();
|
||||
setSelectedWorldId(worldData.id);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to save world";
|
||||
setError(msg);
|
||||
setStatus("");
|
||||
}
|
||||
};
|
||||
|
||||
// World Attribute management
|
||||
const addWorldAttribute = () => {
|
||||
if (!worldData || !newWorldAttribute.name.trim()) return;
|
||||
if (worldData.attributes.some(a => a.name === newWorldAttribute.name)) {
|
||||
setError(`Attribute "${newWorldAttribute.name}" already exists on world.`);
|
||||
return;
|
||||
}
|
||||
setWorldData({
|
||||
...worldData,
|
||||
attributes: [
|
||||
...worldData.attributes,
|
||||
{ name: newWorldAttribute.name, value: newWorldAttribute.value, visibility: newWorldAttribute.visibility, allowedEntities: [] }
|
||||
]
|
||||
});
|
||||
setNewWorldAttribute({ name: "", value: "", visibility: "PUBLIC" });
|
||||
setError("");
|
||||
};
|
||||
|
||||
const removeWorldAttribute = (name: string) => {
|
||||
if (!worldData) return;
|
||||
setWorldData({
|
||||
...worldData,
|
||||
attributes: worldData.attributes.filter(a => a.name !== name)
|
||||
});
|
||||
};
|
||||
|
||||
const updateWorldAttributeValue = (name: string, value: string) => {
|
||||
if (!worldData) return;
|
||||
setWorldData({
|
||||
...worldData,
|
||||
attributes: worldData.attributes.map(a => a.name === name ? { ...a, value } : a)
|
||||
});
|
||||
};
|
||||
|
||||
const updateWorldAttributeVisibility = (name: string, visibility: "PUBLIC" | "PRIVATE") => {
|
||||
if (!worldData) return;
|
||||
setWorldData({
|
||||
...worldData,
|
||||
attributes: worldData.attributes.map(a => a.name === name ? { ...a, visibility, allowedEntities: visibility === "PUBLIC" ? [] : a.allowedEntities } : a)
|
||||
});
|
||||
};
|
||||
|
||||
// Entity management
|
||||
const handleAddEntity = () => {
|
||||
if (!worldData) return;
|
||||
const newId = generateUUID();
|
||||
const newEntity: Entity = {
|
||||
id: newId,
|
||||
attributes: [{ name: "name", value: "New Entity", visibility: "PRIVATE", allowedEntities: [] }]
|
||||
};
|
||||
setWorldData({
|
||||
...worldData,
|
||||
entities: [...worldData.entities, newEntity]
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveEntity = (entityId: string) => {
|
||||
if (!worldData) return;
|
||||
// Also clean up this entity from all attribute ACL lists
|
||||
const updatedEntities = worldData.entities.filter(e => e.id !== entityId).map(e => ({
|
||||
...e,
|
||||
attributes: e.attributes.map(a => ({
|
||||
...a,
|
||||
allowedEntities: a.allowedEntities.filter(id => id !== entityId)
|
||||
}))
|
||||
}));
|
||||
|
||||
const updatedWorldAttributes = worldData.attributes.map(a => ({
|
||||
...a,
|
||||
allowedEntities: a.allowedEntities.filter(id => id !== entityId)
|
||||
}));
|
||||
|
||||
setWorldData({
|
||||
...worldData,
|
||||
attributes: updatedWorldAttributes,
|
||||
entities: updatedEntities
|
||||
});
|
||||
};
|
||||
|
||||
// Entity Attribute management
|
||||
const addEntityAttribute = (entityId: string) => {
|
||||
if (!worldData) return;
|
||||
const input = newEntityAttribute[entityId];
|
||||
if (!input || !input.name.trim()) return;
|
||||
|
||||
const entity = worldData.entities.find(e => e.id === entityId);
|
||||
if (!entity) return;
|
||||
|
||||
if (entity.attributes.some(a => a.name === input.name)) {
|
||||
setError(`Attribute "${input.name}" already exists on entity.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedEntities = worldData.entities.map(e => {
|
||||
if (e.id === entityId) {
|
||||
return {
|
||||
...e,
|
||||
attributes: [
|
||||
...e.attributes,
|
||||
{ name: input.name, value: input.value, visibility: input.visibility, allowedEntities: [] }
|
||||
]
|
||||
};
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
setWorldData({ ...worldData, entities: updatedEntities });
|
||||
setNewEntityAttribute({
|
||||
...newEntityAttribute,
|
||||
[entityId]: { name: "", value: "", visibility: "PRIVATE" }
|
||||
});
|
||||
setError("");
|
||||
};
|
||||
|
||||
const removeEntityAttribute = (entityId: string, attrName: string) => {
|
||||
if (!worldData) return;
|
||||
const updatedEntities = worldData.entities.map(e => {
|
||||
if (e.id === entityId) {
|
||||
return {
|
||||
...e,
|
||||
attributes: e.attributes.filter(a => a.name !== attrName)
|
||||
};
|
||||
}
|
||||
return e;
|
||||
});
|
||||
setWorldData({ ...worldData, entities: updatedEntities });
|
||||
};
|
||||
|
||||
const updateEntityAttributeValue = (entityId: string, attrName: string, value: string) => {
|
||||
if (!worldData) return;
|
||||
const updatedEntities = worldData.entities.map(e => {
|
||||
if (e.id === entityId) {
|
||||
return {
|
||||
...e,
|
||||
attributes: e.attributes.map(a => a.name === attrName ? { ...a, value } : a)
|
||||
};
|
||||
}
|
||||
return e;
|
||||
});
|
||||
setWorldData({ ...worldData, entities: updatedEntities });
|
||||
};
|
||||
|
||||
const updateEntityAttributeVisibility = (entityId: string, attrName: string, visibility: "PUBLIC" | "PRIVATE") => {
|
||||
if (!worldData) return;
|
||||
const updatedEntities = worldData.entities.map(e => {
|
||||
if (e.id === entityId) {
|
||||
return {
|
||||
...e,
|
||||
attributes: e.attributes.map(a => a.name === attrName ? { ...a, visibility, allowedEntities: visibility === "PUBLIC" ? [] : a.allowedEntities } : a)
|
||||
};
|
||||
}
|
||||
return e;
|
||||
});
|
||||
setWorldData({ ...worldData, entities: updatedEntities });
|
||||
};
|
||||
|
||||
const toggleEntityAcl = (targetEntityId: string, attrName: string, allowedEntityId: string, checked: boolean) => {
|
||||
if (!worldData) return;
|
||||
const updatedEntities = worldData.entities.map(e => {
|
||||
if (e.id === targetEntityId) {
|
||||
return {
|
||||
...e,
|
||||
attributes: e.attributes.map(a => {
|
||||
if (a.name === attrName) {
|
||||
const currentAcl = a.allowedEntities;
|
||||
const newAcl = checked
|
||||
? [...currentAcl, allowedEntityId]
|
||||
: currentAcl.filter(id => id !== allowedEntityId);
|
||||
return { ...a, allowedEntities: newAcl };
|
||||
}
|
||||
return a;
|
||||
})
|
||||
};
|
||||
}
|
||||
return e;
|
||||
});
|
||||
setWorldData({ ...worldData, entities: updatedEntities });
|
||||
};
|
||||
|
||||
const toggleWorldAcl = (attrName: string, allowedEntityId: string, checked: boolean) => {
|
||||
if (!worldData) return;
|
||||
setWorldData({
|
||||
...worldData,
|
||||
attributes: worldData.attributes.map(a => {
|
||||
if (a.name === attrName) {
|
||||
const currentAcl = a.allowedEntities;
|
||||
const newAcl = checked
|
||||
? [...currentAcl, allowedEntityId]
|
||||
: currentAcl.filter(id => id !== allowedEntityId);
|
||||
return { ...a, allowedEntities: newAcl };
|
||||
}
|
||||
return a;
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "20px", fontFamily: "sans-serif" }}>
|
||||
<h1>Omnia Scenario Builder</h1>
|
||||
<hr />
|
||||
|
||||
{/* Persistence Controls */}
|
||||
<section style={{ marginBottom: "20px" }}>
|
||||
<h2>World Persistence</h2>
|
||||
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
|
||||
<button onClick={handleCreateNewWorld}>Create New World</button>
|
||||
<span>or Load Existing:</span>
|
||||
<select
|
||||
value={selectedWorldId}
|
||||
onChange={(e) => handleLoadWorld(e.target.value)}
|
||||
>
|
||||
<option value="">-- Select World --</option>
|
||||
{worldsList.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name} ({w.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{worldData && (
|
||||
<>
|
||||
<button onClick={handleSaveWorld} style={{ fontWeight: "bold" }}>
|
||||
Save World to DB
|
||||
</button>
|
||||
<button onClick={() => handleLoadWorld(worldData.id)}>
|
||||
Reload / Discard Changes
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Status Messages */}
|
||||
{status && <div style={{ color: "green", margin: "10px 0" }}><strong>Status:</strong> {status}</div>}
|
||||
{error && <div style={{ color: "red", margin: "10px 0" }}><strong>Error:</strong> {error}</div>}
|
||||
|
||||
{worldData ? (
|
||||
<div>
|
||||
<hr />
|
||||
{/* World Information */}
|
||||
<section style={{ marginBottom: "30px" }}>
|
||||
<h2>World Attributes (ID: {worldData.id})</h2>
|
||||
|
||||
<table border={1} cellPadding={5} style={{ borderCollapse: "collapse", width: "100%", marginBottom: "10px" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Attribute Name</th>
|
||||
<th>Value</th>
|
||||
<th>Visibility</th>
|
||||
<th>ACL (Allowed Entities for PRIVATE)</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{worldData.attributes.map((attr) => (
|
||||
<tr key={attr.name}>
|
||||
<td><strong>{attr.name}</strong></td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={attr.value}
|
||||
onChange={(e) => updateWorldAttributeValue(attr.name, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={attr.visibility}
|
||||
onChange={(e) => updateWorldAttributeVisibility(attr.name, e.target.value as "PUBLIC" | "PRIVATE")}
|
||||
>
|
||||
<option value="PUBLIC">PUBLIC</option>
|
||||
<option value="PRIVATE">PRIVATE</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{attr.visibility === "PRIVATE" ? (
|
||||
<div>
|
||||
{worldData.entities.length === 0 ? (
|
||||
<span style={{ color: "gray" }}>No entities in world to grant access to</span>
|
||||
) : (
|
||||
worldData.entities.map((e) => {
|
||||
const entName = e.attributes.find(a => a.name === "name")?.value || e.id;
|
||||
return (
|
||||
<label key={e.id} style={{ display: "block" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={attr.allowedEntities.includes(e.id)}
|
||||
onChange={(evt) => toggleWorldAcl(attr.name, e.id, evt.target.checked)}
|
||||
/>{" "}
|
||||
{entName} ({e.id.slice(0, 8)}...)
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: "gray" }}>N/A (Visible to all)</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button onClick={() => removeWorldAttribute(attr.name)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Add World Attribute */}
|
||||
<div style={{ background: "#f5f5f5", padding: "10px", border: "1px solid #ccc" }}>
|
||||
<h4>Add World Attribute</h4>
|
||||
Name:{" "}
|
||||
<input
|
||||
type="text"
|
||||
value={newWorldAttribute.name}
|
||||
onChange={(e) => setNewWorldAttribute({ ...newWorldAttribute, name: e.target.value })}
|
||||
placeholder="e.g. description"
|
||||
/>{" "}
|
||||
Value:{" "}
|
||||
<input
|
||||
type="text"
|
||||
value={newWorldAttribute.value}
|
||||
onChange={(e) => setNewWorldAttribute({ ...newWorldAttribute, value: e.target.value })}
|
||||
placeholder="e.g. A lush land"
|
||||
/>{" "}
|
||||
Visibility:{" "}
|
||||
<select
|
||||
value={newWorldAttribute.visibility}
|
||||
onChange={(e) => setNewWorldAttribute({ ...newWorldAttribute, visibility: e.target.value as "PUBLIC" | "PRIVATE" })}
|
||||
>
|
||||
<option value="PUBLIC">PUBLIC</option>
|
||||
<option value="PRIVATE">PRIVATE</option>
|
||||
</select>{" "}
|
||||
<button onClick={addWorldAttribute}>Add Attribute</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Entities section */}
|
||||
<section style={{ marginBottom: "30px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h2>World Entities</h2>
|
||||
<button onClick={handleAddEntity}>+ Add New Entity</button>
|
||||
</div>
|
||||
|
||||
{worldData.entities.length === 0 ? (
|
||||
<p>No entities found in this world. Click "+ Add New Entity" to add one.</p>
|
||||
) : (
|
||||
worldData.entities.map((entity, index) => {
|
||||
const entName = entity.attributes.find(a => a.name === "name")?.value || "Unnamed Entity";
|
||||
const entInputState = newEntityAttribute[entity.id] || { name: "", value: "", visibility: "PRIVATE" };
|
||||
|
||||
return (
|
||||
<div key={entity.id} style={{ border: "1px solid #999", padding: "15px", marginBottom: "20px", background: "#fafafa" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3>Entity #{index + 1}: {entName} <span style={{ fontSize: "12px", color: "gray", fontWeight: "normal" }}>({entity.id})</span></h3>
|
||||
<button onClick={() => handleRemoveEntity(entity.id)} style={{ color: "red" }}>Delete Entity</button>
|
||||
</div>
|
||||
|
||||
<table border={1} cellPadding={5} style={{ borderCollapse: "collapse", width: "100%", marginBottom: "10px", background: "white" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Attribute Name</th>
|
||||
<th>Value</th>
|
||||
<th>Visibility</th>
|
||||
<th>ACL (Allowed Entities for PRIVATE)</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entity.attributes.map((attr) => (
|
||||
<tr key={attr.name}>
|
||||
<td><strong>{attr.name}</strong></td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={attr.value}
|
||||
onChange={(e) => updateEntityAttributeValue(entity.id, attr.name, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={attr.visibility}
|
||||
onChange={(e) => updateEntityAttributeVisibility(entity.id, attr.name, e.target.value as "PUBLIC" | "PRIVATE")}
|
||||
>
|
||||
<option value="PUBLIC">PUBLIC</option>
|
||||
<option value="PRIVATE">PRIVATE</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{attr.visibility === "PRIVATE" ? (
|
||||
<div>
|
||||
{worldData.entities.filter(e => e.id !== entity.id).length === 0 ? (
|
||||
<span style={{ color: "gray" }}>No other entities in world to grant access to</span>
|
||||
) : (
|
||||
worldData.entities
|
||||
.filter(e => e.id !== entity.id)
|
||||
.map((e) => {
|
||||
const otherName = e.attributes.find(a => a.name === "name")?.value || e.id;
|
||||
return (
|
||||
<label key={e.id} style={{ display: "block" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={attr.allowedEntities.includes(e.id)}
|
||||
onChange={(evt) => toggleEntityAcl(entity.id, attr.name, e.id, evt.target.checked)}
|
||||
/>{" "}
|
||||
{otherName} ({e.id.slice(0, 8)}...)
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: "gray" }}>N/A (Visible to all)</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button onClick={() => removeEntityAttribute(entity.id, attr.name)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Add Entity Attribute */}
|
||||
<div style={{ background: "#eee", padding: "10px", border: "1px dashed #777" }}>
|
||||
<h5>Add Attribute to {entName}</h5>
|
||||
Name:{" "}
|
||||
<input
|
||||
type="text"
|
||||
value={entInputState.name}
|
||||
onChange={(e) => setNewEntityAttribute({
|
||||
...newEntityAttribute,
|
||||
[entity.id]: { ...entInputState, name: e.target.value }
|
||||
})}
|
||||
placeholder="e.g. title"
|
||||
/>{" "}
|
||||
Value:{" "}
|
||||
<input
|
||||
type="text"
|
||||
value={entInputState.value}
|
||||
onChange={(e) => setNewEntityAttribute({
|
||||
...newEntityAttribute,
|
||||
[entity.id]: { ...entInputState, value: e.target.value }
|
||||
})}
|
||||
placeholder="e.g. Witcher"
|
||||
/>{" "}
|
||||
Visibility:{" "}
|
||||
<select
|
||||
value={entInputState.visibility}
|
||||
onChange={(e) => setNewEntityAttribute({
|
||||
...newEntityAttribute,
|
||||
[entity.id]: { ...entInputState, visibility: e.target.value as "PUBLIC" | "PRIVATE" }
|
||||
})}
|
||||
>
|
||||
<option value="PUBLIC">PUBLIC</option>
|
||||
<option value="PRIVATE">PRIVATE</option>
|
||||
</select>{" "}
|
||||
<button onClick={() => addEntityAttribute(entity.id)}>Add Attribute</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "40px 0", textAlign: "center", color: "gray" }}>
|
||||
Please select a world to load or click "Create New World" to begin.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
content/scenario-builder/src/proxy.ts
Normal file
31
content/scenario-builder/src/proxy.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const origin = request.headers.get("origin") || "*";
|
||||
|
||||
// Handle preflight OPTIONS requests
|
||||
if (request.method === "OPTIONS") {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": origin,
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle standard requests
|
||||
const response = NextResponse.next();
|
||||
response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: "/api/:path*",
|
||||
};
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -23,9 +19,7 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -33,9 +27,8 @@
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@omnia/scenario",
|
||||
"name": "@omnia/scenario-core",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -10,7 +10,7 @@ import { ScenarioLoader, ScenarioSchema } from "../src/index.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const SCENARIO_PATH = path.resolve(__dirname, "../../../content/demo/scenarios/talking-room.json");
|
||||
const SCENARIO_PATH = path.resolve(__dirname, "../../demo/scenarios/talking-room.json");
|
||||
|
||||
describe("Talking Room Demo Scenario Test (Tier 1)", () => {
|
||||
test("talking-room.json exists, parses, and loads correctly into database", async () => {
|
||||
@@ -6,8 +6,8 @@
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../spatial" },
|
||||
{ "path": "../memory" }
|
||||
{ "path": "../../packages/core" },
|
||||
{ "path": "../../packages/spatial" },
|
||||
{ "path": "../../packages/memory" }
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.astro/**", "**/.next/**"],
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "content/scenario-builder/**", "**/.astro/**"],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
@@ -19,12 +19,5 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ["**/*.tsx"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
},
|
||||
|
||||
eslintConfigPrettier,
|
||||
];
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
"build": "tsc -b",
|
||||
"build:web": "pnpm --filter landing build",
|
||||
"build:docs": "pnpm --filter docs build",
|
||||
"build:gui": "pnpm --filter @omnia/gui build",
|
||||
"build:all": "pnpm build && pnpm build:web && pnpm build:docs && pnpm build:gui",
|
||||
"build:all": "pnpm build && pnpm build:web && pnpm build:docs",
|
||||
"dev:web": "pnpm --filter landing dev",
|
||||
"dev:docs": "pnpm --filter docs dev",
|
||||
"dev:gui": "pnpm --filter @omnia/gui dev",
|
||||
"clean": "git clean -xfd",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
@@ -20,7 +18,8 @@
|
||||
"watch": "tsc -b --watch",
|
||||
"test": "vitest run --project unit",
|
||||
"test:watch": "vitest --project unit",
|
||||
"test:evals": "vitest run --project evals"
|
||||
"test:evals": "vitest run --project evals",
|
||||
"play": "node cli/dist/index.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -48,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/openrouter": "^0.4.3",
|
||||
"@types/node": "^20.19.43",
|
||||
"dotenv": "^17.4.2"
|
||||
}
|
||||
|
||||
@@ -68,29 +68,15 @@ export class ActorAgent {
|
||||
private decoder: IntentDecoder;
|
||||
private generator: IActorProseGenerator;
|
||||
|
||||
private llmProvider: ILLMProvider;
|
||||
|
||||
constructor(
|
||||
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: ILLMProvider },
|
||||
private llmProvider: ILLMProvider,
|
||||
bufferRepo?: BufferRepository,
|
||||
memoryLimit?: number,
|
||||
generator?: IActorProseGenerator,
|
||||
) {
|
||||
let actorProv: ILLMProvider;
|
||||
let decoderProv: ILLMProvider;
|
||||
|
||||
if ("actor" in llmProvider && "decoder" in llmProvider) {
|
||||
actorProv = llmProvider.actor;
|
||||
decoderProv = llmProvider.decoder;
|
||||
} else {
|
||||
actorProv = llmProvider;
|
||||
decoderProv = llmProvider;
|
||||
}
|
||||
|
||||
this.promptBuilder = new ActorPromptBuilder(bufferRepo, memoryLimit);
|
||||
this.decoder = new IntentDecoder(decoderProv);
|
||||
this.generator = generator ?? new LLMActorProseGenerator(actorProv);
|
||||
this.llmProvider = actorProv;
|
||||
this.decoder = new IntentDecoder(llmProvider);
|
||||
this.generator = generator ?? new LLMActorProseGenerator(llmProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,23 +12,9 @@ export class Architect {
|
||||
private validator: LLMValidator;
|
||||
private timeDeltaGenerator: TimeDeltaGenerator;
|
||||
|
||||
constructor(
|
||||
llmProvider: ILLMProvider | { validator: ILLMProvider; timedelta: ILLMProvider },
|
||||
private repo?: SQLiteRepository,
|
||||
) {
|
||||
let valProv: ILLMProvider;
|
||||
let timeProv: ILLMProvider;
|
||||
|
||||
if ("validator" in llmProvider && "timedelta" in llmProvider) {
|
||||
valProv = llmProvider.validator;
|
||||
timeProv = llmProvider.timedelta;
|
||||
} else {
|
||||
valProv = llmProvider;
|
||||
timeProv = llmProvider;
|
||||
}
|
||||
|
||||
this.validator = new LLMValidator(valProv);
|
||||
this.timeDeltaGenerator = new TimeDeltaGenerator(timeProv);
|
||||
constructor(llmProvider: ILLMProvider, private repo?: SQLiteRepository) {
|
||||
this.validator = new LLMValidator(llmProvider);
|
||||
this.timeDeltaGenerator = new TimeDeltaGenerator(llmProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -209,7 +209,7 @@ export function serializeSubjectiveWorldState(
|
||||
lines.push(" Entities present with you:");
|
||||
for (const e of coLocated) {
|
||||
const alias = resolveAliasViewer(viewer, e.id);
|
||||
lines.push(` - ${alias}:`);
|
||||
lines.push(` - ${alias} (ID: ${e.id}):`);
|
||||
const eVisible = e.getVisibleAttributesFor(viewerId);
|
||||
lines.push(serializeVisibleAttributes(eVisible).split("\n").map((l) => " " + l).join("\n"));
|
||||
}
|
||||
@@ -221,7 +221,7 @@ export function serializeSubjectiveWorldState(
|
||||
lines.push(" Other presences you are aware of (elsewhere):");
|
||||
for (const e of elsewhere) {
|
||||
const alias = resolveAliasViewer(viewer, e.id);
|
||||
lines.push(` - ${alias} [elsewhere]`);
|
||||
lines.push(` - ${alias} (ID: ${e.id}) [elsewhere]`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^26.1.0",
|
||||
"better-sqlite3": "^12.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13"
|
||||
"@types/node": "^26.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
const LLMConfigSchema = z.object({
|
||||
GOOGLE_API_KEY: z.string().optional(),
|
||||
OPENROUTER_API_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const llmConfig = LLMConfigSchema.parse(process.env);
|
||||
|
||||
@@ -2,4 +2,4 @@ export * from "./llm.js";
|
||||
export * from "./config.js";
|
||||
export * from "./providers/google-genai.js";
|
||||
export * from "./providers/mock.js";
|
||||
export * from "./provider-manager.js";
|
||||
export * from "./providers/openrouter.js";
|
||||
|
||||
@@ -11,21 +11,6 @@ export interface LLMResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LLMCallRecord {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ILLMProvider {
|
||||
@@ -34,14 +19,4 @@ export interface ILLMProvider {
|
||||
generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>>;
|
||||
lastCalls?: LLMCallRecord[];
|
||||
}
|
||||
|
||||
export interface LLMProviderInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: boolean;
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import type { LLMProviderInstance } from "./llm.js";
|
||||
|
||||
function getWorkspaceRoot() {
|
||||
let current = process.cwd();
|
||||
while (current !== "/" && current !== path.parse(current).root) {
|
||||
if (
|
||||
fs.existsSync(path.join(current, "pnpm-workspace.yaml")) ||
|
||||
fs.existsSync(path.join(current, "package.json"))
|
||||
) {
|
||||
if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function getSettingsDb() {
|
||||
const wsRoot = getWorkspaceRoot();
|
||||
const dbDir = path.resolve(wsRoot, "data");
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
const dbPath = path.join(dbDir, "settings.db");
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS provider_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
providerName TEXT NOT NULL,
|
||||
apiKey TEXT NOT NULL,
|
||||
isActive INTEGER NOT NULL DEFAULT 0,
|
||||
modelName TEXT
|
||||
)
|
||||
`).run();
|
||||
|
||||
try {
|
||||
db.prepare(`ALTER TABLE provider_instances ADD COLUMN modelName TEXT`).run();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export class ProviderManager {
|
||||
static list(): LLMProviderInstance[] {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const rows = db.prepare(`SELECT * FROM provider_instances`).all() as {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
}[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
providerName: r.providerName,
|
||||
apiKey: r.apiKey,
|
||||
isActive: r.isActive === 1,
|
||||
modelName: r.modelName || undefined,
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static create(name: string, providerName: string, apiKey: string, modelName?: string): LLMProviderInstance {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const id = "provider-" + Date.now();
|
||||
const activeCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1`).get() as { count: number };
|
||||
const isActive = activeCount.count === 0 ? 1 : 0;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, name, providerName, apiKey, isActive, modelName || null);
|
||||
|
||||
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static delete(id: string): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const provider = db.prepare(`SELECT isActive FROM provider_instances WHERE id = ?`).get(id) as { isActive: number } | undefined;
|
||||
db.prepare(`DELETE FROM provider_instances WHERE id = ?`).run(id);
|
||||
|
||||
if (provider && provider.isActive === 1) {
|
||||
const next = db.prepare(`SELECT id FROM provider_instances LIMIT 1`).get() as { id: string } | undefined;
|
||||
if (next) {
|
||||
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(next.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static setActive(id: string): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
db.prepare(`UPDATE provider_instances SET isActive = 0`).run();
|
||||
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(id);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static update(id: string, name: string, providerName: string, apiKey?: string, modelName?: string): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
if (apiKey && apiKey.trim()) {
|
||||
db.prepare(`
|
||||
UPDATE provider_instances
|
||||
SET name = ?, providerName = ?, apiKey = ?, modelName = ?
|
||||
WHERE id = ?
|
||||
`).run(name, providerName, apiKey, modelName || null, id);
|
||||
} else {
|
||||
db.prepare(`
|
||||
UPDATE provider_instances
|
||||
SET name = ?, providerName = ?, modelName = ?
|
||||
WHERE id = ?
|
||||
`).run(name, providerName, modelName || null, id);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static getActive(): LLMProviderInstance | null {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
// Query the DB
|
||||
const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1`).get() as {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
} | undefined;
|
||||
|
||||
if (!row) {
|
||||
// Check if there are any rows at all
|
||||
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
|
||||
if (totalCount.count === 0) {
|
||||
// Database is completely empty! Check if GOOGLE_API_KEY env is set.
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey && envKey.trim()) {
|
||||
// Auto-bootstrap default active instance from env
|
||||
const id = "provider-default-env";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "Default (Env)", "google-genai", envKey, 1, "gemini-2.5-flash");
|
||||
|
||||
return {
|
||||
id,
|
||||
name: "Default (Env)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
isActive: true,
|
||||
modelName: "gemini-2.5-flash",
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
providerName: row.providerName,
|
||||
apiKey: row.apiKey,
|
||||
isActive: true,
|
||||
modelName: row.modelName || undefined,
|
||||
};
|
||||
} catch {
|
||||
// Lock or write issue fallback: return an in-memory active key if env key exists
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
return {
|
||||
id: "provider-default-env-fallback",
|
||||
name: "Default (Env Fallback)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
isActive: true,
|
||||
modelName: "gemini-2.5-flash",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static getMappings(): Record<string, string> {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS provider_mappings (
|
||||
task TEXT PRIMARY KEY,
|
||||
providerInstanceId TEXT NOT NULL
|
||||
)
|
||||
`).run();
|
||||
const rows = db.prepare(`SELECT * FROM provider_mappings`).all() as {
|
||||
task: string;
|
||||
providerInstanceId: string;
|
||||
}[];
|
||||
const mappings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
mappings[row.task] = row.providerInstanceId;
|
||||
}
|
||||
return mappings;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static setMapping(task: string, providerInstanceId: string): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS provider_mappings (
|
||||
task TEXT PRIMARY KEY,
|
||||
providerInstanceId TEXT NOT NULL
|
||||
)
|
||||
`).run();
|
||||
if (!providerInstanceId) {
|
||||
db.prepare(`DELETE FROM provider_mappings WHERE task = ?`).run(task);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO provider_mappings (task, providerInstanceId)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(task) DO UPDATE SET providerInstanceId = excluded.providerInstanceId
|
||||
`).run(task, providerInstanceId);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +1,31 @@
|
||||
import { z } from "zod";
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js";
|
||||
import { llmConfig } from "../config.js";
|
||||
import { ProviderManager } from "../provider-manager.js";
|
||||
|
||||
export class GeminiProvider implements ILLMProvider {
|
||||
providerName = "Gemini";
|
||||
private model: ChatGoogleGenerativeAI;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(apiKey?: string, modelName?: string) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
|
||||
if (!key) {
|
||||
const active = ProviderManager.getActive();
|
||||
if (active) {
|
||||
key = active.apiKey;
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
key = llmConfig.GOOGLE_API_KEY;
|
||||
}
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
const key = apiKey || llmConfig.GOOGLE_API_KEY;
|
||||
if (!key) {
|
||||
throw new Error("GOOGLE_API_KEY is required to initialize GeminiProvider");
|
||||
}
|
||||
|
||||
this.model = new ChatGoogleGenerativeAI({
|
||||
apiKey: key,
|
||||
model: model || "gemini-2.5-flash",
|
||||
model: "gemini-2.5-flash",
|
||||
});
|
||||
}
|
||||
|
||||
async generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>> {
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
|
||||
const result = (await structuredModel.invoke([
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema);
|
||||
const result = await structuredModel.invoke([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
])) as unknown as {
|
||||
parsed?: z.infer<T>;
|
||||
raw?: {
|
||||
usage_metadata?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const parsed = result?.parsed;
|
||||
const raw = result?.raw;
|
||||
|
||||
const usage = raw?.usage_metadata ? {
|
||||
inputTokens: raw.usage_metadata.input_tokens || 0,
|
||||
outputTokens: raw.usage_metadata.output_tokens || 0,
|
||||
totalTokens: raw.usage_metadata.total_tokens || 0,
|
||||
} : undefined;
|
||||
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
userContext: request.userContext,
|
||||
usage,
|
||||
});
|
||||
|
||||
return { success: true, data: parsed, usage };
|
||||
]);
|
||||
return { success: true, data: result as z.infer<T> };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js";
|
||||
|
||||
export class MockLLMProvider implements ILLMProvider {
|
||||
providerName = "mock";
|
||||
private callCount = 0;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(private responses: unknown[]) {}
|
||||
|
||||
@@ -15,15 +14,9 @@ export class MockLLMProvider implements ILLMProvider {
|
||||
if (next === undefined) {
|
||||
return { success: false, error: "Mock responses exhausted" };
|
||||
}
|
||||
const usage = { inputTokens: 100, outputTokens: 50, totalTokens: 150 };
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
userContext: request.userContext,
|
||||
usage,
|
||||
});
|
||||
try {
|
||||
const parsed = request.schema.parse(next);
|
||||
return { success: true, data: parsed, usage };
|
||||
return { success: true, data: parsed };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
|
||||
31
packages/llm/src/providers/openrouter.ts
Normal file
31
packages/llm/src/providers/openrouter.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
import { ChatOpenRouter } from "@langchain/openrouter";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js";
|
||||
import { llmConfig } from "../config.js";
|
||||
|
||||
export class OpenRouterProvider implements ILLMProvider {
|
||||
providerName = "OpenRouter";
|
||||
private model: ChatOpenRouter;
|
||||
|
||||
constructor(apiKey?: string, modelName: string = "google/gemini-2.5-flash") {
|
||||
const key = apiKey || llmConfig.OPENROUTER_API_KEY;
|
||||
if (!key) {
|
||||
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
|
||||
}
|
||||
this.model = new ChatOpenRouter({
|
||||
apiKey: key,
|
||||
model: modelName,
|
||||
});
|
||||
}
|
||||
|
||||
async generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>> {
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema);
|
||||
const result = await structuredModel.invoke([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
]);
|
||||
return { success: true, data: result as z.infer<T> };
|
||||
}
|
||||
}
|
||||
81
packages/llm/tests/openrouter.test.ts
Normal file
81
packages/llm/tests/openrouter.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, test, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { OpenRouterProvider } from "../src/providers/openrouter.js";
|
||||
import { llmConfig } from "../src/config.js";
|
||||
|
||||
// Mock the ChatOpenRouter class
|
||||
vi.mock("@langchain/openrouter", () => {
|
||||
return {
|
||||
ChatOpenRouter: class {
|
||||
config: unknown;
|
||||
constructor(config: unknown) {
|
||||
this.config = config;
|
||||
}
|
||||
withStructuredOutput = vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
invoke: vi.fn().mockImplementation(async () => {
|
||||
// Return a mock output that matches a sample schema
|
||||
return {
|
||||
name: "mocked response",
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
test("initializes successfully with a provided apiKey", () => {
|
||||
const provider = new OpenRouterProvider("dummy-key");
|
||||
expect(provider.providerName).toBe("OpenRouter");
|
||||
});
|
||||
|
||||
test("initializes successfully with apiKey from config", () => {
|
||||
// Save current config
|
||||
const originalKey = llmConfig.OPENROUTER_API_KEY;
|
||||
llmConfig.OPENROUTER_API_KEY = "env-dummy-key";
|
||||
|
||||
try {
|
||||
const provider = new OpenRouterProvider();
|
||||
expect(provider.providerName).toBe("OpenRouter");
|
||||
} finally {
|
||||
llmConfig.OPENROUTER_API_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error if no API key is provided or in config", () => {
|
||||
// Save current config
|
||||
const originalKey = llmConfig.OPENROUTER_API_KEY;
|
||||
llmConfig.OPENROUTER_API_KEY = undefined;
|
||||
|
||||
try {
|
||||
expect(() => new OpenRouterProvider()).toThrow(
|
||||
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider"
|
||||
);
|
||||
} finally {
|
||||
llmConfig.OPENROUTER_API_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("generateStructuredResponse invokes the model with structured output", async () => {
|
||||
const provider = new OpenRouterProvider("dummy-key");
|
||||
const TestSchema = z.object({
|
||||
name: z.string(),
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
const response = await provider.generateStructuredResponse({
|
||||
systemPrompt: "system prompt",
|
||||
userContext: "user context",
|
||||
schema: TestSchema,
|
||||
});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toEqual({
|
||||
name: "mocked response",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,14 +32,10 @@ export function serializeSubjectiveBufferEntry(
|
||||
);
|
||||
|
||||
let details: string;
|
||||
const content = entry.intent.description.trim() || entry.intent.originalText.trim();
|
||||
|
||||
if (entry.intent.type === "dialogue") {
|
||||
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${content}"`;
|
||||
} else if (entry.intent.type === "monologue") {
|
||||
details = `thought: "${content}"`;
|
||||
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${entry.intent.description}"`;
|
||||
} else {
|
||||
details = content;
|
||||
details = `${entry.intent.description}`;
|
||||
if (entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
|
||||
2965
pnpm-lock.yaml
generated
2965
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
- "apps/*"
|
||||
- "cli"
|
||||
- "web/*"
|
||||
|
||||
- "content/scenario-builder"
|
||||
- "content/scenario-core"
|
||||
allowBuilds:
|
||||
better-sqlite3: true
|
||||
esbuild: true
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "path": "./packages/spatial" },
|
||||
{ "path": "./packages/llm" },
|
||||
{ "path": "./packages/actor" },
|
||||
{ "path": "./packages/scenario" }
|
||||
{ "path": "./content/scenario-core" },
|
||||
{ "path": "./cli" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ export default defineConfig({
|
||||
"@omnia/memory": path.resolve(__dirname, "./packages/memory/src"),
|
||||
"@omnia/spatial": path.resolve(__dirname, "./packages/spatial/src"),
|
||||
"@omnia/actor": path.resolve(__dirname, "./packages/actor/src"),
|
||||
"@omnia/scenario": path.resolve(__dirname, "./packages/scenario/src"),
|
||||
"@omnia/scenario-core": path.resolve(__dirname, "./content/scenario-core/src"),
|
||||
"@omnia/cli": path.resolve(__dirname, "./cli/src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
title: LLM Providers & Configuration
|
||||
description: Details of the LLM provider instances, task routing, and self-bootstrapping setup in Omnia.
|
||||
sidebar:
|
||||
order: 5
|
||||
---
|
||||
|
||||
In Omnia, all non-player character behaviors, action validation, intent decoding, and time step logic are simulated using Large Language Models (LLMs). The LLM subsystem is built around **polymorphism, key instance management, and task provider routing**.
|
||||
|
||||
## Core Interfaces
|
||||
|
||||
All LLM providers implement the common `ILLMProvider` interface defined in `packages/llm/src/llm.ts`:
|
||||
|
||||
```typescript
|
||||
export interface ILLMProvider {
|
||||
providerName: string;
|
||||
generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>>;
|
||||
lastCalls?: LLMCallRecord[];
|
||||
}
|
||||
```
|
||||
|
||||
The codebase provides two primary implementations:
|
||||
1. **`GeminiProvider`:** The production provider utilizing Google's Gemini Models via the `@langchain/google-genai` SDK.
|
||||
2. **`MockLLMProvider`:** A stateless, pre-programmed mock provider used for fast, deterministic unit testing and local integration tests.
|
||||
|
||||
---
|
||||
|
||||
## LLM Provider Instances
|
||||
|
||||
To support multiple different API keys, key rotation, and model variation, Omnia utilizes a **Provider Instance model** rather than relying on static configuration:
|
||||
|
||||
```typescript
|
||||
export interface LLMProviderInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: boolean;
|
||||
modelName?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Users can register multiple provider instances in the **Configuration Page** under the GUI. Each instance is given:
|
||||
* A friendly, human-readable name (e.g., `"Gemini Production Key"`, `"Experimental Gemini Pro"`).
|
||||
* A provider type (e.g., `google-genai`, `mock`).
|
||||
* An API key credential.
|
||||
* A custom target model name (e.g., `gemini-2.5-flash` or `gemini-2.5-pro`).
|
||||
* An **Active** status flag (one key is marked as globally active).
|
||||
|
||||
Configurations are stored globally in `data/settings.db` (separated from specific simulation run databases like `data/sim-*.db` to keep key storage and audit logs isolated).
|
||||
|
||||
---
|
||||
|
||||
## Task Provider Routing
|
||||
|
||||
During a simulation run, the engine executes four distinct LLM operations. To optimize costs, latency, or model accuracy, you can route each of these tasks to different LLM provider instances:
|
||||
|
||||
| Task Name | Key ID | Description | Default Model |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Actor Prose Generation** | `actor-prose` | Generates roleplay and narrative behavioral prose for Non-Player Characters. | `gemini-2.5-flash` |
|
||||
| **LLM Validator** | `llm-validator` | Arbitrates and validates proposed actions against the world state rules and constraints. | `gemini-2.5-flash` |
|
||||
| **Intent Decoder** | `intent-decoder` | Parses and splits free-text actions/prose into structured intent sequences. | `gemini-2.5-flash` |
|
||||
| **TimeDelta Generator** | `timedelta` | Calculates the duration of character actions to advance the game clock. | `gemini-2.5-flash` |
|
||||
|
||||
If no specific provider instance is mapped to a task, the task automatically routes to the globally marked **Active** provider instance.
|
||||
|
||||
---
|
||||
|
||||
## Automatic Bootstrapping (Environment Key Fallback)
|
||||
|
||||
To maintain backwards-compatibility and support headless runs, live evaluation suites, and automated unit tests without requiring database pre-configuration, the config manager supports **self-bootstrapping**:
|
||||
|
||||
1. When the provider manager queries the active key instance, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY`.
|
||||
2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Default (Env)`) in `settings.db`.
|
||||
3. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` to keep execution fluent and error-free.
|
||||
|
||||
---
|
||||
|
||||
## Developer Guide: Managing Mappings
|
||||
|
||||
Configuration settings are managed through `ProviderManager` static methods:
|
||||
|
||||
```typescript
|
||||
// Query the active provider configuration
|
||||
const activeConfig = ProviderManager.getActive();
|
||||
|
||||
// List all registered provider instances
|
||||
const allConfigs = ProviderManager.list();
|
||||
|
||||
// Retrieve task-specific mappings
|
||||
const mappings = ProviderManager.getMappings(); // e.g., { "actor-prose": "provider-123" }
|
||||
|
||||
// Map a task to a provider instance
|
||||
ProviderManager.setMapping("actor-prose", "provider-123");
|
||||
```
|
||||
@@ -15,11 +15,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
|
||||
|
||||
Reference in New Issue
Block a user