import type { AIMessage, Message } from "@langchain/langgraph-sdk"; interface GenericMessageGroup { 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( 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 = /\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: * - ... * - ... * - ... */ export function stripUploadedFilesTag(content: string): string { return content .replace(/[\s\S]*?<\/uploaded_files>/g, "") .replace(/[\s\S]*?<\/mentioned_files>/g, "") .replace(/[\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 ... tag const uploadedFilesRegex = /([\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; }