mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-23 00:16:48 +00:00
258ca800fe
Address @ShenAC-SAC's BUG-006 review and the Copilot inline comment on #3131. The previous cut filtered hidden/tool messages out of the JSON export but still serialised `msg.content` verbatim, so: - inline `<think>…</think>` wrappers stayed in the exported `content` even with `includeReasoning: false`, - content-array thinking blocks leaked the `thinking` field, - `<uploaded_files>…</uploaded_files>` markers leaked the workspace paths a user uploaded files to. JSON now goes through the same sanitiser the Markdown path uses (`extractContentFromMessage` + `stripUploadedFilesTag`). Reasoning and tool_calls remain gated behind their `ExportOptions` flags. AI / human rows that sanitise to empty content with no opted-in reasoning or tool calls are dropped so the JSON matches the Markdown path's `continue` on empty assistant fragments. New regression tests cover the three leak shapes the reviewer called out plus the empty-content-drop case. Refs: bytedance/deer-flow#3107 (BUG-006), bytedance/deer-flow#3131 review
221 lines
6.2 KiB
TypeScript
221 lines
6.2 KiB
TypeScript
import type { Message } from "@langchain/langgraph-sdk";
|
|
|
|
import {
|
|
extractContentFromMessage,
|
|
extractReasoningContentFromMessage,
|
|
hasContent,
|
|
hasToolCalls,
|
|
isHiddenFromUIMessage,
|
|
stripUploadedFilesTag,
|
|
} from "../messages/utils";
|
|
|
|
import type { AgentThread } from "./types";
|
|
import { titleOfThread } from "./utils";
|
|
|
|
/**
|
|
* Optional debug switches for advanced exports.
|
|
*
|
|
* Bytedance/deer-flow issue #3107 BUG-006 explicitly prescribes that the
|
|
* default export includes only the user-visible transcript and excludes
|
|
* thinking/reasoning content, tool calls, tool results, hidden messages,
|
|
* memory injection, and `<system-reminder>` payloads. These options let a
|
|
* future "debug export" surface re-include any of those categories without
|
|
* forking the formatter. They are not currently wired to any UI control —
|
|
* callers that want them must construct the options object explicitly.
|
|
*/
|
|
export interface ExportOptions {
|
|
includeReasoning?: boolean;
|
|
includeToolCalls?: boolean;
|
|
includeToolMessages?: boolean;
|
|
includeHidden?: boolean;
|
|
}
|
|
|
|
function visibleMessages(
|
|
messages: Message[],
|
|
options: ExportOptions,
|
|
): Message[] {
|
|
return messages.filter((message) => {
|
|
if (!options.includeHidden && isHiddenFromUIMessage(message)) {
|
|
return false;
|
|
}
|
|
if (!options.includeToolMessages && message.type === "tool") {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
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[],
|
|
options: ExportOptions = {},
|
|
): 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 visibleMessages(messages, options)) {
|
|
if (message.type === "human") {
|
|
const content = formatMessageContent(message);
|
|
if (content) {
|
|
lines.push(`## 🧑 User`, "", content, "", "---", "");
|
|
}
|
|
} else if (message.type === "ai") {
|
|
const reasoning = options.includeReasoning
|
|
? extractReasoningContentFromMessage(message)
|
|
: undefined;
|
|
const content = formatMessageContent(message);
|
|
const toolCalls = options.includeToolCalls
|
|
? 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";
|
|
}
|
|
|
|
interface JSONExportMessage {
|
|
type: Message["type"];
|
|
id: string | undefined;
|
|
content: string;
|
|
reasoning?: string;
|
|
tool_calls?: unknown;
|
|
}
|
|
|
|
function buildJSONMessage(
|
|
msg: Message,
|
|
options: ExportOptions,
|
|
): JSONExportMessage | null {
|
|
// Run the same sanitiser the Markdown path uses so the JSON `content`
|
|
// field never carries inline `<think>...</think>` wrappers, content-array
|
|
// thinking blocks, `<uploaded_files>` markers, or other internal payloads.
|
|
const content = formatMessageContent(msg);
|
|
const reasoning =
|
|
options.includeReasoning && msg.type === "ai"
|
|
? (extractReasoningContentFromMessage(msg) ?? undefined)
|
|
: undefined;
|
|
const toolCalls =
|
|
options.includeToolCalls &&
|
|
msg.type === "ai" &&
|
|
"tool_calls" in msg &&
|
|
msg.tool_calls?.length
|
|
? msg.tool_calls
|
|
: undefined;
|
|
|
|
// Drop rows with no exportable payload (empty content + no opted-in
|
|
// reasoning / tool_calls). This matches the Markdown path's `continue`
|
|
// on `!content && !toolCalls && !reasoning` so the two formats agree on
|
|
// which AI fragments are visible to the user.
|
|
if (!content && reasoning === undefined && toolCalls === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
type: msg.type,
|
|
id: msg.id,
|
|
content,
|
|
...(reasoning !== undefined ? { reasoning } : {}),
|
|
...(toolCalls !== undefined ? { tool_calls: toolCalls } : {}),
|
|
};
|
|
}
|
|
|
|
export function formatThreadAsJSON(
|
|
thread: AgentThread,
|
|
messages: Message[],
|
|
options: ExportOptions = {},
|
|
): string {
|
|
const exportData = {
|
|
title: titleOfThread(thread),
|
|
thread_id: thread.thread_id,
|
|
created_at: thread.created_at,
|
|
exported_at: new Date().toISOString(),
|
|
messages: visibleMessages(messages, options)
|
|
.map((msg) => buildJSONMessage(msg, options))
|
|
.filter((m): m is JSONExportMessage => m !== null),
|
|
};
|
|
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");
|
|
}
|