feat(frontend): 对齐消息工具并保留 YAML 操作

This commit is contained in:
肖应宇 2026-03-28 23:53:08 +08:00
parent 98caba9cf1
commit a8f6e934ad
2 changed files with 146 additions and 150 deletions

View File

@ -138,7 +138,7 @@ function MessageContent_({
if (!Array.isArray(files) || files.length === 0) { if (!Array.isArray(files) || files.length === 0) {
if (rawContent.includes("<uploaded_files>")) { if (rawContent.includes("<uploaded_files>")) {
// If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility. // If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility.
return parseUploadedFiles(rawContent).files as FileInMessage[]; return parseUploadedFiles(rawContent);
} }
return null; return null;
} }

View File

@ -34,93 +34,58 @@ export function groupMessages<T>(
return []; return [];
} }
// 预处理:收集所有 ToolMessage 的 tool_call_id
const toolMessageIds = new Set<string>();
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<string>();
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[] = []; const groups: MessageGroup[] = [];
for (const message of filteredMessages) { // Returns the last group if it can still accept tool messages
const lastGroup = groups[groups.length - 1]; // (i.e. it's an in-flight processing group, not a terminal human/assistant group).
if (message.type === "human") { function lastOpenGroup() {
groups.push({ const last = groups[groups.length - 1];
id: message.id,
type: "human",
messages: [message],
});
} else if (message.type === "tool") {
// 检查是否为澄清问题的工具消息
if (isClarificationToolMessage(message)) {
// 如果有可用的处理组,添加到其中(保持工具调用关联)
if ( if (
lastGroup && last &&
lastGroup.type !== "human" && last.type !== "human" &&
lastGroup.type !== "assistant" && last.type !== "assistant" &&
lastGroup.type !== "assistant:clarification" last.type !== "assistant:clarification"
) { ) {
lastGroup.messages.push(message); 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] });
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({ groups.push({
id: message.id, id: message.id,
type: "assistant:clarification", type: "assistant:clarification",
messages: [message], messages: [message],
}); });
} else if (
lastGroup &&
lastGroup.type !== "human" &&
lastGroup.type !== "assistant" &&
lastGroup.type !== "assistant:clarification"
) {
lastGroup.messages.push(message);
} else { } else {
// 悬空的工具消息(如生成被中断导致) const open = lastOpenGroup();
// 创建独立的处理组以便显示 if (open) {
console.warn( open.messages.push(message);
"检测到悬空的工具消息,创建独立组:", } else {
message.tool_call_id, console.error(
"Unexpected tool message outside a processing group",
message,
); );
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
} }
} else if (message.type === "ai") { }
if (hasReasoning(message) || hasToolCalls(message)) { continue;
}
if (message.type === "ai") {
if (hasPresentFiles(message)) { if (hasPresentFiles(message)) {
groups.push({ groups.push({
id: message.id, id: message.id,
@ -133,47 +98,36 @@ export function groupMessages<T>(
type: "assistant:subagent", type: "assistant:subagent",
messages: [message], messages: [message],
}); });
} else { } 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") { if (lastGroup?.type !== "assistant:processing") {
groups.push({ groups.push({
id: message.id, id: message.id,
type: "assistant:processing", type: "assistant:processing",
messages: [],
});
}
const currentGroup = groups[groups.length - 1];
if (currentGroup?.type === "assistant:processing") {
currentGroup.messages.push(message);
} else {
throw new Error(
"带有推理或工具调用的 AI 消息必须位于处理组之后",
);
}
}
}
if (hasContent(message) && !hasToolCalls(message)) {
groups.push({
id: message.id,
type: "assistant",
messages: [message], 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] });
} }
} }
} }
const resultsOfGroups: T[] = []; return groups
for (const group of groups) { .map(mapper)
const resultOfGroup = mapper(group); .filter((result) => result !== undefined && result !== null) as T[];
if (resultOfGroup !== undefined && resultOfGroup !== null) {
resultsOfGroups.push(resultOfGroup);
}
}
return resultsOfGroups;
} }
export function extractTextFromMessage(message: Message) { export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") { if (typeof message.content === "string") {
return message.content.trim(); return splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim();
} }
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
return message.content return message.content
@ -184,9 +138,36 @@ export function extractTextFromMessage(message: Message) {
return ""; 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) { export function extractContentFromMessage(message: Message) {
if (typeof message.content === "string") { if (typeof message.content === "string") {
return message.content.trim(); return splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim();
} }
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
return message.content return message.content
@ -208,12 +189,24 @@ export function extractContentFromMessage(message: Message) {
} }
export function extractReasoningContentFromMessage(message: Message) { export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai" || !message.additional_kwargs) { if (message.type !== "ai") {
return null; 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; 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; return null;
} }
@ -239,7 +232,9 @@ export function extractURLFromImageURLContent(
export function hasContent(message: Message) { export function hasContent(message: Message) {
if (typeof message.content === "string") { 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)) { if (Array.isArray(message.content)) {
return message.content.length > 0; return message.content.length > 0;
@ -248,10 +243,21 @@ export function hasContent(message: Message) {
} }
export function hasReasoning(message: Message) { export function hasReasoning(message: Message) {
return ( if (message.type !== "ai") {
message.type === "ai" && return false;
typeof message.additional_kwargs?.reasoning_content === "string" }
); 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) { export function hasToolCalls(message: Message) {
@ -309,71 +315,61 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
} }
/** /**
* Represents an uploaded file parsed from the <uploaded_files> 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 { export interface FileInMessage {
filename: string; filename: string;
size: string | number; size: number; // bytes
path?: string; path?: string; // virtual path, may not be set during upload
status?: "uploading" | "uploaded"; status?: "uploading" | "uploaded";
} }
/** /**
* Result of parsing uploaded files from message content * Strip <uploaded_files> tag from message content.
* Returns the content with the tag removed.
*/ */
export interface ParsedUploadedFiles { export function stripUploadedFilesTag(content: string): string {
files: UploadedFile[]; return content
cleanContent: string; .replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>/g, "")
.trim();
} }
/** export function parseUploadedFiles(content: string): FileInMessage[] {
* Parse <uploaded_files> 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 {
// Match <uploaded_files>...</uploaded_files> tag // Match <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/; const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
const match = content.match(uploadedFilesRegex); const match = content.match(uploadedFilesRegex);
if (!match) { if (!match) {
return { files: [], cleanContent: content }; return [];
} }
const uploadedFilesContent = match[1]; const uploadedFilesContent = match[1];
const cleanContent = content.replace(uploadedFilesRegex, "").trim();
// Check if it's "No files have been uploaded yet." // Check if it's "No files have been uploaded yet."
if (uploadedFilesContent?.includes("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 // Parse file list
// Format: - filename (size)\n Path: /path/to/file // Format: - filename (size)\n Path: /path/to/file
const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g; const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g;
const files: UploadedFile[] = []; const files: FileInMessage[] = [];
let fileMatch; let fileMatch;
while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) { while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) {
files.push({ files.push({
filename: fileMatch[1].trim(), filename: fileMatch[1].trim(),
size: fileMatch[2].trim(), size: parseInt(fileMatch[2].trim(), 10) ?? 0,
path: fileMatch[3].trim(), path: fileMatch[3].trim(),
}); });
} }
return { files, cleanContent }; return files;
}
export function stripUploadedFilesTag(content: string): string {
return content
.replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>/g, "")
.trim();
} }