mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-21 21:24:46 +08:00
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:
committed by
GitHub
parent
1c981ead2a
commit
38ace61617
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||||
|
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||||
import { InputBox } from "@/components/workspace/input-box";
|
import { InputBox } from "@/components/workspace/input-box";
|
||||||
import { MessageList } from "@/components/workspace/messages";
|
import { MessageList } from "@/components/workspace/messages";
|
||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
@@ -114,6 +115,7 @@ export default function AgentChatPage() {
|
|||||||
<PlusSquare /> {t.agents.newChat}
|
<PlusSquare /> {t.agents.newChat}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<ExportTrigger threadId={threadId} />
|
||||||
<ArtifactTrigger />
|
<ArtifactTrigger />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useSpecificChatMode,
|
useSpecificChatMode,
|
||||||
useThreadChat,
|
useThreadChat,
|
||||||
} from "@/components/workspace/chats";
|
} from "@/components/workspace/chats";
|
||||||
|
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||||
import { InputBox } from "@/components/workspace/input-box";
|
import { InputBox } from "@/components/workspace/input-box";
|
||||||
import { MessageList } from "@/components/workspace/messages";
|
import { MessageList } from "@/components/workspace/messages";
|
||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
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">
|
<div className="flex w-full items-center text-sm font-medium">
|
||||||
<ThreadTitle threadId={threadId} thread={thread} />
|
<ThreadTitle threadId={threadId} thread={thread} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center">
|
||||||
|
<ExportTrigger threadId={threadId} />
|
||||||
<ArtifactTrigger />
|
<ArtifactTrigger />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
81
frontend/src/components/workspace/export-trigger.tsx
Normal file
81
frontend/src/components/workspace/export-trigger.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
@@ -19,6 +27,9 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -31,12 +42,18 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import {
|
||||||
|
exportThreadAsJSON,
|
||||||
|
exportThreadAsMarkdown,
|
||||||
|
} from "@/core/threads/export";
|
||||||
import {
|
import {
|
||||||
useDeleteThread,
|
useDeleteThread,
|
||||||
useRenameThread,
|
useRenameThread,
|
||||||
useThreads,
|
useThreads,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
|
||||||
@@ -110,6 +127,32 @@ export function RecentChatList() {
|
|||||||
},
|
},
|
||||||
[t],
|
[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) {
|
if (threads.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -172,6 +215,30 @@ export function RecentChatList() {
|
|||||||
<Share2 className="text-muted-foreground" />
|
<Share2 className="text-muted-foreground" />
|
||||||
<span>{t.common.share}</span>
|
<span>{t.common.share}</span>
|
||||||
</DropdownMenuItem>
|
</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 />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => handleDelete(thread.thread_id)}
|
onSelect={() => handleDelete(thread.thread_id)}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export const enUS: Translations = {
|
|||||||
save: "Save",
|
save: "Save",
|
||||||
install: "Install",
|
install: "Install",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
|
export: "Export",
|
||||||
|
exportAsMarkdown: "Export as Markdown",
|
||||||
|
exportAsJSON: "Export as JSON",
|
||||||
|
exportSuccess: "Conversation exported",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Welcome
|
// Welcome
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface Translations {
|
|||||||
save: string;
|
save: string;
|
||||||
install: string;
|
install: string;
|
||||||
create: string;
|
create: string;
|
||||||
|
export: string;
|
||||||
|
exportAsMarkdown: string;
|
||||||
|
exportAsJSON: string;
|
||||||
|
exportSuccess: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Welcome
|
// Welcome
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export const zhCN: Translations = {
|
|||||||
save: "保存",
|
save: "保存",
|
||||||
install: "安装",
|
install: "安装",
|
||||||
create: "创建",
|
create: "创建",
|
||||||
|
export: "导出",
|
||||||
|
exportAsMarkdown: "导出为 Markdown",
|
||||||
|
exportAsJSON: "导出为 JSON",
|
||||||
|
exportSuccess: "对话已导出",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Welcome
|
// Welcome
|
||||||
|
|||||||
142
frontend/src/core/threads/export.ts
Normal file
142
frontend/src/core/threads/export.ts
Normal 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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user