20 Commits

Author SHA1 Message Date
381afdcb1a feat(builder): Added calssic menubar 2026-07-16 10:34:38 +05:30
44f3e29e62 minor(builder): misc ui fixes 2026-07-15 22:41:28 +05:30
d2506ec542 minor(builder): hooked up isAgent to EntityBuilder 2026-07-15 21:26:56 +05:30
13483d54dd feat(builder): Added worldmap visualization 2026-07-15 21:10:56 +05:30
2cc89d2e3c chore: formatting 2026-07-15 20:17:37 +05:30
b5c5fa9f0a chore: Merge branch 'master' into feat/scenario-builder 2026-07-15 20:16:52 +05:30
ce8b9176d3 minor(gui): Disable simulations if no model provider instance is present 2026-07-15 19:59:38 +05:30
9d18444220 chore: Format files 2026-07-15 19:20:13 +05:30
9838d4ce59 feat(core): Added isAgent field for entities 2026-07-15 19:19:29 +05:30
03a5bec366 refactor(builder): tab content out of page.tsx 2026-07-15 19:04:03 +05:30
663d409a46 feat(builder): implement dynamic UUID generation 2026-07-15 19:01:12 +05:30
30bf5f76c2 feat(content): Added scenario builder UI 2026-07-15 18:54:39 +05:30
3b2a85aeaf fix(tests): Incorporate TimeDeltaGenerator short-circuit for tests 2026-07-15 18:19:34 +05:30
d59b372a57 chore: bump pnpm version to 11.13.0 2026-07-15 18:19:11 +05:30
2f20224bac docs(astro): Update astro config 2026-07-13 07:22:24 +05:30
8c1b593453 fix paths 2026-07-13 07:20:15 +05:30
9aaac17449 docs(diary): Fix image paths 2026-07-13 07:18:35 +05:30
ab181cb5c7 Merge branch 'master' of github.com:sortedcord/omnia-consolidated 2026-07-13 07:14:12 +05:30
be9ef29de6 docs(diary): Added first anecdote 2026-07-13 07:13:50 +05:30
765eb0c01b minor(architect): Add shortcircuit for TimeDeltaGenerator 2026-07-13 07:13:33 +05:30
111 changed files with 13715 additions and 5402 deletions

5
.codegraph/.gitignore vendored Normal file
View 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

View File

@@ -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"

View File

@@ -20,6 +20,7 @@
"@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",

View File

@@ -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;
@@ -168,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();
@@ -197,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 }
> {
@@ -207,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 {
@@ -220,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 };
@@ -234,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();
}
@@ -246,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> {
@@ -266,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>> {
@@ -284,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),
};
}
}

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,13 @@ import { Suspense } from "react";
export default function PlayPage() {
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin text-primary">Loading...</div>
</div>
}>
<Suspense
fallback={
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin text-primary">Loading...</div>
</div>
}
>
<PlayView />
</Suspense>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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;
}

View 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);
}

View File

