463 lines
13 KiB
TypeScript
463 lines
13 KiB
TypeScript
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 ``;
|
||
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;
|
||
}
|