refactor(gui): Provider Instances Configurator into its own component

This commit is contained in:
2026-07-12 08:30:23 +05:30
parent 3906259328
commit 4b71db3a34
13 changed files with 1306 additions and 772 deletions

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -10,6 +10,7 @@
"lint": "next lint"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@omnia/actor": "workspace:*",
"@omnia/architect": "workspace:*",
"@omnia/core": "workspace:*",
@@ -18,6 +19,7 @@
"@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",

View File

@@ -4,28 +4,13 @@ import { useEffect, useState, useCallback } from "react";
import {
getConfigStatus,
listProviderInstances,
createProviderInstance,
deleteProviderInstance,
setActiveProviderInstance,
getProviderMappings,
setProviderMapping,
updateProviderInstance,
getAvailableProviders,
regenerateEmbeddings,
} from "@/app/play/actions";
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ProviderInstancesConfig } from "@/components/config/ProviderInstancesConfig";
import {
Table,
TableBody,
@@ -50,61 +35,6 @@ export default function ConfigPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editProvider, setEditProvider] = useState("google-genai");
const [editKey, setEditKey] = useState("");
const [editModel, setEditModel] = useState("gemini-2.5-flash");
const [editIsActive, setEditIsActive] = useState(false);
const [editType, setEditType] = useState<"generative" | "embedding">("generative");
const [editMaxContext, setEditMaxContext] = useState<number>(32768);
useEffect(() => {
if (selectedInstanceId === null) {
setEditName("");
setEditProvider("google-genai");
setEditKey("");
setEditModel("gemini-2.5-flash");
setEditIsActive(false);
setEditType("generative");
setEditMaxContext(32768);
} else if (selectedInstanceId === "new") {
setEditName("");
const defaultProvider = "google-genai";
setEditProvider(defaultProvider);
setEditKey("");
setEditType("generative");
const pMeta = availableProviders.find((p) => p.id === defaultProvider);
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
setEditIsActive(false);
setEditMaxContext(32768);
} else {
const inst = instances.find((i) => i.id === selectedInstanceId);
if (inst) {
setEditName(inst.name);
setEditProvider(inst.providerName);
setEditKey("");
setEditType(inst.type || "generative");
const pMeta = availableProviders.find((p) => p.id === inst.providerName);
setEditModel(inst.modelName || (inst.type === "embedding" ? pMeta?.defaultEmbeddingModel : pMeta?.defaultModel) || "gemini-2.5-flash");
setEditIsActive(inst.isActive);
setEditMaxContext(inst.maxContext !== undefined && inst.maxContext !== null ? inst.maxContext : 32768);
}
}
}, [selectedInstanceId, instances, availableProviders]);
const handleProviderChange = (providerId: string) => {
setEditProvider(providerId);
const pMeta = availableProviders.find((p) => p.id === providerId);
setEditModel(editType === "embedding" ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "");
};
const handleTypeChange = (type: "generative" | "embedding") => {
setEditType(type);
const pMeta = availableProviders.find((p) => p.id === editProvider);
setEditModel(type === "embedding" ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "");
};
const loadInstances = useCallback(async () => {
const list = await listProviderInstances();
setInstances(list);
@@ -136,90 +66,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, editType === "generative" ? editMaxContext : 0);
if (editIsActive) {
await setActiveProviderInstance(created.id);
}
targetInstanceId = created.id;
setSelectedInstanceId(created.id);
} else {
if (!selectedInstanceId) return;
const inst = instances.find((i) => i.id === selectedInstanceId);
if (inst && inst.type === "embedding") {
const isMapped = mappings["embeddings"] === selectedInstanceId;
const isActive = inst.isActive && !mappings["embeddings"];
if (isMapped || isActive) {
const hasChanged = inst.providerName !== editProvider || inst.modelName !== editModel;
if (hasChanged) {
const confirmChange = window.confirm(
"You have changed the configuration of the active embedding provider. This will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?"
);
if (!confirmChange) {
setLoading(false);
return;
}
shouldRegenerate = true;
}
}
}
await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined, editModel || undefined, editType, editType === "generative" ? editMaxContext : 0);
if (editIsActive) {
await setActiveProviderInstance(selectedInstanceId);
}
}
await loadInstances();
await loadMappings();
if (shouldRegenerate && targetInstanceId && targetInstanceId !== "new") {
await regenerateEmbeddings(targetInstanceId);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
if (!confirm("Are you sure you want to delete this provider instance?")) return;
try {
setLoading(true);
setError("");
await deleteProviderInstance(selectedInstanceId);
setSelectedInstanceId(null);
await loadInstances();
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const handleUpdateMapping = async (task: string, providerInstanceId: string) => {
if (task === "embeddings" && mappings[task] !== providerInstanceId) {
const confirmChange = window.confirm(
@@ -255,195 +101,15 @@ export default function ConfigPage() {
{config && (
<div className={loading ? "opacity-60 pointer-events-none transition-opacity duration-200" : "transition-opacity duration-200"}>
<section className="mb-8 pb-6">
<h2 className="mb-3 text-lg">LLM Provider Instances</h2>
<div className="mt-4 grid min-h-[400px] grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white md:grid-cols-[30%_70%]">
{/* 30% area */}
<div className="flex flex-col border-r border-gray-200 bg-gray-50">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-4 py-4">
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">
Instances
</h3>
<Button
onClick={() => setSelectedInstanceId("new")}
size="sm"
className="bg-emerald-500 text-white hover:bg-emerald-600"
>
+ Add
</Button>
</div>
<div className="flex flex-1 flex-col overflow-y-auto">
{instances.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-gray-400">
No instances configured
</div>
) : (
instances.map((inst) => (
<div
key={inst.id}
onClick={() => setSelectedInstanceId(inst.id)}
className={`cursor-pointer border-b border-gray-200 border-l-[3px] px-4 py-4 transition-all hover:bg-gray-100 ${
selectedInstanceId === inst.id
? "border-l-blue-500 bg-blue-50"
: "border-l-transparent"
}`}
>
<div className="text-sm font-medium text-[#111]">
{inst.name}
</div>
<div className="mt-1 flex items-center justify-between text-xs text-gray-500">
<span>{inst.providerName} ({inst.type || "generative"})</span>
{inst.isActive && (
<Badge className="bg-green-100 text-green-700 hover:bg-green-100 border-green-200">
Active
</Badge>
)}
</div>
</div>
))
)}
</div>
</div>
{/* 70% area */}
<div className="flex flex-col bg-white">
{selectedInstanceId === null ? (
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center text-sm text-gray-400">
Press + to add or select an existing Instance to edit
</div>
) : (
<form onSubmit={handleSave} className="flex h-full flex-col justify-between">
<div className="flex flex-1 flex-col gap-5 p-6">
<h3 className="m-0 mb-2 text-lg font-semibold text-[#111]">
{selectedInstanceId === "new"
? "Create New Provider Instance"
: `Configure: ${editName}`}
</h3>
<div className="flex flex-col gap-1.5">
<Label htmlFor="formName">Friendly Name</Label>
<Input
id="formName"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="e.g. Gemini - Production"
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Instance Type</Label>
<Select value={editType} onValueChange={(v) => handleTypeChange(v as "generative" | "embedding")}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="generative">Generative (Chat / Text Completion)</SelectItem>
<SelectItem value="embedding">Embedding (Vector generation)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Provider Type</Label>
<Select value={editProvider} onValueChange={handleProviderChange}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableProviders.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
{editProvider && availableProviders.length > 0 && (
<span className="mt-1 block rounded border border-2 bg-muted px-3 py-2 text-xs text-muted-foreground">
{availableProviders.find((p) => p.id === editProvider)?.description}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="formKey">API Key</Label>
<Input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="formModel">Model Name</Label>
<Input
id="formModel"
value={editModel}
onChange={(e) => setEditModel(e.target.value)}
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
/>
</div>
{editType === "generative" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="formMaxContext">Max Context Length (Tokens, 0 for infinite)</Label>
<Input
id="formMaxContext"
type="number"
value={editMaxContext}
onChange={(e) => setEditMaxContext(parseInt(e.target.value) || 0)}
min={0}
placeholder="e.g. 32768"
/>
</div>
)}
<div className="mt-1 flex flex-row items-center gap-2">
<Checkbox
id="formActive"
checked={editIsActive}
onCheckedChange={(v) => setEditIsActive(v === true)}
/>
<Label htmlFor="formActive" className="cursor-pointer">
Set as Active Instance
</Label>
</div>
</div>
<div className="flex items-center justify-between border-t-2 bg-muted/50 px-6 py-4">
<div>
{selectedInstanceId !== "new" && (
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
Delete
</Button>
)}
</div>
<div>
<Button
type="submit"
disabled={loading}
>
{loading ? "Saving..." : "Save"}
</Button>
</div>
</div>
</form>
)}
</div>
</div>
</section>
<ProviderInstancesConfig
instances={instances}
availableProviders={availableProviders}
mappings={mappings}
onChanged={async () => {
await loadInstances();
await loadMappings();
}}
/>
<section className="mb-8 pb-6">
<h2 className="mb-3 text-lg">Task Provider Routing</h2>

View File

@@ -1,7 +1,16 @@
"use client";
import type { ReactNode } from "react";
import { Archivo_Black, Space_Grotesk } from "next/font/google";
import { NavBar } from "@/components/nav/NavBar";
import Link from "next/link";
import { usePathname } from "next/navigation";
import "./globals.css";
import {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuLink,
} from "@/components/ui/navigation-menu";
const archivoBlack = Archivo_Black({
subsets: ["latin"],
@@ -16,16 +25,34 @@ const spaceGrotesk = Space_Grotesk({
display: "swap",
});
export const metadata = {
title: "Omnia",
description: "Omnia Narrative Simulation Engine",
};
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={`${archivoBlack.variable} ${spaceGrotesk.variable} min-h-dvh bg-background text-foreground font-sans`}>
<NavBar />
<nav className="flex items-center gap-4 border-b-2 px-4 py-3">
<Link href="/" className="font-head text-base font-bold no-underline text-foreground">
Omnia
</Link>
<NavigationMenu viewport={false}>
<NavigationMenuList>
{links.map((link) => (
<NavigationMenuItem key={link.href}>
<NavigationMenuLink asChild active={pathname === link.href}>
<Link href={link.href}>{link.label}</Link>
</NavigationMenuLink>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
</nav>
{children}
</body>
</html>

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

View File

@@ -1,35 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
const links = [
{ href: "/", label: "Home" },
{ href: "/play", label: "Play" },
{ href: "/config", label: "Config" },
];
export function NavBar() {
const pathname = usePathname();
return (
<nav className="flex items-center gap-4 border-b-2 px-4 py-3">
<Button variant="link" asChild className="font-head text-base font-bold no-underline">
<Link href="/">Omnia</Link>
</Button>
<div className="flex gap-1">
{links.map((link) => (
<Button
key={link.href}
variant={pathname === link.href ? "default" : "ghost"}
size="sm"
asChild
>
<Link href={link.href}>{link.label}</Link>
</Button>
))}
</div>
</nav>
);
}

View File

@@ -17,19 +17,7 @@ import type { ModelProviderInstance } from "@omnia/llm";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
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";
import { PromptModal } from "./PromptModal";
function IntentTag({
intent,
@@ -69,313 +57,6 @@ function IntentTag({
);
}
function PromptModal({
entry,
onClose,
}: {
entry: SimSnapshot["log"][number];
onClose: () => void;
}) {
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>
);
}
function formatSimTime(isoString: string) {
try {

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

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

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

View File

@@ -1,21 +1,14 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
const Select = SelectPrimitive.Root
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
@@ -25,10 +18,14 @@ function SelectGroup({
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
@@ -36,7 +33,7 @@ function SelectTrigger({
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
@@ -50,9 +47,11 @@ function SelectTrigger({
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
@@ -60,32 +59,38 @@ function SelectTrigger({
function SelectContent({
className,
children,
position = "item-aligned",
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
{...props}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
<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-2 bg-popover text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
@@ -93,9 +98,9 @@ function SelectContent({
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.Label
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
@@ -107,7 +112,7 @@ function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
@@ -117,12 +122,16 @@ function SelectItem({
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
@@ -130,7 +139,7 @@ function SelectItem({
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
@@ -143,36 +152,36 @@ function SelectSeparator({
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpButton
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownButton
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.ScrollDownArrow>
)
}

View File

@@ -1,28 +1,31 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
data-slot="separator"
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
}
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }