mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
Compare commits
6 Commits
0c59756c08
...
feat/confi
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9d0006e5 | |||
| 6626adf38d | |||
| 053748564f | |||
| acd62bdb65 | |||
| 46b12cd668 | |||
| 4ef52f926e |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,6 +46,12 @@ Thumbs.db
|
||||
|
||||
# Database
|
||||
omnia.db
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
**/data/*.db
|
||||
data/
|
||||
|
||||
# Environment Files
|
||||
.env
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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": "workspace:*",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"dotenv": "^17.4.2"
|
||||
}
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
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";
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Resolve playEntityId to actual entity UUID if name/ID substring matches
|
||||
let resolvedPlayEntityId: string | undefined = undefined;
|
||||
if (playEntityId) {
|
||||
let matched = worldState.getEntity(playEntityId);
|
||||
if (!matched) {
|
||||
for (const ent of worldState.entities.values()) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue();
|
||||
if (nameAttr && nameAttr.toLowerCase() === playEntityId.toLowerCase()) {
|
||||
matched = ent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const ent of worldState.entities.values()) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue();
|
||||
if (
|
||||
(nameAttr && nameAttr.toLowerCase().includes(playEntityId.toLowerCase())) ||
|
||||
ent.id.toLowerCase().includes(playEntityId.toLowerCase())
|
||||
) {
|
||||
matched = ent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
resolvedPlayEntityId = matched.id;
|
||||
console.log(`Resolved player character "${playEntityId}" to entity ID "${matched.id}" (Name: ${matched.attributes.get("name")?.getValue() || "Unnamed"})`);
|
||||
} else {
|
||||
console.warn(`Warning: Could not find any entity matching "${playEntityId}". Running in observer mode.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (resolvedPlayEntityId) {
|
||||
const matched = worldState.getEntity(resolvedPlayEntityId);
|
||||
console.log(`Player Role: Controlling entity "${resolvedPlayEntityId}" (Name: ${matched?.attributes.get("name")?.getValue() || "Unnamed"})`);
|
||||
} 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 = resolvedPlayEntityId && entity.id === resolvedPlayEntityId;
|
||||
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);
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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": "../../packages/scenario" }
|
||||
]
|
||||
}
|
||||
6
apps/gui/next-env.d.ts
vendored
Normal file
6
apps/gui/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
30
apps/gui/next.config.ts
Normal file
30
apps/gui/next.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: [
|
||||
"@omnia/core",
|
||||
"@omnia/llm",
|
||||
"@omnia/intent",
|
||||
"@omnia/architect",
|
||||
"@omnia/actor",
|
||||
"@omnia/memory",
|
||||
"@omnia/spatial",
|
||||
"@omnia/scenario",
|
||||
],
|
||||
serverExternalPackages: ["better-sqlite3"],
|
||||
allowedDevOrigins: ["192.168.0.18", "localhost", "127.0.0.1"],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
allowedOrigins: [
|
||||
"192.168.0.18:3000",
|
||||
"192.168.0.18:3001",
|
||||
"192.168.0.18:3002",
|
||||
"localhost:3000",
|
||||
"localhost:3001",
|
||||
"localhost:3002",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
32
apps/gui/package.json
Normal file
32
apps/gui/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@omnia/gui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/architect": "workspace:*",
|
||||
"@omnia/actor": "workspace:*",
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/spatial": "workspace:*",
|
||||
"@omnia/scenario": "workspace:*",
|
||||
"dotenv": "^17.4.2",
|
||||
"next": "^16.2.10",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
777
apps/gui/src/app/config/page.tsx
Normal file
777
apps/gui/src/app/config/page.tsx
Normal file
@@ -0,0 +1,777 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
getConfigStatus,
|
||||
listProviderInstances,
|
||||
createProviderInstance,
|
||||
deleteProviderInstance,
|
||||
setActiveProviderInstance,
|
||||
getProviderMappings,
|
||||
setProviderMapping,
|
||||
updateProviderInstance,
|
||||
} from "@/app/play/actions";
|
||||
import type { LLMProviderInstance } from "@omnia/llm";
|
||||
|
||||
interface ConfigStatus {
|
||||
apiKeySet: boolean;
|
||||
apiKeyPreview: string;
|
||||
model: string;
|
||||
availableScenarios: { path: string; name: string }[];
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [config, setConfig] = useState<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>
|
||||
);
|
||||
}
|
||||
18
apps/gui/src/app/globals.css
Normal file
18
apps/gui/src/app/globals.css
Normal file
@@ -0,0 +1,18 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
color: #111;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
19
apps/gui/src/app/layout.tsx
Normal file
19
apps/gui/src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { NavBar } from "@/components/nav/NavBar";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "Omnia GUI",
|
||||
description: "Omnia Narrative Simulation Engine — Web Interface",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<NavBar />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
63
apps/gui/src/app/page.tsx
Normal file
63
apps/gui/src/app/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="home">
|
||||
<h1>Omnia GUI</h1>
|
||||
<p className="subtitle">
|
||||
Configuration and gameplay interface for the Omnia simulation engine.
|
||||
</p>
|
||||
<div className="home-links">
|
||||
<Link href="/play" className="home-card">
|
||||
<h2>Play</h2>
|
||||
<p>Start a simulation and interact with NPCs</p>
|
||||
</Link>
|
||||
<Link href="/config" className="home-card">
|
||||
<h2>Config</h2>
|
||||
<p>Check environment, API keys, and available scenarios</p>
|
||||
</Link>
|
||||
</div>
|
||||
<style>{`
|
||||
.home {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
.home h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #555;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.home-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.home-card {
|
||||
flex: 1;
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.home-card:hover {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
.home-card h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.home-card p {
|
||||
font-size: 0.875rem;
|
||||
color: #555;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
276
apps/gui/src/app/play/actions.ts
Normal file
276
apps/gui/src/app/play/actions.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
"use server";
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { simulationManager } from "@/lib/simulation";
|
||||
import type { SimSnapshot } from "@/lib/simulation";
|
||||
import { ProviderManager, LLMProviderInstance } from "@omnia/llm";
|
||||
|
||||
function resolveScenarioPath(relative: string): string {
|
||||
const cwd = process.cwd();
|
||||
const candidates = [
|
||||
path.resolve(cwd, relative),
|
||||
path.resolve(cwd, "content/demo/scenarios", relative),
|
||||
path.resolve(cwd, "../../", relative),
|
||||
path.resolve(cwd, "../../content/demo/scenarios", relative),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
if (fs.statSync(c).isFile()) return c;
|
||||
} catch {
|
||||
/* not found */
|
||||
}
|
||||
}
|
||||
return path.resolve(cwd, relative);
|
||||
}
|
||||
|
||||
type ActionResult =
|
||||
| { ok: true; snapshot: SimSnapshot }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function startSimulation(input: {
|
||||
scenario?: string;
|
||||
playEntity?: string;
|
||||
providerInstanceId?: string;
|
||||
}): Promise<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);
|
||||
}
|
||||
5
apps/gui/src/app/play/page.tsx
Normal file
5
apps/gui/src/app/play/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PlayView } from "@/components/play/PlayView";
|
||||
|
||||
export default function PlayPage() {
|
||||
return <PlayView />;
|
||||
}
|
||||
69
apps/gui/src/components/nav/NavBar.tsx
Normal file
69
apps/gui/src/components/nav/NavBar.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/play", label: "Play" },
|
||||
{ href: "/config", label: "Config" },
|
||||
];
|
||||
|
||||
export function NavBar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<Link href="/" className="nav-brand">
|
||||
Omnia
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={pathname === link.href ? "nav-link active" : "nav-link"}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<style>{`
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
.nav-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: #111;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nav-link {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111;
|
||||
}
|
||||
.nav-link.active {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
1137
apps/gui/src/components/play/PlayView.tsx
Normal file
1137
apps/gui/src/components/play/PlayView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
62
apps/gui/src/lib/simulation-types.ts
Normal file
62
apps/gui/src/lib/simulation-types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export interface IntentInfo {
|
||||
type: string;
|
||||
description: string;
|
||||
targetIds: string[];
|
||||
isValid?: boolean;
|
||||
reason?: string;
|
||||
minutesToAdvance?: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
turn: number;
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
narrativeProse: string;
|
||||
intents: IntentInfo[];
|
||||
timestamp: string;
|
||||
rawPrompt?: {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
};
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
decoderPrompt?: {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
};
|
||||
decoderUsage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EntityInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isPlayer: boolean;
|
||||
}
|
||||
|
||||
export interface WaitingContext {
|
||||
entityId: string;
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
}
|
||||
|
||||
export interface SimSnapshot {
|
||||
id: string;
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
entities: EntityInfo[];
|
||||
log: LogEntry[];
|
||||
entityIndex: number;
|
||||
waitingEntity?: WaitingContext;
|
||||
error?: string;
|
||||
}
|
||||
828
apps/gui/src/lib/simulation.ts
Normal file
828
apps/gui/src/lib/simulation.ts
Normal file
@@ -0,0 +1,828 @@
|
||||
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();
|
||||
41
apps/gui/tsconfig.json
Normal file
41
apps/gui/tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.astro/**"],
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.astro/**", "**/.next/**"],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
@@ -19,5 +19,12 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ["**/*.tsx"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
},
|
||||
|
||||
eslintConfigPrettier,
|
||||
];
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
"build": "tsc -b",
|
||||
"build:web": "pnpm --filter landing build",
|
||||
"build:docs": "pnpm --filter docs build",
|
||||
"build:all": "pnpm build && pnpm build:web && pnpm build:docs",
|
||||
"build:gui": "pnpm --filter @omnia/gui build",
|
||||
"build:all": "pnpm build && pnpm build:web && pnpm build:docs && pnpm build:gui",
|
||||
"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",
|
||||
@@ -18,8 +20,7 @@
|
||||
"watch": "tsc -b --watch",
|
||||
"test": "vitest run --project unit",
|
||||
"test:watch": "vitest --project unit",
|
||||
"test:evals": "vitest run --project evals",
|
||||
"play": "node apps/cli/dist/index.js"
|
||||
"test:evals": "vitest run --project evals"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -68,15 +68,29 @@ export class ActorAgent {
|
||||
private decoder: IntentDecoder;
|
||||
private generator: IActorProseGenerator;
|
||||
|
||||
private llmProvider: ILLMProvider;
|
||||
|
||||
constructor(
|
||||
private llmProvider: ILLMProvider,
|
||||
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: 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(llmProvider);
|
||||
this.generator = generator ?? new LLMActorProseGenerator(llmProvider);
|
||||
this.decoder = new IntentDecoder(decoderProv);
|
||||
this.generator = generator ?? new LLMActorProseGenerator(actorProv);
|
||||
this.llmProvider = actorProv;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,9 +12,23 @@ export class Architect {
|
||||
private validator: LLMValidator;
|
||||
private timeDeltaGenerator: TimeDeltaGenerator;
|
||||
|
||||
constructor(llmProvider: ILLMProvider, private repo?: SQLiteRepository) {
|
||||
this.validator = new LLMValidator(llmProvider);
|
||||
this.timeDeltaGenerator = new TimeDeltaGenerator(llmProvider);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^26.1.0"
|
||||
"@types/node": "^26.1.0",
|
||||
"better-sqlite3": "^12.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +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";
|
||||
|
||||
@@ -11,6 +11,21 @@ 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 {
|
||||
@@ -19,4 +34,14 @@ 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;
|
||||
}
|
||||
|
||||
254
packages/llm/src/provider-manager.ts
Normal file
254
packages/llm/src/provider-manager.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
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,31 +1,75 @@
|
||||
import { z } from "zod";
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } 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: "gemini-2.5-flash",
|
||||
model: 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);
|
||||
const result = await structuredModel.invoke([
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
|
||||
const result = (await structuredModel.invoke([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
]);
|
||||
return { success: true, data: result as z.infer<T> };
|
||||
])) 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
|
||||
|
||||
export class MockLLMProvider implements ILLMProvider {
|
||||
providerName = "mock";
|
||||
private callCount = 0;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(private responses: unknown[]) {}
|
||||
|
||||
@@ -14,9 +15,15 @@ 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 };
|
||||
return { success: true, data: parsed, usage };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
|
||||
259
pnpm-lock.yaml
generated
259
pnpm-lock.yaml
generated
@@ -251,7 +251,7 @@ importers:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
|
||||
apps/cli:
|
||||
apps/gui:
|
||||
dependencies:
|
||||
'@omnia/actor':
|
||||
specifier: workspace:*
|
||||
@@ -277,12 +277,31 @@ importers:
|
||||
'@omnia/spatial':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/spatial
|
||||
better-sqlite3:
|
||||
specifier: ^12.11.1
|
||||
version: 12.11.1
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
next:
|
||||
specifier: ^16.2.10
|
||||
version: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.7
|
||||
react-dom:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.7(react@19.2.7)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
'@types/react':
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.17
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.3(@types/react@19.2.17)
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
|
||||
packages/actor:
|
||||
dependencies:
|
||||
@@ -346,6 +365,13 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
better-sqlite3:
|
||||
specifier: ^12.11.1
|
||||
version: 12.11.1
|
||||
devDependencies:
|
||||
'@types/better-sqlite3':
|
||||
specifier: ^7.6.13
|
||||
version: 7.6.13
|
||||
|
||||
packages/memory:
|
||||
dependencies:
|
||||
@@ -1124,6 +1150,61 @@ packages:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@next/env@16.2.10':
|
||||
resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.2.10':
|
||||
resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.2.10':
|
||||
resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.2.10':
|
||||
resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.2.10':
|
||||
resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.2.10':
|
||||
resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.2.10':
|
||||
resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.2.10':
|
||||
resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.2.10':
|
||||
resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oslojs/encoding@1.1.0':
|
||||
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
|
||||
|
||||
@@ -1309,6 +1390,9 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
||||
|
||||
@@ -1363,6 +1447,14 @@ packages:
|
||||
'@types/node@26.1.0':
|
||||
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
|
||||
'@types/react@19.2.17':
|
||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||
|
||||
'@types/sax@1.2.7':
|
||||
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
|
||||
|
||||
@@ -1534,6 +1626,11 @@ packages:
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
baseline-browser-mapping@2.10.42:
|
||||
resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bcp-47-match@2.0.3:
|
||||
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
|
||||
|
||||
@@ -1560,6 +1657,9 @@ packages:
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
caniuse-lite@1.0.30001803:
|
||||
resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
@@ -1590,6 +1690,9 @@ packages:
|
||||
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1666,6 +1769,9 @@ packages:
|
||||
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -2450,6 +2556,27 @@ packages:
|
||||
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
next@16.2.10:
|
||||
resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
'@playwright/test': ^1.51.1
|
||||
babel-plugin-react-compiler: '*'
|
||||
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
sass: ^1.3.0
|
||||
peerDependenciesMeta:
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@playwright/test':
|
||||
optional: true
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
nlcst-to-string@4.0.0:
|
||||
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
|
||||
|
||||
@@ -2576,6 +2703,10 @@ packages:
|
||||
resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.4.31:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.16:
|
||||
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -2620,6 +2751,15 @@ packages:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-dom@19.2.7:
|
||||
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
|
||||
peerDependencies:
|
||||
react: ^19.2.7
|
||||
|
||||
react@19.2.7:
|
||||
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -2724,6 +2864,9 @@ packages:
|
||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
semver@7.8.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2809,6 +2952,19 @@ packages:
|
||||
style-to-object@1.0.14:
|
||||
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': '*'
|
||||
babel-plugin-macros: '*'
|
||||
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
|
||||
peerDependenciesMeta:
|
||||
'@babel/core':
|
||||
optional: true
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
svgo@4.0.1:
|
||||
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -3785,6 +3941,32 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@next/env@16.2.10': {}
|
||||
|
||||
'@next/swc-darwin-arm64@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@oslojs/encoding@1.1.0': {}
|
||||
|
||||
'@oxc-project/types@0.138.0': {}
|
||||
@@ -3911,6 +4093,10 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3971,9 +4157,17 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@types/react@19.2.17':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/sax@1.2.7':
|
||||
dependencies:
|
||||
'@types/node': 20.19.43
|
||||
'@types/node': 26.1.0
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
@@ -4254,6 +4448,8 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.10.42: {}
|
||||
|
||||
bcp-47-match@2.0.3: {}
|
||||
|
||||
bcp-47@2.1.1:
|
||||
@@ -4288,6 +4484,8 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
caniuse-lite@1.0.30001803: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
@@ -4308,6 +4506,8 @@ snapshots:
|
||||
|
||||
ci-info@4.4.0: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
collapse-white-space@2.1.0: {}
|
||||
@@ -4378,6 +4578,8 @@ snapshots:
|
||||
dependencies:
|
||||
css-tree: 2.2.1
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -5524,6 +5726,30 @@ snapshots:
|
||||
|
||||
neotraverse@0.6.18: {}
|
||||
|
||||
next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
'@next/env': 16.2.10
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.10.42
|
||||
caniuse-lite: 1.0.30001803
|
||||
postcss: 8.4.31
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
styled-jsx: 5.1.6(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.2.10
|
||||
'@next/swc-darwin-x64': 16.2.10
|
||||
'@next/swc-linux-arm64-gnu': 16.2.10
|
||||
'@next/swc-linux-arm64-musl': 16.2.10
|
||||
'@next/swc-linux-x64-gnu': 16.2.10
|
||||
'@next/swc-linux-x64-musl': 16.2.10
|
||||
'@next/swc-win32-arm64-msvc': 16.2.10
|
||||
'@next/swc-win32-x64-msvc': 16.2.10
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
nlcst-to-string@4.0.0:
|
||||
dependencies:
|
||||
'@types/nlcst': 2.0.3
|
||||
@@ -5662,6 +5888,12 @@ snapshots:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.15
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.16:
|
||||
dependencies:
|
||||
nanoid: 3.3.15
|
||||
@@ -5709,6 +5941,13 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
react-dom@19.2.7(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
scheduler: 0.27.0
|
||||
|
||||
react@19.2.7: {}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
@@ -5924,6 +6163,8 @@ snapshots:
|
||||
|
||||
sax@1.6.0: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
sharp@0.33.5:
|
||||
@@ -6057,6 +6298,11 @@ snapshots:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.7):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.7
|
||||
|
||||
svgo@4.0.1:
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
@@ -6105,8 +6351,7 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
dependencies:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
- "apps/cli"
|
||||
- "apps/*"
|
||||
- "web/*"
|
||||
|
||||
allowBuilds:
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
{ "path": "./packages/spatial" },
|
||||
{ "path": "./packages/llm" },
|
||||
{ "path": "./packages/actor" },
|
||||
{ "path": "./packages/scenario" },
|
||||
{ "path": "./apps/cli" }
|
||||
{ "path": "./packages/scenario" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export default defineConfig({
|
||||
"@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/cli": path.resolve(__dirname, "./apps/cli/src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
97
web/docs/src/content/docs/architecture/llm-providers.md
Normal file
97
web/docs/src/content/docs/architecture/llm-providers.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
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");
|
||||
```
|
||||
Reference in New Issue
Block a user