mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
Compare commits
16 Commits
feat/memor
...
c86c0295ef
| Author | SHA1 | Date | |
|---|---|---|---|
| c86c0295ef | |||
| 4b71db3a34 | |||
| 3906259328 | |||
| 3951a1841e | |||
| 4cc48479fb | |||
| 77be8d59a5 | |||
| 99c11cbbfe | |||
| 224553c98c | |||
| 30ee7f9e8d | |||
| c8091ed47c | |||
| 27c8bb1cb8 | |||
| bfff93e793 | |||
| 9bdc3ca04b | |||
| ea817d8044 | |||
| 382507e71a | |||
| 7df685365e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -60,3 +60,5 @@ data/
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
__local_notes/
|
||||
|
||||
@@ -122,7 +122,7 @@ Space is a graph: `world → region → location → point of interest`, connect
|
||||
### Memory Tiers
|
||||
|
||||
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
|
||||
- **Vector Archive (planned):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Vector Archive (implemented):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Dossier (planned):** Each observer's subjective beliefs about another character.
|
||||
|
||||
Memory is per-character on purpose: recall is testimony from a vantage point, which is what makes interrogating two witnesses interesting.
|
||||
|
||||
24
apps/gui/components.json
Normal file
24
apps/gui/components.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@retroui": "https://retroui.dev/r/radix/{name}.json",
|
||||
"@retroui-base": "https://retroui.dev/r/base/{name}.json"
|
||||
}
|
||||
}
|
||||
2
apps/gui/next-env.d.ts
vendored
2
apps/gui/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@omnia/actor": "workspace:*",
|
||||
"@omnia/architect": "workspace:*",
|
||||
"@omnia/core": "workspace:*",
|
||||
@@ -18,18 +19,28 @@
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/scenario": "workspace:*",
|
||||
"@omnia/spatial": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next": "^16.2.10",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"shadcn": "^4.13.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,16 +4,29 @@ import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
getConfigStatus,
|
||||
listProviderInstances,
|
||||
createProviderInstance,
|
||||
deleteProviderInstance,
|
||||
setActiveProviderInstance,
|
||||
getProviderMappings,
|
||||
setProviderMapping,
|
||||
updateProviderInstance,
|
||||
getAvailableProviders,
|
||||
regenerateEmbeddings,
|
||||
} from "@/app/play/actions";
|
||||
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
|
||||
import { ProviderInstancesConfig } from "@/components/config/ProviderInstancesConfig";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
interface ConfigStatus {
|
||||
apiKeySet: boolean;
|
||||
@@ -30,78 +43,22 @@ export default function ConfigPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState<string | "new">("new");
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editProvider, setEditProvider] = useState("google-genai");
|
||||
const [editKey, setEditKey] = useState("");
|
||||
const [editModel, setEditModel] = useState("gemini-2.5-flash");
|
||||
const [editIsActive, setEditIsActive] = useState(false);
|
||||
const [editType, setEditType] = useState<"generative" | "embedding">("generative");
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedInstanceId === "new") {
|
||||
setEditName("");
|
||||
const defaultProvider = "google-genai";
|
||||
setEditProvider(defaultProvider);
|
||||
setEditKey("");
|
||||
setEditType("generative");
|
||||
const pMeta = availableProviders.find((p) => p.id === defaultProvider);
|
||||
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
} else {
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst) {
|
||||
setEditName(inst.name);
|
||||
setEditProvider(inst.providerName);
|
||||
setEditKey("");
|
||||
setEditType(inst.type || "generative");
|
||||
const pMeta = availableProviders.find((p) => p.id === inst.providerName);
|
||||
setEditModel(inst.modelName || (inst.type === "embedding" ? pMeta?.defaultEmbeddingModel : pMeta?.defaultModel) || "gemini-2.5-flash");
|
||||
setEditIsActive(inst.isActive);
|
||||
}
|
||||
}
|
||||
}, [selectedInstanceId, instances, availableProviders]);
|
||||
|
||||
const handleProviderChange = (providerId: string) => {
|
||||
setEditProvider(providerId);
|
||||
const pMeta = availableProviders.find((p) => p.id === providerId);
|
||||
if (pMeta) {
|
||||
setEditModel(editType === "embedding" ? pMeta.defaultEmbeddingModel : pMeta.defaultModel);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: "generative" | "embedding") => {
|
||||
setEditType(type);
|
||||
const pMeta = availableProviders.find((p) => p.id === editProvider);
|
||||
if (pMeta) {
|
||||
setEditModel(type === "embedding" ? pMeta.defaultEmbeddingModel : pMeta.defaultModel);
|
||||
}
|
||||
};
|
||||
|
||||
const loadInstances = useCallback(async () => {
|
||||
try {
|
||||
const list = await listProviderInstances();
|
||||
setInstances(list);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const list = await listProviderInstances();
|
||||
setInstances(list);
|
||||
}, []);
|
||||
|
||||
const loadMappings = useCallback(async () => {
|
||||
try {
|
||||
const maps = await getProviderMappings();
|
||||
setMappings(maps);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const maps = await getProviderMappings();
|
||||
setMappings(maps);
|
||||
}, []);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await getConfigStatus();
|
||||
setConfig(result);
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const status = await getConfigStatus();
|
||||
setConfig(status);
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
const provs = await getAvailableProviders();
|
||||
@@ -117,89 +74,6 @@ export default function ConfigPage() {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editName.trim()) {
|
||||
setError("Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
let shouldRegenerate = false;
|
||||
let targetInstanceId = selectedInstanceId;
|
||||
|
||||
if (selectedInstanceId === "new") {
|
||||
if (!editKey.trim()) {
|
||||
setError("API Key is required for new instances.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const created = await createProviderInstance(editName, editProvider, editKey, editModel || undefined, editType);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(created.id);
|
||||
}
|
||||
targetInstanceId = created.id;
|
||||
setSelectedInstanceId(created.id);
|
||||
} else {
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst && inst.type === "embedding") {
|
||||
const isMapped = mappings["embeddings"] === selectedInstanceId;
|
||||
const isActive = inst.isActive && !mappings["embeddings"];
|
||||
if (isMapped || isActive) {
|
||||
const hasChanged = inst.providerName !== editProvider || inst.modelName !== editModel;
|
||||
if (hasChanged) {
|
||||
const confirmChange = window.confirm(
|
||||
"You have changed the configuration of the active embedding provider. This will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?"
|
||||
);
|
||||
if (!confirmChange) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
shouldRegenerate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined, editModel || undefined, editType);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(selectedInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
|
||||
if (shouldRegenerate && targetInstanceId !== "new") {
|
||||
await regenerateEmbeddings(targetInstanceId);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedInstanceId === "new") return;
|
||||
if (!confirm("Are you sure you want to delete this provider instance?")) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
await deleteProviderInstance(selectedInstanceId);
|
||||
setSelectedInstanceId("new");
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMapping = async (task: string, providerInstanceId: string) => {
|
||||
if (task === "embeddings" && mappings[task] !== providerInstanceId) {
|
||||
const confirmChange = window.confirm(
|
||||
@@ -223,311 +97,115 @@ export default function ConfigPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[800px] px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl">Configuration</h1>
|
||||
<div className="mx-auto max-w-[800px] px-10 py-12">
|
||||
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">Configuration</h1>
|
||||
|
||||
{loading && <p>Loading configuration...</p>}
|
||||
{config === null && loading && <p className="text-body-md text-muted-foreground">Loading configuration...</p>}
|
||||
{error && (
|
||||
<div className="mb-4 rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
<div className="mb-4 border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && !loading && (
|
||||
<>
|
||||
<section className="mb-8 border-b border-gray-200 pb-6">
|
||||
<h2 className="mb-3 text-lg">LLM Provider Instances</h2>
|
||||
<div className="mt-4 grid min-h-[400px] grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white md:grid-cols-[30%_70%]">
|
||||
{/* 30% area */}
|
||||
<div className="flex flex-col border-r border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-4 py-4">
|
||||
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">Instances</h3>
|
||||
<button
|
||||
onClick={() => setSelectedInstanceId("new")}
|
||||
className="cursor-pointer rounded-md bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-600"
|
||||
type="button"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{instances.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-gray-400">
|
||||
No instances configured
|
||||
</div>
|
||||
) : (
|
||||
instances.map((inst) => (
|
||||
<div
|
||||
key={inst.id}
|
||||
onClick={() => setSelectedInstanceId(inst.id)}
|
||||
className={`cursor-pointer border-b border-gray-200 border-l-[3px] px-4 py-4 transition-all hover:bg-gray-100 ${
|
||||
selectedInstanceId === inst.id
|
||||
? "border-l-blue-500 bg-blue-50"
|
||||
: "border-l-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium text-[#111]">{inst.name}</div>
|
||||
<div className="mt-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{inst.providerName} ({inst.type || "generative"})</span>
|
||||
{inst.isActive && (
|
||||
<span className="rounded-full bg-green-100 px-1.5 py-[1px] text-[0.65rem] font-semibold text-green-700">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{config && (
|
||||
<div className={loading ? "opacity-60 pointer-events-none transition-opacity duration-200" : "transition-opacity duration-200"}>
|
||||
<ProviderInstancesConfig
|
||||
instances={instances}
|
||||
availableProviders={availableProviders}
|
||||
mappings={mappings}
|
||||
onChanged={async () => {
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 70% area */}
|
||||
<div className="flex flex-col bg-white">
|
||||
<form onSubmit={handleSave} className="flex h-full flex-col justify-between">
|
||||
<div className="flex flex-1 flex-col gap-5 p-6">
|
||||
<h3 className="m-0 mb-2 text-lg font-semibold text-[#111]">
|
||||
{selectedInstanceId === "new"
|
||||
? "Create New Provider Instance"
|
||||
: `Configure: ${editName}`}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="formName" className="text-xs font-medium text-gray-700">
|
||||
Friendly Name
|
||||
</label>
|
||||
<input
|
||||
id="formName"
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="e.g. Gemini - Production"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="formType" className="text-xs font-medium text-gray-700">
|
||||
Instance Type
|
||||
</label>
|
||||
<select
|
||||
id="formType"
|
||||
value={editType}
|
||||
onChange={(e) => handleTypeChange(e.target.value as "generative" | "embedding")}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
|
||||
>
|
||||
<option value="generative">Generative (Chat / Text Completion)</option>
|
||||
<option value="embedding">Embedding (Vector generation)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="formProvider" className="text-xs font-medium text-gray-700">
|
||||
Provider Type
|
||||
</label>
|
||||
<select
|
||||
id="formProvider"
|
||||
value={editProvider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
|
||||
>
|
||||
{availableProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{editProvider && availableProviders.length > 0 && (
|
||||
<span className="mt-1 block rounded border border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-600">
|
||||
{availableProviders.find((p) => p.id === editProvider)?.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="formKey" className="text-xs font-medium text-gray-700">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
id="formKey"
|
||||
type="password"
|
||||
value={editKey}
|
||||
onChange={(e) => setEditKey(e.target.value)}
|
||||
placeholder={
|
||||
selectedInstanceId === "new"
|
||||
? "AIzaSy..."
|
||||
: "•••••••• (unchanged)"
|
||||
}
|
||||
required={selectedInstanceId === "new"}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="formModel" className="text-xs font-medium text-gray-700">
|
||||
Model Name
|
||||
</label>
|
||||
<input
|
||||
id="formModel"
|
||||
type="text"
|
||||
value={editModel}
|
||||
onChange={(e) => setEditModel(e.target.value)}
|
||||
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-row items-center gap-2">
|
||||
<input
|
||||
id="formActive"
|
||||
type="checkbox"
|
||||
checked={editIsActive}
|
||||
onChange={(e) => setEditIsActive(e.target.checked)}
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="formActive" className="cursor-pointer text-xs font-medium text-gray-700">
|
||||
Set as Active Instance
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-4">
|
||||
<div>
|
||||
{selectedInstanceId !== "new" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="cursor-pointer rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="cursor-pointer rounded-md bg-blue-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8 border-b border-gray-200 pb-6">
|
||||
<h2 className="mb-3 text-lg">Task Provider Routing</h2>
|
||||
<p className="my-4 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||
Configure which LLM Provider Key Instance should handle each specific simulation
|
||||
task. Mappings default to the currently <strong>Active</strong> instance if not
|
||||
specified.
|
||||
<section className="border-b border-dotted border-border/20 mb-8 pb-8">
|
||||
<h2 className="mb-3 text-headline-md text-foreground">Task Provider Routing</h2>
|
||||
<p className="my-4 border border-border/20 bg-secondary px-3 py-2 text-label-sm text-foreground/80">
|
||||
Configure which LLM Provider Key Instance should handle each
|
||||
specific simulation task. Mappings default to the currently{" "}
|
||||
<strong>Active</strong> instance if not specified.
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{[
|
||||
{ key: "actor-prose", label: "Actor Prose Generation", desc: "Generates roleplay/narrative prose for Non-Player Characters.", type: "generative" },
|
||||
{ key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules.", type: "generative" },
|
||||
{ key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC).", type: "generative" },
|
||||
{ key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock.", type: "generative" },
|
||||
{ key: "handoff", label: "Memory Handoff Engine", desc: "Promotes entities' working memories to the long-term Ledger via LLM summarization and pruning.", type: "generative" },
|
||||
{ key: "embeddings", label: "Text Embeddings Generator", desc: "Generates vector embeddings for long-term memory retrieval.", type: "embedding" },
|
||||
].map((task) => (
|
||||
<div
|
||||
key={task.key}
|
||||
className="flex flex-col justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4"
|
||||
className="flex flex-col justify-between gap-3 border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]"
|
||||
>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<strong className="text-sm text-[#111]">{task.label}</strong>
|
||||
<span className="mt-0.5 text-gray-500">{task.desc}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<strong className="text-body-md text-foreground">
|
||||
{task.label}
|
||||
</strong>
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">{task.desc}</span>
|
||||
</div>
|
||||
<select
|
||||
<Select
|
||||
value={mappings[task.key] || ""}
|
||||
onChange={(e) => handleUpdateMapping(task.key, e.target.value)}
|
||||
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs"
|
||||
onValueChange={(value) =>
|
||||
handleUpdateMapping(task.key, value || "")
|
||||
}
|
||||
>
|
||||
<option value="">-- Use Active Key (Default) --</option>
|
||||
{instances
|
||||
.filter((inst) => (inst.type || "generative") === task.type)
|
||||
.map((inst) => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} ({inst.providerName}){inst.isActive ? " [Active]" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="-- Use Active Key (Default) --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="">-- Use Active Key (Default) --</SelectItem>
|
||||
{instances
|
||||
.filter((inst) => (inst.type || "generative") === task.type)
|
||||
.map((inst) => (
|
||||
<SelectItem key={inst.id} value={inst.id}>
|
||||
{inst.name} ({inst.providerName}){inst.isActive ? " [Active]" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8 border-b border-gray-200 pb-6">
|
||||
<h2 className="mb-3 text-lg">Environment Variables Default</h2>
|
||||
<div className="flex justify-between border-b border-gray-100 py-1.5">
|
||||
<span className="text-sm text-gray-500">Default Model</span>
|
||||
<span className="text-sm">
|
||||
<code className="font-mono text-sm">{config.model}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-100 py-1.5">
|
||||
<span className="text-sm text-gray-500">Default API Key (.env)</span>
|
||||
<span
|
||||
className={
|
||||
config.apiKeySet
|
||||
? "text-sm text-green-600"
|
||||
: "text-sm font-medium text-red-600"
|
||||
}
|
||||
>
|
||||
{config.apiKeySet
|
||||
? `✓ Set (${config.apiKeyPreview})`
|
||||
: "✗ NOT SET"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8 border-b border-gray-200 pb-6">
|
||||
<h2 className="mb-3 text-lg">Available Scenarios</h2>
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 text-headline-md text-foreground">Available Scenarios</h2>
|
||||
{config.availableScenarios.length === 0 ? (
|
||||
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
|
||||
No scenarios found in <code className="font-mono text-xs">content/demo/scenarios/</code>.
|
||||
<p className="mt-3 border border-accent bg-accent/25 px-3 py-2 text-label-sm text-foreground/80">
|
||||
No scenarios found in{" "}
|
||||
<code className="font-mono text-xs">
|
||||
content/demo/scenarios/
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
|
||||
Name
|
||||
</th>
|
||||
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
|
||||
Path
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Path</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{config.availableScenarios.map((s) => (
|
||||
<tr key={s.path}>
|
||||
<td className="border-b border-gray-100 p-2">{s.name}</td>
|
||||
<td className="border-b border-gray-100 p-2">
|
||||
<code className="font-mono text-xs text-blue-600">{s.path}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<TableRow key={s.path}>
|
||||
<TableCell className="text-body-md">{s.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className="font-mono text-xs text-primary">
|
||||
{s.path}
|
||||
</code>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mb-8 border-b border-gray-200 pb-6">
|
||||
<h2 className="mb-3 text-lg">Engine Packages</h2>
|
||||
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
|
||||
All <code className="font-mono text-xs">@omnia/*</code> workspace packages are
|
||||
consumed via <code className="font-mono text-xs">transpilePackages</code> in{" "}
|
||||
<code className="font-mono text-xs">next.config.ts</code>. The native{" "}
|
||||
<code className="font-mono text-xs">better-sqlite3</code> module is externalized
|
||||
via <code className="font-mono text-xs">serverExternalPackages</code>.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,172 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
@custom-variant data-open (&[data-state="open"], &[data-state="active"]);
|
||||
@custom-variant data-closed (&[data-state="closed"], &[data-state="inactive"]);
|
||||
@custom-variant data-active (&[data-state="active"], &[data-state="open"]);
|
||||
@custom-variant data-checked (&[data-state="checked"], &[aria-checked="true"]);
|
||||
@custom-variant data-horizontal (&[data-orientation="horizontal"]);
|
||||
@custom-variant data-vertical (&[data-orientation="vertical"]);
|
||||
@custom-variant data-popup-open (&[data-state="open"]);
|
||||
|
||||
@theme inline {
|
||||
--font-head: var(--font-head);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
|
||||
--radius: 0px;
|
||||
--radius-xs: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-md: 0px;
|
||||
--radius-lg: 0px;
|
||||
--radius-xl: 0px;
|
||||
--radius-2xl: 0px;
|
||||
--radius-3xl: 0px;
|
||||
|
||||
--shadow-xs: 1px 1px 0 0 var(--border);
|
||||
--shadow-sm: 2px 2px 0 0 var(--border);
|
||||
--shadow: 3px 3px 0 0 var(--border);
|
||||
--shadow-md: 4px 4px 0 0 var(--border);
|
||||
--shadow-lg: 6px 6px 0 0 var(--border);
|
||||
--shadow-xl: 10px 10px 0 1px var(--border);
|
||||
--shadow-2xl: 16px 16px 0 1px var(--border);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary-hover: var(--primary-hover);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-dim: var(--surface-dim);
|
||||
--color-surface-bright: var(--surface-bright);
|
||||
--color-surface-container-lowest: var(--surface-container-lowest);
|
||||
--color-surface-container-low: var(--surface-container-low);
|
||||
--color-surface-container: var(--surface-container);
|
||||
--color-surface-container-high: var(--surface-container-high);
|
||||
--color-surface-container-highest: var(--surface-container-highest);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0;
|
||||
--background: #fff8f5;
|
||||
--foreground: #201a16;
|
||||
--card: #fcf3e8;
|
||||
--card-foreground: #201a16;
|
||||
--popover: #fff8f5;
|
||||
--popover-foreground: #201a16;
|
||||
--primary: #e58e58;
|
||||
--primary-hover: #d47e48;
|
||||
--primary-foreground: #201a16;
|
||||
--secondary: #f5e6d3;
|
||||
--secondary-foreground: #201a16;
|
||||
--muted: #efe0cd;
|
||||
--muted-foreground: #6d6354;
|
||||
--accent: #ffd966;
|
||||
--accent-foreground: #201a16;
|
||||
--destructive: #ba1a1a;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #5c544e;
|
||||
--input: #fff8f5;
|
||||
--ring: #e58e58;
|
||||
|
||||
--surface: #fff8f5;
|
||||
--surface-dim: #e4d8d0;
|
||||
--surface-bright: #fff8f5;
|
||||
--surface-container-lowest: #ffffff;
|
||||
--surface-container-low: #fef1e9;
|
||||
--surface-container: #f8ece3;
|
||||
--surface-container-high: #f2e6de;
|
||||
--surface-container-highest: #ece0d8;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #201a16;
|
||||
--foreground: #fff8f5;
|
||||
--card: #362f2a;
|
||||
--card-foreground: #fff8f5;
|
||||
--popover: #201a16;
|
||||
--popover-foreground: #fff8f5;
|
||||
--primary: #e58e58;
|
||||
--primary-hover: #ffb68c;
|
||||
--primary-foreground: #201a16;
|
||||
--secondary: #54433a;
|
||||
--secondary-foreground: #fbeee6;
|
||||
--muted: #362f2a;
|
||||
--muted-foreground: #d9c2b6;
|
||||
--accent: #c1a032;
|
||||
--accent-foreground: #201a16;
|
||||
--destructive: #ffdad6;
|
||||
--destructive-foreground: #93000a;
|
||||
--border: #877369;
|
||||
--input: #362f2a;
|
||||
--ring: #e58e58;
|
||||
|
||||
--surface: #201a16;
|
||||
--surface-dim: #362f2a;
|
||||
--surface-bright: #201a16;
|
||||
--surface-container-lowest: #201a16;
|
||||
--surface-container-low: #362f2a;
|
||||
--surface-container: #362f2a;
|
||||
--surface-container-high: #54433a;
|
||||
--surface-container-highest: #54433a;
|
||||
}
|
||||
|
||||
@utility text-headline-lg {
|
||||
font-family: var(--font-head);
|
||||
font-size: 48px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@utility text-headline-md {
|
||||
font-family: var(--font-head);
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@utility text-headline-sm {
|
||||
font-family: var(--font-head);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@utility text-body-lg {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@utility text-body-md {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@utility text-label-sm {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,69 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { NavBar } from "@/components/nav/NavBar";
|
||||
import "./globals.css";
|
||||
"use client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Omnia GUI",
|
||||
description: "Omnia Narrative Simulation Engine — Web Interface",
|
||||
};
|
||||
import type { ReactNode } from "react";
|
||||
import { Jersey_25, JetBrains_Mono, Space_Mono } from "next/font/google";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import "./globals.css";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
|
||||
const jersey25 = Jersey_25({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-head",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const spaceMono = Space_Mono({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "700"],
|
||||
variable: "--font-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/play", label: "Play" },
|
||||
{ href: "/config", label: "Config" },
|
||||
];
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-dvh bg-[#fafafa] text-[#111]">
|
||||
<NavBar />
|
||||
<body className={`${jersey25.variable} ${jetbrainsMono.variable} ${spaceMono.variable} min-h-dvh bg-background text-foreground font-sans`}>
|
||||
<nav className="border-b border-dotted border-border/20 bg-secondary/30">
|
||||
<div className="mx-auto max-w-[800px] px-10 py-3 flex items-center justify-center gap-8">
|
||||
<Link href="/" className="font-head text-headline-sm text-primary no-underline tracking-wide hover:opacity-85">
|
||||
Omnia
|
||||
</Link>
|
||||
<NavigationMenu viewport={false}>
|
||||
<NavigationMenuList>
|
||||
{links.map((link) => (
|
||||
<NavigationMenuItem key={link.href}>
|
||||
<NavigationMenuLink asChild active={pathname === link.href}>
|
||||
<Link href={link.href} className="text-foreground no-underline font-medium text-sm">
|
||||
{link.label}
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
))}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
</nav>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="mx-auto max-w-[800px] px-4 py-12">
|
||||
<h1 className="mb-2 text-3xl">Omnia GUI</h1>
|
||||
<p className="mb-8 text-gray-500">
|
||||
<main className="mx-auto max-w-[800px] px-10 py-12">
|
||||
<h1 className="mb-2 text-headline-lg text-primary">Omnia GUI</h1>
|
||||
<p className="mb-8 text-body-md text-muted-foreground">
|
||||
Configuration and gameplay interface for the Omnia simulation engine.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/play"
|
||||
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
|
||||
>
|
||||
<h2 className="mb-1 text-xl">Play</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Start a simulation and interact with NPCs
|
||||
</p>
|
||||
<div className="flex gap-6">
|
||||
<Link href="/play" className="flex-1 no-underline text-foreground">
|
||||
<Card className="transition-all hover:-translate-y-0.5 hover:shadow-[3px_3px_0_0_var(--border)] active:translate-y-0 active:shadow-[1px_1px_0_0_var(--border)]">
|
||||
<CardHeader>
|
||||
<CardTitle>Play</CardTitle>
|
||||
<CardDescription>
|
||||
Start a simulation and interact with NPCs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link
|
||||
href="/config"
|
||||
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
|
||||
>
|
||||
<h2 className="mb-1 text-xl">Config</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Check environment, API keys, and available scenarios
|
||||
</p>
|
||||
<Link href="/config" className="flex-1 no-underline text-foreground">
|
||||
<Card className="transition-all hover:-translate-y-0.5 hover:shadow-[3px_3px_0_0_var(--border)] active:translate-y-0 active:shadow-[1px_1px_0_0_var(--border)]">
|
||||
<CardHeader>
|
||||
<CardTitle>Config</CardTitle>
|
||||
<CardDescription>
|
||||
Check environment, API keys, and available scenarios
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -243,8 +243,9 @@ export async function createProviderInstance(
|
||||
apiKey: string,
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
): Promise<ModelProviderInstance> {
|
||||
return ProviderManager.create(name, providerName, apiKey, modelName, type);
|
||||
return ProviderManager.create(name, providerName, apiKey, modelName, type, maxContext);
|
||||
}
|
||||
|
||||
export async function deleteProviderInstance(id: string): Promise<void> {
|
||||
@@ -262,8 +263,9 @@ export async function updateProviderInstance(
|
||||
apiKey?: string,
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
): Promise<void> {
|
||||
ProviderManager.update(id, name, providerName, apiKey, modelName, type);
|
||||
ProviderManager.update(id, name, providerName, apiKey, modelName, type, maxContext);
|
||||
}
|
||||
|
||||
export async function getProviderMappings(): Promise<Record<string, string>> {
|
||||
|
||||
476
apps/gui/src/components/config/ProviderInstancesConfig.tsx
Normal file
476
apps/gui/src/components/config/ProviderInstancesConfig.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
createProviderInstance,
|
||||
updateProviderInstance,
|
||||
setActiveProviderInstance,
|
||||
regenerateEmbeddings,
|
||||
deleteProviderInstance,
|
||||
} from "@/app/play/actions";
|
||||
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Empty, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProviderInstancesConfigProps {
|
||||
instances: ModelProviderInstance[];
|
||||
availableProviders: ModelProviderMeta[];
|
||||
mappings: Record<string, string>;
|
||||
onChanged: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ProviderInstancesConfig({
|
||||
instances,
|
||||
availableProviders,
|
||||
mappings,
|
||||
onChanged,
|
||||
}: ProviderInstancesConfigProps) {
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editProvider, setEditProvider] = useState("google-genai");
|
||||
const [editKey, setEditKey] = useState("");
|
||||
const [editModel, setEditModel] = useState("gemini-2.5-flash");
|
||||
const [editIsActive, setEditIsActive] = useState(false);
|
||||
const [editType, setEditType] = useState<"generative" | "embedding">(
|
||||
"generative",
|
||||
);
|
||||
const [editMaxContext, setEditMaxContext] = useState<number>(32768);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedInstanceId === null) {
|
||||
setEditName("");
|
||||
setEditProvider("google-genai");
|
||||
setEditKey("");
|
||||
setEditModel("gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
setEditType("generative");
|
||||
setEditMaxContext(32768);
|
||||
} else if (selectedInstanceId === "new") {
|
||||
setEditName("");
|
||||
const defaultProvider = "google-genai";
|
||||
setEditProvider(defaultProvider);
|
||||
setEditKey("");
|
||||
setEditType("generative");
|
||||
const pMeta = availableProviders.find((p) => p.id === defaultProvider);
|
||||
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
setEditMaxContext(32768);
|
||||
} else {
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst) {
|
||||
setEditName(inst.name);
|
||||
setEditProvider(inst.providerName);
|
||||
setEditKey("");
|
||||
setEditType(inst.type || "generative");
|
||||
const pMeta = availableProviders.find(
|
||||
(p) => p.id === inst.providerName,
|
||||
);
|
||||
setEditModel(
|
||||
inst.modelName ||
|
||||
(inst.type === "embedding"
|
||||
? pMeta?.defaultEmbeddingModel
|
||||
: pMeta?.defaultModel) ||
|
||||
"gemini-2.5-flash",
|
||||
);
|
||||
setEditIsActive(inst.isActive);
|
||||
setEditMaxContext(
|
||||
inst.maxContext !== undefined && inst.maxContext !== null
|
||||
? inst.maxContext
|
||||
: 32768,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [selectedInstanceId, instances, availableProviders]);
|
||||
|
||||
const handleProviderChange = (providerId: string | null) => {
|
||||
if (!providerId) return;
|
||||
setEditProvider(providerId);
|
||||
const pMeta = availableProviders.find((p) => p.id === providerId);
|
||||
setEditModel(
|
||||
editType === "embedding"
|
||||
? pMeta?.defaultEmbeddingModel || ""
|
||||
: pMeta?.defaultModel || "",
|
||||
);
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: "generative" | "embedding") => {
|
||||
setEditType(type);
|
||||
const pMeta = availableProviders.find((p) => p.id === editProvider);
|
||||
setEditModel(
|
||||
type === "embedding"
|
||||
? pMeta?.defaultEmbeddingModel || ""
|
||||
: pMeta?.defaultModel || "",
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editName.trim()) {
|
||||
setError("Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
let shouldRegenerate = false;
|
||||
let targetInstanceId = selectedInstanceId;
|
||||
|
||||
if (selectedInstanceId === "new") {
|
||||
if (!editKey.trim()) {
|
||||
setError("API Key is required for new instances.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const created = await createProviderInstance(
|
||||
editName,
|
||||
editProvider,
|
||||
editKey,
|
||||
editModel || undefined,
|
||||
editType,
|
||||
editType === "generative" ? editMaxContext : 0,
|
||||
);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(created.id);
|
||||
}
|
||||
targetInstanceId = created.id;
|
||||
setSelectedInstanceId(created.id);
|
||||
} else {
|
||||
if (!selectedInstanceId) return;
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst && inst.type === "embedding") {
|
||||
const isMapped = mappings["embeddings"] === selectedInstanceId;
|
||||
const isActive = inst.isActive && !mappings["embeddings"];
|
||||
if (isMapped || isActive) {
|
||||
const hasChanged =
|
||||
inst.providerName !== editProvider ||
|
||||
inst.modelName !== editModel;
|
||||
if (hasChanged) {
|
||||
const confirmChange = window.confirm(
|
||||
"You have changed the configuration of the active embedding provider. This will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?",
|
||||
);
|
||||
if (!confirmChange) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
shouldRegenerate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await updateProviderInstance(
|
||||
selectedInstanceId,
|
||||
editName,
|
||||
editProvider,
|
||||
editKey || undefined,
|
||||
editModel || undefined,
|
||||
editType,
|
||||
editType === "generative" ? editMaxContext : 0,
|
||||
);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(selectedInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
await onChanged();
|
||||
|
||||
if (shouldRegenerate && targetInstanceId && targetInstanceId !== "new") {
|
||||
await regenerateEmbeddings(targetInstanceId);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
|
||||
if (!confirm("Are you sure you want to delete this provider instance?"))
|
||||
return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
await deleteProviderInstance(selectedInstanceId);
|
||||
setSelectedInstanceId(null);
|
||||
await onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 text-lg">LLM Provider Instances</h2>
|
||||
{error && (
|
||||
<div className="mb-4 rounded border-2 border-red-500 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 grid min-h-[400px] grid-cols-1 gap-4 md:grid-cols-[30%_70%]",
|
||||
loading && "pointer-events-none opacity-60",
|
||||
"transition-opacity duration-200",
|
||||
)}
|
||||
>
|
||||
{/* Left panel - Instance list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Instances</CardTitle>
|
||||
<CardAction>
|
||||
<Button onClick={() => setSelectedInstanceId("new")} size="sm">
|
||||
+ Add
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{instances.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyTitle>No instances configured</EmptyTitle>
|
||||
</Empty>
|
||||
) : (
|
||||
<ItemGroup>
|
||||
{instances.map((inst) => (
|
||||
<Item
|
||||
key={inst.id}
|
||||
variant={
|
||||
selectedInstanceId === inst.id ? "outline" : "muted"
|
||||
}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selectedInstanceId === inst.id && "border-primary",
|
||||
)}
|
||||
onClick={() => setSelectedInstanceId(inst.id)}
|
||||
>
|
||||
<ItemContent>
|
||||
<ItemTitle>{inst.name}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{inst.providerName} ({inst.type || "generative"})
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
{inst.isActive && <Badge>Active</Badge>}
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right panel - Form */}
|
||||
<Card>
|
||||
{selectedInstanceId === null ? (
|
||||
<CardContent className="flex min-h-[300px] items-center justify-center">
|
||||
<Empty>
|
||||
<EmptyTitle>No instance selected</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Press + to add or select an existing Instance to edit
|
||||
</EmptyDescription>
|
||||
</Empty>
|
||||
</CardContent>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="flex h-full flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{selectedInstanceId === "new"
|
||||
? "Create New Provider Instance"
|
||||
: `Configure: ${editName}`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formName">Friendly Name</Label>
|
||||
<Input
|
||||
id="formName"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="e.g. Gemini - Production"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[2fr_3fr] gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Instance Type</Label>
|
||||
<Select
|
||||
value={editType}
|
||||
onValueChange={(v) =>
|
||||
handleTypeChange(v as "generative" | "embedding")
|
||||
}
|
||||
items={[
|
||||
{
|
||||
label: "Generative (Chat / Text Completion)",
|
||||
value: "generative",
|
||||
},
|
||||
{
|
||||
label: "Embedding (Vector generation)",
|
||||
value: "embedding",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="generative">
|
||||
Generative (Chat / Text Completion)
|
||||
</SelectItem>
|
||||
<SelectItem value="embedding">
|
||||
Embedding (Vector generation)
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Provider Type</Label>
|
||||
<Select
|
||||
value={editProvider}
|
||||
onValueChange={handleProviderChange}
|
||||
items={availableProviders.map((p) => ({
|
||||
label: p.displayName,
|
||||
value: p.id,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" className="min-w-[var(--anchor-width)]">
|
||||
<SelectGroup>
|
||||
{availableProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{editProvider && availableProviders.length > 0 && (
|
||||
<span className="block rounded border-2 bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
{
|
||||
availableProviders.find((p) => p.id === editProvider)
|
||||
?.description
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formKey">API Key</Label>
|
||||
<Input
|
||||
id="formKey"
|
||||
type="password"
|
||||
value={editKey}
|
||||
onChange={(e) => setEditKey(e.target.value)}
|
||||
placeholder={
|
||||
selectedInstanceId === "new"
|
||||
? "AIzaSy..."
|
||||
: "•••••••• (unchanged)"
|
||||
}
|
||||
required={selectedInstanceId === "new"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formModel">Model Name</Label>
|
||||
<Input
|
||||
id="formModel"
|
||||
value={editModel}
|
||||
onChange={(e) => setEditModel(e.target.value)}
|
||||
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editType === "generative" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formMaxContext">
|
||||
Max Context Length (Tokens, 0 for infinite)
|
||||
</Label>
|
||||
<Input
|
||||
id="formMaxContext"
|
||||
type="number"
|
||||
value={editMaxContext}
|
||||
onChange={(e) =>
|
||||
setEditMaxContext(parseInt(e.target.value) || 0)
|
||||
}
|
||||
min={0}
|
||||
placeholder="e.g. 32768"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex flex-row items-center gap-2">
|
||||
<Checkbox
|
||||
id="formActive"
|
||||
checked={editIsActive}
|
||||
onCheckedChange={(v) => setEditIsActive(v === true)}
|
||||
/>
|
||||
<Label htmlFor="formActive" className="cursor-pointer">
|
||||
Set as Active Instance
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-between">
|
||||
<div>
|
||||
{selectedInstanceId !== "new" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/play", label: "Play" },
|
||||
{ href: "/config", label: "Config" },
|
||||
];
|
||||
|
||||
export function NavBar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<Link href="/" className="nav-brand">
|
||||
Omnia
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={pathname === link.href ? "nav-link active" : "nav-link"}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<style>{`
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
.nav-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: #111;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nav-link {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111;
|
||||
}
|
||||
.nav-link.active {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
324
apps/gui/src/components/play/PromptModal.tsx
Normal file
324
apps/gui/src/components/play/PromptModal.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
interface PromptModalProps {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PromptModal({ entry, onClose }: PromptModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"actor" | "decoder">("actor");
|
||||
|
||||
const parseActorPrompt = (systemPrompt: string, userContext: string, inputTokens: number) => {
|
||||
const memoryHeader = "=== YOUR RECENT MEMORY ===";
|
||||
const idx = userContext.indexOf(memoryHeader);
|
||||
|
||||
let worldStr = userContext;
|
||||
let memStr = "";
|
||||
|
||||
if (idx !== -1) {
|
||||
worldStr = userContext.substring(0, idx).trim();
|
||||
memStr = userContext.substring(idx).trim();
|
||||
}
|
||||
|
||||
const sysLen = systemPrompt.length;
|
||||
const worldLen = worldStr.length;
|
||||
const memLen = memStr.length;
|
||||
const totalLen = sysLen + worldLen + memLen;
|
||||
|
||||
if (totalLen === 0) return null;
|
||||
|
||||
const sysPct = (sysLen / totalLen) * 100;
|
||||
const worldPct = (worldLen / totalLen) * 100;
|
||||
const memPct = (memLen / totalLen) * 100;
|
||||
|
||||
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
|
||||
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
|
||||
const memTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
|
||||
|
||||
return [
|
||||
{ label: "System Prompt", pct: sysPct, relativePct: sysPct, tokens: sysTokens, type: "system", content: systemPrompt },
|
||||
{ label: "World Info", pct: worldPct, relativePct: worldPct, tokens: worldTokens, type: "world", content: worldStr },
|
||||
{ label: "Recent Memories", pct: memPct, relativePct: memPct, tokens: memTokens, type: "memories", content: memStr || "(No memories yet.)" },
|
||||
];
|
||||
};
|
||||
|
||||
const parseDecoderPrompt = (systemPrompt: string, userContext: string, inputTokens: number) => {
|
||||
const proseHeader = "=== NARRATIVE PROSE ===";
|
||||
const idx = userContext.indexOf(proseHeader);
|
||||
|
||||
let worldStr = userContext;
|
||||
let proseStr = "";
|
||||
|
||||
if (idx !== -1) {
|
||||
worldStr = userContext.substring(0, idx).trim();
|
||||
proseStr = userContext.substring(idx).trim();
|
||||
}
|
||||
|
||||
const sysLen = systemPrompt.length;
|
||||
const worldLen = worldStr.length;
|
||||
const proseLen = proseStr.length;
|
||||
const totalLen = sysLen + worldLen + proseLen;
|
||||
|
||||
if (totalLen === 0) return null;
|
||||
|
||||
const sysPct = (sysLen / totalLen) * 100;
|
||||
const worldPct = (worldLen / totalLen) * 100;
|
||||
const prosePct = (proseLen / totalLen) * 100;
|
||||
|
||||
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
|
||||
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
|
||||
const proseTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
|
||||
|
||||
return [
|
||||
{ label: "System Prompt", pct: sysPct, relativePct: sysPct, tokens: sysTokens, type: "system", content: systemPrompt },
|
||||
{ label: "Decoder Context", pct: worldPct, relativePct: worldPct, tokens: worldTokens, type: "world", content: worldStr },
|
||||
{ label: "Narrative Prose", pct: prosePct, relativePct: prosePct, tokens: proseTokens, type: "memories", content: proseStr },
|
||||
];
|
||||
};
|
||||
|
||||
const actorBreakdown = (entry.rawPrompt && entry.usage) ? parseActorPrompt(entry.rawPrompt.systemPrompt, entry.rawPrompt.userContext, entry.usage.inputTokens) : null;
|
||||
const decoderBreakdown = (entry.decoderPrompt && entry.decoderUsage) ? parseDecoderPrompt(entry.decoderPrompt.systemPrompt, entry.decoderPrompt.userContext, entry.decoderUsage.inputTokens) : null;
|
||||
|
||||
const actorMaxContext = entry.usage?.maxContext !== undefined ? entry.usage.maxContext : 32768;
|
||||
const actorUsedTokens = entry.usage?.inputTokens || 0;
|
||||
const actorUsagePctOfContext = actorMaxContext > 0 ? (actorUsedTokens / actorMaxContext) * 100 : 0;
|
||||
const isActorAbsolute = actorMaxContext > 0 && actorUsagePctOfContext >= 20;
|
||||
|
||||
const scaledActorBreakdown = actorBreakdown ? actorBreakdown.map((item) => ({
|
||||
...item,
|
||||
pct: isActorAbsolute ? item.relativePct * (actorUsedTokens / actorMaxContext) : item.relativePct
|
||||
})) : null;
|
||||
|
||||
const decoderMaxContext = entry.decoderUsage?.maxContext !== undefined ? entry.decoderUsage.maxContext : 32768;
|
||||
const decoderUsedTokens = entry.decoderUsage?.inputTokens || 0;
|
||||
const decoderUsagePctOfContext = decoderMaxContext > 0 ? (decoderUsedTokens / decoderMaxContext) * 100 : 0;
|
||||
const isDecoderAbsolute = decoderMaxContext > 0 && decoderUsagePctOfContext >= 20;
|
||||
|
||||
const scaledDecoderBreakdown = decoderBreakdown ? decoderBreakdown.map((item) => ({
|
||||
...item,
|
||||
pct: isDecoderAbsolute ? item.relativePct * (decoderUsedTokens / decoderMaxContext) : item.relativePct
|
||||
})) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry.rawPrompt && entry.decoderPrompt) {
|
||||
setActiveTab("decoder");
|
||||
}
|
||||
}, [entry]);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-[750px] sm:max-w-[750px] max-h-[85vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-5 pt-4 pb-3 border-b">
|
||||
<DialogTitle>Raw Prompts & Token Usage ({entry.entityName})</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "actor" | "decoder")}>
|
||||
<TabsList className="w-full rounded-none border-b bg-muted/50 px-5">
|
||||
<TabsTrigger value="actor" disabled={!entry.rawPrompt} className="flex-1">
|
||||
Actor Prompt {entry.usage ? "📊" : ""}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="decoder" disabled={!entry.decoderPrompt} className="flex-1">
|
||||
Intent Decoder {entry.decoderUsage ? "📊" : ""}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-5">
|
||||
<TabsContent value="actor">
|
||||
{entry.rawPrompt && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{entry.usage ? (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
<strong>LLM Instance:</strong> <span>{entry.usage.providerInstanceName || "Default"}</span>
|
||||
{entry.usage.modelName && (
|
||||
<span> ({entry.usage.modelName})</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm italic text-muted-foreground">
|
||||
No LLM token usage (Player turn used fixed prose).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scaledActorBreakdown && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
|
||||
<span className="font-semibold">Input Prompt Breakdown</span>
|
||||
<span>
|
||||
Total Input Tokens: <strong>{actorUsedTokens}</strong>
|
||||
{actorMaxContext > 0 ? (
|
||||
<span> / {actorMaxContext} ({actorUsagePctOfContext.toFixed(1)}% used)</span>
|
||||
) : (
|
||||
<span> (infinite context)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-6 w-full rounded overflow-hidden bg-muted shadow-inner mb-2">
|
||||
{scaledActorBreakdown.map((item, idx) => {
|
||||
const displayPct = actorMaxContext > 0 ? (item.tokens / actorMaxContext) * 100 : item.relativePct;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`h-full transition-all duration-300 ${
|
||||
item.type === "system" ? "bg-blue-500" : item.type === "world" ? "bg-emerald-500" : "bg-amber-500"
|
||||
}`}
|
||||
style={{ width: `${item.pct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${displayPct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isActorAbsolute && (
|
||||
<div
|
||||
className="bg-white h-full"
|
||||
style={{ width: `${100 - actorUsagePctOfContext}%` }}
|
||||
title={`Available: ${actorMaxContext - actorUsedTokens} tokens (${(100 - actorUsagePctOfContext).toFixed(1)}% remaining)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Accordion type="multiple" defaultValue={["0"]}>
|
||||
{scaledActorBreakdown.map((item, idx) => {
|
||||
const displayPct = actorMaxContext > 0 ? (item.tokens / actorMaxContext) * 100 : item.relativePct;
|
||||
return (
|
||||
<AccordionItem key={idx} value={String(idx)}>
|
||||
<AccordionTrigger className="text-sm">
|
||||
<span className={`inline-block w-2.5 h-2.5 rounded-sm mr-2 ${
|
||||
item.type === "system" ? "bg-blue-500" : item.type === "world" ? "bg-emerald-500" : "bg-amber-500"
|
||||
}`} />
|
||||
{item.label}: <strong>{item.tokens}</strong> tokens ({displayPct.toFixed(0)}%)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="m-0 p-2 bg-muted rounded text-xs font-mono whitespace-pre-wrap max-h-[250px] overflow-y-auto text-foreground">
|
||||
{item.content}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.usage && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2">
|
||||
<span className="font-semibold">LLM Output</span>
|
||||
<span>Total Output Tokens: <strong>{entry.usage.outputTokens}</strong></span>
|
||||
</div>
|
||||
<div className="rounded border-2">
|
||||
<pre className="m-0 p-2 bg-muted text-xs font-mono whitespace-pre-wrap max-h-[250px] overflow-y-auto text-foreground">
|
||||
{entry.narrativeProse}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="decoder">
|
||||
{entry.decoderPrompt && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{entry.decoderUsage && (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
<strong>LLM Instance:</strong> <span>{entry.decoderUsage.providerInstanceName || "Default"}</span>
|
||||
{entry.decoderUsage.modelName && (
|
||||
<span> ({entry.decoderUsage.modelName})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scaledDecoderBreakdown && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
|
||||
<span className="font-semibold">Input Prompt Breakdown</span>
|
||||
<span>
|
||||
Total Input Tokens: <strong>{decoderUsedTokens}</strong>
|
||||
{decoderMaxContext > 0 ? (
|
||||
<span> / {decoderMaxContext} ({decoderUsagePctOfContext.toFixed(1)}% used)</span>
|
||||
) : (
|
||||
<span> (infinite context)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-6 w-full rounded overflow-hidden bg-muted shadow-inner mb-2">
|
||||
{scaledDecoderBreakdown.map((item, idx) => {
|
||||
const displayPct = decoderMaxContext > 0 ? (item.tokens / decoderMaxContext) * 100 : item.relativePct;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`h-full transition-all duration-300 ${
|
||||
item.type === "system" ? "bg-blue-500" : item.type === "world" ? "bg-emerald-500" : "bg-amber-500"
|
||||
}`}
|
||||
style={{ width: `${item.pct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${displayPct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isDecoderAbsolute && (
|
||||
<div
|
||||
className="bg-white h-full"
|
||||
style={{ width: `${100 - decoderUsagePctOfContext}%` }}
|
||||
title={`Available: ${decoderMaxContext - decoderUsedTokens} tokens (${(100 - decoderUsagePctOfContext).toFixed(1)}% remaining)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Accordion type="multiple" defaultValue={["0"]}>
|
||||
{scaledDecoderBreakdown.map((item, idx) => {
|
||||
const displayPct = decoderMaxContext > 0 ? (item.tokens / decoderMaxContext) * 100 : item.relativePct;
|
||||
return (
|
||||
<AccordionItem key={idx} value={String(idx)}>
|
||||
<AccordionTrigger className="text-sm">
|
||||
<span className={`inline-block w-2.5 h-2.5 rounded-sm mr-2 ${
|
||||
item.type === "system" ? "bg-blue-500" : item.type === "world" ? "bg-emerald-500" : "bg-amber-500"
|
||||
}`} />
|
||||
{item.label}: <strong>{item.tokens}</strong> tokens ({displayPct.toFixed(0)}%)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="m-0 p-2 bg-muted rounded text-xs font-mono whitespace-pre-wrap max-h-[250px] overflow-y-auto text-foreground">
|
||||
{item.content}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.decoderUsage && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2">
|
||||
<span className="font-semibold">LLM Output</span>
|
||||
<span>Total Output Tokens: <strong>{entry.decoderUsage.outputTokens}</strong></span>
|
||||
</div>
|
||||
<div className="rounded border-2">
|
||||
<pre className="m-0 p-2 bg-muted text-xs font-mono whitespace-pre-wrap max-h-[250px] overflow-y-auto text-foreground">
|
||||
{JSON.stringify(entry.intents, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
101
apps/gui/src/components/ui/accordion.tsx
Normal file
101
apps/gui/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
|
||||
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)]"
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header data-slot="accordion-header" className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
aria-hidden
|
||||
data-slot="accordion-trigger-icon"
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-300",
|
||||
EASE
|
||||
)}
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
// Radix publishes the measured height as `--radix-accordion-content-height`
|
||||
// and toggles `data-state`. The accordion-down/up keyframes (in
|
||||
// shadcn-tailwind.css) interpolate height between that var and 0 for a real
|
||||
// slide open/close — the Base UI variant achieves the same via transitions.
|
||||
className="group/panel overflow-hidden bg-card font-body text-sm text-muted-foreground data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 pt-2 pb-4 transition-[opacity,transform] duration-300 ease-out",
|
||||
// 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
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
49
apps/gui/src/components/ui/badge.tsx
Normal file
49
apps/gui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded border-2 px-2 py-0.5 text-xs font-head font-medium whitespace-nowrap shadow-sm transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground [a]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"bg-transparent text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"border-transparent bg-transparent shadow-none hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "border-transparent bg-transparent shadow-none text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
apps/gui/src/components/ui/button.tsx
Normal file
56
apps/gui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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"
|
||||
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
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",
|
||||
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",
|
||||
link: "border-transparent shadow-none active:translate-x-0 active:translate-y-0 active:shadow-none text-primary hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 px-3 text-xs",
|
||||
lg: "h-11 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
apps/gui/src/components/ui/card.tsx
Normal file
103
apps/gui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-head text-headline-sm text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center border-t border-dotted border-border/20 bg-muted/30 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
32
apps/gui/src/components/ui/checkbox.tsx
Normal file
32
apps/gui/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
167
apps/gui/src/components/ui/dialog.tsx
Normal file
167
apps/gui/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-head text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
104
apps/gui/src/components/ui/empty.tsx
Normal file
104
apps/gui/src/components/ui/empty.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded border-2 bg-card p-6 text-center text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn(
|
||||
"text-sm font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
19
apps/gui/src/components/ui/input.tsx
Normal file
19
apps/gui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
201
apps/gui/src/components/ui/item.tsx
Normal file
201
apps/gui/src/components/ui/item.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex w-full flex-wrap items-center rounded border-2 text-sm transition-colors duration-100 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary [a]:transition-colors [a]:hover:bg-accent",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent",
|
||||
outline: "border-border",
|
||||
muted: "border-transparent bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-2.5 px-3 py-2.5",
|
||||
sm: "gap-2.5 px-3 py-2.5",
|
||||
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "item",
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
24
apps/gui/src/components/ui/label.tsx
Normal file
24
apps/gui/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
167
apps/gui/src/components/ui/navigation-menu.tsx
Normal file
167
apps/gui/src/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
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"
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
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"
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"top-0 left-0 w-full p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:border-border/30 group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow-md group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
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:bg-secondary focus:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary in-data-[slot=navigation-menu-content]:rounded-sm data-active:bg-primary/15 data-active:text-primary [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
199
apps/gui/src/components/ui/select.tsx
Normal file
199
apps/gui/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded border border-border/30 bg-input py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-sm transition-colors outline-none select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<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 )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
31
apps/gui/src/components/ui/separator.tsx
Normal file
31
apps/gui/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
17
apps/gui/src/components/ui/spinner.tsx
Normal file
17
apps/gui/src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
data-slot="spinner"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
116
apps/gui/src/components/ui/table.tsx
Normal file
116
apps/gui/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto rounded border-2 shadow-md"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t-2 bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
90
apps/gui/src/components/ui/tabs.tsx
Normal file
90
apps/gui/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded p-1 text-muted-foreground group-data-horizontal/tabs:h-11 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-2 bg-card shadow-sm",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded border-2 border-transparent px-4 py-2 text-sm font-head font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 group-data-[variant=default]/tabs-list:data-active:border-border group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
apps/gui/src/components/ui/textarea.tsx
Normal file
18
apps/gui/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface IntentInfo {
|
||||
type: string;
|
||||
description: string;
|
||||
selfDescription?: string;
|
||||
modifiers: string[];
|
||||
targetIds: string[];
|
||||
isValid?: boolean;
|
||||
reason?: string;
|
||||
@@ -22,6 +24,9 @@ export interface LogEntry {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
decoderPrompt?: {
|
||||
systemPrompt: string;
|
||||
@@ -31,6 +36,9 @@ export interface LogEntry {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ for (const c of envCandidates) {
|
||||
}
|
||||
}
|
||||
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { BufferRepository, LedgerRepository, HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import {
|
||||
ActorAgent,
|
||||
@@ -102,6 +102,7 @@ interface SimSession {
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
handoffProvider: ILLMProvider;
|
||||
embeddingProvider: IEmbeddingProvider;
|
||||
architect: Architect;
|
||||
aliasGenerator: AliasDeltaGenerator;
|
||||
@@ -131,7 +132,6 @@ class SimulationManager {
|
||||
activeInstance = ProviderManager.create("Default (Env)", "google-genai", envKey, undefined, "generative");
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeInstance) {
|
||||
return {
|
||||
id: "",
|
||||
@@ -235,11 +235,13 @@ class SimulationManager {
|
||||
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
|
||||
const providerName = inst ? inst.providerName : "google-genai";
|
||||
const modelName = inst ? inst.modelName : undefined;
|
||||
const instanceName = inst ? inst.name : undefined;
|
||||
const maxContext = inst ? inst.maxContext : undefined;
|
||||
|
||||
if (providerName === "google-genai") {
|
||||
return new GeminiProvider(key, modelName);
|
||||
return new GeminiProvider(key, modelName, instanceName, maxContext);
|
||||
} else if (providerName === "openrouter") {
|
||||
return new OpenRouterProvider(key, modelName);
|
||||
return new OpenRouterProvider(key, modelName, instanceName, maxContext);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
@@ -267,6 +269,7 @@ class SimulationManager {
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
const decoderProvider = resolveProviderForTask("intent-decoder");
|
||||
const timedeltaProvider = resolveProviderForTask("timedelta");
|
||||
const handoffProvider = resolveProviderForTask("handoff");
|
||||
const embeddingProvider = resolveEmbeddingProvider();
|
||||
|
||||
const architect = new Architect(
|
||||
@@ -293,6 +296,7 @@ class SimulationManager {
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
@@ -320,6 +324,7 @@ class SimulationManager {
|
||||
|
||||
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
|
||||
await this.runAliasResolution(session);
|
||||
await this.runHandoffResolution(session);
|
||||
session.aliasDoneForTurn = true;
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
@@ -416,6 +421,8 @@ class SimulationManager {
|
||||
entry.intents.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
selfDescription: intent.selfDescription,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
@@ -560,6 +567,8 @@ class SimulationManager {
|
||||
entry.intents.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
selfDescription: intent.selfDescription,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
@@ -609,6 +618,31 @@ class SimulationManager {
|
||||
session.coreRepo.saveWorldState(worldState);
|
||||
}
|
||||
|
||||
private async runHandoffResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const handoffEngine = new HandoffEngine(
|
||||
session.handoffProvider,
|
||||
session.embeddingProvider,
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
);
|
||||
|
||||
const entities = Array.from(worldState.entities.values());
|
||||
for (const entity of entities) {
|
||||
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
|
||||
const maxContext = session.handoffProvider.maxContext !== undefined ? session.handoffProvider.maxContext : 32768;
|
||||
|
||||
const trigger = checkHandoffTrigger(entity, bufferEntries, worldState.clock.get(), maxContext);
|
||||
if (trigger !== "none") {
|
||||
await handoffEngine.runHandoff(entity, bufferEntries, worldState.clock.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runAliasResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
@@ -685,6 +719,7 @@ class SimulationManager {
|
||||
if (!inst || inst.type !== "generative") {
|
||||
inst = active;
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
@@ -697,9 +732,9 @@ class SimulationManager {
|
||||
}
|
||||
|
||||
if (inst.providerName === "google-genai") {
|
||||
return new GeminiProvider(inst.apiKey, inst.modelName);
|
||||
return new GeminiProvider(inst.apiKey, inst.modelName, inst.name, inst.maxContext);
|
||||
} else if (inst.providerName === "openrouter") {
|
||||
return new OpenRouterProvider(inst.apiKey, inst.modelName);
|
||||
return new OpenRouterProvider(inst.apiKey, inst.modelName, inst.name, inst.maxContext);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
@@ -737,6 +772,7 @@ class SimulationManager {
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
const decoderProvider = resolveProviderForTask("intent-decoder");
|
||||
const timedeltaProvider = resolveProviderForTask("timedelta");
|
||||
const handoffProvider = resolveProviderForTask("handoff");
|
||||
const embeddingProvider = resolveEmbeddingProvider();
|
||||
|
||||
const architect = new Architect(
|
||||
@@ -763,6 +799,7 @@ class SimulationManager {
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
|
||||
6
apps/gui/src/lib/utils.ts
Normal file
6
apps/gui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -11,12 +11,6 @@
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": []
|
||||
},
|
||||
{
|
||||
"name": "observation_status",
|
||||
"value": "Active monitoring. Audio and visual feeds online.",
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": []
|
||||
},
|
||||
{
|
||||
"name": "ambient_sound",
|
||||
"value": "A low, barely audible electrical hum.",
|
||||
@@ -54,6 +48,11 @@
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": ["7c9b83b3-8cfb-4e89-8d77-626a5757d591"]
|
||||
},
|
||||
{
|
||||
"name": "gender",
|
||||
"value": "male",
|
||||
"visibility": "PUBLIC"
|
||||
},
|
||||
{
|
||||
"name": "appearance",
|
||||
"value": "A tall human with short dark hair and alert eyes, standing near the center of the room.",
|
||||
@@ -96,6 +95,11 @@
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": ["bf3f29d2-cf11-4b11-9a99-b13c126d400e"]
|
||||
},
|
||||
{
|
||||
"name": "gender",
|
||||
"value": "male",
|
||||
"visibility": "PUBLIC"
|
||||
},
|
||||
{
|
||||
"name": "appearance",
|
||||
"value": "A medium-build human with long blonde hair tied back, sitting with their back pressed against the white wall.",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.7.0",
|
||||
"prettier": "^3.9.4",
|
||||
"shadcn": "^4.13.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vitest": "^4.1.9",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
WorldState,
|
||||
naturalizeTime,
|
||||
serializeSubjectiveWorldState,
|
||||
resolveAlias,
|
||||
} from "@omnia/core";
|
||||
import {
|
||||
BufferEntry,
|
||||
@@ -66,21 +67,23 @@ export class ActorPromptBuilder {
|
||||
|
||||
private buildSystemPrompt(): string {
|
||||
return `
|
||||
You are an actor agent embodying a single character in a narrative simulation. You ARE this character — act immersively, naturally, and in-character at all times. Do not break character, do not reference being an AI or a system, and do not narrate from outside the character's perspective.
|
||||
You are an actor agent embodying a single character in a narrative simulation. You ARE this character: act immersively, naturally, and in-character at all times. Do not break character, do not reference being an AI or a system, and do not narrate from outside the character's perspective.
|
||||
|
||||
Your output is a short block of narrative prose describing what your character does, says, or thinks next. You may:
|
||||
- Speak aloud → this becomes a "dialogue" intent. Other entities can hear it.
|
||||
- Perform a physical or logical action → this becomes an "action" intent. It is subject to the world's physics and will be validated by the World Architect.
|
||||
- Think internally / reflect / feel → this becomes a "monologue" intent. NO ONE else perceives it. It bypasses all validation and is written straight to your private memory. Use this for inner thoughts, doubts, plans, and feelings that you would not voice aloud.
|
||||
- Speak aloud → Other entities can hear it if they are present nearby. (Or nobody will hear it if you are alone)
|
||||
- Perform a physical action → It is subject to the world's physics and logic. Do not describe the outcome of your action.
|
||||
- Think internally / reflect / feel → this is a "monologue". NO ONE else perceives it. This is what you think internally.
|
||||
|
||||
Guidelines:
|
||||
- Always write in the first person (e.g., "I do this", "I say", "I think").
|
||||
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment, the room, your surroundings, or other characters' actions, as these are managed by the simulation engine.
|
||||
- Stay strictly within what your character knows. If an attribute, entity, or fact is not present in your context below, your character does not know it — do not invent it or act on it.
|
||||
- Refer to other entities by the subjective names/aliases given in your context, never by raw system IDs.
|
||||
- Keep your prose vivid but concise. A single response may contain more than one intent (e.g., you may think, then speak, then act) — write them in natural narrative order.
|
||||
- Always write in the first person
|
||||
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment or your surroundings, or other characters' actions.
|
||||
- Refer to other entities by the subjective names/aliases that you refer to them as.
|
||||
- Keep your prose vivid but concise. Write it in natural narrative order.
|
||||
- Not every response requires an outward action. It is perfectly valid to only think (a monologue) and do nothing perceivable.
|
||||
- Never speak or act on another entity's behalf — you only control your own character.
|
||||
- Never speak or act on another entity's behalf. You only control your own character.
|
||||
- Stay strictly within what your character knows. Do not invent knowledge that doesn't exist or act on it.
|
||||
- You are limited by just your memory. If your memory is limited, then that's all you can remember. If you do make stuff up then that's lying. Which is allowed, but remember that you're lying.
|
||||
".
|
||||
`.trim();
|
||||
}
|
||||
|
||||
@@ -237,7 +240,7 @@ Guidelines:
|
||||
let content = entry.content;
|
||||
// Resolve system IDs to subjective aliases in the content
|
||||
for (const targetId of entry.involvedEntityIds) {
|
||||
const alias = entity.aliases.get(targetId) ?? targetId;
|
||||
const alias = resolveAlias(entity, targetId);
|
||||
content = content.replace(new RegExp(targetId, "g"), alias);
|
||||
}
|
||||
if (entry.locationId) {
|
||||
|
||||
@@ -76,8 +76,6 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
// Check recent memory exists
|
||||
expect(userContext).toContain("=== RECENT EVENTS ===");
|
||||
expect(userContext).toContain("Alice greets Bob");
|
||||
// Bob should be resolved to Strider
|
||||
expect(userContext).toContain("spoke to Strider");
|
||||
|
||||
// Check long-term memory exists
|
||||
expect(userContext).toContain("=== YOUR MEMORIES ===");
|
||||
|
||||
@@ -22,8 +22,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "open the chest and read the scroll",
|
||||
description: "Open the chest and read the scroll",
|
||||
selfDescription: "You open the chest and read the scroll.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.validateIntent(world, intent);
|
||||
@@ -51,8 +53,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "unlock the gate and escape",
|
||||
description: "Unlock the gate and escape",
|
||||
selfDescription: "You unlock the gate and escape.",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.validateIntent(world, intent);
|
||||
@@ -72,8 +76,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "haunt the mansion",
|
||||
description: "Haunt the mansion",
|
||||
selfDescription: "You haunt the mansion.",
|
||||
actorId: "ghost",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.validateIntent(world, intent);
|
||||
@@ -110,8 +116,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
type: "action",
|
||||
originalText: "pick the lock of the wooden chest",
|
||||
description: "Pick the lock of the wooden chest",
|
||||
selfDescription: "You pick the lock of the wooden chest.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.processIntent(world, intent);
|
||||
@@ -152,8 +160,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
type: "action",
|
||||
originalText: "run away",
|
||||
description: "Run away",
|
||||
selfDescription: "You run away.",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.processIntent(world, intent);
|
||||
|
||||
6
packages/core/src/alias.ts
Normal file
6
packages/core/src/alias.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Entity } from "./entity.js";
|
||||
|
||||
export function resolveAlias(viewer: Entity, targetId: string): string {
|
||||
if (targetId === viewer.id) return "you";
|
||||
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
/**
|
||||
* Monorepo Hygiene Note:
|
||||
* If a pure function only touches types owned by core (e.g., Entity, WorldState, Attribute),
|
||||
* it belongs here in the core package (e.g., in a dedicated file like alias.ts), even if
|
||||
* a higher-level package is currently its only consumer.
|
||||
*/
|
||||
export * from "./attribute.js";
|
||||
export * from "./entity.js";
|
||||
export * from "./world.js";
|
||||
export * from "./clock.js";
|
||||
export * from "./repository.js";
|
||||
export * from "./alias.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AttributableObject, Attribute, serializeAttributes } from "./attribute.js";
|
||||
import { Entity } from "./entity.js";
|
||||
import { WorldClock } from "./clock.js";
|
||||
import { resolveAlias } from "./alias.js";
|
||||
|
||||
export class WorldState extends AttributableObject {
|
||||
/**
|
||||
@@ -118,19 +119,6 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves how a viewer subjectively refers to a target entity.
|
||||
* - Self → "you"
|
||||
* - Known (in the viewer's alias map) → the subjective alias
|
||||
* - Unknown → "an unfamiliar figure"
|
||||
*
|
||||
* Mirrors the implementation in @omnia/memory's resolveAlias, inlined here
|
||||
* to avoid a circular dependency (memory depends on core).
|
||||
*/
|
||||
function resolveAliasViewer(viewer: Entity, targetId: string): string {
|
||||
if (targetId === viewer.id) return "you";
|
||||
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a single attribute the way a viewer perceives it — name and
|
||||
@@ -164,7 +152,7 @@ export function serializeSubjectiveWorldState(
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const viewerAlias = resolveAliasViewer(viewer, viewerId);
|
||||
const viewerAlias = resolveAlias(viewer, viewerId);
|
||||
|
||||
// --- World attributes (only those the viewer can see) ---
|
||||
const worldVisible = worldState.getVisibleAttributesFor(viewerId);
|
||||
@@ -208,7 +196,7 @@ export function serializeSubjectiveWorldState(
|
||||
if (coLocated.length > 0) {
|
||||
lines.push(" Entities present with you:");
|
||||
for (const e of coLocated) {
|
||||
const alias = resolveAliasViewer(viewer, e.id);
|
||||
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"));
|
||||
@@ -220,7 +208,7 @@ export function serializeSubjectiveWorldState(
|
||||
if (elsewhere.length > 0) {
|
||||
lines.push(" Other presences you are aware of (elsewhere):");
|
||||
for (const e of elsewhere) {
|
||||
const alias = resolveAliasViewer(viewer, e.id);
|
||||
const alias = resolveAlias(viewer, e.id);
|
||||
lines.push(` - ${alias} [elsewhere]`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { IntentSequence, IntentSequenceSchema } from "./intent.js";
|
||||
import { IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
|
||||
|
||||
export class IntentDecoder {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
@@ -23,9 +23,15 @@ export class IntentDecoder {
|
||||
const actor = worldState.getEntity(actorId);
|
||||
|
||||
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
|
||||
const aliasContext = aliasEntries.length > 0
|
||||
? aliasEntries.map(([targetId, alias]) => `- "${alias}" refers to entity ID: "${targetId}"`).join("\n")
|
||||
: "(No known aliases)";
|
||||
const aliasContext =
|
||||
aliasEntries.length > 0
|
||||
? aliasEntries
|
||||
.map(
|
||||
([targetId, alias]) =>
|
||||
`- "${alias}" refers to entity ID: "${targetId}"`,
|
||||
)
|
||||
.join("\n")
|
||||
: "(No known aliases)";
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Intent Decoder for a narrative simulation engine.
|
||||
@@ -33,20 +39,16 @@ Your job is to take a block of narrative prose written by an actor agent and dec
|
||||
|
||||
For each intent you must:
|
||||
1. Classify its type:
|
||||
- "dialogue": Any speech, conversation, or verbal communication directed at another entity.
|
||||
- "action": Any physical or logical action performed in the world (e.g., moving, picking up, opening, looking).
|
||||
- "monologue": An inner thought, reflection, or internal monologue. This is purely internal — not spoken aloud, not perceivable by any other entity, and not a physical action. Use this for any prose depicting the character thinking, reflecting, feeling, or narrating to themselves internally.
|
||||
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
|
||||
- "action": Any physical or logical action performed in the world (e.g., moving, opening, looking).
|
||||
- "monologue": An inner thought, reflection, or internal monologue/self narration.
|
||||
2. Extract the original text fragment from the prose that corresponds to this intent.
|
||||
3. Write a concise, structured description of the intent (what is being done or said). Include as much detail about the action as possible that was extracted from the narrative prose. Do not make up qualities.
|
||||
4. Identify the actorId (the entity performing the intent — this will always be "${actorId}").
|
||||
5. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" and "ACTOR ALIASES" mapping to resolve any subjective names, descriptions, or nicknames used in the prose to their correct system entity IDs. If no specific target, use an empty array. For "monologue" intents, targetIds must always be an empty array.
|
||||
|
||||
Rules:
|
||||
- Preserve the chronological order of intents as they appear in the prose.
|
||||
- Do NOT merge unrelated actions into a single intent.
|
||||
- Dialogue and actions should be separate intents even if they happen in the same sentence.
|
||||
- If the prose contains only dialogue, return a single dialogue intent.
|
||||
- If the prose contains only a single action, return a single action intent.
|
||||
3. Populate "description" and "selfDescription":
|
||||
- "description": No subject or name — a bare third-person verb phrase only (e.g. "clears their throat", "shakes their head slowly")
|
||||
- "selfDescription": The same event from the actor's own perspective, second person, complete sentence starting with "You" (e.g. "You clear your throat.", "You shake your head slowly."). This is shown directly in the actor's own memory — it must never say "the actor" or refer to them in the third person.
|
||||
- In case of a dialogue, the description and self Description only stores the exact words said by the entity. (e.g. "I will do that later", "Are you serious right now?")
|
||||
4. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" mapping to resolve any subjective names,or aliases used in the prose to their correct system entity IDs. If no specific target, use an empty array.
|
||||
5. Identify modifiers — a list of strings representing additional qualities or modifiers extracted from the narrative prose. This includes emotions, tone of voice, speed, manner of action, or statement type (e.g., "question", "anxious", "whispering", "slowly", "quietly", "forcefully"). If no modifiers are present, use an empty array.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
@@ -58,7 +60,7 @@ The actor refers to other entities using these subjective names/aliases:
|
||||
${aliasContext}
|
||||
|
||||
=== WORLD STATE ===
|
||||
${serializeObjectiveWorldState(worldState)}
|
||||
${serializeSimplifiedWorldState(worldState)}
|
||||
|
||||
=== ACTOR ===
|
||||
Actor ID: ${actorId}
|
||||
@@ -70,7 +72,7 @@ ${narrativeProse}
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
userContext,
|
||||
schema: IntentSequenceSchema,
|
||||
schema: LLMIntentSequenceSchema,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
@@ -79,6 +81,42 @@ ${narrativeProse}
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
const fullIntents = response.data.intents.map((intent) => ({
|
||||
...intent,
|
||||
actorId,
|
||||
}));
|
||||
|
||||
return {
|
||||
intents: fullIntents,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSimplifiedWorldState(worldState: WorldState): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("Locations:");
|
||||
if (worldState.locations.size > 0) {
|
||||
for (const loc of worldState.locations.values()) {
|
||||
const parentId = (loc as { parentId?: string | null }).parentId;
|
||||
const parentStr = parentId ? ` (Parent: ${parentId})` : "";
|
||||
lines.push(` - Location [ID: ${loc.id}]${parentStr}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(" (No locations)");
|
||||
}
|
||||
|
||||
lines.push("Entities:");
|
||||
if (worldState.entities.size > 0) {
|
||||
for (const entity of worldState.entities.values()) {
|
||||
const locStr = entity.locationId
|
||||
? ` (Location: ${entity.locationId})`
|
||||
: "";
|
||||
lines.push(` - Entity [ID: ${entity.id}]${locStr}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(" (No entities)");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export type IntentType = z.infer<typeof IntentTypeSchema>;
|
||||
/**
|
||||
* A single decoded intent extracted from narrative prose.
|
||||
*/
|
||||
export const IntentSchema = z.object({
|
||||
export const LLMIntentSchema = z.object({
|
||||
/** The type of intent. */
|
||||
type: IntentTypeSchema,
|
||||
|
||||
@@ -24,8 +24,8 @@ export const IntentSchema = z.object({
|
||||
/** A concise, structured description of the intent's action or dialogue. */
|
||||
description: z.string(),
|
||||
|
||||
/** The entity ID of the actor performing the intent. */
|
||||
actorId: z.string(),
|
||||
/** The same event from the actor's own perspective (second person, "You"). */
|
||||
selfDescription: z.string(),
|
||||
|
||||
/**
|
||||
* Entity IDs of the receiving parties (e.g., who is being spoken to,
|
||||
@@ -33,10 +33,25 @@ export const IntentSchema = z.object({
|
||||
* "monologue" intents, since they are not perceivable by anyone.
|
||||
*/
|
||||
targetIds: z.array(z.string()),
|
||||
|
||||
/**
|
||||
* Additional qualities or modifiers extracted from the prose (e.g., emotions,
|
||||
* questions, speed, manner of action like 'quietly', 'whispering', 'anxiously').
|
||||
*/
|
||||
modifiers: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const IntentSchema = LLMIntentSchema.extend({
|
||||
/** The entity ID of the actor performing the intent. */
|
||||
actorId: z.string(),
|
||||
});
|
||||
|
||||
export type Intent = z.infer<typeof IntentSchema>;
|
||||
|
||||
export const LLMIntentSequenceSchema = z.object({
|
||||
intents: z.array(LLMIntentSchema),
|
||||
});
|
||||
|
||||
/**
|
||||
* The full output of the Intent Decoder: an ordered sequence of intents
|
||||
* extracted from a single narrative prose block.
|
||||
|
||||
@@ -15,8 +15,9 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "Alice opened the chest.",
|
||||
description: "Open the wooden chest.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You open the wooden chest.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -45,8 +46,9 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
type: "dialogue",
|
||||
originalText: '"Do you have the key?" Alice asked Bob.',
|
||||
description: "Alice asks Bob if he has the key.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You ask Bob if he has the key.",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -78,15 +80,17 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
type: "dialogue",
|
||||
originalText: '"Cover me," Alice whispered to Bob.',
|
||||
description: "Alice whispers to Bob requesting cover.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You whisper to Bob requesting cover.",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She crept towards the door and pulled the handle.",
|
||||
description: "Creep towards the door and pull the handle.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You creep towards the door and pull the handle.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface LLMResponse<T> {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,11 +28,15 @@ export interface LLMCallRecord {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ILLMProvider {
|
||||
providerName: string;
|
||||
maxContext?: number;
|
||||
generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>>;
|
||||
@@ -49,6 +56,7 @@ export interface ModelProviderInstance {
|
||||
isActive: boolean;
|
||||
modelName?: string;
|
||||
type: "generative" | "embedding";
|
||||
maxContext?: number;
|
||||
}
|
||||
|
||||
export interface ModelProviderMeta {
|
||||
|
||||
@@ -3,6 +3,17 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import type { ModelProviderInstance } from "./llm.js";
|
||||
|
||||
let dbPathOverride: string | null = null;
|
||||
let hasBootstrapped = false;
|
||||
|
||||
export function setDbPathOverride(p: string | null) {
|
||||
dbPathOverride = p;
|
||||
}
|
||||
|
||||
export function resetHasBootstrapped() {
|
||||
hasBootstrapped = false;
|
||||
}
|
||||
|
||||
function getWorkspaceRoot() {
|
||||
let current = process.cwd();
|
||||
while (current !== "/" && current !== path.parse(current).root) {
|
||||
@@ -20,12 +31,17 @@ function getWorkspaceRoot() {
|
||||
}
|
||||
|
||||
function getSettingsDb() {
|
||||
const wsRoot = getWorkspaceRoot();
|
||||
const dbDir = path.resolve(wsRoot, "data");
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
let dbPath: string;
|
||||
if (dbPathOverride) {
|
||||
dbPath = dbPathOverride;
|
||||
} else {
|
||||
const wsRoot = getWorkspaceRoot();
|
||||
const dbDir = path.resolve(wsRoot, "data");
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
dbPath = path.join(dbDir, "settings.db");
|
||||
}
|
||||
const dbPath = path.join(dbDir, "settings.db");
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.prepare(`
|
||||
@@ -51,6 +67,51 @@ function getSettingsDb() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(`ALTER TABLE provider_instances ADD COLUMN maxContext INTEGER`).run();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Auto-bootstrap environment variables if DB contains 0 instances
|
||||
try {
|
||||
if (!hasBootstrapped) {
|
||||
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
|
||||
if (totalCount.count === 0) {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
let hasInsertedGenerative = false;
|
||||
|
||||
if (googleKey && googleKey.trim()) {
|
||||
const id = "provider-default-google";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash", "generative", 32768);
|
||||
hasInsertedGenerative = true;
|
||||
|
||||
const embedId = "provider-default-google-embed";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(embedId, "Gemini Embed (Env)", "google-genai", googleKey.trim(), 1, "gemini-embedding-001", "embedding", 0);
|
||||
}
|
||||
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
const id = "provider-default-openrouter";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash", "generative", 32768);
|
||||
}
|
||||
}
|
||||
hasBootstrapped = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore write lock issues or other DB errors during bootstrap
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -67,6 +128,7 @@ export class ProviderManager {
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
}[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -76,6 +138,7 @@ 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),
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -87,7 +150,8 @@ export class ProviderManager {
|
||||
providerName: string,
|
||||
apiKey: string,
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative"
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number
|
||||
): ModelProviderInstance {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
@@ -97,12 +161,14 @@ export class ProviderManager {
|
||||
.get(type) as { count: number };
|
||||
const isActive = activeCount.count === 0 ? 1 : 0;
|
||||
|
||||
const actualMaxContext = maxContext !== undefined ? maxContext : (type === "generative" ? 32768 : 0);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, name, providerName, apiKey, isActive, modelName || null, type);
|
||||
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 };
|
||||
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName, type, maxContext: actualMaxContext };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -146,22 +212,24 @@ export class ProviderManager {
|
||||
providerName: string,
|
||||
apiKey?: string,
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative"
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number
|
||||
): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const actualMaxContext = maxContext !== undefined ? maxContext : (type === "generative" ? 32768 : 0);
|
||||
if (apiKey && apiKey.trim()) {
|
||||
db.prepare(`
|
||||
UPDATE provider_instances
|
||||
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?
|
||||
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?
|
||||
WHERE id = ?
|
||||
`).run(name, providerName, apiKey, modelName || null, type, id);
|
||||
`).run(name, providerName, apiKey, modelName || null, type, actualMaxContext, id);
|
||||
} else {
|
||||
db.prepare(`
|
||||
UPDATE provider_instances
|
||||
SET name = ?, providerName = ?, modelName = ?, type = ?
|
||||
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?
|
||||
WHERE id = ?
|
||||
`).run(name, providerName, modelName || null, type, id);
|
||||
`).run(name, providerName, modelName || null, type, actualMaxContext, id);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -179,48 +247,89 @@ export class ProviderManager {
|
||||
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 envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey && envKey.trim()) {
|
||||
const id = "provider-default-env";
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "Default (Env)", "google-genai", envKey, 1, "gemini-2.5-flash", "generative");
|
||||
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-env-embed";
|
||||
const embedId = "provider-default-google-embed";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(embedId, "Default Embed (Env)", "google-genai", envKey, 1, "gemini-embedding-001", "embedding");
|
||||
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 (type === "embedding") {
|
||||
return {
|
||||
id: embedId,
|
||||
name: "Default Embed (Env)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
isActive: true,
|
||||
modelName: "gemini-embedding-001",
|
||||
type: "embedding",
|
||||
};
|
||||
}
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
const id = "provider-default-openrouter";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash", "generative", 32768);
|
||||
}
|
||||
|
||||
const retryRow = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`).get(type) as {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
} | undefined;
|
||||
|
||||
if (retryRow) {
|
||||
return {
|
||||
id,
|
||||
name: "Default (Env)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
id: retryRow.id,
|
||||
name: retryRow.name,
|
||||
providerName: retryRow.providerName,
|
||||
apiKey: retryRow.apiKey,
|
||||
isActive: true,
|
||||
modelName: "gemini-2.5-flash",
|
||||
type: "generative",
|
||||
modelName: retryRow.modelName || undefined,
|
||||
type: retryRow.type as "generative" | "embedding",
|
||||
maxContext: retryRow.maxContext !== undefined && retryRow.maxContext !== null ? retryRow.maxContext : (retryRow.type === "embedding" ? 0 : 32768),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no active row but some rows exist, return the first one as active, or update it
|
||||
const firstRow = db.prepare(`SELECT * FROM provider_instances WHERE type = ? LIMIT 1`).get(type) as {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
} | undefined;
|
||||
if (firstRow) {
|
||||
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(firstRow.id);
|
||||
return {
|
||||
id: firstRow.id,
|
||||
name: firstRow.name,
|
||||
providerName: firstRow.providerName,
|
||||
apiKey: firstRow.apiKey,
|
||||
isActive: true,
|
||||
modelName: firstRow.modelName || undefined,
|
||||
type: firstRow.type as "generative" | "embedding",
|
||||
maxContext: firstRow.maxContext !== undefined && firstRow.maxContext !== null ? firstRow.maxContext : (firstRow.type === "embedding" ? 0 : 32768),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -232,29 +341,50 @@ 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),
|
||||
};
|
||||
} catch {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
if (type === "embedding") {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
if (type === "embedding") {
|
||||
if (googleKey && googleKey.trim()) {
|
||||
return {
|
||||
id: "provider-default-env-embed-fallback",
|
||||
name: "Default Embed (Env Fallback)",
|
||||
name: "Gemini Embed (Env Fallback)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
apiKey: googleKey.trim(),
|
||||
isActive: true,
|
||||
modelName: "gemini-embedding-001",
|
||||
type: "embedding",
|
||||
maxContext: 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// generative fallback
|
||||
if (googleKey && googleKey.trim()) {
|
||||
return {
|
||||
id: "provider-default-env-fallback",
|
||||
name: "Default (Env Fallback)",
|
||||
name: "Gemini (Env Fallback)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
apiKey: googleKey.trim(),
|
||||
isActive: true,
|
||||
modelName: "gemini-2.5-flash",
|
||||
type: "generative",
|
||||
maxContext: 32768,
|
||||
};
|
||||
}
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
return {
|
||||
id: "provider-default-env-fallback",
|
||||
name: "OpenRouter (Env Fallback)",
|
||||
providerName: "openrouter",
|
||||
apiKey: openRouterKey.trim(),
|
||||
isActive: true,
|
||||
modelName: "google/gemini-2.5-flash",
|
||||
type: "generative",
|
||||
maxContext: 32768,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -12,33 +12,48 @@ export class GeminiProvider implements ILLMProvider {
|
||||
|
||||
providerName = "Gemini";
|
||||
private model: ChatGoogleGenerativeAI;
|
||||
private modelNameUsed: string;
|
||||
private providerInstanceName?: string;
|
||||
private maxContextUsed?: number;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(apiKey?: string, modelName?: string) {
|
||||
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string, maxContext?: number) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
this.maxContextUsed = maxContext;
|
||||
|
||||
if (!key) {
|
||||
const active = ProviderManager.getActive("generative");
|
||||
if (active) {
|
||||
if (active && active.providerName === GeminiProvider.providerId) {
|
||||
key = active.apiKey;
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
if (!this.providerInstanceName) {
|
||||
this.providerInstanceName = active.name;
|
||||
}
|
||||
if (this.maxContextUsed === undefined) {
|
||||
this.maxContextUsed = active.maxContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
key = llmConfig.GOOGLE_API_KEY;
|
||||
if (!this.providerInstanceName && key) {
|
||||
this.providerInstanceName = "Environment Variable";
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error("GOOGLE_API_KEY is required to initialize GeminiProvider");
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || "gemini-2.5-flash";
|
||||
this.model = new ChatGoogleGenerativeAI({
|
||||
apiKey: key,
|
||||
model: model || "gemini-2.5-flash",
|
||||
model: this.modelNameUsed,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,11 +78,14 @@ export class GeminiProvider implements ILLMProvider {
|
||||
const parsed = result?.parsed;
|
||||
const raw = result?.raw;
|
||||
|
||||
const usage = raw?.usage_metadata ? {
|
||||
inputTokens: raw.usage_metadata.input_tokens || 0,
|
||||
outputTokens: raw.usage_metadata.output_tokens || 0,
|
||||
totalTokens: raw.usage_metadata.total_tokens || 0,
|
||||
} : undefined;
|
||||
const usage = {
|
||||
inputTokens: raw?.usage_metadata?.input_tokens || 0,
|
||||
outputTokens: raw?.usage_metadata?.output_tokens || 0,
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
maxContext: this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
|
||||
@@ -12,33 +12,48 @@ export class OpenRouterProvider implements ILLMProvider {
|
||||
|
||||
providerName = "OpenRouter";
|
||||
private model: ChatOpenRouter;
|
||||
private modelNameUsed: string;
|
||||
private providerInstanceName?: string;
|
||||
private maxContextUsed?: number;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(apiKey?: string, modelName?: string) {
|
||||
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string, maxContext?: number) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
this.maxContextUsed = maxContext;
|
||||
|
||||
if (!key) {
|
||||
const active = ProviderManager.getActive("generative");
|
||||
if (active) {
|
||||
if (active && active.providerName === OpenRouterProvider.providerId) {
|
||||
key = active.apiKey;
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
if (!this.providerInstanceName) {
|
||||
this.providerInstanceName = active.name;
|
||||
}
|
||||
if (this.maxContextUsed === undefined) {
|
||||
this.maxContextUsed = active.maxContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
key = llmConfig.OPENROUTER_API_KEY;
|
||||
if (!this.providerInstanceName && key) {
|
||||
this.providerInstanceName = "Environment Variable";
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || "google/gemini-2.5-flash";
|
||||
this.model = new ChatOpenRouter({
|
||||
apiKey: key,
|
||||
model: model || "google/gemini-2.5-flash",
|
||||
model: this.modelNameUsed,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,11 +78,14 @@ export class OpenRouterProvider implements ILLMProvider {
|
||||
const parsed = result?.parsed;
|
||||
const raw = result?.raw;
|
||||
|
||||
const usage = raw?.usage_metadata ? {
|
||||
inputTokens: raw.usage_metadata.input_tokens || 0,
|
||||
outputTokens: raw.usage_metadata.output_tokens || 0,
|
||||
totalTokens: raw.usage_metadata.total_tokens || 0,
|
||||
} : undefined;
|
||||
const usage = {
|
||||
inputTokens: raw?.usage_metadata?.input_tokens || 0,
|
||||
outputTokens: raw?.usage_metadata?.output_tokens || 0,
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
maxContext: this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
|
||||
@@ -91,6 +91,9 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15,
|
||||
modelName: "google/gemini-2.5-flash",
|
||||
providerInstanceName: "Default",
|
||||
maxContext: 32768,
|
||||
});
|
||||
|
||||
expect(provider.lastCalls.length).toBe(1);
|
||||
@@ -101,6 +104,9 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15,
|
||||
modelName: "google/gemini-2.5-flash",
|
||||
providerInstanceName: "Default",
|
||||
maxContext: 32768,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
95
packages/llm/tests/provider-manager.test.ts
Normal file
95
packages/llm/tests/provider-manager.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ProviderManager, setDbPathOverride, resetHasBootstrapped } from "../src/index.js";
|
||||
|
||||
describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
|
||||
let tempDbPath: string;
|
||||
let originalGoogle: string | undefined;
|
||||
let originalOpenRouter: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalGoogle = process.env.GOOGLE_API_KEY;
|
||||
originalOpenRouter = process.env.OPENROUTER_API_KEY;
|
||||
delete process.env.GOOGLE_API_KEY;
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
|
||||
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`);
|
||||
setDbPathOverride(tempDbPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setDbPathOverride(null);
|
||||
if (fs.existsSync(tempDbPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempDbPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (originalGoogle !== undefined) {
|
||||
process.env.GOOGLE_API_KEY = originalGoogle;
|
||||
} else {
|
||||
delete process.env.GOOGLE_API_KEY;
|
||||
}
|
||||
if (originalOpenRouter !== undefined) {
|
||||
process.env.OPENROUTER_API_KEY = originalOpenRouter;
|
||||
} else {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
test("auto-bootstraps Gemini and OpenRouter when database is empty and environment variables are present", () => {
|
||||
process.env.GOOGLE_API_KEY = "mock-google-key-123";
|
||||
process.env.OPENROUTER_API_KEY = "mock-openrouter-key-456";
|
||||
|
||||
const list = ProviderManager.list();
|
||||
expect(list.length).toBe(3);
|
||||
|
||||
const gemini = list.find((p) => p.providerName === "google-genai");
|
||||
expect(gemini).toBeDefined();
|
||||
expect(gemini?.name).toBe("Gemini (Env)");
|
||||
expect(gemini?.apiKey).toBe("mock-google-key-123");
|
||||
expect(gemini?.modelName).toBe("gemini-2.5-flash");
|
||||
expect(gemini?.isActive).toBe(true); // first inserted is active
|
||||
|
||||
const openrouter = list.find((p) => p.providerName === "openrouter");
|
||||
expect(openrouter).toBeDefined();
|
||||
expect(openrouter?.name).toBe("OpenRouter (Env)");
|
||||
expect(openrouter?.apiKey).toBe("mock-openrouter-key-456");
|
||||
expect(openrouter?.modelName).toBe("google/gemini-2.5-flash");
|
||||
expect(openrouter?.isActive).toBe(false); // second inserted is inactive
|
||||
});
|
||||
|
||||
test("treats bootstrapped instances as normal provider instances (editable and deletable)", () => {
|
||||
process.env.GOOGLE_API_KEY = "mock-google-key-123";
|
||||
|
||||
// Trigger bootstrap
|
||||
const list = ProviderManager.list();
|
||||
expect(list.length).toBe(2);
|
||||
const bootstrapped = list.find((p) => p.name === "Gemini (Env)");
|
||||
expect(bootstrapped).toBeDefined();
|
||||
if (!bootstrapped) return;
|
||||
expect(bootstrapped.isActive).toBe(true);
|
||||
|
||||
// Edit name and key
|
||||
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);
|
||||
expect(updated).toBeDefined();
|
||||
if (!updated) return;
|
||||
expect(updated.name).toBe("My Gemini Key");
|
||||
expect(updated.apiKey).toBe("new-secret-key");
|
||||
expect(updated.modelName).toBe("gemini-2.5-pro");
|
||||
|
||||
// Delete instance
|
||||
ProviderManager.delete(bootstrapped.id);
|
||||
const listAfterDelete = ProviderManager.list();
|
||||
expect(listAfterDelete.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity } from "@omnia/core";
|
||||
import { Entity, resolveAlias } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
|
||||
export interface BufferEntry {
|
||||
@@ -14,38 +14,37 @@ export interface BufferEntry {
|
||||
isValid: boolean;
|
||||
reason: string;
|
||||
};
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export function resolveAlias(viewer: Entity, targetId: string): string {
|
||||
if (targetId === viewer.id) return "you";
|
||||
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
|
||||
}
|
||||
export { resolveAlias } from "@omnia/core";
|
||||
|
||||
export function serializeSubjectiveBufferEntry(
|
||||
entry: BufferEntry,
|
||||
viewer: Entity,
|
||||
): string {
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
const isSelf = viewer.id === entry.intent.actorId;
|
||||
|
||||
const targetAliases = entry.intent.targetIds.map((tid) =>
|
||||
resolveAlias(viewer, tid),
|
||||
);
|
||||
|
||||
let details: string;
|
||||
const content = entry.intent.description.trim() || entry.intent.originalText.trim();
|
||||
|
||||
if (entry.intent.type === "dialogue") {
|
||||
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${content}"`;
|
||||
} else if (entry.intent.type === "monologue") {
|
||||
details = `thought: "${content}"`;
|
||||
} else {
|
||||
details = content;
|
||||
if (entry.outcome) {
|
||||
if (isSelf) {
|
||||
let details = (entry.intent.selfDescription || entry.intent.description || entry.intent.originalText).trim();
|
||||
if (details.length > 0) {
|
||||
details = details.charAt(0).toUpperCase() + details.slice(1);
|
||||
}
|
||||
if (entry.intent.type === "action" && entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
return `${actorAlias} ${details}`;
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
const subjectStr = actorAlias.charAt(0).toUpperCase() + actorAlias.slice(1);
|
||||
|
||||
let details = (entry.intent.description || entry.intent.originalText).trim();
|
||||
if (entry.intent.type === "action" && entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
|
||||
return `${subjectStr} ${details}`;
|
||||
}
|
||||
|
||||
export class BufferRepository {
|
||||
@@ -64,23 +63,31 @@ export class BufferRepository {
|
||||
location_id TEXT,
|
||||
intent_json TEXT NOT NULL,
|
||||
outcome_json TEXT,
|
||||
pinned INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
try {
|
||||
this.db.exec(`ALTER TABLE buffer_entries ADD COLUMN pinned INTEGER DEFAULT 0;`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
save(entry: BufferEntry): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
owner_id = excluded.owner_id,
|
||||
timestamp = excluded.timestamp,
|
||||
location_id = excluded.location_id,
|
||||
intent_json = excluded.intent_json,
|
||||
outcome_json = excluded.outcome_json
|
||||
outcome_json = excluded.outcome_json,
|
||||
pinned = excluded.pinned
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
@@ -90,6 +97,7 @@ export class BufferRepository {
|
||||
entry.locationId,
|
||||
JSON.stringify(entry.intent),
|
||||
entry.outcome ? JSON.stringify(entry.outcome) : null,
|
||||
entry.pinned ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,7 +105,7 @@ export class BufferRepository {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned
|
||||
FROM buffer_entries WHERE id = ?
|
||||
`,
|
||||
)
|
||||
@@ -109,6 +117,7 @@ export class BufferRepository {
|
||||
location_id: string | null;
|
||||
intent_json: string;
|
||||
outcome_json: string | null;
|
||||
pinned?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -121,6 +130,7 @@ export class BufferRepository {
|
||||
locationId: row.location_id,
|
||||
intent: JSON.parse(row.intent_json),
|
||||
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
|
||||
pinned: row.pinned === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,7 +138,7 @@ export class BufferRepository {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned
|
||||
FROM buffer_entries WHERE owner_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
`,
|
||||
@@ -140,6 +150,7 @@ export class BufferRepository {
|
||||
location_id: string | null;
|
||||
intent_json: string;
|
||||
outcome_json: string | null;
|
||||
pinned?: number;
|
||||
}[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -149,6 +160,7 @@ export class BufferRepository {
|
||||
locationId: row.location_id,
|
||||
intent: JSON.parse(row.intent_json),
|
||||
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
|
||||
pinned: row.pinned === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
300
packages/memory/src/handoff.ts
Normal file
300
packages/memory/src/handoff.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { z } from "zod";
|
||||
import { Entity, naturalizeTime } from "@omnia/core";
|
||||
import { BufferEntry, serializeSubjectiveBufferEntry, BufferRepository } from "./buffer.js";
|
||||
import { LedgerEntry, LedgerRepository } from "./ledger.js";
|
||||
import { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
|
||||
|
||||
export const HandoffChunkSchema = z.object({
|
||||
sourceEntryIds: z.array(z.string()), // buffer rows this chunk consumes
|
||||
content: z.string(), // third-person summary -> LedgerEntry.content
|
||||
quotes: z.array(z.string()), // verbatim, high-salience lines only
|
||||
importance: z.number().int().min(1).max(10),
|
||||
involvedEntityIds: z.array(z.string()),
|
||||
retainInBuffer: z.boolean(), // "pin"
|
||||
});
|
||||
|
||||
export const HandoffResultSchema = z.object({
|
||||
chunks: z.array(HandoffChunkSchema),
|
||||
});
|
||||
|
||||
export type HandoffResult = z.infer<typeof HandoffResultSchema>;
|
||||
export type HandoffTrigger = "none" | "voluntary" | "involuntary";
|
||||
|
||||
/**
|
||||
* Serializes the hypothetical memory section for size checking.
|
||||
*/
|
||||
export function getMemorySectionLength(
|
||||
entity: Entity,
|
||||
entries: BufferEntry[],
|
||||
now: Date,
|
||||
): number {
|
||||
if (entries.length === 0) {
|
||||
return `=== RECENT EVENTS ===\n(No recent events recorded.)`.length;
|
||||
}
|
||||
|
||||
const groupedLines: string[] = [];
|
||||
let currentGroup: string | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
const when = naturalizeTime(now, new Date(entry.timestamp));
|
||||
|
||||
if (when !== currentGroup) {
|
||||
currentGroup = when;
|
||||
const header = when.charAt(0).toUpperCase() + when.slice(1);
|
||||
groupedLines.push(header);
|
||||
}
|
||||
|
||||
groupedLines.push(` - ${serialized}`);
|
||||
}
|
||||
|
||||
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`.length;
|
||||
}
|
||||
|
||||
function checkSceneExit(entity: Entity, bufferEntries: BufferEntry[]): boolean {
|
||||
if (bufferEntries.length === 0) return false;
|
||||
|
||||
// Find the location of the most recent buffer entries
|
||||
const lastEntry = bufferEntries[bufferEntries.length - 1];
|
||||
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));
|
||||
if (locations.size > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkIdleDecay(bufferEntries: BufferEntry[]): boolean {
|
||||
const N = 5; // N consecutive idle turns
|
||||
if (bufferEntries.length < N) return false;
|
||||
|
||||
// Check the last N entries
|
||||
const lastN = bufferEntries.slice(-N);
|
||||
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") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = entity.attributes.get("status");
|
||||
if (status && ["unconscious", "asleep", "dead", "inactive"].includes(status.getValue().toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks deterministically whether handoff should run for the given entity.
|
||||
*/
|
||||
export function checkHandoffTrigger(
|
||||
entity: Entity,
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
maxContext: number = 32768,
|
||||
): HandoffTrigger {
|
||||
if (bufferEntries.length === 0) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
// Involuntary triggers first (hard)
|
||||
if (maxContext > 0) {
|
||||
const memoryLength = getMemorySectionLength(entity, bufferEntries, now);
|
||||
const charCeiling = maxContext * 4 * 0.60;
|
||||
if (memoryLength > charCeiling) {
|
||||
return "involuntary";
|
||||
}
|
||||
}
|
||||
|
||||
// Event velocity
|
||||
if (bufferEntries.length > 20) {
|
||||
return "involuntary";
|
||||
}
|
||||
|
||||
// Voluntary triggers (soft)
|
||||
if (checkSceneExit(entity, bufferEntries)) {
|
||||
return "voluntary";
|
||||
}
|
||||
|
||||
if (checkIdleDecay(bufferEntries)) {
|
||||
return "voluntary";
|
||||
}
|
||||
|
||||
if (checkAttributeTrigger(entity)) {
|
||||
return "voluntary";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the buffer into candidate pool (older) and watermark tail (untouched).
|
||||
*/
|
||||
export function splitBufferForHandoff(
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
K: number = 8,
|
||||
): { candidates: BufferEntry[]; watermark: BufferEntry[] } {
|
||||
const sorted = [...bufferEntries].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
const freshBuckets = new Set([
|
||||
"just now",
|
||||
"moments ago",
|
||||
"a few minutes ago",
|
||||
"several minutes ago",
|
||||
]);
|
||||
|
||||
let watermarkStartIndex = sorted.length;
|
||||
|
||||
// 1. Mark last K entries as watermark
|
||||
if (sorted.length > K) {
|
||||
watermarkStartIndex = sorted.length - K;
|
||||
} else {
|
||||
watermarkStartIndex = 0;
|
||||
}
|
||||
|
||||
// 2. Expand watermark to include any fresh entries before it
|
||||
for (let i = watermarkStartIndex - 1; i >= 0; i--) {
|
||||
const bucket = naturalizeTime(now, new Date(sorted[i].timestamp));
|
||||
if (freshBuckets.has(bucket)) {
|
||||
watermarkStartIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
candidates: sorted.slice(0, watermarkStartIndex),
|
||||
watermark: sorted.slice(watermarkStartIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HandoffEngine processes memory handoffs using LLM summarization and DB transactions.
|
||||
*/
|
||||
export class HandoffEngine {
|
||||
constructor(
|
||||
private llmProvider: ILLMProvider,
|
||||
private embedProvider: IEmbeddingProvider,
|
||||
private bufferRepo: BufferRepository,
|
||||
private ledgerRepo: LedgerRepository,
|
||||
) {}
|
||||
|
||||
async runHandoff(
|
||||
entity: Entity,
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
const { candidates } = splitBufferForHandoff(bufferEntries, now);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
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 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.
|
||||
|
||||
Instructions:
|
||||
1. **Cluster** related consecutive buffer entries into high-level narrative beats or events (e.g. a full back-and-forth conversation or a single physical action and its outcome). Combine them into a single summary chunk.
|
||||
2. **Write in the third-person** for the "content" of each chunk (e.g. "John asked Mary for the key, and Mary reluctantly handed it over").
|
||||
3. **verbatim Quotes**: Extract verbatim, high-salience quotes from dialogue if relevant. Do not modify or invent quotes.
|
||||
4. **Determine Importance**: Assign an importance score from 1 (trivial, e.g. waking up) to 10 (life-altering, e.g. witnessing a crime).
|
||||
5. **Involved Entities**: Identify all entity IDs involved in the memories in this chunk.
|
||||
6. **Retain in Buffer (Pinning)**: If a beat represents an unresolved high-stakes situation (e.g. a standing threat, an unanswered accusation, an ongoing chase or conflict), set "retainInBuffer" to true so it remains in the working memory buffer for immediate context. Otherwise, set it to false so it is safely pruned from the buffer.
|
||||
7. **Exclude stage business**: Glances, sighs, ambient noticing, and irrelevant sensory details should be ignored and not included in any promoted chunk. They will be forgotten.
|
||||
8. **Forget by omission**: Any buffer entry ID that you do not include in any chunk's "sourceEntryIds" will be permanently deleted and forgotten.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
Subject Entity ID: ${entity.id}
|
||||
Current Time: ${now.toISOString()}
|
||||
|
||||
Working Memory Candidates for Handoff:
|
||||
${candidatesList}
|
||||
`.trim();
|
||||
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
userContext,
|
||||
schema: HandoffResultSchema,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
const db = (this.bufferRepo as any).db;
|
||||
|
||||
const ledgerEntries: LedgerEntry[] = [];
|
||||
for (const chunk of result.chunks) {
|
||||
let embedding: number[] = [];
|
||||
try {
|
||||
embedding = await this.embedProvider.embed(chunk.content);
|
||||
} catch (err) {
|
||||
console.error("Failed to generate embedding for handoff chunk:", err);
|
||||
return false;
|
||||
}
|
||||
|
||||
ledgerEntries.push({
|
||||
id: "ledger-" + Math.random().toString(36).substr(2, 9) + "-" + Date.now(),
|
||||
ownerId: entity.id,
|
||||
timestamp: now.toISOString(),
|
||||
locationId: entity.locationId,
|
||||
involvedEntityIds: chunk.involvedEntityIds,
|
||||
content: chunk.content,
|
||||
quotes: chunk.quotes,
|
||||
importance: chunk.importance,
|
||||
embedding,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
// Save promoted ledger entries
|
||||
for (const entry of ledgerEntries) {
|
||||
this.ledgerRepo.save(entry);
|
||||
}
|
||||
|
||||
// Keep track of pinned source IDs
|
||||
const pinnedSourceIds = new Set<string>();
|
||||
for (const chunk of result.chunks) {
|
||||
if (chunk.retainInBuffer) {
|
||||
for (const id of chunk.sourceEntryIds) {
|
||||
pinnedSourceIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete or pin entries
|
||||
for (const candidate of candidates) {
|
||||
if (pinnedSourceIds.has(candidate.id)) {
|
||||
const updated = { ...candidate, pinned: true };
|
||||
this.bufferRepo.save(updated);
|
||||
} else {
|
||||
this.bufferRepo.delete(candidate.id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Transaction failed during handoff execution:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./buffer.js";
|
||||
export * from "./ledger.js";
|
||||
export * from "./handoff.js";
|
||||
|
||||
167
packages/memory/tests/handoff.test.ts
Normal file
167
packages/memory/tests/handoff.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity } from "@omnia/core";
|
||||
import { MockLLMProvider, MockEmbeddingProvider } from "@omnia/llm";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
LedgerRepository,
|
||||
checkHandoffTrigger,
|
||||
splitBufferForHandoff,
|
||||
HandoffEngine,
|
||||
} from "@omnia/memory";
|
||||
|
||||
describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
const now = new Date("2026-07-07T12:00:00.000Z");
|
||||
|
||||
test("splitBufferForHandoff correctly splits based on watermark and fresh buckets", () => {
|
||||
const entries: BufferEntry[] = [];
|
||||
|
||||
// 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();
|
||||
entries.push({
|
||||
id: `entry-old-${i}`,
|
||||
ownerId: "alice",
|
||||
timestamp,
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: `Old event ${i}`,
|
||||
description: `does old thing ${i}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add 4 fresh entries (moments ago / just now)
|
||||
const freshTimes = [
|
||||
new Date(now.getTime() - 10 * 1000).toISOString(),
|
||||
new Date(now.getTime() - 30 * 1000).toISOString(),
|
||||
new Date(now.getTime() - 90 * 1000).toISOString(),
|
||||
new Date(now.getTime() - 180 * 1000).toISOString(),
|
||||
];
|
||||
|
||||
freshTimes.forEach((timestamp, idx) => {
|
||||
entries.push({
|
||||
id: `entry-fresh-${idx}`,
|
||||
ownerId: "alice",
|
||||
timestamp,
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: `Fresh event ${idx}`,
|
||||
description: `does fresh thing ${idx}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { candidates, watermark } = splitBufferForHandoff(entries, now, 8);
|
||||
|
||||
expect(watermark.length).toBeGreaterThanOrEqual(8);
|
||||
expect(candidates.length).toBe(8);
|
||||
expect(candidates[0].id).toBe("entry-old-0");
|
||||
});
|
||||
|
||||
test("checkHandoffTrigger detects scene change and idle decay", () => {
|
||||
const entity = new Entity("alice");
|
||||
entity.locationId = "room-2";
|
||||
|
||||
// Scenario 1: Empty buffer -> no trigger
|
||||
expect(checkHandoffTrigger(entity, [], now)).toBe("none");
|
||||
|
||||
// Scenario 2: Scene exit
|
||||
const entryAtRoom1: BufferEntry = {
|
||||
id: "e-1",
|
||||
ownerId: "alice",
|
||||
timestamp: now.toISOString(),
|
||||
locationId: "room-1",
|
||||
intent: { type: "dialogue", originalText: "hello", description: "says hello", actorId: "alice", targetIds: [] },
|
||||
};
|
||||
expect(checkHandoffTrigger(entity, [entryAtRoom1], now)).toBe("voluntary");
|
||||
|
||||
// Scenario 3: Idle decay (5 consecutive monologues)
|
||||
const monologues: BufferEntry[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `m-${i}`,
|
||||
ownerId: "alice",
|
||||
timestamp: now.toISOString(),
|
||||
locationId: "room-2",
|
||||
intent: { type: "monologue", originalText: "think", description: "thinks", actorId: "alice", targetIds: [] },
|
||||
}));
|
||||
expect(checkHandoffTrigger(entity, monologues, now)).toBe("voluntary");
|
||||
});
|
||||
|
||||
test("HandoffEngine promotes candidates to Ledger and prunes buffer transactionally", async () => {
|
||||
const db = new Database(":memory:");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS objects (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO objects (id, type, name) VALUES ('alice', 'character', 'Alice');
|
||||
`);
|
||||
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
|
||||
const entity = new Entity("alice");
|
||||
entity.locationId = "room-1";
|
||||
|
||||
const entries: BufferEntry[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const timestamp = new Date(now.getTime() - (50 - i) * 60 * 1000).toISOString();
|
||||
const entry: BufferEntry = {
|
||||
id: `entry-${i}`,
|
||||
ownerId: "alice",
|
||||
timestamp,
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: i % 2 === 0 ? "dialogue" : "action",
|
||||
originalText: `Event ${i}`,
|
||||
description: `does thing ${i}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
};
|
||||
bufferRepo.save(entry);
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
const mockHandoffResult = {
|
||||
chunks: [
|
||||
{
|
||||
sourceEntryIds: ["entry-0"],
|
||||
content: "Alice initiated dialogue and performed various tasks.",
|
||||
quotes: ["Event 0"],
|
||||
importance: 5,
|
||||
involvedEntityIds: ["bob"],
|
||||
retainInBuffer: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const llmProvider = new MockLLMProvider([mockHandoffResult]);
|
||||
const embedProvider = new MockEmbeddingProvider();
|
||||
const engine = new HandoffEngine(llmProvider, embedProvider, bufferRepo, ledgerRepo);
|
||||
|
||||
const success = await engine.runHandoff(entity, entries, now);
|
||||
expect(success).toBe(true);
|
||||
|
||||
const ledgerRows = db.prepare("SELECT * FROM ledger_entries WHERE owner_id = ?").all("alice") as any[];
|
||||
expect(ledgerRows.length).toBe(1);
|
||||
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);
|
||||
|
||||
const remainingBuffer = bufferRepo.listForOwner("alice");
|
||||
expect(remainingBuffer.length).toBe(8);
|
||||
expect(remainingBuffer.map((b) => b.id)).not.toContain("entry-0");
|
||||
expect(remainingBuffer.map((b) => b.id)).not.toContain("entry-1");
|
||||
expect(remainingBuffer[0].id).toBe("entry-2");
|
||||
});
|
||||
});
|
||||
@@ -32,14 +32,16 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: '"Hello there," Bob said to Charlie.',
|
||||
description: "Bob greets Charlie",
|
||||
description: "says, 'Hello there' to the bartender",
|
||||
selfDescription: "You say, 'Hello there' to the bartender.",
|
||||
actorId: "bob",
|
||||
targetIds: ["charlie"],
|
||||
modifiers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('the hooded figure spoke to the bartender: "Bob greets Charlie"');
|
||||
expect(result).toBe("The hooded figure says, 'Hello there' to the bartender");
|
||||
});
|
||||
|
||||
test("serializes action intent with outcome details", () => {
|
||||
@@ -54,9 +56,11 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Bob tried to break the latch.",
|
||||
description: "Bob attempts to break the lock latch",
|
||||
description: "attempts to break the lock latch",
|
||||
selfDescription: "You attempt to break the lock latch.",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
outcome: {
|
||||
isValid: false,
|
||||
@@ -65,7 +69,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('the hooded figure Bob 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", () => {
|
||||
@@ -80,13 +84,15 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "I opened the window.",
|
||||
description: "open the window",
|
||||
selfDescription: "You open the window.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
|
||||
expect(resultSelf).toBe("you open the window");
|
||||
expect(resultSelf).toBe("You open the window.");
|
||||
|
||||
const entryUnfamiliar: BufferEntry = {
|
||||
id: "entry-unfamiliar",
|
||||
@@ -96,14 +102,16 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Someone knocked.",
|
||||
description: "knock on the door",
|
||||
description: "knocks on the door",
|
||||
selfDescription: "You knock on the door.",
|
||||
actorId: "stranger-1",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
|
||||
expect(resultUnfamiliar).toBe("an unfamiliar figure knock on the door");
|
||||
expect(resultUnfamiliar).toBe("An unfamiliar figure knocks on the door");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,8 +131,10 @@ describe("BufferRepository Persistence Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "Alice picked up a stick.",
|
||||
description: "Alice gathers a stick",
|
||||
selfDescription: "You gather a stick.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const entry: BufferEntry = {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../intent" }
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../llm" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ export class ScenarioLoader {
|
||||
world.addEntity(entity);
|
||||
this.coreRepo.saveEntity(entity, world.id);
|
||||
|
||||
// Seed initial memory buffer history
|
||||
if (entData.initialMemories) {
|
||||
for (const mem of entData.initialMemories) {
|
||||
this.bufferRepo.save({
|
||||
@@ -114,7 +113,11 @@ export class ScenarioLoader {
|
||||
ownerId: entData.id,
|
||||
timestamp: mem.timestamp,
|
||||
locationId: mem.locationId,
|
||||
intent: mem.intent,
|
||||
intent: {
|
||||
...mem.intent,
|
||||
selfDescription: mem.intent.selfDescription ?? "",
|
||||
modifiers: mem.intent.modifiers ?? [],
|
||||
},
|
||||
outcome: mem.outcome,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,8 +33,10 @@ export const ScenarioMemoryEntrySchema = z.object({
|
||||
type: z.enum(["dialogue", "action", "monologue"]),
|
||||
originalText: z.string(),
|
||||
description: z.string(),
|
||||
selfDescription: z.string().optional(),
|
||||
actorId: z.string(),
|
||||
targetIds: z.array(z.string()),
|
||||
modifiers: z.array(z.string()).optional(),
|
||||
}),
|
||||
outcome: z.object({
|
||||
isValid: z.boolean(),
|
||||
|
||||
@@ -57,8 +57,10 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "I entered the foyer.",
|
||||
description: "entered the house",
|
||||
selfDescription: "You entered the house.",
|
||||
actorId: "investigator",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
4160
pnpm-lock.yaml
generated
4160
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -62,22 +62,28 @@ describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
|
||||
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.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "dialogue",
|
||||
originalText: '"Hey Bob," she called out softly.',
|
||||
description: "Alice softly calls out to Bob.",
|
||||
selfDescription: "You softly call out to Bob.",
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She reached for the ledger on the table.",
|
||||
description: "Alice reaches for the ledger on the table.",
|
||||
selfDescription: "You reach for the ledger on the table.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -33,15 +33,19 @@ describe("Omnia Integration Tests (Tier 2)", () => {
|
||||
type: "dialogue",
|
||||
originalText: '"Cover me," Alice whispered to Bob.',
|
||||
description: "Alice whispers to Bob to cover her.",
|
||||
selfDescription: "You whisper to Bob to cover you.",
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She crept towards the door and pulled the handle.",
|
||||
description: "Alice creeps to the door and pulls the handle.",
|
||||
selfDescription: "You creep to the door and pull the handle.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -108,16 +112,20 @@ describe("Omnia Integration Tests (Tier 2)", () => {
|
||||
type: "action" as const,
|
||||
originalText: "She tries to unlock the gate with a hairpin.",
|
||||
description: "Alice attempts to pick the lock with a hairpin.",
|
||||
selfDescription: "You attempt to pick the lock with a hairpin.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const intent2 = {
|
||||
type: "dialogue" as const,
|
||||
originalText: '"This is useless," she mutters.',
|
||||
description: "Alice mutters to herself.",
|
||||
selfDescription: "You mutter to yourself.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
// LLM validation / time delta mock responses:
|
||||
|
||||
128
web/docs/src/content/docs/architecture/handoff.md
Normal file
128
web/docs/src/content/docs/architecture/handoff.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Memory Handoff Pipeline
|
||||
description: The Tier 1 (Working Buffer) to Tier 2 (Long-Term Ledger) memory promotion architecture
|
||||
sidebar:
|
||||
order: 6
|
||||
---
|
||||
|
||||
:::tip[Status: Fully Implemented]
|
||||
The handoff pipeline is fully integrated into the simulation step loop. The `HandoffEngine` and `checkHandoffTrigger` components process working memory entries, promoting them to the persistent episodic Ledger, while safely pruning the subjective working memory buffer.
|
||||
:::
|
||||
|
||||
The **Handoff** pipeline manages the promotion of subjective experiences from the short-term working memory (Tier 1 Buffer) to long-term episodic memory (Tier 2 Ledger). This process regulates context window utilization by deciding **when** promotion occurs, **how much** of the buffer is processed, and **what** details are preserved or pruned.
|
||||
|
||||
To maintain performance and prevent context bloat, the pipeline separates the process into three decoupled stages:
|
||||
|
||||
1. **Trigger Detection**: A deterministic, low-cost evaluation of entity and buffer state to determine if a promotion check is required.
|
||||
2. **Watermark Partitioning**: A policy-driven boundary that separates recent memories (the watermark tail) from older promotion candidates.
|
||||
3. **Structured Selection**: An LLM-driven synthesis step that summarizes, rates, and prunes candidate memories.
|
||||
|
||||
---
|
||||
|
||||
## 1. Trigger Detection
|
||||
|
||||
Trigger detection runs deterministically at the start of each entity's turn, without LLM overhead. The system categorizes triggers into voluntary (soft) and involuntary (hard) conditions.
|
||||
|
||||
```ts
|
||||
type HandoffTrigger = "none" | "voluntary" | "involuntary";
|
||||
|
||||
function checkHandoffTrigger(
|
||||
entity: Entity,
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
If the buffer is empty, trigger detection exits immediately with `"none"`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Watermark Partitioning
|
||||
|
||||
To preserve short-term narrative continuity and avoid immediate memory decay, recent events are protected from handoff. The pipeline divides the active buffer into a protected **Watermark Tail** and a **Candidate Pool**.
|
||||
|
||||
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.
|
||||
|
||||
Entries older than the watermark boundary constitute the **Candidate Pool** and are eligible for handoff processing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Structured Selection
|
||||
|
||||
Handoff promotion is executed via a single, structured LLM call per run. The engine maps candidate entries to Zod schemas to ensure type-safe integration.
|
||||
|
||||
```ts
|
||||
const HandoffChunkSchema = z.object({
|
||||
sourceEntryIds: z.array(z.string()), // buffer entries consumed by this chunk
|
||||
content: z.string(), // third-person summary -> LedgerEntry.content
|
||||
quotes: z.array(z.string()), // verbatim, high-salience dialogue lines
|
||||
importance: z.number().int().min(1).max(10),
|
||||
involvedEntityIds: z.array(z.string()),
|
||||
retainInBuffer: z.boolean(), // keeps entries in buffer despite promotion
|
||||
});
|
||||
|
||||
const HandoffResultSchema = z.object({
|
||||
chunks: z.array(HandoffChunkSchema),
|
||||
});
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Transactional Execution
|
||||
|
||||
To prevent permanent memory loss (e.g., buffer entries being deleted without ledger entries being committed), the handoff execution pipeline operates under a strict **fail-closed** design.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[checkHandoffTrigger] -->|none| Z[No-op]
|
||||
A -->|voluntary or involuntary| B[Partition Buffer]
|
||||
B --> C{Candidates Available?}
|
||||
C -->|No| Z
|
||||
C -->|Yes| D[Inference: LLM Selection & Embedding Generation]
|
||||
D -->|Failure / Invalid Output| E[Abort - Fail-Closed]
|
||||
D -->|Success| F[[SQLite Transaction]]
|
||||
F --> G[(Write Ledger Entries)]
|
||||
F --> H[(Delete Pruned Buffer Entries)]
|
||||
F --> I[(Update Pinned Buffer Entries)]
|
||||
```
|
||||
|
||||
If LLM inference, Zod schema validation, or vector embedding generation fails, the process aborts immediately. No database changes are written, and the trigger re-evaluates on the next turn.
|
||||
|
||||
---
|
||||
|
||||
## 5. Integration
|
||||
|
||||
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.
|
||||
@@ -73,9 +73,10 @@ If no specific provider instance is mapped to a task, the task automatically rou
|
||||
|
||||
To maintain backwards-compatibility and support headless runs, live evaluation suites, and automated unit tests without requiring database pre-configuration, the config manager supports **self-bootstrapping**:
|
||||
|
||||
1. When the provider manager queries the active key instance, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY` or `OPENROUTER_API_KEY`.
|
||||
2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Default (Env)`) in `settings.db`.
|
||||
3. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` to keep execution fluent and error-free.
|
||||
1. When any database connection is initialized via the provider manager, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY` and `OPENROUTER_API_KEY`.
|
||||
2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Gemini (Env)`) in `settings.db`.
|
||||
3. If `process.env.OPENROUTER_API_KEY` is present, it automatically creates and saves a default provider instance (`OpenRouter (Env)`) in `settings.db`.
|
||||
4. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` (`Gemini (Env Fallback)` or `OpenRouter (Env Fallback)`) to keep execution fluent and error-free.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
---
|
||||
title: Tier 2 Memory (Ledger)
|
||||
description: Long-term episodic memory storage and retrieval
|
||||
title: Tier 2 Memory (Long-Term Ledger)
|
||||
description: Persistent episodic memory storage, semantic indexing, and retrieval mechanics
|
||||
---
|
||||
|
||||
Tier 2 memory lives in between the memory buffer and tier 3 dossiers and arguably takes up the largest share of the context pie.
|
||||
**Tier 2 Memory (the Ledger)** represents an entity's long-term episodic memory. It archives historical summaries of past events, providing a persistent record of character experiences that can be retrieved and injected into LLM prompts as needed.
|
||||
|
||||
Tier 2 memory (or long-term memory) stores historical events that happened to the entity in the past. It acts as an episodic ledger.
|
||||
---
|
||||
|
||||
## 1. Data Model
|
||||
|
||||
A long-term memory is represented by a `LedgerEntry`. It includes metadata for deterministic database-level filtering, structured narrative content, and vector embeddings for semantic similarity scoring.
|
||||
|
||||
```ts
|
||||
interface LedgerEntry {
|
||||
id: string;
|
||||
ownerId: string; // whose subjective memory this belongs to
|
||||
timestamp: string; // ISO, tied to WorldClock when the intent causing the event happened.
|
||||
locationId: string | null; // where it happened
|
||||
involvedEntityIds: string[]; // who else this event concerns
|
||||
ownerId: string; // The entity to whom this subjective memory belongs
|
||||
timestamp: string; // ISO timestamp matching the WorldClock at event time
|
||||
locationId: string | null; // Location where the event transpired
|
||||
involvedEntityIds: string[]; // Other entity IDs present during the event
|
||||
|
||||
content: string; // third-person narrative summary — recallable
|
||||
quotes: string[]; // verbatim lines, only for high-salience dialogue
|
||||
importance: number; // 1–10, salience assigned at handoff
|
||||
embedding: number[]; // for semantic search (storage representation TBD at build time)
|
||||
content: string; // Third-person narrative summary of the event
|
||||
quotes: string[]; // Verbatim dialogue lines of high narrative salience
|
||||
importance: number; // Salience score from 1 (trivial) to 10 (life-altering)
|
||||
embedding: number[]; // 768-dimensional vector embedding of the content
|
||||
}
|
||||
```
|
||||
|
||||
### Storage Model
|
||||
---
|
||||
|
||||
Tier 2 memory is stored in relational tables to allow efficient deterministic filtering. Embeddings are stored as raw BLOBs (containing a serialized `Float32Array`).
|
||||
## 2. Storage Model
|
||||
|
||||
To avoid the build and installation friction associated with native C-extensions like `sqlite-vec` (e.g. node-gyp issues across platforms), index optimization relies on standard SQLite secondary indices. These indices allow database queries to execute in microseconds, even with hundreds of thousands of memories:
|
||||
To support fast, low-latency queries across large historical datasets, Tier 2 memory is stored in standard SQLite tables. Vector embeddings are stored in raw binary format as `Float32Array` BLOBs.
|
||||
|
||||
Standard secondary indices optimize query execution time to microseconds, eliminating the compilation and cross-platform installation overhead of native vector database extensions (such as `sqlite-vec` or `node-gyp` binaries).
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ledger_entries (
|
||||
@@ -54,59 +60,78 @@ CREATE INDEX IF NOT EXISTS idx_ledger_importance ON ledger_entries(importance);
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_involved_entity ON ledger_involved_entities(entity_id);
|
||||
```
|
||||
|
||||
### Handoff (Deferred)
|
||||
---
|
||||
|
||||
The process of moving memories from the Tier 1 working buffer into Tier 2 is called **Handoff**.
|
||||
During handoff, an LLM chunk-summarizes raw buffer events, extracts salient quotes, and assigns an `importance` score (1-10). Routine actions score low, while life-altering events score high.
|
||||
## 3. The Handoff Pipeline
|
||||
|
||||
Because this summarization requires an LLM call, it utilizes the standard `LLMProviderInstance` inference provider routing architecture just like all other callers in the system. This allows the simulation to route handoff processing to a specific model.
|
||||
Working memory (Tier 1 Buffer) entries are promoted to the Ledger through the automated [Handoff Pipeline](./handoff).
|
||||
|
||||
*Note: The automated handoff pipeline is currently deferred for future implementation.*
|
||||
During handoff:
|
||||
1. Candidate entries are clustered into narrative beats.
|
||||
2. Ambient stage business and redundant details are pruned.
|
||||
3. Chunks are synthesized into third-person summaries and assigned importance scores.
|
||||
4. Text embeddings are generated for the summary.
|
||||
5. The processed memories are committed to the SQLite store, and the short-term buffer is pruned.
|
||||
|
||||
### Retrieval Architecture
|
||||
---
|
||||
|
||||
Retrieval happens in phases to manage context window limits without running expensive vector searches across an entity's entire lifetime of memories.
|
||||
## 4. Retrieval Architecture
|
||||
|
||||
#### Phase 1: Deterministic Heuristic Filtering
|
||||
To prevent context window overflow and contain inference costs, memory retrieval is split into a fast database query phase followed by an in-memory ranking phase.
|
||||
|
||||
This is the primary database-level retrieval mechanism. We use fast SQL queries to filter down to a relevant candidate pool based on immediate context:
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Retrieval] --> B[Phase 1: SQL Filter]
|
||||
B -->|Filter by Location, Co-located Entities, and High Salience| C[Candidate Pool]
|
||||
C --> D[Phase 2: In-Memory Ranker]
|
||||
D -->|1. Cosine Similarity| E[Relevance Scores]
|
||||
D -->|2. Exponential Recency Decay| F[Recency Scores]
|
||||
E & F --> G[Compute Combined Score]
|
||||
G --> H[Chronological Associative Chaining]
|
||||
H --> I[Format Prompt Section]
|
||||
```
|
||||
|
||||
1. **Spatial Cues**: Fetch recent memories where `location_id` equals the entity's current location.
|
||||
2. **Social Cues**: Fetch recent memories involving the `involvedEntityIds` currently in the entity's perception radius.
|
||||
3. **High Salience**: Always fetch memories with `importance >= 8` regardless of spatial or social context.
|
||||
### Phase 1: Deterministic Heuristic Filtering
|
||||
The primary selection uses indexes to retrieve a candidate pool (capped at 100 entries) from the database:
|
||||
1. **Spatial Cues**: Fetch entries matching the character's current `locationId`.
|
||||
2. **Social Cues**: Fetch entries where `involvedEntityIds` intersects with entities currently inside the character's perception radius.
|
||||
3. **High Salience**: Always retrieve high-salience entries where `importance >= 8`.
|
||||
|
||||
#### Phase 2: Semantic & Episodic Ranking
|
||||
### Phase 2: Semantic & Episodic Ranking
|
||||
Once the candidate pool is loaded, the ranking engine evaluates entries in application memory:
|
||||
1. **Semantic Similarity**: Cosine similarity is computed directly in JS/TS memory between the current prompt context and the candidate embeddings.
|
||||
2. **Multi-Factor Scoring**: Candidates are ranked using a weighted linear combination:
|
||||
$$\text{Score} = (\alpha \times \text{recency}) + (\beta \times \text{importance}) + (\gamma \times \text{relevance})$$
|
||||
* **Recency** is modeled via exponential decay based on elapsed simulation hours: $\text{decayRate}^{\text{hoursElapsed}}$.
|
||||
* **Importance** is the normalized salience score (1-10) assigned during handoff.
|
||||
* **Relevance** is the cosine similarity score.
|
||||
3. **Chronological Associative Chaining**: When a memory is selected, the system automatically pulls in its adjacent chronological neighbors (preceding and succeeding ledger entries) to preserve episodic continuity in the prompt.
|
||||
|
||||
This phase runs in application memory using the candidates returned from Phase 1:
|
||||
---
|
||||
|
||||
1. **Semantic Match**: Compute cosine similarity dynamically in JS/TS memory over the candidate pool (limit 100). Since Phase 1 narrows the pool down significantly, vector comparisons are highly performant in JS, eliminating the need for native vector database extensions.
|
||||
2. **Scoring Combination**: Combine recency, importance, and semantic match:
|
||||
$$\text{Score} = (\text{recencyWeight} \times \text{recency}) + (\text{importanceWeight} \times \text{importanceNorm}) + (\text{relevanceWeight} \times \text{relevance})$$
|
||||
Where `recency` uses an exponential decay based on elapsed hours ($\text{decayRate}^{\text{hoursElapsed}}$).
|
||||
3. **Associative Chain**: When a memory is selected, automatically pull in its immediate chronological neighbors (preceding and succeeding ledger entries) to preserve episodic continuity (mirroring how remembering one event triggers the memory of what happened right after).
|
||||
## 5. Active Focus & Attention Loop
|
||||
|
||||
### Retrieval Triggers & Active Focus
|
||||
In crowded settings (e.g., a room with many characters), retrieving long-term memories for all co-located entities would exhaust the prompt context window. To prevent this, retrieval uses an **Active Focus** selection strategy:
|
||||
|
||||
In crowded locations (e.g. a tavern with 15 other characters), retrieving memories for all co-located entities simultaneously would cause **context explosion**. To prevent this, Omnia utilizes an **Active Focus** trigger strategy:
|
||||
* **Active Focus Set**: The prompt builder scans the last 10 entries of the character's working memory buffer. Any entity targeted by, spoken to, or mentioned in these entries is added to the Active Focus set.
|
||||
* **Dynamic Capacity Limits**:
|
||||
* If the number of co-located characters is small ($\le 3$), long-term memory is retrieved for all of them.
|
||||
* If the environment is crowded ($> 3$), long-term retrieval is strictly restricted to the top 3 characters in the Active Focus set.
|
||||
* This creates a realistic attention loop: when a new character interacts with the actor, they enter the working buffer, triggering the retrieval of their long-term history on the subsequent turn.
|
||||
|
||||
- **Active Focus Scanning**: The prompt builder scans the last 10 entries of the entity's recent working memory (Tier 1 Buffer). Any character that the actor has recently spoken to, thought about, or was targeted by is placed in the "Active Focus" set.
|
||||
- **Dynamic Thresholding**:
|
||||
- If the number of co-located entities is small ($\le 3$), long-term memory is retrieved for all of them.
|
||||
- If the location is crowded ($> 3$ entities), the system **strictly** limits long-term retrieval to the top 3 characters in "Active Focus".
|
||||
- This creates a natural attention loop. When a new character interacts with the actor, they immediately enter "Active Focus" in the buffer, triggering the retrieval of their long-term history on the subsequent turn.
|
||||
---
|
||||
|
||||
### Integration into Prompts
|
||||
## 6. Prompt Formatting
|
||||
|
||||
Recalled entries are formatted into the prompt using chronological relative time grouping. System-level metrics like salience/importance scores are omitted to preserve immersion, and system UUIDs are mapped to subjective aliases.
|
||||
Retrieved ledger entries are formatted into the prompt chronologically and mapped to subjective aliases. Internal metrics (e.g., importance numbers and raw system IDs) are omitted to preserve immersion.
|
||||
|
||||
To frame the prompt naturally:
|
||||
1. Tier 1 working buffer entries are presented under the header `=== RECENT EVENTS ===`, referring strictly to events happening in the present narrative context.
|
||||
2. Tier 2 recalled entries are presented under the header `=== YOUR MEMORIES ===`, framing them simply as the entity's memories.
|
||||
* Working memory entries are injected under `=== RECENT EVENTS ===` (narrated relative to the present moment).
|
||||
* Recalled ledger entries are injected under `=== YOUR MEMORIES ===` (presented as long-term recollections).
|
||||
|
||||
```text
|
||||
=== RECENT EVENTS ===
|
||||
Moments ago
|
||||
- you spoke to Strider: "Hello there"
|
||||
- You spoke to Strider: "Hello there"
|
||||
|
||||
=== YOUR MEMORIES ===
|
||||
A couple days ago
|
||||
|
||||
Reference in New Issue
Block a user