2025-02-04 13:39:50 -05:00
|
|
|
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
|
|
|
import { Box, Typography, Paper, Avatar, IconButton } from '@mui/material';
|
|
|
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
2025-02-04 01:30:36 -05:00
|
|
|
import type { DocumentMetadata } from '../../../electron/types';
|
|
|
|
import ReactMarkdown from 'react-markdown';
|
|
|
|
import rehypeHighlight from 'rehype-highlight';
|
|
|
|
import 'highlight.js/styles/github.css';
|
|
|
|
|
|
|
|
interface ChatMessage {
|
|
|
|
id: string;
|
|
|
|
text: string;
|
|
|
|
isUser: boolean;
|
|
|
|
timestamp: string;
|
|
|
|
sources?: DocumentMetadata[];
|
|
|
|
avatar?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface MessageListProps {
|
|
|
|
messages: ChatMessage[];
|
|
|
|
}
|
|
|
|
|
|
|
|
export default function MessageList({ messages }: MessageListProps) {
|
|
|
|
const messageListRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (messageListRef.current) {
|
|
|
|
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
|
|
|
|
}
|
|
|
|
}, [messages]);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Box
|
|
|
|
ref={messageListRef}
|
|
|
|
sx={{
|
|
|
|
flex: 1,
|
|
|
|
overflow: 'auto',
|
|
|
|
p: 2,
|
|
|
|
display: 'flex',
|
|
|
|
flexDirection: 'column',
|
|
|
|
gap: 2,
|
|
|
|
scrollBehavior: 'smooth',
|
|
|
|
'&::-webkit-scrollbar': {
|
|
|
|
width: '8px',
|
|
|
|
background: 'transparent',
|
|
|
|
},
|
|
|
|
'&::-webkit-scrollbar-thumb': {
|
|
|
|
background: (theme) => theme.palette.divider,
|
|
|
|
borderRadius: '4px',
|
|
|
|
'&:hover': {
|
|
|
|
background: (theme) => theme.palette.action.hover,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
'&::-webkit-scrollbar-track': {
|
|
|
|
background: 'transparent',
|
|
|
|
},
|
|
|
|
}}>
|
|
|
|
{messages.map((message) => (
|
|
|
|
<Box
|
|
|
|
key={message.id}
|
|
|
|
sx={{
|
|
|
|
display: 'flex',
|
|
|
|
flexDirection: message.isUser ? 'row-reverse' : 'row',
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
gap: 2,
|
2025-02-04 13:39:50 -05:00
|
|
|
maxWidth: message.isUser ? '80%' : '100%',
|
|
|
|
alignSelf: message.isUser ? 'flex-end' : 'stretch',
|
2025-02-04 01:30:36 -05:00
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
|
|
|
|
<Avatar
|
|
|
|
src={message.isUser ? '/profiles/user-profile.webp' : '/profiles/ai-profile.webp'}
|
|
|
|
alt={message.isUser ? 'User' : 'AI'}
|
|
|
|
variant="square"
|
|
|
|
sx={{
|
|
|
|
width: 40,
|
|
|
|
height: 40,
|
|
|
|
boxShadow: 1
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
<Typography
|
|
|
|
variant="caption"
|
|
|
|
sx={{
|
|
|
|
fontSize: '0.75rem',
|
|
|
|
color: 'text.secondary',
|
|
|
|
fontWeight: 500
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{message.isUser ? 'User' : 'Data Hound'}
|
|
|
|
</Typography>
|
|
|
|
</Box>
|
|
|
|
<Box
|
|
|
|
component={Paper}
|
|
|
|
elevation={1}
|
|
|
|
sx={{
|
2025-02-04 13:39:50 -05:00
|
|
|
p: 2.5,
|
2025-02-04 01:30:36 -05:00
|
|
|
flex: 1,
|
|
|
|
bgcolor: message.isUser ? 'primary.main' : 'background.paper',
|
|
|
|
color: message.isUser ? 'primary.contrastText' : 'text.primary',
|
|
|
|
transition: 'all 0.2s ease-in-out',
|
|
|
|
boxShadow: 1,
|
|
|
|
borderRadius: 2,
|
|
|
|
'&:hover': {
|
|
|
|
boxShadow: 2,
|
|
|
|
},
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Box sx={{
|
|
|
|
'& .markdown-body': {
|
|
|
|
whiteSpace: 'pre-wrap',
|
|
|
|
wordBreak: 'break-word',
|
|
|
|
minHeight: '1.5em',
|
|
|
|
lineHeight: 1.6,
|
|
|
|
'& pre': {
|
|
|
|
backgroundColor: (theme) => theme.palette.mode === 'dark' ? '#1e1e1e' : '#f6f8fa',
|
|
|
|
padding: 2,
|
|
|
|
borderRadius: 1,
|
|
|
|
overflow: 'auto'
|
|
|
|
},
|
|
|
|
'& code': {
|
|
|
|
backgroundColor: (theme) => theme.palette.mode === 'dark' ? '#1e1e1e' : '#f6f8fa',
|
|
|
|
padding: '0.2em 0.4em',
|
|
|
|
borderRadius: 1,
|
|
|
|
fontSize: '85%'
|
|
|
|
},
|
|
|
|
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
|
|
|
marginTop: '24px',
|
|
|
|
marginBottom: '16px',
|
|
|
|
fontWeight: 600,
|
|
|
|
lineHeight: 1.25
|
|
|
|
},
|
|
|
|
'& p': {
|
|
|
|
marginTop: '0',
|
|
|
|
marginBottom: '16px'
|
|
|
|
},
|
|
|
|
'& a': {
|
|
|
|
color: (theme) => theme.palette.primary.main,
|
|
|
|
textDecoration: 'none',
|
|
|
|
'&:hover': {
|
|
|
|
textDecoration: 'underline'
|
|
|
|
}
|
|
|
|
},
|
|
|
|
'& img': {
|
|
|
|
maxWidth: '100%',
|
|
|
|
height: 'auto'
|
|
|
|
},
|
|
|
|
'& blockquote': {
|
|
|
|
padding: '0 1em',
|
|
|
|
color: (theme) => theme.palette.text.secondary,
|
|
|
|
borderLeft: (theme) => `0.25em solid ${theme.palette.divider}`,
|
|
|
|
margin: '0 0 16px 0'
|
|
|
|
},
|
|
|
|
'& ul, & ol': {
|
|
|
|
paddingLeft: '2em',
|
|
|
|
marginBottom: '16px'
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}}>
|
2025-02-04 13:39:50 -05:00
|
|
|
{message.text.includes('<think>') ? (
|
|
|
|
message.text.split(/<think>|<\/think>/).map((segment, index) => {
|
|
|
|
if (index % 2 === 1) { // This is a thinking section
|
|
|
|
// Calculate thinking time - assume 1 character = 0.1s
|
|
|
|
const thinkingTime = (segment.length * 0.1).toFixed(1);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div key={index} className="thinking-section">
|
|
|
|
<div
|
|
|
|
className="thinking-header"
|
|
|
|
onClick={() => {
|
|
|
|
const content = document.getElementById(`thinking-content-${message.id}-${index}`);
|
|
|
|
const indicator = document.getElementById(`thinking-indicator-${message.id}-${index}`);
|
|
|
|
if (content && indicator) {
|
|
|
|
content.classList.toggle('open');
|
|
|
|
indicator.classList.toggle('open');
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<div className="thinking-header-left">
|
|
|
|
<ChevronRightIcon
|
|
|
|
id={`thinking-indicator-${message.id}-${index}`}
|
|
|
|
className="indicator"
|
|
|
|
/>
|
|
|
|
<span>Reasoning</span>
|
|
|
|
</div>
|
|
|
|
<span className="timestamp">(thought for {thinkingTime}s)</span>
|
|
|
|
</div>
|
|
|
|
<div
|
|
|
|
id={`thinking-content-${message.id}-${index}`}
|
|
|
|
className="thinking-content"
|
|
|
|
>
|
|
|
|
{segment}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
} else if (segment.includes('Sorry, there was an error processing your request.')) {
|
|
|
|
return (
|
|
|
|
<div key={index} className="error-message">
|
|
|
|
{segment}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return (
|
|
|
|
<ReactMarkdown
|
|
|
|
key={index}
|
|
|
|
className="markdown-body"
|
|
|
|
rehypePlugins={[rehypeHighlight]}
|
|
|
|
>
|
|
|
|
{segment}
|
|
|
|
</ReactMarkdown>
|
|
|
|
);
|
|
|
|
})
|
|
|
|
) : (
|
|
|
|
<ReactMarkdown
|
|
|
|
className="markdown-body"
|
|
|
|
rehypePlugins={[rehypeHighlight]}
|
|
|
|
>
|
|
|
|
{message.text}
|
|
|
|
</ReactMarkdown>
|
|
|
|
)}
|
2025-02-04 01:30:36 -05:00
|
|
|
</Box>
|
|
|
|
{message.sources && message.sources.length > 0 && (
|
2025-02-04 13:39:50 -05:00
|
|
|
<Box sx={{ mt: 1, pt: 0, borderTop: '1px solid', borderColor: 'divider' }}>
|
2025-02-04 01:30:36 -05:00
|
|
|
<Typography
|
|
|
|
variant="caption"
|
|
|
|
color="text.secondary"
|
|
|
|
sx={{
|
|
|
|
display: 'block',
|
|
|
|
mb: 0.5,
|
|
|
|
fontWeight: 'medium'
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
Sources:
|
|
|
|
</Typography>
|
|
|
|
{message.sources.map((source, index) => (
|
|
|
|
<Typography
|
|
|
|
key={index}
|
|
|
|
variant="caption"
|
|
|
|
component="div"
|
|
|
|
color="text.secondary"
|
|
|
|
sx={{
|
|
|
|
pl: 1,
|
|
|
|
borderLeft: '2px solid',
|
|
|
|
borderColor: 'divider'
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{source.path}
|
|
|
|
</Typography>
|
|
|
|
))}
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
<Typography
|
|
|
|
variant="caption"
|
|
|
|
color={message.isUser ? '#ffffff' : 'text.secondary'}
|
|
|
|
sx={{ display: 'block', mt: 0.5 }}
|
|
|
|
>
|
|
|
|
{new Date(message.timestamp).toLocaleTimeString()}
|
|
|
|
</Typography>
|
|
|
|
</Box>
|
|
|
|
</Box>
|
|
|
|
))}
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|