feat(ai-chat): add Markdown export button for agent chat
Add an "Export chat" button to the AI agent chat window header that downloads the active conversation as a Markdown file. The export is client-only: it serializes the already-loaded persisted message rows (no new network call, no server/DB change) and includes the request internals the chat already holds — tool calls with their input/output, per-message token usage, finish reason and error info. - New util apps/client/src/features/ai-chat/utils/export-chat.ts: buildChatMarkdown() + exportChatAsMarkdown(); reuses tool-parts helpers so tool labels match the on-screen labels; fence() escapes embedded code fences; slugify() yields a safe filename with a chatId fallback; downloads via file-saver's saveAs. - ai-chat-window.tsx: IconFileExport button in the header, shown only for a saved chat with loaded rows (canExport); drag is unaffected. - en-US: add "Export chat" and "You" i18n keys.
This commit is contained in:
@@ -10,6 +10,7 @@ import { Group, Loader, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsDiagonal,
|
||||
IconChevronDown,
|
||||
IconFileExport,
|
||||
IconGripVertical,
|
||||
IconMinus,
|
||||
IconPlus,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
import { exportChatAsMarkdown } from "@/features/ai-chat/utils/export-chat.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
||||
|
||||
// Default window geometry (from the GitmostAgent.jsx design).
|
||||
@@ -154,6 +156,26 @@ export default function AiChatWindow() {
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
}, [activeChatId, queryClient]);
|
||||
|
||||
// The active chat object (for its title) and an export gate: only enable the
|
||||
// export button when an existing chat with loaded persisted rows is active.
|
||||
const activeChat = useMemo(
|
||||
() => chats?.items?.find((c) => c.id === activeChatId) ?? null,
|
||||
[chats, activeChatId],
|
||||
);
|
||||
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
|
||||
|
||||
// Build a Markdown export from the already-loaded persisted rows (no network
|
||||
// call) and trigger a browser download. The download dialog is the feedback.
|
||||
const handleExport = useCallback(() => {
|
||||
if (!activeChatId || !messageRows || messageRows.length === 0) return;
|
||||
exportChatAsMarkdown({
|
||||
title: activeChat?.title ?? null,
|
||||
chatId: activeChatId,
|
||||
rows: messageRows,
|
||||
t,
|
||||
});
|
||||
}, [activeChatId, messageRows, activeChat, t]);
|
||||
|
||||
// When awaiting a new chat's id, adopt the most-recent chat (the list is
|
||||
// ordered newest-first) once it appears.
|
||||
useEffect(() => {
|
||||
@@ -308,6 +330,17 @@ export default function AiChatWindow() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{canExport && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.headerBtn}
|
||||
title={t("Export chat")}
|
||||
aria-label={t("Export chat")}
|
||||
onClick={handleExport}
|
||||
>
|
||||
<IconFileExport size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={classes.headerBtn}
|
||||
|
||||
194
apps/client/src/features/ai-chat/utils/export-chat.ts
Normal file
194
apps/client/src/features/ai-chat/utils/export-chat.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Client-only Markdown exporter for an AI agent chat. Serializes the already
|
||||
* persisted message rows (loaded via `useAiChatMessagesQuery`) into a single
|
||||
* Markdown document and triggers a browser download. NO network call is made
|
||||
* and NO server/DB code is touched — this reuses the rich "request internals"
|
||||
* (tool calls with input/output, per-message token usage, finish/error info)
|
||||
* that the chat already holds client-side.
|
||||
*
|
||||
* Only role labels and tool action labels are localized via the passed-in `t`
|
||||
* translator; the structural document words (Input/Output/Error/Tokens/...) are
|
||||
* plain English constants because the export is a technical artifact.
|
||||
*/
|
||||
|
||||
import { saveAs } from "file-saver";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
ToolUiPart,
|
||||
getToolName,
|
||||
toolRunState,
|
||||
toolLabelKey,
|
||||
} from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
|
||||
// Minimal translator signature compatible with react-i18next's `t`.
|
||||
type Translate = (key: string, values?: Record<string, unknown>) => string;
|
||||
|
||||
interface ExportChatArgs {
|
||||
title: string | null;
|
||||
chatId: string;
|
||||
rows: IAiChatMessageRow[];
|
||||
t: Translate;
|
||||
}
|
||||
|
||||
/** A single AI SDK UIMessage part (text part or other). */
|
||||
interface TextLikePart {
|
||||
type: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify an arbitrary tool input/output value for a fenced block. Strings
|
||||
* pass through as-is; everything else is pretty-printed JSON, falling back to
|
||||
* `String(value)` if serialization throws (e.g. a circular structure).
|
||||
*/
|
||||
function stringify(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `code` in a fenced code block whose backtick delimiter is LONGER than
|
||||
* the longest backtick run inside the content, so embedded backticks (or even
|
||||
* a literal ``` fence) never break out of the block. Minimum 3 backticks.
|
||||
*/
|
||||
function fence(code: string, lang = ""): string {
|
||||
const runs: string[] = code.match(/`+/g) ?? [];
|
||||
const longest = runs.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const delim = "`".repeat(Math.max(3, longest + 1));
|
||||
return `${delim}${lang}\n${code}\n${delim}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a filesystem-safe slug from a chat title: lowercase, collapse any run
|
||||
* of non-alphanumeric characters to a single "-", trim stray dashes, cap the
|
||||
* length. Falls back to `chatId` when the title yields an empty slug.
|
||||
*/
|
||||
function slugify(title: string | null, chatId: string): string {
|
||||
const slug = (title ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 50)
|
||||
.replace(/-+$/g, "");
|
||||
return slug.length > 0 ? slug : chatId;
|
||||
}
|
||||
|
||||
/** Per-row token count, mirroring the header sum in ai-chat-window.tsx. */
|
||||
function rowTokens(usage: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
}): number {
|
||||
return (
|
||||
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
|
||||
* export timestamp), so it is straightforward to unit-test.
|
||||
*/
|
||||
export function buildChatMarkdown(args: ExportChatArgs): string {
|
||||
const { title, chatId, rows, t } = args;
|
||||
const blocks: string[] = [];
|
||||
|
||||
const heading = (title ?? "").trim() || t("Untitled chat");
|
||||
blocks.push(`# ${heading}`);
|
||||
|
||||
// Metadata bullet list. Total tokens is only shown when there is a sum.
|
||||
const totalTokens = rows.reduce((sum, row) => {
|
||||
const usage = row.metadata?.usage;
|
||||
return usage ? sum + rowTokens(usage) : sum;
|
||||
}, 0);
|
||||
const meta = [
|
||||
`- Chat ID: \`${chatId}\``,
|
||||
`- Exported: ${new Date().toISOString()}`,
|
||||
`- Messages: ${rows.length}`,
|
||||
];
|
||||
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
|
||||
blocks.push(meta.join("\n"));
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
blocks.push("---");
|
||||
|
||||
const roleLabel = row.role === "assistant" ? t("AI agent") : t("You");
|
||||
blocks.push(`## ${index + 1}. ${roleLabel}`);
|
||||
|
||||
// Created-at kept in source as an HTML comment (out of the rendered prose).
|
||||
blocks.push(`<!-- ${row.createdAt} -->`);
|
||||
|
||||
// Resolve parts: prefer the rich persisted parts, else a single text part
|
||||
// built from the plain-text content (mirrors `rowToUiMessage`).
|
||||
const parts: TextLikePart[] =
|
||||
Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
|
||||
? (row.metadata.parts as TextLikePart[])
|
||||
: [{ type: "text", text: row.content ?? "" }];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
const text = (part.text ?? "").trim();
|
||||
// Skip empty/whitespace-only text parts (matches MessageItem).
|
||||
if (text.length > 0) blocks.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isToolPart =
|
||||
part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
||||
if (!isToolPart) continue;
|
||||
|
||||
const tp = part as unknown as ToolUiPart;
|
||||
const name = getToolName(tp);
|
||||
const { key, values } = toolLabelKey(name);
|
||||
const label = t(key, values);
|
||||
const state = toolRunState(tp.state);
|
||||
|
||||
const toolLines: string[] = [
|
||||
`**Tool: ${label}** (\`${name}\`) — ${state}`,
|
||||
];
|
||||
if (tp.input !== undefined) {
|
||||
toolLines.push("Input:");
|
||||
toolLines.push(fence(stringify(tp.input), "json"));
|
||||
}
|
||||
if (tp.output !== undefined) {
|
||||
toolLines.push("Output:");
|
||||
toolLines.push(fence(stringify(tp.output), "json"));
|
||||
}
|
||||
if (tp.errorText) {
|
||||
toolLines.push(`**Error:** ${tp.errorText}`);
|
||||
}
|
||||
blocks.push(toolLines.join("\n\n"));
|
||||
}
|
||||
|
||||
if (row.metadata?.error) {
|
||||
blocks.push(`**⚠️ Error:** ${row.metadata.error}`);
|
||||
}
|
||||
|
||||
const usage = row.metadata?.usage;
|
||||
if (usage) {
|
||||
const total = usage.totalTokens ?? rowTokens(usage);
|
||||
blocks.push(
|
||||
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}, total: ${total}_`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Blank line between blocks so the Markdown renders cleanly.
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Markdown, wrap it in a Blob, and trigger a browser download. The
|
||||
* file name is `gitmost-chat-<slug>-<YYYYMMDD>.md`.
|
||||
*/
|
||||
export function exportChatAsMarkdown(args: ExportChatArgs): void {
|
||||
const markdown = buildChatMarkdown(args);
|
||||
const slug = slugify(args.title, args.chatId);
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const filename = `gitmost-chat-${slug}-${date}.md`;
|
||||
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
|
||||
saveAs(blob, filename);
|
||||
}
|
||||
Reference in New Issue
Block a user