changes
This commit is contained in:
@@ -528,6 +528,7 @@ export default function ChatLayout() {
|
||||
};
|
||||
|
||||
const handleRemoveChat = async (chatId: string) => {
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!window.confirm('Are you sure you want to delete this chat?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.markdown {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -14,19 +13,20 @@
|
||||
.codeBlock {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fadeIn {
|
||||
animation: textFadeIn 0.3s ease-out forwards;
|
||||
animation: text-fade-in 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes textFadeIn {
|
||||
@keyframes text-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
+257
-154
@@ -18,6 +18,7 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { ToolCallDisplay } from './ToolCallDisplay';
|
||||
import classes from './MarkdownMessage.module.css';
|
||||
// Import KaTeX CSS for LaTeX rendering
|
||||
import 'katex/dist/katex.min.css';
|
||||
@@ -27,6 +28,71 @@ interface MarkdownMessageProps {
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
interface ParsedToolCall {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result: string;
|
||||
}
|
||||
|
||||
interface ContentSegment {
|
||||
type: 'text' | 'tool';
|
||||
content?: string;
|
||||
toolCall?: ParsedToolCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content to extract tool calls and regular text segments
|
||||
*/
|
||||
function parseContentWithToolCalls(content: string): ContentSegment[] {
|
||||
const segments: ContentSegment[] = [];
|
||||
const toolPattern = /<!--TOOL_START:(\w+):(\{.*?\})-->([\s\S]*?)<!--TOOL_END-->/g;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = toolPattern.exec(content)) !== null) {
|
||||
// Add text before this tool call
|
||||
if (match.index > lastIndex) {
|
||||
const textBefore = content.slice(lastIndex, match.index).trim();
|
||||
if (textBefore) {
|
||||
segments.push({ type: 'text', content: textBefore });
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the tool call
|
||||
const toolName = match[1];
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = JSON.parse(match[2]);
|
||||
} catch {
|
||||
// Invalid JSON, use empty args
|
||||
}
|
||||
const result = match[3].trim();
|
||||
|
||||
segments.push({
|
||||
type: 'tool',
|
||||
toolCall: { toolName, args, result },
|
||||
});
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text after last tool call
|
||||
if (lastIndex < content.length) {
|
||||
const remainingText = content.slice(lastIndex).trim();
|
||||
if (remainingText) {
|
||||
segments.push({ type: 'text', content: remainingText });
|
||||
}
|
||||
}
|
||||
|
||||
// If no tool calls found, return the whole content as text
|
||||
if (segments.length === 0 && content.trim()) {
|
||||
segments.push({ type: 'text', content });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that tracks content changes and returns content with fade-in markers for new text
|
||||
*/
|
||||
@@ -90,165 +156,202 @@ function escapeHtml(text: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip tool call markers from content for streaming display
|
||||
*/
|
||||
function stripToolMarkers(content: string): string {
|
||||
return content.replace(/<!--TOOL_START:\w+:\{.*?\}-->/g, '').replace(/<!--TOOL_END-->/g, '');
|
||||
}
|
||||
|
||||
function MarkdownContent({ content }: { content: string }) {
|
||||
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="sm" fw={700} mb="xs">
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
|
||||
// Paragraphs and text
|
||||
p: ({ children }) => (
|
||||
<Text size="sm" 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="sm" mb="xs">
|
||||
{children}
|
||||
</List>
|
||||
);
|
||||
},
|
||||
ol: ({ children }) => (
|
||||
<List type="ordered" size="sm" 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 the fade-in spans to pass through
|
||||
span: ({ className, children }) => <span className={className}>{children}</span>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) {
|
||||
const processedContent = useStreamingContent(content, isStreaming);
|
||||
|
||||
// During streaming, just show the raw content (with tool markers stripped)
|
||||
if (isStreaming) {
|
||||
return (
|
||||
<div className={classes.markdown}>
|
||||
<MarkdownContent content={stripToolMarkers(processedContent)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// When not streaming, parse and render tool calls
|
||||
const segments = parseContentWithToolCalls(content);
|
||||
|
||||
return (
|
||||
<div className={classes.markdown}>
|
||||
<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="sm" fw={700} mb="xs">
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
|
||||
// Paragraphs and text
|
||||
p: ({ children }) => (
|
||||
<Text size="sm" 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="sm" mb="xs">
|
||||
{children}
|
||||
</List>
|
||||
);
|
||||
},
|
||||
ol: ({ children }) => (
|
||||
<List type="ordered" size="sm" 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 the fade-in spans to pass through
|
||||
span: ({ className, children }) => <span className={className}>{children}</span>,
|
||||
}}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === 'tool' && segment.toolCall) {
|
||||
return (
|
||||
<ToolCallDisplay
|
||||
key={`tool-${index}`}
|
||||
toolName={segment.toolCall.toolName}
|
||||
args={segment.toolCall.args}
|
||||
result={segment.toolCall.result}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <MarkdownContent key={`text-${index}`} content={segment.content || ''} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.container {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.header:hover {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 var(--mantine-spacing-xs) var(--mantine-spacing-xs);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IconChevronDown, IconChevronRight, IconTool } from '@tabler/icons-react';
|
||||
import { ActionIcon, Code, Collapse, Group, Paper, Text, useMantineTheme } from '@mantine/core';
|
||||
import { useThemeContext } from '@/components/DynamicThemeProvider';
|
||||
import classes from './ToolCallDisplay.module.css';
|
||||
|
||||
interface ToolCallDisplayProps {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result: string;
|
||||
}
|
||||
|
||||
// Friendly tool names
|
||||
const toolDisplayNames: Record<string, string> = {
|
||||
calculator: 'Calculator',
|
||||
get_current_datetime: 'Date/Time',
|
||||
fetch_url: 'Fetch URL',
|
||||
web_search: 'Web Search',
|
||||
execute_code: 'Code Execution',
|
||||
read_file: 'Read File',
|
||||
write_file: 'Write File',
|
||||
get_weather: 'Weather',
|
||||
generate_image: 'Image Generation',
|
||||
};
|
||||
|
||||
export function ToolCallDisplay({ toolName, args, result }: ToolCallDisplayProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { primaryColor } = useThemeContext();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const displayName = toolDisplayNames[toolName] || toolName;
|
||||
const isError = result.startsWith('Error:');
|
||||
|
||||
// Format args for display
|
||||
const argsDisplay = Object.entries(args)
|
||||
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<Paper className={classes.container} withBorder radius="sm" p={0} my="xs">
|
||||
<Group
|
||||
className={classes.header}
|
||||
onClick={() => setOpened(!opened)}
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<ActionIcon variant="subtle" color={primaryColor} size="xs">
|
||||
{opened ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
<IconTool size={16} color={theme.colors[primaryColor][6]} />
|
||||
<Text size="sm" fw={500} c={primaryColor}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{!opened && argsDisplay && (
|
||||
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
|
||||
{argsDisplay}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Collapse in={opened}>
|
||||
<div className={classes.content}>
|
||||
{Object.keys(args).length > 0 && (
|
||||
<div className={classes.section}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
Arguments:
|
||||
</Text>
|
||||
<Code block className={classes.code}>
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</Code>
|
||||
</div>
|
||||
)}
|
||||
<div className={classes.section}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
Result:
|
||||
</Text>
|
||||
<Code block className={classes.code} c={isError ? 'red' : undefined}>
|
||||
{result}
|
||||
</Code>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user