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/package.json b/apps/gui/package.json index bfe39ce..f4bac81 100644 --- a/apps/gui/package.json +++ b/apps/gui/package.json @@ -20,6 +20,7 @@ "@omnia/scenario": "workspace:*", "@omnia/spatial": "workspace:*", "@radix-ui/react-dialog": "^1.1.19", + "@radix-ui/react-dropdown-menu": "^2.1.20", "@radix-ui/react-separator": "^1.1.11", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-tooltip": "^1.2.12", diff --git a/apps/gui/src/app/actions.ts b/apps/gui/src/app/actions.ts index eefc5c1..4ca0a3c 100644 --- a/apps/gui/src/app/actions.ts +++ b/apps/gui/src/app/actions.ts @@ -10,6 +10,7 @@ import { AVAILABLE_PROVIDERS, ModelProviderMeta, } from "@omnia/llm"; +import { ScenarioSchema } from "@omnia/scenario"; function resolveScenarioPath(relative: string): string { const cwd = process.cwd(); @@ -313,3 +314,42 @@ export async function regenerateEmbeddings( ): 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 0d397b7..cf2f37d 100644 --- a/apps/gui/src/app/builder/page.tsx +++ b/apps/gui/src/app/builder/page.tsx @@ -1,18 +1,766 @@ "use client"; +import { useEffect, useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { + SidebarProvider, + Sidebar, + SidebarContent, +} from "@/components/ui/sidebar"; +import { + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarSub, + MenubarSubTrigger, + MenubarSubContent, + MenubarRadioGroup, + MenubarRadioItem, +} from "@/components/ui/menubar"; +import { getConfigStatus, loadScenarioJson, saveScenario } from "@/app/actions"; +import type { Scenario } from "@omnia/scenario"; +import { + Save, + FileJson, + Globe, + MapPin, + Users, + Info, + Eye, + Pencil, +} from "lucide-react"; + +// Import refactored builder components +import { MetadataTab } from "@/components/builder/MetadataTab"; +import { LocationsTab } from "@/components/builder/LocationsTab"; +import { EntitiesTab } from "@/components/builder/EntitiesTab"; +import { JsonTab } from "@/components/builder/JsonTab"; +import type { + LocationData, + EntityData, + AttributeData, +} from "@/components/builder/types"; + +const generateUUID = () => { + if ( + typeof window !== "undefined" && + window.crypto && + window.crypto.randomUUID + ) { + return window.crypto.randomUUID(); + } + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +}; + export default function BuilderPage() { + const router = useRouter(); + + // Load scenarios templates list + const [availableScenarios, setAvailableScenarios] = useState< + { path: string; name: string; description: string }[] + >([]); + + // Tabs: "metadata", "locations", "entities", "json" + const [activeTab, setActiveTab] = useState< + "metadata" | "locations" | "entities" | "json" + >("metadata"); + + // Form State + const [scenarioId, setScenarioId] = useState(""); + 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([]); + const [entities, setEntities] = useState([]); + + // 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); + + // Initialize dynamic UUIDs client-side to prevent NextJS SSR hydration mismatch + useEffect(() => { + if (!scenarioId) { + const uId = generateUUID(); + const locId = generateUUID(); + const entId = generateUUID(); + + setScenarioId(uId); + setLocations([{ id: locId, attributes: [], connections: [] }]); + setEntities([ + { + id: entId, + locationId: locId, + attributes: [ + { + name: "role", + value: "adventurer", + visibility: "PUBLIC", + allowedEntities: [], + }, + ], + aliases: {}, + initialMemories: [], + }, + ]); + } + }, [scenarioId]); + + // 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: generateUUID(), 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 || generateUUID(), + 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: generateUUID(), + locationId: locs[0]?.id || generateUUID(), + 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" }); + } + }; + + const handleResetScenario = () => { + setScenarioId(""); + setName("My Custom Scenario"); + setDescription("A custom scenario template created via builder."); + setStartTime("2026-07-06T12:00:00.000Z"); + setWorldAttributes([]); + setSelectedLocIndex(0); + setSelectedEntIndex(0); + setStatusMessage({ text: "Scenario reset successfully.", type: "success" }); + }; + + const handleAddLocation = () => { + const newId = generateUUID(); + setLocations([ + ...locations, + { id: newId, attributes: [], connections: [] }, + ]); + setSelectedLocIndex(locations.length); + setActiveTab("locations"); + }; + + const handleAddEntity = () => { + const newId = generateUUID(); + setEntities([ + ...entities, + { + id: newId, + locationId: locationIds[0] || "", + attributes: [], + aliases: {}, + initialMemories: [], + isAgent: true, + }, + ]); + setSelectedEntIndex(entities.length); + setActiveTab("entities"); + }; + return ( -
-
-

- Scenario Builder -

-
-

- Scenario builder interface coming soon... -

+
+ {/* Save Status Banner */} + {statusMessage && ( +
+
+ +
{statusMessage.text}
+
+
+ )} + + {/* Menubar spanning full page width right below navbar */} +
+
+ + + + + + File + + + + Load Template + + + {availableScenarios.length === 0 ? ( + No templates + ) : ( + availableScenarios.map((sc) => ( + { + handleLoadTemplate(sc.path); + }} + > + {sc.name} + + )) + )} + + + + + Save to Server + + + Export JSON + + + + + + Edit + + + Reset Scenario + + + + Add New Location + + + Add New Entity + + + + + + View + + setActiveTab(val as typeof activeTab)} + > + + World Metadata + + + Locations + + + Entities + + + Live JSON Preview + + + + + +
+
+ {scenarioId ? `ID: ${scenarioId}` : "Unsaved Scenario"}
+ + +
+ {/* Viewport-level Vertical Sidebar on the Left Side */} + + +
+ + Configuration + + + + + + + + + +
+ + {/* Sidebar Footer link */} +
+ +
+
+
+ + {/* Main Centered Content Pane on the Right */} +
+
+ {/* Header block with Page Name */} +
+

