This commit is contained in:
seb
2026-10-04 02:13:50 +02:00
parent 0d0c5dfdfb
commit f2fce9097b
19 changed files with 3087 additions and 9 deletions

View 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>
);
}
}