u
This commit is contained in:
381
src/components/chat/ChatFlowList.jsx
Normal file
381
src/components/chat/ChatFlowList.jsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import React, { PureComponent, createRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import HealingMarkdown from './HealingMarkdown';
|
||||
|
||||
function formatMessageTime(isoString) {
|
||||
if (!isoString) return '';
|
||||
try {
|
||||
const d = new Date(isoString);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function parseMessagePayload(raw) {
|
||||
if (typeof raw !== 'string') return { content: raw };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') return parsed;
|
||||
} catch {}
|
||||
return { content: raw };
|
||||
}
|
||||
|
||||
function MessageRow({ msg, showTime = true }) {
|
||||
|
||||
const payload = parseMessagePayload(msg.message);
|
||||
const textContent = payload.content !== undefined ? payload.content : msg.message;
|
||||
const toolCalls = Array.isArray(payload.tool_calls) && payload.tool_calls.length > 0 ? payload.tool_calls : null;
|
||||
|
||||
const isUser = msg.role === 'user';
|
||||
const isSystem = msg.role === 'system';
|
||||
const isTool = msg.role === 'tool';
|
||||
const isReasoning = msg.role === "reasoning" || Boolean(payload.reasoning_content && !toolCalls && payload.content === payload.reasoning_content);
|
||||
const timeStr = showTime ? formatMessageTime(msg.created_at) : '';
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 0.75, px: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'action.hover',
|
||||
color: 'text.secondary',
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: 4,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
maxWidth: '85%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{textContent}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isReasoning) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.5, px: 2 }}>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
bgcolor: 'transparent',
|
||||
color: 'text.disabled',
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
borderRadius: 2,
|
||||
fontSize: '0.75rem',
|
||||
fontStyle: 'italic',
|
||||
maxWidth: '85%',
|
||||
borderLeft: `2px solid ${theme.palette.divider}`,
|
||||
whiteSpace: 'pre-wrap',
|
||||
})}
|
||||
>
|
||||
<Box sx={{ fontWeight: 600, fontSize: '0.68rem', mb: 0.25 }}>Gedankengang</Box>
|
||||
{textContent}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolCalls) {
|
||||
const formattedCalls = toolCalls.map((tc) => {
|
||||
let args = {};
|
||||
try {
|
||||
if (typeof tc.function?.arguments === 'string') {
|
||||
args = JSON.parse(tc.function.arguments);
|
||||
} else if (tc.function?.arguments) {
|
||||
args = tc.function.arguments;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (tc.function?.name === 'ask_user') {
|
||||
const q = args.question || '';
|
||||
return `ask_user: ${q}`;
|
||||
}
|
||||
|
||||
const method = args.method || (tc.function?.name ? tc.function.name.toUpperCase() : 'CALL');
|
||||
const path = args.path || args.url || '';
|
||||
if (path) {
|
||||
return `${method} ${path}`;
|
||||
}
|
||||
return `${tc.function?.name || 'tool'} ${JSON.stringify(args)}`;
|
||||
}).join('\n');
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
|
||||
color: 'text.secondary',
|
||||
px: 1.25,
|
||||
py: 0.35,
|
||||
borderRadius: 2,
|
||||
fontSize: '0.72rem',
|
||||
maxWidth: '90%',
|
||||
fontFamily: 'monospace',
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
})}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.65rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
color: 'text.disabled',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Werkzeug
|
||||
</Box>
|
||||
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{formattedCalls}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isTool) {
|
||||
let statusText = '';
|
||||
let bodyLength = null;
|
||||
|
||||
let resObj = null;
|
||||
if (typeof textContent === 'object' && textContent !== null) {
|
||||
resObj = textContent;
|
||||
} else if (typeof textContent === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(textContent);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
resObj = parsed;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (resObj && ('status' in resObj || 'statusCode' in resObj || 'body' in resObj)) {
|
||||
const status = resObj.status ?? resObj.statusCode ?? 200;
|
||||
statusText = `Status ${status}`;
|
||||
if (typeof resObj.body === 'string') {
|
||||
bodyLength = resObj.body.length;
|
||||
} else if (resObj.body != null) {
|
||||
bodyLength = JSON.stringify(resObj.body).length;
|
||||
} else if (resObj.data != null) {
|
||||
bodyLength = JSON.stringify(resObj.data).length;
|
||||
}
|
||||
} else if (typeof textContent === 'string') {
|
||||
bodyLength = textContent.length;
|
||||
}
|
||||
|
||||
let displayText = '';
|
||||
if (resObj && ('answer' in resObj || 'content' in resObj) && ('tool_call_id' in resObj) && !('status' in resObj)) {
|
||||
displayText = resObj.answer || resObj.content || '';
|
||||
} else {
|
||||
displayText = statusText
|
||||
? `${statusText}${bodyLength != null ? ` · ${bodyLength} byte` : ''}`
|
||||
: (bodyLength != null ? `${bodyLength} byte` : String(textContent));
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
|
||||
color: 'text.secondary',
|
||||
px: 1.25,
|
||||
py: 0.35,
|
||||
borderRadius: 2,
|
||||
fontSize: '0.72rem',
|
||||
maxWidth: '90%',
|
||||
fontFamily: 'monospace',
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
})}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.65rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
color: 'text.disabled',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Ergebnis
|
||||
</Box>
|
||||
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{displayText}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: isUser ? 'flex-end' : 'flex-start',
|
||||
px: 2,
|
||||
py: 0.75,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: '85%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: isUser ? 'flex-end' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
{timeStr ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 0.3,
|
||||
px: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.68rem' }}>
|
||||
{timeStr}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
|
||||
bgcolor: isUser
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.mode === 'dark'
|
||||
? 'background.paper'
|
||||
: '#f4f6f8',
|
||||
color: isUser
|
||||
? theme.palette.primary.contrastText
|
||||
: theme.palette.text.primary,
|
||||
boxShadow: isUser
|
||||
? '0 2px 8px rgba(59, 91, 219, 0.25)'
|
||||
: '0 1px 4px rgba(0, 0, 0, 0.05)',
|
||||
border: isUser ? 'none' : `1px solid ${theme.palette.divider}`,
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: isUser ? 'pre-wrap' : 'normal',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.5,
|
||||
})}
|
||||
>
|
||||
{isUser ? (
|
||||
textContent
|
||||
) : (
|
||||
<HealingMarkdown text={textContent} streaming={msg.streaming} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default class ChatFlowList extends PureComponent {
|
||||
containerRef = createRef();
|
||||
bottomRef = createRef();
|
||||
scrollTimeout = null;
|
||||
|
||||
componentDidMount() {
|
||||
this.scrollToBottom('auto');
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
this.props.messages !== prevProps.messages ||
|
||||
this.props.messages?.length !== prevProps.messages?.length
|
||||
) {
|
||||
this.scrollToBottom('smooth');
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.scrollTimeout) {
|
||||
clearTimeout(this.scrollTimeout);
|
||||
this.scrollTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom = (behavior = 'smooth') => {
|
||||
const doScroll = () => {
|
||||
if (this.bottomRef.current?.scrollIntoView) {
|
||||
this.bottomRef.current.scrollIntoView({ behavior, block: 'end' });
|
||||
}
|
||||
if (this.containerRef.current) {
|
||||
this.containerRef.current.scrollTop = this.containerRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// Immediate frame
|
||||
requestAnimationFrame(doScroll);
|
||||
|
||||
// Delayed fallback for font/layout reflows
|
||||
if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
|
||||
this.scrollTimeout = setTimeout(doScroll, 60);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { messages = [] } = this.props;
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={this.containerRef}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
py: 1,
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
{messages.map((msg, index) => {
|
||||
const prevMsg = index > 0 ? messages[index - 1] : null;
|
||||
const currTime = formatMessageTime(msg.created_at);
|
||||
const prevTime = prevMsg ? formatMessageTime(prevMsg.created_at) : '';
|
||||
const showTime = Boolean(currTime && currTime !== prevTime);
|
||||
|
||||
return (
|
||||
<MessageRow
|
||||
key={msg.id || index}
|
||||
msg={msg}
|
||||
showTime={showTime}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user