18 Commits

Author SHA1 Message Date
Regina Fischer
82fb3c34f7 Setup Docker builds and optimize Dockerfile layers (#33)
* feat(ci): Setup docker builds for GUI

* refactor(ci):  Split dockerfile into layers
2026-07-19 18:04:27 +05:30
896ff6f210 feat(scenario): Custom names for simulations 2026-07-18 14:14:57 +05:30
9223ae3750 refactor(gui): Pull out Interact and Manage simulation tabs into their own components 2026-07-18 13:46:16 +05:30
eabe49552d chore: Clean up repo 2026-07-17 09:22:18 +05:30
444c722708 feat(gui): Improve provider icons 2026-07-17 08:28:38 +05:30
64e049c976 feat(gui): Added icons for model providers 2026-07-17 07:25:48 +05:30
74cce6edd1 Merge pull request #24 from sortedcord/feat/more-providers
feat(llm, gui): More Inference Providers
2026-07-16 22:26:30 +05:30
Tom Pike
61ea9fe237 refactor!(llm): Remove bootstrapper in favor of setup-provider 2026-07-16 22:21:59 +05:30
250bb87a8d refactor!(llm): implement new model registration system 2026-07-16 22:05:45 +05:30
8ad94d3fc2 feat(llm): Dynamically fetch available models from providers 2026-07-16 19:16:55 +05:30
2b56c01e4c feat(llm): Added Groq and Deepseek providers 2026-07-16 18:25:07 +05:30
13155cba23 feat(llm): Added OpenAI provider 2026-07-16 13:29:21 +05:30
622fdfe2f1 feat(llm): Added Anthropic Claude provider 2026-07-16 13:21:00 +05:30
d6ab076b09 docs(llm): Added reamde for @omnia/llm 2026-07-16 13:11:23 +05:30
9356e1f7d0 feat(llm): Added ollama provider 2026-07-16 13:10:59 +05:30
ccafe56dfe docs: Added alt text for illustrations 2026-07-16 09:06:46 +05:30
906ef2cdf3 refactor!(gui): Refactor simulation.ts 2026-07-16 08:43:07 +05:30
c55c7e222a refactor(gui): Improve nextjs scaffolding 2026-07-16 08:14:46 +05:30
78 changed files with 5730 additions and 5562 deletions

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
node_modules/
.git/
.gitignore
.gitattributes
*.md
.editorconfig
.prettierrc
.prettierignore
eslint.config.mjs
.env
.env*
tests/
content/
docs/
web/landing/
web/docs/
vitest.config.ts
*.tsbuildinfo
.next/
dist/
.astro/

65
.github/workflows/build-image.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Build Docker Image
on:
push:
branches: [master]
tags: ["v*"]
paths-ignore:
- 'web/docs/**'
- 'web/landing/**'
- 'content/**'
- 'README.md'
- 'CONTRIBUTING.md'
- '.github/workflows/deploy-docs.yml'
pull_request:
branches: [master]
paths-ignore:
- 'web/docs/**'
- 'web/landing/**'
- 'content/**'
- '*.md'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/omnia-gui
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: apps/gui/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false

View File

@@ -17,7 +17,7 @@
The <b>world state lives outside the model</b>, characters act through <b>intents that get validated</b> and applied by engine code. Each character's knowledge, memory, and emotional state are subjective and partial by construction.
<p align="center">
<img src="./web/docs/src/assets/img/puppet.webp" />
<img src="./web/docs/src/assets/img/puppet.webp" alt="This pixel-art style image, set against a black background with the title 'The Puppet Master Paradox' at the top, depicts two identical, stylized puppets with white, round heads and orange patterned bodies facing each other. Between them hovers a small, glowing, four-pointed star, while each puppet has an orange-outlined speech bubble above it: the left one states, 'Good evening. Welcome to my humble bakery!', and the right one replies, 'Nice to meet you Assassin Bob. Wait... wha-', concluding with a small white 'X' icon in the bottom right corner." />
</p>
Single-agent or single-context systems (AI Dungeon and its descendants) prompt one model to _be_ the world and everyone in it. That breaks in predictable ways over long sessions:
@@ -41,7 +41,7 @@ Omnia answers every one of these failures with the same move: **pull the thing t
## What this buys you
<p align="center">
<img src="./web/docs/src/assets/img/features.webp" />
<img src="./web/docs/src/assets/img/features.webp" alt="This pixel-art image features a grid of six distinct panels, each with an orange-outlined, jagged border, illustrating different concepts: 'Emergent Deceit' shows a person hiding a sword behind their back while offering a rose to another person; 'Divergent Perceptions' depicts two figures sitting at a table with speech bubbles labeled 'A' and 'B'; 'Player Agnostic' shows three figures engaged in different activities—watering a plant, standing still, and juggling—under the plumbob symbol from the sims; 'State Validated Agency' displays a stylized symbol of a person partially inside a portal crossed out by a large red 'X'; 'Deterministic Time' shows four circular panels representing a day-night cycle connected by arrows and clock icons; and 'Bring Your Own Model' features a large, central omnia icon surrounded by various LLM model logos including chatgpt, mistral, deepseek, claude, etc****" />
</p>
The payoff is scenario complexity that **uni-agent systems structurally cannot represent, no matter how good the model gets**.

59
apps/gui/Dockerfile Normal file
View File

@@ -0,0 +1,59 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm" \
PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
WORKDIR /app
FROM base AS build-deps
RUN apt-get update -qq && \
apt-get install -qq -y --no-install-recommends \
python3 make g++ && \
rm -rf /var/lib/apt/lists/*
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
COPY package.json tsconfig.json tsconfig.base.json ./
COPY apps/gui/package.json apps/gui/package.json
COPY apps/gui/tsconfig.json apps/gui/tsconfig.json
COPY apps/gui/next.config.ts apps/gui/next.config.ts
COPY apps/gui/postcss.config.mjs apps/gui/postcss.config.mjs
COPY apps/gui/components.json apps/gui/components.json
COPY packages/actor/package.json packages/actor/package.json
COPY packages/architect/package.json packages/architect/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/intent/package.json packages/intent/package.json
COPY packages/llm/package.json packages/llm/package.json
COPY packages/memory/package.json packages/memory/package.json
COPY packages/scenario/package.json packages/scenario/package.json
COPY packages/spatial/package.json packages/spatial/package.json
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
FROM build-deps AS pkg-builder
COPY packages/ ./packages/
RUN pnpm build
FROM pkg-builder AS gui-builder
COPY apps/gui/src ./apps/gui/src
COPY apps/gui/public ./apps/gui/public
RUN pnpm --filter @omnia/gui build
FROM base AS runner
ENV NODE_ENV=production
RUN apt-get update -qq && \
apt-get install -qq -y --no-install-recommends \
python3 make g++ && \
rm -rf /var/lib/apt/lists/*
COPY --from=gui-builder /app/package.json /app/pnpm-workspace.yaml ./
COPY --from=gui-builder /app/node_modules ./node_modules
COPY --from=gui-builder /app/packages ./packages
COPY --from=gui-builder /app/apps/gui/package.json \
/app/apps/gui/next.config.ts \
/app/apps/gui/postcss.config.mjs \
./apps/gui/
COPY --from=gui-builder /app/apps/gui/.next ./apps/gui/.next
COPY --from=gui-builder /app/apps/gui/public ./apps/gui/public
WORKDIR /app/apps/gui
EXPOSE 3000
CMD ["pnpm", "start"]

View File

@@ -20,7 +20,6 @@
"@omnia/scenario": "workspace:*",
"@omnia/spatial": "workspace:*",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tooltip": "^1.2.12",

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -7,10 +7,11 @@ import type { SimSnapshot } from "@/lib/simulation";
import {
ProviderManager,
ModelProviderInstance,
AVAILABLE_PROVIDERS,
getAvailableProviders as listAvailableProviders,
ModelProviderMeta,
ModelLister,
ModelInfo,
} from "@omnia/llm";
import { ScenarioSchema } from "@omnia/scenario";
function resolveScenarioPath(relative: string): string {
const cwd = process.cwd();
@@ -37,6 +38,7 @@ export async function startSimulation(input: {
scenario?: string;
playEntity?: string;
providerInstanceId?: string;
customName?: string;
}): Promise<ActionResult> {
try {
const scenarioFile =
@@ -51,6 +53,7 @@ export async function startSimulation(input: {
resolved,
input.playEntity || undefined,
input.providerInstanceId,
input.customName,
);
if (snapshot.status === "error") {
@@ -255,6 +258,7 @@ export async function createProviderInstance(
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): Promise<ModelProviderInstance> {
return ProviderManager.create(
name,
@@ -263,6 +267,7 @@ export async function createProviderInstance(
modelName,
type,
maxContext,
endpointUrl,
);
}
@@ -282,6 +287,7 @@ export async function updateProviderInstance(
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): Promise<void> {
ProviderManager.update(
id,
@@ -291,6 +297,7 @@ export async function updateProviderInstance(
modelName,
type,
maxContext,
endpointUrl,
);
}
@@ -306,7 +313,7 @@ export async function setProviderMapping(
}
export async function getAvailableProviders(): Promise<ModelProviderMeta[]> {
return AVAILABLE_PROVIDERS;
return listAvailableProviders();
}
export async function regenerateEmbeddings(
@@ -315,41 +322,49 @@ export async function regenerateEmbeddings(
await simulationManager.regenerateAllEmbeddings(newProviderInstanceId);
}
export async function saveScenario(
scenario: unknown,
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const parsed = ScenarioSchema.parse(scenario);
const cwd = process.cwd();
const dir = path.resolve(cwd, "content/demo/scenarios");
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const filePath = path.join(dir, `${parsed.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), "utf-8");
return { ok: true };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
/**
* Fetch available models for a provider given its credentials.
* Used when creating a new instance (before it's saved to the DB).
*/
export async function fetchAvailableModels(
providerName: string,
apiKey: string,
endpointUrl?: string,
): Promise<ModelInfo[]> {
return ModelLister.listModels(providerName, apiKey, endpointUrl);
}
export async function loadScenarioJson(
scenarioPath: string,
): Promise<{ ok: true; scenario: unknown } | { ok: false; error: string }> {
/**
* Fetch available models for an existing saved provider instance.
* The API key is retrieved from the DB server-side — never sent to the client.
*/
export async function fetchAvailableModelsForInstance(
instanceId: string,
): Promise<ModelInfo[]> {
const instances = ProviderManager.list();
const inst = instances.find((i) => i.id === instanceId);
if (!inst) return [];
return ModelLister.listModels(
inst.providerName,
inst.apiKey,
inst.endpointUrl,
);
}
export async function renameSimulation(
simId: string,
newName: string,
): Promise<{ ok: true; snapshot: SimSnapshot } | { ok: false; error: string }> {
try {
const resolved = resolveScenarioPath(scenarioPath);
if (!fs.existsSync(resolved)) {
return { ok: false, error: `Scenario file not found: ${scenarioPath}` };
const snapshot = await simulationManager.rename(simId, newName);
if (!snapshot) {
return { ok: false, error: "Simulation session not found" };
}
const content = JSON.parse(fs.readFileSync(resolved, "utf-8"));
return { ok: true, scenario: content };
return { ok: true, snapshot };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
error: err instanceof Error ? err.message : "Failed to rename simulation",
};
}
}

View File

@@ -1,766 +1,5 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import {
SidebarProvider,
Sidebar,
SidebarContent,
} from "@/components/ui/sidebar";
import {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
MenubarRadioGroup,
MenubarRadioItem,
} from "@/components/ui/menubar";
import { getConfigStatus, loadScenarioJson, saveScenario } from "@/app/actions";
import type { Scenario } from "@omnia/scenario";
import {
Save,
FileJson,
Globe,
MapPin,
Users,
Info,
Eye,
Pencil,
} from "lucide-react";
// Import refactored builder components
import { MetadataTab } from "@/components/builder/MetadataTab";
import { LocationsTab } from "@/components/builder/LocationsTab";
import { EntitiesTab } from "@/components/builder/EntitiesTab";
import { JsonTab } from "@/components/builder/JsonTab";
import type {
LocationData,
EntityData,
AttributeData,
} from "@/components/builder/types";
const generateUUID = () => {
if (
typeof window !== "undefined" &&
window.crypto &&
window.crypto.randomUUID
) {
return window.crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
import { BuilderView } from "@/components/builder/BuilderView";
export default function BuilderPage() {
const router = useRouter();
// Load scenarios templates list
const [availableScenarios, setAvailableScenarios] = useState<
{ path: string; name: string; description: string }[]
>([]);
// Tabs: "metadata", "locations", "entities", "json"
const [activeTab, setActiveTab] = useState<
"metadata" | "locations" | "entities" | "json"
>("metadata");
// Form State
const [scenarioId, setScenarioId] = useState("");
const [name, setName] = useState("My Custom Scenario");
const [description, setDescription] = useState(
"A custom scenario template created via builder.",
);
const [startTime, setStartTime] = useState("2026-07-06T12:00:00.000Z");
const [worldAttributes, setWorldAttributes] = useState<AttributeData[]>([]);
const [locations, setLocations] = useState<LocationData[]>([]);
const [entities, setEntities] = useState<EntityData[]>([]);
// Selected sub-items for active editing lists
const [selectedLocIndex, setSelectedLocIndex] = useState(0);
const [selectedEntIndex, setSelectedEntIndex] = useState(0);
// Status & Notification Banners
const [statusMessage, setStatusMessage] = useState<{
text: string;
type: "success" | "error" | "info";
} | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize dynamic UUIDs client-side to prevent NextJS SSR hydration mismatch
useEffect(() => {
if (!scenarioId) {
const uId = generateUUID();
const locId = generateUUID();
const entId = generateUUID();
setScenarioId(uId);
setLocations([{ id: locId, attributes: [], connections: [] }]);
setEntities([
{
id: entId,
locationId: locId,
attributes: [
{
name: "role",
value: "adventurer",
visibility: "PUBLIC",
allowedEntities: [],
},
],
aliases: {},
initialMemories: [],
},
]);
}
}, [scenarioId]);
// Fetch available templates on load
useEffect(() => {
async function loadTemplates() {
try {
const config = await getConfigStatus();
setAvailableScenarios(config.availableScenarios);
} catch (err) {
console.error("Failed to load scenario list:", err);
}
}
loadTemplates();
}, []);
// Set timeout to dismiss messages
useEffect(() => {
if (statusMessage) {
const timer = setTimeout(() => {
setStatusMessage(null);
}, 6000);
return () => clearTimeout(timer);
}
}, [statusMessage]);
// Populate helper lists
const locationIds = useMemo(
() => locations.map((l) => l.id).filter(Boolean),
[locations],
);
const entityIds = useMemo(
() => entities.map((e) => e.id).filter(Boolean),
[entities],
);
// Load selected template
const handleLoadTemplate = async (path: string) => {
if (!path) return;
setStatusMessage({ text: "Loading template...", type: "info" });
try {
const res = await loadScenarioJson(path);
if (!res.ok) {
setStatusMessage({
text: res.error || "Failed to load template.",
type: "error",
});
return;
}
const s = res.scenario as Scenario;
if (!s) {
setStatusMessage({
text: "Scenario template was empty.",
type: "error",
});
return;
}
setScenarioId(s.id || "custom-scenario");
setName(s.name || "Loaded Scenario");
setDescription(s.description || "");
setStartTime(s.startTime || "2026-07-06T12:00:00.000Z");
// World attributes
const wAttrs = (s.world?.attributes || []).map((a) => ({
name: a.name,
value: a.value,
visibility: a.visibility,
allowedEntities: a.allowedEntities || [],
}));
setWorldAttributes(wAttrs);
// Locations
const locs = (s.locations || []).map((l) => ({
id: l.id,
parentId: l.parentId || undefined,
attributes: (l.attributes || []).map((a) => ({
name: a.name,
value: a.value,
visibility: a.visibility,
allowedEntities: a.allowedEntities || [],
})),
connections: (l.connections || []).map((c) => ({
targetId: c.targetId,
portalName: c.portalName,
portalStateDescriptor: c.portalStateDescriptor,
visionProp: c.visionProp,
soundProp: c.soundProp,
bidirectional: c.bidirectional ?? true,
})),
}));
setLocations(
locs.length > 0
? locs
: [{ id: generateUUID(), attributes: [], connections: [] }],
);
setSelectedLocIndex(0);
// Entities
const ents = (s.entities || []).map((e) => ({
id: e.id,
locationId: e.locationId || undefined,
attributes: (e.attributes || []).map((a) => ({
name: a.name,
value: a.value,
visibility: a.visibility,
allowedEntities: a.allowedEntities || [],
})),
aliases: e.aliases || {},
initialMemories: (e.initialMemories || []).map((m) => ({
id: m.id || generateUUID(),
timestamp: m.timestamp || s.startTime,
locationId: m.locationId || null,
intent: {
type: m.intent.type,
originalText: m.intent.originalText,
description: m.intent.description,
selfDescription: m.intent.selfDescription,
actorId: m.intent.actorId || e.id,
targetIds: m.intent.targetIds || [],
modifiers: m.intent.modifiers || [],
},
outcome: m.outcome
? {
isValid: m.outcome.isValid,
reason: m.outcome.reason,
}
: undefined,
})),
}));
setEntities(
ents.length > 0
? ents
: [
{
id: generateUUID(),
locationId: locs[0]?.id || generateUUID(),
attributes: [],
aliases: {},
initialMemories: [],
},
],
);
setSelectedEntIndex(0);
setStatusMessage({
text: "Template loaded successfully!",
type: "success",
});
} catch (err) {
setStatusMessage({
text: err instanceof Error ? err.message : String(err),
type: "error",
});
}
};
// Compile full scenario object
const compiledScenario = useMemo(() => {
return {
id: scenarioId.trim(),
name: name.trim(),
description: description.trim(),
startTime: startTime.trim(),
world:
worldAttributes.length > 0
? {
attributes: worldAttributes.map((a) => ({
name: a.name.trim(),
value: a.value.trim(),
visibility: a.visibility,
...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0
? { allowedEntities: a.allowedEntities }
: {}),
})),
}
: undefined,
locations: locations.map((l) => ({
id: l.id.trim(),
...(l.parentId ? { parentId: l.parentId } : {}),
...(l.attributes.length > 0
? {
attributes: l.attributes.map((a) => ({
name: a.name.trim(),
value: a.value.trim(),
visibility: a.visibility,
...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0
? { allowedEntities: a.allowedEntities }
: {}),
})),
}
: {}),
...(l.connections.length > 0
? {
connections: l.connections.map((c) => ({
targetId: c.targetId,
...(c.portalName ? { portalName: c.portalName.trim() } : {}),
...(c.portalStateDescriptor
? { portalStateDescriptor: c.portalStateDescriptor.trim() }
: {}),
visionProp: Number(c.visionProp),
soundProp: Number(c.soundProp),
bidirectional: !!c.bidirectional,
})),
}
: {}),
})),
entities: entities.map((e) => ({
id: e.id.trim(),
...(e.locationId ? { locationId: e.locationId } : {}),
...(e.attributes.length > 0
? {
attributes: e.attributes.map((a) => ({
name: a.name.trim(),
value: a.value.trim(),
visibility: a.visibility,
...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0
? { allowedEntities: a.allowedEntities }
: {}),
})),
}
: {}),
...(Object.keys(e.aliases).length > 0 ? { aliases: e.aliases } : {}),
...(e.initialMemories.length > 0
? {
initialMemories: e.initialMemories.map((m) => ({
id: m.id,
timestamp: m.timestamp,
locationId: m.locationId,
intent: {
type: m.intent.type,
originalText: m.intent.originalText.trim(),
description: m.intent.description.trim(),
...(m.intent.selfDescription
? { selfDescription: m.intent.selfDescription.trim() }
: {}),
actorId: m.intent.actorId,
targetIds: m.intent.targetIds,
...(m.intent.modifiers && m.intent.modifiers.length > 0
? { modifiers: m.intent.modifiers }
: []),
},
...(m.outcome
? {
outcome: {
isValid: !!m.outcome.isValid,
reason: m.outcome.reason.trim(),
},
}
: {}),
})),
}
: {}),
})),
};
}, [
scenarioId,
name,
description,
startTime,
worldAttributes,
locations,
entities,
]);
// Save scenario to server
const handleSaveToServer = async () => {
if (!scenarioId.trim()) {
setStatusMessage({
text: "Scenario Template ID is required to save.",
type: "error",
});
return;
}
setIsSubmitting(true);
setStatusMessage({ text: "Saving scenario file...", type: "info" });
try {
const res = await saveScenario(compiledScenario);
if (res.ok) {
setStatusMessage({
text: `Scenario template saved as ${scenarioId}.json successfully!`,
type: "success",
});
// Refresh template list
const config = await getConfigStatus();
setAvailableScenarios(config.availableScenarios);
} else {
setStatusMessage({
text: res.error || "Failed to save scenario.",
type: "error",
});
}
} catch (err) {
setStatusMessage({
text: err instanceof Error ? err.message : String(err),
type: "error",
});
} finally {
setIsSubmitting(false);
}
};
// Download scenario file directly
const handleDownloadJson = () => {
try {
const jsonStr = JSON.stringify(compiledScenario, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${scenarioId || "scenario"}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setStatusMessage({ text: "JSON download initiated.", type: "success" });
} catch {
setStatusMessage({ text: "Download failed.", type: "error" });
}
};
const handleResetScenario = () => {
setScenarioId("");
setName("My Custom Scenario");
setDescription("A custom scenario template created via builder.");
setStartTime("2026-07-06T12:00:00.000Z");
setWorldAttributes([]);
setSelectedLocIndex(0);
setSelectedEntIndex(0);
setStatusMessage({ text: "Scenario reset successfully.", type: "success" });
};
const handleAddLocation = () => {
const newId = generateUUID();
setLocations([
...locations,
{ id: newId, attributes: [], connections: [] },
]);
setSelectedLocIndex(locations.length);
setActiveTab("locations");
};
const handleAddEntity = () => {
const newId = generateUUID();
setEntities([
...entities,
{
id: newId,
locationId: locationIds[0] || "",
attributes: [],
aliases: {},
initialMemories: [],
isAgent: true,
},
]);
setSelectedEntIndex(entities.length);
setActiveTab("entities");
};
return (
<div className="flex flex-col flex-1 min-h-0 w-full bg-background overflow-hidden">
{/* Save Status Banner */}
{statusMessage && (
<div
className={`fixed top-14 right-4 z-50 max-w-sm border p-4 shadow-lg animate-fade-in ${
statusMessage.type === "success"
? "bg-emerald-950/80 border-emerald-500 text-emerald-300"
: statusMessage.type === "error"
? "bg-destructive/10 border-destructive text-destructive"
: "bg-secondary/90 border-border text-foreground"
}`}
>
<div className="flex items-start gap-3">
<Info className="size-4 shrink-0 mt-0.5" />
<div className="text-xs">{statusMessage.text}</div>
</div>
</div>
)}
{/* Menubar spanning full page width right below navbar */}
<div className="w-full border-b border-border/20 bg-card py-1.5 px-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<span className="text-xs font-head text-primary tracking-wider font-bold flex items-center gap-1.5">
<Pencil className="size-4 text-primary" />
</span>
<Menubar className="border-none shadow-none bg-transparent h-7 p-0">
<MenubarMenu>
<MenubarTrigger className="cursor-pointer">File</MenubarTrigger>
<MenubarContent>
<MenubarSub>
<MenubarSubTrigger className="cursor-pointer">
Load Template
</MenubarSubTrigger>
<MenubarSubContent>
{availableScenarios.length === 0 ? (
<MenubarItem disabled>No templates</MenubarItem>
) : (
availableScenarios.map((sc) => (
<MenubarItem
key={sc.path}
className="cursor-pointer"
onClick={() => {
handleLoadTemplate(sc.path);
}}
>
{sc.name}
</MenubarItem>
))
)}
</MenubarSubContent>
</MenubarSub>
<MenubarSeparator />
<MenubarItem
className="cursor-pointer"
onClick={handleSaveToServer}
disabled={isSubmitting || !scenarioId.trim()}
>
<Save className="size-4 mr-2 inline" /> Save to Server
</MenubarItem>
<MenubarItem
className="cursor-pointer"
onClick={handleDownloadJson}
>
<FileJson className="size-4 mr-2 inline" /> Export JSON
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="cursor-pointer">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem
className="cursor-pointer"
onClick={handleResetScenario}
>
Reset Scenario
</MenubarItem>
<MenubarSeparator />
<MenubarItem
className="cursor-pointer"
onClick={handleAddLocation}
>
Add New Location
</MenubarItem>
<MenubarItem
className="cursor-pointer"
onClick={handleAddEntity}
>
Add New Entity
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="cursor-pointer">View</MenubarTrigger>
<MenubarContent>
<MenubarRadioGroup
value={activeTab}
onValueChange={(val) => setActiveTab(val as typeof activeTab)}
>
<MenubarRadioItem value="metadata" className="cursor-pointer">
World Metadata
</MenubarRadioItem>
<MenubarRadioItem
value="locations"
className="cursor-pointer"
>
Locations
</MenubarRadioItem>
<MenubarRadioItem value="entities" className="cursor-pointer">
Entities
</MenubarRadioItem>
<MenubarRadioItem value="json" className="cursor-pointer">
Live JSON Preview
</MenubarRadioItem>
</MenubarRadioGroup>
</MenubarContent>
</MenubarMenu>
</Menubar>
</div>
<div className="text-[10px] font-mono text-muted-foreground">
{scenarioId ? `ID: ${scenarioId}` : "Unsaved Scenario"}
</div>
</div>
<SidebarProvider className="flex-1 min-h-0">
<div className="flex flex-1 min-h-0 w-full overflow-hidden">
{/* Viewport-level Vertical Sidebar on the Left Side */}
<Sidebar
collapsible="none"
className="h-full border-r border-border/30 bg-card shrink-0"
>
<SidebarContent className="flex flex-col justify-between h-full bg-card p-6">
<div className="flex flex-col gap-2 font-head">
<span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground block mb-2">
Configuration
</span>
<button
onClick={() => setActiveTab("metadata")}
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
activeTab === "metadata"
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
}`}
>
<Globe className="size-3.5" />
<span>World Metadata</span>
</button>
<button
onClick={() => setActiveTab("locations")}
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
activeTab === "locations"
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
}`}
>
<MapPin className="size-3.5" />
<span>Locations</span>
<span className="ml-auto text-[10px] font-mono border border-muted-foreground/20 bg-muted/10 px-1 rounded">
{locations.length}
</span>
</button>
<button
onClick={() => setActiveTab("entities")}
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
activeTab === "entities"
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
}`}
>
<Users className="size-3.5" />
<span>Entities</span>
<span className="ml-auto text-[10px] font-mono border border-muted-foreground/20 bg-muted/10 px-1 rounded">
{entities.length}
</span>
</button>
<button
onClick={() => setActiveTab("json")}
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
activeTab === "json"
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
}`}
>
<Eye className="size-3.5" />
<span>Live JSON Preview</span>
</button>
</div>
{/* Sidebar Footer link */}
<div className="border-t border-border/10 pt-3 flex items-center justify-between text-[10px] text-muted-foreground">
<button
onClick={() => router.push("/")}
className="w-full py-1 text-center hover:text-foreground text-primary font-bold uppercase transition-colors cursor-pointer"
>
Back to Dashboard
</button>
</div>
</SidebarContent>
</Sidebar>
{/* Main Centered Content Pane on the Right */}
<main className="flex-1 overflow-y-auto px-10 py-8 min-h-0 flex flex-col">
<div className="mx-auto max-w-[1200px] w-full flex-1 flex flex-col min-h-0 gap-6">
{/* Header block with Page Name */}
<div className="shrink-0">
<h1 className="text-headline-md text-primary flex items-center gap-2 font-head">
Scenario Builder
</h1>
</div>
{/* Active configuration tab form */}
<div className="flex-1 min-h-0">
{/* TAB 1: World Metadata & Attributes */}
{activeTab === "metadata" && (
<MetadataTab
scenarioId={scenarioId}
setScenarioId={setScenarioId}
name={name}
setName={setName}
description={description}
setDescription={setDescription}
startTime={startTime}
setStartTime={setStartTime}
worldAttributes={worldAttributes}
setWorldAttributes={setWorldAttributes}
entityIds={entityIds}
entities={entities}
/>
)}
{/* TAB 2: Locations & Spatial connections */}
{activeTab === "locations" && (
<LocationsTab
locations={locations}
setLocations={setLocations}
entities={entities}
locationIds={locationIds}
entityIds={entityIds}
selectedLocIndex={selectedLocIndex}
setSelectedLocIndex={setSelectedLocIndex}
generateUUID={generateUUID}
/>
)}
{/* TAB 3: Entities */}
{activeTab === "entities" && (
<EntitiesTab
entities={entities}
setEntities={setEntities}
locations={locations}
locationIds={locationIds}
entityIds={entityIds}
selectedEntIndex={selectedEntIndex}
setSelectedEntIndex={setSelectedEntIndex}
startTime={startTime}
generateUUID={generateUUID}
/>
)}
{/* TAB 4: Live JSON Preview */}
{activeTab === "json" && (
<JsonTab
compiledScenario={compiledScenario}
onCopySuccess={() =>
setStatusMessage({
text: "JSON copied to clipboard!",
type: "success",
})
}
/>
)}
</div>
</div>
</main>
</div>
</SidebarProvider>
</div>
);
return <BuilderView />;
}

View File

