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

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