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:
vvzvlad
2026-06-18 05:12:15 +03:00
parent 4d25c6fe2e
commit 1968879fe5
3 changed files with 229 additions and 0 deletions

View File

@@ -947,6 +947,8 @@
"Try a different search term.": "Try a different search term.",
"Try again": "Try again",
"Untitled chat": "Untitled chat",
"Export chat": "Export chat",
"You": "You",
"What can I help you with?": "What can I help you with?",
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Automatically provision users and groups from your identity provider via SCIM.",

View File

@@ -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}

View 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);
}