3 Commits

Author SHA1 Message Date
814f216ea7 feat(gui): Minor UI Improvements to InteractView 2026-07-20 16:16:27 +05:30
7f74617077 chore(ci): Bump pnpm to 11.15.1 2026-07-20 11:47:36 +05:30
264a5ea0fe feat(gui): Duplicate existing model instances (#32)
(fixes Allow Instance Duplication
Fixes #32)
2026-07-20 11:41:28 +05:30
25 changed files with 553 additions and 261 deletions

View File

@@ -5,19 +5,19 @@ on:
branches: [master]
tags: ["v*"]
paths-ignore:
- 'web/docs/**'
- 'web/landing/**'
- 'content/**'
- 'README.md'
- 'CONTRIBUTING.md'
- '.github/workflows/deploy-docs.yml'
- "web/docs/**"
- "web/landing/**"
- "content/**"
- "README.md"
- "CONTRIBUTING.md"
- ".github/workflows/deploy-docs.yml"
pull_request:
branches: [master]
paths-ignore:
- 'web/docs/**'
- 'web/landing/**'
- 'content/**'
- '*.md'
- "web/docs/**"
- "web/landing/**"
- "content/**"
- "*.md"
workflow_dispatch:
jobs:

View File

@@ -1,7 +1,7 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm" \
PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
FROM base AS build-deps

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

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>
@@ -128,7 +128,13 @@ export function HandoffModal({ entry, onClose }: HandoffModalProps) {
</h3>
{chunks.map(
(
chunk: { content: string; importance: number },
chunk: {
content: string;
importance: number;
quotes?: string[];
retainInBuffer?: boolean;
involvedEntityIds?: string[];
},
index: number,
) => (
<div
@@ -240,7 +246,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

@@ -0,0 +1,188 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import type { SimSnapshot } from "@/lib/simulation-types";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { formatSimDate, formatSimTimeHM, getClockIcon } from "@/lib/utils";
interface InteractDockProps {
snapshot: SimSnapshot;
loading: boolean;
playerInput: string;
setPlayerInput: (value: string) => void;
onSubmitAction: (e: React.FormEvent<HTMLFormElement>) => void;
onPauseRequested: () => void;
onResumeRequested: () => void;
onStopRequested: () => void;
}
export function InteractDock({
snapshot,
loading,
playerInput,
setPlayerInput,
onSubmitAction,
onPauseRequested,
onResumeRequested,
onStopRequested,
}: InteractDockProps) {
const router = useRouter();
return (
<footer className="sticky bottom-0 px-8 py-4 z-20 shrink-0 bg-background/70 backdrop-blur-md border-t border-border/10">
<div className="max-w-200 mx-auto relative">
{snapshot.status === "running" ||
snapshot.status === "waiting_player" ? (
<div className="border border-border/30 bg-card/85 p-4 shadow-sm backdrop-blur-sm relative z-10">
{snapshot.status === "waiting_player" && snapshot.waitingEntity && (
<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-37.5 overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
)}
<div className="flex flex-col gap-3">
<div className="flex justify-between items-center gap-2">
{snapshot.worldTime ? (
<span className="text-base font-mono text-muted-foreground font-medium">
<img
src="/calendar_logo.png"
alt="Date"
className="w-7 h-7 inline-block align-middle mr-1 opacity-70"
/>
{formatSimDate(snapshot.worldTime)}
<img
src={getClockIcon(snapshot.worldTime)}
alt="Time"
className="w-7 h-7 inline-block align-middle ml-2 mr-1 opacity-70"
/>
{formatSimTimeHM(snapshot.worldTime)}
{snapshot.currentLocation && (
<>
<img
src="/map_pointer_icon.png"
alt="Location"
className="w-7 h-7 inline-block align-middle ml-2 mr-1 opacity-70"
/>
{snapshot.currentLocation}
</>
)}
</span>
) : (
<div />
)}
<div className="flex gap-2">
{loading ? (
<Button
type="button"
variant="secondary"
size="sm"
onClick={onPauseRequested}
>
Pause
</Button>
) : (
snapshot.status === "running" && (
<Button
type="button"
variant="secondary"
size="sm"
onClick={onResumeRequested}
>
Resume
</Button>
)
)}
<Button
type="button"
variant="destructive"
size="sm"
onClick={onStopRequested}
>
Stop
</Button>
</div>
</div>
{snapshot.status === "waiting_player" &&
snapshot.waitingEntity && (
<form
onSubmit={onSubmitAction}
className="flex flex-row gap-2 items-stretch"
>
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
className="flex-1 min-h-26"
disabled={loading}
/>
<Button
type="submit"
disabled={loading || !playerInput.trim()}
className="w-32 shrink-0 self-stretch"
>
{loading ? "Processing..." : "Submit"}
</Button>
</form>
)}
</div>
</div>
) : snapshot.status === "done" || snapshot.status === "error" ? (
<div className="flex justify-between items-center bg-card/85 border border-border/30 p-4 shadow-sm backdrop-blur-sm">
<div className="flex flex-col gap-1">
<span className="text-sm font-mono text-muted-foreground">
{snapshot.status === "error"
? "Simulation finished with an error."
: "Simulation complete."}
</span>
{snapshot.worldTime && (
<div className="flex items-center gap-2 text-base font-mono text-muted-foreground">
<img
src="/calendar_logo.png"
alt="Date"
className="w-7 h-7 opacity-70"
/>
<span>{formatSimDate(snapshot.worldTime)}</span>
<img
src={getClockIcon(snapshot.worldTime)}
alt="Time"
className="w-7 h-7 opacity-70"
/>
<span>{formatSimTimeHM(snapshot.worldTime)}</span>
{snapshot.currentLocation && (
<>
<img
src="/map_pointer_icon.png"
alt="Location"
className="w-7 h-7 opacity-70"
/>
<span>{snapshot.currentLocation}</span>
</>
)}
</div>
)}
</div>
<Button
onClick={() => {
router.push("/");
}}
size="sm"
>
{snapshot.status === "error"
? "Back to Dashboard"
: "New Simulation"}
</Button>
</div>
) : null}
</div>
</footer>
);
}

View File

@@ -1,19 +1,19 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import type { SimSnapshot } from "@/lib/simulation-types";
import { Button } from "@/components/ui/button";
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,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { InteractDock } from "./InteractDock";
function IntentTag({
intent,
@@ -26,19 +26,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 +64,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 />
</>
);
}
@@ -112,38 +123,40 @@ function LogEntryCard({
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}
@@ -168,6 +181,9 @@ interface InteractViewProps {
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
onShowHandoff: (entry: SimSnapshot["log"][number]) => void;
logEndRef: React.RefObject<HTMLDivElement | null>;
onPauseRequested: () => void;
onResumeRequested: () => void;
onStopRequested: () => void;
}
export function InteractView({
@@ -180,16 +196,17 @@ export function InteractView({
onShowPrompt,
onShowHandoff,
logEndRef,
onPauseRequested,
onResumeRequested,
onStopRequested,
}: InteractViewProps) {
const router = useRouter();
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
return (
<>
{/* 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-44 md:pb-52">
{snapshot.log.map((entry, i) => {
if (entry.isHandoff) {
return (
@@ -243,54 +260,16 @@ export function InteractView({
</div>
</main>
{/* 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">
{snapshot.status === "waiting_player" && snapshot.waitingEntity ? (
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
<strong>Your context as {snapshot.waitingEntity.name}</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
<form onSubmit={onSubmitAction} className="flex flex-col gap-2">
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
disabled={loading}
/>
<Button type="submit" disabled={loading || !playerInput.trim()}>
{loading ? "Processing..." : "Submit Action"}
</Button>
</form>
</div>
) : snapshot.status === "done" || snapshot.status === "error" ? (
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
<span className="text-sm font-mono text-muted-foreground">
{snapshot.status === "error"
? "Simulation finished with an error."
: "Simulation complete."}
</span>
<Button
onClick={() => {
router.push("/");
}}
size="sm"
>
{snapshot.status === "error"
? "Back to Dashboard"
: "New Simulation"}
</Button>
</div>
) : null}
</div>
</footer>
<InteractDock
snapshot={snapshot}
loading={loading}
playerInput={playerInput}
setPlayerInput={setPlayerInput}
onSubmitAction={onSubmitAction}
onPauseRequested={onPauseRequested}
onResumeRequested={onResumeRequested}
onStopRequested={onStopRequested}
/>
</>
);
}

View File

@@ -319,43 +319,6 @@ export function PlayView() {
</p>
</div>
</div>
{/* Simulation Global Controls */}
<div className="flex gap-2 shrink-0">
{snapshot.status !== "done" && snapshot.status !== "error" && (
<>
{snapshot.status === "running" &&
(loading ? (
<Button
variant="secondary"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
}}
>
Pause
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => runSteps(snapshot.id)}
>
Resume
</Button>
))}
<Button
variant="destructive"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
router.push("/");
}}
>
Stop
</Button>
</>
)}
</div>
</div>
<div className="flex items-center justify-between text-xs font-mono mt-1 pt-1.5 border-t border-border/10">
<span className="text-muted-foreground">
@@ -390,6 +353,14 @@ export function PlayView() {
onShowPrompt={setSelectedEntryForModal}
onShowHandoff={setSelectedHandoffForModal}
logEndRef={logEndRef}
onPauseRequested={() => {
pauseRequestedRef.current = true;
}}
onResumeRequested={() => runSteps(snapshot.id)}
onStopRequested={() => {
pauseRequestedRef.current = true;
router.push("/");
}}
/>
) : (
<ManageView snapshot={snapshot} onRename={setSnapshot} />

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

@@ -42,6 +42,9 @@ export interface HandoffResult {
chunks: {
content: string;
importance: number;
quotes?: string[];
retainInBuffer?: boolean;
involvedEntityIds?: string[];
}[];
}
@@ -103,4 +106,6 @@ export interface SimSnapshot {
entityIndex: number;
waitingEntity?: WaitingContext;
error?: string;
worldTime?: string;
currentLocation?: string;
}

View File

@@ -63,7 +63,7 @@ export async function runHandoffResolution(session: SimSession): Promise<void> {
}
: undefined,
usage: lastCall?.usage,
handoffResult: lastResult?.response || lastCall?.response,
handoffResult: (lastResult?.response || lastCall?.response) as any,
});
}
}

View File

@@ -483,6 +483,23 @@ export class SimulationManager {
};
});
// Get current location from the waiting entity
let currentLocation: string | undefined;
if (
worldState &&
session.entityIndex >= 0 &&
session.entityIndex < session.entities.length
) {
const currentEntityInfo = session.entities[session.entityIndex];
const actualEntity = worldState.getEntity(currentEntityInfo.id);
if (actualEntity?.locationId) {
const location = worldState.getLocation(actualEntity.locationId);
if (location) {
currentLocation = location.id;
}
}
}
return {
id: session.worldInstanceId,
status: session.status,
@@ -495,6 +512,8 @@ export class SimulationManager {
entityIndex: session.entityIndex,
waitingEntity: session.waitingEntity,
error: session.error,
worldTime: worldState?.clock.get().toISOString(),
currentLocation,
};
}
}

