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 SupportIcon from '@mui/icons-material/Support'; import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'; import PhonelinkIcon from '@mui/icons-material/Phonelink'; import SaveAsIcon from '@mui/icons-material/SaveAs'; import DescriptionIcon from '@mui/icons-material/Description'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import DeleteIcon from '@mui/icons-material/Delete'; import GoogleIcon from '@mui/icons-material/Google'; import WebIcon from '@mui/icons-material/Web'; import EscalatorWarningIcon from '@mui/icons-material/EscalatorWarning'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import ManageSearchIcon from '@mui/icons-material/ManageSearch'; import LensBlurIcon from '@mui/icons-material/LensBlur'; import CalculateIcon from '@mui/icons-material/Calculate'; import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; import DirectionsIcon from '@mui/icons-material/Directions'; import HealingMarkdown from './HealingMarkdown'; import { VfsAttachmentLink, ExtractionPill, parseAttachmentsFromText, parseExtractionsFromText, stripUserEnv, } from './ChatAttachmentItems'; import { CHROMA, SHADOW_BUBBLE_ASSISTANT, SHADOW_BUBBLE_USER, SURFACE_RAISED, } from '../../colors'; class GoogleFaviconIcon extends PureComponent { state = { hasError: false }; handleError = () => { this.setState({ hasError: true }); }; render() { const { sx } = this.props; if (this.state.hasError) { return ; } return ( ); } } const TOOL_ICON_MAP = { find_files: SearchRoundedIcon, javascript_execute: ComputerRoundedIcon, search_images: ImageSearchRoundedIcon, analyze_image: CompareRoundedIcon, apisuggestionsorissues: SupportIcon, ask_user: RecordVoiceOverIcon, dashback_request: PhonelinkIcon, patch: SaveAsIcon, read_file: DescriptionIcon, reset_vfs: DeleteForeverIcon, rm: DeleteIcon, web_search: GoogleFaviconIcon, url_get: WebIcon, sub_agent: EscalatorWarningIcon, pdf_read: PictureAsPdfIcon, search_articles: ManageSearchIcon, edit_image: LensBlurIcon, net_gross_calc: CalculateIcon, ean_checksum: QrCodeScannerIcon, 'set-user-route': DirectionsIcon, set_user_route: DirectionsIcon, }; 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, error) { 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 && result !== '') { try { const resStr = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result); if (!parts.includes(resStr)) { parts.push(resStr); } } catch { const resStr = String(result); if (!parts.includes(resStr)) { parts.push(resStr); } } } if (error !== undefined && error !== null && error !== '') { try { const errStr = typeof error === 'object' ? JSON.stringify(error, null, 2) : String(error); if (!parts.includes(errStr)) { parts.push(errStr); } } catch { const errStr = String(error); if (!parts.includes(errStr)) { parts.push(errStr); } } } 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 ( {children} ); } } function HandshakeIconView({ state }) { const isGreen = state === 'green'; const isBlack = state === 'black'; return ( ); } function extractReasoningText(msg, payload) { if (payload && typeof payload === 'object') { if (payload.plain !== undefined) { if (typeof payload.plain === 'string' && payload.plain.trim()) { return payload.plain.trim(); } return ''; } if (payload.encrypted) { return ''; } const candidate = payload.text || payload.content || payload.thinking || payload.reasoning; if (candidate && typeof candidate === 'string') { const trimmed = candidate.trim(); if (trimmed.startsWith('{') && (trimmed.includes('"encrypted"') || trimmed.includes('"plain"'))) { try { const inner = JSON.parse(trimmed); if (inner.plain && typeof inner.plain === 'string' && inner.plain.trim()) { return inner.plain.trim(); } } catch { } return ''; } return trimmed; } } if (typeof msg?.message === 'string') { const raw = msg.message.trim(); if (raw.startsWith('{') && (raw.includes('"encrypted"') || raw.includes('"plain"'))) { try { const parsed = JSON.parse(raw); if (parsed.plain && typeof parsed.plain === 'string' && parsed.plain.trim()) { return parsed.plain.trim(); } } catch { } return ''; } if (!raw.startsWith('{')) { return raw; } } return ''; } class ReasoningTooltipContent extends PureComponent { boxRef = createRef(); scrollToBottom = () => { const el = this.boxRef.current; if (el) { el.scrollTop = el.scrollHeight; requestAnimationFrame(() => { if (this.boxRef.current) { this.boxRef.current.scrollTop = this.boxRef.current.scrollHeight; } }); } }; componentDidMount() { this.scrollToBottom(); } componentDidUpdate(prevProps) { if (this.props.text !== prevProps.text) { this.scrollToBottom(); } } render() { const { text } = this.props; return ( {text} ); } } function BrainIconView({ reasoning }) { const tooltipContent = reasoning?.text?.trim() || ''; if (!tooltipContent) return null; if (tooltipContent.startsWith('{') && (tooltipContent.includes('"encrypted"') || tooltipContent.includes('"plain"'))) { return null; } return ( } > ); } function extractUrlFromTool(tool) { if (!tool) return null; const candidates = [tool.parameters, tool.arguments, tool.content, tool.result]; for (const item of candidates) { if (!item) continue; if (typeof item === 'object') { const u = item.url || item.Url || item.uri || item.URI || item.link; if (typeof u === 'string' && u.trim()) return u.trim(); } const str = typeof item === 'string' ? item : JSON.stringify(item); const match = str.match(/https?:\/\/[^\s"'`<>{}|\\]+/); if (match) { return match[0].replace(/[.,;:)]+$/, ''); } } return null; } class UrlFaviconIcon extends PureComponent { state = { srcIndex: 0, hasError: false, }; sources = []; constructor(props) { super(props); this.sources = this.buildSources(props.url); } componentDidUpdate(prevProps) { if (this.props.url !== prevProps.url) { this.sources = this.buildSources(this.props.url); this.setState({ srcIndex: 0, hasError: false }); } } buildSources(urlStr) { if (!urlStr || typeof urlStr !== 'string') return []; try { const parsed = new URL( urlStr.startsWith('http://') || urlStr.startsWith('https://') ? urlStr : `https://${urlStr}` ); const hostname = parsed.hostname; const origin = parsed.origin; if (!hostname) return []; const list = []; if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.endsWith('.local')) { list.push(`${origin}/favicon.ico`); } else { list.push(`https://icons.duckduckgo.com/ip3/${hostname}.ico`); list.push(`${origin}/favicon.ico`); } return list; } catch { return []; } } handleError = () => { if (this.state.srcIndex + 1 < this.sources.length) { this.setState((prev) => ({ srcIndex: prev.srcIndex + 1 })); } else { this.setState({ hasError: true }); } }; render() { const { sx } = this.props; const { srcIndex, hasError } = this.state; const currentSrc = this.sources[srcIndex]; if (!currentSrc || hasError) { return ; } return ( ); } } function WorkIconView({ tool }) { if (!tool || !tool.name) return null; const isGreen = tool?.state === 'green'; const isUrlGet = tool?.name === 'url_get'; 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 || tool?.arguments, prefix); const targetUrl = isUrlGet ? extractUrlFromTool(tool) : null; return ( {tooltipText} } > {isUrlGet ? ( ) : ToolNameIcon ? ( ) : ( <> {tool?.name ? ( {tool.name} ) : null} )} ); } function UsageChipView({ usage }) { const text = formatUsageText(usage); if (!text) return null; return ( {text} ); } 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 ( {/* Turn status line: left of the chat */} {showStatusLine ? ( {showHandshake ? : null} {turn.reasonings?.map((r, i) => ( ))} {turn.tools?.map((t, i) => ( ))} ) : null} {/* Assistant message content for this turn (if any) */} {hasContent ? ( ({ 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, })} > ) : null} {/* Pricing / usage is always on a new line */} {turn.usage ? ( ) : null} ); } 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 findExistingTool = (toolCallId) => { if (!toolCallId) return null; if (currentTurn?.tools) { const t = currentTurn.tools.find((tool) => tool.id === toolCallId); if (t) return t; } for (let i = currentAssistantTurns.length - 1; i >= 0; i--) { const t = currentAssistantTurns[i].tools?.find((tool) => tool.id === toolCallId); if (t) return t; } return null; }; 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); const userText = payload.content !== undefined ? payload.content : msg.message; if (msg.role === 'user') { flushAssistant(); result.push({ ...msg, message: userText, }); continue; } if (msg.role === 'reasoning') { const text = extractReasoningText(msg, payload); if (text) { const turn = getOrCreateTurn(msg); turn.reasonings.push({ id: msg.id || `r-${turn.reasonings.length}`, text, sealed: true, }); } continue; } if (msg.role === 'tool') { const existingTool = payload.tool_call_id ? findExistingTool(payload.tool_call_id) : null; const isAskUser = payload.name === 'ask_user' || existingTool?.name === 'ask_user'; if (!isAskUser && existingTool) { existingTool.result = payload.result !== undefined ? payload.result : (payload.content !== undefined ? payload.content : ''); if (payload.parameters) { existingTool.parameters = payload.parameters; } if (payload.name && !existingTool.name) { existingTool.name = payload.name; } continue; } const turn = getOrCreateTurn(msg); turn.tools.push({ id: payload.tool_call_id || msg.id, state: 'green', name: isAskUser ? 'ask_user' : (payload.name || ''), content: payload.content !== undefined ? payload.content : (payload.answer || 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 { } const isAskUser = tc.function?.name === 'ask_user'; const existing = !isAskUser && tc.id ? findExistingTool(tc.id) : null; if (existing) { existing.parameters = tc.function?.arguments || args; if (tc.function?.name && !existing.name) existing.name = tc.function.name; } else { turn.tools.push({ id: tc.id || `tc-${Date.now()}`, state: 'green', name: tc.function?.name || 'tool', parameters: tc.function?.arguments || args, content: null, 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 ( {msg.turns.map((t, idx) => ( ))} ); } const payload = parseMessagePayload(msg.message); let textContent = payload.content !== undefined ? payload.content : msg.message; if (typeof textContent === 'string' && textContent.startsWith('{')) { try { const nested = JSON.parse(textContent); if (nested && typeof nested === 'object' && nested.content !== undefined) { textContent = nested.content; } } catch { } } const { extractions, cleanText: textAfterExtractions } = parseExtractionsFromText(textContent); const { attachments: vfsAttachments, cleanText: rawCleanText } = parseAttachmentsFromText(textAfterExtractions); const cleanText = stripUserEnv(rawCleanText); if (msg.role === 'system') { return ( {textContent} ); } const isUser = msg.role === 'user'; const timeStr = showTime ? formatMessageTime(msg.created_at) : ''; return ( {timeStr ? ( {timeStr} ) : null} ({ 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, })} > {extractions.length > 0 && ( 0 ? 0.75 : 0, }} > {extractions.map((ext, idx) => ( ))} )} {vfsAttachments.length > 0 && ( {vfsAttachments.map((att, idx) => ( ))} )} {cleanText ? ( isUser ? ( cleanText ) : ( ) ) : null} ); } 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 ( {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 ( ); })} {/* Live streaming turns */} {activeTurns && activeTurns.length > 0 ? activeTurns.map((turn, idx) => ( )) : null}
); } }