This commit is contained in:
Zacharias-Brohn
2026-01-15 00:05:31 +01:00
parent 51aead70b6
commit 8365dfbce2
2 changed files with 152 additions and 179 deletions
+127 -178
View File
@@ -1,6 +1,5 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
@@ -29,21 +28,18 @@ interface MarkdownMessageProps {
isStreaming?: boolean; isStreaming?: boolean;
} }
interface ParsedToolCall { /**
toolName: string; * Parsed segment with streaming state
args: Record<string, unknown>; */
result: string; interface ParsedSegment {
} type: 'text' | 'thinking' | 'toolGroup';
interface ContentSegment {
type: 'text' | 'tool' | 'thinking';
content?: string; content?: string;
toolCall?: ParsedToolCall; toolCalls?: ToolCall[];
isStreaming?: boolean; // True if this segment is still being streamed
} }
/** /**
* Clean text content by removing text-based tool call patterns * Clean text content by removing text-based tool call patterns
* These are tool calls the model outputs as text (not our structured markers)
*/ */
function cleanTextContent(text: string): string { function cleanTextContent(text: string): string {
return text return text
@@ -55,36 +51,66 @@ function cleanTextContent(text: string): string {
} }
/** /**
* Parse content to extract thinking blocks, tool calls, and regular text segments * Parse content into segments, handling both complete and incomplete (streaming) blocks
*/ */
function parseContentWithToolCalls(content: string): ContentSegment[] { function parseContentRealtime(content: string, isStreaming: boolean): ParsedSegment[] {
const segments: ContentSegment[] = []; const segments: ParsedSegment[] = [];
// Combined pattern for both thinking blocks and tool calls // Check for incomplete thinking block (opened but not closed)
// This ensures we parse them in the order they appear const hasOpenThink = content.includes('<think>');
const hasCloseThink = content.includes('</think>');
const isThinkingIncomplete = hasOpenThink && !hasCloseThink;
// Check for incomplete tool call
const hasOpenTool = content.includes('<!--TOOL_START:');
const lastToolEndIndex = content.lastIndexOf('<!--TOOL_END-->');
const lastToolStartIndex = content.lastIndexOf('<!--TOOL_START:');
const isToolIncomplete = hasOpenTool && lastToolStartIndex > lastToolEndIndex;
// Pattern for complete blocks only
const combinedPattern = const combinedPattern =
/(<think>([\s\S]*?)<\/think>)|<!--TOOL_START:(\w+):(\{.*?\})-->([\s\S]*?)<!--TOOL_END-->/g; /(<think>([\s\S]*?)<\/think>)|<!--TOOL_START:(\w+):(\{.*?\})-->([\s\S]*?)<!--TOOL_END-->/g;
let lastIndex = 0; let lastIndex = 0;
let match; let match;
let currentToolGroup: ToolCall[] = [];
// Helper to flush tool group
const flushToolGroup = (streaming = false) => {
if (currentToolGroup.length > 0) {
segments.push({
type: 'toolGroup',
toolCalls: [...currentToolGroup],
isStreaming: streaming,
});
currentToolGroup = [];
}
};
// Helper to add text segment
const addTextSegment = (text: string, streaming = false) => {
const cleaned = cleanTextContent(text);
if (cleaned) {
flushToolGroup(); // Flush any pending tools before text
segments.push({ type: 'text', content: cleaned, isStreaming: streaming });
}
};
while ((match = combinedPattern.exec(content)) !== null) { while ((match = combinedPattern.exec(content)) !== null) {
// Add text before this match // Add text before this match
if (match.index > lastIndex) { if (match.index > lastIndex) {
const textBefore = cleanTextContent(content.slice(lastIndex, match.index)); addTextSegment(content.slice(lastIndex, match.index));
if (textBefore) {
segments.push({ type: 'text', content: textBefore });
}
} }
if (match[1]) { if (match[1]) {
// This is a thinking block // Complete thinking block
flushToolGroup();
const thinkingContent = match[2].trim(); const thinkingContent = match[2].trim();
if (thinkingContent) { if (thinkingContent) {
segments.push({ type: 'thinking', content: thinkingContent }); segments.push({ type: 'thinking', content: thinkingContent, isStreaming: false });
} }
} else if (match[3]) { } else if (match[3]) {
// This is a tool call // Complete tool call - add to current group
const toolName = match[3]; const toolName = match[3];
let args: Record<string, unknown> = {}; let args: Record<string, unknown> = {};
try { try {
@@ -93,156 +119,74 @@ function parseContentWithToolCalls(content: string): ContentSegment[] {
// Invalid JSON, use empty args // Invalid JSON, use empty args
} }
const result = match[5].trim(); const result = match[5].trim();
currentToolGroup.push({ toolName, args, result });
segments.push({
type: 'tool',
toolCall: { toolName, args, result },
});
} }
lastIndex = match.index + match[0].length; lastIndex = match.index + match[0].length;
} }
// Add remaining text after last match // Handle remaining content after last complete match
if (lastIndex < content.length) { const remaining = content.slice(lastIndex);
const remainingText = cleanTextContent(content.slice(lastIndex));
if (remainingText) { if (isThinkingIncomplete) {
segments.push({ type: 'text', content: remainingText }); // We have an open <think> tag - extract the thinking content
flushToolGroup();
const thinkStartIndex = remaining.indexOf('<think>');
if (thinkStartIndex !== -1) {
// Text before <think>
const textBefore = remaining.slice(0, thinkStartIndex);
addTextSegment(textBefore);
// Thinking content (everything after <think>)
const thinkingContent = remaining.slice(thinkStartIndex + 7).trim();
if (thinkingContent) {
segments.push({ type: 'thinking', content: thinkingContent, isStreaming: true });
} else {
// Empty thinking block that just started
segments.push({ type: 'thinking', content: '', isStreaming: true });
} }
} }
} else if (isToolIncomplete) {
// We have an incomplete tool call
// First, add any complete text before the incomplete tool
const toolStartMatch = remaining.match(/<!--TOOL_START:(\w+):(\{.*?\})-->/);
if (toolStartMatch && toolStartMatch.index !== undefined) {
const textBefore = remaining.slice(0, toolStartMatch.index);
addTextSegment(textBefore);
// Parse the incomplete tool call
const toolName = toolStartMatch[1];
let args: Record<string, unknown> = {};
try {
args = JSON.parse(toolStartMatch[2]);
} catch {
// Invalid JSON
}
// Result is everything after the start marker (still streaming)
const resultStart = toolStartMatch.index + toolStartMatch[0].length;
const partialResult = remaining.slice(resultStart).trim();
currentToolGroup.push({ toolName, args, result: partialResult || 'Loading...' });
flushToolGroup(true); // Mark as streaming
} else {
addTextSegment(remaining, isStreaming);
}
} else {
// No incomplete blocks - just add remaining text
addTextSegment(remaining, isStreaming && remaining.length > 0);
}
// If no special blocks found, return the whole content as text (cleaned) // Flush any remaining tool group
if (segments.length === 0 && content.trim()) { flushToolGroup();
const cleanedContent = cleanTextContent(content);
if (cleanedContent) {
segments.push({ type: 'text', content: cleanedContent });
}
}
return segments; return segments;
} }
/**
* Grouped segment type - text, thinking block, or group of consecutive tool calls
*/
interface GroupedSegment {
type: 'text' | 'toolGroup' | 'thinking';
content?: string;
toolCalls?: ToolCall[];
}
/**
* Group consecutive tool call segments together, keep thinking blocks separate
*/
function groupConsecutiveToolCalls(segments: ContentSegment[]): GroupedSegment[] {
const grouped: GroupedSegment[] = [];
let currentToolGroup: ToolCall[] = [];
for (const segment of segments) {
if (segment.type === 'tool' && segment.toolCall) {
// Add to current tool group
currentToolGroup.push(segment.toolCall);
} else {
// Flush any pending tool group before adding other segment
if (currentToolGroup.length > 0) {
grouped.push({ type: 'toolGroup', toolCalls: currentToolGroup });
currentToolGroup = [];
}
if (segment.type === 'thinking' && segment.content) {
// Add thinking block as-is
grouped.push({ type: 'thinking', content: segment.content });
} else if (segment.content) {
// Add text segment
grouped.push({ type: 'text', content: segment.content });
}
}
}
// Flush any remaining tool group
if (currentToolGroup.length > 0) {
grouped.push({ type: 'toolGroup', toolCalls: currentToolGroup });
}
return grouped;
}
/**
* Hook that tracks content changes and returns content with fade-in markers for new text
*/
function useStreamingContent(content: string, isStreaming: boolean) {
const [processedContent, setProcessedContent] = useState(content);
const prevContentRef = useRef('');
const fadeIdRef = useRef(0);
useEffect(() => {
if (!isStreaming) {
// When not streaming, just use the content directly (no animation spans)
setProcessedContent(content);
prevContentRef.current = content;
return;
}
const prevContent = prevContentRef.current;
const prevLength = prevContent.length;
const currentLength = content.length;
if (currentLength > prevLength) {
// New content arrived - wrap it in a fade-in span
const existingContent = content.slice(0, prevLength);
const newContent = content.slice(prevLength);
const fadeId = fadeIdRef.current++;
// Wrap new content in a span with fade-in class
// Use a unique key to force re-render of the animation
const wrappedNew = `<span class="${classes.fadeIn}" data-fade-id="${fadeId}">${escapeHtml(newContent)}</span>`;
setProcessedContent(existingContent + wrappedNew);
prevContentRef.current = content;
} else if (currentLength < prevLength) {
// Content was reset
setProcessedContent(content);
prevContentRef.current = content;
fadeIdRef.current = 0;
}
}, [content, isStreaming]);
// When streaming ends, clean up the spans and show plain content
useEffect(() => {
if (!isStreaming && content) {
setProcessedContent(content);
prevContentRef.current = content;
}
}, [isStreaming, content]);
return processedContent;
}
/**
* Escape HTML special characters to prevent XSS
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Strip tool call and thinking markers from content for streaming display
*/
function stripToolMarkers(content: string): string {
return content
.replace(/<!--TOOL_START:\w+:\{.*?\}-->/g, '')
.replace(/<!--TOOL_END-->/g, '')
.replace(/<\/?think>/g, '') // Remove think tags but keep content visible during streaming
.replace(/\w+\[ARGS\]\{[^}]*\}/g, '') // Remove text-based tool calls like get_weather[ARGS]{...}
.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, ''); // Remove XML-style tool calls
}
function MarkdownContent({ content }: { content: string }) { function MarkdownContent({ content }: { content: string }) {
if (!content.trim()) {
return null;
}
return ( return (
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]} remarkPlugins={[remarkGfm, remarkMath]}
@@ -392,7 +336,7 @@ function MarkdownContent({ content }: { content: string }) {
th: ({ children }) => <Table.Th>{children}</Table.Th>, th: ({ children }) => <Table.Th>{children}</Table.Th>,
td: ({ children }) => <Table.Td>{children}</Table.Td>, td: ({ children }) => <Table.Td>{children}</Table.Td>,
// Allow the fade-in spans to pass through // Allow custom spans to pass through
span: ({ className, children }) => <span className={className}>{children}</span>, span: ({ className, children }) => <span className={className}>{children}</span>,
}} }}
> >
@@ -402,29 +346,34 @@ function MarkdownContent({ content }: { content: string }) {
} }
export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) { export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) {
const processedContent = useStreamingContent(content, isStreaming); // Parse content into segments (works for both streaming and complete content)
const segments = parseContentRealtime(content, isStreaming);
// If no segments, show nothing
if (segments.length === 0) {
return null;
}
// During streaming, just show the raw content (with tool markers stripped)
if (isStreaming) {
return ( return (
<div className={classes.markdown}> <div className={classes.markdown}>
<MarkdownContent content={stripToolMarkers(processedContent)} /> {segments.map((segment, index) => {
</div> if (segment.type === 'thinking') {
return (
<ThinkingBlock
key={`thinking-${index}`}
content={segment.content || ''}
isStreaming={segment.isStreaming}
/>
); );
} }
// When not streaming, parse and render tool calls (grouped)
const segments = parseContentWithToolCalls(content);
const groupedSegments = groupConsecutiveToolCalls(segments);
return (
<div className={classes.markdown}>
{groupedSegments.map((segment, index) => {
if (segment.type === 'thinking' && segment.content) {
return <ThinkingBlock key={`thinking-${index}`} content={segment.content} />;
}
if (segment.type === 'toolGroup' && segment.toolCalls) { if (segment.type === 'toolGroup' && segment.toolCalls) {
return <ToolCallGroup key={`toolgroup-${index}`} toolCalls={segment.toolCalls} />; return (
<ToolCallGroup
key={`toolgroup-${index}`}
toolCalls={segment.toolCalls}
isStreaming={segment.isStreaming}
/>
);
} }
return <MarkdownContent key={`text-${index}`} content={segment.content || ''} />; return <MarkdownContent key={`text-${index}`} content={segment.content || ''} />;
})} })}
+26 -2
View File
@@ -2,7 +2,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { IconChevronDown, IconChevronRight, IconTool } from '@tabler/icons-react'; import { IconChevronDown, IconChevronRight, IconTool } from '@tabler/icons-react';
import { ActionIcon, Code, Collapse, Group, Paper, Text, useMantineTheme } from '@mantine/core'; import {
ActionIcon,
Code,
Collapse,
Group,
Loader,
Paper,
Text,
useMantineTheme,
} from '@mantine/core';
import { useThemeContext } from '@/components/DynamicThemeProvider'; import { useThemeContext } from '@/components/DynamicThemeProvider';
import classes from './ToolCallDisplay.module.css'; import classes from './ToolCallDisplay.module.css';
@@ -21,6 +30,7 @@ interface ToolCallDisplayProps {
interface ToolCallGroupProps { interface ToolCallGroupProps {
toolCalls: ToolCall[]; toolCalls: ToolCall[];
isStreaming?: boolean;
} }
// Friendly tool names // Friendly tool names
@@ -113,12 +123,26 @@ export function ToolCallDisplay({ toolName, args, result, nested = false }: Tool
/** /**
* Display a group of consecutive tool calls in a single collapsible container * Display a group of consecutive tool calls in a single collapsible container
*/ */
export function ToolCallGroup({ toolCalls }: ToolCallGroupProps) { export function ToolCallGroup({ toolCalls, isStreaming }: ToolCallGroupProps) {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const { primaryColor } = useThemeContext(); const { primaryColor } = useThemeContext();
const theme = useMantineTheme(); const theme = useMantineTheme();
if (toolCalls.length === 0) { if (toolCalls.length === 0) {
// If streaming with no calls yet, show loading indicator
if (isStreaming) {
return (
<Paper className={classes.groupContainer} withBorder radius="sm" p="xs" my="xs">
<Group gap="xs" wrap="nowrap">
<Loader size={14} color={primaryColor} />
<IconTool size={16} color={theme.colors[primaryColor][6]} />
<Text size="sm" fw={500} c={primaryColor}>
Running tool...
</Text>
</Group>
</Paper>
);
}
return null; return null;
} }