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)}%)
+
+ ▼
+
+
+
+ ))}
+
+
+ )}
-
-
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)}%)
+
+ ▼
+
+
+
+ ))}
+
+
+ )}
-
-
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",
},
});
});