1405 lines
39 KiB
JavaScript
1405 lines
39 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 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 EngineeringIcon from '@mui/icons-material/Engineering';
|
|
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 EditNoteIcon from '@mui/icons-material/EditNote';
|
|
import PaidRoundedIcon from '@mui/icons-material/PaidRounded';
|
|
import Button from '@mui/material/Button';
|
|
import { enqueueSnackbar } from 'notistack';
|
|
import deliveryEditorAutomat from '../../automats/deliveryEditorAutomat';
|
|
import { setLastDeliveryPath } from '../../automats/navigationAutomat';
|
|
import HealingMarkdown from './HealingMarkdown';
|
|
import {
|
|
VfsAttachmentLink,
|
|
ExtractionPill,
|
|
parseAttachmentsFromText,
|
|
parseExtractionsFromText,
|
|
stripUserEnv,
|
|
} from './ChatAttachmentItems';
|
|
import {
|
|
CHROMA,
|
|
SHADOW_BUBBLE_ASSISTANT,
|
|
SHADOW_BUBBLE_USER,
|
|
SURFACE_RAISED,
|
|
} from '../../colors';
|
|
import {
|
|
TOOL_ICON_MAP,
|
|
TOOL_GERMAN_NAMES,
|
|
GoogleFaviconIcon,
|
|
} from './chatTools.jsx';
|
|
import { InlineMentionText } from './ChatMentionPill';
|
|
|
|
export { TOOL_ICON_MAP, TOOL_GERMAN_NAMES, GoogleFaviconIcon };
|
|
|
|
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 formatUsageTooltip(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)}` : null;
|
|
const fmt = (n) => n.toLocaleString('de-DE');
|
|
|
|
const lines = [
|
|
`Prompt: ${fmt(input)} Tokens`,
|
|
];
|
|
if (cached > 0) {
|
|
lines.push(`Cache: ${fmt(cached)} Tokens`);
|
|
}
|
|
lines.push(`Antwort: ${fmt(output)} Tokens`);
|
|
if (cost) {
|
|
lines.push(`Kosten: ${cost}`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
export function aggregateUsage(turns = []) {
|
|
if (!Array.isArray(turns) || turns.length === 0) return null;
|
|
let hasUsage = false;
|
|
let totalInput = 0;
|
|
let totalOutput = 0;
|
|
let totalCached = 0;
|
|
let totalCost = 0;
|
|
let hasCost = false;
|
|
|
|
for (const turn of turns) {
|
|
const u = turn?.usage;
|
|
if (!u) continue;
|
|
hasUsage = true;
|
|
totalInput += Number(u.input ?? u.prompt_tokens ?? 0);
|
|
totalOutput += Number(u.output ?? u.completion_tokens ?? 0);
|
|
totalCached += Number(u.cached ?? 0);
|
|
if (typeof u.cost === 'number' && !Number.isNaN(u.cost)) {
|
|
totalCost += u.cost;
|
|
hasCost = true;
|
|
}
|
|
}
|
|
|
|
if (!hasUsage) return null;
|
|
|
|
return {
|
|
input: totalInput,
|
|
output: totalOutput,
|
|
cached: totalCached,
|
|
cost: hasCost ? totalCost : undefined,
|
|
};
|
|
}
|
|
|
|
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 (
|
|
<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}
|
|
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 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 (
|
|
<Box
|
|
ref={this.boxRef}
|
|
sx={{
|
|
p: 0.5,
|
|
maxWidth: 440,
|
|
maxHeight: 280,
|
|
overflowY: 'auto',
|
|
whiteSpace: 'pre-wrap',
|
|
fontSize: '0.75rem',
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
{text}
|
|
</Box>
|
|
);
|
|
}
|
|
}
|
|
|
|
function BrainIconView({ reasoning }) {
|
|
const tooltipContent = reasoning?.text?.trim() || '';
|
|
if (!tooltipContent) return null;
|
|
if (tooltipContent.startsWith('{') && (tooltipContent.includes('"encrypted"') || tooltipContent.includes('"plain"'))) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<ClickTooltip
|
|
title={<ReasoningTooltipContent text={tooltipContent} />}
|
|
>
|
|
<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 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 <WebIcon sx={sx} />;
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
component="img"
|
|
src={currentSrc}
|
|
alt=""
|
|
onError={this.handleError}
|
|
sx={{
|
|
width: 18,
|
|
height: 18,
|
|
borderRadius: '2px',
|
|
objectFit: 'contain',
|
|
verticalAlign: 'middle',
|
|
display: 'inline-block',
|
|
...sx,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
export function isDraftDeliveryManifestTool(name) {
|
|
if (!name || typeof name !== 'string') return false;
|
|
const n = name.trim().toLowerCase();
|
|
return n === 'draft_delivery_manifest' || n === 'draft-delivery-manifest';
|
|
}
|
|
|
|
function tryExtractObject(val) {
|
|
if (!val) return null;
|
|
if (typeof val === 'object') return val;
|
|
if (typeof val === 'string') {
|
|
const trimmed = val.trim();
|
|
try {
|
|
return JSON.parse(trimmed);
|
|
} catch { }
|
|
const match = trimmed.match(/\{[\s\S]*\}/);
|
|
if (match) {
|
|
try {
|
|
return JSON.parse(match[0]);
|
|
} catch { }
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function parseDraftDeliveryManifest(tool) {
|
|
if (!tool) return null;
|
|
const candidates = [tool.parameters, tool.arguments, tool.content, tool.result, tool];
|
|
for (const c of candidates) {
|
|
const obj = tryExtractObject(c);
|
|
if (obj && typeof obj === 'object') {
|
|
if (obj.manifest && typeof obj.manifest === 'object') {
|
|
return obj.manifest;
|
|
}
|
|
if (obj.basedata || obj.baseData || obj.items || obj.lines || obj.lineitems) {
|
|
return obj;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
class DraftDeliveryManifestUseButton extends PureComponent {
|
|
state = {
|
|
applied: false,
|
|
};
|
|
|
|
handleClick = async (e) => {
|
|
e?.stopPropagation();
|
|
const { tool } = this.props;
|
|
const manifest = parseDraftDeliveryManifest(tool);
|
|
if (!manifest) {
|
|
enqueueSnackbar('Keine gültigen Lieferscheindaten gefunden', { variant: 'warning' });
|
|
return;
|
|
}
|
|
|
|
deliveryEditorAutomat.actions.loadDraftManifest(manifest);
|
|
setLastDeliveryPath('/lieferung/neu');
|
|
|
|
this.setState({ applied: true });
|
|
setTimeout(() => {
|
|
this.setState({ applied: false });
|
|
}, 2500);
|
|
|
|
const count =
|
|
manifest.items?.numberOfItemsToAdd ??
|
|
(Array.isArray(manifest.items?.items)
|
|
? manifest.items.items.length
|
|
: Array.isArray(manifest.items)
|
|
? manifest.items.length
|
|
: Array.isArray(manifest.lines)
|
|
? manifest.lines.length
|
|
: 0);
|
|
|
|
enqueueSnackbar(
|
|
count > 0
|
|
? `${count} Positionen in den Lieferschein-Editor geladen`
|
|
: 'Lieferschein in den Editor geladen',
|
|
{ variant: 'success' }
|
|
);
|
|
|
|
try {
|
|
const { router } = await import('../../appRouter');
|
|
if (router?.navigate) {
|
|
const curPath =
|
|
router.state?.location?.pathname ||
|
|
(typeof window !== 'undefined' ? window.location.pathname : '');
|
|
if (!curPath.startsWith('/lieferung/neu')) {
|
|
void router.navigate('/lieferung/neu');
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[DraftDeliveryManifestUseButton] Navigation failed:', err);
|
|
}
|
|
};
|
|
|
|
render() {
|
|
const { applied } = this.state;
|
|
return (
|
|
<Button
|
|
size="small"
|
|
variant={applied ? 'contained' : 'outlined'}
|
|
color={applied ? 'success' : 'primary'}
|
|
onClick={this.handleClick}
|
|
sx={{
|
|
height: 20,
|
|
minHeight: 20,
|
|
py: 0,
|
|
px: 0.9,
|
|
fontSize: '0.72rem',
|
|
fontWeight: 700,
|
|
textTransform: 'none',
|
|
lineHeight: 1,
|
|
borderRadius: '4px',
|
|
borderColor: applied ? undefined : 'rgba(0, 0, 0, 0.23)',
|
|
bgcolor: applied ? 'success.main' : 'transparent',
|
|
color: applied ? '#ffffff' : 'inherit',
|
|
'&:hover': {
|
|
bgcolor: applied ? 'success.dark' : 'action.hover',
|
|
borderColor: applied ? undefined : 'primary.main',
|
|
},
|
|
}}
|
|
>
|
|
{applied ? '✓ Use' : 'Use'}
|
|
</Button>
|
|
);
|
|
}
|
|
}
|
|
|
|
function WorkIconView({ tool }) {
|
|
if (!tool || !tool.name) return null;
|
|
const isGreen = tool?.state === 'green';
|
|
const isUrlGet = tool?.name === 'url_get';
|
|
const isDraftDelivery = isDraftDeliveryManifestTool(tool?.name);
|
|
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;
|
|
|
|
const iconElement = (
|
|
<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',
|
|
},
|
|
}}
|
|
>
|
|
{isUrlGet ? (
|
|
<UrlFaviconIcon url={targetUrl} sx={{ fontSize: 18, color: 'inherit' }} />
|
|
) : 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>
|
|
);
|
|
|
|
if (isDraftDelivery) {
|
|
return (
|
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
|
{iconElement}
|
|
<DraftDeliveryManifestUseButton tool={tool} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return iconElement;
|
|
}
|
|
|
|
function UsageChipView({ usage, isSummary = false }) {
|
|
const text = formatUsageText(usage);
|
|
if (!text) return null;
|
|
|
|
return (
|
|
<Box
|
|
component="span"
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
fontSize: '0.68rem',
|
|
fontFamily: 'monospace',
|
|
color: isSummary ? 'text.primary' : 'text.secondary',
|
|
fontWeight: isSummary ? 400 : 400,
|
|
bgcolor: isSummary ? 'action.selected' : 'action.hover',
|
|
border: isSummary ? '1px solid' : 'none',
|
|
borderColor: isSummary ? 'divider' : 'transparent',
|
|
px: 0.8,
|
|
py: 0.25,
|
|
borderRadius: 1.5,
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{text}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function UsageIconView({ usage }) {
|
|
if (!usage) return null;
|
|
const tooltipText = formatUsageTooltip(usage);
|
|
if (!tooltipText) return null;
|
|
|
|
return (
|
|
<ClickTooltip
|
|
title={
|
|
<Box
|
|
sx={{
|
|
p: 0.5,
|
|
maxWidth: 320,
|
|
whiteSpace: 'pre-wrap',
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.72rem',
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
{tooltipText}
|
|
</Box>
|
|
}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: 24,
|
|
height: 24,
|
|
color: 'text.secondary',
|
|
borderRadius: 1,
|
|
'&:hover': {
|
|
bgcolor: 'action.hover',
|
|
color: 'text.primary',
|
|
},
|
|
}}
|
|
>
|
|
<PaidRoundedIcon sx={{ fontSize: 18, color: 'inherit' }} />
|
|
</Box>
|
|
</ClickTooltip>
|
|
);
|
|
}
|
|
|
|
function turnHasIcons(turn) {
|
|
if (!turn) return false;
|
|
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 hasUsage = Boolean(turn.usage);
|
|
const hasContent = Boolean(turn.content);
|
|
const showHandshake = !hasReasonings && !hasTools && !hasContent && !hasUsage;
|
|
return showHandshake || hasReasonings || hasTools || hasUsage;
|
|
}
|
|
|
|
export function TurnIcons({ turn }) {
|
|
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 hasUsage = Boolean(turn.usage);
|
|
const hasContent = Boolean(turn.content);
|
|
const showHandshake = !hasReasonings && !hasTools && !hasContent && !hasUsage;
|
|
|
|
const showStatus = showHandshake || hasReasonings || hasTools || hasUsage;
|
|
if (!showStatus) return null;
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.5,
|
|
}}
|
|
>
|
|
{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} />
|
|
))}
|
|
|
|
{hasUsage ? (
|
|
<UsageIconView usage={turn.usage} />
|
|
) : null}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function TurnsView({ turns = [], isLive = false }) {
|
|
if (!Array.isArray(turns) || turns.length === 0) return null;
|
|
|
|
// Segment turns into chunks where icons precede content
|
|
const segments = [];
|
|
let pendingTurns = [];
|
|
|
|
for (let i = 0; i < turns.length; i++) {
|
|
const turn = turns[i];
|
|
pendingTurns.push(turn);
|
|
|
|
if (turn.content) {
|
|
segments.push({
|
|
statusTurns: [...pendingTurns],
|
|
contentTurn: turn,
|
|
});
|
|
pendingTurns = [];
|
|
}
|
|
}
|
|
|
|
if (pendingTurns.length > 0) {
|
|
segments.push({
|
|
statusTurns: pendingTurns,
|
|
contentTurn: null,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, width: '100%' }}>
|
|
{segments.map((seg, sIdx) => {
|
|
const hasAnyIcons = seg.statusTurns.some(turnHasIcons);
|
|
|
|
return (
|
|
<Box
|
|
key={sIdx}
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'flex-start',
|
|
px: 2,
|
|
py: 0.4,
|
|
gap: 0.75,
|
|
maxWidth: '100%',
|
|
}}
|
|
>
|
|
{/* All turn icons in ONE line */}
|
|
{hasAnyIcons ? (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1,
|
|
flexWrap: 'wrap',
|
|
minHeight: 24,
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{seg.statusTurns.map((t, tIdx) => (
|
|
<TurnIcons key={t.id || tIdx} turn={t} />
|
|
))}
|
|
</Box>
|
|
) : null}
|
|
|
|
{/* Assistant message content for this segment (if any) */}
|
|
{seg.contentTurn?.content ? (
|
|
<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={seg.contentTurn.content}
|
|
streaming={isLive && !seg.contentTurn.sealed}
|
|
/>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function TurnRow({ turn, isLive = false }) {
|
|
if (!turn) return null;
|
|
return <TurnsView turns={[turn]} isLive={isLive} />;
|
|
}
|
|
|
|
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) {
|
|
const summaryUsage = aggregateUsage(msg.turns);
|
|
const turnCount = msg.turns.length;
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
|
<TurnsView turns={msg.turns} isLive={false} />
|
|
{summaryUsage ? (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 0.4 }}>
|
|
<UsageChipView
|
|
usage={summaryUsage}
|
|
isSummary={true}
|
|
/>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<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,
|
|
})}
|
|
>
|
|
{extractions.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: 0.75,
|
|
mb: cleanText || vfsAttachments.length > 0 ? 0.75 : 0,
|
|
}}
|
|
>
|
|
{extractions.map((ext, idx) => (
|
|
<ExtractionPill
|
|
key={ext.id || idx}
|
|
label={ext.label || 'parsed invoice'}
|
|
text={ext.text}
|
|
isUser={isUser}
|
|
/>
|
|
))}
|
|
</Box>
|
|
)}
|
|
|
|
{vfsAttachments.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: 0.75,
|
|
mb: cleanText ? 0.75 : 0,
|
|
}}
|
|
>
|
|
{vfsAttachments.map((att, idx) => (
|
|
<VfsAttachmentLink
|
|
key={att.path || idx}
|
|
name={att.name}
|
|
path={att.path}
|
|
type={att.type}
|
|
isUser={isUser}
|
|
/>
|
|
))}
|
|
</Box>
|
|
)}
|
|
|
|
{cleanText ? (
|
|
isUser ? (
|
|
<InlineMentionText text={cleanText} isUser={true} />
|
|
) : (
|
|
<HealingMarkdown text={cleanText} streaming={msg.streaming} />
|
|
)
|
|
) : null}
|
|
</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 ? (
|
|
<>
|
|
<TurnsView turns={activeTurns} isLive={true} />
|
|
{(() => {
|
|
const activeSummary = aggregateUsage(activeTurns);
|
|
if (!activeSummary) return null;
|
|
return (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 0.4 }}>
|
|
<UsageChipView
|
|
usage={activeSummary}
|
|
isSummary={true}
|
|
/>
|
|
</Box>
|
|
);
|
|
})()}
|
|
</>
|
|
) : null}
|
|
|
|
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
|
|
</Box>
|
|
);
|
|
}
|
|
}
|