mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
refactor(gui): Provider Instances Configurator into its own component
This commit is contained in:
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/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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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,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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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,
|
||||
}
|
||||
201
apps/gui/src/components/ui/item.tsx
Normal file
201
apps/gui/src/components/ui/item.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex w-full flex-wrap items-center rounded border-2 text-sm transition-colors duration-100 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary [a]:transition-colors [a]:hover:bg-accent",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent",
|
||||
outline: "border-border",
|
||||
muted: "border-transparent bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-2.5 px-3 py-2.5",
|
||||
sm: "gap-2.5 px-3 py-2.5",
|
||||
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "item",
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
@@ -1,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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user