View File

@@ -10,6 +10,7 @@ import type {
IntentInfo,
LogEntry,
WaitingContext,
ValidatorCall,
} from "../simulation-types";
// ---------------------------------------------------------------------------
@@ -88,7 +89,7 @@ async function processIntents(
prompt: {
systemPrompt: lastResult.systemPrompt || "",
userContext: lastResult.userContext || "",
components: lastResult.promptComponents,
components: lastResult.components,
},
response: {
isValid: outcome.isValid,

View File

@@ -4,3 +4,41 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatSimDate(isoString: string): string {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
} catch {
return isoString;
}
}
export function formatSimTimeHM(isoString: string): string {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
const hh = String(d.getUTCHours());
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
} catch {
return isoString;
}
}
export function getClockIcon(isoString: string): string {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return "/clock_day_icon.png";
const hour = d.getUTCHours();
return hour >= 6 && hour < 18
? "/clock_day_icon.png"
: "/clock_night_icon.png";
} catch {
return "/clock_day_icon.png";
}
}

View File

@@ -28,7 +28,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.13.0",
"version": "11.15.1",
"onFail": "download"
}
},

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

76
pnpm-lock.yaml generated
View File

@@ -6,74 +6,74 @@ importers:
configDependencies: {}
packageManagerDependencies:
"@pnpm/exe":
specifier: 11.13.0
version: 11.13.0
specifier: 11.15.1
version: 11.15.1
pnpm:
specifier: 11.13.0
version: 11.13.0
specifier: 11.15.1
version: 11.15.1
packages:
"@pnpm/exe@11.13.0":
"@pnpm/exe@11.15.1":
resolution:
{
integrity: sha512-aXsm4VW1awRuh4X4G4fl+1xdV+L9DElKrgc4Yq/3zgGQQ12ATj1n3MMeosfa68aX/i7E9VyWT42adDSg8+kEFQ==,
integrity: sha512-+e+DQrUPtDqH+9blSre23l7od3P3lRy43HN8TOKHNvbtuTNDRQtqQ0seHk/kn7iJl+ZSKBALFrQXPvbItNgRwA==,
}
hasBin: true
"@pnpm/linux-arm64@11.13.0":
"@pnpm/linux-arm64@11.15.1":
resolution:
{
integrity: sha512-86ri9P/i3Enbv0NBAEtz27lBHHwCJqsC7YxHX56+jXPyFobGbsDVR5OUyoCCVVNz/Phs2O7c9SaDVdqKPzUFCA==,
integrity: sha512-8QOTdBeklXcE/AY4cAR2RJ4809I38kLxtInAYW4sVDfQoWYjmEIoU/ZxpHsk78KM8VU0BehtOB+iZsMxSCyiLQ==,
}
cpu: [arm64]
os: [linux]
"@pnpm/linux-x64@11.13.0":
"@pnpm/linux-x64@11.15.1":
resolution:
{
integrity: sha512-ZXvUMP5ew1WZllTq1f7Tm3l6sF5LK5sMesfdu9J6uxsznK/3tTi6edUeiNN6dA22mAHhjvrzVj+MeQ6X3DG0tA==,
integrity: sha512-hbhuC2Rh5Zw9ERUsQhOSLPnwfWSRTU6phtuFdK2I2raYCXa7v5C4i+XKTH+sKLPG5vJiKPyi4Hd62UECHZoy2w==,
}
cpu: [x64]
os: [linux]
"@pnpm/linuxstatic-arm64@11.13.0":
"@pnpm/linuxstatic-arm64@11.15.1":
resolution:
{
integrity: sha512-elwRj0sE3W54gDECgbHu2/TerpZsHXRmLSXfzuIvReumGuOYk5zlUhD5dJqzp0B1ktetVNU4Rb1keENz5/Gb/Q==,
integrity: sha512-xEL9kDvLXnNYAgQak9tfIhO/BaBf1R/1+/dzzntuquzSqrEaPs0x0P4qMmwq02hVBDfAGA2MAD4n9kmc6Q//tw==,
}
cpu: [arm64]
os: [linux]
libc: [musl]
"@pnpm/linuxstatic-x64@11.13.0":
"@pnpm/linuxstatic-x64@11.15.1":
resolution:
{
integrity: sha512-PQyfFMRA6/uYB+1vqx0wSXfOirC5CUOKRlTjXNgI1q/R1Ug/NMDfKOr52qbWFllcG9pIHKV8hZTo4WyBUjS1mA==,
integrity: sha512-SLJ4neJx649ri4qHdseQN6SnAy6nNFnsLGi7g+EH32cVux4aWX7tjMNTPHrUKJ6Oc7C6oPGXUpLmemEvAwCPOg==,
}
cpu: [x64]
os: [linux]
libc: [musl]
"@pnpm/macos-arm64@11.13.0":
"@pnpm/macos-arm64@11.15.1":
resolution:
{
integrity: sha512-qWf2wbvESMICLJcVfWNh4pwIsr2oK6yFwbuv1uHi0UY3OnWNt0+POmhqWtYWtDj+EPWM3pSfEAc+KFQAiaBCvg==,
integrity: sha512-GbIUQcDZgO/i1Ql38Pk269KNYpEbSCWYcSXBHgT62y8qVTrpEbmi+TKNaLVYuUkK0k/hMibySRa5GUs7/vnNyw==,
}
cpu: [arm64]
os: [darwin]
"@pnpm/win-arm64@11.13.0":
"@pnpm/win-arm64@11.15.1":
resolution:
{
integrity: sha512-ZvbGFdjU3cX32+/sHOhV9VkkbBucJ9QyW/F6VSsqgkvmTZTXlQglYp4K1KBbKWxYKz4i6M9p5PStI6yYB+y0jA==,
integrity: sha512-pLlcpdtoaHJxVfhWahoQg5U7Mv9b6/X/8og+dY1GX1GvsZU3Wf7Y+LlA1sfxqoPcohtQdo6MdJkWiOZ3nIFMCQ==,
}
cpu: [arm64]
os: [win32]
"@pnpm/win-x64@11.13.0":
"@pnpm/win-x64@11.15.1":
resolution:
{
integrity: sha512-5KX2lMFbXZNvKC+gLftR5FiU25j/jMp24Zi9JB01T3r42uxrHt12GQP7CNkNjxCXdi8jioUB7e3iT1eINS8qVQ==,
integrity: sha512-gAvQjBOrNb8zHXRQawBfZRtjDWEFe6p9aSBi8uuMtWB4XAaj0+YuzstsR93llZ/UgP67oTkbkCOITBLayRVGGQ==,
}
cpu: [x64]
os: [win32]
@@ -168,47 +168,47 @@ packages:
}
engines: { node: ">=8" }
pnpm@11.13.0:
pnpm@11.15.1:
resolution:
{
integrity: sha512-iNlHJNjy5sGGdEpVhMblnsrIaex7oV6ctM1ijo3HBmggskgdjuO1HqjaMjpzeAaKpYxVajcg0yt8IKBR0Ig2Og==,
integrity: sha512-gTULB+U8lTigLx8jA7QpD6LXvgTlbiqXDEzEtBfcdh3hlu2r1J1Vx9yVgNuBAHxEFD5OPX5GKzAA0jwlUSLQZQ==,
}
engines: { node: ">=22.13" }
hasBin: true
snapshots:
"@pnpm/exe@11.13.0":
"@pnpm/exe@11.15.1":
dependencies:
"@reflink/reflink": 0.1.19
detect-libc: 2.1.2
optionalDependencies:
"@pnpm/linux-arm64": 11.13.0
"@pnpm/linux-x64": 11.13.0
"@pnpm/linuxstatic-arm64": 11.13.0
"@pnpm/linuxstatic-x64": 11.13.0
"@pnpm/macos-arm64": 11.13.0
"@pnpm/win-arm64": 11.13.0
"@pnpm/win-x64": 11.13.0
"@pnpm/linux-arm64": 11.15.1
"@pnpm/linux-x64": 11.15.1
"@pnpm/linuxstatic-arm64": 11.15.1
"@pnpm/linuxstatic-x64": 11.15.1
"@pnpm/macos-arm64": 11.15.1
"@pnpm/win-arm64": 11.15.1
"@pnpm/win-x64": 11.15.1
"@pnpm/linux-arm64@11.13.0":
"@pnpm/linux-arm64@11.15.1":
optional: true
"@pnpm/linux-x64@11.13.0":
"@pnpm/linux-x64@11.15.1":
optional: true
"@pnpm/linuxstatic-arm64@11.13.0":
"@pnpm/linuxstatic-arm64@11.15.1":
optional: true
"@pnpm/linuxstatic-x64@11.13.0":
"@pnpm/linuxstatic-x64@11.15.1":
optional: true
"@pnpm/macos-arm64@11.13.0":
"@pnpm/macos-arm64@11.15.1":
optional: true
"@pnpm/win-arm64@11.13.0":
"@pnpm/win-arm64@11.15.1":
optional: true
"@pnpm/win-x64@11.13.0":
"@pnpm/win-x64@11.15.1":
optional: true
"@reflink/reflink-darwin-arm64@0.1.19":
@@ -248,7 +248,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.13.0: {}
pnpm@11.15.1: {}
---
lockfileVersion: "9.0"