mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
Compare commits
31 Commits
3906259328
...
feat/scena
| Author | SHA1 | Date | |
|---|---|---|---|
| 381afdcb1a | |||
| 44f3e29e62 | |||
| d2506ec542 | |||
| 13483d54dd | |||
| 2cc89d2e3c | |||
| b5c5fa9f0a | |||
| ce8b9176d3 | |||
| 9d18444220 | |||
| 9838d4ce59 | |||
| 03a5bec366 | |||
| 663d409a46 | |||
| 30bf5f76c2 | |||
| 3b2a85aeaf | |||
| d59b372a57 | |||
| 2f20224bac | |||
| 8c1b593453 | |||
| 9aaac17449 | |||
| ab181cb5c7 | |||
| be9ef29de6 | |||
| 765eb0c01b | |||
| aaffffaf7e | |||
| c714fca6d7 | |||
| 9e1eb41c55 | |||
| 35ab4b4d40 | |||
| 126da35e88 | |||
| 0dcb968670 | |||
| 01b2e650f3 | |||
| 607a68fdb5 | |||
| 35518ea096 | |||
| c86c0295ef | |||
| 4b71db3a34 |
5
.codegraph/.gitignore
vendored
Normal file
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
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"
|
||||
|
||||
@@ -148,7 +148,7 @@ The bundled demo scenario is exactly this: [`talking-room`](./content/demo/scena
|
||||
|
||||
## Project Status: What `v0` Means
|
||||
|
||||
The finish line for the first milestone is small on purpose.
|
||||
The finish line for the first milestone is small on purpose. `v0` is almost on the horizon. All assigned systems for `v0` have been implemented and tested throughout.
|
||||
|
||||
**Currently Implemented:**
|
||||
|
||||
@@ -163,13 +163,11 @@ The finish line for the first milestone is small on purpose.
|
||||
**[The `v0` Milestone:](https://github.com/sortedcord/omnia-consolidated/milestone/1)**
|
||||
|
||||
- [x] Two hand-authored NPCs live in one location, playable via CLI.
|
||||
- [ ] Each has buffer and vector-archive memory and recalls something said a few turns earlier. _(buffer: done; vector archive: not started)_
|
||||
- [x] Each has buffer and vector-archive 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.
|
||||
|
||||
**Explicitly out of scope for `v0`:** Constraint validators (beyond basic sense-checking), multi-location perception, affect-vector decay math, the Dossier, whims/simulation tiering, the delta ledger.
|
||||
|
||||
### A Note on Tech Debt
|
||||
|
||||
The Architect currently trusts an LLM's judgement about reasonable consequences rather than validating every change against declarative constraints. A general constraint solver is worth building eventually, but building it before anything is playable is foundational perfectionism that produces beautiful architecture and no framework. `v0` keeps the single-call Architect on purpose.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@omnia/actor": "workspace:*",
|
||||
"@omnia/architect": "workspace:*",
|
||||
"@omnia/core": "workspace:*",
|
||||
@@ -18,7 +19,11 @@
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/scenario": "workspace:*",
|
||||
"@omnia/spatial": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.2",
|
||||
|
||||
BIN
apps/gui/public/background.png
Normal file
BIN
apps/gui/public/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/gui/public/logo-shadow.png
Normal file
BIN
apps/gui/public/logo-shadow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
apps/gui/public/logo.png
Normal file
BIN
apps/gui/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/gui/public/output.webm
Normal file
BIN
apps/gui/public/output.webm
Normal file
Binary file not shown.
@@ -4,7 +4,13 @@ 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,
|
||||
AVAILABLE_PROVIDERS,
|
||||
ModelProviderMeta,
|
||||
} from "@omnia/llm";
|
||||
import { ScenarioSchema } from "@omnia/scenario";
|
||||
|
||||
function resolveScenarioPath(relative: string): string {
|
||||
const cwd = process.cwd();
|
||||
@@ -25,8 +31,7 @@ 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;
|
||||
@@ -123,10 +128,10 @@ export async function getConfigStatus(): Promise<{
|
||||
apiKeySet: boolean;
|
||||
apiKeyPreview: string;
|
||||
model: string;
|
||||
availableScenarios: { path: string; name: string }[];
|
||||
availableScenarios: { path: string; name: string; description: string }[];
|
||||
}> {
|
||||
const apiKey = process.env.GOOGLE_API_KEY;
|
||||
const scenarios: { path: string; name: string }[] = [];
|
||||
const scenarios: { path: string; name: string; description: string }[] = [];
|
||||
|
||||
const cwd = process.cwd();
|
||||
const candidates = [
|
||||
@@ -150,6 +155,7 @@ export async function getConfigStatus(): Promise<{
|
||||
scenarios.push({
|
||||
path: `content/demo/scenarios/${file}`,
|
||||
name: content.name || file,
|
||||
description: content.description || "",
|
||||
});
|
||||
} catch {
|
||||
/* skip invalid */
|
||||
@@ -167,8 +173,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();
|
||||
@@ -196,7 +201,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 }
|
||||
> {
|
||||
@@ -206,10 +213,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 {
|
||||
@@ -219,9 +228,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 };
|
||||
@@ -233,7 +242,9 @@ export async function deleteSimulation(simId: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProviderInstances(): Promise<ModelProviderInstance[]> {
|
||||
export async function listProviderInstances(): Promise<
|
||||
ModelProviderInstance[]
|
||||
> {
|
||||
return ProviderManager.list();
|
||||
}
|
||||
|
||||
@@ -245,7 +256,14 @@ export async function createProviderInstance(
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
): Promise<ModelProviderInstance> {
|
||||
return ProviderManager.create(name, providerName, apiKey, modelName, type, maxContext);
|
||||
return ProviderManager.create(
|
||||
name,
|
||||
providerName,
|
||||
apiKey,
|
||||
modelName,
|
||||
type,
|
||||
maxContext,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteProviderInstance(id: string): Promise<void> {
|
||||
@@ -265,7 +283,15 @@ export async function updateProviderInstance(
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
): Promise<void> {
|
||||
ProviderManager.update(id, name, providerName, apiKey, modelName, type, maxContext);
|
||||
ProviderManager.update(
|
||||
id,
|
||||
name,
|
||||
providerName,
|
||||
apiKey,
|
||||
modelName,
|
||||
type,
|
||||
maxContext,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProviderMappings(): Promise<Record<string, string>> {
|
||||
@@ -283,6 +309,47 @@ export async function getAvailableProviders(): Promise<ModelProviderMeta[]> {
|
||||
return AVAILABLE_PROVIDERS;
|
||||
}
|
||||
|
||||
export async function regenerateEmbeddings(newProviderInstanceId?: string): Promise<void> {
|
||||
export async function regenerateEmbeddings(
|
||||
newProviderInstanceId?: string,
|
||||
): Promise<void> {
|
||||
await simulationManager.regenerateAllEmbeddings(newProviderInstanceId);
|
||||
}
|
||||
|
||||
export async function saveScenario(
|
||||
scenario: unknown,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
try {
|
||||
const parsed = ScenarioSchema.parse(scenario);
|
||||
const cwd = process.cwd();
|
||||
const dir = path.resolve(cwd, "content/demo/scenarios");
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const filePath = path.join(dir, `${parsed.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), "utf-8");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadScenarioJson(
|
||||
scenarioPath: string,
|
||||
): Promise<{ ok: true; scenario: unknown } | { ok: false; error: string }> {
|
||||
try {
|
||||
const resolved = resolveScenarioPath(scenarioPath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
return { ok: false, error: `Scenario file not found: ${scenarioPath}` };
|
||||
}
|
||||
const content = JSON.parse(fs.readFileSync(resolved, "utf-8"));
|
||||
return { ok: true, scenario: content };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
766
apps/gui/src/app/builder/page.tsx
Normal file
766
apps/gui/src/app/builder/page.tsx
Normal file
@@ -0,0 +1,766 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
SidebarProvider,
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
} from "@/components/ui/menubar";
|
||||
import { getConfigStatus, loadScenarioJson, saveScenario } from "@/app/actions";
|
||||
import type { Scenario } from "@omnia/scenario";
|
||||
import {
|
||||
Save,
|
||||
FileJson,
|
||||
Globe,
|
||||
MapPin,
|
||||
Users,
|
||||
Info,
|
||||
Eye,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
|
||||
// Import refactored builder components
|
||||
import { MetadataTab } from "@/components/builder/MetadataTab";
|
||||
import { LocationsTab } from "@/components/builder/LocationsTab";
|
||||
import { EntitiesTab } from "@/components/builder/EntitiesTab";
|
||||
import { JsonTab } from "@/components/builder/JsonTab";
|
||||
import type {
|
||||
LocationData,
|
||||
EntityData,
|
||||
AttributeData,
|
||||
} from "@/components/builder/types";
|
||||
|
||||
const generateUUID = () => {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.crypto &&
|
||||
window.crypto.randomUUID
|
||||
) {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
export default function BuilderPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Load scenarios templates list
|
||||
const [availableScenarios, setAvailableScenarios] = useState<
|
||||
{ path: string; name: string; description: string }[]
|
||||
>([]);
|
||||
|
||||
// Tabs: "metadata", "locations", "entities", "json"
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"metadata" | "locations" | "entities" | "json"
|
||||
>("metadata");
|
||||
|
||||
// Form State
|
||||
const [scenarioId, setScenarioId] = useState("");
|
||||
const [name, setName] = useState("My Custom Scenario");
|
||||
const [description, setDescription] = useState(
|
||||
"A custom scenario template created via builder.",
|
||||
);
|
||||
const [startTime, setStartTime] = useState("2026-07-06T12:00:00.000Z");
|
||||
const [worldAttributes, setWorldAttributes] = useState<AttributeData[]>([]);
|
||||
const [locations, setLocations] = useState<LocationData[]>([]);
|
||||
const [entities, setEntities] = useState<EntityData[]>([]);
|
||||
|
||||
// Selected sub-items for active editing lists
|
||||
const [selectedLocIndex, setSelectedLocIndex] = useState(0);
|
||||
const [selectedEntIndex, setSelectedEntIndex] = useState(0);
|
||||
|
||||
// Status & Notification Banners
|
||||
const [statusMessage, setStatusMessage] = useState<{
|
||||
text: string;
|
||||
type: "success" | "error" | "info";
|
||||
} | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Initialize dynamic UUIDs client-side to prevent NextJS SSR hydration mismatch
|
||||
useEffect(() => {
|
||||
if (!scenarioId) {
|
||||
const uId = generateUUID();
|
||||
const locId = generateUUID();
|
||||
const entId = generateUUID();
|
||||
|
||||
setScenarioId(uId);
|
||||
setLocations([{ id: locId, attributes: [], connections: [] }]);
|
||||
setEntities([
|
||||
{
|
||||
id: entId,
|
||||
locationId: locId,
|
||||
attributes: [
|
||||
{
|
||||
name: "role",
|
||||
value: "adventurer",
|
||||
visibility: "PUBLIC",
|
||||
allowedEntities: [],
|
||||
},
|
||||
],
|
||||
aliases: {},
|
||||
initialMemories: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [scenarioId]);
|
||||
|
||||
// Fetch available templates on load
|
||||
useEffect(() => {
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const config = await getConfigStatus();
|
||||
setAvailableScenarios(config.availableScenarios);
|
||||
} catch (err) {
|
||||
console.error("Failed to load scenario list:", err);
|
||||
}
|
||||
}
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
// Set timeout to dismiss messages
|
||||
useEffect(() => {
|
||||
if (statusMessage) {
|
||||
const timer = setTimeout(() => {
|
||||
setStatusMessage(null);
|
||||
}, 6000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [statusMessage]);
|
||||
|
||||
// Populate helper lists
|
||||
const locationIds = useMemo(
|
||||
() => locations.map((l) => l.id).filter(Boolean),
|
||||
[locations],
|
||||
);
|
||||
const entityIds = useMemo(
|
||||
() => entities.map((e) => e.id).filter(Boolean),
|
||||
[entities],
|
||||
);
|
||||
|
||||
// Load selected template
|
||||
const handleLoadTemplate = async (path: string) => {
|
||||
if (!path) return;
|
||||
setStatusMessage({ text: "Loading template...", type: "info" });
|
||||
try {
|
||||
const res = await loadScenarioJson(path);
|
||||
if (!res.ok) {
|
||||
setStatusMessage({
|
||||
text: res.error || "Failed to load template.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const s = res.scenario as Scenario;
|
||||
if (!s) {
|
||||
setStatusMessage({
|
||||
text: "Scenario template was empty.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setScenarioId(s.id || "custom-scenario");
|
||||
setName(s.name || "Loaded Scenario");
|
||||
setDescription(s.description || "");
|
||||
setStartTime(s.startTime || "2026-07-06T12:00:00.000Z");
|
||||
|
||||
// World attributes
|
||||
const wAttrs = (s.world?.attributes || []).map((a) => ({
|
||||
name: a.name,
|
||||
value: a.value,
|
||||
visibility: a.visibility,
|
||||
allowedEntities: a.allowedEntities || [],
|
||||
}));
|
||||
setWorldAttributes(wAttrs);
|
||||
|
||||
// Locations
|
||||
const locs = (s.locations || []).map((l) => ({
|
||||
id: l.id,
|
||||
parentId: l.parentId || undefined,
|
||||
attributes: (l.attributes || []).map((a) => ({
|
||||
name: a.name,
|
||||
value: a.value,
|
||||
visibility: a.visibility,
|
||||
allowedEntities: a.allowedEntities || [],
|
||||
})),
|
||||
connections: (l.connections || []).map((c) => ({
|
||||
targetId: c.targetId,
|
||||
portalName: c.portalName,
|
||||
portalStateDescriptor: c.portalStateDescriptor,
|
||||
visionProp: c.visionProp,
|
||||
soundProp: c.soundProp,
|
||||
bidirectional: c.bidirectional ?? true,
|
||||
})),
|
||||
}));
|
||||
setLocations(
|
||||
locs.length > 0
|
||||
? locs
|
||||
: [{ id: generateUUID(), attributes: [], connections: [] }],
|
||||
);
|
||||
setSelectedLocIndex(0);
|
||||
|
||||
// Entities
|
||||
const ents = (s.entities || []).map((e) => ({
|
||||
id: e.id,
|
||||
locationId: e.locationId || undefined,
|
||||
attributes: (e.attributes || []).map((a) => ({
|
||||
name: a.name,
|
||||
value: a.value,
|
||||
visibility: a.visibility,
|
||||
allowedEntities: a.allowedEntities || [],
|
||||
})),
|
||||
aliases: e.aliases || {},
|
||||
initialMemories: (e.initialMemories || []).map((m) => ({
|
||||
id: m.id || generateUUID(),
|
||||
timestamp: m.timestamp || s.startTime,
|
||||
locationId: m.locationId || null,
|
||||
intent: {
|
||||
type: m.intent.type,
|
||||
originalText: m.intent.originalText,
|
||||
description: m.intent.description,
|
||||
selfDescription: m.intent.selfDescription,
|
||||
actorId: m.intent.actorId || e.id,
|
||||
targetIds: m.intent.targetIds || [],
|
||||
modifiers: m.intent.modifiers || [],
|
||||
},
|
||||
outcome: m.outcome
|
||||
? {
|
||||
isValid: m.outcome.isValid,
|
||||
reason: m.outcome.reason,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
}));
|
||||
setEntities(
|
||||
ents.length > 0
|
||||
? ents
|
||||
: [
|
||||
{
|
||||
id: generateUUID(),
|
||||
locationId: locs[0]?.id || generateUUID(),
|
||||
attributes: [],
|
||||
aliases: {},
|
||||
initialMemories: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
setSelectedEntIndex(0);
|
||||
|
||||
setStatusMessage({
|
||||
text: "Template loaded successfully!",
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
setStatusMessage({
|
||||
text: err instanceof Error ? err.message : String(err),
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Compile full scenario object
|
||||
const compiledScenario = useMemo(() => {
|
||||
return {
|
||||
id: scenarioId.trim(),
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
startTime: startTime.trim(),
|
||||
world:
|
||||
worldAttributes.length > 0
|
||||
? {
|
||||
attributes: worldAttributes.map((a) => ({
|
||||
name: a.name.trim(),
|
||||
value: a.value.trim(),
|
||||
visibility: a.visibility,
|
||||
...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0
|
||||
? { allowedEntities: a.allowedEntities }
|
||||
: {}),
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
locations: locations.map((l) => ({
|
||||
id: l.id.trim(),
|
||||
...(l.parentId ? { parentId: l.parentId } : {}),
|
||||
...(l.attributes.length > 0
|
||||
? {
|
||||
attributes: l.attributes.map((a) => ({
|
||||
name: a.name.trim(),
|
||||
value: a.value.trim(),
|
||||
visibility: a.visibility,
|
||||
...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0
|
||||
? { allowedEntities: a.allowedEntities }
|
||||
: {}),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(l.connections.length > 0
|
||||
? {
|
||||
connections: l.connections.map((c) => ({
|
||||
targetId: c.targetId,
|
||||
...(c.portalName ? { portalName: c.portalName.trim() } : {}),
|
||||
...(c.portalStateDescriptor
|
||||
? { portalStateDescriptor: c.portalStateDescriptor.trim() }
|
||||
: {}),
|
||||
visionProp: Number(c.visionProp),
|
||||
soundProp: Number(c.soundProp),
|
||||
bidirectional: !!c.bidirectional,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
entities: entities.map((e) => ({
|
||||
id: e.id.trim(),
|
||||
...(e.locationId ? { locationId: e.locationId } : {}),
|
||||
...(e.attributes.length > 0
|
||||
? {
|
||||
attributes: e.attributes.map((a) => ({
|
||||
name: a.name.trim(),
|
||||
value: a.value.trim(),
|
||||
visibility: a.visibility,
|
||||
...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0
|
||||
? { allowedEntities: a.allowedEntities }
|
||||
: {}),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(Object.keys(e.aliases).length > 0 ? { aliases: e.aliases } : {}),
|
||||
...(e.initialMemories.length > 0
|
||||
? {
|
||||
initialMemories: e.initialMemories.map((m) => ({
|
||||
id: m.id,
|
||||
timestamp: m.timestamp,
|
||||
locationId: m.locationId,
|
||||
intent: {
|
||||
type: m.intent.type,
|
||||
originalText: m.intent.originalText.trim(),
|
||||
description: m.intent.description.trim(),
|
||||
...(m.intent.selfDescription
|
||||
? { selfDescription: m.intent.selfDescription.trim() }
|
||||
: {}),
|
||||
actorId: m.intent.actorId,
|
||||
targetIds: m.intent.targetIds,
|
||||
...(m.intent.modifiers && m.intent.modifiers.length > 0
|
||||
? { modifiers: m.intent.modifiers }
|
||||
: []),
|
||||
},
|
||||
...(m.outcome
|
||||
? {
|
||||
outcome: {
|
||||
isValid: !!m.outcome.isValid,
|
||||
reason: m.outcome.reason.trim(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
}, [
|
||||
scenarioId,
|
||||
name,
|
||||
description,
|
||||
startTime,
|
||||
worldAttributes,
|
||||
locations,
|
||||
entities,
|
||||
]);
|
||||
|
||||
// Save scenario to server
|
||||
const handleSaveToServer = async () => {
|
||||
if (!scenarioId.trim()) {
|
||||
setStatusMessage({
|
||||
text: "Scenario Template ID is required to save.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
setStatusMessage({ text: "Saving scenario file...", type: "info" });
|
||||
try {
|
||||
const res = await saveScenario(compiledScenario);
|
||||
if (res.ok) {
|
||||
setStatusMessage({
|
||||
text: `Scenario template saved as ${scenarioId}.json successfully!`,
|
||||
type: "success",
|
||||
});
|
||||
// Refresh template list
|
||||
const config = await getConfigStatus();
|
||||
setAvailableScenarios(config.availableScenarios);
|
||||
} else {
|
||||
setStatusMessage({
|
||||
text: res.error || "Failed to save scenario.",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setStatusMessage({
|
||||
text: err instanceof Error ? err.message : String(err),
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Download scenario file directly
|
||||
const handleDownloadJson = () => {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(compiledScenario, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${scenarioId || "scenario"}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
setStatusMessage({ text: "JSON download initiated.", type: "success" });
|
||||
} catch {
|
||||
setStatusMessage({ text: "Download failed.", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetScenario = () => {
|
||||
setScenarioId("");
|
||||
setName("My Custom Scenario");
|
||||
setDescription("A custom scenario template created via builder.");
|
||||
setStartTime("2026-07-06T12:00:00.000Z");
|
||||
setWorldAttributes([]);
|
||||
setSelectedLocIndex(0);
|
||||
setSelectedEntIndex(0);
|
||||
setStatusMessage({ text: "Scenario reset successfully.", type: "success" });
|
||||
};
|
||||
|
||||
const handleAddLocation = () => {
|
||||
const newId = generateUUID();
|
||||
setLocations([
|
||||
...locations,
|
||||
{ id: newId, attributes: [], connections: [] },
|
||||
]);
|
||||
setSelectedLocIndex(locations.length);
|
||||
setActiveTab("locations");
|
||||
};
|
||||
|
||||
const handleAddEntity = () => {
|
||||
const newId = generateUUID();
|
||||
setEntities([
|
||||
...entities,
|
||||
{
|
||||
id: newId,
|
||||
locationId: locationIds[0] || "",
|
||||
attributes: [],
|
||||
aliases: {},
|
||||
initialMemories: [],
|
||||
isAgent: true,
|
||||
},
|
||||
]);
|
||||
setSelectedEntIndex(entities.length);
|
||||
setActiveTab("entities");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 w-full bg-background overflow-hidden">
|
||||
{/* Save Status Banner */}
|
||||
{statusMessage && (
|
||||
<div
|
||||
className={`fixed top-14 right-4 z-50 max-w-sm border p-4 shadow-lg animate-fade-in ${
|
||||
statusMessage.type === "success"
|
||||
? "bg-emerald-950/80 border-emerald-500 text-emerald-300"
|
||||
: statusMessage.type === "error"
|
||||
? "bg-destructive/10 border-destructive text-destructive"
|
||||
: "bg-secondary/90 border-border text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<div className="text-xs">{statusMessage.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menubar spanning full page width right below navbar */}
|
||||
<div className="w-full border-b border-border/20 bg-card py-1.5 px-4 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-head text-primary tracking-wider font-bold flex items-center gap-1.5">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
</span>
|
||||
<Menubar className="border-none shadow-none bg-transparent h-7 p-0">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="cursor-pointer">File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger className="cursor-pointer">
|
||||
Load Template
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
{availableScenarios.length === 0 ? (
|
||||
<MenubarItem disabled>No templates</MenubarItem>
|
||||
) : (
|
||||
availableScenarios.map((sc) => (
|
||||
<MenubarItem
|
||||
key={sc.path}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
handleLoadTemplate(sc.path);
|
||||
}}
|
||||
>
|
||||
{sc.name}
|
||||
</MenubarItem>
|
||||
))
|
||||
)}
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleSaveToServer}
|
||||
disabled={isSubmitting || !scenarioId.trim()}
|
||||
>
|
||||
<Save className="size-4 mr-2 inline" /> Save to Server
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleDownloadJson}
|
||||
>
|
||||
<FileJson className="size-4 mr-2 inline" /> Export JSON
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="cursor-pointer">Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleResetScenario}
|
||||
>
|
||||
Reset Scenario
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleAddLocation}
|
||||
>
|
||||
Add New Location
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleAddEntity}
|
||||
>
|
||||
Add New Entity
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="cursor-pointer">View</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarRadioGroup
|
||||
value={activeTab}
|
||||
onValueChange={(val) => setActiveTab(val as typeof activeTab)}
|
||||
>
|
||||
<MenubarRadioItem value="metadata" className="cursor-pointer">
|
||||
World Metadata
|
||||
</MenubarRadioItem>
|
||||
<MenubarRadioItem
|
||||
value="locations"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Locations
|
||||
</MenubarRadioItem>
|
||||
<MenubarRadioItem value="entities" className="cursor-pointer">
|
||||
Entities
|
||||
</MenubarRadioItem>
|
||||
<MenubarRadioItem value="json" className="cursor-pointer">
|
||||
Live JSON Preview
|
||||
</MenubarRadioItem>
|
||||
</MenubarRadioGroup>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
{scenarioId ? `ID: ${scenarioId}` : "Unsaved Scenario"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SidebarProvider className="flex-1 min-h-0">
|
||||
<div className="flex flex-1 min-h-0 w-full overflow-hidden">
|
||||
{/* Viewport-level Vertical Sidebar on the Left Side */}
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="h-full border-r border-border/30 bg-card shrink-0"
|
||||
>
|
||||
<SidebarContent className="flex flex-col justify-between h-full bg-card p-6">
|
||||
<div className="flex flex-col gap-2 font-head">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground block mb-2">
|
||||
Configuration
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("metadata")}
|
||||
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
|
||||
activeTab === "metadata"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
|
||||
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
<span>World Metadata</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("locations")}
|
||||
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
|
||||
activeTab === "locations"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
|
||||
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<MapPin className="size-3.5" />
|
||||
<span>Locations</span>
|
||||
<span className="ml-auto text-[10px] font-mono border border-muted-foreground/20 bg-muted/10 px-1 rounded">
|
||||
{locations.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("entities")}
|
||||
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
|
||||
activeTab === "entities"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
|
||||
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Users className="size-3.5" />
|
||||
<span>Entities</span>
|
||||
<span className="ml-auto text-[10px] font-mono border border-muted-foreground/20 bg-muted/10 px-1 rounded">
|
||||
{entities.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("json")}
|
||||
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
|
||||
activeTab === "json"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
|
||||
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
<span>Live JSON Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer link */}
|
||||
<div className="border-t border-border/10 pt-3 flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="w-full py-1 text-center hover:text-foreground text-primary font-bold uppercase transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
{/* Main Centered Content Pane on the Right */}
|
||||
<main className="flex-1 overflow-y-auto px-10 py-8 min-h-0 flex flex-col">
|
||||
<div className="mx-auto max-w-[1200px] w-full flex-1 flex flex-col min-h-0 gap-6">
|
||||
{/* Header block with Page Name */}
|
||||
<div className="shrink-0">
|
||||
<h1 className="text-headline-md text-primary flex items-center gap-2 font-head">
|
||||
Scenario Builder
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Active configuration tab form */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{/* TAB 1: World Metadata & Attributes */}
|
||||
{activeTab === "metadata" && (
|
||||
<MetadataTab
|
||||
scenarioId={scenarioId}
|
||||
setScenarioId={setScenarioId}
|
||||
name={name}
|
||||
setName={setName}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
startTime={startTime}
|
||||
setStartTime={setStartTime}
|
||||
worldAttributes={worldAttributes}
|
||||
setWorldAttributes={setWorldAttributes}
|
||||
entityIds={entityIds}
|
||||
entities={entities}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TAB 2: Locations & Spatial connections */}
|
||||
{activeTab === "locations" && (
|
||||
<LocationsTab
|
||||
locations={locations}
|
||||
setLocations={setLocations}
|
||||
entities={entities}
|
||||
locationIds={locationIds}
|
||||
entityIds={entityIds}
|
||||
selectedLocIndex={selectedLocIndex}
|
||||
setSelectedLocIndex={setSelectedLocIndex}
|
||||
generateUUID={generateUUID}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TAB 3: Entities */}
|
||||
{activeTab === "entities" && (
|
||||
<EntitiesTab
|
||||
entities={entities}
|
||||
setEntities={setEntities}
|
||||
locations={locations}
|
||||
locationIds={locationIds}
|
||||
entityIds={entityIds}
|
||||
selectedEntIndex={selectedEntIndex}
|
||||
setSelectedEntIndex={setSelectedEntIndex}
|
||||
startTime={startTime}
|
||||
generateUUID={generateUUID}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TAB 4: Live JSON Preview */}
|
||||
{activeTab === "json" && (
|
||||
<JsonTab
|
||||
compiledScenario={compiledScenario}
|
||||
onCopySuccess={() =>
|
||||
setStatusMessage({
|
||||
text: "JSON copied to clipboard!",
|
||||
type: "success",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,532 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
getConfigStatus,
|
||||
listProviderInstances,
|
||||
createProviderInstance,
|
||||
deleteProviderInstance,
|
||||
setActiveProviderInstance,
|
||||
getProviderMappings,
|
||||
setProviderMapping,
|
||||
updateProviderInstance,
|
||||
getAvailableProviders,
|
||||
regenerateEmbeddings,
|
||||
} from "@/app/play/actions";
|
||||
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
interface ConfigStatus {
|
||||
apiKeySet: boolean;
|
||||
apiKeyPreview: string;
|
||||
model: string;
|
||||
availableScenarios: { path: string; name: string }[];
|
||||
}
|
||||
import { ConfigView } from "@/components/config/ConfigView";
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [config, setConfig] = useState<ConfigStatus | null>(null);
|
||||
const [instances, setInstances] = useState<ModelProviderInstance[]>([]);
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({});
|
||||
const [availableProviders, setAvailableProviders] = useState<ModelProviderMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editProvider, setEditProvider] = useState("google-genai");
|
||||
const [editKey, setEditKey] = useState("");
|
||||
const [editModel, setEditModel] = useState("gemini-2.5-flash");
|
||||
const [editIsActive, setEditIsActive] = useState(false);
|
||||
const [editType, setEditType] = useState<"generative" | "embedding">("generative");
|
||||
const [editMaxContext, setEditMaxContext] = useState<number>(32768);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedInstanceId === null) {
|
||||
setEditName("");
|
||||
setEditProvider("google-genai");
|
||||
setEditKey("");
|
||||
setEditModel("gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
setEditType("generative");
|
||||
setEditMaxContext(32768);
|
||||
} else if (selectedInstanceId === "new") {
|
||||
setEditName("");
|
||||
const defaultProvider = "google-genai";
|
||||
setEditProvider(defaultProvider);
|
||||
setEditKey("");
|
||||
setEditType("generative");
|
||||
const pMeta = availableProviders.find((p) => p.id === defaultProvider);
|
||||
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
setEditMaxContext(32768);
|
||||
} else {
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst) {
|
||||
setEditName(inst.name);
|
||||
setEditProvider(inst.providerName);
|
||||
setEditKey("");
|
||||
setEditType(inst.type || "generative");
|
||||
const pMeta = availableProviders.find((p) => p.id === inst.providerName);
|
||||
setEditModel(inst.modelName || (inst.type === "embedding" ? pMeta?.defaultEmbeddingModel : pMeta?.defaultModel) || "gemini-2.5-flash");
|
||||
setEditIsActive(inst.isActive);
|
||||
setEditMaxContext(inst.maxContext !== undefined && inst.maxContext !== null ? inst.maxContext : 32768);
|
||||
}
|
||||
}
|
||||
}, [selectedInstanceId, instances, availableProviders]);
|
||||
|
||||
const handleProviderChange = (providerId: string) => {
|
||||
setEditProvider(providerId);
|
||||
const pMeta = availableProviders.find((p) => p.id === providerId);
|
||||
setEditModel(editType === "embedding" ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "");
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: "generative" | "embedding") => {
|
||||
setEditType(type);
|
||||
const pMeta = availableProviders.find((p) => p.id === editProvider);
|
||||
setEditModel(type === "embedding" ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "");
|
||||
};
|
||||
|
||||
const loadInstances = useCallback(async () => {
|
||||
const list = await listProviderInstances();
|
||||
setInstances(list);
|
||||
}, []);
|
||||
|
||||
const loadMappings = useCallback(async () => {
|
||||
const maps = await getProviderMappings();
|
||||
setMappings(maps);
|
||||
}, []);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const status = await getConfigStatus();
|
||||
setConfig(status);
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
const provs = await getAvailableProviders();
|
||||
setAvailableProviders(provs);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadInstances, loadMappings]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editName.trim()) {
|
||||
setError("Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
let shouldRegenerate = false;
|
||||
let targetInstanceId = selectedInstanceId;
|
||||
|
||||
if (selectedInstanceId === "new") {
|
||||
if (!editKey.trim()) {
|
||||
setError("API Key is required for new instances.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const created = await createProviderInstance(editName, editProvider, editKey, editModel || undefined, editType, editType === "generative" ? editMaxContext : 0);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(created.id);
|
||||
}
|
||||
targetInstanceId = created.id;
|
||||
setSelectedInstanceId(created.id);
|
||||
} else {
|
||||
if (!selectedInstanceId) return;
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst && inst.type === "embedding") {
|
||||
const isMapped = mappings["embeddings"] === selectedInstanceId;
|
||||
const isActive = inst.isActive && !mappings["embeddings"];
|
||||
if (isMapped || isActive) {
|
||||
const hasChanged = inst.providerName !== editProvider || inst.modelName !== editModel;
|
||||
if (hasChanged) {
|
||||
const confirmChange = window.confirm(
|
||||
"You have changed the configuration of the active embedding provider. This will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?"
|
||||
);
|
||||
if (!confirmChange) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
shouldRegenerate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined, editModel || undefined, editType, editType === "generative" ? editMaxContext : 0);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(selectedInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
|
||||
if (shouldRegenerate && targetInstanceId && targetInstanceId !== "new") {
|
||||
await regenerateEmbeddings(targetInstanceId);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
|
||||
if (!confirm("Are you sure you want to delete this provider instance?")) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
await deleteProviderInstance(selectedInstanceId);
|
||||
setSelectedInstanceId(null);
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMapping = async (task: string, providerInstanceId: string) => {
|
||||
if (task === "embeddings" && mappings[task] !== providerInstanceId) {
|
||||
const confirmChange = window.confirm(
|
||||
"Changing the embeddings provider will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?"
|
||||
);
|
||||
if (!confirmChange) return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await setProviderMapping(task, providerInstanceId);
|
||||
if (task === "embeddings") {
|
||||
await regenerateEmbeddings(providerInstanceId);
|
||||
}
|
||||
await loadMappings();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[800px] px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl">Configuration</h1>
|
||||
|
||||
{config === null && loading && <p>Loading configuration...</p>}
|
||||
{error && (
|
||||
<div className="mb-4 rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<div className={loading ? "opacity-60 pointer-events-none transition-opacity duration-200" : "transition-opacity duration-200"}>
|
||||
<section className="mb-8 pb-6">
|
||||
<h2 className="mb-3 text-lg">LLM Provider Instances</h2>
|
||||
<div className="mt-4 grid min-h-[400px] grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white md:grid-cols-[30%_70%]">
|
||||
{/* 30% area */}
|
||||
<div className="flex flex-col border-r border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-4 py-4">
|
||||
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">
|
||||
Instances
|
||||
</h3>
|
||||
<Button
|
||||
onClick={() => setSelectedInstanceId("new")}
|
||||
size="sm"
|
||||
className="bg-emerald-500 text-white hover:bg-emerald-600"
|
||||
>
|
||||
+ Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{instances.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-gray-400">
|
||||
No instances configured
|
||||
</div>
|
||||
) : (
|
||||
instances.map((inst) => (
|
||||
<div
|
||||
key={inst.id}
|
||||
onClick={() => setSelectedInstanceId(inst.id)}
|
||||
className={`cursor-pointer border-b border-gray-200 border-l-[3px] px-4 py-4 transition-all hover:bg-gray-100 ${
|
||||
selectedInstanceId === inst.id
|
||||
? "border-l-blue-500 bg-blue-50"
|
||||
: "border-l-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium text-[#111]">
|
||||
{inst.name}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{inst.providerName} ({inst.type || "generative"})</span>
|
||||
{inst.isActive && (
|
||||
<Badge className="bg-green-100 text-green-700 hover:bg-green-100 border-green-200">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 70% area */}
|
||||
<div className="flex flex-col bg-white">
|
||||
{selectedInstanceId === null ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center text-sm text-gray-400">
|
||||
Press + to add or select an existing Instance to edit
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="flex h-full flex-col justify-between">
|
||||
<div className="flex flex-1 flex-col gap-5 p-6">
|
||||
<h3 className="m-0 mb-2 text-lg font-semibold text-[#111]">
|
||||
{selectedInstanceId === "new"
|
||||
? "Create New Provider Instance"
|
||||
: `Configure: ${editName}`}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formName">Friendly Name</Label>
|
||||
<Input
|
||||
id="formName"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="e.g. Gemini - Production"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Instance Type</Label>
|
||||
<Select value={editType} onValueChange={(v) => handleTypeChange(v as "generative" | "embedding")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="generative">Generative (Chat / Text Completion)</SelectItem>
|
||||
<SelectItem value="embedding">Embedding (Vector generation)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Provider Type</Label>
|
||||
<Select value={editProvider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{editProvider && availableProviders.length > 0 && (
|
||||
<span className="mt-1 block rounded border border-2 bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
{availableProviders.find((p) => p.id === editProvider)?.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formModel">Model Name</Label>
|
||||
<Input
|
||||
id="formModel"
|
||||
value={editModel}
|
||||
onChange={(e) => setEditModel(e.target.value)}
|
||||
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editType === "generative" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formMaxContext">Max Context Length (Tokens, 0 for infinite)</Label>
|
||||
<Input
|
||||
id="formMaxContext"
|
||||
type="number"
|
||||
value={editMaxContext}
|
||||
onChange={(e) => setEditMaxContext(parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
placeholder="e.g. 32768"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex flex-row items-center gap-2">
|
||||
<Checkbox
|
||||
id="formActive"
|
||||
checked={editIsActive}
|
||||
onCheckedChange={(v) => setEditIsActive(v === true)}
|
||||
/>
|
||||
<Label htmlFor="formActive" className="cursor-pointer">
|
||||
Set as Active Instance
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t-2 bg-muted/50 px-6 py-4">
|
||||
<div>
|
||||
{selectedInstanceId !== "new" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8 pb-6">
|
||||
<h2 className="mb-3 text-lg">Task Provider Routing</h2>
|
||||
<p className="my-4 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||
Configure which LLM Provider Key Instance should handle each
|
||||
specific simulation task. Mappings default to the currently{" "}
|
||||
<strong>Active</strong> instance if not specified.
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{[
|
||||
{ key: "actor-prose", label: "Actor Prose Generation", desc: "Generates roleplay/narrative prose for Non-Player Characters.", type: "generative" },
|
||||
{ key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules.", type: "generative" },
|
||||
{ key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC).", type: "generative" },
|
||||
{ key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock.", type: "generative" },
|
||||
{ key: "handoff", label: "Memory Handoff Engine", desc: "Promotes entities' working memories to the long-term Ledger via LLM summarization and pruning.", type: "generative" },
|
||||
{ key: "embeddings", label: "Text Embeddings Generator", desc: "Generates vector embeddings for long-term memory retrieval.", type: "embedding" },
|
||||
].map((task) => (
|
||||
<div
|
||||
key={task.key}
|
||||
className="flex flex-col justify-between gap-3 rounded-lg border-2 bg-card p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<strong className="text-sm text-foreground">
|
||||
{task.label}
|
||||
</strong>
|
||||
<span className="mt-0.5 text-muted-foreground">{task.desc}</span>
|
||||
</div>
|
||||
<select
|
||||
value={mappings[task.key] || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateMapping(task.key, e.target.value)
|
||||
}
|
||||
className="w-full rounded border-2 bg-input px-2 py-1.5 text-xs shadow-sm outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
<option value="">-- Use Active Key (Default) --</option>
|
||||
{instances
|
||||
.filter((inst) => (inst.type || "generative") === task.type)
|
||||
.map((inst) => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} ({inst.providerName}){inst.isActive ? " [Active]" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8 pb-6">
|
||||
<h2 className="mb-3 text-lg">Available Scenarios</h2>
|
||||
{config.availableScenarios.length === 0 ? (
|
||||
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
|
||||
No scenarios found in{" "}
|
||||
<code className="font-mono text-xs">
|
||||
content/demo/scenarios/
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Path</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{config.availableScenarios.map((s) => (
|
||||
<TableRow key={s.path}>
|
||||
<TableCell>{s.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className="font-mono text-xs text-blue-600">
|
||||
{s.path}
|
||||
</code>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <ConfigView />;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,21 @@
|
||||
@custom-variant data-horizontal (&[data-orientation="horizontal"]);
|
||||
@custom-variant data-vertical (&[data-orientation="vertical"]);
|
||||
@custom-variant data-popup-open (&[data-state="open"]);
|
||||
@custom-variant data-highlighted (&[data-highlighted]);
|
||||
|
||||
@theme inline {
|
||||
--font-head: var(--font-head);
|
||||
--font-sans: var(--font-sans);
|
||||
--radius: var(--radius);
|
||||
--font-mono: var(--font-mono);
|
||||
|
||||
--radius: 0px;
|
||||
--radius-xs: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-md: 0px;
|
||||
--radius-lg: 0px;
|
||||
--radius-xl: 0px;
|
||||
--radius-2xl: 0px;
|
||||
--radius-3xl: 0px;
|
||||
|
||||
--shadow-xs: 1px 1px 0 0 var(--border);
|
||||
--shadow-sm: 2px 2px 0 0 var(--border);
|
||||
@@ -42,51 +52,149 @@
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-dim: var(--surface-dim);
|
||||
--color-surface-bright: var(--surface-bright);
|
||||
--color-surface-container-lowest: var(--surface-container-lowest);
|
||||
--color-surface-container-low: var(--surface-container-low);
|
||||
--color-surface-container: var(--surface-container);
|
||||
--color-surface-container-high: var(--surface-container-high);
|
||||
--color-surface-container-highest: var(--surface-container-highest);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0;
|
||||
--background: #fff7e8;
|
||||
--foreground: #000;
|
||||
--card: #fff;
|
||||
--card-foreground: #000;
|
||||
--popover: #fff;
|
||||
--popover-foreground: #000;
|
||||
--primary: #ffdc58;
|
||||
--primary-hover: #ffd12e;
|
||||
--primary-foreground: #000;
|
||||
--secondary: #000;
|
||||
--secondary-foreground: #fff;
|
||||
--muted: #efe7d6;
|
||||
--muted-foreground: #6b6355;
|
||||
--accent: #ffe7a3;
|
||||
--accent-foreground: #000;
|
||||
--destructive: #e63946;
|
||||
--destructive-foreground: #fff;
|
||||
--border: #000;
|
||||
--input: #fff;
|
||||
--ring: #000;
|
||||
--background: #f4e9dc;
|
||||
--foreground: #201a16;
|
||||
--card: #fcf3e8;
|
||||
--card-foreground: #201a16;
|
||||
--popover: #fff8f5;
|
||||
--popover-foreground: #201a16;
|
||||
--primary: #e58e58;
|
||||
--primary-hover: #d47e48;
|
||||
--primary-foreground: #201a16;
|
||||
--secondary: #f5e6d3;
|
||||
--secondary-foreground: #201a16;
|
||||
--muted: #efe0cd;
|
||||
--muted-foreground: #6d6354;
|
||||
--accent: #ffd966;
|
||||
--accent-foreground: #201a16;
|
||||
--destructive: #ba1a1a;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #5c544e;
|
||||
--input: #fff8f5;
|
||||
--ring: #e58e58;
|
||||
|
||||
--sidebar: #fcf3e8;
|
||||
--sidebar-foreground: #201a16;
|
||||
--sidebar-primary: #e58e58;
|
||||
--sidebar-primary-foreground: #201a16;
|
||||
--sidebar-accent: #ffd966;
|
||||
--sidebar-accent-foreground: #201a16;
|
||||
--sidebar-border: #5c544e;
|
||||
--sidebar-ring: #e58e58;
|
||||
|
||||
--surface: #fff8f5;
|
||||
--surface-dim: #e4d8d0;
|
||||
--surface-bright: #fff8f5;
|
||||
--surface-container-lowest: #ffffff;
|
||||
--surface-container-low: #fef1e9;
|
||||
--surface-container: #f8ece3;
|
||||
--surface-container-high: #f2e6de;
|
||||
--surface-container-highest: #ece0d8;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #1a1815;
|
||||
--foreground: #f5f0e6;
|
||||
--card: #262320;
|
||||
--card-foreground: #f5f0e6;
|
||||
--popover: #262320;
|
||||
--popover-foreground: #f5f0e6;
|
||||
--primary: #ffdc58;
|
||||
--primary-hover: #ffd12e;
|
||||
--primary-foreground: #000;
|
||||
--secondary: #3a352f;
|
||||
--secondary-foreground: #f5f0e6;
|
||||
--muted: #2e2a24;
|
||||
--muted-foreground: #b3ac9e;
|
||||
--accent: #38342b;
|
||||
--accent-foreground: #f5f0e6;
|
||||
--destructive: #ff6b6b;
|
||||
--destructive-foreground: #1a1815;
|
||||
--border: #000;
|
||||
--input: #262320;
|
||||
--ring: #ffdc58;
|
||||
--background: #181310;
|
||||
--foreground: #fff8f5;
|
||||
--card: #362f2a;
|
||||
--card-foreground: #fff8f5;
|
||||
--popover: #201a16;
|
||||
--popover-foreground: #fff8f5;
|
||||
--primary: #e58e58;
|
||||
--primary-hover: #ffb68c;
|
||||
--primary-foreground: #201a16;
|
||||
--secondary: #54433a;
|
||||
--secondary-foreground: #fbeee6;
|
||||
--muted: #362f2a;
|
||||
--muted-foreground: #d9c2b6;
|
||||
--accent: #c1a032;
|
||||
--accent-foreground: #201a16;
|
||||
--destructive: #ffdad6;
|
||||
--destructive-foreground: #93000a;
|
||||
--border: #877369;
|
||||
--input: #362f2a;
|
||||
--ring: #e58e58;
|
||||
|
||||
--sidebar: #201a16;
|
||||
--sidebar-foreground: #fff8f5;
|
||||
--sidebar-primary: #e58e58;
|
||||
--sidebar-primary-foreground: #201a16;
|
||||
--sidebar-accent: #c1a032;
|
||||
--sidebar-accent-foreground: #201a16;
|
||||
--sidebar-border: #877369;
|
||||
--sidebar-ring: #e58e58;
|
||||
|
||||
--surface: #201a16;
|
||||
--surface-dim: #362f2a;
|
||||
--surface-bright: #201a16;
|
||||
--surface-container-lowest: #201a16;
|
||||
--surface-container-low: #362f2a;
|
||||
--surface-container: #362f2a;
|
||||
--surface-container-high: #54433a;
|
||||
--surface-container-highest: #54433a;
|
||||
}
|
||||
|
||||
@utility text-headline-lg {
|
||||
font-family: var(--font-head);
|
||||
font-size: 48px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@utility text-headline-md {
|
||||
font-family: var(--font-head);
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@utility text-headline-sm {
|
||||
font-family: var(--font-head);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@utility text-body-lg {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@utility text-body-md {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@utility text-label-sm {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,96 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Archivo_Black, Space_Grotesk } from "next/font/google";
|
||||
import { NavBar } from "@/components/nav/NavBar";
|
||||
import "./globals.css";
|
||||
"use client";
|
||||
|
||||
const archivoBlack = Archivo_Black({
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { Jersey_25, JetBrains_Mono, Space_Mono } from "next/font/google";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import "./globals.css";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
|
||||
const jersey25 = Jersey_25({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-head",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "Omnia",
|
||||
description: "Omnia Narrative Simulation Engine",
|
||||
};
|
||||
const spaceMono = Space_Mono({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "700"],
|
||||
variable: "--font-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/builder", label: "Builder" },
|
||||
{ href: "/config", label: "Config" },
|
||||
];
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Omnia";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${archivoBlack.variable} ${spaceGrotesk.variable} min-h-dvh bg-background text-foreground font-sans`}>
|
||||
<NavBar />
|
||||
{children}
|
||||
<body
|
||||
className={`${jersey25.variable} ${jetbrainsMono.variable} ${spaceMono.variable} flex flex-col h-dvh overflow-hidden bg-background text-foreground font-sans`}
|
||||
>
|
||||
{!pathname?.startsWith("/play") && (
|
||||
<video
|
||||
className="fixed inset-0 w-full h-full object-cover pointer-events-none"
|
||||
src="/output.webm"
|
||||
poster="/background.png"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
<nav className="border-b border-dotted border-border/20 bg-secondary/30 shrink-0">
|
||||
<div className="mx-auto max-w-[800px] px-10 py-3 flex items-center justify-center">
|
||||
<NavigationMenu viewport={false}>
|
||||
<NavigationMenuList>
|
||||
{links.map((link) => {
|
||||
const isActive =
|
||||
pathname === link.href ||
|
||||
(link.href !== "/" && pathname?.startsWith(link.href));
|
||||
return (
|
||||
<NavigationMenuItem key={link.href}>
|
||||
<NavigationMenuLink asChild active={isActive}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className={`text-foreground no-underline font-medium text-sm p-2 transition-all outline-none ${
|
||||
isActive
|
||||
? "bg-primary/15 text-primary"
|
||||
: "hover:bg-secondary hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
})}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="flex-1 flex flex-col min-h-0">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { DashboardView } from "@/components/play/DashboardView";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="mx-auto max-w-[800px] px-4 py-12">
|
||||
<h1 className="mb-2 text-3xl">Omnia GUI</h1>
|
||||
<p className="mb-8 text-muted-foreground">
|
||||
Configuration and gameplay interface for the Omnia simulation engine.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link href="/play" className="flex-1 no-underline">
|
||||
<Card className="transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]">
|
||||
<CardHeader>
|
||||
<CardTitle>Play</CardTitle>
|
||||
<CardDescription>
|
||||
Start a simulation and interact with NPCs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/config" className="flex-1 no-underline">
|
||||
<Card className="transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]">
|
||||
<CardHeader>
|
||||
<CardTitle>Config</CardTitle>
|
||||
<CardDescription>
|
||||
Check environment, API keys, and available scenarios
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <DashboardView />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { PlayView } from "@/components/play/PlayView";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export default function PlayPage() {
|
||||
return <PlayView />;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin text-primary">Loading...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PlayView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
167
apps/gui/src/components/builder/AttributeEditor.tsx
Normal file
167
apps/gui/src/components/builder/AttributeEditor.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { AttributeData, EntityData } from "./types";
|
||||
import { getEntityDisplayNameById } from "./utils";
|
||||
|
||||
interface AttributeEditorProps {
|
||||
title?: string;
|
||||
attributes: AttributeData[];
|
||||
onChange: (attrs: AttributeData[]) => void;
|
||||
onAdd: () => void;
|
||||
entityIds: string[];
|
||||
entities?: EntityData[];
|
||||
}
|
||||
|
||||
export function AttributeEditor({
|
||||
title = "Attributes",
|
||||
attributes,
|
||||
onChange,
|
||||
onAdd,
|
||||
entityIds,
|
||||
entities,
|
||||
}: AttributeEditorProps) {
|
||||
const handleAttrChange = <K extends keyof AttributeData>(
|
||||
index: number,
|
||||
key: K,
|
||||
val: AttributeData[K],
|
||||
) => {
|
||||
const copy = [...attributes];
|
||||
copy[index] = { ...copy[index], [key]: val };
|
||||
onChange(copy);
|
||||
};
|
||||
|
||||
const handleToggleEntityAccess = (index: number, entId: string) => {
|
||||
const copy = [...attributes];
|
||||
const allowed = copy[index].allowedEntities || [];
|
||||
if (allowed.includes(entId)) {
|
||||
copy[index].allowedEntities = allowed.filter((id) => id !== entId);
|
||||
} else {
|
||||
copy[index].allowedEntities = [...allowed, entId];
|
||||
}
|
||||
onChange(copy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md font-mono text-foreground font-bold">
|
||||
{title}
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onAdd}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Attribute
|
||||
</Button>
|
||||
</div>
|
||||
{attributes.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No attributes defined yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{attributes.map((attr, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-border/20 bg-secondary/10 p-3 rounded space-y-3"
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
placeholder="Name (e.g. role)"
|
||||
value={attr.name}
|
||||
onChange={(e) =>
|
||||
handleAttrChange(index, "name", e.target.value)
|
||||
}
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Value (e.g. merchant)"
|
||||
value={attr.value}
|
||||
onChange={(e) =>
|
||||
handleAttrChange(index, "value", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 cursor-pointer"
|
||||
onClick={() =>
|
||||
onChange(attributes.filter((_, i) => i !== index))
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-muted-foreground cursor-pointer flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={attr.visibility === "PUBLIC"}
|
||||
onCheckedChange={(checked) =>
|
||||
handleAttrChange(
|
||||
index,
|
||||
"visibility",
|
||||
checked ? "PUBLIC" : "PRIVATE",
|
||||
)
|
||||
}
|
||||
/>
|
||||
Publicly Visible
|
||||
</Label>
|
||||
</div>
|
||||
{attr.visibility === "PRIVATE" && (
|
||||
<div className="flex-1 border-l border-border/25 pl-4">
|
||||
<span className="text-muted-foreground font-semibold block mb-1">
|
||||
Visible to Entities:
|
||||
</span>
|
||||
{entityIds.length === 0 ? (
|
||||
<span className="text-[10px] italic text-muted-foreground">
|
||||
Add entities first to grant private access
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entityIds.map((entId) => {
|
||||
const isAllowed =
|
||||
attr.allowedEntities?.includes(entId);
|
||||
return (
|
||||
<button
|
||||
key={entId}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleToggleEntityAccess(index, entId)
|
||||
}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-mono border transition-all cursor-pointer ${
|
||||
isAllowed
|
||||
? "bg-primary/20 border-primary text-primary font-bold"
|
||||
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{entities
|
||||
? getEntityDisplayNameById(entId, entities)
|
||||
: entId}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
563
apps/gui/src/components/builder/EntitiesTab.tsx
Normal file
563
apps/gui/src/components/builder/EntitiesTab.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { AttributeEditor } from "./AttributeEditor";
|
||||
import { Plus, Trash2, ChevronRight } from "lucide-react";
|
||||
import type { EntityData, MemoryData, LocationData } from "./types";
|
||||
import {
|
||||
getEntityDisplayName,
|
||||
getEntityDisplayNameById,
|
||||
getLocationDisplayNameById,
|
||||
} from "./utils";
|
||||
|
||||
interface EntitiesTabProps {
|
||||
entities: EntityData[];
|
||||
setEntities: (ents: EntityData[]) => void;
|
||||
locations: LocationData[];
|
||||
locationIds: string[];
|
||||
entityIds: string[];
|
||||
selectedEntIndex: number;
|
||||
setSelectedEntIndex: (idx: number) => void;
|
||||
startTime: string;
|
||||
generateUUID: () => string;
|
||||
}
|
||||
|
||||
export function EntitiesTab({
|
||||
entities,
|
||||
setEntities,
|
||||
locations,
|
||||
locationIds,
|
||||
entityIds,
|
||||
selectedEntIndex,
|
||||
setSelectedEntIndex,
|
||||
startTime,
|
||||
generateUUID,
|
||||
}: EntitiesTabProps) {
|
||||
const selectedEnt = entities[selectedEntIndex];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0 pb-12">
|
||||
{/* Left sidebar: Entities list */}
|
||||
<div className="md:col-span-1 border border-border/20 bg-card shadow-[2px_2px_0_0_var(--border)] flex flex-col max-h-[500px]">
|
||||
<div className="p-3 border-b border-border/25 flex justify-between items-center bg-secondary/15">
|
||||
<strong className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Entities
|
||||
</strong>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newId = generateUUID();
|
||||
setEntities([
|
||||
...entities,
|
||||
{
|
||||
id: newId,
|
||||
locationId: locationIds[0],
|
||||
attributes: [],
|
||||
aliases: {},
|
||||
initialMemories: [],
|
||||
isAgent: true,
|
||||
},
|
||||
]);
|
||||
setSelectedEntIndex(entities.length);
|
||||
}}
|
||||
className="h-6 text-[10px] px-2 flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto divide-y divide-border/10 flex-1">
|
||||
{entities.map((ent, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setSelectedEntIndex(idx)}
|
||||
className={`p-3 text-xs font-mono cursor-pointer flex justify-between items-center transition-all ${
|
||||
selectedEntIndex === idx
|
||||
? "bg-primary/10 text-primary font-bold border-l-4 border-primary"
|
||||
: "hover:bg-secondary/40 text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{getEntityDisplayName(ent) || `(Empty ID)`}
|
||||
</span>
|
||||
{entities.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newEnts = entities.filter((_, i) => i !== idx);
|
||||
setEntities(newEnts);
|
||||
setSelectedEntIndex(Math.max(0, idx - 1));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive pl-2 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Edit selected entity details */}
|
||||
{selectedEnt ? (
|
||||
<div className="md:col-span-3 space-y-6">
|
||||
{/* Entity Configuration Card */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-4">
|
||||
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
|
||||
Entity Configuration
|
||||
</h2>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Entity ID</Label>
|
||||
<Input
|
||||
value={selectedEnt.id}
|
||||
readOnly
|
||||
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Current Location</Label>
|
||||
<Combobox
|
||||
items={locationIds}
|
||||
value={selectedEnt.locationId || ""}
|
||||
onValueChange={(value) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].locationId = value || undefined;
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<ComboboxInput placeholder="Select a location..." showClear />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No locations found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-1 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-muted-foreground cursor-pointer flex items-center gap-1.5 text-sm font-semibold">
|
||||
<Checkbox
|
||||
checked={selectedEnt.isAgent !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].isAgent = !!checked;
|
||||
setEntities(copy);
|
||||
}}
|
||||
/>
|
||||
Is Agent?
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/75 pl-5 select-none leading-normal">
|
||||
When enabled, this entity will run an autonomous LLM loop to
|
||||
perceive its environment, update its memories, and generate
|
||||
prose narrative actions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Attributes */}
|
||||
<div className="pt-2">
|
||||
<AttributeEditor
|
||||
title="Entity Attributes"
|
||||
attributes={selectedEnt.attributes || []}
|
||||
onChange={(newAttrs) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].attributes = newAttrs;
|
||||
setEntities(copy);
|
||||
}}
|
||||
onAdd={() => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].attributes = [
|
||||
...copy[selectedEntIndex].attributes,
|
||||
{
|
||||
name: "",
|
||||
value: "",
|
||||
visibility: "PUBLIC",
|
||||
allowedEntities: [],
|
||||
},
|
||||
];
|
||||
setEntities(copy);
|
||||
}}
|
||||
entityIds={entityIds}
|
||||
entities={entities}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aliases Card */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Aliases (Perceptions)
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const target = entityIds.find(
|
||||
(id) => id !== selectedEnt.id && !selectedEnt.aliases[id],
|
||||
);
|
||||
if (target) {
|
||||
copy[selectedEntIndex].aliases = {
|
||||
...selectedEnt.aliases,
|
||||
[target]: "",
|
||||
};
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
disabled={entityIds.length <= 1}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Alias
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(selectedEnt.aliases || {}).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No descriptive aliases configured. Defaults to actual entity ID.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(selectedEnt.aliases).map(
|
||||
([targetId, aliasText]) => (
|
||||
<div
|
||||
key={targetId}
|
||||
className="flex gap-2 items-center bg-secondary/15 p-2 rounded"
|
||||
>
|
||||
<span
|
||||
className="text-[11px] font-mono text-muted-foreground w-1/3 truncate"
|
||||
title={targetId}
|
||||
>
|
||||
{getEntityDisplayNameById(targetId, entities)}
|
||||
</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
placeholder="Descriptive name (e.g. the guard)"
|
||||
value={aliasText}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].aliases = {
|
||||
...selectedEnt.aliases,
|
||||
[targetId]: e.target.value,
|
||||
};
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const updated = { ...selectedEnt.aliases };
|
||||
delete updated[targetId];
|
||||
copy[selectedEntIndex].aliases = updated;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Initial Memories Card */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Initial Memories
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const newMem: MemoryData = {
|
||||
id: generateUUID(),
|
||||
timestamp: startTime,
|
||||
locationId: selectedEnt.locationId || null,
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: "",
|
||||
description: "",
|
||||
actorId: selectedEnt.id,
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
copy[selectedEntIndex].initialMemories = [
|
||||
...selectedEnt.initialMemories,
|
||||
newMem,
|
||||
];
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Memory
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!selectedEnt.initialMemories ||
|
||||
selectedEnt.initialMemories.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No initial memories loaded. Entities will start blank.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 overflow-y-auto max-h-[300px] pr-1">
|
||||
{selectedEnt.initialMemories.map((mem, memIdx) => (
|
||||
<div
|
||||
key={mem.id}
|
||||
className="border border-border/20 bg-secondary/5 p-3 rounded space-y-3 relative"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories =
|
||||
selectedEnt.initialMemories.filter(
|
||||
(_, i) => i !== memIdx,
|
||||
);
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Type
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded"
|
||||
value={mem.intent.type}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.type = e.target.value as
|
||||
"dialogue" | "action" | "monologue";
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<option value="dialogue">Dialogue</option>
|
||||
<option value="action">Action</option>
|
||||
<option value="monologue">Monologue</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Location
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded font-mono"
|
||||
value={mem.locationId || ""}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].locationId = e.target.value || null;
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<option value="">-- Nowhere --</option>
|
||||
{locationIds.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Verbatim Text (originalText)
|
||||
</span>
|
||||
<Input
|
||||
placeholder='e.g. "We should leave," Alice said.'
|
||||
value={mem.intent.originalText}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.originalText = e.target.value;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Objective Description
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. Alice says she wants to leave."
|
||||
value={mem.intent.description}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.description = e.target.value;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Targets multi-select */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Involved Targets
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entityIds
|
||||
.filter((id) => id !== selectedEnt.id)
|
||||
.map((entId) => {
|
||||
const isSelected =
|
||||
mem.intent.targetIds?.includes(entId);
|
||||
return (
|
||||
<button
|
||||
key={entId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const targets =
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds || [];
|
||||
if (targets.includes(entId)) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds = targets.filter(
|
||||
(t) => t !== entId,
|
||||
);
|
||||
} else {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds = [...targets, entId];
|
||||
}
|
||||
setEntities(copy);
|
||||
}}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] font-mono border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-primary/20 border-primary text-primary"
|
||||
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{getEntityDisplayNameById(entId, entities)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Validation outcome */}
|
||||
{mem.intent.type === "action" && (
|
||||
<div className="border-t border-border/20 pt-2 space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!mem.outcome}
|
||||
onCheckedChange={(checked) => {
|
||||
const copy = [...entities];
|
||||
if (checked) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome = { isValid: true, reason: "" };
|
||||
} else {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome = undefined;
|
||||
}
|
||||
setEntities(copy);
|
||||
}}
|
||||
/>
|
||||
Include validation outcome
|
||||
</Label>
|
||||
{mem.outcome && (
|
||||
<div className="grid grid-cols-3 gap-2 bg-secondary/15 p-2 rounded">
|
||||
<div className="col-span-1 flex flex-col justify-center">
|
||||
<Label className="text-[9px] mb-1">isValid</Label>
|
||||
<Checkbox
|
||||
checked={mem.outcome.isValid}
|
||||
onCheckedChange={(checked) => {
|
||||
const copy = [...entities];
|
||||
if (
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome
|
||||
) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome!.isValid = !!checked;
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-[9px]">Reason</Label>
|
||||
<Input
|
||||
placeholder="Reason for valid/invalid status"
|
||||
value={mem.outcome.reason}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
if (
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome
|
||||
) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome!.reason = e.target.value;
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
className="h-6 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="md:col-span-3 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] text-center text-xs text-muted-foreground">
|
||||
No entities defined.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/gui/src/components/builder/JsonTab.tsx
Normal file
38
apps/gui/src/components/builder/JsonTab.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface JsonTabProps {
|
||||
compiledScenario: Record<string, unknown>;
|
||||
onCopySuccess: () => void;
|
||||
}
|
||||
|
||||
export function JsonTab({ compiledScenario, onCopySuccess }: JsonTabProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] min-h-[400px]">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-3 mb-4">
|
||||
<h2 className="text-body-lg text-primary font-bold">
|
||||
Scenario JSON Code Output
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
JSON.stringify(compiledScenario, null, 2),
|
||||
);
|
||||
onCopySuccess();
|
||||
}}
|
||||
className="h-8 text-xs cursor-pointer"
|
||||
>
|
||||
Copy to Clipboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="flex-1 bg-black border border-border/10 p-4 rounded overflow-auto font-mono text-xs text-emerald-400 select-text leading-relaxed">
|
||||
{JSON.stringify(compiledScenario, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
419
apps/gui/src/components/builder/LocationsTab.tsx
Normal file
419
apps/gui/src/components/builder/LocationsTab.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { AttributeEditor } from "./AttributeEditor";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { LocationData, ConnectionData, EntityData } from "./types";
|
||||
import { getLocationDisplayName, getLocationDisplayNameById } from "./utils";
|
||||
import { WorldMap } from "./WorldMap";
|
||||
|
||||
interface LocationsTabProps {
|
||||
locations: LocationData[];
|
||||
setLocations: (locs: LocationData[]) => void;
|
||||
entities: EntityData[];
|
||||
locationIds: string[];
|
||||
entityIds: string[];
|
||||
selectedLocIndex: number;
|
||||
setSelectedLocIndex: (idx: number) => void;
|
||||
generateUUID: () => string;
|
||||
}
|
||||
|
||||
export function LocationsTab({
|
||||
locations,
|
||||
setLocations,
|
||||
entities,
|
||||
locationIds,
|
||||
entityIds,
|
||||
selectedLocIndex,
|
||||
setSelectedLocIndex,
|
||||
generateUUID,
|
||||
}: LocationsTabProps) {
|
||||
const addLocationConnection = (locIndex: number) => {
|
||||
const copy = [...locations];
|
||||
copy[locIndex].connections = [
|
||||
...copy[locIndex].connections,
|
||||
{
|
||||
targetId:
|
||||
locationIds.filter((id) => id !== locations[locIndex].id)[0] || "",
|
||||
visionProp: 10,
|
||||
soundProp: 10,
|
||||
bidirectional: true,
|
||||
},
|
||||
];
|
||||
setLocations(copy);
|
||||
};
|
||||
|
||||
const updateLocationConnection = <K extends keyof ConnectionData>(
|
||||
locIndex: number,
|
||||
connIndex: number,
|
||||
key: K,
|
||||
val: ConnectionData[K],
|
||||
) => {
|
||||
const copy = [...locations];
|
||||
copy[locIndex].connections[connIndex] = {
|
||||
...copy[locIndex].connections[connIndex],
|
||||
[key]: val,
|
||||
};
|
||||
setLocations(copy);
|
||||
};
|
||||
|
||||
const removeLocationConnection = (locIndex: number, connIndex: number) => {
|
||||
const copy = [...locations];
|
||||
copy[locIndex].connections = copy[locIndex].connections.filter(
|
||||
(_, i) => i !== connIndex,
|
||||
);
|
||||
setLocations(copy);
|
||||
};
|
||||
|
||||
const selectedLoc = locations[selectedLocIndex];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0 pb-12">
|
||||
{/* Map Visualizer */}
|
||||
{locations.length > 0 && (
|
||||
<div className="md:col-span-4 w-full">
|
||||
<WorldMap
|
||||
locations={locations}
|
||||
selectedLocId={selectedLoc?.id}
|
||||
onSelectLocId={(id) => {
|
||||
const idx = locations.findIndex((l) => l.id === id);
|
||||
if (idx !== -1) setSelectedLocIndex(idx);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left sidebar: Locations list */}
|
||||
<div className="md:col-span-1 border border-border/20 bg-card shadow-[2px_2px_0_0_var(--border)] flex flex-col max-h-[500px]">
|
||||
<div className="p-3 border-b border-border/25 flex justify-between items-center bg-secondary/15">
|
||||
<strong className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Locations
|
||||
</strong>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newId = generateUUID();
|
||||
setLocations([
|
||||
...locations,
|
||||
{ id: newId, attributes: [], connections: [] },
|
||||
]);
|
||||
setSelectedLocIndex(locations.length);
|
||||
}}
|
||||
className="h-6 text-[10px] px-2 flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto divide-y divide-border/10 flex-1">
|
||||
{locations.map((loc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setSelectedLocIndex(idx)}
|
||||
className={`p-3 text-xs font-mono cursor-pointer flex justify-between items-center transition-all ${
|
||||
selectedLocIndex === idx
|
||||
? "bg-primary/10 text-primary font-bold border-l-4 border-primary"
|
||||
: "hover:bg-secondary/40 text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{getLocationDisplayName(loc) || `(Empty ID)`}
|
||||
</span>
|
||||
{locations.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newLocs = locations.filter((_, i) => i !== idx);
|
||||
setLocations(newLocs);
|
||||
setSelectedLocIndex(Math.max(0, idx - 1));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors pl-2 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel wrapper */}
|
||||
<div className="md:col-span-3 space-y-6">
|
||||
{selectedLoc ? (
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic location fields */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
|
||||
Location Configuration
|
||||
</h2>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Location ID</Label>
|
||||
<Input
|
||||
value={selectedLoc.id}
|
||||
readOnly
|
||||
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Parent Location (Optional)</Label>
|
||||
<Combobox
|
||||
items={locationIds.filter((id) => id !== selectedLoc.id)}
|
||||
value={selectedLoc.parentId || ""}
|
||||
onValueChange={(value) => {
|
||||
const copy = [...locations];
|
||||
copy[selectedLocIndex].parentId = value || undefined;
|
||||
setLocations(copy);
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Select a parent location..."
|
||||
showClear
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No locations found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
{/* Attributes for Location */}
|
||||
<div className="pt-2">
|
||||
<AttributeEditor
|
||||
title="Location Attributes"
|
||||
attributes={selectedLoc.attributes || []}
|
||||
onChange={(newAttrs) => {
|
||||
const copy = [...locations];
|
||||
copy[selectedLocIndex].attributes = newAttrs;
|
||||
setLocations(copy);
|
||||
}}
|
||||
onAdd={() => {
|
||||
const copy = [...locations];
|
||||
copy[selectedLocIndex].attributes = [
|
||||
...copy[selectedLocIndex].attributes,
|
||||
{
|
||||
name: "",
|
||||
value: "",
|
||||
visibility: "PUBLIC",
|
||||
allowedEntities: [],
|
||||
},
|
||||
];
|
||||
setLocations(copy);
|
||||
}}
|
||||
entityIds={entityIds}
|
||||
entities={entities}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections (spatial paths) */}
|
||||
<div className="space-y-4 border-t lg:border-t-0 lg:border-l border-border/20 lg:pl-6 pt-4 lg:pt-0">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Connections / Portals
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addLocationConnection(selectedLocIndex)}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!selectedLoc.connections ||
|
||||
selectedLoc.connections.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No connections leading from this location.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 overflow-y-auto max-h-[400px] pr-1">
|
||||
{selectedLoc.connections.map((conn, connIdx) => (
|
||||
<div
|
||||
key={connIdx}
|
||||
className="border border-border/20 bg-secondary/10 p-3 rounded space-y-3 relative group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removeLocationConnection(selectedLocIndex, connIdx)
|
||||
}
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
title="Delete connection"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 pr-6">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Target Location
|
||||
</span>
|
||||
<Combobox
|
||||
items={locationIds.filter(
|
||||
(id) => id !== selectedLoc.id,
|
||||
)}
|
||||
value={conn.targetId}
|
||||
onValueChange={(value) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"targetId",
|
||||
value ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Choose target..."
|
||||
showClear
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No locations found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Portal Name
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. wooden door"
|
||||
value={conn.portalName || ""}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"portalName",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Portal State
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. locked, closed, heavy iron gate"
|
||||
value={conn.portalStateDescriptor || ""}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"portalStateDescriptor",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Vision Propagation ({conn.visionProp})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={conn.visionProp}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"visionProp",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Sound Propagation ({conn.soundProp})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={conn.soundProp}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"soundProp",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-[11px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={conn.bidirectional}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"bidirectional",
|
||||
!!checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Bidirectional Connection (creates reverse path
|
||||
automatically)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] text-center py-12 text-xs text-muted-foreground">
|
||||
No location selected. Choose a location from the sidebar to edit, or
|
||||
view the world layout below.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
apps/gui/src/components/builder/MetadataTab.tsx
Normal file
163
apps/gui/src/components/builder/MetadataTab.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AttributeEditor } from "./AttributeEditor";
|
||||
import type { AttributeData, EntityData } from "./types";
|
||||
|
||||
interface MetadataTabProps {
|
||||
scenarioId: string;
|
||||
setScenarioId: (val: string) => void;
|
||||
name: string;
|
||||
setName: (val: string) => void;
|
||||
description: string;
|
||||
setDescription: (val: string) => void;
|
||||
startTime: string;
|
||||
setStartTime: (val: string) => void;
|
||||
worldAttributes: AttributeData[];
|
||||
setWorldAttributes: (attrs: AttributeData[]) => void;
|
||||
entityIds: string[];
|
||||
entities: EntityData[];
|
||||
}
|
||||
|
||||
export function MetadataTab({
|
||||
scenarioId,
|
||||
name,
|
||||
setName,
|
||||
description,
|
||||
setDescription,
|
||||
startTime,
|
||||
setStartTime,
|
||||
worldAttributes,
|
||||
setWorldAttributes,
|
||||
entityIds,
|
||||
entities,
|
||||
}: MetadataTabProps) {
|
||||
const addWorldAttribute = () => {
|
||||
setWorldAttributes([
|
||||
...worldAttributes,
|
||||
{ name: "", value: "", visibility: "PUBLIC", allowedEntities: [] },
|
||||
]);
|
||||
};
|
||||
|
||||
// Parse initial state from ISO string (or fallback to now)
|
||||
const parsedDate = useMemo(() => {
|
||||
try {
|
||||
const d = new Date(startTime);
|
||||
if (isNaN(d.getTime())) return new Date();
|
||||
return d;
|
||||
} catch {
|
||||
return new Date();
|
||||
}
|
||||
}, [startTime]);
|
||||
|
||||
const dateValue = useMemo(() => {
|
||||
return parsedDate.toISOString().split("T")[0];
|
||||
}, [parsedDate]);
|
||||
|
||||
const timeValue = useMemo(() => {
|
||||
return parsedDate.toISOString().split("T")[1].slice(0, 8);
|
||||
}, [parsedDate]);
|
||||
|
||||
const handleDateChange = (newDateStr: string) => {
|
||||
if (!newDateStr) return;
|
||||
const combined = `${newDateStr}T${timeValue}.000Z`;
|
||||
setStartTime(combined);
|
||||
};
|
||||
|
||||
const handleTimeChange = (newTimeStr: string) => {
|
||||
if (!newTimeStr) return;
|
||||
const formattedTime =
|
||||
newTimeStr.split(":").length === 2 ? `${newTimeStr}:00` : newTimeStr;
|
||||
const combined = `${dateValue}T${formattedTime}.000Z`;
|
||||
setStartTime(combined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-12">
|
||||
{/* Basic Fields */}
|
||||
<div className="lg:col-span-2 space-y-5 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
|
||||
Scenario Metadata
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sc-id">Scenario Template ID</Label>
|
||||
<Input
|
||||
id="sc-id"
|
||||
value={scenarioId}
|
||||
readOnly
|
||||
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Unique filename ID. Alphanumeric, hyphens and underscores only.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Start Time</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={dateValue}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
type="time"
|
||||
step="1"
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Global clock starting date and time (stored in ISO UTC).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sc-name">Scenario Name</Label>
|
||||
<Input
|
||||
id="sc-name"
|
||||
placeholder="e.g. The Quiet Tavern"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sc-desc">Description</Label>
|
||||
<Textarea
|
||||
id="sc-desc"
|
||||
placeholder="Describe the starting setup..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="text-xs h-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* World level Attributes */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<AttributeEditor
|
||||
title="World Attributes"
|
||||
attributes={worldAttributes}
|
||||
onChange={setWorldAttributes}
|
||||
onAdd={addWorldAttribute}
|
||||
entityIds={entityIds}
|
||||
entities={entities}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
528
apps/gui/src/components/builder/WorldMap.tsx
Normal file
528
apps/gui/src/components/builder/WorldMap.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
|
||||
import type { LocationData } from "./types";
|
||||
|
||||
interface BoxNode {
|
||||
id: string;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
children: BoxNode[];
|
||||
location: LocationData;
|
||||
}
|
||||
|
||||
interface WorldMapProps {
|
||||
locations: LocationData[];
|
||||
selectedLocId?: string;
|
||||
onSelectLocId: (id: string) => void;
|
||||
}
|
||||
|
||||
export function WorldMap({
|
||||
locations,
|
||||
selectedLocId,
|
||||
onSelectLocId,
|
||||
}: WorldMapProps) {
|
||||
// Zoom and Pan States for Visualizer
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const panStartRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (e.button === 0) {
|
||||
setIsPanning(true);
|
||||
panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (isPanning) {
|
||||
setPan({
|
||||
x: e.clientX - panStartRef.current.x,
|
||||
y: e.clientY - panStartRef.current.y,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsPanning(false);
|
||||
};
|
||||
|
||||
// Build tree and layout computation
|
||||
const { roots, nodeMap } = useMemo(() => {
|
||||
const nodeMap = new Map<string, BoxNode>();
|
||||
|
||||
// 1. Initialize all nodes
|
||||
locations.forEach((loc) => {
|
||||
const nameAttr = loc.attributes?.find(
|
||||
(a) => a.name.toLowerCase() === "name",
|
||||
)?.value;
|
||||
const name = nameAttr ? nameAttr : loc.id;
|
||||
nodeMap.set(loc.id, {
|
||||
id: loc.id,
|
||||
name,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
children: [],
|
||||
location: loc,
|
||||
});
|
||||
});
|
||||
|
||||
const roots: BoxNode[] = [];
|
||||
|
||||
// 2. Link parents and children
|
||||
locations.forEach((loc) => {
|
||||
const node = nodeMap.get(loc.id)!;
|
||||
if (loc.parentId && nodeMap.has(loc.parentId)) {
|
||||
nodeMap.get(loc.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Measure recursively
|
||||
const measureNode = (node: BoxNode) => {
|
||||
const textWidth = node.name.length * 7.2 + 24;
|
||||
|
||||
if (node.children.length === 0) {
|
||||
node.width = Math.max(140, textWidth);
|
||||
node.height = 70;
|
||||
return;
|
||||
}
|
||||
|
||||
node.children.forEach((child) => measureNode(child));
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(node.children.length));
|
||||
const rows = Math.ceil(node.children.length / cols);
|
||||
|
||||
const gap = 20;
|
||||
const padLeft = 20;
|
||||
const padRight = 20;
|
||||
const padTop = 45;
|
||||
const padBottom = 20;
|
||||
|
||||
const colWidths = new Array(cols).fill(0);
|
||||
const rowHeights = new Array(rows).fill(0);
|
||||
|
||||
node.children.forEach((child, idx) => {
|
||||
const col = idx % cols;
|
||||
const row = Math.floor(idx / cols);
|
||||
colWidths[col] = Math.max(colWidths[col], child.width);
|
||||
rowHeights[row] = Math.max(rowHeights[row], child.height);
|
||||
});
|
||||
|
||||
const totalGridWidth =
|
||||
colWidths.reduce((sum, w) => sum + w, 0) + (cols - 1) * gap;
|
||||
const totalGridHeight =
|
||||
rowHeights.reduce((sum, h) => sum + h, 0) + (rows - 1) * gap;
|
||||
|
||||
node.width = Math.max(
|
||||
180,
|
||||
totalGridWidth + padLeft + padRight,
|
||||
textWidth,
|
||||
);
|
||||
node.height = Math.max(100, totalGridHeight + padTop + padBottom);
|
||||
|
||||
let yOffset = padTop;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let xOffset = padLeft;
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = r * cols + c;
|
||||
if (idx >= node.children.length) break;
|
||||
const child = node.children[idx];
|
||||
|
||||
const cellWidth = colWidths[c];
|
||||
const cellHeight = rowHeights[r];
|
||||
child.x = xOffset + (cellWidth - child.width) / 2;
|
||||
child.y = yOffset + (cellHeight - child.height) / 2;
|
||||
|
||||
xOffset += cellWidth + gap;
|
||||
}
|
||||
yOffset += rowHeights[r] + gap;
|
||||
}
|
||||
};
|
||||
|
||||
roots.forEach((root) => measureNode(root));
|
||||
|
||||
// 4. Position roots and assign global coordinates
|
||||
const assignGlobalCoordinates = (
|
||||
node: BoxNode,
|
||||
parentX: number,
|
||||
parentY: number,
|
||||
) => {
|
||||
node.x += parentX;
|
||||
node.y += parentY;
|
||||
node.children.forEach((child) => {
|
||||
assignGlobalCoordinates(child, node.x, node.y);
|
||||
});
|
||||
};
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(roots.length));
|
||||
const gap = 40;
|
||||
|
||||
const colWidths = new Array(cols).fill(0);
|
||||
const rowHeights: number[] = [];
|
||||
|
||||
roots.forEach((root, idx) => {
|
||||
const col = idx % cols;
|
||||
const row = Math.floor(idx / cols);
|
||||
colWidths[col] = Math.max(colWidths[col], root.width);
|
||||
if (rowHeights[row] === undefined) rowHeights[row] = 0;
|
||||
rowHeights[row] = Math.max(rowHeights[row], root.height);
|
||||
});
|
||||
|
||||
let yOffset = 40;
|
||||
let maxWidth = 0;
|
||||
|
||||
for (let r = 0; r < rowHeights.length; r++) {
|
||||
let xOffset = 40;
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = r * cols + c;
|
||||
if (idx >= roots.length) break;
|
||||
const root = roots[idx];
|
||||
|
||||
root.x = xOffset;
|
||||
root.y = yOffset;
|
||||
|
||||
assignGlobalCoordinates(root, 0, 0);
|
||||
xOffset += colWidths[c] + gap;
|
||||
}
|
||||
yOffset += rowHeights[r] + gap;
|
||||
maxWidth = Math.max(maxWidth, xOffset);
|
||||
}
|
||||
|
||||
return { roots, nodeMap };
|
||||
}, [locations]);
|
||||
|
||||
// Connection lines computation
|
||||
const connectionLines = useMemo(() => {
|
||||
const lines: {
|
||||
id: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
portalName?: string;
|
||||
portalState?: string;
|
||||
vision?: number;
|
||||
sound?: number;
|
||||
bidirectional?: boolean;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
locations.forEach((loc) => {
|
||||
const nodeA = nodeMap.get(loc.id);
|
||||
if (!nodeA) return;
|
||||
|
||||
loc.connections?.forEach((conn, cIdx) => {
|
||||
const nodeB = nodeMap.get(conn.targetId);
|
||||
if (!nodeB) return;
|
||||
|
||||
const isParentChild =
|
||||
loc.parentId === conn.targetId || nodeB.location.parentId === loc.id;
|
||||
if (isParentChild) return;
|
||||
|
||||
const sortedIds = [loc.id, conn.targetId].sort();
|
||||
const key = conn.bidirectional
|
||||
? `bidi-${sortedIds[0]}-${sortedIds[1]}`
|
||||
: `uni-${loc.id}-${conn.targetId}`;
|
||||
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
const x1 = nodeA.x + nodeA.width / 2;
|
||||
const y1 = nodeA.y + nodeA.height / 2;
|
||||
const x2 = nodeB.x + nodeB.width / 2;
|
||||
const y2 = nodeB.y + nodeB.height / 2;
|
||||
|
||||
lines.push({
|
||||
id: `${loc.id}-${conn.targetId}-${cIdx}`,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
portalName: conn.portalName,
|
||||
portalState: conn.portalStateDescriptor,
|
||||
vision: conn.visionProp,
|
||||
sound: conn.soundProp,
|
||||
bidirectional: conn.bidirectional,
|
||||
sourceId: loc.id,
|
||||
targetId: conn.targetId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return lines;
|
||||
}, [locations, nodeMap]);
|
||||
|
||||
// Recursive render helper for boxes
|
||||
const renderNode = (node: BoxNode) => {
|
||||
const isSelected = selectedLocId === node.id;
|
||||
|
||||
// Find parent connection details
|
||||
const parentLoc = locations.find((l) => l.id === node.location.parentId);
|
||||
const childToParentConn = node.location.parentId
|
||||
? node.location.connections?.find(
|
||||
(c) => c.targetId === node.location.parentId,
|
||||
)
|
||||
: undefined;
|
||||
const parentToChildConn =
|
||||
node.location.parentId && parentLoc
|
||||
? parentLoc.connections?.find((c) => c.targetId === node.id)
|
||||
: undefined;
|
||||
|
||||
const hasParentConn = !!(childToParentConn || parentToChildConn);
|
||||
const portalName =
|
||||
childToParentConn?.portalName || parentToChildConn?.portalName;
|
||||
|
||||
return (
|
||||
<g key={node.id}>
|
||||
{/* Box */}
|
||||
<rect
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rx={8}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectLocId(node.id);
|
||||
}}
|
||||
className={`transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "fill-primary/5 stroke-primary stroke-2"
|
||||
: node.children.length > 0
|
||||
? "fill-secondary/5 stroke-border/40 hover:stroke-border/80"
|
||||
: "fill-secondary/20 stroke-border/40 hover:stroke-foreground/40 hover:fill-secondary/30"
|
||||
}`}
|
||||
style={{ strokeWidth: isSelected ? 2 : 1 }}
|
||||
strokeDasharray={
|
||||
hasParentConn
|
||||
? "4, 4"
|
||||
: node.children.length > 0
|
||||
? "3, 3"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={node.x + 12}
|
||||
y={node.y + 24}
|
||||
className={`text-xs font-mono select-none font-semibold ${
|
||||
isSelected ? "fill-primary font-bold" : "fill-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{node.name}
|
||||
</text>
|
||||
|
||||
{/* Portal Name on Boundary of Child */}
|
||||
{hasParentConn && portalName && (
|
||||
<g transform={`translate(${node.x + node.width / 2}, ${node.y})`}>
|
||||
<rect
|
||||
x={-((portalName.length * 5) / 2) - 4}
|
||||
y={-6}
|
||||
width={portalName.length * 5 + 8}
|
||||
height={12}
|
||||
rx={3}
|
||||
className="fill-zinc-900 stroke stroke-border/40"
|
||||
style={{ strokeWidth: 0.5 }}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y={3}
|
||||
className="text-[8px] font-mono fill-primary font-semibold select-none"
|
||||
>
|
||||
{portalName}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Render children inside */}
|
||||
{node.children.map((child) => renderNode(child))}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
{/* Controls */}
|
||||
<div className="flex gap-1 bg-secondary/15 p-1 rounded border border-border/10">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setZoom((z) => Math.min(4, z + 0.1))}
|
||||
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setZoom((z) => Math.max(0.3, z - 0.1))}
|
||||
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}}
|
||||
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
|
||||
title="Reset view"
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas container */}
|
||||
<div className="relative border border-border/35 bg-zinc-950/95 rounded-lg overflow-hidden h-[450px]">
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className={`w-full h-full select-none ${
|
||||
isPanning ? "cursor-grabbing" : "cursor-grab"
|
||||
}`}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="grid-pattern"
|
||||
width="40"
|
||||
height="40"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path
|
||||
d="M 40 0 L 0 0 0 40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-muted-foreground/10"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Grid Background */}
|
||||
<rect width="100%" height="100%" fill="url(#grid-pattern)" />
|
||||
|
||||
{/* Draggable/scalable group */}
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
||||
{/* Draw Connection Lines first so they are behind boxes */}
|
||||
{connectionLines.map((line) => {
|
||||
const isSourceSelected = selectedLocId === line.sourceId;
|
||||
const isTargetSelected = selectedLocId === line.targetId;
|
||||
const isHighlighted = isSourceSelected || isTargetSelected;
|
||||
|
||||
const mx = (line.x1 + line.x2) / 2;
|
||||
const my = (line.y1 + line.y2) / 2;
|
||||
const angle =
|
||||
Math.atan2(line.y2 - line.y1, line.x2 - line.x1) *
|
||||
(180 / Math.PI);
|
||||
|
||||
return (
|
||||
<g key={line.id} className="transition-all">
|
||||
{/* Glow path for highlighted connections */}
|
||||
{isHighlighted && (
|
||||
<line
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
className="stroke-primary/30 stroke-[4px] blur-sm animate-pulse"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Connection Line */}
|
||||
<line
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
className={`transition-all ${
|
||||
isHighlighted
|
||||
? "stroke-primary stroke-2"
|
||||
: "stroke-border/40 stroke-1"
|
||||
}`}
|
||||
style={{
|
||||
strokeDasharray: line.bidirectional ? undefined : "4, 4",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Midpoint Direction Arrow for Uni-directional connections */}
|
||||
{!line.bidirectional && (
|
||||
<g transform={`translate(${mx}, ${my}) rotate(${angle})`}>
|
||||
<path
|
||||
d="M -4 -3.5 L 4 0 L -4 3.5 Z"
|
||||
className={
|
||||
isHighlighted
|
||||
? "fill-primary"
|
||||
: "fill-muted-foreground/60"
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Portal Name Label bubble */}
|
||||
{line.portalName && (
|
||||
<g
|
||||
transform={`translate(${mx}, ${my - 12})`}
|
||||
className="cursor-default"
|
||||
>
|
||||
<rect
|
||||
x={-((line.portalName.length * 6) / 2) - 4}
|
||||
y={-8}
|
||||
width={line.portalName.length * 6 + 8}
|
||||
height={16}
|
||||
rx={4}
|
||||
className="fill-background/90 stroke stroke-border/30"
|
||||
style={{ strokeWidth: 0.5 }}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y={3}
|
||||
className="text-[9px] font-mono fill-muted-foreground select-none"
|
||||
>
|
||||
{line.portalName}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Draw Box Nodes recursively */}
|
||||
{roots.map((root) => renderNode(root))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Map Hint Overlay */}
|
||||
<div className="absolute bottom-2 right-2 px-2 py-1 rounded bg-black/60 border border-border/20 text-[9.5px] text-muted-foreground font-mono pointer-events-none">
|
||||
Drag to pan • Scroll or buttons to zoom • Click boxes to select
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
apps/gui/src/components/builder/types.ts
Normal file
50
apps/gui/src/components/builder/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export interface AttributeData {
|
||||
name: string;
|
||||
value: string;
|
||||
visibility: "PUBLIC" | "PRIVATE";
|
||||
allowedEntities: string[];
|
||||
}
|
||||
|
||||
export interface ConnectionData {
|
||||
targetId: string;
|
||||
portalName?: string;
|
||||
portalStateDescriptor?: string;
|
||||
visionProp: number;
|
||||
soundProp: number;
|
||||
bidirectional: boolean;
|
||||
}
|
||||
|
||||
export interface LocationData {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
attributes: AttributeData[];
|
||||
connections: ConnectionData[];
|
||||
}
|
||||
|
||||
export interface MemoryData {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
locationId: string | null;
|
||||
intent: {
|
||||
type: "dialogue" | "action" | "monologue";
|
||||
originalText: string;
|
||||
description: string;
|
||||
selfDescription?: string;
|
||||
actorId: string;
|
||||
targetIds: string[];
|
||||
modifiers?: string[];
|
||||
};
|
||||
outcome?: {
|
||||
isValid: boolean;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EntityData {
|
||||
id: string;
|
||||
locationId?: string;
|
||||
attributes: AttributeData[];
|
||||
aliases: Record<string, string>;
|
||||
initialMemories: MemoryData[];
|
||||
isAgent?: boolean;
|
||||
}
|
||||
45
apps/gui/src/components/builder/utils.ts
Normal file
45
apps/gui/src/components/builder/utils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { EntityData, LocationData } from "./types";
|
||||
|
||||
export function getEntityDisplayName(
|
||||
entity: EntityData | undefined,
|
||||
fallbackId: string = "",
|
||||
): string {
|
||||
if (!entity) return fallbackId;
|
||||
const nameAttr = entity.attributes?.find(
|
||||
(a) => a.name.toLowerCase() === "name",
|
||||
);
|
||||
if (nameAttr?.value) {
|
||||
return `${nameAttr.value} (${entity.id})`;
|
||||
}
|
||||
return entity.id;
|
||||
}
|
||||
|
||||
export function getEntityDisplayNameById(
|
||||
id: string,
|
||||
entities: EntityData[],
|
||||
): string {
|
||||
const entity = entities.find((e) => e.id === id);
|
||||
return getEntityDisplayName(entity, id);
|
||||
}
|
||||
|
||||
export function getLocationDisplayName(
|
||||
location: LocationData | undefined,
|
||||
fallbackId: string = "",
|
||||
): string {
|
||||
if (!location) return fallbackId;
|
||||
const nameAttr = location.attributes?.find(
|
||||
(a) => a.name.toLowerCase() === "name",
|
||||
);
|
||||
if (nameAttr?.value) {
|
||||
return `${nameAttr.value} (${location.id})`;
|
||||
}
|
||||
return location.id;
|
||||
}
|
||||
|
||||
export function getLocationDisplayNameById(
|
||||
id: string,
|
||||
locations: LocationData[],
|
||||
): string {
|
||||
const location = locations.find((l) => l.id === id);
|
||||
return getLocationDisplayName(location, id);
|
||||
}
|
||||
284
apps/gui/src/components/config/ConfigView.tsx
Normal file
284
apps/gui/src/components/config/ConfigView.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
getConfigStatus,
|
||||
listProviderInstances,
|
||||
getProviderMappings,
|
||||
setProviderMapping,
|
||||
getAvailableProviders,
|
||||
regenerateEmbeddings,
|
||||
} from "@/app/actions";
|
||||
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
|
||||
import { ProviderInstancesConfig } from "@/components/config/ProviderInstancesConfig";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
interface ConfigStatus {
|
||||
apiKeySet: boolean;
|
||||
apiKeyPreview: string;
|
||||
model: string;
|
||||
availableScenarios: { path: string; name: string }[];
|
||||
}
|
||||
|
||||
export function ConfigView() {
|
||||
const [config, setConfig] = useState<ConfigStatus | null>(null);
|
||||
const [instances, setInstances] = useState<ModelProviderInstance[]>([]);
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({});
|
||||
const [availableProviders, setAvailableProviders] = useState<
|
||||
ModelProviderMeta[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadInstances = useCallback(async () => {
|
||||
const list = await listProviderInstances();
|
||||
setInstances(list);
|
||||
}, []);
|
||||
|
||||
const loadMappings = useCallback(async () => {
|
||||
const maps = await getProviderMappings();
|
||||
setMappings(maps);
|
||||
}, []);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const status = await getConfigStatus();
|
||||
setConfig(status);
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
const provs = await getAvailableProviders();
|
||||
setAvailableProviders(provs);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadInstances, loadMappings]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
const handleUpdateMapping = async (
|
||||
task: string,
|
||||
providerInstanceId: string,
|
||||
) => {
|
||||
if (task === "embeddings" && mappings[task] !== providerInstanceId) {
|
||||
const confirmChange = window.confirm(
|
||||
"Changing the embeddings provider will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?",
|
||||
);
|
||||
if (!confirmChange) return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await setProviderMapping(task, providerInstanceId);
|
||||
if (task === "embeddings") {
|
||||
await regenerateEmbeddings(providerInstanceId);
|
||||
}
|
||||
await loadMappings();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">
|
||||
Configuration
|
||||
</h1>
|
||||
<h2 className="mb-3 text-headline-md text-foreground">
|
||||
Manage Model Instances
|
||||
</h2>
|
||||
{config === null && loading && (
|
||||
<p className="text-body-md text-muted-foreground">
|
||||
Loading configuration...
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<div
|
||||
className={
|
||||
loading
|
||||
? "opacity-60 pointer-events-none transition-opacity duration-200"
|
||||
: "transition-opacity duration-200"
|
||||
}
|
||||
>
|
||||
<ProviderInstancesConfig
|
||||
instances={instances}
|
||||
availableProviders={availableProviders}
|
||||
mappings={mappings}
|
||||
onChanged={async () => {
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="border-b border-dotted border-border/20 mb-8 pb-8">
|
||||
<h2 className="mb-3 text-headline-md text-foreground">
|
||||
Task Provider Routing
|
||||
</h2>
|
||||
<p className="my-4 border border-border/20 bg-secondary px-3 py-2 text-label-sm text-foreground/80">
|
||||
Configure which LLM Provider Key Instance should handle each
|
||||
specific simulation task. Mappings default to the currently{" "}
|
||||
<strong>Active</strong> instance if not specified.
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
key: "actor-prose",
|
||||
label: "Actor Prose Generation",
|
||||
desc: "Generates roleplay/narrative prose for Non-Player Characters.",
|
||||
type: "generative",
|
||||
},
|
||||
{
|
||||
key: "llm-validator",
|
||||
label: "LLM Validator",
|
||||
desc: "Arbitrates and validates proposed actions against the world state rules.",
|
||||
type: "generative",
|
||||
},
|
||||
{
|
||||
key: "intent-decoder",
|
||||
label: "Intent Decoder",
|
||||
desc: "Splits raw prose actions into structured intents (Player and NPC).",
|
||||
type: "generative",
|
||||
},
|
||||
{
|
||||
key: "timedelta",
|
||||
label: "TimeDelta Generator",
|
||||
desc: "Calculates the duration of character actions to advance the game clock.",
|
||||
type: "generative",
|
||||
},
|
||||
{
|
||||
key: "handoff",
|
||||
label: "Memory Handoff Engine",
|
||||
desc: "Promotes entities' working memories to the long-term Ledger via LLM summarization and pruning.",
|
||||
type: "generative",
|
||||
},
|
||||
{
|
||||
key: "embeddings",
|
||||
label: "Text Embeddings Generator",
|
||||
desc: "Generates vector embeddings for long-term memory retrieval.",
|
||||
type: "embedding",
|
||||
},
|
||||
].map((task) => (
|
||||
<div
|
||||
key={task.key}
|
||||
className="flex flex-col justify-between gap-3 border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<strong className="text-body-md text-foreground">
|
||||
{task.label}
|
||||
</strong>
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">
|
||||
{task.desc}
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={mappings[task.key] || ""}
|
||||
onValueChange={(value) =>
|
||||
handleUpdateMapping(task.key, value || "")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="-- Use Active Key (Default) --">
|
||||
{(() => {
|
||||
const inst = instances.find(
|
||||
(i) => i.id === mappings[task.key],
|
||||
);
|
||||
return inst
|
||||
? `${inst.name} (${inst.providerName})${inst.isActive ? " [Active]" : ""}`
|
||||
: null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="">
|
||||
-- Use Active Key (Default) --
|
||||
</SelectItem>
|
||||
{instances
|
||||
.filter(
|
||||
(inst) =>
|
||||
(inst.type || "generative") === task.type,
|
||||
)
|
||||
.map((inst) => (
|
||||
<SelectItem key={inst.id} value={inst.id}>
|
||||
{inst.name} ({inst.providerName})
|
||||
{inst.isActive ? " [Active]" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 text-headline-md text-foreground">
|
||||
Available Scenarios
|
||||
</h2>
|
||||
{config.availableScenarios.length === 0 ? (
|
||||
<p className="mt-3 border border-accent bg-accent/25 px-3 py-2 text-label-sm text-foreground/80">
|
||||
No scenarios found in{" "}
|
||||
<code className="font-mono text-xs">
|
||||
content/demo/scenarios/
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Path</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{config.availableScenarios.map((s) => (
|
||||
<TableRow key={s.path}>
|
||||
<TableCell className="text-body-md">{s.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className="font-mono text-xs text-primary">
|
||||
{s.path}
|
||||
</code>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
480
apps/gui/src/components/config/ProviderInstancesConfig.tsx
Normal file
480
apps/gui/src/components/config/ProviderInstancesConfig.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
createProviderInstance,
|
||||
updateProviderInstance,
|
||||
setActiveProviderInstance,
|
||||
regenerateEmbeddings,
|
||||
deleteProviderInstance,
|
||||
} from "@/app/actions";
|
||||
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Empty, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProviderInstancesConfigProps {
|
||||
instances: ModelProviderInstance[];
|
||||
availableProviders: ModelProviderMeta[];
|
||||
mappings: Record<string, string>;
|
||||
onChanged: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ProviderInstancesConfig({
|
||||
instances,
|
||||
availableProviders,
|
||||
mappings,
|
||||
onChanged,
|
||||
}: ProviderInstancesConfigProps) {
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editProvider, setEditProvider] = useState("google-genai");
|
||||
const [editKey, setEditKey] = useState("");
|
||||
const [editModel, setEditModel] = useState("gemini-2.5-flash");
|
||||
const [editIsActive, setEditIsActive] = useState(false);
|
||||
const [editType, setEditType] = useState<"generative" | "embedding">(
|
||||
"generative",
|
||||
);
|
||||
const [editMaxContext, setEditMaxContext] = useState<number>(32768);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedInstanceId === null) {
|
||||
setEditName("");
|
||||
setEditProvider("google-genai");
|
||||
setEditKey("");
|
||||
setEditModel("gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
setEditType("generative");
|
||||
setEditMaxContext(32768);
|
||||
} else if (selectedInstanceId === "new") {
|
||||
setEditName("");
|
||||
const defaultProvider = "google-genai";
|
||||
setEditProvider(defaultProvider);
|
||||
setEditKey("");
|
||||
setEditType("generative");
|
||||
const pMeta = availableProviders.find((p) => p.id === defaultProvider);
|
||||
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
setEditMaxContext(32768);
|
||||
} else {
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst) {
|
||||
setEditName(inst.name);
|
||||
setEditProvider(inst.providerName);
|
||||
setEditKey("");
|
||||
setEditType(inst.type || "generative");
|
||||
const pMeta = availableProviders.find(
|
||||
(p) => p.id === inst.providerName,
|
||||
);
|
||||
setEditModel(
|
||||
inst.modelName ||
|
||||
(inst.type === "embedding"
|
||||
? pMeta?.defaultEmbeddingModel
|
||||
: pMeta?.defaultModel) ||
|
||||
"gemini-2.5-flash",
|
||||
);
|
||||
setEditIsActive(inst.isActive);
|
||||
setEditMaxContext(
|
||||
inst.maxContext !== undefined && inst.maxContext !== null
|
||||
? inst.maxContext
|
||||
: 32768,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [selectedInstanceId, instances, availableProviders]);
|
||||
|
||||
const handleProviderChange = (providerId: string | null) => {
|
||||
if (!providerId) return;
|
||||
setEditProvider(providerId);
|
||||
const pMeta = availableProviders.find((p) => p.id === providerId);
|
||||
setEditModel(
|
||||
editType === "embedding"
|
||||
? pMeta?.defaultEmbeddingModel || ""
|
||||
: pMeta?.defaultModel || "",
|
||||
);
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: "generative" | "embedding") => {
|
||||
setEditType(type);
|
||||
const pMeta = availableProviders.find((p) => p.id === editProvider);
|
||||
setEditModel(
|
||||
type === "embedding"
|
||||
? pMeta?.defaultEmbeddingModel || ""
|
||||
: pMeta?.defaultModel || "",
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editName.trim()) {
|
||||
setError("Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
let shouldRegenerate = false;
|
||||
let targetInstanceId = selectedInstanceId;
|
||||
|
||||
if (selectedInstanceId === "new") {
|
||||
if (!editKey.trim()) {
|
||||
setError("API Key is required for new instances.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const created = await createProviderInstance(
|
||||
editName,
|
||||
editProvider,
|
||||
editKey,
|
||||
editModel || undefined,
|
||||
editType,
|
||||
editType === "generative" ? editMaxContext : 0,
|
||||
);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(created.id);
|
||||
}
|
||||
targetInstanceId = created.id;
|
||||
setSelectedInstanceId(created.id);
|
||||
} else {
|
||||
if (!selectedInstanceId) return;
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst && inst.type === "embedding") {
|
||||
const isMapped = mappings["embeddings"] === selectedInstanceId;
|
||||
const isActive = inst.isActive && !mappings["embeddings"];
|
||||
if (isMapped || isActive) {
|
||||
const hasChanged =
|
||||
inst.providerName !== editProvider ||
|
||||
inst.modelName !== editModel;
|
||||
if (hasChanged) {
|
||||
const confirmChange = window.confirm(
|
||||
"You have changed the configuration of the active embedding provider. This will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?",
|
||||
);
|
||||
if (!confirmChange) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
shouldRegenerate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await updateProviderInstance(
|
||||
selectedInstanceId,
|
||||
editName,
|
||||
editProvider,
|
||||
editKey || undefined,
|
||||
editModel || undefined,
|
||||
editType,
|
||||
editType === "generative" ? editMaxContext : 0,
|
||||
);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(selectedInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
await onChanged();
|
||||
|
||||
if (shouldRegenerate && targetInstanceId && targetInstanceId !== "new") {
|
||||
await regenerateEmbeddings(targetInstanceId);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
|
||||
if (!confirm("Are you sure you want to delete this provider instance?"))
|
||||
return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
await deleteProviderInstance(selectedInstanceId);
|
||||
setSelectedInstanceId(null);
|
||||
await onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-8">
|
||||
{error && (
|
||||
<div className="mb-4 rounded border-2 border-red-500 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 grid min-h-[400px] grid-cols-1 gap-4 md:grid-cols-[30%_70%]",
|
||||
loading && "pointer-events-none opacity-60",
|
||||
"transition-opacity duration-200",
|
||||
)}
|
||||
>
|
||||
{/* Left panel - Instance list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Instances</CardTitle>
|
||||
<CardAction>
|
||||
<Button onClick={() => setSelectedInstanceId("new")} size="sm">
|
||||
+ Add
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{instances.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyTitle>No instances configured</EmptyTitle>
|
||||
</Empty>
|
||||
) : (
|
||||
<ItemGroup>
|
||||
{instances.map((inst) => (
|
||||
<Item
|
||||
key={inst.id}
|
||||
variant={
|
||||
selectedInstanceId === inst.id ? "outline" : "muted"
|
||||
}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selectedInstanceId === inst.id && "border-primary",
|
||||
)}
|
||||
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>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right panel - Form */}
|
||||
<Card>
|
||||
{selectedInstanceId === null ? (
|
||||
<CardContent className="flex min-h-[300px] items-center justify-center">
|
||||
<Empty>
|
||||
<EmptyTitle>No instance selected</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Press + to add or select an existing Instance to edit
|
||||
</EmptyDescription>
|
||||
</Empty>
|
||||
</CardContent>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="flex h-full flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{selectedInstanceId === "new"
|
||||
? "Create New Provider Instance"
|
||||
: `Configure: ${editName}`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formName">Friendly Name</Label>
|
||||
<Input
|
||||
id="formName"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="e.g. Gemini - Production"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[2fr_3fr] gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Instance Type</Label>
|
||||
<Select
|
||||
value={editType}
|
||||
onValueChange={(v) =>
|
||||
handleTypeChange(v as "generative" | "embedding")
|
||||
}
|
||||
items={[
|
||||
{
|
||||
label: "Generative (Text Completion)",
|
||||
value: "generative",
|
||||
},
|
||||
{
|
||||
label: "Embedding (Vector generation)",
|
||||
value: "embedding",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="generative">
|
||||
Generative (Chat / Text Completion)
|
||||
</SelectItem>
|
||||
<SelectItem value="embedding">
|
||||
Embedding (Vector generation)
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Provider Type</Label>
|
||||
<Select
|
||||
value={editProvider}
|
||||
onValueChange={handleProviderChange}
|
||||
items={availableProviders.map((p) => ({
|
||||
label: p.displayName,
|
||||
value: p.id,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="end"
|
||||
className="min-w-[var(--anchor-width)]"
|
||||
>
|
||||
<SelectGroup>
|
||||
{availableProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{editProvider && availableProviders.length > 0 && (
|
||||
<span className="block rounded border-2 bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
{
|
||||
availableProviders.find((p) => p.id === editProvider)
|
||||
?.description
|
||||
}
|
||||
</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>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formModel">Model Name</Label>
|
||||
<Input
|
||||
id="formModel"
|
||||
value={editModel}
|
||||
onChange={(e) => setEditModel(e.target.value)}
|
||||
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editType === "generative" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formMaxContext">
|
||||
Max Context Length (Tokens, 0 for infinite)
|
||||
</Label>
|
||||
<Input
|
||||
id="formMaxContext"
|
||||
type="number"
|
||||
value={editMaxContext}
|
||||
onChange={(e) =>
|
||||
setEditMaxContext(parseInt(e.target.value) || 0)
|
||||
}
|
||||
min={0}
|
||||
placeholder="e.g. 32768"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Checkbox
|
||||
id="formActive"
|
||||
checked={editIsActive}
|
||||
onCheckedChange={(v) => setEditIsActive(v === true)}
|
||||
/>
|
||||
<Label htmlFor="formActive" className="cursor-pointer">
|
||||
Set as Active Instance
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<div>
|
||||
{selectedInstanceId !== "new" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/play", label: "Play" },
|
||||
{ href: "/config", label: "Config" },
|
||||
];
|
||||
|
||||
export function NavBar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-4 border-b-2 px-4 py-3">
|
||||
<Button variant="link" asChild className="font-head text-base font-bold no-underline">
|
||||
<Link href="/">Omnia</Link>
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
{links.map((link) => (
|
||||
<Button
|
||||
key={link.href}
|
||||
variant={pathname === link.href ? "default" : "ghost"}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href={link.href}>{link.label}</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
448
apps/gui/src/components/play/DashboardView.tsx
Normal file
448
apps/gui/src/components/play/DashboardView.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
startSimulation,
|
||||
listSavedSimulations,
|
||||
getConfigStatus,
|
||||
getScenarioEntities,
|
||||
deleteSimulation,
|
||||
listProviderInstances,
|
||||
} from "@/app/actions";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import type { ModelProviderInstance } from "@omnia/llm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScenarioCard } from "@/components/play/ScenarioCard";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function DashboardView() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [savedSessions, setSavedSessions] = useState<SimSnapshot[]>([]);
|
||||
const [scenarios, setScenarios] = useState<
|
||||
{ path: string; name: string; description: string }[]
|
||||
>([]);
|
||||
const [providerInstances, setProviderInstances] = useState<
|
||||
ModelProviderInstance[]
|
||||
>([]);
|
||||
|
||||
// Modal State
|
||||
const [scenarioForModal, setScenarioForModal] = useState<{
|
||||
path: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [modalEntities, setModalEntities] = useState<
|
||||
{ id: string; name: string }[]
|
||||
>([]);
|
||||
const [loadingEntities, setLoadingEntities] = useState(false);
|
||||
const [selectedEntityForModal, setSelectedEntityForModal] =
|
||||
useState<string>("");
|
||||
|
||||
const loadSavedSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await listSavedSimulations();
|
||||
if (res.ok) {
|
||||
setSavedSessions(res.sessions);
|
||||
}
|
||||
} catch {
|
||||
// sessions load failed, ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
async function loadAll() {
|
||||
setLoadingData(true);
|
||||
try {
|
||||
const sessionsRes = await listSavedSimulations();
|
||||
if (active && sessionsRes.ok) {
|
||||
setSavedSessions(sessionsRes.sessions);
|
||||
}
|
||||
} catch {
|
||||
// session load failed, ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const configStatus = await getConfigStatus();
|
||||
if (active) {
|
||||
setScenarios(configStatus.availableScenarios);
|
||||
}
|
||||
} catch {
|
||||
// scenarios load failed, ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const providersList = await listProviderInstances();
|
||||
if (active) {
|
||||
setProviderInstances(providersList);
|
||||
}
|
||||
} catch {
|
||||
// providers load failed, ignore
|
||||
}
|
||||
|
||||
if (active) {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}
|
||||
loadAll();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleResume = (id: string) => {
|
||||
router.push(`/play?simId=${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm("Are you sure you want to delete this simulation session?"))
|
||||
return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await deleteSimulation(id);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
} else {
|
||||
await loadSavedSessions();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to delete session.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScenarioClick = async (scenario: {
|
||||
path: string;
|
||||
name: string;
|
||||
}) => {
|
||||
setScenarioForModal(scenario);
|
||||
setLoadingEntities(true);
|
||||
setSelectedEntityForModal(""); // Reset selection to Spectator
|
||||
try {
|
||||
const res = await getScenarioEntities(scenario.path);
|
||||
if (res.ok) {
|
||||
setModalEntities(res.entities);
|
||||
} else {
|
||||
setModalEntities([]);
|
||||
}
|
||||
} catch {
|
||||
setModalEntities([]);
|
||||
} finally {
|
||||
setLoadingEntities(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartFromModal = async () => {
|
||||
if (!scenarioForModal) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const targetScenario = scenarioForModal;
|
||||
setScenarioForModal(null); // close modal
|
||||
try {
|
||||
const result = await startSimulation({
|
||||
scenario: targetScenario.path,
|
||||
playEntity: selectedEntityForModal || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to the separate play page with the newly created simulation ID
|
||||
router.push(`/play?simId=${result.snapshot.id}`);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to start simulation.",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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="animate-fade-in">
|
||||
{/* Centered Big Logo */}
|
||||
<div className="flex flex-col items-center justify-center mb-10 pt-4">
|
||||
<img
|
||||
src="/logo-shadow.png"
|
||||
alt="Omnia Logo"
|
||||
className="h-28 object-contain mb-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</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">
|
||||
Simulations
|
||||
</h2>
|
||||
{loadingData ? (
|
||||
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all flex flex-col justify-between h-[148px]"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : savedSessions.length === 0 ? (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
No saved simulations found. Start a new one below!
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
|
||||
{savedSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
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">
|
||||
{s.scenarioName}
|
||||
</strong>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Turn {s.turn} · {s.entities.length} entities
|
||||
· {s.status}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60 font-mono">
|
||||
ID: {s.id.substring(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-xs text-primary font-mono uppercase tracking-wider">
|
||||
Resume
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={(e) => handleDelete(s.id, e)}
|
||||
disabled={loading}
|
||||
title="Delete Session"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Scenarios Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-headline-lg text-primary mb-6 animate-fade-in">
|
||||
Scenarios
|
||||
</h2>
|
||||
{loadingData ? (
|
||||
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
|
||||
<Link href="/builder" className="no-underline flex-shrink-0">
|
||||
<div className="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 flex flex-col justify-between h-full min-h-[148px]">
|
||||
<div>
|
||||
<strong className="text-body-md text-foreground block mb-1">
|
||||
Build a scenario
|
||||
</strong>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Create a custom simulation starting point
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs font-mono uppercase tracking-wider text-primary">
|
||||
<span className="flex items-center justify-center size-5 border border-primary text-primary font-bold text-sm bg-primary/10">
|
||||
+
|
||||
</span>
|
||||
<span>Create New</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm flex flex-col justify-between h-[148px]"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
|
||||
<Link href="/builder" className="no-underline flex-shrink-0">
|
||||
<div className="w-64 border border-primary bg-primary p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
|
||||
<div>
|
||||
<strong className="text-body-md text-surface block mb-1">
|
||||
Build a scenario
|
||||
</strong>
|
||||
<span className="text-xs text-surface/80">
|
||||
Create a custom simulation starting point
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs font-mono uppercase tracking-wider text-surface">
|
||||
<span className="flex items-center justify-center size-5 border border-surface text-surface font-bold text-sm bg-surface/20">
|
||||
+
|
||||
</span>
|
||||
<span>Create New</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{scenarios.map((s) => (
|
||||
<ScenarioCard
|
||||
key={s.path}
|
||||
name={s.name}
|
||||
description={s.description}
|
||||
onClick={() => handleScenarioClick(s)}
|
||||
disabled={providerInstances.length === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Start Scenario Selection Modal */}
|
||||
{scenarioForModal && (
|
||||
<Dialog
|
||||
open={!!scenarioForModal}
|
||||
onOpenChange={(open) => !open && setScenarioForModal(null)}
|
||||
>
|
||||
<DialogContent className="max-w-[400px]">
|
||||
<DialogHeader className="border-b border-dotted border-border/20 pb-4 mb-2">
|
||||
<DialogTitle>Start Scenario</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose how you want to interact with{" "}
|
||||
<strong>{scenarioForModal.name}</strong>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loadingEntities ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
|
||||
<Spinner />
|
||||
<span>Loading scenario entities...</span>
|
||||
</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">
|
||||
Simulation Mode / Play as
|
||||
</label>
|
||||
<Select
|
||||
value={selectedEntityForModal}
|
||||
onValueChange={(val) =>
|
||||
setSelectedEntityForModal(val || "")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="-- Run Fully Autonomously --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="">
|
||||
-- Run Fully Autonomously --
|
||||
</SelectItem>
|
||||
{modalEntities.map((ent) => (
|
||||
<SelectItem key={ent.id} value={ent.id}>
|
||||
Play as {ent.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setScenarioForModal(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStartFromModal}
|
||||
disabled={loading || loadingEntities}
|
||||
>
|
||||
{loading ? "Starting..." : "Launch"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
457
apps/gui/src/components/play/PromptModal.tsx
Normal file
457
apps/gui/src/components/play/PromptModal.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
import { PromptSwitcher } from "@/components/play/PromptSwitcher";
|
||||
|
||||
interface PromptModalProps {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry.rawPrompt && entry.decoderPrompt) {
|
||||
setActiveTab("decoder");
|
||||
}
|
||||
}, [entry]);
|
||||
|
||||
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">
|
||||
<DialogHeader className="px-6 pt-5 pb-4 border-b">
|
||||
<DialogTitle className="text-lg">
|
||||
Raw Prompts & Token Usage ({entry.entityName})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<PromptSwitcher
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
hasActor={!!entry.rawPrompt}
|
||||
hasDecoder={!!entry.decoderPrompt}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
43
apps/gui/src/components/play/PromptSwitcher.tsx
Normal file
43
apps/gui/src/components/play/PromptSwitcher.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
interface PromptSwitcherProps {
|
||||
activeTab: "actor" | "decoder";
|
||||
onTabChange: (tab: "actor" | "decoder") => void;
|
||||
hasActor: boolean;
|
||||
hasDecoder: boolean;
|
||||
}
|
||||
|
||||
export function PromptSwitcher({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
hasActor,
|
||||
hasDecoder,
|
||||
}: 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>
|
||||
);
|
||||
}
|
||||
47
apps/gui/src/components/play/ScenarioCard.tsx
Normal file
47
apps/gui/src/components/play/ScenarioCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen).trimEnd() + "...";
|
||||
}
|
||||
|
||||
interface ScenarioCardProps {
|
||||
name: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ScenarioCard({
|
||||
name,
|
||||
description,
|
||||
onClick,
|
||||
disabled,
|
||||
}: ScenarioCardProps) {
|
||||
return (
|
||||
<div
|
||||
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 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 };
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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-2 px-2 py-0.5 text-xs font-head font-medium whitespace-nowrap shadow-sm 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!",
|
||||
"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!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -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,28 +1,28 @@
|
||||
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 rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary-hover",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
outline: "bg-background hover:bg-secondary",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"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",
|
||||
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 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
sm: "h-9 px-3 text-xs",
|
||||
lg: "h-11 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
@@ -30,27 +30,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,
|
||||
@@ -12,12 +12,12 @@ function Card({
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded border-2 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-md [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -25,25 +25,22 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
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">) {
|
||||
@@ -84,12 +81,12 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t-2 bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
"flex items-center border-t border-dotted border-border/20 bg-muted/30 px-(--card-spacing) pt-(--card-spacing)",
|
||||
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 };
|
||||
|
||||
302
apps/gui/src/components/ui/combobox.tsx
Normal file
302
apps/gui/src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
"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 && (
|
||||
<ComboboxTrigger
|
||||
render={
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{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(
|
||||
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
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-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
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(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm border-2 bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
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}
|
||||
>
|
||||
@@ -70,7 +70,7 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon"
|
||||
>
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
200
apps/gui/src/components/ui/dropdown-menu.tsx
Normal file
200
apps/gui/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
101
apps/gui/src/components/ui/empty.tsx
Normal file
101
apps/gui/src/components/ui/empty.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
};
|
||||
170
apps/gui/src/components/ui/input-group.tsx
Normal file
170
apps/gui/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"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 border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]",
|
||||
"h-9 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
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)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] 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",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
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(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
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 focus-visible:ring-0 dark: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-3 shadow-none focus-visible:ring-0 dark: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 };
|
||||
|
||||
201
apps/gui/src/components/ui/item.tsx
Normal file
201
apps/gui/src/components/ui/item.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
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";
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex w-full flex-wrap items-center rounded border-2 text-sm transition-colors duration-100 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary [a]:transition-colors [a]:hover:bg-accent",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent",
|
||||
outline: "border-border",
|
||||
muted: "border-transparent bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-2.5 px-3 py-2.5",
|
||||
sm: "gap-2.5 px-3 py-2.5",
|
||||
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "item",
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
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 };
|
||||
|
||||
292
apps/gui/src/components/ui/menubar.tsx
Normal file
292
apps/gui/src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar";
|
||||
import { CheckIcon, ChevronRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
|
||||
return (
|
||||
<MenubarPrimitive
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
"flex h-8 items-center gap-0.5 rounded border-2 bg-background p-[3px] shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="menubar-menu" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="menubar-group" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="menubar-portal" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-none select-none hover:bg-primary/15 hover:text-primary aria-expanded:bg-primary/15 aria-expanded:text-primary cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props & {
|
||||
align?: MenuPrimitive.Positioner.Props["align"];
|
||||
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
|
||||
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="menubar-content"
|
||||
className={cn(
|
||||
"min-w-36 rounded border-2 bg-popover p-1 text-popover-foreground shadow-md duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/menubar-item gap-1.5 rounded-sm px-2 py-1.5 text-sm focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary not-data-[variant=destructive]:focus:**:text-primary not-data-[variant=destructive]:data-highlighted:**:text-primary data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-sm py-1.5 pr-2 pl-7 text-sm outline-none select-none focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary focus:**:text-primary data-highlighted:**:text-primary data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-sm py-1.5 pr-2 pl-7 text-sm outline-none select-none focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary focus:**:text-primary data-highlighted:**:text-primary data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-primary group-data-highlighted/menubar-item:text-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Submenu components using SubmenuRoot and SubmenuTrigger
|
||||
function MenubarSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="menubar-sub" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-1.5 rounded-sm px-2 py-1.5 text-sm focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary data-inset:pl-7 data-open:bg-primary/15 data-open:text-primary data-highlighted:text-primary [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto size-4" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props & {
|
||||
align?: MenuPrimitive.Positioner.Props["align"];
|
||||
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
|
||||
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"min-w-32 rounded border-2 bg-popover p-1 text-popover-foreground shadow-md duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
};
|
||||
@@ -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 rounded px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-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-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-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({
|
||||
@@ -90,12 +90,12 @@ function NavigationMenuContent({
|
||||
<NavigationMenuPrimitive.Content
|
||||
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:rounded group-data-[viewport=false]/navigation-menu:border-2 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
|
||||
"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,
|
||||
)}
|
||||
{...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 rounded border-2 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
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
@@ -128,12 +128,12 @@ function NavigationMenuLink({
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-sm p-2 text-sm transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-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-accent data-active:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
"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,
|
||||
)}
|
||||
{...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,34 +1,31 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
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";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
@@ -36,144 +33,159 @@ function SelectTrigger({
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded border-2 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
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded border-2 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=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", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
{...props}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
"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}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
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}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -187,4 +199,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
141
apps/gui/src/components/ui/sheet.tsx
Normal file
141
apps/gui/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"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 { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
702
apps/gui/src/components/ui/sidebar.tsx
Normal file
702
apps/gui/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,702 @@
|
||||
"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 { 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";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} 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";
|
||||
|
||||
type SidebarContextProps = {
|
||||
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);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
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 setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
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 contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-card text-sidebar-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-card p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"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)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-card group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! 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-open:hover:bg-accent data-open:hover:text-accent-foreground data-active:bg-primary data-active:font-medium data-active:text-primary-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-accent hover:text-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-accent hover:text-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
16
apps/gui/src/components/ui/skeleton.tsx
Normal file
16
apps/gui/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted/60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
30
apps/gui/src/components/ui/tooltip.tsx
Normal file
30
apps/gui/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
21
apps/gui/src/hooks/use-mobile.tsx
Normal file
21
apps/gui/src/hooks/use-mobile.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export interface EntityInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isPlayer: boolean;
|
||||
isAgent: boolean;
|
||||
}
|
||||
|
||||
export interface WaitingContext {
|
||||
|
||||
@@ -17,7 +17,12 @@ for (const c of envCandidates) {
|
||||
}
|
||||
}
|
||||
|
||||
import { BufferRepository, LedgerRepository, HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
|
||||
import {
|
||||
BufferRepository,
|
||||
LedgerRepository,
|
||||
HandoffEngine,
|
||||
checkHandoffTrigger,
|
||||
} from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import {
|
||||
ActorAgent,
|
||||
@@ -25,7 +30,17 @@ import {
|
||||
IActorProseGenerator,
|
||||
buildBufferEntryForIntent,
|
||||
} from "@omnia/actor";
|
||||
import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager, OpenRouterProvider, IEmbeddingProvider, GeminiEmbeddingProvider, MockEmbeddingProvider, ModelProviderInstance } from "@omnia/llm";
|
||||
import {
|
||||
GeminiProvider,
|
||||
ILLMProvider,
|
||||
MockLLMProvider,
|
||||
ProviderManager,
|
||||
OpenRouterProvider,
|
||||
IEmbeddingProvider,
|
||||
GeminiEmbeddingProvider,
|
||||
MockEmbeddingProvider,
|
||||
ModelProviderInstance,
|
||||
} from "@omnia/llm";
|
||||
import { ScenarioLoader } from "@omnia/scenario";
|
||||
|
||||
import type {
|
||||
@@ -69,15 +84,22 @@ interface SavedState {
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
|
||||
function loadSessionState(db: Database.Database, id: string): SavedState | null {
|
||||
function loadSessionState(
|
||||
db: Database.Database,
|
||||
id: string,
|
||||
): SavedState | null {
|
||||
try {
|
||||
db.prepare(`
|
||||
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;
|
||||
`,
|
||||
).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;
|
||||
@@ -129,7 +151,13 @@ class SimulationManager {
|
||||
if (!activeInstance) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
activeInstance = ProviderManager.create("Default (Env)", "google-genai", envKey, undefined, "generative");
|
||||
activeInstance = ProviderManager.create(
|
||||
"Default (Env)",
|
||||
"google-genai",
|
||||
envKey,
|
||||
undefined,
|
||||
"generative",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!activeInstance) {
|
||||
@@ -143,7 +171,8 @@ class SimulationManager {
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
error: "No active LLM Provider Instance found. Please configure a key in Settings first.",
|
||||
error:
|
||||
"No active LLM Provider Instance found. Please configure a key in Settings first.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +213,7 @@ class SimulationManager {
|
||||
id: e.id,
|
||||
name: (e.attributes.get("name")?.getValue() as string) || e.id,
|
||||
isPlayer: false,
|
||||
isAgent: e.isAgent,
|
||||
}));
|
||||
|
||||
let playerEntityId: string | undefined;
|
||||
@@ -192,8 +222,7 @@ class SimulationManager {
|
||||
if (!matched) {
|
||||
for (const ent of rawEntities) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue() as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
|
||||
matched = ent;
|
||||
break;
|
||||
@@ -203,8 +232,7 @@ class SimulationManager {
|
||||
if (!matched) {
|
||||
for (const ent of rawEntities) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue() as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
if (
|
||||
nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
|
||||
ent.id.toLowerCase().includes(playEntityName.toLowerCase())
|
||||
@@ -232,7 +260,7 @@ class SimulationManager {
|
||||
inst = active;
|
||||
}
|
||||
|
||||
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
|
||||
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;
|
||||
@@ -254,7 +282,7 @@ class SimulationManager {
|
||||
inst = ProviderManager.getActive("embedding");
|
||||
}
|
||||
|
||||
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
|
||||
const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || "";
|
||||
const providerName = inst ? inst.providerName : "google-genai";
|
||||
const modelName = inst ? inst.modelName : undefined;
|
||||
|
||||
@@ -340,6 +368,12 @@ class SimulationManager {
|
||||
|
||||
const info = session.entities[session.entityIndex];
|
||||
|
||||
if (!info.isAgent) {
|
||||
session.entityIndex++;
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
if (info.isPlayer) {
|
||||
await this.preparePlayerTurn(session, info);
|
||||
this.save(session);
|
||||
@@ -402,8 +436,14 @@ class SimulationManager {
|
||||
},
|
||||
};
|
||||
|
||||
if (session.decoderProvider.lastCalls && session.decoderProvider.lastCalls.length > 0) {
|
||||
const call = session.decoderProvider.lastCalls[session.decoderProvider.lastCalls.length - 1];
|
||||
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,
|
||||
@@ -487,15 +527,17 @@ class SimulationManager {
|
||||
session: SimSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
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 promptBuilder = new ActorPromptBuilder(
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
);
|
||||
const { systemPrompt, userContext } = promptBuilder.build(
|
||||
worldState,
|
||||
entity,
|
||||
@@ -514,9 +556,7 @@ class SimulationManager {
|
||||
session: SimSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(info.id);
|
||||
@@ -539,8 +579,14 @@ class SimulationManager {
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
};
|
||||
|
||||
if (session.actorProvider.lastCalls && session.actorProvider.lastCalls.length > 0) {
|
||||
const actorCall = session.actorProvider.lastCalls[session.actorProvider.lastCalls.length - 1];
|
||||
if (
|
||||
session.actorProvider.lastCalls &&
|
||||
session.actorProvider.lastCalls.length > 0
|
||||
) {
|
||||
const actorCall =
|
||||
session.actorProvider.lastCalls[
|
||||
session.actorProvider.lastCalls.length - 1
|
||||
];
|
||||
entry.rawPrompt = {
|
||||
systemPrompt: actorCall.systemPrompt,
|
||||
userContext: actorCall.userContext,
|
||||
@@ -548,8 +594,14 @@ class SimulationManager {
|
||||
entry.usage = actorCall.usage;
|
||||
}
|
||||
|
||||
if (session.decoderProvider.lastCalls && session.decoderProvider.lastCalls.length > 0) {
|
||||
const decoderCall = session.decoderProvider.lastCalls[session.decoderProvider.lastCalls.length - 1];
|
||||
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,
|
||||
@@ -558,10 +610,7 @@ class SimulationManager {
|
||||
}
|
||||
|
||||
for (const intent of result.intents.intents) {
|
||||
const outcome = await session.architect.processIntent(
|
||||
worldState,
|
||||
intent,
|
||||
);
|
||||
const outcome = await session.architect.processIntent(worldState, intent);
|
||||
const ts = worldState.clock.get().toISOString();
|
||||
|
||||
entry.intents.push({
|
||||
@@ -593,10 +642,7 @@ class SimulationManager {
|
||||
(intent.type === "dialogue" || intent.type === "action")
|
||||
) {
|
||||
for (const [, other] of worldState.entities) {
|
||||
if (
|
||||
other.id !== info.id &&
|
||||
other.locationId === entity.locationId
|
||||
) {
|
||||
if (other.id !== info.id && other.locationId === entity.locationId) {
|
||||
const observerEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
ts,
|
||||
@@ -619,9 +665,7 @@ class SimulationManager {
|
||||
}
|
||||
|
||||
private async runHandoffResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const handoffEngine = new HandoffEngine(
|
||||
@@ -633,24 +677,36 @@ class SimulationManager {
|
||||
|
||||
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 maxContext =
|
||||
session.handoffProvider.maxContext !== undefined
|
||||
? session.handoffProvider.maxContext
|
||||
: 32768;
|
||||
|
||||
const trigger = checkHandoffTrigger(entity, bufferEntries, worldState.clock.get(), maxContext);
|
||||
const trigger = checkHandoffTrigger(
|
||||
entity,
|
||||
bufferEntries,
|
||||
worldState.clock.get(),
|
||||
maxContext,
|
||||
);
|
||||
if (trigger !== "none") {
|
||||
await handoffEngine.runHandoff(entity, bufferEntries, worldState.clock.get());
|
||||
await handoffEngine.runHandoff(
|
||||
entity,
|
||||
bufferEntries,
|
||||
worldState.clock.get(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runAliasResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
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;
|
||||
@@ -723,18 +779,36 @@ class SimulationManager {
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
inst = ProviderManager.create("Default (Env)", "google-genai", envKey, undefined, "generative");
|
||||
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.`);
|
||||
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);
|
||||
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);
|
||||
return new OpenRouterProvider(
|
||||
inst.apiKey,
|
||||
inst.modelName,
|
||||
inst.name,
|
||||
inst.maxContext,
|
||||
);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
@@ -749,12 +823,20 @@ class SimulationManager {
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
inst = ProviderManager.create("Default Embed (Env)", "google-genai", envKey, "gemini-embedding-001", "embedding");
|
||||
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.`);
|
||||
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") {
|
||||
@@ -824,7 +906,9 @@ class SimulationManager {
|
||||
if (!fs.existsSync(dbDir)) return [];
|
||||
|
||||
const snapshots: SimSnapshot[] = [];
|
||||
const files = fs.readdirSync(dbDir).filter(f => f.startsWith("sim-") && f.endsWith(".db"));
|
||||
const files = fs
|
||||
.readdirSync(dbDir)
|
||||
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
|
||||
|
||||
for (const file of files) {
|
||||
const id = file.replace(".db", "");
|
||||
@@ -872,15 +956,19 @@ class SimulationManager {
|
||||
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 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;
|
||||
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 key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || "";
|
||||
const providerName = inst ? inst.providerName : "google-genai";
|
||||
const modelName = inst ? inst.modelName : undefined;
|
||||
|
||||
@@ -898,12 +986,16 @@ class SimulationManager {
|
||||
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 }[];
|
||||
|
||||
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);
|
||||
db.prepare(
|
||||
`UPDATE ledger_entries SET embedding = ? WHERE id = ?`,
|
||||
).run(buffer, row.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to regenerate embeddings for ${file}:`, err);
|
||||
@@ -932,18 +1024,26 @@ class SimulationManager {
|
||||
providerMappings: session.providerMappings,
|
||||
};
|
||||
|
||||
session.db.prepare(`
|
||||
session.db
|
||||
.prepare(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS gui_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
state_json TEXT
|
||||
)
|
||||
`).run();
|
||||
`,
|
||||
)
|
||||
.run();
|
||||
|
||||
session.db.prepare(`
|
||||
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));
|
||||
`,
|
||||
)
|
||||
.run(session.worldInstanceId, JSON.stringify(state));
|
||||
}
|
||||
|
||||
getSnapshot(id: string): SimSnapshot | null {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "^11.9.0",
|
||||
"version": "11.13.0",
|
||||
"onFail": "download"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -106,7 +106,9 @@ Guidelines:
|
||||
if (this.bufferRepo) {
|
||||
try {
|
||||
recentEntries = this.bufferRepo.listForOwner(entity.id);
|
||||
} catch {}
|
||||
} catch {
|
||||
// bufferRepo may not be available
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recent memory ---
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { Entity, WorldState } from "@omnia/core";
|
||||
import { ILLMProvider } 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,
|
||||
@@ -89,7 +92,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,10 +109,13 @@ 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> {
|
||||
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 } = this.promptBuilder.build(
|
||||
worldState,
|
||||
entity,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { WorldState, Entity, AttributeVisibility } from "@omnia/core";
|
||||
import { WorldState, Entity } from "@omnia/core";
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { ActorPromptBuilder } from "../src/actor-prompt-builder";
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
|
||||
|
||||
// Core database schemas for testing
|
||||
db.exec(`
|
||||
CREATE TABLE objects (
|
||||
@@ -32,8 +32,11 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
});
|
||||
|
||||
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"));
|
||||
|
||||
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");
|
||||
@@ -85,7 +88,10 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
||||
19
packages/actor/tests/actor.test.ts
Normal file
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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,8 @@ export class Architect {
|
||||
private timeDeltaGenerator: TimeDeltaGenerator;
|
||||
|
||||
constructor(
|
||||
llmProvider: ILLMProvider | { validator: ILLMProvider; timedelta: ILLMProvider },
|
||||
llmProvider:
|
||||
ILLMProvider | { validator: ILLMProvider; timedelta: ILLMProvider },
|
||||
private repo?: SQLiteRepository,
|
||||
) {
|
||||
let valProv: ILLMProvider;
|
||||
@@ -60,8 +61,12 @@ export class Architect {
|
||||
if (intent.type === "monologue") {
|
||||
return {
|
||||
isValid: true,
|
||||
reason: "Monologue intent bypasses validation (internal thought, not perceivable).",
|
||||
timeDelta: { minutesToAdvance: 0, explanation: "Internal thought — no time elapsed." },
|
||||
reason:
|
||||
"Monologue intent bypasses validation (internal thought, not perceivable).",
|
||||
timeDelta: {
|
||||
minutesToAdvance: 0,
|
||||
explanation: "Internal thought — no time elapsed.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,21 @@ export class TimeDeltaGenerator implements IDeltaGenerator<TimeDelta> {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
|
||||
async generate(worldState: WorldState, intent: Intent): Promise<TimeDelta> {
|
||||
// We can do this right now because we haven't yet started the implementation of the Event Scheduler or a Tick Engine
|
||||
// Once we do, please review this code.
|
||||
if (intent.type === "dialogue") {
|
||||
return {
|
||||
minutesToAdvance: 1,
|
||||
explanation: "Dialogue action; 1 minute granted for quick exchange.",
|
||||
};
|
||||
}
|
||||
if (intent.type === "monologue") {
|
||||
return {
|
||||
minutesToAdvance: 0,
|
||||
explanation: "Monologue action; no time advanced for internal thought.",
|
||||
};
|
||||
}
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Time Delta Generator for the World Architect.
|
||||
Your task is to judge how much time (in minutes) a proposed action would logically take to execute in the physical world.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./llm-validator.js";
|
||||
export * from "./architect.js";
|
||||
export * from "./delta.js";
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ export class LLMValidator {
|
||||
if (intent.type === "monologue") {
|
||||
return {
|
||||
isValid: true,
|
||||
reason: "Monologue intents are internal thoughts and bypass validation.",
|
||||
reason:
|
||||
"Monologue intents are internal thoughts and bypass validation.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { WorldState, Entity, SQLiteRepository, AttributeVisibility } from "@omnia/core";
|
||||
import {
|
||||
WorldState,
|
||||
Entity,
|
||||
SQLiteRepository,
|
||||
AttributeVisibility,
|
||||
} from "@omnia/core";
|
||||
import { MockLLMProvider } from "@omnia/llm";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import { Intent } from "@omnia/intent";
|
||||
@@ -96,7 +101,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,8 +114,14 @@ 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);
|
||||
@@ -131,7 +145,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,7 +167,10 @@ 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);
|
||||
@@ -188,8 +207,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);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -25,7 +25,11 @@ 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");
|
||||
|
||||
@@ -5,8 +5,5 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../llm" }
|
||||
]
|
||||
"references": [{ "path": "../core" }, { "path": "../llm" }]
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
|
||||
{
|
||||
id: "openrouter",
|
||||
displayName: "OpenRouter",
|
||||
description: "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
|
||||
description:
|
||||
"Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
|
||||
defaultModel: "google/gemini-2.5-flash",
|
||||
defaultEmbeddingModel: "openai/text-embedding-3-small",
|
||||
},
|
||||
|
||||
@@ -43,8 +43,9 @@ function getSettingsDb() {
|
||||
dbPath = path.join(dbDir, "settings.db");
|
||||
}
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.prepare(`
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS provider_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -54,22 +55,29 @@ function getSettingsDb() {
|
||||
modelName TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'generative'
|
||||
)
|
||||
`).run();
|
||||
`,
|
||||
).run();
|
||||
|
||||
try {
|
||||
db.prepare(`ALTER TABLE provider_instances ADD COLUMN modelName TEXT`).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE provider_instances ADD COLUMN modelName TEXT`,
|
||||
).run();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(`ALTER TABLE provider_instances ADD COLUMN type TEXT NOT NULL DEFAULT 'generative'`).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE provider_instances ADD COLUMN type TEXT NOT NULL DEFAULT 'generative'`,
|
||||
).run();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(`ALTER TABLE provider_instances ADD COLUMN maxContext INTEGER`).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE provider_instances ADD COLUMN maxContext INTEGER`,
|
||||
).run();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -77,7 +85,9 @@ function getSettingsDb() {
|
||||
// Auto-bootstrap environment variables if DB contains 0 instances
|
||||
try {
|
||||
if (!hasBootstrapped) {
|
||||
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
|
||||
const totalCount = db
|
||||
.prepare(`SELECT COUNT(*) as count FROM provider_instances`)
|
||||
.get() as { count: number };
|
||||
if (totalCount.count === 0) {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
@@ -85,26 +95,59 @@ function getSettingsDb() {
|
||||
|
||||
if (googleKey && googleKey.trim()) {
|
||||
const id = "provider-default-google";
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash", "generative", 32768);
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
"Gemini (Env)",
|
||||
"google-genai",
|
||||
googleKey.trim(),
|
||||
1,
|
||||
"gemini-2.5-flash",
|
||||
"generative",
|
||||
32768,
|
||||
);
|
||||
hasInsertedGenerative = true;
|
||||
|
||||
const embedId = "provider-default-google-embed";
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(embedId, "Gemini Embed (Env)", "google-genai", googleKey.trim(), 1, "gemini-embedding-001", "embedding", 0);
|
||||
`,
|
||||
).run(
|
||||
embedId,
|
||||
"Gemini Embed (Env)",
|
||||
"google-genai",
|
||||
googleKey.trim(),
|
||||
1,
|
||||
"gemini-embedding-001",
|
||||
"embedding",
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
const id = "provider-default-openrouter";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash", "generative", 32768);
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
"OpenRouter (Env)",
|
||||
"openrouter",
|
||||
openRouterKey.trim(),
|
||||
isActive,
|
||||
"google/gemini-2.5-flash",
|
||||
"generative",
|
||||
32768,
|
||||
);
|
||||
}
|
||||
}
|
||||
hasBootstrapped = true;
|
||||
@@ -112,7 +155,7 @@ function getSettingsDb() {
|
||||
} catch {
|
||||
// ignore write lock issues or other DB errors during bootstrap
|
||||
}
|
||||
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -138,7 +181,12 @@ export class ProviderManager {
|
||||
isActive: r.isActive === 1,
|
||||
modelName: r.modelName || undefined,
|
||||
type: (r.type as "generative" | "embedding") || "generative",
|
||||
maxContext: r.maxContext !== undefined && r.maxContext !== null ? r.maxContext : (r.type === "embedding" ? 0 : 32768),
|
||||
maxContext:
|
||||
r.maxContext !== undefined && r.maxContext !== null
|
||||
? r.maxContext
|
||||
: r.type === "embedding"
|
||||
? 0
|
||||
: 32768,
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -151,24 +199,51 @@ export class ProviderManager {
|
||||
apiKey: string,
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number
|
||||
maxContext?: number,
|
||||
): ModelProviderInstance {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const id = "provider-" + Date.now();
|
||||
const activeCount = db
|
||||
.prepare(`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?`)
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?`,
|
||||
)
|
||||
.get(type) as { count: number };
|
||||
const isActive = activeCount.count === 0 ? 1 : 0;
|
||||
|
||||
const actualMaxContext = maxContext !== undefined ? maxContext : (type === "generative" ? 32768 : 0);
|
||||
|
||||
db.prepare(`
|
||||
const actualMaxContext =
|
||||
maxContext !== undefined
|
||||
? maxContext
|
||||
: type === "generative"
|
||||
? 32768
|
||||
: 0;
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, name, providerName, apiKey, isActive, modelName || null, type, actualMaxContext);
|
||||
|
||||
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName, type, maxContext: actualMaxContext };
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
name,
|
||||
providerName,
|
||||
apiKey,
|
||||
isActive,
|
||||
modelName || null,
|
||||
type,
|
||||
actualMaxContext,
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
providerName,
|
||||
apiKey,
|
||||
isActive: isActive === 1,
|
||||
modelName,
|
||||
type,
|
||||
maxContext: actualMaxContext,
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -177,15 +252,19 @@ export class ProviderManager {
|
||||
static delete(id: string): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const provider = db.prepare(`SELECT isActive, type FROM provider_instances WHERE id = ?`).get(id) as { isActive: number; type: string } | undefined;
|
||||
const provider = db
|
||||
.prepare(`SELECT isActive, type FROM provider_instances WHERE id = ?`)
|
||||
.get(id) as { isActive: number; type: string } | undefined;
|
||||
db.prepare(`DELETE FROM provider_instances WHERE id = ?`).run(id);
|
||||
|
||||
|
||||
if (provider && provider.isActive === 1) {
|
||||
const next = db
|
||||
.prepare(`SELECT id FROM provider_instances WHERE type = ? LIMIT 1`)
|
||||
.get(provider.type) as { id: string } | undefined;
|
||||
if (next) {
|
||||
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(next.id);
|
||||
db.prepare(
|
||||
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
|
||||
).run(next.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -196,10 +275,16 @@ export class ProviderManager {
|
||||
static setActive(id: string): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const target = db.prepare(`SELECT type FROM provider_instances WHERE id = ?`).get(id) as { type: string } | undefined;
|
||||
const target = db
|
||||
.prepare(`SELECT type FROM provider_instances WHERE id = ?`)
|
||||
.get(id) as { type: string } | undefined;
|
||||
if (target) {
|
||||
db.prepare(`UPDATE provider_instances SET isActive = 0 WHERE type = ?`).run(target.type);
|
||||
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(id);
|
||||
db.prepare(
|
||||
`UPDATE provider_instances SET isActive = 0 WHERE type = ?`,
|
||||
).run(target.type);
|
||||
db.prepare(
|
||||
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
|
||||
).run(id);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -213,75 +298,64 @@ export class ProviderManager {
|
||||
apiKey?: string,
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number
|
||||
maxContext?: number,
|
||||
): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const actualMaxContext = maxContext !== undefined ? maxContext : (type === "generative" ? 32768 : 0);
|
||||
const actualMaxContext =
|
||||
maxContext !== undefined
|
||||
? maxContext
|
||||
: type === "generative"
|
||||
? 32768
|
||||
: 0;
|
||||
if (apiKey && apiKey.trim()) {
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE provider_instances
|
||||
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?
|
||||
WHERE id = ?
|
||||
`).run(name, providerName, apiKey, modelName || null, type, actualMaxContext, id);
|
||||
`,
|
||||
).run(
|
||||
name,
|
||||
providerName,
|
||||
apiKey,
|
||||
modelName || null,
|
||||
type,
|
||||
actualMaxContext,
|
||||
id,
|
||||
);
|
||||
} else {
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE provider_instances
|
||||
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?
|
||||
WHERE id = ?
|
||||
`).run(name, providerName, modelName || null, type, actualMaxContext, id);
|
||||
`,
|
||||
).run(
|
||||
name,
|
||||
providerName,
|
||||
modelName || null,
|
||||
type,
|
||||
actualMaxContext,
|
||||
id,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static getActive(type: "generative" | "embedding" = "generative"): ModelProviderInstance | null {
|
||||
static getActive(
|
||||
type: "generative" | "embedding" = "generative",
|
||||
): ModelProviderInstance | null {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`).get(type) as {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
} | undefined;
|
||||
|
||||
if (!row) {
|
||||
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
|
||||
if (totalCount.count === 0) {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
let hasInsertedGenerative = false;
|
||||
|
||||
if (googleKey && googleKey.trim()) {
|
||||
const id = "provider-default-google";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash", "generative", 32768);
|
||||
hasInsertedGenerative = true;
|
||||
|
||||
const embedId = "provider-default-google-embed";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(embedId, "Gemini Embed (Env)", "google-genai", googleKey.trim(), 1, "gemini-embedding-001", "embedding", 0);
|
||||
}
|
||||
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
const id = "provider-default-openrouter";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash", "generative", 32768);
|
||||
}
|
||||
|
||||
const retryRow = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`).get(type) as {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`,
|
||||
)
|
||||
.get(type) as
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
@@ -290,7 +364,91 @@ export class ProviderManager {
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
} | undefined;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) {
|
||||
const totalCount = db
|
||||
.prepare(`SELECT COUNT(*) as count FROM provider_instances`)
|
||||
.get() as { count: number };
|
||||
if (totalCount.count === 0) {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
let hasInsertedGenerative = false;
|
||||
|
||||
if (googleKey && googleKey.trim()) {
|
||||
const id = "provider-default-google";
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
"Gemini (Env)",
|
||||
"google-genai",
|
||||
googleKey.trim(),
|
||||
1,
|
||||
"gemini-2.5-flash",
|
||||
"generative",
|
||||
32768,
|
||||
);
|
||||
hasInsertedGenerative = true;
|
||||
|
||||
const embedId = "provider-default-google-embed";
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
embedId,
|
||||
"Gemini Embed (Env)",
|
||||
"google-genai",
|
||||
googleKey.trim(),
|
||||
1,
|
||||
"gemini-embedding-001",
|
||||
"embedding",
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
const id = "provider-default-openrouter";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
"OpenRouter (Env)",
|
||||
"openrouter",
|
||||
openRouterKey.trim(),
|
||||
isActive,
|
||||
"google/gemini-2.5-flash",
|
||||
"generative",
|
||||
32768,
|
||||
);
|
||||
}
|
||||
|
||||
const retryRow = db
|
||||
.prepare(
|
||||
`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`,
|
||||
)
|
||||
.get(type) as
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (retryRow) {
|
||||
return {
|
||||
@@ -301,24 +459,36 @@ export class ProviderManager {
|
||||
isActive: true,
|
||||
modelName: retryRow.modelName || undefined,
|
||||
type: retryRow.type as "generative" | "embedding",
|
||||
maxContext: retryRow.maxContext !== undefined && retryRow.maxContext !== null ? retryRow.maxContext : (retryRow.type === "embedding" ? 0 : 32768),
|
||||
maxContext:
|
||||
retryRow.maxContext !== undefined &&
|
||||
retryRow.maxContext !== null
|
||||
? retryRow.maxContext
|
||||
: retryRow.type === "embedding"
|
||||
? 0
|
||||
: 32768,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no active row but some rows exist, return the first one as active, or update it
|
||||
const firstRow = db.prepare(`SELECT * FROM provider_instances WHERE type = ? LIMIT 1`).get(type) as {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
} | undefined;
|
||||
const firstRow = db
|
||||
.prepare(`SELECT * FROM provider_instances WHERE type = ? LIMIT 1`)
|
||||
.get(type) as
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
}
|
||||
| undefined;
|
||||
if (firstRow) {
|
||||
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(firstRow.id);
|
||||
db.prepare(
|
||||
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
|
||||
).run(firstRow.id);
|
||||
return {
|
||||
id: firstRow.id,
|
||||
name: firstRow.name,
|
||||
@@ -327,7 +497,12 @@ export class ProviderManager {
|
||||
isActive: true,
|
||||
modelName: firstRow.modelName || undefined,
|
||||
type: firstRow.type as "generative" | "embedding",
|
||||
maxContext: firstRow.maxContext !== undefined && firstRow.maxContext !== null ? firstRow.maxContext : (firstRow.type === "embedding" ? 0 : 32768),
|
||||
maxContext:
|
||||
firstRow.maxContext !== undefined && firstRow.maxContext !== null
|
||||
? firstRow.maxContext
|
||||
: firstRow.type === "embedding"
|
||||
? 0
|
||||
: 32768,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -341,7 +516,12 @@ export class ProviderManager {
|
||||
isActive: true,
|
||||
modelName: row.modelName || undefined,
|
||||
type: (row.type as "generative" | "embedding") || "generative",
|
||||
maxContext: row.maxContext !== undefined && row.maxContext !== null ? row.maxContext : (row.type === "embedding" ? 0 : 32768),
|
||||
maxContext:
|
||||
row.maxContext !== undefined && row.maxContext !== null
|
||||
? row.maxContext
|
||||
: row.type === "embedding"
|
||||
? 0
|
||||
: 32768,
|
||||
};
|
||||
} catch {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
@@ -396,12 +576,14 @@ export class ProviderManager {
|
||||
static getMappings(): Record<string, string> {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS provider_mappings (
|
||||
task TEXT PRIMARY KEY,
|
||||
providerInstanceId TEXT NOT NULL
|
||||
)
|
||||
`).run();
|
||||
`,
|
||||
).run();
|
||||
const rows = db.prepare(`SELECT * FROM provider_mappings`).all() as {
|
||||
task: string;
|
||||
providerInstanceId: string;
|
||||
@@ -419,20 +601,24 @@ export class ProviderManager {
|
||||
static setMapping(task: string, providerInstanceId: string): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS provider_mappings (
|
||||
task TEXT PRIMARY KEY,
|
||||
providerInstanceId TEXT NOT NULL
|
||||
)
|
||||
`).run();
|
||||
`,
|
||||
).run();
|
||||
if (!providerInstanceId) {
|
||||
db.prepare(`DELETE FROM provider_mappings WHERE task = ?`).run(task);
|
||||
} else {
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_mappings (task, providerInstanceId)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(task) DO UPDATE SET providerInstanceId = excluded.providerInstanceId
|
||||
`).run(task, providerInstanceId);
|
||||
`,
|
||||
).run(task, providerInstanceId);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { z } from "zod";
|
||||
import { ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings } from "@langchain/google-genai";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord, IEmbeddingProvider } from "../llm.js";
|
||||
import {
|
||||
ChatGoogleGenerativeAI,
|
||||
GoogleGenerativeAIEmbeddings,
|
||||
} from "@langchain/google-genai";
|
||||
import {
|
||||
ILLMProvider,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
LLMCallRecord,
|
||||
IEmbeddingProvider,
|
||||
} from "../llm.js";
|
||||
import { llmConfig } from "../config.js";
|
||||
import { ProviderManager } from "../provider-manager.js";
|
||||
|
||||
export class GeminiProvider implements ILLMProvider {
|
||||
static readonly providerId = "google-genai";
|
||||
static readonly displayName = "Google Gemini";
|
||||
static readonly description = "Official Gemini integration using Google Gen AI SDK";
|
||||
static readonly description =
|
||||
"Official Gemini integration using Google Gen AI SDK";
|
||||
static readonly defaultModel = "gemini-2.5-flash";
|
||||
|
||||
providerName = "Gemini";
|
||||
@@ -17,7 +27,12 @@ export class GeminiProvider implements ILLMProvider {
|
||||
private maxContextUsed?: number;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string, maxContext?: number) {
|
||||
constructor(
|
||||
apiKey?: string,
|
||||
modelName?: string,
|
||||
providerInstanceName?: string,
|
||||
maxContext?: number,
|
||||
) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
@@ -47,7 +62,9 @@ export class GeminiProvider implements ILLMProvider {
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error("GOOGLE_API_KEY is required to initialize GeminiProvider");
|
||||
throw new Error(
|
||||
"GOOGLE_API_KEY is required to initialize GeminiProvider",
|
||||
);
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || "gemini-2.5-flash";
|
||||
@@ -60,7 +77,9 @@ export class GeminiProvider implements ILLMProvider {
|
||||
async generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>> {
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, {
|
||||
includeRaw: true,
|
||||
});
|
||||
const result = (await structuredModel.invoke([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
@@ -84,7 +103,8 @@ export class GeminiProvider implements ILLMProvider {
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
maxContext: this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
|
||||
maxContext:
|
||||
this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
@@ -123,7 +143,9 @@ export class GeminiEmbeddingProvider implements IEmbeddingProvider {
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error("GOOGLE_API_KEY is required to initialize GeminiEmbeddingProvider");
|
||||
throw new Error(
|
||||
"GOOGLE_API_KEY is required to initialize GeminiEmbeddingProvider",
|
||||
);
|
||||
}
|
||||
|
||||
this.model = new GoogleGenerativeAIEmbeddings({
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord, IEmbeddingProvider } from "../llm.js";
|
||||
import {
|
||||
ILLMProvider,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
LLMCallRecord,
|
||||
IEmbeddingProvider,
|
||||
} from "../llm.js";
|
||||
|
||||
export class MockLLMProvider implements ILLMProvider {
|
||||
static readonly providerId = "mock";
|
||||
static readonly displayName = "Mock LLM Provider";
|
||||
static readonly description = "Stateless mock provider for testing and offline development";
|
||||
static readonly description =
|
||||
"Stateless mock provider for testing and offline development";
|
||||
static readonly defaultModel = "mock";
|
||||
|
||||
providerName = "mock";
|
||||
@@ -30,7 +37,10 @@ export class MockLLMProvider implements ILLMProvider {
|
||||
const parsed = request.schema.parse(next);
|
||||
return { success: true, data: parsed, usage };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import { ChatOpenRouter } from "@langchain/openrouter";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
|
||||
import {
|
||||
ILLMProvider,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
LLMCallRecord,
|
||||
} from "../llm.js";
|
||||
import { llmConfig } from "../config.js";
|
||||
import { ProviderManager } from "../provider-manager.js";
|
||||
|
||||
export class OpenRouterProvider implements ILLMProvider {
|
||||
static readonly providerId = "openrouter";
|
||||
static readonly displayName = "OpenRouter";
|
||||
static readonly description = "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models";
|
||||
static readonly description =
|
||||
"Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models";
|
||||
static readonly defaultModel = "google/gemini-2.5-flash";
|
||||
|
||||
providerName = "OpenRouter";
|
||||
@@ -17,7 +23,12 @@ export class OpenRouterProvider implements ILLMProvider {
|
||||
private maxContextUsed?: number;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string, maxContext?: number) {
|
||||
constructor(
|
||||
apiKey?: string,
|
||||
modelName?: string,
|
||||
providerInstanceName?: string,
|
||||
maxContext?: number,
|
||||
) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
@@ -47,7 +58,9 @@ export class OpenRouterProvider implements ILLMProvider {
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
|
||||
throw new Error(
|
||||
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider",
|
||||
);
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || "google/gemini-2.5-flash";
|
||||
@@ -60,7 +73,9 @@ export class OpenRouterProvider implements ILLMProvider {
|
||||
async generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>> {
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, {
|
||||
includeRaw: true,
|
||||
});
|
||||
const result = (await structuredModel.invoke([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
@@ -84,7 +99,8 @@ export class OpenRouterProvider implements ILLMProvider {
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
maxContext: this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
|
||||
maxContext:
|
||||
this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("MockEmbeddingProvider Unit Tests (Tier 1)", () => {
|
||||
expect(vec1.length).toBe(768);
|
||||
expect(vec2.length).toBe(768);
|
||||
expect(vec1).toEqual(vec2); // Deterministic
|
||||
|
||||
|
||||
// Ensure values are numbers between -1.0 and 1.0 (since they are generated with Math.sin)
|
||||
expect(typeof vec1[0]).toBe("number");
|
||||
expect(vec1[0]).toBeGreaterThanOrEqual(-1.0);
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
// Save current config
|
||||
const originalKey = llmConfig.OPENROUTER_API_KEY;
|
||||
llmConfig.OPENROUTER_API_KEY = "env-dummy-key";
|
||||
|
||||
|
||||
try {
|
||||
const provider = new OpenRouterProvider();
|
||||
expect(provider.providerName).toBe("OpenRouter");
|
||||
@@ -61,7 +61,7 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
|
||||
try {
|
||||
expect(() => new OpenRouterProvider()).toThrow(
|
||||
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider"
|
||||
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider",
|
||||
);
|
||||
} finally {
|
||||
llmConfig.OPENROUTER_API_KEY = originalKey;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ProviderManager, setDbPathOverride, resetHasBootstrapped } from "../src/index.js";
|
||||
import {
|
||||
ProviderManager,
|
||||
setDbPathOverride,
|
||||
resetHasBootstrapped,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
|
||||
let tempDbPath: string;
|
||||
@@ -17,7 +21,10 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
|
||||
resetHasBootstrapped();
|
||||
|
||||
// Generate a unique temp database path for this test run
|
||||
tempDbPath = path.resolve(process.cwd(), `test-settings-${Date.now()}-${Math.random().toString(36).substring(2)}.db`);
|
||||
tempDbPath = path.resolve(
|
||||
process.cwd(),
|
||||
`test-settings-${Date.now()}-${Math.random().toString(36).substring(2)}.db`,
|
||||
);
|
||||
setDbPathOverride(tempDbPath);
|
||||
});
|
||||
|
||||
@@ -76,8 +83,14 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
|
||||
expect(bootstrapped.isActive).toBe(true);
|
||||
|
||||
// Edit name and key
|
||||
ProviderManager.update(bootstrapped.id, "My Gemini Key", "google-genai", "new-secret-key", "gemini-2.5-pro");
|
||||
|
||||
ProviderManager.update(
|
||||
bootstrapped.id,
|
||||
"My Gemini Key",
|
||||
"google-genai",
|
||||
"new-secret-key",
|
||||
"gemini-2.5-pro",
|
||||
);
|
||||
|
||||
const listAfterUpdate = ProviderManager.list();
|
||||
expect(listAfterUpdate.length).toBe(2);
|
||||
const updated = listAfterUpdate.find((p) => p.id === bootstrapped.id);
|
||||
|
||||
@@ -26,7 +26,11 @@ export function serializeSubjectiveBufferEntry(
|
||||
const isSelf = viewer.id === entry.intent.actorId;
|
||||
|
||||
if (isSelf) {
|
||||
let details = (entry.intent.selfDescription || entry.intent.description || entry.intent.originalText).trim();
|
||||
let details = (
|
||||
entry.intent.selfDescription ||
|
||||
entry.intent.description ||
|
||||
entry.intent.originalText
|
||||
).trim();
|
||||
if (details.length > 0) {
|
||||
details = details.charAt(0).toUpperCase() + details.slice(1);
|
||||
}
|
||||
@@ -69,7 +73,9 @@ export class BufferRepository {
|
||||
`);
|
||||
|
||||
try {
|
||||
this.db.exec(`ALTER TABLE buffer_entries ADD COLUMN pinned INTEGER DEFAULT 0;`);
|
||||
this.db.exec(
|
||||
`ALTER TABLE buffer_entries ADD COLUMN pinned INTEGER DEFAULT 0;`,
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { Entity, naturalizeTime } from "@omnia/core";
|
||||
import { BufferEntry, serializeSubjectiveBufferEntry, BufferRepository } from "./buffer.js";
|
||||
import {
|
||||
BufferEntry,
|
||||
serializeSubjectiveBufferEntry,
|
||||
BufferRepository,
|
||||
} from "./buffer.js";
|
||||
import { LedgerEntry, LedgerRepository } from "./ledger.js";
|
||||
import { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
|
||||
|
||||
@@ -56,12 +60,18 @@ function checkSceneExit(entity: Entity, bufferEntries: BufferEntry[]): boolean {
|
||||
|
||||
// Find the location of the most recent buffer entries
|
||||
const lastEntry = bufferEntries[bufferEntries.length - 1];
|
||||
if (lastEntry.locationId && entity.locationId && lastEntry.locationId !== entity.locationId) {
|
||||
if (
|
||||
lastEntry.locationId &&
|
||||
entity.locationId &&
|
||||
lastEntry.locationId !== entity.locationId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check if there are entries from different locations in the buffer
|
||||
const locations = new Set(bufferEntries.map(e => e.locationId).filter(loc => loc !== null));
|
||||
const locations = new Set(
|
||||
bufferEntries.map((e) => e.locationId).filter((loc) => loc !== null),
|
||||
);
|
||||
if (locations.size > 1) {
|
||||
return true;
|
||||
}
|
||||
@@ -75,17 +85,25 @@ function checkIdleDecay(bufferEntries: BufferEntry[]): boolean {
|
||||
|
||||
// Check the last N entries
|
||||
const lastN = bufferEntries.slice(-N);
|
||||
return lastN.every(e => e.intent.type === "monologue");
|
||||
return lastN.every((e) => e.intent.type === "monologue");
|
||||
}
|
||||
|
||||
function checkAttributeTrigger(entity: Entity): boolean {
|
||||
const consciousness = entity.attributes.get("consciousness");
|
||||
if (consciousness && consciousness.getValue().toLowerCase() === "unconscious") {
|
||||
if (
|
||||
consciousness &&
|
||||
consciousness.getValue().toLowerCase() === "unconscious"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = entity.attributes.get("status");
|
||||
if (status && ["unconscious", "asleep", "dead", "inactive"].includes(status.getValue().toLowerCase())) {
|
||||
if (
|
||||
status &&
|
||||
["unconscious", "asleep", "dead", "inactive"].includes(
|
||||
status.getValue().toLowerCase(),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -108,7 +126,7 @@ export function checkHandoffTrigger(
|
||||
// Involuntary triggers first (hard)
|
||||
if (maxContext > 0) {
|
||||
const memoryLength = getMemorySectionLength(entity, bufferEntries, now);
|
||||
const charCeiling = maxContext * 4 * 0.60;
|
||||
const charCeiling = maxContext * 4 * 0.6;
|
||||
if (memoryLength > charCeiling) {
|
||||
return "involuntary";
|
||||
}
|
||||
@@ -154,7 +172,7 @@ export function splitBufferForHandoff(
|
||||
"several minutes ago",
|
||||
]);
|
||||
|
||||
let watermarkStartIndex = sorted.length;
|
||||
let watermarkStartIndex: number;
|
||||
|
||||
// 1. Mark last K entries as watermark
|
||||
if (sorted.length > K) {
|
||||
@@ -201,10 +219,12 @@ export class HandoffEngine {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidatesList = candidates.map((entry) => {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
return `ID: ${entry.id} | Timestamp: ${entry.timestamp} | Location: ${entry.locationId || "None"}\nContent: ${serialized}`;
|
||||
}).join("\n---\n");
|
||||
const candidatesList = candidates
|
||||
.map((entry) => {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
return `ID: ${entry.id} | Timestamp: ${entry.timestamp} | Location: ${entry.locationId || "None"}\nContent: ${serialized}`;
|
||||
})
|
||||
.join("\n---\n");
|
||||
|
||||
const systemPrompt = `
|
||||
You are the memory Handoff Engine. Your task is to process a list of recent working memory buffer entries for an entity and select which memories to promote to the long-term Ledger, and which to forget or summarize.
|
||||
@@ -239,11 +259,15 @@ ${candidatesList}
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
const db = (this.bufferRepo as any).db;
|
||||
const db = (
|
||||
this.bufferRepo as unknown as {
|
||||
db: { transaction: (fn: () => void) => void };
|
||||
}
|
||||
).db;
|
||||
|
||||
const ledgerEntries: LedgerEntry[] = [];
|
||||
for (const chunk of result.chunks) {
|
||||
let embedding: number[] = [];
|
||||
let embedding: number[];
|
||||
try {
|
||||
embedding = await this.embedProvider.embed(chunk.content);
|
||||
} catch (err) {
|
||||
@@ -252,7 +276,11 @@ ${candidatesList}
|
||||
}
|
||||
|
||||
ledgerEntries.push({
|
||||
id: "ledger-" + Math.random().toString(36).substr(2, 9) + "-" + Date.now(),
|
||||
id:
|
||||
"ledger-" +
|
||||
Math.random().toString(36).substr(2, 9) +
|
||||
"-" +
|
||||
Date.now(),
|
||||
ownerId: entity.id,
|
||||
timestamp: now.toISOString(),
|
||||
locationId: entity.locationId,
|
||||
|
||||
@@ -82,7 +82,7 @@ export class LedgerRepository {
|
||||
entry.importance,
|
||||
entry.embedding.length > 0
|
||||
? Buffer.from(new Float32Array(entry.embedding).buffer)
|
||||
: null
|
||||
: null,
|
||||
);
|
||||
|
||||
deleteEntities.run(entry.id);
|
||||
@@ -92,28 +92,31 @@ export class LedgerRepository {
|
||||
})();
|
||||
}
|
||||
|
||||
private mapRowToEntry(row: any, involvedEntityIds: string[]): LedgerEntry {
|
||||
let embedding: number[] = [];
|
||||
if (row.embedding) {
|
||||
const buffer = row.embedding as Buffer;
|
||||
const floatArray = new Float32Array(
|
||||
buffer.buffer,
|
||||
buffer.byteOffset,
|
||||
buffer.byteLength / Float32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
embedding = Array.from(floatArray);
|
||||
}
|
||||
private mapRowToEntry(
|
||||
row: Record<string, unknown>,
|
||||
involvedEntityIds: string[],
|
||||
): LedgerEntry {
|
||||
const embedding: number[] = row.embedding
|
||||
? Array.from(
|
||||
new Float32Array(
|
||||
(row.embedding as Buffer).buffer,
|
||||
(row.embedding as Buffer).byteOffset,
|
||||
(row.embedding as Buffer).byteLength /
|
||||
Float32Array.BYTES_PER_ELEMENT,
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
timestamp: row.timestamp,
|
||||
locationId: row.location_id,
|
||||
id: row.id as string,
|
||||
ownerId: row.owner_id as string,
|
||||
timestamp: row.timestamp as string,
|
||||
locationId: row.location_id as string | null,
|
||||
involvedEntityIds,
|
||||
content: row.content,
|
||||
quotes: JSON.parse(row.quotes_json || "[]"),
|
||||
importance: row.importance,
|
||||
embedding: embedding,
|
||||
content: row.content as string,
|
||||
quotes: JSON.parse((row.quotes_json as string) || "[]"),
|
||||
importance: row.importance as number,
|
||||
embedding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,9 +127,9 @@ export class LedgerRepository {
|
||||
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
|
||||
FROM ledger_entries
|
||||
WHERE id = ?
|
||||
`
|
||||
`,
|
||||
)
|
||||
.get(id) as any;
|
||||
.get(id) as Record<string, unknown> | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
@@ -134,11 +137,14 @@ export class LedgerRepository {
|
||||
.prepare(
|
||||
`
|
||||
SELECT entity_id FROM ledger_involved_entities WHERE entry_id = ?
|
||||
`
|
||||
`,
|
||||
)
|
||||
.all(id) as { entity_id: string }[];
|
||||
|
||||
return this.mapRowToEntry(row, entitiesRows.map((er) => er.entity_id));
|
||||
return this.mapRowToEntry(
|
||||
row,
|
||||
entitiesRows.map((er) => er.entity_id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +158,7 @@ export class LedgerRepository {
|
||||
ownerId: string,
|
||||
currentLocationId: string | null,
|
||||
currentInvolvedEntityIds: string[],
|
||||
limit: number = 20
|
||||
limit: number = 20,
|
||||
): LedgerEntry[] {
|
||||
let query = `
|
||||
SELECT DISTINCT le.id, le.owner_id, le.timestamp, le.location_id, le.content, le.quotes_json, le.importance, le.embedding
|
||||
@@ -163,7 +169,7 @@ export class LedgerRepository {
|
||||
le.importance >= 8
|
||||
`;
|
||||
|
||||
const params: any[] = [ownerId];
|
||||
const params: (string | number)[] = [ownerId];
|
||||
|
||||
if (currentLocationId) {
|
||||
query += ` OR le.location_id = ?`;
|
||||
@@ -183,18 +189,21 @@ export class LedgerRepository {
|
||||
`;
|
||||
params.push(limit);
|
||||
|
||||
const rows = this.db.prepare(query).all(...params) as any[];
|
||||
const rows = this.db.prepare(query).all(...params) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const entryIds = rows.map((r) => r.id);
|
||||
const entryIds = rows.map((r) => r.id as string);
|
||||
const placeholders = entryIds.map(() => "?").join(",");
|
||||
const entitiesRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT entry_id, entity_id FROM ledger_involved_entities
|
||||
WHERE entry_id IN (${placeholders})
|
||||
`
|
||||
`,
|
||||
)
|
||||
.all(...entryIds) as { entry_id: string; entity_id: string }[];
|
||||
|
||||
@@ -206,7 +215,9 @@ export class LedgerRepository {
|
||||
entitiesMap.get(er.entry_id)!.push(er.entity_id);
|
||||
}
|
||||
|
||||
return rows.map((row) => this.mapRowToEntry(row, entitiesMap.get(row.id) || []));
|
||||
return rows.map((row) =>
|
||||
this.mapRowToEntry(row, entitiesMap.get(row.id as string) || []),
|
||||
);
|
||||
}
|
||||
|
||||
private fetchRawNeighbors(ownerId: string, timestamp: string): LedgerEntry[] {
|
||||
@@ -221,9 +232,9 @@ export class LedgerRepository {
|
||||
WHERE owner_id = ? AND timestamp < ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`
|
||||
`,
|
||||
)
|
||||
.get(ownerId, timestamp) as any;
|
||||
.get(ownerId, timestamp) as Record<string, unknown> | undefined;
|
||||
|
||||
if (preceding) {
|
||||
neighbors.push(this.mapRowToEntry(preceding, []));
|
||||
@@ -238,9 +249,9 @@ export class LedgerRepository {
|
||||
WHERE owner_id = ? AND timestamp > ?
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT 1
|
||||
`
|
||||
`,
|
||||
)
|
||||
.get(ownerId, timestamp) as any;
|
||||
.get(ownerId, timestamp) as Record<string, unknown> | undefined;
|
||||
|
||||
if (succeeding) {
|
||||
neighbors.push(this.mapRowToEntry(succeeding, []));
|
||||
@@ -270,16 +281,22 @@ export class LedgerRepository {
|
||||
importanceWeight?: number;
|
||||
relevanceWeight?: number;
|
||||
decayRate?: number;
|
||||
}
|
||||
},
|
||||
): LedgerEntry[] {
|
||||
const includeAssociativeNeighbors = options?.includeAssociativeNeighbors ?? false;
|
||||
const includeAssociativeNeighbors =
|
||||
options?.includeAssociativeNeighbors ?? false;
|
||||
const recencyWeight = options?.recencyWeight ?? 1.0;
|
||||
const importanceWeight = options?.importanceWeight ?? 1.0;
|
||||
const relevanceWeight = options?.relevanceWeight ?? 1.0;
|
||||
const decayRate = options?.decayRate ?? 0.99;
|
||||
|
||||
// Fetch candidate pool (limit 100 to provide enough options for Phase 2 ranking)
|
||||
const candidates = this.getRelevant(ownerId, currentLocationId, currentInvolvedEntityIds, 100);
|
||||
const candidates = this.getRelevant(
|
||||
ownerId,
|
||||
currentLocationId,
|
||||
currentInvolvedEntityIds,
|
||||
100,
|
||||
);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
// Score candidates
|
||||
@@ -310,7 +327,7 @@ export class LedgerRepository {
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const selected = scored.slice(0, limit).map((s) => s.entry);
|
||||
|
||||
let finalEntries = [...selected];
|
||||
const finalEntries = [...selected];
|
||||
|
||||
// Optionally retrieve associative neighbors
|
||||
if (includeAssociativeNeighbors && selected.length > 0) {
|
||||
@@ -319,7 +336,10 @@ export class LedgerRepository {
|
||||
for (const entry of selected) {
|
||||
const rawNeighbors = this.fetchRawNeighbors(ownerId, entry.timestamp);
|
||||
for (const rn of rawNeighbors) {
|
||||
if (!finalEntries.some((fe) => fe.id === rn.id) && !neighborMap.has(rn.id)) {
|
||||
if (
|
||||
!finalEntries.some((fe) => fe.id === rn.id) &&
|
||||
!neighborMap.has(rn.id)
|
||||
) {
|
||||
neighborMap.set(rn.id, rn);
|
||||
}
|
||||
}
|
||||
@@ -334,7 +354,7 @@ export class LedgerRepository {
|
||||
`
|
||||
SELECT entry_id, entity_id FROM ledger_involved_entities
|
||||
WHERE entry_id IN (${placeholders})
|
||||
`
|
||||
`,
|
||||
)
|
||||
.all(...neighborIds) as { entry_id: string; entity_id: string }[];
|
||||
|
||||
@@ -354,7 +374,10 @@ export class LedgerRepository {
|
||||
}
|
||||
|
||||
// Sort chronologically ASC for the final prompt output
|
||||
finalEntries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
finalEntries.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return finalEntries;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
// Add 12 older entries (older than 30 minutes)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const minutesAgo = 60 - i;
|
||||
const timestamp = new Date(now.getTime() - minutesAgo * 60 * 1000).toISOString();
|
||||
const timestamp = new Date(
|
||||
now.getTime() - minutesAgo * 60 * 1000,
|
||||
).toISOString();
|
||||
entries.push({
|
||||
id: `entry-old-${i}`,
|
||||
ownerId: "alice",
|
||||
@@ -80,7 +82,13 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
ownerId: "alice",
|
||||
timestamp: now.toISOString(),
|
||||
locationId: "room-1",
|
||||
intent: { type: "dialogue", originalText: "hello", description: "says hello", actorId: "alice", targetIds: [] },
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: "hello",
|
||||
description: "says hello",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
expect(checkHandoffTrigger(entity, [entryAtRoom1], now)).toBe("voluntary");
|
||||
|
||||
@@ -90,7 +98,13 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
ownerId: "alice",
|
||||
timestamp: now.toISOString(),
|
||||
locationId: "room-2",
|
||||
intent: { type: "monologue", originalText: "think", description: "thinks", actorId: "alice", targetIds: [] },
|
||||
intent: {
|
||||
type: "monologue",
|
||||
originalText: "think",
|
||||
description: "thinks",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
}));
|
||||
expect(checkHandoffTrigger(entity, monologues, now)).toBe("voluntary");
|
||||
});
|
||||
@@ -114,7 +128,9 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
|
||||
const entries: BufferEntry[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const timestamp = new Date(now.getTime() - (50 - i) * 60 * 1000).toISOString();
|
||||
const timestamp = new Date(
|
||||
now.getTime() - (50 - i) * 60 * 1000,
|
||||
).toISOString();
|
||||
const entry: BufferEntry = {
|
||||
id: `entry-${i}`,
|
||||
ownerId: "alice",
|
||||
@@ -147,14 +163,23 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
|
||||
const llmProvider = new MockLLMProvider([mockHandoffResult]);
|
||||
const embedProvider = new MockEmbeddingProvider();
|
||||
const engine = new HandoffEngine(llmProvider, embedProvider, bufferRepo, ledgerRepo);
|
||||
const engine = new HandoffEngine(
|
||||
llmProvider,
|
||||
embedProvider,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
);
|
||||
|
||||
const success = await engine.runHandoff(entity, entries, now);
|
||||
expect(success).toBe(true);
|
||||
|
||||
const ledgerRows = db.prepare("SELECT * FROM ledger_entries WHERE owner_id = ?").all("alice") as any[];
|
||||
const ledgerRows = db
|
||||
.prepare("SELECT * FROM ledger_entries WHERE owner_id = ?")
|
||||
.all("alice") as Record<string, unknown>[];
|
||||
expect(ledgerRows.length).toBe(1);
|
||||
expect(ledgerRows[0].content).toBe("Alice initiated dialogue and performed various tasks.");
|
||||
expect(ledgerRows[0].content).toBe(
|
||||
"Alice initiated dialogue and performed various tasks.",
|
||||
);
|
||||
expect(JSON.parse(ledgerRows[0].quotes_json)).toEqual(["Event 0"]);
|
||||
expect(ledgerRows[0].importance).toBe(5);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("LedgerRepository", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
|
||||
|
||||
// We need to create a dummy objects table to satisfy foreign keys
|
||||
db.exec(`
|
||||
CREATE TABLE objects (
|
||||
@@ -51,7 +51,7 @@ describe("LedgerRepository", () => {
|
||||
expect(loaded?.content).toBe(entry.content);
|
||||
expect(loaded?.quotes).toEqual(entry.quotes);
|
||||
expect(loaded?.importance).toBe(5);
|
||||
|
||||
|
||||
// Check float precision
|
||||
expect(loaded?.embedding[0]).toBeCloseTo(0.1);
|
||||
expect(loaded?.embedding[1]).toBeCloseTo(0.2);
|
||||
@@ -113,7 +113,7 @@ describe("LedgerRepository", () => {
|
||||
});
|
||||
|
||||
const relevant = repo.getRelevant("alice", "loc1", ["bob"]);
|
||||
|
||||
|
||||
expect(relevant).toHaveLength(3);
|
||||
const ids = relevant.map((r) => r.id);
|
||||
expect(ids).toContain("mem_high_salience"); // due to importance >= 8
|
||||
@@ -210,16 +210,32 @@ describe("LedgerRepository", () => {
|
||||
});
|
||||
|
||||
// Without neighbors: only returns mem_target
|
||||
const withoutNeighbors = repo.retrieve("alice", "loc1", [], undefined, new Date("2024-01-10T14:00:00.000Z"), 1, {
|
||||
includeAssociativeNeighbors: false,
|
||||
});
|
||||
const withoutNeighbors = repo.retrieve(
|
||||
"alice",
|
||||
"loc1",
|
||||
[],
|
||||
undefined,
|
||||
new Date("2024-01-10T14:00:00.000Z"),
|
||||
1,
|
||||
{
|
||||
includeAssociativeNeighbors: false,
|
||||
},
|
||||
);
|
||||
expect(withoutNeighbors).toHaveLength(1);
|
||||
expect(withoutNeighbors[0].id).toBe("mem_target");
|
||||
|
||||
// With neighbors: returns preceding, target, and succeeding sorted chronologically
|
||||
const withNeighbors = repo.retrieve("alice", "loc1", [], undefined, new Date("2024-01-10T14:00:00.000Z"), 1, {
|
||||
includeAssociativeNeighbors: true,
|
||||
});
|
||||
const withNeighbors = repo.retrieve(
|
||||
"alice",
|
||||
"loc1",
|
||||
[],
|
||||
undefined,
|
||||
new Date("2024-01-10T14:00:00.000Z"),
|
||||
1,
|
||||
{
|
||||
includeAssociativeNeighbors: true,
|
||||
},
|
||||
);
|
||||
expect(withNeighbors).toHaveLength(3);
|
||||
expect(withNeighbors[0].id).toBe("mem_preceding");
|
||||
expect(withNeighbors[1].id).toBe("mem_target");
|
||||
|
||||
@@ -41,7 +41,9 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe("The hooded figure says, 'Hello there' to the bartender");
|
||||
expect(result).toBe(
|
||||
"The hooded figure says, 'Hello there' to the bartender",
|
||||
);
|
||||
});
|
||||
|
||||
test("serializes action intent with outcome details", () => {
|
||||
@@ -69,7 +71,9 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('The hooded figure attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
|
||||
expect(result).toBe(
|
||||
"The hooded figure attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)",
|
||||
);
|
||||
});
|
||||
|
||||
test("serializes self-reference and unfamiliar actors", () => {
|
||||
@@ -110,7 +114,10 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
|
||||
const resultUnfamiliar = serializeSubjectiveBufferEntry(
|
||||
entryUnfamiliar,
|
||||
viewer,
|
||||
);
|
||||
expect(resultUnfamiliar).toBe("An unfamiliar figure knocks on the door");
|
||||
});
|
||||
});
|
||||
@@ -118,7 +125,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
describe("BufferRepository Persistence Tests (Tier 1)", () => {
|
||||
test("saves, loads, lists, and deletes buffer entries in SQLite database", () => {
|
||||
const db = new Database(":memory:");
|
||||
|
||||
|
||||
// We need SQLiteRepository to initialize the objects table because buffer_entries depends on objects(id) via FK
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const repo = new BufferRepository(db);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { WorldState, Entity, SQLiteRepository, AttributeVisibility } from "@omnia/core";
|
||||
import {
|
||||
WorldState,
|
||||
Entity,
|
||||
SQLiteRepository,
|
||||
AttributeVisibility,
|
||||
} from "@omnia/core";
|
||||
import { Location } from "@omnia/spatial";
|
||||
import { BufferRepository } from "@omnia/memory";
|
||||
import { ScenarioSchema, Scenario } from "./schema.js";
|
||||
@@ -17,24 +22,40 @@ export class ScenarioLoader {
|
||||
* @param targetWorldId The unique ID for the running instance to create (e.g. UUID).
|
||||
* Allows launching multiple active runs from one scenario.
|
||||
*/
|
||||
async initializeWorld(scenarioJson: unknown, targetWorldId: string): Promise<string> {
|
||||
async initializeWorld(
|
||||
scenarioJson: unknown,
|
||||
targetWorldId: string,
|
||||
): Promise<string> {
|
||||
// 1. Validate scenario template schema
|
||||
const scenario: Scenario = ScenarioSchema.parse(scenarioJson);
|
||||
|
||||
// 2. Instantiate running WorldState using the target instance ID
|
||||
const world = new WorldState(targetWorldId, new Date(scenario.startTime));
|
||||
|
||||
|
||||
// Seed world-level attributes as system-only (private, empty ACL)
|
||||
world.addAttribute("name", scenario.name, AttributeVisibility.PRIVATE, new Set());
|
||||
world.addAttribute("description", scenario.description, AttributeVisibility.PRIVATE, new Set());
|
||||
|
||||
world.addAttribute(
|
||||
"name",
|
||||
scenario.name,
|
||||
AttributeVisibility.PRIVATE,
|
||||
new Set(),
|
||||
);
|
||||
world.addAttribute(
|
||||
"description",
|
||||
scenario.description,
|
||||
AttributeVisibility.PRIVATE,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
if (scenario.world?.attributes) {
|
||||
for (const attr of scenario.world.attributes) {
|
||||
const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE;
|
||||
const vis =
|
||||
attr.visibility === "PUBLIC"
|
||||
? AttributeVisibility.PUBLIC
|
||||
: AttributeVisibility.PRIVATE;
|
||||
world.addAttribute(
|
||||
attr.name,
|
||||
attr.value,
|
||||
vis,
|
||||
attr.name,
|
||||
attr.value,
|
||||
vis,
|
||||
attr.allowedEntities ? new Set(attr.allowedEntities) : null,
|
||||
);
|
||||
}
|
||||
@@ -47,10 +68,13 @@ export class ScenarioLoader {
|
||||
if (scenario.locations) {
|
||||
for (const locData of scenario.locations) {
|
||||
const location = new Location(locData.id, locData.parentId ?? null);
|
||||
|
||||
|
||||
if (locData.attributes) {
|
||||
for (const attr of locData.attributes) {
|
||||
const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE;
|
||||
const vis =
|
||||
attr.visibility === "PUBLIC"
|
||||
? AttributeVisibility.PUBLIC
|
||||
: AttributeVisibility.PRIVATE;
|
||||
location.addAttribute(
|
||||
attr.name,
|
||||
attr.value,
|
||||
@@ -80,12 +104,19 @@ export class ScenarioLoader {
|
||||
// 5. Instantiate and Persist Entities (with Aliases & Memory Buffers)
|
||||
if (scenario.entities) {
|
||||
for (const entData of scenario.entities) {
|
||||
const entity = new Entity(entData.id, entData.locationId ?? null);
|
||||
|
||||
const entity = new Entity(
|
||||
entData.id,
|
||||
entData.locationId ?? null,
|
||||
entData.isAgent,
|
||||
);
|
||||
|
||||
// Load attributes
|
||||
if (entData.attributes) {
|
||||
for (const attr of entData.attributes) {
|
||||
const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE;
|
||||
const vis =
|
||||
attr.visibility === "PUBLIC"
|
||||
? AttributeVisibility.PUBLIC
|
||||
: AttributeVisibility.PRIVATE;
|
||||
entity.addAttribute(
|
||||
attr.name,
|
||||
attr.value,
|
||||
|
||||
@@ -38,10 +38,12 @@ export const ScenarioMemoryEntrySchema = z.object({
|
||||
targetIds: z.array(z.string()),
|
||||
modifiers: z.array(z.string()).optional(),
|
||||
}),
|
||||
outcome: z.object({
|
||||
isValid: z.boolean(),
|
||||
reason: z.string(),
|
||||
}).optional(),
|
||||
outcome: z
|
||||
.object({
|
||||
isValid: z.boolean(),
|
||||
reason: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ScenarioEntitySchema = z.object({
|
||||
@@ -50,6 +52,7 @@ export const ScenarioEntitySchema = z.object({
|
||||
attributes: z.array(ScenarioAttributeSchema).optional(),
|
||||
aliases: z.record(z.string(), z.string()).optional(), // targetId -> subjective descriptor
|
||||
initialMemories: z.array(ScenarioMemoryEntrySchema).optional(),
|
||||
isAgent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ScenarioSchema = z.object({
|
||||
@@ -57,9 +60,11 @@ export const ScenarioSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
startTime: z.string(), // ISO string
|
||||
world: z.object({
|
||||
attributes: z.array(ScenarioAttributeSchema).optional(),
|
||||
}).optional(),
|
||||
world: z
|
||||
.object({
|
||||
attributes: z.array(ScenarioAttributeSchema).optional(),
|
||||
})
|
||||
.optional(),
|
||||
locations: z.array(ScenarioLocationSchema).optional(),
|
||||
entities: z.array(ScenarioEntitySchema).optional(),
|
||||
});
|
||||
|
||||
@@ -12,17 +12,13 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
|
||||
description: "A spooky old manor.",
|
||||
startTime: "2026-07-09T08:00:00.000Z",
|
||||
world: {
|
||||
attributes: [
|
||||
{ name: "weather", value: "stormy", visibility: "PUBLIC" },
|
||||
],
|
||||
attributes: [{ name: "weather", value: "stormy", visibility: "PUBLIC" }],
|
||||
},
|
||||
locations: [
|
||||
{
|
||||
id: "lobby",
|
||||
parentId: null,
|
||||
attributes: [
|
||||
{ name: "light", value: "dim", visibility: "PUBLIC" },
|
||||
],
|
||||
attributes: [{ name: "light", value: "dim", visibility: "PUBLIC" }],
|
||||
connections: [
|
||||
{
|
||||
targetId: "kitchen",
|
||||
@@ -43,7 +39,12 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
|
||||
id: "investigator",
|
||||
locationId: "lobby",
|
||||
attributes: [
|
||||
{ name: "sanity", value: "100", visibility: "PRIVATE", allowedEntities: ["investigator"] },
|
||||
{
|
||||
name: "sanity",
|
||||
value: "100",
|
||||
visibility: "PRIVATE",
|
||||
allowedEntities: ["investigator"],
|
||||
},
|
||||
],
|
||||
aliases: {
|
||||
ghost: "shadowy specter",
|
||||
@@ -98,11 +99,16 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
|
||||
expect(world).not.toBeNull();
|
||||
expect(world!.id).toBe(targetWorldId);
|
||||
expect(world!.clock.get().toISOString()).toBe("2026-07-09T08:00:00.000Z");
|
||||
expect(world!.attributes.get("name")?.getValue()).toBe("Haunted House Mystery");
|
||||
expect(world!.attributes.get("name")?.getValue()).toBe(
|
||||
"Haunted House Mystery",
|
||||
);
|
||||
expect(world!.attributes.get("weather")?.getValue()).toBe("stormy");
|
||||
|
||||
// 2. Verify Locations loaded with connections & hierarchy
|
||||
const locations = coreRepo.listLocations(targetWorldId, (id, parentId) => new Location(id, parentId));
|
||||
const locations = coreRepo.listLocations(
|
||||
targetWorldId,
|
||||
(id, parentId) => new Location(id, parentId),
|
||||
);
|
||||
expect(locations).toHaveLength(2);
|
||||
|
||||
const lobby = locations.find((l) => l.id === "lobby");
|
||||
@@ -122,7 +128,9 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
|
||||
const loadedInvestigator = world!.getEntity("investigator");
|
||||
expect(loadedInvestigator).toBeDefined();
|
||||
expect(loadedInvestigator!.locationId).toBe("lobby");
|
||||
expect(loadedInvestigator!.attributes.get("sanity")?.getValue()).toBe("100");
|
||||
expect(loadedInvestigator!.attributes.get("sanity")?.getValue()).toBe(
|
||||
"100",
|
||||
);
|
||||
expect(loadedInvestigator!.aliases.get("ghost")).toBe("shadowy specter");
|
||||
|
||||
// 4. Verify pre-seeded memories loaded in BufferRepository
|
||||
|
||||
@@ -10,7 +10,10 @@ import { ScenarioLoader, ScenarioSchema } from "../src/index.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const SCENARIO_PATH = path.resolve(__dirname, "../../../content/demo/scenarios/talking-room.json");
|
||||
const SCENARIO_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../../content/demo/scenarios/talking-room.json",
|
||||
);
|
||||
|
||||
describe("Talking Room Demo Scenario Test (Tier 1)", () => {
|
||||
test("talking-room.json exists, parses, and loads correctly into database", async () => {
|
||||
@@ -37,17 +40,30 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
|
||||
expect(world).not.toBeNull();
|
||||
expect(world!.attributes.get("name")?.getValue()).toBe("Talking Room");
|
||||
expect(world!.attributes.get("name")?.visibility).toBe("PRIVATE");
|
||||
expect(world!.attributes.get("description")?.getValue()).toBe(scenarioJson.description);
|
||||
expect(world!.attributes.get("description")?.getValue()).toBe(
|
||||
scenarioJson.description,
|
||||
);
|
||||
expect(world!.attributes.get("description")?.visibility).toBe("PRIVATE");
|
||||
expect(world!.attributes.get("experiment_codename")?.getValue()).toBe("Project Tabula Rasa (Phase 3)");
|
||||
expect(world!.attributes.get("experiment_codename")?.visibility).toBe("PRIVATE");
|
||||
expect(world!.attributes.get("experiment_codename")?.getAllowedEntities()).toHaveLength(0); // System only!
|
||||
expect(world!.attributes.get("experiment_codename")?.getValue()).toBe(
|
||||
"Project Tabula Rasa (Phase 3)",
|
||||
);
|
||||
expect(world!.attributes.get("experiment_codename")?.visibility).toBe(
|
||||
"PRIVATE",
|
||||
);
|
||||
expect(
|
||||
world!.attributes.get("experiment_codename")?.getAllowedEntities(),
|
||||
).toHaveLength(0); // System only!
|
||||
|
||||
// 5. Assert location
|
||||
const locations = coreRepo.listLocations(worldInstanceId, (id, parentId) => new Location(id, parentId));
|
||||
const locations = coreRepo.listLocations(
|
||||
worldInstanceId,
|
||||
(id, parentId) => new Location(id, parentId),
|
||||
);
|
||||
expect(locations).toHaveLength(1);
|
||||
expect(locations[0].id).toBe("white-room");
|
||||
expect(locations[0].attributes.get("description")?.getValue()).toContain("A pristine, featureless room");
|
||||
expect(locations[0].attributes.get("description")?.getValue()).toContain(
|
||||
"A pristine, featureless room",
|
||||
);
|
||||
|
||||
// 6. Assert entities and their private attributes / allowedEntities
|
||||
const alphaId = "7c9b83b3-8cfb-4e89-8d77-626a5757d591";
|
||||
@@ -56,14 +72,14 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
|
||||
const alpha = world!.getEntity(alphaId);
|
||||
expect(alpha).toBeDefined();
|
||||
expect(alpha!.locationId).toBe("white-room");
|
||||
|
||||
|
||||
// Name visibility check
|
||||
const alphaName = alpha!.attributes.get("name")!;
|
||||
expect(alphaName.getValue()).toBe("Bob");
|
||||
expect(alphaName.visibility).toBe("PRIVATE");
|
||||
expect(alphaName.hasAccess(alphaId)).toBe(true);
|
||||
expect(alphaName.hasAccess(betaId)).toBe(false);
|
||||
|
||||
|
||||
// Check system-only attribute (neural_erasure_dose)
|
||||
const alphaDose = alpha!.attributes.get("neural_erasure_dose")!;
|
||||
expect(alphaDose.visibility).toBe("PRIVATE");
|
||||
@@ -81,7 +97,9 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
|
||||
// Verify subjective aliases can be dynamically resolved via AliasDeltaGenerator
|
||||
const { AliasDeltaGenerator } = await import("@omnia/architect");
|
||||
const { MockLLMProvider } = await import("@omnia/llm");
|
||||
const llmProvider = new MockLLMProvider([{ alias: "the person in the Beta jumpsuit" }]);
|
||||
const llmProvider = new MockLLMProvider([
|
||||
{ alias: "the person in the Beta jumpsuit" },
|
||||
]);
|
||||
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
|
||||
|
||||
const generatedAlias = await aliasGenerator.generate(alpha!, beta!);
|
||||
@@ -94,7 +112,9 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
|
||||
const subjectiveState = serializeSubjectiveWorldState(world!, alphaId);
|
||||
expect(subjectiveState).toContain("You are at location: white-room");
|
||||
expect(subjectiveState).toContain("Location attributes:");
|
||||
expect(subjectiveState).toContain("description: A pristine, featureless room");
|
||||
expect(subjectiveState).toContain(
|
||||
"description: A pristine, featureless room",
|
||||
);
|
||||
expect(subjectiveState).toContain("lighting: Bright, uniform illumination");
|
||||
|
||||
// Verify objective world state serializes locations (physics awareness)
|
||||
@@ -102,7 +122,9 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
|
||||
const objectiveState = serializeObjectiveWorldState(world!);
|
||||
expect(objectiveState).toContain("Locations:");
|
||||
expect(objectiveState).toContain("- Location [ID: white-room]:");
|
||||
expect(objectiveState).toContain("description: A pristine, featureless room");
|
||||
expect(objectiveState).toContain(
|
||||
"description: A pristine, featureless room",
|
||||
);
|
||||
|
||||
// 7. Assert initial pre-seeded memories
|
||||
const alphaMemories = bufferRepo.listForOwner(alphaId);
|
||||
|
||||
11864
pnpm-lock.yaml
generated
11864
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -10,5 +10,5 @@ allowBuilds:
|
||||
unrs-resolver: true
|
||||
workerd: true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@astrojs/telemetry@3.3.3'
|
||||
- "@astrojs/telemetry@3.3.3"
|
||||
- astro@7.0.7
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user