export type AssistantMessageSections = { answer: string; thought: string | null; thoughtComplete: boolean; }; /* ------------------------------------------------------------------ */ /* Tool-call types */ /* ------------------------------------------------------------------ */ export type ToolCall = { id: string; tool: string; params: Record; }; 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 = /([\s\S]*?)<\/think>/gi; const THINK_OPEN_TAG = ""; const THINK_CLOSE_TAG = ""; 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":"locate_pipes","params":{...}} */ /* */ /* Returns ordered segments (text + tool_call interleaved) so the */ /* UI can render them inline where the AI placed them. */ /* ------------------------------------------------------------------ */ const TOOL_CALL_BLOCK_PATTERN = /([\s\S]*?)<\/tool_call>/gi; const TOOL_CALL_OPEN_TAG = ""; /** 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 ... blocks const regex = /([\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; }; 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 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. "