@@ -224,7 +224,8 @@ export function ConfigView() {
</SelectItem>
{instances
.filter(
(inst) => (inst.type || "generative") === task.type,
(inst) =>
(inst.type || "generative") === task.type,
)
.map((inst) => (
<SelectItem key={inst.id} value={inst.id}>

View File

@@ -282,7 +282,9 @@ export function ProviderInstancesConfig({
<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>
<Badge variant="outline">
{inst.type === "generative" ? "gen" : "embed"}
</Badge>
</div>
</ItemContent>
</Item>

View File

@@ -44,7 +44,9 @@ export function DashboardView() {
const [scenarios, setScenarios] = useState<
{ path: string; name: string; description: string }[]
>([]);
const [, setProviderInstances] = useState<ModelProviderInstance[]>([]);
const [providerInstances, setProviderInstances] = useState<
ModelProviderInstance[]
>([]);
// Modal State
const [scenarioForModal, setScenarioForModal] = useState<{
@@ -203,6 +205,24 @@ export function DashboardView() {
</div>
)}
{providerInstances.length === 0 && !loadingData && (
<div className="mb-6 border border-yellow-500/30 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 flex flex-col gap-2">
<span className="font-semibold flex items-center gap-1">
No LLM providers have been configured.
</span>
<span>
Please go to the{" "}
<Link
href="/config"
className="underline font-medium hover:text-yellow-700"
>
Configuration
</Link>{" "}
page to set up at least one provider before running simulations.
</span>
</div>
)}
{/* Simulations Section */}
<section className="mb-10">
<h2 className="text-headline-lg text-primary mb-6 animate-fade-in">
@@ -235,8 +255,16 @@ export function DashboardView() {
{savedSessions.map((s) => (
<div
key={s.id}
onClick={() => handleResume(s.id)}
className="flex-shrink-0 w-72 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all relative group"
onClick={
providerInstances.length === 0
? undefined
: () => handleResume(s.id)
}
className={`flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all relative group ${
providerInstances.length === 0
? "opacity-50 cursor-not-allowed filter grayscale"
: "cursor-pointer hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm"
}`}
>
<div className="flex flex-col gap-1 text-sm mb-4">
<strong className="text-body-md text-foreground block">
@@ -337,6 +365,7 @@ export function DashboardView() {
name={s.name}
description={s.description}
onClick={() => handleScenarioClick(s)}
disabled={providerInstances.length === 0}
/>
))}
</div>

View File

@@ -422,242 +422,247 @@ export function PlayView() {
</p>
</div>
</div>
{/* Simulation Global Controls */}
<div className="flex gap-2 shrink-0">
{snapshot.status !== "done" && snapshot.status !== "error" && (
<>
{snapshot.status === "running" &&
(loading ? (
<Button
variant="secondary"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
}}
>
Pause
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => runSteps(snapshot.id)}
>
Resume
</Button>
))}
<Button
variant="destructive"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
router.push("/");
}}
>
Stop
</Button>
</>
)}
</div>
</div>
<div className="flex items-center justify-between text-xs font-mono mt-1 pt-1.5 border-t border-border/10">
<span className="text-muted-foreground">
Status:{" "}
<span className="text-primary font-bold">
{getUnifiedStatus()}
</span>
</span>
<span className="text-muted-foreground">
Turn:{" "}
<span className="text-foreground font-bold">{snapshot.turn}</span>
</span>
</div>
{statusMessage() && (
<p className="text-xs font-medium text-primary mt-1 font-mono">
{loading && "⏳ "}
{statusMessage()}
</p>
)}
</header>
{/* Scrollable Center Viewport */}
<main className="flex-1 overflow-y-auto px-8 py-6">
{activeTab === "interact" ? (
<div className="flex flex-col gap-4 max-w-[800px] mx-auto pb-12">
{(() => {
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
return snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={setSelectedEntryForModal}
isPlayerCard={entry.entityId === playerEntity?.id}
/>
));
})()}
{loading && (
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
<Spinner />
{statusText || "Processing..."}
</div>
)}
<div ref={logEndRef} />
</div>
) : (
<div className="max-w-[800px] mx-auto space-y-6 pb-12">
{/* Simulation Info */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Simulation Info
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-mono">
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Session ID
</span>
<span className="text-foreground font-bold break-all">
{snapshot.id}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Max Turns
</span>
<span className="text-foreground font-bold">
{snapshot.maxTurns}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Turn Count
</span>
<span className="text-foreground font-bold">
{snapshot.turn}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Entities Registered
</span>
<span className="text-foreground font-bold">
{snapshot.entities.length}
</span>
</div>
</div>
</div>
{/* Entities Involved */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Entities Involved
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{snapshot.entities.map((ent) => (
<div
key={ent.id}
className="border border-border/20 bg-secondary/20 p-4 shadow-[1px_1px_0_0_var(--border)] flex justify-between items-center"
>
<div>
<strong className="text-sm text-foreground block font-head tracking-wide">
{ent.name}
</strong>
<span className="text-xs text-muted-foreground font-mono block mt-1">
ID: {ent.id}
</span>
</div>
<div className="flex items-center gap-2">
{ent.isPlayer ? (
<span className="bg-primary/20 text-primary border border-primary/30 px-2 py-0.5 text-xs font-mono">
PLAYER
</span>
) : (
<span className="bg-secondary/60 text-muted-foreground border border-border/20 px-2 py-0.5 text-xs font-mono">
NPC
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
</main>
{/* Sticky Chat / Interaction Input Footer */}
{activeTab === "interact" && (
<footer className="sticky bottom-0 bg-background/95 backdrop-blur-xs border-t border-dotted border-border/20 px-8 py-4 z-10 shrink-0">
<div className="max-w-[800px] mx-auto">
{snapshot.status === "waiting_player" &&
snapshot.waitingEntity ? (
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
<strong>
Your context as {snapshot.waitingEntity.name}
</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
<form
onSubmit={handleSubmitAction}
className="flex flex-col gap-2"
>
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
disabled={loading}
/>
{/* Simulation Global Controls */}
<div className="flex gap-2 shrink-0">
{snapshot.status !== "done" && snapshot.status !== "error" && (
<>
{snapshot.status === "running" &&
(loading ? (
<Button
variant="secondary"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
}}
>
Pause
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => runSteps(snapshot.id)}
>
Resume
</Button>
))}
<Button
type="submit"
disabled={loading || !playerInput.trim()}
variant="destructive"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
router.push("/");
}}
>
{loading ? "Processing..." : "Submit Action"}
Stop
</Button>
</form>
</div>
) : snapshot.status === "done" || snapshot.status === "error" ? (
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
<span className="text-sm font-mono text-muted-foreground">
{snapshot.status === "error"
? "Simulation finished with an error."
: "Simulation complete."}
</span>
<Button
onClick={() => {
router.push("/");
}}
size="sm"
>
{snapshot.status === "error"
? "Back to Dashboard"
: "New Simulation"}
</Button>
</div>
) : null}
</>
)}
</div>
</div>
</footer>
<div className="flex items-center justify-between text-xs font-mono mt-1 pt-1.5 border-t border-border/10">
<span className="text-muted-foreground">
Status:{" "}
<span className="text-primary font-bold">
{getUnifiedStatus()}
</span>
</span>
<span className="text-muted-foreground">
Turn:{" "}
<span className="text-foreground font-bold">
{snapshot.turn}
</span>
</span>
</div>
{statusMessage() && (
<p className="text-xs font-medium text-primary mt-1 font-mono">
{loading && "⏳ "}
{statusMessage()}
</p>
)}
</header>
{/* Scrollable Center Viewport */}
<main className="flex-1 overflow-y-auto px-8 py-6">
{activeTab === "interact" ? (
<div className="flex flex-col gap-4 max-w-[800px] mx-auto pb-12">
{(() => {
const playerEntity = snapshot.entities.find(
(e) => e.isPlayer,
);
return snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={setSelectedEntryForModal}
isPlayerCard={entry.entityId === playerEntity?.id}
/>
));
})()}
{loading && (
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
<Spinner />
{statusText || "Processing..."}
</div>
)}
<div ref={logEndRef} />
</div>
) : (
<div className="max-w-[800px] mx-auto space-y-6 pb-12">
{/* Simulation Info */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Simulation Info
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-mono">
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Session ID
</span>
<span className="text-foreground font-bold break-all">
{snapshot.id}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Max Turns
</span>
<span className="text-foreground font-bold">
{snapshot.maxTurns}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Turn Count
</span>
<span className="text-foreground font-bold">
{snapshot.turn}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Entities Registered
</span>
<span className="text-foreground font-bold">
{snapshot.entities.length}
</span>
</div>
</div>
</div>
{/* Entities Involved */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Entities Involved
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{snapshot.entities.map((ent) => (
<div
key={ent.id}
className="border border-border/20 bg-secondary/20 p-4 shadow-[1px_1px_0_0_var(--border)] flex justify-between items-center"
>
<div>
<strong className="text-sm text-foreground block font-head tracking-wide">
{ent.name}
</strong>
<span className="text-xs text-muted-foreground font-mono block mt-1">
ID: {ent.id}
</span>
</div>
<div className="flex items-center gap-2">
{ent.isPlayer ? (
<span className="bg-primary/20 text-primary border border-primary/30 px-2 py-0.5 text-xs font-mono">
PLAYER
</span>
) : (
<span className="bg-secondary/60 text-muted-foreground border border-border/20 px-2 py-0.5 text-xs font-mono">
NPC
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
</main>
{/* Sticky Chat / Interaction Input Footer */}
{activeTab === "interact" && (
<footer className="sticky bottom-0 bg-background/95 backdrop-blur-xs border-t border-dotted border-border/20 px-8 py-4 z-10 shrink-0">
<div className="max-w-[800px] mx-auto">
{snapshot.status === "waiting_player" &&
snapshot.waitingEntity ? (
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
<strong>
Your context as {snapshot.waitingEntity.name}
</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
<form
onSubmit={handleSubmitAction}
className="flex flex-col gap-2"
>
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
disabled={loading}
/>
<Button
type="submit"
disabled={loading || !playerInput.trim()}
>
{loading ? "Processing..." : "Submit Action"}
</Button>
</form>
</div>
) : snapshot.status === "done" ||
snapshot.status === "error" ? (
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
<span className="text-sm font-mono text-muted-foreground">
{snapshot.status === "error"
? "Simulation finished with an error."
: "Simulation complete."}
</span>
<Button
onClick={() => {
router.push("/");
}}
size="sm"
>
{snapshot.status === "error"
? "Back to Dashboard"
: "New Simulation"}
</Button>
</div>
) : null}
</div>
</footer>
)}
</div>
{error && !loading && (
<div className="fixed bottom-4 right-4 z-50 border border-destructive bg-destructive/90 text-destructive-foreground px-4 py-3 shadow-[3px_3px_0_0_var(--border)] text-sm">
{error}
</div>
)}
{selectedEntryForModal && (
<PromptModal
entry={selectedEntryForModal}
onClose={() => setSelectedEntryForModal(null)}
/>
)}
</div>
{error && !loading && (
<div className="fixed bottom-4 right-4 z-50 border border-destructive bg-destructive/90 text-destructive-foreground px-4 py-3 shadow-[3px_3px_0_0_var(--border)] text-sm">
{error}
</div>
)}
{selectedEntryForModal && (
<PromptModal
entry={selectedEntryForModal}
onClose={() => setSelectedEntryForModal(null)}
/>
)}
</div>
</SidebarProvider>
);
}

View File

@@ -53,8 +53,16 @@ export function PromptModal({ entry, onClose }: PromptModalProps) {
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.)" },
{
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);

View File

@@ -9,17 +9,39 @@ interface ScenarioCardProps {
name: string;
description: string;
onClick: () => void;
disabled?: boolean;
}
export function ScenarioCard({ name, description, onClick }: ScenarioCardProps) {
export function ScenarioCard({
name,
description,
onClick,
disabled,
}: ScenarioCardProps) {
return (
<div
onClick={onClick}
className="flex-shrink-0 w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all"
onClick={disabled ? undefined : onClick}
className={`flex-shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm transition-all ${
disabled
? "opacity-50 cursor-not-allowed filter grayscale"
: "cursor-pointer hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm"
}`}
>
<strong className="text-body-md text-foreground block mb-2">{name}</strong>
<p className="text-xs text-muted-foreground leading-relaxed mb-1">{truncate(description, 80)}</p>
<span className="mt-4 flex items-center justify-center size-7 border border-primary bg-primary/10 text-primary font-mono text-sm font-bold">{'>'}</span>
<strong className="text-body-md text-foreground block mb-2">
{name}
</strong>
<p className="text-xs text-muted-foreground leading-relaxed mb-1">
{truncate(description, 80)}
</p>
<span
className={`mt-4 flex items-center justify-center size-7 border font-mono text-sm font-bold ${
disabled
? "border-muted text-muted bg-muted/10"
: "border-primary bg-primary/10 text-primary"
}`}
>
{">"}
</span>
</div>
);
}

View File

@@ -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 };

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded border px-2 py-0.5 text-xs font-head font-medium whitespace-nowrap transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
@@ -24,8 +24,8 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Badge({
className,
@@ -34,7 +34,7 @@ function Badge({
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
@@ -43,7 +43,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium border border-border/30 outline-none transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 shadow-sm active:translate-x-[1px] active:translate-y-[1px] active:shadow-xs focus-visible:ring-2 focus-visible:ring-ring",
@@ -12,11 +12,11 @@ const buttonVariants = cva(
default: "bg-primary text-primary-foreground hover:bg-primary-hover",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"bg-background hover:bg-secondary",
outline: "bg-background hover:bg-secondary",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary-foreground/10",
ghost: "border-transparent shadow-none active:translate-x-0 active:translate-y-0 active:shadow-none hover:bg-secondary",
ghost:
"border-transparent shadow-none active:translate-x-0 active:translate-y-0 active:shadow-none hover:bg-secondary",
link: "border-transparent shadow-none active:translate-x-0 active:translate-y-0 active:shadow-none text-primary hover:underline",
},
size: {
@@ -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 };

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Card({
className,
@@ -13,11 +13,11 @@ function Card({
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden border border-border/30 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[2px_2px_0_0_var(--border)] [--card-spacing:--spacing(4)] data-[size=sm]:[--card-spacing:--spacing(3)]",
className
className,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -26,24 +26,21 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] border-b border-dotted border-border/20 pb-4 mb-2",
className
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-head text-headline-sm text-foreground",
className
)}
className={cn("font-head text-headline-sm text-foreground", className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -53,7 +50,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -62,11 +59,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -76,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -85,11 +82,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-footer"
className={cn(
"flex items-center border-t border-dotted border-border/20 bg-muted/30 px-(--card-spacing) pt-(--card-spacing)",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -100,4 +97,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};

View File

@@ -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 };

View 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,
};

View File

@@ -1,34 +1,34 @@
"use client"
"use client";
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
@@ -40,11 +40,11 @@ function DialogOverlay({
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-foreground/20 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
className,
)}
{...props}
/>
)
);
}
function DialogContent({
@@ -53,7 +53,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
@@ -62,7 +62,7 @@ function DialogContent({
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded border-2 bg-popover p-4 text-sm text-popover-foreground shadow-md duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
className,
)}
{...props}
>
@@ -81,7 +81,7 @@ function DialogContent({
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -91,7 +91,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
);
}
function DialogFooter({
@@ -100,14 +100,14 @@ function DialogFooter({
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t-2 bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
>
@@ -118,7 +118,7 @@ function DialogFooter({
</DialogPrimitive.Close>
)}
</div>
)
);
}
function DialogTitle({
@@ -128,13 +128,10 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-head text-base leading-none font-medium",
className
)}
className={cn("font-head text-base leading-none font-medium", className)}
{...props}
/>
)
);
}
function DialogDescription({
@@ -146,11 +143,11 @@ function DialogDescription({
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -164,4 +161,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};

View 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,
};

View File

@@ -1,6 +1,6 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -8,11 +8,11 @@ function Empty({ className, ...props }: React.ComponentProps<"div">) {
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded border-2 bg-card p-6 text-center text-balance",
className
className,
)}
{...props}
/>
)
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -22,7 +22,7 @@ function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
);
}
const emptyMediaVariants = cva(
@@ -37,8 +37,8 @@ const emptyMediaVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function EmptyMedia({
className,
@@ -52,20 +52,17 @@ function EmptyMedia({
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"text-sm font-medium tracking-tight",
className
)}
className={cn("text-sm font-medium tracking-tight", className)}
{...props}
/>
)
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
@@ -74,11 +71,11 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
className,
)}
{...props}
/>
)
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -87,11 +84,11 @@ function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -101,4 +98,4 @@ export {
EmptyDescription,
EmptyContent,
EmptyMedia,
}
};

View 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,
};

View File

@@ -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 };

View File

@@ -1,10 +1,10 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -13,11 +13,11 @@ function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
className,
)}
{...props}
/>
)
);
}
function ItemSeparator({
@@ -31,7 +31,7 @@ function ItemSeparator({
className={cn("my-2", className)}
{...props}
/>
)
);
}
const itemVariants = cva(
@@ -53,8 +53,8 @@ const itemVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Item({
className,
@@ -69,7 +69,7 @@ function Item({
{
className: cn(itemVariants({ variant, size, className })),
},
props
props,
),
render,
state: {
@@ -77,7 +77,7 @@ function Item({
variant,
size,
},
})
});
}
const itemMediaVariants = cva(
@@ -94,8 +94,8 @@ const itemMediaVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function ItemMedia({
className,
@@ -109,7 +109,7 @@ function ItemMedia({
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -118,11 +118,11 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
className,
)}
{...props}
/>
)
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -131,11 +131,11 @@ function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
className,
)}
{...props}
/>
)
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
@@ -144,11 +144,11 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
className,
)}
{...props}
/>
)
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
@@ -158,7 +158,7 @@ function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -167,11 +167,11 @@ function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
className,
)}
{...props}
/>
)
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -180,11 +180,11 @@ function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -198,4 +198,4 @@ export {
ItemDescription,
ItemHeader,
ItemFooter,
}
};

