Skip to content

Commit 9878f7a

Browse files
GenerQAQclaude
andauthored
feat: add download messages button with format selection (#423)
Add a dropdown Download button on the messages page that allows exporting all session messages in different API formats (OpenAI, Anthropic, Gemini, Acontext). Messages are fetched in full via cursor-based pagination and downloaded as JSON. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 48b420c commit 9878f7a

7 files changed

Lines changed: 225 additions & 5 deletions

File tree

dashboard/app/project/[id]/session/[sessionId]/messages/messages-page-client.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ import {
2929
SelectTrigger,
3030
SelectValue,
3131
} from "@/components/ui/select";
32+
import {
33+
DropdownMenu,
34+
DropdownMenuContent,
35+
DropdownMenuItem,
36+
DropdownMenuTrigger,
37+
} from "@/components/ui/dropdown-menu";
3238
import {
3339
Loader2,
3440
Plus,
@@ -49,9 +55,10 @@ import {
4955
HardDrive,
5056
StickyNote,
5157
Flag,
58+
Download,
5259
} from "lucide-react";
5360
import { Project, Message, SessionEvent, TimelineItem, Part } from "@/types";
54-
import { getMessages, sendMessage, getSessionConfigs } from "../../actions";
61+
import { getMessages, sendMessage, getSessionConfigs, downloadMessages } from "../../actions";
5562
import { toast } from "sonner";
5663
import {
5764
generateTempId,
@@ -465,6 +472,28 @@ export function MessagesPageClient({
465472
}
466473
};
467474

475+
const [isDownloading, setIsDownloading] = useState(false);
476+
477+
const handleDownload = async (format: "acontext" | "openai" | "anthropic" | "gemini") => {
478+
setIsDownloading(true);
479+
try {
480+
const data = await downloadMessages(project.id, sessionId, format);
481+
const blob = new Blob([data], { type: "application/json" });
482+
const url = URL.createObjectURL(blob);
483+
const a = document.createElement("a");
484+
a.href = url;
485+
a.download = `messages-${sessionId}-${format}.json`;
486+
a.click();
487+
URL.revokeObjectURL(url);
488+
toast.success(`Downloaded messages in ${format} format`);
489+
} catch (error) {
490+
console.error("Failed to download messages:", error);
491+
toast.error("Failed to download messages");
492+
} finally {
493+
setIsDownloading(false);
494+
}
495+
};
496+
468497
const handleGoBack = () => {
469498
const encodedProjectId = encodeId(project.id);
470499
router.push(`/project/${encodedProjectId}/session`);
@@ -499,6 +528,35 @@ export function MessagesPageClient({
499528
<Plus className="h-4 w-4" />
500529
Create Message
501530
</Button>
531+
<DropdownMenu>
532+
<DropdownMenuTrigger asChild>
533+
<Button
534+
variant="outline"
535+
disabled={isLoadingMessages || isDownloading || allMessages.length === 0}
536+
>
537+
{isDownloading ? (
538+
<Loader2 className="h-4 w-4 animate-spin" />
539+
) : (
540+
<Download className="h-4 w-4" />
541+
)}
542+
Download
543+
</Button>
544+
</DropdownMenuTrigger>
545+
<DropdownMenuContent align="end">
546+
<DropdownMenuItem onClick={() => handleDownload("openai")}>
547+
OpenAI
548+
</DropdownMenuItem>
549+
<DropdownMenuItem onClick={() => handleDownload("anthropic")}>
550+
Anthropic
551+
</DropdownMenuItem>
552+
<DropdownMenuItem onClick={() => handleDownload("gemini")}>
553+
Gemini
554+
</DropdownMenuItem>
555+
<DropdownMenuItem onClick={() => handleDownload("acontext")}>
556+
Acontext
557+
</DropdownMenuItem>
558+
</DropdownMenuContent>
559+
</DropdownMenu>
502560
<Button
503561
variant="outline"
504562
onClick={handleRefreshMessages}

dashboard/app/project/[id]/session/actions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,21 @@ export async function getMessages(
9696
}
9797
}
9898

99+
export async function downloadMessages(
100+
projectId: string,
101+
sessionId: string,
102+
format: "acontext" | "openai" | "anthropic" | "gemini"
103+
): Promise<string> {
104+
try {
105+
const client = new AcontextClient();
106+
const data = await client.downloadMessages(projectId, sessionId, format);
107+
return JSON.stringify(data, null, 2);
108+
} catch (error) {
109+
console.error("Failed to download messages:", error);
110+
throw error;
111+
}
112+
}
113+
99114
export async function sendMessage(
100115
projectId: string,
101116
sessionId: string,

dashboard/lib/acontext/operations/message.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,42 @@ export function MessageOperations<T extends Constructor<BaseClient>>(Base: T) {
9797
};
9898
}
9999

100+
async downloadMessages(
101+
projectId: string,
102+
sessionId: string,
103+
format: "acontext" | "openai" | "anthropic" | "gemini"
104+
): Promise<unknown> {
105+
let allItems: unknown[] = [];
106+
let cursor: string | undefined;
107+
let hasMore = true;
108+
109+
while (hasMore) {
110+
const params = new URLSearchParams({
111+
limit: "100",
112+
format,
113+
});
114+
if (cursor) {
115+
params.append("cursor", cursor);
116+
}
117+
118+
const result = await this.request<{
119+
items?: unknown[];
120+
messages?: unknown[];
121+
next_cursor?: string;
122+
has_more?: boolean;
123+
}>(`/api/v1/session/${sessionId}/messages?${params.toString()}`, {
124+
projectId,
125+
});
126+
127+
const items = result.items || result.messages || [];
128+
allItems = allItems.concat(items);
129+
cursor = result.next_cursor;
130+
hasMore = result.has_more || false;
131+
}
132+
133+
return allItems;
134+
}
135+
100136
async sendMessage(
101137
projectId: string,
102138
sessionId: string,

src/server/ui/app/session/[sessionId]/messages/page.tsx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,15 @@ import {
3636
SelectTrigger,
3737
SelectValue,
3838
} from "@/components/ui/select";
39-
import { Loader2, Plus, RefreshCw, Upload, X, ArrowLeft, FileText, Image as ImageIcon, Video, Music, File, Code, CheckCircle2, ExternalLink, Brain, ShieldOff, HardDrive, StickyNote, Flag } from "lucide-react";
39+
import {
40+
DropdownMenu,
41+
DropdownMenuContent,
42+
DropdownMenuItem,
43+
DropdownMenuTrigger,
44+
} from "@/components/ui/dropdown-menu";
45+
import { Loader2, Plus, RefreshCw, Upload, X, ArrowLeft, FileText, Image as ImageIcon, Video, Music, File, Code, CheckCircle2, ExternalLink, Brain, ShieldOff, HardDrive, StickyNote, Flag, Download } from "lucide-react";
4046
import Image from "next/image";
41-
import { getMessages, storeMessage, getSessionConfigs } from "@/app/session/actions";
47+
import { getMessages, storeMessage, getSessionConfigs, downloadMessages } from "@/app/session/actions";
4248
import {
4349
Message,
4450
SessionEvent,
@@ -470,6 +476,32 @@ export default function MessagesPage() {
470476
}
471477
};
472478

479+
const [isDownloading, setIsDownloading] = useState(false);
480+
481+
const handleDownload = async (format: "acontext" | "openai" | "anthropic" | "gemini") => {
482+
setIsDownloading(true);
483+
try {
484+
const result = await downloadMessages(sessionId, format);
485+
if (!result.success || !result.data) {
486+
toast.error(t("downloadFailed"));
487+
return;
488+
}
489+
const blob = new Blob([result.data], { type: "application/json" });
490+
const url = URL.createObjectURL(blob);
491+
const a = document.createElement("a");
492+
a.href = url;
493+
a.download = `messages-${sessionId}-${format}.json`;
494+
a.click();
495+
URL.revokeObjectURL(url);
496+
toast.success(t("downloadSuccess"));
497+
} catch (error) {
498+
console.error("Failed to download messages:", error);
499+
toast.error(t("downloadFailed"));
500+
} finally {
501+
setIsDownloading(false);
502+
}
503+
};
504+
473505
const handleGoBack = () => {
474506
router.push("/session");
475507
};
@@ -504,6 +536,35 @@ export default function MessagesPage() {
504536
<Plus className="h-4 w-4" />
505537
{t("createMessage")}
506538
</Button>
539+
<DropdownMenu>
540+
<DropdownMenuTrigger asChild>
541+
<Button
542+
variant="outline"
543+
disabled={isLoadingMessages || isDownloading || allMessages.length === 0}
544+
>
545+
{isDownloading ? (
546+
<Loader2 className="h-4 w-4 animate-spin" />
547+
) : (
548+
<Download className="h-4 w-4" />
549+
)}
550+
{t("download")}
551+
</Button>
552+
</DropdownMenuTrigger>
553+
<DropdownMenuContent align="end">
554+
<DropdownMenuItem onClick={() => handleDownload("openai")}>
555+
OpenAI
556+
</DropdownMenuItem>
557+
<DropdownMenuItem onClick={() => handleDownload("anthropic")}>
558+
Anthropic
559+
</DropdownMenuItem>
560+
<DropdownMenuItem onClick={() => handleDownload("gemini")}>
561+
Gemini
562+
</DropdownMenuItem>
563+
<DropdownMenuItem onClick={() => handleDownload("acontext")}>
564+
Acontext
565+
</DropdownMenuItem>
566+
</DropdownMenuContent>
567+
</DropdownMenu>
507568
<Button
508569
variant="outline"
509570
onClick={handleRefreshMessages}

src/server/ui/app/session/actions.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,50 @@ export async function getMessages(
171171
}
172172
}
173173

174+
export async function downloadMessages(
175+
session_id: string,
176+
format: "acontext" | "openai" | "anthropic" | "gemini"
177+
): Promise<ApiResponse<string>> {
178+
try {
179+
let allItems: unknown[] = [];
180+
let cursor: string | undefined;
181+
let hasMore = true;
182+
183+
while (hasMore) {
184+
const params = new URLSearchParams({
185+
limit: "100",
186+
format,
187+
});
188+
if (cursor) {
189+
params.append("cursor", cursor);
190+
}
191+
192+
const response = await fetch(
193+
`${API_SERVER_URL}/api/v1/session/${session_id}/messages?${params.toString()}`,
194+
{
195+
method: "GET",
196+
headers: getAuthHeaders(),
197+
}
198+
);
199+
200+
if (!response.ok) {
201+
const text = await response.text();
202+
return { success: false, error: text };
203+
}
204+
205+
const result = await response.json();
206+
const items = result.items || result.messages || [];
207+
allItems = allItems.concat(items);
208+
cursor = result.next_cursor;
209+
hasMore = result.has_more || false;
210+
}
211+
212+
return { success: true, data: JSON.stringify(allItems, null, 2) };
213+
} catch (error) {
214+
return handleError(error, "downloadMessages");
215+
}
216+
}
217+
174218
export async function storeMessage(
175219
session_id: string,
176220
role: MessageRole,

src/server/ui/messages/en.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,10 @@
213213
"eventPath": "Path",
214214
"eventNote": "Note",
215215
"eventText": "Text",
216-
"eventCreatedAt": "Event Created At"
216+
"eventCreatedAt": "Event Created At",
217+
"download": "Download",
218+
"downloadSuccess": "Messages downloaded successfully",
219+
"downloadFailed": "Failed to download messages"
217220
},
218221
"agentSkills": {
219222
"title": "Agent Skills",

src/server/ui/messages/zh.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,10 @@
213213
"eventPath": "路径",
214214
"eventNote": "备注",
215215
"eventText": "文本",
216-
"eventCreatedAt": "事件创建时间"
216+
"eventCreatedAt": "事件创建时间",
217+
"download": "下载",
218+
"downloadSuccess": "消息下载成功",
219+
"downloadFailed": "消息下载失败"
217220
},
218221
"agentSkills": {
219222
"title": "Agent Skills",

0 commit comments

Comments
 (0)