Skip to content

Commit 46160a9

Browse files
committed
feat: enhance conversation export functionality
1 parent de3174d commit 46160a9

9 files changed

Lines changed: 74 additions & 39 deletions

File tree

frontend/features/admin/api/settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ function contentDispositionFileName(value: string | null): string | null {
140140

141141
export async function exportAllConversations(accessToken: string): Promise<AdminConversationExportFile> {
142142
const response = await authedFetch("/api/v1/admin/conversations/export", { accessToken });
143+
if (!response.ok) {
144+
throw new Error(`export failed: ${response.status}`);
145+
}
143146
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
144147
return {
145148
blob: await response.blob(),

frontend/features/admin/components/sections/conversation/admin-conversation.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "@/components/ui/dialog";
2222
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2323
import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
24+
import { downloadBlob, readExportManifest } from "@/shared/lib/export-download";
2425
import {
2526
SettingsFieldInset,
2627
SettingsFieldItem,
@@ -387,15 +388,13 @@ export function AdminConversationSettingsPage() {
387388
const token = await resolveAccessToken();
388389
if (!token) return;
389390
const { blob, fileName } = await exportAllConversations(token);
390-
const url = URL.createObjectURL(blob);
391-
const link = document.createElement("a");
392-
link.href = url;
393-
link.download = fileName;
394-
link.rel = "noopener";
395-
document.body.appendChild(link);
396-
link.click();
397-
link.remove();
398-
URL.revokeObjectURL(url);
391+
const manifest = await readExportManifest(blob);
392+
downloadBlob(blob, fileName);
393+
if (manifest && (!manifest.complete || (manifest.failed ?? 0) > 0)) {
394+
toast.warning(t("dataExport.partial", { exported: manifest.exported ?? 0, failed: manifest.failed ?? 0 }));
395+
} else if (manifest) {
396+
toast.success(t("dataExport.success", { count: manifest.exported ?? 0 }));
397+
}
399398
} catch {
400399
toast.error(t("dataExport.failed"));
401400
} finally {

frontend/features/recent/components/sections/recent-toolbar.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import * as React from "react";
4-
import { Archive, Download, Link2Off, Trash, X } from "lucide-react";
4+
import { Archive, Download, Link2Off, SquareMousePointer, Trash, X } from "lucide-react";
55
import { useTranslations } from "next-intl";
66

77
import { RecentFilterGroup } from "@/features/recent/components/sections/recent-filter-group";
@@ -179,10 +179,12 @@ export function RecentToolbar({
179179
<span className="hidden md:inline">{t("allConversationsDescription")}</span>
180180
<button
181181
type="button"
182-
className="shrink-0 underline underline-offset-4 transition-colors hover:text-foreground"
182+
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground"
183183
onClick={onEnterSelectionMode}
184+
aria-label={t("enterSelection")}
185+
title={t("enterSelection")}
184186
>
185-
{t("select")}
187+
<SquareMousePointer className="size-4" strokeWidth={1.4} />
186188
</button>
187189
<button
188190
type="button"

frontend/features/recent/hooks/use-recent-page.ts

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useSidebarRecents } from "@/features/recent/context/sidebar-recents-con
1111
import { useSettingsChatPreferences } from "@/features/settings/hooks/use-settings-chat-preferences";
1212
import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
1313
import { runBulkActionInChunks } from "@/shared/lib/bulk-action";
14+
import { downloadBlob, readExportManifest } from "@/shared/lib/export-download";
1415
import {
1516
exportConversation,
1617
exportAllConversations,
@@ -318,36 +319,15 @@ export function useRecentPage() {
318319
const token = await resolveAccessToken();
319320
if (!token) return;
320321
const blob = await exportAllConversations(token);
321-
const text = await blob.text();
322-
const lines = text.trimEnd().split("\n");
323-
const lastLine = lines[lines.length - 1];
324-
let manifest: { _type?: string; complete?: boolean; exported?: number; failed?: number } | null = null;
325-
try {
326-
const parsed = JSON.parse(lastLine) as Record<string, unknown>;
327-
if (parsed._type === "export_manifest") {
328-
manifest = parsed as typeof manifest;
329-
}
330-
} catch {
331-
// not valid JSON — treat as no manifest
332-
}
322+
const manifest = await readExportManifest(blob);
323+
downloadBlob(blob, `my-conversations-${new Date().toISOString().slice(0, 10)}.jsonl`);
333324

334-
const downloadBlob = new Blob([text], { type: "application/x-ndjson" });
335-
const url = URL.createObjectURL(downloadBlob);
336-
const link = document.createElement("a");
337-
link.href = url;
338-
link.download = `my-conversations-${new Date().toISOString().slice(0, 10)}.jsonl`;
339-
link.rel = "noopener";
340-
document.body.appendChild(link);
341-
link.click();
342-
link.remove();
343-
URL.revokeObjectURL(url);
344-
345-
if (manifest && !manifest.complete) {
346-
toast.warning(t("toast.exportAllPartial", { exported: manifest.exported ?? 0, failed: manifest.failed ?? 0 }));
347-
} else if (manifest && (manifest.failed ?? 0) > 0) {
325+
if (manifest && (!manifest.complete || (manifest.failed ?? 0) > 0)) {
348326
toast.warning(t("toast.exportAllPartial", { exported: manifest.exported ?? 0, failed: manifest.failed ?? 0 }));
327+
} else if (!manifest) {
328+
toast.success(t("toast.exportAllDownloaded"));
349329
} else {
350-
toast.success(t("toast.exportAllSuccess", { count: manifest?.exported ?? lines.length }));
330+
toast.success(t("toast.exportAllSuccess", { count: manifest.exported ?? 0 }));
351331
}
352332
} catch {
353333
toast.error(t("toast.exportAllFailed"));

frontend/i18n/messages/en-US/admin-conversation.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@
155155
"title": "All conversations",
156156
"description": "Export all conversations, messages, and metadata as a JSONL file.",
157157
"exportButton": "Export",
158+
"success": "Exported {count} conversations",
159+
"partial": "Exported {exported} conversations, {failed} failed",
158160
"failed": "Export failed. Please try again."
159161
}
160162
}

frontend/i18n/messages/en-US/recent.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"toast": {
7474
"exportAllSuccess": "Exported {count} conversations",
7575
"exportAllPartial": "Exported {exported} conversations, {failed} failed",
76+
"exportAllDownloaded": "Conversation export downloaded",
7677
"exportAllFailed": "Failed to export conversations"
7778
},
7879
"conversationCount": "{count}",

frontend/i18n/messages/zh-CN/admin-conversation.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@
155155
"title": "全部对话",
156156
"description": "导出所有对话、消息和元数据为 JSONL 文件。",
157157
"exportButton": "导出",
158+
"success": "已导出 {count} 个对话",
159+
"partial": "已导出 {exported} 个对话,{failed} 个失败",
158160
"failed": "导出失败,请重试。"
159161
}
160162
}

frontend/i18n/messages/zh-CN/recent.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"toast": {
7474
"exportAllSuccess": "已导出 {count} 个对话",
7575
"exportAllPartial": "已导出 {exported} 个对话,{failed} 个失败",
76+
"exportAllDownloaded": "对话导出文件已下载",
7677
"exportAllFailed": "导出对话失败"
7778
},
7879
"conversationCount": "{count} 个",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const EXPORT_MANIFEST_TAIL_BYTES = 1024 * 1024;
2+
3+
export type ExportManifest = {
4+
_type?: string;
5+
complete?: boolean;
6+
exported?: number;
7+
failed?: number;
8+
};
9+
10+
export async function readExportManifest(blob: Blob): Promise<ExportManifest | null> {
11+
if (blob.size <= 0) {
12+
return null;
13+
}
14+
const start = Math.max(0, blob.size - EXPORT_MANIFEST_TAIL_BYTES);
15+
const tail = await blob.slice(start).text();
16+
const lastLine = tail.trimEnd().split(/\r?\n/).at(-1);
17+
if (!lastLine) {
18+
return null;
19+
}
20+
try {
21+
const parsed = JSON.parse(lastLine) as unknown;
22+
if (!parsed || typeof parsed !== "object") {
23+
return null;
24+
}
25+
const manifest = parsed as ExportManifest;
26+
return manifest._type === "export_manifest" ? manifest : null;
27+
} catch {
28+
return null;
29+
}
30+
}
31+
32+
export function downloadBlob(blob: Blob, fileName: string): void {
33+
const url = URL.createObjectURL(blob);
34+
const link = document.createElement("a");
35+
link.href = url;
36+
link.download = fileName;
37+
link.rel = "noopener";
38+
document.body.appendChild(link);
39+
try {
40+
link.click();
41+
} finally {
42+
link.remove();
43+
window.setTimeout(() => URL.revokeObjectURL(url), 0);
44+
}
45+
}

0 commit comments

Comments
 (0)