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,
|
||||
savedMemories,
|
||||
latestInteraction,
|
||||
runtimeMode,
|
||||
runtimeStatus,
|
||||
});
|
||||
await navigator.clipboard.writeText(transcript);
|
||||
setDebugCopied(true);
|
||||
|
||||
@@ -21,6 +21,8 @@ describe("formatChatDebugTranscript", () => {
|
||||
created_at: 1710000060,
|
||||
},
|
||||
],
|
||||
runtimeMode: "remote",
|
||||
runtimeStatus: "ready",
|
||||
});
|
||||
|
||||
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 {
|
||||
chatId: number | null;
|
||||
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 {
|
||||
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 = [
|
||||
"# Chat Debug Export",
|
||||
`Exported at: ${new Date().toISOString()}`,
|
||||
`Chat ID: ${chatId ?? "new-chat"}`,
|
||||
`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) {
|
||||
lines.push(`[${message.role.toUpperCase()}] #${message.id}`);
|
||||
lines.push(`Created: ${formatTimestamp(message.created_at)}`);
|
||||
|
||||
Reference in new issue
Block a user