167 lines
5.3 KiB
TypeScript
167 lines
5.3 KiB
TypeScript
export type AssistantMessageSections = {
|
||
answer: string;
|
||
thought: string | null;
|
||
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>";
|
||
|
||
export const parseAssistantMessageSections = (
|
||
content: string,
|
||
): AssistantMessageSections => {
|
||
if (!content) {
|
||
return { answer: "", thought: null, thoughtComplete: false };
|
||
}
|
||
|
||
const thoughtParts: string[] = [];
|
||
let answer = content;
|
||
|
||
answer = answer.replace(THINK_BLOCK_PATTERN, (_, thoughtContent: string) => {
|
||
const trimmedThought = thoughtContent.trim();
|
||
if (trimmedThought) {
|
||
thoughtParts.push(trimmedThought);
|
||
}
|
||
|
||
return "\n";
|
||
});
|
||
|
||
const lastOpenIndex = answer.lastIndexOf(THINK_OPEN_TAG);
|
||
const lastCloseIndex = answer.lastIndexOf(THINK_CLOSE_TAG);
|
||
const hasUnclosedThought =
|
||
lastOpenIndex !== -1 && lastOpenIndex > lastCloseIndex;
|
||
|
||
if (hasUnclosedThought) {
|
||
const streamingThought = answer
|
||
.slice(lastOpenIndex + THINK_OPEN_TAG.length)
|
||
.trim();
|
||
|
||
if (streamingThought) {
|
||
thoughtParts.push(streamingThought);
|
||
}
|
||
|
||
answer = answer.slice(0, lastOpenIndex);
|
||
}
|
||
|
||
const normalizedAnswer = answer.replace(/\n{3,}/g, "\n\n").trim();
|
||
const normalizedThought = thoughtParts.join("\n\n").trim();
|
||
|
||
return {
|
||
answer: normalizedAnswer,
|
||
thought: normalizedThought || null,
|
||
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 };
|
||
};
|