feat(frontend): 对齐消息工具并保留 YAML 操作
This commit is contained in:
parent
98caba9cf1
commit
a8f6e934ad
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,146 +34,100 @@ 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).
|
||||||
|
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 (message.type === "human") {
|
||||||
groups.push({
|
groups.push({ id: message.id, type: "human", messages: [message] });
|
||||||
id: message.id,
|
continue;
|
||||||
type: "human",
|
}
|
||||||
messages: [message],
|
|
||||||
});
|
if (message.type === "tool") {
|
||||||
} else if (message.type === "tool") {
|
|
||||||
// 检查是否为澄清问题的工具消息
|
|
||||||
if (isClarificationToolMessage(message)) {
|
if (isClarificationToolMessage(message)) {
|
||||||
// 如果有可用的处理组,添加到其中(保持工具调用关联)
|
// Add to the preceding processing group to preserve tool-call association,
|
||||||
if (
|
// then also open a standalone clarification group for prominent display.
|
||||||
lastGroup &&
|
lastOpenGroup()?.messages.push(message);
|
||||||
lastGroup.type !== "human" &&
|
|
||||||
lastGroup.type !== "assistant" &&
|
|
||||||
lastGroup.type !== "assistant:clarification"
|
|
||||||
) {
|
|
||||||
lastGroup.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "ai") {
|
||||||
|
if (hasPresentFiles(message)) {
|
||||||
groups.push({
|
groups.push({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
type: "assistant:processing",
|
type: "assistant:present-files",
|
||||||
messages: [message],
|
messages: [message],
|
||||||
});
|
});
|
||||||
}
|
} else if (hasSubagent(message)) {
|
||||||
} else if (message.type === "ai") {
|
groups.push({
|
||||||
if (hasReasoning(message) || hasToolCalls(message)) {
|
id: message.id,
|
||||||
if (hasPresentFiles(message)) {
|
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({
|
groups.push({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
type: "assistant:present-files",
|
type: "assistant:processing",
|
||||||
messages: [message],
|
|
||||||
});
|
|
||||||
} else if (hasSubagent(message)) {
|
|
||||||
groups.push({
|
|
||||||
id: message.id,
|
|
||||||
type: "assistant:subagent",
|
|
||||||
messages: [message],
|
messages: [message],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (lastGroup?.type !== "assistant:processing") {
|
lastGroup.messages.push(message);
|
||||||
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 消息必须位于处理组之后",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)) {
|
if (hasContent(message) && !hasToolCalls(message)) {
|
||||||
groups.push({
|
groups.push({ id: message.id, type: "assistant", messages: [message] });
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue