Files
dashfront/src/components/chat/ChatFlowList.jsx
2026-10-06 00:19:28 +02:00

752 lines
20 KiB
JavaScript

import React, { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import HandshakeRoundedIcon from '@mui/icons-material/HandshakeRounded';
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import ComputerRoundedIcon from '@mui/icons-material/ComputerRounded';
import ImageSearchRoundedIcon from '@mui/icons-material/ImageSearchRounded';
import CompareRoundedIcon from '@mui/icons-material/CompareRounded';
import HealingMarkdown from './HealingMarkdown';
import {
CHROMA,
SHADOW_BUBBLE_ASSISTANT,
SHADOW_BUBBLE_USER,
SURFACE_RAISED,
} from '../../colors';
const TOOL_ICON_MAP = {
find_files: SearchRoundedIcon,
javascript_execute: ComputerRoundedIcon,
search_images: ImageSearchRoundedIcon,
analyze_image: CompareRoundedIcon,
};
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 formatUsageText(usage) {
if (!usage) return '';
const input = Number(usage.input ?? usage.prompt_tokens ?? 0);
const output = Number(usage.output ?? usage.completion_tokens ?? 0);
const cached = Number(usage.cached ?? 0);
const cost = typeof usage.cost === 'number' ? ` · $${usage.cost.toFixed(6)}` : '';
const fmt = (n) => n.toLocaleString('de-DE');
const cachedPart = cached > 0 ? ` / ${fmt(cached)} Cache` : '';
return `${fmt(input)} Prompt${cachedPart} · ${fmt(output)} Antwort${cost}`;
}
function formatToolTooltip(content, result, parameters, prefix) {
const parts = [];
if (prefix) {
parts.push(prefix);
}
if (parameters !== undefined && parameters !== null && parameters !== '') {
try {
const parsed = typeof parameters === 'string' ? JSON.parse(parameters) : parameters;
parts.push(typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : String(parameters));
} catch {
parts.push(String(parameters));
}
}
if (content !== undefined && content !== null && content !== '') {
try {
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
const str = typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : String(content);
if (!parts.includes(str)) {
parts.push(str);
}
} catch {
const str = String(content);
if (!parts.includes(str)) {
parts.push(str);
}
}
}
if (result !== undefined && result !== null) {
try {
const resStr = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result);
if (!parts.some((p) => p.includes(resStr))) {
parts.push(resStr);
}
} catch {}
}
return parts.join('\n\n') || '';
}
class ClickTooltip extends PureComponent {
state = { open: false };
handleClose = () => {
if (this.state.open) this.setState({ open: false });
};
handleToggle = (e) => {
e?.stopPropagation();
this.setState((prev) => ({ open: !prev.open }));
};
render() {
const { title, children } = this.props;
return (
<ClickAwayListener onClickAway={this.handleClose}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', height: 24 }}>
<Tooltip
open={this.state.open}
onClose={this.handleClose}
disableFocusListener
disableHoverListener
disableTouchListener
enterDelay={0}
leaveDelay={0}
TransitionProps={{ timeout: 0 }}
slotProps={{ transition: { timeout: 0 } }}
title={title}
arrow
placement="top"
>
<Box
onClick={this.handleToggle}
sx={{
display: 'inline-flex',
alignItems: 'center',
height: 24,
cursor: 'pointer',
}}
>
{children}
</Box>
</Tooltip>
</Box>
</ClickAwayListener>
);
}
}
function HandshakeIconView({ state }) {
const isGreen = state === 'green';
const isBlack = state === 'black';
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
color: isGreen
? CHROMA.GREEN.dark
: isBlack
? '#000000'
: '#9e9e9e',
transition: 'color 180ms ease-in-out',
}}
>
<HandshakeRoundedIcon sx={{ fontSize: 20, color: 'inherit' }} />
</Box>
);
}
function BrainIconView({ reasoning }) {
const tooltipContent = reasoning?.text?.trim() || '';
if (!tooltipContent) return null;
return (
<ClickTooltip
title={
<Box
sx={{
p: 0.5,
maxWidth: 440,
maxHeight: 280,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
fontSize: '0.75rem',
lineHeight: 1.45,
}}
>
{tooltipContent}
</Box>
}
>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
color: CHROMA.PURPLE.dark,
borderRadius: 1,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<PsychologyRoundedIcon sx={{ fontSize: 20, color: 'inherit' }} />
</Box>
</ClickTooltip>
);
}
function WorkIconView({ tool }) {
const isGreen = tool?.state === 'green';
const ToolNameIcon = tool?.name ? TOOL_ICON_MAP[tool.name] : null;
const prefix = ToolNameIcon ? tool?.name : null;
const tooltipText = formatToolTooltip(tool?.content, tool?.result, tool?.parameters, prefix);
return (
<ClickTooltip
title={
<Box
sx={{
p: 0.5,
maxWidth: 480,
maxHeight: 300,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: '0.72rem',
lineHeight: 1.4,
}}
>
{tooltipText}
</Box>
}
>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
height: 24,
gap: 0.5,
color: isGreen ? CHROMA.GREEN.dark : '#000000',
px: 0.25,
borderRadius: 1,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
{ToolNameIcon ? (
<ToolNameIcon sx={{ fontSize: 18, color: 'inherit' }} />
) : (
<>
<BuildRoundedIcon sx={{ fontSize: 18, color: 'inherit' }} />
{tool?.name ? (
<Typography
component="span"
sx={{
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: 'text.secondary',
lineHeight: 1,
}}
>
{tool.name}
</Typography>
) : null}
</>
)}
</Box>
</ClickTooltip>
);
}
function UsageChipView({ usage }) {
const text = formatUsageText(usage);
if (!text) return null;
return (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
fontSize: '0.68rem',
fontFamily: 'monospace',
color: 'text.secondary',
bgcolor: 'action.hover',
px: 0.8,
py: 0.25,
borderRadius: 1.5,
whiteSpace: 'nowrap',
}}
>
{text}
</Box>
);
}
export function TurnRow({ turn, isLive = false }) {
if (!turn) return null;
const hasReasonings = Boolean(
turn.reasonings &&
turn.reasonings.some((r) => Boolean(r.text && r.text.trim()))
);
const hasTools = Boolean(turn.tools && turn.tools.length > 0);
const hasContent = Boolean(turn.content);
const showHandshake = !hasReasonings && !hasTools && !hasContent;
const showStatusLine = showHandshake || hasReasonings || hasTools;
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
px: 2,
py: 0.4,
gap: 0.75,
maxWidth: '100%',
}}
>
{/* Turn status line: left of the chat */}
{showStatusLine ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
minHeight: 24,
lineHeight: 1,
}}
>
{showHandshake ? <HandshakeIconView state={turn.handshake} /> : null}
{turn.reasonings?.map((r, i) => (
<BrainIconView key={r.id || i} reasoning={r} />
))}
{turn.tools?.map((t, i) => (
<WorkIconView key={t.id || i} tool={t} />
))}
</Box>
) : null}
{/* Assistant message content for this turn (if any) */}
{hasContent ? (
<Box
sx={(theme) => ({
maxWidth: '85%',
px: 2,
py: 1.25,
borderRadius: '16px 16px 16px 4px',
bgcolor:
theme.palette.mode === 'dark'
? 'background.paper'
: SURFACE_RAISED,
color: theme.palette.text.primary,
boxShadow: SHADOW_BUBBLE_ASSISTANT,
border: `1px solid ${theme.palette.divider}`,
wordBreak: 'break-word',
fontSize: '0.875rem',
lineHeight: 1.5,
})}
>
<HealingMarkdown text={turn.content} streaming={isLive && !turn.sealed} />
</Box>
) : null}
{/* Pricing / usage is always on a new line */}
{turn.usage ? (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<UsageChipView usage={turn.usage} />
</Box>
) : null}
</Box>
);
}
export function normalizeChatMessages(rawMessages = []) {
if (!Array.isArray(rawMessages) || rawMessages.length === 0) return [];
const result = [];
let currentAssistantTurns = [];
let currentTurn = null;
const flushAssistant = () => {
if (currentTurn) {
if (
(currentTurn.reasonings && currentTurn.reasonings.length > 0) ||
(currentTurn.tools && currentTurn.tools.length > 0) ||
currentTurn.content ||
currentTurn.usage
) {
currentAssistantTurns.push(currentTurn);
}
currentTurn = null;
}
if (currentAssistantTurns.length > 0) {
const fullText = currentAssistantTurns
.map((t) => t.content)
.filter(Boolean)
.join('\n\n');
result.push({
id: currentAssistantTurns[0]?.id ? `asst-${currentAssistantTurns[0].id}` : `asst-${result.length}`,
role: 'assistant',
message: fullText,
turns: currentAssistantTurns,
created_at: currentAssistantTurns[0]?.created_at,
});
currentAssistantTurns = [];
}
};
let turnSeq = 0;
const getOrCreateTurn = (msg) => {
const shouldStartNew =
!currentTurn ||
currentTurn.sealed ||
(msg?.role === 'reasoning' && (currentTurn.tools.length > 0 || Boolean(currentTurn.content))) ||
(msg?.role === 'tool' && Boolean(currentTurn.content));
if (!currentTurn) {
turnSeq++;
currentTurn = {
id: msg?.id ? `turn-msg-${msg.id}` : `turn-${result.length}-${turnSeq}`,
handshake: 'green',
reasonings: [],
tools: [],
content: '',
usage: null,
sealed: false,
created_at: msg?.created_at,
};
} else if (shouldStartNew) {
currentAssistantTurns.push(currentTurn);
turnSeq++;
currentTurn = {
id: msg?.id ? `turn-msg-${msg.id}` : `turn-${result.length}-${turnSeq}`,
handshake: 'green',
reasonings: [],
tools: [],
content: '',
usage: null,
sealed: false,
created_at: msg?.created_at,
};
}
return currentTurn;
};
for (const msg of rawMessages) {
if (!msg) continue;
if (msg.turns && Array.isArray(msg.turns) && msg.turns.length > 0) {
flushAssistant();
result.push(msg);
continue;
}
const payload = parseMessagePayload(msg.message);
if (msg.role === 'user') {
flushAssistant();
result.push({
...msg,
message: payload.content !== undefined ? payload.content : msg.message,
});
continue;
}
if (msg.role === 'reasoning') {
const turn = getOrCreateTurn(msg);
const text =
payload.plain ||
payload.text ||
payload.content ||
payload.thinking ||
payload.reasoning ||
(typeof msg.message === 'string' ? msg.message : '');
turn.reasonings.push({
id: msg.id || `r-${turn.reasonings.length}`,
text,
sealed: true,
});
continue;
}
if (msg.role === 'tool') {
const turn = getOrCreateTurn(msg);
turn.tools.push({
id: payload.tool_call_id || msg.id,
state: 'green',
name: payload.name || '',
content: payload.content !== undefined ? payload.content : (payload.parameters || ''),
parameters: payload.parameters,
result: payload.result,
});
continue;
}
if (msg.role === 'usage') {
const turn = getOrCreateTurn(msg);
turn.usage = payload;
turn.sealed = true;
continue;
}
if (msg.role === 'assistant' || msg.role === 'model') {
const toolCalls = Array.isArray(payload.tool_calls) && payload.tool_calls.length > 0 ? payload.tool_calls : null;
if (toolCalls) {
const turn = getOrCreateTurn(msg);
for (const tc of toolCalls) {
let args = {};
try {
args = typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : (tc.function?.arguments || {});
} catch {}
turn.tools.push({
id: tc.id || `tc-${Date.now()}`,
state: 'green',
name: tc.function?.name || 'tool',
content: JSON.stringify(args, null, 2),
result: null,
});
}
}
const content = payload.content !== undefined ? payload.content : msg.message;
if (content && typeof content === 'string') {
const turn = getOrCreateTurn(msg);
turn.content = content;
}
continue;
}
flushAssistant();
result.push(msg);
}
flushAssistant();
return result;
}
function MessageRow({ msg, showTime = true }) {
// If the message has structured turns, render each turn
if (msg.turns && Array.isArray(msg.turns) && msg.turns.length > 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{msg.turns.map((t, idx) => (
<TurnRow key={t.id || idx} turn={t} isLive={false} />
))}
</Box>
);
}
const payload = parseMessagePayload(msg.message);
const textContent = payload.content !== undefined ? payload.content : msg.message;
if (msg.role === 'system') {
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>
);
}
const isUser = msg.role === 'user';
const timeStr = showTime ? formatMessageTime(msg.created_at) : '';
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'
: SURFACE_RAISED,
color: isUser ? theme.palette.primary.contrastText : theme.palette.text.primary,
boxShadow: isUser ? SHADOW_BUBBLE_USER : SHADOW_BUBBLE_ASSISTANT,
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.props.activeTurns !== prevProps.activeTurns
) {
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 = [], activeTurns = [] } = this.props;
const normalizedMessages = normalizeChatMessages(messages);
if (normalizedMessages.length === 0 && activeTurns.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,
}}
>
{normalizedMessages.map((msg, index) => {
const prevMsg = index > 0 ? normalizedMessages[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}
/>
);
})}
{/* Live streaming turns */}
{activeTurns && activeTurns.length > 0
? activeTurns.map((turn, idx) => (
<TurnRow key={turn.id || idx} turn={turn} isLive={true} />
))
: null}
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
</Box>
);
}
}