+ Scenario Builder +

+
+ + {/* Active configuration tab form */} +
+ {/* TAB 1: World Metadata & Attributes */} + {activeTab === "metadata" && ( + + )} + + {/* TAB 2: Locations & Spatial connections */} + {activeTab === "locations" && ( + + )} + + {/* TAB 3: Entities */} + {activeTab === "entities" && ( + + )} + + {/* TAB 4: Live JSON Preview */} + {activeTab === "json" && ( + + setStatusMessage({ + text: "JSON copied to clipboard!", + type: "success", + }) + } + /> + )} +
+
+
+
+
); } diff --git a/apps/gui/src/app/globals.css b/apps/gui/src/app/globals.css index 2cb32dc..8a9f557 100644 --- a/apps/gui/src/app/globals.css +++ b/apps/gui/src/app/globals.css @@ -8,6 +8,7 @@ @custom-variant data-horizontal (&[data-orientation="horizontal"]); @custom-variant data-vertical (&[data-orientation="vertical"]); @custom-variant data-popup-open (&[data-state="open"]); +@custom-variant data-highlighted (&[data-highlighted]); @theme inline { --font-head: var(--font-head); diff --git a/apps/gui/src/app/layout.tsx b/apps/gui/src/app/layout.tsx index d67b030..8388816 100644 --- a/apps/gui/src/app/layout.tsx +++ b/apps/gui/src/app/layout.tsx @@ -34,6 +34,7 @@ const spaceMono = Space_Mono({ const links = [ { href: "/", label: "Home" }, + { href: "/builder", label: "Builder" }, { href: "/config", label: "Config" }, ]; diff --git a/apps/gui/src/components/builder/AttributeEditor.tsx b/apps/gui/src/components/builder/AttributeEditor.tsx new file mode 100644 index 0000000..ac68217 --- /dev/null +++ b/apps/gui/src/components/builder/AttributeEditor.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Plus, Trash2 } from "lucide-react"; +import type { AttributeData, EntityData } from "./types"; +import { getEntityDisplayNameById } from "./utils"; + +interface AttributeEditorProps { + title?: string; + attributes: AttributeData[]; + onChange: (attrs: AttributeData[]) => void; + onAdd: () => void; + entityIds: string[]; + entities?: EntityData[]; +} + +export function AttributeEditor({ + title = "Attributes", + attributes, + onChange, + onAdd, + entityIds, + entities, +}: AttributeEditorProps) { + 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 ( + + ); + })} +
+ )} +
+ )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/gui/src/components/builder/EntitiesTab.tsx b/apps/gui/src/components/builder/EntitiesTab.tsx new file mode 100644 index 0000000..0818bcf --- /dev/null +++ b/apps/gui/src/components/builder/EntitiesTab.tsx @@ -0,0 +1,563 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { AttributeEditor } from "./AttributeEditor"; +import { Plus, Trash2, ChevronRight } from "lucide-react"; +import type { EntityData, MemoryData, LocationData } from "./types"; +import { + getEntityDisplayName, + getEntityDisplayNameById, + getLocationDisplayNameById, +} from "./utils"; + +interface EntitiesTabProps { + entities: EntityData[]; + setEntities: (ents: EntityData[]) => void; + locations: LocationData[]; + locationIds: string[]; + entityIds: string[]; + selectedEntIndex: number; + setSelectedEntIndex: (idx: number) => void; + startTime: string; + generateUUID: () => string; +} + +export function EntitiesTab({ + entities, + setEntities, + locations, + locationIds, + entityIds, + selectedEntIndex, + setSelectedEntIndex, + startTime, + generateUUID, +}: EntitiesTabProps) { + const selectedEnt = entities[selectedEntIndex]; + + return ( +
+ {/* Left sidebar: Entities list */} +
+
+ + Entities + + +
+
+ {entities.map((ent, idx) => ( +
setSelectedEntIndex(idx)} + className={`p-3 text-xs font-mono cursor-pointer flex justify-between items-center transition-all ${ + selectedEntIndex === idx + ? "bg-primary/10 text-primary font-bold border-l-4 border-primary" + : "hover:bg-secondary/40 text-foreground" + }`} + > + + {getEntityDisplayName(ent) || `(Empty ID)`} + + {entities.length > 1 && ( + + )} +
+ ))} +
+
+ + {/* Right panel: Edit selected entity details */} + {selectedEnt ? ( +
+ {/* Entity Configuration Card */} +
+

+ Entity Configuration +

+ +
+ + +
+ +
+ + { + const copy = [...entities]; + copy[selectedEntIndex].locationId = value || undefined; + setEntities(copy); + }} + > + + + No locations found. + + {(id: string) => ( + + {getLocationDisplayNameById(id, locations)} + + )} + + + +
+ +
+
+ +
+

+ When enabled, this entity will run an autonomous LLM loop to + perceive its environment, update its memories, and generate + prose narrative actions. +

+
+ + {/* Attributes */} +
+ { + const copy = [...entities]; + copy[selectedEntIndex].attributes = newAttrs; + setEntities(copy); + }} + onAdd={() => { + const copy = [...entities]; + copy[selectedEntIndex].attributes = [ + ...copy[selectedEntIndex].attributes, + { + name: "", + value: "", + visibility: "PUBLIC", + allowedEntities: [], + }, + ]; + setEntities(copy); + }} + entityIds={entityIds} + entities={entities} + /> +
+
+ + {/* Aliases Card */} +
+
+

+ Aliases (Perceptions) +

+ +
+ + {Object.keys(selectedEnt.aliases || {}).length === 0 ? ( +

+ No descriptive aliases configured. Defaults to actual entity ID. +

+ ) : ( +
+ {Object.entries(selectedEnt.aliases).map( + ([targetId, aliasText]) => ( +
+ + {getEntityDisplayNameById(targetId, entities)} + + + { + const copy = [...entities]; + copy[selectedEntIndex].aliases = { + ...selectedEnt.aliases, + [targetId]: e.target.value, + }; + setEntities(copy); + }} + className="h-7 text-xs flex-1" + /> + +
+ ), + )} +
+ )} +
+ + {/* Initial Memories Card */} +
+
+

