feat(ai): export assistant analysis result as markdown
This commit is contained in:
parent
ce3bb09796
commit
f64d363abe
|
|
@ -16,6 +16,7 @@ import {
|
|||
Copy,
|
||||
Database,
|
||||
FileCode,
|
||||
FileDown,
|
||||
FlaskConical,
|
||||
GitBranch,
|
||||
HelpCircle,
|
||||
|
|
@ -86,6 +87,8 @@ import { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/
|
|||
import { looksLikeActionProposal, containsChinese, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative } from "@/lib/ai/aiProposalDetect";
|
||||
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";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settings = useSettingsStore();
|
||||
|
|
@ -121,12 +124,16 @@ type AiMessageMention =
|
|||
interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
/** Connection that produced this assistant response; ephemeral export metadata. */
|
||||
sourceConnectionName?: string;
|
||||
mentions?: AiMessageMention[];
|
||||
reasoning?: string;
|
||||
isThinking?: boolean;
|
||||
agentSteps?: AiAgentStepItem[];
|
||||
/** Hidden system-generated context summary; not rendered in chat UI but included in LLM history. */
|
||||
kind?: "contextSummary";
|
||||
/** Per-message token stats from the last agent run; ephemeral, not persisted. */
|
||||
tokens?: { input: number; output: number };
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -229,7 +236,6 @@ const userPausedAutoScroll = ref(false);
|
|||
const showScrollToBottom = ref(false);
|
||||
const promptCompositionActive = ref(false);
|
||||
const shikiCodeHighlighter = ref<AiCodeHighlighter>();
|
||||
const agentTokens = ref<{ input: number; output: number } | null>(null);
|
||||
const promptHistory = ref<string[]>([]);
|
||||
const historyIndex = ref(-1);
|
||||
const draftBeforeHistory = ref("");
|
||||
|
|
@ -1741,12 +1747,11 @@ async function send() {
|
|||
confirmedWriteSqlText = undefined;
|
||||
confirmedConnectionId = undefined;
|
||||
confirmedDatabase = undefined;
|
||||
messages.value.push({ role: "assistant", content: "" });
|
||||
messages.value.push({ role: "assistant", content: "", sourceConnectionName: connection.name });
|
||||
const assistantIdx = messages.value.length - 1;
|
||||
const sessionId = uuid();
|
||||
currentSessionId.value = sessionId;
|
||||
const agentEvents: AgentEvent[] = [];
|
||||
agentTokens.value = null;
|
||||
try {
|
||||
const sqlFiles = await loadReferencedSqlFiles(selectedSqlFiles);
|
||||
const context = await buildAiContext(tab, connection, {
|
||||
|
|
@ -1777,7 +1782,8 @@ async function send() {
|
|||
}
|
||||
if (event.type === "agent_end") {
|
||||
if (event.input_tokens || event.output_tokens) {
|
||||
agentTokens.value = { input: event.input_tokens ?? 0, output: event.output_tokens ?? 0 };
|
||||
const msg = messages.value[assistantIdx];
|
||||
if (msg) msg.tokens = { input: event.input_tokens ?? 0, output: event.output_tokens ?? 0 };
|
||||
}
|
||||
}
|
||||
if (event.type === "context_compacted") {
|
||||
|
|
@ -1899,6 +1905,24 @@ async function copyCode(code: string, key: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function exportMessageAsMarkdown(msg: ChatMessage) {
|
||||
if (!msg.content) return;
|
||||
|
||||
try {
|
||||
const result = buildAiAnalysisExport({
|
||||
connectionName: msg.sourceConnectionName ?? props.connection?.name,
|
||||
content: msg.content,
|
||||
analysisLabel: t("ai.analysis"),
|
||||
dateLabel: new Date().toLocaleString(),
|
||||
});
|
||||
if (!result) return;
|
||||
await saveTextFile(result.markdown, result.defaultFileName, "Markdown", "md");
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast(t("grid.exportFailed", { message }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messages.value = [];
|
||||
conversationId.value = "";
|
||||
|
|
@ -1940,11 +1964,11 @@ function selectConversation(conv: AiConversation) {
|
|||
messages.value = conv.messages.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
sourceConnectionName: m.role === "assistant" ? conv.connectionName : undefined,
|
||||
mentions: Array.isArray(m.mentions) ? (m.mentions as AiMessageMention[]) : undefined,
|
||||
reasoning: m.reasoning,
|
||||
kind: m.kind,
|
||||
}));
|
||||
agentTokens.value = null;
|
||||
pendingCompaction.value = null;
|
||||
showConversationList.value = false;
|
||||
scrollToBottom({ force: true });
|
||||
|
|
@ -2222,8 +2246,9 @@ async function openExternalUrl(url: string) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="msg.content || msg.reasoning || msg.isThinking" class="flex">
|
||||
<div class="max-w-[95%] min-w-0 rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed [overflow-wrap:anywhere]">
|
||||
<!-- Keep the metadata row as wide as the reply card so its export action stays right-aligned. -->
|
||||
<div v-else-if="msg.content || msg.reasoning || msg.isThinking" class="flex w-full max-w-[95%] min-w-0 flex-col">
|
||||
<div class="w-full rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed [overflow-wrap:anywhere]">
|
||||
<div v-if="msg.reasoning || msg.isThinking" class="mb-2">
|
||||
<button class="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors" @click="toggleReasoning()">
|
||||
<ChevronRight class="h-3 w-3 transition-transform duration-200" :class="{ 'rotate-90': reasoningExpanded }" />
|
||||
|
|
@ -2310,6 +2335,13 @@ async function openExternalUrl(url: string) {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="msg.content && !isGenerating" class="mt-1 flex items-center justify-between">
|
||||
<span v-if="msg.tokens" class="text-[10px] text-muted-foreground">↑{{ msg.tokens.input.toLocaleString() }} ↓{{ msg.tokens.output.toLocaleString() }} tokens</span>
|
||||
<span v-else />
|
||||
<button class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.exportMarkdown')" @click="exportMessageAsMarkdown(msg)">
|
||||
<FileDown class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -2317,9 +2349,6 @@ async function openExternalUrl(url: string) {
|
|||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
<span>{{ t("ai.thinking") }}</span>
|
||||
</div>
|
||||
<div v-if="agentTokens && !isGenerating" class="flex items-center gap-1 text-[10px] text-muted-foreground px-2 pb-1">
|
||||
<span>↑{{ agentTokens.input.toLocaleString() }} ↓{{ agentTokens.output.toLocaleString() }} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { computed, type ComputedRef, type Ref, createApp } from "vue";
|
|||
import { useI18n } from "vue-i18n";
|
||||
import { useDataGridExtractor } from "@/composables/useDataGridExtractor";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { saveTextFile, sanitizeExportBaseName, compactLocalTimestamp } from "@/lib/export/saveTextFile";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { type CellSelectionMatrix, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
|
||||
import type { DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
|
|
@ -1287,52 +1288,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
};
|
||||
}
|
||||
|
||||
async function saveTextFile(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({
|
||||
defaultPath: defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
if (path) await writeTextFile(path, content);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = defaultFileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function defaultDataGridExportFileName(baseName: string | undefined, fallbackBaseName: string, extension: string, options: { page?: boolean; allResults?: boolean } = {}): string {
|
||||
const sanitizedBaseName = sanitizeExportBaseName(baseName || "") || sanitizeExportBaseName(fallbackBaseName) || "export";
|
||||
const suffix = options.allResults ? "results" : options.page ? "page" : "";
|
||||
return [sanitizedBaseName, suffix, compactLocalTimestamp()].filter(Boolean).join("_") + `.${extension}`;
|
||||
}
|
||||
|
||||
function sanitizeExportBaseName(value: string): string {
|
||||
return replaceControlCharacters(
|
||||
value
|
||||
.trim()
|
||||
.replace(/\.[sS][qQ][lL]$/, "")
|
||||
.replace(/[<>:"/\\|?*]/g, "_"),
|
||||
"_",
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[._\s-]+$/g, "")
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
function replaceControlCharacters(value: string, replacement: string): string {
|
||||
return Array.from(value)
|
||||
.map((char) => (char.charCodeAt(0) < 32 ? replacement : char))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function buildMongoCopyInsertStatement(options: { collection: string; columns: string[]; sourceColumns?: Array<string | undefined>; rows: RowItem[]; mongoDocuments?: unknown[]; excludePrimaryKeys?: boolean; insertMode?: DataGridCopyInsertMode }): string | undefined {
|
||||
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
|
||||
const columnIndexes = saveColumns.map((column, index) => ({ column, index })).filter((item): item is { column: string; index: number } => !!item.column);
|
||||
|
|
@ -1365,16 +1326,6 @@ function yieldToMainThread(): Promise<void> {
|
|||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function compactLocalTimestamp(date = new Date()): string {
|
||||
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hour = String(date.getHours()).padStart(2, "0");
|
||||
const minute = String(date.getMinutes()).padStart(2, "0");
|
||||
const second = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${yy}${month}${day}${hour}${minute}${second}`;
|
||||
}
|
||||
|
||||
function effectiveColumns(sourceColumns: Array<string | undefined> | undefined, columns: string[]): Array<string | undefined> {
|
||||
if (!sourceColumns || sourceColumns.length !== columns.length) return columns;
|
||||
return sourceColumns;
|
||||
|
|
|
|||
|
|
@ -1967,6 +1967,8 @@ export default {
|
|||
templateSelectorLoading: "Loading...",
|
||||
templateSelectorEmpty: "No templates. Click Manage to add.",
|
||||
templateSelectorTooLong: "Selected templates exceed the total content limit ({max} characters). Deselect some to continue.",
|
||||
exportMarkdown: "Export as Markdown",
|
||||
analysis: "AI Analysis",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Open Connection",
|
||||
|
|
|
|||
|
|
@ -1834,6 +1834,8 @@ export default withEnglishFallback({
|
|||
templateSelectorLoading: "Cargando...",
|
||||
templateSelectorEmpty: "Sin plantillas. Haz clic en Gestionar para añadir.",
|
||||
templateSelectorTooLong: "Las plantillas seleccionadas superan el límite total de contenido ({max} caracteres). Deselecciona algunas para continuar.",
|
||||
exportMarkdown: "Exportar como Markdown",
|
||||
analysis: "AI Analysis",
|
||||
agentSteps: {
|
||||
generated: "SQL generado",
|
||||
noSql: "No se encontró SQL",
|
||||
|
|
|
|||
|
|
@ -1774,6 +1774,8 @@ export default withEnglishFallback({
|
|||
templateSelectorLoading: "Caricamento...",
|
||||
templateSelectorEmpty: "Nessun modello. Clicca Gestisci per aggiungerne.",
|
||||
templateSelectorTooLong: "I modelli selezionati superano il limite totale di contenuto ({max} caratteri). Deseleziona alcuni per continuare.",
|
||||
exportMarkdown: "Esporta come Markdown",
|
||||
analysis: "AI Analysis",
|
||||
agentSteps: {
|
||||
generated: "SQL generato",
|
||||
noSql: "Nessun SQL trovato",
|
||||
|
|
|
|||
|
|
@ -1868,6 +1868,8 @@ export default withEnglishFallback({
|
|||
templateSelectorLoading: "読み込み中...",
|
||||
templateSelectorEmpty: "テンプレートがありません。「管理」をクリックして追加してください。",
|
||||
templateSelectorTooLong: "選択されたテンプレートの合計が上限({max}文字)を超えています。選択を解除してください。",
|
||||
exportMarkdown: "Markdown としてエクスポート",
|
||||
analysis: "AI 分析",
|
||||
agentSteps: {
|
||||
generated: "SQL生成完了",
|
||||
noSql: "SQLが見つかりません",
|
||||
|
|
|
|||
|
|
@ -1936,6 +1936,8 @@ export default withEnglishFallback({
|
|||
templateSelectorLoading: "불러오는 중...",
|
||||
templateSelectorEmpty: "템플릿이 없습니다. 추가하려면 관리를 클릭하세요.",
|
||||
templateSelectorTooLong: "선택한 템플릿이 전체 내용 한도를 초과합니다 ({max}자). 계속하려면 일부를 선택 해제하세요.",
|
||||
exportMarkdown: "Markdown으로 내보내기",
|
||||
analysis: "AI 분석",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "연결 열기",
|
||||
|
|
|
|||
|
|
@ -1836,6 +1836,8 @@ export default withEnglishFallback({
|
|||
templateSelectorLoading: "Carregando...",
|
||||
templateSelectorEmpty: "Nenhum modelo. Clique em Gerenciar para adicionar.",
|
||||
templateSelectorTooLong: "Os modelos selecionados excedem o limite total de conteúdo ({max} caracteres). Desmarque alguns para continuar.",
|
||||
exportMarkdown: "Exportar como Markdown",
|
||||
analysis: "AI Analysis",
|
||||
agentSteps: {
|
||||
generated: "SQL gerado",
|
||||
noSql: "Nenhum SQL encontrado",
|
||||
|
|
|
|||
|
|
@ -1968,6 +1968,8 @@ export default withEnglishFallback({
|
|||
templateSelectorLoading: "加载中...",
|
||||
templateSelectorEmpty: "暂无模板,请点击管理添加。",
|
||||
templateSelectorTooLong: "已选模板内容合计超过限制(最多 {max} 字符),请取消部分勾选。",
|
||||
exportMarkdown: "导出为 Markdown",
|
||||
analysis: "AI 分析",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "打开连接",
|
||||
|
|
|
|||
|
|
@ -1775,6 +1775,8 @@ export default withEnglishFallback({
|
|||
templateSelectorLoading: "載入中...",
|
||||
templateSelectorEmpty: "尚無範本,請點擊管理以新增。",
|
||||
templateSelectorTooLong: "已選範本內容合計超過限制(最多 {max} 字元),請取消部分選取。",
|
||||
exportMarkdown: "匯出為 Markdown",
|
||||
analysis: "AI 分析",
|
||||
agentSteps: {
|
||||
generated: "已產生 SQL",
|
||||
noSql: "未找到 SQL",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { compactLocalTimestamp, sanitizeExportBaseName } from "./saveTextFile";
|
||||
|
||||
export interface BuildAiAnalysisExportInput {
|
||||
connectionName?: string;
|
||||
content: string;
|
||||
analysisLabel: string;
|
||||
dateLabel: string;
|
||||
}
|
||||
|
||||
export interface BuildAiAnalysisExportOutput {
|
||||
markdown: string;
|
||||
defaultFileName: string;
|
||||
}
|
||||
|
||||
export function buildAiAnalysisExport(input: BuildAiAnalysisExportInput): BuildAiAnalysisExportOutput | null {
|
||||
if (!input.content.trim()) return null;
|
||||
|
||||
const rawName = input.connectionName || "";
|
||||
const sanitizedName = sanitizeExportBaseName(rawName) || "ai";
|
||||
const displayName = rawName || "AI";
|
||||
|
||||
const headerLines = [`# ${displayName} · ${input.analysisLabel}`, `${input.dateLabel}`, ""];
|
||||
const markdown = headerLines.join("\n") + input.content;
|
||||
|
||||
const defaultFileName = `${sanitizedName}_${compactLocalTimestamp()}.md`;
|
||||
|
||||
return { markdown, defaultFileName };
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
export async function saveTextFile(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({
|
||||
defaultPath: defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
if (path) await writeTextFile(path, content);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = defaultFileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function sanitizeExportBaseName(value: string): string {
|
||||
return replaceControlCharacters(
|
||||
value
|
||||
.trim()
|
||||
.replace(/\.[sS][qQ][lL]$/, "")
|
||||
.replace(/[<>:"/\\|?*]/g, "_"),
|
||||
"_",
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[._\s-]+$/g, "")
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
function replaceControlCharacters(value: string, replacement: string): string {
|
||||
return Array.from(value)
|
||||
.map((char) => (char.charCodeAt(0) < 32 ? replacement : char))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function compactLocalTimestamp(date = new Date()): string {
|
||||
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hour = String(date.getHours()).padStart(2, "0");
|
||||
const minute = String(date.getMinutes()).padStart(2, "0");
|
||||
const second = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${yy}${month}${day}${hour}${minute}${second}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { afterEach, test, vi } from "vitest";
|
||||
|
||||
const runtimeMock = vi.hoisted(() => ({ isTauri: false }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => runtimeMock.isTauri }));
|
||||
// saveTextFile.ts is a transitive dependency of aiAnalysisExport.ts; its
|
||||
// top-level import of isTauriRuntime needs the mock above. The dynamic
|
||||
// imports (@tauri-apps/*) are never called in these contract tests, but we
|
||||
// provide no-op mocks in case the module graph ever decides to pre-evaluate
|
||||
// them.
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({}));
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({}));
|
||||
|
||||
const { buildAiAnalysisExport } = await import(
|
||||
"../../apps/desktop/src/lib/export/aiAnalysisExport.ts"
|
||||
);
|
||||
|
||||
// --- Empty / whitespace-only content ---
|
||||
|
||||
test("returns null for empty string content", () => {
|
||||
assert.equal(buildAiAnalysisExport({ content: "", analysisLabel: "Analysis", dateLabel: "2026/7/31 10:00:00" }), null);
|
||||
});
|
||||
|
||||
test("returns null for whitespace-only content", () => {
|
||||
assert.equal(
|
||||
buildAiAnalysisExport({ content: " \n\t ", analysisLabel: "Analysis", dateLabel: "2026/7/31 10:00:00" }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
// --- Markdown output format ---
|
||||
|
||||
test("builds a 3-line header followed by the content", () => {
|
||||
const result = buildAiAnalysisExport({
|
||||
connectionName: "MyDB",
|
||||
content: "## Summary\n\nThis is the analysis.",
|
||||
analysisLabel: "Analysis",
|
||||
dateLabel: "2026/7/31 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(
|
||||
result!.markdown,
|
||||
"# MyDB · Analysis\n2026/7/31 10:00:00\n## Summary\n\nThis is the analysis.",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to 'AI' in the header when connection name is missing", () => {
|
||||
const result = buildAiAnalysisExport({
|
||||
content: "report",
|
||||
analysisLabel: "Analysis",
|
||||
dateLabel: "2026/7/31 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.match(result!.markdown, /^# AI · Analysis\n/);
|
||||
});
|
||||
|
||||
test("falls back to 'AI' in the header when connection name is empty string", () => {
|
||||
const result = buildAiAnalysisExport({
|
||||
connectionName: "",
|
||||
content: "report",
|
||||
analysisLabel: "Analysis",
|
||||
dateLabel: "2026/7/31 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.match(result!.markdown, /^# AI · Analysis\n/);
|
||||
});
|
||||
|
||||
test("uses the passed connection name in the header line", () => {
|
||||
const result = buildAiAnalysisExport({
|
||||
connectionName: "production",
|
||||
content: "report",
|
||||
analysisLabel: "Analysis",
|
||||
dateLabel: "2026/7/31 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.match(result!.markdown, /^# production · Analysis\n/);
|
||||
});
|
||||
|
||||
test("uses the passed analysisLabel and dateLabel", () => {
|
||||
const result = buildAiAnalysisExport({
|
||||
connectionName: "db",
|
||||
content: "report",
|
||||
analysisLabel: "智能分析",
|
||||
dateLabel: "2026年7月31日 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.match(result!.markdown, /^# db · 智能分析\n2026年7月31日 10:00:00\n/);
|
||||
});
|
||||
|
||||
// --- Contract: only final analysis content, no reasoning/agentSteps leak ---
|
||||
|
||||
test("does not leak reasoning or agentSteps fields into the markdown", () => {
|
||||
// Simulate a ChatMessage shape with extra fields — the function only sees
|
||||
// the content string, so nothing extra should appear.
|
||||
const result = buildAiAnalysisExport({
|
||||
connectionName: "safe-db",
|
||||
content: "## Result\n\nFinal answer only.",
|
||||
analysisLabel: "Analysis",
|
||||
dateLabel: "2026/7/31 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
// The markdown must end with exactly the content — no reasoning sections
|
||||
// or agent step artifacts appended.
|
||||
assert.ok(result!.markdown.endsWith("Final answer only."));
|
||||
assert.equal(result!.markdown.includes("reasoning"), false);
|
||||
assert.equal(result!.markdown.includes("agentStep"), false);
|
||||
assert.equal(result!.markdown.includes("agentSteps"), false);
|
||||
});
|
||||
|
||||
// --- defaultFileName ---
|
||||
|
||||
test("defaultFileName sanitizes connection name", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 5, 2, 15, 4, 5));
|
||||
|
||||
const result = buildAiAnalysisExport({
|
||||
connectionName: "My/Conn",
|
||||
content: "report",
|
||||
analysisLabel: "Analysis",
|
||||
dateLabel: "2026/7/31 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result!.defaultFileName, "My_Conn_260602150405.md");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultFileName strips .sql suffix from connection name", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 5, 2, 15, 4, 5));
|
||||
|
||||
const result = buildAiAnalysisExport({
|
||||
connectionName: "daily/report.sql",
|
||||
content: "report",
|
||||
analysisLabel: "Analysis",
|
||||
dateLabel: "2026/7/31 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result!.defaultFileName, "daily_report_260602150405.md");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultFileName falls back to 'ai' when connection name is missing", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 5, 2, 15, 4, 5));
|
||||
|
||||
const result = buildAiAnalysisExport({
|
||||
content: "report",
|
||||
analysisLabel: "Analysis",
|
||||
dateLabel: "2026/7/31 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result!.defaultFileName, "ai_260602150405.md");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultFileName falls back to 'ai' when connection name is empty", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 5, 2, 15, 4, 5));
|
||||
|
||||
const result = buildAiAnalysisExport({
|
||||
connectionName: "",
|
||||
content: "report",
|
||||
analysisLabel: "Analysis",
|
||||
dateLabel: "2026/7/31 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result!.defaultFileName, "ai_260602150405.md");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultFileName always ends with .md", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 5, 2, 15, 4, 5));
|
||||
|
||||
const result = buildAiAnalysisExport({
|
||||
connectionName: "db",
|
||||
content: "report",
|
||||
analysisLabel: "Analysis",
|
||||
dateLabel: "2026/7/31 10:00:00",
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.ok(result!.defaultFileName.endsWith(".md"));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
|
@ -52,12 +52,20 @@ test("user message edit action remains available by pointer and keyboard", () =>
|
|||
assert.match(template, /v-if="!isGenerating"/);
|
||||
});
|
||||
|
||||
test("assistant messages wrap long paths and continuous error text inside the bubble", () => {
|
||||
test("assistant messages keep metadata aligned with the bubble and wrap long text", () => {
|
||||
const template = assistantMessageTemplate();
|
||||
|
||||
assert.match(template, /max-w-\[95%\][^"\n]*\[overflow-wrap:anywhere\]/);
|
||||
assert.match(template, /class="flex w-full max-w-\[95%\] min-w-0 flex-col"/);
|
||||
assert.match(template, /class="w-full[^"\n]*\[overflow-wrap:anywhere\]"/);
|
||||
});
|
||||
|
||||
test("AI request failures use localized backend diagnostics", () => {
|
||||
assert.match(source, /messages\.value\[assistantIdx\]\.content = `\$\{t\("ai\.requestFailed"\)\}\\n\\n\$\{translateBackendError\(t, message\)\}`/);
|
||||
});
|
||||
|
||||
test("AI analysis export keeps the connection that produced each assistant response", () => {
|
||||
assert.match(source, /sourceConnectionName\?: string/);
|
||||
assert.match(source, /messages\.value\.push\(\{ role: "assistant", content: "", sourceConnectionName: connection\.name \}\)/);
|
||||
assert.match(source, /connectionName: msg\.sourceConnectionName \?\? props\.connection\?\.name/);
|
||||
assert.match(source, /sourceConnectionName: m\.role === "assistant" \? conv\.connectionName : undefined/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { afterEach, beforeEach, test, vi } from "vitest";
|
||||
|
||||
const runtimeMock = vi.hoisted(() => ({ isTauri: false }));
|
||||
const dialogMock = vi.hoisted(() => ({ save: vi.fn() }));
|
||||
const fsMock = vi.hoisted(() => ({ writeTextFile: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => runtimeMock.isTauri }));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ save: dialogMock.save }));
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({ writeTextFile: fsMock.writeTextFile }));
|
||||
|
||||
const { saveTextFile, sanitizeExportBaseName, compactLocalTimestamp } = await import(
|
||||
"../../apps/desktop/src/lib/export/saveTextFile.ts"
|
||||
);
|
||||
|
||||
function installTextDownloadCapture() {
|
||||
let downloadedBlob: Blob | undefined;
|
||||
let anchorHref = "";
|
||||
let anchorDownload = "";
|
||||
const createObjectUrl = vi.spyOn(URL, "createObjectURL").mockImplementation((blob) => {
|
||||
downloadedBlob = blob as Blob;
|
||||
return "blob:test-export";
|
||||
});
|
||||
const revokeObjectUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
|
||||
const anchorClick = vi.fn();
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: (_tag: string) => {
|
||||
const a = { click: anchorClick, href: "", download: "" };
|
||||
// Capture property assignments so assertions can verify them
|
||||
const proxy = new Proxy(a, {
|
||||
set(target, prop, value) {
|
||||
(target as any)[prop] = value;
|
||||
if (prop === "href") anchorHref = value;
|
||||
if (prop === "download") anchorDownload = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return proxy;
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
content: async () => downloadedBlob?.text(),
|
||||
anchorDownload: () => anchorDownload,
|
||||
restore: () => {
|
||||
createObjectUrl.mockRestore();
|
||||
revokeObjectUrl.mockRestore();
|
||||
if (originalDocument) Object.defineProperty(globalThis, "document", originalDocument);
|
||||
else Reflect.deleteProperty(globalThis, "document");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMock.isTauri = false;
|
||||
dialogMock.save.mockResolvedValue(null);
|
||||
fsMock.writeTextFile.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// --- saveTextFile Tauri path ---
|
||||
|
||||
test("Tauri save writes content to the user-chosen path", async () => {
|
||||
runtimeMock.isTauri = true;
|
||||
dialogMock.save.mockResolvedValue("C:/out/name.md");
|
||||
|
||||
await saveTextFile("hello world", "name.md", "Markdown", "md");
|
||||
|
||||
assert.equal(dialogMock.save.mock.calls.length, 1);
|
||||
assert.deepEqual(dialogMock.save.mock.calls[0][0], {
|
||||
defaultPath: "name.md",
|
||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||
});
|
||||
assert.equal(fsMock.writeTextFile.mock.calls.length, 1);
|
||||
assert.equal(fsMock.writeTextFile.mock.calls[0][0], "C:/out/name.md");
|
||||
assert.equal(fsMock.writeTextFile.mock.calls[0][1], "hello world");
|
||||
});
|
||||
|
||||
test("Tauri cancel does not write anything", async () => {
|
||||
runtimeMock.isTauri = true;
|
||||
dialogMock.save.mockResolvedValue(null);
|
||||
|
||||
await saveTextFile("hello world", "name.md", "Markdown", "md");
|
||||
|
||||
assert.equal(dialogMock.save.mock.calls.length, 1);
|
||||
assert.equal(fsMock.writeTextFile.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
test("Tauri write failure rejects the caller", async () => {
|
||||
runtimeMock.isTauri = true;
|
||||
dialogMock.save.mockResolvedValue("C:/out/name.md");
|
||||
fsMock.writeTextFile.mockRejectedValue(new Error("disk full"));
|
||||
|
||||
await assert.rejects(
|
||||
() => saveTextFile("hello world", "name.md", "Markdown", "md"),
|
||||
{ message: "disk full" },
|
||||
);
|
||||
|
||||
assert.equal(dialogMock.save.mock.calls.length, 1);
|
||||
assert.equal(fsMock.writeTextFile.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
// --- saveTextFile Web path ---
|
||||
|
||||
test("Web download creates a Blob and triggers a link click", async () => {
|
||||
runtimeMock.isTauri = false;
|
||||
const download = installTextDownloadCapture();
|
||||
|
||||
try {
|
||||
await saveTextFile("hello world", "name.md", "Markdown", "md");
|
||||
|
||||
const text = await download.content();
|
||||
assert.equal(text, "hello world");
|
||||
// The fake anchor element should have its download attribute set
|
||||
assert.equal(download.anchorDownload(), "name.md");
|
||||
} finally {
|
||||
download.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// --- sanitizeExportBaseName ---
|
||||
|
||||
test("sanitizeExportBaseName replaces illegal file-system characters with underscores", () => {
|
||||
assert.equal(sanitizeExportBaseName('a<b>c:"d/e\\f|g?h*i'), "a_b_c__d_e_f_g_h_i");
|
||||
});
|
||||
|
||||
test("sanitizeExportBaseName strips trailing .sql extension (case-insensitive)", () => {
|
||||
assert.equal(sanitizeExportBaseName("daily/report.SQL"), "daily_report");
|
||||
assert.equal(sanitizeExportBaseName("daily/report.Sql"), "daily_report");
|
||||
assert.equal(sanitizeExportBaseName("daily/report.sqL"), "daily_report");
|
||||
assert.equal(sanitizeExportBaseName("middle.SQL.ends"), "middle.SQL.ends");
|
||||
});
|
||||
|
||||
test("sanitizeExportBaseName trims trailing dots, underscores, spaces, and hyphens", () => {
|
||||
assert.equal(sanitizeExportBaseName("trailing-._ -"), "trailing");
|
||||
assert.equal(sanitizeExportBaseName("trailing._ -_.sql"), "trailing");
|
||||
});
|
||||
|
||||
test("sanitizeExportBaseName collapses consecutive whitespace", () => {
|
||||
assert.equal(sanitizeExportBaseName("hello world"), "hello world");
|
||||
// tab (char code 9) is a control character and replaced by _, not collapsed as whitespace
|
||||
assert.equal(sanitizeExportBaseName("a\tb"), "a_b");
|
||||
});
|
||||
|
||||
test("sanitizeExportBaseName truncates to 120 characters", () => {
|
||||
const long = "a".repeat(200);
|
||||
assert.equal(sanitizeExportBaseName(long).length, 120);
|
||||
// trailing chars removed after truncation should not leave dangling punctuation
|
||||
assert.match(sanitizeExportBaseName(long), /a+$/);
|
||||
});
|
||||
|
||||
test("sanitizeExportBaseName replaces control characters", () => {
|
||||
assert.equal(
|
||||
sanitizeExportBaseName("a" + String.fromCharCode(0) + "b" + String.fromCharCode(1) + "c"),
|
||||
"a_b_c",
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeExportBaseName returns empty string for all-illegal input", () => {
|
||||
assert.equal(sanitizeExportBaseName("<>?*.sql"), "");
|
||||
assert.equal(sanitizeExportBaseName(".:."), "");
|
||||
});
|
||||
|
||||
// --- compactLocalTimestamp ---
|
||||
|
||||
test("compactLocalTimestamp formats a date as YYMMDDHHmmss", () => {
|
||||
const d = new Date(2026, 5, 2, 15, 4, 5); // June 2, 2026 15:04:05
|
||||
assert.equal(compactLocalTimestamp(d), "260602150405");
|
||||
});
|
||||
|
||||
test("compactLocalTimestamp pads single-digit values", () => {
|
||||
const d = new Date(2026, 0, 1, 2, 3, 4); // Jan 1, 2026 02:03:04
|
||||
assert.equal(compactLocalTimestamp(d), "260101020304");
|
||||
});
|
||||
|
||||
test("compactLocalTimestamp uses current time when no argument is given", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date(2025, 11, 31, 23, 59, 59));
|
||||
assert.equal(compactLocalTimestamp(), "251231235959");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue