diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx
index aaf09f2..9219c26 100644
--- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx
+++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx
@@ -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() {
{t.agents.newChat}
+
diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx
index 26af3f5..a447e6f 100644
--- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx
+++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx
@@ -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() {
+
diff --git a/frontend/src/components/workspace/export-trigger.tsx b/frontend/src/components/workspace/export-trigger.tsx
new file mode 100644
index 0000000..b75d4e4
--- /dev/null
+++ b/frontend/src/components/workspace/export-trigger.tsx
@@ -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 (
+
+
+
+
+
+
+
+ handleExport("markdown")}>
+
+ {t.common.exportAsMarkdown}
+
+ handleExport("json")}>
+
+ {t.common.exportAsJSON}
+
+
+
+ );
+}
diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx
index 220aee2..09f0b34 100644
--- a/frontend/src/components/workspace/recent-chat-list.tsx
+++ b/frontend/src/components/workspace/recent-chat-list.tsx
@@ -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
(
+ 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() {
{t.common.share}
+
+
+
+ {t.common.export}
+
+
+
+ handleExport(thread, "markdown")
+ }
+ >
+
+ {t.common.exportAsMarkdown}
+
+
+ handleExport(thread, "json")
+ }
+ >
+
+ {t.common.exportAsJSON}
+
+
+
handleDelete(thread.thread_id)}
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index bc4b84a..9af241f 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -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
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index b385d45..9d68901 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -32,6 +32,10 @@ export interface Translations {
save: string;
install: string;
create: string;
+ export: string;
+ exportAsMarkdown: string;
+ exportAsJSON: string;
+ exportSuccess: string;
};
// Welcome
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index d6d8031..0a33997 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -43,6 +43,10 @@ export const zhCN: Translations = {
save: "保存",
install: "安装",
create: "创建",
+ export: "导出",
+ exportAsMarkdown: "导出为 Markdown",
+ exportAsJSON: "导出为 JSON",
+ exportSuccess: "对话已导出",
},
// Welcome
diff --git a/frontend/src/core/threads/export.ts b/frontend/src/core/threads/export.ts
new file mode 100644
index 0000000..cf3e2f3
--- /dev/null
+++ b/frontend/src/core/threads/export.ts
@@ -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(
+ "",
+ "",
+ "Thinking
",
+ "",
+ reasoning,
+ "",
+ " ",
+ );
+ }
+
+ 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");
+}