383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import Image from 'next/image';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import rehypeKatex from 'rehype-katex';
|
|
import rehypeRaw from 'rehype-raw';
|
|
import remarkGfm from 'remark-gfm';
|
|
import remarkMath from 'remark-math';
|
|
import {
|
|
Anchor,
|
|
Blockquote,
|
|
Checkbox,
|
|
Code,
|
|
Divider,
|
|
List,
|
|
Table,
|
|
Text,
|
|
Title,
|
|
} from '@mantine/core';
|
|
import { ThinkingBlock } from './ThinkingBlock';
|
|
import { ToolCall, ToolCallGroup } from './ToolCallDisplay';
|
|
import classes from './MarkdownMessage.module.css';
|
|
// Import KaTeX CSS for LaTeX rendering
|
|
import 'katex/dist/katex.min.css';
|
|
|
|
interface MarkdownMessageProps {
|
|
content: string;
|
|
isStreaming?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Parsed segment with streaming state
|
|
*/
|
|
interface ParsedSegment {
|
|
type: 'text' | 'thinking' | 'toolGroup';
|
|
content?: string;
|
|
toolCalls?: ToolCall[];
|
|
isStreaming?: boolean; // True if this segment is still being streamed
|
|
}
|
|
|
|
/**
|
|
* Clean text content by removing text-based tool call patterns
|
|
*/
|
|
function cleanTextContent(text: string): string {
|
|
return text
|
|
.replace(/\w+\[ARGS\]\{[^}]*\}/g, '') // Remove tool_name[ARGS]{...} patterns
|
|
.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '') // Remove <tool_call>...</tool_call>
|
|
.replace(/\{[\s\S]*?"(?:tool|function)"[\s\S]*?\}/g, '') // Remove JSON tool objects
|
|
.replace(/\n{3,}/g, '\n\n') // Collapse multiple newlines
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Parse content into segments, handling both complete and incomplete (streaming) blocks
|
|
*/
|
|
function parseContentRealtime(content: string, isStreaming: boolean): ParsedSegment[] {
|
|
const segments: ParsedSegment[] = [];
|
|
|
|
// Check for incomplete thinking block (opened but not closed)
|
|
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 =
|
|
/(<think>([\s\S]*?)<\/think>)|<!--TOOL_START:(\w+):(\{.*?\})-->([\s\S]*?)<!--TOOL_END-->/g;
|
|
|
|
let lastIndex = 0;
|
|
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) {
|
|
// Add text before this match
|
|
if (match.index > lastIndex) {
|
|
addTextSegment(content.slice(lastIndex, match.index));
|
|
}
|
|
|
|
if (match[1]) {
|
|
// Complete thinking block
|
|
flushToolGroup();
|
|
const thinkingContent = match[2].trim();
|
|
if (thinkingContent) {
|
|
segments.push({ type: 'thinking', content: thinkingContent, isStreaming: false });
|
|
}
|
|
} else if (match[3]) {
|
|
// Complete tool call - add to current group
|
|
const toolName = match[3];
|
|
let args: Record<string, unknown> = {};
|
|
try {
|
|
args = JSON.parse(match[4]);
|
|
} catch {
|
|
// Invalid JSON, use empty args
|
|
}
|
|
const result = match[5].trim();
|
|
currentToolGroup.push({ toolName, args, result });
|
|
}
|
|
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
// Handle remaining content after last complete match
|
|
const remaining = content.slice(lastIndex);
|
|
|
|
if (isThinkingIncomplete) {
|
|
// 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);
|
|
}
|
|
|
|
// Flush any remaining tool group
|
|
flushToolGroup();
|
|
|
|
return segments;
|
|
}
|
|
|
|
function MarkdownContent({ content }: { content: string }) {
|
|
if (!content.trim()) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm, remarkMath]}
|
|
rehypePlugins={[rehypeRaw, rehypeKatex]}
|
|
components={{
|
|
// Headings
|
|
h1: ({ children }) => (
|
|
<Title order={2} mb="xs">
|
|
{children}
|
|
</Title>
|
|
),
|
|
h2: ({ children }) => (
|
|
<Title order={3} mb="xs">
|
|
{children}
|
|
</Title>
|
|
),
|
|
h3: ({ children }) => (
|
|
<Title order={4} mb="xs">
|
|
{children}
|
|
</Title>
|
|
),
|
|
h4: ({ children }) => (
|
|
<Title order={5} mb="xs">
|
|
{children}
|
|
</Title>
|
|
),
|
|
h5: ({ children }) => (
|
|
<Title order={6} mb="xs">
|
|
{children}
|
|
</Title>
|
|
),
|
|
h6: ({ children }) => (
|
|
<Text size="md" fw={700} mb="xs">
|
|
{children}
|
|
</Text>
|
|
),
|
|
|
|
// Paragraphs and text
|
|
p: ({ children }) => (
|
|
<Text size="md" style={{ lineHeight: 1.6, marginBottom: '0.5em' }}>
|
|
{children}
|
|
</Text>
|
|
),
|
|
strong: ({ children }) => (
|
|
<Text component="strong" fw={700} inherit>
|
|
{children}
|
|
</Text>
|
|
),
|
|
em: ({ children }) => (
|
|
<Text component="em" fs="italic" inherit>
|
|
{children}
|
|
</Text>
|
|
),
|
|
del: ({ children }) => (
|
|
<Text component="del" td="line-through" inherit>
|
|
{children}
|
|
</Text>
|
|
),
|
|
|
|
// Code
|
|
code: ({ className, children }) => {
|
|
const isInline = !className;
|
|
if (isInline) {
|
|
return <Code>{children}</Code>;
|
|
}
|
|
return (
|
|
<Code block className={classes.codeBlock}>
|
|
{children}
|
|
</Code>
|
|
);
|
|
},
|
|
pre: ({ children }) => <div className={classes.preWrapper}>{children}</div>,
|
|
|
|
// Lists
|
|
ul: ({ children, className }) => {
|
|
// Check if this is a task list (GFM)
|
|
if (className?.includes('contains-task-list')) {
|
|
return <ul className={classes.taskList}>{children}</ul>;
|
|
}
|
|
return (
|
|
<List size="md" mb="xs">
|
|
{children}
|
|
</List>
|
|
);
|
|
},
|
|
ol: ({ children }) => (
|
|
<List type="ordered" size="md" mb="xs">
|
|
{children}
|
|
</List>
|
|
),
|
|
li: ({ children, className }) => {
|
|
// Task list items have a checkbox
|
|
if (className?.includes('task-list-item')) {
|
|
return <li className={classes.taskListItem}>{children}</li>;
|
|
}
|
|
return <List.Item>{children}</List.Item>;
|
|
},
|
|
input: ({ checked, type }) => {
|
|
if (type === 'checkbox') {
|
|
return <Checkbox checked={checked} readOnly size="xs" mr="xs" />;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
// Links and media
|
|
a: ({ href, children }) => (
|
|
<Anchor href={href} target="_blank" rel="noopener noreferrer">
|
|
{children}
|
|
</Anchor>
|
|
),
|
|
img: ({ src, alt }) => {
|
|
const imgSrc = typeof src === 'string' ? src : '';
|
|
return (
|
|
<span className={classes.imageWrapper}>
|
|
<Image
|
|
src={imgSrc}
|
|
alt={alt || ''}
|
|
width={600}
|
|
height={400}
|
|
style={{ maxWidth: '100%', height: 'auto' }}
|
|
unoptimized // Allow external images
|
|
/>
|
|
</span>
|
|
);
|
|
},
|
|
|
|
// Block elements
|
|
blockquote: ({ children }) => <Blockquote mb="xs">{children}</Blockquote>,
|
|
hr: () => <Divider my="md" />,
|
|
|
|
// Tables (GFM)
|
|
table: ({ children }) => (
|
|
<Table
|
|
striped
|
|
highlightOnHover
|
|
withTableBorder
|
|
withColumnBorders
|
|
mb="md"
|
|
className={classes.table}
|
|
>
|
|
{children}
|
|
</Table>
|
|
),
|
|
thead: ({ children }) => <Table.Thead>{children}</Table.Thead>,
|
|
tbody: ({ children }) => <Table.Tbody>{children}</Table.Tbody>,
|
|
tr: ({ children }) => <Table.Tr>{children}</Table.Tr>,
|
|
th: ({ children }) => <Table.Th>{children}</Table.Th>,
|
|
td: ({ children }) => <Table.Td>{children}</Table.Td>,
|
|
|
|
// Allow custom spans to pass through
|
|
span: ({ className, children }) => <span className={className}>{children}</span>,
|
|
}}
|
|
>
|
|
{content}
|
|
</ReactMarkdown>
|
|
);
|
|
}
|
|
|
|
export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) {
|
|
// 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;
|
|
}
|
|
|
|
return (
|
|
<div className={classes.markdown}>
|
|
{segments.map((segment, index) => {
|
|
if (segment.type === 'thinking') {
|
|
return (
|
|
<ThinkingBlock
|
|
key={`thinking-${index}`}
|
|
content={segment.content || ''}
|
|
isStreaming={segment.isStreaming}
|
|
/>
|
|
);
|
|
}
|
|
if (segment.type === 'toolGroup' && segment.toolCalls) {
|
|
return (
|
|
<ToolCallGroup
|
|
key={`toolgroup-${index}`}
|
|
toolCalls={segment.toolCalls}
|
|
isStreaming={segment.isStreaming}
|
|
/>
|
|
);
|
|
}
|
|
return <MarkdownContent key={`text-${index}`} content={segment.content || ''} />;
|
|
})}
|
|
</div>
|
|
);
|
|
}
|