feat(ai): export conversation as markdown or html
This commit is contained in:
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>
|
||||
|
||||
+5
-1
@@ -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", () => {
|
||||
|
||||
+43
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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が見つかりません",
|
||||
|
||||
@@ -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: "연결 열기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -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: "打开连接",
|
||||
|
||||
@@ -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: "開啟連線",
|
||||
|
||||
@@ -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, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
|
||||
@@ -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("<script>");
|
||||
expect(html).toContain("<img src=x onerror="alert(1)">");
|
||||
expect(html).toContain("<!doctype html>");
|
||||
});
|
||||
|
||||
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</title><script>alert(1)</script> · 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 " })],
|
||||
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("<b>hi</b>");
|
||||
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();
|
||||
Reference in new issue
Block a user