mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
refactor(gui): Revamp the home page and refactor playview out
This commit is contained in:
BIN
apps/gui/public/logo.png
Normal file
BIN
apps/gui/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
14
apps/gui/src/app/builder/page.tsx
Normal file
14
apps/gui/src/app/builder/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
export default function BuilderPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[800px] px-10 py-12">
|
||||
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">Scenario Builder</h1>
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] min-h-[300px] flex flex-col items-center justify-center">
|
||||
<p className="text-body-md text-muted-foreground font-mono text-center">
|
||||
Scenario builder interface coming soon...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
:root {
|
||||
--radius: 0;
|
||||
--background: #fff8f5;
|
||||
--background: #f4e9dc;
|
||||
--foreground: #201a16;
|
||||
--card: #fcf3e8;
|
||||
--card-foreground: #201a16;
|
||||
@@ -96,7 +96,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #201a16;
|
||||
--background: #181310;
|
||||
--foreground: #fff8f5;
|
||||
--card: #362f2a;
|
||||
--card-foreground: #fff8f5;
|
||||
|
||||
@@ -34,7 +34,6 @@ const spaceMono = Space_Mono({
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/play", label: "Play" },
|
||||
{ href: "/config", label: "Config" },
|
||||
];
|
||||
|
||||
@@ -45,21 +44,26 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<html lang="en">
|
||||
<body className={`${jersey25.variable} ${jetbrainsMono.variable} ${spaceMono.variable} min-h-dvh bg-background text-foreground font-sans`}>
|
||||
<nav className="border-b border-dotted border-border/20 bg-secondary/30">
|
||||
<div className="mx-auto max-w-[800px] px-10 py-3 flex items-center justify-center gap-8">
|
||||
<Link href="/" className="font-head text-headline-sm text-primary no-underline tracking-wide hover:opacity-85">
|
||||
Omnia
|
||||
</Link>
|
||||
<div className="mx-auto max-w-[800px] px-10 py-3 flex items-center justify-center">
|
||||
<NavigationMenu viewport={false}>
|
||||
<NavigationMenuList>
|
||||
{links.map((link) => (
|
||||
<NavigationMenuItem key={link.href}>
|
||||
<NavigationMenuLink asChild active={pathname === link.href}>
|
||||
<Link href={link.href} className="text-foreground no-underline font-medium text-sm">
|
||||
{link.label}
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
))}
|
||||
{links.map((link) => {
|
||||
const isActive = pathname === link.href || (link.href !== "/" && pathname?.startsWith(link.href));
|
||||
return (
|
||||
<NavigationMenuItem key={link.href}>
|
||||
<NavigationMenuLink asChild active={isActive}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className={`text-foreground no-underline font-medium text-sm p-2 transition-all outline-none ${
|
||||
isActive ? "bg-primary/15 text-primary" : "hover:bg-secondary hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
})}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
startSimulation,
|
||||
listSavedSimulations,
|
||||
getConfigStatus,
|
||||
getScenarioEntities,
|
||||
deleteSimulation,
|
||||
listProviderInstances,
|
||||
} from "@/app/play/actions";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import type { ModelProviderInstance } from "@omnia/llm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [savedSessions, setSavedSessions] = useState<SimSnapshot[]>([]);
|
||||
const [scenarios, setScenarios] = useState<{ path: string; name: string }[]>([]);
|
||||
const [providerInstances, setProviderInstances] = useState<ModelProviderInstance[]>([]);
|
||||
|
||||
// Modal State
|
||||
const [scenarioForModal, setScenarioForModal] = useState<{ path: string; name: string } | null>(null);
|
||||
const [modalEntities, setModalEntities] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadingEntities, setLoadingEntities] = useState(false);
|
||||
const [selectedEntityForModal, setSelectedEntityForModal] = useState<string>("");
|
||||
|
||||
const loadSavedSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await listSavedSimulations();
|
||||
if (res.ok) {
|
||||
setSavedSessions(res.sessions);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
async function loadAll() {
|
||||
setLoadingData(true);
|
||||
try {
|
||||
const sessionsRes = await listSavedSimulations();
|
||||
if (active && sessionsRes.ok) {
|
||||
setSavedSessions(sessionsRes.sessions);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const configStatus = await getConfigStatus();
|
||||
if (active) {
|
||||
setScenarios(configStatus.availableScenarios);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const providersList = await listProviderInstances();
|
||||
if (active) {
|
||||
setProviderInstances(providersList);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (active) {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}
|
||||
loadAll();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleResume = (id: string) => {
|
||||
router.push(`/play?simId=${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm("Are you sure you want to delete this simulation session?")) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await deleteSimulation(id);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
} else {
|
||||
await loadSavedSessions();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete session.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScenarioClick = async (scenario: { path: string; name: string }) => {
|
||||
setScenarioForModal(scenario);
|
||||
setLoadingEntities(true);
|
||||
setSelectedEntityForModal(""); // Reset selection to Spectator
|
||||
try {
|
||||
const res = await getScenarioEntities(scenario.path);
|
||||
if (res.ok) {
|
||||
setModalEntities(res.entities);
|
||||
} else {
|
||||
setModalEntities([]);
|
||||
}
|
||||
} catch {
|
||||
setModalEntities([]);
|
||||
} finally {
|
||||
setLoadingEntities(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartFromModal = async () => {
|
||||
if (!scenarioForModal) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const targetScenario = scenarioForModal;
|
||||
setScenarioForModal(null); // close modal
|
||||
try {
|
||||
const result = await startSimulation({
|
||||
scenario: targetScenario.path,
|
||||
playEntity: selectedEntityForModal || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to the separate play page with the newly created simulation ID
|
||||
router.push(`/play?simId=${result.snapshot.id}`);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to start simulation.",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[800px] px-10 py-12">
|
||||
<h1 className="mb-2 text-headline-lg text-primary">Omnia GUI</h1>
|
||||
<p className="mb-8 text-body-md text-muted-foreground">
|
||||
Configuration and gameplay interface for the Omnia simulation engine.
|
||||
</p>
|
||||
<div className="flex gap-6">
|
||||
<Link href="/play" className="flex-1 no-underline text-foreground">
|
||||
<Card className="transition-all hover:-translate-y-0.5 hover:shadow-[3px_3px_0_0_var(--border)] active:translate-y-0 active:shadow-[1px_1px_0_0_var(--border)]">
|
||||
<CardHeader>
|
||||
<CardTitle>Play</CardTitle>
|
||||
<CardDescription>
|
||||
Start a simulation and interact with NPCs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/config" className="flex-1 no-underline text-foreground">
|
||||
<Card className="transition-all hover:-translate-y-0.5 hover:shadow-[3px_3px_0_0_var(--border)] active:translate-y-0 active:shadow-[1px_1px_0_0_var(--border)]">
|
||||
<CardHeader>
|
||||
<CardTitle>Config</CardTitle>
|
||||
<CardDescription>
|
||||
Check environment, API keys, and available scenarios
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
<div className="mx-auto max-w-[800px] 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">
|
||||
<img src="/logo.png" alt="Omnia Logo" className="h-28 object-contain mb-3" />
|
||||
<p className="text-body-md text-muted-foreground font-mono uppercase tracking-widest">Simulation Engine</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simulations Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-headline-lg text-primary mb-6 animate-fade-in">Simulations</h2>
|
||||
{loadingData ? (
|
||||
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
|
||||
{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]"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : savedSessions.length === 0 ? (
|
||||
<p className="text-sm italic text-muted-foreground">No saved simulations found. Start a new one below!</p>
|
||||
) : (
|
||||
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
|
||||
{savedSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => handleResume(s.id)}
|
||||
className="flex-shrink-0 w-72 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 relative group"
|
||||
>
|
||||
<div className="flex flex-col gap-1 text-sm mb-4">
|
||||
<strong className="text-body-md text-foreground block">{s.scenarioName}</strong>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Turn {s.turn} · {s.entities.length} entities · {s.status}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60 font-mono">
|
||||
ID: {s.id.substring(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-xs text-primary font-mono uppercase tracking-wider">Resume</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={(e) => handleDelete(s.id, e)}
|
||||
disabled={loading}
|
||||
title="Delete Session"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Scenarios Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-headline-lg text-primary mb-6 animate-fade-in">Scenarios</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]">
|
||||
<div>
|
||||
<strong className="text-body-md text-foreground block mb-1">Build a scenario</strong>
|
||||
<span className="text-xs text-muted-foreground">Create a custom simulation starting point</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs font-mono uppercase tracking-wider text-primary">
|
||||
<span className="flex items-center justify-center size-5 border border-primary text-primary font-bold text-sm bg-primary/10">+</span>
|
||||
<span>Create New</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{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]"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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-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]">
|
||||
<div>
|
||||
<strong className="text-body-md text-foreground block mb-1">Build a scenario</strong>
|
||||
<span className="text-xs text-muted-foreground">Create a custom simulation starting point</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs font-mono uppercase tracking-wider text-primary">
|
||||
<span className="flex items-center justify-center size-5 border border-primary text-primary font-bold text-sm bg-primary/10">+</span>
|
||||
<span>Create New</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{scenarios.map((s) => (
|
||||
<div
|
||||
key={s.path}
|
||||
onClick={() => handleScenarioClick(s)}
|
||||
className="flex-shrink-0 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"
|
||||
>
|
||||
<strong className="text-body-md text-foreground block mb-2">{s.name}</strong>
|
||||
<code className="text-xs text-primary font-mono block truncate mb-1">{s.path}</code>
|
||||
<span className="text-xs text-muted-foreground font-mono uppercase tracking-wider block mt-4">Click to Start</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Start Scenario Selection Modal */}
|
||||
{scenarioForModal && (
|
||||
<Dialog open={!!scenarioForModal} onOpenChange={(open) => !open && setScenarioForModal(null)}>
|
||||
<DialogContent className="max-w-[400px]">
|
||||
<DialogHeader className="border-b border-dotted border-border/20 pb-4 mb-2">
|
||||
<DialogTitle>Start Scenario</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose how you want to interact with <strong>{scenarioForModal.name}</strong>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loadingEntities ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
|
||||
<Spinner />
|
||||
<span>Loading scenario entities...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setScenarioForModal(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleStartFromModal} disabled={loading || loadingEntities}>
|
||||
{loading ? "Starting..." : "Launch"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { PlayView } from "@/components/play/PlayView";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export default function PlayPage() {
|
||||
return <PlayView />;
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin text-primary">Loading...</div>
|
||||
</div>
|
||||
}>
|
||||
<PlayView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
startSimulation,
|
||||
stepSimulation,
|
||||
submitPlayerAction,
|
||||
listSavedSimulations,
|
||||
resumeSimulation,
|
||||
getConfigStatus,
|
||||
getScenarioEntities,
|
||||
deleteSimulation,
|
||||
listProviderInstances,
|
||||
} from "@/app/play/actions";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
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 { PromptModal } from "./PromptModal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
function IntentTag({
|
||||
intent,
|
||||
@@ -66,7 +52,6 @@ function IntentTag({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function formatSimTime(isoString: string) {
|
||||
try {
|
||||
const d = new Date(isoString);
|
||||
@@ -131,12 +116,17 @@ function LogEntryCard({
|
||||
}
|
||||
|
||||
export function PlayView() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const simId = searchParams.get("simId");
|
||||
|
||||
const [snapshot, setSnapshot] = useState<SimSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [playerInput, setPlayerInput] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [statusText, setStatusText] = useState("");
|
||||
const [selectedEntryForModal, setSelectedEntryForModal] = useState<SimSnapshot["log"][number] | null>(null);
|
||||
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const steppingRef = useRef(false);
|
||||
const pauseRequestedRef = useRef(false);
|
||||
@@ -203,26 +193,7 @@ export function PlayView() {
|
||||
[snapshot],
|
||||
);
|
||||
|
||||
const [savedSessions, setSavedSessions] = useState<SimSnapshot[]>([]);
|
||||
|
||||
const loadSavedSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await listSavedSimulations();
|
||||
if (res.ok) {
|
||||
setSavedSessions(res.sessions);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot) {
|
||||
loadSavedSessions();
|
||||
}
|
||||
}, [snapshot, loadSavedSessions]);
|
||||
|
||||
const handleResume = async (id: string) => {
|
||||
const handleResume = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -242,114 +213,16 @@ export function PlayView() {
|
||||
setError(err instanceof Error ? err.message : "Failed to resume session.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [runSteps]);
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm("Are you sure you want to delete this simulation session?")) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await deleteSimulation(id);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
} else {
|
||||
await loadSavedSessions();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete session.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [scenarios, setScenarios] = useState<{ path: string; name: string }[]>([]);
|
||||
const [selectedScenario, setSelectedScenario] = useState("");
|
||||
const [availableEntities, setAvailableEntities] = useState<{ id: string; name: string }[]>([]);
|
||||
const [selectedEntity, setSelectedEntity] = useState("");
|
||||
|
||||
const [providerInstances, setProviderInstances] = useState<ModelProviderInstance[]>([]);
|
||||
|
||||
// Load scenarios and provider instances on mount
|
||||
// Load simulation on mount
|
||||
useEffect(() => {
|
||||
async function loadScenariosAndProviders() {
|
||||
try {
|
||||
const configStatus = await getConfigStatus();
|
||||
setScenarios(configStatus.availableScenarios);
|
||||
if (configStatus.availableScenarios.length > 0) {
|
||||
setSelectedScenario(configStatus.availableScenarios[0].path);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const providersList = await listProviderInstances();
|
||||
setProviderInstances(providersList);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
loadScenariosAndProviders();
|
||||
}, [snapshot]);
|
||||
|
||||
// Fetch entities when selectedScenario changes
|
||||
useEffect(() => {
|
||||
if (!selectedScenario) {
|
||||
setAvailableEntities([]);
|
||||
setSelectedEntity("");
|
||||
if (!simId) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
async function loadEntities() {
|
||||
try {
|
||||
const res = await getScenarioEntities(selectedScenario);
|
||||
if (res.ok) {
|
||||
setAvailableEntities(res.entities);
|
||||
if (res.entities.length > 0) {
|
||||
setSelectedEntity(res.entities[0].id);
|
||||
} else {
|
||||
setSelectedEntity("");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
loadEntities();
|
||||
}, [selectedScenario]);
|
||||
|
||||
const handleStart = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const form = new FormData(e.currentTarget);
|
||||
const result = await startSimulation({
|
||||
scenario: (form.get("scenario") as string) || undefined,
|
||||
playEntity: (form.get("playEntity") as string) || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSnapshot(result.snapshot);
|
||||
|
||||
if (result.snapshot.status === "running") {
|
||||
await runSteps(result.snapshot.id);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to start simulation.",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
handleResume(simId);
|
||||
}, [simId, handleResume, router]);
|
||||
|
||||
const handleSubmitAction = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -402,235 +275,162 @@ export function PlayView() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!snapshot && loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-3">
|
||||
<Spinner />
|
||||
<span className="text-sm text-muted-foreground font-mono">Initializing simulation...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot && error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[800px] px-10 py-12">
|
||||
<div className="border border-destructive bg-destructive/10 px-4 py-3 text-sm text-destructive mb-6">
|
||||
{error}
|
||||
</div>
|
||||
<Button onClick={() => router.push("/")}>Back to Dashboard</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[800px] px-10 py-12">
|
||||
<h1 className="text-headline-lg text-primary mb-6 animate-fade-in">Omnia Play</h1>
|
||||
|
||||
{!snapshot && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1.2fr_1fr] gap-8 mt-4">
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h2 className="text-headline-md text-foreground mb-5 pb-2 border-b border-dotted border-border/20">Start New Simulation</h2>
|
||||
<form onSubmit={handleStart} className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="rounded border-2 border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="scenario" className="text-sm font-medium">Scenario</label>
|
||||
<Select
|
||||
value={selectedScenario}
|
||||
onValueChange={(val) => setSelectedScenario(val || "")}
|
||||
<div className="mx-auto max-w-[800px] px-10 py-12 animate-fade-in">
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h2 className="text-headline-md text-primary">{snapshot.scenarioName}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
pauseRequestedRef.current = true;
|
||||
router.push("/");
|
||||
}}
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
{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("/");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{scenarios.map((s) => (
|
||||
<SelectItem key={s.path} value={s.path}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="playEntity" className="text-sm font-medium">
|
||||
Play as (Entity)
|
||||
</label>
|
||||
<Select
|
||||
value={selectedEntity}
|
||||
onValueChange={(val) => setSelectedEntity(val || "")}
|
||||
disabled={availableEntities.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="-- Spectator (Observer) --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="">-- Spectator (Observer) --</SelectItem>
|
||||
{availableEntities.map((ent) => (
|
||||
<SelectItem key={ent.id} value={ent.id}>
|
||||
{ent.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading || providerInstances.length === 0}>
|
||||
{loading ? "Starting..." : "Start Simulation"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h2 className="text-headline-md text-foreground mb-5 pb-2 border-b border-dotted border-border/20">Resume Simulation</h2>
|
||||
{savedSessions.length === 0 ? (
|
||||
<p className="text-sm italic text-muted-foreground">No saved sessions found. Start a new one!</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto pr-1">
|
||||
{savedSessions.map((s) => (
|
||||
<div key={s.id} className="border border-border/30 bg-secondary/40 p-3 flex justify-between items-center gap-4 shadow-[1px_1px_0_0_var(--border)]">
|
||||
<div className="flex flex-col gap-0.5 text-sm">
|
||||
<strong className="text-sm text-foreground">{s.scenarioName}</strong>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Turn {s.turn} · {s.entities.length} entities · {s.status}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
Session ID: <code className="font-mono text-xs">{s.id}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button size="sm" onClick={() => handleResume(s.id)} disabled={loading || providerInstances.length === 0}>
|
||||
Resume
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={(e) => handleDelete(s.id, e)}
|
||||
disabled={loading}
|
||||
title="Delete Session"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{snapshot.scenarioDescription}</p>
|
||||
<p className="text-sm font-medium text-primary mt-1">
|
||||
{loading && "⏳ "}
|
||||
{statusMessage()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 mb-6 max-h-[55vh] overflow-y-auto border border-border/20 bg-secondary/30 p-4 shadow-[inset_1px_1px_4px_rgba(0,0,0,0.05)]">
|
||||
{(() => {
|
||||
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
|
||||
return snapshot.log.map((entry, i) => (
|
||||
<LogEntryCard
|
||||
key={i}
|
||||
entry={entry}
|
||||
onShowPrompt={setSelectedEntryForModal}
|
||||
isPlayerCard={entry.entityId === playerEntity?.id}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
|
||||
<Spinner />
|
||||
{statusText || "Processing..."}
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<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-[200px] overflow-y-auto mt-2 font-mono">
|
||||
{snapshot.waitingEntity.userContext}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<form onSubmit={handleSubmitAction} 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 && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h2 className="text-headline-md text-primary">{snapshot.scenarioName}</h2>
|
||||
{snapshot.status !== "done" && snapshot.status !== "error" && (
|
||||
<div className="flex gap-2">
|
||||
{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={() => {
|
||||
setSnapshot(null);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{snapshot.scenarioDescription}</p>
|
||||
<p className="text-sm font-medium text-primary mt-1">
|
||||
{loading && "⏳ "}
|
||||
{statusMessage()}
|
||||
</p>
|
||||
</div>
|
||||
{(snapshot.status === "done" || snapshot.status === "error") && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
{snapshot.status === "error" ? "Back to Dashboard" : "New Simulation"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 mb-6 max-h-[55vh] overflow-y-auto border border-border/20 bg-secondary/30 p-4 shadow-[inset_1px_1px_4px_rgba(0,0,0,0.05)]">
|
||||
{(() => {
|
||||
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
|
||||
return snapshot.log.map((entry, i) => (
|
||||
<LogEntryCard
|
||||
key={i}
|
||||
entry={entry}
|
||||
onShowPrompt={setSelectedEntryForModal}
|
||||
isPlayerCard={entry.entityId === playerEntity?.id}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
|
||||
<Spinner />
|
||||
{statusText || "Processing..."}
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
{error && !loading && (
|
||||
<div className="border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive mt-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<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-[200px] overflow-y-auto mt-2 font-mono">
|
||||
{snapshot.waitingEntity.userContext}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<form onSubmit={handleSubmitAction} 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") && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSnapshot(null);
|
||||
setError("");
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
{snapshot.status === "error" ? "Try Again" : "New Simulation"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive mt-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEntryForModal && (
|
||||
<PromptModal
|
||||
entry={selectedEntryForModal}
|
||||
onClose={() => setSelectedEntryForModal(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{selectedEntryForModal && (
|
||||
<PromptModal
|
||||
entry={selectedEntryForModal}
|
||||
onClose={() => setSelectedEntryForModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -128,7 +128,7 @@ function NavigationMenuLink({
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 text-sm transition-all outline-none hover:bg-secondary hover:text-foreground focus:bg-secondary focus:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary in-data-[slot=navigation-menu-content]:rounded-sm data-active:bg-primary/15 data-active:text-primary [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex items-center gap-2 p-2 text-sm transition-all outline-none hover:bg-secondary hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary in-data-[slot=navigation-menu-content]:rounded-sm data-active:bg-primary/15 data-active:text-primary [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
16
apps/gui/src/components/ui/skeleton.tsx
Normal file
16
apps/gui/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted/60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
Reference in New Issue
Block a user