@@ -8,7 +8,6 @@
@custom-variant data-horizontal (&[data-orientation="horizontal"]);
@custom-variant data-vertical (&[data-orientation="vertical"]);
@custom-variant data-popup-open (&[data-state="open"]);
@custom-variant data-highlighted (&[data-highlighted]);
@theme inline {
--font-head: var(--font-head);

View File

@@ -34,7 +34,6 @@ const spaceMono = Space_Mono({
const links = [
{ href: "/", label: "Home" },
{ href: "/builder", label: "Builder" },
{ href: "/config", label: "Config" },
];

View File

@@ -1,5 +1,5 @@
import { DashboardView } from "@/components/play/DashboardView";
import { HomeView } from "@/components/home/HomeView";
export default function Home() {
return <DashboardView />;
return <HomeView />;
}

View File

@@ -1,167 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, Trash2 } from "lucide-react";
import type { AttributeData, EntityData } from "./types";
import { getEntityDisplayNameById } from "./utils";
interface AttributeEditorProps {
title?: string;
attributes: AttributeData[];
onChange: (attrs: AttributeData[]) => void;
onAdd: () => void;
entityIds: string[];
entities?: EntityData[];
}
export function AttributeEditor({
title = "Attributes",
attributes,
onChange,
onAdd,
entityIds,
entities,
}: AttributeEditorProps) {
const handleAttrChange = <K extends keyof AttributeData>(
index: number,
key: K,
val: AttributeData[K],
) => {
const copy = [...attributes];
copy[index] = { ...copy[index], [key]: val };
onChange(copy);
};
const handleToggleEntityAccess = (index: number, entId: string) => {
const copy = [...attributes];
const allowed = copy[index].allowedEntities || [];
if (allowed.includes(entId)) {
copy[index].allowedEntities = allowed.filter((id) => id !== entId);
} else {
copy[index].allowedEntities = [...allowed, entId];
}
onChange(copy);
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center border-b border-border/20 pb-2">
<h3 className="text-body-md font-mono text-foreground font-bold">
{title}
</h3>
<Button
type="button"
size="sm"
variant="outline"
onClick={onAdd}
className="h-7 text-xs flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add Attribute
</Button>
</div>
{attributes.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No attributes defined yet.
</p>
) : (
<div className="space-y-3">
{attributes.map((attr, index) => (
<div
key={index}
className="border border-border/20 bg-secondary/10 p-3 rounded space-y-3"
>
<div className="flex gap-2 items-center">
<div className="flex-1 grid grid-cols-2 gap-2">
<Input
placeholder="Name (e.g. role)"
value={attr.name}
onChange={(e) =>
handleAttrChange(index, "name", e.target.value)
}
className="h-8 font-mono text-xs"
/>
<Input
placeholder="Value (e.g. merchant)"
value={attr.value}
onChange={(e) =>
handleAttrChange(index, "value", e.target.value)
}
className="h-8 text-xs"
/>
</div>
<Button
type="button"
variant="destructive"
size="icon"
className="size-8 shrink-0 cursor-pointer"
onClick={() =>
onChange(attributes.filter((_, i) => i !== index))
}
>
<Trash2 className="size-4" />
</Button>
</div>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-2">
<Label className="text-muted-foreground cursor-pointer flex items-center gap-1.5">
<Checkbox
checked={attr.visibility === "PUBLIC"}
onCheckedChange={(checked) =>
handleAttrChange(
index,
"visibility",
checked ? "PUBLIC" : "PRIVATE",
)
}
/>
Publicly Visible
</Label>
</div>
{attr.visibility === "PRIVATE" && (
<div className="flex-1 border-l border-border/25 pl-4">
<span className="text-muted-foreground font-semibold block mb-1">
Visible to Entities:
</span>
{entityIds.length === 0 ? (
<span className="text-[10px] italic text-muted-foreground">
Add entities first to grant private access
</span>
) : (
<div className="flex flex-wrap gap-1">
{entityIds.map((entId) => {
const isAllowed =
attr.allowedEntities?.includes(entId);
return (
<button
key={entId}
type="button"
onClick={() =>
handleToggleEntityAccess(index, entId)
}
className={`px-2 py-0.5 rounded text-[10px] font-mono border transition-all cursor-pointer ${
isAllowed
? "bg-primary/20 border-primary text-primary font-bold"
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
}`}
>
{entities
? getEntityDisplayNameById(entId, entities)
: entId}
</button>
);
})}
</div>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
export function BuilderView() {
return (
<div className="flex-1 overflow-y-auto w-full">
<div className="mx-auto max-w-[800px] px-10 py-12">
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">
Scenario Builder
</h1>
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] min-h-[300px] flex flex-col items-center justify-center">
<p className="text-body-md text-muted-foreground font-mono text-center">
Scenario builder interface coming soon...
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,563 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { AttributeEditor } from "./AttributeEditor";
import { Plus, Trash2, ChevronRight } from "lucide-react";
import type { EntityData, MemoryData, LocationData } from "./types";
import {
getEntityDisplayName,
getEntityDisplayNameById,
getLocationDisplayNameById,
} from "./utils";
interface EntitiesTabProps {
entities: EntityData[];
setEntities: (ents: EntityData[]) => void;
locations: LocationData[];
locationIds: string[];
entityIds: string[];
selectedEntIndex: number;
setSelectedEntIndex: (idx: number) => void;
startTime: string;
generateUUID: () => string;
}
export function EntitiesTab({
entities,
setEntities,
locations,
locationIds,
entityIds,
selectedEntIndex,
setSelectedEntIndex,
startTime,
generateUUID,
}: EntitiesTabProps) {
const selectedEnt = entities[selectedEntIndex];
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0 pb-12">
{/* Left sidebar: Entities list */}
<div className="md:col-span-1 border border-border/20 bg-card shadow-[2px_2px_0_0_var(--border)] flex flex-col max-h-[500px]">
<div className="p-3 border-b border-border/25 flex justify-between items-center bg-secondary/15">
<strong className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
Entities
</strong>
<Button
type="button"
size="sm"
onClick={() => {
const newId = generateUUID();
setEntities([
...entities,
{
id: newId,
locationId: locationIds[0],
attributes: [],
aliases: {},
initialMemories: [],
isAgent: true,
},
]);
setSelectedEntIndex(entities.length);
}}
className="h-6 text-[10px] px-2 flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add
</Button>
</div>
<div className="overflow-y-auto divide-y divide-border/10 flex-1">
{entities.map((ent, idx) => (
<div
key={idx}
onClick={() => setSelectedEntIndex(idx)}
className={`p-3 text-xs font-mono cursor-pointer flex justify-between items-center transition-all ${
selectedEntIndex === idx
? "bg-primary/10 text-primary font-bold border-l-4 border-primary"
: "hover:bg-secondary/40 text-foreground"
}`}
>
<span className="truncate">
{getEntityDisplayName(ent) || `(Empty ID)`}
</span>
{entities.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const newEnts = entities.filter((_, i) => i !== idx);
setEntities(newEnts);
setSelectedEntIndex(Math.max(0, idx - 1));
}}
className="text-muted-foreground hover:text-destructive pl-2 cursor-pointer"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
))}
</div>
</div>
{/* Right panel: Edit selected entity details */}
{selectedEnt ? (
<div className="md:col-span-3 space-y-6">
{/* Entity Configuration Card */}
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-4">
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
Entity Configuration
</h2>
<div className="space-y-1.5">
<Label>Entity ID</Label>
<Input
value={selectedEnt.id}
readOnly
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
/>
</div>
<div className="space-y-1.5">
<Label>Current Location</Label>
<Combobox
items={locationIds}
value={selectedEnt.locationId || ""}
onValueChange={(value) => {
const copy = [...entities];
copy[selectedEntIndex].locationId = value || undefined;
setEntities(copy);
}}
>
<ComboboxInput placeholder="Select a location..." showClear />
<ComboboxContent>
<ComboboxEmpty>No locations found.</ComboboxEmpty>
<ComboboxList>
{(id: string) => (
<ComboboxItem key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="space-y-1.5 pt-1 pb-2">
<div className="flex items-center gap-2">
<Label className="text-muted-foreground cursor-pointer flex items-center gap-1.5 text-sm font-semibold">
<Checkbox
checked={selectedEnt.isAgent !== false}
onCheckedChange={(checked) => {
const copy = [...entities];
copy[selectedEntIndex].isAgent = !!checked;
setEntities(copy);
}}
/>
Is Agent?
</Label>
</div>
<p className="text-[11px] text-muted-foreground/75 pl-5 select-none leading-normal">
When enabled, this entity will run an autonomous LLM loop to
perceive its environment, update its memories, and generate
prose narrative actions.
</p>
</div>
{/* Attributes */}
<div className="pt-2">
<AttributeEditor
title="Entity Attributes"
attributes={selectedEnt.attributes || []}
onChange={(newAttrs) => {
const copy = [...entities];
copy[selectedEntIndex].attributes = newAttrs;
setEntities(copy);
}}
onAdd={() => {
const copy = [...entities];
copy[selectedEntIndex].attributes = [
...copy[selectedEntIndex].attributes,
{
name: "",
value: "",
visibility: "PUBLIC",
allowedEntities: [],
},
];
setEntities(copy);
}}
entityIds={entityIds}
entities={entities}
/>
</div>
</div>
{/* Aliases Card */}
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-3">
<div className="flex justify-between items-center border-b border-border/20 pb-2">
<h3 className="text-body-md text-foreground font-bold">
Aliases (Perceptions)
</h3>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const copy = [...entities];
const target = entityIds.find(
(id) => id !== selectedEnt.id && !selectedEnt.aliases[id],
);
if (target) {
copy[selectedEntIndex].aliases = {
...selectedEnt.aliases,
[target]: "",
};
setEntities(copy);
}
}}
disabled={entityIds.length <= 1}
className="h-7 text-xs flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add Alias
</Button>
</div>
{Object.keys(selectedEnt.aliases || {}).length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No descriptive aliases configured. Defaults to actual entity ID.
</p>
) : (
<div className="space-y-2">
{Object.entries(selectedEnt.aliases).map(
([targetId, aliasText]) => (
<div
key={targetId}
className="flex gap-2 items-center bg-secondary/15 p-2 rounded"
>
<span
className="text-[11px] font-mono text-muted-foreground w-1/3 truncate"
title={targetId}
>
{getEntityDisplayNameById(targetId, entities)}
</span>
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
<Input
placeholder="Descriptive name (e.g. the guard)"
value={aliasText}
onChange={(e) => {
const copy = [...entities];
copy[selectedEntIndex].aliases = {
...selectedEnt.aliases,
[targetId]: e.target.value,
};
setEntities(copy);
}}
className="h-7 text-xs flex-1"
/>
<button
type="button"
onClick={() => {
const copy = [...entities];
const updated = { ...selectedEnt.aliases };
delete updated[targetId];
copy[selectedEntIndex].aliases = updated;
setEntities(copy);
}}
className="text-muted-foreground hover:text-destructive cursor-pointer"
>
<Trash2 className="size-3.5" />
</button>
</div>
),
)}
</div>
)}
</div>
{/* Initial Memories Card */}
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-3">
<div className="flex justify-between items-center border-b border-border/20 pb-2">
<h3 className="text-body-md text-foreground font-bold">
Initial Memories
</h3>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const copy = [...entities];
const newMem: MemoryData = {
id: generateUUID(),
timestamp: startTime,
locationId: selectedEnt.locationId || null,
intent: {
type: "dialogue",
originalText: "",
description: "",
actorId: selectedEnt.id,
targetIds: [],
},
};
copy[selectedEntIndex].initialMemories = [
...selectedEnt.initialMemories,
newMem,
];
setEntities(copy);
}}
className="h-7 text-xs flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add Memory
</Button>
</div>
{!selectedEnt.initialMemories ||
selectedEnt.initialMemories.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No initial memories loaded. Entities will start blank.
</p>
) : (
<div className="space-y-4 overflow-y-auto max-h-[300px] pr-1">
{selectedEnt.initialMemories.map((mem, memIdx) => (
<div
key={mem.id}
className="border border-border/20 bg-secondary/5 p-3 rounded space-y-3 relative"
>
<button
type="button"
onClick={() => {
const copy = [...entities];
copy[selectedEntIndex].initialMemories =
selectedEnt.initialMemories.filter(
(_, i) => i !== memIdx,
);
setEntities(copy);
}}
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
>
<Trash2 className="size-3.5" />
</button>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground">
Type
</span>
<select
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded"
value={mem.intent.type}
onChange={(e) => {
const copy = [...entities];
copy[selectedEntIndex].initialMemories[
memIdx
].intent.type = e.target.value as
"dialogue" | "action" | "monologue";
setEntities(copy);
}}
>
<option value="dialogue">Dialogue</option>
<option value="action">Action</option>
<option value="monologue">Monologue</option>
</select>
</div>
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground">
Location
</span>
<select
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded font-mono"
value={mem.locationId || ""}
onChange={(e) => {
const copy = [...entities];
copy[selectedEntIndex].initialMemories[
memIdx
].locationId = e.target.value || null;
setEntities(copy);
}}
>
<option value="">-- Nowhere --</option>
{locationIds.map((id) => (
<option key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground">
Verbatim Text (originalText)
</span>
<Input
placeholder='e.g. "We should leave," Alice said.'
value={mem.intent.originalText}
onChange={(e) => {
const copy = [...entities];
copy[selectedEntIndex].initialMemories[
memIdx
].intent.originalText = e.target.value;
setEntities(copy);
}}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground">
Objective Description
</span>
<Input
placeholder="e.g. Alice says she wants to leave."
value={mem.intent.description}
onChange={(e) => {
const copy = [...entities];
copy[selectedEntIndex].initialMemories[
memIdx
].intent.description = e.target.value;
setEntities(copy);
}}
className="h-7 text-xs"
/>
</div>
{/* Targets multi-select */}
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground block">
Involved Targets
</span>
<div className="flex flex-wrap gap-1">
{entityIds
.filter((id) => id !== selectedEnt.id)
.map((entId) => {
const isSelected =
mem.intent.targetIds?.includes(entId);
return (
<button
key={entId}
type="button"
onClick={() => {
const copy = [...entities];
const targets =
copy[selectedEntIndex].initialMemories[
memIdx
].intent.targetIds || [];
if (targets.includes(entId)) {
copy[selectedEntIndex].initialMemories[
memIdx
].intent.targetIds = targets.filter(
(t) => t !== entId,
);
} else {
copy[selectedEntIndex].initialMemories[
memIdx
].intent.targetIds = [...targets, entId];
}
setEntities(copy);
}}
className={`px-1.5 py-0.5 rounded text-[9px] font-mono border transition-all cursor-pointer ${
isSelected
? "bg-primary/20 border-primary text-primary"
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
}`}
>
{getEntityDisplayNameById(entId, entities)}
</button>
);
})}
</div>
</div>
{/* Action Validation outcome */}
{mem.intent.type === "action" && (
<div className="border-t border-border/20 pt-2 space-y-2">
<Label className="text-[10px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
<Checkbox
checked={!!mem.outcome}
onCheckedChange={(checked) => {
const copy = [...entities];
if (checked) {
copy[selectedEntIndex].initialMemories[
memIdx
].outcome = { isValid: true, reason: "" };
} else {
copy[selectedEntIndex].initialMemories[
memIdx
].outcome = undefined;
}
setEntities(copy);
}}
/>
Include validation outcome
</Label>
{mem.outcome && (
<div className="grid grid-cols-3 gap-2 bg-secondary/15 p-2 rounded">
<div className="col-span-1 flex flex-col justify-center">
<Label className="text-[9px] mb-1">isValid</Label>
<Checkbox
checked={mem.outcome.isValid}
onCheckedChange={(checked) => {
const copy = [...entities];
if (
copy[selectedEntIndex].initialMemories[
memIdx
].outcome
) {
copy[selectedEntIndex].initialMemories[
memIdx
].outcome!.isValid = !!checked;
setEntities(copy);
}
}}
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-[9px]">Reason</Label>
<Input
placeholder="Reason for valid/invalid status"
value={mem.outcome.reason}
onChange={(e) => {
const copy = [...entities];
if (
copy[selectedEntIndex].initialMemories[
memIdx
].outcome
) {
copy[selectedEntIndex].initialMemories[
memIdx
].outcome!.reason = e.target.value;
setEntities(copy);
}
}}
className="h-6 text-[10px]"
/>
</div>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
) : (
<div className="md:col-span-3 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] text-center text-xs text-muted-foreground">
No entities defined.
</div>
)}
</div>
);
}

View File

@@ -1,38 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
interface JsonTabProps {
compiledScenario: Record<string, unknown>;
onCopySuccess: () => void;
}
export function JsonTab({ compiledScenario, onCopySuccess }: JsonTabProps) {
return (
<div className="flex-1 flex flex-col border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] min-h-[400px]">
<div className="flex justify-between items-center border-b border-border/20 pb-3 mb-4">
<h2 className="text-body-lg text-primary font-bold">
Scenario JSON Code Output
</h2>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(
JSON.stringify(compiledScenario, null, 2),
);
onCopySuccess();
}}
className="h-8 text-xs cursor-pointer"
>
Copy to Clipboard
</Button>
</div>
</div>
<pre className="flex-1 bg-black border border-border/10 p-4 rounded overflow-auto font-mono text-xs text-emerald-400 select-text leading-relaxed">
{JSON.stringify(compiledScenario, null, 2)}
</pre>
</div>
);
}

View File

@@ -1,419 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { AttributeEditor } from "./AttributeEditor";
import { Plus, Trash2 } from "lucide-react";
import type { LocationData, ConnectionData, EntityData } from "./types";
import { getLocationDisplayName, getLocationDisplayNameById } from "./utils";
import { WorldMap } from "./WorldMap";
interface LocationsTabProps {
locations: LocationData[];
setLocations: (locs: LocationData[]) => void;
entities: EntityData[];
locationIds: string[];
entityIds: string[];
selectedLocIndex: number;
setSelectedLocIndex: (idx: number) => void;
generateUUID: () => string;
}
export function LocationsTab({
locations,
setLocations,
entities,
locationIds,
entityIds,
selectedLocIndex,
setSelectedLocIndex,
generateUUID,
}: LocationsTabProps) {
const addLocationConnection = (locIndex: number) => {
const copy = [...locations];
copy[locIndex].connections = [
...copy[locIndex].connections,
{
targetId:
locationIds.filter((id) => id !== locations[locIndex].id)[0] || "",
visionProp: 10,
soundProp: 10,
bidirectional: true,
},
];
setLocations(copy);
};
const updateLocationConnection = <K extends keyof ConnectionData>(
locIndex: number,
connIndex: number,
key: K,
val: ConnectionData[K],
) => {
const copy = [...locations];
copy[locIndex].connections[connIndex] = {
...copy[locIndex].connections[connIndex],
[key]: val,
};
setLocations(copy);
};
const removeLocationConnection = (locIndex: number, connIndex: number) => {
const copy = [...locations];
copy[locIndex].connections = copy[locIndex].connections.filter(
(_, i) => i !== connIndex,
);
setLocations(copy);
};
const selectedLoc = locations[selectedLocIndex];
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0 pb-12">
{/* Map Visualizer */}
{locations.length > 0 && (
<div className="md:col-span-4 w-full">
<WorldMap
locations={locations}
selectedLocId={selectedLoc?.id}
onSelectLocId={(id) => {
const idx = locations.findIndex((l) => l.id === id);
if (idx !== -1) setSelectedLocIndex(idx);
}}
/>
</div>
)}
{/* Left sidebar: Locations list */}
<div className="md:col-span-1 border border-border/20 bg-card shadow-[2px_2px_0_0_var(--border)] flex flex-col max-h-[500px]">
<div className="p-3 border-b border-border/25 flex justify-between items-center bg-secondary/15">
<strong className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
Locations
</strong>
<Button
type="button"
size="sm"
onClick={() => {
const newId = generateUUID();
setLocations([
...locations,
{ id: newId, attributes: [], connections: [] },
]);
setSelectedLocIndex(locations.length);
}}
className="h-6 text-[10px] px-2 flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add
</Button>
</div>
<div className="overflow-y-auto divide-y divide-border/10 flex-1">
{locations.map((loc, idx) => (
<div
key={idx}
onClick={() => setSelectedLocIndex(idx)}
className={`p-3 text-xs font-mono cursor-pointer flex justify-between items-center transition-all ${
selectedLocIndex === idx
? "bg-primary/10 text-primary font-bold border-l-4 border-primary"
: "hover:bg-secondary/40 text-foreground"
}`}
>
<span className="truncate">
{getLocationDisplayName(loc) || `(Empty ID)`}
</span>
{locations.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const newLocs = locations.filter((_, i) => i !== idx);
setLocations(newLocs);
setSelectedLocIndex(Math.max(0, idx - 1));
}}
className="text-muted-foreground hover:text-destructive transition-colors pl-2 cursor-pointer"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
))}
</div>
</div>
{/* Right panel wrapper */}
<div className="md:col-span-3 space-y-6">
{selectedLoc ? (
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic location fields */}
<div className="space-y-4">
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
Location Configuration
</h2>
<div className="space-y-1.5">
<Label>Location ID</Label>
<Input
value={selectedLoc.id}
readOnly
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
/>
</div>
<div className="space-y-1.5">
<Label>Parent Location (Optional)</Label>
<Combobox
items={locationIds.filter((id) => id !== selectedLoc.id)}
value={selectedLoc.parentId || ""}
onValueChange={(value) => {
const copy = [...locations];
copy[selectedLocIndex].parentId = value || undefined;
setLocations(copy);
}}
>
<ComboboxInput
placeholder="Select a parent location..."
showClear
/>
<ComboboxContent>
<ComboboxEmpty>No locations found.</ComboboxEmpty>
<ComboboxList>
{(id: string) => (
<ComboboxItem key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{/* Attributes for Location */}
<div className="pt-2">
<AttributeEditor
title="Location Attributes"
attributes={selectedLoc.attributes || []}
onChange={(newAttrs) => {
const copy = [...locations];
copy[selectedLocIndex].attributes = newAttrs;
setLocations(copy);
}}
onAdd={() => {
const copy = [...locations];
copy[selectedLocIndex].attributes = [
...copy[selectedLocIndex].attributes,
{
name: "",
value: "",
visibility: "PUBLIC",
allowedEntities: [],
},
];
setLocations(copy);
}}
entityIds={entityIds}
entities={entities}
/>
</div>
</div>
{/* Connections (spatial paths) */}
<div className="space-y-4 border-t lg:border-t-0 lg:border-l border-border/20 lg:pl-6 pt-4 lg:pt-0">
<div className="flex justify-between items-center border-b border-border/20 pb-2">
<h3 className="text-body-md text-foreground font-bold">
Connections / Portals
</h3>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => addLocationConnection(selectedLocIndex)}
className="h-7 text-xs flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add Connection
</Button>
</div>
{!selectedLoc.connections ||
selectedLoc.connections.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No connections leading from this location.
</p>
) : (
<div className="space-y-4 overflow-y-auto max-h-[400px] pr-1">
{selectedLoc.connections.map((conn, connIdx) => (
<div
key={connIdx}
className="border border-border/20 bg-secondary/10 p-3 rounded space-y-3 relative group"
>
<button
type="button"
onClick={() =>
removeLocationConnection(selectedLocIndex, connIdx)
}
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
title="Delete connection"
>
<Trash2 className="size-3.5" />
</button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 pr-6">
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground">
Target Location
</span>
<Combobox
items={locationIds.filter(
(id) => id !== selectedLoc.id,
)}
value={conn.targetId}
onValueChange={(value) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"targetId",
value ?? "",
)
}
>
<ComboboxInput
placeholder="Choose target..."
showClear
/>
<ComboboxContent>
<ComboboxEmpty>No locations found.</ComboboxEmpty>
<ComboboxList>
{(id: string) => (
<ComboboxItem key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground">
Portal Name
</span>
<Input
placeholder="e.g. wooden door"
value={conn.portalName || ""}
onChange={(e) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"portalName",
e.target.value,
)
}
className="h-7 text-xs"
/>
</div>
</div>
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground">
Portal State
</span>
<Input
placeholder="e.g. locked, closed, heavy iron gate"
value={conn.portalStateDescriptor || ""}
onChange={(e) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"portalStateDescriptor",
e.target.value,
)
}
className="h-7 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground block">
Vision Propagation ({conn.visionProp})
</span>
<input
type="range"
min="0"
max="10"
value={conn.visionProp}
onChange={(e) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"visionProp",
Number(e.target.value),
)
}
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground block">
Sound Propagation ({conn.soundProp})
</span>
<input
type="range"
min="0"
max="10"
value={conn.soundProp}
onChange={(e) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"soundProp",
Number(e.target.value),
)
}
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Label className="text-[11px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
<Checkbox
checked={conn.bidirectional}
onCheckedChange={(checked) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"bidirectional",
!!checked,
)
}
/>
Bidirectional Connection (creates reverse path
automatically)
</Label>
</div>
</div>
))}
</div>
)}
</div>
</div>
) : (
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] text-center py-12 text-xs text-muted-foreground">
No location selected. Choose a location from the sidebar to edit, or
view the world layout below.
</div>
)}
</div>
</div>
);
}

View File

@@ -1,163 +0,0 @@
"use client";
import { useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { AttributeEditor } from "./AttributeEditor";
import type { AttributeData, EntityData } from "./types";
interface MetadataTabProps {
scenarioId: string;
setScenarioId: (val: string) => void;
name: string;
setName: (val: string) => void;
description: string;
setDescription: (val: string) => void;
startTime: string;
setStartTime: (val: string) => void;
worldAttributes: AttributeData[];
setWorldAttributes: (attrs: AttributeData[]) => void;
entityIds: string[];
entities: EntityData[];
}
export function MetadataTab({
scenarioId,
name,
setName,
description,
setDescription,
startTime,
setStartTime,
worldAttributes,
setWorldAttributes,
entityIds,
entities,
}: MetadataTabProps) {
const addWorldAttribute = () => {
setWorldAttributes([
...worldAttributes,
{ name: "", value: "", visibility: "PUBLIC", allowedEntities: [] },
]);
};
// Parse initial state from ISO string (or fallback to now)
const parsedDate = useMemo(() => {
try {
const d = new Date(startTime);
if (isNaN(d.getTime())) return new Date();
return d;
} catch {
return new Date();
}
}, [startTime]);
const dateValue = useMemo(() => {
return parsedDate.toISOString().split("T")[0];
}, [parsedDate]);
const timeValue = useMemo(() => {
return parsedDate.toISOString().split("T")[1].slice(0, 8);
}, [parsedDate]);
const handleDateChange = (newDateStr: string) => {
if (!newDateStr) return;
const combined = `${newDateStr}T${timeValue}.000Z`;
setStartTime(combined);
};
const handleTimeChange = (newTimeStr: string) => {
if (!newTimeStr) return;
const formattedTime =
newTimeStr.split(":").length === 2 ? `${newTimeStr}:00` : newTimeStr;
const combined = `${dateValue}T${formattedTime}.000Z`;
setStartTime(combined);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-12">
{/* Basic Fields */}
<div className="lg:col-span-2 space-y-5 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
Scenario Metadata
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="sc-id">Scenario Template ID</Label>
<Input
id="sc-id"
value={scenarioId}
readOnly
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
/>
<span className="text-[10px] text-muted-foreground">
Unique filename ID. Alphanumeric, hyphens and underscores only.
</span>
</div>
<div className="space-y-1.5">
<Label>Start Time</Label>
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<Input
type="date"
value={dateValue}
onChange={(e) => handleDateChange(e.target.value)}
className="text-xs font-mono"
/>
</div>
<div className="flex-1 space-y-1">
<Input
type="time"
step="1"
value={timeValue}
onChange={(e) => handleTimeChange(e.target.value)}
className="text-xs font-mono"
/>
</div>
</div>
<span className="text-[10px] text-muted-foreground">
Global clock starting date and time (stored in ISO UTC).
</span>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="sc-name">Scenario Name</Label>
<Input
id="sc-name"
placeholder="e.g. The Quiet Tavern"
value={name}
onChange={(e) => setName(e.target.value)}
className="text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sc-desc">Description</Label>
<Textarea
id="sc-desc"
placeholder="Describe the starting setup..."
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs h-24"
/>
</div>
</div>
{/* World level Attributes */}
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<AttributeEditor
title="World Attributes"
attributes={worldAttributes}
onChange={setWorldAttributes}
onAdd={addWorldAttribute}
entityIds={entityIds}
entities={entities}
/>
</div>
</div>
);
}

View File

@@ -1,528 +0,0 @@
"use client";
import { useState, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
import type { LocationData } from "./types";
interface BoxNode {
id: string;
name: string;
width: number;
height: number;
x: number;
y: number;
children: BoxNode[];
location: LocationData;
}
interface WorldMapProps {
locations: LocationData[];
selectedLocId?: string;
onSelectLocId: (id: string) => void;
}
export function WorldMap({
locations,
selectedLocId,
onSelectLocId,
}: WorldMapProps) {
// Zoom and Pan States for Visualizer
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const panStartRef = useRef({ x: 0, y: 0 });
const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
if (e.button === 0) {
setIsPanning(true);
panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
}
};
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (isPanning) {
setPan({
x: e.clientX - panStartRef.current.x,
y: e.clientY - panStartRef.current.y,
});
}
};
const handleMouseUp = () => {
setIsPanning(false);
};
// Build tree and layout computation
const { roots, nodeMap } = useMemo(() => {
const nodeMap = new Map<string, BoxNode>();
// 1. Initialize all nodes
locations.forEach((loc) => {
const nameAttr = loc.attributes?.find(
(a) => a.name.toLowerCase() === "name",
)?.value;
const name = nameAttr ? nameAttr : loc.id;
nodeMap.set(loc.id, {
id: loc.id,
name,
width: 0,
height: 0,
x: 0,
y: 0,
children: [],
location: loc,
});
});
const roots: BoxNode[] = [];
// 2. Link parents and children
locations.forEach((loc) => {
const node = nodeMap.get(loc.id)!;
if (loc.parentId && nodeMap.has(loc.parentId)) {
nodeMap.get(loc.parentId)!.children.push(node);
} else {
roots.push(node);
}
});
// 3. Measure recursively
const measureNode = (node: BoxNode) => {
const textWidth = node.name.length * 7.2 + 24;
if (node.children.length === 0) {
node.width = Math.max(140, textWidth);
node.height = 70;
return;
}
node.children.forEach((child) => measureNode(child));
const cols = Math.ceil(Math.sqrt(node.children.length));
const rows = Math.ceil(node.children.length / cols);
const gap = 20;
const padLeft = 20;
const padRight = 20;
const padTop = 45;
const padBottom = 20;
const colWidths = new Array(cols).fill(0);
const rowHeights = new Array(rows).fill(0);
node.children.forEach((child, idx) => {
const col = idx % cols;
const row = Math.floor(idx / cols);
colWidths[col] = Math.max(colWidths[col], child.width);
rowHeights[row] = Math.max(rowHeights[row], child.height);
});
const totalGridWidth =
colWidths.reduce((sum, w) => sum + w, 0) + (cols - 1) * gap;
const totalGridHeight =
rowHeights.reduce((sum, h) => sum + h, 0) + (rows - 1) * gap;
node.width = Math.max(
180,
totalGridWidth + padLeft + padRight,
textWidth,
);
node.height = Math.max(100, totalGridHeight + padTop + padBottom);
let yOffset = padTop;
for (let r = 0; r < rows; r++) {
let xOffset = padLeft;
for (let c = 0; c < cols; c++) {
const idx = r * cols + c;
if (idx >= node.children.length) break;
const child = node.children[idx];
const cellWidth = colWidths[c];
const cellHeight = rowHeights[r];
child.x = xOffset + (cellWidth - child.width) / 2;
child.y = yOffset + (cellHeight - child.height) / 2;
xOffset += cellWidth + gap;
}
yOffset += rowHeights[r] + gap;
}
};
roots.forEach((root) => measureNode(root));
// 4. Position roots and assign global coordinates
const assignGlobalCoordinates = (
node: BoxNode,
parentX: number,
parentY: number,
) => {
node.x += parentX;
node.y += parentY;
node.children.forEach((child) => {
assignGlobalCoordinates(child, node.x, node.y);
});
};
const cols = Math.ceil(Math.sqrt(roots.length));
const gap = 40;
const colWidths = new Array(cols).fill(0);
const rowHeights: number[] = [];
roots.forEach((root, idx) => {
const col = idx % cols;
const row = Math.floor(idx / cols);
colWidths[col] = Math.max(colWidths[col], root.width);
if (rowHeights[row] === undefined) rowHeights[row] = 0;
rowHeights[row] = Math.max(rowHeights[row], root.height);
});
let yOffset = 40;
let maxWidth = 0;
for (let r = 0; r < rowHeights.length; r++) {
let xOffset = 40;
for (let c = 0; c < cols; c++) {
const idx = r * cols + c;
if (idx >= roots.length) break;
const root = roots[idx];
root.x = xOffset;
root.y = yOffset;
assignGlobalCoordinates(root, 0, 0);
xOffset += colWidths[c] + gap;
}
yOffset += rowHeights[r] + gap;
maxWidth = Math.max(maxWidth, xOffset);
}
return { roots, nodeMap };
}, [locations]);
// Connection lines computation
const connectionLines = useMemo(() => {
const lines: {
id: string;
x1: number;
y1: number;
x2: number;
y2: number;
portalName?: string;
portalState?: string;
vision?: number;
sound?: number;
bidirectional?: boolean;
sourceId: string;
targetId: string;
}[] = [];
const seen = new Set<string>();
locations.forEach((loc) => {
const nodeA = nodeMap.get(loc.id);
if (!nodeA) return;
loc.connections?.forEach((conn, cIdx) => {
const nodeB = nodeMap.get(conn.targetId);
if (!nodeB) return;
const isParentChild =
loc.parentId === conn.targetId || nodeB.location.parentId === loc.id;
if (isParentChild) return;
const sortedIds = [loc.id, conn.targetId].sort();
const key = conn.bidirectional
? `bidi-${sortedIds[0]}-${sortedIds[1]}`
: `uni-${loc.id}-${conn.targetId}`;
if (seen.has(key)) return;
seen.add(key);
const x1 = nodeA.x + nodeA.width / 2;
const y1 = nodeA.y + nodeA.height / 2;
const x2 = nodeB.x + nodeB.width / 2;
const y2 = nodeB.y + nodeB.height / 2;
lines.push({
id: `${loc.id}-${conn.targetId}-${cIdx}`,
x1,
y1,
x2,
y2,
portalName: conn.portalName,
portalState: conn.portalStateDescriptor,
vision: conn.visionProp,
sound: conn.soundProp,
bidirectional: conn.bidirectional,
sourceId: loc.id,
targetId: conn.targetId,
});
});
});
return lines;
}, [locations, nodeMap]);
// Recursive render helper for boxes
const renderNode = (node: BoxNode) => {
const isSelected = selectedLocId === node.id;
// Find parent connection details
const parentLoc = locations.find((l) => l.id === node.location.parentId);
const childToParentConn = node.location.parentId
? node.location.connections?.find(
(c) => c.targetId === node.location.parentId,
)
: undefined;
const parentToChildConn =
node.location.parentId && parentLoc
? parentLoc.connections?.find((c) => c.targetId === node.id)
: undefined;
const hasParentConn = !!(childToParentConn || parentToChildConn);
const portalName =
childToParentConn?.portalName || parentToChildConn?.portalName;
return (
<g key={node.id}>
{/* Box */}
<rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rx={8}
onClick={(e) => {
e.stopPropagation();
onSelectLocId(node.id);
}}
className={`transition-all cursor-pointer ${
isSelected
? "fill-primary/5 stroke-primary stroke-2"
: node.children.length > 0
? "fill-secondary/5 stroke-border/40 hover:stroke-border/80"
: "fill-secondary/20 stroke-border/40 hover:stroke-foreground/40 hover:fill-secondary/30"
}`}
style={{ strokeWidth: isSelected ? 2 : 1 }}
strokeDasharray={
hasParentConn
? "4, 4"
: node.children.length > 0
? "3, 3"
: undefined
}
/>
{/* Label */}
<text
x={node.x + 12}
y={node.y + 24}
className={`text-xs font-mono select-none font-semibold ${
isSelected ? "fill-primary font-bold" : "fill-muted-foreground"
}`}
>
{node.name}
</text>
{/* Portal Name on Boundary of Child */}
{hasParentConn && portalName && (
<g transform={`translate(${node.x + node.width / 2}, ${node.y})`}>
<rect
x={-((portalName.length * 5) / 2) - 4}
y={-6}
width={portalName.length * 5 + 8}
height={12}
rx={3}
className="fill-zinc-900 stroke stroke-border/40"
style={{ strokeWidth: 0.5 }}
/>
<text
textAnchor="middle"
y={3}
className="text-[8px] font-mono fill-primary font-semibold select-none"
>
{portalName}
</text>
</g>
)}
{/* Render children inside */}
{node.children.map((child) => renderNode(child))}
</g>
);
};
return (
<>
{/* Header */}
<div className="flex justify-between items-center pb-2">
{/* Controls */}
<div className="flex gap-1 bg-secondary/15 p-1 rounded border border-border/10">
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => setZoom((z) => Math.min(4, z + 0.1))}
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
title="Zoom In"
>
<ZoomIn className="size-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => setZoom((z) => Math.max(0.3, z - 0.1))}
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
title="Zoom Out"
>
<ZoomOut className="size-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}}
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
title="Reset view"
>
<RotateCcw className="size-4" />
</Button>
</div>
</div>
{/* Canvas container */}
<div className="relative border border-border/35 bg-zinc-950/95 rounded-lg overflow-hidden h-[450px]">
<svg
width="100%"
height="100%"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className={`w-full h-full select-none ${
isPanning ? "cursor-grabbing" : "cursor-grab"
}`}
>
<defs>
<pattern
id="grid-pattern"
width="40"
height="40"
patternUnits="userSpaceOnUse"
>
<path
d="M 40 0 L 0 0 0 40"
fill="none"
stroke="currentColor"
className="text-muted-foreground/10"
strokeWidth="1"
/>
</pattern>
</defs>
{/* Grid Background */}
<rect width="100%" height="100%" fill="url(#grid-pattern)" />
{/* Draggable/scalable group */}
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{/* Draw Connection Lines first so they are behind boxes */}
{connectionLines.map((line) => {
const isSourceSelected = selectedLocId === line.sourceId;
const isTargetSelected = selectedLocId === line.targetId;
const isHighlighted = isSourceSelected || isTargetSelected;
const mx = (line.x1 + line.x2) / 2;
const my = (line.y1 + line.y2) / 2;
const angle =
Math.atan2(line.y2 - line.y1, line.x2 - line.x1) *
(180 / Math.PI);
return (
<g key={line.id} className="transition-all">
{/* Glow path for highlighted connections */}
{isHighlighted && (
<line
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
className="stroke-primary/30 stroke-[4px] blur-sm animate-pulse"
/>
)}
{/* Main Connection Line */}
<line
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
className={`transition-all ${
isHighlighted
? "stroke-primary stroke-2"
: "stroke-border/40 stroke-1"
}`}
style={{
strokeDasharray: line.bidirectional ? undefined : "4, 4",
}}
/>
{/* Midpoint Direction Arrow for Uni-directional connections */}
{!line.bidirectional && (
<g transform={`translate(${mx}, ${my}) rotate(${angle})`}>
<path
d="M -4 -3.5 L 4 0 L -4 3.5 Z"
className={
isHighlighted
? "fill-primary"
: "fill-muted-foreground/60"
}
/>
</g>
)}
{/* Portal Name Label bubble */}
{line.portalName && (
<g
transform={`translate(${mx}, ${my - 12})`}
className="cursor-default"
>
<rect
x={-((line.portalName.length * 6) / 2) - 4}
y={-8}
width={line.portalName.length * 6 + 8}
height={16}
rx={4}
className="fill-background/90 stroke stroke-border/30"
style={{ strokeWidth: 0.5 }}
/>
<text
textAnchor="middle"
y={3}
className="text-[9px] font-mono fill-muted-foreground select-none"
>
{line.portalName}
</text>
</g>
)}
</g>
);
})}
{/* Draw Box Nodes recursively */}
{roots.map((root) => renderNode(root))}
</g>
</svg>
{/* Map Hint Overlay */}
<div className="absolute bottom-2 right-2 px-2 py-1 rounded bg-black/60 border border-border/20 text-[9.5px] text-muted-foreground font-mono pointer-events-none">
Drag to pan Scroll or buttons to zoom Click boxes to select
</div>
</div>
</>
);
}

View File

@@ -1,50 +0,0 @@
export interface AttributeData {
name: string;
value: string;
visibility: "PUBLIC" | "PRIVATE";
allowedEntities: string[];
}
export interface ConnectionData {
targetId: string;
portalName?: string;
portalStateDescriptor?: string;
visionProp: number;
soundProp: number;
bidirectional: boolean;
}
export interface LocationData {
id: string;
parentId?: string;
attributes: AttributeData[];
connections: ConnectionData[];
}
export interface MemoryData {
id: string;
timestamp: string;
locationId: string | null;
intent: {
type: "dialogue" | "action" | "monologue";
originalText: string;
description: string;
selfDescription?: string;
actorId: string;
targetIds: string[];
modifiers?: string[];
};
outcome?: {
isValid: boolean;
reason: string;
};
}
export interface EntityData {
id: string;
locationId?: string;
attributes: AttributeData[];
aliases: Record<string, string>;
initialMemories: MemoryData[];
isAgent?: boolean;
}

View File

@@ -1,45 +0,0 @@
import type { EntityData, LocationData } from "./types";
export function getEntityDisplayName(
entity: EntityData | undefined,
fallbackId: string = "",
): string {
if (!entity) return fallbackId;
const nameAttr = entity.attributes?.find(
(a) => a.name.toLowerCase() === "name",
);
if (nameAttr?.value) {
return `${nameAttr.value} (${entity.id})`;
}
return entity.id;
}
export function getEntityDisplayNameById(
id: string,
entities: EntityData[],
): string {
const entity = entities.find((e) => e.id === id);
return getEntityDisplayName(entity, id);
}
export function getLocationDisplayName(
location: LocationData | undefined,
fallbackId: string = "",
): string {
if (!location) return fallbackId;
const nameAttr = location.attributes?.find(
(a) => a.name.toLowerCase() === "name",
);
if (nameAttr?.value) {
return `${nameAttr.value} (${location.id})`;
}
return location.id;
}
export function getLocationDisplayNameById(
id: string,
locations: LocationData[],
): string {
const location = locations.find((l) => l.id === id);
return getLocationDisplayName(location, id);
}

View File

@@ -103,7 +103,7 @@ export function ConfigView() {
return (
<div className="flex-1 overflow-y-auto w-full relative">
<div className="relative z-10 mx-auto max-w-[800px] px-10 py-12">
<div className="relative z-10 mx-auto max-w-[1024px] px-10 py-12">
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">
Configuration
</h1>

View File

@@ -1,14 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
createProviderInstance,
updateProviderInstance,
setActiveProviderInstance,
regenerateEmbeddings,
deleteProviderInstance,
fetchAvailableModels,
fetchAvailableModelsForInstance,
} from "@/app/actions";
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
import type {
ModelInfo,
ModelProviderInstance,
ModelProviderMeta,
} from "@omnia/llm";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -22,6 +28,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Card,
CardHeader,
@@ -38,6 +52,17 @@ import {
} from "@/components/ui/item";
import { Empty, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
import { cn } from "@/lib/utils";
import { RefreshCwIcon } from "lucide-react";
const providerLogoMap: Record<string, string> = {
anthropic: "/claude_logo.webp",
"google-genai": "/gemini_logo.webp",
openrouter: "/openrouter_logo.webp",
ollama: "/ollama_logo.webp",
deepseek: "/deepseek_logo.webp",
openai: "/openai_logo.webp",
groq: "/groq_logo.webp",
};
interface ProviderInstancesConfigProps {
instances: ModelProviderInstance[];
@@ -64,9 +89,65 @@ export function ProviderInstancesConfig({
"generative",
);
const [editMaxContext, setEditMaxContext] = useState<number>(32768);
const [editEndpointUrl, setEditEndpointUrl] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Model listing state
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Fetch models for an existing saved instance
const fetchModelsForExistingInstance = async (instanceId: string) => {
setModelsLoading(true);
setAvailableModels([]);
try {
const models = await fetchAvailableModelsForInstance(instanceId);
setAvailableModels(models);
} catch {
setAvailableModels([]);
} finally {
setModelsLoading(false);
}
};
// Fetch models for a new instance (using live key/endpoint from form)
const fetchModelsForNewInstance = (
provider: string,
apiKey: string,
endpointUrl: string,
) => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
const isOllama = provider === "ollama";
const hasCredentials = isOllama
? endpointUrl.trim().length > 0
: apiKey.trim().length > 8; // don't spam API with partial keys
if (!hasCredentials) {
setAvailableModels([]);
return;
}
debounceTimer.current = setTimeout(async () => {
setModelsLoading(true);
setAvailableModels([]);
try {
const models = await fetchAvailableModels(
provider,
isOllama ? "none" : apiKey,
isOllama ? endpointUrl : undefined,
);
setAvailableModels(models);
} catch {
setAvailableModels([]);
} finally {
setModelsLoading(false);
}
}, 600);
};
useEffect(() => {
if (selectedInstanceId === null) {
setEditName("");
@@ -76,6 +157,8 @@ export function ProviderInstancesConfig({
setEditIsActive(false);
setEditType("generative");
setEditMaxContext(32768);
setEditEndpointUrl("");
setAvailableModels([]);
} else if (selectedInstanceId === "new") {
setEditName("");
const defaultProvider = "google-genai";
@@ -86,6 +169,8 @@ export function ProviderInstancesConfig({
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
setEditIsActive(false);
setEditMaxContext(32768);
setEditEndpointUrl("");
setAvailableModels([]);
} else {
const inst = instances.find((i) => i.id === selectedInstanceId);
if (inst) {
@@ -109,10 +194,22 @@ export function ProviderInstancesConfig({
? inst.maxContext
: 32768,
);
setEditEndpointUrl(inst.endpointUrl || "");
setAvailableModels([]);
// Auto-fetch models for existing instances
fetchModelsForExistingInstance(selectedInstanceId);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInstanceId, instances, availableProviders]);
// Re-fetch models when provider/key/endpoint changes on new instance form
useEffect(() => {
if (selectedInstanceId !== "new") return;
fetchModelsForNewInstance(editProvider, editKey, editEndpointUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editProvider, editKey, editEndpointUrl, selectedInstanceId]);
const handleProviderChange = (providerId: string | null) => {
if (!providerId) return;
setEditProvider(providerId);
@@ -122,6 +219,7 @@ export function ProviderInstancesConfig({
? pMeta?.defaultEmbeddingModel || ""
: pMeta?.defaultModel || "",
);
setAvailableModels([]);
};
const handleTypeChange = (type: "generative" | "embedding") => {
@@ -134,6 +232,14 @@ export function ProviderInstancesConfig({
);
};
const handleRefreshModels = () => {
if (selectedInstanceId && selectedInstanceId !== "new") {
fetchModelsForExistingInstance(selectedInstanceId);
} else {
fetchModelsForNewInstance(editProvider, editKey, editEndpointUrl);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!editName.trim()) {
@@ -149,7 +255,7 @@ export function ProviderInstancesConfig({
let targetInstanceId = selectedInstanceId;
if (selectedInstanceId === "new") {
if (!editKey.trim()) {
if (editProvider !== "ollama" && !editKey.trim()) {
setError("API Key is required for new instances.");
setLoading(false);
return;
@@ -157,10 +263,13 @@ export function ProviderInstancesConfig({
const created = await createProviderInstance(
editName,
editProvider,
editKey,
editProvider === "ollama" ? "none" : editKey,
editModel || undefined,
editType,
editType === "generative" ? editMaxContext : 0,
editProvider === "ollama"
? editEndpointUrl || "http://localhost:11434"
: undefined,
);
if (editIsActive) {
await setActiveProviderInstance(created.id);
@@ -194,10 +303,13 @@ export function ProviderInstancesConfig({
selectedInstanceId,
editName,
editProvider,
editKey || undefined,
editProvider === "ollama" ? "none" : editKey || undefined,
editModel || undefined,
editType,
editType === "generative" ? editMaxContext : 0,
editProvider === "ollama"
? editEndpointUrl || "http://localhost:11434"
: undefined,
);
if (editIsActive) {
await setActiveProviderInstance(selectedInstanceId);
@@ -235,7 +347,7 @@ export function ProviderInstancesConfig({
};
return (
<section className="mb-8">
<section className="mb-8 flex min-h-[600px] flex-col">
{error && (
<div className="mb-4 rounded border-2 border-red-500 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
@@ -243,7 +355,7 @@ export function ProviderInstancesConfig({
)}
<div
className={cn(
"mt-4 grid min-h-[400px] grid-cols-1 gap-4 md:grid-cols-[30%_70%]",
"mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-[30%_70%]",
loading && "pointer-events-none opacity-60",
"transition-opacity duration-200",
)}
@@ -277,14 +389,22 @@ export function ProviderInstancesConfig({
)}
onClick={() => setSelectedInstanceId(inst.id)}
>
<ItemContent>
<ItemTitle>{inst.name}</ItemTitle>
<ItemDescription>{inst.providerName}</ItemDescription>
<div className="flex flex-row gap-1.5">
{inst.isActive && <Badge>Active</Badge>}
<Badge variant="outline">
{inst.type === "generative" ? "gen" : "embed"}
</Badge>
<ItemContent className="flex-row items-center gap-3">
{providerLogoMap[inst.providerName] && (
<img
src={providerLogoMap[inst.providerName]}
alt={inst.providerName}
className="max-h-[38px] max-w-[38px] shrink-0 rounded-sm object-contain"
/>
)}
<div className="flex flex-col gap-1">
<ItemTitle>{inst.name}</ItemTitle>
<div className="flex flex-row gap-1.5">
{inst.isActive && <Badge>Active</Badge>}
<Badge variant="outline">
{inst.type === "generative" ? "gen" : "embed"}
</Badge>
</div>
</div>
</ItemContent>
</Item>
@@ -329,6 +449,7 @@ export function ProviderInstancesConfig({
<div className="grid grid-cols-[2fr_3fr] gap-4">
<div className="flex flex-col gap-1.5">
<Label>Instance Type</Label>
{/* TODO: Change this to choice card */}
<Select
value={editType}
onValueChange={(v) =>
@@ -336,11 +457,11 @@ export function ProviderInstancesConfig({
}
items={[
{
label: "Generative (Text Completion)",
label: "Generative (Text Generation)",
value: "generative",
},
{
label: "Embedding (Vector generation)",
label: "Embedding (Vector Embeddings)",
value: "embedding",
},
]}
@@ -351,10 +472,10 @@ export function ProviderInstancesConfig({
<SelectContent>
<SelectGroup>
<SelectItem value="generative">
Generative (Chat / Text Completion)
Generative (Text Generation)
</SelectItem>
<SelectItem value="embedding">
Embedding (Vector generation)
Embedding (Vector Embeddings)
</SelectItem>
</SelectGroup>
</SelectContent>
@@ -398,30 +519,109 @@ export function ProviderInstancesConfig({
</span>
)}
<div className="flex flex-col gap-1.5">
<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>
{editProvider !== "ollama" && (
<div className="flex flex-col gap-1.5">
<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>
)}
{editProvider === "ollama" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="formEndpoint">Endpoint URL</Label>
<Input
id="formEndpoint"
value={editEndpointUrl}
onChange={(e) => setEditEndpointUrl(e.target.value)}
placeholder="e.g. http://localhost:11434"
required
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="formModel">Model Name</Label>
<Input
id="formModel"
<div className="flex items-center justify-between">
<Label htmlFor="formModel">Model</Label>
<button
type="button"
onClick={handleRefreshModels}
disabled={modelsLoading}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
title="Refresh model list"
>
<RefreshCwIcon
className={cn(
"size-3",
modelsLoading && "animate-spin",
)}
/>
{modelsLoading
? "Fetching…"
: availableModels.length > 0
? `${availableModels.length} models`
: "Fetch models"}
</button>
</div>
<Combobox
value={editModel}
onChange={(e) => setEditModel(e.target.value)}
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
/>
onValueChange={(v) => {
if (v) setEditModel(v);
}}
items={availableModels.map((m) => m.id)}
>
<ComboboxInput
id="formModel"
placeholder={
modelsLoading
? "Fetching models…"
: availableModels.length > 0
? "Search or select a model…"
: "e.g. gemini-2.5-flash"
}
disabled={modelsLoading}
showClear={false}
value={editModel}
onChange={(e) =>
setEditModel((e.target as HTMLInputElement).value)
}
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
{modelsLoading
? "Fetching models…"
: "No models found. Type a custom model name above."}
</ComboboxEmpty>
<ComboboxList>
{(modelId: string) => {
const model = availableModels.find(
(m) => m.id === modelId,
);
return (
<ComboboxItem key={modelId} value={modelId}>
<span className="flex-1 truncate">{modelId}</span>
{model?.ownedBy && (
<span className="ml-2 shrink-0 text-xs text-muted-foreground">
{model.ownedBy}
</span>
)}
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{editType === "generative" && (

View File

@@ -33,8 +33,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
export function DashboardView() {
export function HomeView() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [loadingData, setLoadingData] = useState(true);
@@ -59,6 +60,7 @@ export function DashboardView() {
const [loadingEntities, setLoadingEntities] = useState(false);
const [selectedEntityForModal, setSelectedEntityForModal] =
useState<string>("");
const [customName, setCustomName] = useState<string>("");
const loadSavedSessions = useCallback(async () => {
try {
@@ -144,6 +146,7 @@ export function DashboardView() {
setScenarioForModal(scenario);
setLoadingEntities(true);
setSelectedEntityForModal(""); // Reset selection to Spectator
setCustomName(scenario.name); // Set custom simulation name default
try {
const res = await getScenarioEntities(scenario.path);
if (res.ok) {
@@ -168,6 +171,7 @@ export function DashboardView() {
const result = await startSimulation({
scenario: targetScenario.path,
playEntity: selectedEntityForModal || undefined,
customName: customName.trim() || undefined,
});
if (!result.ok) {
@@ -188,7 +192,7 @@ export function DashboardView() {
return (
<div className="flex-1 overflow-y-auto w-full relative">
<div className="relative z-10 mx-auto max-w-[800px] px-10 py-12">
<div className="relative z-10 mx-auto max-w-[1024px] px-10 py-12">
<div className="animate-fade-in">
{/* Centered Big Logo */}
<div className="flex flex-col items-center justify-center mb-10 pt-4">
@@ -394,6 +398,16 @@ export function DashboardView() {
</div>
) : (
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Custom Simulation Name
</label>
<Input
value={customName}
onChange={(e) => setCustomName(e.target.value)}
placeholder="Enter custom name..."
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Simulation Mode / Play as

View File

@@ -0,0 +1,218 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import type { SimSnapshot } from "@/lib/simulation-types";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
function IntentTag({
intent,
isSelf,
}: {
intent: SimSnapshot["log"][number]["intents"][number];
isSelf?: boolean;
}) {
const labels: Record<string, string> = {
monologue: "thought",
dialogue: "dialogue",
action: "action",
};
const label = labels[intent.type] || intent.type;
let outcome = "";
if (intent.type === "action") {
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
}
const textToDisplay =
isSelf && intent.selfDescription
? intent.selfDescription
: intent.description;
const modifiersStr =
intent.modifiers && intent.modifiers.length > 0 ? (
<span className="italic opacity-80 text-muted-foreground ml-1">
({intent.modifiers.join(", ")})
</span>
) : null;
return (
<span className="text-sm text-muted-foreground">
[{label}] &ldquo;{textToDisplay}&rdquo;{modifiersStr}
{outcome}
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
</span>
);
}
function formatSimTime(isoString: string) {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const hh = String(d.getUTCHours()).padStart(2, "0");
const min = String(d.getUTCMinutes()).padStart(2, "0");
const ss = String(d.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
} catch {
return isoString;
}
}
function LogEntryCard({
entry,
onShowPrompt,
isPlayerCard,
}: {
entry: SimSnapshot["log"][number];
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
isPlayerCard: boolean;
}) {
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
return (
<div
className={cn(
"border p-4 shadow-[2px_2px_0_0_var(--border)]",
isPlayerCard
? "border-primary bg-surface-container-low"
: "border-border/30 bg-card",
)}
>
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
<div className="flex items-center gap-2">
<strong className="text-body-md font-bold text-foreground">
{entry.entityName}
</strong>
<span className="text-xs text-muted-foreground font-mono">
Turn {entry.turn} &middot; {formatSimTime(entry.timestamp)}
</span>
</div>
{showMenu && (
<Button
variant="ghost"
size="icon"
onClick={() => onShowPrompt(entry)}
title="View Raw Prompts & Token Usage"
>
</Button>
)}
</div>
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
{entry.narrativeProse}
</div>
<div className="flex flex-col gap-1.5 mt-2 border-t border-dotted border-border/10 pt-2">
{entry.intents.map((intent, i) => (
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
))}
</div>
</div>
);
}
interface InteractViewProps {
snapshot: SimSnapshot;
loading: boolean;
statusText: string;
playerInput: string;
setPlayerInput: (value: string) => void;
onSubmitAction: (e: React.FormEvent<HTMLFormElement>) => void;
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
logEndRef: React.RefObject<HTMLDivElement | null>;
}
export function InteractView({
snapshot,
loading,
statusText,
playerInput,
setPlayerInput,
onSubmitAction,
onShowPrompt,
logEndRef,
}: InteractViewProps) {
const router = useRouter();
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
return (
<>
{/* Scrollable Center Viewport */}
<main className="flex-1 overflow-y-auto px-8 py-6">
<div className="flex flex-col gap-4 max-w-[800px] mx-auto pb-12">
{snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={onShowPrompt}
isPlayerCard={entry.entityId === playerEntity?.id}
/>
))}
{loading && (
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
<Spinner />
{statusText || "Processing..."}
</div>
)}
<div ref={logEndRef} />
</div>
</main>
{/* Sticky Chat / Interaction Input Footer */}
<footer className="sticky bottom-0 bg-background/95 backdrop-blur-xs border-t border-dotted border-border/20 px-8 py-4 z-10 shrink-0">
<div className="max-w-[800px] mx-auto">
{snapshot.status === "waiting_player" && snapshot.waitingEntity ? (
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
<strong>Your context as {snapshot.waitingEntity.name}</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
<form onSubmit={onSubmitAction} className="flex flex-col gap-2">
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
disabled={loading}
/>
<Button type="submit" disabled={loading || !playerInput.trim()}>
{loading ? "Processing..." : "Submit Action"}
</Button>
</form>
</div>
) : snapshot.status === "done" || snapshot.status === "error" ? (
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
<span className="text-sm font-mono text-muted-foreground">
{snapshot.status === "error"
? "Simulation finished with an error."
: "Simulation complete."}
</span>
<Button
onClick={() => {
router.push("/");
}}
size="sm"
>
{snapshot.status === "error"
? "Back to Dashboard"
: "New Simulation"}
</Button>
</div>
) : null}
</div>
</footer>
</>
);
}

View File

@@ -0,0 +1,176 @@
"use client";
import * as React from "react";
import { useState } from "react";
import type { SimSnapshot } from "@/lib/simulation-types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { renameSimulation } from "@/app/actions";
interface ManageViewProps {
snapshot: SimSnapshot;
onRename: (updated: SimSnapshot) => void;
}
export function ManageView({ snapshot, onRename }: ManageViewProps) {
const [isEditingName, setIsEditingName] = useState(false);
const [editedName, setEditedName] = useState(snapshot.scenarioName);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
React.useEffect(() => {
setEditedName(snapshot.scenarioName);
}, [snapshot.scenarioName]);
const handleSaveName = async () => {
if (!editedName.trim()) return;
setSaving(true);
setError("");
try {
const res = await renameSimulation(snapshot.id, editedName.trim());
if (res.ok) {
onRename(res.snapshot);
setIsEditingName(false);
} else {
setError(res.error);
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to rename simulation",
);
} finally {
setSaving(false);
}
};
return (
<main className="flex-1 overflow-y-auto px-8 py-6">
<div className="max-w-[800px] mx-auto space-y-6 pb-12">
{/* Simulation Info */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Simulation Info
</h3>
{error && (
<div className="mb-4 border border-destructive bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-mono">
<div className="flex flex-col gap-1 border-b border-border/10 pb-2 md:col-span-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Simulation Name
</span>
{isEditingName ? (
<div className="flex items-center gap-2 mt-1">
<Input
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
className="h-8 max-w-sm font-sans"
disabled={saving}
/>
<Button size="sm" onClick={handleSaveName} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setIsEditingName(false)}
disabled={saving}
>
Cancel
</Button>
</div>
) : (
<div className="flex items-center gap-3">
<span className="text-foreground font-bold text-base font-head">
{snapshot.scenarioName}
</span>
<Button
size="sm"
variant="outline"
className="h-6 text-[10px]"
onClick={() => {
setEditedName(snapshot.scenarioName);
setIsEditingName(true);
}}
>
Rename
</Button>
</div>
)}
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Session ID
</span>
<span className="text-foreground font-bold break-all">
{snapshot.id}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Max Turns
</span>
<span className="text-foreground font-bold font-mono">
{snapshot.maxTurns}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Turn Count
</span>
<span className="text-foreground font-bold font-mono">
{snapshot.turn}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Entities Registered
</span>
<span className="text-foreground font-bold font-mono">
{snapshot.entities.length}
</span>
</div>
</div>
</div>
{/* Entities Involved */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Entities Involved
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{snapshot.entities.map((ent) => (
<div
key={ent.id}
className="border border-border/20 bg-secondary/20 p-4 shadow-[1px_1px_0_0_var(--border)] flex justify-between items-center"
>
<div>
<strong className="text-sm text-foreground block font-head tracking-wide">
{ent.name}
</strong>
<span className="text-xs text-muted-foreground font-mono block mt-1">
ID: {ent.id}
</span>
</div>
<div className="flex items-center gap-2">
{ent.isPlayer ? (
<span className="bg-primary/20 text-primary border border-primary/30 px-2 py-0.5 text-xs font-mono">
PLAYER
</span>
) : (
<span className="bg-secondary/60 text-muted-foreground border border-border/20 px-2 py-0.5 text-xs font-mono">
NPC
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
</main>
);
}

View File

@@ -9,9 +9,10 @@ import {
} from "@/app/actions";
import type { SimSnapshot } from "@/lib/simulation-types";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { PromptModal } from "./PromptModal";
import { InteractView } from "./InteractView";
import { ManageView } from "./ManageView";
import { cn } from "@/lib/utils";
import { ChevronLeft } from "lucide-react";
import {
@@ -22,114 +23,6 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
function IntentTag({
intent,
isSelf,
}: {
intent: SimSnapshot["log"][number]["intents"][number];
isSelf?: boolean;
}) {
const labels: Record<string, string> = {
monologue: "thought",
dialogue: "dialogue",
action: "action",
};
const label = labels[intent.type] || intent.type;
let outcome = "";
if (intent.type === "action") {
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
}
const textToDisplay =
isSelf && intent.selfDescription
? intent.selfDescription
: intent.description;
const modifiersStr =
intent.modifiers && intent.modifiers.length > 0 ? (
<span className="italic opacity-80 text-muted-foreground ml-1">
({intent.modifiers.join(", ")})
</span>
) : null;
return (
<span className="text-sm text-muted-foreground">
[{label}] &ldquo;{textToDisplay}&rdquo;{modifiersStr}
{outcome}
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
</span>
);
}
function formatSimTime(isoString: string) {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const hh = String(d.getUTCHours()).padStart(2, "0");
const min = String(d.getUTCMinutes()).padStart(2, "0");
const ss = String(d.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
} catch {
return isoString;
}
}
function LogEntryCard({
entry,
onShowPrompt,
isPlayerCard,
}: {
entry: SimSnapshot["log"][number];
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
isPlayerCard: boolean;
}) {
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
return (
<div
className={cn(
"border p-4 shadow-[2px_2px_0_0_var(--border)]",
isPlayerCard
? "border-primary bg-surface-container-low"
: "border-border/30 bg-card",
)}
>
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
<div className="flex items-center gap-2">
<strong className="text-body-md font-bold text-foreground">
{entry.entityName}
</strong>
<span className="text-xs text-muted-foreground font-mono">
Turn {entry.turn} &middot; {formatSimTime(entry.timestamp)}
</span>
</div>
{showMenu && (
<Button
variant="ghost"
size="icon"
onClick={() => onShowPrompt(entry)}
title="View Raw Prompts & Token Usage"
>
</Button>
)}
</div>
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
{entry.narrativeProse}
</div>
<div className="flex flex-col gap-1.5 mt-2 border-t border-dotted border-border/10 pt-2">
{entry.intents.map((intent, i) => (
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
))}
</div>
</div>
);
}
function MobileSidebarClose() {
const { isMobile, setOpenMobile } = useSidebar();
if (!isMobile) return null;
@@ -482,171 +375,19 @@ export function PlayView() {
)}
</header>
{/* Scrollable Center Viewport */}
<main className="flex-1 overflow-y-auto px-8 py-6">
{activeTab === "interact" ? (
<div className="flex flex-col gap-4 max-w-[800px] mx-auto pb-12">
{(() => {
const playerEntity = snapshot.entities.find(
(e) => e.isPlayer,
);
return snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={setSelectedEntryForModal}
isPlayerCard={entry.entityId === playerEntity?.id}
/>
));
})()}
{loading && (
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
<Spinner />
{statusText || "Processing..."}
</div>
)}
<div ref={logEndRef} />
</div>
) : (
<div className="max-w-[800px] mx-auto space-y-6 pb-12">
{/* Simulation Info */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Simulation Info
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-mono">
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Session ID
</span>
<span className="text-foreground font-bold break-all">
{snapshot.id}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Max Turns
</span>
<span className="text-foreground font-bold">
{snapshot.maxTurns}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Turn Count
</span>
<span className="text-foreground font-bold">
{snapshot.turn}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Entities Registered
</span>
<span className="text-foreground font-bold">
{snapshot.entities.length}
</span>
</div>
</div>
</div>
{/* Entities Involved */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Entities Involved
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{snapshot.entities.map((ent) => (
<div
key={ent.id}
className="border border-border/20 bg-secondary/20 p-4 shadow-[1px_1px_0_0_var(--border)] flex justify-between items-center"
>
<div>
<strong className="text-sm text-foreground block font-head tracking-wide">
{ent.name}
</strong>
<span className="text-xs text-muted-foreground font-mono block mt-1">
ID: {ent.id}
</span>
</div>
<div className="flex items-center gap-2">
{ent.isPlayer ? (
<span className="bg-primary/20 text-primary border border-primary/30 px-2 py-0.5 text-xs font-mono">
PLAYER
</span>
) : (
<span className="bg-secondary/60 text-muted-foreground border border-border/20 px-2 py-0.5 text-xs font-mono">
NPC
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
</main>
{/* Sticky Chat / Interaction Input Footer */}
{activeTab === "interact" && (
<footer className="sticky bottom-0 bg-background/95 backdrop-blur-xs border-t border-dotted border-border/20 px-8 py-4 z-10 shrink-0">
<div className="max-w-[800px] mx-auto">
{snapshot.status === "waiting_player" &&
snapshot.waitingEntity ? (
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
<strong>
Your context as {snapshot.waitingEntity.name}
</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
<form
onSubmit={handleSubmitAction}
className="flex flex-col gap-2"
>
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
disabled={loading}
/>
<Button
type="submit"
disabled={loading || !playerInput.trim()}
>
{loading ? "Processing..." : "Submit Action"}
</Button>
</form>
</div>
) : snapshot.status === "done" ||
snapshot.status === "error" ? (
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
<span className="text-sm font-mono text-muted-foreground">
{snapshot.status === "error"
? "Simulation finished with an error."
: "Simulation complete."}
</span>
<Button
onClick={() => {
router.push("/");
}}
size="sm"
>
{snapshot.status === "error"
? "Back to Dashboard"
: "New Simulation"}
</Button>
</div>
) : null}
</div>
</footer>
{activeTab === "interact" ? (
<InteractView
snapshot={snapshot}
loading={loading}
statusText={statusText}
playerInput={playerInput}
setPlayerInput={setPlayerInput}
onSubmitAction={handleSubmitAction}
onShowPrompt={setSelectedEntryForModal}
logEndRef={logEndRef}
/>
) : (
<ManageView snapshot={snapshot} onRename={setSnapshot} />
)}
</div>

View File

@@ -5,6 +5,7 @@ import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
@@ -67,16 +68,13 @@ function ComboboxInput({
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<ComboboxTrigger
render={
<InputGroupButton
size="icon-xs"
variant="ghost"
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
}
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
@@ -113,7 +111,7 @@ function ComboboxContent({
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"cn-menu-target cn-menu-translucent group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+var(--spacing-7,1.75rem))] origin-(--transform-origin) overflow-hidden rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
@@ -128,7 +126,7 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
"no-scrollbar max-h-72 scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className,
)}
{...props}
@@ -246,7 +244,7 @@ function ComboboxChip({
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm border-2 bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
"flex h-[calc(var(--spacing,0.25rem)*5.25)] w-fit items-center justify-center gap-1 rounded-sm border-2 bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className,
)}
{...props}

View File

@@ -1,200 +0,0 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -14,21 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]",
"h-9 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded border-2 bg-input shadow-sm transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:outline-2 has-[[data-slot=input-group-control]:focus-visible]:outline-offset-2 has-[[data-slot=input-group-control]:focus-visible]:outline-primary has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
@@ -37,18 +23,18 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
@@ -84,10 +70,10 @@ const inputGroupButtonVariants = cva(
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
@@ -102,9 +88,25 @@ function InputGroupButton({
type = "button",
variant = "ghost",
size = "xs",
render,
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset";
render?: React.ReactElement;
}) {
if (render) {
return React.cloneElement(render, {
className: cn(
inputGroupButtonVariants({ size }),
(render.props as any)?.className,
className,
),
type,
...props,
} as any);
}
return (
<Button
type={type}
@@ -120,7 +122,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -136,7 +138,7 @@ function InputGroupInput({
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
"flex-1 rounded-none border-0 bg-transparent shadow-none outline-none focus-visible:outline-none disabled:bg-transparent aria-invalid:outline-none dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
@@ -152,7 +154,7 @@ function InputGroupTextarea({
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none outline-none focus-visible:outline-none disabled:bg-transparent aria-invalid:outline-none dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}

View File

@@ -1,292 +0,0 @@
"use client";
import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar";
import { CheckIcon, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
return (
<MenubarPrimitive
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded border-2 bg-background p-[3px] shadow-md",
className,
)}
{...props}
/>
);
}
function MenubarMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
return (
<MenuPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-none select-none hover:bg-primary/15 hover:text-primary aria-expanded:bg-primary/15 aria-expanded:text-primary cursor-pointer",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: MenuPrimitive.Popup.Props & {
align?: MenuPrimitive.Positioner.Props["align"];
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
}) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="menubar-content"
className={cn(
"min-w-36 rounded border-2 bg-popover p-1 text-popover-foreground shadow-md duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item gap-1.5 rounded-sm px-2 py-1.5 text-sm focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary not-data-[variant=destructive]:focus:**:text-primary not-data-[variant=destructive]:data-highlighted:**:text-primary data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-sm py-1.5 pr-2 pl-7 text-sm outline-none select-none focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary focus:**:text-primary data-highlighted:**:text-primary data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function MenubarRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-sm py-1.5 pr-2 pl-7 text-sm outline-none select-none focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary focus:**:text-primary data-highlighted:**:text-primary data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
className,
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-primary group-data-highlighted/menubar-item:text-primary",
className,
)}
{...props}
/>
);
}
// Submenu components using SubmenuRoot and SubmenuTrigger
function MenubarSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default select-none items-center gap-1.5 rounded-sm px-2 py-1.5 text-sm focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary data-inset:pl-7 data-open:bg-primary/15 data-open:text-primary data-highlighted:text-primary [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto size-4" />
</MenuPrimitive.SubmenuTrigger>
);
}
function MenubarSubContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: MenuPrimitive.Popup.Props & {
align?: MenuPrimitive.Positioner.Props["align"];
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
}) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="menubar-sub-content"
className={cn(
"min-w-32 rounded border-2 bg-popover p-1 text-popover-foreground shadow-md duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
import { HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
import type { SimSession } from "./types";
/**
* Runs the HandoffEngine for every agent entity that has accumulated enough
* buffer entries to warrant a handoff (compression to long-term memory).
*/
export async function runHandoffResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const handoffEngine = new HandoffEngine(
session.handoffProvider,
session.embeddingProvider,
session.bufferRepo,
session.ledgerRepo,
);
const entities = Array.from(worldState.entities.values());
for (const entity of entities) {
if (!entity.isAgent) continue;
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
const maxContext =
session.handoffProvider.maxContext !== undefined
? session.handoffProvider.maxContext
: 32768;
const trigger = checkHandoffTrigger(
entity,
bufferEntries,
worldState.clock.get(),
maxContext,
);
if (trigger !== "none") {
await handoffEngine.runHandoff(
entity,
bufferEntries,
worldState.clock.get(),
);
}
}
}
/**
* For every agent that shares a location with another entity they haven't
* previously encountered, generates a first-person alias description and
* persists it on the viewing entity.
*/
export async function 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.isAgent) continue;
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);
}
}
}
}

View File

@@ -0,0 +1,16 @@
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
// 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;
}
}

View File

@@ -0,0 +1,17 @@
/**
* Barrel entry point for the simulation module.
*
* Consumers import from "@/lib/simulation" exactly as before — no import
* paths need to change anywhere in the codebase.
*/
import { SimulationManager } from "./simulation-manager";
export const simulationManager = new SimulationManager();
export type {
SimSnapshot,
EntityInfo,
LogEntry,
IntentInfo,
WaitingContext,
} from "../simulation-types";

View File

@@ -0,0 +1,146 @@
import {
MockLLMProvider,
MockEmbeddingProvider,
ProviderManager,
buildLLMProvider,
buildEmbeddingProvider,
} from "@omnia/llm";
import type {
ILLMProvider,
IEmbeddingProvider,
ModelProviderInstance,
} from "@omnia/llm";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface ResolvedProviders {
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
}
export interface ProviderResolverOptions {
/**
* Pre-resolved generative instance to fall back to when ProviderManager has
* no active generative provider (e.g. when the caller already validated a
* specific provider during session creation).
*/
fallbackInstance?: ModelProviderInstance | null;
/**
* When true, throws an Error if no provider can be resolved for a task.
* When false (default), falls back silently to MockLLMProvider / MockEmbeddingProvider.
*/
required?: boolean;
}
// ---------------------------------------------------------------------------
// Resolution logic
// ---------------------------------------------------------------------------
/**
// Public API
// ---------------------------------------------------------------------------
/**
* Resolves all six LLM + embedding providers needed for a simulation session.
*
* Resolution order for each generative task:
* 1. Task-specific mapping from ProviderManager (via `mappings[task]`)
* 2. ProviderManager active generative instance
* 3. `fallbackInstance` (if supplied)
* 4. GOOGLE_API_KEY env var → auto-creates a temporary GeminiProvider
* 5. Throws (if `required`) or returns MockLLMProvider
*/
export function resolveProviders(
mappings: Record<string, string>,
options: ProviderResolverOptions = {},
): ResolvedProviders {
const { fallbackInstance = null, required = false } = options;
const list = ProviderManager.list();
const activeGenerative =
ProviderManager.getActive("generative") ?? fallbackInstance ?? null;
const resolveGenerative = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst: ModelProviderInstance | null = mappedId
? (list.find((p) => p.id === mappedId) ?? null)
: null;
if (!inst || inst.type !== "generative") {
inst = activeGenerative;
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create(
"Default (Env)",
"google-genai",
envKey,
undefined,
"generative",
);
}
}
if (!inst) {
if (required) {
throw new Error(
`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`,
);
}
return new MockLLMProvider([]);
}
return buildLLMProvider(inst);
};
const resolveEmbedding = (): IEmbeddingProvider => {
const mappedId = mappings["embeddings"];
let inst: ModelProviderInstance | null = mappedId
? (list.find((p) => p.id === mappedId) ?? null)
: null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create(
"Default Embed (Env)",
"google-genai",
envKey,
"gemini-embedding-001",
"embedding",
);
}
}
if (!inst) {
if (required) {
throw new Error(
`No active Embedding Provider Instance found. Please configure an embedding key in Settings first.`,
);
}
return new MockEmbeddingProvider(undefined);
}
return buildEmbeddingProvider(inst);
};
return {
actorProvider: resolveGenerative("actor-prose"),
validatorProvider: resolveGenerative("llm-validator"),
decoderProvider: resolveGenerative("intent-decoder"),
timedeltaProvider: resolveGenerative("timedelta"),
handoffProvider: resolveGenerative("handoff"),
embeddingProvider: resolveEmbedding(),
};
}

View File

@@ -0,0 +1,139 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import type { SimSession, SavedState } from "./types";
import type { SimSnapshot } from "../simulation-types";
export const DATA_DIR = path.resolve(process.cwd(), "data");
// ---------------------------------------------------------------------------
// Low-level read/write helpers
// ---------------------------------------------------------------------------
export 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;
}
}
export function saveSession(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));
}
// ---------------------------------------------------------------------------
// Session file management
// ---------------------------------------------------------------------------
export function deleteSessionFile(id: string): void {
const dbPath = path.join(DATA_DIR, `${id}.db`);
if (fs.existsSync(dbPath)) {
try {
fs.unlinkSync(dbPath);
} catch (err) {
console.error(`Failed to delete session file ${dbPath}:`, err);
}
}
}
/**
* Lists all saved simulation snapshots by scanning the data directory.
* Active in-memory sessions are snapshotted via the provided callback;
* inactive ones are read directly from their `.db` files.
*/
export function listSavedSessions(
activeSessions: Map<string, SimSession>,
snapshotFn: (session: SimSession) => SimSnapshot,
): SimSnapshot[] {
if (!fs.existsSync(DATA_DIR)) return [];
const snapshots: SimSnapshot[] = [];
const files = fs
.readdirSync(DATA_DIR)
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
for (const file of files) {
const id = file.replace(".db", "");
const dbPath = path.join(DATA_DIR, file);
const active = activeSessions.get(id);
if (active) {
snapshots.push(snapshotFn(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 corrupt / in-use db files */
}
}
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;
});
}

View File

@@ -0,0 +1,485 @@
import "./env"; // Must be first — loads .env before any code reads process.env
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import { SQLiteRepository } from "@omnia/core";
import { BufferRepository, LedgerRepository } from "@omnia/memory";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import { ProviderManager, buildEmbeddingProvider } from "@omnia/llm";
import type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type { SimSnapshot } from "../simulation-types";
import type { SimSession, EntityInfo } from "./types";
import { resolveProviders } from "./provider-resolver";
import {
DATA_DIR,
loadSessionState,
saveSession,
listSavedSessions,
deleteSessionFile,
} from "./session-store";
import {
preparePlayerTurn,
processNpcTurn,
executePlayerAction,
} from "./turn-executor";
import { runAliasResolution, runHandoffResolution } from "./alias-handoff";
export class SimulationManager {
private sessions = new Map<string, SimSession>();
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
async create(
scenarioPath: string,
playEntityName?: string,
providerInstanceId?: string,
customName?: string,
): Promise<SimSnapshot> {
// Resolve or validate the active generative provider upfront so we can
// return a clean error snapshot before touching the filesystem.
let activeInstance: ModelProviderInstance | null = providerInstanceId
? ProviderManager.list().find((p) => p.id === providerInstanceId) || null
: ProviderManager.getActive("generative");
if (!activeInstance) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
activeInstance = ProviderManager.create(
"Default (Env)",
"google-genai",
envKey,
undefined,
"generative",
);
}
}
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()}`;
fs.mkdirSync(DATA_DIR, { recursive: true });
const dbPath = path.join(DATA_DIR, `${id}.db`);
const db = new Database(dbPath);
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(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.",
};
}
// Build entity list
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,
isAgent: e.isAgent,
}));
// Resolve player entity (exact match → name match → fuzzy)
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 mappings = ProviderManager.getMappings();
const {
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
} = resolveProviders(mappings, { fallbackInstance: activeInstance });
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId,
scenarioName: customName || scenarioJson.name,
scenarioDescription: scenarioJson.description || "",
turn: 1,
maxTurns: 20,
entities: entityInfos,
playerEntityId,
entityIndex: 0,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
architect,
aliasGenerator,
log: [],
status: "running",
aliasDoneForTurn: false,
providerMappings: mappings,
};
this.sessions.set(id, session);
return this.snapshot(session);
}
async load(id: string): Promise<SimSnapshot | null> {
const active = this.sessions.get(id);
if (active) return this.snapshot(active);
const dbPath = path.join(DATA_DIR, `${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 mappings = state.providerMappings || {};
const {
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
} = resolveProviders(mappings, { required: true });
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
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,
handoffProvider,
embeddingProvider,
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;
}
}
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);
}
deleteSessionFile(id);
}
listSavedSessions(): SimSnapshot[] {
return listSavedSessions(this.sessions, (s) => this.snapshot(s));
}
getSnapshot(id: string): SimSnapshot | null {
const session = this.sessions.get(id);
return session ? this.snapshot(session) : null;
}
async rename(id: string, newName: string): Promise<SimSnapshot | null> {
let session = this.sessions.get(id);
if (!session) {
await this.load(id);
session = this.sessions.get(id);
}
if (!session) return null;
session.scenarioName = newName;
saveSession(session);
return this.snapshot(session);
}
// ---------------------------------------------------------------------------
// Simulation stepping
// ---------------------------------------------------------------------------
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";
saveSession(session);
return this.snapshot(session);
}
// Start of turn: alias + handoff resolution before any entity acts
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
await runAliasResolution(session);
await runHandoffResolution(session);
session.aliasDoneForTurn = true;
saveSession(session);
return this.snapshot(session);
}
// End of turn: advance to next turn
if (session.entityIndex >= session.entities.length) {
session.turn++;
session.entityIndex = 0;
session.aliasDoneForTurn = false;
saveSession(session);
return this.snapshot(session);
}
const info = session.entities[session.entityIndex];
if (!info.isAgent) {
session.entityIndex++;
saveSession(session);
return this.snapshot(session);
}
if (info.isPlayer) {
await preparePlayerTurn(session, info);
saveSession(session);
return this.snapshot(session);
}
await processNpcTurn(session, info);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
saveSession(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 {
await executePlayerAction(session, ctx, prose);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
saveSession(session);
return this.snapshot(session);
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
async regenerateAllEmbeddings(newProviderInstanceId?: string): Promise<void> {
if (!fs.existsSync(DATA_DIR)) return;
const files = fs
.readdirSync(DATA_DIR)
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
const list = ProviderManager.list();
let inst = newProviderInstanceId
? (list.find((p) => p.id === newProviderInstanceId) ?? null)
: null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY || "";
if (envKey) {
inst = {
id: "regen-env-fallback",
name: "Gemini Embed (Env)",
providerName: "google-genai",
apiKey: envKey,
isActive: true,
modelName: "gemini-embedding-001",
type: "embedding",
maxContext: 0,
};
} else {
inst = {
id: "regen-mock-fallback",
name: "Mock Embed (Fallback)",
providerName: "mock",
apiKey: "",
isActive: true,
modelName: undefined,
type: "embedding",
maxContext: 0,
};
}
}
const embeddingProvider: IEmbeddingProvider = buildEmbeddingProvider(inst);
for (const file of files) {
const dbPath = path.join(DATA_DIR, file);
const fileId = file.replace(".db", "");
const activeSession = this.sessions.get(fileId);
const db = activeSession ? activeSession.db : new Database(dbPath);
try {
const rows = db
.prepare(`SELECT id, content FROM ledger_entries`)
.all() as { id: string; content: string }[];
for (const row of rows) {
const vector = await embeddingProvider.embed(row.content);
const buffer = Buffer.from(new Float32Array(vector).buffer);
db.prepare(
`UPDATE ledger_entries SET embedding = ? WHERE id = ?`,
).run(buffer, row.id);
}
} catch (err) {
console.error(`Failed to regenerate embeddings for ${file}:`, err);
} finally {
if (!activeSession) db.close();
}
}
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
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,
};
}
}

View File

@@ -0,0 +1,277 @@
import {
ActorAgent,
ActorPromptBuilder,
buildBufferEntryForIntent,
} from "@omnia/actor";
import type { IActorProseGenerator } from "@omnia/actor";
import type { SimSession } from "./types";
import type {
EntityInfo,
IntentInfo,
LogEntry,
WaitingContext,
} from "../simulation-types";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Prose generator that returns a fixed player-supplied string verbatim. */
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;
}
}
/**
* Processes every intent produced by an actor turn:
* - Validates via Architect
* - Appends to actor's own buffer
* - Fan-outs to co-located observers for dialogue/action intents
*
* Extracted to eliminate verbatim duplication between NPC and player paths.
*/
async function processIntents(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
intents: any[],
actorEntityId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
entity: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
worldState: any,
session: SimSession,
): Promise<IntentInfo[]> {
const intentInfos: IntentInfo[] = [];
for (const intent of intents) {
const outcome = await session.architect.processIntent(worldState, intent);
const ts = worldState.clock.get().toISOString();
intentInfos.push({
type: intent.type,
description: intent.description,
selfDescription: intent.selfDescription,
modifiers: intent.modifiers || [],
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);
// Fan-out observable events to co-located entities
if (
entity.locationId &&
(intent.type === "dialogue" || intent.type === "action")
) {
for (const [, other] of worldState.entities) {
if (
other.id !== actorEntityId &&
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 });
}
}
}
}
return intentInfos;
}
// ---------------------------------------------------------------------------
// Exported turn functions
// ---------------------------------------------------------------------------
/**
* Builds the prompt for the player entity and sets the session to
* `waiting_player` so the next client call can supply the prose.
*/
export async function 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,
session.ledgerRepo,
20,
);
const { systemPrompt, userContext } = promptBuilder.build(worldState, entity);
session.waitingEntity = {
entityId: info.id,
name: info.name,
systemPrompt,
userContext,
};
session.status = "waiting_player";
}
/**
* Runs an autonomous NPC turn: generates prose via ActorAgent, validates
* and persists all intents, and appends a LogEntry to the session.
*/
export async function 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,
session.ledgerRepo,
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;
}
entry.intents = await processIntents(
result.intents.intents,
info.id,
entity,
worldState,
session,
);
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}
/**
* Executes the player's turn using the prose they supplied.
* Uses a `FixedProseGenerator` so the ActorAgent bypasses its LLM call and
* returns the player's text directly.
*/
export async function executePlayerAction(
session: SimSession,
ctx: WaitingContext,
prose: string,
): Promise<void> {
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,
session.ledgerRepo,
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;
}
entry.intents = await processIntents(
result.intents.intents,
ctx.entityId,
entity,
worldState,
session,
);
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}

View File

@@ -0,0 +1,68 @@
import type Database from "better-sqlite3";
import type { SQLiteRepository } from "@omnia/core";
import type { BufferRepository, LedgerRepository } from "@omnia/memory";
import type { Architect, AliasDeltaGenerator } from "@omnia/architect";
import type { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
import type { EntityInfo, LogEntry, WaitingContext } from "../simulation-types";
export type {
EntityInfo,
IntentInfo,
LogEntry,
SimSnapshot,
WaitingContext,
} from "../simulation-types";
// ---------------------------------------------------------------------------
// Persisted state (written to sqlite gui_meta table as JSON)
// ---------------------------------------------------------------------------
export 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>;
}
// ---------------------------------------------------------------------------
// In-memory session (held in SimulationManager.sessions Map)
// ---------------------------------------------------------------------------
export interface SimSession {
db: Database.Database;
dbPath: string;
coreRepo: SQLiteRepository;
bufferRepo: BufferRepository;
ledgerRepo: LedgerRepository;
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;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
architect: Architect;
aliasGenerator: AliasDeltaGenerator;
log: LogEntry[];
status: "running" | "waiting_player" | "done" | "error";
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
providerMappings: Record<string, string>;
}

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
omnia-gui:
build:
context: .
dockerfile: apps/gui/Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
volumes:
- omnia-data:/app/apps/gui/data
volumes:
omnia-data:

View File

@@ -19,7 +19,8 @@
"watch": "tsc -b --watch",
"test": "vitest run --project unit",
"test:watch": "vitest --project unit",
"test:evals": "vitest run --project evals"
"test:evals": "vitest run --project evals",
"setup-provider": "node packages/llm/dist/bin/setup-provider.js"
},
"keywords": [],
"author": "sortedcord",
@@ -47,7 +48,12 @@
"zod": "^4.4.3"
},
"dependencies": {
"@langchain/anthropic": "^0.3.11",
"@langchain/deepseek": "^1.1.5",
"@langchain/google-genai": "^2.2.0",
"@langchain/groq": "^1.3.1",
"@langchain/ollama": "^0.2.3",
"@langchain/openai": "^0.3.17",
"@langchain/openrouter": "^0.4.3",
"@types/node": "^20.19.43",
"dotenv": "^17.4.2"

532
packages/llm/README.md Normal file
View File

@@ -0,0 +1,532 @@
# @omnia/llm
LLM abstraction layer providing pluggable, database-backed provider instances for generative and embedding tasks.
## Architecture Overview
The system is built around four layers:
1. **Registry** — each provider class self-registers its metadata (id, envVar, capabilities, default model, etc.) via `static {}` blocks; `PROVIDER_REGISTRY` is derived from these registrations at runtime — there is no hand-maintained provider list
2. **Provider Manager** — SQLite-backed CRUD for persisted provider instances, with env-var bootstrap driven by the registry
3. **Provider Factory**`buildLLMProvider(inst)` / `buildEmbeddingProvider(inst)` resolve a stored instance to a live provider class via the registry
4. **Interfaces** — contracts that all providers implement
```mermaid
graph TD
subgraph Self-Registering Providers
GP["GeminiProvider"]
ORP["OpenRouterProvider"]
MP["MockLLMProvider"]
GEP["GeminiEmbeddingProvider"]
MEP["MockEmbeddingProvider"]
end
subgraph Registry
PR["ProviderRegistry\n(derived, not authored)"]
end
subgraph Storage
PM["ProviderManager\n(db.ts + bootstrap.ts + row-mapper.ts)"]
DB[("settings.db")]
end
subgraph Factory
PF["buildLLMProvider()\nbuildEmbeddingProvider()"]
end
GP -->|static block| PR
ORP -->|static block| PR
MP -->|static block| PR
GEP -->|static block| PR
MEP -->|static block| PR
PM -->|reads/writes| DB
PM -->|bootstrap from| PR
PF -->|looks up| PR
PF -->|instantiates| GP
PF -->|instantiates| ORP
```
## Core Interfaces
Defined in [`llm.ts`](src/llm.ts):
### `ILLMProvider`
The primary contract for generative (text-to-structured-data) providers.
| Member | Type | Description |
| ---------------------------------------- | ------------------------- | ---------------------------------------------------------- |
| `providerName` | `string` | Human-readable provider label |
| `maxContext` | `number?` | Maximum context window in tokens |
| `generateStructuredResponse<T>(request)` | `Promise<LLMResponse<T>>` | Sends a prompt + Zod schema → returns parsed, typed output |
| `lastCalls` | `LLMCallRecord[]?` | Audit trail of recent calls (prompts + usage) |
### `IEmbeddingProvider`
Contract for text-to-vector embedding providers.
| Member | Type | Description |
| -------------- | ------------------- | -------------------------------------------------- |
| `providerName` | `string` | Human-readable provider label |
| `embed(text)` | `Promise<number[]>` | Returns a dense vector embedding of the input text |
### `LLMRequest<T>`
Input to `generateStructuredResponse`:
```typescript
{
systemPrompt: string; // System-level instructions
userContext: string; // User/task-specific context
schema: T; // Zod schema — output is validated against this
temperature?: number; // Sampling temperature (optional)
}
```
### `LLMResponse<T>`
Output from `generateStructuredResponse`:
```typescript
{
success: boolean;
data?: T; // Parsed, schema-validated output
error?: string; // Error message on failure
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
}
```
### `ModelProviderInstance`
The persisted configuration record for a single provider instance:
```typescript
{
id: string; // Unique ID ("provider-<timestamp>")
name: string; // User-facing name ("Gemini (Env)")
providerName: string; // Provider type key ("google-genai" | "openrouter" | "mock")
apiKey: string; // API key
isActive: boolean; // Whether this is the active instance for its type
modelName?: string; // Specific model to use
type: "generative" | "embedding"; // Instance category
maxContext?: number; // Context window limit
}
```
### `ModelProviderMeta`
Static metadata for each available provider type (used by the UI's provider picker):
| Member | Type | Description |
| ----------------------- | -------- | ------------------------------------------------------------ |
| `id` | `string` | `"google-genai"` \| `"openrouter"` \| `"ollama"` \| `"mock"` |
| `displayName` | `string` | Human-readable name |
| `description` | `string` | Human-readable description |
| `defaultModel` | `string` | Default generative model |
| `defaultEmbeddingModel` | `string` | Default embedding model |
Provider metadata is **self-declared** by each provider class in a `static {}` block and collected into `PROVIDER_REGISTRY` (derived, not authored). The `getAvailableProviders()` function and `AVAILABLE_PROVIDERS` helper in [`llm.ts`](src/llm.ts) read from the registry at call time.
## Provider Manager
`ProviderManager` is a **static class** that provides full CRUD over provider instances, backed by a SQLite database (`data/settings.db` at the workspace root). Internally split across:
- [`db.ts`](src/db.ts) — memoized DB handle + schema migrations (`PRAGMA user_version`)
- [`bin/setup-provider.ts`](src/bin/setup-provider.ts) — CLI tool to set up provider instances in the database
- [`row-mapper.ts`](src/row-mapper.ts) — `mapRow()` (written once, used everywhere)
- [`provider-manager.ts`](src/provider-manager.ts) — thin CRUD: `list`, `create`, `delete`, `setActive`, `update`, `getActive`, `getMappings`, `setMapping`
### Storage
The database is auto-created on first access. The table schema:
```sql
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,
type TEXT NOT NULL DEFAULT 'generative',
maxContext INTEGER
);
```
A second table stores per-task provider overrides:
```sql
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
);
```
### API
| Method | Signature | Description |
| ------------------------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- |
| `list()` | `→ ModelProviderInstance[]` | Returns all saved instances |
| `create(name, providerName, apiKey, modelName?, type?, maxContext?)` | `→ ModelProviderInstance` | Creates a new instance. Auto-activates if it's the first of its type |
| `delete(id)` | `→ void` | Removes an instance. If it was active, auto-promotes the next instance of the same type |
| `setActive(id)` | `→ void` | Deactivates all instances of the same type, then activates the target |
| `update(id, name, providerName, apiKey?, modelName?, type?, maxContext?)` | `→ void` | Updates an existing instance. If `apiKey` is empty/omitted, the existing key is preserved |
| `getActive(type?)` | `→ ModelProviderInstance \| null` | Returns the currently active instance for the given type (`"generative"` by default) |
| `getMappings()` | `→ Record<string, string>` | Returns all task → providerInstanceId mappings |
| `setMapping(task, providerInstanceId)` | `→ void` | Sets or removes (if `providerInstanceId` is empty) a task-specific mapping |
### Active Instance Invariants
- **Only one active instance per type** — `setActive()` deactivates all sibling instances before activating the target.
- **Auto-promotion on delete** — if the deleted instance was active, the first remaining instance of the same type is promoted.
- **Auto-activation on create** — if no active instance exists for the type, the new instance is automatically activated.
### Manual Seeding via CLI
Rather than automatically bootstrapping from environment variables at runtime, which adds runtime complexity, you can quickly seed the database using the CLI setup tool:
#### Seeding All Environment-Variable Providers
```bash
pnpm setup-provider --all
```
This command auto-detects and inserts provider instances into `data/settings.db` for any registered providers whose corresponding environment variables (such as `GOOGLE_API_KEY`, `OPENAI_API_KEY`, etc.) are defined.
#### Creating a Specific Provider Instance
```bash
pnpm setup-provider --provider google-genai --key YOUR_API_KEY [--name "My Gemini"] [--model gemini-2.5-flash] [--type generative] [--max-context 32768] [--endpoint url]
```
### Credential Resolution Cascade
When initializing a provider (e.g. `new GeminiProvider()`):
1. **Explicit Credentials** — If `apiKey`, `modelName`, etc. are passed directly to the constructor, they are used.
2. **Active DB Instance** — If not explicitly passed, it looks up the active DB instance via `ProviderManager.getActive()`. If found and matches the provider, its API key and configuration are used.
3. **Environment Fallback** — If there is no matching active DB instance, it resolves the key directly from the corresponding environment variable (e.g., `GOOGLE_API_KEY`) via `resolveCredentials`.
## Available Providers
### Google Gemini — `GeminiProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------------------ |
| **File** | [`providers/google-genai.ts`](src/providers/google-genai.ts) |
| **Provider ID** | `google-genai` |
| **SDK** | `@langchain/google-genai` (`ChatGoogleGenerativeAI`) |
| **Default Model** | `gemini-2.5-flash` |
| **Default Embedding Model** | `gemini-embedding-001` |
| **Default Max Context** | `32768` |
| **Type** | Generative |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "google-genai"
3. GOOGLE_API_KEY env var → final fallback
4. None found → throw Error
```
Also exports `GeminiEmbeddingProvider` (implements `IEmbeddingProvider`) using the same key resolution pattern but querying for the `"embedding"` type.
### Anthropic Claude — `AnthropicProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------------ |
| **File** | [`providers/anthropic.ts`](src/providers/anthropic.ts) |
| **Provider ID** | `anthropic` |
| **SDK** | `@langchain/anthropic` (`ChatAnthropic`) |
| **Default Model** | `claude-3-5-sonnet-latest` |
| **Default Embedding Model** | _(none)_ |
| **Default Max Context** | `200000` |
| **Type** | Generative only (no embedding provider) |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "anthropic"
3. ANTHROPIC_API_KEY env var → final fallback
4. None found → throw Error
```
### Groq — `GroqProvider`
| Property | Value |
| --------------------------- | -------------------------------------------- |
| **File** | [`providers/groq.ts`](src/providers/groq.ts) |
| **Provider ID** | `groq` |
| **SDK** | `@langchain/groq` (`ChatGroq`) |
| **Default Model** | `llama-3.3-70b-versatile` |
| **Default Embedding Model** | _(none)_ |
| **Default Max Context** | `8192` |
| **Type** | Generative only (no embedding provider) |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "groq"
3. GROQ_API_KEY env var → final fallback
4. None found → throw Error
```
### DeepSeek — `DeepSeekProvider`
| Property | Value |
| --------------------------- | ---------------------------------------------------- |
| **File** | [`providers/deepseek.ts`](src/providers/deepseek.ts) |
| **Provider ID** | `deepseek` |
| **SDK** | `@langchain/deepseek` (`ChatDeepSeek`) |
| **Default Model** | `deepseek-chat` |
| **Default Embedding Model** | _(none)_ |
| **Default Max Context** | `64000` |
| **Type** | Generative only (no embedding provider) |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "deepseek"
3. DEEPSEEK_API_KEY env var → final fallback
4. None found → throw Error
```
### OpenAI — `OpenAIProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------------ |
| **File** | [`providers/openai.ts`](src/providers/openai.ts) |
| **Provider ID** | `openai` |
| **SDK** | `@langchain/openai` (`ChatOpenAI`, `OpenAIEmbeddings`) |
| **Default Model** | `gpt-4o-mini` |
| **Default Embedding Model** | `text-embedding-3-small` |
| **Default Max Context** | `128000` |
| **Type** | Generative + Embedding |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "openai"
3. OPENAI_API_KEY env var → final fallback
4. None found → throw Error
```
Also exports `OpenAIEmbeddingProvider` (implements `IEmbeddingProvider`) using the same key resolution pattern against the `"embedding"` type instance. The default embedding model is `text-embedding-3-small`.
### OpenRouter — `OpenRouterProvider`
| Property | Value |
| --------------------------- | -------------------------------------------------------- |
| **File** | [`providers/openrouter.ts`](src/providers/openrouter.ts) |
| **Provider ID** | `openrouter` |
| **SDK** | `@langchain/openrouter` (`ChatOpenRouter`) |
| **Default Model** | `google/gemini-2.5-flash` |
| **Default Embedding Model** | `openai/text-embedding-3-small` |
| **Default Max Context** | `32768` |
| **Type** | Generative only (no embedding provider) |
Same three-step key resolution as Gemini (`explicit → ProviderManager → env var`), using `OPENROUTER_API_KEY`.
### Ollama — `OllamaProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------ |
| **File** | [`providers/ollama.ts`](src/providers/ollama.ts) |
| **Provider ID** | `ollama` |
| **SDK** | `@langchain/ollama` (`ChatOllama`) |
| **Default Model** | `llama3.1` |
| **Default Embedding Model** | `nomic-embed-text` |
| **Default Max Context** | `32768` |
| **Type** | Generative + Embedding |
Ollama runs **locally** — no API key is required. The `endpointUrl` field in `ModelProviderInstance` stores the Ollama server base URL (default: `http://localhost:11434`).
**Key resolution** in the constructor:
```
1. Explicit baseUrl argument → use it
2. ProviderManager.getActive() → if providerName matches "ollama"
(endpointUrl field = base URL)
3. Default → http://localhost:11434
```
Also exports `OllamaEmbeddingProvider` (implements `IEmbeddingProvider`), which uses the same resolution pattern against the `"embedding"` type instance. The default embedding model is `nomic-embed-text`.
> [!TIP]
> To get started: `ollama pull llama3.1` and `ollama pull nomic-embed-text`. Then create a provider instance with `endpointUrl` = `http://localhost:11434`.
### Mock — `MockLLMProvider`
| Property | Value |
| --------------- | -------------------------------------------- |
| **File** | [`providers/mock.ts`](src/providers/mock.ts) |
| **Provider ID** | `mock` |
| **Type** | Generative + Embedding |
Stateless mock for testing and offline development:
- **Generative** (`MockLLMProvider`): Takes an array of canned responses at construction. Returns them in order, one per call. Returns `{ success: false, error: "Mock responses exhausted" }` when depleted.
- **Embedding** (`MockEmbeddingProvider`): Returns a deterministic 768-dimensional vector derived from the input text using `Math.sin`.
## Provider Resolution (Runtime)
The [`resolveProviders()`](../../../apps/gui/src/lib/simulation/provider-resolver.ts) function (in `apps/gui`) instantiates all providers needed for a simulation session. It resolves **six** provider slots:
| Slot | Type | Task Key |
| ------------------- | ---------- | ------------------ |
| `actorProvider` | Generative | `"actor-prose"` |
| `validatorProvider` | Generative | `"llm-validator"` |
| `decoderProvider` | Generative | `"intent-decoder"` |
| `timedeltaProvider` | Generative | `"timedelta"` |
| `handoffProvider` | Generative | `"handoff"` |
| `embeddingProvider` | Embedding | `"embeddings"` |
### Generative Resolution Order
For each generative slot (`resolveGenerative(task)`):
```
1. Task-specific mapping → mappings[task] → find instance by ID
2. Active generative instance → ProviderManager.getActive("generative")
3. Fallback instance → options.fallbackInstance (if provided)
4. GOOGLE_API_KEY env var → auto-create via ProviderManager.create()
5. No provider available → throw Error (if required) or MockLLMProvider
```
### Embedding Resolution Order
For the embedding slot (`resolveEmbedding()`):
```
1. Task-specific mapping → mappings["embeddings"] → find instance by ID
2. Active embedding instance → ProviderManager.getActive("embedding")
3. GOOGLE_API_KEY env var → auto-create via ProviderManager.create()
4. No provider available → throw Error (if required) or MockEmbeddingProvider
```
### Instance → Class Mapping
The `buildLLMProvider()` and `buildEmbeddingProvider()` functions perform the final dispatch:
| `providerName` | Generative Class | Embedding Class |
| ----------------- | -------------------- | ------------------------- |
| `"google-genai"` | `GeminiProvider` | `GeminiEmbeddingProvider` |
| `"openai"` | `OpenAIProvider` | `OpenAIEmbeddingProvider` |
| `"openrouter"` | `OpenRouterProvider` | _(falls through to mock)_ |
| `"ollama"` | `OllamaProvider` | `OllamaEmbeddingProvider` |
| `"anthropic"` | `AnthropicProvider` | _(falls through to mock)_ |
| `"groq"` | `GroqProvider` | _(falls through to mock)_ |
| `"deepseek"` | `DeepSeekProvider` | _(falls through to mock)_ |
| _(anything else)_ | `MockLLMProvider` | `MockEmbeddingProvider` |
## Model Listing and Discovery
The `ModelLister` class provides a unified interface to dynamically query available models from the remote provider APIs.
### Caching and TTL
All list requests are cached in-memory with a **5-minute TTL** (`300,000ms`) to prevent rapid, repetitive remote API requests and avoid rate limit exhaustion.
- **Cache Key**: Generated using `providerName` combined with either the `apiKey` or `endpointUrl`.
- **Invalidation**: Call `ModelLister.invalidateCache(providerName, apiKey, endpointUrl)` to clear cache for specific instances, or `ModelLister.clearCache()` to wipe all lists.
### Provider Integration Details
| Provider | Endpoint | Auth Header | Pagination |
| ----------------- | ---------------------------- | ----------------------- | --------------------------- |
| **Google Gemini** | `GET /v1beta/models?key=KEY` | Query Param | ✅ Loop via `nextPageToken` |
| **OpenAI** | `GET /v1/models` | `Authorization: Bearer` | ❌ |
| **Anthropic** | `GET /v1/models` | `x-api-key` | ✅ Loop via `after_id` |
| **Groq** | `GET /openai/v1/models` | `Authorization: Bearer` | ❌ |
| **DeepSeek** | `GET /models` | `Authorization: Bearer` | ❌ |
| **Ollama** | `GET /api/tags` | None (Local) | ❌ |
| **OpenRouter** | `GET /api/v1/models` | Optional Bearer | ❌ |
| **Mock** | Instant return (no fetch) | — | — |
### Methods
- `listModels(providerName: string, apiKey: string, endpointUrl?: string): Promise<ModelInfo[]>`
- `invalidateCache(providerName: string, apiKey: string, endpointUrl?: string): void`
- `clearCache(): void`
---
## Structured Output
All real providers use LangChain's `.withStructuredOutput(schema, { includeRaw: true })` pattern:
```typescript
const structuredModel = this.model.withStructuredOutput(request.schema, {
includeRaw: true,
});
const result = await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
]);
```
This sends the Zod schema to the model as a structured output constraint. The response includes both `parsed` (schema-validated data) and `raw` (full API response with usage metadata).
## Configuration
[`config.ts`](src/config.ts) parses environment variables using Zod:
| Variable | Required | Description |
| -------------------- | -------- | ------------------------ |
| `GOOGLE_API_KEY` | No | Google Gemini API key |
| `OPENAI_API_KEY` | No | OpenAI API key |
| `OPENROUTER_API_KEY` | No | OpenRouter API key |
| `ANTHROPIC_API_KEY` | No | Anthropic Claude API key |
| `GROQ_API_KEY` | No | Groq API key |
| `DEEPSEEK_API_KEY` | No | DeepSeek API key |
Env var keys are derived from `PROVIDER_REGISTRY` — each provider's `envVar` field is read by `getLlmConfig()` to build the zod schema lazily. Adding a new provider with `envVar: "NEW_KEY"` automatically adds it to config validation.
## File Map
```
packages/llm/
├── src/
│ ├── index.ts # Re-exports everything
│ ├── llm.ts # Interfaces, types, getAvailableProviders()
│ ├── registry.ts # ProviderRegistry (derived), registerProvider/registerGenerative/registerEmbedding
│ ├── base-provider.ts # BaseLLMProvider (shared generateStructuredResponse), resolveCredentials
│ ├── config.ts # Env var parsing (lazy, registry-derived Zod)
│ ├── model-lister.ts # ModelLister (cache + fetchWithTimeout), fetchOpenAICompatibleModels
│ ├── provider-factory.ts # buildLLMProvider() / buildEmbeddingProvider() (registry lookup)
│ ├── provider-manager.ts # ProviderManager (thin CRUD)
│ ├── db.ts # Memoized DB handle + migrations
│ ├── row-mapper.ts # mapRow()
│ ├── bin/
│ │ └── setup-provider.ts # CLI tool to set up provider instances in the database
│ └── providers/
│ ├── google-genai.ts # GeminiProvider + GeminiEmbeddingProvider (self-registering)
│ ├── ollama.ts # OllamaProvider + OllamaEmbeddingProvider
│ ├── openrouter.ts # OpenRouterProvider
│ ├── anthropic.ts # AnthropicProvider
│ ├── openai.ts # OpenAIProvider + OpenAIEmbeddingProvider
│ ├── groq.ts # GroqProvider
│ ├── deepseek.ts # DeepSeekProvider
│ └── mock.ts # MockLLMProvider + MockEmbeddingProvider
├── tests/
│ ├── mock.test.ts
│ ├── openrouter.test.ts
│ ├── model-lister.test.ts # ModelLister cache and fetch logic unit tests
│ ├── provider-manager.test.ts
│ └── cli.test.ts # Integration tests for setup-provider CLI tool
└── package.json
```

View File

@@ -6,6 +6,9 @@
"exports": {
".": "./dist/index.js"
},
"scripts": {
"setup-provider": "node ./dist/bin/setup-provider.js"
},
"dependencies": {
"@types/node": "^26.1.0",
"better-sqlite3": "^12.11.1"

View File

@@ -0,0 +1,112 @@
import { z } from "zod";
import type {
ILLMProvider,
LLMRequest,
LLMResponse,
LLMCallRecord,
} from "./llm.js";
import { ProviderManager } from "./provider-manager.js";
import { getLlmConfig } from "./config.js";
export interface ResolvedCredentials {
key: string | undefined;
model: string | undefined;
providerInstanceName: string | undefined;
maxContext: number | undefined;
}
export function resolveCredentials(opts: {
explicitKey?: string;
explicitModel?: string;
explicitProviderInstanceName?: string;
explicitMaxContext?: number;
providerId: string;
envVarName: string;
type: "generative" | "embedding";
}): ResolvedCredentials {
let key = opts.explicitKey;
let model = opts.explicitModel;
let providerInstanceName = opts.explicitProviderInstanceName;
let maxContext = opts.explicitMaxContext;
if (!key) {
const active = ProviderManager.getActive(opts.type);
if (active && active.providerName === opts.providerId) {
key = active.apiKey;
if (!model) model = active.modelName;
if (!providerInstanceName) providerInstanceName = active.name;
if (maxContext === undefined) maxContext = active.maxContext;
}
}
if (!key) {
const cfg = getLlmConfig();
key = cfg[opts.envVarName];
if (!providerInstanceName && key) {
providerInstanceName = "Environment Variable";
}
}
return { key, model, providerInstanceName, maxContext };
}
export abstract class BaseLLMProvider implements ILLMProvider {
abstract providerName: string;
protected abstract readonly model: unknown;
protected abstract modelNameUsed: string;
protected abstract providerInstanceName?: string;
protected abstract maxContextUsed?: number;
protected abstract defaultMaxContext: number;
lastCalls: LLMCallRecord[] = [];
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const structuredModel = (
this.model as {
withStructuredOutput(
s: z.ZodTypeAny,
o: { includeRaw: true },
): {
invoke(m: unknown): Promise<unknown>;
};
}
).withStructuredOutput(request.schema, { includeRaw: true });
const result = (await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
])) as unknown as {
parsed?: z.infer<T>;
raw?: {
usage_metadata?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
};
};
};
const parsed = result?.parsed;
const raw = result?.raw;
const usage = {
inputTokens: raw?.usage_metadata?.input_tokens || 0,
outputTokens: raw?.usage_metadata?.output_tokens || 0,
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
maxContext:
this.maxContextUsed !== undefined
? this.maxContextUsed
: this.defaultMaxContext,
};
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
return { success: true, data: parsed, usage };
}
}

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env node
import { ProviderRegistry, ProviderManager } from "../index.js";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
// Load dotenv from workspace root
function loadEnv() {
let current = process.cwd();
while (current !== "/" && current !== path.parse(current).root) {
if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) {
dotenv.config({ path: path.join(current, ".env") });
return;
}
current = path.dirname(current);
}
dotenv.config();
}
loadEnv();
function printHelp() {
console.log(`
Usage:
node packages/llm/dist/bin/setup-provider.js [options]
Options:
--provider <id> ID of the provider (e.g. google-genai, openai, anthropic, groq, etc.)
--name <name> Display name for the instance (default: provider displayName)
--key <key> API key (default: loaded from the provider's env variable, e.g. GOOGLE_API_KEY)
--model <model> Model name (default: provider's default model)
--type <type> "generative" | "embedding" (default: "generative")
--max-context <num> Max context window tokens (default: provider's default max context)
--endpoint <url> Custom endpoint URL (optional)
--all Auto-detect and seed all providers whose environment variables are set
-h, --help Show this help message
Registered Providers:
${ProviderRegistry.all()
.map((p) => ` - ${p.id} (${p.displayName}) [Env: ${p.envVar || "None"}]`)
.join("\n")}
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes("-h") || args.includes("--help") || args.length === 0) {
printHelp();
process.exit(0);
}
const options: Record<string, string> = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith("--")) {
const key = arg.slice(2);
const nextVal = args[i + 1];
if (nextVal && !nextVal.startsWith("--")) {
options[key] = nextVal;
i++;
} else {
options[key] = "true";
}
}
}
if (options.all === "true") {
// Seed all providers from environment variables
const existing = ProviderManager.list();
let seededCount = 0;
for (const def of ProviderRegistry.all()) {
if (!def.envVar) continue;
const key = process.env[def.envVar]?.trim();
if (!key) continue;
if (def.capabilities.generative) {
const hasGen = existing.some(
(p) => p.providerName === def.id && p.type === "generative",
);
if (!hasGen) {
const name = `${def.displayName} (CLI)`;
ProviderManager.create(
name,
def.id,
key,
def.defaultModel,
"generative",
def.defaultMaxContext,
);
console.log(`Created generative instance: ${name}`);
seededCount++;
}
}
if (def.capabilities.embedding) {
const hasEmbed = existing.some(
(p) => p.providerName === def.id && p.type === "embedding",
);
if (!hasEmbed) {
const name = `${def.displayName} Embed (CLI)`;
ProviderManager.create(
name,
def.id,
key,
def.defaultEmbeddingModel || "",
"embedding",
0,
);
console.log(`Created embedding instance: ${name}`);
seededCount++;
}
}
}
if (seededCount === 0) {
console.log(
"No new provider instances seeded. (Either already existed or env vars not set)",
);
} else {
console.log(`Successfully seeded ${seededCount} provider instance(s).`);
}
process.exit(0);
}
const providerId = options.provider;
if (!providerId) {
console.error("Error: --provider <id> or --all is required.");
printHelp();
process.exit(1);
}
const def = ProviderRegistry.get(providerId);
if (!def) {
console.error(`Error: Provider '${providerId}' is not registered.`);
console.error(
`Available providers: ${ProviderRegistry.all()
.map((p) => p.id)
.join(", ")}`,
);
process.exit(1);
}
const type = (options.type === "embedding" ? "embedding" : "generative") as
"generative" | "embedding";
// Resolve key
let apiKey: string | undefined = options.key;
if (!apiKey && def.envVar) {
apiKey = process.env[def.envVar]?.trim();
}
if (!apiKey) {
console.error(
`Error: API Key is required. Please set ${def.envVar || "the environment variable"} or pass --key <apiKey>.`,
);
process.exit(1);
}
// Resolve model
const defaultModel =
type === "embedding" ? def.defaultEmbeddingModel || "" : def.defaultModel;
const modelName = options.model || defaultModel;
// Resolve name
const name = options.name || `${def.displayName} (CLI)`;
// Resolve maxContext
const maxContext = options["max-context"]
? parseInt(options["max-context"], 10)
: type === "embedding"
? 0
: def.defaultMaxContext;
const endpointUrl = options.endpoint;
const instance = ProviderManager.create(
name,
def.id,
apiKey,
modelName,
type,
maxContext,
endpointUrl,
);
console.log(`Successfully created provider instance:`);
console.log(JSON.stringify(instance, null, 2));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,8 +1,25 @@
import { z } from "zod";
import { ProviderRegistry } from "./registry.js";
const LLMConfigSchema = z.object({
GOOGLE_API_KEY: z.string().optional(),
OPENROUTER_API_KEY: z.string().optional(),
});
let _config: Record<string, string | undefined> | null = null;
export const llmConfig = LLMConfigSchema.parse(process.env);
export function getLlmConfig(): Record<string, string | undefined> {
if (!_config) {
const envVars: string[] = [];
for (const def of ProviderRegistry.all()) {
if (def.envVar && !envVars.includes(def.envVar)) {
envVars.push(def.envVar);
}
}
const shape: Record<string, z.ZodOptional<z.ZodString>> = {};
for (const key of envVars) {
shape[key] = z.string().optional();
}
_config = z.object(shape).parse(process.env);
}
return _config;
}
export function resetLlmConfig(): void {
_config = null;
}

82
packages/llm/src/db.ts Normal file
View File

@@ -0,0 +1,82 @@
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import path from "path";
import fs from "fs";
let _db: BetterSqlite3.Database | null = null;
let _dbPathOverride: string | null = null;
export function setDbPath(p: string | null) {
if (_dbPathOverride !== p) {
_db?.close();
_db = null;
_dbPathOverride = p;
}
}
function findDbPath(): string {
if (process.env.OMNIA_DB_PATH) {
const dir = path.dirname(process.env.OMNIA_DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return process.env.OMNIA_DB_PATH;
}
let current = process.cwd();
while (current !== "/" && current !== path.parse(current).root) {
if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) {
const dbDir = path.resolve(current, "data");
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
return path.join(dbDir, "settings.db");
}
current = path.dirname(current);
}
const dbDir = path.resolve(process.cwd(), "data");
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
return path.join(dbDir, "settings.db");
}
function runMigrations(db: BetterSqlite3.Database): void {
const version = db.pragma("user_version", { simple: true }) as number;
if (version < 1) {
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,
type TEXT NOT NULL DEFAULT 'generative',
maxContext INTEGER,
endpointUrl TEXT
)
`,
).run();
db.pragma("user_version = 1");
}
db.prepare(
`
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
)
`,
).run();
}
export function getDb(): BetterSqlite3.Database {
if (!_db) {
const dbPath = _dbPathOverride ?? findDbPath();
_db = new Database(dbPath);
runMigrations(_db);
}
return _db;
}

View File

@@ -1,6 +1,14 @@
export * from "./llm.js";
export * from "./config.js";
export * from "./registry.js";
export * from "./provider-factory.js";
export * from "./model-lister.js";
export * from "./provider-manager.js";
export * from "./providers/google-genai.js";
export * from "./providers/mock.js";
export * from "./providers/ollama.js";
export * from "./providers/openrouter.js";
export * from "./provider-manager.js";
export * from "./providers/anthropic.js";
export * from "./providers/openai.js";
export * from "./providers/groq.js";
export * from "./providers/deepseek.js";

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { ProviderRegistry } from "./registry.js";
export interface LLMRequest<T extends z.ZodTypeAny> {
systemPrompt: string;
@@ -57,6 +58,7 @@ export interface ModelProviderInstance {
modelName?: string;
type: "generative" | "embedding";
maxContext?: number;
endpointUrl?: string;
}
export interface ModelProviderMeta {
@@ -67,27 +69,21 @@ export interface ModelProviderMeta {
defaultEmbeddingModel: string;
}
export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
{
id: "google-genai",
displayName: "Google Gemini",
description: "Official Gemini integration using Google Gen AI SDK",
defaultModel: "gemini-2.5-flash",
defaultEmbeddingModel: "gemini-embedding-001",
export function getAvailableProviders(): ModelProviderMeta[] {
return ProviderRegistry.all().map((def) => ({
id: def.id,
displayName: def.displayName,
description: def.description,
defaultModel: def.defaultModel,
defaultEmbeddingModel: def.defaultEmbeddingModel || "",
}));
}
export const AVAILABLE_PROVIDERS = {
get count(): number {
return getAvailableProviders().length;
},
{
id: "openrouter",
displayName: "OpenRouter",
description:
"Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
defaultModel: "google/gemini-2.5-flash",
defaultEmbeddingModel: "openai/text-embedding-3-small",
toArray(): ModelProviderMeta[] {
return getAvailableProviders();
},
{
id: "mock",
displayName: "Mock LLM Provider",
description: "Stateless mock provider for testing and offline development",
defaultModel: "mock",
defaultEmbeddingModel: "mock-embeddings",
},
];
};

View File

@@ -0,0 +1,105 @@
/**
* ModelLister — fetches available models from each provider's REST API.
* Results are cached in-memory with a 5-minute TTL to avoid repeated calls.
*/
import { ProviderRegistry } from "./registry.js";
export interface ModelInfo {
id: string;
name: string;
ownedBy?: string;
}
interface CacheEntry {
models: ModelInfo[];
fetchedAt: number;
}
const CACHE_TTL_MS = 5 * 60 * 1000;
const FETCH_TIMEOUT_MS = 10_000;
const modelCache = new Map<string, CacheEntry>();
function cacheKey(
providerName: string,
apiKey: string,
endpointUrl?: string,
): string {
return `${providerName}:${endpointUrl || apiKey}`;
}
export async function fetchWithTimeout(
url: string,
init?: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function fetchOpenAICompatibleModels(
baseUrl: string,
apiKey: string,
): Promise<ModelInfo[]> {
const res = await fetchWithTimeout(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
if (!res.ok) return [];
const json = (await res.json()) as {
data?: { id: string; owned_by?: string; name?: string }[];
};
return (json.data ?? []).map((m) => ({
id: m.id,
name: m.name || m.id,
ownedBy: m.owned_by,
}));
}
export class ModelLister {
static async listModels(
providerName: string,
apiKey: string,
endpointUrl?: string,
): Promise<ModelInfo[]> {
const key = cacheKey(providerName, apiKey, endpointUrl);
const cached = modelCache.get(key);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.models;
}
const def = ProviderRegistry.get(providerName);
let models: ModelInfo[] = [];
try {
if (def?.listModels) {
models = await def.listModels(apiKey, endpointUrl);
}
} catch {
models = [];
}
modelCache.set(key, { models, fetchedAt: Date.now() });
return models;
}
static invalidateCache(
providerName: string,
apiKey: string,
endpointUrl?: string,
): void {
modelCache.delete(cacheKey(providerName, apiKey, endpointUrl));
}
static clearCache(): void {
modelCache.clear();
}
}

View File

@@ -0,0 +1,21 @@
import type {
ILLMProvider,
IEmbeddingProvider,
ModelProviderInstance,
} from "./llm.js";
import { MockLLMProvider, MockEmbeddingProvider } from "./providers/mock.js";
import { ProviderRegistry } from "./registry.js";
export function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider {
const def = ProviderRegistry.get(inst.providerName);
return def?.generativeCreate?.(inst) ?? new MockLLMProvider([]);
}
export function buildEmbeddingProvider(
inst: ModelProviderInstance,
): IEmbeddingProvider {
const def = ProviderRegistry.get(inst.providerName);
return (
def?.embeddingCreate?.(inst) ?? new MockEmbeddingProvider(inst.modelName)
);
}

View File

@@ -1,196 +1,16 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import type { ModelProviderInstance } from "./llm.js";
import { getDb } from "./db.js";
import { mapRow, type DbRow } from "./row-mapper.js";
let dbPathOverride: string | null = null;
let hasBootstrapped = false;
export function setDbPathOverride(p: string | null) {
dbPathOverride = p;
}
export function resetHasBootstrapped() {
hasBootstrapped = false;
}
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() {
let dbPath: string;
if (dbPathOverride) {
dbPath = dbPathOverride;
} else {
const wsRoot = getWorkspaceRoot();
const dbDir = path.resolve(wsRoot, "data");
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
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,
type TEXT NOT NULL DEFAULT 'generative'
)
`,
).run();
try {
db.prepare(
`ALTER TABLE provider_instances ADD COLUMN modelName TEXT`,
).run();
} catch {
// ignore
}
try {
db.prepare(
`ALTER TABLE provider_instances ADD COLUMN type TEXT NOT NULL DEFAULT 'generative'`,
).run();
} catch {
// ignore
}
try {
db.prepare(
`ALTER TABLE provider_instances ADD COLUMN maxContext INTEGER`,
).run();
} catch {
// ignore
}
// Auto-bootstrap environment variables if DB contains 0 instances
try {
if (!hasBootstrapped) {
const totalCount = db
.prepare(`SELECT COUNT(*) as count FROM provider_instances`)
.get() as { count: number };
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
let hasInsertedGenerative = false;
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"Gemini (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-2.5-flash",
"generative",
32768,
);
hasInsertedGenerative = true;
const embedId = "provider-default-google-embed";
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
embedId,
"Gemini Embed (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-embedding-001",
"embedding",
0,
);
}
if (openRouterKey && openRouterKey.trim()) {
const id = "provider-default-openrouter";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"OpenRouter (Env)",
"openrouter",
openRouterKey.trim(),
isActive,
"google/gemini-2.5-flash",
"generative",
32768,
);
}
}
hasBootstrapped = true;
}
} catch {
// ignore write lock issues or other DB errors during bootstrap
}
return db;
}
export { setDbPath as setDbPathOverride } from "./db.js";
export class ProviderManager {
static list(): ModelProviderInstance[] {
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;
type: string;
maxContext?: number;
}[];
return rows.map((r) => ({
id: r.id,
name: r.name,
providerName: r.providerName,
apiKey: r.apiKey,
isActive: r.isActive === 1,
modelName: r.modelName || undefined,
type: (r.type as "generative" | "embedding") || "generative",
maxContext:
r.maxContext !== undefined && r.maxContext !== null
? r.maxContext
: r.type === "embedding"
? 0
: 32768,
}));
} finally {
db.close();
}
const db = getDb();
const rows = db
.prepare("SELECT * FROM provider_instances")
.all() as DbRow[];
return rows.map(mapRow);
}
static create(
@@ -200,94 +20,79 @@ export class ProviderManager {
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): ModelProviderInstance {
const db = getSettingsDb();
try {
const id = "provider-" + Date.now();
const activeCount = db
.prepare(
`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?`,
)
.get(type) as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
const db = getDb();
const id = "provider-" + Date.now();
const activeCount = db
.prepare(
"SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?",
)
.get(type) as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
const actualMaxContext =
maxContext !== undefined
? maxContext
: type === "generative"
? 32768
: 0;
const actualMaxContext =
maxContext !== undefined ? maxContext : type === "generative" ? 32768 : 0;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
name,
providerName,
apiKey,
isActive,
modelName || null,
type,
actualMaxContext,
);
db.prepare(
`INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext, endpointUrl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id,
name,
providerName,
apiKey,
isActive,
modelName || null,
type,
actualMaxContext,
endpointUrl || null,
);
return {
id,
name,
providerName,
apiKey,
isActive: isActive === 1,
modelName,
type,
maxContext: actualMaxContext,
};
} finally {
db.close();
}
return {
id,
name,
providerName,
apiKey,
isActive: isActive === 1,
modelName,
type,
maxContext: actualMaxContext,
endpointUrl,
};
}
static delete(id: string): void {
const db = getSettingsDb();
try {
const provider = db
.prepare(`SELECT isActive, type FROM provider_instances WHERE id = ?`)
.get(id) as { isActive: number; type: string } | undefined;
db.prepare(`DELETE FROM provider_instances WHERE id = ?`).run(id);
const db = getDb();
const provider = db
.prepare("SELECT isActive, type FROM provider_instances WHERE id = ?")
.get(id) as { isActive: number; type: string } | 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 WHERE type = ? LIMIT 1`)
.get(provider.type) as { id: string } | undefined;
if (next) {
db.prepare(
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
).run(next.id);
}
if (provider && provider.isActive === 1) {
const next = db
.prepare("SELECT id FROM provider_instances WHERE type = ? LIMIT 1")
.get(provider.type) 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 {
const target = db
.prepare(`SELECT type FROM provider_instances WHERE id = ?`)
.get(id) as { type: string } | undefined;
if (target) {
db.prepare(
`UPDATE provider_instances SET isActive = 0 WHERE type = ?`,
).run(target.type);
db.prepare(
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
).run(id);
}
} finally {
db.close();
const db = getDb();
const target = db
.prepare("SELECT type FROM provider_instances WHERE id = ?")
.get(id) as { type: string } | undefined;
if (target) {
db.prepare(
"UPDATE provider_instances SET isActive = 0 WHERE type = ?",
).run(target.type);
db.prepare("UPDATE provider_instances SET isActive = 1 WHERE id = ?").run(
id,
);
}
}
@@ -299,329 +104,99 @@ export class ProviderManager {
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): void {
const db = getSettingsDb();
try {
const actualMaxContext =
maxContext !== undefined
? maxContext
: type === "generative"
? 32768
: 0;
if (apiKey && apiKey.trim()) {
db.prepare(
`
UPDATE provider_instances
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?
WHERE id = ?
`,
).run(
name,
providerName,
apiKey,
modelName || null,
type,
actualMaxContext,
id,
);
} else {
db.prepare(
`
UPDATE provider_instances
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?
WHERE id = ?
`,
).run(
name,
providerName,
modelName || null,
type,
actualMaxContext,
id,
);
}
} finally {
db.close();
const db = getDb();
const actualMaxContext =
maxContext !== undefined ? maxContext : type === "generative" ? 32768 : 0;
if (apiKey && apiKey.trim()) {
db.prepare(
`UPDATE provider_instances
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ?
WHERE id = ?`,
).run(
name,
providerName,
apiKey,
modelName || null,
type,
actualMaxContext,
endpointUrl || null,
id,
);
} else {
db.prepare(
`UPDATE provider_instances
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ?
WHERE id = ?`,
).run(
name,
providerName,
modelName || null,
type,
actualMaxContext,
endpointUrl || null,
id,
);
}
}
static getActive(
type: "generative" | "embedding" = "generative",
): ModelProviderInstance | null {
const db = getSettingsDb();
const db = getDb();
try {
const row = db
.prepare(
`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`,
"SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?",
)
.get(type) as
| {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
}
| undefined;
.get(type) as DbRow | undefined;
if (!row) {
const totalCount = db
.prepare(`SELECT COUNT(*) as count FROM provider_instances`)
.get() as { count: number };
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
let hasInsertedGenerative = false;
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"Gemini (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-2.5-flash",
"generative",
32768,
);
hasInsertedGenerative = true;
const embedId = "provider-default-google-embed";
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
embedId,
"Gemini Embed (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-embedding-001",
"embedding",
0,
);
}
if (openRouterKey && openRouterKey.trim()) {
const id = "provider-default-openrouter";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"OpenRouter (Env)",
"openrouter",
openRouterKey.trim(),
isActive,
"google/gemini-2.5-flash",
"generative",
32768,
);
}
const retryRow = db
.prepare(
`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`,
)
.get(type) as
| {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
}
| undefined;
if (retryRow) {
return {
id: retryRow.id,
name: retryRow.name,
providerName: retryRow.providerName,
apiKey: retryRow.apiKey,
isActive: true,
modelName: retryRow.modelName || undefined,
type: retryRow.type as "generative" | "embedding",
maxContext:
retryRow.maxContext !== undefined &&
retryRow.maxContext !== null
? retryRow.maxContext
: retryRow.type === "embedding"
? 0
: 32768,
};
}
}
// If there's no active row but some rows exist, return the first one as active, or update it
const firstRow = db
.prepare(`SELECT * FROM provider_instances WHERE type = ? LIMIT 1`)
.get(type) as
| {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
}
| undefined;
if (firstRow) {
db.prepare(
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
).run(firstRow.id);
return {
id: firstRow.id,
name: firstRow.name,
providerName: firstRow.providerName,
apiKey: firstRow.apiKey,
isActive: true,
modelName: firstRow.modelName || undefined,
type: firstRow.type as "generative" | "embedding",
maxContext:
firstRow.maxContext !== undefined && firstRow.maxContext !== null
? firstRow.maxContext
: firstRow.type === "embedding"
? 0
: 32768,
};
}
return null;
if (row) {
return mapRow(row);
}
return {
id: row.id,
name: row.name,
providerName: row.providerName,
apiKey: row.apiKey,
isActive: true,
modelName: row.modelName || undefined,
type: (row.type as "generative" | "embedding") || "generative",
maxContext:
row.maxContext !== undefined && row.maxContext !== null
? row.maxContext
: row.type === "embedding"
? 0
: 32768,
};
} catch {
const googleKey = process.env.GOOGLE_API_KEY;
if (type === "embedding") {
if (googleKey && googleKey.trim()) {
return {
id: "provider-default-env-embed-fallback",
name: "Gemini Embed (Env Fallback)",
providerName: "google-genai",
apiKey: googleKey.trim(),
isActive: true,
modelName: "gemini-embedding-001",
type: "embedding",
maxContext: 0,
};
}
return null;
const firstRow = db
.prepare("SELECT * FROM provider_instances WHERE type = ? LIMIT 1")
.get(type) as DbRow | undefined;
if (firstRow) {
db.prepare(
"UPDATE provider_instances SET isActive = 1 WHERE id = ?",
).run(firstRow.id);
return mapRow(firstRow);
}
// generative fallback
if (googleKey && googleKey.trim()) {
return {
id: "provider-default-env-fallback",
name: "Gemini (Env Fallback)",
providerName: "google-genai",
apiKey: googleKey.trim(),
isActive: true,
modelName: "gemini-2.5-flash",
type: "generative",
maxContext: 32768,
};
}
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (openRouterKey && openRouterKey.trim()) {
return {
id: "provider-default-env-fallback",
name: "OpenRouter (Env Fallback)",
providerName: "openrouter",
apiKey: openRouterKey.trim(),
isActive: true,
modelName: "google/gemini-2.5-flash",
type: "generative",
maxContext: 32768,
};
}
return null;
} finally {
db.close();
} catch {
return null;
}
}
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();
const db = getDb();
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;
}
static setMapping(task: string, providerInstanceId: string): void {
const db = getSettingsDb();
try {
const db = getDb();
if (!providerInstanceId) {
db.prepare("DELETE FROM provider_mappings WHERE task = ?").run(task);
} else {
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();
`INSERT INTO provider_mappings (task, providerInstanceId)
VALUES (?, ?)
ON CONFLICT(task) DO UPDATE SET providerInstanceId = excluded.providerInstanceId`,
).run(task, providerInstanceId);
}
}
}

View File

@@ -0,0 +1,116 @@
import { ChatAnthropic } from "@langchain/anthropic";
import { ILLMProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import { registerProvider, registerGenerative } from "../registry.js";
import { fetchWithTimeout, type ModelInfo } from "../model-lister.js";
async function fetchAnthropicModels(apiKey: string): Promise<ModelInfo[]> {
const models: ModelInfo[] = [];
let afterId: string | undefined;
do {
const url = new URL("https://api.anthropic.com/v1/models");
url.searchParams.set("limit", "1000");
if (afterId) {
url.searchParams.set("after_id", afterId);
}
const res = await fetchWithTimeout(url.toString(), {
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
Accept: "application/json",
},
});
if (!res.ok) return models;
const json = (await res.json()) as {
data?: { id: string; display_name?: string }[];
has_more?: boolean;
last_id?: string;
};
for (const m of json.data ?? []) {
models.push({ id: m.id, name: m.display_name || m.id });
}
afterId = json.has_more ? json.last_id : undefined;
} while (afterId);
return models;
}
export class AnthropicProvider extends BaseLLMProvider {
static {
registerProvider({
id: "anthropic",
displayName: "Anthropic Claude",
description: "Official Claude integration using @langchain/anthropic SDK",
envVar: "ANTHROPIC_API_KEY",
capabilities: { generative: true, embedding: false },
defaultModel: "claude-3-5-sonnet-latest",
defaultMaxContext: 200000,
fallbackPriority: 2,
listModels: fetchAnthropicModels,
});
registerGenerative(
"anthropic",
(inst: ModelProviderInstance) =>
new AnthropicProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new AnthropicProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "Anthropic";
protected readonly model: ChatAnthropic;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 200000;
constructor(
apiKey?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
super();
const {
key,
model,
providerInstanceName: resolvedName,
maxContext: resolvedMax,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "anthropic",
envVarName: "ANTHROPIC_API_KEY",
type: "generative",
});
if (!key) {
throw new Error(
"ANTHROPIC_API_KEY is required to initialize AnthropicProvider",
);
}
this.providerInstanceName = resolvedName;
this.maxContextUsed = resolvedMax;
this.modelNameUsed = model || "claude-3-5-sonnet-latest";
this.model = new ChatAnthropic({ apiKey: key, model: this.modelNameUsed });
}
}

View File

@@ -0,0 +1,82 @@
import { ChatDeepSeek } from "@langchain/deepseek";
import { ILLMProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import { registerProvider, registerGenerative } from "../registry.js";
import { fetchOpenAICompatibleModels } from "../model-lister.js";
export class DeepSeekProvider extends BaseLLMProvider {
static {
registerProvider({
id: "deepseek",
displayName: "DeepSeek",
description:
"Official DeepSeek integration using @langchain/deepseek SDK",
envVar: "DEEPSEEK_API_KEY",
capabilities: { generative: true, embedding: false },
defaultModel: "deepseek-chat",
defaultMaxContext: 64000,
fallbackPriority: 4,
listModels: (apiKey) =>
fetchOpenAICompatibleModels("https://api.deepseek.com", apiKey),
});
registerGenerative(
"deepseek",
(inst: ModelProviderInstance) =>
new DeepSeekProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new DeepSeekProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "DeepSeek";
protected readonly model: ChatDeepSeek;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 64000;
constructor(
apiKey?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
super();
const {
key,
model,
providerInstanceName: resolvedName,
maxContext: resolvedMax,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "deepseek",
envVarName: "DEEPSEEK_API_KEY",
type: "generative",
});
if (!key) {
throw new Error(
"DEEPSEEK_API_KEY is required to initialize DeepSeekProvider",
);
}
this.providerInstanceName = resolvedName;
this.maxContextUsed = resolvedMax;
this.modelNameUsed = model || "deepseek-chat";
this.model = new ChatDeepSeek({ apiKey: key, model: this.modelNameUsed });
}
}

View File

@@ -1,31 +1,96 @@
import { z } from "zod";
import {
ChatGoogleGenerativeAI,
GoogleGenerativeAIEmbeddings,
} from "@langchain/google-genai";
import {
import type {
ILLMProvider,
LLMRequest,
LLMResponse,
LLMCallRecord,
IEmbeddingProvider,
ModelProviderInstance,
} from "../llm.js";
import { llmConfig } from "../config.js";
import {
registerProvider,
registerGenerative,
registerEmbedding,
} from "../registry.js";
import { fetchWithTimeout, type ModelInfo } from "../model-lister.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import { getLlmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
export class GeminiProvider implements ILLMProvider {
static readonly providerId = "google-genai";
static readonly displayName = "Google Gemini";
static readonly description =
"Official Gemini integration using Google Gen AI SDK";
static readonly defaultModel = "gemini-2.5-flash";
async function fetchGeminiModels(apiKey: string): Promise<ModelInfo[]> {
const models: ModelInfo[] = [];
let pageToken: string | undefined;
do {
const url = new URL(
"https://generativelanguage.googleapis.com/v1beta/models",
);
url.searchParams.set("key", apiKey);
url.searchParams.set("pageSize", "100");
if (pageToken) {
url.searchParams.set("pageToken", pageToken);
}
const res = await fetchWithTimeout(url.toString());
if (!res.ok) return models;
const json = (await res.json()) as {
models?: { name: string; displayName?: string }[];
nextPageToken?: string;
};
for (const m of json.models ?? []) {
const id = m.name.replace(/^models\//, "");
models.push({ id, name: m.displayName || id });
}
pageToken = json.nextPageToken;
} while (pageToken);
return models;
}
export class GeminiProvider extends BaseLLMProvider {
static {
registerProvider({
id: "google-genai",
displayName: "Google Gemini",
description: "Official Gemini integration using Google Gen AI SDK",
envVar: "GOOGLE_API_KEY",
capabilities: { generative: true, embedding: true },
defaultModel: "gemini-2.5-flash",
defaultEmbeddingModel: "gemini-embedding-001",
defaultMaxContext: 32768,
fallbackPriority: 0,
listModels: fetchGeminiModels,
});
registerGenerative(
"google-genai",
(inst: ModelProviderInstance) =>
new GeminiProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
providerName = "Gemini";
private model: ChatGoogleGenerativeAI;
private modelNameUsed: string;
private providerInstanceName?: string;
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
protected readonly model: ChatGoogleGenerativeAI;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected readonly defaultMaxContext = 32768;
static create(inst: ModelProviderInstance): ILLMProvider {
return new GeminiProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
constructor(
apiKey?: string,
@@ -33,113 +98,66 @@ export class GeminiProvider implements ILLMProvider {
providerInstanceName?: string,
maxContext?: number,
) {
let key = apiKey;
let model = modelName;
this.providerInstanceName = providerInstanceName;
this.maxContextUsed = maxContext;
if (!key) {
const active = ProviderManager.getActive("generative");
if (active && active.providerName === GeminiProvider.providerId) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
if (this.maxContextUsed === undefined) {
this.maxContextUsed = active.maxContext;
}
}
}
if (!key) {
key = llmConfig.GOOGLE_API_KEY;
if (!this.providerInstanceName && key) {
this.providerInstanceName = "Environment Variable";
}
}
super();
const {
key,
model,
providerInstanceName: pn,
maxContext: mc,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "google-genai",
envVarName: "GOOGLE_API_KEY",
type: "generative",
});
if (!key) {
throw new Error(
"GOOGLE_API_KEY is required to initialize GeminiProvider",
);
}
this.providerInstanceName = pn;
this.maxContextUsed = mc;
this.modelNameUsed = model || "gemini-2.5-flash";
this.model = new ChatGoogleGenerativeAI({
apiKey: key,
model: this.modelNameUsed,
});
}
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const structuredModel = this.model.withStructuredOutput(request.schema, {
includeRaw: true,
});
const result = (await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
])) as unknown as {
parsed?: z.infer<T>;
raw?: {
usage_metadata?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
};
};
};
const parsed = result?.parsed;
const raw = result?.raw;
const usage = {
inputTokens: raw?.usage_metadata?.input_tokens || 0,
outputTokens: raw?.usage_metadata?.output_tokens || 0,
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
maxContext:
this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
};
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
return { success: true, data: parsed, usage };
}
}
export class GeminiEmbeddingProvider implements IEmbeddingProvider {
static readonly providerId = "google-genai";
static readonly displayName = "Google Gemini Embeddings";
static {
registerEmbedding(
"google-genai",
(inst: ModelProviderInstance) =>
new GeminiEmbeddingProvider(inst.apiKey, inst.modelName),
);
}
providerName = "Gemini";
private model: GoogleGenerativeAIEmbeddings;
static create(inst: ModelProviderInstance): IEmbeddingProvider {
return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName);
}
constructor(apiKey?: string, modelName?: string) {
let key = apiKey;
let model = modelName;
if (!key) {
const active = ProviderManager.getActive("embedding");
if (active) {
if (active && active.providerName === "google-genai") {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
if (!model) model = active.modelName;
}
}
if (!key) {
key = llmConfig.GOOGLE_API_KEY;
key = getLlmConfig().GOOGLE_API_KEY;
}
if (!key) {

View File

@@ -0,0 +1,79 @@
import { ChatGroq } from "@langchain/groq";
import { ILLMProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import { registerProvider, registerGenerative } from "../registry.js";
import { fetchOpenAICompatibleModels } from "../model-lister.js";
export class GroqProvider extends BaseLLMProvider {
static {
registerProvider({
id: "groq",
displayName: "Groq",
description: "Official Groq integration using @langchain/groq SDK",
envVar: "GROQ_API_KEY",
capabilities: { generative: true, embedding: false },
defaultModel: "llama-3.3-70b-versatile",
defaultMaxContext: 8192,
fallbackPriority: 3,
listModels: (apiKey) =>
fetchOpenAICompatibleModels("https://api.groq.com/openai/v1", apiKey),
});
registerGenerative(
"groq",
(inst: ModelProviderInstance) =>
new GroqProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new GroqProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "Groq";
protected readonly model: ChatGroq;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 8192;
constructor(
apiKey?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
super();
const {
key,
model,
providerInstanceName: resolvedName,
maxContext: resolvedMax,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "groq",
envVarName: "GROQ_API_KEY",
type: "generative",
});
if (!key) {
throw new Error("GROQ_API_KEY is required to initialize GroqProvider");
}
this.providerInstanceName = resolvedName;
this.maxContextUsed = resolvedMax;
this.modelNameUsed = model || "llama-3.3-70b-versatile";
this.model = new ChatGroq({ apiKey: key, model: this.modelNameUsed });
}
}

View File

@@ -6,13 +6,33 @@ import {
LLMCallRecord,
IEmbeddingProvider,
} from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import {
registerProvider,
registerGenerative,
registerEmbedding,
} from "../registry.js";
export class MockLLMProvider implements ILLMProvider {
static readonly providerId = "mock";
static readonly displayName = "Mock LLM Provider";
static readonly description =
"Stateless mock provider for testing and offline development";
static readonly defaultModel = "mock";
static {
registerProvider({
id: "mock",
displayName: "Mock LLM Provider",
description:
"Stateless mock provider for testing and offline development",
capabilities: { generative: true, embedding: true },
defaultModel: "mock",
defaultEmbeddingModel: "mock-embeddings",
defaultMaxContext: 0,
fallbackPriority: 1000,
listModels: () => Promise.resolve([{ id: "mock", name: "Mock Model" }]),
});
registerGenerative("mock", () => new MockLLMProvider([]));
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new MockLLMProvider([]);
}
providerName = "mock";
private callCount = 0;
@@ -46,7 +66,17 @@ export class MockLLMProvider implements ILLMProvider {
}
export class MockEmbeddingProvider implements IEmbeddingProvider {
static readonly providerId = "mock";
static {
registerEmbedding(
"mock",
(inst: ModelProviderInstance) =>
new MockEmbeddingProvider(inst.modelName),
);
}
static create(inst: ModelProviderInstance): IEmbeddingProvider {
return new MockEmbeddingProvider(inst.modelName);
}
providerName = "mock";

View File

@@ -0,0 +1,172 @@
import { ChatOllama, OllamaEmbeddings } from "@langchain/ollama";
import { ILLMProvider, IEmbeddingProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { ProviderManager } from "../provider-manager.js";
import { BaseLLMProvider } from "../base-provider.js";
import {
registerProvider,
registerGenerative,
registerEmbedding,
} from "../registry.js";
import { fetchWithTimeout, type ModelInfo } from "../model-lister.js";
async function fetchOllamaModels(endpointUrl: string): Promise<ModelInfo[]> {
const base = endpointUrl.replace(/\/$/, "");
const res = await fetchWithTimeout(`${base}/api/tags`);
if (!res.ok) return [];
const json = (await res.json()) as {
models?: { name: string; model?: string }[];
};
return (json.models ?? []).map((m) => ({
id: m.name,
name: m.name,
}));
}
export class OllamaProvider extends BaseLLMProvider {
static {
registerProvider({
id: "ollama",
displayName: "Ollama",
description:
"Local model runner supporting open-source LLMs via the Ollama server",
capabilities: { generative: true, embedding: true },
defaultModel: "llama3.1",
defaultEmbeddingModel: "nomic-embed-text",
defaultMaxContext: 32768,
fallbackPriority: 100,
listModels: (_apiKey, endpointUrl) =>
fetchOllamaModels(endpointUrl || "http://localhost:11434"),
});
registerGenerative(
"ollama",
(inst: ModelProviderInstance) =>
new OllamaProvider(
inst.endpointUrl,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new OllamaProvider(
inst.endpointUrl,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "Ollama";
protected readonly model: ChatOllama;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 32768;
/**
* Creates an OllamaProvider.
*
* Resolution order for configuration:
* 1. Explicit constructor arguments
* 2. Active "generative" instance in ProviderManager whose providerName === "ollama"
* 3. Defaults (baseUrl: http://localhost:11434, model: llama3.1)
*
* No API key is required for Ollama. The `endpointUrl` in
* ModelProviderInstance stores the Ollama server base URL
* (e.g. "http://localhost:11434").
*/
constructor(
baseUrl?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
super();
let url = baseUrl;
let model = modelName;
this.providerInstanceName = providerInstanceName;
this.maxContextUsed = maxContext;
if (!url || !model) {
const active = ProviderManager.getActive("generative");
if (active && active.providerName === "ollama") {
if (!url) {
url = active.endpointUrl;
}
if (!model) {
model = active.modelName;
}
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
if (this.maxContextUsed === undefined) {
this.maxContextUsed = active.maxContext;
}
}
}
this.modelNameUsed = model || "llama3.1";
this.model = new ChatOllama({
baseUrl: url || "http://localhost:11434",
model: this.modelNameUsed,
});
}
}
export class OllamaEmbeddingProvider implements IEmbeddingProvider {
static {
registerEmbedding(
"ollama",
(inst: ModelProviderInstance) =>
new OllamaEmbeddingProvider(inst.endpointUrl, inst.modelName),
);
}
static create(inst: ModelProviderInstance): IEmbeddingProvider {
return new OllamaEmbeddingProvider(inst.endpointUrl, inst.modelName);
}
providerName = "Ollama";
private model: OllamaEmbeddings;
/**
* Creates an OllamaEmbeddingProvider.
*
* Resolution order:
* 1. Explicit constructor arguments
* 2. Active "embedding" instance in ProviderManager
* 3. Defaults (baseUrl: http://localhost:11434, model: nomic-embed-text)
*
* The `endpointUrl` field in ModelProviderInstance stores the base URL.
*/
constructor(baseUrl?: string, modelName?: string) {
let url = baseUrl;
let model = modelName;
if (!url || !model) {
const active = ProviderManager.getActive("embedding");
if (active && active.providerName === "ollama") {
if (!url) {
url = active.endpointUrl;
}
if (!model) {
model = active.modelName;
}
}
}
this.model = new OllamaEmbeddings({
baseUrl: url || "http://localhost:11434",
model: model || "nomic-embed-text",
});
}
async embed(text: string): Promise<number[]> {
return this.model.embedQuery(text);
}
}

View File

@@ -0,0 +1,139 @@
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { ILLMProvider, IEmbeddingProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { getLlmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import {
registerProvider,
registerGenerative,
registerEmbedding,
} from "../registry.js";
import { fetchOpenAICompatibleModels } from "../model-lister.js";
export class OpenAIProvider extends BaseLLMProvider {
static {
registerProvider({
id: "openai",
displayName: "OpenAI",
description: "Official OpenAI integration using @langchain/openai SDK",
envVar: "OPENAI_API_KEY",
capabilities: { generative: true, embedding: true },
defaultModel: "gpt-4o-mini",
defaultEmbeddingModel: "text-embedding-3-small",
defaultMaxContext: 128000,
fallbackPriority: 1,
listModels: (apiKey) =>
fetchOpenAICompatibleModels("https://api.openai.com/v1", apiKey),
});
registerGenerative(
"openai",
(inst: ModelProviderInstance) =>
new OpenAIProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new OpenAIProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "OpenAI";
protected readonly model: ChatOpenAI;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 128000;
constructor(
apiKey?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
super();
const {
key,
model,
providerInstanceName: resolvedName,
maxContext: resolvedMax,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "openai",
envVarName: "OPENAI_API_KEY",
type: "generative",
});
if (!key) {
throw new Error(
"OPENAI_API_KEY is required to initialize OpenAIProvider",
);
}
this.providerInstanceName = resolvedName;
this.maxContextUsed = resolvedMax;
this.modelNameUsed = model || "gpt-4o-mini";
this.model = new ChatOpenAI({ apiKey: key, model: this.modelNameUsed });
}
}
export class OpenAIEmbeddingProvider implements IEmbeddingProvider {
static {
registerEmbedding(
"openai",
(inst: ModelProviderInstance) =>
new OpenAIEmbeddingProvider(inst.apiKey, inst.modelName),
);
}
static create(inst: ModelProviderInstance): IEmbeddingProvider {
return new OpenAIEmbeddingProvider(inst.apiKey, inst.modelName);
}
providerName = "OpenAI";
private model: OpenAIEmbeddings;
constructor(apiKey?: string, modelName?: string) {
let key = apiKey;
let model = modelName;
if (!key) {
const active = ProviderManager.getActive("embedding");
if (active && active.providerName === "openai") {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
}
}
if (!key) {
key = getLlmConfig().OPENAI_API_KEY;
}
if (!key) {
throw new Error(
"OPENAI_API_KEY is required to initialize OpenAIEmbeddingProvider",
);
}
this.model = new OpenAIEmbeddings({
apiKey: key,
model: model || "text-embedding-3-small",
});
}
async embed(text: string): Promise<number[]> {
return this.model.embedQuery(text);
}
}

View File

@@ -1,27 +1,77 @@
import { z } from "zod";
import { ChatOpenRouter } from "@langchain/openrouter";
import {
ILLMProvider,
LLMRequest,
LLMResponse,
LLMCallRecord,
} from "../llm.js";
import { llmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
import { ILLMProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import { registerProvider, registerGenerative } from "../registry.js";
import { fetchWithTimeout, type ModelInfo } from "../model-lister.js";
export class OpenRouterProvider implements ILLMProvider {
static readonly providerId = "openrouter";
static readonly displayName = "OpenRouter";
static readonly description =
"Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models";
static readonly defaultModel = "google/gemini-2.5-flash";
async function fetchOpenRouterModels(apiKey: string): Promise<ModelInfo[]> {
const res = await fetchWithTimeout(
"https://openrouter.ai/api/v1/models",
apiKey
? {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
}
: { headers: { Accept: "application/json" } },
);
if (!res.ok) return [];
const json = (await res.json()) as {
data?: { id: string; name?: string; owned_by?: string }[];
};
return (json.data ?? []).map((m) => ({
id: m.id,
name: m.name || m.id,
ownedBy: m.owned_by,
}));
}
export class OpenRouterProvider extends BaseLLMProvider {
static {
registerProvider({
id: "openrouter",
displayName: "OpenRouter",
description:
"Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
envVar: "OPENROUTER_API_KEY",
capabilities: { generative: true, embedding: false },
defaultModel: "google/gemini-2.5-flash",
defaultEmbeddingModel: "openai/text-embedding-3-small",
defaultMaxContext: 32768,
fallbackPriority: 5,
listModels: fetchOpenRouterModels,
});
registerGenerative(
"openrouter",
(inst: ModelProviderInstance) =>
new OpenRouterProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new OpenRouterProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "OpenRouter";
private model: ChatOpenRouter;
private modelNameUsed: string;
private providerInstanceName?: string;
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
protected readonly model: ChatOpenRouter;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 32768;
constructor(
apiKey?: string,
@@ -29,86 +79,29 @@ export class OpenRouterProvider implements ILLMProvider {
providerInstanceName?: string,
maxContext?: number,
) {
let key = apiKey;
let model = modelName;
this.providerInstanceName = providerInstanceName;
this.maxContextUsed = maxContext;
if (!key) {
const active = ProviderManager.getActive("generative");
if (active && active.providerName === OpenRouterProvider.providerId) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
if (this.maxContextUsed === undefined) {
this.maxContextUsed = active.maxContext;
}
}
}
if (!key) {
key = llmConfig.OPENROUTER_API_KEY;
if (!this.providerInstanceName && key) {
this.providerInstanceName = "Environment Variable";
}
}
super();
const {
key,
model,
providerInstanceName: resolvedName,
maxContext: resolvedMax,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "openrouter",
envVarName: "OPENROUTER_API_KEY",
type: "generative",
});
if (!key) {
throw new Error(
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider",
);
}
this.providerInstanceName = resolvedName;
this.maxContextUsed = resolvedMax;
this.modelNameUsed = model || "google/gemini-2.5-flash";
this.model = new ChatOpenRouter({
apiKey: key,
model: this.modelNameUsed,
});
}
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const structuredModel = this.model.withStructuredOutput(request.schema, {
includeRaw: true,
});
const result = (await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
])) as unknown as {
parsed?: z.infer<T>;
raw?: {
usage_metadata?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
};
};
};
const parsed = result?.parsed;
const raw = result?.raw;
const usage = {
inputTokens: raw?.usage_metadata?.input_tokens || 0,
outputTokens: raw?.usage_metadata?.output_tokens || 0,
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
maxContext:
this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
};
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
return { success: true, data: parsed, usage };
this.model = new ChatOpenRouter({ apiKey: key, model: this.modelNameUsed });
}
}

View File

@@ -0,0 +1,79 @@
import type {
ILLMProvider,
IEmbeddingProvider,
ModelProviderInstance,
ModelProviderMeta,
} from "./llm.js";
import type { ModelInfo } from "./model-lister.js";
export interface ProviderDefinition {
id: string;
displayName: string;
description: string;
envVar?: string;
capabilities: { generative: boolean; embedding: boolean };
defaultModel: string;
defaultEmbeddingModel?: string;
defaultMaxContext: number;
fallbackPriority: number;
listModels?: (apiKey: string, endpointUrl?: string) => Promise<ModelInfo[]>;
generativeCreate?: (inst: ModelProviderInstance) => ILLMProvider;
embeddingCreate?: (inst: ModelProviderInstance) => IEmbeddingProvider;
}
const _entries = new Map<string, ProviderDefinition>();
type ProviderMeta = Omit<
ProviderDefinition,
"generativeCreate" | "embeddingCreate"
>;
export function registerProvider(meta: ProviderMeta) {
const existing = _entries.get(meta.id);
_entries.set(meta.id, {
...existing,
...meta,
generativeCreate: existing?.generativeCreate,
embeddingCreate: existing?.embeddingCreate,
});
}
export function registerGenerative(
id: string,
createFn: (inst: ModelProviderInstance) => ILLMProvider,
) {
const existing = _entries.get(id);
if (existing) {
existing.generativeCreate = createFn;
} else {
_entries.set(id, { id, generativeCreate: createFn } as ProviderDefinition);
}
}
export function registerEmbedding(
id: string,
createFn: (inst: ModelProviderInstance) => IEmbeddingProvider,
) {
const existing = _entries.get(id);
if (existing) {
existing.embeddingCreate = createFn;
} else {
_entries.set(id, { id, embeddingCreate: createFn } as ProviderDefinition);
}
}
export const ProviderRegistry = {
all: (): ProviderDefinition[] => [..._entries.values()],
get: (id: string): ProviderDefinition | undefined => _entries.get(id),
has: (id: string): boolean => _entries.has(id),
} as const;
export function toProviderMeta(def: ProviderDefinition): ModelProviderMeta {
return {
id: def.id,
displayName: def.displayName,
description: def.description,
defaultModel: def.defaultModel,
defaultEmbeddingModel: def.defaultEmbeddingModel || "",
};
}

View File

@@ -0,0 +1,32 @@
import type { ModelProviderInstance } from "./llm.js";
export type DbRow = {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
endpointUrl?: string;
};
export function mapRow(r: DbRow): ModelProviderInstance {
return {
id: r.id,
name: r.name,
providerName: r.providerName,
apiKey: r.apiKey,
isActive: r.isActive === 1,
modelName: r.modelName || undefined,
type: (r.type as "generative" | "embedding") || "generative",
maxContext:
r.maxContext !== undefined && r.maxContext !== null
? r.maxContext
: r.type === "embedding"
? 0
: 32768,
endpointUrl: r.endpointUrl || undefined,
};
}

View File

@@ -0,0 +1,126 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
import Database from "better-sqlite3";
describe("setup-provider CLI Tool Tests", () => {
let tempDbPath: string;
let scriptPath: string;
beforeEach(() => {
// Generate a unique temp database path
tempDbPath = path.resolve(
process.cwd(),
`test-cli-${Date.now()}-${Math.random().toString(36).substring(2)}.db`,
);
scriptPath = path.resolve(
process.cwd(),
"packages/llm/dist/bin/setup-provider.js",
);
});
afterEach(() => {
if (fs.existsSync(tempDbPath)) {
try {
fs.unlinkSync(tempDbPath);
} catch {
// ignore
}
}
});
test("prints help message when --help or -h is passed", () => {
const stdout = execSync(`node ${scriptPath} --help`).toString();
expect(stdout).toContain("Usage:");
expect(stdout).toContain("Options:");
expect(stdout).toContain("Registered Providers:");
});
test("creates a provider instance successfully via CLI flags", () => {
const cmd = `node ${scriptPath} --provider google-genai --key mock-key-abc --name "Test Gemini" --model "gemini-2.5-flash"`;
const stdout = execSync(cmd, {
env: { ...process.env, OMNIA_DB_PATH: tempDbPath },
}).toString();
expect(stdout).toContain("Successfully created provider instance:");
expect(stdout).toContain("Test Gemini");
expect(stdout).toContain("google-genai");
expect(stdout).toContain("mock-key-abc");
// Read the SQLite db directly to verify
const db = new Database(tempDbPath);
const rows = db.prepare("SELECT * FROM provider_instances").all() as {
name: string;
providerName: string;
apiKey: string;
modelName: string;
isActive: number;
}[];
expect(rows.length).toBe(1);
expect(rows[0].name).toBe("Test Gemini");
expect(rows[0].providerName).toBe("google-genai");
expect(rows[0].apiKey).toBe("mock-key-abc");
expect(rows[0].modelName).toBe("gemini-2.5-flash");
expect(rows[0].isActive).toBe(1);
db.close();
});
test("fails when required key is missing and env var is not set", () => {
let error: { status?: number; stderr?: Buffer } | undefined;
try {
execSync(`node ${scriptPath} --provider google-genai`, {
env: { ...process.env, OMNIA_DB_PATH: tempDbPath, GOOGLE_API_KEY: "" },
stdio: "pipe",
});
} catch (e) {
error = e as { status?: number; stderr?: Buffer };
}
expect(error).toBeDefined();
expect(error?.status).toBe(1);
expect(error?.stderr?.toString()).toContain("Error: API Key is required");
});
test("seeds from environment variables when using --all", () => {
const cmd = `node ${scriptPath} --all`;
const stdout = execSync(cmd, {
env: {
...process.env,
OMNIA_DB_PATH: tempDbPath,
GOOGLE_API_KEY: "mock-google-key-all",
OPENAI_API_KEY: "",
ANTHROPIC_API_KEY: "",
GROQ_API_KEY: "",
DEEPSEEK_API_KEY: "",
OPENROUTER_API_KEY: "",
},
}).toString();
expect(stdout).toContain(
"Created generative instance: Google Gemini (CLI)",
);
expect(stdout).toContain(
"Created embedding instance: Google Gemini Embed (CLI)",
);
const db = new Database(tempDbPath);
const rows = db.prepare("SELECT * FROM provider_instances").all() as {
type: string;
apiKey: string;
modelName: string;
}[];
// Should have both generative and embedding instances
expect(rows.length).toBe(2);
const gen = rows.find((r) => r.type === "generative");
const embed = rows.find((r) => r.type === "embedding");
expect(gen).toBeDefined();
expect(gen?.apiKey).toBe("mock-google-key-all");
expect(gen?.modelName).toBe("gemini-2.5-flash");
expect(embed).toBeDefined();
expect(embed?.apiKey).toBe("mock-google-key-all");
expect(embed?.modelName).toBe("gemini-embedding-001");
db.close();
});
});

View File

@@ -0,0 +1,128 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
vi.mock("../src/config.js", () => ({
getLlmConfig: () => mockConfig,
resetLlmConfig: () => {
for (const key of Object.keys(mockConfig)) {
delete mockConfig[key];
}
},
}));
const { getActiveMock } = vi.hoisted(() => ({
getActiveMock: vi.fn().mockReturnValue(null),
}));
vi.mock("../src/provider-manager.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/provider-manager.js")>();
return {
...actual,
ProviderManager: {
...actual.ProviderManager,
getActive: getActiveMock,
},
};
});
import { DeepSeekProvider } from "../src/providers/deepseek.js";
// Mock the ChatDeepSeek class
vi.mock("@langchain/deepseek", () => {
return {
ChatDeepSeek: class {
config: unknown;
constructor(config: unknown) {
this.config = config;
}
withStructuredOutput = vi.fn().mockImplementation(() => {
return {
invoke: vi.fn().mockImplementation(async () => {
return {
parsed: {
name: "mocked response",
success: true,
},
raw: {
usage_metadata: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
},
};
}),
};
});
},
};
});
describe("DeepSeekProvider Unit Tests (Tier 1)", () => {
test("initializes successfully with a provided apiKey", () => {
const provider = new DeepSeekProvider("dummy-key");
expect(provider.providerName).toBe("DeepSeek");
});
test("initializes successfully with apiKey from config", () => {
const originalKey = process.env.DEEPSEEK_API_KEY;
process.env.DEEPSEEK_API_KEY = "env-dummy-key";
mockConfig.DEEPSEEK_API_KEY = "env-dummy-key";
try {
const provider = new DeepSeekProvider();
expect(provider.providerName).toBe("DeepSeek");
} finally {
process.env.DEEPSEEK_API_KEY = originalKey;
delete mockConfig.DEEPSEEK_API_KEY;
}
});
test("throws error if no API key is provided or in config", () => {
const originalKey = process.env.DEEPSEEK_API_KEY;
process.env.DEEPSEEK_API_KEY = undefined;
mockConfig.DEEPSEEK_API_KEY = undefined;
try {
expect(() => new DeepSeekProvider()).toThrow(
"DEEPSEEK_API_KEY is required to initialize DeepSeekProvider",
);
} finally {
process.env.DEEPSEEK_API_KEY = originalKey;
}
});
test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => {
const provider = new DeepSeekProvider("dummy-key");
const TestSchema = z.object({
name: z.string(),
success: z.boolean(),
});
const response = await provider.generateStructuredResponse({
systemPrompt: "system prompt",
userContext: "user context",
schema: TestSchema,
});
expect(response.success).toBe(true);
expect(response.data).toEqual({
name: "mocked response",
success: true,
});
expect(response.usage).toEqual({
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
modelName: "deepseek-chat",
providerInstanceName: "Default",
maxContext: 64000,
});
expect(provider.lastCalls.length).toBe(1);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
vi.mock("../src/config.js", () => ({
getLlmConfig: () => mockConfig,
resetLlmConfig: () => {
for (const key of Object.keys(mockConfig)) {
delete mockConfig[key];
}
},
}));
const { getActiveMock } = vi.hoisted(() => ({
getActiveMock: vi.fn().mockReturnValue(null),
}));
vi.mock("../src/provider-manager.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/provider-manager.js")>();
return {
...actual,
ProviderManager: {
...actual.ProviderManager,
getActive: getActiveMock,
},
};
});
import { GroqProvider } from "../src/providers/groq.js";
// Mock the ChatGroq class
vi.mock("@langchain/groq", () => {
return {
ChatGroq: class {
config: unknown;
constructor(config: unknown) {
this.config = config;
}
withStructuredOutput = vi.fn().mockImplementation(() => {
return {
invoke: vi.fn().mockImplementation(async () => {
return {
parsed: {
name: "mocked response",
success: true,
},
raw: {
usage_metadata: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
},
};
}),
};
});
},
};
});
describe("GroqProvider Unit Tests (Tier 1)", () => {
test("initializes successfully with a provided apiKey", () => {
const provider = new GroqProvider("dummy-key");
expect(provider.providerName).toBe("Groq");
});
test("initializes successfully with apiKey from config", () => {
const originalKey = process.env.GROQ_API_KEY;
process.env.GROQ_API_KEY = "env-dummy-key";
mockConfig.GROQ_API_KEY = "env-dummy-key";
try {
const provider = new GroqProvider();
expect(provider.providerName).toBe("Groq");
} finally {
process.env.GROQ_API_KEY = originalKey;
delete mockConfig.GROQ_API_KEY;
}
});
test("throws error if no API key is provided or in config", () => {
const originalKey = process.env.GROQ_API_KEY;
process.env.GROQ_API_KEY = undefined;
mockConfig.GROQ_API_KEY = undefined;
try {
expect(() => new GroqProvider()).toThrow(
"GROQ_API_KEY is required to initialize GroqProvider",
);
} finally {
process.env.GROQ_API_KEY = originalKey;
}
});
test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => {
const provider = new GroqProvider("dummy-key");
const TestSchema = z.object({
name: z.string(),
success: z.boolean(),
});
const response = await provider.generateStructuredResponse({
systemPrompt: "system prompt",
userContext: "user context",
schema: TestSchema,
});
expect(response.success).toBe(true);
expect(response.data).toEqual({
name: "mocked response",
success: true,
});
expect(response.usage).toEqual({
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
modelName: "llama-3.3-70b-versatile",
providerInstanceName: "Default",
maxContext: 8192,
});
expect(provider.lastCalls.length).toBe(1);
});
});

View File

@@ -0,0 +1,120 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { ModelLister } from "@omnia/llm";
describe("ModelLister Unit Tests (Tier 1)", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
ModelLister.clearCache();
});
afterEach(() => {
vi.restoreAllMocks();
});
test("returns mock provider list instantly without fetch", async () => {
const models = await ModelLister.listModels("mock", "none");
expect(models).toEqual([{ id: "mock", name: "Mock Model" }]);
expect(fetch).not.toHaveBeenCalled();
});
test("fetches and caches OpenAI-compatible models", async () => {
const mockResponse = {
data: [
{ id: "gpt-4o", owned_by: "openai" },
{ id: "gpt-4o-mini", owned_by: "openai" },
],
};
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockResponse,
});
vi.stubGlobal("fetch", mockFetch);
// First call: Should fetch
const models = await ModelLister.listModels("openai", "test-key");
expect(models).toEqual([
{ id: "gpt-4o", name: "gpt-4o", ownedBy: "openai" },
{ id: "gpt-4o-mini", name: "gpt-4o-mini", ownedBy: "openai" },
]);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
"https://api.openai.com/v1/models",
expect.objectContaining({
headers: {
Authorization: "Bearer test-key",
Accept: "application/json",
},
}),
);
// Second call: Should read from cache
const cachedModels = await ModelLister.listModels("openai", "test-key");
expect(cachedModels).toEqual(models);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
test("respects cache invalidation", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: "model-1" }] }),
});
vi.stubGlobal("fetch", mockFetch);
await ModelLister.listModels("openai", "test-key");
expect(mockFetch).toHaveBeenCalledTimes(1);
// Invalidate
ModelLister.invalidateCache("openai", "test-key");
// Second call: Should fetch again
await ModelLister.listModels("openai", "test-key");
expect(mockFetch).toHaveBeenCalledTimes(2);
});
test("gracefully returns empty array on fetch failure", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
vi.stubGlobal("fetch", mockFetch);
const models = await ModelLister.listModels("openai", "bad-key");
expect(models).toEqual([]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
test("handles Gemini pagination correctly", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
models: [
{
name: "models/gemini-2.5-flash",
displayName: "Gemini 2.5 Flash",
},
],
nextPageToken: "token-1",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
models: [
{ name: "models/gemini-2.5-pro", displayName: "Gemini 2.5 Pro" },
],
}),
});
vi.stubGlobal("fetch", mockFetch);
const models = await ModelLister.listModels("google-genai", "gemini-key");
expect(models).toEqual([
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
]);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,167 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
vi.mock("../src/config.js", () => ({
getLlmConfig: () => mockConfig,
resetLlmConfig: () => {
for (const key of Object.keys(mockConfig)) {
delete mockConfig[key];
}
},
}));
const { getActiveMock } = vi.hoisted(() => ({
getActiveMock: vi.fn().mockReturnValue(null),
}));
vi.mock("../src/provider-manager.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/provider-manager.js")>();
return {
...actual,
ProviderManager: {
...actual.ProviderManager,
getActive: getActiveMock,
},
};
});
import {
OpenAIProvider,
OpenAIEmbeddingProvider,
} from "../src/providers/openai.js";
// Mock the ChatOpenAI and OpenAIEmbeddings classes
vi.mock("@langchain/openai", () => {
return {
ChatOpenAI: class {
config: unknown;
constructor(config: unknown) {
this.config = config;
}
withStructuredOutput = vi.fn().mockImplementation(() => {
return {
invoke: vi.fn().mockImplementation(async () => {
return {
parsed: {
name: "mocked response",
success: true,
},
raw: {
usage_metadata: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
},
};
}),
};
});
},
OpenAIEmbeddings: class {
config: unknown;
constructor(config: unknown) {
this.config = config;
}
embedQuery = vi.fn().mockImplementation(async (text: string) => {
return [0.1, 0.2, 0.3];
});
},
};
});
describe("OpenAIProvider Unit Tests (Tier 1)", () => {
test("initializes successfully with a provided apiKey", () => {
const provider = new OpenAIProvider("dummy-key");
expect(provider.providerName).toBe("OpenAI");
});
test("initializes successfully with apiKey from config", () => {
const originalKey = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "env-dummy-key";
mockConfig.OPENAI_API_KEY = "env-dummy-key";
try {
const provider = new OpenAIProvider();
expect(provider.providerName).toBe("OpenAI");
} finally {
process.env.OPENAI_API_KEY = originalKey;
delete mockConfig.OPENAI_API_KEY;
}
});
test("throws error if no API key is provided or in config", () => {
const originalKey = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = undefined;
mockConfig.OPENAI_API_KEY = undefined;
try {
expect(() => new OpenAIProvider()).toThrow(
"OPENAI_API_KEY is required to initialize OpenAIProvider",
);
} finally {
process.env.OPENAI_API_KEY = originalKey;
}
});
test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => {
const provider = new OpenAIProvider("dummy-key");
const TestSchema = z.object({
name: z.string(),
success: z.boolean(),
});
const response = await provider.generateStructuredResponse({
systemPrompt: "system prompt",
userContext: "user context",
schema: TestSchema,
});
expect(response.success).toBe(true);
expect(response.data).toEqual({
name: "mocked response",
success: true,
});
expect(response.usage).toEqual({
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
modelName: "gpt-4o-mini",
providerInstanceName: "Default",
maxContext: 128000,
});
expect(provider.lastCalls.length).toBe(1);
});
});
describe("OpenAIEmbeddingProvider Unit Tests (Tier 1)", () => {
test("initializes successfully with a provided apiKey", () => {
const provider = new OpenAIEmbeddingProvider("dummy-key");
expect(provider.providerName).toBe("OpenAI");
});
test("initializes successfully with apiKey from config", () => {
const originalKey = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "env-dummy-key";
mockConfig.OPENAI_API_KEY = "env-dummy-key";
try {
const provider = new OpenAIEmbeddingProvider();
expect(provider.providerName).toBe("OpenAI");
} finally {
process.env.OPENAI_API_KEY = originalKey;
delete mockConfig.OPENAI_API_KEY;
}
});
test("embed returns dummy array successfully", async () => {
const provider = new OpenAIEmbeddingProvider("dummy-key");
const result = await provider.embed("hello");
expect(result).toEqual([0.1, 0.2, 0.3]);
});
});

View File

@@ -1,7 +1,34 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
vi.mock("../src/config.js", () => ({
getLlmConfig: () => mockConfig,
resetLlmConfig: () => {
for (const key of Object.keys(mockConfig)) {
delete mockConfig[key];
}
},
}));
const { getActiveMock } = vi.hoisted(() => ({
getActiveMock: vi.fn().mockReturnValue(null),
}));
vi.mock("../src/provider-manager.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/provider-manager.js")>();
return {
...actual,
ProviderManager: {
...actual.ProviderManager,
getActive: getActiveMock,
},
};
});
import { OpenRouterProvider } from "../src/providers/openrouter.js";
import { llmConfig } from "../src/config.js";
// Mock the ChatOpenRouter class
vi.mock("@langchain/openrouter", () => {
@@ -14,7 +41,6 @@ vi.mock("@langchain/openrouter", () => {
withStructuredOutput = vi.fn().mockImplementation(() => {
return {
invoke: vi.fn().mockImplementation(async () => {
// Return a mock output that matches the includeRaw: true structure
return {
parsed: {
name: "mocked response",
@@ -42,29 +68,30 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
});
test("initializes successfully with apiKey from config", () => {
// Save current config
const originalKey = llmConfig.OPENROUTER_API_KEY;
llmConfig.OPENROUTER_API_KEY = "env-dummy-key";
const originalKey = process.env.OPENROUTER_API_KEY;
process.env.OPENROUTER_API_KEY = "env-dummy-key";
mockConfig.OPENROUTER_API_KEY = "env-dummy-key";
try {
const provider = new OpenRouterProvider();
expect(provider.providerName).toBe("OpenRouter");
} finally {
llmConfig.OPENROUTER_API_KEY = originalKey;
process.env.OPENROUTER_API_KEY = originalKey;
delete mockConfig.OPENROUTER_API_KEY;
}
});
test("throws error if no API key is provided or in config", () => {
// Save current config
const originalKey = llmConfig.OPENROUTER_API_KEY;
llmConfig.OPENROUTER_API_KEY = undefined;
const originalKey = process.env.OPENROUTER_API_KEY;
process.env.OPENROUTER_API_KEY = undefined;
mockConfig.OPENROUTER_API_KEY = undefined;
try {
expect(() => new OpenRouterProvider()).toThrow(
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider",
);
} finally {
llmConfig.OPENROUTER_API_KEY = originalKey;
process.env.OPENROUTER_API_KEY = originalKey;
}
});

View File

@@ -1,24 +1,27 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import {
ProviderManager,
setDbPathOverride,
resetHasBootstrapped,
} from "../src/index.js";
import { ProviderManager, setDbPathOverride } from "../src/index.js";
describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
let tempDbPath: string;
let originalGoogle: string | undefined;
let originalOpenRouter: string | undefined;
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
originalGoogle = process.env.GOOGLE_API_KEY;
originalOpenRouter = process.env.OPENROUTER_API_KEY;
savedEnv = {
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
GROQ_API_KEY: process.env.GROQ_API_KEY,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
};
delete process.env.GOOGLE_API_KEY;
delete process.env.OPENROUTER_API_KEY;
resetHasBootstrapped();
delete process.env.ANTHROPIC_API_KEY;
delete process.env.OPENAI_API_KEY;
delete process.env.GROQ_API_KEY;
delete process.env.DEEPSEEK_API_KEY;
// Generate a unique temp database path for this test run
tempDbPath = path.resolve(
@@ -37,54 +40,189 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
// ignore
}
}
if (originalGoogle !== undefined) {
process.env.GOOGLE_API_KEY = originalGoogle;
} else {
delete process.env.GOOGLE_API_KEY;
}
if (originalOpenRouter !== undefined) {
process.env.OPENROUTER_API_KEY = originalOpenRouter;
} else {
delete process.env.OPENROUTER_API_KEY;
for (const [key, value] of Object.entries(savedEnv)) {
if (value !== undefined) {
process.env[key] = value;
} else {
delete process.env[key];
}
}
});
test("auto-bootstraps Gemini and OpenRouter when database is empty and environment variables are present", () => {
process.env.GOOGLE_API_KEY = "mock-google-key-123";
process.env.OPENROUTER_API_KEY = "mock-openrouter-key-456";
test("returns empty list when database is empty and no auto-bootstraps", () => {
process.env.GOOGLE_API_KEY = "mock-google-key";
const list = ProviderManager.list();
expect(list.length).toBe(3);
const gemini = list.find((p) => p.providerName === "google-genai");
expect(gemini).toBeDefined();
expect(gemini?.name).toBe("Gemini (Env)");
expect(gemini?.apiKey).toBe("mock-google-key-123");
expect(gemini?.modelName).toBe("gemini-2.5-flash");
expect(gemini?.isActive).toBe(true); // first inserted is active
const openrouter = list.find((p) => p.providerName === "openrouter");
expect(openrouter).toBeDefined();
expect(openrouter?.name).toBe("OpenRouter (Env)");
expect(openrouter?.apiKey).toBe("mock-openrouter-key-456");
expect(openrouter?.modelName).toBe("google/gemini-2.5-flash");
expect(openrouter?.isActive).toBe(false); // second inserted is inactive
expect(list.length).toBe(0);
});
test("treats bootstrapped instances as normal provider instances (editable and deletable)", () => {
process.env.GOOGLE_API_KEY = "mock-google-key-123";
test("getActive returns null when no providers exist and no env vars", () => {
const active = ProviderManager.getActive("generative");
expect(active).toBeNull();
const activeEmbed = ProviderManager.getActive("embedding");
expect(activeEmbed).toBeNull();
});
test("getActive returns null when DB is empty", () => {
process.env.GOOGLE_API_KEY = "mock-google-key-123";
const active = ProviderManager.getActive("generative");
expect(active).toBeNull();
});
test("getActive returns first instance of type when none is active", () => {
// Manually create instances without any env var bootstrap
const inst1 = ProviderManager.create("Test Gemini", "google-genai", "key1");
const inst2 = ProviderManager.create(
"Test OpenAI",
"openai",
"key2",
"gpt-4o",
"generative",
128000,
);
expect(inst1.isActive).toBe(true); // first created auto-activates
expect(inst2.isActive).toBe(false);
// Deactivate both
ProviderManager.setActive("__nonexistent__"); // no-op for nonexistent
// Deactivate inst1 by setting another as active, then delete that
ProviderManager.setActive(inst2.id);
expect(
ProviderManager.list().find((p) => p.id === inst2.id)?.isActive,
).toBe(true);
expect(
ProviderManager.list().find((p) => p.id === inst1.id)?.isActive,
).toBe(false);
// Delete the active one → auto-promotes inst1
ProviderManager.delete(inst2.id);
const promoted = ProviderManager.list().find((p) => p.id === inst1.id);
expect(promoted?.isActive).toBe(true);
});
test("setActive correctly deactivates siblings and activates target", () => {
const inst1 = ProviderManager.create(
"First Gemini",
"google-genai",
"key1",
undefined,
"generative",
);
const inst2 = ProviderManager.create(
"Second Gemini",
"google-genai",
"key2",
undefined,
"generative",
);
expect(inst1.isActive).toBe(true);
expect(inst2.isActive).toBe(false);
ProviderManager.setActive(inst2.id);
// Trigger bootstrap
const list = ProviderManager.list();
expect(list.length).toBe(2);
const bootstrapped = list.find((p) => p.name === "Gemini (Env)");
expect(bootstrapped).toBeDefined();
if (!bootstrapped) return;
expect(bootstrapped.isActive).toBe(true);
const updated1 = list.find((p) => p.id === inst1.id);
const updated2 = list.find((p) => p.id === inst2.id);
expect(updated1?.isActive).toBe(false);
expect(updated2?.isActive).toBe(true);
});
test("getMappings returns empty object initially, setMapping persists mappings", () => {
const mappings = ProviderManager.getMappings();
expect(mappings).toEqual({});
const inst = ProviderManager.create(
"Test Provider",
"google-genai",
"key1",
);
ProviderManager.setMapping("actor-prose", inst.id);
ProviderManager.setMapping("embeddings", inst.id);
const updated = ProviderManager.getMappings();
expect(updated["actor-prose"]).toBe(inst.id);
expect(updated["embeddings"]).toBe(inst.id);
});
test("setMapping with empty providerInstanceId deletes the mapping", () => {
const inst = ProviderManager.create(
"Test Provider",
"google-genai",
"key1",
);
ProviderManager.setMapping("test-task", inst.id);
expect(ProviderManager.getMappings()["test-task"]).toBe(inst.id);
ProviderManager.setMapping("test-task", "");
expect(ProviderManager.getMappings()["test-task"]).toBeUndefined();
});
test("create returns instance with correct fields and endpointUrl support", () => {
const inst = ProviderManager.create(
"Ollama Local",
"ollama",
"",
"llama3.1",
"generative",
32768,
"http://localhost:11434",
);
expect(inst.id).toMatch(/^provider-/);
expect(inst.name).toBe("Ollama Local");
expect(inst.providerName).toBe("ollama");
expect(inst.modelName).toBe("llama3.1");
expect(inst.endpointUrl).toBe("http://localhost:11434");
});
test("update preserves apiKey when not provided", () => {
const inst = ProviderManager.create(
"Original",
"openai",
"original-key",
"gpt-4o",
"generative",
128000,
);
ProviderManager.update(
inst.id,
"Renamed",
"openai",
undefined, // no apiKey → preserve existing
"gpt-4o-mini",
"generative",
64000,
);
const updated = ProviderManager.list().find((p) => p.id === inst.id);
expect(updated?.name).toBe("Renamed");
expect(updated?.apiKey).toBe("original-key"); // preserved
expect(updated?.modelName).toBe("gpt-4o-mini");
expect(updated?.maxContext).toBe(64000);
});
test("treats created instances as normal provider instances (editable and deletable)", () => {
const inst = ProviderManager.create(
"Google Gemini (Env)",
"google-genai",
"mock-google-key-123",
"gemini-2.5-flash",
"generative",
);
const list = ProviderManager.list();
expect(list.length).toBe(1);
const created = list.find((p) => p.id === inst.id);
expect(created).toBeDefined();
if (!created) return;
expect(created.isActive).toBe(true);
// Edit name and key
ProviderManager.update(
bootstrapped.id,
created.id,
"My Gemini Key",
"google-genai",
"new-secret-key",
@@ -92,8 +230,8 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
);
const listAfterUpdate = ProviderManager.list();
expect(listAfterUpdate.length).toBe(2);
const updated = listAfterUpdate.find((p) => p.id === bootstrapped.id);
expect(listAfterUpdate.length).toBe(1);
const updated = listAfterUpdate.find((p) => p.id === created.id);
expect(updated).toBeDefined();
if (!updated) return;
expect(updated.name).toBe("My Gemini Key");
@@ -101,8 +239,8 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
expect(updated.modelName).toBe("gemini-2.5-pro");
// Delete instance
ProviderManager.delete(bootstrapped.id);
ProviderManager.delete(created.id);
const listAfterDelete = ProviderManager.list();
expect(listAfterDelete.length).toBe(1);
expect(listAfterDelete.length).toBe(0);
});
});

View File

@@ -259,11 +259,7 @@ ${candidatesList}
}
const result = response.data;
const db = (
this.bufferRepo as unknown as {
db: { transaction: (fn: () => void) => void };
}
).db;
const db = (this.bufferRepo as any).db;
const ledgerEntries: LedgerEntry[] = [];
for (const chunk of result.chunks) {

492
pnpm-lock.yaml generated
View File

@@ -260,12 +260,27 @@ settings:
importers:
.:
dependencies:
"@langchain/anthropic":
specifier: ^0.3.11
version: 0.3.34(zod@4.4.3)
"@langchain/deepseek":
specifier: ^1.1.5
version: 1.1.5(ws@8.21.0)
"@langchain/google-genai":
specifier: ^2.2.0
version: 2.2.0(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))
"@langchain/groq":
specifier: ^1.3.1
version: 1.3.1
"@langchain/ollama":
specifier: ^0.2.3
version: 0.2.4
"@langchain/openai":
specifier: ^0.3.17
version: 0.3.17(ws@8.21.0)
"@langchain/openrouter":
specifier: ^0.4.3
version: 0.4.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)(zod@4.4.3)
version: 0.4.3(ws@8.21.0)(zod@4.4.3)
"@types/node":
specifier: ^20.19.43
version: 20.19.43
@@ -342,9 +357,6 @@ importers:
"@radix-ui/react-dialog":
specifier: ^1.1.19
version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
"@radix-ui/react-dropdown-menu":
specifier: ^2.1.20
version: 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
"@radix-ui/react-separator":
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -564,6 +576,18 @@ packages:
integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==,
}
"@anthropic-ai/sdk@0.65.0":
resolution:
{
integrity: sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw==,
}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
"@astrojs/compiler-binding-darwin-arm64@0.3.0":
resolution:
{
@@ -2052,6 +2076,15 @@ packages:
integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==,
}
"@langchain/anthropic@0.3.34":
resolution:
{
integrity: sha512-8bOW1A2VHRCjbzdYElrjxutKNs9NSIxYRGtR+OJWVzluMqoKKh2NmmFrpPizEyqCUEG2tTq5xt6XA1lwfqMJRA==,
}
engines: { node: ">=18" }
peerDependencies:
"@langchain/core": ">=0.3.58 <0.4.0"
"@langchain/core@1.2.1":
resolution:
{
@@ -2059,6 +2092,15 @@ packages:
}
engines: { node: ">=20" }
"@langchain/deepseek@1.1.5":
resolution:
{
integrity: sha512-5IRoEUaHAgIF8TyIncNVhhjavCqsjWTjakWsnus1yJN2X3W15Bw8Qmf+vJzCnFo7yndICsGdOGfHJoIN5xNxoQ==,
}
engines: { node: ">=20" }
peerDependencies:
"@langchain/core": ^1.0.0
"@langchain/google-genai@2.2.0":
resolution:
{
@@ -2068,6 +2110,33 @@ packages:
peerDependencies:
"@langchain/core": ^1.2.0
"@langchain/groq@1.3.1":
resolution:
{
integrity: sha512-ImxfGBis4FEHpZdeT6dot6V6l09uBLYm/1BHQ6x3XEQmJFF7aKwbOeSOV5h/h5IeRx+2gaInR+LfyYoqT8satQ==,
}
engines: { node: ">=20" }
peerDependencies:
"@langchain/core": ^1.1.30
"@langchain/ollama@0.2.4":
resolution:
{
integrity: sha512-XThDrZurNPcUO6sasN13rkes1aGgu5gWAtDkkyIGT3ZeMOvrYgPKGft+bbhvsigTIH9C01TfPzrSp8LAmvHIjA==,
}
engines: { node: ">=18" }
peerDependencies:
"@langchain/core": ">=0.3.58 <0.4.0"
"@langchain/openai@0.3.17":
resolution:
{
integrity: sha512-uw4po32OKptVjq+CYHrumgbfh4NuD7LqyE+ZgqY9I/LrLc6bHLMc+sisHmI17vgek0K/yqtarI0alPJbzrwyag==,
}
engines: { node: ">=18" }
peerDependencies:
"@langchain/core": ">=0.3.29 <0.4.0"
"@langchain/openai@1.5.3":
resolution:
{
@@ -2077,6 +2146,15 @@ packages:
peerDependencies:
"@langchain/core": ^1.2.1
"@langchain/openai@1.5.5":
resolution:
{
integrity: sha512-wX7dwb9z4nf5FHXlIl/X2mk08pzonvRHCt1D4+s1zXLP0duYDC95j7dulPIQJ6fmhbyYQc9Ki8mEhY/D1lB8kw==,
}
engines: { node: ">=20" }
peerDependencies:
"@langchain/core": ^1.2.2
"@langchain/openrouter@0.4.3":
resolution:
{
@@ -3861,6 +3939,18 @@ packages:
integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==,
}
"@types/node-fetch@2.6.13":
resolution:
{
integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==,
}
"@types/node@18.19.130":
resolution:
{
integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==,
}
"@types/node@20.19.43":
resolution:
{
@@ -4074,6 +4164,13 @@ packages:
integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==,
}
abort-controller@3.0.0:
resolution:
{
integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==,
}
engines: { node: ">=6.5" }
accepts@2.0.0:
resolution:
{
@@ -4097,6 +4194,13 @@ packages:
engines: { node: ">=0.4.0" }
hasBin: true
agentkeepalive@4.6.0:
resolution:
{
integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==,
}
engines: { node: ">= 8.0.0" }
ajv-formats@2.1.1:
resolution:
{
@@ -4253,6 +4357,12 @@ packages:
"@astrojs/markdown-remark":
optional: true
asynckit@0.4.0:
resolution:
{
integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==,
}
atomically@1.7.0:
resolution:
{
@@ -4558,6 +4668,13 @@ packages:
}
engines: { node: ">=12.5.0" }
combined-stream@1.0.8:
resolution:
{
integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==,
}
engines: { node: ">= 0.8" }
comma-separated-tokens@2.0.3:
resolution:
{
@@ -5140,6 +5257,13 @@ packages:
integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==,
}
delayed-stream@1.0.0:
resolution:
{
integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==,
}
engines: { node: ">=0.4.0" }
depd@2.0.0:
resolution:
{
@@ -5363,6 +5487,13 @@ packages:
}
engines: { node: ">= 0.4" }
es-set-tostringtag@2.1.0:
resolution:
{
integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==,
}
engines: { node: ">= 0.4" }
es-toolkit@1.49.0:
resolution:
{
@@ -5557,6 +5688,13 @@ packages:
}
engines: { node: ">= 0.6" }
event-target-shim@5.0.1:
resolution:
{
integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==,
}
engines: { node: ">=6" }
eventemitter3@4.0.7:
resolution:
{
@@ -5688,6 +5826,13 @@ packages:
integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==,
}
fast-xml-parser@4.5.7:
resolution:
{
integrity: sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==,
}
hasBin: true
fastq@1.20.1:
resolution:
{
@@ -5787,6 +5932,26 @@ packages:
}
engines: { node: ">=20" }
form-data-encoder@1.7.2:
resolution:
{
integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==,
}
form-data@4.0.6:
resolution:
{
integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==,
}
engines: { node: ">= 6" }
formdata-node@4.4.1:
resolution:
{
integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==,
}
engines: { node: ">= 12.20" }
forwarded@0.2.0:
resolution:
{
@@ -5949,6 +6114,13 @@ packages:
integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==,
}
groq-sdk@1.3.0:
resolution:
{
integrity: sha512-mvgUIpAxlk/VxWIoliHx4R+Ha78Bd/g0t24OFjCXtdLbNiY1rW4h9AcznKBwFho7K/Nq732ZSjxMYkNb1xeCFg==,
}
hasBin: true
h3@1.15.11:
resolution:
{
@@ -5968,6 +6140,13 @@ packages:
}
engines: { node: ">= 0.4" }
has-tostringtag@1.0.2:
resolution:
{
integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==,
}
engines: { node: ">= 0.4" }
hasown@2.0.4:
resolution:
{
@@ -6147,6 +6326,12 @@ packages:
}
engines: { node: ">=18.18.0" }
humanize-ms@1.2.1:
resolution:
{
integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==,
}
i18next@26.3.4:
resolution:
{
@@ -6507,6 +6692,13 @@ packages:
integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==,
}
json-schema-to-ts@3.1.1:
resolution:
{
integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==,
}
engines: { node: ">=16" }
json-schema-traverse@0.4.1:
resolution:
{
@@ -7225,6 +7417,13 @@ packages:
}
engines: { node: ">=8.6" }
mime-db@1.52.0:
resolution:
{
integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==,
}
engines: { node: ">= 0.6" }
mime-db@1.54.0:
resolution:
{
@@ -7232,6 +7431,13 @@ packages:
}
engines: { node: ">= 0.6" }
mime-types@2.1.35:
resolution:
{
integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==,
}
engines: { node: ">= 0.6" }
mime-types@3.0.2:
resolution:
{
@@ -7385,12 +7591,32 @@ packages:
}
engines: { node: ">=10" }
node-domexception@1.0.0:
resolution:
{
integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==,
}
engines: { node: ">=10.5.0" }
deprecated: Use your platform's native DOMException instead
node-fetch-native@1.6.7:
resolution:
{
integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==,
}
node-fetch@2.7.0:
resolution:
{
integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==,
}
engines: { node: 4.x || >=6.0.0 }
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-mock-http@1.0.4:
resolution:
{
@@ -7471,6 +7697,12 @@ packages:
integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==,
}
ollama@0.5.18:
resolution:
{
integrity: sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==,
}
on-finished@2.4.1:
resolution:
{
@@ -7524,6 +7756,21 @@ packages:
}
engines: { node: ">=12" }
openai@4.104.0:
resolution:
{
integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==,
}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
openai@6.45.0:
resolution:
{
@@ -8627,6 +8874,12 @@ packages:
}
engines: { node: ">=0.10.0" }
strnum@1.1.2:
resolution:
{
integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==,
}
style-to-js@1.1.21:
resolution:
{
@@ -8785,6 +9038,12 @@ packages:
}
engines: { node: ">=0.6" }
tr46@0.0.3:
resolution:
{
integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==,
}
trim-lines@3.0.1:
resolution:
{
@@ -8797,6 +9056,12 @@ packages:
integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==,
}
ts-algebra@2.0.0:
resolution:
{
integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==,
}
ts-api-utils@2.5.0:
resolution:
{
@@ -8888,6 +9153,12 @@ packages:
integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==,
}
undici-types@5.26.5:
resolution:
{
integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==,
}
undici-types@6.21.0:
resolution:
{
@@ -9139,6 +9410,14 @@ packages:
integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==,
}
uuid@10.0.0:
resolution:
{
integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==,
}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@14.0.1:
resolution:
{
@@ -9285,6 +9564,31 @@ packages:
integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==,
}
web-streams-polyfill@4.0.0-beta.3:
resolution:
{
integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==,
}
engines: { node: ">= 14" }
webidl-conversions@3.0.1:
resolution:
{
integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==,
}
whatwg-fetch@3.6.20:
resolution:
{
integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==,
}
whatwg-url@5.0.0:
resolution:
{
integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==,
}
which@2.0.2:
resolution:
{
@@ -9458,6 +9762,12 @@ snapshots:
package-manager-detector: 1.7.0
tinyexec: 1.2.4
"@anthropic-ai/sdk@0.65.0(zod@4.4.3)":
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.4.3
"@astrojs/compiler-binding-darwin-arm64@0.3.0":
optional: true
@@ -10338,6 +10648,13 @@ snapshots:
"@jridgewell/resolve-uri": 3.1.2
"@jridgewell/sourcemap-codec": 1.5.5
"@langchain/anthropic@0.3.34(zod@4.4.3)":
dependencies:
"@anthropic-ai/sdk": 0.65.0(zod@4.4.3)
fast-xml-parser: 4.5.7
transitivePeerDependencies:
- zod
"@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)":
dependencies:
"@cfworker/json-schema": 4.1.1
@@ -10354,14 +10671,41 @@ snapshots:
- openai
- ws
"@langchain/deepseek@1.1.5(ws@8.21.0)":
dependencies:
"@langchain/openai": 1.5.5(ws@8.21.0)
transitivePeerDependencies:
- "@aws-sdk/credential-provider-node"
- "@smithy/hash-node"
- "@smithy/signature-v4"
- ws
"@langchain/google-genai@2.2.0(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))":
dependencies:
"@google/generative-ai": 0.24.1
"@langchain/core": 1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)
"@langchain/openai@1.5.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)":
"@langchain/groq@1.3.1":
dependencies:
groq-sdk: 1.3.0
"@langchain/ollama@0.2.4":
dependencies:
ollama: 0.5.18
uuid: 10.0.0
"@langchain/openai@0.3.17(ws@8.21.0)":
dependencies:
js-tiktoken: 1.0.21
openai: 4.104.0(ws@8.21.0)(zod@3.25.76)
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
transitivePeerDependencies:
- encoding
- ws
"@langchain/openai@1.5.3(ws@8.21.0)":
dependencies:
"@langchain/core": 1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)
js-tiktoken: 1.0.21
openai: 6.45.0(ws@8.21.0)(zod@4.4.3)
zod: 4.4.3
@@ -10371,10 +10715,20 @@ snapshots:
- "@smithy/signature-v4"
- ws
"@langchain/openrouter@0.4.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)(zod@4.4.3)":
"@langchain/openai@1.5.5(ws@8.21.0)":
dependencies:
"@langchain/core": 1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)
"@langchain/openai": 1.5.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)
js-tiktoken: 1.0.21
openai: 6.45.0(ws@8.21.0)(zod@4.4.3)
zod: 4.4.3
transitivePeerDependencies:
- "@aws-sdk/credential-provider-node"
- "@smithy/hash-node"
- "@smithy/signature-v4"
- ws
"@langchain/openrouter@0.4.3(ws@8.21.0)(zod@4.4.3)":
dependencies:
"@langchain/openai": 1.5.3(ws@8.21.0)
eventsource-parser: 3.1.0
openai: 6.45.0(ws@8.21.0)(zod@4.4.3)
transitivePeerDependencies:
@@ -11625,6 +11979,15 @@ snapshots:
dependencies:
"@types/unist": 3.0.3
"@types/node-fetch@2.6.13":
dependencies:
"@types/node": 26.1.0
form-data: 4.0.6
"@types/node@18.19.130":
dependencies:
undici-types: 5.26.5
"@types/node@20.19.43":
dependencies:
undici-types: 6.21.0
@@ -11797,6 +12160,10 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -11808,6 +12175,10 @@ snapshots:
acorn@8.17.0: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
ajv-formats@2.1.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
@@ -11972,6 +12343,8 @@ snapshots:
- uploadthing
- yaml
asynckit@0.4.0: {}
atomically@1.7.0: {}
autoprefixer@10.5.2(postcss@8.5.16):
@@ -12131,6 +12504,10 @@ snapshots:
color-convert: 2.0.1
color-string: 1.9.1
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
@@ -12461,6 +12838,8 @@ snapshots:
dependencies:
robust-predicates: 3.0.3
delayed-stream@1.0.0: {}
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -12561,6 +12940,13 @@ snapshots:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
es-toolkit@1.49.0: {}
esast-util-from-estree@2.0.0:
@@ -12723,6 +13109,8 @@ snapshots:
etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
@@ -12837,6 +13225,10 @@ snapshots:
dependencies:
fast-string-width: 3.0.2
fast-xml-parser@4.5.7:
dependencies:
strnum: 1.1.2
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -12896,6 +13288,21 @@ snapshots:
dependencies:
tiny-inflate: 1.0.3
form-data-encoder@1.7.2: {}
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
forwarded@0.2.0: {}
fraction.js@5.3.4: {}
@@ -12972,6 +13379,8 @@ snapshots:
graceful-fs@4.2.11: {}
groq-sdk@1.3.0: {}
h3@1.15.11:
dependencies:
cookie-es: 1.2.3
@@ -12988,6 +13397,10 @@ snapshots:
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
@@ -13203,6 +13616,10 @@ snapshots:
human-signals@8.0.1: {}
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
i18next@26.3.4(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
@@ -13333,6 +13750,11 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
json-schema-to-ts@3.1.1:
dependencies:
"@babel/runtime": 7.29.7
ts-algebra: 2.0.0
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -13980,8 +14402,14 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
@@ -14062,8 +14490,14 @@ snapshots:
dependencies:
semver: 7.8.5
node-domexception@1.0.0: {}
node-fetch-native@1.6.7: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-mock-http@1.0.4: {}
node-releases@2.0.51: {}
@@ -14099,6 +14533,10 @@ snapshots:
ohash@2.0.11: {}
ollama@0.5.18:
dependencies:
whatwg-fetch: 3.6.20
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -14138,6 +14576,21 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openai@4.104.0(ws@8.21.0)(zod@3.25.76):
dependencies:
"@types/node": 18.19.130
"@types/node-fetch": 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
optionalDependencies:
ws: 8.21.0
zod: 3.25.76
transitivePeerDependencies:
- encoding
openai@6.45.0(ws@8.21.0)(zod@4.4.3):
optionalDependencies:
ws: 8.21.0
@@ -15016,6 +15469,8 @@ snapshots:
strip-json-comments@2.0.1: {}
strnum@1.1.2: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -15095,10 +15550,14 @@ snapshots:
toidentifier@1.0.1: {}
tr46@0.0.3: {}
trim-lines@3.0.1: {}
trough@2.2.0: {}
ts-algebra@2.0.0: {}
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
@@ -15151,6 +15610,8 @@ snapshots:
uncrypto@0.1.3: {}
undici-types@5.26.5: {}
undici-types@6.21.0: {}
undici-types@7.18.2: {}
@@ -15275,6 +15736,8 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
uuid@14.0.1: {}
validate-npm-package-name@7.0.2: {}
@@ -15355,6 +15818,17 @@ snapshots:
web-namespaces@2.0.1: {}
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {}
whatwg-fetch@3.6.20: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0

View File

@@ -71,14 +71,27 @@ If no specific provider instance is mapped to a task, the task automatically rou
---
## Automatic Bootstrapping (Environment Key Fallback)
## CLI Setup & Seeding
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**:
Rather than automatically bootstrapping from environment variables at runtime, which adds runtime complexity, you can quickly seed the database using the CLI setup tool:
1. When any database connection is initialized via the provider manager, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY` and `OPENROUTER_API_KEY`.
2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Gemini (Env)`) in `settings.db`.
3. If `process.env.OPENROUTER_API_KEY` is present, it automatically creates and saves a default provider instance (`OpenRouter (Env)`) in `settings.db`.
4. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` (`Gemini (Env Fallback)` or `OpenRouter (Env Fallback)`) to keep execution fluent and error-free.
### Seeding All Environment-Variable Providers
```bash
pnpm setup-provider --all
```
This command auto-detects and inserts provider instances into `data/settings.db` for any registered providers whose corresponding environment variables (such as `GOOGLE_API_KEY`, `OPENAI_API_KEY`, etc.) are defined.
### Creating a Specific Provider Instance
```bash
pnpm setup-provider --provider google-genai --key YOUR_API_KEY [--name "My Gemini"] [--model gemini-2.5-flash] [--type generative] [--max-context 32768] [--endpoint url]
```
### Environment Variable Fallback
If the database contains no active provider instances, the LLM providers (e.g. `GeminiProvider`, `OpenAIProvider`, etc.) will fall back directly to reading their keys from environment variables (e.g. `GOOGLE_API_KEY`, `OPENAI_API_KEY`) via `resolveCredentials`.
---