添加工具调用解析和聊天工具操作处理

This commit is contained in:
2026-04-03 11:49:05 +08:00
parent a1c8041b11
commit d610a09c14
11 changed files with 1269 additions and 18 deletions
+111
View File
@@ -4,6 +4,30 @@ export type AssistantMessageSections = {
thoughtComplete: boolean;
};
/* ------------------------------------------------------------------ */
/* Tool-call types */
/* ------------------------------------------------------------------ */
export type ToolCall = {
id: string;
tool: string;
params: Record<string, unknown>;
};
export type ContentSegment =
| { type: "text"; content: string }
| { type: "tool_call"; toolCall: ToolCall }
| { type: "tool_call_pending" };
export type ParsedToolContent = {
segments: ContentSegment[];
toolCalls: ToolCall[];
};
/* ------------------------------------------------------------------ */
/* Think-block parsing */
/* ------------------------------------------------------------------ */
const THINK_BLOCK_PATTERN = /<think>([\s\S]*?)<\/think>/gi;
const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
@@ -53,3 +77,90 @@ export const parseAssistantMessageSections = (
thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought,
};
};
/* ------------------------------------------------------------------ */
/* Tool-call parsing */
/* */
/* AI responses may embed tool calls using: */
/* <tool_call>{"tool":"locate_pipes","params":{...}}</tool_call> */
/* */
/* Returns ordered segments (text + tool_call interleaved) so the */
/* UI can render them inline where the AI placed them. */
/* ------------------------------------------------------------------ */
const TOOL_CALL_BLOCK_PATTERN = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
const TOOL_CALL_OPEN_TAG = "<tool_call>";
/** Regex to strip partial opening tag at the end of text during streaming. */
const PARTIAL_TOOL_TAG_TAIL = /<(?:t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l)?)?)?)?)?)?)?)?)?$/;
export const parseContentWithToolCalls = (
content: string,
): ParsedToolContent => {
if (!content) {
return { segments: [], toolCalls: [] };
}
const segments: ContentSegment[] = [];
const toolCalls: ToolCall[] = [];
let lastIndex = 0;
let tcIndex = 0;
// Find all complete <tool_call>...</tool_call> blocks
const regex = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
// Text before this tool call
const textBefore = content.slice(lastIndex, match.index);
if (textBefore.trim()) {
segments.push({ type: "text", content: textBefore.trim() });
}
// Parse the tool call JSON
try {
const parsed = JSON.parse(match[1].trim()) as {
tool?: string;
params?: Record<string, unknown>;
};
const toolCall: ToolCall = {
id: `tc-${tcIndex++}`,
tool: parsed.tool ?? "unknown",
params: parsed.params ?? {},
};
segments.push({ type: "tool_call", toolCall });
toolCalls.push(toolCall);
} catch {
// Malformed JSON treat as plain text
segments.push({ type: "text", content: match[0] });
}
lastIndex = match.index + match[0].length;
}
// Handle remaining text after the last match
const remaining = content.slice(lastIndex);
// Check for an unclosed <tool_call> tag (still streaming)
const unclosedIdx = remaining.lastIndexOf(TOOL_CALL_OPEN_TAG);
if (unclosedIdx !== -1) {
const textBefore = remaining.slice(0, unclosedIdx);
if (textBefore.trim()) {
segments.push({ type: "text", content: textBefore.trim() });
}
segments.push({ type: "tool_call_pending" });
} else {
// Strip partial opening tags at the end (e.g. "<tool_c" while streaming)
const cleaned = remaining.replace(PARTIAL_TOOL_TAG_TAIL, "").trim();
if (cleaned) {
segments.push({ type: "text", content: cleaned });
}
}
// If nothing was parsed, return the original content as a single text segment
if (segments.length === 0) {
segments.push({ type: "text", content });
}
return { segments, toolCalls };
};