export interface AssistantConversationExportMessage { id: string; parentId: string | null; format: string; content: unknown; createdAt?: Date | string | null; } interface AssistantConversationExportInput { thread: { id: string; title?: string | null; modelId?: string | null; createdAt?: Date | string | null; updatedAt?: Date | string | null; }; messages: AssistantConversationExportMessage[]; exportedAt?: Date; } const sensitiveKeyPattern = /(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|session[-_]?token|(?:access|auth|refresh|generation)[-_]?token|^token$)$/iu; const dataUrlPattern = /^data:([^;,]+)[;,]/iu; const bearerTokenPattern = /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/giu; const commonApiKeyPattern = /\bsk-(?:or-v1-)?[A-Za-z0-9_-]{16,}\b/giu; const maxDebugStringLength = 12_000; const asRecord = (value: unknown): Record | null => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : null; const redactDebugValue = (value: unknown, key?: string): unknown => { if (key && sensitiveKeyPattern.test(key)) return "[redacted]"; if (typeof value === "string") { let text = value.replaceAll(bearerTokenPattern, "Bearer [redacted]").replaceAll(commonApiKeyPattern, "[redacted]"); const dataUrl = dataUrlPattern.exec(text); if (dataUrl) return `[${dataUrl[1] ?? "binary"} data omitted]`; if (/^https?:\/\//iu.test(text)) { try { const url = new URL(text); if (url.username) url.username = "redacted"; if (url.password) url.password = "redacted"; for (const name of url.searchParams.keys()) { if (sensitiveKeyPattern.test(name)) url.searchParams.set(name, "redacted"); } text = url.toString(); } catch { // Keep malformed URLs as ordinary bounded debug strings. } } return text.length > maxDebugStringLength ? `${text.slice(0, maxDebugStringLength)}\n… [truncated ${text.length - maxDebugStringLength} characters]` : text; } if (Array.isArray(value)) return value.map((entry) => redactDebugValue(entry)); const record = asRecord(value); if (!record) return value; return Object.fromEntries( Object.entries(record).map(([entryKey, entry]) => [entryKey, redactDebugValue(entry, entryKey)]), ); }; const fencedJson = (value: unknown) => { const serialized = JSON.stringify(redactDebugValue(value), null, 2) ?? String(redactDebugValue(value)); return `\n\`\`\`json\n${serialized}\n\`\`\`\n`; }; const formatDate = (value: Date | string | null | undefined) => { if (!value) return "Not recorded"; const date = value instanceof Date ? value : new Date(value); return Number.isNaN(date.getTime()) ? String(value) : date.toISOString(); }; const roleLabel = (role: unknown) => { switch (role) { case "user": return "User message"; case "assistant": return "Assistant response"; case "system": return "System message"; case "tool": return "Tool message"; default: return "Server message"; } }; const getToolName = (part: Record) => { if (typeof part.toolName === "string") return part.toolName; if (typeof part.type !== "string") return null; if (part.type === "tool-call") return "unknown tool"; return part.type.startsWith("tool-") ? part.type.slice("tool-".length) : null; }; const isToolPart = (part: unknown) => { const record = asRecord(part); if (!record || typeof record.type !== "string") return false; return record.type === "dynamic-tool" || record.type === "tool-call" || record.type.startsWith("tool-"); }; const getToolValue = (part: Record, primary: string, fallback: string) => primary in part ? part[primary] : part[fallback]; const renderToolPart = (part: Record) => { const toolName = getToolName(part) ?? "unknown tool"; const input = getToolValue(part, "input", "args"); const output = getToolValue(part, "output", "result"); const rawInput = getToolValue(part, "rawInput", "argsText"); const state = typeof part.state === "string" ? part.state : part.isError === true ? "output-error" : output !== undefined ? "output-available" : "input-available"; const lines = [`### Tool call: \`${toolName}\``, `- State: \`${state}\``]; if (typeof part.toolCallId === "string") lines.push(`- Call ID: \`${part.toolCallId}\``); if (typeof part.providerExecuted === "boolean") lines.push(`- Provider executed: ${part.providerExecuted ? "Yes" : "No"}`); if (typeof part.preliminary === "boolean") lines.push(`- Preliminary result: ${part.preliminary ? "Yes" : "No"}`); if (input !== undefined) lines.push("#### Input", fencedJson(input)); if (rawInput !== undefined && rawInput !== input) lines.push("#### Raw streamed input", fencedJson(rawInput)); if (output !== undefined) lines.push("#### Output", fencedJson(output)); if (typeof part.errorText === "string") lines.push("#### Error", part.errorText); else if (part.isError === true) lines.push("#### Error", "The tool result was marked as an error."); if (part.approval !== undefined) lines.push("#### Approval", fencedJson(part.approval)); const diagnostics = Object.fromEntries( Object.entries(part).filter( ([key]) => ![ "type", "toolName", "toolCallId", "state", "input", "args", "output", "result", "rawInput", "argsText", "errorText", "isError", "approval", "providerExecuted", "preliminary", ].includes(key), ), ); if (Object.keys(diagnostics).length > 0) { lines.push("
", "Tool diagnostics", fencedJson(diagnostics), "
"); } return lines.join("\n"); }; const renderSourcePart = (part: Record) => { const title = typeof part.title === "string" ? part.title : "Source"; if (typeof part.url === "string") return `- [${title}](${part.url})`; return `### Source document\n${fencedJson(part)}`; }; const renderMessagePart = (part: unknown, index: number) => { const record = asRecord(part); if (!record) return `### Part ${index}\n${fencedJson(part)}`; const type = typeof record.type === "string" ? record.type : "unknown"; if (type === "text" && typeof record.text === "string") return record.text; if (type === "reasoning" && typeof record.text === "string") { return `
\nReasoning\n\n${record.text}\n\n
`; } if (type === "step-start") return "**Agent step started**"; if (isToolPart(record)) return renderToolPart(record); if (type === "source" || type === "source-url" || type === "source-document") return renderSourcePart(record); if (type === "file" || type === "image" || type === "audio" || type === "reasoning-file") { return `### ${type}\n${fencedJson(record)}`; } if (type === "data" || type.startsWith("data-") || type === "generative-ui" || type === "custom") { return `### ${type}\n${fencedJson(record)}`; } return `### ${type}\n${fencedJson(record)}`; }; const getMessagePayload = (message: AssistantConversationExportMessage) => asRecord(message.content); const getMessageParts = (message: AssistantConversationExportMessage) => { const payload = getMessagePayload(message); if (Array.isArray(payload?.parts)) return payload.parts; return Array.isArray(payload?.content) ? payload.content : []; }; const getMessageToolCount = (message: AssistantConversationExportMessage) => getMessageParts(message).filter(isToolPart).length; const renderMessage = (message: AssistantConversationExportMessage, index: number) => { const payload = getMessagePayload(message); const parts = getMessageParts(message); const createdAt = message.createdAt ?? payload?.createdAt; const lines = [ `## ${index + 1}. ${roleLabel(payload?.role)}`, `- Message ID: \`${message.id}\``, `- Parent ID: ${message.parentId ? `\`${message.parentId}\`` : "None"}`, `- Storage format: \`${message.format}\``, `- Created: ${formatDate(createdAt as Date | string | null | undefined)}`, `- Tool calls: ${getMessageToolCount(message)}`, ]; if (payload?.status !== undefined) lines.push(`- Status: \`${JSON.stringify(redactDebugValue(payload.status))}\``); if (parts.length === 0) lines.push("", "_No normalized message parts were stored._", fencedJson(message.content)); else lines.push("", ...parts.map((part, partIndex) => renderMessagePart(part, partIndex + 1))); if (payload?.attachments !== undefined) { lines.push("", "
", "Attachments", fencedJson(payload.attachments), "
"); } if (payload?.metadata !== undefined) { lines.push("", "
", "Server metadata", fencedJson(payload.metadata), "
"); } return lines.join("\n"); }; export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => { const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`; return `# ${title}\n\n> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`; }; export const buildAssistantConversationMarkdown = ({ thread, messages, exportedAt = new Date(), }: AssistantConversationExportInput) => { const title = thread.title?.trim() || "Untitled assistant conversation"; const toolCount = messages.reduce((total, message) => total + getMessageToolCount(message), 0); const header = [ `# ${title}`, "", "> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.", "", `- Conversation ID: \`${thread.id}\``, `- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`, `- Created: ${formatDate(thread.createdAt)}`, `- Updated: ${formatDate(thread.updatedAt)}`, `- Exported: ${exportedAt.toISOString()}`, `- Stored messages: ${messages.length}`, `- Tool calls: ${toolCount}`, ]; if (messages.length === 0) return `${header.join("\n")}\n\n_No messages have been stored for this conversation._\n`; return `${header.join("\n")}\n\n${messages.map(renderMessage).join("\n\n---\n\n")}\n`; }; export const getAssistantConversationExportFilename = (title: string | null | undefined, threadId: string) => { const slug = (title?.trim() || "assistant-conversation") .normalize("NFKD") .replaceAll(/\p{Mark}+/gu, "") .replaceAll(/[^\p{Letter}\p{Number}]+/gu, "-") .replaceAll(/^-+|-+$/gu, "") .slice(0, 64) .toLowerCase(); return `${slug || "assistant-conversation"}-${threadId.slice(0, 8)}.md`; };