Compare commits
39 Commits
2f20224bac
...
fix/handof
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6fe0dc28 | ||
| 01ec062194 | |||
| 8ff1650657 | |||
| 7baf583b13 | |||
| ee25bf4a4c | |||
|
|
1e34becec7 | ||
|
|
f8977a14c6 | ||
| c2926261a1 | |||
| 5c3a79e8b6 | |||
| a4b620502a | |||
| 84bff92631 | |||
| 0512be647c | |||
| 1ed1edf4cf | |||
| b4bf70dbae | |||
| c6b32bb546 | |||
| 896ff6f210 | |||
| 821f6a03cd | |||
| 9223ae3750 | |||
| 32ee8cda3a | |||
| eabe49552d | |||
| 444c722708 | |||
| 64e049c976 | |||
| 74cce6edd1 | |||
|
|
61ea9fe237 | ||
| 250bb87a8d | |||
| 8ad94d3fc2 | |||
| 2b56c01e4c | |||
| 13155cba23 | |||
| 622fdfe2f1 | |||
| d6ab076b09 | |||
| 9356e1f7d0 | |||
| ccafe56dfe | |||
| 906ef2cdf3 | |||
| c55c7e222a | |||
| ce8b9176d3 | |||
| 9d18444220 | |||
| 9838d4ce59 | |||
| 3b2a85aeaf | |||
| d59b372a57 |
5
.codegraph/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
10
.github/workflows/deploy-docs.yml
vendored
@@ -5,9 +5,9 @@ on:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'web/docs/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
- "web/docs/**"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/deploy-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -41,4 +41,4 @@ jobs:
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: 'web/docs'
|
||||
workingDirectory: "web/docs"
|
||||
|
||||
16
README.md
@@ -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**.
|
||||
@@ -91,7 +91,7 @@ Access the application locally at `http://localhost:3000`.
|
||||
|
||||
### The Actor Agent
|
||||
|
||||
Each entity takes turns through an **Actor Agent** that receives a strictly epistemically bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its subjective memory buffer, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose.
|
||||
Each entity takes turns through an **Actor Agent** that receives a strictly epistemically bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its **Cognitive Buffer**, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose.
|
||||
|
||||
Prose is decoded into typed intents:
|
||||
|
||||
@@ -121,8 +121,8 @@ Space is a graph: `world → region → location → point of interest`, connect
|
||||
|
||||
### Memory Tiers
|
||||
|
||||
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
|
||||
- **Vector Archive (implemented):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Cognitive Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
|
||||
- **Memory Ledger (implemented):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Dossier (planned):** Each observer's subjective beliefs about another character.
|
||||
|
||||
Memory is per-character on purpose: recall is testimony from a vantage point, which is what makes interrogating two witnesses interesting.
|
||||
@@ -156,14 +156,14 @@ The finish line for the first milestone is small on purpose. `v0` is almost on t
|
||||
- [x] Typed intent pipeline: `dialogue` / `action` / `monologue`, decoded from free prose.
|
||||
- [x] World Architect: LLM validation plus time-delta generation, end-to-end for single actions.
|
||||
- [x] Actor Agent with epistemically-bounded prompts (self, memory, co-located entities, subjective time).
|
||||
- [x] Verbatim memory buffer with per-observer subjective serialization and alias resolution.
|
||||
- [x] Verbatim Cognitive Buffer with per-observer subjective serialization and alias resolution.
|
||||
- [x] Spatial location graph (data model; perception is co-location only).
|
||||
- [x] Scenario loader (JSON → SQLite) and a playable CLI loop with human or LLM actors.
|
||||
|
||||
**[The `v0` Milestone:](https://github.com/sortedcord/omnia-consolidated/milestone/1)**
|
||||
|
||||
- [x] Two hand-authored NPCs live in one location, playable via CLI.
|
||||
- [x] Each has buffer and vector-archive memory and recalls something said a few turns earlier.
|
||||
- [x] Each has Cognitive Buffer and Memory Ledger memory and recalls something said a few turns earlier.
|
||||
- [x] One NPC knows a fact the other does not and, provably by testing, will not leak it.
|
||||
- [x] The Architect processes at least one non-trivial action per exchange with a visible state change.
|
||||
- [x] The whole thing persists to a SQLite file and reloads identically.
|
||||
@@ -183,7 +183,7 @@ omnia/
|
||||
intent/ intent types (dialogue/action/monologue) and the prose decoder
|
||||
architect/ World Architect: LLM validation plus time-delta generation
|
||||
actor/ actor agent: epistemically-bounded prompts, pluggable prose generators
|
||||
memory/ verbatim buffer; later the vector archive, dossier, and affect vectors
|
||||
memory/ Cognitive Buffer; Memory Ledger (vector archive), dossier, and affect vectors
|
||||
spatial/ location and POI graph, portal-based perception
|
||||
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
|
||||
scenario/ scenario JSON schema and loader (JSON → SQLite)
|
||||
|
||||
2
apps/gui/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/scenario": "workspace:*",
|
||||
"@omnia/spatial": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
|
||||
BIN
apps/gui/public/claude_logo.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/gui/public/deepseek_logo.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/gui/public/gemini_logo.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/gui/public/groq_logo.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
apps/gui/public/ollama_logo.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/gui/public/openai_logo.webp
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/gui/public/openrouter_logo.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
@@ -4,7 +4,14 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import { simulationManager } from "@/lib/simulation";
|
||||
import type { SimSnapshot } from "@/lib/simulation";
|
||||
import { ProviderManager, ModelProviderInstance, AVAILABLE_PROVIDERS, ModelProviderMeta } from "@omnia/llm";
|
||||
import {
|
||||
ProviderManager,
|
||||
ModelProviderInstance,
|
||||
getAvailableProviders as listAvailableProviders,
|
||||
ModelProviderMeta,
|
||||
ModelLister,
|
||||
ModelInfo,
|
||||
} from "@omnia/llm";
|
||||
|
||||
function resolveScenarioPath(relative: string): string {
|
||||
const cwd = process.cwd();
|
||||
@@ -25,13 +32,13 @@ function resolveScenarioPath(relative: string): string {
|
||||
}
|
||||
|
||||
type ActionResult =
|
||||
| { ok: true; snapshot: SimSnapshot }
|
||||
| { ok: false; error: string };
|
||||
{ ok: true; snapshot: SimSnapshot } | { ok: false; error: string };
|
||||
|
||||
export async function startSimulation(input: {
|
||||
scenario?: string;
|
||||
playEntity?: string;
|
||||
providerInstanceId?: string;
|
||||
customName?: string;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const scenarioFile =
|
||||
@@ -46,6 +53,7 @@ export async function startSimulation(input: {
|
||||
resolved,
|
||||
input.playEntity || undefined,
|
||||
input.providerInstanceId,
|
||||
input.customName,
|
||||
);
|
||||
|
||||
if (snapshot.status === "error") {
|
||||
@@ -168,8 +176,7 @@ export async function getConfigStatus(): Promise<{
|
||||
}
|
||||
|
||||
export async function listSavedSimulations(): Promise<
|
||||
| { ok: true; sessions: SimSnapshot[] }
|
||||
| { ok: false; error: string }
|
||||
{ ok: true; sessions: SimSnapshot[] } | { ok: false; error: string }
|
||||
> {
|
||||
try {
|
||||
const sessions = simulationManager.listSavedSessions();
|
||||
@@ -197,7 +204,9 @@ export async function resumeSimulation(simId: string): Promise<ActionResult> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getScenarioEntities(scenarioPath: string): Promise<
|
||||
export async function getScenarioEntities(
|
||||
scenarioPath: string,
|
||||
): Promise<
|
||||
| { ok: true; entities: { id: string; name: string }[] }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
@@ -207,10 +216,12 @@ export async function getScenarioEntities(scenarioPath: string): Promise<
|
||||
return { ok: false, error: `Scenario file not found: ${scenarioPath}` };
|
||||
}
|
||||
const content = JSON.parse(fs.readFileSync(resolved, "utf-8"));
|
||||
const entities = (content.entities || []).map((e: { id: string; name?: string }) => ({
|
||||
id: e.id,
|
||||
name: e.name || e.id,
|
||||
}));
|
||||
const entities = (content.entities || []).map(
|
||||
(e: { id: string; name?: string }) => ({
|
||||
id: e.id,
|
||||
name: e.name || e.id,
|
||||
}),
|
||||
);
|
||||
return { ok: true, entities };
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -220,9 +231,9 @@ export async function getScenarioEntities(scenarioPath: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSimulation(simId: string): Promise<
|
||||
{ ok: true } | { ok: false; error: string }
|
||||
> {
|
||||
export async function deleteSimulation(
|
||||
simId: string,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
try {
|
||||
simulationManager.deleteSession(simId);
|
||||
return { ok: true };
|
||||
@@ -234,7 +245,9 @@ export async function deleteSimulation(simId: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProviderInstances(): Promise<ModelProviderInstance[]> {
|
||||
export async function listProviderInstances(): Promise<
|
||||
ModelProviderInstance[]
|
||||
> {
|
||||
return ProviderManager.list();
|
||||
}
|
||||
|
||||
@@ -245,8 +258,17 @@ export async function createProviderInstance(
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
endpointUrl?: string,
|
||||
): Promise<ModelProviderInstance> {
|
||||
return ProviderManager.create(name, providerName, apiKey, modelName, type, maxContext);
|
||||
return ProviderManager.create(
|
||||
name,
|
||||
providerName,
|
||||
apiKey,
|
||||
modelName,
|
||||
type,
|
||||
maxContext,
|
||||
endpointUrl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteProviderInstance(id: string): Promise<void> {
|
||||
@@ -265,8 +287,18 @@ export async function updateProviderInstance(
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
endpointUrl?: string,
|
||||
): Promise<void> {
|
||||
ProviderManager.update(id, name, providerName, apiKey, modelName, type, maxContext);
|
||||
ProviderManager.update(
|
||||
id,
|
||||
name,
|
||||
providerName,
|
||||
apiKey,
|
||||
modelName,
|
||||
type,
|
||||
maxContext,
|
||||
endpointUrl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProviderMappings(): Promise<Record<string, string>> {
|
||||
@@ -281,9 +313,58 @@ export async function setProviderMapping(
|
||||
}
|
||||
|
||||
export async function getAvailableProviders(): Promise<ModelProviderMeta[]> {
|
||||
return AVAILABLE_PROVIDERS;
|
||||
return listAvailableProviders();
|
||||
}
|
||||
|
||||
export async function regenerateEmbeddings(newProviderInstanceId?: string): Promise<void> {
|
||||
export async function regenerateEmbeddings(
|
||||
newProviderInstanceId?: string,
|
||||
): Promise<void> {
|
||||
await simulationManager.regenerateAllEmbeddings(newProviderInstanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 snapshot = await simulationManager.rename(simId, newName);
|
||||
if (!snapshot) {
|
||||
return { ok: false, error: "Simulation session not found" };
|
||||
}
|
||||
return { ok: true, snapshot };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Failed to rename simulation",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
"use client";
|
||||
import { BuilderView } from "@/components/builder/BuilderView";
|
||||
|
||||
export default function BuilderPage() {
|
||||
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>
|
||||
);
|
||||
return <BuilderView />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DashboardView } from "@/components/play/DashboardView";
|
||||
import { HomeView } from "@/components/home/HomeView";
|
||||
|
||||
export default function Home() {
|
||||
return <DashboardView />;
|
||||
return <HomeView />;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { Suspense } from "react";
|
||||
|
||||
export default function PlayPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin text-primary">Loading...</div>
|
||||
</div>
|
||||
}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin text-primary">Loading...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PlayView />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
18
apps/gui/src/components/builder/BuilderView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -177,13 +177,13 @@ export function ConfigView() {
|
||||
{
|
||||
key: "handoff",
|
||||
label: "Memory Handoff Engine",
|
||||
desc: "Promotes entities' working memories to the long-term Ledger via LLM summarization and pruning.",
|
||||
desc: "Promotes entities' Cognitive Buffer entries to the Memory Ledger via LLM summarization and pruning.",
|
||||
type: "generative",
|
||||
},
|
||||
{
|
||||
key: "embeddings",
|
||||
label: "Text Embeddings Generator",
|
||||
desc: "Generates vector embeddings for long-term memory retrieval.",
|
||||
desc: "Generates vector embeddings for Memory Ledger retrieval.",
|
||||
type: "embedding",
|
||||
},
|
||||
].map((task) => (
|
||||
@@ -224,7 +224,8 @@ export function ConfigView() {
|
||||
</SelectItem>
|
||||
{instances
|
||||
.filter(
|
||||
(inst) => (inst.type || "generative") === task.type,
|
||||
(inst) =>
|
||||
(inst.type || "generative") === task.type,
|
||||
)
|
||||
.map((inst) => (
|
||||
<SelectItem key={inst.id} value={inst.id}>
|
||||
|
||||
@@ -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,
|
||||
@@ -29,15 +43,20 @@ import {
|
||||
CardTitle,
|
||||
CardAction,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Item, ItemContent, ItemGroup, ItemTitle } 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 +83,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 +151,8 @@ export function ProviderInstancesConfig({
|
||||
setEditIsActive(false);
|
||||
setEditType("generative");
|
||||
setEditMaxContext(32768);
|
||||
setEditEndpointUrl("");
|
||||
setAvailableModels([]);
|
||||
} else if (selectedInstanceId === "new") {
|
||||
setEditName("");
|
||||
const defaultProvider = "google-genai";
|
||||
@@ -86,6 +163,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 +188,20 @@ export function ProviderInstancesConfig({
|
||||
? inst.maxContext
|
||||
: 32768,
|
||||
);
|
||||
setEditEndpointUrl(inst.endpointUrl || "");
|
||||
setAvailableModels([]);
|
||||
// Auto-fetch models for existing instances
|
||||
fetchModelsForExistingInstance(selectedInstanceId);
|
||||
}
|
||||
}
|
||||
}, [selectedInstanceId, instances, availableProviders]);
|
||||
|
||||
// Re-fetch models when provider/key/endpoint changes on new instance form
|
||||
useEffect(() => {
|
||||
if (selectedInstanceId !== "new") return;
|
||||
fetchModelsForNewInstance(editProvider, editKey, editEndpointUrl);
|
||||
}, [editProvider, editKey, editEndpointUrl, selectedInstanceId]);
|
||||
|
||||
const handleProviderChange = (providerId: string | null) => {
|
||||
if (!providerId) return;
|
||||
setEditProvider(providerId);
|
||||
@@ -122,6 +211,7 @@ export function ProviderInstancesConfig({
|
||||
? pMeta?.defaultEmbeddingModel || ""
|
||||
: pMeta?.defaultModel || "",
|
||||
);
|
||||
setAvailableModels([]);
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: "generative" | "embedding") => {
|
||||
@@ -134,6 +224,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 +247,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 +255,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 +295,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 +339,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 +347,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,12 +381,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>
|
||||
@@ -327,6 +441,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) =>
|
||||
@@ -334,11 +449,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",
|
||||
},
|
||||
]}
|
||||
@@ -349,10 +464,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>
|
||||
@@ -396,30 +511,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" && (
|
||||
|
||||
@@ -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);
|
||||
@@ -44,7 +45,9 @@ export function DashboardView() {
|
||||
const [scenarios, setScenarios] = useState<
|
||||
{ path: string; name: string; description: string }[]
|
||||
>([]);
|
||||
const [, setProviderInstances] = useState<ModelProviderInstance[]>([]);
|
||||
const [providerInstances, setProviderInstances] = useState<
|
||||
ModelProviderInstance[]
|
||||
>([]);
|
||||
|
||||
// Modal State
|
||||
const [scenarioForModal, setScenarioForModal] = useState<{
|
||||
@@ -57,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 {
|
||||
@@ -142,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) {
|
||||
@@ -166,6 +171,7 @@ export function DashboardView() {
|
||||
const result = await startSimulation({
|
||||
scenario: targetScenario.path,
|
||||
playEntity: selectedEntityForModal || undefined,
|
||||
customName: customName.trim() || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -186,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">
|
||||
@@ -203,6 +209,24 @@ export function DashboardView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providerInstances.length === 0 && !loadingData && (
|
||||
<div className="mb-6 border border-yellow-500/30 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 flex flex-col gap-2">
|
||||
<span className="font-semibold flex items-center gap-1">
|
||||
⚠️ No LLM providers have been configured.
|
||||
</span>
|
||||
<span>
|
||||
Please go to the{" "}
|
||||
<Link
|
||||
href="/config"
|
||||
className="underline font-medium hover:text-yellow-700"
|
||||
>
|
||||
Configuration
|
||||
</Link>{" "}
|
||||
page to set up at least one provider before running simulations.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simulations Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-headline-lg text-primary mb-6 animate-fade-in">
|
||||
@@ -235,8 +259,16 @@ export function DashboardView() {
|
||||
{savedSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => handleResume(s.id)}
|
||||
className="flex-shrink-0 w-72 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all relative group"
|
||||
onClick={
|
||||
providerInstances.length === 0
|
||||
? undefined
|
||||
: () => handleResume(s.id)
|
||||
}
|
||||
className={`flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all relative group ${
|
||||
providerInstances.length === 0
|
||||
? "opacity-50 cursor-not-allowed filter grayscale"
|
||||
: "cursor-pointer hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1 text-sm mb-4">
|
||||
<strong className="text-body-md text-foreground block">
|
||||
@@ -337,6 +369,7 @@ export function DashboardView() {
|
||||
name={s.name}
|
||||
description={s.description}
|
||||
onClick={() => handleScenarioClick(s)}
|
||||
disabled={providerInstances.length === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -365,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
|
||||
254
apps/gui/src/components/play/HandoffModal.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PromptAnalyzer } from "@/components/play/PromptAnalyzer";
|
||||
|
||||
interface HandoffModalProps {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function HandoffModal({ entry, onClose }: HandoffModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"chunks" | "prompt" | "output">(
|
||||
"chunks",
|
||||
);
|
||||
|
||||
const handoffResult = entry.handoffResult;
|
||||
const chunks = handoffResult?.chunks || [];
|
||||
|
||||
const getImportanceColor = (score: number) => {
|
||||
if (score >= 8)
|
||||
return "bg-destructive/10 text-destructive border-destructive/30";
|
||||
if (score >= 5) return "bg-amber-500/10 text-amber-500 border-amber-500/30";
|
||||
return "bg-emerald-500/10 text-emerald-500 border-emerald-500/30";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-[800px] sm:max-w-[800px] h-[85vh] overflow-hidden flex flex-col p-0 gap-0 border-2">
|
||||
<DialogHeader className="px-6 pt-5 pb-4 border-b">
|
||||
<DialogTitle className="text-lg font-head tracking-wide text-primary flex items-center justify-between">
|
||||
<span>Memory Handoff Details — {entry.entityName}</span>
|
||||
{entry.usage && (
|
||||
<span className="text-xs font-mono font-normal text-muted-foreground">
|
||||
{entry.usage.modelName || "Handoff Model"}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Custom Tab Switcher */}
|
||||
<div className="flex border-b bg-muted/20 px-6 py-2 gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab("chunks")}
|
||||
className={`px-3 py-1.5 text-xs font-medium border transition-all duration-100 ${
|
||||
activeTab === "chunks"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[1px_1px_0_0_var(--primary)]"
|
||||
: "border-transparent hover:bg-secondary text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Promoted Chunks ({chunks.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("prompt")}
|
||||
className={`px-3 py-1.5 text-xs font-medium border transition-all duration-100 ${
|
||||
activeTab === "prompt"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[1px_1px_0_0_var(--primary)]"
|
||||
: "border-transparent hover:bg-secondary text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Raw LLM Prompt
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("output")}
|
||||
className={`px-3 py-1.5 text-xs font-medium border transition-all duration-100 ${
|
||||
activeTab === "output"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[1px_1px_0_0_var(--primary)]"
|
||||
: "border-transparent hover:bg-secondary text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Raw JSON Output
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-6 space-y-4">
|
||||
{activeTab === "chunks" && (
|
||||
<div className="space-y-4">
|
||||
{entry.usage && (
|
||||
<div className="grid grid-cols-3 gap-4 border border-dotted border-border/20 p-3 bg-secondary/10 rounded text-xs font-mono">
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider text-[10px]">
|
||||
Input Tokens
|
||||
</span>
|
||||
<strong className="text-foreground">
|
||||
{entry.usage.inputTokens}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider text-[10px]">
|
||||
Output Tokens
|
||||
</span>
|
||||
<strong className="text-foreground">
|
||||
{entry.usage.outputTokens}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider text-[10px]">
|
||||
Total Tokens
|
||||
</span>
|
||||
<strong className="text-foreground">
|
||||
{entry.usage.totalTokens}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chunks.length === 0 ? (
|
||||
<div className="border border-dotted border-border/30 p-8 text-center bg-card text-muted-foreground rounded">
|
||||
<p className="text-sm">
|
||||
No memories were promoted to the Memory Ledger during this
|
||||
turn.
|
||||
</p>
|
||||
<p className="text-xs mt-1">
|
||||
All Cognitive Buffer entries were summarized or forgotten.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
|
||||
Memory Ledger Additions
|
||||
</h3>
|
||||
{chunks.map(
|
||||
(
|
||||
chunk: { content: string; importance: number },
|
||||
index: number,
|
||||
) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-border/30 bg-card p-4 shadow-sm relative flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex-1 text-sm text-foreground/90 leading-relaxed font-sans">
|
||||
{chunk.content}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`font-mono text-xs ${getImportanceColor(chunk.importance)}`}
|
||||
>
|
||||
Importance: {chunk.importance}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{chunk.quotes && chunk.quotes.length > 0 && (
|
||||
<div className="bg-secondary/10 border-l-2 border-primary/50 p-2.5 my-1 text-xs italic text-muted-foreground space-y-1">
|
||||
{chunk.quotes.map((quote: string, qIdx: number) => (
|
||||
<div key={qIdx}>“{quote}”</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs pt-2 border-t border-dotted border-border/10">
|
||||
{chunk.retainInBuffer ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-primary/5 text-primary border-primary/20 text-[10px] font-mono"
|
||||
>
|
||||
Pinned in Buffer
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-muted text-muted-foreground border-border/20 text-[10px] font-mono"
|
||||
>
|
||||
Pruned from Buffer
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{chunk.involvedEntityIds &&
|
||||
chunk.involvedEntityIds.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 ml-auto text-[10px] font-mono text-muted-foreground">
|
||||
<span>Entities:</span>
|
||||
{chunk.involvedEntityIds.map(
|
||||
(entId: string) => (
|
||||
<Badge
|
||||
key={entId}
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 border-border/20 font-mono"
|
||||
>
|
||||
{entId}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "prompt" && entry.rawPrompt && (
|
||||
<PromptAnalyzer
|
||||
components={
|
||||
entry.rawPrompt.components &&
|
||||
entry.rawPrompt.components.length > 0
|
||||
? entry.rawPrompt.components
|
||||
: [
|
||||
{
|
||||
label: "System Prompt",
|
||||
type: "system",
|
||||
content: entry.rawPrompt.systemPrompt || "",
|
||||
},
|
||||
{
|
||||
label: "User Context",
|
||||
type: "world",
|
||||
content: entry.rawPrompt.userContext || "",
|
||||
},
|
||||
]
|
||||
}
|
||||
inputTokens={entry.usage?.inputTokens || 0}
|
||||
maxContext={
|
||||
entry.usage?.maxContext !== undefined
|
||||
? entry.usage.maxContext
|
||||
: 32768
|
||||
}
|
||||
modelName={entry.usage?.modelName}
|
||||
providerInstanceName={entry.usage?.providerInstanceName}
|
||||
outputLabel="LLM Output (Promoted Memory Chunks)"
|
||||
outputText={
|
||||
handoffResult
|
||||
? JSON.stringify(handoffResult, null, 2)
|
||||
: undefined
|
||||
}
|
||||
outputTokens={entry.usage?.outputTokens}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "output" && (
|
||||
<div className="space-y-2 h-full flex flex-col">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
|
||||
Raw JSON Output
|
||||
</h4>
|
||||
<pre className="p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border flex-1 overflow-y-auto max-h-[500px]">
|
||||
{handoffResult
|
||||
? JSON.stringify(handoffResult, null, 2)
|
||||
: "No JSON Output recorded."}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
296
apps/gui/src/components/play/InteractView.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
"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";
|
||||
import { hydrate } from "@omnia/voice";
|
||||
import {
|
||||
Alert,
|
||||
AlertAction,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
|
||||
function IntentTag({
|
||||
intent,
|
||||
playerAliases,
|
||||
playerId,
|
||||
entities,
|
||||
}: {
|
||||
intent: SimSnapshot["log"][number]["intents"][number];
|
||||
playerAliases: Record<string, string>;
|
||||
playerId: string;
|
||||
entities: SimSnapshot["entities"];
|
||||
}) {
|
||||
const labels: Record<string, string> = {
|
||||
monologue: "thought",
|
||||
thought: "thought",
|
||||
dialogue: "dialogue",
|
||||
action: "action",
|
||||
};
|
||||
|
||||
const label = labels[intent.type] || intent.type;
|
||||
|
||||
let outcome = "";
|
||||
if (intent.type === "action") {
|
||||
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
|
||||
}
|
||||
|
||||
const viewerAliasesMap = new Map<string, string>();
|
||||
if (entities) {
|
||||
for (const ent of entities) {
|
||||
viewerAliasesMap.set(ent.id, ent.name || ent.id);
|
||||
}
|
||||
}
|
||||
if (playerAliases) {
|
||||
for (const [targetId, alias] of Object.entries(playerAliases)) {
|
||||
viewerAliasesMap.set(targetId, alias);
|
||||
}
|
||||
}
|
||||
|
||||
const viewerEntityMock = {
|
||||
id: playerId || "",
|
||||
aliases: viewerAliasesMap,
|
||||
};
|
||||
|
||||
const textToDisplay = hydrate(
|
||||
intent.content,
|
||||
viewerEntityMock as unknown as Parameters<typeof hydrate>[1],
|
||||
);
|
||||
|
||||
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}] “{textToDisplay}”{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,
|
||||
playerAliases,
|
||||
playerId,
|
||||
entities,
|
||||
}: {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
|
||||
isPlayerCard: boolean;
|
||||
playerAliases: Record<string, string>;
|
||||
playerId: string;
|
||||
entities: SimSnapshot["entities"];
|
||||
}) {
|
||||
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} · {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}
|
||||
playerAliases={playerAliases}
|
||||
playerId={playerId}
|
||||
entities={entities}
|
||||
/>
|
||||
))}
|
||||
</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;
|
||||
onShowHandoff: (entry: SimSnapshot["log"][number]) => void;
|
||||
logEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function InteractView({
|
||||
snapshot,
|
||||
loading,
|
||||
statusText,
|
||||
playerInput,
|
||||
setPlayerInput,
|
||||
onSubmitAction,
|
||||
onShowPrompt,
|
||||
onShowHandoff,
|
||||
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) => {
|
||||
if (entry.isHandoff) {
|
||||
return (
|
||||
<Alert
|
||||
key={i}
|
||||
className="max-w-md border-dashed bg-secondary/10"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<AlertTitle>
|
||||
Handoff triggered for {entry.entityName}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Memories were transferred from Cognitive Buffer to Memory
|
||||
Ledger
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<AlertAction>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => onShowHandoff(entry)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
const playerAliases = playerEntity?.aliases || {};
|
||||
const playerId = playerEntity?.id || "";
|
||||
|
||||
return (
|
||||
<LogEntryCard
|
||||
key={i}
|
||||
entry={entry}
|
||||
onShowPrompt={onShowPrompt}
|
||||
isPlayerCard={entry.entityId === playerEntity?.id}
|
||||
playerAliases={playerAliases}
|
||||
playerId={playerId}
|
||||
entities={snapshot.entities}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
apps/gui/src/components/play/ManageView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,11 @@ 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 { HandoffModal } from "./HandoffModal";
|
||||
import { InteractView } from "./InteractView";
|
||||
import { ManageView } from "./ManageView";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import {
|
||||
@@ -22,114 +24,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}] “{textToDisplay}”{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} · {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;
|
||||
@@ -163,6 +57,9 @@ export function PlayView() {
|
||||
const [selectedEntryForModal, setSelectedEntryForModal] = useState<
|
||||
SimSnapshot["log"][number] | null
|
||||
>(null);
|
||||
const [selectedHandoffForModal, setSelectedHandoffForModal] = useState<
|
||||
SimSnapshot["log"][number] | null
|
||||
>(null);
|
||||
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const steppingRef = useRef(false);
|
||||
@@ -422,242 +319,103 @@ export function PlayView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Simulation Global Controls */}
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{snapshot.status !== "done" && snapshot.status !== "error" && (
|
||||
<>
|
||||
{snapshot.status === "running" &&
|
||||
(loading ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
pauseRequestedRef.current = true;
|
||||
}}
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => runSteps(snapshot.id)}
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
pauseRequestedRef.current = true;
|
||||
router.push("/");
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mt-1 pt-1.5 border-t border-border/10">
|
||||
<span className="text-muted-foreground">
|
||||
Status:{" "}
|
||||
<span className="text-primary font-bold">
|
||||
{getUnifiedStatus()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Turn:{" "}
|
||||
<span className="text-foreground font-bold">{snapshot.turn}</span>
|
||||
</span>
|
||||
</div>
|
||||
{statusMessage() && (
|
||||
<p className="text-xs font-medium text-primary mt-1 font-mono">
|
||||
{loading && "⏳ "}
|
||||
{statusMessage()}
|
||||
</p>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
{/* Simulation Global Controls */}
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{snapshot.status !== "done" && snapshot.status !== "error" && (
|
||||
<>
|
||||
{snapshot.status === "running" &&
|
||||
(loading ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
pauseRequestedRef.current = true;
|
||||
}}
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => runSteps(snapshot.id)}
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !playerInput.trim()}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
pauseRequestedRef.current = true;
|
||||
router.push("/");
|
||||
}}
|
||||
>
|
||||
{loading ? "Processing..." : "Submit Action"}
|
||||
Stop
|
||||
</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>
|
||||
</div>
|
||||
</footer>
|
||||
<div className="flex items-center justify-between text-xs font-mono mt-1 pt-1.5 border-t border-border/10">
|
||||
<span className="text-muted-foreground">
|
||||
Status:{" "}
|
||||
<span className="text-primary font-bold">
|
||||
{getUnifiedStatus()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Turn:{" "}
|
||||
<span className="text-foreground font-bold">
|
||||
{snapshot.turn}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{statusMessage() && (
|
||||
<p className="text-xs font-medium text-primary mt-1 font-mono">
|
||||
{loading && "⏳ "}
|
||||
{statusMessage()}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{activeTab === "interact" ? (
|
||||
<InteractView
|
||||
snapshot={snapshot}
|
||||
loading={loading}
|
||||
statusText={statusText}
|
||||
playerInput={playerInput}
|
||||
setPlayerInput={setPlayerInput}
|
||||
onSubmitAction={handleSubmitAction}
|
||||
onShowPrompt={setSelectedEntryForModal}
|
||||
onShowHandoff={setSelectedHandoffForModal}
|
||||
logEndRef={logEndRef}
|
||||
/>
|
||||
) : (
|
||||
<ManageView snapshot={snapshot} onRename={setSnapshot} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && !loading && (
|
||||
<div className="fixed bottom-4 right-4 z-50 border border-destructive bg-destructive/90 text-destructive-foreground px-4 py-3 shadow-[3px_3px_0_0_var(--border)] text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEntryForModal && (
|
||||
<PromptModal
|
||||
entry={selectedEntryForModal}
|
||||
onClose={() => setSelectedEntryForModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedHandoffForModal && (
|
||||
<HandoffModal
|
||||
entry={selectedHandoffForModal}
|
||||
onClose={() => setSelectedHandoffForModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && !loading && (
|
||||
<div className="fixed bottom-4 right-4 z-50 border border-destructive bg-destructive/90 text-destructive-foreground px-4 py-3 shadow-[3px_3px_0_0_var(--border)] text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEntryForModal && (
|
||||
<PromptModal
|
||||
entry={selectedEntryForModal}
|
||||
onClose={() => setSelectedEntryForModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
172
apps/gui/src/components/play/PromptAnalyzer.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import type { PromptComponent } from "@/lib/simulation-types";
|
||||
|
||||
interface PromptAnalyzerProps {
|
||||
components: PromptComponent[];
|
||||
inputTokens: number;
|
||||
maxContext?: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
outputLabel?: string;
|
||||
outputText?: string;
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
||||
export function PromptAnalyzer({
|
||||
components,
|
||||
inputTokens,
|
||||
maxContext = 32768,
|
||||
modelName,
|
||||
providerInstanceName,
|
||||
outputLabel = "LLM Output",
|
||||
outputText,
|
||||
outputTokens,
|
||||
}: PromptAnalyzerProps) {
|
||||
const totalLen = components.reduce((sum, s) => sum + s.content.length, 0);
|
||||
|
||||
if (totalLen === 0) {
|
||||
return (
|
||||
<div className="text-sm italic text-muted-foreground">
|
||||
No prompt context recorded.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = components.map((s) => {
|
||||
const pct = totalLen > 0 ? (s.content.length / totalLen) * 100 : 0;
|
||||
return {
|
||||
...s,
|
||||
pct,
|
||||
tokens: Math.round((s.content.length / totalLen) * inputTokens),
|
||||
};
|
||||
});
|
||||
|
||||
const usagePctOfContext =
|
||||
maxContext > 0 ? (inputTokens / maxContext) * 100 : 0;
|
||||
const isAbsolute = maxContext > 0 && usagePctOfContext >= 20;
|
||||
|
||||
const getColorClass = (type: string) => {
|
||||
switch (type) {
|
||||
case "system":
|
||||
return "bg-blue-500";
|
||||
case "world":
|
||||
return "bg-emerald-500";
|
||||
case "events":
|
||||
return "bg-purple-500";
|
||||
case "memories":
|
||||
return "bg-pink-500";
|
||||
case "input":
|
||||
return "bg-amber-500";
|
||||
default:
|
||||
return "bg-slate-500";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Provider Details */}
|
||||
{(providerInstanceName || modelName) && (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
<strong>LLM Instance:</strong>{" "}
|
||||
<span>{providerInstanceName || "Default"}</span>
|
||||
{modelName && <span> ({modelName})</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar & Breakdown */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
|
||||
<span className="font-semibold">Input Prompt Breakdown</span>
|
||||
<span>
|
||||
Total Input Tokens: <strong>{inputTokens}</strong>
|
||||
{maxContext > 0 ? (
|
||||
<span>
|
||||
{" "}
|
||||
/ {maxContext} ({usagePctOfContext.toFixed(1)}% used)
|
||||
</span>
|
||||
) : (
|
||||
<span> (infinite context)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Token Bar */}
|
||||
<div className="flex h-6 w-full rounded border overflow-hidden bg-muted shadow-inner mb-2">
|
||||
{sections.map((item, idx) => {
|
||||
const widthPct = isAbsolute
|
||||
? item.pct * (inputTokens / maxContext)
|
||||
: item.pct;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`h-full transition-all duration-300 ${getColorClass(item.type)}`}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isAbsolute && (
|
||||
<div
|
||||
className="bg-white h-full"
|
||||
style={{ width: `${100 - usagePctOfContext}%` }}
|
||||
title={`Available: ${maxContext - inputTokens} tokens (${(100 - usagePctOfContext).toFixed(1)}% remaining)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Accordion Components */}
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{sections.map((item, idx) => {
|
||||
return (
|
||||
<AccordionItem key={idx} value={String(idx)}>
|
||||
<AccordionTrigger className="text-sm py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-sm ${getColorClass(item.type)}`}
|
||||
/>
|
||||
<span>{item.label}:</span>
|
||||
<span className="text-muted-foreground font-normal">
|
||||
<strong>{item.tokens}</strong> tokens (
|
||||
{item.pct.toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="m-0 p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border max-h-[300px] overflow-y-auto">
|
||||
{item.content}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Output Section */}
|
||||
{outputText && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2 font-mono">
|
||||
<span className="font-semibold">{outputLabel}</span>
|
||||
{outputTokens !== undefined && (
|
||||
<span>
|
||||
Total Output Tokens: <strong>{outputTokens}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded border-2">
|
||||
<pre className="m-0 p-3 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground max-h-[250px] overflow-y-auto">
|
||||
{outputText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import type { SimSnapshot, PromptBreakdown } from "@/lib/simulation-types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,13 +9,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
import { PromptSwitcher } from "@/components/play/PromptSwitcher";
|
||||
import { PromptAnalyzer } from "@/components/play/PromptAnalyzer";
|
||||
|
||||
interface PromptModalProps {
|
||||
entry: SimSnapshot["log"][number];
|
||||
@@ -23,164 +18,7 @@ interface PromptModalProps {
|
||||
}
|
||||
|
||||
export function PromptModal({ entry, onClose }: PromptModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"actor" | "decoder">("actor");
|
||||
|
||||
const parseActorPrompt = (
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
inputTokens: number,
|
||||
) => {
|
||||
const recentHeader = "=== RECENT EVENTS ===";
|
||||
const ledgerHeader = "=== YOUR MEMORIES ===";
|
||||
|
||||
const recentIdx = userContext.indexOf(recentHeader);
|
||||
let worldStr = userContext;
|
||||
let recentStr = "";
|
||||
let ledgerStr = "";
|
||||
|
||||
if (recentIdx !== -1) {
|
||||
worldStr = userContext.substring(0, recentIdx).trim();
|
||||
const rest = userContext.substring(recentIdx).trim();
|
||||
const ledgerIdx = rest.indexOf(ledgerHeader);
|
||||
if (ledgerIdx !== -1) {
|
||||
recentStr = rest.substring(0, ledgerIdx).trim();
|
||||
ledgerStr = rest.substring(ledgerIdx).trim();
|
||||
} else {
|
||||
recentStr = rest;
|
||||
}
|
||||
}
|
||||
|
||||
const sections: { label: string; type: string; content: string }[] = [
|
||||
{ label: "System Prompt", type: "system", content: systemPrompt },
|
||||
{ label: "World Info", type: "world", content: worldStr },
|
||||
{ label: "Recent Events", type: "events", content: recentStr || "(No recent events.)" },
|
||||
{ label: "Long-Term Memories", type: "memories", content: ledgerStr || "(No long-term memories.)" },
|
||||
];
|
||||
|
||||
const totalLen = sections.reduce((sum, s) => sum + s.content.length, 0);
|
||||
if (totalLen === 0) return null;
|
||||
|
||||
return sections.map((s) => {
|
||||
const pct = (s.content.length / totalLen) * 100;
|
||||
return {
|
||||
...s,
|
||||
pct,
|
||||
relativePct: pct,
|
||||
tokens: Math.round((s.content.length / totalLen) * inputTokens),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const parseDecoderPrompt = (
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
inputTokens: number,
|
||||
) => {
|
||||
const proseHeader = "=== NARRATIVE PROSE ===";
|
||||
const idx = userContext.indexOf(proseHeader);
|
||||
|
||||
let worldStr = userContext;
|
||||
let proseStr = "";
|
||||
|
||||
if (idx !== -1) {
|
||||
worldStr = userContext.substring(0, idx).trim();
|
||||
proseStr = userContext.substring(idx).trim();
|
||||
}
|
||||
|
||||
const sysLen = systemPrompt.length;
|
||||
const worldLen = worldStr.length;
|
||||
const proseLen = proseStr.length;
|
||||
const totalLen = sysLen + worldLen + proseLen;
|
||||
|
||||
if (totalLen === 0) return null;
|
||||
|
||||
const sysPct = (sysLen / totalLen) * 100;
|
||||
const worldPct = (worldLen / totalLen) * 100;
|
||||
const prosePct = (proseLen / totalLen) * 100;
|
||||
|
||||
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
|
||||
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
|
||||
const proseTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
|
||||
|
||||
return [
|
||||
{
|
||||
label: "System Prompt",
|
||||
pct: sysPct,
|
||||
relativePct: sysPct,
|
||||
tokens: sysTokens,
|
||||
type: "system",
|
||||
content: systemPrompt,
|
||||
},
|
||||
{
|
||||
label: "Decoder Context",
|
||||
pct: worldPct,
|
||||
relativePct: worldPct,
|
||||
tokens: worldTokens,
|
||||
type: "world",
|
||||
content: worldStr,
|
||||
},
|
||||
{
|
||||
label: "Narrative Prose",
|
||||
pct: prosePct,
|
||||
relativePct: prosePct,
|
||||
tokens: proseTokens,
|
||||
type: "memories",
|
||||
content: proseStr,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const actorBreakdown =
|
||||
entry.rawPrompt && entry.usage
|
||||
? parseActorPrompt(
|
||||
entry.rawPrompt.systemPrompt,
|
||||
entry.rawPrompt.userContext,
|
||||
entry.usage.inputTokens,
|
||||
)
|
||||
: null;
|
||||
const decoderBreakdown =
|
||||
entry.decoderPrompt && entry.decoderUsage
|
||||
? parseDecoderPrompt(
|
||||
entry.decoderPrompt.systemPrompt,
|
||||
entry.decoderPrompt.userContext,
|
||||
entry.decoderUsage.inputTokens,
|
||||
)
|
||||
: null;
|
||||
|
||||
const actorMaxContext =
|
||||
entry.usage?.maxContext !== undefined ? entry.usage.maxContext : 32768;
|
||||
const actorUsedTokens = entry.usage?.inputTokens || 0;
|
||||
const actorUsagePctOfContext =
|
||||
actorMaxContext > 0 ? (actorUsedTokens / actorMaxContext) * 100 : 0;
|
||||
const isActorAbsolute = actorMaxContext > 0 && actorUsagePctOfContext >= 20;
|
||||
|
||||
const scaledActorBreakdown = actorBreakdown
|
||||
? actorBreakdown.map((item) => ({
|
||||
...item,
|
||||
pct: isActorAbsolute
|
||||
? item.relativePct * (actorUsedTokens / actorMaxContext)
|
||||
: item.relativePct,
|
||||
}))
|
||||
: null;
|
||||
|
||||
const decoderMaxContext =
|
||||
entry.decoderUsage?.maxContext !== undefined
|
||||
? entry.decoderUsage.maxContext
|
||||
: 32768;
|
||||
const decoderUsedTokens = entry.decoderUsage?.inputTokens || 0;
|
||||
const decoderUsagePctOfContext =
|
||||
decoderMaxContext > 0 ? (decoderUsedTokens / decoderMaxContext) * 100 : 0;
|
||||
const isDecoderAbsolute =
|
||||
decoderMaxContext > 0 && decoderUsagePctOfContext >= 20;
|
||||
|
||||
const scaledDecoderBreakdown = decoderBreakdown
|
||||
? decoderBreakdown.map((item) => ({
|
||||
...item,
|
||||
pct: isDecoderAbsolute
|
||||
? item.relativePct * (decoderUsedTokens / decoderMaxContext)
|
||||
: item.relativePct,
|
||||
}))
|
||||
: null;
|
||||
const [activeTab, setActiveTab] = useState<string>("actor");
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry.rawPrompt && entry.decoderPrompt) {
|
||||
@@ -188,9 +26,47 @@ export function PromptModal({ entry, onClose }: PromptModalProps) {
|
||||
}
|
||||
}, [entry]);
|
||||
|
||||
// Helper to resolve components with a fallback if none exist (for backwards-compatibility)
|
||||
const getComponents = (
|
||||
promptBreakdown: PromptBreakdown | null | undefined,
|
||||
defaultType: "world" | "input",
|
||||
) => {
|
||||
if (!promptBreakdown) return [];
|
||||
if (promptBreakdown.components && promptBreakdown.components.length > 0) {
|
||||
return promptBreakdown.components;
|
||||
}
|
||||
// Fallback: convert flat strings into components list
|
||||
return [
|
||||
{
|
||||
label: "System Prompt",
|
||||
type: "system" as const,
|
||||
content: promptBreakdown.systemPrompt || "",
|
||||
},
|
||||
{
|
||||
label: "User Context",
|
||||
type: defaultType,
|
||||
content: promptBreakdown.userContext || "",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const actorComponents = getComponents(entry.rawPrompt, "world");
|
||||
const decoderComponents = getComponents(entry.decoderPrompt, "input");
|
||||
|
||||
const isValidatorTab = activeTab.startsWith("validator-");
|
||||
const validatorIndex = isValidatorTab
|
||||
? parseInt(activeTab.substring("validator-".length), 10)
|
||||
: -1;
|
||||
const validatorCall = isValidatorTab
|
||||
? entry.validatorCalls?.find((c) => c.intentIndex === validatorIndex)
|
||||
: null;
|
||||
const validatorComponents = validatorCall
|
||||
? getComponents(validatorCall.prompt, "world")
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-[750px] sm:max-w-[750px] h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
<DialogContent className="max-w-187.5 sm:max-w-187.5 h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 pt-5 pb-4 border-b">
|
||||
<DialogTitle className="text-lg">
|
||||
Raw Prompts & Token Usage ({entry.entityName})
|
||||
@@ -202,244 +78,78 @@ export function PromptModal({ entry, onClose }: PromptModalProps) {
|
||||
onTabChange={setActiveTab}
|
||||
hasActor={!!entry.rawPrompt}
|
||||
hasDecoder={!!entry.decoderPrompt}
|
||||
validatorCalls={
|
||||
entry.validatorCalls?.map((c) => ({
|
||||
intentIndex: c.intentIndex,
|
||||
intentContent: c.intentContent,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-5">
|
||||
{activeTab === "actor" && entry.rawPrompt && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{entry.usage ? (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
<strong>LLM Instance:</strong>{" "}
|
||||
<span>{entry.usage.providerInstanceName || "Default"}</span>
|
||||
{entry.usage.modelName && (
|
||||
<span> ({entry.usage.modelName})</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm italic text-muted-foreground">
|
||||
No LLM token usage (Player turn used fixed prose).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scaledActorBreakdown && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
|
||||
<span className="font-semibold">
|
||||
Input Prompt Breakdown
|
||||
</span>
|
||||
<span>
|
||||
Total Input Tokens: <strong>{actorUsedTokens}</strong>
|
||||
{actorMaxContext > 0 ? (
|
||||
<span>
|
||||
{" "}
|
||||
/ {actorMaxContext} (
|
||||
{actorUsagePctOfContext.toFixed(1)}% used)
|
||||
</span>
|
||||
) : (
|
||||
<span> (infinite context)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-6 w-full rounded border overflow-hidden bg-muted shadow-inner mb-2">
|
||||
{scaledActorBreakdown.map((item, idx) => {
|
||||
const displayPct =
|
||||
actorMaxContext > 0
|
||||
? (item.tokens / actorMaxContext) * 100
|
||||
: item.relativePct;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`h-full transition-all duration-300 ${
|
||||
item.type === "system"
|
||||
? "bg-blue-500"
|
||||
: item.type === "world"
|
||||
? "bg-emerald-500"
|
||||
: item.type === "memories"
|
||||
? "bg-purple-500"
|
||||
: "bg-amber-500"
|
||||
}`}
|
||||
style={{ width: `${item.pct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${displayPct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isActorAbsolute && (
|
||||
<div
|
||||
className="bg-white h-full"
|
||||
style={{ width: `${100 - actorUsagePctOfContext}%` }}
|
||||
title={`Available: ${actorMaxContext - actorUsedTokens} tokens (${(100 - actorUsagePctOfContext).toFixed(1)}% remaining)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Accordion type="multiple">
|
||||
{scaledActorBreakdown.map((item, idx) => {
|
||||
const displayPct =
|
||||
actorMaxContext > 0
|
||||
? (item.tokens / actorMaxContext) * 100
|
||||
: item.relativePct;
|
||||
return (
|
||||
<AccordionItem key={idx} value={String(idx)}>
|
||||
<AccordionTrigger className="text-sm">
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-sm mr-2 ${
|
||||
item.type === "system"
|
||||
? "bg-blue-500"
|
||||
: item.type === "world"
|
||||
? "bg-emerald-500"
|
||||
: item.type === "memories"
|
||||
? "bg-purple-500"
|
||||
: "bg-amber-500"
|
||||
}`}
|
||||
/>
|
||||
{item.label}: <strong>{item.tokens}</strong> tokens
|
||||
({displayPct.toFixed(0)}%)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="m-0 p-2 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground">
|
||||
{item.content}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.usage && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2">
|
||||
<span className="font-semibold">LLM Output</span>
|
||||
<span>
|
||||
Total Output Tokens:{" "}
|
||||
<strong>{entry.usage.outputTokens}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded border-2">
|
||||
<pre className="m-0 p-2 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground">
|
||||
{entry.narrativeProse}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PromptAnalyzer
|
||||
components={actorComponents}
|
||||
inputTokens={entry.usage?.inputTokens || 0}
|
||||
maxContext={
|
||||
entry.usage?.maxContext !== undefined
|
||||
? entry.usage.maxContext
|
||||
: 32768
|
||||
}
|
||||
modelName={entry.usage?.modelName}
|
||||
providerInstanceName={entry.usage?.providerInstanceName}
|
||||
outputLabel="LLM Output (Narrative Prose)"
|
||||
outputText={entry.narrativeProse}
|
||||
outputTokens={entry.usage?.outputTokens}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "decoder" && entry.decoderPrompt && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{entry.decoderUsage && (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
<strong>LLM Instance:</strong>{" "}
|
||||
<span>
|
||||
{entry.decoderUsage.providerInstanceName || "Default"}
|
||||
</span>
|
||||
{entry.decoderUsage.modelName && (
|
||||
<span> ({entry.decoderUsage.modelName})</span>
|
||||
)}
|
||||
</div>
|
||||
<PromptAnalyzer
|
||||
components={decoderComponents}
|
||||
inputTokens={entry.decoderUsage?.inputTokens || 0}
|
||||
maxContext={
|
||||
entry.decoderUsage?.maxContext !== undefined
|
||||
? entry.decoderUsage.maxContext
|
||||
: 32768
|
||||
}
|
||||
modelName={entry.decoderUsage?.modelName}
|
||||
providerInstanceName={entry.decoderUsage?.providerInstanceName}
|
||||
outputLabel="LLM Output (Decoded Intent Sequence)"
|
||||
outputText={JSON.stringify(
|
||||
entry.decodedIntents || entry.intents,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
outputTokens={entry.decoderUsage?.outputTokens}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scaledDecoderBreakdown && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
|
||||
<span className="font-semibold">
|
||||
Input Prompt Breakdown
|
||||
</span>
|
||||
<span>
|
||||
Total Input Tokens: <strong>{decoderUsedTokens}</strong>
|
||||
{decoderMaxContext > 0 ? (
|
||||
<span>
|
||||
{" "}
|
||||
/ {decoderMaxContext} (
|
||||
{decoderUsagePctOfContext.toFixed(1)}% used)
|
||||
</span>
|
||||
) : (
|
||||
<span> (infinite context)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-6 w-full rounded border overflow-hidden bg-muted shadow-inner mb-2">
|
||||
{scaledDecoderBreakdown.map((item, idx) => {
|
||||
const displayPct =
|
||||
decoderMaxContext > 0
|
||||
? (item.tokens / decoderMaxContext) * 100
|
||||
: item.relativePct;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`h-full transition-all duration-300 ${
|
||||
item.type === "system"
|
||||
? "bg-blue-500"
|
||||
: item.type === "world"
|
||||
? "bg-emerald-500"
|
||||
: item.type === "memories"
|
||||
? "bg-purple-500"
|
||||
: "bg-amber-500"
|
||||
}`}
|
||||
style={{ width: `${item.pct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${displayPct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isDecoderAbsolute && (
|
||||
<div
|
||||
className="bg-white h-full"
|
||||
style={{ width: `${100 - decoderUsagePctOfContext}%` }}
|
||||
title={`Available: ${decoderMaxContext - decoderUsedTokens} tokens (${(100 - decoderUsagePctOfContext).toFixed(1)}% remaining)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Accordion type="multiple">
|
||||
{scaledDecoderBreakdown.map((item, idx) => {
|
||||
const displayPct =
|
||||
decoderMaxContext > 0
|
||||
? (item.tokens / decoderMaxContext) * 100
|
||||
: item.relativePct;
|
||||
return (
|
||||
<AccordionItem key={idx} value={String(idx)}>
|
||||
<AccordionTrigger className="text-sm">
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-sm mr-2 ${
|
||||
item.type === "system"
|
||||
? "bg-blue-500"
|
||||
: item.type === "world"
|
||||
? "bg-emerald-500"
|
||||
: item.type === "memories"
|
||||
? "bg-purple-500"
|
||||
: "bg-amber-500"
|
||||
}`}
|
||||
/>
|
||||
{item.label}: <strong>{item.tokens}</strong> tokens
|
||||
({displayPct.toFixed(0)}%)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="m-0 p-2 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground">
|
||||
{item.content}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
{validatorCall && validatorCall.prompt && (
|
||||
<PromptAnalyzer
|
||||
components={validatorComponents}
|
||||
inputTokens={validatorCall.usage?.inputTokens || 0}
|
||||
maxContext={
|
||||
validatorCall.usage?.maxContext !== undefined
|
||||
? validatorCall.usage.maxContext
|
||||
: 32768
|
||||
}
|
||||
modelName={validatorCall.usage?.modelName}
|
||||
providerInstanceName={validatorCall.usage?.providerInstanceName}
|
||||
outputLabel={`LLM Output (Validation for: "${validatorCall.intentContent}")`}
|
||||
outputText={JSON.stringify(validatorCall.response, null, 2)}
|
||||
outputTokens={validatorCall.usage?.outputTokens}
|
||||
/>
|
||||
)}
|
||||
|
||||
{entry.decoderUsage && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2">
|
||||
<span className="font-semibold">LLM Output</span>
|
||||
<span>
|
||||
Total Output Tokens:{" "}
|
||||
<strong>{entry.decoderUsage.outputTokens}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded border-2">
|
||||
<pre className="m-0 p-2 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground">
|
||||
{JSON.stringify(entry.intents, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{validatorCall && !validatorCall.prompt && (
|
||||
<div className="flex flex-col items-center justify-center border border-dashed rounded-lg bg-muted/20 text-muted-foreground p-8 my-6">
|
||||
<span className="text-sm font-semibold mb-2 text-foreground">
|
||||
Bypassed LLM Validation
|
||||
</span>
|
||||
<p className="text-xs text-center text-muted-foreground max-w-md">
|
||||
{validatorCall.response.reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
interface PromptSwitcherProps {
|
||||
activeTab: "actor" | "decoder";
|
||||
onTabChange: (tab: "actor" | "decoder") => void;
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
hasActor: boolean;
|
||||
hasDecoder: boolean;
|
||||
validatorCalls?: { intentIndex: number; intentContent: string }[];
|
||||
}
|
||||
|
||||
export function PromptSwitcher({
|
||||
@@ -12,32 +13,67 @@ export function PromptSwitcher({
|
||||
onTabChange,
|
||||
hasActor,
|
||||
hasDecoder,
|
||||
validatorCalls = [],
|
||||
}: PromptSwitcherProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-4 border-b bg-muted/50 px-5 py-4">
|
||||
<button
|
||||
onClick={() => onTabChange("actor")}
|
||||
disabled={!hasActor}
|
||||
className={`flex h-14 w-40 items-center justify-center border-2 text-sm font-medium transition-all ${
|
||||
activeTab === "actor"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/30 bg-card text-foreground hover:border-primary/50"
|
||||
} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
Actor Prompt
|
||||
</button>
|
||||
<span className="text-xl text-muted-foreground">→</span>
|
||||
<button
|
||||
onClick={() => onTabChange("decoder")}
|
||||
disabled={!hasDecoder}
|
||||
className={`flex h-14 w-44 items-center justify-center border-2 text-sm font-medium transition-all ${
|
||||
activeTab === "decoder"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/30 bg-card text-foreground hover:border-primary/50"
|
||||
} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
Intent Decoder
|
||||
</button>
|
||||
<div className="flex items-center justify-center gap-4 border-b bg-muted/40 px-6 py-5 overflow-x-auto">
|
||||
{/* Primary Pipeline (Linear flow to the left) */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => onTabChange("actor")}
|
||||
disabled={!hasActor}
|
||||
className={`flex h-12 w-36 items-center justify-center border-2 text-xs font-semibold uppercase tracking-wider transition-all ${
|
||||
activeTab === "actor"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border bg-card text-foreground hover:border-primary/50"
|
||||
} disabled:cursor-not-allowed disabled:opacity-40 rounded`}
|
||||
>
|
||||
Actor Prompt
|
||||
</button>
|
||||
|
||||
<span className="text-lg text-muted-foreground">→</span>
|
||||
|
||||
<button
|
||||
onClick={() => onTabChange("decoder")}
|
||||
disabled={!hasDecoder}
|
||||
className={`flex h-12 w-36 items-center justify-center border-2 text-xs font-semibold uppercase tracking-wider transition-all ${
|
||||
activeTab === "decoder"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border bg-card text-foreground hover:border-primary/50"
|
||||
} disabled:cursor-not-allowed disabled:opacity-40 rounded`}
|
||||
>
|
||||
Intent Decoder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Branching Validator Column to the right of Intent Decoder */}
|
||||
{validatorCalls.length > 0 && (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-lg text-muted-foreground">→</span>
|
||||
|
||||
<div className="flex flex-col gap-2 pl-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{validatorCalls.map((call) => {
|
||||
const tabKey = `validator-${call.intentIndex}`;
|
||||
return (
|
||||
<button
|
||||
key={tabKey}
|
||||
onClick={() => onTabChange(tabKey)}
|
||||
className={`flex h-12 w-36 items-center justify-center border-2 text-xs font-semibold uppercase tracking-wider transition-all rounded ${
|
||||
activeTab === tabKey
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border bg-card text-foreground hover:border-primary/50"
|
||||
}`}
|
||||
title={call.intentContent}
|
||||
>
|
||||
LLM Validator (Intent #{call.intentIndex})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,17 +9,39 @@ interface ScenarioCardProps {
|
||||
name: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ScenarioCard({ name, description, onClick }: ScenarioCardProps) {
|
||||
export function ScenarioCard({
|
||||
name,
|
||||
description,
|
||||
onClick,
|
||||
disabled,
|
||||
}: ScenarioCardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="flex-shrink-0 w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all"
|
||||
onClick={disabled ? undefined : onClick}
|
||||
className={`flex-shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm transition-all ${
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed filter grayscale"
|
||||
: "cursor-pointer hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<strong className="text-body-md text-foreground block mb-2">{name}</strong>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-1">{truncate(description, 80)}</p>
|
||||
<span className="mt-4 flex items-center justify-center size-7 border border-primary bg-primary/10 text-primary font-mono text-sm font-bold">{'>'}</span>
|
||||
<strong className="text-body-md text-foreground block mb-2">
|
||||
{name}
|
||||
</strong>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-1">
|
||||
{truncate(description, 80)}
|
||||
</p>
|
||||
<span
|
||||
className={`mt-4 flex items-center justify-center size-7 border font-mono text-sm font-bold ${
|
||||
disabled
|
||||
? "border-muted text-muted bg-muted/10"
|
||||
: "border-primary bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
{">"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Smooth, premium easing for the open/close — fast out of the gate, gentle
|
||||
// settle. Shared by the panel height and the chevron so they move in lockstep.
|
||||
const EASE = "ease-[cubic-bezier(0.32,0.72,0,1)]"
|
||||
const EASE = "ease-[cubic-bezier(0.32,0.72,0,1)]";
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
@@ -20,7 +20,7 @@ function Accordion({
|
||||
className={cn("flex w-full flex-col gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
@@ -32,11 +32,11 @@ function AccordionItem({
|
||||
data-slot="accordion-item"
|
||||
className={cn(
|
||||
"overflow-hidden rounded border-2 bg-background text-foreground shadow-md transition-shadow duration-200 hover:shadow-sm data-[state=open]:shadow-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
@@ -50,7 +50,7 @@ function AccordionTrigger({
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"flex flex-1 cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left font-head transition-colors hover:bg-muted/50 data-[state=open]:bg-muted/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -60,12 +60,12 @@ function AccordionTrigger({
|
||||
data-slot="accordion-trigger-icon"
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-300",
|
||||
EASE
|
||||
EASE,
|
||||
)}
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
@@ -89,13 +89,13 @@ function AccordionContent({
|
||||
// Fade + nudge the content as the panel opens/closes, synced to the slide.
|
||||
"group-data-[state=closed]/panel:-translate-y-1 group-data-[state=closed]/panel:opacity-0",
|
||||
"[&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
|
||||
62
apps/gui/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(
|
||||
"relative w-full rounded border border-border/30 bg-card p-4 text-sm shadow-[2px_2px_0_0_var(--border)] flex flex-col md:flex-row md:items-center gap-3 justify-between",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"font-head font-bold leading-none tracking-tight text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xs text-muted-foreground mt-1 flex-1 leading-relaxed md:mt-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
const AlertAction = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("shrink-0 flex items-center mt-2 md:mt-0 md:ml-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertAction.displayName = "AlertAction";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction };
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded border px-2 py-0.5 text-xs font-head font-medium whitespace-nowrap transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
@@ -24,8 +24,8 @@ const badgeVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
@@ -34,7 +34,7 @@ function Badge({
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
const Comp = asChild ? Slot.Root : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -43,7 +43,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium border border-border/30 outline-none transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 shadow-sm active:translate-x-[1px] active:translate-y-[1px] active:shadow-xs focus-visible:ring-2 focus-visible:ring-ring",
|
||||
@@ -12,16 +12,17 @@ const buttonVariants = cva(
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary-hover",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"bg-background hover:bg-secondary",
|
||||
outline: "bg-background hover:bg-secondary",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary-foreground/10",
|
||||
ghost: "border-transparent shadow-none active:translate-x-0 active:translate-y-0 active:shadow-none hover:bg-secondary",
|
||||
ghost:
|
||||
"border-transparent shadow-none active:translate-x-0 active:translate-y-0 active:shadow-none hover:bg-secondary",
|
||||
link: "border-transparent shadow-none active:translate-x-0 active:translate-y-0 active:shadow-none text-primary hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 px-3 text-xs",
|
||||
xs: "h-7 px-2.5 text-xs",
|
||||
lg: "h-11 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
@@ -30,27 +31,28 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({
|
||||
className,
|
||||
@@ -13,11 +13,11 @@ function Card({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden border border-border/30 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[2px_2px_0_0_var(--border)] [--card-spacing:--spacing(4)] data-[size=sm]:[--card-spacing:--spacing(3)]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -26,24 +26,21 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] border-b border-dotted border-border/20 pb-4 mb-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-head text-headline-sm text-foreground",
|
||||
className
|
||||
)}
|
||||
className={cn("font-head text-headline-sm text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -53,7 +50,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -62,11 +59,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -76,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -85,11 +82,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center border-t border-dotted border-border/20 bg-muted/30 px-(--card-spacing) pt-(--card-spacing)",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -100,4 +97,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
@@ -15,7 +15,7 @@ function Checkbox({
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-5 shrink-0 items-center justify-center rounded border-2 bg-input shadow-sm transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-checked:border-border data-checked:bg-primary data-checked:text-primary-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -26,7 +26,7 @@ function Checkbox({
|
||||
<CheckIcon />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
299
apps/gui/src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root;
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<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} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"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}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-72 scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-8 flex-wrap items-center gap-1 rounded border-2 bg-input bg-clip-padding px-2.5 py-1 text-sm shadow-sm transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-primary has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"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}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
@@ -1,34 +1,34 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
@@ -40,11 +40,11 @@ function DialogOverlay({
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-foreground/20 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -53,7 +53,7 @@ function DialogContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
@@ -62,7 +62,7 @@ function DialogContent({
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded border-2 bg-popover p-4 text-sm text-popover-foreground shadow-md duration-100 outline-none sm:max-w-sm 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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -81,7 +81,7 @@ function DialogContent({
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -91,7 +91,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
@@ -100,14 +100,14 @@ function DialogFooter({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t-2 bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -118,7 +118,7 @@ function DialogFooter({
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
@@ -128,13 +128,10 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-head text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
className={cn("font-head text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
@@ -146,11 +143,11 @@ function DialogDescription({
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -164,4 +161,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded border-2 bg-card p-6 text-center text-balance",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -22,7 +22,7 @@ function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
@@ -37,8 +37,8 @@ const emptyMediaVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
@@ -52,20 +52,17 @@ function EmptyMedia({
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn(
|
||||
"text-sm font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
className={cn("text-sm font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
@@ -74,11 +71,11 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -87,11 +84,11 @@ function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -101,4 +98,4 @@ export {
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
};
|
||||
|
||||
173
apps/gui/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"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-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end":
|
||||
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
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)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
render,
|
||||
...props
|
||||
}: 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 Record<string, unknown>)?.className as
|
||||
string | undefined,
|
||||
className,
|
||||
),
|
||||
type,
|
||||
...props,
|
||||
} as Record<string, unknown> as React.HTMLAttributes<HTMLElement>);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@@ -9,11 +9,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded border-2 bg-input px-3 py-2 text-sm shadow-sm transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -13,11 +13,11 @@ function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
@@ -31,7 +31,7 @@ function ItemSeparator({
|
||||
className={cn("my-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
@@ -53,8 +53,8 @@ const itemVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Item({
|
||||
className,
|
||||
@@ -69,7 +69,7 @@ function Item({
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
@@ -77,7 +77,7 @@ function Item({
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
@@ -94,8 +94,8 @@ const itemMediaVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
@@ -109,7 +109,7 @@ function ItemMedia({
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -118,11 +118,11 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -131,11 +131,11 @@ function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
@@ -144,11 +144,11 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -158,7 +158,7 @@ function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -167,11 +167,11 @@ function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -180,11 +180,11 @@ function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -198,4 +198,4 @@ export {
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
@@ -14,11 +14,11 @@ function Label({
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-head font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
@@ -11,7 +11,7 @@ function NavigationMenu({
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
@@ -19,14 +19,14 @@ function NavigationMenu({
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
@@ -38,11 +38,11 @@ function NavigationMenuList({
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
@@ -55,12 +55,12 @@ function NavigationMenuItem({
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-secondary hover:text-foreground focus:bg-secondary focus:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-secondary data-popup-open:text-foreground data-open:bg-secondary data-open:text-foreground"
|
||||
)
|
||||
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-secondary hover:text-foreground focus:bg-secondary focus:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-secondary data-popup-open:text-foreground data-open:bg-secondary data-open:text-foreground",
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
@@ -79,7 +79,7 @@ function NavigationMenuTrigger({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
@@ -91,11 +91,11 @@ function NavigationMenuContent({
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"top-0 left-0 w-full p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:border-border/30 group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow-md group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
@@ -105,19 +105,19 @@ function NavigationMenuViewport({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center",
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden border border-border/30 bg-popover text-popover-foreground shadow-md duration-100 md:w-(--radix-navigation-menu-viewport-width) data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
@@ -129,11 +129,11 @@ function NavigationMenuLink({
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 text-sm transition-all outline-none hover:bg-secondary hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary in-data-[slot=navigation-menu-content]:rounded-sm data-active:bg-primary/15 data-active:text-primary [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
@@ -145,13 +145,13 @@ function NavigationMenuIndicator({
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -164,4 +164,4 @@ export {
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
@@ -15,7 +15,7 @@ function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
@@ -25,7 +25,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
@@ -34,7 +34,7 @@ function SelectTrigger({
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -42,7 +42,7 @@ function SelectTrigger({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded border border-border/30 bg-input py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-sm transition-colors outline-none select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -53,7 +53,7 @@ function SelectTrigger({
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
@@ -83,7 +83,10 @@ function SelectContent({
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded border border-border/30 bg-popover text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none 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 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
className={cn(
|
||||
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded border border-border/30 bg-popover text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none 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 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
@@ -92,7 +95,7 @@ function SelectContent({
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
@@ -105,7 +108,7 @@ function SelectLabel({
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
@@ -118,7 +121,7 @@ function SelectItem({
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -133,7 +136,7 @@ function SelectItem({
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
@@ -146,7 +149,7 @@ function SelectSeparator({
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
@@ -158,13 +161,13 @@ function SelectScrollUpButton({
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
@@ -176,13 +179,13 @@ function SelectScrollDownButton({
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -196,4 +199,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
@@ -11,7 +11,7 @@ const Separator = React.forwardRef<
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
@@ -20,12 +20,12 @@ const Separator = React.forwardRef<
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
@@ -22,13 +22,13 @@ const SheetOverlay = React.forwardRef<
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
@@ -46,11 +46,12 @@ const sheetVariants = cva(
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
extends
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
@@ -71,8 +72,8 @@ const SheetContent = React.forwardRef<
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
@@ -81,12 +82,12 @@ const SheetHeader = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
@@ -95,12 +96,12 @@ const SheetFooter = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
@@ -111,8 +112,8 @@ const SheetTitle = React.forwardRef<
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
@@ -123,8 +124,8 @@ const SheetDescription = React.forwardRef<
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
@@ -137,4 +138,4 @@ export {
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
@@ -61,36 +61,36 @@ function SidebarProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
@@ -99,18 +99,18 @@ function SidebarProvider({
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
@@ -122,8 +122,8 @@ function SidebarProvider({
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
@@ -138,14 +138,14 @@ function SidebarProvider({
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
@@ -157,11 +157,11 @@ function Sidebar({
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
@@ -169,13 +169,13 @@ function Sidebar({
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-card text-sidebar-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
@@ -201,7 +201,7 @@ function Sidebar({
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -222,7 +222,7 @@ function Sidebar({
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
@@ -234,7 +234,7 @@ function Sidebar({
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r-2 group-data-[side=right]:border-l-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -247,7 +247,7 @@ function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
@@ -255,7 +255,7 @@ function SidebarTrigger({
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -265,19 +265,19 @@ function SidebarTrigger({
|
||||
size="icon"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -294,11 +294,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
@@ -307,11 +307,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
@@ -325,7 +325,7 @@ function SidebarInput({
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -336,7 +336,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -347,7 +347,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
@@ -361,7 +361,7 @@ function SidebarSeparator({
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -371,11 +371,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -386,7 +386,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
@@ -394,7 +394,7 @@ function SidebarGroupLabel({
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
const Comp = asChild ? Slot.Root : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -402,11 +402,11 @@ function SidebarGroupLabel({
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
@@ -414,7 +414,7 @@ function SidebarGroupAction({
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -422,11 +422,11 @@ function SidebarGroupAction({
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
@@ -440,7 +440,7 @@ function SidebarGroupContent({
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
@@ -451,7 +451,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
@@ -462,7 +462,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
@@ -484,8 +484,8 @@ const sidebarMenuButtonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
@@ -496,12 +496,12 @@ function SidebarMenuButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
@@ -512,16 +512,16 @@ function SidebarMenuButton({
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -534,7 +534,7 @@ function SidebarMenuButton({
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
@@ -543,10 +543,10 @@ function SidebarMenuAction({
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -556,11 +556,11 @@ function SidebarMenuAction({
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
@@ -573,11 +573,11 @@ function SidebarMenuBadge({
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
@@ -585,12 +585,12 @@ function SidebarMenuSkeleton({
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -615,7 +615,7 @@ function SidebarMenuSkeleton({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
@@ -625,11 +625,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
@@ -643,7 +643,7 @@ function SidebarMenuSubItem({
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
@@ -653,11 +653,11 @@ function SidebarMenuSubButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
const Comp = asChild ? Slot.Root : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -667,11 +667,11 @@ function SidebarMenuSubButton({
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 active:bg-accent active:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-primary data-active:text-primary-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -699,4 +699,4 @@ export {
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
@@ -10,7 +10,7 @@ function Skeleton({
|
||||
className={cn("animate-pulse rounded-md bg-muted/60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
@@ -11,7 +11,7 @@ function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
export { Spinner };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
@@ -16,7 +16,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
@@ -26,7 +26,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
className={cn("[&_tr]:border-b-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
@@ -36,7 +36,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
@@ -45,11 +45,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t-2 bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
@@ -58,11 +58,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b-2 transition-colors hover:bg-accent has-aria-expanded:bg-accent data-[state=selected]:bg-accent",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
@@ -71,11 +71,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 bg-muted px-2 text-left align-middle font-head font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
@@ -84,11 +84,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
@@ -101,7 +101,7 @@ function TableCaption({
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -113,4 +113,4 @@ export {
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
@@ -17,11 +17,11 @@ function Tabs({
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
@@ -36,8 +36,8 @@ const tabsListVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
@@ -52,7 +52,7 @@ function TabsList({
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
@@ -67,11 +67,11 @@ function TabsTrigger({
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-primary data-active:text-primary-foreground group-data-[variant=line]/tabs-list:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-1 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-1 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
@@ -84,7 +84,7 @@ function TabsContent({
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded border-2 bg-input px-3 py-2 text-sm shadow-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
@@ -20,11 +20,11 @@ const TooltipContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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-tooltip-content-transform-origin]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(mql.matches)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(mql.matches)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
setIsMobile(mql.matches);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(mql.matches);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile
|
||||
return !!isMobile;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export interface IntentInfo {
|
||||
type: string;
|
||||
description: string;
|
||||
selfDescription?: string;
|
||||
content: string;
|
||||
modifiers: string[];
|
||||
targetIds: string[];
|
||||
isValid?: boolean;
|
||||
@@ -9,16 +8,25 @@ export interface IntentInfo {
|
||||
minutesToAdvance?: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
turn: number;
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
narrativeProse: string;
|
||||
intents: IntentInfo[];
|
||||
timestamp: string;
|
||||
rawPrompt?: {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
export interface PromptComponent {
|
||||
label: string;
|
||||
type: "system" | "world" | "events" | "memories" | "input" | "other";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PromptBreakdown {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
components?: PromptComponent[];
|
||||
}
|
||||
|
||||
export interface ValidatorCall {
|
||||
intentIndex: number;
|
||||
intentContent: string;
|
||||
prompt?: PromptBreakdown;
|
||||
response: {
|
||||
isValid: boolean;
|
||||
reason: string;
|
||||
};
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
@@ -28,10 +36,36 @@ export interface LogEntry {
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
decoderPrompt?: {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
}
|
||||
|
||||
export interface HandoffResult {
|
||||
chunks: {
|
||||
content: string;
|
||||
importance: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
turn: number;
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
narrativeProse: string;
|
||||
intents: IntentInfo[];
|
||||
timestamp: string;
|
||||
isHandoff?: boolean;
|
||||
handoffResult?: HandoffResult;
|
||||
decodedIntents?: IntentInfo[];
|
||||
validatorCalls?: ValidatorCall[];
|
||||
rawPrompt?: PromptBreakdown;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
decoderPrompt?: PromptBreakdown;
|
||||
decoderUsage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -46,6 +80,8 @@ export interface EntityInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isPlayer: boolean;
|
||||
isAgent: boolean;
|
||||
aliases?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface WaitingContext {
|
||||
|
||||
@@ -1,971 +0,0 @@
|
||||
import dotenv from "dotenv";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { SQLiteRepository } from "@omnia/core";
|
||||
|
||||
// Load .env from monorepo root or apps/gui/
|
||||
const cwd = process.cwd();
|
||||
const envCandidates = [
|
||||
path.resolve(cwd, ".env"),
|
||||
path.resolve(cwd, "../../.env"),
|
||||
];
|
||||
for (const c of envCandidates) {
|
||||
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
|
||||
dotenv.config({ path: c });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
import { BufferRepository, LedgerRepository, HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import {
|
||||
ActorAgent,
|
||||
ActorPromptBuilder,
|
||||
IActorProseGenerator,
|
||||
buildBufferEntryForIntent,
|
||||
} from "@omnia/actor";
|
||||
import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager, OpenRouterProvider, IEmbeddingProvider, GeminiEmbeddingProvider, MockEmbeddingProvider, ModelProviderInstance } from "@omnia/llm";
|
||||
import { ScenarioLoader } from "@omnia/scenario";
|
||||
|
||||
import type {
|
||||
IntentInfo,
|
||||
LogEntry,
|
||||
EntityInfo,
|
||||
WaitingContext,
|
||||
SimSnapshot,
|
||||
} from "./simulation-types.js";
|
||||
|
||||
export type { SimSnapshot, EntityInfo, LogEntry, IntentInfo, WaitingContext };
|
||||
|
||||
class FixedProseGenerator implements IActorProseGenerator {
|
||||
constructor(private prose: string) {}
|
||||
|
||||
async generate(
|
||||
entityId: string,
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
): Promise<string> {
|
||||
void entityId;
|
||||
void systemPrompt;
|
||||
void userContext;
|
||||
return this.prose;
|
||||
}
|
||||
}
|
||||
|
||||
interface SavedState {
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
entities: EntityInfo[];
|
||||
playerEntityId: string | undefined;
|
||||
entityIndex: number;
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
error?: string;
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
log: LogEntry[];
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
|
||||
function loadSessionState(db: Database.Database, id: string): SavedState | null {
|
||||
try {
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS gui_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
state_json TEXT
|
||||
)
|
||||
`).run();
|
||||
const row = db.prepare(`SELECT state_json FROM gui_meta WHERE id = ?`).get(id) as { state_json: string } | undefined;
|
||||
return row ? (JSON.parse(row.state_json) as SavedState) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface SimSession {
|
||||
db: Database.Database;
|
||||
dbPath: string;
|
||||
coreRepo: SQLiteRepository;
|
||||
bufferRepo: BufferRepository;
|
||||
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>;
|
||||
}
|
||||
|
||||
class SimulationManager {
|
||||
private sessions = new Map<string, SimSession>();
|
||||
|
||||
async create(
|
||||
scenarioPath: string,
|
||||
playEntityName?: string,
|
||||
providerInstanceId?: string,
|
||||
): Promise<SimSnapshot> {
|
||||
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()}`;
|
||||
|
||||
const dbDir = path.resolve(process.cwd(), "data");
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
const dbPath = path.join(dbDir, `${id}.db`);
|
||||
const db = new Database(dbPath);
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const 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.",
|
||||
};
|
||||
}
|
||||
|
||||
const rawEntities = Array.from(worldState.entities.values());
|
||||
const entityInfos: EntityInfo[] = rawEntities.map((e) => ({
|
||||
id: e.id,
|
||||
name: (e.attributes.get("name")?.getValue() as string) || e.id,
|
||||
isPlayer: false,
|
||||
}));
|
||||
|
||||
let playerEntityId: string | undefined;
|
||||
if (playEntityName) {
|
||||
let matched = worldState.getEntity(playEntityName);
|
||||
if (!matched) {
|
||||
for (const ent of rawEntities) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue() as
|
||||
| string
|
||||
| undefined;
|
||||
if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
|
||||
matched = ent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const ent of rawEntities) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue() as
|
||||
| string
|
||||
| undefined;
|
||||
if (
|
||||
nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
|
||||
ent.id.toLowerCase().includes(playEntityName.toLowerCase())
|
||||
) {
|
||||
matched = ent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
playerEntityId = matched.id;
|
||||
const info = entityInfos.find((e) => e.id === matched.id);
|
||||
if (info) info.isPlayer = true;
|
||||
}
|
||||
}
|
||||
|
||||
const list = ProviderManager.list();
|
||||
const active = ProviderManager.getActive("generative") || activeInstance;
|
||||
const mappings = ProviderManager.getMappings();
|
||||
|
||||
const resolveProviderForTask = (task: string): ILLMProvider => {
|
||||
const mappedId = mappings[task];
|
||||
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
|
||||
if (!inst || inst.type !== "generative") {
|
||||
inst = active;
|
||||
}
|
||||
|
||||
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
|
||||
const providerName = inst ? inst.providerName : "google-genai";
|
||||
const modelName = inst ? inst.modelName : undefined;
|
||||
const instanceName = inst ? inst.name : undefined;
|
||||
const maxContext = inst ? inst.maxContext : undefined;
|
||||
|
||||
if (providerName === "google-genai") {
|
||||
return new GeminiProvider(key, modelName, instanceName, maxContext);
|
||||
} else if (providerName === "openrouter") {
|
||||
return new OpenRouterProvider(key, modelName, instanceName, maxContext);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveEmbeddingProvider = (): IEmbeddingProvider => {
|
||||
const mappedId = mappings["embeddings"];
|
||||
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
|
||||
if (!inst || inst.type !== "embedding") {
|
||||
inst = ProviderManager.getActive("embedding");
|
||||
}
|
||||
|
||||
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
|
||||
const providerName = inst ? inst.providerName : "google-genai";
|
||||
const modelName = inst ? inst.modelName : undefined;
|
||||
|
||||
if (providerName === "google-genai") {
|
||||
return new GeminiEmbeddingProvider(key, modelName);
|
||||
} else {
|
||||
return new MockEmbeddingProvider(modelName);
|
||||
}
|
||||
};
|
||||
|
||||
const actorProvider = resolveProviderForTask("actor-prose");
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
const decoderProvider = resolveProviderForTask("intent-decoder");
|
||||
const timedeltaProvider = resolveProviderForTask("timedelta");
|
||||
const handoffProvider = resolveProviderForTask("handoff");
|
||||
const embeddingProvider = resolveEmbeddingProvider();
|
||||
|
||||
const architect = new Architect(
|
||||
{ validator: validatorProvider, timedelta: timedeltaProvider },
|
||||
coreRepo,
|
||||
);
|
||||
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
|
||||
|
||||
const session: SimSession = {
|
||||
db,
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId: worldInstanceId,
|
||||
scenarioName: 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 step(id: string): Promise<SimSnapshot | null> {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
if (session.status !== "running") return this.snapshot(session);
|
||||
|
||||
try {
|
||||
if (session.turn > session.maxTurns) {
|
||||
session.status = "done";
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
|
||||
await this.runAliasResolution(session);
|
||||
await this.runHandoffResolution(session);
|
||||
session.aliasDoneForTurn = true;
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
if (session.entityIndex >= session.entities.length) {
|
||||
session.turn++;
|
||||
session.entityIndex = 0;
|
||||
session.aliasDoneForTurn = false;
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
const info = session.entities[session.entityIndex];
|
||||
|
||||
if (info.isPlayer) {
|
||||
await this.preparePlayerTurn(session, info);
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
await this.processNpcTurn(session, info);
|
||||
session.entityIndex++;
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
async submitPlayerAction(
|
||||
id: string,
|
||||
prose: string,
|
||||
): Promise<SimSnapshot | null> {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
if (session.status !== "waiting_player") return this.snapshot(session);
|
||||
if (!session.waitingEntity) return this.snapshot(session);
|
||||
|
||||
const ctx = session.waitingEntity;
|
||||
session.waitingEntity = undefined;
|
||||
session.status = "running";
|
||||
|
||||
try {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(ctx.entityId);
|
||||
if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
|
||||
|
||||
const playerActor = new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
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;
|
||||
}
|
||||
|
||||
for (const intent of result.intents.intents) {
|
||||
const outcome = await session.architect.processIntent(
|
||||
worldState,
|
||||
intent,
|
||||
);
|
||||
const ts = worldState.clock.get().toISOString();
|
||||
|
||||
entry.intents.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
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);
|
||||
|
||||
if (
|
||||
entity.locationId &&
|
||||
(intent.type === "dialogue" || intent.type === "action")
|
||||
) {
|
||||
for (const [, other] of worldState.entities) {
|
||||
if (
|
||||
other.id !== ctx.entityId &&
|
||||
other.locationId === entity.locationId
|
||||
) {
|
||||
const observerEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
ts,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
observerEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
session.bufferRepo.save({
|
||||
...observerEntry,
|
||||
ownerId: other.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.log.push(entry);
|
||||
session.coreRepo.saveWorldState(worldState);
|
||||
session.entityIndex++;
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
private async preparePlayerTurn(
|
||||
session: SimSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const promptBuilder = new ActorPromptBuilder(session.bufferRepo, session.ledgerRepo, 20);
|
||||
const { systemPrompt, userContext } = promptBuilder.build(
|
||||
worldState,
|
||||
entity,
|
||||
);
|
||||
|
||||
session.waitingEntity = {
|
||||
entityId: info.id,
|
||||
name: info.name,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
};
|
||||
session.status = "waiting_player";
|
||||
}
|
||||
|
||||
private async processNpcTurn(
|
||||
session: SimSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const actor = new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
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;
|
||||
}
|
||||
|
||||
for (const intent of result.intents.intents) {
|
||||
const outcome = await session.architect.processIntent(
|
||||
worldState,
|
||||
intent,
|
||||
);
|
||||
const ts = worldState.clock.get().toISOString();
|
||||
|
||||
entry.intents.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
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);
|
||||
|
||||
if (
|
||||
entity.locationId &&
|
||||
(intent.type === "dialogue" || intent.type === "action")
|
||||
) {
|
||||
for (const [, other] of worldState.entities) {
|
||||
if (
|
||||
other.id !== info.id &&
|
||||
other.locationId === entity.locationId
|
||||
) {
|
||||
const observerEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
ts,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
observerEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.log.push(entry);
|
||||
session.coreRepo.saveWorldState(worldState);
|
||||
}
|
||||
|
||||
private async 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) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runAliasResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entities = Array.from(worldState.entities.values());
|
||||
for (const viewer of entities) {
|
||||
if (!viewer.locationId) continue;
|
||||
for (const target of entities) {
|
||||
if (viewer.id === target.id) continue;
|
||||
if (
|
||||
target.locationId === viewer.locationId &&
|
||||
!viewer.aliases.has(target.id)
|
||||
) {
|
||||
const alias = await session.aliasGenerator.generate(viewer, target);
|
||||
viewer.aliases.set(target.id, alias);
|
||||
session.coreRepo.saveEntity(viewer, worldState.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) {
|
||||
session.db.close();
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
deleteSession(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) {
|
||||
session.db.close();
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
const dbDir = path.resolve(process.cwd(), "data");
|
||||
const dbPath = path.join(dbDir, `${id}.db`);
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete session file ${dbPath}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async load(id: string): Promise<SimSnapshot | null> {
|
||||
const active = this.sessions.get(id);
|
||||
if (active) {
|
||||
return this.snapshot(active);
|
||||
}
|
||||
|
||||
const dbDir = path.resolve(process.cwd(), "data");
|
||||
const dbPath = path.join(dbDir, `${id}.db`);
|
||||
if (!fs.existsSync(dbPath)) return null;
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
const state = loadSessionState(db, id);
|
||||
if (!state) {
|
||||
db.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const list = ProviderManager.list();
|
||||
const active = ProviderManager.getActive("generative");
|
||||
const mappings = state.providerMappings || {};
|
||||
|
||||
const resolveProviderForTask = (task: string): ILLMProvider => {
|
||||
const mappedId = mappings[task];
|
||||
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
|
||||
if (!inst || inst.type !== "generative") {
|
||||
inst = active;
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
inst = ProviderManager.create("Default (Env)", "google-genai", envKey, undefined, "generative");
|
||||
}
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
throw new Error(`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`);
|
||||
}
|
||||
|
||||
if (inst.providerName === "google-genai") {
|
||||
return new GeminiProvider(inst.apiKey, inst.modelName, inst.name, inst.maxContext);
|
||||
} else if (inst.providerName === "openrouter") {
|
||||
return new OpenRouterProvider(inst.apiKey, inst.modelName, inst.name, inst.maxContext);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveEmbeddingProvider = (): IEmbeddingProvider => {
|
||||
const mappedId = mappings["embeddings"];
|
||||
let inst = mappedId ? list.find((p) => p.id === mappedId) : 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) {
|
||||
throw new Error(`No active Embedding Provider Instance found for task "embeddings". Please configure an embedding key in Settings first.`);
|
||||
}
|
||||
|
||||
if (inst.providerName === "google-genai") {
|
||||
return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName);
|
||||
} else {
|
||||
return new MockEmbeddingProvider(inst.modelName);
|
||||
}
|
||||
};
|
||||
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
|
||||
const actorProvider = resolveProviderForTask("actor-prose");
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
const decoderProvider = resolveProviderForTask("intent-decoder");
|
||||
const timedeltaProvider = resolveProviderForTask("timedelta");
|
||||
const handoffProvider = resolveProviderForTask("handoff");
|
||||
const embeddingProvider = resolveEmbeddingProvider();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
listSavedSessions(): SimSnapshot[] {
|
||||
const dbDir = path.resolve(process.cwd(), "data");
|
||||
if (!fs.existsSync(dbDir)) return [];
|
||||
|
||||
const snapshots: SimSnapshot[] = [];
|
||||
const files = fs.readdirSync(dbDir).filter(f => f.startsWith("sim-") && f.endsWith(".db"));
|
||||
|
||||
for (const file of files) {
|
||||
const id = file.replace(".db", "");
|
||||
const dbPath = path.join(dbDir, file);
|
||||
|
||||
const active = this.sessions.get(id);
|
||||
if (active) {
|
||||
snapshots.push(this.snapshot(active));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
const state = loadSessionState(db, id);
|
||||
db.close();
|
||||
|
||||
if (state) {
|
||||
snapshots.push({
|
||||
id,
|
||||
status: state.status,
|
||||
turn: state.turn,
|
||||
maxTurns: state.maxTurns,
|
||||
scenarioName: state.scenarioName,
|
||||
scenarioDescription: state.scenarioDescription,
|
||||
entities: state.entities || [],
|
||||
log: state.log || [],
|
||||
entityIndex: state.entityIndex,
|
||||
waitingEntity: state.waitingEntity,
|
||||
error: state.error,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
|
||||
return snapshots.sort((a, b) => {
|
||||
const tsA = parseInt(a.id.replace("sim-", ""), 10) || 0;
|
||||
const tsB = parseInt(b.id.replace("sim-", ""), 10) || 0;
|
||||
return tsB - tsA;
|
||||
});
|
||||
}
|
||||
|
||||
async regenerateAllEmbeddings(newProviderInstanceId?: string): Promise<void> {
|
||||
const dbDir = path.resolve(process.cwd(), "data");
|
||||
if (!fs.existsSync(dbDir)) return;
|
||||
|
||||
const files = fs.readdirSync(dbDir).filter(f => f.startsWith("sim-") && f.endsWith(".db"));
|
||||
|
||||
const list = ProviderManager.list();
|
||||
let inst = newProviderInstanceId ? list.find((p) => p.id === newProviderInstanceId) : null;
|
||||
if (!inst || inst.type !== "embedding") {
|
||||
inst = ProviderManager.getActive("embedding");
|
||||
}
|
||||
|
||||
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
|
||||
const providerName = inst ? inst.providerName : "google-genai";
|
||||
const modelName = inst ? inst.modelName : undefined;
|
||||
|
||||
let embeddingProvider: IEmbeddingProvider;
|
||||
if (providerName === "google-genai") {
|
||||
embeddingProvider = new GeminiEmbeddingProvider(key, modelName);
|
||||
} else {
|
||||
embeddingProvider = new MockEmbeddingProvider(modelName);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const dbPath = path.join(dbDir, file);
|
||||
const id = file.replace(".db", "");
|
||||
const activeSession = this.sessions.get(id);
|
||||
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 save(session: SimSession): void {
|
||||
const state: SavedState = {
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
entities: session.entities,
|
||||
playerEntityId: session.playerEntityId,
|
||||
entityIndex: session.entityIndex,
|
||||
status: session.status,
|
||||
error: session.error,
|
||||
waitingEntity: session.waitingEntity,
|
||||
aliasDoneForTurn: session.aliasDoneForTurn,
|
||||
log: session.log,
|
||||
providerMappings: session.providerMappings,
|
||||
};
|
||||
|
||||
session.db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS gui_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
state_json TEXT
|
||||
)
|
||||
`).run();
|
||||
|
||||
session.db.prepare(`
|
||||
INSERT INTO gui_meta (id, state_json)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json
|
||||
`).run(session.worldInstanceId, JSON.stringify(state));
|
||||
}
|
||||
|
||||
getSnapshot(id: string): SimSnapshot | null {
|
||||
const session = this.sessions.get(id);
|
||||
return session ? this.snapshot(session) : null;
|
||||
}
|
||||
|
||||
private snapshot(session: SimSession): SimSnapshot {
|
||||
return {
|
||||
id: session.worldInstanceId,
|
||||
status: session.status,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
entities: session.entities,
|
||||
log: session.log,
|
||||
entityIndex: session.entityIndex,
|
||||
waitingEntity: session.waitingEntity,
|
||||
error: session.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const simulationManager = new SimulationManager();
|
||||
99
apps/gui/src/lib/simulation/alias-handoff.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
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 the Memory Ledger).
|
||||
*/
|
||||
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") {
|
||||
const ran = await handoffEngine.runHandoff(
|
||||
entity,
|
||||
bufferEntries,
|
||||
worldState.clock.get(),
|
||||
);
|
||||
if (ran) {
|
||||
const lastResult = handoffEngine.lastResult;
|
||||
const lastCall =
|
||||
session.handoffProvider.lastCalls?.[
|
||||
(session.handoffProvider.lastCalls?.length || 0) - 1
|
||||
];
|
||||
const info = session.entities.find((e) => e.id === entity.id);
|
||||
const entityName = info?.name || entity.id;
|
||||
|
||||
session.log.push({
|
||||
turn: session.turn,
|
||||
entityId: entity.id,
|
||||
entityName,
|
||||
narrativeProse: `Handoff triggered for ${entityName}: memories were transferred from Cognitive Buffer to Memory Ledger`,
|
||||
intents: [],
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
isHandoff: true,
|
||||
rawPrompt: lastResult
|
||||
? {
|
||||
systemPrompt: lastResult.systemPrompt || "",
|
||||
userContext: lastResult.userContext || "",
|
||||
components: lastResult.promptComponents,
|
||||
}
|
||||
: undefined,
|
||||
usage: lastCall?.usage,
|
||||
handoffResult: lastResult?.response || lastCall?.response,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
apps/gui/src/lib/simulation/env.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
apps/gui/src/lib/simulation/index.ts
Normal 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";
|
||||
146
apps/gui/src/lib/simulation/provider-resolver.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
139
apps/gui/src/lib/simulation/session-store.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
500
apps/gui/src/lib/simulation/simulation-manager.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
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 {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
const hydratedEntities = session.entities.map((e) => {
|
||||
const actualEntity = worldState?.getEntity(e.id);
|
||||
const aliases: Record<string, string> = {};
|
||||
if (actualEntity) {
|
||||
for (const [targetId, alias] of actualEntity.aliases.entries()) {
|
||||
aliases[targetId] = alias;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...e,
|
||||
aliases,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: session.worldInstanceId,
|
||||
status: session.status,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
entities: hydratedEntities,
|
||||
log: session.log,
|
||||
entityIndex: session.entityIndex,
|
||||
waitingEntity: session.waitingEntity,
|
||||
error: session.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
378
apps/gui/src/lib/simulation/turn-executor.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
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<{ intentInfos: IntentInfo[]; validatorCalls: ValidatorCall[] }> {
|
||||
const intentInfos: IntentInfo[] = [];
|
||||
const validatorCalls: ValidatorCall[] = [];
|
||||
|
||||
for (let i = 0; i < intents.length; i++) {
|
||||
const intent = intents[i];
|
||||
const outcome = await session.architect.processIntent(worldState, intent);
|
||||
const ts = worldState.clock.get().toISOString();
|
||||
|
||||
intentInfos.push({
|
||||
type: intent.type,
|
||||
content: intent.content,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
|
||||
});
|
||||
|
||||
if (intent.type === "action" && session.architect.validator.lastResult) {
|
||||
const lastResult = session.architect.validator.lastResult;
|
||||
let usage = undefined;
|
||||
if (
|
||||
session.validatorProvider.lastCalls &&
|
||||
session.validatorProvider.lastCalls.length > 0
|
||||
) {
|
||||
const valCall =
|
||||
session.validatorProvider.lastCalls[
|
||||
session.validatorProvider.lastCalls.length - 1
|
||||
];
|
||||
usage = valCall.usage;
|
||||
}
|
||||
|
||||
validatorCalls.push({
|
||||
intentIndex: i,
|
||||
intentContent: intent.content,
|
||||
prompt: {
|
||||
systemPrompt: lastResult.systemPrompt || "",
|
||||
userContext: lastResult.userContext || "",
|
||||
components: lastResult.promptComponents,
|
||||
},
|
||||
response: {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
},
|
||||
usage,
|
||||
});
|
||||
} else {
|
||||
const reason =
|
||||
intent.type === "dialogue"
|
||||
? "Dialogue intents represent verbal/communication actions and are automatically valid."
|
||||
: "Monologue/thought intents represent internal reflections and bypass validation.";
|
||||
|
||||
validatorCalls.push({
|
||||
intentIndex: i,
|
||||
intentContent: intent.content,
|
||||
response: {
|
||||
isValid: true,
|
||||
reason: outcome.reason || reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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, validatorCalls };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(),
|
||||
rawPrompt: {
|
||||
systemPrompt: result.systemPrompt || "",
|
||||
userContext: result.userContext || "",
|
||||
components: result.promptComponents,
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
session.actorProvider.lastCalls &&
|
||||
session.actorProvider.lastCalls.length > 0
|
||||
) {
|
||||
const actorCall =
|
||||
session.actorProvider.lastCalls[
|
||||
session.actorProvider.lastCalls.length - 1
|
||||
];
|
||||
entry.usage = actorCall.usage;
|
||||
}
|
||||
|
||||
if (
|
||||
session.decoderProvider.lastCalls &&
|
||||
session.decoderProvider.lastCalls.length > 0
|
||||
) {
|
||||
const decoderCall =
|
||||
session.decoderProvider.lastCalls[
|
||||
session.decoderProvider.lastCalls.length - 1
|
||||
];
|
||||
const proseHeader = "=== NARRATIVE PROSE ===";
|
||||
const userContext = decoderCall.userContext;
|
||||
const idx = userContext.indexOf(proseHeader);
|
||||
|
||||
let contextStr = userContext;
|
||||
let proseStr = "";
|
||||
|
||||
if (idx !== -1) {
|
||||
contextStr = userContext.substring(0, idx).trim();
|
||||
proseStr = userContext.substring(idx).trim();
|
||||
}
|
||||
|
||||
entry.decoderPrompt = {
|
||||
systemPrompt: decoderCall.systemPrompt,
|
||||
userContext: decoderCall.userContext,
|
||||
components: [
|
||||
{
|
||||
label: "System Prompt",
|
||||
type: "system",
|
||||
content: decoderCall.systemPrompt,
|
||||
},
|
||||
{ label: "Decoder Context", type: "world", content: contextStr },
|
||||
{ label: "Narrative Prose", type: "input", content: proseStr },
|
||||
],
|
||||
};
|
||||
entry.decoderUsage = decoderCall.usage;
|
||||
}
|
||||
|
||||
const { intentInfos, validatorCalls } = await processIntents(
|
||||
result.intents.intents,
|
||||
info.id,
|
||||
entity,
|
||||
worldState,
|
||||
session,
|
||||
);
|
||||
entry.intents = intentInfos;
|
||||
entry.validatorCalls = validatorCalls;
|
||||
entry.decodedIntents = result.intents.intents.map((intent) => ({
|
||||
type: intent.type,
|
||||
content: intent.content,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
}));
|
||||
|
||||
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: result.systemPrompt || ctx.systemPrompt,
|
||||
userContext: result.userContext || ctx.userContext,
|
||||
components: result.promptComponents,
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
session.decoderProvider.lastCalls &&
|
||||
session.decoderProvider.lastCalls.length > 0
|
||||
) {
|
||||
const call =
|
||||
session.decoderProvider.lastCalls[
|
||||
session.decoderProvider.lastCalls.length - 1
|
||||
];
|
||||
const proseHeader = "=== NARRATIVE PROSE ===";
|
||||
const userContext = call.userContext;
|
||||
const idx = userContext.indexOf(proseHeader);
|
||||
|
||||
let contextStr = userContext;
|
||||
let proseStr = "";
|
||||
|
||||
if (idx !== -1) {
|
||||
contextStr = userContext.substring(0, idx).trim();
|
||||
proseStr = userContext.substring(idx).trim();
|
||||
}
|
||||
|
||||
entry.decoderPrompt = {
|
||||
systemPrompt: call.systemPrompt,
|
||||
userContext: call.userContext,
|
||||
components: [
|
||||
{ label: "System Prompt", type: "system", content: call.systemPrompt },
|
||||
{ label: "Decoder Context", type: "world", content: contextStr },
|
||||
{ label: "Narrative Prose", type: "input", content: proseStr },
|
||||
],
|
||||
};
|
||||
entry.decoderUsage = call.usage;
|
||||
}
|
||||
|
||||
const { intentInfos, validatorCalls: playerValCalls } = await processIntents(
|
||||
result.intents.intents,
|
||||
ctx.entityId,
|
||||
entity,
|
||||
worldState,
|
||||
session,
|
||||
);
|
||||
entry.intents = intentInfos;
|
||||
entry.validatorCalls = playerValCalls;
|
||||
entry.decodedIntents = result.intents.intents.map((intent) => ({
|
||||
type: intent.type,
|
||||
content: intent.content,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
}));
|
||||
|
||||
session.log.push(entry);
|
||||
session.coreRepo.saveWorldState(worldState);
|
||||
}
|
||||
68
apps/gui/src/lib/simulation/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -23,9 +19,7 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -35,7 +29,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -72,16 +72,37 @@
|
||||
],
|
||||
"initialMemories": [
|
||||
{
|
||||
"id": "alpha-wake",
|
||||
"timestamp": "2026-07-09T07:58:00.000Z",
|
||||
"id": "ab3f29d2-cf11-4111-9a99-b13c126d123e",
|
||||
"timestamp": "2026-07-01T07:58:00.000Z",
|
||||
"locationId": "white-room",
|
||||
"intent": {
|
||||
"type": "monologue",
|
||||
"originalText": "I didn't have a choice. I would have been sent to jail if I hadn't agreed to do this experiement.",
|
||||
"description": "",
|
||||
"content": "I didn't have a choice. I would have been sent to jail if I hadn't agreed to do this experiment.",
|
||||
"actorId": "7c9b83b3-8cfb-4e89-8d77-626a5757d591",
|
||||
"targetIds": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "10ak29d2-as11-9811-9a99-b13c126d123e",
|
||||
"timestamp": "2026-07-09T06:00:00.000Z",
|
||||
"locationId": "white-room",
|
||||
"intent": {
|
||||
"type": "action",
|
||||
"content": "entity@7c9b83b3-8cfb-4e89-8d77-626a5757d591[I] woke up today in the room and saw entity@bf3f29d2-cf11-4b11-9a99-b13c126d400e[another man]!",
|
||||
"actorId": "7c9b83b3-8cfb-4e89-8d77-626a5757d591",
|
||||
"targetIds": ["bf3f29d2-cf11-4b11-9a99-b13c126d400e"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "zz3f29d2-as11-9811-9a99-b13c126d123e",
|
||||
"timestamp": "2026-07-09T07:58:00.000Z",
|
||||
"locationId": "white-room",
|
||||
"intent": {
|
||||
"type": "action",
|
||||
"content": "entity@bf3f29d2-cf11-4b11-9a99-b13c126d400e[I] wake up from my sleep.",
|
||||
"actorId": "bf3f29d2-cf11-4b11-9a99-b13c126d400e",
|
||||
"targetIds": []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -119,13 +140,12 @@
|
||||
],
|
||||
"initialMemories": [
|
||||
{
|
||||
"id": "beta-wake",
|
||||
"timestamp": "2026-07-09T07:58:30.000Z",
|
||||
"id": "zx1f29d2-cf11-4111-9a99-b13c126d123e",
|
||||
"timestamp": "2026-07-09T07:58:00.000Z",
|
||||
"locationId": "white-room",
|
||||
"intent": {
|
||||
"type": "action",
|
||||
"originalText": "Why can't I remember anything before the research agreement. It's like my memory was erased.",
|
||||
"description": "",
|
||||
"content": "entity@bf3f29d2-cf11-4b11-9a99-b13c126d400e[I] wake up in an unfamiliar place.",
|
||||
"actorId": "bf3f29d2-cf11-4b11-9a99-b13c126d400e",
|
||||
"targetIds": []
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.astro/**", "**/.next/**"],
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/.astro/**",
|
||||
"**/.next/**",
|
||||
],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
|
||||
11
package.json
@@ -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",
|
||||
@@ -27,7 +28,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "^11.9.0",
|
||||
"version": "11.13.0",
|
||||
"onFail": "download"
|
||||
}
|
||||
},
|
||||
@@ -47,9 +48,15 @@
|
||||
"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",
|
||||
"compromise": "^14.16.0",
|
||||
"dotenv": "^17.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
WorldState,
|
||||
naturalizeTime,
|
||||
serializeSubjectiveWorldState,
|
||||
resolveAlias,
|
||||
} from "@omnia/core";
|
||||
import {
|
||||
BufferEntry,
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
LedgerEntry,
|
||||
LedgerRepository,
|
||||
} from "@omnia/memory";
|
||||
import { hydrate } from "@omnia/voice";
|
||||
import { PromptComponent, IPromptBuilder, PromptBreakdown } from "@omnia/llm";
|
||||
|
||||
/**
|
||||
* Zod schema for the structured response expected from the actor LLM.
|
||||
@@ -33,17 +34,19 @@ export type ActorResponse = z.infer<typeof ActorResponseSchema>;
|
||||
*
|
||||
* The prompt is strictly epistemically bounded: the entity only sees what
|
||||
* it is allowed to see (public attributes + private attributes explicitly
|
||||
* ACL'd to it), its own recent memory buffer, and the entities co-located
|
||||
* ACL'd to it), its own Cognitive Buffer, and the entities co-located
|
||||
* with it. System UUIDs are surfaced as subjective aliases.
|
||||
*/
|
||||
export class ActorPromptBuilder {
|
||||
export class ActorPromptBuilder implements IPromptBuilder<
|
||||
[WorldState, Entity]
|
||||
> {
|
||||
/**
|
||||
* @param bufferRepo Used to fetch the actor's recent memory. Optional —
|
||||
* @param bufferRepo Used to fetch the actor's Cognitive Buffer. Optional —
|
||||
* if absent, the memory section is omitted.
|
||||
* @param ledgerRepo Used to fetch long-term memories. Optional.
|
||||
* @param memoryLimit Maximum number of recent buffer entries to inject.
|
||||
* @param ledgerRepo Used to fetch Memory Ledger entries. Optional.
|
||||
* @param memoryLimit Maximum number of recent Cognitive Buffer entries to inject.
|
||||
* Defaults to 20.
|
||||
* @param ledgerLimit Maximum number of long-term memories to retrieve.
|
||||
* @param ledgerLimit Maximum number of Memory Ledger entries to retrieve.
|
||||
* Defaults to 5.
|
||||
*/
|
||||
constructor(
|
||||
@@ -56,13 +59,20 @@ export class ActorPromptBuilder {
|
||||
/**
|
||||
* Assembles the system prompt and user context for a given entity.
|
||||
*/
|
||||
build(
|
||||
worldState: WorldState,
|
||||
entity: Entity,
|
||||
): { systemPrompt: string; userContext: string } {
|
||||
/**
|
||||
* Assembles the system prompt and user context for a given entity.
|
||||
*/
|
||||
/**
|
||||
* Assembles the system prompt and user context for a given entity.
|
||||
*/
|
||||
build(worldState: WorldState, entity: Entity): PromptBreakdown {
|
||||
const systemPrompt = this.buildSystemPrompt();
|
||||
const userContext = this.buildUserContext(worldState, entity);
|
||||
return { systemPrompt, userContext };
|
||||
const { userContext, components } = this.buildUserContext(
|
||||
worldState,
|
||||
entity,
|
||||
systemPrompt,
|
||||
);
|
||||
return { systemPrompt, userContext, components };
|
||||
}
|
||||
|
||||
private buildSystemPrompt(): string {
|
||||
@@ -76,30 +86,32 @@ Your output is a short block of narrative prose describing what your character d
|
||||
|
||||
Guidelines:
|
||||
- Always write in the first person
|
||||
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment or your surroundings, or other characters' actions.
|
||||
- Refer to other entities by the subjective names/aliases that you refer to them as.
|
||||
- Keep your prose vivid but concise. Write it in natural narrative order.
|
||||
- Not every response requires an outward action. It is perfectly valid to only think (a monologue) and do nothing perceivable.
|
||||
- Never speak or act on another entity's behalf. You only control your own character.
|
||||
- Stay strictly within what your character knows. Do not invent knowledge that doesn't exist or act on it.
|
||||
- You are limited by just your memory. If your memory is limited, then that's all you can remember. If you do make stuff up then that's lying. Which is allowed, but remember that you're lying.
|
||||
- You are limited by just your memory. If your memory is limited, then that's all you can remember. If you do make stuff up then that's lying. Which is allowed, but remember that you're lying
|
||||
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate the environment or your surroundings, or other characters' actions.
|
||||
- Be clear about who or what you are interacting with.
|
||||
".
|
||||
`.trim();
|
||||
}
|
||||
|
||||
private buildUserContext(worldState: WorldState, entity: Entity): string {
|
||||
const sections: string[] = [];
|
||||
private buildUserContext(
|
||||
worldState: WorldState,
|
||||
entity: Entity,
|
||||
systemPrompt: string,
|
||||
): {
|
||||
userContext: string;
|
||||
components: PromptComponent[];
|
||||
} {
|
||||
const now = worldState.clock.get();
|
||||
|
||||
// --- Subjective present time ---
|
||||
sections.push(
|
||||
`=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`,
|
||||
);
|
||||
|
||||
// --- Subjective world state (self + perceived entities + co-location) ---
|
||||
sections.push(
|
||||
`=== THE WORLD AS YOU PERCEIVE IT ===\n${serializeSubjectiveWorldState(worldState, entity.id)}`,
|
||||
);
|
||||
// --- Subjective present time & world state ---
|
||||
const momentStr = `=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`;
|
||||
const perceivedStr = `=== THE WORLD AS YOU PERCEIVE IT ===\n${serializeSubjectiveWorldState(worldState, entity.id)}`;
|
||||
const worldInfo = `${momentStr}\n\n${perceivedStr}`;
|
||||
|
||||
// Fetch recent buffer entries once
|
||||
let recentEntries: BufferEntry[] = [];
|
||||
@@ -111,27 +123,55 @@ Guidelines:
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recent memory ---
|
||||
const memorySection = this.buildMemorySection(entity, recentEntries, now);
|
||||
if (memorySection) {
|
||||
sections.push(memorySection);
|
||||
}
|
||||
|
||||
// --- Recalled Long-Term memory ---
|
||||
// --- Recalled Memory Ledger ---
|
||||
const ledgerSection = this.buildLedgerSection(
|
||||
worldState,
|
||||
entity,
|
||||
recentEntries,
|
||||
now,
|
||||
);
|
||||
if (ledgerSection) {
|
||||
sections.push(ledgerSection);
|
||||
const memoryLedger = ledgerSection || "";
|
||||
|
||||
// --- Cognitive Buffer ---
|
||||
const memorySection = this.buildCognitiveBufferSection(
|
||||
entity,
|
||||
recentEntries,
|
||||
now,
|
||||
);
|
||||
const cognitiveBuffer = memorySection || "";
|
||||
|
||||
// Assemble final user context
|
||||
const parts: string[] = [worldInfo];
|
||||
if (memoryLedger) parts.push(memoryLedger);
|
||||
if (cognitiveBuffer) parts.push(cognitiveBuffer);
|
||||
const userContext = parts.join("\n\n");
|
||||
|
||||
const components: PromptComponent[] = [
|
||||
{ label: "System Prompt", type: "system", content: systemPrompt },
|
||||
{ label: "World Info", type: "world", content: worldInfo },
|
||||
];
|
||||
if (memoryLedger) {
|
||||
components.push({
|
||||
label: "Memory Ledger",
|
||||
type: "memories",
|
||||
content: memoryLedger,
|
||||
});
|
||||
}
|
||||
if (cognitiveBuffer) {
|
||||
components.push({
|
||||
label: "Cognitive Buffer",
|
||||
type: "events",
|
||||
content: cognitiveBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
return {
|
||||
userContext,
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
private buildMemorySection(
|
||||
private buildCognitiveBufferSection(
|
||||
entity: Entity,
|
||||
entries: BufferEntry[],
|
||||
now: Date,
|
||||
@@ -139,7 +179,7 @@ Guidelines:
|
||||
if (!this.bufferRepo) return null;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return `=== RECENT EVENTS ===\n(No recent events recorded.)`;
|
||||
return `=== COGNITIVE BUFFER ===\n(No entries recorded.)`;
|
||||
}
|
||||
|
||||
const recent = entries.slice(-this.memoryLimit);
|
||||
@@ -147,9 +187,16 @@ Guidelines:
|
||||
let currentGroup: string | null = null;
|
||||
|
||||
for (const entry of recent) {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
let serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
const when = naturalizeTime(now, new Date(entry.timestamp));
|
||||
|
||||
if (
|
||||
entry.intent.actorId === entity.id &&
|
||||
entry.intent.type === "dialogue"
|
||||
) {
|
||||
serialized = `I said: ${serialized}`;
|
||||
}
|
||||
|
||||
if (when !== currentGroup) {
|
||||
currentGroup = when;
|
||||
const header = when.charAt(0).toUpperCase() + when.slice(1);
|
||||
@@ -159,7 +206,7 @@ Guidelines:
|
||||
groupedLines.push(` - ${serialized}`);
|
||||
}
|
||||
|
||||
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`;
|
||||
return `=== COGNITIVE BUFFER ===\n${groupedLines.join("\n")}`;
|
||||
}
|
||||
|
||||
private buildLedgerSection(
|
||||
@@ -239,12 +286,7 @@ Guidelines:
|
||||
for (const entry of recalled) {
|
||||
const when = naturalizeTime(now, new Date(entry.timestamp));
|
||||
|
||||
let content = entry.content;
|
||||
// Resolve system IDs to subjective aliases in the content
|
||||
for (const targetId of entry.involvedEntityIds) {
|
||||
const alias = resolveAlias(entity, targetId);
|
||||
content = content.replace(new RegExp(targetId, "g"), alias);
|
||||
}
|
||||
let content = hydrate(entry.content, entity);
|
||||
if (entry.locationId) {
|
||||
content += ` (at ${entry.locationId})`;
|
||||
}
|
||||
@@ -263,6 +305,6 @@ Guidelines:
|
||||
}
|
||||
}
|
||||
|
||||
return `=== YOUR MEMORIES ===\n${groupedLines.join("\n")}`;
|
||||
return `=== MEMORY LEDGER ===\n${groupedLines.join("\n")}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { Entity, WorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { ILLMProvider, PromptComponent } from "@omnia/llm";
|
||||
import { BufferEntry, BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { Intent, IntentDecoder, IntentSequence } from "@omnia/intent";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
LedgerRepository,
|
||||
} from "@omnia/memory";
|
||||
import {
|
||||
Intent,
|
||||
IntentDecoder,
|
||||
IntentSequence,
|
||||
} from "@omnia/intent";
|
||||
import { ActorPromptBuilder, ActorResponseSchema } from "./actor-prompt-builder.js";
|
||||
ActorPromptBuilder,
|
||||
ActorResponseSchema,
|
||||
} from "./actor-prompt-builder.js";
|
||||
|
||||
/**
|
||||
* Interface to generate narrative prose for an actor.
|
||||
* Allows switching between LLM generators and human CLI inputs.
|
||||
*/
|
||||
export interface IActorProseGenerator {
|
||||
generate(entityId: string, systemPrompt: string, userContext: string): Promise<string>;
|
||||
generate(
|
||||
entityId: string,
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +25,11 @@ export interface IActorProseGenerator {
|
||||
export class LLMActorProseGenerator implements IActorProseGenerator {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
|
||||
async generate(entityId: string, systemPrompt: string, userContext: string): Promise<string> {
|
||||
async generate(
|
||||
entityId: string,
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
): Promise<string> {
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
userContext,
|
||||
@@ -51,6 +54,9 @@ export interface ActorTurnResult {
|
||||
narrativeProse: string;
|
||||
/** The decoded intent sequence (split/classified from the prose). */
|
||||
intents: IntentSequence;
|
||||
systemPrompt?: string;
|
||||
userContext?: string;
|
||||
promptComponents?: PromptComponent[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +79,7 @@ export class ActorAgent {
|
||||
|
||||
constructor(
|
||||
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: ILLMProvider },
|
||||
bufferRepo?: BufferRepository,
|
||||
private bufferRepo?: BufferRepository,
|
||||
ledgerRepo?: LedgerRepository,
|
||||
memoryLimit?: number,
|
||||
generator?: IActorProseGenerator,
|
||||
@@ -89,7 +95,11 @@ export class ActorAgent {
|
||||
decoderProv = llmProvider;
|
||||
}
|
||||
|
||||
this.promptBuilder = new ActorPromptBuilder(bufferRepo, ledgerRepo, memoryLimit);
|
||||
this.promptBuilder = new ActorPromptBuilder(
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
memoryLimit,
|
||||
);
|
||||
this.decoder = new IntentDecoder(decoderProv);
|
||||
this.generator = generator ?? new LLMActorProseGenerator(actorProv);
|
||||
this.llmProvider = actorProv;
|
||||
@@ -102,11 +112,14 @@ export class ActorAgent {
|
||||
* 2. Asks the generator (LLM or human) for narrative prose.
|
||||
* 3. Decodes the prose into a structured IntentSequence.
|
||||
*/
|
||||
async act(
|
||||
worldState: WorldState,
|
||||
entity: Entity,
|
||||
): Promise<ActorTurnResult> {
|
||||
const { systemPrompt, userContext } = this.promptBuilder.build(
|
||||
async act(worldState: WorldState, entity: Entity): Promise<ActorTurnResult> {
|
||||
if (!entity.isAgent) {
|
||||
throw new Error(
|
||||
`Entity "${entity.id}" is not an agent and cannot use the actor interface.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { systemPrompt, userContext, components } = this.promptBuilder.build(
|
||||
worldState,
|
||||
entity,
|
||||
);
|
||||
@@ -117,15 +130,27 @@ export class ActorAgent {
|
||||
userContext,
|
||||
);
|
||||
|
||||
const recentEntries = this.bufferRepo
|
||||
? this.bufferRepo.listForOwner(entity.id)
|
||||
: [];
|
||||
const recentIntents = recentEntries
|
||||
.filter((e) => e.intent.actorId !== entity.id)
|
||||
.slice(-3)
|
||||
.map((e) => e.intent);
|
||||
|
||||
const intents = await this.decoder.decode(
|
||||
worldState,
|
||||
entity.id,
|
||||
narrativeProse,
|
||||
recentIntents,
|
||||
);
|
||||
|
||||
return {
|
||||
narrativeProse,
|
||||
intents,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
promptComponents: components,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ import { WorldState, Entity } from "@omnia/core";
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { ActorPromptBuilder } from "../src/actor-prompt-builder";
|
||||
|
||||
describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
describe("ActorPromptBuilder with Memory Ledger Integration", () => {
|
||||
let db: Database.Database;
|
||||
let bufferRepo: BufferRepository;
|
||||
let ledgerRepo: LedgerRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
|
||||
|
||||
// Core database schemas for testing
|
||||
db.exec(`
|
||||
CREATE TABLE objects (
|
||||
@@ -31,9 +31,12 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("should inject both recent memory and recalled long-term memory with subjective aliases resolved", () => {
|
||||
const world = new WorldState("world-123", new Date("2024-01-10T12:00:00.000Z"));
|
||||
|
||||
it("should inject both Cognitive Buffer and recalled Memory Ledger entries with subjective aliases resolved", () => {
|
||||
const world = new WorldState(
|
||||
"world-123",
|
||||
new Date("2024-01-10T12:00:00.000Z"),
|
||||
);
|
||||
|
||||
const alice = new Entity("alice", "tavern");
|
||||
// Add subjective alias for bob
|
||||
alice.aliases.set("bob", "Strider");
|
||||
@@ -52,19 +55,19 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
type: "dialogue",
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
originalText: "Hello there",
|
||||
description: "Alice greets Bob",
|
||||
content: "entity@alice[I] say 'Hello there' to entity@bob[Bob]",
|
||||
modifiers: [],
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Populate ledger repository (long-term memory)
|
||||
// 2. Populate ledger repository (Memory Ledger)
|
||||
ledgerRepo.save({
|
||||
id: "ledger1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2024-01-08T12:00:00.000Z", // 2 days ago
|
||||
locationId: "tavern",
|
||||
involvedEntityIds: ["bob"],
|
||||
content: "alice met bob at the tavern.",
|
||||
content: "entity@alice[Alice] met entity@bob[bob] at the tavern.",
|
||||
quotes: ["I am a ranger."],
|
||||
importance: 9,
|
||||
embedding: [],
|
||||
@@ -73,26 +76,29 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
|
||||
const { userContext } = builder.build(world, alice);
|
||||
|
||||
// Check recent memory exists
|
||||
expect(userContext).toContain("=== RECENT EVENTS ===");
|
||||
expect(userContext).toContain("Alice greets Bob");
|
||||
// Check Cognitive Buffer exists
|
||||
expect(userContext).toContain("=== COGNITIVE BUFFER ===");
|
||||
expect(userContext).toContain("I said: I say 'Hello there' to Strider");
|
||||
|
||||
// Check long-term memory exists
|
||||
expect(userContext).toContain("=== YOUR MEMORIES ===");
|
||||
// Bob should be resolved to Strider in the ledger content
|
||||
expect(userContext).toContain("alice met Strider at the tavern.");
|
||||
// Check Memory Ledger exists
|
||||
expect(userContext).toContain("=== MEMORY LEDGER ===");
|
||||
// Bob should be resolved to Strider, and alice to I in the ledger content
|
||||
expect(userContext).toContain("I met Strider at the tavern.");
|
||||
expect(userContext).toContain('Quote: "I am a ranger."');
|
||||
});
|
||||
|
||||
it("should not explode if ledger contains no memories or is empty", () => {
|
||||
const world = new WorldState("world-123", new Date("2024-01-10T12:00:00.000Z"));
|
||||
const world = new WorldState(
|
||||
"world-123",
|
||||
new Date("2024-01-10T12:00:00.000Z"),
|
||||
);
|
||||
const alice = new Entity("alice", "tavern");
|
||||
world.addEntity(alice);
|
||||
|
||||
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
|
||||
const { userContext } = builder.build(world, alice);
|
||||
|
||||
expect(userContext).toContain("=== RECENT EVENTS ===");
|
||||
expect(userContext).not.toContain("=== YOUR MEMORIES ===");
|
||||
expect(userContext).toContain("=== COGNITIVE BUFFER ===");
|
||||
expect(userContext).not.toContain("=== MEMORY LEDGER ===");
|
||||
});
|
||||
});
|
||||
|
||||
19
packages/actor/tests/actor.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { WorldState, Entity } from "@omnia/core";
|
||||
import { ActorAgent } from "../src/actor.js";
|
||||
import { MockLLMProvider } from "@omnia/llm";
|
||||
|
||||
describe("ActorAgent Unit Tests", () => {
|
||||
it("should throw an error if trying to act with a non-agent entity", async () => {
|
||||
const world = new WorldState("world-123");
|
||||
const nonAgentEntity = new Entity("stone", null, false); // isAgent = false
|
||||
world.addEntity(nonAgentEntity);
|
||||
|
||||
const mockLlm = new MockLLMProvider([]);
|
||||
const actor = new ActorAgent(mockLlm);
|
||||
|
||||
await expect(actor.act(world, nonAgentEntity)).rejects.toThrow(
|
||||
'Entity "stone" is not an agent and cannot use the actor interface.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
{ "path": "../core" },
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../memory" }
|
||||
{ "path": "../memory" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@ export interface ProcessResult extends ValidationResult {
|
||||
}
|
||||
|
||||
export class Architect {
|
||||
private validator: LLMValidator;
|
||||
public validator: LLMValidator;
|
||||
private timeDeltaGenerator: TimeDeltaGenerator;
|
||||
|
||||
constructor(
|
||||
llmProvider: ILLMProvider | { validator: ILLMProvider; timedelta: ILLMProvider },
|
||||
llmProvider:
|
||||
ILLMProvider | { validator: ILLMProvider; timedelta: ILLMProvider },
|
||||
private repo?: SQLiteRepository,
|
||||
) {
|
||||
let valProv: ILLMProvider;
|
||||
@@ -46,22 +47,26 @@ export class Architect {
|
||||
* Processes, validates, generates deltas, applies them to the world state,
|
||||
* and persists the changes to the database.
|
||||
*
|
||||
* "monologue" intents are internal thoughts — they bypass validation and
|
||||
* "monologue" and "thought" intents are internal thoughts — they bypass validation and
|
||||
* time-delta generation entirely: the clock does not advance, the world
|
||||
* state is not mutated or persisted. The caller is responsible for writing
|
||||
* the monologue to the actor's memory buffer.
|
||||
* the monologue/thought to the actor's Cognitive Buffer.
|
||||
*/
|
||||
async processIntent(
|
||||
worldState: WorldState,
|
||||
intent: Intent,
|
||||
): Promise<ProcessResult> {
|
||||
// 0. Monologue intents are purely internal — short-circuit before any
|
||||
// 0. Monologue/thought intents are purely internal — short-circuit before any
|
||||
// validation or world mutation.
|
||||
if (intent.type === "monologue") {
|
||||
if (intent.type === "monologue" || intent.type === "thought") {
|
||||
return {
|
||||
isValid: true,
|
||||
reason: "Monologue intent bypasses validation (internal thought, not perceivable).",
|
||||
timeDelta: { minutesToAdvance: 0, explanation: "Internal thought — no time elapsed." },
|
||||
reason:
|
||||
"Monologue/thought intent bypasses validation (internal thought, not perceivable).",
|
||||
timeDelta: {
|
||||
minutesToAdvance: 0,
|
||||
explanation: "Internal thought — no time elapsed.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import { hydrateObjective } from "@omnia/voice";
|
||||
|
||||
export const TimeDeltaSchema = z.object({
|
||||
minutesToAdvance: z.number().int().nonnegative(),
|
||||
@@ -26,10 +27,11 @@ export class TimeDeltaGenerator implements IDeltaGenerator<TimeDelta> {
|
||||
explanation: "Dialogue action; 1 minute granted for quick exchange.",
|
||||
};
|
||||
}
|
||||
if (intent.type === "monologue") {
|
||||
if (intent.type === "monologue" || intent.type === "thought") {
|
||||
return {
|
||||
minutesToAdvance: 0,
|
||||
explanation: "Monologue action; no time advanced for internal thought.",
|
||||
explanation:
|
||||
"Monologue/thought action; no time advanced for internal thought.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +47,8 @@ Return a structured JSON object containing:
|
||||
- "explanation": a brief explanation of why this amount of time is appropriate.
|
||||
`.trim();
|
||||
|
||||
const objectiveContent = hydrateObjective(intent.content, worldState);
|
||||
|
||||
const userContext = `
|
||||
=== CURRENT WORLD STATE ===
|
||||
Current Time: ${worldState.clock.get().toISOString()}
|
||||
@@ -54,8 +58,7 @@ ${serializeObjectiveWorldState(worldState)}
|
||||
=== ACTION ===
|
||||
Actor ID: ${intent.actorId}
|
||||
Type: ${intent.type}
|
||||
Description: "${intent.description}"
|
||||
Original Text: "${intent.originalText}"
|
||||
Content: "${objectiveContent}"
|
||||
Target IDs: ${intent.targetIds.join(", ") || "(None)"}
|
||||
`.trim();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./llm-validator.js";
|
||||
export * from "./llm-validator-prompt-builder.js";
|
||||
export * from "./architect.js";
|
||||
export * from "./delta.js";
|
||||
|
||||
|
||||
59
packages/architect/src/llm-validator-prompt-builder.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import { hydrateObjective } from "@omnia/voice";
|
||||
import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm";
|
||||
|
||||
/**
|
||||
* Prompt builder for the LLM Validator (World Architect).
|
||||
* Separates prompt generation, structure, and component breakdowns.
|
||||
*/
|
||||
export class LLMValidatorPromptBuilder implements IPromptBuilder<
|
||||
[WorldState, Intent]
|
||||
> {
|
||||
build(worldState: WorldState, intent: Intent): PromptBreakdown {
|
||||
const serializedWorld = serializeObjectiveWorldState(worldState);
|
||||
|
||||
const systemPrompt = `
|
||||
You are the World Architect, a deterministic and objective judge of reality, physics, and narration for a simulation game.
|
||||
Your task is to judge whether a proposed action (Intent) by an actor is physically and logically possible given the current objective state of the world.
|
||||
Exempt dialogue or speech actions from validation (consider them always valid).
|
||||
Enforce logical boundaries such as:
|
||||
- Spatial boundaries (an actor cannot grab an object in another location unless they are there).
|
||||
- Physical boundaries (an actor cannot open a locked drawer without a key or breaking it).
|
||||
- State Boundaries (an actor cannot perform a task if their state doesn't allow them to do so).
|
||||
- State/Attribute constraints.
|
||||
- An actor can perform actions on themselves as long as it follows the boundaries stated above.
|
||||
|
||||
You must respond with a JSON object containing:
|
||||
- "isValid": boolean indicating if the action is possible/allowed.
|
||||
- "reason": a very short explanation of why the action is allowed or denied.
|
||||
`.trim();
|
||||
|
||||
const objectiveContent = hydrateObjective(intent.content, worldState);
|
||||
|
||||
const worldStateSection = `=== CURRENT WORLD STATE ===\nCurrent Time: ${worldState.clock.get().toISOString()}\nEntities & Attributes:\n${serializedWorld}`;
|
||||
const proposedActionSection = `=== PROPOSED ACTION ===\nActor ID: ${intent.actorId}\nType: ${intent.type}\nContent: "${objectiveContent}"\nTarget IDs: ${intent.targetIds.join(", ") || "(None)"}`;
|
||||
|
||||
const userContext = `${worldStateSection}\n\n${proposedActionSection}\n\nDecide if the proposed action is logically valid and physically possible.`;
|
||||
|
||||
const components: PromptComponent[] = [
|
||||
{ label: "System Prompt", type: "system", content: systemPrompt },
|
||||
{
|
||||
label: "Current World State",
|
||||
type: "world",
|
||||
content: worldStateSection,
|
||||
},
|
||||
{
|
||||
label: "Proposed Action",
|
||||
type: "input",
|
||||
content: proposedActionSection,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
components,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { ILLMProvider, PromptBreakdown } from "@omnia/llm";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import { LLMValidatorPromptBuilder } from "./llm-validator-prompt-builder.js";
|
||||
|
||||
export const ValidationResultSchema = z.object({
|
||||
isValid: z.boolean(),
|
||||
@@ -11,12 +12,17 @@ export const ValidationResultSchema = z.object({
|
||||
export type ValidationResult = z.infer<typeof ValidationResultSchema>;
|
||||
|
||||
export class LLMValidator {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
public lastResult: PromptBreakdown | null = null;
|
||||
private promptBuilder: LLMValidatorPromptBuilder;
|
||||
|
||||
constructor(private llmProvider: ILLMProvider) {
|
||||
this.promptBuilder = new LLMValidatorPromptBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an action intent against the objective world state.
|
||||
*
|
||||
* "monologue" intents must never reach this validator — they are internal
|
||||
* "monologue" and "thought" intents must never reach this validator — they are internal
|
||||
* thoughts that bypass validation entirely (see Architect.processIntent).
|
||||
* This guard exists as a defensive safeguard.
|
||||
*/
|
||||
@@ -24,11 +30,14 @@ export class LLMValidator {
|
||||
worldState: WorldState,
|
||||
intent: Intent,
|
||||
): Promise<ValidationResult> {
|
||||
// Defensive guard: monologue intents bypass validation.
|
||||
if (intent.type === "monologue") {
|
||||
this.lastResult = null;
|
||||
|
||||
// Defensive guard: monologue and thought intents bypass validation.
|
||||
if (intent.type === "monologue" || intent.type === "thought") {
|
||||
return {
|
||||
isValid: true,
|
||||
reason: "Monologue intents are internal thoughts and bypass validation.",
|
||||
reason:
|
||||
"Monologue/thought intents are internal thoughts and bypass validation.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,40 +49,16 @@ export class LLMValidator {
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Serialize the objective world state for the LLM
|
||||
const serializedWorld = serializeObjectiveWorldState(worldState);
|
||||
const { systemPrompt, userContext, components } = this.promptBuilder.build(
|
||||
worldState,
|
||||
intent,
|
||||
);
|
||||
|
||||
// 2. Build the prompts
|
||||
const systemPrompt = `
|
||||
You are the World Architect, a deterministic and objective judge of reality, physics, and narration for a simulation game.
|
||||
Your task is to judge whether a proposed action (Intent) by an actor is physically and logically possible given the current objective state of the world.
|
||||
Exempt dialogue or speech actions from validation (consider them always valid).
|
||||
Enforce logical boundaries such as:
|
||||
- Spatial boundaries (an actor cannot grab an object in another location unless they are there).
|
||||
- Physical boundaries (an actor cannot open a locked drawer without a key or breaking it).
|
||||
- State Boundaries (an actor cannot perform a task if their state doesn't allow them to do so).
|
||||
- State/Attribute constraints.
|
||||
|
||||
You must respond with a JSON object containing:
|
||||
- "isValid": boolean indicating if the action is possible/allowed.
|
||||
- "reason": a concise explanation of why the action is allowed or denied.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
=== CURRENT WORLD STATE ===
|
||||
Current Time: ${worldState.clock.get().toISOString()}
|
||||
Entities & Attributes:
|
||||
${serializedWorld}
|
||||
|
||||
=== PROPOSED ACTION ===
|
||||
Actor ID: ${intent.actorId}
|
||||
Type: ${intent.type}
|
||||
Description: "${intent.description}"
|
||||
Original Text: "${intent.originalText}"
|
||||
Target IDs: ${intent.targetIds.join(", ") || "(None)"}
|
||||
|
||||
Decide if the proposed action is logically valid and physically possible.
|
||||
`.trim();
|
||||
this.lastResult = {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
components,
|
||||
};
|
||||
|
||||
// structured call via the LLM provider
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { WorldState, Entity, SQLiteRepository, AttributeVisibility } from "@omnia/core";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
WorldState,
|
||||
Entity,
|
||||
SQLiteRepository,
|
||||
AttributeVisibility,
|
||||
} from "@omnia/core";
|
||||
import { MockLLMProvider } from "@omnia/llm";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import { Architect, AliasDeltaGenerator } from "../src/index.js";
|
||||
|
||||
describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
test("returns valid response when LLM validates intent as successful", async () => {
|
||||
describe("World Architect Validation Tests (Tier 1)", () => {
|
||||
test("returns valid response when LLM confirms the intent", async () => {
|
||||
const world = new WorldState("world-1");
|
||||
const alice = new Entity("alice");
|
||||
world.addEntity(alice);
|
||||
|
||||
// Setup mock LLM response
|
||||
const mockResponse = {
|
||||
isValid: true,
|
||||
reason: "Alice is in the room and the chest is unlocked.",
|
||||
@@ -20,9 +26,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "open the chest and read the scroll",
|
||||
description: "Open the chest and read the scroll",
|
||||
selfDescription: "You open the chest and read the scroll.",
|
||||
content: "entity@alice[I] open the chest and read the scroll",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -51,9 +55,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "unlock the gate and escape",
|
||||
description: "Unlock the gate and escape",
|
||||
selfDescription: "You unlock the gate and escape.",
|
||||
content: "entity@bob[I] unlock the gate and escape",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -74,9 +76,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "haunt the mansion",
|
||||
description: "Haunt the mansion",
|
||||
selfDescription: "You haunt the mansion.",
|
||||
content: "entity@ghost[I] haunt the mansion",
|
||||
actorId: "ghost",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -96,7 +96,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
const db = new Database(":memory:");
|
||||
const repo = new SQLiteRepository(db);
|
||||
|
||||
const world = new WorldState("world-xyz", new Date("2026-07-06T12:00:00.000Z"));
|
||||
const world = new WorldState(
|
||||
"world-xyz",
|
||||
new Date("2026-07-06T12:00:00.000Z"),
|
||||
);
|
||||
const alice = new Entity("alice");
|
||||
world.addEntity(alice);
|
||||
|
||||
@@ -106,17 +109,21 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
// Setup mock LLM responses:
|
||||
// First call: validateIntent (ValidationResult)
|
||||
// Second call: TimeDeltaGenerator (TimeDelta)
|
||||
const mockValidation = { isValid: true, reason: "Alice has the lockpick kit and skill." };
|
||||
const mockTimeDelta = { minutesToAdvance: 20, explanation: "Picking a lock takes time." };
|
||||
const mockValidation = {
|
||||
isValid: true,
|
||||
reason: "Alice has the lockpick kit and skill.",
|
||||
};
|
||||
const mockTimeDelta = {
|
||||
minutesToAdvance: 20,
|
||||
explanation: "Picking a lock takes time.",
|
||||
};
|
||||
const llmProvider = new MockLLMProvider([mockValidation, mockTimeDelta]);
|
||||
|
||||
const architect = new Architect(llmProvider, repo);
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "pick the lock of the wooden chest",
|
||||
description: "Pick the lock of the wooden chest",
|
||||
selfDescription: "You pick the lock of the wooden chest.",
|
||||
content: "entity@alice[I] pick the lock of the wooden chest",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -131,7 +138,9 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
expect(result.timeDelta!.minutesToAdvance).toBe(20);
|
||||
|
||||
// Verify clock was advanced locally
|
||||
const expectedTime = new Date(new Date("2026-07-06T12:00:00.000Z").getTime() + 20 * 60_000);
|
||||
const expectedTime = new Date(
|
||||
new Date("2026-07-06T12:00:00.000Z").getTime() + 20 * 60_000,
|
||||
);
|
||||
expect(world.clock.get().toISOString()).toBe(expectedTime.toISOString());
|
||||
|
||||
// Verify it was persisted to the database
|
||||
@@ -151,16 +160,17 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
world.addEntity(bob);
|
||||
repo.saveWorldState(world);
|
||||
|
||||
const mockValidation = { isValid: false, reason: "Bob is bound by chains." };
|
||||
const mockValidation = {
|
||||
isValid: false,
|
||||
reason: "Bob is bound by chains.",
|
||||
};
|
||||
const llmProvider = new MockLLMProvider([mockValidation]); // TimeDeltaGenerator shouldn't be called
|
||||
|
||||
const architect = new Architect(llmProvider, repo);
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "run away",
|
||||
description: "Run away",
|
||||
selfDescription: "You run away.",
|
||||
content: "entity@bob[I] run away",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -188,8 +198,16 @@ describe("AliasDeltaGenerator Unit Tests (Tier 1)", () => {
|
||||
const world = new WorldState("world-1");
|
||||
const viewer = new Entity("viewer-1");
|
||||
const target = new Entity("target-1");
|
||||
target.addAttribute("appearance", "A tall elf with silver hair", AttributeVisibility.PUBLIC);
|
||||
target.addAttribute("clothing", "A green tunic", AttributeVisibility.PUBLIC);
|
||||
target.addAttribute(
|
||||
"appearance",
|
||||
"A tall elf with silver hair",
|
||||
AttributeVisibility.PUBLIC,
|
||||
);
|
||||
target.addAttribute(
|
||||
"clothing",
|
||||
"A green tunic",
|
||||
AttributeVisibility.PUBLIC,
|
||||
);
|
||||
world.addEntity(viewer);
|
||||
world.addEntity(target);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../intent" }
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function naturalizeTime(now: Date, past: Date): string {
|
||||
const nowHour = now.getHours();
|
||||
const pastIsWaking = pastHour >= 5 && pastHour < 22;
|
||||
const nowIsWaking = nowHour >= 5 && nowHour < 22;
|
||||
|
||||
|
||||
const isSameSubjectiveDay = pastIsWaking && nowIsWaking && deltaHours < 18;
|
||||
|
||||
if (isSameSubjectiveDay) {
|
||||
|
||||
@@ -3,9 +3,11 @@ import { AttributableObject } from "./attribute.js";
|
||||
export class Entity extends AttributableObject {
|
||||
locationId: string | null = null;
|
||||
readonly aliases: Map<string, string> = new Map();
|
||||
isAgent: boolean = true;
|
||||
|
||||
constructor(id?: string, locationId?: string | null) {
|
||||
constructor(id?: string, locationId?: string | null, isAgent?: boolean) {
|
||||
super(id);
|
||||
this.locationId = locationId ?? null;
|
||||
this.isAgent = isAgent ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,3 @@ export * from "./world.js";
|
||||
export * from "./clock.js";
|
||||
export * from "./repository.js";
|
||||
export * from "./alias.js";
|
||||
|
||||
|
||||
@@ -73,6 +73,13 @@ export class SQLiteRepository {
|
||||
} catch {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Safely add is_agent column if it does not exist in an existing database
|
||||
try {
|
||||
this.db.exec("ALTER TABLE objects ADD COLUMN is_agent INTEGER;");
|
||||
} catch {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
}
|
||||
|
||||
save(obj: AttributableObject, type: string, worldId?: string): void {
|
||||
@@ -85,15 +92,20 @@ export class SQLiteRepository {
|
||||
let locationId: string | null = null;
|
||||
let aliasesJson: string | null = null;
|
||||
let connectionsJson: string | null = null;
|
||||
let isAgent: number | null = null;
|
||||
|
||||
if (obj instanceof Entity) {
|
||||
locationId = obj.locationId;
|
||||
aliasesJson = JSON.stringify(Array.from(obj.aliases.entries()));
|
||||
isAgent = obj.isAgent ? 1 : 0;
|
||||
}
|
||||
|
||||
// Check if it's a location (using duck typing to avoid circular import of Location)
|
||||
if (type === "location") {
|
||||
const loc = obj as { parentId?: string | null; connections?: unknown[] };
|
||||
const loc = obj as {
|
||||
parentId?: string | null;
|
||||
connections?: unknown[];
|
||||
};
|
||||
locationId = loc.parentId ?? null;
|
||||
if (loc.connections) {
|
||||
connectionsJson = JSON.stringify(loc.connections);
|
||||
@@ -104,18 +116,28 @@ export class SQLiteRepository {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO objects (id, type, world_id, clock_iso, location_id, aliases_json, connections_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO objects (id, type, world_id, clock_iso, location_id, aliases_json, connections_json, is_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
world_id = excluded.world_id,
|
||||
clock_iso = excluded.clock_iso,
|
||||
location_id = excluded.location_id,
|
||||
aliases_json = excluded.aliases_json,
|
||||
connections_json = excluded.connections_json
|
||||
connections_json = excluded.connections_json,
|
||||
is_agent = excluded.is_agent
|
||||
`,
|
||||
)
|
||||
.run(obj.id, type, worldId || null, clockIso, locationId, aliasesJson, connectionsJson);
|
||||
.run(
|
||||
obj.id,
|
||||
type,
|
||||
worldId || null,
|
||||
clockIso,
|
||||
locationId,
|
||||
aliasesJson,
|
||||
connectionsJson,
|
||||
isAgent,
|
||||
);
|
||||
|
||||
// Get current attributes from db to delete the ones that are no longer present
|
||||
const existingAttrs = this.db
|
||||
@@ -208,16 +230,27 @@ export class SQLiteRepository {
|
||||
const objRow = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT type, location_id, aliases_json FROM objects WHERE id = ?
|
||||
SELECT type, location_id, aliases_json, is_agent FROM objects WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.get(id) as { type: string; location_id: string | null; aliases_json: string | null } | undefined;
|
||||
.get(id) as
|
||||
| {
|
||||
type: string;
|
||||
location_id: string | null;
|
||||
aliases_json: string | null;
|
||||
is_agent: number | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!objRow || objRow.type !== "entity") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entity = new Entity(id, objRow.location_id);
|
||||
const entity = new Entity(
|
||||
id,
|
||||
objRow.location_id,
|
||||
objRow.is_agent !== null ? objRow.is_agent === 1 : true,
|
||||
);
|
||||
if (objRow.aliases_json) {
|
||||
const entries = JSON.parse(objRow.aliases_json) as [string, string][];
|
||||
for (const [k, v] of entries) {
|
||||
@@ -238,7 +271,13 @@ export class SQLiteRepository {
|
||||
SELECT type, location_id, connections_json FROM objects WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.get(id) as { type: string; location_id: string | null; connections_json: string | null } | undefined;
|
||||
.get(id) as
|
||||
| {
|
||||
type: string;
|
||||
location_id: string | null;
|
||||
connections_json: string | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!objRow || objRow.type !== "location") {
|
||||
return null;
|
||||
@@ -246,7 +285,9 @@ export class SQLiteRepository {
|
||||
|
||||
const location = factory(id, objRow.location_id);
|
||||
if (objRow.connections_json) {
|
||||
(location as { connections?: unknown[] }).connections = JSON.parse(objRow.connections_json);
|
||||
(location as { connections?: unknown[] }).connections = JSON.parse(
|
||||
objRow.connections_json,
|
||||
);
|
||||
}
|
||||
this.reconstituteAttributes(location);
|
||||
return location;
|
||||
@@ -262,13 +303,19 @@ export class SQLiteRepository {
|
||||
SELECT id, location_id, connections_json FROM objects WHERE type = 'location' AND world_id = ?
|
||||
`,
|
||||
)
|
||||
.all(worldId) as { id: string; location_id: string | null; connections_json: string | null }[];
|
||||
.all(worldId) as {
|
||||
id: string;
|
||||
location_id: string | null;
|
||||
connections_json: string | null;
|
||||
}[];
|
||||
|
||||
const locations: T[] = [];
|
||||
for (const row of rows) {
|
||||
const loc = factory(row.id, row.location_id);
|
||||
if (row.connections_json) {
|
||||
(loc as { connections?: unknown[] }).connections = JSON.parse(row.connections_json);
|
||||
(loc as { connections?: unknown[] }).connections = JSON.parse(
|
||||
row.connections_json,
|
||||
);
|
||||
}
|
||||
this.reconstituteAttributes(loc);
|
||||
locations.push(loc);
|
||||
@@ -300,13 +347,19 @@ export class SQLiteRepository {
|
||||
SELECT id, location_id, connections_json FROM objects WHERE type = 'location' AND world_id = ?
|
||||
`,
|
||||
)
|
||||
.all(id) as { id: string; location_id: string | null; connections_json: string | null }[];
|
||||
.all(id) as {
|
||||
id: string;
|
||||
location_id: string | null;
|
||||
connections_json: string | null;
|
||||
}[];
|
||||
|
||||
for (const row of locationRows) {
|
||||
const loc = new GenericObject(row.id);
|
||||
(loc as { parentId?: string | null }).parentId = row.location_id;
|
||||
if (row.connections_json) {
|
||||
(loc as { connections?: unknown[] }).connections = JSON.parse(row.connections_json);
|
||||
(loc as { connections?: unknown[] }).connections = JSON.parse(
|
||||
row.connections_json,
|
||||
);
|
||||
}
|
||||
this.reconstituteAttributes(loc);
|
||||
worldState.addLocation(loc);
|
||||
@@ -316,13 +369,22 @@ export class SQLiteRepository {
|
||||
const entityRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, location_id, aliases_json FROM objects WHERE type = 'entity' AND world_id = ?
|
||||
SELECT id, location_id, aliases_json, is_agent FROM objects WHERE type = 'entity' AND world_id = ?
|
||||
`,
|
||||
)
|
||||
.all(id) as { id: string; location_id: string | null; aliases_json: string | null }[];
|
||||
.all(id) as {
|
||||
id: string;
|
||||
location_id: string | null;
|
||||
aliases_json: string | null;
|
||||
is_agent: number | null;
|
||||
}[];
|
||||
|
||||
for (const row of entityRows) {
|
||||
const entity = new Entity(row.id, row.location_id);
|
||||
const entity = new Entity(
|
||||
row.id,
|
||||
row.location_id,
|
||||
row.is_agent !== null ? row.is_agent === 1 : true,
|
||||
);
|
||||
if (row.aliases_json) {
|
||||
const entries = JSON.parse(row.aliases_json) as [string, string][];
|
||||
for (const [k, v] of entries) {
|
||||
@@ -340,14 +402,22 @@ export class SQLiteRepository {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, aliases_json FROM objects WHERE type = 'entity'
|
||||
SELECT id, aliases_json, is_agent FROM objects WHERE type = 'entity'
|
||||
`,
|
||||
)
|
||||
.all() as { id: string; aliases_json: string | null }[];
|
||||
.all() as {
|
||||
id: string;
|
||||
aliases_json: string | null;
|
||||
is_agent: number | null;
|
||||
}[];
|
||||
|
||||
const entities: Entity[] = [];
|
||||
for (const row of rows) {
|
||||
const entity = new Entity(row.id);
|
||||
const entity = new Entity(
|
||||
row.id,
|
||||
null,
|
||||
row.is_agent !== null ? row.is_agent === 1 : true,
|
||||
);
|
||||
if (row.aliases_json) {
|
||||
const entries = JSON.parse(row.aliases_json) as [string, string][];
|
||||
for (const [k, v] of entries) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { AttributableObject, Attribute, serializeAttributes } from "./attribute.js";
|
||||
import {
|
||||
AttributableObject,
|
||||
Attribute,
|
||||
serializeAttributes,
|
||||
} from "./attribute.js";
|
||||
import { Entity } from "./entity.js";
|
||||
import { WorldClock } from "./clock.js";
|
||||
import { resolveAlias } from "./alias.js";
|
||||
@@ -54,8 +58,15 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
|
||||
// Serialize world attributes
|
||||
if (worldState.attributes.size > 0) {
|
||||
lines.push("World Attributes:");
|
||||
const worldAttrsStr = serializeAttributes(Array.from(worldState.attributes.values()));
|
||||
lines.push(worldAttrsStr.split("\n").map(l => " " + l).join("\n"));
|
||||
const worldAttrsStr = serializeAttributes(
|
||||
Array.from(worldState.attributes.values()),
|
||||
);
|
||||
lines.push(
|
||||
worldAttrsStr
|
||||
.split("\n")
|
||||
.map((l) => " " + l)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// Serialize locations and their attributes/portals
|
||||
@@ -63,33 +74,46 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
|
||||
if (worldState.locations.size > 0) {
|
||||
for (const loc of worldState.locations.values()) {
|
||||
lines.push(` - Location [ID: ${loc.id}]:`);
|
||||
|
||||
|
||||
const parentId = (loc as { parentId?: string | null }).parentId;
|
||||
if (parentId) {
|
||||
lines.push(` * Parent Location ID: ${parentId}`);
|
||||
}
|
||||
|
||||
|
||||
if (loc.attributes.size > 0) {
|
||||
const locAttrsStr = serializeAttributes(Array.from(loc.attributes.values()));
|
||||
lines.push(locAttrsStr.split("\n").map(l => " " + l).join("\n"));
|
||||
const locAttrsStr = serializeAttributes(
|
||||
Array.from(loc.attributes.values()),
|
||||
);
|
||||
lines.push(
|
||||
locAttrsStr
|
||||
.split("\n")
|
||||
.map((l) => " " + l)
|
||||
.join("\n"),
|
||||
);
|
||||
} else {
|
||||
lines.push(" * (No attributes)");
|
||||
}
|
||||
|
||||
const connections = (loc as { connections?: unknown[] }).connections as {
|
||||
targetId: string;
|
||||
portalName?: string;
|
||||
portalStateDescriptor?: string;
|
||||
visionProp: number;
|
||||
soundProp: number;
|
||||
bidirectional: boolean;
|
||||
}[] | undefined;
|
||||
const connections = (loc as { connections?: unknown[] }).connections as
|
||||
| {
|
||||
targetId: string;
|
||||
portalName?: string;
|
||||
portalStateDescriptor?: string;
|
||||
visionProp: number;
|
||||
soundProp: number;
|
||||
bidirectional: boolean;
|
||||
}[]
|
||||
| undefined;
|
||||
|
||||
if (connections && connections.length > 0) {
|
||||
lines.push(" * Connections:");
|
||||
for (const conn of connections) {
|
||||
const portalStr = conn.portalName ? ` via ${conn.portalName} (${conn.portalStateDescriptor || "normal"})` : "";
|
||||
lines.push(` -> To: ${conn.targetId}${portalStr} (Vision: ${conn.visionProp}, Sound: ${conn.soundProp})`);
|
||||
const portalStr = conn.portalName
|
||||
? ` via ${conn.portalName} (${conn.portalStateDescriptor || "normal"})`
|
||||
: "";
|
||||
lines.push(
|
||||
` -> To: ${conn.targetId}${portalStr} (Vision: ${conn.visionProp}, Sound: ${conn.soundProp})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,8 +130,15 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
|
||||
lines.push(` * Location ID: ${entity.locationId}`);
|
||||
}
|
||||
if (entity.attributes.size > 0) {
|
||||
const entityAttrsStr = serializeAttributes(Array.from(entity.attributes.values()));
|
||||
lines.push(entityAttrsStr.split("\n").map(l => " " + l).join("\n"));
|
||||
const entityAttrsStr = serializeAttributes(
|
||||
Array.from(entity.attributes.values()),
|
||||
);
|
||||
lines.push(
|
||||
entityAttrsStr
|
||||
.split("\n")
|
||||
.map((l) => " " + l)
|
||||
.join("\n"),
|
||||
);
|
||||
} else {
|
||||
lines.push(" * (No attributes)");
|
||||
}
|
||||
@@ -119,7 +150,6 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Serializes a single attribute the way a viewer perceives it — name and
|
||||
* value only, no visibility/ACL metadata (the viewer already sees only
|
||||
@@ -158,13 +188,23 @@ export function serializeSubjectiveWorldState(
|
||||
const worldVisible = worldState.getVisibleAttributesFor(viewerId);
|
||||
if (worldVisible.length > 0) {
|
||||
lines.push("World (as you know it):");
|
||||
lines.push(serializeVisibleAttributes(worldVisible).split("\n").map((l) => " " + l).join("\n"));
|
||||
lines.push(
|
||||
serializeVisibleAttributes(worldVisible)
|
||||
.split("\n")
|
||||
.map((l) => " " + l)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Self ---
|
||||
lines.push(`Self (${viewerAlias}):`);
|
||||
const selfVisible = viewer.getVisibleAttributesFor(viewerId);
|
||||
lines.push(serializeVisibleAttributes(selfVisible).split("\n").map((l) => " " + l).join("\n"));
|
||||
lines.push(
|
||||
serializeVisibleAttributes(selfVisible)
|
||||
.split("\n")
|
||||
.map((l) => " " + l)
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
// --- Location / perceived entities ---
|
||||
lines.push("What you perceive around you:");
|
||||
@@ -175,7 +215,12 @@ export function serializeSubjectiveWorldState(
|
||||
const locVisible = location.getVisibleAttributesFor(viewerId);
|
||||
if (locVisible.length > 0) {
|
||||
lines.push(" Location attributes:");
|
||||
lines.push(serializeVisibleAttributes(locVisible).split("\n").map((l) => " " + l).join("\n"));
|
||||
lines.push(
|
||||
serializeVisibleAttributes(locVisible)
|
||||
.split("\n")
|
||||
.map((l) => " " + l)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -199,7 +244,12 @@ export function serializeSubjectiveWorldState(
|
||||
const alias = resolveAlias(viewer, e.id);
|
||||
lines.push(` - ${alias}:`);
|
||||
const eVisible = e.getVisibleAttributesFor(viewerId);
|
||||
lines.push(serializeVisibleAttributes(eVisible).split("\n").map((l) => " " + l).join("\n"));
|
||||
lines.push(
|
||||
serializeVisibleAttributes(eVisible)
|
||||
.split("\n")
|
||||
.map((l) => " " + l)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lines.push(" You are alone here.");
|
||||
|
||||
@@ -11,23 +11,39 @@ describe("TimeNaturalization Unit Tests (Tier 1)", () => {
|
||||
|
||||
test("Tier 1: relative time ranges (< 6 hours)", () => {
|
||||
const now = new Date(2026, 6, 8, 12, 0, 0);
|
||||
|
||||
|
||||
// < 1 minute
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 59, 30))).toBe("just now");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 59, 30))).toBe(
|
||||
"just now",
|
||||
);
|
||||
// < 3 minutes
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 58, 0))).toBe("moments ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 58, 0))).toBe(
|
||||
"moments ago",
|
||||
);
|
||||
// < 10 minutes
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 52, 0))).toBe("a few minutes ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 52, 0))).toBe(
|
||||
"a few minutes ago",
|
||||
);
|
||||
// < 30 minutes
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 40, 0))).toBe("several minutes ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 40, 0))).toBe(
|
||||
"several minutes ago",
|
||||
);
|
||||
// < 45 minutes
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 20, 0))).toBe("about half an hour ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 20, 0))).toBe(
|
||||
"about half an hour ago",
|
||||
);
|
||||
// < 1.5 hours (90 minutes)
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 10, 50, 0))).toBe("about an hour ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 10, 50, 0))).toBe(
|
||||
"about an hour ago",
|
||||
);
|
||||
// < 3 hours
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 9, 30, 0))).toBe("a couple hours ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 9, 30, 0))).toBe(
|
||||
"a couple hours ago",
|
||||
);
|
||||
// < 6 hours
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 7, 0, 0))).toBe("a few hours ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 7, 0, 0))).toBe(
|
||||
"a few hours ago",
|
||||
);
|
||||
});
|
||||
|
||||
test("Tier 2: Same subjective day vs yesterday periods (6h <= delta < 48h)", () => {
|
||||
@@ -35,12 +51,16 @@ describe("TimeNaturalization Unit Tests (Tier 1)", () => {
|
||||
// 9am to 3pm (delta 6h) -> morning
|
||||
const nowWaking = new Date(2026, 6, 8, 15, 0, 0);
|
||||
const pastMorning = new Date(2026, 6, 8, 9, 0, 0);
|
||||
expect(naturalizeTime(nowWaking, pastMorning)).toBe("earlier today, in the morning");
|
||||
expect(naturalizeTime(nowWaking, pastMorning)).toBe(
|
||||
"earlier today, in the morning",
|
||||
);
|
||||
|
||||
// 2pm to 9pm (delta 7h) -> afternoon
|
||||
const nowEvening = new Date(2026, 6, 8, 21, 0, 0);
|
||||
const pastAfternoon = new Date(2026, 6, 8, 14, 0, 0);
|
||||
expect(naturalizeTime(nowEvening, pastAfternoon)).toBe("earlier today, in the afternoon");
|
||||
expect(naturalizeTime(nowEvening, pastAfternoon)).toBe(
|
||||
"earlier today, in the afternoon",
|
||||
);
|
||||
|
||||
// 2. Previous night (past period: night (21-23))
|
||||
// 11pm to 5am (delta 6h) -> last night
|
||||
@@ -52,45 +72,73 @@ describe("TimeNaturalization Unit Tests (Tier 1)", () => {
|
||||
// 1am to 7am (delta 6h) -> around midnight
|
||||
const nowMidnightRun = new Date(2026, 6, 8, 7, 0, 0);
|
||||
const pastMidnight = new Date(2026, 6, 8, 1, 0, 0);
|
||||
expect(naturalizeTime(nowMidnightRun, pastMidnight)).toBe("around midnight");
|
||||
expect(naturalizeTime(nowMidnightRun, pastMidnight)).toBe(
|
||||
"around midnight",
|
||||
);
|
||||
|
||||
// 4. Late night (past period: late night (3-4))
|
||||
// 3am to 9am (delta 6h) -> late last night
|
||||
const nowLateNightRun = new Date(2026, 6, 8, 9, 0, 0);
|
||||
const pastLateNight = new Date(2026, 6, 8, 3, 0, 0);
|
||||
expect(naturalizeTime(nowLateNightRun, pastLateNight)).toBe("late last night");
|
||||
expect(naturalizeTime(nowLateNightRun, pastLateNight)).toBe(
|
||||
"late last night",
|
||||
);
|
||||
|
||||
// 5. Yesterday waking hours (delta >= 6h, diff day / waking check fails)
|
||||
// 3pm to 9am next day (delta 18h) -> yesterday afternoon
|
||||
const nowNextDay = new Date(2026, 6, 9, 9, 0, 0);
|
||||
const pastYesterdayAfternoon = new Date(2026, 6, 8, 15, 0, 0);
|
||||
expect(naturalizeTime(nowNextDay, pastYesterdayAfternoon)).toBe("yesterday afternoon");
|
||||
expect(naturalizeTime(nowNextDay, pastYesterdayAfternoon)).toBe(
|
||||
"yesterday afternoon",
|
||||
);
|
||||
});
|
||||
|
||||
test("Tier 3: Coarse relative time ranges (delta >= 48 hours)", () => {
|
||||
const now = new Date(2026, 6, 10, 12, 0, 0);
|
||||
|
||||
// 2 days
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 0, 0))).toBe("a couple days ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 0, 0))).toBe(
|
||||
"a couple days ago",
|
||||
);
|
||||
// 3-6 days
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 6, 12, 0, 0))).toBe("a few days ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 6, 12, 0, 0))).toBe(
|
||||
"a few days ago",
|
||||
);
|
||||
// 7-13 days
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 1, 12, 0, 0))).toBe("about a week ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 6, 1, 12, 0, 0))).toBe(
|
||||
"about a week ago",
|
||||
);
|
||||
// 14-20 days
|
||||
expect(naturalizeTime(now, new Date(2026, 5, 25, 12, 0, 0))).toBe("a couple weeks ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 5, 25, 12, 0, 0))).toBe(
|
||||
"a couple weeks ago",
|
||||
);
|
||||
// 21-29 days
|
||||
expect(naturalizeTime(now, new Date(2026, 5, 15, 12, 0, 0))).toBe("a few weeks ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 5, 15, 12, 0, 0))).toBe(
|
||||
"a few weeks ago",
|
||||
);
|
||||
// 30-59 days
|
||||
expect(naturalizeTime(now, new Date(2026, 5, 1, 12, 0, 0))).toBe("about a month ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 5, 1, 12, 0, 0))).toBe(
|
||||
"about a month ago",
|
||||
);
|
||||
// 60-89 days
|
||||
expect(naturalizeTime(now, new Date(2026, 4, 1, 12, 0, 0))).toBe("a couple months ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 4, 1, 12, 0, 0))).toBe(
|
||||
"a couple months ago",
|
||||
);
|
||||
// 90-179 days
|
||||
expect(naturalizeTime(now, new Date(2026, 3, 1, 12, 0, 0))).toBe("a few months ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 3, 1, 12, 0, 0))).toBe(
|
||||
"a few months ago",
|
||||
);
|
||||
// 180-364 days
|
||||
expect(naturalizeTime(now, new Date(2026, 0, 1, 12, 0, 0))).toBe("many months ago");
|
||||
expect(naturalizeTime(now, new Date(2026, 0, 1, 12, 0, 0))).toBe(
|
||||
"many months ago",
|
||||
);
|
||||
// 365-729 days
|
||||
expect(naturalizeTime(now, new Date(2025, 6, 1, 12, 0, 0))).toBe("about a year ago");
|
||||
expect(naturalizeTime(now, new Date(2025, 6, 1, 12, 0, 0))).toBe(
|
||||
"about a year ago",
|
||||
);
|
||||
// >= 730 days
|
||||
expect(naturalizeTime(now, new Date(2023, 6, 1, 12, 0, 0))).toBe("years ago");
|
||||
expect(naturalizeTime(now, new Date(2023, 6, 1, 12, 0, 0))).toBe(
|
||||
"years ago",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,16 +61,21 @@ describe("Attribute & AttributableObject Unit Tests (Tier 1)", () => {
|
||||
test("AttributableObject visibility filtering", () => {
|
||||
const actor = new MockAttributable("actor");
|
||||
actor.addAttribute("eyes", "blue", AttributeVisibility.PUBLIC);
|
||||
actor.addAttribute("secret", "42", AttributeVisibility.PRIVATE, new Set(["friend"]));
|
||||
actor.addAttribute(
|
||||
"secret",
|
||||
"42",
|
||||
AttributeVisibility.PRIVATE,
|
||||
new Set(["friend"]),
|
||||
);
|
||||
|
||||
// Public viewer should only see public attributes
|
||||
const publicAttrs = actor.getVisibleAttributesFor("stranger");
|
||||
expect(publicAttrs.map(a => a.name)).toEqual(["eyes"]);
|
||||
expect(publicAttrs.map((a) => a.name)).toEqual(["eyes"]);
|
||||
|
||||
// Authorized viewer should see both
|
||||
const privateAttrs = actor.getVisibleAttributesFor("friend");
|
||||
expect(privateAttrs.map(a => a.name)).toContain("eyes");
|
||||
expect(privateAttrs.map(a => a.name)).toContain("secret");
|
||||
expect(privateAttrs.map((a) => a.name)).toContain("eyes");
|
||||
expect(privateAttrs.map((a) => a.name)).toContain("secret");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,7 +136,12 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => {
|
||||
const alice = new Entity("alice", "location-a");
|
||||
alice.addAttribute("name", "Alice Smith", AttributeVisibility.PUBLIC);
|
||||
// Secret attribute visible only to 'bob'
|
||||
alice.addAttribute("diaries", "Private thoughts", AttributeVisibility.PRIVATE, new Set(["bob"]));
|
||||
alice.addAttribute(
|
||||
"diaries",
|
||||
"Private thoughts",
|
||||
AttributeVisibility.PRIVATE,
|
||||
new Set(["bob"]),
|
||||
);
|
||||
world.addEntity(alice);
|
||||
|
||||
const bob = new Entity("bob");
|
||||
@@ -161,7 +171,9 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => {
|
||||
expect(loadedAlice!.locationId).toBe("location-a");
|
||||
expect(loadedBob!.locationId).toBeNull();
|
||||
expect(loadedAlice!.attributes.get("name")?.getValue()).toBe("Alice Smith");
|
||||
expect(loadedAlice!.attributes.get("name")?.visibility).toBe(AttributeVisibility.PUBLIC);
|
||||
expect(loadedAlice!.attributes.get("name")?.visibility).toBe(
|
||||
AttributeVisibility.PUBLIC,
|
||||
);
|
||||
|
||||
const diaryAttr = loadedAlice!.attributes.get("diaries")!;
|
||||
expect(diaryAttr.getValue()).toBe("Private thoughts");
|
||||
@@ -191,17 +203,46 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => {
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("Save and load entity isAgent property", () => {
|
||||
const db = new Database(":memory:");
|
||||
const repo = new SQLiteRepository(db);
|
||||
|
||||
const world = new WorldState("world-xyz");
|
||||
const alice = new Entity("alice", null, false);
|
||||
const bob = new Entity("bob", null, true);
|
||||
world.addEntity(alice);
|
||||
world.addEntity(bob);
|
||||
|
||||
repo.saveWorldState(world);
|
||||
|
||||
const loadedWorld = repo.loadWorldState("world-xyz")!;
|
||||
const loadedAlice = loadedWorld.getEntity("alice")!;
|
||||
const loadedBob = loadedWorld.getEntity("bob")!;
|
||||
|
||||
expect(loadedAlice.isAgent).toBe(false);
|
||||
expect(loadedBob.isAgent).toBe(true);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Serializer & serializeObjectiveWorldState Unit Tests (Tier 1)", () => {
|
||||
test("serializeAttributes utility", () => {
|
||||
const obj = new MockAttributable("obj-1");
|
||||
obj.addAttribute("health", "100", AttributeVisibility.PUBLIC);
|
||||
obj.addAttribute("secret", "key-123", AttributeVisibility.PRIVATE, new Set(["alice"]));
|
||||
obj.addAttribute(
|
||||
"secret",
|
||||
"key-123",
|
||||
AttributeVisibility.PRIVATE,
|
||||
new Set(["alice"]),
|
||||
);
|
||||
|
||||
const result = serializeAttributes(Array.from(obj.attributes.values()));
|
||||
expect(result).toContain("* health: 100 (Visibility: PUBLIC)");
|
||||
expect(result).toContain("* secret: key-123 (Visibility: PRIVATE) (Visible to: alice)");
|
||||
expect(result).toContain(
|
||||
"* secret: key-123 (Visibility: PRIVATE) (Visible to: alice)",
|
||||
);
|
||||
});
|
||||
|
||||
test("serializeObjectiveWorldState utility", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,34 @@
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
|
||||
import { dehydrate, expandContractions } from "@omnia/voice";
|
||||
import { Intent, IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
|
||||
import { IntentDecoderPromptBuilder } from "./intent-prompt-builder.js";
|
||||
|
||||
export class IntentDecoder {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
private promptBuilder: IntentDecoderPromptBuilder;
|
||||
|
||||
constructor(private llmProvider: ILLMProvider) {
|
||||
this.promptBuilder = new IntentDecoderPromptBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes narrative prose into an ordered sequence of structured intents.
|
||||
*
|
||||
* Responsibilities (from docs/intents.md):
|
||||
* - Split prose into multiple intents when applicable.
|
||||
* - Classify each intent as "dialogue", "action", or "monologue".
|
||||
* - Parse narrative text into structured JSON with minimal information loss.
|
||||
* - Contextually resolve receiving parties (targets).
|
||||
*/
|
||||
async decode(
|
||||
worldState: WorldState,
|
||||
actorId: string,
|
||||
narrativeProse: string,
|
||||
recentIntents: Intent[] = [],
|
||||
): Promise<IntentSequence> {
|
||||
const entityIds = Array.from(worldState.entities.keys());
|
||||
const processedProse = expandContractions(narrativeProse);
|
||||
const actor = worldState.getEntity(actorId);
|
||||
|
||||
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
|
||||
const aliasContext =
|
||||
aliasEntries.length > 0
|
||||
? aliasEntries
|
||||
.map(
|
||||
([targetId, alias]) =>
|
||||
`- "${alias}" refers to entity ID: "${targetId}"`,
|
||||
)
|
||||
.join("\n")
|
||||
: "(No known aliases)";
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Intent Decoder for a narrative simulation engine.
|
||||
Your job is to take a block of narrative prose written by an actor agent and decompose it into an ordered sequence of discrete intents.
|
||||
|
||||
For each intent you must:
|
||||
1. Classify its type:
|
||||
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
|
||||
- "action": Any physical or logical action performed in the world (e.g., moving, opening, looking).
|
||||
- "monologue": An inner thought, reflection, or internal monologue/self narration.
|
||||
2. Extract the original text fragment from the prose that corresponds to this intent.
|
||||
3. Populate "description" and "selfDescription":
|
||||
- "description": No subject or name — a bare third-person verb phrase only (e.g. "clears their throat", "shakes their head slowly")
|
||||
- "selfDescription": The same event from the actor's own perspective, second person, complete sentence starting with "You" (e.g. "You clear your throat.", "You shake your head slowly."). This is shown directly in the actor's own memory — it must never say "the actor" or refer to them in the third person.
|
||||
- In case of a dialogue, the description and self Description only stores the exact words said by the entity. (e.g. "I will do that later", "Are you serious right now?")
|
||||
4. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" mapping to resolve any subjective names,or aliases used in the prose to their correct system entity IDs. If no specific target, use an empty array.
|
||||
5. Identify modifiers — a list of strings representing additional qualities or modifiers extracted from the narrative prose. This includes emotions, tone of voice, speed, manner of action, or statement type (e.g., "question", "anxious", "whispering", "slowly", "quietly", "forcefully"). If no modifiers are present, use an empty array.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
=== KNOWN ENTITY IDS ===
|
||||
${entityIds.length > 0 ? entityIds.join(", ") : "(No entities)"}
|
||||
|
||||
=== ACTOR ALIASES ===
|
||||
The actor refers to other entities using these subjective names/aliases:
|
||||
${aliasContext}
|
||||
|
||||
=== WORLD STATE ===
|
||||
${serializeSimplifiedWorldState(worldState)}
|
||||
|
||||
=== ACTOR ===
|
||||
Actor ID: ${actorId}
|
||||
|
||||
=== NARRATIVE PROSE ===
|
||||
${narrativeProse}
|
||||
`.trim();
|
||||
const { systemPrompt, userContext, components } = this.promptBuilder.build(
|
||||
worldState,
|
||||
actorId,
|
||||
processedProse,
|
||||
recentIntents,
|
||||
);
|
||||
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
@@ -81,42 +42,32 @@ ${narrativeProse}
|
||||
);
|
||||
}
|
||||
|
||||
const fullIntents = response.data.intents.map((intent) => ({
|
||||
...intent,
|
||||
actorId,
|
||||
}));
|
||||
const aliasMap: Record<string, string> = {};
|
||||
if (actor) {
|
||||
for (const [targetId, alias] of actor.aliases.entries()) {
|
||||
aliasMap[alias] = targetId;
|
||||
}
|
||||
}
|
||||
|
||||
const fullIntents = response.data.intents.map((intent) => {
|
||||
const dehydrated = dehydrate(
|
||||
intent.content,
|
||||
actorId,
|
||||
intent.targetIds,
|
||||
aliasMap,
|
||||
);
|
||||
return {
|
||||
...intent,
|
||||
content: dehydrated,
|
||||
actorId,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
intents: fullIntents,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
promptComponents: components,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSimplifiedWorldState(worldState: WorldState): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("Locations:");
|
||||
if (worldState.locations.size > 0) {
|
||||
for (const loc of worldState.locations.values()) {
|
||||
const parentId = (loc as { parentId?: string | null }).parentId;
|
||||
const parentStr = parentId ? ` (Parent: ${parentId})` : "";
|
||||
lines.push(` - Location [ID: ${loc.id}]${parentStr}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(" (No locations)");
|
||||
}
|
||||
|
||||
lines.push("Entities:");
|
||||
if (worldState.entities.size > 0) {
|
||||
for (const entity of worldState.entities.values()) {
|
||||
const locStr = entity.locationId
|
||||
? ` (Location: ${entity.locationId})`
|
||||
: "";
|
||||
lines.push(` - Entity [ID: ${entity.id}]${locStr}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(" (No entities)");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
102
packages/intent/src/intent-prompt-builder.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { WorldState, resolveAlias } from "@omnia/core";
|
||||
import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm";
|
||||
import { Intent } from "./intent.js";
|
||||
|
||||
/**
|
||||
* Prompt builder for the Intent Decoder.
|
||||
* Separates prompt generation, structure, and component breakdowns.
|
||||
*/
|
||||
export class IntentDecoderPromptBuilder implements IPromptBuilder<
|
||||
[WorldState, string, string, Intent[]]
|
||||
> {
|
||||
build(
|
||||
worldState: WorldState,
|
||||
actorId: string,
|
||||
processedProse: string,
|
||||
recentIntents: Intent[],
|
||||
): PromptBreakdown {
|
||||
const actor = worldState.getEntity(actorId);
|
||||
|
||||
// 1. Get other entities co-located in the same context
|
||||
const otherEntitiesLines: string[] = [];
|
||||
for (const otherEntity of worldState.entities.values()) {
|
||||
if (
|
||||
otherEntity.id !== actorId &&
|
||||
otherEntity.locationId === actor?.locationId
|
||||
) {
|
||||
const alias = actor
|
||||
? resolveAlias(actor, otherEntity.id)
|
||||
: otherEntity.id;
|
||||
otherEntitiesLines.push(` - Alias="${alias}" ID=${otherEntity.id}`);
|
||||
}
|
||||
}
|
||||
const otherEntitiesContext =
|
||||
otherEntitiesLines.length > 0
|
||||
? otherEntitiesLines.join("\n")
|
||||
: " (No other entities in context)";
|
||||
|
||||
// 2. Format historical context (2-3 recent intents received by the actor)
|
||||
const historicalLines: string[] = [];
|
||||
for (const prior of recentIntents) {
|
||||
const targetIds =
|
||||
prior.actorId !== actorId ? [prior.actorId] : prior.targetIds;
|
||||
const targetsStr = targetIds
|
||||
.map((tid) => {
|
||||
const alias = actor ? resolveAlias(actor, tid) : tid;
|
||||
return `(Alias="${alias}", ID="${tid}")`;
|
||||
})
|
||||
.join(", ");
|
||||
historicalLines.push(
|
||||
` - Content: "${prior.content}", Type: ${prior.type}, Target Entities: ${targetsStr || "None"}`,
|
||||
);
|
||||
}
|
||||
const historicalContext =
|
||||
historicalLines.length > 0
|
||||
? historicalLines.join("\n")
|
||||
: " (No prior intents in context)";
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Intent Decoder for a narrative simulation engine.
|
||||
Your job is to take a block of narrative prose written by an actor agent and decompose it into an ordered sequence of discrete intents.
|
||||
|
||||
For each intent you must:
|
||||
1. Classify its type:
|
||||
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
|
||||
- "action": Any physical action performed in the world (e.g., moving, opening, looking). DO NOT CLASSIFY SPEAKING MODIFIERS AS ACTIONS
|
||||
- "monologue" (or "thought"): An inner thought, reflection, or monologue/self narration.
|
||||
2. Extract the original narrative text fragment from the prose that corresponds to this intent and populate it as "content". Do not paraphrase, do not convert to third person, and do not convert to second person. Keep the original text fragment exactly as written in the prose (first-person voice).
|
||||
3. Identify targetIds — the entity IDs of the receiving parties. Use the "Other entities in context" list to resolve any subjective names, aliases, or descriptions used in the prose to their correct entity IDs. If no specific target, use an empty array.
|
||||
4. Identify modifiers — a list of strings representing additional qualities or modifiers extracted from the narrative prose. This includes emotions, tone of voice, speed, manner of action, or statement type (e.g., "question", "anxious", "whispering", "slowly", "quietly", "forcefully"). If no modifiers are present, use an empty array.
|
||||
5. For dialogue intents always use the following format for content field:
|
||||
I say "<dialogue>" (optionally: to him/her/alias).
|
||||
`.trim();
|
||||
|
||||
const decoderContext = `
|
||||
Intent Source: ${actorId}
|
||||
Other entities in context:
|
||||
${otherEntitiesContext}
|
||||
Historical Context:
|
||||
${historicalContext}
|
||||
`.trim();
|
||||
|
||||
const narrativeProseSection = `=== NARRATIVE PROSE ===\n${processedProse}`;
|
||||
|
||||
const userContext = `${decoderContext}\n\n${narrativeProseSection}`;
|
||||
|
||||
const components: PromptComponent[] = [
|
||||
{ label: "System Prompt", type: "system", content: systemPrompt },
|
||||
{ label: "Decoder Context", type: "world", content: decoderContext },
|
||||
{
|
||||
label: "Narrative Prose",
|
||||
type: "input",
|
||||
content: narrativeProseSection,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
components,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,15 @@ import { z } from "zod";
|
||||
* - "action": A physical or logical action performed in the world.
|
||||
* - "monologue": An inner thought or internal monologue. Not perceivable by
|
||||
* any other entity. Bypasses the Architect/validators entirely and is
|
||||
* written directly to the actor's memory buffer with no outcome.
|
||||
* written directly to the actor's Cognitive Buffer with no outcome.
|
||||
* - "thought": Equivalent/alias to "monologue".
|
||||
*/
|
||||
export const IntentTypeSchema = z.enum(["dialogue", "action", "monologue"]);
|
||||
export const IntentTypeSchema = z.enum([
|
||||
"dialogue",
|
||||
"action",
|
||||
"monologue",
|
||||
"thought",
|
||||
]);
|
||||
export type IntentType = z.infer<typeof IntentTypeSchema>;
|
||||
|
||||
/**
|
||||
@@ -18,19 +24,13 @@ export const LLMIntentSchema = z.object({
|
||||
/** The type of intent. */
|
||||
type: IntentTypeSchema,
|
||||
|
||||
/** The original narrative text fragment this intent was extracted from. */
|
||||
originalText: z.string(),
|
||||
|
||||
/** A concise, structured description of the intent's action or dialogue. */
|
||||
description: z.string(),
|
||||
|
||||
/** The same event from the actor's own perspective (second person, "You"). */
|
||||
selfDescription: z.string(),
|
||||
/** The dehydrated canonical content of the intent. */
|
||||
content: z.string(),
|
||||
|
||||
/**
|
||||
* Entity IDs of the receiving parties (e.g., who is being spoken to,
|
||||
* what object is being interacted with). Always an empty array for
|
||||
* "monologue" intents, since they are not perceivable by anyone.
|
||||
* "monologue" and "thought" intents, since they are not perceivable by anyone.
|
||||
*/
|
||||
targetIds: z.array(z.string()),
|
||||
|
||||
@@ -56,8 +56,14 @@ export const LLMIntentSequenceSchema = z.object({
|
||||
* The full output of the Intent Decoder: an ordered sequence of intents
|
||||
* extracted from a single narrative prose block.
|
||||
*/
|
||||
import { PromptComponent } from "@omnia/llm";
|
||||
|
||||
export const IntentSequenceSchema = z.object({
|
||||
intents: z.array(IntentSchema),
|
||||
});
|
||||
|
||||
export type IntentSequence = z.infer<typeof IntentSequenceSchema>;
|
||||
export type IntentSequence = z.infer<typeof IntentSequenceSchema> & {
|
||||
systemPrompt?: string;
|
||||
userContext?: string;
|
||||
promptComponents?: PromptComponent[];
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { WorldState, Entity } from "@omnia/core";
|
||||
import { MockLLMProvider } from "@omnia/llm";
|
||||
import { IntentDecoder, IntentSequence } from "@omnia/intent";
|
||||
import { IntentDecoder } from "@omnia/intent";
|
||||
|
||||
describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
test("decodes prose with a single action intent", async () => {
|
||||
@@ -9,13 +9,11 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
const alice = new Entity("alice");
|
||||
world.addEntity(alice);
|
||||
|
||||
const mockResponse: IntentSequence = {
|
||||
const mockResponse = {
|
||||
intents: [
|
||||
{
|
||||
type: "action",
|
||||
originalText: "Alice opened the chest.",
|
||||
description: "Open the wooden chest.",
|
||||
selfDescription: "You open the wooden chest.",
|
||||
content: "I open the wooden chest.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
@@ -25,11 +23,16 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
const llm = new MockLLMProvider([mockResponse]);
|
||||
const decoder = new IntentDecoder(llm);
|
||||
|
||||
const result = await decoder.decode(world, "alice", "Alice opened the chest.");
|
||||
const result = await decoder.decode(
|
||||
world,
|
||||
"alice",
|
||||
"Alice opened the chest.",
|
||||
);
|
||||
|
||||
expect(result.intents).toHaveLength(1);
|
||||
expect(result.intents[0].type).toBe("action");
|
||||
expect(result.intents[0].actorId).toBe("alice");
|
||||
expect(result.intents[0].content).toContain("entity@alice[I]");
|
||||
expect(result.intents[0].targetIds).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -40,13 +43,11 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
world.addEntity(alice);
|
||||
world.addEntity(bob);
|
||||
|
||||
const mockResponse: IntentSequence = {
|
||||
const mockResponse = {
|
||||
intents: [
|
||||
{
|
||||
type: "dialogue",
|
||||
originalText: '"Do you have the key?" Alice asked Bob.',
|
||||
description: "Alice asks Bob if he has the key.",
|
||||
selfDescription: "You ask Bob if he has the key.",
|
||||
content: '"Do you have the key?" I asked Bob.',
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
@@ -64,6 +65,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
|
||||
expect(result.intents).toHaveLength(1);
|
||||
expect(result.intents[0].type).toBe("dialogue");
|
||||
expect(result.intents[0].content).toContain("entity@alice[I]");
|
||||
expect(result.intents[0].targetIds).toEqual(["bob"]);
|
||||
});
|
||||
|
||||
@@ -74,21 +76,17 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
world.addEntity(alice);
|
||||
world.addEntity(bob);
|
||||
|
||||
const mockResponse: IntentSequence = {
|
||||
const mockResponse = {
|
||||
intents: [
|
||||
{
|
||||
type: "dialogue",
|
||||
originalText: '"Cover me," Alice whispered to Bob.',
|
||||
description: "Alice whispers to Bob requesting cover.",
|
||||
selfDescription: "You whisper to Bob requesting cover.",
|
||||
content: '"Cover me," I whispered to Bob.',
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She crept towards the door and pulled the handle.",
|
||||
description: "Creep towards the door and pull the handle.",
|
||||
selfDescription: "You creep towards the door and pull the handle.",
|
||||
content: "I crept towards the door and pulled the handle.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
@@ -109,6 +107,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
expect(result.intents[0].targetIds).toEqual(["bob"]);
|
||||
expect(result.intents[1].type).toBe("action");
|
||||
expect(result.intents[1].actorId).toBe("alice");
|
||||
expect(result.intents[1].content).toContain("entity@alice[I]");
|
||||
});
|
||||
|
||||
test("throws on LLM failure", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../llm" }
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
532
packages/llm/README.md
Normal 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
|
||||
```
|
||||
@@ -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"
|
||||
|
||||
113
packages/llm/src/base-provider.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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,
|
||||
response: parsed,
|
||||
});
|
||||
|
||||
return { success: true, data: parsed, usage };
|
||||
}
|
||||
}
|
||||
193
packages/llm/src/bin/setup-provider.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||