'use client'
import { memo, useState, useEffect, useRef } from 'react'
import { format } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import {
Bot,
Copy,
Check,
RefreshCw,
Wrench,
FileText,
Image as ImageIcon,
Loader2,
CheckCircle2,
Info,
AlertTriangle,
Lightbulb,
ChevronRight,
AlertCircle,
BookOpen,
SquareTerminal,
Cpu,
MessageSquare,
CircleSlash,
type LucideIcon,
} from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import mermaid from 'mermaid'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import 'katex/dist/katex.min.css'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { Message, ToolCall, Attachment, EventItem, QuestionInfo } from '@/lib/types'
import { formatToolOutput } from '@/lib/format-utils'
interface MessageListProps {
messages: Message[]
onRegenerate?: (assistantMessageId: string) => void
agentName?: string
}
export function MessageList({ messages, onRegenerate, agentName }: MessageListProps) {
if (messages.length === 0) {
return null
}
return (
<div className="mx-auto max-w-4xl space-y-6 px-4 py-6">
{messages.map(message => (
<MessageItem
key={message.id}
message={message}
onRegenerate={onRegenerate}
agentName={agentName}
/>
))}
</div>
)
}
const MessageItem = memo(function MessageItem({
message,
onRegenerate,
agentName,
}: {
message: Message
onRegenerate?: (assistantMessageId: string) => void
agentName?: string
}) {
const [copied, setCopied] = useState(false)
const [processExpanded, setProcessExpanded] = useState(false)
const isUser = message.role === 'user'
const hasPendingQuestion =
!isUser && !!message.question?.length && message.questionStatus === 'pending'
const hasProcessModules =
!isUser &&
!!message.events?.some(
e =>
e.type === 'thinking' ||
e.type === 'tool.call.started' ||
e.type === 'tool.call.response' ||
e.type === 'question.asked'
)
const processCollapsible =
!isUser && !message.isStreaming && hasProcessModules && !hasPendingQuestion
const showProcess = !processCollapsible || processExpanded
let durationText: string | null = null
if (!isUser && !message.isStreaming && message.events && message.events.length >= 2) {
const timestamps = message.events
.map(e => e.timestamp)
.filter((t): t is number => typeof t === 'number' && t > 0)
if (timestamps.length >= 2) {
const totalSec = Math.round((Math.max(...timestamps) - Math.min(...timestamps)) / 1000)
if (totalSec >= 1) {
const m = Math.floor(totalSec / 60)
durationText = m > 0 ? `${m}m${totalSec % 60}s` : `${totalSec}s`
}
}
}
const handleCopy = async () => {
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(message.content)
} else {
const textArea = document.createElement('textarea')
textArea.value = message.content
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
}
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
return (
<div className={cn('group animate-message-in', isUser && 'flex flex-row-reverse')}>
<div className={cn('flex flex-col gap-2', isUser ? 'max-w-[80%] items-end' : 'w-full')}>
{/* 助手消息头部:头像 + 名称 */}
{!isUser && (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6 bg-accent">
<AvatarFallback className="bg-accent text-accent-foreground">
<Bot className="h-3.5 w-3.5" />
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{agentName || 'AI 助手'}</span>
</div>
)}
{/* 已完成耗时:点击展开/收起过程模块(深度思考/工具调用/提问) */}
{processCollapsible && (
<button
onClick={() => setProcessExpanded(!processExpanded)}
className="group/mod mb-1 flex w-fit items-center gap-2 text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<span>已完成{durationText ? ` ${durationText}` : ''}</span>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 transition-all duration-150',
processExpanded && 'rotate-90'
)}
/>
</button>
)}
{/* Attachments */}
{message.attachments && message.attachments.length > 0 && (
<div className="flex flex-wrap gap-2">
{message.attachments.map(attachment => (
<AttachmentBadge key={attachment.id} attachment={attachment} />
))}
</div>
)}
{/* Events in order — 按时间线渲染:深度思考 / 正文流式输出 / 工具调用 / 提问流程 */}
{!isUser && message.events && message.events.length > 0 && (
<div className="space-y-3">
{(() => {
const visibleEvents = message.events!
const groupedEvents: any[] = []
let currentThinkingGroup: any[] = []
let currentDeltaGroup: any[] = []
visibleEvents.forEach((event, index) => {
if (event.type === 'thinking') {
if (currentDeltaGroup.length > 0) {
groupedEvents.push({ type: 'delta-group', events: currentDeltaGroup })
currentDeltaGroup = []
}
currentThinkingGroup.push(event)
} else if (event.type === 'message.delta') {
if (currentThinkingGroup.length > 0) {
groupedEvents.push({ type: 'thinking-group', events: currentThinkingGroup })
currentThinkingGroup = []
}
currentDeltaGroup.push(event)
} else {
if (currentThinkingGroup.length > 0) {
groupedEvents.push({ type: 'thinking-group', events: currentThinkingGroup })
currentThinkingGroup = []
}
if (currentDeltaGroup.length > 0) {
groupedEvents.push({ type: 'delta-group', events: currentDeltaGroup })
currentDeltaGroup = []
}
groupedEvents.push(event)
}
})
if (currentThinkingGroup.length > 0) {
groupedEvents.push({ type: 'thinking-group', events: currentThinkingGroup })
}
if (currentDeltaGroup.length > 0) {
groupedEvents.push({ type: 'delta-group', events: currentDeltaGroup })
}
// 去重并合并工具调用事件
const toolCallMap = new Map()
const deduplicatedGroups = []
for (const group of groupedEvents) {
if (
(group.type === 'tool.call.started' || group.type === 'tool.call.response') &&
group.toolCall?.id
) {
const toolCallId = group.toolCall.id
if (toolCallMap.has(toolCallId)) {
const existing = toolCallMap.get(toolCallId)
const mergedToolCall = { ...existing.toolCall, ...group.toolCall }
if (
(!group.toolCall.input ||
(typeof group.toolCall.input === 'object' &&
Object.keys(group.toolCall.input).length === 0)) &&
existing.toolCall.input
) {
mergedToolCall.input = existing.toolCall.input
}
toolCallMap.set(toolCallId, { ...group, toolCall: mergedToolCall })
} else {
toolCallMap.set(toolCallId, group)
}
}
}
const processedToolCallIds = new Set()
for (const group of groupedEvents) {
if (
(group.type === 'tool.call.started' || group.type === 'tool.call.response') &&
group.toolCall?.id
) {
const toolCallId = group.toolCall.id
if (processedToolCallIds.has(toolCallId)) continue
processedToolCallIds.add(toolCallId)
deduplicatedGroups.push(toolCallMap.get(toolCallId))
} else {
deduplicatedGroups.push(group)
}
}
return deduplicatedGroups
})().map((group: any, groupIndex: number, groups: any[]) => {
// 回答完毕后,过程模块折叠在「已完成」行下,仅保留正文(delta-group)
if (!showProcess && group.type !== 'delta-group') return null
if (group.type === 'thinking-group') {
const isLastGroup = groupIndex === groups.length - 1
const thinkingCompleted = !!message.content || !isLastGroup || !message.isStreaming
return (
<ThinkingGroup
key={`thinking-group-${groupIndex}`}
events={group.events}
completed={thinkingCompleted}
/>
)
} else if (group.type === 'delta-group') {
const deltaContent = group.events.map((event: EventItem) => event.content).join('')
if (!deltaContent) return null
return <ResponseBlock key={`delta-group-${groupIndex}`} content={deltaContent} />
} else if (
group.type === 'tool.call.started' ||
group.type === 'tool.call.response'
) {
return (
<div key={`tool-call-${group.toolCall?.id || groupIndex}`}>
{group.toolCall && <ToolCallBadge toolCall={group.toolCall} />}
</div>
)
} else if (group.type === 'question.asked') {
// 该轮提问的结论事件(replied / rejected)在其之后
const resolutionEvent =
groups
.slice(groupIndex + 1)
.find(
(g: any) => g.type === 'question.replied' || g.type === 'question.rejected'
) ?? null
const isLastAsked = !groups
.slice(groupIndex + 1)
.some((g: any) => g.type === 'question.asked')
return (
<QuestionFlowBlock
key={`question-${groupIndex}`}
askedEvent={group}
message={message}
isLastAsked={isLastAsked}
resolutionEvent={resolutionEvent}
/>
)
} else if (group.type === 'question.replied' || group.type === 'question.rejected') {
// 结论已合并进对应的 QuestionFlowBlock 渲染
return null
}
return null
})}
</div>
)}
{/* 流式进行中的尾随状态行(已有正文流式输出时) */}
{!isUser &&
message.isStreaming &&
!hasPendingQuestion &&
message.events?.some(e => e.type === 'message.delta') && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>生成回复中</span>
</div>
)}
{/* Message Content — 当 events 中有 delta 时隐藏纯文本内容,避免重复渲染 */}
{(isUser ||
!message.events ||
message.events.length === 0 ||
!message.events.some(e => e.type === 'message.delta')) &&
(isUser || message.content || (message.isStreaming && !hasPendingQuestion)) && (
<>
{message.status === 'interrupted' && !message.content ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
<span>思考已中断</span>
</div>
) : isUser ? (
<div className="rounded-2xl bg-[#edf3fe] px-4 py-3">
<div className="prose prose-sm dark:prose-invert max-w-none">
<MessageContent content={message.content} isStreaming={message.isStreaming} />
</div>
</div>
) : (
<ResponseBlock content={message.content} isStreaming={message.isStreaming} />
)}
</>
)}
{/* Usage Information */}
{message.usage && (
<div className="rounded-lg border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-2">
<span>输入 tokens: {message.usage.inputTokens}</span>
<span>输出 tokens: {message.usage.outputTokens}</span>
<span>成本: ${message.usage.totalCost || 0}</span>
</div>
</div>
)}
{/* Timestamp & Actions */}
<div
className={cn(
'flex items-center gap-2 text-xs text-muted-foreground',
isUser && 'flex-row-reverse'
)}
>
<span suppressHydrationWarning>
{format(message.timestamp, 'HH:mm', { locale: zhCN })}
</span>
{!message.isStreaming && (
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleCopy}>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
</TooltipTrigger>
<TooltipContent>复制</TooltipContent>
</Tooltip>
{!isUser && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onRegenerate?.(message.id)}
>
<RefreshCw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>重新生成</TooltipContent>
</Tooltip>
</>
)}
</TooltipProvider>
</div>
)}
</div>
</div>
</div>
)
})
function ResponseBlock({ content, isStreaming }: { content: string; isStreaming?: boolean }) {
return (
<div className="prose prose-sm dark:prose-invert max-w-none">
<MessageContent content={content} isStreaming={isStreaming} />
</div>
)
}
function MessageContent({ content, isStreaming }: { content: string; isStreaming?: boolean }) {
const mermaidInitialized = useRef(false)
useEffect(() => {
if (!mermaidInitialized.current) {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
})
mermaidInitialized.current = true
}
}, [])
if (!content && isStreaming) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>生成回复中</span>
</div>
)
}
return (
<>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex, rehypeRaw]}
components={{
h1: ({ children }) => (
<h1 className="mb-2 mt-4 text-xl font-bold first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-3 text-base font-semibold first:mt-0">{children}</h3>
),
h4: ({ children }) => (
<h4 className="mb-2 mt-3 text-sm font-semibold first:mt-0">{children}</h4>
),
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="mb-2 list-disc space-y-1 pl-5">{children}</ul>,
ol: ({ children }) => <ol className="mb-2 list-decimal space-y-1 pl-5">{children}</ol>,
li: ({ children }) => <li className="text-sm leading-relaxed">{children}</li>,
code: ({ className, children, node, ...props }) => {
const isInline = !className
if (isInline) {
return (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm" {...props}>
{children}
</code>
)
}
return null
},
pre: ({ children, ...props }) => {
const child = children as React.ReactElement<any>
const codeElement = child?.props?.children
const className = child?.props?.className || ''
const match = /language-(\w+)/.exec(className)
const language = match ? match[1] : ''
const code =
typeof codeElement === 'string' ? codeElement : String(codeElement || '').trim()
if (language === 'mermaid') {
return <MermaidChart chart={code} />
}
return <CodeBlock code={code} language={language} showLineNumbers={false} />
},
blockquote: ({ children, ...props }) => {
return <Admonition type="blockquote">{children}</Admonition>
},
a: ({ href, children }) => (
<a
href={href}
className="text-primary underline hover:text-primary/80"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
table: ({ children }) => (
<div className="mb-2 overflow-x-auto">
<table className="min-w-full divide-y divide-border text-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
th: ({ children }) => <th className="px-3 py-2 text-left font-semibold">{children}</th>,
td: ({ children }) => <td className="px-3 py-2">{children}</td>,
hr: () => <hr className="my-4 border-border" />,
img: ({ src, alt }) => (
<img src={src} alt={alt} className="max-w-full rounded-lg" loading="lazy" />
),
}}
>
{content}
</ReactMarkdown>
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-blink bg-foreground" />
)}
</>
)
}
function CodeBlock({
code,
language,
showLineNumbers = false,
}: {
code: string
language: string
showLineNumbers?: boolean
}) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy code:', err)
}
}
const displayLanguage = language || 'text'
return (
<div className="group relative mb-2 overflow-hidden rounded-lg bg-[#282c34]">
<div className="flex items-center justify-between px-4 py-2 bg-[#21252b] border-b border-gray-700">
<span className="text-xs text-gray-400 font-medium uppercase">{displayLanguage}</span>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-2 py-1 text-xs text-gray-400 hover:text-white hover:bg-white/10 rounded transition-colors"
>
{copied ? (
<>
<Check className="h-3.5 w-3.5" />
<span>已复制</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
<span>复制</span>
</>
)}
</button>
</div>
<div className="overflow-x-auto">
<SyntaxHighlighter
language={language || 'text'}
style={oneDark}
showLineNumbers={showLineNumbers}
customStyle={{
margin: 0,
padding: '1rem',
background: 'transparent',
fontSize: '0.875rem',
lineHeight: '1.5',
}}
codeTagProps={{
style: {
fontFamily: 'Menlo, Monaco, Consolas, "Courier New", monospace',
},
}}
wrapLines={true}
wrapLongLines={true}
>
{code}
</SyntaxHighlighter>
</div>
</div>
)
}
function MermaidChart({ chart }: { chart: string }) {
const [svg, setSvg] = useState<string>('')
const [error, setError] = useState<string>('')
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const renderChart = async () => {
try {
const id = `mermaid-${Math.random().toString(36).substring(2, 11)}`
const { svg } = await mermaid.render(id, chart)
setSvg(svg)
setError('')
} catch (err) {
setError(err instanceof Error ? err.message : '渲染流程图失败')
} finally {
setIsLoading(false)
}
}
renderChart()
}, [chart])
if (isLoading) {
return (
<div className="mb-2 flex items-center justify-center rounded-lg bg-muted p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">渲染流程图...</span>
</div>
)
}
if (error) {
return (
<div className="mb-2 rounded-lg border border-red-500/50 bg-red-500/10 p-3 text-sm text-red-500">
{error}
</div>
)
}
return (
<div
className="mb-2 overflow-x-auto rounded-lg bg-muted p-4"
dangerouslySetInnerHTML={{ __html: svg }}
/>
)
}
function Admonition({ children, type }: { children: React.ReactNode; type?: string }) {
const [isOpen, setIsOpen] = useState(true)
const [isDetails, setIsDetails] = useState(false)
useEffect(() => {
if (Array.isArray(children)) {
const hasDetails = children.some(
child => child && typeof child === 'object' && 'type' in child && child.type === 'details'
)
setIsDetails(hasDetails)
}
}, [children])
const getAdmonitionConfig = () => {
const typeMap: Record<
string,
{ icon: React.ReactNode; color: string; bg: string; label: string }
> = {
note: {
icon: <Info className="h-5 w-5" />,
color: 'text-blue-500',
bg: 'bg-blue-500/10 border-blue-500/50',
label: '提示',
},
info: {
icon: <Info className="h-5 w-5" />,
color: 'text-blue-500',
bg: 'bg-blue-500/10 border-blue-500/50',
label: '信息',
},
tip: {
icon: <Lightbulb className="h-5 w-5" />,
color: 'text-green-500',
bg: 'bg-green-500/10 border-green-500/50',
label: '技巧',
},
warning: {
icon: <AlertTriangle className="h-5 w-5" />,
color: 'text-yellow-500',
bg: 'bg-yellow-500/10 border-yellow-500/50',
label: '警告',
},
caution: {
icon: <AlertTriangle className="h-5 w-5" />,
color: 'text-orange-500',
bg: 'bg-orange-500/10 border-orange-500/50',
label: '注意',
},
danger: {
icon: <AlertTriangle className="h-5 w-5" />,
color: 'text-red-500',
bg: 'bg-red-500/10 border-red-500/50',
label: '危险',
},
important: {
icon: <AlertTriangle className="h-5 w-5" />,
color: 'text-purple-500',
bg: 'bg-purple-500/10 border-purple-500/50',
label: '重要',
},
}
return (
typeMap[type?.toLowerCase() || ''] || {
icon: null,
color: 'border-muted-foreground/30',
bg: 'border-l-4',
label: '',
}
)
}
const config = getAdmonitionConfig()
if (isDetails) {
return <div className="mb-2">{children}</div>
}
if (!config.label) {
return (
<blockquote className="mb-2 border-l-4 border-muted-foreground/30 pl-4 italic">
{children}
</blockquote>
)
}
return (
<div className={`mb-2 rounded-lg border ${config.bg} p-4`}>
<div className={`mb-2 flex items-center gap-2 font-semibold ${config.color}`}>
{config.icon}
<span>{config.label}</span>
<button
onClick={() => setIsOpen(!isOpen)}
className="ml-auto rounded p-1 hover:bg-black/10"
>
<ChevronRight className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
</button>
</div>
{isOpen && <div className="text-sm">{children}</div>}
</div>
)
}
function ToolCallBadge({ toolCall }: { toolCall: ToolCall }) {
const isRunning = toolCall.status === 'running'
const isCompleted = toolCall.status === 'completed'
const [isExpanded, setIsExpanded] = useState(false)
// 处理换行符,确保在 HTML 中正确显示
const formatForDisplay = (text: string): string => {
if (!text) return ''
return text.split('\\n').join('\n')
}
const formattedOutput = toolCall.output ? formatToolOutput(toolCall.output) : null
const displayOutput = formattedOutput ? formatForDisplay(formattedOutput) : null
const statusConfig = {
running: {
icon: Loader2,
iconClass: 'animate-spin text-primary',
label: '运行中',
labelClass: 'bg-primary/10 text-primary',
},
completed: {
icon: CheckCircle2,
iconClass: 'text-accent',
label: '已完成',
labelClass: 'bg-accent/10 text-accent',
},
error: {
icon: AlertCircle,
iconClass: 'text-red-500',
label: '出错',
labelClass: 'bg-red-500/10 text-red-500',
},
pending: {
icon: Wrench,
iconClass: 'text-muted-foreground',
label: '待执行',
labelClass: 'bg-muted text-muted-foreground',
},
}
const config = statusConfig[toolCall.status] || statusConfig.pending
const StatusIcon = config.icon
// 根据工具名称映射不同的图标,未知工具默认用 Wrench
const toolIconMap: Record<string, LucideIcon> = {
read: BookOpen,
exec: SquareTerminal,
process: Cpu,
}
const ToolIcon = toolIconMap[toolCall.name] || Wrench
// 提取文件路径(用于 read 工具)
const getReadFilePath = (): string | null => {
if (toolCall.name !== 'read' || !toolCall.input) return null
const input = toolCall.input as Record<string, unknown>
return (input.file_path as string) || (input.path as string) || null
}
const readFilePath = getReadFilePath()
// 提取命令文本(用于 exec 工具)
const getExecCommand = (): string | null => {
if (toolCall.name !== 'exec' || !toolCall.input) return null
const input = toolCall.input as Record<string, unknown>
return (input.command as string) || null
}
const execCommand = getExecCommand()
return (
<div className="text-sm">
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="group/mod flex min-w-0 max-w-full items-center gap-2 py-1 text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<ToolIcon className={cn('h-3.5 w-3.5 shrink-0', config.iconClass)} />
</span>
</TooltipTrigger>
{toolCall.name === 'read' && <TooltipContent>查看文件</TooltipContent>}
</Tooltip>
</TooltipProvider>
<span className="font-mono text-xs font-medium truncate max-w-[70%]">
{toolCall.name === 'exec'
? isExpanded
? toolCall.name
: execCommand || toolCall.name
: readFilePath || toolCall.name}
</span>
{toolCall.duration && (
<span className="text-xs text-muted-foreground/70 shrink-0 font-mono">
{(toolCall.duration / 1000).toFixed(1)}s
</span>
)}
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 transition-all duration-150',
'opacity-0 -translate-x-1 group-hover/mod:translate-x-0 group-hover/mod:opacity-100',
isExpanded && 'rotate-90'
)}
/>
</button>
{/* Expandable content */}
{isExpanded && (
<div className="ml-5 mt-1 space-y-2 border-l border-border/50 pl-3 text-xs">
{toolCall.displayText &&
toolCall.name !== 'read' &&
displayOutput &&
!toolCall.displayText.includes(displayOutput.slice(0, 50)) && (
<div className="text-muted-foreground">{toolCall.displayText}</div>
)}
{toolCall.name === 'read' ? (
// read 工具:直接显示文件内容
displayOutput ? (
<pre className="bg-muted/50 rounded p-2 overflow-x-auto whitespace-pre-wrap break-words font-mono leading-relaxed">
{displayOutput}
</pre>
) : toolCall.error ? (
<pre className="bg-red-500/10 rounded p-2 overflow-x-auto whitespace-pre-wrap break-words font-mono leading-relaxed">
{formatForDisplay(
typeof toolCall.error === 'string'
? toolCall.error
: JSON.stringify(toolCall.error, null, 2)
)}
</pre>
) : null
) : toolCall.name === 'exec' ? (
// exec 工具:终端风格
<div className="bg-zinc-950 rounded-md p-3 font-mono text-xs leading-relaxed space-y-2">
{(execCommand || toolCall.inputRaw) && (
<div className="flex items-start gap-2">
<span className="text-green-400 shrink-0 select-none">$</span>
<span className="text-zinc-100 whitespace-pre-wrap break-words">
{formatForDisplay(execCommand || toolCall.inputRaw || '')}
</span>
</div>
)}
{displayOutput && (
<div
className={cn(
'whitespace-pre-wrap break-words border-t border-zinc-800 pt-2',
toolCall.status === 'error' ? 'text-red-400' : 'text-zinc-300'
)}
>
{displayOutput}
</div>
)}
</div>
) : (
// 其他工具:保持原有格式
<>
{toolCall.input ? (
<div>
<div className="text-muted-foreground mb-1 font-medium">输入</div>
<pre className="bg-muted/50 rounded p-2 overflow-x-auto whitespace-pre-wrap break-words font-mono leading-relaxed">
{formatForDisplay(
typeof toolCall.input === 'string'
? toolCall.input
: JSON.stringify(toolCall.input, null, 2)
)}
</pre>
</div>
) : toolCall.inputRaw ? (
// tool.call.delta 流式累积的原始内容
<div>
<div className="text-muted-foreground mb-1 font-medium">输入(流式)</div>
<pre className="bg-muted/50 rounded p-2 overflow-x-auto whitespace-pre-wrap break-words font-mono leading-relaxed">
{toolCall.inputRaw}
</pre>
</div>
) : null}
{/* 错误状态下 output 通常与 error 内容重复,只展示错误区域 */}
{displayOutput && toolCall.status !== 'error' && (
<div>
<div className="text-muted-foreground mb-1 font-medium">输出</div>
<pre className="bg-muted/50 rounded p-2 overflow-x-auto whitespace-pre-wrap break-words font-mono leading-relaxed">
{displayOutput}
</pre>
</div>
)}
{toolCall.error && (
<div>
<div className="text-red-500 mb-1 font-medium">错误</div>
<pre className="bg-red-500/10 rounded p-2 overflow-x-auto whitespace-pre-wrap break-words font-mono leading-relaxed">
{formatForDisplay(
typeof toolCall.error === 'string'
? toolCall.error
: JSON.stringify(toolCall.error, null, 2)
)}
</pre>
</div>
)}
</>
)}
</div>
)}
</div>
)
}
function QuestionFlowBlock({
askedEvent,
message,
isLastAsked,
resolutionEvent,
}: {
askedEvent: EventItem
message: Message
isLastAsked: boolean
resolutionEvent: EventItem | null
}) {
// message.question 始终持有最新一轮提问;历史轮次从事件 payload 还原
const askedQuestions =
(askedEvent.payload?.questions as QuestionInfo[] | null | undefined) ?? null
const questions = isLastAsked ? (message.question ?? askedQuestions) : askedQuestions
let status: 'pending' | 'replied' | 'rejected' = 'pending'
let answers: string[][] | null = null
if (resolutionEvent) {
status = resolutionEvent.type === 'question.replied' ? 'replied' : 'rejected'
answers =
(resolutionEvent.payload?.answers as string[][] | undefined) ??
(isLastAsked ? (message.questionAnswers ?? null) : null)
} else if (isLastAsked && message.questionStatus) {
status = message.questionStatus
answers = message.questionAnswers ?? null
}
const [statusExpanded, setStatusExpanded] = useState(false)
const [cardExpanded, setCardExpanded] = useState(status === 'replied')
// 已跳过
if (status === 'rejected') {
return (
<div className="flex items-center gap-2 py-0.5 text-sm text-muted-foreground">
<CircleSlash className="h-3.5 w-3.5 shrink-0" />
<span>您跳过了此问题</span>
</div>
)
}
// 提问中:活跃等待态仅在消息仍在流式生成时展示
const waiting =
status === 'pending' &&
isLastAsked &&
!!message.isStreaming &&
(message.questionStatus ?? 'pending') === 'pending'
return (
<div className="space-y-2">
{/* 状态行:仅提问中(pending)展示 */}
{status === 'pending' && (
<div>
<button
onClick={() => questions?.length && setStatusExpanded(!statusExpanded)}
className="group/mod flex items-center gap-1.5 text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
{waiting && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />}
<span>等待你的回答</span>
{questions && questions.length > 0 && (
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 transition-all duration-150',
'opacity-0 -translate-x-1 group-hover/mod:translate-x-0 group-hover/mod:opacity-100',
statusExpanded && 'rotate-90'
)}
/>
)}
</button>
{statusExpanded && questions && (
<div className="mt-1.5 space-y-1 border-l border-border/50 pl-3 text-sm">
{questions.map((q, i) => (
<p key={i} className="leading-relaxed text-muted-foreground/80">
{i + 1}. {q.header || q.question}
</p>
))}
</div>
)}
</div>
)}
{/* 提问卡片行:向用户提问 */}
<div>
<button
onClick={() => setCardExpanded(!cardExpanded)}
className="group/mod flex items-center gap-1.5 text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span>向用户提问</span>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 transition-all duration-150',
'opacity-0 -translate-x-1 group-hover/mod:translate-x-0 group-hover/mod:opacity-100',
cardExpanded && 'rotate-90'
)}
/>
</button>
{cardExpanded && (
<div className="mt-1.5 space-y-3 rounded-xl bg-muted/50 px-4 py-3">
{questions && questions.length > 0 ? (
questions.map((q, i) => {
const ans = answers?.[i] ?? []
return (
<div key={i}>
<div className="text-sm text-muted-foreground">{q.header || q.question}</div>
{ans.length > 0 ? (
<div className="mt-0.5 text-sm font-semibold text-foreground">
{ans.join('、')}
</div>
) : (
<div className="mt-0.5 text-sm text-muted-foreground/60">
{waiting ? '待回答' : '(未作答)'}
</div>
)}
</div>
)
})
) : (
<div className="text-sm text-muted-foreground">{askedEvent.content}</div>
)}
</div>
)}
</div>
</div>
)
}
function ThinkingGroup({ events, completed }: { events: EventItem[]; completed: boolean }) {
const [expanded, setExpanded] = useState(!completed)
const scrollRef = useRef<HTMLDivElement>(null)
// 流式输出时保持滚动到底部
useEffect(() => {
if (expanded && !completed && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [events.length, expanded, completed])
return (
<div>
<button
onClick={() => setExpanded(!expanded)}
className="group/mod flex items-center gap-1.5 text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<span>深度思考</span>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 transition-all duration-150',
'opacity-0 -translate-x-1 group-hover/mod:translate-x-0 group-hover/mod:opacity-100',
expanded && 'rotate-90'
)}
/>
</button>
{expanded && (
<div
ref={scrollRef}
className="mt-1.5 max-h-72 space-y-1.5 overflow-y-auto border-l border-border/50 pl-3 text-sm scrollbar-thin"
>
{events.map((event, index) => (
<p key={`thinking-step-${index}`} className="leading-relaxed text-muted-foreground/80">
{event.content}
</p>
))}
</div>
)}
</div>
)
}
function AttachmentBadge({ attachment }: { attachment: Attachment }) {
const Icon = attachment.type === 'image' ? ImageIcon : FileText
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="max-w-[150px] truncate">{attachment.name}</span>
<span className="text-muted-foreground">{formatSize(attachment.size)}</span>
</div>
)
}