feat(ui): include API runtime status in debug transcript export
Added 'runtimeMode' and 'runtimeStatus' to the 'Copy Debug' transcript. This helps diagnose model loading errors or API initialization issues when users export chat logs for troubleshooting.
This commit is contained in:
1 parent
2144a92349
commit
d14f4adb0b
3 files changed
+220
-3
No files matched your search
@@ -725,6 +725,8 @@ export default function ChatView({
|
|||||||
agenticMode,
|
agenticMode,
|
||||||
savedMemories,
|
savedMemories,
|
||||||
latestInteraction,
|
latestInteraction,
|
||||||
|
runtimeMode,
|
||||||
|
runtimeStatus,
|
||||||
});
|
});
|
||||||
await navigator.clipboard.writeText(transcript);
|
await navigator.clipboard.writeText(transcript);
|
||||||
setDebugCopied(true);
|
setDebugCopied(true);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ describe("formatChatDebugTranscript", () => {
|
|||||||
created_at: 1710000060,
|
created_at: 1710000060,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
runtimeMode: "remote",
|
||||||
|
runtimeStatus: "ready",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(output).toContain("Chat ID: 7");
|
expect(output).toContain("Chat ID: 7");
|
||||||
|
|||||||
@@ -1,23 +1,236 @@
|
|||||||
import type { Message } from "./types";
|
import type { Message, UserMemory } from "./types";
|
||||||
|
import type { InteractionLog } from "./devLogger";
|
||||||
|
|
||||||
interface FormatChatDebugTranscriptOptions {
|
interface FormatChatDebugTranscriptOptions {
|
||||||
chatId: number | null;
|
chatId: number | null;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
|
model?: string;
|
||||||
|
server?: string;
|
||||||
|
apiKeyPresent?: boolean;
|
||||||
|
memoryEnabled?: boolean;
|
||||||
|
streamEnabled?: boolean;
|
||||||
|
agenticMode?: boolean;
|
||||||
|
savedMemories?: UserMemory[];
|
||||||
|
latestInteraction?: InteractionLog | null;
|
||||||
|
runtimeMode?: "local" | "remote";
|
||||||
|
runtimeStatus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(timestamp: number | null | undefined): string {
|
function formatTimestamp(timestamp: number | null | undefined): string {
|
||||||
if (!timestamp) return "unknown";
|
if (!timestamp) return "unknown";
|
||||||
return new Date(timestamp * 1000).toISOString();
|
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000;
|
||||||
|
return new Date(normalized).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatChatDebugTranscript({ chatId, messages }: FormatChatDebugTranscriptOptions): string {
|
function formatJsonBlock(label: string, value: unknown): string[] {
|
||||||
|
return [label, "```json", JSON.stringify(value, null, 2), "```", ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactJsonBlock(label: string, value: unknown): string[] {
|
||||||
|
return [label, "```json", JSON.stringify(value, null, 2), "```", ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string, max = 1200): string {
|
||||||
|
if (value.length <= max) return value;
|
||||||
|
return `${value.slice(0, max)}\n…[truncated ${value.length - max} chars]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeVisibleIssue(interaction: InteractionLog | null): string[] {
|
||||||
|
if (!interaction) return [];
|
||||||
|
|
||||||
|
const visibleEmpty = interaction.parsed.visibleContent.trim().length === 0;
|
||||||
|
const reasoningPresent = interaction.parsed.reasoningContent.trim().length > 0;
|
||||||
|
const corruptedWrite = interaction.dbWrites.some((write) => write.operation.toLowerCase().includes("corrupted"));
|
||||||
|
|
||||||
|
if (!visibleEmpty && !interaction.parsed.isCorrupted && interaction.errors.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = ["## Diagnostic Summary"];
|
||||||
|
|
||||||
|
if (visibleEmpty && reasoningPresent) {
|
||||||
|
lines.push("- The model produced reasoning/thinking but no user-visible answer.");
|
||||||
|
}
|
||||||
|
if (corruptedWrite) {
|
||||||
|
lines.push("- The assistant response was persisted using the fallback corrupted-response placeholder.");
|
||||||
|
}
|
||||||
|
if (interaction.parsed.transcriptLeakageDetected) {
|
||||||
|
lines.push("- Transcript leakage markers were detected in the model output.");
|
||||||
|
}
|
||||||
|
if (interaction.parsed.thinkingOpen) {
|
||||||
|
lines.push("- The response finished with an open thinking block.");
|
||||||
|
}
|
||||||
|
if (interaction.errors.length > 0) {
|
||||||
|
lines.push(`- ${interaction.errors.length} error(s) were captured during the request lifecycle.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeStreamChunks(interaction: InteractionLog): Array<{ label: string; value: string }> {
|
||||||
|
const chunks = interaction.streaming.rawChunks;
|
||||||
|
if (chunks.length === 0) return [];
|
||||||
|
|
||||||
|
const samples = [chunks[0], chunks.find((chunk) => chunk.deltaContent?.includes("<think>")), chunks[chunks.length - 1]].filter(
|
||||||
|
(chunk, index, arr): chunk is NonNullable<typeof chunk> => Boolean(chunk) && arr.indexOf(chunk) === index,
|
||||||
|
);
|
||||||
|
|
||||||
|
return samples.map((chunk) => ({
|
||||||
|
label: `chunk #${chunk.index}`,
|
||||||
|
value: `delta=${chunk.deltaContent ? truncate(chunk.deltaContent, 120) : "(none)"} | raw=${truncate(chunk.raw, 180)}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeSanitization(interaction: InteractionLog): Array<{ stage: string; count: number; repairedEncoding: boolean }> {
|
||||||
|
const grouped = new Map<string, { count: number; repairedEncoding: boolean }>();
|
||||||
|
|
||||||
|
for (const event of interaction.sanitization) {
|
||||||
|
if (!event.changed && !event.repairedEncoding) continue;
|
||||||
|
const current = grouped.get(event.stage) ?? { count: 0, repairedEncoding: false };
|
||||||
|
current.count += 1;
|
||||||
|
current.repairedEncoding = current.repairedEncoding || Boolean(event.repairedEncoding);
|
||||||
|
grouped.set(event.stage, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(grouped.entries()).map(([stage, value]) => ({
|
||||||
|
stage,
|
||||||
|
count: value.count,
|
||||||
|
repairedEncoding: value.repairedEncoding,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatChatDebugTranscript({
|
||||||
|
chatId,
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
server,
|
||||||
|
apiKeyPresent,
|
||||||
|
memoryEnabled,
|
||||||
|
streamEnabled,
|
||||||
|
agenticMode,
|
||||||
|
savedMemories = [],
|
||||||
|
latestInteraction,
|
||||||
|
runtimeMode,
|
||||||
|
runtimeStatus,
|
||||||
|
}: FormatChatDebugTranscriptOptions): string {
|
||||||
const lines = [
|
const lines = [
|
||||||
"# Chat Debug Export",
|
"# Chat Debug Export",
|
||||||
|
`Exported at: ${new Date().toISOString()}`,
|
||||||
`Chat ID: ${chatId ?? "new-chat"}`,
|
`Chat ID: ${chatId ?? "new-chat"}`,
|
||||||
`Message count: ${messages.length}`,
|
`Message count: ${messages.length}`,
|
||||||
|
`Model: ${model ?? "unknown"}`,
|
||||||
|
`Server: ${server ?? "unknown"}`,
|
||||||
|
`API key present: ${apiKeyPresent ? "yes" : "no"}`,
|
||||||
|
`Streaming enabled: ${streamEnabled ? "yes" : "no"}`,
|
||||||
|
`Memory enabled: ${memoryEnabled ? "yes" : "no"}`,
|
||||||
|
`Agentic mode: ${agenticMode ? "on" : "off"}`,
|
||||||
|
`Runtime mode: ${runtimeMode ?? "unknown"}`,
|
||||||
|
`Runtime status: ${runtimeStatus ?? "unknown"}`,
|
||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (savedMemories.length > 0) {
|
||||||
|
lines.push("## Saved Memories Considered");
|
||||||
|
for (const memory of savedMemories) {
|
||||||
|
lines.push(`- [${memory.key}] confidence=${(memory.confidence * 100).toFixed(0)}% updated=${formatTimestamp(memory.updated_at)}`);
|
||||||
|
lines.push(` ${memory.value}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
} else {
|
||||||
|
lines.push("## Saved Memories Considered");
|
||||||
|
lines.push("(none)");
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestInteraction) {
|
||||||
|
lines.push(...summarizeVisibleIssue(latestInteraction));
|
||||||
|
|
||||||
|
lines.push("## Latest Interaction Summary");
|
||||||
|
lines.push(`Interaction ID: ${latestInteraction.id}`);
|
||||||
|
lines.push(`Status: ${latestInteraction.status}`);
|
||||||
|
lines.push(`Started: ${latestInteraction.startedAt}`);
|
||||||
|
lines.push(`Finished: ${latestInteraction.finishedAt ?? "pending"}`);
|
||||||
|
lines.push(`Duration ms: ${latestInteraction.durationMs ?? "n/a"}`);
|
||||||
|
lines.push(`User input chars: ${latestInteraction.userInput.charCount}`);
|
||||||
|
lines.push(`Context messages: ${latestInteraction.context.messageCount}`);
|
||||||
|
lines.push(`Context chars: ${latestInteraction.context.totalContextChars}`);
|
||||||
|
lines.push(`TTFT ms: ${latestInteraction.streaming.timeToFirstTokenMs != null ? Math.round(latestInteraction.streaming.timeToFirstTokenMs) : "n/a"}`);
|
||||||
|
lines.push(`Chunks received: ${latestInteraction.streaming.chunksReceived}`);
|
||||||
|
lines.push(`Usage: prompt=${latestInteraction.usage.promptTokens ?? 0} completion=${latestInteraction.usage.completionTokens ?? 0} total=${latestInteraction.usage.totalTokens ?? 0} tps=${latestInteraction.usage.tokensPerSecond != null ? latestInteraction.usage.tokensPerSecond.toFixed(1) : "n/a"}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push(...formatCompactJsonBlock("### Request overview", {
|
||||||
|
url: latestInteraction.request.url,
|
||||||
|
method: latestInteraction.request.method,
|
||||||
|
model: latestInteraction.request.model,
|
||||||
|
stream: latestInteraction.request.stream,
|
||||||
|
maxTokens: latestInteraction.request.maxTokens,
|
||||||
|
bodySize: latestInteraction.request.bodySize,
|
||||||
|
contentType: latestInteraction.response.contentType,
|
||||||
|
httpStatus: latestInteraction.response.httpStatus,
|
||||||
|
}));
|
||||||
|
|
||||||
|
lines.push(...formatJsonBlock("### Full request body", latestInteraction.request.fullBody));
|
||||||
|
|
||||||
|
if (Object.keys(latestInteraction.request.headers).length > 0) {
|
||||||
|
lines.push(...formatCompactJsonBlock("### Request headers (sanitized)", latestInteraction.request.headers));
|
||||||
|
}
|
||||||
|
if (Object.keys(latestInteraction.response.responseHeaders).length > 0) {
|
||||||
|
lines.push(...formatCompactJsonBlock("### Response headers", latestInteraction.response.responseHeaders));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("### Context messages sent to model");
|
||||||
|
latestInteraction.context.messages.forEach((message, index) => {
|
||||||
|
lines.push(`- #${index + 1} role=${message.role} len=${message.contentLength}`);
|
||||||
|
lines.push(` preview=${truncate(message.contentPreview, 220)}`);
|
||||||
|
});
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("### Parsed result");
|
||||||
|
lines.push(`Visible content:\n${latestInteraction.parsed.visibleContent || "(empty)"}`);
|
||||||
|
lines.push("");
|
||||||
|
if (latestInteraction.parsed.reasoningContent.trim()) {
|
||||||
|
lines.push(`Thinking / reasoning:\n${truncate(latestInteraction.parsed.reasoningContent, 4000)}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
lines.push(`Raw content:\n${truncate(latestInteraction.parsed.rawContent || "(empty)", 3000)}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const chunkSummary = summarizeStreamChunks(latestInteraction);
|
||||||
|
if (chunkSummary.length > 0) {
|
||||||
|
lines.push("### Stream summary");
|
||||||
|
chunkSummary.forEach((chunk) => {
|
||||||
|
lines.push(`- ${chunk.label}: ${chunk.value}`);
|
||||||
|
});
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizationSummary = summarizeSanitization(latestInteraction);
|
||||||
|
if (sanitizationSummary.length > 0) {
|
||||||
|
lines.push("### Sanitization summary");
|
||||||
|
sanitizationSummary.forEach((event) => {
|
||||||
|
lines.push(`- ${event.stage}: ${event.count} change(s)${event.repairedEncoding ? " with encoding repair" : ""}`);
|
||||||
|
});
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestInteraction.dbWrites.length > 0) {
|
||||||
|
lines.push(...formatJsonBlock("### DB writes", latestInteraction.dbWrites));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestInteraction.errors.length > 0) {
|
||||||
|
lines.push(...formatJsonBlock("### Errors", latestInteraction.errors));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("### Timeline");
|
||||||
|
latestInteraction.timeline.forEach((step) => {
|
||||||
|
lines.push(`- ${step.timestamp} [${step.phase}] ${step.detail}`);
|
||||||
|
});
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("## Persisted Messages");
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
lines.push(`[${message.role.toUpperCase()}] #${message.id}`);
|
lines.push(`[${message.role.toUpperCase()}] #${message.id}`);
|
||||||
lines.push(`Created: ${formatTimestamp(message.created_at)}`);
|
lines.push(`Created: ${formatTimestamp(message.created_at)}`);
|
||||||
|
|||||||
Reference in new issue
Block a user