feat(web): add conversation export as Markdown and JSON (#1002)

* feat(web): add conversation export as Markdown and JSON (#976)

Add the ability to export conversations in Markdown and JSON formats,
accessible from both the chat header and the sidebar context menu.

- Add export utility (formatThreadAsMarkdown, formatThreadAsJSON) with
  support for user/assistant messages, thinking blocks, and tool calls
- Add ExportTrigger component in chat header (appears when messages exist)
- Add Export submenu to sidebar dropdown (fetches full thread state on demand)
- Add i18n translations for en-US and zh-CN

Closes #976

Made-with: Cursor

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update thread creation timestamp to updated_at

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ben Ghorbel Mohamed Aziz
2026-03-23 01:21:54 +01:00
committed by GitHub
parent 1c981ead2a
commit 38ace61617
8 changed files with 308 additions and 2 deletions

View File

@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
@@ -114,6 +115,7 @@ export default function AgentChatPage() {
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>

View File

@@ -9,6 +9,7 @@ import {
useSpecificChatMode,
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
@@ -84,7 +85,8 @@ export default function ChatPage() {
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div>
<div className="flex items-center">
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>

View File

@@ -0,0 +1,81 @@
"use client";
import { Download, FileJson, FileText } from "lucide-react";
import { useCallback } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useI18n } from "@/core/i18n/hooks";
import {
exportThreadAsJSON,
exportThreadAsMarkdown,
} from "@/core/threads/export";
import type { AgentThread } from "@/core/threads/types";
import { useThread } from "./messages/context";
import { Tooltip } from "./tooltip";
export function ExportTrigger({ threadId }: { threadId: string }) {
const { t } = useI18n();
const { thread } = useThread();
const messages = thread.messages;
const handleExport = useCallback(
(format: "markdown" | "json") => {
if (messages.length === 0) {
toast.error(t.conversation.noMessages);
return;
}
const agentThread = {
thread_id: threadId,
updated_at: new Date().toISOString(),
values: thread.values,
} as AgentThread;
if (format === "markdown") {
exportThreadAsMarkdown(agentThread, messages);
} else {
exportThreadAsJSON(agentThread, messages);
}
toast.success(t.common.exportSuccess);
},
[messages, thread.values, threadId, t],
);
if (messages.length === 0) {
return null;
}
return (
<DropdownMenu>
<Tooltip content={t.common.export}>
<DropdownMenuTrigger asChild>
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
>
<Download />
{t.common.export}
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => handleExport("markdown")}>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleExport("json")}>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,6 +1,14 @@
"use client";
import { MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
import {
Download,
FileJson,
FileText,
MoreHorizontal,
Pencil,
Share2,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useCallback, useState } from "react";
@@ -19,6 +27,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
@@ -31,12 +42,18 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
exportThreadAsJSON,
exportThreadAsMarkdown,
} from "@/core/threads/export";
import {
useDeleteThread,
useRenameThread,
useThreads,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { env } from "@/env";
@@ -110,6 +127,32 @@ export function RecentChatList() {
},
[t],
);
const handleExport = useCallback(
async (thread: AgentThread, format: "markdown" | "json") => {
try {
const apiClient = getAPIClient();
const state = await apiClient.threads.getState<AgentThreadState>(
thread.thread_id,
);
const messages = state.values?.messages ?? [];
if (messages.length === 0) {
toast.error(t.conversation.noMessages);
return;
}
if (format === "markdown") {
exportThreadAsMarkdown(thread, messages);
} else {
exportThreadAsJSON(thread, messages);
}
toast.success(t.common.exportSuccess);
} catch {
toast.error("Failed to export conversation");
}
},
[t],
);
if (threads.length === 0) {
return null;
}
@@ -172,6 +215,30 @@ export function RecentChatList() {
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}

View File

@@ -43,6 +43,10 @@ export const enUS: Translations = {
save: "Save",
install: "Install",
create: "Create",
export: "Export",
exportAsMarkdown: "Export as Markdown",
exportAsJSON: "Export as JSON",
exportSuccess: "Conversation exported",
},
// Welcome

View File

@@ -32,6 +32,10 @@ export interface Translations {
save: string;
install: string;
create: string;
export: string;
exportAsMarkdown: string;
exportAsJSON: string;
exportSuccess: string;
};
// Welcome

View File

@@ -43,6 +43,10 @@ export const zhCN: Translations = {
save: "保存",
install: "安装",
create: "创建",
export: "导出",
exportAsMarkdown: "导出为 Markdown",
exportAsJSON: "导出为 JSON",
exportSuccess: "对话已导出",
},
// Welcome

View File

@@ -0,0 +1,142 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
hasContent,
hasToolCalls,
stripUploadedFilesTag,
} from "../messages/utils";
import type { AgentThread } from "./types";
import { titleOfThread } from "./utils";
function formatMessageContent(message: Message): string {
const text = extractContentFromMessage(message);
if (!text) return "";
return stripUploadedFilesTag(text);
}
function formatToolCalls(message: Message): string {
if (message.type !== "ai" || !hasToolCalls(message)) return "";
const calls = message.tool_calls ?? [];
return calls.map((call) => `- **Tool:** \`${call.name}\``).join("\n");
}
export function formatThreadAsMarkdown(
thread: AgentThread,
messages: Message[],
): string {
const title = titleOfThread(thread);
const createdAt = thread.created_at
? new Date(thread.created_at).toLocaleString()
: "Unknown";
const lines: string[] = [
`# ${title}`,
"",
`*Exported on ${new Date().toLocaleString()} · Created ${createdAt}*`,
"",
"---",
"",
];
for (const message of messages) {
if (message.type === "human") {
const content = formatMessageContent(message);
if (content) {
lines.push(`## 🧑 User`, "", content, "", "---", "");
}
} else if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
const content = formatMessageContent(message);
const toolCalls = formatToolCalls(message);
if (!content && !toolCalls && !reasoning) continue;
lines.push(`## 🤖 Assistant`);
if (reasoning) {
lines.push(
"",
"<details>",
"<summary>Thinking</summary>",
"",
reasoning,
"",
"</details>",
);
}
if (toolCalls) {
lines.push("", toolCalls);
}
if (content && hasContent(message)) {
lines.push("", content);
}
lines.push("", "---", "");
}
}
return lines.join("\n").trimEnd() + "\n";
}
export function formatThreadAsJSON(
thread: AgentThread,
messages: Message[],
): string {
const exportData = {
title: titleOfThread(thread),
thread_id: thread.thread_id,
created_at: thread.created_at,
exported_at: new Date().toISOString(),
messages: messages.map((msg) => ({
type: msg.type,
id: msg.id,
content: typeof msg.content === "string" ? msg.content : msg.content,
...(msg.type === "ai" && msg.tool_calls?.length
? { tool_calls: msg.tool_calls }
: {}),
})),
};
return JSON.stringify(exportData, null, 2);
}
function sanitizeFilename(name: string): string {
return (
name.replace(/[^\p{L}\p{N}_\- ]/gu, "").trim() || "conversation"
);
}
export function downloadAsFile(
content: string,
filename: string,
mimeType: string,
) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function exportThreadAsMarkdown(
thread: AgentThread,
messages: Message[],
) {
const markdown = formatThreadAsMarkdown(thread, messages);
const filename = `${sanitizeFilename(titleOfThread(thread))}.md`;
downloadAsFile(markdown, filename, "text/markdown;charset=utf-8");
}
export function exportThreadAsJSON(thread: AgentThread, messages: Message[]) {
const json = formatThreadAsJSON(thread, messages);
const filename = `${sanitizeFilename(titleOfThread(thread))}.json`;
downloadAsFile(json, filename, "application/json;charset=utf-8");
}