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"); +}