diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 8c612e34..f3efa9c4 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -138,7 +138,7 @@ function MessageContent_({ if (!Array.isArray(files) || files.length === 0) { if (rawContent.includes("")) { // If the content contains the tag, we return the parsed files from the content for backward compatibility. - return parseUploadedFiles(rawContent).files as FileInMessage[]; + return parseUploadedFiles(rawContent); } return null; } diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 423d3f2e..57e05bbc 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -34,146 +34,100 @@ export function groupMessages( return []; } - // 预处理:收集所有 ToolMessage 的 tool_call_id - const toolMessageIds = new Set(); - for (const message of messages) { - if (message.type === "tool" && message.tool_call_id) { - toolMessageIds.add(message.tool_call_id); - } - } - - // 预处理:检查哪些 tool_calls 没有对应的响应 - const danglingToolCallIds = new Set(); - for (const message of messages) { - if (message.type === "ai" && message.tool_calls) { - for (const tc of message.tool_calls) { - const tcId = tc.id; - if (tcId && !toolMessageIds.has(tcId)) { - danglingToolCallIds.add(tcId); - } - } - } - } - - // 过滤掉只有悬空 tool_calls 且没有其他内容的 AI 消息 - const filteredMessages = messages.filter((message) => { - if (message.type === "ai" && hasToolCalls(message)) { - // 检查是否所有 tool_calls 都是悬空的 - const allDangling = message.tool_calls?.every((tc) => - danglingToolCallIds.has(tc.id!), - ); - // 如果全部悬空且没有其他内容,跳过该消息 - if (allDangling && !hasReasoning(message) && !hasContent(message)) { - console.warn("过滤只有悬空 tool_calls 的 AI 消息:", message.id); - return false; - } - } - return true; - }); - const groups: MessageGroup[] = []; - for (const message of filteredMessages) { - const lastGroup = groups[groups.length - 1]; + // 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") { - groups.push({ - id: message.id, - type: "human", - messages: [message], - }); - } else if (message.type === "tool") { - // 检查是否为澄清问题的工具消息 + groups.push({ id: message.id, type: "human", messages: [message] }); + continue; + } + + if (message.type === "tool") { if (isClarificationToolMessage(message)) { - // 如果有可用的处理组,添加到其中(保持工具调用关联) - if ( - lastGroup && - lastGroup.type !== "human" && - lastGroup.type !== "assistant" && - lastGroup.type !== "assistant:clarification" - ) { - lastGroup.messages.push(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 if ( - lastGroup && - lastGroup.type !== "human" && - lastGroup.type !== "assistant" && - lastGroup.type !== "assistant:clarification" - ) { - lastGroup.messages.push(message); } else { - // 悬空的工具消息(如生成被中断导致) - // 创建独立的处理组以便显示 - console.warn( - "检测到悬空的工具消息,创建独立组:", - message.tool_call_id, - ); + 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:processing", + type: "assistant:present-files", messages: [message], }); - } - } else if (message.type === "ai") { - if (hasReasoning(message) || hasToolCalls(message)) { - if (hasPresentFiles(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:present-files", - messages: [message], - }); - } else if (hasSubagent(message)) { - groups.push({ - id: message.id, - type: "assistant:subagent", + type: "assistant:processing", messages: [message], }); } else { - if (lastGroup?.type !== "assistant:processing") { - groups.push({ - id: message.id, - type: "assistant:processing", - messages: [], - }); - } - const currentGroup = groups[groups.length - 1]; - if (currentGroup?.type === "assistant:processing") { - currentGroup.messages.push(message); - } else { - throw new Error( - "带有推理或工具调用的 AI 消息必须位于处理组之后", - ); - } + 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], - }); + groups.push({ id: message.id, type: "assistant", messages: [message] }); } } } - const resultsOfGroups: T[] = []; - for (const group of groups) { - const resultOfGroup = mapper(group); - if (resultOfGroup !== undefined && resultOfGroup !== null) { - resultsOfGroups.push(resultOfGroup); - } - } - return resultsOfGroups; + return groups + .map(mapper) + .filter((result) => result !== undefined && result !== null) as T[]; } export function extractTextFromMessage(message: Message) { if (typeof message.content === "string") { - return message.content.trim(); + return splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim(); } if (Array.isArray(message.content)) { return message.content @@ -184,9 +138,36 @@ export function extractTextFromMessage(message: Message) { 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 message.content.trim(); + return splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim(); } if (Array.isArray(message.content)) { return message.content @@ -208,12 +189,24 @@ export function extractContentFromMessage(message: Message) { } export function extractReasoningContentFromMessage(message: Message) { - if (message.type !== "ai" || !message.additional_kwargs) { + if (message.type !== "ai") { return null; } - if ("reasoning_content" in message.additional_kwargs) { + 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; } @@ -239,7 +232,9 @@ export function extractURLFromImageURLContent( export function hasContent(message: Message) { if (typeof message.content === "string") { - return message.content.trim().length > 0; + return ( + splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim() + ).length > 0; } if (Array.isArray(message.content)) { return message.content.length > 0; @@ -248,10 +243,21 @@ export function hasContent(message: Message) { } export function hasReasoning(message: Message) { - return ( - message.type === "ai" && - typeof message.additional_kwargs?.reasoning_content === "string" - ); + 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) { @@ -309,71 +315,61 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) { } /** - * Represents an uploaded file parsed from the tag + * Represents a file stored in message additional_kwargs.files. + * Used for optimistic UI (uploading state) and structured file metadata. */ -export interface UploadedFile { - filename: string; - size: string; - path: string; -} - -// Compatibility type for newer UI modules. export interface FileInMessage { filename: string; - size: string | number; - path?: string; + size: number; // bytes + path?: string; // virtual path, may not be set during upload status?: "uploading" | "uploaded"; } /** - * Result of parsing uploaded files from message content + * Strip tag from message content. + * Returns the content with the tag removed. */ -export interface ParsedUploadedFiles { - files: UploadedFile[]; - cleanContent: string; +export function stripUploadedFilesTag(content: string): string { + return content + .replace(/[\s\S]*?<\/uploaded_files>/g, "") + .trim(); } -/** - * Parse tag from message content and extract file information. - * Returns the list of uploaded files and the content with the tag removed. - */ -export function parseUploadedFiles(content: string): ParsedUploadedFiles { +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 { files: [], cleanContent: content }; + return []; } const uploadedFilesContent = match[1]; - const cleanContent = content.replace(uploadedFilesRegex, "").trim(); // Check if it's "No files have been uploaded yet." if (uploadedFilesContent?.includes("No files have been uploaded yet.")) { - return { files: [], cleanContent }; + 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: UploadedFile[] = []; + const files: FileInMessage[] = []; let fileMatch; while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) { files.push({ filename: fileMatch[1].trim(), - size: fileMatch[2].trim(), + size: parseInt(fileMatch[2].trim(), 10) ?? 0, path: fileMatch[3].trim(), }); } - return { files, cleanContent }; -} - -export function stripUploadedFilesTag(content: string): string { - return content - .replace(/[\s\S]*?<\/uploaded_files>/g, "") - .trim(); + return files; }