From 2b2d1f0d952a70404525ed2c932d845f6ff9398b Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Tue, 8 Sep 2026 16:46:22 +0800 Subject: [PATCH] feat(ai): export conversation as markdown or html --- .../src/components/editor/AiAssistant.vue | 91 ++++++++++- .../AiAssistant.clearAndCancel.spec.ts | 6 +- .../AiAssistant.conversationExport.spec.ts | 43 ++++++ .../__tests__/aiRichContentParity.spec.ts | 12 ++ apps/desktop/src/i18n/locales/en.ts | 7 + apps/desktop/src/i18n/locales/es.ts | 7 + apps/desktop/src/i18n/locales/it.ts | 7 + apps/desktop/src/i18n/locales/ja.ts | 7 + apps/desktop/src/i18n/locales/ko.ts | 7 + apps/desktop/src/i18n/locales/pt-BR.ts | 7 + apps/desktop/src/i18n/locales/tr.ts | 7 + apps/desktop/src/i18n/locales/zh-CN.ts | 7 + apps/desktop/src/i18n/locales/zh-TW.ts | 7 + apps/desktop/src/lib/ai/aiMarkdown.ts | 2 +- apps/desktop/src/lib/backend/tauri.ts | 2 + .../__tests__/aiConversationExport.spec.ts | 145 ++++++++++++++++++ .../src/lib/export/aiConversationExport.ts | 135 ++++++++++++++++ crates/dbx-core/src/ai.rs | 4 + crates/dbx-core/src/storage.rs | 1 + packages/app-tests/aiMessageLayout.test.ts | 7 +- 20 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/components/editor/__tests__/AiAssistant.conversationExport.spec.ts create mode 100644 apps/desktop/src/lib/export/__tests__/aiConversationExport.spec.ts create mode 100644 apps/desktop/src/lib/export/aiConversationExport.ts diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 11701a5062..5ac1c80c1b 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -49,6 +49,7 @@ import { Search, } from "@lucide/vue"; import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -171,6 +172,7 @@ import { visibleToActualIndex } from "@/lib/ai/aiMessageEdit"; import { shouldShowReasoningCharCount, reasoningCharCountClass } from "@/lib/ai/aiReasoningPresentation"; import { saveTextFile } from "@/lib/export/saveTextFile"; import { buildAiAnalysisExport } from "@/lib/export/aiAnalysisExport"; +import { buildAiConversationExport, type AiConversationExportFormat } from "@/lib/export/aiConversationExport"; import { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from "@/lib/ai/aiConversationSearch"; import AiAttachmentCard from "@/components/editor/AiAttachmentCard.vue"; import { resolveAiMessageCopyText } from "@/lib/ai/aiMessageCopy"; @@ -249,6 +251,9 @@ interface ChatMessage { /** Image payloads stay in memory only and are never written to conversation storage. */ imageAttachments?: AiImageAttachment[]; reasoning?: string; + /** Set when this message's generation failed; persisted with the conversation + * so the export failure marker survives reloads and locale switches. */ + failed?: boolean; isThinking?: boolean; agentSteps?: AiAgentStepItem[]; /** Hidden system-generated context summary; not rendered in chat UI but included in LLM history. @@ -3202,7 +3207,10 @@ async function send() { if (generationCanContinue()) { const message = e instanceof Error ? e.message : String(e); const msg = runMessages[assistantIdx]; - if (msg) msg.content = `${t("ai.requestFailed")}\n\n${translateBackendError(t, message)}`; + if (msg) { + msg.content = `${t("ai.requestFailed")}\n\n${translateBackendError(t, message)}`; + msg.failed = true; + } if (detachedRun) finishDesktopAiRun(detachedRun, "failed"); } } finally { @@ -3680,6 +3688,73 @@ async function exportMessageAsMarkdown(msg: ChatMessage) { } } +/** + * Conversation-level export (#6467 PR3): dumps the visible transcript as a + * Markdown or HTML report. Same source boundary as the UI — `visibleMessages` + * already drops `contextSummary` — and the builder skips it again defensively. + */ +/** True when the current conversation's run ended in failure/interruption — + * either transport (desktop run registry, or the web fallback status map). + * Background-failed turns keep their partial content without the persisted + * `failed` flag, so the export also consults the run status to mark them. */ +function isCurrentRunFailed(): boolean { + if (!conversationId.value) return false; + const run = backgroundAiRunsEnabled ? desktopAiRun(conversationId.value) : undefined; + const status = run?.status ?? conversationRunStatus.get(conversationId.value); + return status === "failed" || status === "interrupted"; +} + +async function exportConversationAs(format: AiConversationExportFormat) { + try { + const runFailed = isCurrentRunFailed(); + // A failed/interrupted run is always answering the LATEST user turn, so only + // an assistant reply that came after it can belong to the failed run. When a + // restart interrupts the turn before its first delta, the last reply in the + // transcript belongs to the preceding successful turn and must stay unmarked. + const transcript = visibleMessages.value; + let lastUserIndex = -1; + for (let i = transcript.length - 1; i >= 0; i--) { + if (transcript[i].role === "user") { + lastUserIndex = i; + break; + } + } + let failedTurnAssistant: ChatMessage | undefined; + for (let i = transcript.length - 1; i > lastUserIndex; i--) { + const candidate = transcript[i]; + if (candidate.role === "assistant" && candidate.content) { + failedTurnAssistant = candidate; + break; + } + } + const result = buildAiConversationExport({ + connectionName: props.connection?.name, + dateLabel: new Date().toLocaleString(), + messages: visibleMessages.value.map((msg) => ({ + role: msg.role, + content: msg.content, + kind: msg.kind, + failed: msg.failed === true || (runFailed && msg === failedTurnAssistant), + })), + format, + labels: { + analysisLabel: t("ai.analysis"), + userLabel: t("ai.conversationRoleUser"), + assistantLabel: t("ai.conversationRoleAssistant"), + failedLabel: t("ai.conversationFailedMarker"), + }, + }); + if (!result) { + toast(t("ai.conversationExportEmpty"), 5000); + return; + } + await saveTextFile(result.content, result.defaultFileName, format === "markdown" ? "Markdown" : "HTML", format === "markdown" ? "md" : "html"); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + toast(t("grid.exportFailed", { message }), 5000); + } +} + function clearMessages() { // If a request is still in flight, abandon it before wiping the transcript it // was writing into. abandonInFlightRequest() invalidates the active generation @@ -3736,6 +3811,7 @@ function buildConversationSnapshot(targetConversationId: string, targetMessages: ...(m.mentions?.length ? { mentions: m.mentions } : {}), ...(m.reasoning ? { reasoning: m.reasoning } : {}), ...(m.kind ? { kind: m.kind } : {}), + ...(m.failed ? { failed: true } : {}), })), // The conversation's single queued "send later" input, persisted so it // survives a restart (parent PRD §5). @@ -3852,6 +3928,7 @@ async function persistPendingInputRecovery(conversation: AiConversation, message ...(m.mentions?.length ? { mentions: m.mentions } : {}), ...(m.reasoning ? { reasoning: m.reasoning } : {}), ...(m.kind ? { kind: m.kind } : {}), + ...(m.failed ? { failed: true } : {}), })), queuedInput: conversation.queuedInput, createdAt: conversation.createdAt, @@ -3884,6 +3961,7 @@ function chatMessagesFromConversation(conv: AiConversation): ChatMessage[] { mentions: Array.isArray(m.mentions) ? (m.mentions as AiMessageMention[]) : undefined, reasoning: m.reasoning, kind: m.kind, + failed: m.failed === true ? true : undefined, })); } @@ -4403,6 +4481,17 @@ async function openExternalUrl(url: string) { {{ chatTitle }} + + + + + + {{ t("ai.conversationExportMarkdown") }} + {{ t("ai.conversationExportHtml") }} + + diff --git a/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts b/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts index 90bf8fe32b..a7413aefe9 100644 --- a/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts @@ -217,7 +217,11 @@ describe("AI assistant uses platform-specific conversation lifecycle", () => { expect(catchBody).not.toContain("messages.value[assistantIdx].content ="); expect(catchBody).toContain("generationCanContinue()"); expect(catchBody).toContain("const msg = runMessages[assistantIdx];"); - expect(catchBody).toContain("if (msg) msg.content ="); + expect(catchBody).toContain("if (msg) {"); + expect(catchBody).toContain("msg.content ="); + // The failure flag rides along with the prefix text (#6467 PR3): the export + // derives its failure marker from this state, not from the localized text. + expect(catchBody).toContain("msg.failed = true;"); }); it("send()'s finally block only mutates shared state (isGenerating, currentSessionId) when still current", () => { diff --git a/apps/desktop/src/components/editor/__tests__/AiAssistant.conversationExport.spec.ts b/apps/desktop/src/components/editor/__tests__/AiAssistant.conversationExport.spec.ts new file mode 100644 index 0000000000..b32121c70b --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/AiAssistant.conversationExport.spec.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8"); + +describe("AI assistant conversation export", () => { + it("passes native save-dialog short extensions, never the bare format key", () => { + expect(source).toContain('saveTextFile(result.content, result.defaultFileName, format === "markdown" ? "Markdown" : "HTML", format === "markdown" ? "md" : "html")'); + expect(source).not.toMatch(/saveTextFile\([^)]*,\s*format\)/); + }); + + it("records the failure on the message state at the same point the prefix text is written", () => { + expect(source).toContain("msg.failed = true;"); + }); + + it("persists the failed flag through both conversation snapshot paths", () => { + // buildConversationSnapshot and persistPendingInputRecovery both whitelist + // fields on the way to storage; serde drops unknown fields on the Rust + // side, so a path that forgets `failed` silently loses the marker. + expect(source).toContain("...(m.failed ? { failed: true } : {})"); + expect(source).toContain("failed: m.failed === true ? true : undefined"); + }); + + it("derives the export failure marker from persisted state and run status, not locale text", () => { + expect(source).toContain("failed: msg.failed === true || (runFailed && msg === failedTurnAssistant)"); + expect(source).not.toContain('msg.content.startsWith(t("ai.requestFailed"))'); + }); + + it("attributes run-status failure only to an assistant reply after the latest user turn", () => { + // Restart-before-first-delta regression: when a run is restored as + // interrupted before producing any output, the last reply in the transcript + // belongs to the PRECEDING successful turn — it must stay unmarked. + expect(source).toContain("let lastUserIndex = -1;"); + expect(source).toContain("i > lastUserIndex"); + expect(source).toContain("failedTurnAssistant = candidate;"); + expect(source).not.toContain("lastVisibleAssistant"); + }); + + it("consults the run registry / fallback status map for background-failed turns", () => { + expect(source).toContain("function isCurrentRunFailed()"); + expect(source).toContain('return status === "failed" || status === "interrupted";'); + }); +}); diff --git a/apps/desktop/src/i18n/__tests__/aiRichContentParity.spec.ts b/apps/desktop/src/i18n/__tests__/aiRichContentParity.spec.ts index 1cb524ffc6..bd767db6e0 100644 --- a/apps/desktop/src/i18n/__tests__/aiRichContentParity.spec.ts +++ b/apps/desktop/src/i18n/__tests__/aiRichContentParity.spec.ts @@ -26,6 +26,10 @@ const locales: Array<[string, Record]> = [ // the user runs, and a missing key would leak raw keys into the toolbar. const AI_HTML_PREVIEW_KEYS = ["htmlPreviewLabel", "htmlExpandPreview", "htmlExpandPreviewHint", "htmlSaveSafe", "htmlSaveFailed", "htmlCopySource", "htmlCopyRiskBody", "htmlCopyRiskAccept", "htmlCopyRiskRemember", "htmlCopyRiskToast"] as const; +// Keys the conversation export (PR3 of #6467) adds to the `ai` namespace — +// header menu entry, report role labels, failure marker, and empty-state copy. +const AI_CONVERSATION_EXPORT_KEYS = ["exportConversation", "conversationExportMarkdown", "conversationExportHtml", "conversationRoleUser", "conversationRoleAssistant", "conversationFailedMarker", "conversationExportEmpty"] as const; + describe("AI rich content locale parity", () => { it.each(locales)("%s exposes the full ai.html* key set with non-empty copy", (_name, locale) => { const ai = (locale as { ai: Record }).ai; @@ -34,4 +38,12 @@ describe("AI rich content locale parity", () => { expect(ai[key] as string, `${_name}: ai.${key}`).not.toHaveLength(0); } }); + + it.each(locales)("%s exposes the full conversation-export key set with non-empty copy", (_name, locale) => { + const ai = (locale as { ai: Record }).ai; + for (const key of AI_CONVERSATION_EXPORT_KEYS) { + expect(ai[key], `${_name}: ai.${key}`).toBeTypeOf("string"); + expect(ai[key] as string, `${_name}: ai.${key}`).not.toHaveLength(0); + } + }); }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 1c15b0185a..5d0af19b65 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3047,6 +3047,13 @@ export default { htmlCopyRiskAccept: "Copy anyway", htmlCopyRiskRemember: "Don't ask again this session", htmlCopyRiskToast: "Copied. This AI-generated HTML may execute scripts or access the network when opened outside DBX.", + exportConversation: "Export conversation", + conversationExportMarkdown: "Export as Markdown", + conversationExportHtml: "Export as HTML", + conversationRoleUser: "User", + conversationRoleAssistant: "AI", + conversationFailedMarker: "This reply failed to generate", + conversationExportEmpty: "No messages to export yet", }, contextMenu: { openConnection: "Open Connection", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 1cbf3f3886..9a9f99ebff 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2834,6 +2834,13 @@ export default withEnglishFallback({ htmlCopyRiskAccept: "Copiar de todos modos", htmlCopyRiskRemember: "No volver a preguntar en esta sesión", htmlCopyRiskToast: "Copiado. Este HTML generado por IA puede ejecutar scripts o acceder a la red si se abre fuera de DBX.", + exportConversation: "Exportar conversación", + conversationExportMarkdown: "Exportar como Markdown", + conversationExportHtml: "Exportar como HTML", + conversationRoleUser: "Usuario", + conversationRoleAssistant: "IA", + conversationFailedMarker: "Esta respuesta no se pudo generar", + conversationExportEmpty: "Aún no hay mensajes para exportar", agentSteps: { generated: "SQL generado", noSql: "No se encontró SQL", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index b9fc7c2720..ed198711f2 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2728,6 +2728,13 @@ export default withEnglishFallback({ htmlCopyRiskAccept: "Copia comunque", htmlCopyRiskRemember: "Non chiedere più in questa sessione", htmlCopyRiskToast: "Copiato. Questo HTML generato dall'IA può eseguire script o accedere alla rete se aperto fuori da DBX.", + exportConversation: "Esporta conversazione", + conversationExportMarkdown: "Esporta come Markdown", + conversationExportHtml: "Esporta come HTML", + conversationRoleUser: "Utente", + conversationRoleAssistant: "IA", + conversationFailedMarker: "Generazione di questa risposta non riuscita", + conversationExportEmpty: "Nessun messaggio da esportare", agentSteps: { generated: "SQL generato", noSql: "Nessun SQL trovato", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 2741a4753d..77458de50d 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2870,6 +2870,13 @@ export default withEnglishFallback({ htmlCopyRiskAccept: "それでもコピー", htmlCopyRiskRemember: "このセッションでは今後確認しない", htmlCopyRiskToast: "コピーしました。このAI生成HTMLは、DBXの外部で開くとスクリプトを実行したりネットワークへアクセスしたりする可能性があります。", + exportConversation: "会話をエクスポート", + conversationExportMarkdown: "Markdownでエクスポート", + conversationExportHtml: "HTMLでエクスポート", + conversationRoleUser: "ユーザー", + conversationRoleAssistant: "AI", + conversationFailedMarker: "この返信の生成に失敗しました", + conversationExportEmpty: "エクスポートできるメッセージがありません", agentSteps: { generated: "SQL生成完了", noSql: "SQLが見つかりません", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index e20eaa9986..fe74590aeb 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2840,6 +2840,13 @@ export default withEnglishFallback({ htmlCopyRiskAccept: "그래도 복사", htmlCopyRiskRemember: "이 세션에서 다시 묻지 않기", htmlCopyRiskToast: "복사되었습니다. 이 AI 생성 HTML은 DBX 외부에서 열면 스크립트를 실행하거나 네트워크에 접근할 수 있습니다.", + exportConversation: "대화 내보내기", + conversationExportMarkdown: "Markdown으로 내보내기", + conversationExportHtml: "HTML로 내보내기", + conversationRoleUser: "사용자", + conversationRoleAssistant: "AI", + conversationFailedMarker: "이 응답 생성에 실패했습니다", + conversationExportEmpty: "내보낼 메시지가 없습니다", }, contextMenu: { openConnection: "연결 열기", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c8a4efe3b7..0e91a26865 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2836,6 +2836,13 @@ export default withEnglishFallback({ htmlCopyRiskAccept: "Copiar mesmo assim", htmlCopyRiskRemember: "Não perguntar novamente nesta sessão", htmlCopyRiskToast: "Copiado. Este HTML gerado por IA pode executar scripts ou acessar a rede quando aberto fora do DBX.", + exportConversation: "Exportar conversa", + conversationExportMarkdown: "Exportar como Markdown", + conversationExportHtml: "Exportar como HTML", + conversationRoleUser: "Usuário", + conversationRoleAssistant: "IA", + conversationFailedMarker: "Falha ao gerar esta resposta", + conversationExportEmpty: "Ainda não há mensagens para exportar", agentSteps: { generated: "SQL gerado", noSql: "Nenhum SQL encontrado", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 837b8968c6..30ecf8e3fa 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -3021,6 +3021,13 @@ export default withEnglishFallback({ htmlCopyRiskAccept: "Yine de kopyala", htmlCopyRiskRemember: "Bu oturumda tekrar sorma", htmlCopyRiskToast: "Kopyalandı. Bu AI tarafından oluşturulan HTML, DBX dışında açıldığında betik çalıştırabilir veya ağa erişebilir.", + exportConversation: "Sohbeti dışa aktar", + conversationExportMarkdown: "Markdown olarak dışa aktar", + conversationExportHtml: "HTML olarak dışa aktar", + conversationRoleUser: "Kullanıcı", + conversationRoleAssistant: "AI", + conversationFailedMarker: "Bu yanıt oluşturulamadı", + conversationExportEmpty: "Dışa aktarılacak mesaj yok", }, contextMenu: { openConnection: "Bağlantıyı Aç", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 0e453ac454..6db7acc288 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2970,6 +2970,13 @@ export default withEnglishFallback({ htmlCopyRiskAccept: "仍要复制", htmlCopyRiskRemember: "本会话不再询问", htmlCopyRiskToast: "已复制。此 AI 生成的 HTML 在 DBX 外打开可能执行脚本或访问网络。", + exportConversation: "导出对话", + conversationExportMarkdown: "导出为 Markdown", + conversationExportHtml: "导出为 HTML", + conversationRoleUser: "用户", + conversationRoleAssistant: "AI", + conversationFailedMarker: "此回复生成失败", + conversationExportEmpty: "暂无可导出的消息", }, contextMenu: { openConnection: "打开连接", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c1eb8385df..41dd0f7d97 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2948,6 +2948,13 @@ export default withEnglishFallback({ htmlCopyRiskAccept: "仍要複製", htmlCopyRiskRemember: "本次工作階段不再詢問", htmlCopyRiskToast: "已複製。此 AI 產生的 HTML 在 DBX 外開啟可能執行指令碼或存取網路。", + exportConversation: "匯出對話", + conversationExportMarkdown: "匯出為 Markdown", + conversationExportHtml: "匯出為 HTML", + conversationRoleUser: "使用者", + conversationRoleAssistant: "AI", + conversationFailedMarker: "此回覆產生失敗", + conversationExportEmpty: "尚無可匯出的訊息", }, contextMenu: { openConnection: "開啟連線", diff --git a/apps/desktop/src/lib/ai/aiMarkdown.ts b/apps/desktop/src/lib/ai/aiMarkdown.ts index 689a01852b..7b633ad95e 100644 --- a/apps/desktop/src/lib/ai/aiMarkdown.ts +++ b/apps/desktop/src/lib/ai/aiMarkdown.ts @@ -92,7 +92,7 @@ export function handleAiMarkdownLinkClick(event: AiMarkdownLinkClickEvent, openU return true; } -function escapeHtml(value: string): string { +export function escapeHtml(value: string): string { return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 0eb8d93317..c90e84319a 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1030,6 +1030,8 @@ export interface AiChatMessage { mentions?: unknown[]; reasoning?: string; kind?: "contextSummary" | "writeSqlConfirmation" | "productionWriteBlocked"; + /** Set on the assistant message whose generation failed; persisted (mirrors dbx-core `AiChatMessage.failed`). */ + failed?: boolean; } export interface AiConversation { diff --git a/apps/desktop/src/lib/export/__tests__/aiConversationExport.spec.ts b/apps/desktop/src/lib/export/__tests__/aiConversationExport.spec.ts new file mode 100644 index 0000000000..db950b4eb1 --- /dev/null +++ b/apps/desktop/src/lib/export/__tests__/aiConversationExport.spec.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { buildAiConversationExport, type AiConversationExportLabels, type AiConversationExportMessage } from "../aiConversationExport"; + +const LABELS: AiConversationExportLabels = { + analysisLabel: "AI Analysis", + userLabel: "User", + assistantLabel: "AI", + failedLabel: "This reply failed to generate", +}; + +const BASE = { + connectionName: "prod-mysql", + dateLabel: "2026-09-08 10:00:00", + labels: LABELS, +}; + +function msg(partial: Partial & Pick): AiConversationExportMessage { + return partial; +} + +describe("buildAiConversationExport", () => { + it("returns null for an empty conversation (no messages or whitespace-only content)", () => { + expect(buildAiConversationExport({ ...BASE, messages: [], format: "markdown" })).toBeNull(); + expect(buildAiConversationExport({ ...BASE, messages: [msg({ role: "assistant", content: " \n " })], format: "markdown" })).toBeNull(); + }); + + it("returns null when every message is a hidden contextSummary entry", () => { + const result = buildAiConversationExport({ + ...BASE, + messages: [msg({ role: "assistant", content: "internal summary", kind: "contextSummary" })], + format: "markdown", + }); + expect(result).toBeNull(); + }); + + it("markdown export writes header, role headings, and message content verbatim", () => { + const result = buildAiConversationExport({ + ...BASE, + messages: [msg({ role: "user", content: "按地区统计销售额" }), msg({ role: "assistant", content: '```chart-json\n{"version":1,"type":"bar"}\n```\n华东最高。' })], + format: "markdown", + }); + expect(result).not.toBeNull(); + const md = result!.content; + expect(md).toContain("# prod-mysql · AI Analysis"); + expect(md).toContain("2026-09-08 10:00:00"); + expect(md).toContain("## User"); + expect(md).toContain("## AI"); + expect(md).toContain("按地区统计销售额"); + // chart-json fence survives byte-identical (zero data loss, round-tripable) + expect(md).toContain('```chart-json\n{"version":1,"type":"bar"}\n```'); + expect(result!.defaultFileName).toMatch(/^prod-mysql_\d{12}\.md$/); + }); + + it("markdown export marks failed assistant replies", () => { + const result = buildAiConversationExport({ + ...BASE, + messages: [msg({ role: "assistant", content: "AI request failed\n\nboom", failed: true })], + format: "markdown", + }); + expect(result!.content).toContain(`**${LABELS.failedLabel}**`); + }); + + it("html export escapes raw HTML in message content", () => { + const result = buildAiConversationExport({ + ...BASE, + messages: [msg({ role: "assistant", content: '' }), msg({ role: "user", content: '' }), msg({ role: "assistant", content: "full doc" })], + format: "html", + }); + expect(result).not.toBeNull(); + const html = result!.content; + // No raw script/img tag may survive into the report body. + expect(html).not.toContain("", + messages: [msg({ role: "user", content: "hi" })], + format: "html", + }); + const html = result!.content; + expect(html).toContain("db</title><script>alert(1)</script> · AI Analysis"); + expect(html).not.toContain("