feat(gui): Duplicate existing model instances (#32)

(fixes Allow Instance Duplication
Fixes #32)
This commit is contained in:
2026-07-20 11:41:28 +05:30
parent a8b2d425a1
commit 264a5ea0fe
9 changed files with 222 additions and 123 deletions

View File

@@ -271,6 +271,12 @@ export async function createProviderInstance(
);
}
export async function duplicateProviderInstance(
id: string,
): Promise<ModelProviderInstance | null> {
return ProviderManager.duplicate(id);
}
export async function deleteProviderInstance(id: string): Promise<void> {
ProviderManager.delete(id);
}

View File

@@ -7,6 +7,7 @@ import {
setActiveProviderInstance,
regenerateEmbeddings,
deleteProviderInstance,
duplicateProviderInstance,
fetchAvailableModels,
fetchAvailableModelsForInstance,
} from "@/app/actions";
@@ -338,6 +339,23 @@ export function ProviderInstancesConfig({
}
};
const handleDuplicate = async () => {
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
try {
setLoading(true);
setError("");
const duplicated = await duplicateProviderInstance(selectedInstanceId);
if (duplicated) {
setSelectedInstanceId(duplicated.id);
await onChanged();
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
return (
<section className="mb-8 flex min-h-[600px] flex-col">
{error && (
@@ -646,16 +664,26 @@ export function ProviderInstancesConfig({
</div>
<div className="flex flex-row items-center justify-between gap-2">
<div>
<div className="flex flex-row gap-2">
{selectedInstanceId !== "new" && (
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
Delete
</Button>
<>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
Delete
</Button>
<Button
type="button"
variant="secondary"
onClick={handleDuplicate}
disabled={loading}
>
Duplicate
</Button>
</>
)}
</div>
<Button type="submit" disabled={loading}>

View File

@@ -25,15 +25,8 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Bot, UserRound } from "lucide-react";
export function HomeView() {
const router = useRouter();
@@ -192,7 +185,7 @@ export function HomeView() {
return (
<div className="flex-1 overflow-y-auto w-full relative">
<div className="relative z-10 mx-auto max-w-[1024px] px-10 py-12">
<div className="relative z-10 mx-auto max-w-5xl px-10 py-12">
<div className="animate-fade-in">
{/* Centered Big Logo */}
<div className="flex flex-col items-center justify-center mb-10 pt-4">
@@ -237,7 +230,7 @@ export function HomeView() {
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all flex flex-col justify-between h-[148px]"
className="shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all flex flex-col justify-between h-37"
>
<div className="space-y-3">
<Skeleton className="h-5 w-2/3" />
@@ -264,7 +257,7 @@ export function HomeView() {
? undefined
: () => handleResume(s.id)
}
className={`flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all relative group ${
className={`shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all relative group ${
providerInstances.length === 0
? "opacity-50 cursor-not-allowed filter grayscale"
: "cursor-pointer hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm"
@@ -310,8 +303,8 @@ export function HomeView() {
</h2>
{loadingData ? (
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
<Link href="/builder" className="no-underline flex-shrink-0">
<div className="w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
<Link href="/builder" className="no-underline shrink-0">
<div className="w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-37">
<div>
<strong className="text-body-md text-foreground block mb-1">
Build a scenario
@@ -331,7 +324,7 @@ export function HomeView() {
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex-shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm flex flex-col justify-between h-[148px]"
className="shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm flex flex-col justify-between h-37"
>
<div className="space-y-3">
<Skeleton className="h-5 w-3/4" />
@@ -345,8 +338,8 @@ export function HomeView() {
</div>
) : (
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
<Link href="/builder" className="no-underline flex-shrink-0">
<div className="w-64 border border-primary bg-primary p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
<Link href="/builder" className="no-underline shrink-0">
<div className="w-64 border border-primary bg-primary p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-37">
<div>
<strong className="text-body-md text-surface block mb-1">
Build a scenario
@@ -382,7 +375,7 @@ export function HomeView() {
open={!!scenarioForModal}
onOpenChange={(open) => !open && setScenarioForModal(null)}
>
<DialogContent className="max-w-[400px]">
<DialogContent className="max-w-100">
<DialogHeader className="border-b border-dotted border-border/20 pb-4 mb-2">
<DialogTitle>Start Scenario</DialogTitle>
<DialogDescription>
@@ -412,28 +405,37 @@ export function HomeView() {
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Simulation Mode / Play as
</label>
<Select
value={selectedEntityForModal}
onValueChange={(val) =>
setSelectedEntityForModal(val || "")
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="-- Run Fully Autonomously --" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="">
-- Run Fully Autonomously --
</SelectItem>
{modalEntities.map((ent) => (
<SelectItem key={ent.id} value={ent.id}>
Play as {ent.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={
selectedEntityForModal === ""
? "default"
: "outline"
}
onClick={() => setSelectedEntityForModal("")}
className="flex-1 min-w-40"
>
<Bot />
Run Fully Autonomously
</Button>
{modalEntities.map((ent) => (
<Button
key={ent.id}
type="button"
variant={
selectedEntityForModal === ent.id
? "default"
: "outline"
}
onClick={() => setSelectedEntityForModal(ent.id)}
className="flex-1 min-w-40"
>
<UserRound />
Play as {ent.name}
</Button>
))}
</div>
</div>
</div>
)}

View File

@@ -33,7 +33,7 @@ export function HandoffModal({ entry, onClose }: HandoffModalProps) {
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-[800px] sm:max-w-[800px] h-[85vh] overflow-hidden flex flex-col p-0 gap-0 border-2">
<DialogContent className="max-w-200 sm:max-w-200 h-[85vh] overflow-hidden flex flex-col p-0 gap-0 border-2">
<DialogHeader className="px-6 pt-5 pb-4 border-b">
<DialogTitle className="text-lg font-head tracking-wide text-primary flex items-center justify-between">
<span>Memory Handoff Details &mdash; {entry.entityName}</span>
@@ -240,7 +240,7 @@ export function HandoffModal({ entry, onClose }: HandoffModalProps) {
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Raw JSON Output
</h4>
<pre className="p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border flex-1 overflow-y-auto max-h-[500px]">
<pre className="p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border flex-1 overflow-y-auto max-h-125">
{handoffResult
? JSON.stringify(handoffResult, null, 2)
: "No JSON Output recorded."}

View File

@@ -8,6 +8,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { hydrate } from "@omnia/voice";
import { Brain, PersonStanding, Speech } from "lucide-react";
import {
Alert,
AlertAction,
@@ -26,19 +27,14 @@ function IntentTag({
playerId: string;
entities: SimSnapshot["entities"];
}) {
const labels: Record<string, string> = {
monologue: "thought",
thought: "thought",
dialogue: "dialogue",
action: "action",
const icons: Record<string, React.ReactNode> = {
monologue: <Brain className="size-4" />,
thought: <Brain className="size-4" />,
dialogue: <Speech className="size-4" />,
action: <PersonStanding className="size-4" />,
};
const label = labels[intent.type] || intent.type;
let outcome = "";
if (intent.type === "action") {
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
}
const icon = icons[intent.type] || null;
const viewerAliasesMap = new Map<string, string>();
if (entities) {
@@ -69,12 +65,28 @@ function IntentTag({
</span>
) : null;
const invalidActionReason =
intent.type === "action" && !intent.isValid && intent.reason
? ` (${intent.reason})`
: "";
const invalidActionClassName =
intent.type === "action" && !intent.isValid ? " text-destructive" : "";
return (
<span className="text-sm text-muted-foreground">
[{label}] &ldquo;{textToDisplay}&rdquo;{modifiersStr}
{outcome}
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
</span>
<>
<span className="text-sm text-muted-foreground inline-flex items-start gap-1">
<span className="mt-0.5 inline-flex shrink-0 items-center justify-center">
{icon}
</span>
<span className={invalidActionClassName}>
&ldquo;{textToDisplay}&rdquo;{modifiersStr}
{invalidActionReason}
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
</span>
</span>
<br />
</>
);
}
@@ -105,45 +117,47 @@ function LogEntryCard({
entry: SimSnapshot["log"][number];
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
isPlayerCard: boolean;
playerAliases: Record<string, string>;
playerAlciases: Record<string, string>;
playerId: string;
entities: SimSnapshot["entities"];
}) {
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
return (
<div
className={cn(
"border p-4 shadow-[2px_2px_0_0_var(--border)]",
isPlayerCard
? "border-primary bg-surface-container-low"
: "border-border/30 bg-card",
)}
>
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
<div className="flex items-center gap-2">
<strong className="text-body-md font-bold text-foreground">
{entry.entityName}
</strong>
<span className="text-xs text-muted-foreground font-mono">
Turn {entry.turn} &middot; {formatSimTime(entry.timestamp)}
</span>
</div>
{showMenu && (
<Button
variant="ghost"
size="icon"
onClick={() => onShowPrompt(entry)}
title="View Raw Prompts & Token Usage"
>
</Button>
<div className={cn("mb-2")}>
<div
className={cn(
"border p-4 shadow-sm",
isPlayerCard
? "border-primary bg-surface-container-low"
: "border-border/30 bg-card",
)}
>
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
<div className="flex items-center gap-2">
<strong className="text-body-md font-bold text-foreground">
{entry.entityName}
</strong>
<span className="text-xs text-muted-foreground font-mono">
Turn {entry.turn} &middot; {formatSimTime(entry.timestamp)}
</span>
</div>
{showMenu && (
<Button
variant="ghost"
size="icon"
onClick={() => onShowPrompt(entry)}
title="View Raw Prompts & Token Usage"
>
</Button>
)}
</div>
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
{entry.narrativeProse}
</div>
</div>
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
{entry.narrativeProse}
</div>
<div className="flex flex-col gap-1.5 mt-2 border-t border-dotted border-border/10 pt-2">
<div className={cn("mt-3 ms-3")}>
{entry.intents.map((intent, i) => (
<IntentTag
key={i}
@@ -189,7 +203,7 @@ export function InteractView({
<>
{/* Scrollable Center Viewport */}
<main className="flex-1 overflow-y-auto px-8 py-6">
<div className="flex flex-col gap-4 max-w-[800px] mx-auto pb-12">
<div className="flex flex-col gap-4 max-w-200 mx-auto pb-12">
{snapshot.log.map((entry, i) => {
if (entry.isHandoff) {
return (
@@ -245,14 +259,14 @@ export function InteractView({
{/* Sticky Chat / Interaction Input Footer */}
<footer className="sticky bottom-0 bg-background/95 backdrop-blur-xs border-t border-dotted border-border/20 px-8 py-4 z-10 shrink-0">
<div className="max-w-[800px] mx-auto">
<div className="max-w-200 mx-auto">
{snapshot.status === "waiting_player" && snapshot.waitingEntity ? (
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
<div className="border border-border/30 bg-card p-4 shadow-sm">
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
<strong>Your context as {snapshot.waitingEntity.name}</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-37.5 overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
@@ -271,7 +285,7 @@ export function InteractView({
</form>
</div>
) : snapshot.status === "done" || snapshot.status === "error" ? (
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-sm">
<span className="text-sm font-mono text-muted-foreground">
{snapshot.status === "error"
? "Simulation finished with an error."

View File

@@ -52,21 +52,26 @@ export function PromptAnalyzer({
maxContext > 0 ? (inputTokens / maxContext) * 100 : 0;
const isAbsolute = maxContext > 0 && usagePctOfContext >= 20;
const getColorClass = (type: string) => {
switch (type) {
case "system":
return "bg-blue-500";
case "world":
return "bg-emerald-500";
case "events":
return "bg-purple-500";
case "memories":
return "bg-pink-500";
case "input":
return "bg-amber-500";
default:
return "bg-slate-500";
}
const legentColors = [
"bg-blue-500",
"bg-emerald-500",
"bg-purple-500",
"bg-orange-500",
"bg-pink-500",
"bg-amber-500",
"bg-teal-500",
"bg-cyan-500",
"bg-indigo-500",
"bg-violet-500",
"bg-rose-500",
"bg-sky-500",
"bg-lime-500",
"bg-fuchsia-500",
"bg-red-500",
];
const getColorClass = (index: number) => {
return legentColors[index % legentColors.length];
};
return (
@@ -106,7 +111,7 @@ export function PromptAnalyzer({
return (
<div
key={idx}
className={`h-full transition-all duration-300 ${getColorClass(item.type)}`}
className={`h-full transition-all duration-300 ${getColorClass(idx)}`}
style={{ width: `${widthPct}%` }}
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
/>
@@ -129,7 +134,7 @@ export function PromptAnalyzer({
<AccordionTrigger className="text-sm py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<span
className={`inline-block w-2.5 h-2.5 rounded-sm ${getColorClass(item.type)}`}
className={`inline-block w-2.5 h-2.5 rounded-sm ${getColorClass(idx)}`}
/>
<span>{item.label}:</span>
<span className="text-muted-foreground font-normal">
@@ -139,7 +144,7 @@ export function PromptAnalyzer({
</div>
</AccordionTrigger>
<AccordionContent>
<pre className="m-0 p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border max-h-[300px] overflow-y-auto">
<pre className="m-0 p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border max-h-75 overflow-y-auto">
{item.content}
</pre>
</AccordionContent>
@@ -161,7 +166,7 @@ export function PromptAnalyzer({
)}
</div>
<div className="rounded border-2">
<pre className="m-0 p-3 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground max-h-[250px] overflow-y-auto">
<pre className="m-0 p-3 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground max-h-62.5 overflow-y-auto">
{outputText}
</pre>
</div>

View File

@@ -136,7 +136,7 @@ export function PromptModal({ entry, onClose }: PromptModalProps) {
}
modelName={validatorCall.usage?.modelName}
providerInstanceName={validatorCall.usage?.providerInstanceName}
outputLabel={`LLM Output (Validation for: "${validatorCall.intentContent}")`}
outputLabel={`LLM Output`}
outputText={JSON.stringify(validatorCall.response, null, 2)}
outputTokens={validatorCall.usage?.outputTokens}
/>

View File

@@ -21,7 +21,7 @@ export function ScenarioCard({
return (
<div
onClick={disabled ? undefined : onClick}
className={`flex-shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm transition-all ${
className={`shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm transition-all ${
disabled
? "opacity-50 cursor-not-allowed filter grayscale"
: "cursor-pointer hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm"

View File

@@ -62,6 +62,50 @@ export class ProviderManager {
};
}
static duplicate(id: string): ModelProviderInstance | null {
const db = getDb();
const source = db
.prepare("SELECT * FROM provider_instances WHERE id = ?")
.get(id) as DbRow | undefined;
if (!source) return null;
const newId = "provider-" + Date.now();
const newName = `${source.name} (Copy)`;
const activeCount = db
.prepare(
"SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?",
)
.get(source.type) as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
db.prepare(
`INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext, endpointUrl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
newId,
newName,
source.providerName,
source.apiKey,
isActive,
source.modelName,
source.type,
source.maxContext,
source.endpointUrl,
);
return {
id: newId,
name: newName,
providerName: source.providerName,
apiKey: source.apiKey,
isActive: isActive === 1,
modelName: source.modelName || undefined,
type: source.type as "generative" | "embedding",
maxContext: source.maxContext,
endpointUrl: source.endpointUrl || undefined,
};
}
static delete(id: string): void {
const db = getDb();
const provider = db