Merge remote-tracking branch 'origin/master' into master and resolve conflicts

This commit is contained in:
2026-07-11 16:38:39 +05:30
22 changed files with 1007 additions and 409 deletions

View File

@@ -30,7 +30,7 @@ export default function ConfigPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedInstanceId, setSelectedInstanceId] = useState<string | "new">("new");
const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editProvider, setEditProvider] = useState("google-genai");
const [editKey, setEditKey] = useState("");
@@ -39,7 +39,14 @@ export default function ConfigPage() {
const [editType, setEditType] = useState<"generative" | "embedding">("generative");
useEffect(() => {
if (selectedInstanceId === "new") {
if (selectedInstanceId === null) {
setEditName("");
setEditProvider("google-genai");
setEditKey("");
setEditModel("gemini-2.5-flash");
setEditIsActive(false);
setEditType("generative");
} else if (selectedInstanceId === "new") {
setEditName("");
const defaultProvider = "google-genai";
setEditProvider(defaultProvider);
@@ -65,43 +72,31 @@ export default function ConfigPage() {
const handleProviderChange = (providerId: string) => {
setEditProvider(providerId);
const pMeta = availableProviders.find((p) => p.id === providerId);
if (pMeta) {
setEditModel(editType === "embedding" ? pMeta.defaultEmbeddingModel : pMeta.defaultModel);
}
setEditModel(editType === "embedding" ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "");
};
const handleTypeChange = (type: "generative" | "embedding") => {
setEditType(type);
const pMeta = availableProviders.find((p) => p.id === editProvider);
if (pMeta) {
setEditModel(type === "embedding" ? pMeta.defaultEmbeddingModel : pMeta.defaultModel);
}
setEditModel(type === "embedding" ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "");
};
const loadInstances = useCallback(async () => {
try {
const list = await listProviderInstances();
setInstances(list);
} catch {
// ignore
}
const list = await listProviderInstances();
setInstances(list);
}, []);
const loadMappings = useCallback(async () => {
try {
const maps = await getProviderMappings();
setMappings(maps);
} catch {
// ignore
}
const maps = await getProviderMappings();
setMappings(maps);
}, []);
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const result = await getConfigStatus();
setConfig(result);
setLoading(true);
setError("");
const status = await getConfigStatus();
setConfig(status);
await loadInstances();
await loadMappings();
const provs = await getAvailableProviders();
@@ -183,14 +178,14 @@ export default function ConfigPage() {
};
const handleDelete = async () => {
if (selectedInstanceId === "new") return;
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
if (!confirm("Are you sure you want to delete this provider instance?")) return;
try {
setLoading(true);
setError("");
await deleteProviderInstance(selectedInstanceId);
setSelectedInstanceId("new");
setSelectedInstanceId(null);
await loadInstances();
await loadMappings();
} catch (err) {
@@ -226,22 +221,24 @@ export default function ConfigPage() {
<div className="mx-auto max-w-[800px] px-4 py-8">
<h1 className="mb-6 text-2xl">Configuration</h1>
{loading && <p>Loading configuration...</p>}
{config === null && loading && <p>Loading configuration...</p>}
{error && (
<div className="mb-4 rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{config && !loading && (
<>
<section className="mb-8 border-b border-gray-200 pb-6">
{config && (
<div className={loading ? "opacity-60 pointer-events-none transition-opacity duration-200" : "transition-opacity duration-200"}>
<section className="mb-8 pb-6">
<h2 className="mb-3 text-lg">LLM Provider Instances</h2>
<div className="mt-4 grid min-h-[400px] grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white md:grid-cols-[30%_70%]">
{/* 30% area */}
<div className="flex flex-col border-r border-gray-200 bg-gray-50">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-4 py-4">
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">Instances</h3>
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">
Instances
</h3>
<button
onClick={() => setSelectedInstanceId("new")}
className="cursor-pointer rounded-md bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-600"
@@ -266,7 +263,9 @@ export default function ConfigPage() {
: "border-l-transparent"
}`}
>
<div className="text-sm font-medium text-[#111]">{inst.name}</div>
<div className="text-sm font-medium text-[#111]">
{inst.name}
</div>
<div className="mt-1 flex items-center justify-between text-xs text-gray-500">
<span>{inst.providerName} ({inst.type || "generative"})</span>
{inst.isActive && (
@@ -283,148 +282,154 @@ export default function ConfigPage() {
{/* 70% area */}
<div className="flex flex-col bg-white">
<form onSubmit={handleSave} className="flex h-full flex-col justify-between">
<div className="flex flex-1 flex-col gap-5 p-6">
<h3 className="m-0 mb-2 text-lg font-semibold text-[#111]">
{selectedInstanceId === "new"
? "Create New Provider Instance"
: `Configure: ${editName}`}
</h3>
<div className="flex flex-col gap-1.5">
<label htmlFor="formName" className="text-xs font-medium text-gray-700">
Friendly Name
</label>
<input
id="formName"
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="e.g. Gemini - Production"
required
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formType" className="text-xs font-medium text-gray-700">
Instance Type
</label>
<select
id="formType"
value={editType}
onChange={(e) => handleTypeChange(e.target.value as "generative" | "embedding")}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
>
<option value="generative">Generative (Chat / Text Completion)</option>
<option value="embedding">Embedding (Vector generation)</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formProvider" className="text-xs font-medium text-gray-700">
Provider Type
</label>
<select
id="formProvider"
value={editProvider}
onChange={(e) => handleProviderChange(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
>
{availableProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.displayName}
</option>
))}
</select>
{editProvider && availableProviders.length > 0 && (
<span className="mt-1 block rounded border border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-600">
{availableProviders.find((p) => p.id === editProvider)?.description}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formKey" className="text-xs font-medium text-gray-700">
API Key
</label>
<input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formModel" className="text-xs font-medium text-gray-700">
Model Name
</label>
<input
id="formModel"
type="text"
value={editModel}
onChange={(e) => setEditModel(e.target.value)}
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="mt-1 flex flex-row items-center gap-2">
<input
id="formActive"
type="checkbox"
checked={editIsActive}
onChange={(e) => setEditIsActive(e.target.checked)}
className="h-4 w-4 cursor-pointer"
/>
<label htmlFor="formActive" className="cursor-pointer text-xs font-medium text-gray-700">
Set as Active Instance
</label>
</div>
{selectedInstanceId === null ? (
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center text-sm text-gray-400">
Press + to add or select an existing Instance to edit
</div>
) : (
<form onSubmit={handleSave} className="flex h-full flex-col justify-between">
<div className="flex flex-1 flex-col gap-5 p-6">
<h3 className="m-0 mb-2 text-lg font-semibold text-[#111]">
{selectedInstanceId === "new"
? "Create New Provider Instance"
: `Configure: ${editName}`}
</h3>
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-4">
<div>
{selectedInstanceId !== "new" && (
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="cursor-pointer rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600 disabled:opacity-50"
<div className="flex flex-col gap-1.5">
<label htmlFor="formName" className="text-xs font-medium text-gray-700">
Friendly Name
</label>
<input
id="formName"
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="e.g. Gemini - Production"
required
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formType" className="text-xs font-medium text-gray-700">
Instance Type
</label>
<select
id="formType"
value={editType}
onChange={(e) => handleTypeChange(e.target.value as "generative" | "embedding")}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
>
Delete
<option value="generative">Generative (Chat / Text Completion)</option>
<option value="embedding">Embedding (Vector generation)</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formProvider" className="text-xs font-medium text-gray-700">
Provider Type
</label>
<select
id="formProvider"
value={editProvider}
onChange={(e) => handleProviderChange(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
>
{availableProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.displayName}
</option>
))}
</select>
{editProvider && availableProviders.length > 0 && (
<span className="mt-1 block rounded border border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-600">
{availableProviders.find((p) => p.id === editProvider)?.description}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formKey" className="text-xs font-medium text-gray-700">
API Key
</label>
<input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formModel" className="text-xs font-medium text-gray-700">
Model Name
</label>
<input
id="formModel"
type="text"
value={editModel}
onChange={(e) => setEditModel(e.target.value)}
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="mt-1 flex flex-row items-center gap-2">
<input
id="formActive"
type="checkbox"
checked={editIsActive}
onChange={(e) => setEditIsActive(e.target.checked)}
className="h-4 w-4 cursor-pointer"
/>
<label htmlFor="formActive" className="cursor-pointer text-xs font-medium text-gray-700">
Set as Active Instance
</label>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-4">
<div>
{selectedInstanceId !== "new" && (
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="cursor-pointer rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600 disabled:opacity-50"
>
Delete
</button>
)}
</div>
<div>
<button
type="submit"
disabled={loading}
className="cursor-pointer rounded-md bg-blue-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Saving..." : "Save"}
</button>
)}
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="cursor-pointer rounded-md bg-blue-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Saving..." : "Save"}
</button>
</div>
</div>
</form>
</form>
)}
</div>
</div>
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<section className="mb-8 pb-6">
<h2 className="mb-3 text-lg">Task Provider Routing</h2>
<p className="my-4 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
Configure which LLM Provider Key Instance should handle each specific simulation
task. Mappings default to the currently <strong>Active</strong> instance if not
specified.
Configure which LLM Provider Key Instance should handle each
specific simulation task. Mappings default to the currently{" "}
<strong>Active</strong> instance if not specified.
</p>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
{[
@@ -439,12 +444,16 @@ export default function ConfigPage() {
className="flex flex-col justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4"
>
<div className="flex flex-col gap-1 text-xs">
<strong className="text-sm text-[#111]">{task.label}</strong>
<strong className="text-sm text-[#111]">
{task.label}
</strong>
<span className="mt-0.5 text-gray-500">{task.desc}</span>
</div>
<select
value={mappings[task.key] || ""}
onChange={(e) => handleUpdateMapping(task.key, e.target.value)}
onChange={(e) =>
handleUpdateMapping(task.key, e.target.value)
}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs"
>
<option value="">-- Use Active Key (Default) --</option>
@@ -461,35 +470,15 @@ export default function ConfigPage() {
</div>
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Environment Variables Default</h2>
<div className="flex justify-between border-b border-gray-100 py-1.5">
<span className="text-sm text-gray-500">Default Model</span>
<span className="text-sm">
<code className="font-mono text-sm">{config.model}</code>
</span>
</div>
<div className="flex justify-between border-b border-gray-100 py-1.5">
<span className="text-sm text-gray-500">Default API Key (.env)</span>
<span
className={
config.apiKeySet
? "text-sm text-green-600"
: "text-sm font-medium text-red-600"
}
>
{config.apiKeySet
? `✓ Set (${config.apiKeyPreview})`
: "✗ NOT SET"}
</span>
</div>
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<section className="mb-8 pb-6">
<h2 className="mb-3 text-lg">Available Scenarios</h2>
{config.availableScenarios.length === 0 ? (
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
No scenarios found in <code className="font-mono text-xs">content/demo/scenarios/</code>.
No scenarios found in{" "}
<code className="font-mono text-xs">
content/demo/scenarios/
</code>
.
</p>
) : (
<table className="w-full border-collapse text-sm">
@@ -508,7 +497,9 @@ export default function ConfigPage() {
<tr key={s.path}>
<td className="border-b border-gray-100 p-2">{s.name}</td>
<td className="border-b border-gray-100 p-2">
<code className="font-mono text-xs text-blue-600">{s.path}</code>
<code className="font-mono text-xs text-blue-600">
{s.path}
</code>
</td>
</tr>
))}
@@ -516,18 +507,7 @@ export default function ConfigPage() {
</table>
)}
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Engine Packages</h2>
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
All <code className="font-mono text-xs">@omnia/*</code> workspace packages are
consumed via <code className="font-mono text-xs">transpilePackages</code> in{" "}
<code className="font-mono text-xs">next.config.ts</code>. The native{" "}
<code className="font-mono text-xs">better-sqlite3</code> module is externalized
via <code className="font-mono text-xs">serverExternalPackages</code>.
</p>
</section>
</>
</div>
)}
</div>
);

View File

@@ -17,8 +17,10 @@ import type { LLMProviderInstance } from "@omnia/llm";
function IntentTag({
intent,
isSelf,
}: {
intent: SimSnapshot["log"][number]["intents"][number];
isSelf?: boolean;
}) {
const labels: Record<string, string> = {
monologue: "thought",
@@ -33,9 +35,19 @@ function IntentTag({
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
}
const textToDisplay = (isSelf && intent.selfDescription)
? intent.selfDescription
: intent.description;
const modifiersStr = intent.modifiers && intent.modifiers.length > 0 ? (
<span className="intent-modifiers" style={{ fontStyle: "italic", opacity: 0.8, color: "#4b5563", marginLeft: "0.25rem" }}>
({intent.modifiers.join(", ")})
</span>
) : null;
return (
<span className="intent-tag">
[{label}] &ldquo;{intent.description}&rdquo;{outcome}
[{label}] &ldquo;{textToDisplay}&rdquo;{modifiersStr}{outcome}
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
</span>
);
@@ -50,6 +62,77 @@ function PromptModal({
}) {
const [activeTab, setActiveTab] = useState<"actor" | "decoder">("actor");
const parseActorPrompt = (systemPrompt: string, userContext: string, inputTokens: number) => {
const memoryHeader = "=== YOUR RECENT MEMORY ===";
const idx = userContext.indexOf(memoryHeader);
let worldStr = userContext;
let memStr = "";
if (idx !== -1) {
worldStr = userContext.substring(0, idx).trim();
memStr = userContext.substring(idx).trim();
}
const sysLen = systemPrompt.length;
const worldLen = worldStr.length;
const memLen = memStr.length;
const totalLen = sysLen + worldLen + memLen;
if (totalLen === 0) return null;
const sysPct = (sysLen / totalLen) * 100;
const worldPct = (worldLen / totalLen) * 100;
const memPct = (memLen / totalLen) * 100;
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
const memTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
return [
{ label: "System Prompt", pct: sysPct, tokens: sysTokens, type: "system", content: systemPrompt },
{ label: "World Info", pct: worldPct, tokens: worldTokens, type: "world", content: worldStr },
{ label: "Recent Memories", pct: memPct, tokens: memTokens, type: "memories", content: memStr || "(No memories yet.)" },
];
};
const parseDecoderPrompt = (systemPrompt: string, userContext: string, inputTokens: number) => {
const proseHeader = "=== NARRATIVE PROSE ===";
const idx = userContext.indexOf(proseHeader);
let worldStr = userContext;
let proseStr = "";
if (idx !== -1) {
worldStr = userContext.substring(0, idx).trim();
proseStr = userContext.substring(idx).trim();
}
const sysLen = systemPrompt.length;
const worldLen = worldStr.length;
const proseLen = proseStr.length;
const totalLen = sysLen + worldLen + proseLen;
if (totalLen === 0) return null;
const sysPct = (sysLen / totalLen) * 100;
const worldPct = (worldLen / totalLen) * 100;
const prosePct = (proseLen / totalLen) * 100;
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
const proseTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
return [
{ label: "System Prompt", pct: sysPct, tokens: sysTokens, type: "system", content: systemPrompt },
{ label: "Decoder Context", pct: worldPct, tokens: worldTokens, type: "world", content: worldStr },
{ label: "Narrative Prose", pct: prosePct, tokens: proseTokens, type: "memories", content: proseStr },
];
};
const actorBreakdown = (entry.rawPrompt && entry.usage) ? parseActorPrompt(entry.rawPrompt.systemPrompt, entry.rawPrompt.userContext, entry.usage.inputTokens) : null;
const decoderBreakdown = (entry.decoderPrompt && entry.decoderUsage) ? parseDecoderPrompt(entry.decoderPrompt.systemPrompt, entry.decoderPrompt.userContext, entry.decoderUsage.inputTokens) : null;
useEffect(() => {
if (!entry.rawPrompt && entry.decoderPrompt) {
setActiveTab("decoder");
@@ -85,50 +168,124 @@ function PromptModal({
{activeTab === "actor" && entry.rawPrompt && (
<div className="tab-pane">
{entry.usage ? (
<div className="usage-stats">
<strong>Token Usage:</strong>
<span>Input: <code>{entry.usage.inputTokens}</code></span> &middot;{" "}
<span>Output: <code>{entry.usage.outputTokens}</code></span> &middot;{" "}
<span>Total: <code>{entry.usage.totalTokens}</code></span>
<div className="provider-info">
<strong>LLM Instance:</strong> <span>{entry.usage.providerInstanceName || "Default"}</span>
{entry.usage.modelName && (
<span> ({entry.usage.modelName})</span>
)}
</div>
) : (
<div className="usage-stats italic text-gray">
<div className="provider-info italic text-gray">
No LLM token usage (Player turn used fixed prose).
</div>
)}
<div className="prompt-field">
<h4>System Prompt</h4>
<pre>{entry.rawPrompt.systemPrompt}</pre>
</div>
{actorBreakdown && (
<div className="prompt-breakdown-container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.25rem" }}>
<span style={{ fontWeight: 600 }}>Input Prompt Breakdown</span>
<span>Total Input Tokens: <strong>{entry.usage?.inputTokens}</strong></span>
</div>
<div className="prompt-breakdown-bar">
{actorBreakdown.map((item, idx) => (
<div
key={idx}
className={`bar-section ${item.type}`}
style={{ width: `${item.pct}%` }}
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
/>
))}
</div>
<div className="breakdown-accordion">
{actorBreakdown.map((item, idx) => (
<details key={idx} className="breakdown-accordion-item" open={idx === 0}>
<summary className="accordion-header">
<span className={`legend-color ${item.type}`} />
<span className="header-text">
{item.label}: <strong>{item.tokens}</strong> tokens ({item.pct.toFixed(0)}%)
</span>
<span className="accordion-chevron"></span>
</summary>
<div className="accordion-content">
<pre>{item.content}</pre>
</div>
</details>
))}
</div>
</div>
)}
<div className="prompt-field">
<h4>User Context</h4>
<pre>{entry.rawPrompt.userContext}</pre>
</div>
{entry.usage && (
<div className="prompt-output-section" style={{ marginTop: "0.5rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.5rem" }}>
<span style={{ fontWeight: 600 }}>LLM Output</span>
<span>Total Output Tokens: <strong>{entry.usage.outputTokens}</strong></span>
</div>
<div className="accordion-content" style={{ border: "1px solid #e5e7eb", borderRadius: "6px" }}>
<pre>{entry.narrativeProse}</pre>
</div>
</div>
)}
</div>
)}
{activeTab === "decoder" && entry.decoderPrompt && (
<div className="tab-pane">
{entry.decoderUsage && (
<div className="usage-stats">
<strong>Token Usage:</strong>
<span>Input: <code>{entry.decoderUsage.inputTokens}</code></span> &middot;{" "}
<span>Output: <code>{entry.decoderUsage.outputTokens}</code></span> &middot;{" "}
<span>Total: <code>{entry.decoderUsage.totalTokens}</code></span>
<div className="provider-info">
<strong>LLM Instance:</strong> <span>{entry.decoderUsage.providerInstanceName || "Default"}</span>
{entry.decoderUsage.modelName && (
<span> ({entry.decoderUsage.modelName})</span>
)}
</div>
)}
<div className="prompt-field">
<h4>System Prompt</h4>
<pre>{entry.decoderPrompt.systemPrompt}</pre>
</div>
{decoderBreakdown && (
<div className="prompt-breakdown-container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.25rem" }}>
<span style={{ fontWeight: 600 }}>Input Prompt Breakdown</span>
<span>Total Input Tokens: <strong>{entry.decoderUsage?.inputTokens}</strong></span>
</div>
<div className="prompt-breakdown-bar">
{decoderBreakdown.map((item, idx) => (
<div
key={idx}
className={`bar-section ${item.type}`}
style={{ width: `${item.pct}%` }}
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
/>
))}
</div>
<div className="breakdown-accordion">
{decoderBreakdown.map((item, idx) => (
<details key={idx} className="breakdown-accordion-item" open={idx === 0}>
<summary className="accordion-header">
<span className={`legend-color ${item.type}`} />
<span className="header-text">
{item.label}: <strong>{item.tokens}</strong> tokens ({item.pct.toFixed(0)}%)
</span>
<span className="accordion-chevron"></span>
</summary>
<div className="accordion-content">
<pre>{item.content}</pre>
</div>
</details>
))}
</div>
</div>
)}
<div className="prompt-field">
<h4>User Context</h4>
<pre>{entry.decoderPrompt.userContext}</pre>
</div>
{entry.decoderUsage && (
<div className="prompt-output-section" style={{ marginTop: "0.5rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.5rem" }}>
<span style={{ fontWeight: 600 }}>LLM Output</span>
<span>Total Output Tokens: <strong>{entry.decoderUsage.outputTokens}</strong></span>
</div>
<div className="accordion-content" style={{ border: "1px solid #e5e7eb", borderRadius: "6px" }}>
<pre>{JSON.stringify(entry.intents, null, 2)}</pre>
</div>
</div>
)}
</div>
)}
</div>
@@ -137,12 +294,30 @@ function PromptModal({
);
}
function formatSimTime(isoString: string) {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const hh = String(d.getUTCHours()).padStart(2, "0");
const min = String(d.getUTCMinutes()).padStart(2, "0");
const ss = String(d.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
} catch {
return isoString;
}
}
function LogEntryCard({
entry,
onShowPrompt,
isPlayerCard,
}: {
entry: SimSnapshot["log"][number];
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
isPlayerCard: boolean;
}) {
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
@@ -153,7 +328,7 @@ function LogEntryCard({
<strong>{entry.entityName}</strong>
<span className="log-meta">
Turn {entry.turn} &middot;{" "}
{new Date(entry.timestamp).toLocaleTimeString()}
{formatSimTime(entry.timestamp)}
</span>
</div>
{showMenu && (
@@ -169,7 +344,7 @@ function LogEntryCard({
<div className="log-prose">{entry.narrativeProse}</div>
<div className="log-intents">
{entry.intents.map((intent, i) => (
<IntentTag key={i} intent={intent} />
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
))}
</div>
</div>
@@ -185,6 +360,7 @@ export function PlayView() {
const [selectedEntryForModal, setSelectedEntryForModal] = useState<SimSnapshot["log"][number] | null>(null);
const logEndRef = useRef<HTMLDivElement>(null);
const steppingRef = useRef(false);
const pauseRequestedRef = useRef(false);
const scrollToBottom = useCallback(() => {
setTimeout(
@@ -203,10 +379,14 @@ export function PlayView() {
steppingRef.current = true;
setLoading(true);
setError("");
pauseRequestedRef.current = false;
try {
let current = snapshot;
while (true) {
if (pauseRequestedRef.current) {
break;
}
const result = await stepSimulation({ simId: id });
if (!result.ok) {
setError(result.error);
@@ -276,6 +456,8 @@ export function PlayView() {
setSnapshot(res.snapshot);
if (res.snapshot.status === "running") {
await runSteps(res.snapshot.id);
} else {
setLoading(false);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to resume session.");
@@ -307,7 +489,6 @@ export function PlayView() {
const [selectedEntity, setSelectedEntity] = useState("");
const [providerInstances, setProviderInstances] = useState<LLMProviderInstance[]>([]);
const [selectedProviderInstance, setSelectedProviderInstance] = useState("");
// Load scenarios and provider instances on mount
useEffect(() => {
@@ -324,12 +505,6 @@ export function PlayView() {
try {
const providersList = await listProviderInstances();
setProviderInstances(providersList);
const active = providersList.find(p => p.isActive);
if (active) {
setSelectedProviderInstance(active.id);
} else if (providersList.length > 0) {
setSelectedProviderInstance(providersList[0].id);
}
} catch {
// ignore
}
@@ -372,7 +547,6 @@ export function PlayView() {
const result = await startSimulation({
scenario: (form.get("scenario") as string) || undefined,
playEntity: (form.get("playEntity") as string) || undefined,
providerInstanceId: selectedProviderInstance || undefined,
});
if (!result.ok) {
@@ -385,6 +559,8 @@ export function PlayView() {
if (result.snapshot.status === "running") {
await runSteps(result.snapshot.id);
} else {
setLoading(false);
}
} catch (err) {
setError(
@@ -491,26 +667,8 @@ export function PlayView() {
))}
</select>
</div>
<div className="field">
<label htmlFor="llmInstance">LLM Key / Instance</label>
<select
id="llmInstance"
value={selectedProviderInstance}
onChange={(e) => setSelectedProviderInstance(e.target.value)}
disabled={providerInstances.length === 0}
>
{providerInstances.length === 0 ? (
<option value="">Default (from Env variable)</option>
) : (
providerInstances.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.providerName}) {p.isActive ? " [Active]" : ""}
</option>
))
)}
</select>
</div>
<button type="submit" disabled={loading}>
<button type="submit" disabled={loading || providerInstances.length === 0}>
{loading ? "Starting..." : "Start Simulation"}
</button>
</form>
@@ -534,7 +692,7 @@ export function PlayView() {
</span>
</div>
<div className="card-actions">
<button onClick={() => handleResume(s.id)} disabled={loading}>
<button onClick={() => handleResume(s.id)} disabled={loading || providerInstances.length === 0}>
Resume
</button>
<button
@@ -560,15 +718,36 @@ export function PlayView() {
<div className="sim-info-header">
<h2>{snapshot.scenarioName}</h2>
{snapshot.status !== "done" && snapshot.status !== "error" && (
<button
className="stop-btn"
onClick={() => {
setSnapshot(null);
setError("");
}}
>
Stop
</button>
<div style={{ display: "flex", gap: "0.5rem" }}>
{snapshot.status === "running" && (
loading ? (
<button
className="pause-btn"
onClick={() => {
pauseRequestedRef.current = true;
}}
>
Pause
</button>
) : (
<button
className="resume-btn"
onClick={() => runSteps(snapshot.id)}
>
Resume
</button>
)
)}
<button
className="stop-btn"
onClick={() => {
setSnapshot(null);
setError("");
}}
>
Stop
</button>
</div>
)}
</div>
<p>{snapshot.scenarioDescription}</p>
@@ -579,13 +758,17 @@ export function PlayView() {
</div>
<div className="log-container">
{snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={setSelectedEntryForModal}
/>
))}
{(() => {
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
return snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={setSelectedEntryForModal}
isPlayerCard={entry.entityId === playerEntity?.id}
/>
));
})()}
{loading && (
<div className="log-processing">
<span className="spinner" />
@@ -721,6 +904,18 @@ export function PlayView() {
padding: 0.25rem 0.75rem;
}
.pause-btn {
background: #d97706;
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
}
.resume-btn {
background: #059669;
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
}
.sim-info {
margin-bottom: 1rem;
}
@@ -959,18 +1154,112 @@ export function PlayView() {
flex-direction: column;
gap: 1rem;
}
.usage-stats {
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #1e3a8a;
padding: 0.625rem 0.875rem;
.provider-info {
background: #f9fafb;
border: 1px solid #e5e7eb;
color: #374151;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.prompt-breakdown-bar {
display: flex;
height: 24px;
width: 100%;
border-radius: 4px;
overflow: hidden;
background: #e5e7eb;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.bar-section {
height: 100%;
transition: width 0.3s ease;
}
.bar-section.system {
background: #3b82f6;
}
.bar-section.world {
background: #10b981;
}
.bar-section.memories {
background: #f59e0b;
}
.breakdown-accordion {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
details.breakdown-accordion-item {
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 0.5rem;
background: #fff;
overflow: hidden;
}
summary.accordion-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f9fafb;
cursor: pointer;
user-select: none;
font-size: 0.8125rem;
font-weight: 500;
}
summary.accordion-header::-webkit-details-marker {
display: none;
}
summary.accordion-header {
list-style: none;
}
.header-text {
flex-grow: 1;
}
.accordion-chevron {
font-size: 0.75rem;
color: #9ca3af;
transition: transform 0.2s ease;
}
details[open] .accordion-chevron {
transform: rotate(180deg);
}
.accordion-content {
padding: 0.75rem;
border-top: 1px solid #e5e7eb;
background: #fafafa;
}
.accordion-content pre {
margin: 0;
padding: 0.5rem;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
max-height: 250px;
overflow-y: auto;
color: #1f2937;
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 2px;
display: inline-block;
}
.legend-color.system {
background: #3b82f6;
}
.legend-color.world {
background: #10b981;
}
.legend-color.memories {
background: #f59e0b;
}
.usage-stats code {
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;

View File

@@ -1,6 +1,8 @@
export interface IntentInfo {
type: string;
description: string;
selfDescription?: string;
modifiers: string[];
targetIds: string[];
isValid?: boolean;
reason?: string;
@@ -22,6 +24,8 @@ export interface LogEntry {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
};
decoderPrompt?: {
systemPrompt: string;
@@ -31,6 +35,8 @@ export interface LogEntry {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
};
}

View File

@@ -131,7 +131,6 @@ class SimulationManager {
activeInstance = ProviderManager.create("Default (Env)", "google-genai", envKey, undefined, "generative");
}
}
if (!activeInstance) {
return {
id: "",
@@ -235,11 +234,12 @@ class SimulationManager {
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
const providerName = inst ? inst.providerName : "google-genai";
const modelName = inst ? inst.modelName : undefined;
const instanceName = inst ? inst.name : undefined;
if (providerName === "google-genai") {
return new GeminiProvider(key, modelName);
return new GeminiProvider(key, modelName, instanceName);
} else if (providerName === "openrouter") {
return new OpenRouterProvider(key, modelName);
return new OpenRouterProvider(key, modelName, instanceName);
} else {
return new MockLLMProvider([]);
}
@@ -416,6 +416,8 @@ class SimulationManager {
entry.intents.push({
type: intent.type,
description: intent.description,
selfDescription: intent.selfDescription,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
isValid: outcome.isValid,
reason: outcome.reason,
@@ -560,6 +562,8 @@ class SimulationManager {
entry.intents.push({
type: intent.type,
description: intent.description,
selfDescription: intent.selfDescription,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
isValid: outcome.isValid,
reason: outcome.reason,
@@ -685,6 +689,7 @@ class SimulationManager {
if (!inst || inst.type !== "generative") {
inst = active;
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
@@ -697,9 +702,9 @@ class SimulationManager {
}
if (inst.providerName === "google-genai") {
return new GeminiProvider(inst.apiKey, inst.modelName);
return new GeminiProvider(inst.apiKey, inst.modelName, inst.name);
} else if (inst.providerName === "openrouter") {
return new OpenRouterProvider(inst.apiKey, inst.modelName);
return new OpenRouterProvider(inst.apiKey, inst.modelName, inst.name);
} else {
return new MockLLMProvider([]);
}