mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
feat: LLMProviderInstance over LLMProvider
allows for using multiple keys of the same provider implemented per llm call providerinstance mapping
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getConfigStatus } from "@/app/play/actions";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
getConfigStatus,
|
||||
listProviderInstances,
|
||||
createProviderInstance,
|
||||
deleteProviderInstance,
|
||||
setActiveProviderInstance,
|
||||
getProviderMappings,
|
||||
setProviderMapping,
|
||||
} from "@/app/play/actions";
|
||||
import type { LLMProviderInstance } from "@/lib/provider-manager";
|
||||
|
||||
interface ConfigStatus {
|
||||
apiKeySet: boolean;
|
||||
@@ -12,37 +21,106 @@ interface ConfigStatus {
|
||||
|
||||
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("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newProvider, setNewProvider] = useState("google-genai");
|
||||
const [newKey, setNewKey] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await getConfigStatus();
|
||||
if (!cancelled) {
|
||||
setConfig(result);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const loadInstances = useCallback(async () => {
|
||||
try {
|
||||
const list = await listProviderInstances();
|
||||
setInstances(list);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 handleAddInstance = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim() || !newKey.trim()) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
await createProviderInstance(newName, newProvider, newKey);
|
||||
setNewName("");
|
||||
setNewKey("");
|
||||
await loadInstances();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInstance = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this provider instance?")) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
await deleteProviderInstance(id);
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetActiveInstance = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await setActiveProviderInstance(id);
|
||||
await loadInstances();
|
||||
} 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>
|
||||
@@ -53,19 +131,145 @@ export default function ConfigPage() {
|
||||
{config && !loading && (
|
||||
<>
|
||||
<section className="config-section">
|
||||
<h2>LLM Provider</h2>
|
||||
<div className="config-row">
|
||||
<span className="config-label">Provider</span>
|
||||
<span className="config-value">Google Gemini</span>
|
||||
<h2>LLM Provider Instances</h2>
|
||||
<div className="instances-container">
|
||||
{instances.length === 0 ? (
|
||||
<div className="config-hint">
|
||||
No custom LLM provider instances configured. Defaulting to the environment <code>GOOGLE_API_KEY</code> if present.
|
||||
</div>
|
||||
) : (
|
||||
<table className="scenario-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Friendly Name</th>
|
||||
<th>Provider Type</th>
|
||||
<th>API Key Preview</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{instances.map((inst) => (
|
||||
<tr key={inst.id}>
|
||||
<td><strong>{inst.name}</strong></td>
|
||||
<td><code>{inst.providerName}</code></td>
|
||||
<td>
|
||||
<code>
|
||||
{inst.apiKey.substring(0, 10)}...{inst.apiKey.substring(inst.apiKey.length - 4)}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
{inst.isActive ? (
|
||||
<span className="status-pill active">Active</span>
|
||||
) : (
|
||||
<span className="status-pill inactive">Inactive</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="action-buttons">
|
||||
{!inst.isActive && (
|
||||
<button onClick={() => handleSetActiveInstance(inst.id)} className="btn-sm">
|
||||
Make Active
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleDeleteInstance(inst.id)} className="btn-sm delete-btn">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddInstance} className="add-instance-form">
|
||||
<h3>Add LLM Provider Instance</h3>
|
||||
<div className="form-fields">
|
||||
<div className="field">
|
||||
<label htmlFor="instName">Friendly Name</label>
|
||||
<input
|
||||
id="instName"
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="e.g. Gemini - Production"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="instProvider">Provider Type</label>
|
||||
<select
|
||||
id="instProvider"
|
||||
value={newProvider}
|
||||
onChange={(e) => setNewProvider(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="field">
|
||||
<label htmlFor="instKey">API Key</label>
|
||||
<input
|
||||
id="instKey"
|
||||
type="password"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="AIzaSy..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-add">
|
||||
Add Instance
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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">Model</span>
|
||||
<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">API Key</span>
|
||||
<span className="config-label">Default API Key (.env)</span>
|
||||
<span
|
||||
className={
|
||||
config.apiKeySet
|
||||
@@ -78,21 +282,13 @@ export default function ConfigPage() {
|
||||
: "✗ NOT SET"}
|
||||
</span>
|
||||
</div>
|
||||
{!config.apiKeySet && (
|
||||
<div className="config-hint">
|
||||
Add <code>GOOGLE_API_KEY=your_key</code> to the{" "}
|
||||
<code>.env</code> file in the project root, then restart the
|
||||
server.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h2>Available Scenarios</h2>
|
||||
{config.availableScenarios.length === 0 ? (
|
||||
<p className="config-hint">
|
||||
No scenarios found in{" "}
|
||||
<code>content/demo/scenarios/</code>.
|
||||
No scenarios found in <code>content/demo/scenarios/</code>.
|
||||
</p>
|
||||
) : (
|
||||
<table className="scenario-table">
|
||||
@@ -215,6 +411,142 @@ export default function ConfigPage() {
|
||||
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;
|
||||
}
|
||||
.add-instance-form {
|
||||
margin-top: 1.5rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.add-instance-form h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #111;
|
||||
}
|
||||
.form-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.form-fields {
|
||||
grid-template-columns: 1fr 1fr 1.5fr auto;
|
||||
}
|
||||
}
|
||||
.form-fields .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.form-fields .field label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-fields .field input,
|
||||
.form-fields .field select {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
.btn-add {
|
||||
padding: 0.375rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-add:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
/* 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>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import { simulationManager } from "@/lib/simulation";
|
||||
import type { SimSnapshot } from "@/lib/simulation";
|
||||
import { ProviderManager } from "@/lib/provider-manager";
|
||||
import type { LLMProviderInstance } from "@/lib/provider-manager";
|
||||
|
||||
function resolveScenarioPath(relative: string): string {
|
||||
const cwd = process.cwd();
|
||||
@@ -30,6 +32,7 @@ type ActionResult =
|
||||
export async function startSimulation(input: {
|
||||
scenario?: string;
|
||||
playEntity?: string;
|
||||
providerInstanceId?: string;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const scenarioFile =
|
||||
@@ -43,6 +46,7 @@ export async function startSimulation(input: {
|
||||
const snapshot = await simulationManager.create(
|
||||
resolved,
|
||||
input.playEntity || undefined,
|
||||
input.providerInstanceId,
|
||||
);
|
||||
|
||||
if (snapshot.status === "error") {
|
||||
@@ -229,3 +233,34 @@ export async function deleteSimulation(simId: string): Promise<
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProviderInstances(): Promise<LLMProviderInstance[]> {
|
||||
return ProviderManager.list();
|
||||
}
|
||||
|
||||
export async function createProviderInstance(
|
||||
name: string,
|
||||
providerName: string,
|
||||
apiKey: string,
|
||||
): Promise<LLMProviderInstance> {
|
||||
return ProviderManager.create(name, providerName, apiKey);
|
||||
}
|
||||
|
||||
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 getProviderMappings(): Promise<Record<string, string>> {
|
||||
return ProviderManager.getMappings();
|
||||
}
|
||||
|
||||
export async function setProviderMapping(
|
||||
task: string,
|
||||
providerInstanceId: string,
|
||||
): Promise<void> {
|
||||
ProviderManager.setMapping(task, providerInstanceId);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
getConfigStatus,
|
||||
getScenarioEntities,
|
||||
deleteSimulation,
|
||||
listProviderInstances,
|
||||
} from "@/app/play/actions";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import type { LLMProviderInstance } from "@/lib/provider-manager";
|
||||
|
||||
function IntentTag({
|
||||
intent,
|
||||
@@ -304,9 +306,12 @@ export function PlayView() {
|
||||
const [availableEntities, setAvailableEntities] = useState<{ id: string; name: string }[]>([]);
|
||||
const [selectedEntity, setSelectedEntity] = useState("");
|
||||
|
||||
// Load scenarios on mount
|
||||
const [providerInstances, setProviderInstances] = useState<LLMProviderInstance[]>([]);
|
||||
const [selectedProviderInstance, setSelectedProviderInstance] = useState("");
|
||||
|
||||
// Load scenarios and provider instances on mount
|
||||
useEffect(() => {
|
||||
async function loadScenarios() {
|
||||
async function loadScenariosAndProviders() {
|
||||
try {
|
||||
const configStatus = await getConfigStatus();
|
||||
setScenarios(configStatus.availableScenarios);
|
||||
@@ -316,9 +321,21 @@ export function PlayView() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const providersList = await listProviderInstances();
|
||||
setProviderInstances(providersList);
|
||||
const active = providersList.find(p => p.isActive);
|
||||
if (active) {
|
||||
setSelectedProviderInstance(active.id);
|
||||
} else if (providersList.length > 0) {
|
||||
setSelectedProviderInstance(providersList[0].id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
loadScenarios();
|
||||
}, []);
|
||||
loadScenariosAndProviders();
|
||||
}, [snapshot]);
|
||||
|
||||
// Fetch entities when selectedScenario changes
|
||||
useEffect(() => {
|
||||
@@ -355,6 +372,7 @@ export function PlayView() {
|
||||
const result = await startSimulation({
|
||||
scenario: (form.get("scenario") as string) || undefined,
|
||||
playEntity: (form.get("playEntity") as string) || undefined,
|
||||
providerInstanceId: selectedProviderInstance || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -473,6 +491,25 @@ export function PlayView() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="llmInstance">LLM Key / Instance</label>
|
||||
<select
|
||||
id="llmInstance"
|
||||
value={selectedProviderInstance}
|
||||
onChange={(e) => setSelectedProviderInstance(e.target.value)}
|
||||
disabled={providerInstances.length === 0}
|
||||
>
|
||||
{providerInstances.length === 0 ? (
|
||||
<option value="">Default (from Env variable)</option>
|
||||
) : (
|
||||
providerInstances.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.providerName}) {p.isActive ? " [Active]" : ""}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? "Starting..." : "Start Simulation"}
|
||||
</button>
|
||||
|
||||
165
apps/gui/src/lib/provider-manager.ts
Normal file
165
apps/gui/src/lib/provider-manager.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import type { LLMProviderInstance } from "@omnia/llm";
|
||||
|
||||
export type { LLMProviderInstance };
|
||||
|
||||
function getSettingsDb() {
|
||||
const dbDir = path.resolve(process.cwd(), "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
|
||||
)
|
||||
`).run();
|
||||
|
||||
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;
|
||||
}[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
providerName: r.providerName,
|
||||
apiKey: r.apiKey,
|
||||
isActive: r.isActive === 1,
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static create(name: string, providerName: string, apiKey: 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)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(id, name, providerName, apiKey, isActive);
|
||||
|
||||
return { id, name, providerName, apiKey, isActive: isActive === 1 };
|
||||
} 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 getActive(): LLMProviderInstance | null {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1`).get() as {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
} | undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
providerName: row.providerName,
|
||||
apiKey: row.apiKey,
|
||||
isActive: true,
|
||||
};
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
IActorProseGenerator,
|
||||
buildBufferEntryForIntent,
|
||||
} from "@omnia/actor";
|
||||
import { GeminiProvider } from "@omnia/llm";
|
||||
import { GeminiProvider, ILLMProvider, MockLLMProvider } from "@omnia/llm";
|
||||
import { ScenarioLoader } from "@omnia/scenario";
|
||||
import { ProviderManager } from "./provider-manager";
|
||||
|
||||
import type {
|
||||
IntentInfo,
|
||||
@@ -66,6 +67,7 @@ interface SavedState {
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
log: LogEntry[];
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
|
||||
function loadSessionState(db: Database.Database, id: string): SavedState | null {
|
||||
@@ -96,7 +98,10 @@ interface SimSession {
|
||||
entities: EntityInfo[];
|
||||
playerEntityId: string | undefined;
|
||||
entityIndex: number;
|
||||
llmProvider: GeminiProvider;
|
||||
actorProvider: ILLMProvider;
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
architect: Architect;
|
||||
aliasGenerator: AliasDeltaGenerator;
|
||||
log: LogEntry[];
|
||||
@@ -104,6 +109,7 @@ interface SimSession {
|
||||
error?: string;
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
|
||||
class SimulationManager {
|
||||
@@ -112,9 +118,20 @@ class SimulationManager {
|
||||
async create(
|
||||
scenarioPath: string,
|
||||
playEntityName?: string,
|
||||
providerInstanceId?: string,
|
||||
): Promise<SimSnapshot> {
|
||||
const apiKey = process.env.GOOGLE_API_KEY;
|
||||
if (!apiKey) {
|
||||
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",
|
||||
@@ -125,7 +142,7 @@ class SimulationManager {
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
error: "GOOGLE_API_KEY is not set. Add it to your .env file.",
|
||||
error: "No active LLM Provider Instance found. Please configure a key in Settings first.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,9 +219,37 @@ class SimulationManager {
|
||||
}
|
||||
}
|
||||
|
||||
const llmProvider = new GeminiProvider(apiKey);
|
||||
const architect = new Architect(llmProvider, coreRepo);
|
||||
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
|
||||
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,
|
||||
@@ -219,12 +264,16 @@ class SimulationManager {
|
||||
entities: entityInfos,
|
||||
playerEntityId,
|
||||
entityIndex: 0,
|
||||
llmProvider,
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: [],
|
||||
status: "running",
|
||||
aliasDoneForTurn: false,
|
||||
providerMappings: mappings,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
@@ -300,15 +349,13 @@ class SimulationManager {
|
||||
if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
|
||||
|
||||
const playerActor = new ActorAgent(
|
||||
session.llmProvider,
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
20,
|
||||
new FixedProseGenerator(prose),
|
||||
);
|
||||
|
||||
const startCallIdx = session.llmProvider.lastCalls.length;
|
||||
const result = await playerActor.act(worldState, entity);
|
||||
const endCallIdx = session.llmProvider.lastCalls.length;
|
||||
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
@@ -323,8 +370,8 @@ class SimulationManager {
|
||||
},
|
||||
};
|
||||
|
||||
if (endCallIdx > startCallIdx) {
|
||||
const call = session.llmProvider.lastCalls[startCallIdx];
|
||||
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,
|
||||
@@ -441,10 +488,12 @@ class SimulationManager {
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const actor = new ActorAgent(session.llmProvider, session.bufferRepo, 20);
|
||||
const startCallIdx = session.llmProvider.lastCalls.length;
|
||||
const actor = new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
20,
|
||||
);
|
||||
const result = await actor.act(worldState, entity);
|
||||
const endCallIdx = session.llmProvider.lastCalls.length;
|
||||
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
@@ -455,28 +504,22 @@ class SimulationManager {
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
};
|
||||
|
||||
if (endCallIdx - startCallIdx >= 2) {
|
||||
const actorCall = session.llmProvider.lastCalls[startCallIdx];
|
||||
const decoderCall = session.llmProvider.lastCalls[startCallIdx + 1];
|
||||
|
||||
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;
|
||||
} else if (endCallIdx - startCallIdx === 1) {
|
||||
const call = session.llmProvider.lastCalls[startCallIdx];
|
||||
entry.rawPrompt = {
|
||||
systemPrompt: call.systemPrompt,
|
||||
userContext: call.userContext,
|
||||
};
|
||||
entry.usage = call.usage;
|
||||
}
|
||||
|
||||
for (const intent of result.intents.intents) {
|
||||
@@ -604,17 +647,47 @@ class SimulationManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiKey = process.env.GOOGLE_API_KEY;
|
||||
if (!apiKey) {
|
||||
db.close();
|
||||
throw new Error("GOOGLE_API_KEY is not set.");
|
||||
}
|
||||
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 llmProvider = new GeminiProvider(apiKey);
|
||||
const architect = new Architect(llmProvider, coreRepo);
|
||||
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
|
||||
|
||||
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,
|
||||
@@ -629,7 +702,10 @@ class SimulationManager {
|
||||
entities: state.entities || [],
|
||||
playerEntityId: state.playerEntityId,
|
||||
entityIndex: state.entityIndex,
|
||||
llmProvider,
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: state.log || [],
|
||||
@@ -637,6 +713,7 @@ class SimulationManager {
|
||||
error: state.error,
|
||||
waitingEntity: state.waitingEntity,
|
||||
aliasDoneForTurn: state.aliasDoneForTurn || false,
|
||||
providerMappings: mappings,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
@@ -710,6 +787,7 @@ class SimulationManager {
|
||||
waitingEntity: session.waitingEntity,
|
||||
aliasDoneForTurn: session.aliasDoneForTurn,
|
||||
log: session.log,
|
||||
providerMappings: session.providerMappings,
|
||||
};
|
||||
|
||||
session.db.prepare(`
|
||||
|
||||
Reference in New Issue
Block a user