feat(ai): export conversation as markdown or html

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-08 16:46:22 +08:00
1 parent d0832f17f4
commit 2b2d1f0d95
20 files changed
+506 -5

No files matched your search

@@ -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<ChatMessage>(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 }}
</span>
<ProductionContextBadge v-if="productionContext.active" compact />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('ai.exportConversation')" :aria-label="t('ai.exportConversation')">
<FileDown class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="exportConversationAs('markdown')">{{ t("ai.conversationExportMarkdown") }}</DropdownMenuItem>
<DropdownMenuItem @click="exportConversationAs('html')">{{ t("ai.conversationExportHtml") }}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="startNewChat" :title="t('ai.newChat')">
<MessageSquarePlus class="h-3.5 w-3.5" />
</Button>
@@ -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", () => {
@@ -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";');
});
});
@@ -26,6 +26,10 @@ const locales: Array<[string, Record<string, unknown>]> = [
// 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<string, unknown> }).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<string, unknown> }).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);
}
});
});
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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が見つかりません",
+7
View File
@@ -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: "연결 열기",
+7
View File
@@ -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",
+7
View File
@@ -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ç",
+7
View File
@@ -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: "打开连接",
+7
View File
@@ -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: "開啟連線",
+1 -1
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
+2
View File
@@ -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 {
@@ -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<AiConversationExportMessage> & Pick<AiConversationExportMessage, "role" | "content">): 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: '<script>alert("x")</script>' }), msg({ role: "user", content: '<img src=x onerror="alert(1)">' }), msg({ role: "assistant", content: "<!doctype html><html><body>full doc</body></html>" })],
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("<script>");
expect(html).not.toContain("<img");
expect(html).not.toContain("<!doctype html><html>");
// The escaped forms are present so the user still sees the original text.
expect(html).toContain("&lt;script&gt;");
expect(html).toContain("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
expect(html).toContain("&lt;!doctype html&gt;");
});
it("html export escapes title and connection name in the document head", () => {
const result = buildAiConversationExport({
...BASE,
connectionName: "db</title><script>alert(1)</script>",
messages: [msg({ role: "user", content: "hi" })],
format: "html",
});
const html = result!.content;
expect(html).toContain("<title>db&lt;/title&gt;&lt;script&gt;alert(1)&lt;/script&gt; · AI Analysis</title>");
expect(html).not.toContain("<script>alert(1)");
// `<>/"` become underscores (same rule as every export file name); trailing underscores are trimmed.
expect(result!.defaultFileName).toMatch(/^db__title__script_alert\(1\)__script_\d{12}\.html$/);
});
it("html export carries the shared preview CSP and renders fences as escaped code blocks", () => {
const result = buildAiConversationExport({
...BASE,
messages: [msg({ role: "assistant", content: "```html\n<b>hi</b>\n```" }), msg({ role: "assistant", content: "see ![pic](https://example.invalid/x.png)" })],
format: "html",
});
const html = result!.content;
expect(html).toContain('http-equiv="Content-Security-Policy"');
expect(html).toContain("default-src 'none'");
// The ```html fence body is escaped text inside a code block, never live markup.
expect(html).toContain("&lt;b&gt;hi&lt;/b&gt;");
expect(html).not.toContain("<b>hi</b>");
expect(result!.defaultFileName).toMatch(/\.html$/);
});
it("html export labels roles and marks failed replies", () => {
const result = buildAiConversationExport({
...BASE,
messages: [msg({ role: "user", content: "q" }), msg({ role: "assistant", content: "AI request failed", failed: true })],
format: "html",
});
const html = result!.content;
expect(html).toContain('<article class="user">');
expect(html).toContain('<article class="assistant">');
expect(html).toContain(`<p class="role">User</p>`);
expect(html).toContain(`<p class="failed">${LABELS.failedLabel}</p>`);
});
it("sanitizes the connection name for the file name while keeping the display name", () => {
const result = buildAiConversationExport({
...BASE,
connectionName: 'prod:<db>"*?"',
messages: [msg({ role: "user", content: "hi" })],
format: "markdown",
});
expect(result!.content).toContain('# prod:<db>"*?" · AI Analysis');
// `<>:"*?` become underscores; the trailing run of underscores is trimmed.
expect(result!.defaultFileName).toMatch(/^prod__db_\d{12}\.md$/);
});
it("falls back to the ai prefix when no connection name is given", () => {
const result = buildAiConversationExport({
...BASE,
connectionName: undefined,
messages: [msg({ role: "user", content: "hi" })],
format: "markdown",
});
expect(result!.defaultFileName).toMatch(/^ai_\d{12}\.md$/);
expect(result!.content).toContain("# AI · AI Analysis");
});
});
@@ -0,0 +1,135 @@
import { formatAiInlineMarkdown, escapeHtml } from "@/lib/ai/aiMarkdown";
import { AI_HTML_PREVIEW_CSP } from "@/lib/ai/richContent/aiHtmlPreview";
import { compactLocalTimestamp, sanitizeExportBaseName } from "./saveTextFile";
/**
* Conversation-level export (#6467 PR3).
*
* Source boundary: the caller passes the **visible transcript**
* (`visibleMessages`) — the same messages the chat renders. Internal entries
* (`contextSummary`, reasoning, tool arguments/results, attachment payloads)
* never reach this module; `kind === "contextSummary"` is additionally skipped
* here as defense in depth.
*
* Rendering rules (V1):
* - Markdown export keeps every message's content **verbatim** — fenced
* ```chart-json / ```html blocks survive byte-identical.
* - HTML export renders each message through `formatAiInlineMarkdown`, whose
* renderer escapes raw HTML, so ```chart-json / ```html fences come out as
* escaped code blocks — original HTML is never injected into the report.
* - The report document carries the same CSP meta as the safe-HTML preview
* shell (`AI_HTML_PREVIEW_CSP`): markdown links/images are the one path
* where a report could reference remote resources, and the CSP blocks those
* fetches in the standalone file.
*/
export type AiConversationExportFormat = "markdown" | "html";
export interface AiConversationExportMessage {
role: "user" | "assistant";
content: string;
/** Hidden system-generated entries; `contextSummary` is never exported. */
kind?: string;
/** Marks assistant replies whose generation failed; rendered with a marker. */
failed?: boolean;
}
export interface AiConversationExportLabels {
analysisLabel: string;
userLabel: string;
assistantLabel: string;
failedLabel: string;
}
export interface BuildAiConversationExportInput {
connectionName?: string;
dateLabel: string;
messages: AiConversationExportMessage[];
format: AiConversationExportFormat;
labels: AiConversationExportLabels;
}
export interface BuildAiConversationExportOutput {
content: string;
defaultFileName: string;
}
function exportableMessages(messages: AiConversationExportMessage[]): AiConversationExportMessage[] {
return messages.filter((msg) => msg.kind !== "contextSummary" && !!msg.content.trim());
}
export function buildAiConversationExport(input: BuildAiConversationExportInput): BuildAiConversationExportOutput | null {
const messages = exportableMessages(input.messages);
if (!messages.length) return null;
const rawName = input.connectionName || "";
const sanitizedName = sanitizeExportBaseName(rawName) || "ai";
const displayName = rawName || "AI";
const extension = input.format === "markdown" ? "md" : "html";
const title = `${displayName} · ${input.labels.analysisLabel}`;
const content = input.format === "markdown" ? buildMarkdownReport(title, input.dateLabel, messages, input.labels) : buildHtmlReport(title, input.dateLabel, messages, input.labels);
return { content, defaultFileName: `${sanitizedName}_${compactLocalTimestamp()}.${extension}` };
}
function buildMarkdownReport(title: string, dateLabel: string, messages: AiConversationExportMessage[], labels: AiConversationExportLabels): string {
const lines: string[] = [`# ${title}`, dateLabel, ""];
for (const msg of messages) {
lines.push(`## ${msg.role === "user" ? labels.userLabel : labels.assistantLabel}`, "");
if (msg.failed) lines.push(`**${labels.failedLabel}**`, "");
lines.push(msg.content, "");
}
return lines.join("\n");
}
function buildHtmlReport(title: string, dateLabel: string, messages: AiConversationExportMessage[], labels: AiConversationExportLabels): string {
const escapedTitle = escapeHtml(title);
const articles = messages
.map((msg) => {
const roleLabel = escapeHtml(msg.role === "user" ? labels.userLabel : labels.assistantLabel);
const body = formatAiInlineMarkdown(msg.content);
const failedMark = msg.failed ? `\n<p class="failed">${escapeHtml(labels.failedLabel)}</p>` : "";
return `<article class="${msg.role}">\n<p class="role">${roleLabel}</p>\n<div class="content">\n${body}\n</div>${failedMark}\n</article>`;
})
.join("\n");
return [
"<!doctype html>",
"<html>",
"<head>",
'<meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${AI_HTML_PREVIEW_CSP}">`,
`<title>${escapedTitle}</title>`,
"<style>",
AI_CONVERSATION_REPORT_CSS,
"</style>",
"</head>",
"<body>",
`<header><h1>${escapedTitle}</h1><p class="meta">${escapeHtml(dateLabel)}</p></header>`,
`<main>${articles}</main>`,
"</body>",
"</html>",
].join("\n");
}
/** Static, light-only report styling. The CSP only allows inline `style-src`, so the sheet rides in the document. */
const AI_CONVERSATION_REPORT_CSS = `
body { margin: 0 auto; max-width: 52rem; padding: 2rem 1rem 4rem; color: #1c1917; background: #ffffff; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; line-height: 1.6; }
header h1 { margin: 0 0 0.25rem; font-size: 1.25rem; }
header .meta { margin: 0 0 1.5rem; color: #78716c; font-size: 0.85rem; }
article { border: 1px solid #e7e5e4; border-radius: 8px; padding: 0.75rem 1rem; margin: 0 0 1rem; overflow-wrap: break-word; }
article.user { background: #fafaf9; }
article .role { margin: 0 0 0.25rem; color: #78716c; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
article .content > :first-child { margin-top: 0; }
article .content > :last-child { margin-bottom: 0; }
article .failed { margin: 0.75rem 0 0; color: #dc2626; font-size: 0.85rem; font-weight: 600; }
pre { background: #f5f5f4; border: 1px solid #e7e5e4; border-radius: 6px; padding: 0.75rem; overflow: auto; font-size: 0.8rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
:not(pre) > code { background: #f5f5f4; border-radius: 4px; padding: 0.1rem 0.3rem; font-size: 0.85em; }
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; font-size: 0.85rem; }
th, td { border: 1px solid #e7e5e4; padding: 0.35rem 0.5rem; text-align: left; }
.ai-markdown-table-wrap { overflow-x: auto; }
a { color: #2563eb; }
blockquote { margin: 0.5rem 0; padding: 0.25rem 0.75rem; border-left: 3px solid #e7e5e4; color: #57534e; }
`.trim();