diff --git a/apps/gui/next-env.d.ts b/apps/gui/next-env.d.ts index c4b7818..9edff1c 100644 --- a/apps/gui/next-env.d.ts +++ b/apps/gui/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/apps/gui/src/app/actions.ts b/apps/gui/src/app/actions.ts index 4503301..2fe70e4 100644 --- a/apps/gui/src/app/actions.ts +++ b/apps/gui/src/app/actions.ts @@ -5,6 +5,7 @@ import fs from "fs"; import { simulationManager } from "@/lib/simulation"; import type { SimSnapshot } from "@/lib/simulation"; import { ProviderManager, ModelProviderInstance, AVAILABLE_PROVIDERS, ModelProviderMeta } from "@omnia/llm"; +import { ScenarioSchema } from "@omnia/scenario"; function resolveScenarioPath(relative: string): string { const cwd = process.cwd(); @@ -287,3 +288,35 @@ export async function getAvailableProviders(): Promise { export async function regenerateEmbeddings(newProviderInstanceId?: string): Promise { await simulationManager.regenerateAllEmbeddings(newProviderInstanceId); } + +export async function saveScenario(scenario: unknown): Promise<{ ok: true } | { ok: false; error: string }> { + try { + const parsed = ScenarioSchema.parse(scenario); + const cwd = process.cwd(); + const dir = path.resolve(cwd, "content/demo/scenarios"); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const filePath = path.join(dir, `${parsed.id}.json`); + fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), "utf-8"); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function loadScenarioJson(scenarioPath: string): Promise< + | { ok: true; scenario: unknown } + | { ok: false; error: string } +> { + try { + const resolved = resolveScenarioPath(scenarioPath); + if (!fs.existsSync(resolved)) { + return { ok: false, error: `Scenario file not found: ${scenarioPath}` }; + } + const content = JSON.parse(fs.readFileSync(resolved, "utf-8")); + return { ok: true, scenario: content }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/apps/gui/src/app/builder/page.tsx b/apps/gui/src/app/builder/page.tsx index b8b973c..1680582 100644 --- a/apps/gui/src/app/builder/page.tsx +++ b/apps/gui/src/app/builder/page.tsx @@ -1,16 +1,1365 @@ "use client"; +import { useEffect, useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + SidebarProvider, + Sidebar, + SidebarContent, +} from "@/components/ui/sidebar"; +import { + getConfigStatus, + loadScenarioJson, + saveScenario +} from "@/app/actions"; +import type { Scenario } from "@omnia/scenario"; +import { + Plus, + Trash2, + Save, + FileJson, + Globe, + MapPin, + Users, + Sparkles, + ChevronRight, + Info, + Eye +} from "lucide-react"; + +interface AttributeData { + name: string; + value: string; + visibility: "PUBLIC" | "PRIVATE"; + allowedEntities: string[]; +} + +interface ConnectionData { + targetId: string; + portalName?: string; + portalStateDescriptor?: string; + visionProp: number; + soundProp: number; + bidirectional: boolean; +} + +interface LocationData { + id: string; + parentId?: string; + attributes: AttributeData[]; + connections: ConnectionData[]; +} + +interface MemoryData { + id: string; + timestamp: string; + locationId: string | null; + intent: { + type: "dialogue" | "action" | "monologue"; + originalText: string; + description: string; + selfDescription?: string; + actorId: string; + targetIds: string[]; + modifiers?: string[]; + }; + outcome?: { + isValid: boolean; + reason: string; + }; +} + +interface EntityData { + id: string; + locationId?: string; + attributes: AttributeData[]; + aliases: Record; // targetId -> subjective descriptor + initialMemories: MemoryData[]; +} + export default function BuilderPage() { - return ( -
-
-

Scenario Builder

-
-

- Scenario builder interface coming soon... -

+ const router = useRouter(); + + // Load scenarios templates list + const [availableScenarios, setAvailableScenarios] = useState< + { path: string; name: string; description: string }[] + >([]); + const [selectedTemplatePath, setSelectedTemplatePath] = useState(""); + + // Tabs: "metadata", "locations", "entities", "json" + const [activeTab, setActiveTab] = useState<"metadata" | "locations" | "entities" | "json">("metadata"); + + // Form State + const [scenarioId, setScenarioId] = useState("custom-scenario"); + const [name, setName] = useState("My Custom Scenario"); + const [description, setDescription] = useState("A custom scenario template created via builder."); + const [startTime, setStartTime] = useState("2026-07-06T12:00:00.000Z"); + const [worldAttributes, setWorldAttributes] = useState([]); + const [locations, setLocations] = useState([ + { id: "room-1", attributes: [], connections: [] } + ]); + const [entities, setEntities] = useState([ + { id: "hero-1", locationId: "room-1", attributes: [{ name: "role", value: "adventurer", visibility: "PUBLIC", allowedEntities: [] }], aliases: {}, initialMemories: [] } + ]); + + // Selected sub-items for active editing lists + const [selectedLocIndex, setSelectedLocIndex] = useState(0); + const [selectedEntIndex, setSelectedEntIndex] = useState(0); + + // Status & Notification Banners + const [statusMessage, setStatusMessage] = useState<{ text: string; type: "success" | "error" | "info" } | null>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Fetch available templates on load + useEffect(() => { + async function loadTemplates() { + try { + const config = await getConfigStatus(); + setAvailableScenarios(config.availableScenarios); + } catch (err) { + console.error("Failed to load scenario list:", err); + } + } + loadTemplates(); + }, []); + + // Set timeout to dismiss messages + useEffect(() => { + if (statusMessage) { + const timer = setTimeout(() => { + setStatusMessage(null); + }, 6000); + return () => clearTimeout(timer); + } + }, [statusMessage]); + + // Populate helper lists + const locationIds = useMemo(() => locations.map(l => l.id).filter(Boolean), [locations]); + const entityIds = useMemo(() => entities.map(e => e.id).filter(Boolean), [entities]); + + // Load selected template + const handleLoadTemplate = async (path: string) => { + if (!path) return; + setStatusMessage({ text: "Loading template...", type: "info" }); + try { + const res = await loadScenarioJson(path); + if (!res.ok) { + setStatusMessage({ text: res.error || "Failed to load template.", type: "error" }); + return; + } + + const s = res.scenario as Scenario; + if (!s) { + setStatusMessage({ text: "Scenario template was empty.", type: "error" }); + return; + } + + setScenarioId(s.id || "custom-scenario"); + setName(s.name || "Loaded Scenario"); + setDescription(s.description || ""); + setStartTime(s.startTime || "2026-07-06T12:00:00.000Z"); + + // World attributes + const wAttrs = (s.world?.attributes || []).map(a => ({ + name: a.name, + value: a.value, + visibility: a.visibility, + allowedEntities: a.allowedEntities || [], + })); + setWorldAttributes(wAttrs); + + // Locations + const locs = (s.locations || []).map(l => ({ + id: l.id, + parentId: l.parentId || undefined, + attributes: (l.attributes || []).map(a => ({ + name: a.name, + value: a.value, + visibility: a.visibility, + allowedEntities: a.allowedEntities || [], + })), + connections: (l.connections || []).map(c => ({ + targetId: c.targetId, + portalName: c.portalName, + portalStateDescriptor: c.portalStateDescriptor, + visionProp: c.visionProp, + soundProp: c.soundProp, + bidirectional: c.bidirectional ?? true, + })), + })); + setLocations(locs.length > 0 ? locs : [{ id: "room-1", attributes: [], connections: [] }]); + setSelectedLocIndex(0); + + // Entities + const ents = (s.entities || []).map(e => ({ + id: e.id, + locationId: e.locationId || undefined, + attributes: (e.attributes || []).map(a => ({ + name: a.name, + value: a.value, + visibility: a.visibility, + allowedEntities: a.allowedEntities || [], + })), + aliases: e.aliases || {}, + initialMemories: (e.initialMemories || []).map(m => ({ + id: m.id || "mem-" + Math.random().toString(36).substr(2, 9), + timestamp: m.timestamp || s.startTime, + locationId: m.locationId || null, + intent: { + type: m.intent.type, + originalText: m.intent.originalText, + description: m.intent.description, + selfDescription: m.intent.selfDescription, + actorId: m.intent.actorId || e.id, + targetIds: m.intent.targetIds || [], + modifiers: m.intent.modifiers || [], + }, + outcome: m.outcome ? { + isValid: m.outcome.isValid, + reason: m.outcome.reason + } : undefined, + })), + })); + setEntities(ents.length > 0 ? ents : [{ id: "hero-1", locationId: locs[0]?.id || "room-1", attributes: [], aliases: {}, initialMemories: [] }]); + setSelectedEntIndex(0); + + setStatusMessage({ text: "Template loaded successfully!", type: "success" }); + } catch (err) { + setStatusMessage({ text: err instanceof Error ? err.message : String(err), type: "error" }); + } + }; + + // Compile full scenario object + const compiledScenario = useMemo(() => { + return { + id: scenarioId.trim(), + name: name.trim(), + description: description.trim(), + startTime: startTime.trim(), + world: worldAttributes.length > 0 ? { + attributes: worldAttributes.map(a => ({ + name: a.name.trim(), + value: a.value.trim(), + visibility: a.visibility, + ...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0 ? { allowedEntities: a.allowedEntities } : {}) + })) + } : undefined, + locations: locations.map(l => ({ + id: l.id.trim(), + ...(l.parentId ? { parentId: l.parentId } : {}), + ...(l.attributes.length > 0 ? { + attributes: l.attributes.map(a => ({ + name: a.name.trim(), + value: a.value.trim(), + visibility: a.visibility, + ...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0 ? { allowedEntities: a.allowedEntities } : {}) + })) + } : {}), + ...(l.connections.length > 0 ? { + connections: l.connections.map(c => ({ + targetId: c.targetId, + ...(c.portalName ? { portalName: c.portalName.trim() } : {}), + ...(c.portalStateDescriptor ? { portalStateDescriptor: c.portalStateDescriptor.trim() } : {}), + visionProp: Number(c.visionProp), + soundProp: Number(c.soundProp), + bidirectional: !!c.bidirectional + })) + } : {}) + })), + entities: entities.map(e => ({ + id: e.id.trim(), + ...(e.locationId ? { locationId: e.locationId } : {}), + ...(e.attributes.length > 0 ? { + attributes: e.attributes.map(a => ({ + name: a.name.trim(), + value: a.value.trim(), + visibility: a.visibility, + ...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0 ? { allowedEntities: a.allowedEntities } : {}) + })) + } : {}), + ...(Object.keys(e.aliases).length > 0 ? { aliases: e.aliases } : {}), + ...(e.initialMemories.length > 0 ? { + initialMemories: e.initialMemories.map(m => ({ + id: m.id, + timestamp: m.timestamp, + locationId: m.locationId, + intent: { + type: m.intent.type, + originalText: m.intent.originalText.trim(), + description: m.intent.description.trim(), + ...(m.intent.selfDescription ? { selfDescription: m.intent.selfDescription.trim() } : {}), + actorId: m.intent.actorId, + targetIds: m.intent.targetIds, + ...(m.intent.modifiers && m.intent.modifiers.length > 0 ? { modifiers: m.intent.modifiers } : []) + }, + ...(m.outcome ? { outcome: { isValid: !!m.outcome.isValid, reason: m.outcome.reason.trim() } } : {}) + })) + } : {}) + })) + }; + }, [scenarioId, name, description, startTime, worldAttributes, locations, entities]); + + // Save scenario to server + const handleSaveToServer = async () => { + if (!scenarioId.trim()) { + setStatusMessage({ text: "Scenario Template ID is required to save.", type: "error" }); + return; + } + setIsSubmitting(true); + setStatusMessage({ text: "Saving scenario file...", type: "info" }); + try { + const res = await saveScenario(compiledScenario); + if (res.ok) { + setStatusMessage({ text: `Scenario template saved as ${scenarioId}.json successfully!`, type: "success" }); + // Refresh template list + const config = await getConfigStatus(); + setAvailableScenarios(config.availableScenarios); + } else { + setStatusMessage({ text: res.error || "Failed to save scenario.", type: "error" }); + } + } catch (err) { + setStatusMessage({ text: err instanceof Error ? err.message : String(err), type: "error" }); + } finally { + setIsSubmitting(false); + } + }; + + // Download scenario file directly + const handleDownloadJson = () => { + try { + const jsonStr = JSON.stringify(compiledScenario, null, 2); + const blob = new Blob([jsonStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${scenarioId || "scenario"}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + setStatusMessage({ text: "JSON download initiated.", type: "success" }); + } catch { + setStatusMessage({ text: "Download failed.", type: "error" }); + } + }; + + // World Attribute Management + const addWorldAttribute = () => { + setWorldAttributes([...worldAttributes, { name: "", value: "", visibility: "PUBLIC", allowedEntities: [] }]); + }; + + // Helper component for Attributes list editor + const AttributeEditor = ({ + attributes, + onChange, + onAdd, + title = "Attributes" + }: { + attributes: AttributeData[]; + onChange: (attrs: AttributeData[]) => void; + onAdd: () => void; + title?: string; + }) => { + const handleAttrChange = (index: number, key: K, val: AttributeData[K]) => { + const copy = [...attributes]; + copy[index] = { ...copy[index], [key]: val }; + onChange(copy); + }; + + const handleToggleEntityAccess = (index: number, entId: string) => { + const copy = [...attributes]; + const allowed = copy[index].allowedEntities || []; + if (allowed.includes(entId)) { + copy[index].allowedEntities = allowed.filter(id => id !== entId); + } else { + copy[index].allowedEntities = [...allowed, entId]; + } + onChange(copy); + }; + + return ( +
+
+

{title}

+
+ {attributes.length === 0 ? ( +

No attributes defined yet.

+ ) : ( +
+ {attributes.map((attr, index) => ( +
+
+
+ handleAttrChange(index, "name", e.target.value)} + className="h-8 font-mono text-xs" + /> + handleAttrChange(index, "value", e.target.value)} + className="h-8 text-xs" + /> +
+ +
+
+
+ +
+ {attr.visibility === "PRIVATE" && ( +
+ Visible to Entities: + {entityIds.length === 0 ? ( + Add entities first to grant private access + ) : ( +
+ {entityIds.map(entId => { + const isAllowed = attr.allowedEntities?.includes(entId); + return ( + + ); + })} +
+ )} +
+ )} +
+
+ ))} +
+ )}
-
+ ); + }; + + // Location connections manager + const addLocationConnection = (locIndex: number) => { + const copy = [...locations]; + copy[locIndex].connections = [ + ...copy[locIndex].connections, + { targetId: locationIds[0] || "", visionProp: 10, soundProp: 10, bidirectional: true } + ]; + setLocations(copy); + }; + + const updateLocationConnection = (locIndex: number, connIndex: number, key: K, val: ConnectionData[K]) => { + const copy = [...locations]; + copy[locIndex].connections[connIndex] = { ...copy[locIndex].connections[connIndex], [key]: val }; + setLocations(copy); + }; + + const removeLocationConnection = (locIndex: number, connIndex: number) => { + const copy = [...locations]; + copy[locIndex].connections = copy[locIndex].connections.filter((_, i) => i !== connIndex); + setLocations(copy); + }; + + return ( + +
+ + {/* Save Status Banner */} + {statusMessage && ( +
+
+ +
{statusMessage.text}
+
+
+ )} + + {/* Viewport-level Vertical Sidebar on the Left Side */} + + +
+ Configuration + + + + + + + + +
+ + {/* Sidebar Footer link */} +
+ +
+
+
+ + {/* Main Centered Content Pane on the Right */} +
+
+ + {/* Header block with Page Name and Load/Save Actions */} +
+
+

+ Scenario Builder +

+
+ +
+ {/* Load Template dropdown */} +
+ Load: + +
+ + + + +
+
+ + {/* Active configuration tab form */} +
+ + {/* TAB 1: World Metadata & Attributes */} + {activeTab === "metadata" && ( +
+ + {/* Basic Fields */} +
+

Scenario Metadata

+ +
+
+ + setScenarioId(e.target.value.toLowerCase().replace(/[^a-z0-9-_]/g, ""))} + className="font-mono text-xs" + /> + Unique filename ID. Alphanumeric, hyphens and underscores only. +
+ +
+ + setStartTime(e.target.value)} + className="font-mono text-xs" + /> + Global clock starting timestamp. +
+
+ +
+ + setName(e.target.value)} + className="text-xs" + /> +
+ +
+ +