Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
File size: 5,508 Bytes
2a2e170 79b2fcc 2a2e170 79b2fcc 2a2e170 79b2fcc 2a2e170 79b2fcc 2a2e170 79b2fcc 2a2e170 79b2fcc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | import { useMemo, useState } from 'react';
import { Box, IconButton, Stack, Tooltip, Typography } from '@mui/material';
import ThumbUpOutlined from '@mui/icons-material/ThumbUpOutlined';
import ThumbUp from '@mui/icons-material/ThumbUp';
import ThumbDownOutlined from '@mui/icons-material/ThumbDownOutlined';
import ThumbDown from '@mui/icons-material/ThumbDown';
import MarkdownContent from './MarkdownContent';
import ToolCallGroup from './ToolCallGroup';
import { apiFetch } from '@/utils/api';
import type { UIMessage } from 'ai';
import type { MessageMeta } from '@/types/agent';
interface AssistantMessageProps {
message: UIMessage;
isStreaming?: boolean;
sessionId?: string | null;
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
}
/**
* Groups consecutive tool parts together so they render as a single
* ToolCallGroup (visually identical to the old segments approach).
*/
type DynamicToolPart = Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }>;
function groupParts(parts: UIMessage['parts']) {
const groups: Array<
| { kind: 'text'; text: string; idx: number }
| { kind: 'tools'; tools: DynamicToolPart[]; idx: number }
> = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.type === 'text') {
groups.push({ kind: 'text', text: part.text, idx: i });
} else if (part.type === 'dynamic-tool') {
const toolPart = part as DynamicToolPart;
const last = groups[groups.length - 1];
if (last?.kind === 'tools') {
last.tools.push(toolPart);
} else {
groups.push({ kind: 'tools', tools: [toolPart], idx: i });
}
}
// step-start, step-end, etc. are ignored visually
}
return groups;
}
export default function AssistantMessage({ message, isStreaming = false, sessionId, approveTools }: AssistantMessageProps) {
const groups = useMemo(() => groupParts(message.parts), [message.parts]);
const [feedback, setFeedback] = useState<'up' | 'down' | null>(null);
const [feedbackBusy, setFeedbackBusy] = useState(false);
const sendFeedback = async (rating: 'up' | 'down') => {
if (!sessionId || feedbackBusy) return;
setFeedbackBusy(true);
// Optimistic toggle — feedback is observability, not a hard requirement.
setFeedback(rating);
try {
await apiFetch(`/api/feedback/${sessionId}`, {
method: 'POST',
body: JSON.stringify({ rating, message_id: message.id }),
});
} catch {
// Silently swallow — don't block chat UX on a telemetry write.
} finally {
setFeedbackBusy(false);
}
};
// Find the last text group index for streaming cursor
let lastTextIdx = -1;
for (let i = groups.length - 1; i >= 0; i--) {
if (groups[i].kind === 'text') { lastTextIdx = i; break; }
}
const meta = message.metadata as MessageMeta | undefined;
const timeStr = meta?.createdAt
? new Date(meta.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: null;
if (groups.length === 0) return null;
return (
<Box sx={{ minWidth: 0 }}>
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
<Typography
variant="caption"
sx={{
fontWeight: 700,
fontSize: '0.72rem',
color: 'var(--muted-text)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
Assistant
</Typography>
{timeStr && (
<Typography variant="caption" sx={{ color: 'var(--muted-text)', fontSize: '0.7rem' }}>
{timeStr}
</Typography>
)}
</Stack>
<Box
sx={{
maxWidth: { xs: '95%', md: '85%' },
bgcolor: 'var(--surface)',
borderRadius: 1.5,
borderTopLeftRadius: 4,
px: { xs: 1.5, md: 2.5 },
py: 1.5,
border: '1px solid var(--border)',
}}
>
{groups.map((group, i) => {
if (group.kind === 'text' && group.text) {
return (
<MarkdownContent
key={group.idx}
content={group.text}
isStreaming={isStreaming && i === lastTextIdx}
/>
);
}
if (group.kind === 'tools' && group.tools.length > 0) {
return (
<ToolCallGroup
key={group.idx}
tools={group.tools}
approveTools={approveTools}
/>
);
}
return null;
})}
</Box>
{!isStreaming && sessionId && (
<Stack
direction="row"
spacing={0.5}
sx={{ mt: 0.5, ml: 0.5, opacity: feedback ? 1 : 0.5, '&:hover': { opacity: 1 } }}
>
<Tooltip title="Helpful">
<IconButton size="small" disabled={feedbackBusy} onClick={() => sendFeedback('up')}>
{feedback === 'up' ? <ThumbUp fontSize="inherit" /> : <ThumbUpOutlined fontSize="inherit" />}
</IconButton>
</Tooltip>
<Tooltip title="Not helpful">
<IconButton size="small" disabled={feedbackBusy} onClick={() => sendFeedback('down')}>
{feedback === 'down' ? <ThumbDown fontSize="inherit" /> : <ThumbDownOutlined fontSize="inherit" />}
</IconButton>
</Tooltip>
</Stack>
)}
</Box>
);
}
|