diff --git a/apps/gui/src/components/play/PlayView.tsx b/apps/gui/src/components/play/PlayView.tsx index 3e25b75..23ecd39 100644 --- a/apps/gui/src/components/play/PlayView.tsx +++ b/apps/gui/src/components/play/PlayView.tsx @@ -50,6 +50,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 +156,124 @@ function PromptModal({ {activeTab === "actor" && entry.rawPrompt && (
{entry.usage ? ( -
- Token Usage: - Input: {entry.usage.inputTokens} ·{" "} - Output: {entry.usage.outputTokens} ·{" "} - Total: {entry.usage.totalTokens} +
+ LLM Instance: {entry.usage.providerInstanceName || "Default"} + {entry.usage.modelName && ( + ({entry.usage.modelName}) + )}
) : ( -
+
No LLM token usage (Player turn used fixed prose).
)} -
-

System Prompt

-
{entry.rawPrompt.systemPrompt}
-
+ {actorBreakdown && ( +
+
+ Input Prompt Breakdown + Total Input Tokens: {entry.usage?.inputTokens} +
+
+ {actorBreakdown.map((item, idx) => ( +
+ ))} +
+
+ {actorBreakdown.map((item, idx) => ( +
+ + + + {item.label}: {item.tokens} tokens ({item.pct.toFixed(0)}%) + + + +
+
{item.content}
+
+
+ ))} +
+
+ )} -
-

User Context

-
{entry.rawPrompt.userContext}
-
+ {entry.usage && ( +
+
+ LLM Output + Total Output Tokens: {entry.usage.outputTokens} +
+
+
{entry.narrativeProse}
+
+
+ )}
)} {activeTab === "decoder" && entry.decoderPrompt && (
{entry.decoderUsage && ( -
- Token Usage: - Input: {entry.decoderUsage.inputTokens} ·{" "} - Output: {entry.decoderUsage.outputTokens} ·{" "} - Total: {entry.decoderUsage.totalTokens} +
+ LLM Instance: {entry.decoderUsage.providerInstanceName || "Default"} + {entry.decoderUsage.modelName && ( + ({entry.decoderUsage.modelName}) + )}
)} -
-

System Prompt

-
{entry.decoderPrompt.systemPrompt}
-
+ {decoderBreakdown && ( +
+
+ Input Prompt Breakdown + Total Input Tokens: {entry.decoderUsage?.inputTokens} +
+
+ {decoderBreakdown.map((item, idx) => ( +
+ ))} +
+
+ {decoderBreakdown.map((item, idx) => ( +
+ + + + {item.label}: {item.tokens} tokens ({item.pct.toFixed(0)}%) + + + +
+
{item.content}
+
+
+ ))} +
+
+ )} -
-

User Context

-
{entry.decoderPrompt.userContext}
-
+ {entry.decoderUsage && ( +
+
+ LLM Output + Total Output Tokens: {entry.decoderUsage.outputTokens} +
+
+
{JSON.stringify(entry.intents, null, 2)}
+
+
+ )}
)}
@@ -137,6 +282,22 @@ 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, @@ -153,7 +314,7 @@ function LogEntryCard({ {entry.entityName} Turn {entry.turn} ·{" "} - {new Date(entry.timestamp).toLocaleTimeString()} + {formatSimTime(entry.timestamp)}
{showMenu && ( @@ -185,6 +346,7 @@ export function PlayView() { const [selectedEntryForModal, setSelectedEntryForModal] = useState(null); const logEndRef = useRef(null); const steppingRef = useRef(false); + const pauseRequestedRef = useRef(false); const scrollToBottom = useCallback(() => { setTimeout( @@ -203,10 +365,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 +442,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 +475,6 @@ export function PlayView() { const [selectedEntity, setSelectedEntity] = useState(""); const [providerInstances, setProviderInstances] = useState([]); - const [selectedProviderInstance, setSelectedProviderInstance] = useState(""); // Load scenarios and provider instances on mount useEffect(() => { @@ -324,12 +491,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 +533,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 +545,8 @@ export function PlayView() { if (result.snapshot.status === "running") { await runSteps(result.snapshot.id); + } else { + setLoading(false); } } catch (err) { setError( @@ -491,25 +653,7 @@ export function PlayView() { ))}
-
- - -
+ @@ -560,15 +704,36 @@ export function PlayView() {

{snapshot.scenarioName}

{snapshot.status !== "done" && snapshot.status !== "error" && ( - +
+ {snapshot.status === "running" && ( + loading ? ( + + ) : ( + + ) + )} + +
)}

{snapshot.scenarioDescription}

@@ -721,6 +886,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 +1136,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; diff --git a/apps/gui/src/lib/simulation-types.ts b/apps/gui/src/lib/simulation-types.ts index 288958d..b5a098c 100644 --- a/apps/gui/src/lib/simulation-types.ts +++ b/apps/gui/src/lib/simulation-types.ts @@ -22,6 +22,8 @@ export interface LogEntry { inputTokens: number; outputTokens: number; totalTokens: number; + modelName?: string; + providerInstanceName?: string; }; decoderPrompt?: { systemPrompt: string; @@ -31,6 +33,8 @@ export interface LogEntry { inputTokens: number; outputTokens: number; totalTokens: number; + modelName?: string; + providerInstanceName?: string; }; } diff --git a/apps/gui/src/lib/simulation.ts b/apps/gui/src/lib/simulation.ts index 41e3f4b..350d172 100644 --- a/apps/gui/src/lib/simulation.ts +++ b/apps/gui/src/lib/simulation.ts @@ -225,11 +225,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([]); } @@ -658,9 +659,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([]); } diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index 5a1d3e8..70788e3 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -15,6 +15,8 @@ export interface LLMResponse { inputTokens: number; outputTokens: number; totalTokens: number; + modelName?: string; + providerInstanceName?: string; }; } @@ -25,6 +27,8 @@ export interface LLMCallRecord { inputTokens: number; outputTokens: number; totalTokens: number; + modelName?: string; + providerInstanceName?: string; }; } diff --git a/packages/llm/src/providers/google-genai.ts b/packages/llm/src/providers/google-genai.ts index 4f26057..5af55e4 100644 --- a/packages/llm/src/providers/google-genai.ts +++ b/packages/llm/src/providers/google-genai.ts @@ -12,11 +12,14 @@ export class GeminiProvider implements ILLMProvider { providerName = "Gemini"; private model: ChatGoogleGenerativeAI; + private modelNameUsed: string; + private providerInstanceName?: string; lastCalls: LLMCallRecord[] = []; - constructor(apiKey?: string, modelName?: string) { + constructor(apiKey?: string, modelName?: string, providerInstanceName?: string) { let key = apiKey; let model = modelName; + this.providerInstanceName = providerInstanceName; if (!key) { const active = ProviderManager.getActive(); @@ -25,20 +28,27 @@ export class GeminiProvider implements ILLMProvider { if (!model) { model = active.modelName; } + if (!this.providerInstanceName) { + this.providerInstanceName = active.name; + } } } if (!key) { key = llmConfig.GOOGLE_API_KEY; + if (!this.providerInstanceName && key) { + this.providerInstanceName = "Environment Variable"; + } } if (!key) { throw new Error("GOOGLE_API_KEY is required to initialize GeminiProvider"); } + this.modelNameUsed = model || "gemini-2.5-flash"; this.model = new ChatGoogleGenerativeAI({ apiKey: key, - model: model || "gemini-2.5-flash", + model: this.modelNameUsed, }); } @@ -63,11 +73,13 @@ export class GeminiProvider implements ILLMProvider { const parsed = result?.parsed; const raw = result?.raw; - const usage = raw?.usage_metadata ? { - inputTokens: raw.usage_metadata.input_tokens || 0, - outputTokens: raw.usage_metadata.output_tokens || 0, - totalTokens: raw.usage_metadata.total_tokens || 0, - } : undefined; + 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", + }; this.lastCalls.push({ systemPrompt: request.systemPrompt, diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts index 5600e9d..042c79e 100644 --- a/packages/llm/src/providers/openrouter.ts +++ b/packages/llm/src/providers/openrouter.ts @@ -12,11 +12,14 @@ export class OpenRouterProvider implements ILLMProvider { providerName = "OpenRouter"; private model: ChatOpenRouter; + private modelNameUsed: string; + private providerInstanceName?: string; lastCalls: LLMCallRecord[] = []; - constructor(apiKey?: string, modelName?: string) { + constructor(apiKey?: string, modelName?: string, providerInstanceName?: string) { let key = apiKey; let model = modelName; + this.providerInstanceName = providerInstanceName; if (!key) { const active = ProviderManager.getActive(); @@ -25,20 +28,27 @@ export class OpenRouterProvider implements ILLMProvider { if (!model) { model = active.modelName; } + if (!this.providerInstanceName) { + this.providerInstanceName = active.name; + } } } if (!key) { key = llmConfig.OPENROUTER_API_KEY; + if (!this.providerInstanceName && key) { + this.providerInstanceName = "Environment Variable"; + } } if (!key) { throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider"); } + this.modelNameUsed = model || "google/gemini-2.5-flash"; this.model = new ChatOpenRouter({ apiKey: key, - model: model || "google/gemini-2.5-flash", + model: this.modelNameUsed, }); } @@ -63,11 +73,13 @@ export class OpenRouterProvider implements ILLMProvider { const parsed = result?.parsed; const raw = result?.raw; - const usage = raw?.usage_metadata ? { - inputTokens: raw.usage_metadata.input_tokens || 0, - outputTokens: raw.usage_metadata.output_tokens || 0, - totalTokens: raw.usage_metadata.total_tokens || 0, - } : undefined; + 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", + }; this.lastCalls.push({ systemPrompt: request.systemPrompt, diff --git a/packages/llm/tests/openrouter.test.ts b/packages/llm/tests/openrouter.test.ts index 6aec82c..354de1a 100644 --- a/packages/llm/tests/openrouter.test.ts +++ b/packages/llm/tests/openrouter.test.ts @@ -91,6 +91,8 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => { inputTokens: 10, outputTokens: 5, totalTokens: 15, + modelName: "google/gemini-2.5-flash", + providerInstanceName: "Default", }); expect(provider.lastCalls.length).toBe(1); @@ -101,6 +103,8 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => { inputTokens: 10, outputTokens: 5, totalTokens: 15, + modelName: "google/gemini-2.5-flash", + providerInstanceName: "Default", }, }); });