deerflow2/frontend/src/core/messages/utils.ts

463 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { AIMessage, Message } from "@langchain/langgraph-sdk";
interface GenericMessageGroup<T = string> {
type: T;
id: string | undefined;
messages: Message[];
}
interface HumanMessageGroup extends GenericMessageGroup<"human"> {}
interface AssistantProcessingGroup extends GenericMessageGroup<"assistant:processing"> {}
interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
| HumanMessageGroup
| AssistantProcessingGroup
| AssistantMessageGroup
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSubagentGroup;
const SUMMARY_MESSAGE_TITLES = [
"Here is a summary of the conversation to date",
"以下是目前对话的摘要",
];
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getSummaryTemplateTitle(content: string) {
return (
SUMMARY_MESSAGE_TITLES.find((title) => {
const titlePattern = new RegExp(
`^\\s*${escapeRegExp(title)}\\s*[:]?(?:\\n|$)`,
"i",
);
return titlePattern.test(content);
}) ?? null
);
}
export function isSummaryTemplateMessage(message: Message) {
if (message.type !== "human") {
return false;
}
return getSummaryTemplateTitle(extractTextFromMessage(message)) !== null;
}
export function extractSummaryTemplateBody(message: Message) {
const content = extractTextFromMessage(message);
const title = getSummaryTemplateTitle(content);
if (!title) {
return content;
}
const titlePrefixPattern = new RegExp(
`^\\s*${escapeRegExp(title)}\\s*[:]?\\s*\\n*`,
"i",
);
return content.replace(titlePrefixPattern, "").trim();
}
export function groupMessages<T>(
messages: Message[],
mapper: (group: MessageGroup) => T,
): T[] {
if (messages.length === 0) {
return [];
}
const groups: MessageGroup[] = [];
// Returns the last group if it can still accept tool messages
// (i.e. it's an in-flight processing group, not a terminal human/assistant group).
function lastOpenGroup() {
const last = groups[groups.length - 1];
if (
last &&
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification"
) {
return last;
}
return null;
}
for (const message of messages) {
if (message.name === "todo_reminder") {
continue;
}
if (message.type === "human") {
// if (isSummaryTemplateMessage(message)) {
// continue;
// }
groups.push({ id: message.id, type: "human", messages: [message] });
continue;
}
if (message.type === "tool") {
if (isClarificationToolMessage(message)) {
// Add to the preceding processing group to preserve tool-call association,
// then also open a standalone clarification group for prominent display.
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:clarification",
messages: [message],
});
} else {
const open = lastOpenGroup();
if (open) {
open.messages.push(message);
} else {
console.error(
"Unexpected tool message outside a processing group",
message,
);
}
}
continue;
}
if (message.type === "ai") {
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
type: "assistant:present-files",
messages: [message],
});
} else if (hasSubagent(message)) {
groups.push({
id: message.id,
type: "assistant:subagent",
messages: [message],
});
} else if (hasReasoning(message) || hasToolCalls(message)) {
const lastGroup = groups[groups.length - 1];
// Accumulate consecutive intermediate AI messages into one processing group.
if (lastGroup?.type !== "assistant:processing") {
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
} else {
lastGroup.messages.push(message);
}
}
// Not an else-if: a message with reasoning + content (but no tool calls) goes
// into the processing group above AND gets its own assistant bubble here.
if (hasContent(message) && !hasToolCalls(message)) {
groups.push({ id: message.id, type: "assistant", messages: [message] });
}
}
}
return groups
.map(mapper)
.filter((result) => result !== undefined && result !== null) as T[];
}
export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") {
return (
splitInlineReasoningFromAIMessage(message)?.content ??
message.content.trim()
);
}
if (Array.isArray(message.content)) {
return message.content
.map((content) => (content.type === "text" ? content.text : ""))
.join("\n")
.trim();
}
return "";
}
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
function splitInlineReasoning(content: string) {
const reasoningParts: string[] = [];
const cleaned = content
.replace(THINK_TAG_RE, (_, reasoning: string) => {
const normalized = reasoning.trim();
if (normalized) {
reasoningParts.push(normalized);
}
return "";
})
.trim();
return {
content: cleaned,
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
};
}
function splitInlineReasoningFromAIMessage(message: Message) {
if (message.type !== "ai" || typeof message.content !== "string") {
return null;
}
return splitInlineReasoning(message.content);
}
export function extractContentFromMessage(message: Message) {
if (typeof message.content === "string") {
return (
splitInlineReasoningFromAIMessage(message)?.content ??
message.content.trim()
);
}
if (Array.isArray(message.content)) {
return message.content
.map((content) => {
switch (content.type) {
case "text":
return content.text;
case "image_url":
const imageURL = extractURLFromImageURLContent(content.image_url);
return `![image](${imageURL})`;
default:
return "";
}
})
.join("\n")
.trim();
}
return "";
}
export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") {
return null;
}
if (
message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs
) {
return message.additional_kwargs.reasoning_content as string | null;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
if (part && "thinking" in part) {
return part.thinking as string;
}
}
if (typeof message.content === "string") {
return splitInlineReasoning(message.content).reasoning;
}
return null;
}
export function removeReasoningContentFromMessage(message: Message) {
if (message.type !== "ai" || !message.additional_kwargs) {
return;
}
delete message.additional_kwargs.reasoning_content;
}
export function extractURLFromImageURLContent(
content:
| string
| {
url: string;
},
) {
if (typeof content === "string") {
return content;
}
return content.url;
}
export function hasContent(message: Message) {
if (typeof message.content === "string") {
return (
(
splitInlineReasoningFromAIMessage(message)?.content ??
message.content.trim()
).length > 0
);
}
if (Array.isArray(message.content)) {
return message.content.length > 0;
}
return false;
}
export function hasReasoning(message: Message) {
if (message.type !== "ai") {
return false;
}
if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
// Compatible with the Anthropic gateway
return (part as unknown as { type: "thinking" })?.type === "thinking";
}
if (typeof message.content === "string") {
return splitInlineReasoning(message.content).reasoning !== null;
}
return false;
}
export function hasToolCalls(message: Message) {
return (
message.type === "ai" && message.tool_calls && message.tool_calls.length > 0
);
}
export function hasPresentFiles(message: Message) {
return (
message.type === "ai" &&
message.tool_calls?.some((toolCall) => toolCall.name === "present_files")
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
}
const files: string[] = [];
for (const toolCall of message.tool_calls ?? []) {
if (
toolCall.name === "present_files" &&
Array.isArray(toolCall.args.filepaths)
) {
files.push(...(toolCall.args.filepaths as string[]));
}
}
return files;
}
export function hasSubagent(message: AIMessage) {
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.name === "task") {
return true;
}
}
return false;
}
export function findToolCallResult(toolCallId: string, messages: Message[]) {
for (const message of messages) {
if (message.type === "tool" && message.tool_call_id === toolCallId) {
const content = extractTextFromMessage(message);
if (content) {
return content;
}
}
}
return undefined;
}
/**
* Represents a file stored in message additional_kwargs.files.
* Used for optimistic UI (uploading state) and structured file metadata.
*/
export interface FileInMessage {
filename: string;
size: number; // bytes
path?: string; // virtual path, may not be set during upload
status?: "uploading" | "uploaded";
ref_kind?: "mention";
ref_source?: "artifact" | "upload";
}
/**
* Strip internal file-context tags from message content.
* Returns the content with these tags removed:
* - <uploaded_files>...</uploaded_files>
* - <mentioned_files>...</mentioned_files>
* - <sent_files_semantics>...</sent_files_semantics>
*/
export function stripUploadedFilesTag(content: string): string {
return content
.replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>/g, "")
.replace(/<mentioned_files>[\s\S]*?<\/mentioned_files>/g, "")
.replace(/<sent_files_semantics>[\s\S]*?<\/sent_files_semantics>/g, "")
.trim();
}
/**
* Strip the appended priority-hint suffix from a message content.
* Suffix format:
* - XClaw优先使用【附件...】
* - XClaw优先使用【Skill...】
* - XClaw优先使用【附件...】和【Skill...】
*/
export function stripPriorityHintSuffix(content: string): string {
return content
.replace(/\n?XClaw使[^]+(?:[^]+)?\s*$/u, "")
.trim();
}
/**
* Normalize human-authored message text for markdown rendering.
* - Decode literal "\n" into real line breaks.
* - Split Chinese-numbered items (e.g. "1...") into separate paragraphs.
*/
export function normalizeHumanMessageDisplayText(content: string): string {
return content
.replace(/\\n/g, "\n")
.replace(/\r\n?/g, "\n")
.replace(/\n(?=\d+[)]\s*)/g, "\n\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function parseUploadedFiles(content: string): FileInMessage[] {
// Match <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
const match = content.match(uploadedFilesRegex);
if (!match) {
return [];
}
const uploadedFilesContent = match[1];
// Check if it's "No files have been uploaded yet."
if (uploadedFilesContent?.includes("No files have been uploaded yet.")) {
return [];
}
// Check if the backend reported no new files were uploaded in this message
if (uploadedFilesContent?.includes("(empty)")) {
return [];
}
// Parse file list
// Format: - filename (size)\n Path: /path/to/file
const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g;
const files: FileInMessage[] = [];
let fileMatch;
while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) {
files.push({
filename: fileMatch[1].trim(),
size: parseInt(fileMatch[2].trim(), 10) ?? 0,
path: fileMatch[3].trim(),
});
}
return files;
}