+ Initial Memories +

+ +
+ + {!selectedEnt.initialMemories || + selectedEnt.initialMemories.length === 0 ? ( +

+ No initial memories loaded. Entities will start blank. +

+ ) : ( +
+ {selectedEnt.initialMemories.map((mem, memIdx) => ( +
+ + +
+
+ + Type + + +
+ +
+ + Location + + +
+
+ +
+ + Verbatim Text (originalText) + + { + const copy = [...entities]; + copy[selectedEntIndex].initialMemories[ + memIdx + ].intent.originalText = e.target.value; + setEntities(copy); + }} + className="h-7 text-xs" + /> +
+ +
+ + Objective Description + + { + const copy = [...entities]; + copy[selectedEntIndex].initialMemories[ + memIdx + ].intent.description = e.target.value; + setEntities(copy); + }} + className="h-7 text-xs" + /> +
+ + {/* Targets multi-select */} +
+ + Involved Targets + +
+ {entityIds + .filter((id) => id !== selectedEnt.id) + .map((entId) => { + const isSelected = + mem.intent.targetIds?.includes(entId); + return ( + + ); + })} +
+
+ + {/* Action Validation outcome */} + {mem.intent.type === "action" && ( +
+ + {mem.outcome && ( +
+
+ + { + const copy = [...entities]; + if ( + copy[selectedEntIndex].initialMemories[ + memIdx + ].outcome + ) { + copy[selectedEntIndex].initialMemories[ + memIdx + ].outcome!.isValid = !!checked; + setEntities(copy); + } + }} + /> +
+
+ + { + const copy = [...entities]; + if ( + copy[selectedEntIndex].initialMemories[ + memIdx + ].outcome + ) { + copy[selectedEntIndex].initialMemories[ + memIdx + ].outcome!.reason = e.target.value; + setEntities(copy); + } + }} + className="h-6 text-[10px]" + /> +
+
+ )} +
+ )} +
+ ))} +
+ )} +
+
+ ) : ( +
+ No entities defined. +
+ )} +
+ ); +} diff --git a/apps/gui/src/components/builder/JsonTab.tsx b/apps/gui/src/components/builder/JsonTab.tsx new file mode 100644 index 0000000..983a0f6 --- /dev/null +++ b/apps/gui/src/components/builder/JsonTab.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Button } from "@/components/ui/button"; + +interface JsonTabProps { + compiledScenario: Record; + onCopySuccess: () => void; +} + +export function JsonTab({ compiledScenario, onCopySuccess }: JsonTabProps) { + return ( +
+
+

+ Scenario JSON Code Output +

+
+ +
+
+
+        {JSON.stringify(compiledScenario, null, 2)}
+      
+
+ ); +} diff --git a/apps/gui/src/components/builder/LocationsTab.tsx b/apps/gui/src/components/builder/LocationsTab.tsx new file mode 100644 index 0000000..596d65c --- /dev/null +++ b/apps/gui/src/components/builder/LocationsTab.tsx @@ -0,0 +1,419 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { AttributeEditor } from "./AttributeEditor"; +import { Plus, Trash2 } from "lucide-react"; +import type { LocationData, ConnectionData, EntityData } from "./types"; +import { getLocationDisplayName, getLocationDisplayNameById } from "./utils"; +import { WorldMap } from "./WorldMap"; + +interface LocationsTabProps { + locations: LocationData[]; + setLocations: (locs: LocationData[]) => void; + entities: EntityData[]; + locationIds: string[]; + entityIds: string[]; + selectedLocIndex: number; + setSelectedLocIndex: (idx: number) => void; + generateUUID: () => string; +} + +export function LocationsTab({ + locations, + setLocations, + entities, + locationIds, + entityIds, + selectedLocIndex, + setSelectedLocIndex, + generateUUID, +}: LocationsTabProps) { + const addLocationConnection = (locIndex: number) => { + const copy = [...locations]; + copy[locIndex].connections = [ + ...copy[locIndex].connections, + { + targetId: + locationIds.filter((id) => id !== locations[locIndex].id)[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); + }; + + const selectedLoc = locations[selectedLocIndex]; + + return ( +
+ {/* Map Visualizer */} + {locations.length > 0 && ( +
+ { + const idx = locations.findIndex((l) => l.id === id); + if (idx !== -1) setSelectedLocIndex(idx); + }} + /> +
+ )} + + {/* Left sidebar: Locations list */} +
+
+ + Locations + + +
+
+ {locations.map((loc, idx) => ( +
setSelectedLocIndex(idx)} + className={`p-3 text-xs font-mono cursor-pointer flex justify-between items-center transition-all ${ + selectedLocIndex === idx + ? "bg-primary/10 text-primary font-bold border-l-4 border-primary" + : "hover:bg-secondary/40 text-foreground" + }`} + > + + {getLocationDisplayName(loc) || `(Empty ID)`} + + {locations.length > 1 && ( + + )} +
+ ))} +
+
+ + {/* Right panel wrapper */} +
+ {selectedLoc ? ( +
+ {/* Basic location fields */} +
+

+ Location Configuration +

+ +
+ + +
+ +
+ + id !== selectedLoc.id)} + value={selectedLoc.parentId || ""} + onValueChange={(value) => { + const copy = [...locations]; + copy[selectedLocIndex].parentId = value || undefined; + setLocations(copy); + }} + > + + + No locations found. + + {(id: string) => ( + + {getLocationDisplayNameById(id, locations)} + + )} + + + +
+ + {/* Attributes for Location */} +
+ { + const copy = [...locations]; + copy[selectedLocIndex].attributes = newAttrs; + setLocations(copy); + }} + onAdd={() => { + const copy = [...locations]; + copy[selectedLocIndex].attributes = [ + ...copy[selectedLocIndex].attributes, + { + name: "", + value: "", + visibility: "PUBLIC", + allowedEntities: [], + }, + ]; + setLocations(copy); + }} + entityIds={entityIds} + entities={entities} + /> +
+
+ + {/* Connections (spatial paths) */} +
+
+

+ Connections / Portals +

+ +
+ + {!selectedLoc.connections || + selectedLoc.connections.length === 0 ? ( +

+ No connections leading from this location. +

+ ) : ( +
+ {selectedLoc.connections.map((conn, connIdx) => ( +
+ + +
+
+ + Target Location + + id !== selectedLoc.id, + )} + value={conn.targetId} + onValueChange={(value) => + updateLocationConnection( + selectedLocIndex, + connIdx, + "targetId", + value ?? "", + ) + } + > + + + No locations found. + + {(id: string) => ( + + {getLocationDisplayNameById(id, locations)} + + )} + + + +
+ +
+ + Portal Name + + + updateLocationConnection( + selectedLocIndex, + connIdx, + "portalName", + e.target.value, + ) + } + className="h-7 text-xs" + /> +
+
+ +
+ + Portal State + + + updateLocationConnection( + selectedLocIndex, + connIdx, + "portalStateDescriptor", + e.target.value, + ) + } + className="h-7 text-xs" + /> +
+ +
+
+ + Vision Propagation ({conn.visionProp}) + + + updateLocationConnection( + selectedLocIndex, + connIdx, + "visionProp", + Number(e.target.value), + ) + } + className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+ +
+ + Sound Propagation ({conn.soundProp}) + + + updateLocationConnection( + selectedLocIndex, + connIdx, + "soundProp", + Number(e.target.value), + ) + } + className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+
+ +
+ +
+
+ ))} +
+ )} +
+
+ ) : ( +
+ No location selected. Choose a location from the sidebar to edit, or + view the world layout below. +
+ )} +
+
+ ); +} diff --git a/apps/gui/src/components/builder/MetadataTab.tsx b/apps/gui/src/components/builder/MetadataTab.tsx new file mode 100644 index 0000000..fee8ca5 --- /dev/null +++ b/apps/gui/src/components/builder/MetadataTab.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { AttributeEditor } from "./AttributeEditor"; +import type { AttributeData, EntityData } from "./types"; + +interface MetadataTabProps { + scenarioId: string; + setScenarioId: (val: string) => void; + name: string; + setName: (val: string) => void; + description: string; + setDescription: (val: string) => void; + startTime: string; + setStartTime: (val: string) => void; + worldAttributes: AttributeData[]; + setWorldAttributes: (attrs: AttributeData[]) => void; + entityIds: string[]; + entities: EntityData[]; +} + +export function MetadataTab({ + scenarioId, + name, + setName, + description, + setDescription, + startTime, + setStartTime, + worldAttributes, + setWorldAttributes, + entityIds, + entities, +}: MetadataTabProps) { + const addWorldAttribute = () => { + setWorldAttributes([ + ...worldAttributes, + { name: "", value: "", visibility: "PUBLIC", allowedEntities: [] }, + ]); + }; + + // Parse initial state from ISO string (or fallback to now) + const parsedDate = useMemo(() => { + try { + const d = new Date(startTime); + if (isNaN(d.getTime())) return new Date(); + return d; + } catch { + return new Date(); + } + }, [startTime]); + + const dateValue = useMemo(() => { + return parsedDate.toISOString().split("T")[0]; + }, [parsedDate]); + + const timeValue = useMemo(() => { + return parsedDate.toISOString().split("T")[1].slice(0, 8); + }, [parsedDate]); + + const handleDateChange = (newDateStr: string) => { + if (!newDateStr) return; + const combined = `${newDateStr}T${timeValue}.000Z`; + setStartTime(combined); + }; + + const handleTimeChange = (newTimeStr: string) => { + if (!newTimeStr) return; + const formattedTime = + newTimeStr.split(":").length === 2 ? `${newTimeStr}:00` : newTimeStr; + const combined = `${dateValue}T${formattedTime}.000Z`; + setStartTime(combined); + }; + + return ( +
+ {/* Basic Fields */} +
+

+ Scenario Metadata +

+ +
+
+ + + + Unique filename ID. Alphanumeric, hyphens and underscores only. + +
+ +
+ +
+
+ handleDateChange(e.target.value)} + className="text-xs font-mono" + /> +
+
+ handleTimeChange(e.target.value)} + className="text-xs font-mono" + /> +
+
+ + Global clock starting date and time (stored in ISO UTC). + +
+
+ +
+ + setName(e.target.value)} + className="text-xs" + /> +
+ +
+ +