mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(frontend): export complete persisted conversation history (#6530)
* fix(frontend): export complete persisted conversation history * fix(frontend): preserve canonical export order across runs --------- Co-authored-by: stanGSA <225717802+stanGSA@users.noreply.github.com>
This commit is contained in:
parent
f5156b6762
commit
f71999d99c
@ -802,6 +802,11 @@ Signed-in users' notification toggle, default model, conversation mode, and reas
|
||||
In a new chat, the submitted question stays above its streamed reasoning and
|
||||
tool steps while the server creates the conversation and confirms the message.
|
||||
|
||||
Markdown and JSON conversation exports from the chat header or sidebar read all
|
||||
persisted history pages, including earlier turns outside the loaded view or
|
||||
compacted model context. A failed history read stops the export instead of
|
||||
downloading a partial transcript. Public demos export their bundled messages.
|
||||
|
||||
Capability Center groups plugins by office collaboration, documents and knowledge, search and research, business and data, and development and operations. The directory includes setup references alongside existing MCP configurations and Lark. Recommended integrations and built-in support do not imply an installed or verified connection; the Installed filter shows configured MCP entries and installed Lark only.
|
||||
|
||||
Personal MCP connections configured in the web interface are persisted per user.
|
||||
|
||||
@ -170,6 +170,12 @@ Array previews coalesce consecutive generated markers only at the end into one o
|
||||
|
||||
### Key Patterns
|
||||
|
||||
Conversation exports read every persisted message page through
|
||||
`fetchThreadExportMessages` (200 rows/page). Run `mergeMessages` after history
|
||||
deduplication to restore earliest trusted positions for identities repeated across runs.
|
||||
Never export only the loaded UI window or compacted checkpoint. Fail the whole
|
||||
export on a page error or non-advancing cursor; demos keep their fixture path.
|
||||
|
||||
- **Server Components by default**, `"use client"` only for interactive components
|
||||
- **Static root boundary** — `src/app/layout.tsx` must not read cookies or import
|
||||
chat-only KaTeX/Streamdown styles. Auth and workspace layouts own the cookie-derived
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Download, FileJson, FileText } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -12,7 +12,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
||||
import { exportThread, type ThreadExportFormat } from "@/core/threads/export";
|
||||
import { fetchThreadExportMessages } from "@/core/threads/hooks";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
import { useThread } from "./messages/context";
|
||||
@ -20,30 +22,43 @@ import { Tooltip } from "./tooltip";
|
||||
|
||||
export function ExportTrigger({ threadId }: { threadId: string }) {
|
||||
const { t } = useI18n();
|
||||
const { thread } = useThread();
|
||||
const { thread, isMock } = useThread();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const messages = thread.messages;
|
||||
|
||||
const handleExport = useCallback(
|
||||
(format: ThreadExportFormat) => {
|
||||
async (format: ThreadExportFormat) => {
|
||||
if (messages.length === 0) {
|
||||
toast.error(t.conversation.noMessages);
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
const exportMessages =
|
||||
isMock || isStaticWebsiteOnly()
|
||||
? messages
|
||||
: await fetchThreadExportMessages(threadId);
|
||||
if (exportMessages.length === 0) {
|
||||
toast.error(t.conversation.noMessages);
|
||||
return;
|
||||
}
|
||||
const agentThread = {
|
||||
thread_id: threadId,
|
||||
updated_at: new Date().toISOString(),
|
||||
values: thread.values,
|
||||
} as AgentThread;
|
||||
|
||||
exportThread(agentThread, messages, format);
|
||||
exportThread(agentThread, exportMessages, format);
|
||||
toast.success(t.common.exportSuccess);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t.common.exportFailed);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
},
|
||||
[messages, thread.values, threadId, t],
|
||||
[messages, thread.values, threadId, isMock, t],
|
||||
);
|
||||
|
||||
if (messages.length === 0) {
|
||||
@ -56,6 +71,7 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={t.common.export}
|
||||
disabled={exporting || thread.isLoading}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
>
|
||||
|
||||
@ -56,6 +56,7 @@ import { useLocalSettings } from "@/core/settings";
|
||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
||||
import { exportThread, type ThreadExportFormat } from "@/core/threads/export";
|
||||
import {
|
||||
fetchThreadExportMessages,
|
||||
useInfiniteThreads,
|
||||
useMoveThreadToProject,
|
||||
usePinThread,
|
||||
@ -137,6 +138,7 @@ export function ThreadSidebarItem({
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [newProjectDialogOpen, setNewProjectDialogOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleRenameSubmit = useCallback(() => {
|
||||
if (renameValue.trim()) {
|
||||
@ -199,20 +201,26 @@ export function ThreadSidebarItem({
|
||||
|
||||
const handleExport = useCallback(
|
||||
async (format: ThreadExportFormat) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const apiClient = getAPIClient();
|
||||
const state = await apiClient.threads.getState<AgentThreadState>(
|
||||
thread.thread_id,
|
||||
);
|
||||
const messages = state.values?.messages ?? [];
|
||||
const messages = isStaticWebsiteOnly()
|
||||
? ((
|
||||
await getAPIClient().threads.getState<AgentThreadState>(
|
||||
thread.thread_id,
|
||||
)
|
||||
).values?.messages ?? [])
|
||||
: await fetchThreadExportMessages(thread.thread_id);
|
||||
if (messages.length === 0) {
|
||||
toast.error(t.conversation.noMessages);
|
||||
return;
|
||||
}
|
||||
exportThread(thread, messages, format);
|
||||
toast.success(t.common.exportSuccess);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t.common.exportFailed);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
},
|
||||
[t, thread],
|
||||
@ -326,7 +334,7 @@ export function ThreadSidebarItem({
|
||||
<span>{t.common.share}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubTrigger disabled={exporting}>
|
||||
<Download className="text-muted-foreground" />
|
||||
<span>{t.common.export}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
@ -628,6 +628,7 @@ export function buildThreadMessagesPageUrl(
|
||||
baseUrl: string,
|
||||
threadId: string,
|
||||
beforeSeq?: number,
|
||||
limit?: number,
|
||||
) {
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
|
||||
const path = `/api/threads/${encodeURIComponent(threadId)}/messages/page`;
|
||||
@ -638,6 +639,7 @@ export function buildThreadMessagesPageUrl(
|
||||
if (beforeSeq !== undefined) {
|
||||
url.searchParams.set("before_seq", String(beforeSeq));
|
||||
}
|
||||
if (limit !== undefined) url.searchParams.set("limit", String(limit));
|
||||
return normalizedBaseUrl ? url.toString() : `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
@ -652,6 +654,40 @@ export function flattenThreadHistoryPages(
|
||||
);
|
||||
}
|
||||
|
||||
/** Read persisted history independently of the loaded UI and compacted state. */
|
||||
export async function fetchThreadExportMessages(threadId: string) {
|
||||
const pages: ThreadMessagesPageResponse[] = [];
|
||||
let beforeSeq: number | undefined;
|
||||
do {
|
||||
const response = await fetch(
|
||||
buildThreadMessagesPageUrl(getBackendBaseURL(), threadId, beforeSeq, 200),
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load conversation for export.");
|
||||
}
|
||||
const page = parseThreadMessagesPageResponse(await response.json());
|
||||
const next = getThreadHistoryNextPageParam(page);
|
||||
if (
|
||||
next !== undefined &&
|
||||
(page.data.length === 0 ||
|
||||
next !== page.data[0]?.seq ||
|
||||
(beforeSeq !== undefined && next >= beforeSeq))
|
||||
) {
|
||||
throw new Error("Thread export history cursor did not advance.");
|
||||
}
|
||||
pages.push(page);
|
||||
beforeSeq = next;
|
||||
} while (beforeSeq !== undefined);
|
||||
return mergeMessages(
|
||||
buildVisibleHistoryMessages(
|
||||
flattenThreadHistoryPages(pages),
|
||||
new Set<string>(),
|
||||
),
|
||||
[],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve rows that this client has already loaded while newest-first cursor
|
||||
* pages move forward during a long run.
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const mocks = rs.hoisted(() => ({
|
||||
fetch: rs.fn(),
|
||||
exportThread: rs.fn<typeof exportThread>(),
|
||||
isMock: false,
|
||||
}));
|
||||
rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
|
||||
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
|
||||
rs.mock("@/core/threads/export", () => ({ exportThread: mocks.exportThread }));
|
||||
rs.mock("sonner", () => ({ toast: { success: rs.fn(), error: rs.fn() } }));
|
||||
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
|
||||
rs.mock("@/components/workspace/tooltip", () => ({
|
||||
Tooltip: ({ children }: PropsWithChildren) => children,
|
||||
}));
|
||||
rs.mock("@/components/workspace/messages/context", () => ({
|
||||
useThread: () => ({
|
||||
isMock: mocks.isMock,
|
||||
thread: {
|
||||
messages: [latest],
|
||||
values: { title: "Long chat" },
|
||||
isLoading: false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
import type { exportThread } from "@/core/threads/export";
|
||||
|
||||
const earliest = { id: "first", type: "human", content: "Original question" };
|
||||
const latest = { id: "last", type: "ai", content: "Final answer" };
|
||||
function page(messages: (typeof latest)[], start: number, next: number | null) {
|
||||
return Response.json({
|
||||
data: messages.map((content, i) => ({
|
||||
content,
|
||||
seq: start + i,
|
||||
run_id: `run-${start}`,
|
||||
})),
|
||||
has_more: next !== null,
|
||||
next_before_seq: next,
|
||||
});
|
||||
}
|
||||
async function selectExport(format = enUS.common.exportAsJSON) {
|
||||
render(<ExportTrigger threadId="chat" />);
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: enUS.common.export }), {
|
||||
key: "Enter",
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: format }));
|
||||
}
|
||||
beforeEach(() => {
|
||||
mocks.isMock = false;
|
||||
rs.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
rs.resetAllMocks();
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
test.each([enUS.common.exportAsJSON, enUS.common.exportAsMarkdown])(
|
||||
"keeps a repeated human identity before both runs' answers for %s",
|
||||
async (format) => {
|
||||
mocks.fetch
|
||||
.mockResolvedValueOnce(
|
||||
page([{ ...earliest, content: "Updated question" }, latest], 100, 100),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
page([earliest, { ...latest, id: "answer-1" }], 1, null),
|
||||
);
|
||||
await selectExport(format);
|
||||
await waitFor(() => expect(mocks.exportThread).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.exportThread.mock.calls[0]?.[1].map((m) => m.id)).toEqual([
|
||||
"first",
|
||||
"answer-1",
|
||||
"last",
|
||||
]);
|
||||
expect(mocks.exportThread.mock.calls[0]?.[1][0]?.content).toBe(
|
||||
"Updated question",
|
||||
);
|
||||
expect(mocks.fetch.mock.calls[1]?.[0]).toContain("before_seq=100");
|
||||
for (const [url] of mocks.fetch.mock.calls) {
|
||||
expect(new URL(url, "http://localhost").searchParams.get("limit")).toBe(
|
||||
"200",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("does not download a partial transcript when an older page fails", async () => {
|
||||
mocks.fetch
|
||||
.mockResolvedValueOnce(page([latest], 100, 100))
|
||||
.mockResolvedValueOnce(new Response("Unavailable", { status: 503 }));
|
||||
await selectExport();
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(enUS.common.exportFailed),
|
||||
);
|
||||
expect(mocks.exportThread).not.toHaveBeenCalled();
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(console.error).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
test("rejects a repeated cursor instead of looping forever", async () => {
|
||||
mocks.fetch.mockImplementation(async () => page([latest], 100, 100));
|
||||
await selectExport();
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(enUS.common.exportFailed),
|
||||
);
|
||||
expect(mocks.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.exportThread).not.toHaveBeenCalled();
|
||||
expect(console.error).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
test("keeps public demo export local", async () => {
|
||||
mocks.isMock = true;
|
||||
await selectExport();
|
||||
await waitFor(() => expect(mocks.exportThread).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.exportThread.mock.calls[0]?.[1]).toEqual([latest]);
|
||||
expect(mocks.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("disables export until history loading finishes", async () => {
|
||||
let finish!: (response: Response) => void;
|
||||
mocks.fetch.mockReturnValue(
|
||||
new Promise<Response>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
await selectExport();
|
||||
const button = screen.getByRole("button", {
|
||||
name: enUS.common.export,
|
||||
});
|
||||
expect(button.hasAttribute("disabled")).toBe(true);
|
||||
expect(mocks.exportThread).not.toHaveBeenCalled();
|
||||
finish(page([latest], 100, null));
|
||||
await waitFor(() => expect(button.hasAttribute("disabled")).toBe(false));
|
||||
expect(mocks.exportThread).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user