import type { ContentBlock, DataBlock, Msg, TextBlock, ToolCallBlock, } from '@agentscope-ai/agentscope/message'; import { ArrowDown, ArrowUp, Bot, CalendarClock, CheckCircle, ChevronDownIcon, CirclePlay, Copy, Loader2, MessageSquareQuote, Wrench, } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { ConfirmCard } from './ConfirmCard'; import { FileAttachment } from './FileAttachment'; import { renderToolGroup } from './tool-renderers'; import type { TFunction, ToolCallWithResult } from './tool-renderers/types'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible.tsx'; import { Item, ItemContent } from '@/components/ui/item.tsx'; import { useAudioBlock, useReplayController } from '@/context/AudioContext'; import { useTranslation } from '@/i18n/useI18n'; import { formatNumber, formatTime } from '@/utils/common'; interface ToolCallGroupBlock { type: 'tool_call_group'; id: string; toolName: string; calls: ToolCallWithResult[]; } type ExtendedContentBlock = ContentBlock | ToolCallGroupBlock; /** * Group tool_call blocks of the same name into a single * `tool_call_group`, with each call paired to its matching * tool_result by id. * * Unlike the previous implementation this does NOT require calls of * the same name to be consecutive. When the agent issues multiple * concurrent tool calls (e.g. Glob + Grep), the content layout is * `[call_Glob, call_Grep, result_Glob, result_Grep]` — the old * "consecutive-same-name" approach would split call and result into * separate groups. This version collects all calls first (preserving * encounter order), then matches results, and finally emits groups * in the order the first call of each tool name appeared, * interleaved with non-tool blocks at their original positions. */ function groupToolCalls(content: ContentBlock[]): ExtendedContentBlock[] { // Pass 1: pair calls ↔ results by id, track non-tool blocks. const callMap = new Map(); const resultMap = new Map(); const ordering: Array<{ type: 'tool'; id: string } | { type: 'other'; block: ContentBlock }> = []; for (const block of content) { if (block.type === 'tool_call') { const entry: ToolCallWithResult = { call: block }; callMap.set(block.id, entry); ordering.push({ type: 'tool', id: block.id }); } else if (block.type === 'tool_result') { const matching = callMap.get(block.id); if (matching) { matching.result = block; } else { resultMap.set(block.id, block); } } else { ordering.push({ type: 'other', block }); } } // Pass 2: walk the ordering, group consecutive same-name calls // (now that results are already attached). const result: ExtendedContentBlock[] = []; let currentGroup: ToolCallWithResult[] = []; let currentToolName: string | null = null; const flush = () => { if (currentGroup.length > 0 && currentToolName) { result.push({ type: 'tool_call_group', id: crypto.randomUUID(), toolName: currentToolName, calls: currentGroup, }); currentGroup = []; currentToolName = null; } }; for (const item of ordering) { if (item.type === 'other') { flush(); result.push(item.block); } else { const entry = callMap.get(item.id); if (!entry) continue; if (currentToolName !== null && currentToolName !== entry.call.name) { flush(); } currentToolName = entry.call.name; currentGroup.push(entry); } } flush(); // Orphan results (no matching call) — render as synthetic groups. for (const [id, block] of resultMap) { if (block.type === 'tool_result') { result.push({ type: 'tool_call_group', id: crypto.randomUUID(), toolName: block.name, calls: [ { call: { type: 'tool_call', id, name: block.name, input: '', state: 'finished' as const, }, result: block, }, ], }); } } return result; } const AUDIO_WAVE_LINES: Array<{ x: number; y1: number; y2: number }> = [ { x: 2, y1: 10, y2: 13 }, { x: 6, y1: 6, y2: 17 }, { x: 10, y1: 3, y2: 21 }, { x: 14, y1: 8, y2: 15 }, { x: 18, y1: 5, y2: 18 }, { x: 22, y1: 10, y2: 13 }, ]; function AudioWave({ isPlaying = true, className }: { isPlaying?: boolean; className?: string }) { return ( <> {isPlaying && ( )} {AUDIO_WAVE_LINES.map(({ x, y1, y2 }, i) => ( ))} ); } /** * Inline audio control rendered *inside* the time/usage Badge so the play * icon visually merges into the same chip rather than floating as its own * pill. */ function AudioInlineControl({ block }: { block: DataBlock }) { const { t } = useTranslation(); const audioState = useAudioBlock(block.id); const replayController = useReplayController(); const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const isStreaming = audioState?.status === 'streaming'; // Don't build the giant base64 data URL while bytes are still streaming — // it would re-allocate on every DATA_BLOCK_DELTA. Live playback during // that window is handled by the manager's WavStreamPlayer; we only need // `src` for replay after the stream ends (or for historical messages). let src: string | null = null; if (!isStreaming) { if (audioState?.url) { src = audioState.url; } else if (block.source.type === 'url') { src = block.source.url; } else if (block.source.type === 'base64' && block.source.data) { src = `data:${block.source.media_type};base64,${block.source.data}`; } } // Reset the hidden