mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 12:02:49 +05:30
Compare commits
9 Commits
13155cba23
...
feat/scena
| Author | SHA1 | Date | |
|---|---|---|---|
| 381afdcb1a | |||
| 44f3e29e62 | |||
| d2506ec542 | |||
| 13483d54dd | |||
| 2cc89d2e3c | |||
| b5c5fa9f0a | |||
| 03a5bec366 | |||
| 663d409a46 | |||
| 30bf5f76c2 |
@@ -17,7 +17,7 @@
|
||||
The <b>world state lives outside the model</b>, characters act through <b>intents that get validated</b> and applied by engine code. Each character's knowledge, memory, and emotional state are subjective and partial by construction.
|
||||
|
||||
<p align="center">
|
||||
<img src="./web/docs/src/assets/img/puppet.webp" alt="This pixel-art style image, set against a black background with the title 'The Puppet Master Paradox' at the top, depicts two identical, stylized puppets with white, round heads and orange patterned bodies facing each other. Between them hovers a small, glowing, four-pointed star, while each puppet has an orange-outlined speech bubble above it: the left one states, 'Good evening. Welcome to my humble bakery!', and the right one replies, 'Nice to meet you Assassin Bob. Wait... wha-', concluding with a small white 'X' icon in the bottom right corner." />
|
||||
<img src="./web/docs/src/assets/img/puppet.webp" />
|
||||
</p>
|
||||
|
||||
Single-agent or single-context systems (AI Dungeon and its descendants) prompt one model to _be_ the world and everyone in it. That breaks in predictable ways over long sessions:
|
||||
@@ -41,7 +41,7 @@ Omnia answers every one of these failures with the same move: **pull the thing t
|
||||
## What this buys you
|
||||
|
||||
<p align="center">
|
||||
<img src="./web/docs/src/assets/img/features.webp" alt="This pixel-art image features a grid of six distinct panels, each with an orange-outlined, jagged border, illustrating different concepts: 'Emergent Deceit' shows a person hiding a sword behind their back while offering a rose to another person; 'Divergent Perceptions' depicts two figures sitting at a table with speech bubbles labeled 'A' and 'B'; 'Player Agnostic' shows three figures engaged in different activities—watering a plant, standing still, and juggling—under the plumbob symbol from the sims; 'State Validated Agency' displays a stylized symbol of a person partially inside a portal crossed out by a large red 'X'; 'Deterministic Time' shows four circular panels representing a day-night cycle connected by arrows and clock icons; and 'Bring Your Own Model' features a large, central omnia icon surrounded by various LLM model logos including chatgpt, mistral, deepseek, claude, etc****" />
|
||||
<img src="./web/docs/src/assets/img/features.webp" />
|
||||
</p>
|
||||
|
||||
The payoff is scenario complexity that **uni-agent systems structurally cannot represent, no matter how good the model gets**.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -254,7 +255,6 @@ export async function createProviderInstance(
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
endpointUrl?: string,
|
||||
): Promise<ModelProviderInstance> {
|
||||
return ProviderManager.create(
|
||||
name,
|
||||
@@ -263,7 +263,6 @@ export async function createProviderInstance(
|
||||
modelName,
|
||||
type,
|
||||
maxContext,
|
||||
endpointUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,7 +282,6 @@ export async function updateProviderInstance(
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
endpointUrl?: string,
|
||||
): Promise<void> {
|
||||
ProviderManager.update(
|
||||
id,
|
||||
@@ -293,7 +291,6 @@ export async function updateProviderInstance(
|
||||
modelName,
|
||||
type,
|
||||
maxContext,
|
||||
endpointUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,3 +314,42 @@ export async function regenerateEmbeddings(
|
||||
): Promise<void> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,766 @@
|
||||
import { BuilderView } from "@/components/builder/BuilderView";
|
||||
"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() {
|
||||
return <BuilderView />;
|
||||
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<AttributeData[]>([]);
|
||||
const [locations, setLocations] = useState<LocationData[]>([]);
|
||||
const [entities, setEntities] = useState<EntityData[]>([]);
|
||||
|
||||
// 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 (
|
||||
<div className="flex flex-col flex-1 min-h-0 w-full bg-background overflow-hidden">
|
||||
{/* Save Status Banner */}
|
||||
{statusMessage && (
|
||||
<div
|
||||
className={`fixed top-14 right-4 z-50 max-w-sm border p-4 shadow-lg animate-fade-in ${
|
||||
statusMessage.type === "success"
|
||||
? "bg-emerald-950/80 border-emerald-500 text-emerald-300"
|
||||
: statusMessage.type === "error"
|
||||
? "bg-destructive/10 border-destructive text-destructive"
|
||||
: "bg-secondary/90 border-border text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<div className="text-xs">{statusMessage.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menubar spanning full page width right below navbar */}
|
||||
<div className="w-full border-b border-border/20 bg-card py-1.5 px-4 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-head text-primary tracking-wider font-bold flex items-center gap-1.5">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
</span>
|
||||
<Menubar className="border-none shadow-none bg-transparent h-7 p-0">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="cursor-pointer">File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger className="cursor-pointer">
|
||||
Load Template
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
{availableScenarios.length === 0 ? (
|
||||
<MenubarItem disabled>No templates</MenubarItem>
|
||||
) : (
|
||||
availableScenarios.map((sc) => (
|
||||
<MenubarItem
|
||||
key={sc.path}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
handleLoadTemplate(sc.path);
|
||||
}}
|
||||
>
|
||||
{sc.name}
|
||||
</MenubarItem>
|
||||
))
|
||||
)}
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleSaveToServer}
|
||||
disabled={isSubmitting || !scenarioId.trim()}
|
||||
>
|
||||
<Save className="size-4 mr-2 inline" /> Save to Server
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleDownloadJson}
|
||||
>
|
||||
<FileJson className="size-4 mr-2 inline" /> Export JSON
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="cursor-pointer">Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleResetScenario}
|
||||
>
|
||||
Reset Scenario
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleAddLocation}
|
||||
>
|
||||
Add New Location
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleAddEntity}
|
||||
>
|
||||
Add New Entity
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="cursor-pointer">View</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarRadioGroup
|
||||
value={activeTab}
|
||||
onValueChange={(val) => setActiveTab(val as typeof activeTab)}
|
||||
>
|
||||
<MenubarRadioItem value="metadata" className="cursor-pointer">
|
||||
World Metadata
|
||||
</MenubarRadioItem>
|
||||
<MenubarRadioItem
|
||||
value="locations"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Locations
|
||||
</MenubarRadioItem>
|
||||
<MenubarRadioItem value="entities" className="cursor-pointer">
|
||||
Entities
|
||||
</MenubarRadioItem>
|
||||
<MenubarRadioItem value="json" className="cursor-pointer">
|
||||
Live JSON Preview
|
||||
</MenubarRadioItem>
|
||||
</MenubarRadioGroup>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
{scenarioId ? `ID: ${scenarioId}` : "Unsaved Scenario"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SidebarProvider className="flex-1 min-h-0">
|
||||
<div className="flex flex-1 min-h-0 w-full overflow-hidden">
|
||||
{/* Viewport-level Vertical Sidebar on the Left Side */}
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="h-full border-r border-border/30 bg-card shrink-0"
|
||||
>
|
||||
<SidebarContent className="flex flex-col justify-between h-full bg-card p-6">
|
||||
<div className="flex flex-col gap-2 font-head">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground block mb-2">
|
||||
Configuration
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("metadata")}
|
||||
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
|
||||
activeTab === "metadata"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
|
||||
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
<span>World Metadata</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("locations")}
|
||||
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
|
||||
activeTab === "locations"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
|
||||
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<MapPin className="size-3.5" />
|
||||
<span>Locations</span>
|
||||
<span className="ml-auto text-[10px] font-mono border border-muted-foreground/20 bg-muted/10 px-1 rounded">
|
||||
{locations.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("entities")}
|
||||
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
|
||||
activeTab === "entities"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
|
||||
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Users className="size-3.5" />
|
||||
<span>Entities</span>
|
||||
<span className="ml-auto text-[10px] font-mono border border-muted-foreground/20 bg-muted/10 px-1 rounded">
|
||||
{entities.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("json")}
|
||||
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
|
||||
activeTab === "json"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
|
||||
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
<span>Live JSON Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer link */}
|
||||
<div className="border-t border-border/10 pt-3 flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="w-full py-1 text-center hover:text-foreground text-primary font-bold uppercase transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
{/* Main Centered Content Pane on the Right */}
|
||||
<main className="flex-1 overflow-y-auto px-10 py-8 min-h-0 flex flex-col">
|
||||
<div className="mx-auto max-w-[1200px] w-full flex-1 flex flex-col min-h-0 gap-6">
|
||||
{/* Header block with Page Name */}
|
||||
<div className="shrink-0">
|
||||
<h1 className="text-headline-md text-primary flex items-center gap-2 font-head">
|
||||
Scenario Builder
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Active configuration tab form */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{/* TAB 1: World Metadata & Attributes */}
|
||||
{activeTab === "metadata" && (
|
||||
<MetadataTab
|
||||
scenarioId={scenarioId}
|
||||
setScenarioId={setScenarioId}
|
||||
name={name}
|
||||
setName={setName}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
startTime={startTime}
|
||||
setStartTime={setStartTime}
|
||||
worldAttributes={worldAttributes}
|
||||
setWorldAttributes={setWorldAttributes}
|
||||
entityIds={entityIds}
|
||||
entities={entities}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TAB 2: Locations & Spatial connections */}
|
||||
{activeTab === "locations" && (
|
||||
<LocationsTab
|
||||
locations={locations}
|
||||
setLocations={setLocations}
|
||||
entities={entities}
|
||||
locationIds={locationIds}
|
||||
entityIds={entityIds}
|
||||
selectedLocIndex={selectedLocIndex}
|
||||
setSelectedLocIndex={setSelectedLocIndex}
|
||||
generateUUID={generateUUID}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TAB 3: Entities */}
|
||||
{activeTab === "entities" && (
|
||||
<EntitiesTab
|
||||
entities={entities}
|
||||
setEntities={setEntities}
|
||||
locations={locations}
|
||||
locationIds={locationIds}
|
||||
entityIds={entityIds}
|
||||
selectedEntIndex={selectedEntIndex}
|
||||
setSelectedEntIndex={setSelectedEntIndex}
|
||||
startTime={startTime}
|
||||
generateUUID={generateUUID}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TAB 4: Live JSON Preview */}
|
||||
{activeTab === "json" && (
|
||||
<JsonTab
|
||||
compiledScenario={compiledScenario}
|
||||
onCopySuccess={() =>
|
||||
setStatusMessage({
|
||||
text: "JSON copied to clipboard!",
|
||||
type: "success",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -34,6 +34,7 @@ const spaceMono = Space_Mono({
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/builder", label: "Builder" },
|
||||
{ href: "/config", label: "Config" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HomeView } from "@/components/home/HomeView";
|
||||
import { DashboardView } from "@/components/play/DashboardView";
|
||||
|
||||
export default function Home() {
|
||||
return <HomeView />;
|
||||
return <DashboardView />;
|
||||
}
|
||||
|
||||
167
apps/gui/src/components/builder/AttributeEditor.tsx
Normal file
167
apps/gui/src/components/builder/AttributeEditor.tsx
Normal file
@@ -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 = <K extends keyof AttributeData>(
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md font-mono text-foreground font-bold">
|
||||
{title}
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onAdd}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Attribute
|
||||
</Button>
|
||||
</div>
|
||||
{attributes.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No attributes defined yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{attributes.map((attr, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-border/20 bg-secondary/10 p-3 rounded space-y-3"
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
placeholder="Name (e.g. role)"
|
||||
value={attr.name}
|
||||
onChange={(e) =>
|
||||
handleAttrChange(index, "name", e.target.value)
|
||||
}
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Value (e.g. merchant)"
|
||||
value={attr.value}
|
||||
onChange={(e) =>
|
||||
handleAttrChange(index, "value", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 cursor-pointer"
|
||||
onClick={() =>
|
||||
onChange(attributes.filter((_, i) => i !== index))
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-muted-foreground cursor-pointer flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={attr.visibility === "PUBLIC"}
|
||||
onCheckedChange={(checked) =>
|
||||
handleAttrChange(
|
||||
index,
|
||||
"visibility",
|
||||
checked ? "PUBLIC" : "PRIVATE",
|
||||
)
|
||||
}
|
||||
/>
|
||||
Publicly Visible
|
||||
</Label>
|
||||
</div>
|
||||
{attr.visibility === "PRIVATE" && (
|
||||
<div className="flex-1 border-l border-border/25 pl-4">
|
||||
<span className="text-muted-foreground font-semibold block mb-1">
|
||||
Visible to Entities:
|
||||
</span>
|
||||
{entityIds.length === 0 ? (
|
||||
<span className="text-[10px] italic text-muted-foreground">
|
||||
Add entities first to grant private access
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entityIds.map((entId) => {
|
||||
const isAllowed =
|
||||
attr.allowedEntities?.includes(entId);
|
||||
return (
|
||||
<button
|
||||
key={entId}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleToggleEntityAccess(index, entId)
|
||||
}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-mono border transition-all cursor-pointer ${
|
||||
isAllowed
|
||||
? "bg-primary/20 border-primary text-primary font-bold"
|
||||
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{entities
|
||||
? getEntityDisplayNameById(entId, entities)
|
||||
: entId}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export function BuilderView() {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto w-full">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
563
apps/gui/src/components/builder/EntitiesTab.tsx
Normal file
563
apps/gui/src/components/builder/EntitiesTab.tsx
Normal file
@@ -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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0 pb-12">
|
||||
{/* Left sidebar: Entities list */}
|
||||
<div className="md:col-span-1 border border-border/20 bg-card shadow-[2px_2px_0_0_var(--border)] flex flex-col max-h-[500px]">
|
||||
<div className="p-3 border-b border-border/25 flex justify-between items-center bg-secondary/15">
|
||||
<strong className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Entities
|
||||
</strong>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newId = generateUUID();
|
||||
setEntities([
|
||||
...entities,
|
||||
{
|
||||
id: newId,
|
||||
locationId: locationIds[0],
|
||||
attributes: [],
|
||||
aliases: {},
|
||||
initialMemories: [],
|
||||
isAgent: true,
|
||||
},
|
||||
]);
|
||||
setSelectedEntIndex(entities.length);
|
||||
}}
|
||||
className="h-6 text-[10px] px-2 flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto divide-y divide-border/10 flex-1">
|
||||
{entities.map((ent, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{getEntityDisplayName(ent) || `(Empty ID)`}
|
||||
</span>
|
||||
{entities.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newEnts = entities.filter((_, i) => i !== idx);
|
||||
setEntities(newEnts);
|
||||
setSelectedEntIndex(Math.max(0, idx - 1));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive pl-2 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Edit selected entity details */}
|
||||
{selectedEnt ? (
|
||||
<div className="md:col-span-3 space-y-6">
|
||||
{/* Entity Configuration Card */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-4">
|
||||
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
|
||||
Entity Configuration
|
||||
</h2>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Entity ID</Label>
|
||||
<Input
|
||||
value={selectedEnt.id}
|
||||
readOnly
|
||||
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Current Location</Label>
|
||||
<Combobox
|
||||
items={locationIds}
|
||||
value={selectedEnt.locationId || ""}
|
||||
onValueChange={(value) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].locationId = value || undefined;
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<ComboboxInput placeholder="Select a location..." showClear />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No locations found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-1 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-muted-foreground cursor-pointer flex items-center gap-1.5 text-sm font-semibold">
|
||||
<Checkbox
|
||||
checked={selectedEnt.isAgent !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].isAgent = !!checked;
|
||||
setEntities(copy);
|
||||
}}
|
||||
/>
|
||||
Is Agent?
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/75 pl-5 select-none leading-normal">
|
||||
When enabled, this entity will run an autonomous LLM loop to
|
||||
perceive its environment, update its memories, and generate
|
||||
prose narrative actions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Attributes */}
|
||||
<div className="pt-2">
|
||||
<AttributeEditor
|
||||
title="Entity Attributes"
|
||||
attributes={selectedEnt.attributes || []}
|
||||
onChange={(newAttrs) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aliases Card */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Aliases (Perceptions)
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const target = entityIds.find(
|
||||
(id) => id !== selectedEnt.id && !selectedEnt.aliases[id],
|
||||
);
|
||||
if (target) {
|
||||
copy[selectedEntIndex].aliases = {
|
||||
...selectedEnt.aliases,
|
||||
[target]: "",
|
||||
};
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
disabled={entityIds.length <= 1}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Alias
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(selectedEnt.aliases || {}).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No descriptive aliases configured. Defaults to actual entity ID.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(selectedEnt.aliases).map(
|
||||
([targetId, aliasText]) => (
|
||||
<div
|
||||
key={targetId}
|
||||
className="flex gap-2 items-center bg-secondary/15 p-2 rounded"
|
||||
>
|
||||
<span
|
||||
className="text-[11px] font-mono text-muted-foreground w-1/3 truncate"
|
||||
title={targetId}
|
||||
>
|
||||
{getEntityDisplayNameById(targetId, entities)}
|
||||
</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
placeholder="Descriptive name (e.g. the guard)"
|
||||
value={aliasText}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].aliases = {
|
||||
...selectedEnt.aliases,
|
||||
[targetId]: e.target.value,
|
||||
};
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const updated = { ...selectedEnt.aliases };
|
||||
delete updated[targetId];
|
||||
copy[selectedEntIndex].aliases = updated;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Initial Memories Card */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Initial Memories
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const newMem: MemoryData = {
|
||||
id: generateUUID(),
|
||||
timestamp: startTime,
|
||||
locationId: selectedEnt.locationId || null,
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: "",
|
||||
description: "",
|
||||
actorId: selectedEnt.id,
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
copy[selectedEntIndex].initialMemories = [
|
||||
...selectedEnt.initialMemories,
|
||||
newMem,
|
||||
];
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Memory
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!selectedEnt.initialMemories ||
|
||||
selectedEnt.initialMemories.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No initial memories loaded. Entities will start blank.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 overflow-y-auto max-h-[300px] pr-1">
|
||||
{selectedEnt.initialMemories.map((mem, memIdx) => (
|
||||
<div
|
||||
key={mem.id}
|
||||
className="border border-border/20 bg-secondary/5 p-3 rounded space-y-3 relative"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories =
|
||||
selectedEnt.initialMemories.filter(
|
||||
(_, i) => i !== memIdx,
|
||||
);
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Type
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded"
|
||||
value={mem.intent.type}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.type = e.target.value as
|
||||
"dialogue" | "action" | "monologue";
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<option value="dialogue">Dialogue</option>
|
||||
<option value="action">Action</option>
|
||||
<option value="monologue">Monologue</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Location
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded font-mono"
|
||||
value={mem.locationId || ""}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].locationId = e.target.value || null;
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<option value="">-- Nowhere --</option>
|
||||
{locationIds.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Verbatim Text (originalText)
|
||||
</span>
|
||||
<Input
|
||||
placeholder='e.g. "We should leave," Alice said.'
|
||||
value={mem.intent.originalText}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.originalText = e.target.value;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Objective Description
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. Alice says she wants to leave."
|
||||
value={mem.intent.description}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.description = e.target.value;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Targets multi-select */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Involved Targets
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entityIds
|
||||
.filter((id) => id !== selectedEnt.id)
|
||||
.map((entId) => {
|
||||
const isSelected =
|
||||
mem.intent.targetIds?.includes(entId);
|
||||
return (
|
||||
<button
|
||||
key={entId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const targets =
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds || [];
|
||||
if (targets.includes(entId)) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds = targets.filter(
|
||||
(t) => t !== entId,
|
||||
);
|
||||
} else {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds = [...targets, entId];
|
||||
}
|
||||
setEntities(copy);
|
||||
}}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] font-mono border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-primary/20 border-primary text-primary"
|
||||
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{getEntityDisplayNameById(entId, entities)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Validation outcome */}
|
||||
{mem.intent.type === "action" && (
|
||||
<div className="border-t border-border/20 pt-2 space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!mem.outcome}
|
||||
onCheckedChange={(checked) => {
|
||||
const copy = [...entities];
|
||||
if (checked) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome = { isValid: true, reason: "" };
|
||||
} else {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome = undefined;
|
||||
}
|
||||
setEntities(copy);
|
||||
}}
|
||||
/>
|
||||
Include validation outcome
|
||||
</Label>
|
||||
{mem.outcome && (
|
||||
<div className="grid grid-cols-3 gap-2 bg-secondary/15 p-2 rounded">
|
||||
<div className="col-span-1 flex flex-col justify-center">
|
||||
<Label className="text-[9px] mb-1">isValid</Label>
|
||||
<Checkbox
|
||||
checked={mem.outcome.isValid}
|
||||
onCheckedChange={(checked) => {
|
||||
const copy = [...entities];
|
||||
if (
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome
|
||||
) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome!.isValid = !!checked;
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-[9px]">Reason</Label>
|
||||
<Input
|
||||
placeholder="Reason for valid/invalid status"
|
||||
value={mem.outcome.reason}
|
||||
onChange={(e) => {
|
||||
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]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="md:col-span-3 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] text-center text-xs text-muted-foreground">
|
||||
No entities defined.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/gui/src/components/builder/JsonTab.tsx
Normal file
38
apps/gui/src/components/builder/JsonTab.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface JsonTabProps {
|
||||
compiledScenario: Record<string, unknown>;
|
||||
onCopySuccess: () => void;
|
||||
}
|
||||
|
||||
export function JsonTab({ compiledScenario, onCopySuccess }: JsonTabProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] min-h-[400px]">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-3 mb-4">
|
||||
<h2 className="text-body-lg text-primary font-bold">
|
||||
Scenario JSON Code Output
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
JSON.stringify(compiledScenario, null, 2),
|
||||
);
|
||||
onCopySuccess();
|
||||
}}
|
||||
className="h-8 text-xs cursor-pointer"
|
||||
>
|
||||
Copy to Clipboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="flex-1 bg-black border border-border/10 p-4 rounded overflow-auto font-mono text-xs text-emerald-400 select-text leading-relaxed">
|
||||
{JSON.stringify(compiledScenario, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
419
apps/gui/src/components/builder/LocationsTab.tsx
Normal file
419
apps/gui/src/components/builder/LocationsTab.tsx
Normal file
@@ -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 = <K extends keyof ConnectionData>(
|
||||
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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0 pb-12">
|
||||
{/* Map Visualizer */}
|
||||
{locations.length > 0 && (
|
||||
<div className="md:col-span-4 w-full">
|
||||
<WorldMap
|
||||
locations={locations}
|
||||
selectedLocId={selectedLoc?.id}
|
||||
onSelectLocId={(id) => {
|
||||
const idx = locations.findIndex((l) => l.id === id);
|
||||
if (idx !== -1) setSelectedLocIndex(idx);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left sidebar: Locations list */}
|
||||
<div className="md:col-span-1 border border-border/20 bg-card shadow-[2px_2px_0_0_var(--border)] flex flex-col max-h-[500px]">
|
||||
<div className="p-3 border-b border-border/25 flex justify-between items-center bg-secondary/15">
|
||||
<strong className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Locations
|
||||
</strong>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newId = generateUUID();
|
||||
setLocations([
|
||||
...locations,
|
||||
{ id: newId, attributes: [], connections: [] },
|
||||
]);
|
||||
setSelectedLocIndex(locations.length);
|
||||
}}
|
||||
className="h-6 text-[10px] px-2 flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto divide-y divide-border/10 flex-1">
|
||||
{locations.map((loc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{getLocationDisplayName(loc) || `(Empty ID)`}
|
||||
</span>
|
||||
{locations.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newLocs = locations.filter((_, i) => i !== idx);
|
||||
setLocations(newLocs);
|
||||
setSelectedLocIndex(Math.max(0, idx - 1));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors pl-2 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel wrapper */}
|
||||
<div className="md:col-span-3 space-y-6">
|
||||
{selectedLoc ? (
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic location fields */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
|
||||
Location Configuration
|
||||
</h2>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Location ID</Label>
|
||||
<Input
|
||||
value={selectedLoc.id}
|
||||
readOnly
|
||||
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Parent Location (Optional)</Label>
|
||||
<Combobox
|
||||
items={locationIds.filter((id) => id !== selectedLoc.id)}
|
||||
value={selectedLoc.parentId || ""}
|
||||
onValueChange={(value) => {
|
||||
const copy = [...locations];
|
||||
copy[selectedLocIndex].parentId = value || undefined;
|
||||
setLocations(copy);
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Select a parent location..."
|
||||
showClear
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No locations found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
{/* Attributes for Location */}
|
||||
<div className="pt-2">
|
||||
<AttributeEditor
|
||||
title="Location Attributes"
|
||||
attributes={selectedLoc.attributes || []}
|
||||
onChange={(newAttrs) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections (spatial paths) */}
|
||||
<div className="space-y-4 border-t lg:border-t-0 lg:border-l border-border/20 lg:pl-6 pt-4 lg:pt-0">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Connections / Portals
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addLocationConnection(selectedLocIndex)}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!selectedLoc.connections ||
|
||||
selectedLoc.connections.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No connections leading from this location.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 overflow-y-auto max-h-[400px] pr-1">
|
||||
{selectedLoc.connections.map((conn, connIdx) => (
|
||||
<div
|
||||
key={connIdx}
|
||||
className="border border-border/20 bg-secondary/10 p-3 rounded space-y-3 relative group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removeLocationConnection(selectedLocIndex, connIdx)
|
||||
}
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
title="Delete connection"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 pr-6">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Target Location
|
||||
</span>
|
||||
<Combobox
|
||||
items={locationIds.filter(
|
||||
(id) => id !== selectedLoc.id,
|
||||
)}
|
||||
value={conn.targetId}
|
||||
onValueChange={(value) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"targetId",
|
||||
value ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Choose target..."
|
||||
showClear
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No locations found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Portal Name
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. wooden door"
|
||||
value={conn.portalName || ""}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"portalName",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Portal State
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. locked, closed, heavy iron gate"
|
||||
value={conn.portalStateDescriptor || ""}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"portalStateDescriptor",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Vision Propagation ({conn.visionProp})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={conn.visionProp}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"visionProp",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Sound Propagation ({conn.soundProp})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={conn.soundProp}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"soundProp",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-[11px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={conn.bidirectional}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"bidirectional",
|
||||
!!checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Bidirectional Connection (creates reverse path
|
||||
automatically)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] text-center py-12 text-xs text-muted-foreground">
|
||||
No location selected. Choose a location from the sidebar to edit, or
|
||||
view the world layout below.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
apps/gui/src/components/builder/MetadataTab.tsx
Normal file
163
apps/gui/src/components/builder/MetadataTab.tsx
Normal file
@@ -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 (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-12">
|
||||
{/* Basic Fields */}
|
||||
<div className="lg:col-span-2 space-y-5 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
|
||||
Scenario Metadata
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sc-id">Scenario Template ID</Label>
|
||||
<Input
|
||||
id="sc-id"
|
||||
value={scenarioId}
|
||||
readOnly
|
||||
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Unique filename ID. Alphanumeric, hyphens and underscores only.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Start Time</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={dateValue}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
type="time"
|
||||
step="1"
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Global clock starting date and time (stored in ISO UTC).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sc-name">Scenario Name</Label>
|
||||
<Input
|
||||
id="sc-name"
|
||||
placeholder="e.g. The Quiet Tavern"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sc-desc">Description</Label>
|
||||
<Textarea
|
||||
id="sc-desc"
|
||||
placeholder="Describe the starting setup..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="text-xs h-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* World level Attributes */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<AttributeEditor
|
||||
title="World Attributes"
|
||||
attributes={worldAttributes}
|
||||
onChange={setWorldAttributes}
|
||||
onAdd={addWorldAttribute}
|
||||
entityIds={entityIds}
|
||||
entities={entities}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
528
apps/gui/src/components/builder/WorldMap.tsx
Normal file
528
apps/gui/src/components/builder/WorldMap.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
|
||||
import type { LocationData } from "./types";
|
||||
|
||||
interface BoxNode {
|
||||
id: string;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
children: BoxNode[];
|
||||
location: LocationData;
|
||||
}
|
||||
|
||||
interface WorldMapProps {
|
||||
locations: LocationData[];
|
||||
selectedLocId?: string;
|
||||
onSelectLocId: (id: string) => void;
|
||||
}
|
||||
|
||||
export function WorldMap({
|
||||
locations,
|
||||
selectedLocId,
|
||||
onSelectLocId,
|
||||
}: WorldMapProps) {
|
||||
// Zoom and Pan States for Visualizer
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const panStartRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (e.button === 0) {
|
||||
setIsPanning(true);
|
||||
panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (isPanning) {
|
||||
setPan({
|
||||
x: e.clientX - panStartRef.current.x,
|
||||
y: e.clientY - panStartRef.current.y,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsPanning(false);
|
||||
};
|
||||
|
||||
// Build tree and layout computation
|
||||
const { roots, nodeMap } = useMemo(() => {
|
||||
const nodeMap = new Map<string, BoxNode>();
|
||||
|
||||
// 1. Initialize all nodes
|
||||
locations.forEach((loc) => {
|
||||
const nameAttr = loc.attributes?.find(
|
||||
(a) => a.name.toLowerCase() === "name",
|
||||
)?.value;
|
||||
const name = nameAttr ? nameAttr : loc.id;
|
||||
nodeMap.set(loc.id, {
|
||||
id: loc.id,
|
||||
name,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
children: [],
|
||||
location: loc,
|
||||
});
|
||||
});
|
||||
|
||||
const roots: BoxNode[] = [];
|
||||
|
||||
// 2. Link parents and children
|
||||
locations.forEach((loc) => {
|
||||
const node = nodeMap.get(loc.id)!;
|
||||
if (loc.parentId && nodeMap.has(loc.parentId)) {
|
||||
nodeMap.get(loc.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Measure recursively
|
||||
const measureNode = (node: BoxNode) => {
|
||||
const textWidth = node.name.length * 7.2 + 24;
|
||||
|
||||
if (node.children.length === 0) {
|
||||
node.width = Math.max(140, textWidth);
|
||||
node.height = 70;
|
||||
return;
|
||||
}
|
||||
|
||||
node.children.forEach((child) => measureNode(child));
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(node.children.length));
|
||||
const rows = Math.ceil(node.children.length / cols);
|
||||
|
||||
const gap = 20;
|
||||
const padLeft = 20;
|
||||
const padRight = 20;
|
||||
const padTop = 45;
|
||||
const padBottom = 20;
|
||||
|
||||
const colWidths = new Array(cols).fill(0);
|
||||
const rowHeights = new Array(rows).fill(0);
|
||||
|
||||
node.children.forEach((child, idx) => {
|
||||
const col = idx % cols;
|
||||
const row = Math.floor(idx / cols);
|
||||
colWidths[col] = Math.max(colWidths[col], child.width);
|
||||
rowHeights[row] = Math.max(rowHeights[row], child.height);
|
||||
});
|
||||
|
||||
const totalGridWidth =
|
||||
colWidths.reduce((sum, w) => sum + w, 0) + (cols - 1) * gap;
|
||||
const totalGridHeight =
|
||||
rowHeights.reduce((sum, h) => sum + h, 0) + (rows - 1) * gap;
|
||||
|
||||
node.width = Math.max(
|
||||
180,
|
||||
totalGridWidth + padLeft + padRight,
|
||||
textWidth,
|
||||
);
|
||||
node.height = Math.max(100, totalGridHeight + padTop + padBottom);
|
||||
|
||||
let yOffset = padTop;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let xOffset = padLeft;
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = r * cols + c;
|
||||
if (idx >= node.children.length) break;
|
||||
const child = node.children[idx];
|
||||
|
||||
const cellWidth = colWidths[c];
|
||||
const cellHeight = rowHeights[r];
|
||||
child.x = xOffset + (cellWidth - child.width) / 2;
|
||||
child.y = yOffset + (cellHeight - child.height) / 2;
|
||||
|
||||
xOffset += cellWidth + gap;
|
||||
}
|
||||
yOffset += rowHeights[r] + gap;
|
||||
}
|
||||
};
|
||||
|
||||
roots.forEach((root) => measureNode(root));
|
||||
|
||||
// 4. Position roots and assign global coordinates
|
||||
const assignGlobalCoordinates = (
|
||||
node: BoxNode,
|
||||
parentX: number,
|
||||
parentY: number,
|
||||
) => {
|
||||
node.x += parentX;
|
||||
node.y += parentY;
|
||||
node.children.forEach((child) => {
|
||||
assignGlobalCoordinates(child, node.x, node.y);
|
||||
});
|
||||
};
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(roots.length));
|
||||
const gap = 40;
|
||||
|
||||
const colWidths = new Array(cols).fill(0);
|
||||
const rowHeights: number[] = [];
|
||||
|
||||
roots.forEach((root, idx) => {
|
||||
const col = idx % cols;
|
||||
const row = Math.floor(idx / cols);
|
||||
colWidths[col] = Math.max(colWidths[col], root.width);
|
||||
if (rowHeights[row] === undefined) rowHeights[row] = 0;
|
||||
rowHeights[row] = Math.max(rowHeights[row], root.height);
|
||||
});
|
||||
|
||||
let yOffset = 40;
|
||||
let maxWidth = 0;
|
||||
|
||||
for (let r = 0; r < rowHeights.length; r++) {
|
||||
let xOffset = 40;
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = r * cols + c;
|
||||
if (idx >= roots.length) break;
|
||||
const root = roots[idx];
|
||||
|
||||
root.x = xOffset;
|
||||
root.y = yOffset;
|
||||
|
||||
assignGlobalCoordinates(root, 0, 0);
|
||||
xOffset += colWidths[c] + gap;
|
||||
}
|
||||
yOffset += rowHeights[r] + gap;
|
||||
maxWidth = Math.max(maxWidth, xOffset);
|
||||
}
|
||||
|
||||
return { roots, nodeMap };
|
||||
}, [locations]);
|
||||
|
||||
// Connection lines computation
|
||||
const connectionLines = useMemo(() => {
|
||||
const lines: {
|
||||
id: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
portalName?: string;
|
||||
portalState?: string;
|
||||
vision?: number;
|
||||
sound?: number;
|
||||
bidirectional?: boolean;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
locations.forEach((loc) => {
|
||||
const nodeA = nodeMap.get(loc.id);
|
||||
if (!nodeA) return;
|
||||
|
||||
loc.connections?.forEach((conn, cIdx) => {
|
||||
const nodeB = nodeMap.get(conn.targetId);
|
||||
if (!nodeB) return;
|
||||
|
||||
const isParentChild =
|
||||
loc.parentId === conn.targetId || nodeB.location.parentId === loc.id;
|
||||
if (isParentChild) return;
|
||||
|
||||
const sortedIds = [loc.id, conn.targetId].sort();
|
||||
const key = conn.bidirectional
|
||||
? `bidi-${sortedIds[0]}-${sortedIds[1]}`
|
||||
: `uni-${loc.id}-${conn.targetId}`;
|
||||
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
const x1 = nodeA.x + nodeA.width / 2;
|
||||
const y1 = nodeA.y + nodeA.height / 2;
|
||||
const x2 = nodeB.x + nodeB.width / 2;
|
||||
const y2 = nodeB.y + nodeB.height / 2;
|
||||
|
||||
lines.push({
|
||||
id: `${loc.id}-${conn.targetId}-${cIdx}`,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
portalName: conn.portalName,
|
||||
portalState: conn.portalStateDescriptor,
|
||||
vision: conn.visionProp,
|
||||
sound: conn.soundProp,
|
||||
bidirectional: conn.bidirectional,
|
||||
sourceId: loc.id,
|
||||
targetId: conn.targetId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return lines;
|
||||
}, [locations, nodeMap]);
|
||||
|
||||
// Recursive render helper for boxes
|
||||
const renderNode = (node: BoxNode) => {
|
||||
const isSelected = selectedLocId === node.id;
|
||||
|
||||
// Find parent connection details
|
||||
const parentLoc = locations.find((l) => l.id === node.location.parentId);
|
||||
const childToParentConn = node.location.parentId
|
||||
? node.location.connections?.find(
|
||||
(c) => c.targetId === node.location.parentId,
|
||||
)
|
||||
: undefined;
|
||||
const parentToChildConn =
|
||||
node.location.parentId && parentLoc
|
||||
? parentLoc.connections?.find((c) => c.targetId === node.id)
|
||||
: undefined;
|
||||
|
||||
const hasParentConn = !!(childToParentConn || parentToChildConn);
|
||||
const portalName =
|
||||
childToParentConn?.portalName || parentToChildConn?.portalName;
|
||||
|
||||
return (
|
||||
<g key={node.id}>
|
||||
{/* Box */}
|
||||
<rect
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rx={8}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectLocId(node.id);
|
||||
}}
|
||||
className={`transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "fill-primary/5 stroke-primary stroke-2"
|
||||
: node.children.length > 0
|
||||
? "fill-secondary/5 stroke-border/40 hover:stroke-border/80"
|
||||
: "fill-secondary/20 stroke-border/40 hover:stroke-foreground/40 hover:fill-secondary/30"
|
||||
}`}
|
||||
style={{ strokeWidth: isSelected ? 2 : 1 }}
|
||||
strokeDasharray={
|
||||
hasParentConn
|
||||
? "4, 4"
|
||||
: node.children.length > 0
|
||||
? "3, 3"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={node.x + 12}
|
||||
y={node.y + 24}
|
||||
className={`text-xs font-mono select-none font-semibold ${
|
||||
isSelected ? "fill-primary font-bold" : "fill-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{node.name}
|
||||
</text>
|
||||
|
||||
{/* Portal Name on Boundary of Child */}
|
||||
{hasParentConn && portalName && (
|
||||
<g transform={`translate(${node.x + node.width / 2}, ${node.y})`}>
|
||||
<rect
|
||||
x={-((portalName.length * 5) / 2) - 4}
|
||||
y={-6}
|
||||
width={portalName.length * 5 + 8}
|
||||
height={12}
|
||||
rx={3}
|
||||
className="fill-zinc-900 stroke stroke-border/40"
|
||||
style={{ strokeWidth: 0.5 }}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y={3}
|
||||
className="text-[8px] font-mono fill-primary font-semibold select-none"
|
||||
>
|
||||
{portalName}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Render children inside */}
|
||||
{node.children.map((child) => renderNode(child))}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
{/* Controls */}
|
||||
<div className="flex gap-1 bg-secondary/15 p-1 rounded border border-border/10">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setZoom((z) => Math.min(4, z + 0.1))}
|
||||
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setZoom((z) => Math.max(0.3, z - 0.1))}
|
||||
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}}
|
||||
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
|
||||
title="Reset view"
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas container */}
|
||||
<div className="relative border border-border/35 bg-zinc-950/95 rounded-lg overflow-hidden h-[450px]">
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className={`w-full h-full select-none ${
|
||||
isPanning ? "cursor-grabbing" : "cursor-grab"
|
||||
}`}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="grid-pattern"
|
||||
width="40"
|
||||
height="40"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path
|
||||
d="M 40 0 L 0 0 0 40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-muted-foreground/10"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Grid Background */}
|
||||
<rect width="100%" height="100%" fill="url(#grid-pattern)" />
|
||||
|
||||
{/* Draggable/scalable group */}
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
||||
{/* Draw Connection Lines first so they are behind boxes */}
|
||||
{connectionLines.map((line) => {
|
||||
const isSourceSelected = selectedLocId === line.sourceId;
|
||||
const isTargetSelected = selectedLocId === line.targetId;
|
||||
const isHighlighted = isSourceSelected || isTargetSelected;
|
||||
|
||||
const mx = (line.x1 + line.x2) / 2;
|
||||
const my = (line.y1 + line.y2) / 2;
|
||||
const angle =
|
||||
Math.atan2(line.y2 - line.y1, line.x2 - line.x1) *
|
||||
(180 / Math.PI);
|
||||
|
||||
return (
|
||||
<g key={line.id} className="transition-all">
|
||||
{/* Glow path for highlighted connections */}
|
||||
{isHighlighted && (
|
||||
<line
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
className="stroke-primary/30 stroke-[4px] blur-sm animate-pulse"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Connection Line */}
|
||||
<line
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
className={`transition-all ${
|
||||
isHighlighted
|
||||
? "stroke-primary stroke-2"
|
||||
: "stroke-border/40 stroke-1"
|
||||
}`}
|
||||
style={{
|
||||
strokeDasharray: line.bidirectional ? undefined : "4, 4",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Midpoint Direction Arrow for Uni-directional connections */}
|
||||
{!line.bidirectional && (
|
||||
<g transform={`translate(${mx}, ${my}) rotate(${angle})`}>
|
||||
<path
|
||||
d="M -4 -3.5 L 4 0 L -4 3.5 Z"
|
||||
className={
|
||||
isHighlighted
|
||||
? "fill-primary"
|
||||
: "fill-muted-foreground/60"
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Portal Name Label bubble */}
|
||||
{line.portalName && (
|
||||
<g
|
||||
transform={`translate(${mx}, ${my - 12})`}
|
||||
className="cursor-default"
|
||||
>
|
||||
<rect
|
||||
x={-((line.portalName.length * 6) / 2) - 4}
|
||||
y={-8}
|
||||
width={line.portalName.length * 6 + 8}
|
||||
height={16}
|
||||
rx={4}
|
||||
className="fill-background/90 stroke stroke-border/30"
|
||||
style={{ strokeWidth: 0.5 }}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y={3}
|
||||
className="text-[9px] font-mono fill-muted-foreground select-none"
|
||||
>
|
||||
{line.portalName}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Draw Box Nodes recursively */}
|
||||
{roots.map((root) => renderNode(root))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Map Hint Overlay */}
|
||||
<div className="absolute bottom-2 right-2 px-2 py-1 rounded bg-black/60 border border-border/20 text-[9.5px] text-muted-foreground font-mono pointer-events-none">
|
||||
Drag to pan • Scroll or buttons to zoom • Click boxes to select
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
apps/gui/src/components/builder/types.ts
Normal file
50
apps/gui/src/components/builder/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export interface AttributeData {
|
||||
name: string;
|
||||
value: string;
|
||||
visibility: "PUBLIC" | "PRIVATE";
|
||||
allowedEntities: string[];
|
||||
}
|
||||
|
||||
export interface ConnectionData {
|
||||
targetId: string;
|
||||
portalName?: string;
|
||||
portalStateDescriptor?: string;
|
||||
visionProp: number;
|
||||
soundProp: number;
|
||||
bidirectional: boolean;
|
||||
}
|
||||
|
||||
export interface LocationData {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
attributes: AttributeData[];
|
||||
connections: ConnectionData[];
|
||||
}
|
||||
|
||||
export 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;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EntityData {
|
||||
id: string;
|
||||
locationId?: string;
|
||||
attributes: AttributeData[];
|
||||
aliases: Record<string, string>;
|
||||
initialMemories: MemoryData[];
|
||||
isAgent?: boolean;
|
||||
}
|
||||
45
apps/gui/src/components/builder/utils.ts
Normal file
45
apps/gui/src/components/builder/utils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { EntityData, LocationData } from "./types";
|
||||
|
||||
export function getEntityDisplayName(
|
||||
entity: EntityData | undefined,
|
||||
fallbackId: string = "",
|
||||
): string {
|
||||
if (!entity) return fallbackId;
|
||||
const nameAttr = entity.attributes?.find(
|
||||
(a) => a.name.toLowerCase() === "name",
|
||||
);
|
||||
if (nameAttr?.value) {
|
||||
return `${nameAttr.value} (${entity.id})`;
|
||||
}
|
||||
return entity.id;
|
||||
}
|
||||
|
||||
export function getEntityDisplayNameById(
|
||||
id: string,
|
||||
entities: EntityData[],
|
||||
): string {
|
||||
const entity = entities.find((e) => e.id === id);
|
||||
return getEntityDisplayName(entity, id);
|
||||
}
|
||||
|
||||
export function getLocationDisplayName(
|
||||
location: LocationData | undefined,
|
||||
fallbackId: string = "",
|
||||
): string {
|
||||
if (!location) return fallbackId;
|
||||
const nameAttr = location.attributes?.find(
|
||||
(a) => a.name.toLowerCase() === "name",
|
||||
);
|
||||
if (nameAttr?.value) {
|
||||
return `${nameAttr.value} (${location.id})`;
|
||||
}
|
||||
return location.id;
|
||||
}
|
||||
|
||||
export function getLocationDisplayNameById(
|
||||
id: string,
|
||||
locations: LocationData[],
|
||||
): string {
|
||||
const location = locations.find((l) => l.id === id);
|
||||
return getLocationDisplayName(location, id);
|
||||
}
|
||||
@@ -64,7 +64,6 @@ export function ProviderInstancesConfig({
|
||||
"generative",
|
||||
);
|
||||
const [editMaxContext, setEditMaxContext] = useState<number>(32768);
|
||||
const [editEndpointUrl, setEditEndpointUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -77,7 +76,6 @@ export function ProviderInstancesConfig({
|
||||
setEditIsActive(false);
|
||||
setEditType("generative");
|
||||
setEditMaxContext(32768);
|
||||
setEditEndpointUrl("");
|
||||
} else if (selectedInstanceId === "new") {
|
||||
setEditName("");
|
||||
const defaultProvider = "google-genai";
|
||||
@@ -88,7 +86,6 @@ export function ProviderInstancesConfig({
|
||||
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
setEditMaxContext(32768);
|
||||
setEditEndpointUrl("");
|
||||
} else {
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst) {
|
||||
@@ -112,7 +109,6 @@ export function ProviderInstancesConfig({
|
||||
? inst.maxContext
|
||||
: 32768,
|
||||
);
|
||||
setEditEndpointUrl(inst.endpointUrl || "");
|
||||
}
|
||||
}
|
||||
}, [selectedInstanceId, instances, availableProviders]);
|
||||
@@ -153,7 +149,7 @@ export function ProviderInstancesConfig({
|
||||
let targetInstanceId = selectedInstanceId;
|
||||
|
||||
if (selectedInstanceId === "new") {
|
||||
if (editProvider !== "ollama" && !editKey.trim()) {
|
||||
if (!editKey.trim()) {
|
||||
setError("API Key is required for new instances.");
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -161,13 +157,10 @@ export function ProviderInstancesConfig({
|
||||
const created = await createProviderInstance(
|
||||
editName,
|
||||
editProvider,
|
||||
editProvider === "ollama" ? "none" : editKey,
|
||||
editKey,
|
||||
editModel || undefined,
|
||||
editType,
|
||||
editType === "generative" ? editMaxContext : 0,
|
||||
editProvider === "ollama"
|
||||
? editEndpointUrl || "http://localhost:11434"
|
||||
: undefined,
|
||||
);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(created.id);
|
||||
@@ -201,13 +194,10 @@ export function ProviderInstancesConfig({
|
||||
selectedInstanceId,
|
||||
editName,
|
||||
editProvider,
|
||||
editProvider === "ollama" ? "none" : editKey || undefined,
|
||||
editKey || undefined,
|
||||
editModel || undefined,
|
||||
editType,
|
||||
editType === "generative" ? editMaxContext : 0,
|
||||
editProvider === "ollama"
|
||||
? editEndpointUrl || "http://localhost:11434"
|
||||
: undefined,
|
||||
);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(selectedInstanceId);
|
||||
@@ -346,11 +336,11 @@ export function ProviderInstancesConfig({
|
||||
}
|
||||
items={[
|
||||
{
|
||||
label: "Generative (Text Generation)",
|
||||
label: "Generative (Text Completion)",
|
||||
value: "generative",
|
||||
},
|
||||
{
|
||||
label: "Embedding (Vector Embeddings)",
|
||||
label: "Embedding (Vector generation)",
|
||||
value: "embedding",
|
||||
},
|
||||
]}
|
||||
@@ -361,10 +351,10 @@ export function ProviderInstancesConfig({
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="generative">
|
||||
Generative (Text Generation)
|
||||
Generative (Chat / Text Completion)
|
||||
</SelectItem>
|
||||
<SelectItem value="embedding">
|
||||
Embedding (Vector Embeddings)
|
||||
Embedding (Vector generation)
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
@@ -408,36 +398,21 @@ export function ProviderInstancesConfig({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{editProvider !== "ollama" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formKey">API Key</Label>
|
||||
<Input
|
||||
id="formKey"
|
||||
type="password"
|
||||
value={editKey}
|
||||
onChange={(e) => setEditKey(e.target.value)}
|
||||
placeholder={
|
||||
selectedInstanceId === "new"
|
||||
? "AIzaSy..."
|
||||
: "•••••••• (unchanged)"
|
||||
}
|
||||
required={selectedInstanceId === "new"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editProvider === "ollama" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formEndpoint">Endpoint URL</Label>
|
||||
<Input
|
||||
id="formEndpoint"
|
||||
value={editEndpointUrl}
|
||||
onChange={(e) => setEditEndpointUrl(e.target.value)}
|
||||
placeholder="e.g. http://localhost:11434"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formKey">API Key</Label>
|
||||
<Input
|
||||
id="formKey"
|
||||
type="password"
|
||||
value={editKey}
|
||||
onChange={(e) => setEditKey(e.target.value)}
|
||||
placeholder={
|
||||
selectedInstanceId === "new"
|
||||
? "AIzaSy..."
|
||||
: "•••••••• (unchanged)"
|
||||
}
|
||||
required={selectedInstanceId === "new"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formModel">Model Name</Label>
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function HomeView() {
|
||||
export function DashboardView() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
302
apps/gui/src/components/ui/combobox.tsx
Normal file
302
apps/gui/src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root;
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<ComboboxTrigger
|
||||
render={
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-8 flex-wrap items-center gap-1 rounded border-2 bg-input bg-clip-padding px-2.5 py-1 text-sm shadow-sm transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-primary has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm border-2 bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
200
apps/gui/src/components/ui/dropdown-menu.tsx
Normal file
200
apps/gui/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
170
apps/gui/src/components/ui/input-group.tsx
Normal file
170
apps/gui/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]",
|
||||
"h-9 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
};
|
||||
292
apps/gui/src/components/ui/menubar.tsx
Normal file
292
apps/gui/src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar";
|
||||
import { CheckIcon, ChevronRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
|
||||
return (
|
||||
<MenubarPrimitive
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
"flex h-8 items-center gap-0.5 rounded border-2 bg-background p-[3px] shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="menubar-menu" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="menubar-group" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="menubar-portal" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-none select-none hover:bg-primary/15 hover:text-primary aria-expanded:bg-primary/15 aria-expanded:text-primary cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props & {
|
||||
align?: MenuPrimitive.Positioner.Props["align"];
|
||||
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
|
||||
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="menubar-content"
|
||||
className={cn(
|
||||
"min-w-36 rounded border-2 bg-popover p-1 text-popover-foreground shadow-md duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/menubar-item gap-1.5 rounded-sm px-2 py-1.5 text-sm focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary not-data-[variant=destructive]:focus:**:text-primary not-data-[variant=destructive]:data-highlighted:**:text-primary data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-sm py-1.5 pr-2 pl-7 text-sm outline-none select-none focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary focus:**:text-primary data-highlighted:**:text-primary data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-sm py-1.5 pr-2 pl-7 text-sm outline-none select-none focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary focus:**:text-primary data-highlighted:**:text-primary data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-primary group-data-highlighted/menubar-item:text-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Submenu components using SubmenuRoot and SubmenuTrigger
|
||||
function MenubarSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="menubar-sub" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-1.5 rounded-sm px-2 py-1.5 text-sm focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary data-inset:pl-7 data-open:bg-primary/15 data-open:text-primary data-highlighted:text-primary [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto size-4" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props & {
|
||||
align?: MenuPrimitive.Positioner.Props["align"];
|
||||
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
|
||||
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"min-w-32 rounded border-2 bg-popover p-1 text-popover-foreground shadow-md duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
};
|
||||
1071
apps/gui/src/lib/simulation.ts
Normal file
1071
apps/gui/src/lib/simulation.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
||||
import { HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
|
||||
import type { SimSession } from "./types";
|
||||
|
||||
/**
|
||||
* Runs the HandoffEngine for every agent entity that has accumulated enough
|
||||
* buffer entries to warrant a handoff (compression to long-term memory).
|
||||
*/
|
||||
export async function runHandoffResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const handoffEngine = new HandoffEngine(
|
||||
session.handoffProvider,
|
||||
session.embeddingProvider,
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
);
|
||||
|
||||
const entities = Array.from(worldState.entities.values());
|
||||
for (const entity of entities) {
|
||||
if (!entity.isAgent) continue;
|
||||
|
||||
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
|
||||
const maxContext =
|
||||
session.handoffProvider.maxContext !== undefined
|
||||
? session.handoffProvider.maxContext
|
||||
: 32768;
|
||||
|
||||
const trigger = checkHandoffTrigger(
|
||||
entity,
|
||||
bufferEntries,
|
||||
worldState.clock.get(),
|
||||
maxContext,
|
||||
);
|
||||
if (trigger !== "none") {
|
||||
await handoffEngine.runHandoff(
|
||||
entity,
|
||||
bufferEntries,
|
||||
worldState.clock.get(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For every agent that shares a location with another entity they haven't
|
||||
* previously encountered, generates a first-person alias description and
|
||||
* persists it on the viewing entity.
|
||||
*/
|
||||
export async function runAliasResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entities = Array.from(worldState.entities.values());
|
||||
for (const viewer of entities) {
|
||||
if (!viewer.isAgent) continue;
|
||||
if (!viewer.locationId) continue;
|
||||
|
||||
for (const target of entities) {
|
||||
if (viewer.id === target.id) continue;
|
||||
if (
|
||||
target.locationId === viewer.locationId &&
|
||||
!viewer.aliases.has(target.id)
|
||||
) {
|
||||
const alias = await session.aliasGenerator.generate(viewer, target);
|
||||
viewer.aliases.set(target.id, alias);
|
||||
session.coreRepo.saveEntity(viewer, worldState.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Load .env from monorepo root or apps/gui/
|
||||
const cwd = process.cwd();
|
||||
const envCandidates = [
|
||||
path.resolve(cwd, ".env"),
|
||||
path.resolve(cwd, "../../.env"),
|
||||
];
|
||||
for (const c of envCandidates) {
|
||||
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
|
||||
dotenv.config({ path: c });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Barrel entry point for the simulation module.
|
||||
*
|
||||
* Consumers import from "@/lib/simulation" exactly as before — no import
|
||||
* paths need to change anywhere in the codebase.
|
||||
*/
|
||||
import { SimulationManager } from "./simulation-manager";
|
||||
|
||||
export const simulationManager = new SimulationManager();
|
||||
|
||||
export type {
|
||||
SimSnapshot,
|
||||
EntityInfo,
|
||||
LogEntry,
|
||||
IntentInfo,
|
||||
WaitingContext,
|
||||
} from "../simulation-types";
|
||||
@@ -1,205 +0,0 @@
|
||||
import {
|
||||
GeminiProvider,
|
||||
MockLLMProvider,
|
||||
OllamaProvider,
|
||||
OllamaEmbeddingProvider,
|
||||
ProviderManager,
|
||||
OpenRouterProvider,
|
||||
AnthropicProvider,
|
||||
OpenAIProvider,
|
||||
OpenAIEmbeddingProvider,
|
||||
GeminiEmbeddingProvider,
|
||||
MockEmbeddingProvider,
|
||||
} from "@omnia/llm";
|
||||
import type {
|
||||
ILLMProvider,
|
||||
IEmbeddingProvider,
|
||||
ModelProviderInstance,
|
||||
} from "@omnia/llm";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ResolvedProviders {
|
||||
actorProvider: ILLMProvider;
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
handoffProvider: ILLMProvider;
|
||||
embeddingProvider: IEmbeddingProvider;
|
||||
}
|
||||
|
||||
export interface ProviderResolverOptions {
|
||||
/**
|
||||
* Pre-resolved generative instance to fall back to when ProviderManager has
|
||||
* no active generative provider (e.g. when the caller already validated a
|
||||
* specific provider during session creation).
|
||||
*/
|
||||
fallbackInstance?: ModelProviderInstance | null;
|
||||
/**
|
||||
* When true, throws an Error if no provider can be resolved for a task.
|
||||
* When false (default), falls back silently to MockLLMProvider / MockEmbeddingProvider.
|
||||
*/
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider {
|
||||
if (inst.providerName === "google-genai") {
|
||||
return new GeminiProvider(
|
||||
inst.apiKey,
|
||||
inst.modelName,
|
||||
inst.name,
|
||||
inst.maxContext,
|
||||
);
|
||||
} else if (inst.providerName === "openrouter") {
|
||||
return new OpenRouterProvider(
|
||||
inst.apiKey,
|
||||
inst.modelName,
|
||||
inst.name,
|
||||
inst.maxContext,
|
||||
);
|
||||
} else if (inst.providerName === "ollama") {
|
||||
return new OllamaProvider(
|
||||
inst.endpointUrl,
|
||||
inst.modelName,
|
||||
inst.name,
|
||||
inst.maxContext,
|
||||
);
|
||||
} else if (inst.providerName === "anthropic") {
|
||||
return new AnthropicProvider(
|
||||
inst.apiKey,
|
||||
inst.modelName,
|
||||
inst.name,
|
||||
inst.maxContext,
|
||||
);
|
||||
} else if (inst.providerName === "openai") {
|
||||
return new OpenAIProvider(
|
||||
inst.apiKey,
|
||||
inst.modelName,
|
||||
inst.name,
|
||||
inst.maxContext,
|
||||
);
|
||||
}
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
|
||||
function buildEmbeddingProvider(
|
||||
inst: ModelProviderInstance,
|
||||
): IEmbeddingProvider {
|
||||
if (inst.providerName === "google-genai") {
|
||||
return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName);
|
||||
} else if (inst.providerName === "ollama") {
|
||||
return new OllamaEmbeddingProvider(inst.endpointUrl, inst.modelName);
|
||||
} else if (inst.providerName === "openai") {
|
||||
return new OpenAIEmbeddingProvider(inst.apiKey, inst.modelName);
|
||||
}
|
||||
return new MockEmbeddingProvider(inst.modelName);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves all six LLM + embedding providers needed for a simulation session.
|
||||
*
|
||||
* Resolution order for each generative task:
|
||||
* 1. Task-specific mapping from ProviderManager (via `mappings[task]`)
|
||||
* 2. ProviderManager active generative instance
|
||||
* 3. `fallbackInstance` (if supplied)
|
||||
* 4. GOOGLE_API_KEY env var → auto-creates a temporary GeminiProvider
|
||||
* 5. Throws (if `required`) or returns MockLLMProvider
|
||||
*/
|
||||
export function resolveProviders(
|
||||
mappings: Record<string, string>,
|
||||
options: ProviderResolverOptions = {},
|
||||
): ResolvedProviders {
|
||||
const { fallbackInstance = null, required = false } = options;
|
||||
const list = ProviderManager.list();
|
||||
const activeGenerative =
|
||||
ProviderManager.getActive("generative") ?? fallbackInstance ?? null;
|
||||
|
||||
const resolveGenerative = (task: string): ILLMProvider => {
|
||||
const mappedId = mappings[task];
|
||||
let inst: ModelProviderInstance | null = mappedId
|
||||
? (list.find((p) => p.id === mappedId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!inst || inst.type !== "generative") {
|
||||
inst = activeGenerative;
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
inst = ProviderManager.create(
|
||||
"Default (Env)",
|
||||
"google-genai",
|
||||
envKey,
|
||||
undefined,
|
||||
"generative",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
if (required) {
|
||||
throw new Error(
|
||||
`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`,
|
||||
);
|
||||
}
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
|
||||
return buildLLMProvider(inst);
|
||||
};
|
||||
|
||||
const resolveEmbedding = (): IEmbeddingProvider => {
|
||||
const mappedId = mappings["embeddings"];
|
||||
let inst: ModelProviderInstance | null = mappedId
|
||||
? (list.find((p) => p.id === mappedId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!inst || inst.type !== "embedding") {
|
||||
inst = ProviderManager.getActive("embedding");
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
inst = ProviderManager.create(
|
||||
"Default Embed (Env)",
|
||||
"google-genai",
|
||||
envKey,
|
||||
"gemini-embedding-001",
|
||||
"embedding",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
if (required) {
|
||||
throw new Error(
|
||||
`No active Embedding Provider Instance found. Please configure an embedding key in Settings first.`,
|
||||
);
|
||||
}
|
||||
return new MockEmbeddingProvider(undefined);
|
||||
}
|
||||
|
||||
return buildEmbeddingProvider(inst);
|
||||
};
|
||||
|
||||
return {
|
||||
actorProvider: resolveGenerative("actor-prose"),
|
||||
validatorProvider: resolveGenerative("llm-validator"),
|
||||
decoderProvider: resolveGenerative("intent-decoder"),
|
||||
timedeltaProvider: resolveGenerative("timedelta"),
|
||||
handoffProvider: resolveGenerative("handoff"),
|
||||
embeddingProvider: resolveEmbedding(),
|
||||
};
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import type { SimSession, SavedState } from "./types";
|
||||
import type { SimSnapshot } from "../simulation-types";
|
||||
|
||||
export const DATA_DIR = path.resolve(process.cwd(), "data");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level read/write helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function loadSessionState(
|
||||
db: Database.Database,
|
||||
id: string,
|
||||
): SavedState | null {
|
||||
try {
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS gui_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
state_json TEXT
|
||||
)`,
|
||||
).run();
|
||||
const row = db
|
||||
.prepare(`SELECT state_json FROM gui_meta WHERE id = ?`)
|
||||
.get(id) as { state_json: string } | undefined;
|
||||
return row ? (JSON.parse(row.state_json) as SavedState) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSession(session: SimSession): void {
|
||||
const state: SavedState = {
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
entities: session.entities,
|
||||
playerEntityId: session.playerEntityId,
|
||||
entityIndex: session.entityIndex,
|
||||
status: session.status,
|
||||
error: session.error,
|
||||
waitingEntity: session.waitingEntity,
|
||||
aliasDoneForTurn: session.aliasDoneForTurn,
|
||||
log: session.log,
|
||||
providerMappings: session.providerMappings,
|
||||
};
|
||||
|
||||
session.db
|
||||
.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS gui_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
state_json TEXT
|
||||
)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
session.db
|
||||
.prepare(
|
||||
`INSERT INTO gui_meta (id, state_json)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json`,
|
||||
)
|
||||
.run(session.worldInstanceId, JSON.stringify(state));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session file management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function deleteSessionFile(id: string): void {
|
||||
const dbPath = path.join(DATA_DIR, `${id}.db`);
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete session file ${dbPath}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all saved simulation snapshots by scanning the data directory.
|
||||
* Active in-memory sessions are snapshotted via the provided callback;
|
||||
* inactive ones are read directly from their `.db` files.
|
||||
*/
|
||||
export function listSavedSessions(
|
||||
activeSessions: Map<string, SimSession>,
|
||||
snapshotFn: (session: SimSession) => SimSnapshot,
|
||||
): SimSnapshot[] {
|
||||
if (!fs.existsSync(DATA_DIR)) return [];
|
||||
|
||||
const snapshots: SimSnapshot[] = [];
|
||||
const files = fs
|
||||
.readdirSync(DATA_DIR)
|
||||
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
|
||||
|
||||
for (const file of files) {
|
||||
const id = file.replace(".db", "");
|
||||
const dbPath = path.join(DATA_DIR, file);
|
||||
|
||||
const active = activeSessions.get(id);
|
||||
if (active) {
|
||||
snapshots.push(snapshotFn(active));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
const state = loadSessionState(db, id);
|
||||
db.close();
|
||||
|
||||
if (state) {
|
||||
snapshots.push({
|
||||
id,
|
||||
status: state.status,
|
||||
turn: state.turn,
|
||||
maxTurns: state.maxTurns,
|
||||
scenarioName: state.scenarioName,
|
||||
scenarioDescription: state.scenarioDescription,
|
||||
entities: state.entities || [],
|
||||
log: state.log || [],
|
||||
entityIndex: state.entityIndex,
|
||||
waitingEntity: state.waitingEntity,
|
||||
error: state.error,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* skip corrupt / in-use db files */
|
||||
}
|
||||
}
|
||||
|
||||
return snapshots.sort((a, b) => {
|
||||
const tsA = parseInt(a.id.replace("sim-", ""), 10) || 0;
|
||||
const tsB = parseInt(b.id.replace("sim-", ""), 10) || 0;
|
||||
return tsB - tsA;
|
||||
});
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
import "./env"; // Must be first — loads .env before any code reads process.env
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { SQLiteRepository } from "@omnia/core";
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import {
|
||||
ProviderManager,
|
||||
GeminiEmbeddingProvider,
|
||||
MockEmbeddingProvider,
|
||||
} from "@omnia/llm";
|
||||
import type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm";
|
||||
import { ScenarioLoader } from "@omnia/scenario";
|
||||
import type { SimSnapshot } from "../simulation-types";
|
||||
import type { SimSession, EntityInfo } from "./types";
|
||||
import { resolveProviders } from "./provider-resolver";
|
||||
import {
|
||||
DATA_DIR,
|
||||
loadSessionState,
|
||||
saveSession,
|
||||
listSavedSessions,
|
||||
deleteSessionFile,
|
||||
} from "./session-store";
|
||||
import {
|
||||
preparePlayerTurn,
|
||||
processNpcTurn,
|
||||
executePlayerAction,
|
||||
} from "./turn-executor";
|
||||
import { runAliasResolution, runHandoffResolution } from "./alias-handoff";
|
||||
|
||||
export class SimulationManager {
|
||||
private sessions = new Map<string, SimSession>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async create(
|
||||
scenarioPath: string,
|
||||
playEntityName?: string,
|
||||
providerInstanceId?: string,
|
||||
): Promise<SimSnapshot> {
|
||||
// Resolve or validate the active generative provider upfront so we can
|
||||
// return a clean error snapshot before touching the filesystem.
|
||||
let activeInstance: ModelProviderInstance | null = providerInstanceId
|
||||
? ProviderManager.list().find((p) => p.id === providerInstanceId) || null
|
||||
: ProviderManager.getActive("generative");
|
||||
|
||||
if (!activeInstance) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
activeInstance = ProviderManager.create(
|
||||
"Default (Env)",
|
||||
"google-genai",
|
||||
envKey,
|
||||
undefined,
|
||||
"generative",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!activeInstance) {
|
||||
return {
|
||||
id: "",
|
||||
status: "error",
|
||||
turn: 0,
|
||||
maxTurns: 20,
|
||||
scenarioName: "",
|
||||
scenarioDescription: "",
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
error:
|
||||
"No active LLM Provider Instance found. Please configure a key in Settings first.",
|
||||
};
|
||||
}
|
||||
|
||||
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
|
||||
const id = `sim-${Date.now()}`;
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const dbPath = path.join(DATA_DIR, `${id}.db`);
|
||||
const db = new Database(dbPath);
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
const loader = new ScenarioLoader(coreRepo, bufferRepo);
|
||||
|
||||
const worldInstanceId = id;
|
||||
await loader.initializeWorld(scenarioJson, worldInstanceId);
|
||||
|
||||
const worldState = coreRepo.loadWorldState(worldInstanceId);
|
||||
if (!worldState) {
|
||||
db.close();
|
||||
return {
|
||||
id: "",
|
||||
status: "error",
|
||||
turn: 0,
|
||||
maxTurns: 20,
|
||||
scenarioName: "",
|
||||
scenarioDescription: "",
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
error: "Failed to load world state after initialization.",
|
||||
};
|
||||
}
|
||||
|
||||
// Build entity list
|
||||
const rawEntities = Array.from(worldState.entities.values());
|
||||
const entityInfos: EntityInfo[] = rawEntities.map((e) => ({
|
||||
id: e.id,
|
||||
name: (e.attributes.get("name")?.getValue() as string) || e.id,
|
||||
isPlayer: false,
|
||||
isAgent: e.isAgent,
|
||||
}));
|
||||
|
||||
// Resolve player entity (exact match → name match → fuzzy)
|
||||
let playerEntityId: string | undefined;
|
||||
if (playEntityName) {
|
||||
let matched = worldState.getEntity(playEntityName);
|
||||
if (!matched) {
|
||||
for (const ent of rawEntities) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue() as
|
||||
string | undefined;
|
||||
if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
|
||||
matched = ent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const ent of rawEntities) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue() as
|
||||
string | undefined;
|
||||
if (
|
||||
nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
|
||||
ent.id.toLowerCase().includes(playEntityName.toLowerCase())
|
||||
) {
|
||||
matched = ent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
playerEntityId = matched.id;
|
||||
const info = entityInfos.find((e) => e.id === matched!.id);
|
||||
if (info) info.isPlayer = true;
|
||||
}
|
||||
}
|
||||
|
||||
const mappings = ProviderManager.getMappings();
|
||||
const {
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
} = resolveProviders(mappings, { fallbackInstance: activeInstance });
|
||||
|
||||
const architect = new Architect(
|
||||
{ validator: validatorProvider, timedelta: timedeltaProvider },
|
||||
coreRepo,
|
||||
);
|
||||
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
|
||||
|
||||
const session: SimSession = {
|
||||
db,
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId,
|
||||
scenarioName: scenarioJson.name,
|
||||
scenarioDescription: scenarioJson.description || "",
|
||||
turn: 1,
|
||||
maxTurns: 20,
|
||||
entities: entityInfos,
|
||||
playerEntityId,
|
||||
entityIndex: 0,
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: [],
|
||||
status: "running",
|
||||
aliasDoneForTurn: false,
|
||||
providerMappings: mappings,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
async load(id: string): Promise<SimSnapshot | null> {
|
||||
const active = this.sessions.get(id);
|
||||
if (active) return this.snapshot(active);
|
||||
|
||||
const dbPath = path.join(DATA_DIR, `${id}.db`);
|
||||
if (!fs.existsSync(dbPath)) return null;
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
const state = loadSessionState(db, id);
|
||||
if (!state) {
|
||||
db.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const mappings = state.providerMappings || {};
|
||||
const {
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
} = resolveProviders(mappings, { required: true });
|
||||
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
const architect = new Architect(
|
||||
{ validator: validatorProvider, timedelta: timedeltaProvider },
|
||||
coreRepo,
|
||||
);
|
||||
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
|
||||
|
||||
const session: SimSession = {
|
||||
db,
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId: id,
|
||||
scenarioName: state.scenarioName,
|
||||
scenarioDescription: state.scenarioDescription,
|
||||
turn: state.turn,
|
||||
maxTurns: state.maxTurns,
|
||||
entities: state.entities || [],
|
||||
playerEntityId: state.playerEntityId,
|
||||
entityIndex: state.entityIndex,
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: state.log || [],
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
waitingEntity: state.waitingEntity,
|
||||
aliasDoneForTurn: state.aliasDoneForTurn || false,
|
||||
providerMappings: mappings,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
return this.snapshot(session);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load session ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) {
|
||||
session.db.close();
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
deleteSession(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) {
|
||||
session.db.close();
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
deleteSessionFile(id);
|
||||
}
|
||||
|
||||
listSavedSessions(): SimSnapshot[] {
|
||||
return listSavedSessions(this.sessions, (s) => this.snapshot(s));
|
||||
}
|
||||
|
||||
getSnapshot(id: string): SimSnapshot | null {
|
||||
const session = this.sessions.get(id);
|
||||
return session ? this.snapshot(session) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulation stepping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async step(id: string): Promise<SimSnapshot | null> {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
if (session.status !== "running") return this.snapshot(session);
|
||||
|
||||
try {
|
||||
if (session.turn > session.maxTurns) {
|
||||
session.status = "done";
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
// Start of turn: alias + handoff resolution before any entity acts
|
||||
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
|
||||
await runAliasResolution(session);
|
||||
await runHandoffResolution(session);
|
||||
session.aliasDoneForTurn = true;
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
// End of turn: advance to next turn
|
||||
if (session.entityIndex >= session.entities.length) {
|
||||
session.turn++;
|
||||
session.entityIndex = 0;
|
||||
session.aliasDoneForTurn = false;
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
const info = session.entities[session.entityIndex];
|
||||
|
||||
if (!info.isAgent) {
|
||||
session.entityIndex++;
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
if (info.isPlayer) {
|
||||
await preparePlayerTurn(session, info);
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
await processNpcTurn(session, info);
|
||||
session.entityIndex++;
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
async submitPlayerAction(
|
||||
id: string,
|
||||
prose: string,
|
||||
): Promise<SimSnapshot | null> {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
if (session.status !== "waiting_player") return this.snapshot(session);
|
||||
if (!session.waitingEntity) return this.snapshot(session);
|
||||
|
||||
const ctx = session.waitingEntity;
|
||||
session.waitingEntity = undefined;
|
||||
session.status = "running";
|
||||
|
||||
try {
|
||||
await executePlayerAction(session, ctx, prose);
|
||||
session.entityIndex++;
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async regenerateAllEmbeddings(newProviderInstanceId?: string): Promise<void> {
|
||||
if (!fs.existsSync(DATA_DIR)) return;
|
||||
|
||||
const files = fs
|
||||
.readdirSync(DATA_DIR)
|
||||
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
|
||||
|
||||
const list = ProviderManager.list();
|
||||
let inst = newProviderInstanceId
|
||||
? (list.find((p) => p.id === newProviderInstanceId) ?? null)
|
||||
: null;
|
||||
if (!inst || inst.type !== "embedding") {
|
||||
inst = ProviderManager.getActive("embedding");
|
||||
}
|
||||
|
||||
const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || "";
|
||||
const providerName = inst ? inst.providerName : "google-genai";
|
||||
const modelName = inst ? inst.modelName : undefined;
|
||||
|
||||
const embeddingProvider: IEmbeddingProvider =
|
||||
providerName === "google-genai"
|
||||
? new GeminiEmbeddingProvider(key, modelName)
|
||||
: new MockEmbeddingProvider(modelName);
|
||||
|
||||
for (const file of files) {
|
||||
const dbPath = path.join(DATA_DIR, file);
|
||||
const fileId = file.replace(".db", "");
|
||||
const activeSession = this.sessions.get(fileId);
|
||||
const db = activeSession ? activeSession.db : new Database(dbPath);
|
||||
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(`SELECT id, content FROM ledger_entries`)
|
||||
.all() as { id: string; content: string }[];
|
||||
|
||||
for (const row of rows) {
|
||||
const vector = await embeddingProvider.embed(row.content);
|
||||
const buffer = Buffer.from(new Float32Array(vector).buffer);
|
||||
db.prepare(
|
||||
`UPDATE ledger_entries SET embedding = ? WHERE id = ?`,
|
||||
).run(buffer, row.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to regenerate embeddings for ${file}:`, err);
|
||||
} finally {
|
||||
if (!activeSession) db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private snapshot(session: SimSession): SimSnapshot {
|
||||
return {
|
||||
id: session.worldInstanceId,
|
||||
status: session.status,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
entities: session.entities,
|
||||
log: session.log,
|
||||
entityIndex: session.entityIndex,
|
||||
waitingEntity: session.waitingEntity,
|
||||
error: session.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import {
|
||||
ActorAgent,
|
||||
ActorPromptBuilder,
|
||||
buildBufferEntryForIntent,
|
||||
} from "@omnia/actor";
|
||||
import type { IActorProseGenerator } from "@omnia/actor";
|
||||
import type { SimSession } from "./types";
|
||||
import type {
|
||||
EntityInfo,
|
||||
IntentInfo,
|
||||
LogEntry,
|
||||
WaitingContext,
|
||||
} from "../simulation-types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Prose generator that returns a fixed player-supplied string verbatim. */
|
||||
class FixedProseGenerator implements IActorProseGenerator {
|
||||
constructor(private prose: string) {}
|
||||
|
||||
async generate(
|
||||
entityId: string,
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
): Promise<string> {
|
||||
void entityId;
|
||||
void systemPrompt;
|
||||
void userContext;
|
||||
return this.prose;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes every intent produced by an actor turn:
|
||||
* - Validates via Architect
|
||||
* - Appends to actor's own buffer
|
||||
* - Fan-outs to co-located observers for dialogue/action intents
|
||||
*
|
||||
* Extracted to eliminate verbatim duplication between NPC and player paths.
|
||||
*/
|
||||
async function processIntents(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
intents: any[],
|
||||
actorEntityId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
entity: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
worldState: any,
|
||||
session: SimSession,
|
||||
): Promise<IntentInfo[]> {
|
||||
const intentInfos: IntentInfo[] = [];
|
||||
|
||||
for (const intent of intents) {
|
||||
const outcome = await session.architect.processIntent(worldState, intent);
|
||||
const ts = worldState.clock.get().toISOString();
|
||||
|
||||
intentInfos.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
selfDescription: intent.selfDescription,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
|
||||
});
|
||||
|
||||
const actorEntry = buildBufferEntryForIntent(intent, ts, entity.locationId);
|
||||
if (intent.type === "action") {
|
||||
actorEntry.outcome = { isValid: outcome.isValid, reason: outcome.reason };
|
||||
}
|
||||
session.bufferRepo.save(actorEntry);
|
||||
|
||||
// Fan-out observable events to co-located entities
|
||||
if (
|
||||
entity.locationId &&
|
||||
(intent.type === "dialogue" || intent.type === "action")
|
||||
) {
|
||||
for (const [, other] of worldState.entities) {
|
||||
if (
|
||||
other.id !== actorEntityId &&
|
||||
other.locationId === entity.locationId
|
||||
) {
|
||||
const observerEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
ts,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
observerEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intentInfos;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported turn functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds the prompt for the player entity and sets the session to
|
||||
* `waiting_player` so the next client call can supply the prose.
|
||||
*/
|
||||
export async function preparePlayerTurn(
|
||||
session: SimSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const promptBuilder = new ActorPromptBuilder(
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
);
|
||||
const { systemPrompt, userContext } = promptBuilder.build(worldState, entity);
|
||||
|
||||
session.waitingEntity = {
|
||||
entityId: info.id,
|
||||
name: info.name,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
};
|
||||
session.status = "waiting_player";
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an autonomous NPC turn: generates prose via ActorAgent, validates
|
||||
* and persists all intents, and appends a LogEntry to the session.
|
||||
*/
|
||||
export async function processNpcTurn(
|
||||
session: SimSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const actor = new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
);
|
||||
const result = await actor.act(worldState, entity);
|
||||
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
entityId: info.id,
|
||||
entityName: info.name,
|
||||
narrativeProse: result.narrativeProse,
|
||||
intents: [],
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
};
|
||||
|
||||
if (
|
||||
session.actorProvider.lastCalls &&
|
||||
session.actorProvider.lastCalls.length > 0
|
||||
) {
|
||||
const actorCall =
|
||||
session.actorProvider.lastCalls[
|
||||
session.actorProvider.lastCalls.length - 1
|
||||
];
|
||||
entry.rawPrompt = {
|
||||
systemPrompt: actorCall.systemPrompt,
|
||||
userContext: actorCall.userContext,
|
||||
};
|
||||
entry.usage = actorCall.usage;
|
||||
}
|
||||
|
||||
if (
|
||||
session.decoderProvider.lastCalls &&
|
||||
session.decoderProvider.lastCalls.length > 0
|
||||
) {
|
||||
const decoderCall =
|
||||
session.decoderProvider.lastCalls[
|
||||
session.decoderProvider.lastCalls.length - 1
|
||||
];
|
||||
entry.decoderPrompt = {
|
||||
systemPrompt: decoderCall.systemPrompt,
|
||||
userContext: decoderCall.userContext,
|
||||
};
|
||||
entry.decoderUsage = decoderCall.usage;
|
||||
}
|
||||
|
||||
entry.intents = await processIntents(
|
||||
result.intents.intents,
|
||||
info.id,
|
||||
entity,
|
||||
worldState,
|
||||
session,
|
||||
);
|
||||
|
||||
session.log.push(entry);
|
||||
session.coreRepo.saveWorldState(worldState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the player's turn using the prose they supplied.
|
||||
* Uses a `FixedProseGenerator` so the ActorAgent bypasses its LLM call and
|
||||
* returns the player's text directly.
|
||||
*/
|
||||
export async function executePlayerAction(
|
||||
session: SimSession,
|
||||
ctx: WaitingContext,
|
||||
prose: string,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(ctx.entityId);
|
||||
if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
|
||||
|
||||
const playerActor = new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
new FixedProseGenerator(prose),
|
||||
);
|
||||
|
||||
const result = await playerActor.act(worldState, entity);
|
||||
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
entityId: ctx.entityId,
|
||||
entityName: ctx.name,
|
||||
narrativeProse: result.narrativeProse,
|
||||
intents: [],
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
rawPrompt: {
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
userContext: ctx.userContext,
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
session.decoderProvider.lastCalls &&
|
||||
session.decoderProvider.lastCalls.length > 0
|
||||
) {
|
||||
const call =
|
||||
session.decoderProvider.lastCalls[
|
||||
session.decoderProvider.lastCalls.length - 1
|
||||
];
|
||||
entry.decoderPrompt = {
|
||||
systemPrompt: call.systemPrompt,
|
||||
userContext: call.userContext,
|
||||
};
|
||||
entry.decoderUsage = call.usage;
|
||||
}
|
||||
|
||||
entry.intents = await processIntents(
|
||||
result.intents.intents,
|
||||
ctx.entityId,
|
||||
entity,
|
||||
worldState,
|
||||
session,
|
||||
);
|
||||
|
||||
session.log.push(entry);
|
||||
session.coreRepo.saveWorldState(worldState);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { SQLiteRepository } from "@omnia/core";
|
||||
import type { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import type { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import type { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
|
||||
import type { EntityInfo, LogEntry, WaitingContext } from "../simulation-types";
|
||||
|
||||
export type {
|
||||
EntityInfo,
|
||||
IntentInfo,
|
||||
LogEntry,
|
||||
SimSnapshot,
|
||||
WaitingContext,
|
||||
} from "../simulation-types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted state (written to sqlite gui_meta table as JSON)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SavedState {
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
entities: EntityInfo[];
|
||||
playerEntityId: string | undefined;
|
||||
entityIndex: number;
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
error?: string;
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
log: LogEntry[];
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory session (held in SimulationManager.sessions Map)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SimSession {
|
||||
db: Database.Database;
|
||||
dbPath: string;
|
||||
coreRepo: SQLiteRepository;
|
||||
bufferRepo: BufferRepository;
|
||||
ledgerRepo: LedgerRepository;
|
||||
worldInstanceId: string;
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
entities: EntityInfo[];
|
||||
playerEntityId: string | undefined;
|
||||
entityIndex: number;
|
||||
actorProvider: ILLMProvider;
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
handoffProvider: ILLMProvider;
|
||||
embeddingProvider: IEmbeddingProvider;
|
||||
architect: Architect;
|
||||
aliasGenerator: AliasDeltaGenerator;
|
||||
log: LogEntry[];
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
error?: string;
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
@@ -47,10 +47,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^0.3.11",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/ollama": "^0.2.3",
|
||||
"@langchain/openai": "^0.3.17",
|
||||
"@langchain/openrouter": "^0.4.3",
|
||||
"@types/node": "^20.19.43",
|
||||
"dotenv": "^17.4.2"
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
# @omnia/llm
|
||||
|
||||
LLM abstraction layer providing pluggable, database-backed provider instances for generative and embedding tasks.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The system is built around three layers:
|
||||
|
||||
1. **Interfaces** — contracts that all providers implement
|
||||
2. **Provider Manager** — SQLite-backed CRUD for persisted provider instances
|
||||
3. **Provider Resolver** — runtime instantiation of concrete provider classes from stored instances
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Interfaces
|
||||
ILP["ILLMProvider"]
|
||||
IEP["IEmbeddingProvider"]
|
||||
MPI["ModelProviderInstance"]
|
||||
end
|
||||
|
||||
subgraph Concrete Providers
|
||||
GP["GeminiProvider"]
|
||||
ORP["OpenRouterProvider"]
|
||||
MP["MockLLMProvider"]
|
||||
GEP["GeminiEmbeddingProvider"]
|
||||
MEP["MockEmbeddingProvider"]
|
||||
end
|
||||
|
||||
subgraph Storage
|
||||
PM["ProviderManager"]
|
||||
DB[("settings.db\nprovider_instances")]
|
||||
DBMAP[("settings.db\nprovider_mappings")]
|
||||
end
|
||||
|
||||
subgraph Resolution
|
||||
PR["resolveProviders()"]
|
||||
end
|
||||
|
||||
GP -->|implements| ILP
|
||||
ORP -->|implements| ILP
|
||||
MP -->|implements| ILP
|
||||
GEP -->|implements| IEP
|
||||
MEP -->|implements| IEP
|
||||
|
||||
PM -->|reads/writes| DB
|
||||
PM -->|reads/writes| DBMAP
|
||||
PM -->|returns| MPI
|
||||
|
||||
PR -->|queries| PM
|
||||
PR -->|instantiates| GP
|
||||
PR -->|instantiates| ORP
|
||||
PR -->|instantiates| GEP
|
||||
PR -->|fallback| MP
|
||||
PR -->|fallback| MEP
|
||||
```
|
||||
|
||||
## Core Interfaces
|
||||
|
||||
Defined in [`llm.ts`](src/llm.ts):
|
||||
|
||||
### `ILLMProvider`
|
||||
|
||||
The primary contract for generative (text-to-structured-data) providers.
|
||||
|
||||
| Member | Type | Description |
|
||||
| ---------------------------------------- | ------------------------- | ---------------------------------------------------------- |
|
||||
| `providerName` | `string` | Human-readable provider label |
|
||||
| `maxContext` | `number?` | Maximum context window in tokens |
|
||||
| `generateStructuredResponse<T>(request)` | `Promise<LLMResponse<T>>` | Sends a prompt + Zod schema → returns parsed, typed output |
|
||||
| `lastCalls` | `LLMCallRecord[]?` | Audit trail of recent calls (prompts + usage) |
|
||||
|
||||
### `IEmbeddingProvider`
|
||||
|
||||
Contract for text-to-vector embedding providers.
|
||||
|
||||
| Member | Type | Description |
|
||||
| -------------- | ------------------- | -------------------------------------------------- |
|
||||
| `providerName` | `string` | Human-readable provider label |
|
||||
| `embed(text)` | `Promise<number[]>` | Returns a dense vector embedding of the input text |
|
||||
|
||||
### `LLMRequest<T>`
|
||||
|
||||
Input to `generateStructuredResponse`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
systemPrompt: string; // System-level instructions
|
||||
userContext: string; // User/task-specific context
|
||||
schema: T; // Zod schema — output is validated against this
|
||||
temperature?: number; // Sampling temperature (optional)
|
||||
}
|
||||
```
|
||||
|
||||
### `LLMResponse<T>`
|
||||
|
||||
Output from `generateStructuredResponse`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
data?: T; // Parsed, schema-validated output
|
||||
error?: string; // Error message on failure
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### `ModelProviderInstance`
|
||||
|
||||
The persisted configuration record for a single provider instance:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string; // Unique ID ("provider-<timestamp>")
|
||||
name: string; // User-facing name ("Gemini (Env)")
|
||||
providerName: string; // Provider type key ("google-genai" | "openrouter" | "mock")
|
||||
apiKey: string; // API key
|
||||
isActive: boolean; // Whether this is the active instance for its type
|
||||
modelName?: string; // Specific model to use
|
||||
type: "generative" | "embedding"; // Instance category
|
||||
maxContext?: number; // Context window limit
|
||||
}
|
||||
```
|
||||
|
||||
### `ModelProviderMeta`
|
||||
|
||||
Static metadata for each available provider type (used by the UI's provider picker):
|
||||
|
||||
| Member | Type | Description |
|
||||
| ----------------------- | -------- | ------------------------------------------------------------ |
|
||||
| `id` | `string` | `"google-genai"` \| `"openrouter"` \| `"ollama"` \| `"mock"` |
|
||||
| `displayName` | `string` | Human-readable name |
|
||||
| `description` | `string` | Human-readable description |
|
||||
| `defaultModel` | `string` | Default generative model |
|
||||
| `defaultEmbeddingModel` | `string` | Default embedding model |
|
||||
|
||||
The [`AVAILABLE_PROVIDERS`](src/llm.ts#L70-L103) constant exports all four provider metas.
|
||||
|
||||
## Provider Manager
|
||||
|
||||
[`ProviderManager`](src/provider-manager.ts) is a **static class** that provides full CRUD over provider instances, backed by a SQLite database (`data/settings.db` at the workspace root).
|
||||
|
||||
### Storage
|
||||
|
||||
The database is auto-created on first access. The table schema:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS provider_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
providerName TEXT NOT NULL,
|
||||
apiKey TEXT NOT NULL,
|
||||
isActive INTEGER NOT NULL DEFAULT 0,
|
||||
modelName TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'generative',
|
||||
maxContext INTEGER
|
||||
);
|
||||
```
|
||||
|
||||
A second table stores per-task provider overrides:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS provider_mappings (
|
||||
task TEXT PRIMARY KEY,
|
||||
providerInstanceId TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
| Method | Signature | Description |
|
||||
| ------------------------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `list()` | `→ ModelProviderInstance[]` | Returns all saved instances |
|
||||
| `create(name, providerName, apiKey, modelName?, type?, maxContext?)` | `→ ModelProviderInstance` | Creates a new instance. Auto-activates if it's the first of its type |
|
||||
| `delete(id)` | `→ void` | Removes an instance. If it was active, auto-promotes the next instance of the same type |
|
||||
| `setActive(id)` | `→ void` | Deactivates all instances of the same type, then activates the target |
|
||||
| `update(id, name, providerName, apiKey?, modelName?, type?, maxContext?)` | `→ void` | Updates an existing instance. If `apiKey` is empty/omitted, the existing key is preserved |
|
||||
| `getActive(type?)` | `→ ModelProviderInstance \| null` | Returns the currently active instance for the given type (`"generative"` by default) |
|
||||
| `getMappings()` | `→ Record<string, string>` | Returns all task → providerInstanceId mappings |
|
||||
| `setMapping(task, providerInstanceId)` | `→ void` | Sets or removes (if `providerInstanceId` is empty) a task-specific mapping |
|
||||
|
||||
### Active Instance Invariants
|
||||
|
||||
- **Only one active instance per type** — `setActive()` deactivates all sibling instances before activating the target.
|
||||
- **Auto-promotion on delete** — if the deleted instance was active, the first remaining instance of the same type is promoted.
|
||||
- **Auto-activation on create** — if no active instance exists for the type, the new instance is automatically activated.
|
||||
|
||||
### Environment Variable Bootstrap
|
||||
|
||||
On first database access (and if the `provider_instances` table is empty), the manager auto-seeds instances from environment variables:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["getSettingsDb() called"] --> B{"DB has 0 rows?"}
|
||||
B -- No --> Z["Return DB"]
|
||||
B -- Yes --> C{"GOOGLE_API_KEY set?"}
|
||||
C -- Yes --> D["Insert 'Gemini (Env)'\ntype: generative, active: true"]
|
||||
D --> E["Insert 'Gemini Embed (Env)'\ntype: embedding, active: true"]
|
||||
E --> F{"OPENROUTER_API_KEY set?"}
|
||||
C -- No --> F
|
||||
F -- Yes --> G["Insert 'OpenRouter (Env)'\ntype: generative\nactive: only if no Google key"]
|
||||
F -- No --> Z
|
||||
G --> Z
|
||||
```
|
||||
|
||||
This same bootstrap logic is **duplicated** inside `getActive()` as a safety net — if the DB is empty at query time, it re-attempts the same env-var seeding.
|
||||
|
||||
### Fallback Chain in `getActive()`
|
||||
|
||||
When no active row is found for the requested type:
|
||||
|
||||
```
|
||||
1. DB query for isActive=1 AND type=<requested>
|
||||
├── Found → return it
|
||||
└── Not found
|
||||
├── DB is empty → bootstrap from env vars → retry query
|
||||
│ ├── Found → return it
|
||||
│ └── Still empty → promote first row of same type
|
||||
│ ├── Found → activate & return
|
||||
│ └── None → return null
|
||||
└── DB has rows but none active for this type
|
||||
→ promote first row of same type (same as above)
|
||||
|
||||
2. On any DB error (catch block) → direct env var fallback
|
||||
├── GOOGLE_API_KEY → synthetic "Gemini (Env Fallback)" instance
|
||||
├── OPENROUTER_API_KEY → synthetic "OpenRouter (Env Fallback)" instance
|
||||
└── Neither → return null
|
||||
```
|
||||
|
||||
## Available Providers
|
||||
|
||||
### Google Gemini — `GeminiProvider`
|
||||
|
||||
| Property | Value |
|
||||
| --------------------------- | ------------------------------------------------------------ |
|
||||
| **File** | [`providers/google-genai.ts`](src/providers/google-genai.ts) |
|
||||
| **Provider ID** | `google-genai` |
|
||||
| **SDK** | `@langchain/google-genai` (`ChatGoogleGenerativeAI`) |
|
||||
| **Default Model** | `gemini-2.5-flash` |
|
||||
| **Default Embedding Model** | `gemini-embedding-001` |
|
||||
| **Default Max Context** | `32768` |
|
||||
| **Type** | Generative |
|
||||
|
||||
**Key resolution** in the constructor follows this cascade:
|
||||
|
||||
```
|
||||
1. Explicit apiKey argument → use it
|
||||
2. ProviderManager.getActive() → if providerName matches "google-genai"
|
||||
3. GOOGLE_API_KEY env var → final fallback
|
||||
4. None found → throw Error
|
||||
```
|
||||
|
||||
Also exports `GeminiEmbeddingProvider` (implements `IEmbeddingProvider`) using the same key resolution pattern but querying for the `"embedding"` type.
|
||||
|
||||
### Anthropic Claude — `AnthropicProvider`
|
||||
|
||||
| Property | Value |
|
||||
| --------------------------- | ------------------------------------------------------ |
|
||||
| **File** | [`providers/anthropic.ts`](src/providers/anthropic.ts) |
|
||||
| **Provider ID** | `anthropic` |
|
||||
| **SDK** | `@langchain/anthropic` (`ChatAnthropic`) |
|
||||
| **Default Model** | `claude-3-5-sonnet-latest` |
|
||||
| **Default Embedding Model** | _(none)_ |
|
||||
| **Default Max Context** | `200000` |
|
||||
| **Type** | Generative only (no embedding provider) |
|
||||
|
||||
**Key resolution** in the constructor follows this cascade:
|
||||
|
||||
```
|
||||
1. Explicit apiKey argument → use it
|
||||
2. ProviderManager.getActive() → if providerName matches "anthropic"
|
||||
3. ANTHROPIC_API_KEY env var → final fallback
|
||||
4. None found → throw Error
|
||||
```
|
||||
|
||||
### OpenAI — `OpenAIProvider`
|
||||
|
||||
| Property | Value |
|
||||
| --------------------------- | ------------------------------------------------------ |
|
||||
| **File** | [`providers/openai.ts`](src/providers/openai.ts) |
|
||||
| **Provider ID** | `openai` |
|
||||
| **SDK** | `@langchain/openai` (`ChatOpenAI`, `OpenAIEmbeddings`) |
|
||||
| **Default Model** | `gpt-4o-mini` |
|
||||
| **Default Embedding Model** | `text-embedding-3-small` |
|
||||
| **Default Max Context** | `128000` |
|
||||
| **Type** | Generative + Embedding |
|
||||
|
||||
**Key resolution** in the constructor follows this cascade:
|
||||
|
||||
```
|
||||
1. Explicit apiKey argument → use it
|
||||
2. ProviderManager.getActive() → if providerName matches "openai"
|
||||
3. OPENAI_API_KEY env var → final fallback
|
||||
4. None found → throw Error
|
||||
```
|
||||
|
||||
Also exports `OpenAIEmbeddingProvider` (implements `IEmbeddingProvider`) using the same key resolution pattern against the `"embedding"` type instance. The default embedding model is `text-embedding-3-small`.
|
||||
|
||||
### OpenRouter — `OpenRouterProvider`
|
||||
|
||||
| Property | Value |
|
||||
| --------------------------- | -------------------------------------------------------- |
|
||||
| **File** | [`providers/openrouter.ts`](src/providers/openrouter.ts) |
|
||||
| **Provider ID** | `openrouter` |
|
||||
| **SDK** | `@langchain/openrouter` (`ChatOpenRouter`) |
|
||||
| **Default Model** | `google/gemini-2.5-flash` |
|
||||
| **Default Embedding Model** | `openai/text-embedding-3-small` |
|
||||
| **Default Max Context** | `32768` |
|
||||
| **Type** | Generative only (no embedding provider) |
|
||||
|
||||
Same three-step key resolution as Gemini (`explicit → ProviderManager → env var`), using `OPENROUTER_API_KEY`.
|
||||
|
||||
### Ollama — `OllamaProvider`
|
||||
|
||||
| Property | Value |
|
||||
| --------------------------- | ------------------------------------------------ |
|
||||
| **File** | [`providers/ollama.ts`](src/providers/ollama.ts) |
|
||||
| **Provider ID** | `ollama` |
|
||||
| **SDK** | `@langchain/ollama` (`ChatOllama`) |
|
||||
| **Default Model** | `llama3.1` |
|
||||
| **Default Embedding Model** | `nomic-embed-text` |
|
||||
| **Default Max Context** | `32768` |
|
||||
| **Type** | Generative + Embedding |
|
||||
|
||||
Ollama runs **locally** — no API key is required. The `endpointUrl` field in `ModelProviderInstance` stores the Ollama server base URL (default: `http://localhost:11434`).
|
||||
|
||||
**Key resolution** in the constructor:
|
||||
|
||||
```
|
||||
1. Explicit baseUrl argument → use it
|
||||
2. ProviderManager.getActive() → if providerName matches "ollama"
|
||||
(endpointUrl field = base URL)
|
||||
3. Default → http://localhost:11434
|
||||
```
|
||||
|
||||
Also exports `OllamaEmbeddingProvider` (implements `IEmbeddingProvider`), which uses the same resolution pattern against the `"embedding"` type instance. The default embedding model is `nomic-embed-text`.
|
||||
|
||||
> [!TIP]
|
||||
> To get started: `ollama pull llama3.1` and `ollama pull nomic-embed-text`. Then create a provider instance with `endpointUrl` = `http://localhost:11434`.
|
||||
|
||||
### Mock — `MockLLMProvider`
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | -------------------------------------------- |
|
||||
| **File** | [`providers/mock.ts`](src/providers/mock.ts) |
|
||||
| **Provider ID** | `mock` |
|
||||
| **Type** | Generative + Embedding |
|
||||
|
||||
Stateless mock for testing and offline development:
|
||||
|
||||
- **Generative** (`MockLLMProvider`): Takes an array of canned responses at construction. Returns them in order, one per call. Returns `{ success: false, error: "Mock responses exhausted" }` when depleted.
|
||||
- **Embedding** (`MockEmbeddingProvider`): Returns a deterministic 768-dimensional vector derived from the input text using `Math.sin`.
|
||||
|
||||
## Provider Resolution (Runtime)
|
||||
|
||||
The [`resolveProviders()`](../../../apps/gui/src/lib/simulation/provider-resolver.ts) function (in `apps/gui`) instantiates all providers needed for a simulation session. It resolves **six** provider slots:
|
||||
|
||||
| Slot | Type | Task Key |
|
||||
| ------------------- | ---------- | ------------------ |
|
||||
| `actorProvider` | Generative | `"actor-prose"` |
|
||||
| `validatorProvider` | Generative | `"llm-validator"` |
|
||||
| `decoderProvider` | Generative | `"intent-decoder"` |
|
||||
| `timedeltaProvider` | Generative | `"timedelta"` |
|
||||
| `handoffProvider` | Generative | `"handoff"` |
|
||||
| `embeddingProvider` | Embedding | `"embeddings"` |
|
||||
|
||||
### Generative Resolution Order
|
||||
|
||||
For each generative slot (`resolveGenerative(task)`):
|
||||
|
||||
```
|
||||
1. Task-specific mapping → mappings[task] → find instance by ID
|
||||
2. Active generative instance → ProviderManager.getActive("generative")
|
||||
3. Fallback instance → options.fallbackInstance (if provided)
|
||||
4. GOOGLE_API_KEY env var → auto-create via ProviderManager.create()
|
||||
5. No provider available → throw Error (if required) or MockLLMProvider
|
||||
```
|
||||
|
||||
### Embedding Resolution Order
|
||||
|
||||
For the embedding slot (`resolveEmbedding()`):
|
||||
|
||||
```
|
||||
1. Task-specific mapping → mappings["embeddings"] → find instance by ID
|
||||
2. Active embedding instance → ProviderManager.getActive("embedding")
|
||||
3. GOOGLE_API_KEY env var → auto-create via ProviderManager.create()
|
||||
4. No provider available → throw Error (if required) or MockEmbeddingProvider
|
||||
```
|
||||
|
||||
### Instance → Class Mapping
|
||||
|
||||
The `buildLLMProvider()` and `buildEmbeddingProvider()` functions perform the final dispatch:
|
||||
|
||||
| `providerName` | Generative Class | Embedding Class |
|
||||
| ----------------- | -------------------- | ------------------------- |
|
||||
| `"google-genai"` | `GeminiProvider` | `GeminiEmbeddingProvider` |
|
||||
| `"openai"` | `OpenAIProvider` | `OpenAIEmbeddingProvider` |
|
||||
| `"openrouter"` | `OpenRouterProvider` | _(falls through to mock)_ |
|
||||
| `"ollama"` | `OllamaProvider` | `OllamaEmbeddingProvider` |
|
||||
| `"anthropic"` | `AnthropicProvider` | _(falls through to mock)_ |
|
||||
| _(anything else)_ | `MockLLMProvider` | `MockEmbeddingProvider` |
|
||||
|
||||
## Structured Output
|
||||
|
||||
All real providers use LangChain's `.withStructuredOutput(schema, { includeRaw: true })` pattern:
|
||||
|
||||
```typescript
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, {
|
||||
includeRaw: true,
|
||||
});
|
||||
const result = await structuredModel.invoke([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
]);
|
||||
```
|
||||
|
||||
This sends the Zod schema to the model as a structured output constraint. The response includes both `parsed` (schema-validated data) and `raw` (full API response with usage metadata).
|
||||
|
||||
## Configuration
|
||||
|
||||
[`config.ts`](src/config.ts) parses environment variables using Zod:
|
||||
|
||||
| Variable | Required | Description |
|
||||
| -------------------- | -------- | ------------------------ |
|
||||
| `GOOGLE_API_KEY` | No | Google Gemini API key |
|
||||
| `OPENAI_API_KEY` | No | OpenAI API key |
|
||||
| `OPENROUTER_API_KEY` | No | OpenRouter API key |
|
||||
| `ANTHROPIC_API_KEY` | No | Anthropic Claude API key |
|
||||
|
||||
Both are optional because providers can also be configured through the database via the GUI settings page.
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
packages/llm/
|
||||
├── src/
|
||||
│ ├── index.ts # Re-exports everything
|
||||
│ ├── llm.ts # Interfaces, types, AVAILABLE_PROVIDERS
|
||||
│ ├── config.ts # Env var parsing (Zod)
|
||||
│ ├── provider-manager.ts # ProviderManager (SQLite CRUD)
|
||||
│ └── providers/
|
||||
│ ├── google-genai.ts # GeminiProvider + GeminiEmbeddingProvider
|
||||
│ ├── ollama.ts # OllamaProvider + OllamaEmbeddingProvider
|
||||
│ ├── openrouter.ts # OpenRouterProvider
|
||||
│ ├── anthropic.ts # AnthropicProvider
|
||||
│ ├── openai.ts # OpenAIProvider + OpenAIEmbeddingProvider
|
||||
│ └── mock.ts # MockLLMProvider + MockEmbeddingProvider
|
||||
├── tests/
|
||||
│ ├── mock.test.ts
|
||||
│ ├── openrouter.test.ts
|
||||
│ └── provider-manager.test.ts
|
||||
└── package.json
|
||||
```
|
||||
@@ -1,225 +0,0 @@
|
||||
> ## Documentation Index
|
||||
>
|
||||
> Fetch the complete documentation index at: https://docs.langchain.com/llms.txt
|
||||
> Use this file to discover all available pages before exploring further.
|
||||
|
||||
# OpenAI integrations
|
||||
|
||||
> Integrate with OpenAI using LangChain JavaScript.
|
||||
|
||||
LangChain integrates with OpenAI and Azure OpenAI through the `@langchain/openai` package.
|
||||
|
||||
> [OpenAI](https://en.wikipedia.org/wiki/OpenAI) is American artificial intelligence (AI) research laboratory
|
||||
> consisting of the non-profit `OpenAI Incorporated`
|
||||
> and its for-profit subsidiary corporation `OpenAI Limited Partnership`.
|
||||
> OpenAI conducts AI research with the declared intention of promoting and developing a friendly AI.
|
||||
> OpenAI systems run on an `Azure`-based supercomputing platform from `Microsoft`.
|
||||
|
||||
> The [OpenAI API](https://platform.openai.com/docs/models) is powered by a diverse set of models with different capabilities and price points.
|
||||
>
|
||||
> [ChatGPT](https://chat.openai.com) is the Artificial Intelligence (AI) chatbot developed by `OpenAI`.
|
||||
|
||||
## Installation and setup
|
||||
|
||||
- Get an OpenAI api key and set it as an environment variable (`OPENAI_API_KEY`)
|
||||
|
||||
## Chat model
|
||||
|
||||
See a [usage example](/oss/javascript/integrations/chat/openai).
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
```
|
||||
|
||||
## LLM
|
||||
|
||||
See a [usage example](/oss/javascript/integrations/llms/openai).
|
||||
|
||||
<Tip>
|
||||
See [this section for general instructions on installing LangChain packages](/oss/javascript/langchain/install).
|
||||
</Tip>
|
||||
|
||||
```bash npm theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
npm install @langchain/openai @langchain/core
|
||||
```
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { OpenAI } from "@langchain/openai";
|
||||
```
|
||||
|
||||
## Text embedding model
|
||||
|
||||
See a [usage example](/oss/javascript/integrations/embeddings/openai)
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { OpenAIEmbeddings } from "@langchain/openai";
|
||||
```
|
||||
|
||||
## Chain
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { OpenAIModerationChain } from "@langchain/classic/chains";
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
Middleware specifically designed for OpenAI models. Learn more about [middleware](/oss/javascript/langchain/middleware/overview).
|
||||
|
||||
| Middleware | Description |
|
||||
| ----------------------------------------- | --------------------------------------------------------- |
|
||||
| [Content moderation](#content-moderation) | Moderate agent traffic using OpenAI's moderation endpoint |
|
||||
|
||||
### Content moderation
|
||||
|
||||
Moderate agent traffic (user input, model output, and tool results) using OpenAI's moderation endpoint to detect and handle unsafe content. Content moderation is useful for the following:
|
||||
|
||||
- Applications requiring content safety and compliance
|
||||
- Filtering harmful, hateful, or inappropriate content
|
||||
- Customer-facing agents that need safety guardrails
|
||||
- Meeting platform moderation requirements
|
||||
|
||||
<Info>
|
||||
Learn more about [OpenAI's moderation models](https://platform.openai.com/docs/guides/moderation) and categories.
|
||||
</Info>
|
||||
|
||||
**API reference:** [`openAIModerationMiddleware`](https://reference.langchain.com/javascript/langchain/index/openAIModerationMiddleware)
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { createAgent, openAIModerationMiddleware } from "langchain";
|
||||
|
||||
const agent = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool, databaseTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
moderationModel: "omni-moderation-latest",
|
||||
checkInput: true,
|
||||
checkOutput: true,
|
||||
exitBehavior: "end",
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Accordion title="Configuration options">
|
||||
<ParamField body="model" type="string | BaseChatModel" required>
|
||||
OpenAI model to use for moderation. Can be either a model name string (e.g., `"openai:gpt-5.5"`) or a `BaseChatModel` instance. The middleware will use this model's client to access the moderation endpoint.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="moderationModel" type="ModerationModel" default="omni-moderation-latest">
|
||||
OpenAI moderation model to use. Options: `'omni-moderation-latest'`, `'omni-moderation-2024-09-26'`, `'text-moderation-latest'`, `'text-moderation-stable'`
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="checkInput" type="boolean" default="true">
|
||||
Whether to check user input messages before the model is called
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="checkOutput" type="boolean" default="true">
|
||||
Whether to check model output messages after the model is called
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="checkToolResults" type="boolean" default="false">
|
||||
Whether to check tool result messages before the model is called
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="exitBehavior" type="'error' | 'end' | 'replace'" default="'end'">
|
||||
How to handle violations when content is flagged. Options:
|
||||
|
||||
* `'end'` - End agent execution immediately with a violation message
|
||||
* `'error'` - Throw `OpenAIModerationError` exception
|
||||
* `'replace'` - Replace the flagged content with the violation message and continue
|
||||
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="violationMessage" type="string | undefined">
|
||||
Custom template for violation messages. Supports template variables:
|
||||
|
||||
* `{categories}` - Comma-separated list of flagged categories
|
||||
* `{category_scores}` - JSON string of category scores
|
||||
* `{original_content}` - The original flagged content
|
||||
|
||||
Default: `"I'm sorry, but I can't comply with that request. It was flagged for {categories}."`
|
||||
|
||||
</ParamField>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Full example">
|
||||
The middleware integrates OpenAI's moderation endpoint to check content at different stages:
|
||||
|
||||
**Moderation stages:**
|
||||
|
||||
- `checkInput` - User messages before model call
|
||||
- `checkOutput` - AI messages after model call
|
||||
- `checkToolResults` - Tool outputs before model call
|
||||
|
||||
**Exit behaviors:**
|
||||
|
||||
- `'end'` (default) - Stop execution with violation message
|
||||
- `'error'` - Throw exception for application handling
|
||||
- `'replace'` - Replace flagged content and continue
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { createAgent, openAIModerationMiddleware } from "langchain";
|
||||
|
||||
// Basic moderation
|
||||
const agent = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool, customerDataTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
moderationModel: "omni-moderation-latest",
|
||||
checkInput: true,
|
||||
checkOutput: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Strict moderation with custom message
|
||||
const agentStrict = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool, customerDataTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
moderationModel: "omni-moderation-latest",
|
||||
checkInput: true,
|
||||
checkOutput: true,
|
||||
checkToolResults: true,
|
||||
exitBehavior: "error",
|
||||
violationMessage:
|
||||
"Content policy violation detected: {categories}. " +
|
||||
"Please rephrase your request.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Moderation with replacement behavior
|
||||
const agentReplace = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
checkInput: true,
|
||||
exitBehavior: "replace",
|
||||
violationMessage: "[Content removed due to safety policies]",
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
---
|
||||
|
||||
<div className="source-links">
|
||||
<Callout icon="terminal-2">
|
||||
[Connect these docs](/use-these-docs) to Claude, VSCode, and more via MCP for real-time answers.
|
||||
</Callout>
|
||||
|
||||
<Callout icon="edit">
|
||||
[Edit this page on GitHub](https://github.com/langchain-ai/docs/edit/main/src/oss/javascript/integrations/providers/openai.mdx) or [file an issue](https://github.com/langchain-ai/docs/issues/new/choose).
|
||||
</Callout>
|
||||
</div>
|
||||
@@ -3,8 +3,6 @@ import { z } from "zod";
|
||||
const LLMConfigSchema = z.object({
|
||||
GOOGLE_API_KEY: z.string().optional(),
|
||||
OPENROUTER_API_KEY: z.string().optional(),
|
||||
ANTHROPIC_API_KEY: z.string().optional(),
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const llmConfig = LLMConfigSchema.parse(process.env);
|
||||
|
||||
@@ -2,8 +2,5 @@ export * from "./llm.js";
|
||||
export * from "./config.js";
|
||||
export * from "./providers/google-genai.js";
|
||||
export * from "./providers/mock.js";
|
||||
export * from "./providers/ollama.js";
|
||||
export * from "./providers/openrouter.js";
|
||||
export * from "./providers/anthropic.js";
|
||||
export * from "./providers/openai.js";
|
||||
export * from "./provider-manager.js";
|
||||
|
||||
@@ -57,7 +57,6 @@ export interface ModelProviderInstance {
|
||||
modelName?: string;
|
||||
type: "generative" | "embedding";
|
||||
maxContext?: number;
|
||||
endpointUrl?: string;
|
||||
}
|
||||
|
||||
export interface ModelProviderMeta {
|
||||
@@ -76,20 +75,6 @@ export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
|
||||
defaultModel: "gemini-2.5-flash",
|
||||
defaultEmbeddingModel: "gemini-embedding-001",
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
displayName: "OpenAI",
|
||||
description: "Official OpenAI integration using @langchain/openai SDK",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
defaultEmbeddingModel: "text-embedding-3-small",
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
displayName: "Anthropic Claude",
|
||||
description: "Official Claude integration using @langchain/anthropic SDK",
|
||||
defaultModel: "claude-3-5-sonnet-latest",
|
||||
defaultEmbeddingModel: "",
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
displayName: "OpenRouter",
|
||||
@@ -98,14 +83,6 @@ export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
|
||||
defaultModel: "google/gemini-2.5-flash",
|
||||
defaultEmbeddingModel: "openai/text-embedding-3-small",
|
||||
},
|
||||
{
|
||||
id: "ollama",
|
||||
displayName: "Ollama",
|
||||
description:
|
||||
"Local model runner — no API key required, uses the Ollama server base URL instead",
|
||||
defaultModel: "llama3.1",
|
||||
defaultEmbeddingModel: "nomic-embed-text",
|
||||
},
|
||||
{
|
||||
id: "mock",
|
||||
displayName: "Mock LLM Provider",
|
||||
|
||||
@@ -82,14 +82,6 @@ function getSettingsDb() {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
`ALTER TABLE provider_instances ADD COLUMN endpointUrl TEXT`,
|
||||
).run();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Auto-bootstrap environment variables if DB contains 0 instances
|
||||
try {
|
||||
if (!hasBootstrapped) {
|
||||
@@ -99,10 +91,7 @@ function getSettingsDb() {
|
||||
if (totalCount.count === 0) {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
const openaiKey = process.env.OPENAI_API_KEY;
|
||||
let hasInsertedGenerative = false;
|
||||
let hasInsertedEmbedding = false;
|
||||
|
||||
if (googleKey && googleKey.trim()) {
|
||||
const id = "provider-default-google";
|
||||
@@ -139,74 +128,6 @@ function getSettingsDb() {
|
||||
"embedding",
|
||||
0,
|
||||
);
|
||||
hasInsertedEmbedding = true;
|
||||
}
|
||||
|
||||
if (anthropicKey && anthropicKey.trim()) {
|
||||
const id = "provider-default-anthropic";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
"Anthropic (Env)",
|
||||
"anthropic",
|
||||
anthropicKey.trim(),
|
||||
isActive,
|
||||
"claude-3-5-sonnet-latest",
|
||||
"generative",
|
||||
200000,
|
||||
);
|
||||
if (isActive === 1) {
|
||||
hasInsertedGenerative = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (openaiKey && openaiKey.trim()) {
|
||||
const id = "provider-default-openai";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
"OpenAI (Env)",
|
||||
"openai",
|
||||
openaiKey.trim(),
|
||||
isActive,
|
||||
"gpt-4o-mini",
|
||||
"generative",
|
||||
128000,
|
||||
);
|
||||
if (isActive === 1) {
|
||||
hasInsertedGenerative = true;
|
||||
}
|
||||
|
||||
const embedId = "provider-default-openai-embed";
|
||||
const isEmbedActive = hasInsertedEmbedding ? 0 : 1;
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
embedId,
|
||||
"OpenAI Embed (Env)",
|
||||
"openai",
|
||||
openaiKey.trim(),
|
||||
isEmbedActive,
|
||||
"text-embedding-3-small",
|
||||
"embedding",
|
||||
0,
|
||||
);
|
||||
if (isEmbedActive === 1) {
|
||||
hasInsertedEmbedding = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
@@ -251,7 +172,6 @@ export class ProviderManager {
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
endpointUrl?: string;
|
||||
}[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -267,7 +187,6 @@ export class ProviderManager {
|
||||
: r.type === "embedding"
|
||||
? 0
|
||||
: 32768,
|
||||
endpointUrl: r.endpointUrl || undefined,
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -281,7 +200,6 @@ export class ProviderManager {
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
endpointUrl?: string,
|
||||
): ModelProviderInstance {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
@@ -302,8 +220,8 @@ export class ProviderManager {
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext, endpointUrl)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
@@ -314,7 +232,6 @@ export class ProviderManager {
|
||||
modelName || null,
|
||||
type,
|
||||
actualMaxContext,
|
||||
endpointUrl || null,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -326,7 +243,6 @@ export class ProviderManager {
|
||||
modelName,
|
||||
type,
|
||||
maxContext: actualMaxContext,
|
||||
endpointUrl,
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -383,7 +299,6 @@ export class ProviderManager {
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
endpointUrl?: string,
|
||||
): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
@@ -397,7 +312,7 @@ export class ProviderManager {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE provider_instances
|
||||
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ?
|
||||
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
).run(
|
||||
@@ -407,14 +322,13 @@ export class ProviderManager {
|
||||
modelName || null,
|
||||
type,
|
||||
actualMaxContext,
|
||||
endpointUrl || null,
|
||||
id,
|
||||
);
|
||||
} else {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE provider_instances
|
||||
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ?
|
||||
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
).run(
|
||||
@@ -423,7 +337,6 @@ export class ProviderManager {
|
||||
modelName || null,
|
||||
type,
|
||||
actualMaxContext,
|
||||
endpointUrl || null,
|
||||
id,
|
||||
);
|
||||
}
|
||||
@@ -451,7 +364,6 @@ export class ProviderManager {
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
endpointUrl?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -462,10 +374,7 @@ export class ProviderManager {
|
||||
if (totalCount.count === 0) {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
const openaiKey = process.env.OPENAI_API_KEY;
|
||||
let hasInsertedGenerative = false;
|
||||
let hasInsertedEmbedding = false;
|
||||
|
||||
if (googleKey && googleKey.trim()) {
|
||||
const id = "provider-default-google";
|
||||
@@ -502,74 +411,6 @@ export class ProviderManager {
|
||||
"embedding",
|
||||
0,
|
||||
);
|
||||
hasInsertedEmbedding = true;
|
||||
}
|
||||
|
||||
if (anthropicKey && anthropicKey.trim()) {
|
||||
const id = "provider-default-anthropic";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
"Anthropic (Env)",
|
||||
"anthropic",
|
||||
anthropicKey.trim(),
|
||||
isActive,
|
||||
"claude-3-5-sonnet-latest",
|
||||
"generative",
|
||||
200000,
|
||||
);
|
||||
if (isActive === 1) {
|
||||
hasInsertedGenerative = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (openaiKey && openaiKey.trim()) {
|
||||
const id = "provider-default-openai";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
"OpenAI (Env)",
|
||||
"openai",
|
||||
openaiKey.trim(),
|
||||
isActive,
|
||||
"gpt-4o-mini",
|
||||
"generative",
|
||||
128000,
|
||||
);
|
||||
if (isActive === 1) {
|
||||
hasInsertedGenerative = true;
|
||||
}
|
||||
|
||||
const embedId = "provider-default-openai-embed";
|
||||
const isEmbedActive = hasInsertedEmbedding ? 0 : 1;
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
embedId,
|
||||
"OpenAI Embed (Env)",
|
||||
"openai",
|
||||
openaiKey.trim(),
|
||||
isEmbedActive,
|
||||
"text-embedding-3-small",
|
||||
"embedding",
|
||||
0,
|
||||
);
|
||||
if (isEmbedActive === 1) {
|
||||
hasInsertedEmbedding = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
@@ -606,7 +447,6 @@ export class ProviderManager {
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
endpointUrl?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -626,7 +466,6 @@ export class ProviderManager {
|
||||
: retryRow.type === "embedding"
|
||||
? 0
|
||||
: 32768,
|
||||
endpointUrl: retryRow.endpointUrl || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -644,7 +483,6 @@ export class ProviderManager {
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
endpointUrl?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (firstRow) {
|
||||
@@ -665,7 +503,6 @@ export class ProviderManager {
|
||||
: firstRow.type === "embedding"
|
||||
? 0
|
||||
: 32768,
|
||||
endpointUrl: firstRow.endpointUrl || undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -685,7 +522,6 @@ export class ProviderManager {
|
||||
: row.type === "embedding"
|
||||
? 0
|
||||
: 32768,
|
||||
endpointUrl: row.endpointUrl || undefined,
|
||||
};
|
||||
} catch {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
@@ -702,19 +538,6 @@ export class ProviderManager {
|
||||
maxContext: 0,
|
||||
};
|
||||
}
|
||||
const openaiKey = process.env.OPENAI_API_KEY;
|
||||
if (openaiKey && openaiKey.trim()) {
|
||||
return {
|
||||
id: "provider-default-env-embed-fallback",
|
||||
name: "OpenAI Embed (Env Fallback)",
|
||||
providerName: "openai",
|
||||
apiKey: openaiKey.trim(),
|
||||
isActive: true,
|
||||
modelName: "text-embedding-3-small",
|
||||
type: "embedding",
|
||||
maxContext: 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -731,32 +554,6 @@ export class ProviderManager {
|
||||
maxContext: 32768,
|
||||
};
|
||||
}
|
||||
const openaiKey = process.env.OPENAI_API_KEY;
|
||||
if (openaiKey && openaiKey.trim()) {
|
||||
return {
|
||||
id: "provider-default-env-fallback",
|
||||
name: "OpenAI (Env Fallback)",
|
||||
providerName: "openai",
|
||||
apiKey: openaiKey.trim(),
|
||||
isActive: true,
|
||||
modelName: "gpt-4o-mini",
|
||||
type: "generative",
|
||||
maxContext: 128000,
|
||||
};
|
||||
}
|
||||
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (anthropicKey && anthropicKey.trim()) {
|
||||
return {
|
||||
id: "provider-default-env-fallback",
|
||||
name: "Anthropic (Env Fallback)",
|
||||
providerName: "anthropic",
|
||||
apiKey: anthropicKey.trim(),
|
||||
isActive: true,
|
||||
modelName: "claude-3-5-sonnet-latest",
|
||||
type: "generative",
|
||||
maxContext: 200000,
|
||||
};
|
||||
}
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
return {
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ChatAnthropic } from "@langchain/anthropic";
|
||||
import {
|
||||
ILLMProvider,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
LLMCallRecord,
|
||||
} from "../llm.js";
|
||||
import { llmConfig } from "../config.js";
|
||||
import { ProviderManager } from "../provider-manager.js";
|
||||
|
||||
export class AnthropicProvider implements ILLMProvider {
|
||||
static readonly providerId = "anthropic";
|
||||
static readonly displayName = "Anthropic Claude";
|
||||
static readonly description =
|
||||
"Official Claude integration using @langchain/anthropic SDK";
|
||||
static readonly defaultModel = "claude-3-5-sonnet-latest";
|
||||
|
||||
providerName = "Anthropic";
|
||||
private model: ChatAnthropic;
|
||||
private modelNameUsed: string;
|
||||
private providerInstanceName?: string;
|
||||
private maxContextUsed?: number;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(
|
||||
apiKey?: string,
|
||||
modelName?: string,
|
||||
providerInstanceName?: string,
|
||||
maxContext?: number,
|
||||
) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
this.maxContextUsed = maxContext;
|
||||
|
||||
if (!key) {
|
||||
const active = ProviderManager.getActive("generative");
|
||||
if (active && active.providerName === AnthropicProvider.providerId) {
|
||||
key = active.apiKey;
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
if (!this.providerInstanceName) {
|
||||
this.providerInstanceName = active.name;
|
||||
}
|
||||
if (this.maxContextUsed === undefined) {
|
||||
this.maxContextUsed = active.maxContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
key = llmConfig.ANTHROPIC_API_KEY;
|
||||
if (!this.providerInstanceName && key) {
|
||||
this.providerInstanceName = "Environment Variable";
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"ANTHROPIC_API_KEY is required to initialize AnthropicProvider",
|
||||
);
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || AnthropicProvider.defaultModel;
|
||||
this.model = new ChatAnthropic({
|
||||
apiKey: key,
|
||||
model: this.modelNameUsed,
|
||||
});
|
||||
}
|
||||
|
||||
async generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>> {
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, {
|
||||
includeRaw: true,
|
||||
});
|
||||
const result = (await structuredModel.invoke([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
])) as unknown as {
|
||||
parsed?: z.infer<T>;
|
||||
raw?: {
|
||||
usage_metadata?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const parsed = result?.parsed;
|
||||
const raw = result?.raw;
|
||||
|
||||
const usage = {
|
||||
inputTokens: raw?.usage_metadata?.input_tokens || 0,
|
||||
outputTokens: raw?.usage_metadata?.output_tokens || 0,
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
maxContext:
|
||||
this.maxContextUsed !== undefined ? this.maxContextUsed : 200000,
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
userContext: request.userContext,
|
||||
usage,
|
||||
});
|
||||
|
||||
return { success: true, data: parsed, usage };
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ChatOllama, OllamaEmbeddings } from "@langchain/ollama";
|
||||
import {
|
||||
ILLMProvider,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
LLMCallRecord,
|
||||
IEmbeddingProvider,
|
||||
} from "../llm.js";
|
||||
import { ProviderManager } from "../provider-manager.js";
|
||||
|
||||
export class OllamaProvider implements ILLMProvider {
|
||||
static readonly providerId = "ollama";
|
||||
static readonly displayName = "Ollama";
|
||||
static readonly description =
|
||||
"Local model runner supporting open-source LLMs via the Ollama server";
|
||||
static readonly defaultModel = "llama3.1";
|
||||
|
||||
providerName = "Ollama";
|
||||
private model: ChatOllama;
|
||||
private modelNameUsed: string;
|
||||
private providerInstanceName?: string;
|
||||
private maxContextUsed?: number;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
/**
|
||||
* Creates an OllamaProvider.
|
||||
*
|
||||
* Resolution order for configuration:
|
||||
* 1. Explicit constructor arguments
|
||||
* 2. Active "generative" instance in ProviderManager whose providerName === "ollama"
|
||||
* 3. Defaults (baseUrl: http://localhost:11434, model: llama3.1)
|
||||
*
|
||||
* No API key is required for Ollama. The `endpointUrl` in
|
||||
* ModelProviderInstance stores the Ollama server base URL
|
||||
* (e.g. "http://localhost:11434").
|
||||
*/
|
||||
constructor(
|
||||
baseUrl?: string,
|
||||
modelName?: string,
|
||||
providerInstanceName?: string,
|
||||
maxContext?: number,
|
||||
) {
|
||||
let url = baseUrl;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
this.maxContextUsed = maxContext;
|
||||
|
||||
if (!url || !model) {
|
||||
const active = ProviderManager.getActive("generative");
|
||||
if (active && active.providerName === OllamaProvider.providerId) {
|
||||
if (!url) {
|
||||
url = active.endpointUrl;
|
||||
}
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
if (!this.providerInstanceName) {
|
||||
this.providerInstanceName = active.name;
|
||||
}
|
||||
if (this.maxContextUsed === undefined) {
|
||||
this.maxContextUsed = active.maxContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || OllamaProvider.defaultModel;
|
||||
this.model = new ChatOllama({
|
||||
baseUrl: url || "http://localhost:11434",
|
||||
model: this.modelNameUsed,
|
||||
});
|
||||
}
|
||||
|
||||
async generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>> {
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, {
|
||||
includeRaw: true,
|
||||
});
|
||||
const result = (await structuredModel.invoke([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
])) as unknown as {
|
||||
parsed?: z.infer<T>;
|
||||
raw?: {
|
||||
usage_metadata?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const parsed = result?.parsed;
|
||||
const raw = result?.raw;
|
||||
|
||||
const usage = {
|
||||
inputTokens: raw?.usage_metadata?.input_tokens || 0,
|
||||
outputTokens: raw?.usage_metadata?.output_tokens || 0,
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
maxContext:
|
||||
this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
userContext: request.userContext,
|
||||
usage,
|
||||
});
|
||||
|
||||
return { success: true, data: parsed, usage };
|
||||
}
|
||||
}
|
||||
|
||||
export class OllamaEmbeddingProvider implements IEmbeddingProvider {
|
||||
static readonly providerId = "ollama";
|
||||
static readonly displayName = "Ollama Embeddings";
|
||||
|
||||
providerName = "Ollama";
|
||||
private model: OllamaEmbeddings;
|
||||
|
||||
/**
|
||||
* Creates an OllamaEmbeddingProvider.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Explicit constructor arguments
|
||||
* 2. Active "embedding" instance in ProviderManager
|
||||
* 3. Defaults (baseUrl: http://localhost:11434, model: nomic-embed-text)
|
||||
*
|
||||
* The `endpointUrl` field in ModelProviderInstance stores the base URL.
|
||||
*/
|
||||
constructor(baseUrl?: string, modelName?: string) {
|
||||
let url = baseUrl;
|
||||
let model = modelName;
|
||||
|
||||
if (!url || !model) {
|
||||
const active = ProviderManager.getActive("embedding");
|
||||
if (
|
||||
active &&
|
||||
active.providerName === OllamaEmbeddingProvider.providerId
|
||||
) {
|
||||
if (!url) {
|
||||
url = active.endpointUrl;
|
||||
}
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.model = new OllamaEmbeddings({
|
||||
baseUrl: url || "http://localhost:11434",
|
||||
model: model || "nomic-embed-text",
|
||||
});
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
return this.model.embedQuery(text);
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
|
||||
import {
|
||||
ILLMProvider,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
LLMCallRecord,
|
||||
IEmbeddingProvider,
|
||||
} from "../llm.js";
|
||||
import { llmConfig } from "../config.js";
|
||||
import { ProviderManager } from "../provider-manager.js";
|
||||
|
||||
export class OpenAIProvider implements ILLMProvider {
|
||||
static readonly providerId = "openai";
|
||||
static readonly displayName = "OpenAI";
|
||||
static readonly description =
|
||||
"Official OpenAI integration using @langchain/openai SDK";
|
||||
static readonly defaultModel = "gpt-4o-mini";
|
||||
|
||||
providerName = "OpenAI";
|
||||
private model: ChatOpenAI;
|
||||
private modelNameUsed: string;
|
||||
private providerInstanceName?: string;
|
||||
private maxContextUsed?: number;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(
|
||||
apiKey?: string,
|
||||
modelName?: string,
|
||||
providerInstanceName?: string,
|
||||
maxContext?: number,
|
||||
) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
this.maxContextUsed = maxContext;
|
||||
|
||||
if (!key) {
|
||||
const active = ProviderManager.getActive("generative");
|
||||
if (active && active.providerName === OpenAIProvider.providerId) {
|
||||
key = active.apiKey;
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
if (!this.providerInstanceName) {
|
||||
this.providerInstanceName = active.name;
|
||||
}
|
||||
if (this.maxContextUsed === undefined) {
|
||||
this.maxContextUsed = active.maxContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
key = llmConfig.OPENAI_API_KEY;
|
||||
if (!this.providerInstanceName && key) {
|
||||
this.providerInstanceName = "Environment Variable";
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"OPENAI_API_KEY is required to initialize OpenAIProvider",
|
||||
);
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || OpenAIProvider.defaultModel;
|
||||
this.model = new ChatOpenAI({
|
||||
apiKey: key,
|
||||
model: this.modelNameUsed,
|
||||
});
|
||||
}
|
||||
|
||||
async generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>> {
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, {
|
||||
includeRaw: true,
|
||||
});
|
||||
const result = (await structuredModel.invoke([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
])) as unknown as {
|
||||
parsed?: z.infer<T>;
|
||||
raw?: {
|
||||
usage_metadata?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const parsed = result?.parsed;
|
||||
const raw = result?.raw;
|
||||
|
||||
const usage = {
|
||||
inputTokens: raw?.usage_metadata?.input_tokens || 0,
|
||||
outputTokens: raw?.usage_metadata?.output_tokens || 0,
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
maxContext:
|
||||
this.maxContextUsed !== undefined ? this.maxContextUsed : 128000,
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
userContext: request.userContext,
|
||||
usage,
|
||||
});
|
||||
|
||||
return { success: true, data: parsed, usage };
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenAIEmbeddingProvider implements IEmbeddingProvider {
|
||||
static readonly providerId = "openai";
|
||||
static readonly displayName = "OpenAI Embeddings";
|
||||
|
||||
providerName = "OpenAI";
|
||||
private model: OpenAIEmbeddings;
|
||||
|
||||
constructor(apiKey?: string, modelName?: string) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
|
||||
if (!key) {
|
||||
const active = ProviderManager.getActive("embedding");
|
||||
if (
|
||||
active &&
|
||||
active.providerName === OpenAIEmbeddingProvider.providerId
|
||||
) {
|
||||
key = active.apiKey;
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
key = llmConfig.OPENAI_API_KEY;
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"OPENAI_API_KEY is required to initialize OpenAIEmbeddingProvider",
|
||||
);
|
||||
}
|
||||
|
||||
this.model = new OpenAIEmbeddings({
|
||||
apiKey: key,
|
||||
model: model || "text-embedding-3-small",
|
||||
});
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
return this.model.embedQuery(text);
|
||||
}
|
||||
}
|
||||
@@ -259,7 +259,11 @@ ${candidatesList}
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
const db = (this.bufferRepo as any).db;
|
||||
const db = (
|
||||
this.bufferRepo as unknown as {
|
||||
db: { transaction: (fn: () => void) => void };
|
||||
}
|
||||
).db;
|
||||
|
||||
const ledgerEntries: LedgerEntry[] = [];
|
||||
for (const chunk of result.chunks) {
|
||||
|
||||
426
pnpm-lock.yaml
generated
426
pnpm-lock.yaml
generated
@@ -260,21 +260,12 @@ settings:
|
||||
importers:
|
||||
.:
|
||||
dependencies:
|
||||
"@langchain/anthropic":
|
||||
specifier: ^0.3.11
|
||||
version: 0.3.34(zod@4.4.3)
|
||||
"@langchain/google-genai":
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))
|
||||
"@langchain/ollama":
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.4
|
||||
"@langchain/openai":
|
||||
specifier: ^0.3.17
|
||||
version: 0.3.17(ws@8.21.0)
|
||||
"@langchain/openrouter":
|
||||
specifier: ^0.4.3
|
||||
version: 0.4.3(ws@8.21.0)(zod@4.4.3)
|
||||
version: 0.4.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)(zod@4.4.3)
|
||||
"@types/node":
|
||||
specifier: ^20.19.43
|
||||
version: 20.19.43
|
||||
@@ -351,6 +342,9 @@ importers:
|
||||
"@radix-ui/react-dialog":
|
||||
specifier: ^1.1.19
|
||||
version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
"@radix-ui/react-dropdown-menu":
|
||||
specifier: ^2.1.20
|
||||
version: 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
"@radix-ui/react-separator":
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -570,18 +564,6 @@ packages:
|
||||
integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==,
|
||||
}
|
||||
|
||||
"@anthropic-ai/sdk@0.65.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw==,
|
||||
}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
"@astrojs/compiler-binding-darwin-arm64@0.3.0":
|
||||
resolution:
|
||||
{
|
||||
@@ -2070,15 +2052,6 @@ packages:
|
||||
integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==,
|
||||
}
|
||||
|
||||
"@langchain/anthropic@0.3.34":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-8bOW1A2VHRCjbzdYElrjxutKNs9NSIxYRGtR+OJWVzluMqoKKh2NmmFrpPizEyqCUEG2tTq5xt6XA1lwfqMJRA==,
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
peerDependencies:
|
||||
"@langchain/core": ">=0.3.58 <0.4.0"
|
||||
|
||||
"@langchain/core@1.2.1":
|
||||
resolution:
|
||||
{
|
||||
@@ -2095,24 +2068,6 @@ packages:
|
||||
peerDependencies:
|
||||
"@langchain/core": ^1.2.0
|
||||
|
||||
"@langchain/ollama@0.2.4":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-XThDrZurNPcUO6sasN13rkes1aGgu5gWAtDkkyIGT3ZeMOvrYgPKGft+bbhvsigTIH9C01TfPzrSp8LAmvHIjA==,
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
peerDependencies:
|
||||
"@langchain/core": ">=0.3.58 <0.4.0"
|
||||
|
||||
"@langchain/openai@0.3.17":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-uw4po32OKptVjq+CYHrumgbfh4NuD7LqyE+ZgqY9I/LrLc6bHLMc+sisHmI17vgek0K/yqtarI0alPJbzrwyag==,
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
peerDependencies:
|
||||
"@langchain/core": ">=0.3.29 <0.4.0"
|
||||
|
||||
"@langchain/openai@1.5.3":
|
||||
resolution:
|
||||
{
|
||||
@@ -3906,18 +3861,6 @@ packages:
|
||||
integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==,
|
||||
}
|
||||
|
||||
"@types/node-fetch@2.6.13":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==,
|
||||
}
|
||||
|
||||
"@types/node@18.19.130":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==,
|
||||
}
|
||||
|
||||
"@types/node@20.19.43":
|
||||
resolution:
|
||||
{
|
||||
@@ -4131,13 +4074,6 @@ packages:
|
||||
integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==,
|
||||
}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==,
|
||||
}
|
||||
engines: { node: ">=6.5" }
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -4161,13 +4097,6 @@ packages:
|
||||
engines: { node: ">=0.4.0" }
|
||||
hasBin: true
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==,
|
||||
}
|
||||
engines: { node: ">= 8.0.0" }
|
||||
|
||||
ajv-formats@2.1.1:
|
||||
resolution:
|
||||
{
|
||||
@@ -4324,12 +4253,6 @@ packages:
|
||||
"@astrojs/markdown-remark":
|
||||
optional: true
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==,
|
||||
}
|
||||
|
||||
atomically@1.7.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -4635,13 +4558,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">=12.5.0" }
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==,
|
||||
}
|
||||
engines: { node: ">= 0.8" }
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution:
|
||||
{
|
||||
@@ -5224,13 +5140,6 @@ packages:
|
||||
integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==,
|
||||
}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==,
|
||||
}
|
||||
engines: { node: ">=0.4.0" }
|
||||
|
||||
depd@2.0.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -5454,13 +5363,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">= 0.4" }
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==,
|
||||
}
|
||||
engines: { node: ">= 0.4" }
|
||||
|
||||
es-toolkit@1.49.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -5655,13 +5557,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">= 0.6" }
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==,
|
||||
}
|
||||
engines: { node: ">=6" }
|
||||
|
||||
eventemitter3@4.0.7:
|
||||
resolution:
|
||||
{
|
||||
@@ -5793,13 +5688,6 @@ packages:
|
||||
integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==,
|
||||
}
|
||||
|
||||
fast-xml-parser@4.5.7:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==,
|
||||
}
|
||||
hasBin: true
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution:
|
||||
{
|
||||
@@ -5899,26 +5787,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">=20" }
|
||||
|
||||
form-data-encoder@1.7.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==,
|
||||
}
|
||||
|
||||
form-data@4.0.6:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==,
|
||||
}
|
||||
engines: { node: ">= 6" }
|
||||
|
||||
formdata-node@4.4.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==,
|
||||
}
|
||||
engines: { node: ">= 12.20" }
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -6100,13 +5968,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">= 0.4" }
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==,
|
||||
}
|
||||
engines: { node: ">= 0.4" }
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution:
|
||||
{
|
||||
@@ -6286,12 +6147,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">=18.18.0" }
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==,
|
||||
}
|
||||
|
||||
i18next@26.3.4:
|
||||
resolution:
|
||||
{
|
||||
@@ -6652,13 +6507,6 @@ packages:
|
||||
integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==,
|
||||
}
|
||||
|
||||
json-schema-to-ts@3.1.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==,
|
||||
}
|
||||
engines: { node: ">=16" }
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution:
|
||||
{
|
||||
@@ -7377,13 +7225,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">=8.6" }
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==,
|
||||
}
|
||||
engines: { node: ">= 0.6" }
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -7391,13 +7232,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">= 0.6" }
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==,
|
||||
}
|
||||
engines: { node: ">= 0.6" }
|
||||
|
||||
mime-types@3.0.2:
|
||||
resolution:
|
||||
{
|
||||
@@ -7551,32 +7385,12 @@ packages:
|
||||
}
|
||||
engines: { node: ">=10" }
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==,
|
||||
}
|
||||
engines: { node: ">=10.5.0" }
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch-native@1.6.7:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==,
|
||||
}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==,
|
||||
}
|
||||
engines: { node: 4.x || >=6.0.0 }
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
node-mock-http@1.0.4:
|
||||
resolution:
|
||||
{
|
||||
@@ -7657,12 +7471,6 @@ packages:
|
||||
integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==,
|
||||
}
|
||||
|
||||
ollama@0.5.18:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==,
|
||||
}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution:
|
||||
{
|
||||
@@ -7716,21 +7524,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">=12" }
|
||||
|
||||
openai@4.104.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==,
|
||||
}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
zod: ^3.23.8
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openai@6.45.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -8834,12 +8627,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">=0.10.0" }
|
||||
|
||||
strnum@1.1.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==,
|
||||
}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
resolution:
|
||||
{
|
||||
@@ -8998,12 +8785,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">=0.6" }
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==,
|
||||
}
|
||||
|
||||
trim-lines@3.0.1:
|
||||
resolution:
|
||||
{
|
||||
@@ -9016,12 +8797,6 @@ packages:
|
||||
integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==,
|
||||
}
|
||||
|
||||
ts-algebra@2.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==,
|
||||
}
|
||||
|
||||
ts-api-utils@2.5.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -9113,12 +8888,6 @@ packages:
|
||||
integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==,
|
||||
}
|
||||
|
||||
undici-types@5.26.5:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==,
|
||||
}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -9370,14 +9139,6 @@ packages:
|
||||
integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==,
|
||||
}
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==,
|
||||
}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@14.0.1:
|
||||
resolution:
|
||||
{
|
||||
@@ -9524,31 +9285,6 @@ packages:
|
||||
integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==,
|
||||
}
|
||||
|
||||
web-streams-polyfill@4.0.0-beta.3:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==,
|
||||
}
|
||||
engines: { node: ">= 14" }
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==,
|
||||
}
|
||||
|
||||
whatwg-fetch@3.6.20:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==,
|
||||
}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==,
|
||||
}
|
||||
|
||||
which@2.0.2:
|
||||
resolution:
|
||||
{
|
||||
@@ -9722,12 +9458,6 @@ snapshots:
|
||||
package-manager-detector: 1.7.0
|
||||
tinyexec: 1.2.4
|
||||
|
||||
"@anthropic-ai/sdk@0.65.0(zod@4.4.3)":
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
optionalDependencies:
|
||||
zod: 4.4.3
|
||||
|
||||
"@astrojs/compiler-binding-darwin-arm64@0.3.0":
|
||||
optional: true
|
||||
|
||||
@@ -10608,13 +10338,6 @@ snapshots:
|
||||
"@jridgewell/resolve-uri": 3.1.2
|
||||
"@jridgewell/sourcemap-codec": 1.5.5
|
||||
|
||||
"@langchain/anthropic@0.3.34(zod@4.4.3)":
|
||||
dependencies:
|
||||
"@anthropic-ai/sdk": 0.65.0(zod@4.4.3)
|
||||
fast-xml-parser: 4.5.7
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
"@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)":
|
||||
dependencies:
|
||||
"@cfworker/json-schema": 4.1.1
|
||||
@@ -10636,23 +10359,9 @@ snapshots:
|
||||
"@google/generative-ai": 0.24.1
|
||||
"@langchain/core": 1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)
|
||||
|
||||
"@langchain/ollama@0.2.4":
|
||||
dependencies:
|
||||
ollama: 0.5.18
|
||||
uuid: 10.0.0
|
||||
|
||||
"@langchain/openai@0.3.17(ws@8.21.0)":
|
||||
dependencies:
|
||||
js-tiktoken: 1.0.21
|
||||
openai: 4.104.0(ws@8.21.0)(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.2(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- ws
|
||||
|
||||
"@langchain/openai@1.5.3(ws@8.21.0)":
|
||||
"@langchain/openai@1.5.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)":
|
||||
dependencies:
|
||||
"@langchain/core": 1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)
|
||||
js-tiktoken: 1.0.21
|
||||
openai: 6.45.0(ws@8.21.0)(zod@4.4.3)
|
||||
zod: 4.4.3
|
||||
@@ -10662,9 +10371,10 @@ snapshots:
|
||||
- "@smithy/signature-v4"
|
||||
- ws
|
||||
|
||||
"@langchain/openrouter@0.4.3(ws@8.21.0)(zod@4.4.3)":
|
||||
"@langchain/openrouter@0.4.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)(zod@4.4.3)":
|
||||
dependencies:
|
||||
"@langchain/openai": 1.5.3(ws@8.21.0)
|
||||
"@langchain/core": 1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)
|
||||
"@langchain/openai": 1.5.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)
|
||||
eventsource-parser: 3.1.0
|
||||
openai: 6.45.0(ws@8.21.0)(zod@4.4.3)
|
||||
transitivePeerDependencies:
|
||||
@@ -11915,15 +11625,6 @@ snapshots:
|
||||
dependencies:
|
||||
"@types/unist": 3.0.3
|
||||
|
||||
"@types/node-fetch@2.6.13":
|
||||
dependencies:
|
||||
"@types/node": 26.1.0
|
||||
form-data: 4.0.6
|
||||
|
||||
"@types/node@18.19.130":
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
"@types/node@20.19.43":
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -12096,10 +11797,6 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -12111,10 +11808,6 @@ snapshots:
|
||||
|
||||
acorn@8.17.0: {}
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
dependencies:
|
||||
humanize-ms: 1.2.1
|
||||
|
||||
ajv-formats@2.1.1(ajv@8.20.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.20.0
|
||||
@@ -12279,8 +11972,6 @@ snapshots:
|
||||
- uploadthing
|
||||
- yaml
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomically@1.7.0: {}
|
||||
|
||||
autoprefixer@10.5.2(postcss@8.5.16):
|
||||
@@ -12440,10 +12131,6 @@ snapshots:
|
||||
color-convert: 2.0.1
|
||||
color-string: 1.9.1
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@11.1.0: {}
|
||||
@@ -12774,8 +12461,6 @@ snapshots:
|
||||
dependencies:
|
||||
robust-predicates: 3.0.3
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
@@ -12876,13 +12561,6 @@ snapshots:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.4
|
||||
|
||||
es-toolkit@1.49.0: {}
|
||||
|
||||
esast-util-from-estree@2.0.0:
|
||||
@@ -13045,8 +12723,6 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
@@ -13161,10 +12837,6 @@ snapshots:
|
||||
dependencies:
|
||||
fast-string-width: 3.0.2
|
||||
|
||||
fast-xml-parser@4.5.7:
|
||||
dependencies:
|
||||
strnum: 1.1.2
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -13224,21 +12896,6 @@ snapshots:
|
||||
dependencies:
|
||||
tiny-inflate: 1.0.3
|
||||
|
||||
form-data-encoder@1.7.2: {}
|
||||
|
||||
form-data@4.0.6:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
formdata-node@4.4.1:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 4.0.0-beta.3
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
@@ -13331,10 +12988,6 @@ snapshots:
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
@@ -13550,10 +13203,6 @@ snapshots:
|
||||
|
||||
human-signals@8.0.1: {}
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
i18next@26.3.4(typescript@6.0.3):
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
@@ -13684,11 +13333,6 @@ snapshots:
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-schema-to-ts@3.1.1:
|
||||
dependencies:
|
||||
"@babel/runtime": 7.29.7
|
||||
ts-algebra: 2.0.0
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
@@ -14336,14 +13980,8 @@ snapshots:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.2
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime-types@3.0.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
@@ -14424,14 +14062,8 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.8.5
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-mock-http@1.0.4: {}
|
||||
|
||||
node-releases@2.0.51: {}
|
||||
@@ -14467,10 +14099,6 @@ snapshots:
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
ollama@0.5.18:
|
||||
dependencies:
|
||||
whatwg-fetch: 3.6.20
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
@@ -14510,21 +14138,6 @@ snapshots:
|
||||
is-docker: 2.2.1
|
||||
is-wsl: 2.2.0
|
||||
|
||||
openai@4.104.0(ws@8.21.0)(zod@3.25.76):
|
||||
dependencies:
|
||||
"@types/node": 18.19.130
|
||||
"@types/node-fetch": 2.6.13
|
||||
abort-controller: 3.0.0
|
||||
agentkeepalive: 4.6.0
|
||||
form-data-encoder: 1.7.2
|
||||
formdata-node: 4.4.1
|
||||
node-fetch: 2.7.0
|
||||
optionalDependencies:
|
||||
ws: 8.21.0
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
openai@6.45.0(ws@8.21.0)(zod@4.4.3):
|
||||
optionalDependencies:
|
||||
ws: 8.21.0
|
||||
@@ -15403,8 +15016,6 @@ snapshots:
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
strnum@1.1.2: {}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
dependencies:
|
||||
style-to-object: 1.0.14
|
||||
@@ -15484,14 +15095,10 @@ snapshots:
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
|
||||
trough@2.2.0: {}
|
||||
|
||||
ts-algebra@2.0.0: {}
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
@@ -15544,8 +15151,6 @@ snapshots:
|
||||
|
||||
uncrypto@0.1.3: {}
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
@@ -15670,8 +15275,6 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@10.0.0: {}
|
||||
|
||||
uuid@14.0.1: {}
|
||||
|
||||
validate-npm-package-name@7.0.2: {}
|
||||
@@ -15752,17 +15355,6 @@ snapshots:
|
||||
|
||||
web-namespaces@2.0.1: {}
|
||||
|
||||
web-streams-polyfill@4.0.0-beta.3: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
whatwg-fetch@3.6.20: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
Reference in New Issue
Block a user