View File

@@ -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 };

View 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,
};

View File

@@ -1,9 +1,9 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
import * as React from "react";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function NavigationMenu({
className,
@@ -11,7 +11,7 @@ function NavigationMenu({
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
@@ -19,14 +19,14 @@ function NavigationMenu({
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
);
}
function NavigationMenuList({
@@ -38,11 +38,11 @@ function NavigationMenuList({
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className
className,
)}
{...props}
/>
)
);
}
function NavigationMenuItem({
@@ -55,12 +55,12 @@ function NavigationMenuItem({
className={cn("relative", className)}
{...props}
/>
)
);
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-secondary hover:text-foreground focus:bg-secondary focus:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-secondary data-popup-open:text-foreground data-open:bg-secondary data-open:text-foreground"
)
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-secondary hover:text-foreground focus:bg-secondary focus:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-secondary data-popup-open:text-foreground data-open:bg-secondary data-open:text-foreground",
);
function NavigationMenuTrigger({
className,
@@ -79,7 +79,7 @@ function NavigationMenuTrigger({
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
);
}
function NavigationMenuContent({
@@ -91,11 +91,11 @@ function NavigationMenuContent({
data-slot="navigation-menu-content"
className={cn(
"top-0 left-0 w-full p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:border-border/30 group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow-md group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className
className,
)}
{...props}
/>
)
);
}
function NavigationMenuViewport({
@@ -105,19 +105,19 @@ function NavigationMenuViewport({
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
"absolute top-full left-0 isolate z-50 flex justify-center",
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden border border-border/30 bg-popover text-popover-foreground shadow-md duration-100 md:w-(--radix-navigation-menu-viewport-width) data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90",
className
className,
)}
{...props}
/>
</div>
)
);
}
function NavigationMenuLink({
@@ -129,11 +129,11 @@ function NavigationMenuLink({
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 p-2 text-sm transition-all outline-none hover:bg-secondary hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary in-data-[slot=navigation-menu-content]:rounded-sm data-active:bg-primary/15 data-active:text-primary [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
/>
)
);
}
function NavigationMenuIndicator({
@@ -145,13 +145,13 @@ function NavigationMenuIndicator({
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
);
}
export {
@@ -164,4 +164,4 @@ export {
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
};

View File

@@ -1,12 +1,12 @@
"use client"
"use client";
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
@@ -15,7 +15,7 @@ function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
);
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
@@ -25,7 +25,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
);
}
function SelectTrigger({
@@ -34,7 +34,7 @@ function SelectTrigger({
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
@@ -42,7 +42,7 @@ function SelectTrigger({
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded border border-border/30 bg-input py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-sm transition-colors outline-none select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
@@ -53,7 +53,7 @@ function SelectTrigger({
}
/>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
@@ -83,7 +83,10 @@ function SelectContent({
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded border border-border/30 bg-popover text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded border border-border/30 bg-popover text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
<SelectScrollUpButton />
@@ -92,7 +95,7 @@ function SelectContent({
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
@@ -105,7 +108,7 @@ function SelectLabel({
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
);
}
function SelectItem({
@@ -118,7 +121,7 @@ function SelectItem({
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
className,
)}
{...props}
>
@@ -133,7 +136,7 @@ function SelectItem({
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
@@ -146,7 +149,7 @@ function SelectSeparator({
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@@ -158,13 +161,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
)
);
}
function SelectScrollDownButton({
@@ -176,13 +179,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
)
);
}
export {
@@ -196,4 +199,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -11,7 +11,7 @@ const Separator = React.forwardRef<
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
@@ -20,12 +20,12 @@ const Separator = React.forwardRef<
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
className,
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };

View File

@@ -1,19 +1,19 @@
"use client"
"use client";
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -22,13 +22,13 @@ const SheetOverlay = React.forwardRef<
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
className,
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@@ -46,11 +46,12 @@ const sheetVariants = cva(
defaultVariants: {
side: "right",
},
}
)
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
extends
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
@@ -71,8 +72,8 @@ const SheetContent = React.forwardRef<
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
@@ -81,12 +82,12 @@ const SheetHeader = ({
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
className,
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
@@ -95,12 +96,12 @@ const SheetFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
className,
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
@@ -111,8 +112,8 @@ const SheetTitle = React.forwardRef<
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
@@ -123,8 +124,8 @@ const SheetDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
@@ -137,4 +138,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};

View File

@@ -1,55 +1,55 @@
"use client"
"use client";
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils";
import { useIsMobile } from "@/hooks/use-mobile";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context
return context;
}
function SidebarProvider({
@@ -61,36 +61,36 @@ function SidebarProvider({
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
)
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -99,18 +99,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@@ -122,8 +122,8 @@ function SidebarProvider({
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
@@ -138,14 +138,14 @@ function SidebarProvider({
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
className,
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
);
}
function Sidebar({
@@ -157,11 +157,11 @@ function Sidebar({
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
@@ -169,13 +169,13 @@ function Sidebar({
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-card text-sidebar-foreground",
className
className,
)}
{...props}
>
{children}
</div>
)
);
}
if (isMobile) {
@@ -201,7 +201,7 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
);
}
return (
@@ -222,7 +222,7 @@ function Sidebar({
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
@@ -234,7 +234,7 @@ function Sidebar({
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r-2 group-data-[side=right]:border-l-2",
className
className,
)}
{...props}
>
@@ -247,7 +247,7 @@ function Sidebar({
</div>
</div>
</div>
)
);
}
function SidebarTrigger({
@@ -255,7 +255,7 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<Button
@@ -265,19 +265,19 @@ function SidebarTrigger({
size="icon"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<button
@@ -294,11 +294,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
className,
)}
{...props}
/>
)
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
@@ -307,11 +307,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
className,
)}
{...props}
/>
)
);
}
function SidebarInput({
@@ -325,7 +325,7 @@ function SidebarInput({
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -336,7 +336,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -347,7 +347,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarSeparator({
@@ -361,7 +361,7 @@ function SidebarSeparator({
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -371,11 +371,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
className,
)}
{...props}
/>
)
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -386,7 +386,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
);
}
function SidebarGroupLabel({
@@ -394,7 +394,7 @@ function SidebarGroupLabel({
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
@@ -402,11 +402,11 @@ function SidebarGroupLabel({
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
className,
)}
{...props}
/>
)
);
}
function SidebarGroupAction({
@@ -414,7 +414,7 @@ function SidebarGroupAction({
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
@@ -422,11 +422,11 @@ function SidebarGroupAction({
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
className,
)}
{...props}
/>
)
);
}
function SidebarGroupContent({
@@ -440,7 +440,7 @@ function SidebarGroupContent({
className={cn("w-full text-sm", className)}
{...props}
/>
)
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
@@ -451,7 +451,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
@@ -462,7 +462,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("group/menu-item relative", className)}
{...props}
/>
)
);
}
const sidebarMenuButtonVariants = cva(
@@ -484,8 +484,8 @@ const sidebarMenuButtonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function SidebarMenuButton({
asChild = false,
@@ -496,12 +496,12 @@ function SidebarMenuButton({
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const Comp = asChild ? Slot.Root : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
@@ -512,16 +512,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
);
if (!tooltip) {
return button
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
};
}
return (
@@ -534,7 +534,7 @@ function SidebarMenuButton({
{...tooltip}
/>
</Tooltip>
)
);
}
function SidebarMenuAction({
@@ -543,10 +543,10 @@ function SidebarMenuAction({
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button"
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
@@ -556,11 +556,11 @@ function SidebarMenuAction({
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
className,
)}
{...props}
/>
)
);
}
function SidebarMenuBadge({
@@ -573,11 +573,11 @@ function SidebarMenuBadge({
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSkeleton({
@@ -585,12 +585,12 @@ function SidebarMenuSkeleton({
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return (
<div
@@ -615,7 +615,7 @@ function SidebarMenuSkeleton({
}
/>
</div>
)
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
@@ -625,11 +625,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSubItem({
@@ -643,7 +643,7 @@ function SidebarMenuSubItem({
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
);
}
function SidebarMenuSubButton({
@@ -653,11 +653,11 @@ function SidebarMenuSubButton({
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot.Root : "a"
const Comp = asChild ? Slot.Root : "a";
return (
<Comp
@@ -667,11 +667,11 @@ function SidebarMenuSubButton({
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 active:bg-accent active:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-primary data-active:text-primary-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -699,4 +699,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
};

View File

@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Skeleton({
className,
@@ -10,7 +10,7 @@ function Skeleton({
className={cn("animate-pulse rounded-md bg-muted/60", className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };

View File

@@ -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 };

View File

@@ -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,
}
};

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -1,15 +1,15 @@
"use client"
"use client";
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
@@ -20,11 +20,11 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
className,
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,19 +1,21 @@
import * as React from "react"
import * as React from "react";
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(mql.matches)
}
mql.addEventListener("change", onChange)
setIsMobile(mql.matches)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsMobile(mql.matches);
};
mql.addEventListener("change", onChange);
setIsMobile(mql.matches);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile
return !!isMobile;
}

View File

@@ -46,6 +46,7 @@ export interface EntityInfo {
id: string;
name: string;
isPlayer: boolean;
isAgent: boolean;
}
export interface WaitingContext {

View File

@@ -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 {

View File

@@ -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"]
}

View File

@@ -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,

View File

@@ -27,7 +27,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "^11.9.0",
"version": "11.13.0",
"onFail": "download"
}
},

View File

@@ -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,

View File

@@ -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);

View 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.',
);
});
});

View File

@@ -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.",
},
};
}

View File

@@ -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.

View File

@@ -1,4 +1,3 @@
export * from "./llm-validator.js";
export * from "./architect.js";
export * from "./delta.js";

View File

@@ -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.",
};
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -10,4 +10,3 @@ export * from "./world.js";
export * from "./clock.js";
export * from "./repository.js";
export * from "./alias.js";

View File

@@ -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) {

View File

@@ -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.");

View File

@@ -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",
);
});
});

View File

@@ -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", () => {

View File

@@ -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");

View File

@@ -5,8 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../core" },
{ "path": "../llm" }
]
"references": [{ "path": "../core" }, { "path": "../llm" }]
}

View File

@@ -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",
},

View File

@@ -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();

View File

@@ -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({

View File

@@ -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),
};
}
}
}

View File

@@ -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({

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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
}

View File

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

View File

@@ -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,14 +92,18 @@ export class LedgerRepository {
})();
}
private mapRowToEntry(row: Record<string, unknown>, involvedEntityIds: string[]): LedgerEntry {
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
)
(row.embedding as Buffer).byteLength /
Float32Array.BYTES_PER_ELEMENT,
),
)
: [];
@@ -123,7 +127,7 @@ export class LedgerRepository {
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
FROM ledger_entries
WHERE id = ?
`
`,
)
.get(id) as Record<string, unknown> | undefined;
@@ -133,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),
);
}
/**
@@ -151,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
@@ -182,18 +189,21 @@ export class LedgerRepository {
`;
params.push(limit);
const rows = this.db.prepare(query).all(...params) as Record<string, unknown>[];
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 }[];
@@ -205,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[] {
@@ -220,7 +232,7 @@ export class LedgerRepository {
WHERE owner_id = ? AND timestamp < ?
ORDER BY timestamp DESC
LIMIT 1
`
`,
)
.get(ownerId, timestamp) as Record<string, unknown> | undefined;
@@ -237,7 +249,7 @@ export class LedgerRepository {
WHERE owner_id = ? AND timestamp > ?
ORDER BY timestamp ASC
LIMIT 1
`
`,
)
.get(ownerId, timestamp) as Record<string, unknown> | undefined;
@@ -269,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
@@ -318,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);
}
}
@@ -333,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 }[];
@@ -353,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;
}

View File

@@ -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 Record<string, unknown>[];
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);

View File

@@ -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");

View File

@@ -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);

View File

@@ -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,

View File

@@ -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(),
});

View File

@@ -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

View File

@@ -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);

11816
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -14,14 +14,15 @@ describe("GeminiProvider Eval", () => {
});
const response = await provider.generateStructuredResponse({
systemPrompt: "You are a helpful assistant. Classify the tone of the user's sentence.",
systemPrompt:
"You are a helpful assistant. Classify the tone of the user's sentence.",
userContext: "I absolutely love this new engine, it works perfectly!",
schema: ToneSchema,
});
expect(response.success).toBe(true);
expect(response.data).toBeDefined();
const data = response.data!;
expect(data.tone).toBe("positive");
expect(data.confidence).toBeGreaterThan(0.8);

View File

@@ -6,7 +6,7 @@ import { IntentDecoder } from "@omnia/intent";
describe("IntentDecoder Live Eval (Tier 3)", () => {
test("decodes real complex narrative prose using live Gemini API", async () => {
expect(llmConfig.GOOGLE_API_KEY).toBeDefined();
// 1. Initialize live provider and decoder
const provider = new GeminiProvider(llmConfig.GOOGLE_API_KEY);
const decoder = new IntentDecoder(provider);
@@ -19,7 +19,8 @@ describe("IntentDecoder Live Eval (Tier 3)", () => {
world.addEntity(bob);
// 3. Narrative prose containing both a dialogue and physical action
const narrativeProse = '"Let\'s see if this key opens the main vault," Alice said to Bob. She slipped the silver key into the keyhole and turned it slowly.';
const narrativeProse =
'"Let\'s see if this key opens the main vault," Alice said to Bob. She slipped the silver key into the keyhole and turned it slowly.';
// 4. Decode prose using live Gemini model
const result = await decoder.decode(world, "alice", narrativeProse);
@@ -30,14 +31,16 @@ describe("IntentDecoder Live Eval (Tier 3)", () => {
expect(result.intents.length).toBeGreaterThanOrEqual(1);
// Verify first intent is dialogue spoken to Bob
const dialogueIntent = result.intents.find(i => i.type === "dialogue");
const dialogueIntent = result.intents.find((i) => i.type === "dialogue");
expect(dialogueIntent).toBeDefined();
expect(dialogueIntent!.actorId).toBe("alice");
expect(dialogueIntent!.targetIds).toContain("bob");
expect(dialogueIntent!.originalText).toContain("Let's see if this key opens the main vault");
expect(dialogueIntent!.originalText).toContain(
"Let's see if this key opens the main vault",
);
// Verify second intent is action
const actionIntent = result.intents.find(i => i.type === "action");
const actionIntent = result.intents.find((i) => i.type === "action");
expect(actionIntent).toBeDefined();
expect(actionIntent!.actorId).toBe("alice");
expect(actionIntent!.originalText).toContain("slipped the silver key");
@@ -45,28 +48,29 @@ describe("IntentDecoder Live Eval (Tier 3)", () => {
test("resolves targets correctly using the actor's subjective alias map", async () => {
expect(llmConfig.GOOGLE_API_KEY).toBeDefined();
const provider = new GeminiProvider(llmConfig.GOOGLE_API_KEY);
const decoder = new IntentDecoder(provider);
const world = new WorldState("world-xyz");
const alice = new Entity("alice");
const bob = new Entity("bob");
// Register alias mapping: Bob is known to Alice as "the hooded stranger"
alice.aliases.set("bob", "the hooded stranger");
world.addEntity(alice);
world.addEntity(bob);
// Narrative prose referring to Bob only by the subjective alias
const narrativeProse = "Alice handed the silver key to the hooded stranger.";
const narrativeProse =
"Alice handed the silver key to the hooded stranger.";
const result = await decoder.decode(world, "alice", narrativeProse);
expect(result.intents).toHaveLength(1);
const intent = result.intents[0];
expect(intent.type).toBe("action");
expect(intent.actorId).toBe("alice");
// Verify target resolution resolved the alias back to the correct system entity ID

View File

@@ -9,10 +9,7 @@ import {
import { MockLLMProvider } from "@omnia/llm";
import { IntentSequence } from "@omnia/intent";
import { Architect } from "@omnia/architect";
import {
BufferRepository,
BufferEntry,
} from "@omnia/memory";
import { BufferRepository, BufferEntry } from "@omnia/memory";
import {
ActorAgent,
ActorResponseSchema,
@@ -53,16 +50,22 @@ describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
// --- Mock LLM response queue ---
// 1. Actor produces prose containing a thought, a spoken line, and an action.
const mockActorProse = { narrativeProse: "I can't believe Bob hasn't noticed me yet, Alice thought. \"Hey Bob,\" she called out softly. She reached for the ledger on the table." };
const mockActorProse = {
narrativeProse:
"I can't believe Bob hasn't noticed me yet, Alice thought. \"Hey Bob,\" she called out softly. She reached for the ledger on the table.",
};
// 2. IntentDecoder splits that prose into 3 intents.
const mockDecodedSequence: IntentSequence = {
intents: [
{
type: "monologue",
originalText: "I can't believe Bob hasn't noticed me yet, Alice thought.",
description: "Alice internally reflects that Bob has not noticed her.",
selfDescription: "You internally reflect that Bob has not noticed you.",
originalText:
"I can't believe Bob hasn't noticed me yet, Alice thought.",
description:
"Alice internally reflects that Bob has not noticed her.",
selfDescription:
"You internally reflect that Bob has not noticed you.",
actorId: "alice",
targetIds: [],
modifiers: [],
@@ -88,20 +91,27 @@ describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
],
};
// 3. Architect: dialogue is always valid (0 min), action is valid (2 min).
// 3. Architect: dialogue is always valid (1 min), action is valid (2 min).
// NOTE: monologue never reaches the validator/delta generator.
const mockDialogueValidation = { isValid: true, reason: "Alice can speak." };
const mockDialogueTimeDelta = { minutesToAdvance: 0, explanation: "Speech is instantaneous." };
const mockActionValidation = { isValid: true, reason: "The ledger is within reach." };
const mockActionTimeDelta = { minutesToAdvance: 2, explanation: "Reaching for the ledger takes 2 minutes." };
const mockDialogueValidation = {
isValid: true,
reason: "Alice can speak.",
};
const mockActionValidation = {
isValid: true,
reason: "The ledger is within reach.",
};
const mockActionTimeDelta = {
minutesToAdvance: 2,
explanation: "Reaching for the ledger takes 2 minutes.",
};
const llmProvider = new MockLLMProvider([
mockActorProse, // 1. Actor generation
mockDecodedSequence, // 2. IntentDecoder
mockDialogueValidation, // 3. Architect.validateIntent (dialogue)
mockDialogueTimeDelta, // 4. TimeDeltaGenerator (dialogue)
mockActionValidation, // 5. Architect.validateIntent (action)
mockActionTimeDelta, // 6. TimeDeltaGenerator (action)
mockActorProse, // 1. Actor generation
mockDecodedSequence, // 2. IntentDecoder
mockDialogueValidation, // 3. Architect.validateIntent (dialogue)
mockActionValidation, // 4. Architect.validateIntent (action)
mockActionTimeDelta, // 5. TimeDeltaGenerator (action)
]);
const actor = new ActorAgent(llmProvider, bufferRepo);
@@ -143,7 +153,7 @@ describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
expect(intents[0].type).toBe("monologue");
expect(writtenEntries[0].outcome).toBeUndefined();
// 4. Dialogue: valid, 0-minute delta, no outcome field.
// 4. Dialogue: valid, 1-minute delta, no outcome field.
expect(writtenEntries[1].outcome).toBeUndefined();
// 5. Action: valid, 2-minute delta, outcome attached.
@@ -152,8 +162,8 @@ describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
reason: "The ledger is within reach.",
});
// 6. Clock advanced by exactly 2 minutes (dialogue 0 + action 2).
const expectedTime = new Date(startTime.getTime() + 2 * 60_000);
// 6. Clock advanced by exactly 3 minutes (dialogue 1 + action 2).
const expectedTime = new Date(startTime.getTime() + 3 * 60_000);
expect(world.clock.get().toISOString()).toBe(expectedTime.toISOString());
// 7. All three intents persisted to Alice's memory buffer.
@@ -180,9 +190,7 @@ describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
expect(ActorResponseSchema.parse(valid)).toEqual(valid);
expect(() => ActorResponseSchema.parse({})).toThrow();
expect(() =>
ActorResponseSchema.parse({ narrativeProse: 123 }),
).toThrow();
expect(() => ActorResponseSchema.parse({ narrativeProse: 123 })).toThrow();
});
test("serializeSubjectiveWorldState is epistemically bounded", async () => {

View File

@@ -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 { IntentDecoder, IntentSequence } from "@omnia/intent";
import { Architect } from "@omnia/architect";
@@ -51,46 +56,66 @@ describe("Omnia Integration Tests (Tier 2)", () => {
};
// 2. Architect validation & delta generation responses
const mockDialogueValidation = { isValid: true, reason: "Alice is able to speak to Bob." };
const mockDialogueTimeDelta = { minutesToAdvance: 0, explanation: "Dialogue is instantaneous." };
const mockDialogueValidation = {
isValid: true,
reason: "Alice is able to speak to Bob.",
};
const mockActionValidation = { isValid: true, reason: "The door is unlocked and reachable." };
const mockActionTimeDelta = { minutesToAdvance: 3, explanation: "Creeping silently and opening a door takes 3 minutes." };
const mockActionValidation = {
isValid: true,
reason: "The door is unlocked and reachable.",
};
const mockActionTimeDelta = {
minutesToAdvance: 3,
explanation: "Creeping silently and opening a door takes 3 minutes.",
};
const llmProvider = new MockLLMProvider([
mockIntentSequence, // Used by IntentDecoder
mockDialogueValidation, // Used by Architect.validateIntent (Dialogue)
mockDialogueTimeDelta, // Used by TimeDeltaGenerator (Dialogue)
mockActionValidation, // Used by Architect.validateIntent (Action)
mockActionTimeDelta // Used by TimeDeltaGenerator (Action)
mockIntentSequence, // Used by IntentDecoder
mockDialogueValidation, // Used by Architect.validateIntent (Dialogue)
mockActionValidation, // Used by Architect.validateIntent (Action)
mockActionTimeDelta, // Used by TimeDeltaGenerator (Action)
]);
const decoder = new IntentDecoder(llmProvider);
const architect = new Architect(llmProvider, repo);
// 1. Decode the raw prose into structured intents
const narrativeProse = '"Cover me," Alice whispered to Bob. She crept towards the door and pulled the handle.';
const decodedSequence = await decoder.decode(world, "alice", narrativeProse);
const narrativeProse =
'"Cover me," Alice whispered to Bob. She crept towards the door and pulled the handle.';
const decodedSequence = await decoder.decode(
world,
"alice",
narrativeProse,
);
expect(decodedSequence.intents).toHaveLength(2);
// 2. Process first intent (dialogue)
const result1 = await architect.processIntent(world, decodedSequence.intents[0]);
const result1 = await architect.processIntent(
world,
decodedSequence.intents[0],
);
expect(result1.isValid).toBe(true);
expect(result1.timeDelta!.minutesToAdvance).toBe(0);
expect(result1.timeDelta!.minutesToAdvance).toBe(1);
// 3. Process second intent (action)
const result2 = await architect.processIntent(world, decodedSequence.intents[1]);
const result2 = await architect.processIntent(
world,
decodedSequence.intents[1],
);
expect(result2.isValid).toBe(true);
expect(result2.timeDelta!.minutesToAdvance).toBe(3);
// 4. Verify local state clock advanced by the time deltas
const expectedTime = new Date(startTime.getTime() + 3 * 60_000); // 3 minutes total
const expectedTime = new Date(startTime.getTime() + 4 * 60_000); // 4 minutes total (dialogue 1 + action 3)
expect(world.clock.get().toISOString()).toBe(expectedTime.toISOString());
// 5. Verify database state clock was advanced and persisted
const reloadedWorld = repo.loadWorldState("world-abc")!;
expect(reloadedWorld.clock.get().toISOString()).toBe(expectedTime.toISOString());
expect(reloadedWorld.clock.get().toISOString()).toBe(
expectedTime.toISOString(),
);
db.close();
});
@@ -130,15 +155,19 @@ describe("Omnia Integration Tests (Tier 2)", () => {
// LLM validation / time delta mock responses:
// For intent1 (action):
const mockActionValidation = { isValid: false, reason: "Hairpins cannot pick high-security locks." };
const mockActionValidation = {
isValid: false,
reason: "Hairpins cannot pick high-security locks.",
};
// For intent2 (dialogue):
const mockDialogueValidation = { isValid: true, reason: "Alice is free to talk." };
const mockDialogueTimeDelta = { minutesToAdvance: 1, explanation: "Speaking takes 1 minute." };
const mockDialogueValidation = {
isValid: true,
reason: "Alice is free to talk.",
};
const llmProvider = new MockLLMProvider([
mockActionValidation, // Used by Architect.validateIntent (Action)
mockDialogueValidation, // Used by Architect.validateIntent (Dialogue)
mockDialogueTimeDelta // Used by TimeDeltaGenerator (Dialogue)
mockActionValidation, // Used by Architect.validateIntent (Action)
mockDialogueValidation, // Used by Architect.validateIntent (Dialogue)
]);
const architect = new Architect(llmProvider, repo);

View File

@@ -15,7 +15,13 @@ export default defineConfig({
src: "./src/assets/img/logo.png",
replacesTitle: true,
},
social: [{ icon: "github", label: "GitHub", href: "https://github.com/sortedcord/omnia-consolidated" }],
social: [
{
icon: "github",
label: "GitHub",
href: "https://github.com/sortedcord/omnia-consolidated",
},
],
sidebar: [
{
label: "Introduction",
@@ -29,6 +35,10 @@ export default defineConfig({
label: "Guides",
items: [{ autogenerate: { directory: "guides" } }],
},
{
label: "Diary",
items: [{ autogenerate: { directory: "diary" } }],
},
],
editLink: {
baseUrl: "https://github.com/sortedcord/omnia/edit/main/web/docs/",

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -34,11 +34,11 @@ Establishes the role, rules, and output contract:
Epistemically bounded, with these sections:
| Section | Content | Source |
|---|---|---|
| Current moment | The subjective present time | `worldState.clock.get().toISOString()` |
| The world as you perceive it | Self-visible attributes, co-located entities + their visible attributes, other presences elsewhere | `serializeSubjectiveWorldState()` |
| Your recent memory | Recent `BufferEntry`s, alias-substituted, with relative time phrasing | `serializeSubjectiveBufferEntry()` |
| Section | Content | Source |
| ---------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------- |
| Current moment | The subjective present time | `worldState.clock.get().toISOString()` |
| The world as you perceive it | Self-visible attributes, co-located entities + their visible attributes, other presences elsewhere | `serializeSubjectiveWorldState()` |
| Your recent memory | Recent `BufferEntry`s, alias-substituted, with relative time phrasing | `serializeSubjectiveBufferEntry()` |
No system UUIDs, no private attributes the entity lacks ACL access to, and no objective-world-state dump are present.
@@ -81,13 +81,13 @@ Monologue (`"monologue"`) is the third intent type. Its properties:
## Key Files
| File | Role |
|---|---|
| `packages/actor/src/actor-prompt-builder.ts` | Assembles the epistemically-bounded actor prompt |
| `packages/actor/src/actor.ts` | `ActorAgent` class: orchestrates prompt → LLM → decoder flow |
| `packages/actor/src/index.ts` | Package exports |
| `packages/core/src/world.ts:72` | `serializeSubjectiveWorldState()` |
| `packages/intent/src/intent.ts:8` | `IntentTypeSchema` — includes `"monologue"` |
| `packages/intent/src/intent-decoder.ts:30` | Decoder system prompt |
| `packages/architect/src/architect.ts:35` | Monologue short-circuit |
| `packages/architect/src/llm-validator.ts:19` | Defensive monologue guard |
| File | Role |
| -------------------------------------------- | ------------------------------------------------------------ |
| `packages/actor/src/actor-prompt-builder.ts` | Assembles the epistemically-bounded actor prompt |
| `packages/actor/src/actor.ts` | `ActorAgent` class: orchestrates prompt → LLM → decoder flow |
| `packages/actor/src/index.ts` | Package exports |
| `packages/core/src/world.ts:72` | `serializeSubjectiveWorldState()` |
| `packages/intent/src/intent.ts:8` | `IntentTypeSchema` — includes `"monologue"` |
| `packages/intent/src/intent-decoder.ts:30` | Decoder system prompt |
| `packages/architect/src/architect.ts:35` | Monologue short-circuit |
| `packages/architect/src/llm-validator.ts:19` | Defensive monologue guard |

View File

@@ -30,20 +30,24 @@ function checkHandoffTrigger(
entity: Entity,
bufferEntries: BufferEntry[],
now: Date,
maxContext?: number
maxContext?: number,
): HandoffTrigger;
```
### Voluntary Triggers (Soft)
Voluntary triggers identify natural narrative boundaries where the entity is inactive or has transitioned contexts:
* **Scene Exit**: The entity's `locationId` changes relative to the location of the entries currently in the buffer, or the buffer contains entries spanning multiple different locations.
* **Idle Decay**: The entity has produced no external interactions (only monologue entries or no entries) for $N$ (default: 5) consecutive turns.
* **Attribute-Driven State**: A character attribute (e.g., `status: asleep` or `consciousness: unconscious`) signals low external activity.
- **Scene Exit**: The entity's `locationId` changes relative to the location of the entries currently in the buffer, or the buffer contains entries spanning multiple different locations.
- **Idle Decay**: The entity has produced no external interactions (only monologue entries or no entries) for $N$ (default: 5) consecutive turns.
- **Attribute-Driven State**: A character attribute (e.g., `status: asleep` or `consciousness: unconscious`) signals low external activity.
### Involuntary Triggers (Hard)
Involuntary triggers enforce context constraints before building the next prompt to prevent token limit overflows:
* **Buffer Ceiling**: The serialized length of the working memory section (as generated by `getMemorySectionLength`) exceeds a configured fraction (default: 60%) of the provider's context window.
* **Event Velocity**: The number of buffer entries exceeds a safe threshold (default: 20), indicating an event-heavy scene generating history faster than voluntary boundaries occur.
- **Buffer Ceiling**: The serialized length of the working memory section (as generated by `getMemorySectionLength`) exceeds a configured fraction (default: 60%) of the provider's context window.
- **Event Velocity**: The number of buffer entries exceeds a safe threshold (default: 20), indicating an event-heavy scene generating history faster than voluntary boundaries occur.
If the buffer is empty, trigger detection exits immediately with `"none"`.
@@ -57,8 +61,8 @@ The watermark boundary is computed as:
$$\text{Watermark} = \max(K \text{ entries}, \text{entries bucketed as fresh by } \textit{naturalizeTime})$$
* **Hard Floor ($K = 8$)**: The last 8 entries in the buffer are always preserved.
* **Temporal Freshness**: Any entries that `naturalizeTime` classifies within the immediate temporal horizon (`"just now"`, `"moments ago"`, `"a few minutes ago"`, or `"several minutes ago"`) are also protected.
- **Hard Floor ($K = 8$)**: The last 8 entries in the buffer are always preserved.
- **Temporal Freshness**: Any entries that `naturalizeTime` classifies within the immediate temporal horizon (`"just now"`, `"moments ago"`, `"a few minutes ago"`, or `"several minutes ago"`) are also protected.
Entries older than the watermark boundary constitute the **Candidate Pool** and are eligible for handoff processing.
@@ -84,16 +88,20 @@ const HandoffResultSchema = z.object({
```
### LLM Processing Rules
The prompt instructs the model to apply the following cognitive operations:
1. **Clustering**: Group related consecutive candidate entries into high-level narrative beats (e.g., consolidating an entire dialogue exchange into one beat).
2. **Third-Person Synthesis**: Write the `content` summary from a third-person narrative perspective.
3. **Dialogue Salience**: Extract verbatim quotes of high emotional or narrative relevance.
4. **Information Pruning (Forgetting)**: Any candidate entry ID omitted from all chunk `sourceEntryIds` is permanently deleted from the buffer and never saved to the ledger. This mirrors natural sensory attenuation.
### Unresolved Thread Retention (Pinning)
When a chunk represents an unresolved high-stakes situation (e.g., an active conflict or standing threat), the LLM sets `retainInBuffer: true`.
* The summary is committed to the long-term Ledger as normal.
* The source buffer entries are exempted from pruning, remaining in the working memory buffer with `pinned: true` until a future handoff pass determines the thread is resolved.
When a chunk represents an unresolved high-stakes situation (e.g., an active conflict or standing threat), the LLM sets `retainInBuffer: true`.
- The summary is committed to the long-term Ledger as normal.
- The source buffer entries are exempted from pruning, remaining in the working memory buffer with `pinned: true` until a future handoff pass determines the thread is resolved.
---
@@ -123,6 +131,6 @@ If LLM inference, Zod schema validation, or vector embedding generation fails, t
The handoff pipeline is exposed via `HandoffEngine` and is wired directly into the simulation loop:
* **Turn Hook**: `SimulationManager.step()` invokes trigger evaluation and handoff resolution for all active entities at the start of the turn, before entities act.
* **Task Routing**: Handoff tasks are routed using the `"handoff"` routing key, enabling independent LLM provider selection and max context configuration.
* **Buffer Garbage Collection**: By combining memory promotion with automatic pruning, the handoff engine bounds buffer memory usage over long simulation runs, eliminating the need for a separate garbage collection mechanism.
- **Turn Hook**: `SimulationManager.step()` invokes trigger evaluation and handoff resolution for all active entities at the start of the turn, before entities act.
- **Task Routing**: Handoff tasks are routed using the `"handoff"` routing key, enabling independent LLM provider selection and max context configuration.
- **Buffer Garbage Collection**: By combining memory promotion with automatic pruning, the handoff engine bounds buffer memory usage over long simulation runs, eliminating the need for a separate garbage collection mechanism.

Some files were not shown because too many files have changed in this diff Show More