diff --git a/tauri-app/src/components/ChatView.tsx b/tauri-app/src/components/ChatView.tsx index 2b02abd..55a3050 100644 --- a/tauri-app/src/components/ChatView.tsx +++ b/tauri-app/src/components/ChatView.tsx @@ -725,6 +725,8 @@ export default function ChatView({ agenticMode, savedMemories, latestInteraction, + runtimeMode, + runtimeStatus, }); await navigator.clipboard.writeText(transcript); setDebugCopied(true); diff --git a/tauri-app/src/lib/chatDebug.test.ts b/tauri-app/src/lib/chatDebug.test.ts index 3449873..6101acf 100644 --- a/tauri-app/src/lib/chatDebug.test.ts +++ b/tauri-app/src/lib/chatDebug.test.ts @@ -21,6 +21,8 @@ describe("formatChatDebugTranscript", () => { created_at: 1710000060, }, ], + runtimeMode: "remote", + runtimeStatus: "ready", }); expect(output).toContain("Chat ID: 7"); diff --git a/tauri-app/src/lib/chatDebug.ts b/tauri-app/src/lib/chatDebug.ts index 5d31a91..96452ef 100644 --- a/tauri-app/src/lib/chatDebug.ts +++ b/tauri-app/src/lib/chatDebug.ts @@ -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("")), chunks[chunks.length - 1]].filter( + (chunk, index, arr): chunk is NonNullable => 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(); + + 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)}`);