Files
deerflow-code/web-code/components/angentchat/AgentChatMessageList.tsx
T
2026-05-28 18:29:37 +08:00

328 lines
11 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { Bot, ChevronDown, CheckCircle2, Loader2, Sparkles, UserRound } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import MarkdownRenderer from '../MarkdownRenderer';
import { parseAgentResponse } from '../agent/agentResponseParser';
export interface AgentChatMsg {
id: string;
role: 'user' | 'assistant';
content: string;
}
interface AvatarProps {
role: 'user' | 'assistant';
isActive?: boolean;
}
const AgentAvatar: React.FC<AvatarProps> = ({ role, isActive = false }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
const gradientClass =
role === 'assistant'
? isDark
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white'
: isDark
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white';
return (
<div
className={`relative flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-2xl shadow-sm ${gradientClass} ${isActive ? 'qa-avatar-active' : ''}`}
>
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
<Icon className={`relative z-10 h-5 w-5 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
{role === 'assistant' && isActive && (
<Sparkles className="qa-avatar-spark absolute -right-0.5 -top-0.5 h-3.5 w-3.5 text-yellow-200" />
)}
</div>
);
};
interface CollapsibleProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
}
const Collapsible: React.FC<CollapsibleProps> = ({
title,
children,
defaultOpen = true,
className = '',
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [open, setOpen] = useState(defaultOpen);
return (
<div
className={`overflow-hidden rounded-2xl border ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}
>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${
isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
}`}
>
<div className="min-w-0 flex-1">{title}</div>
<ChevronDown
className={`h-4 w-4 shrink-0 transition-transform duration-300 ease-out ${open ? 'rotate-0' : '-rotate-90'} ${
isDark ? 'text-slate-400' : 'text-gray-500'
}`}
/>
</button>
<div
className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}
>
<div className="overflow-hidden">
<div
className={`px-4 pb-4 transition-transform duration-300 ease-out ${open ? 'translate-y-0' : '-translate-y-2'} ${
isDark ? 'text-slate-200' : 'text-gray-700'
}`}
>
{children}
</div>
</div>
</div>
</div>
);
};
interface AssistantAnswerProps {
steps: { title: string; content: string }[];
thinkingMarkdown: string;
answerMarkdown: string;
thinkingComplete: boolean;
streaming: boolean;
}
const AssistantAnswer: React.FC<AssistantAnswerProps> = ({
steps,
thinkingMarkdown,
answerMarkdown,
thinkingComplete,
streaming,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const showThinkingSection = Boolean(thinkingMarkdown) || (!thinkingComplete && streaming);
return (
<div className="w-full min-w-0 max-w-5xl space-y-3 overflow-hidden">
{steps.length > 0 && (
<Collapsible
defaultOpen
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 items-center justify-center rounded-2xl ${
isDark ? 'bg-emerald-500/15 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
}`}
>
<CheckCircle2 className="h-5 w-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
步骤
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
展示技能执行、上下文准备和模型调用进度
</div>
</div>
</div>
}
>
<div className="space-y-3">
{steps.map((step, index) => (
<div
key={`${step.title}-${index}`}
className={`rounded-xl border px-3 py-3 ${
isDark ? 'border-slate-700 bg-slate-900/40' : 'border-gray-100 bg-gray-50'
}`}
>
<div className={`text-sm font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
{step.title}
</div>
<div className={`mt-1 whitespace-pre-wrap text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
{step.content}
</div>
</div>
))}
</div>
</Collapsible>
)}
{showThinkingSection && (
<Collapsible
defaultOpen
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 items-center justify-center rounded-2xl ${
isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
}`}
>
<Sparkles className="h-5 w-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
思考过程
</div>
{thinkingComplete ? (
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
思考已完成,可展开查看
</div>
) : (
<div
className={`qa-generating-marquee text-xs ${
isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'
}`}
>
正在生成思考内容...
</div>
)}
</div>
</div>
}
>
{thinkingMarkdown ? (
<MarkdownRenderer
content={thinkingMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
/>
) : (
<div className={`flex items-center gap-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="h-4 w-4 animate-spin" />
等待模型输出思考内容...
</div>
)}
</Collapsible>
)}
{answerMarkdown ? (
<div
className={`rounded-2xl border p-4 ${
isDark ? 'border-slate-600/40 bg-slate-700/60' : 'border-gray-100 bg-white'
}`}
>
<MarkdownRenderer
content={answerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
/>
</div>
) : null}
</div>
);
};
interface MessageRowProps {
msg: AgentChatMsg;
streaming?: boolean;
}
const MessageRow: React.FC<MessageRowProps> = ({ msg, streaming = false }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isUser = msg.role === 'user';
const parsed = useMemo(() => {
if (isUser) return null;
return parseAgentResponse(msg.content);
}, [msg.content, isUser]);
if (isUser) {
return (
<div className="flex items-end justify-end gap-3">
<div
className={`max-w-[72%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm ${
isDark
? 'bg-blue-600/70 text-white'
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
}`}
>
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p>
</div>
<AgentAvatar role="user" />
</div>
);
}
return (
<div className="flex items-start gap-3">
<AgentAvatar role="assistant" isActive={streaming} />
<div className="min-w-0 flex-1">
{parsed ? (
<AssistantAnswer
steps={parsed.steps}
thinkingMarkdown={parsed.thinkingMarkdown}
answerMarkdown={parsed.answerMarkdown}
thinkingComplete={parsed.thinkingComplete && !streaming}
streaming={streaming}
/>
) : (
<div
className={`rounded-2xl border p-4 text-sm ${
isDark
? 'border-slate-600/40 bg-slate-700/60 text-slate-200'
: 'border-gray-100 bg-white text-gray-700'
}`}
>
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
</div>
);
};
interface AgentChatMessageListProps {
messages: AgentChatMsg[];
streamingMsgId?: string | null;
agentName?: string;
}
const AgentChatMessageList: React.FC<AgentChatMessageListProps> = ({
messages,
streamingMsgId,
agentName,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
if (messages.length === 0) {
return (
<div
className={`flex h-full flex-col items-center justify-center text-center ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}
>
<Bot className="mb-3 h-10 w-10" />
<p className="text-sm">直接与 {agentName || 'MCP 智能体'} 对话以测试当前配置</p>
<p className="mt-1 text-xs">已绑定技能会在步骤区显示实际执行过程</p>
</div>
);
}
return (
<div className="space-y-5">
{messages.map((msg, idx) => {
const isLast = idx === messages.length - 1;
const isStreaming = isLast && msg.role === 'assistant' && msg.id === streamingMsgId;
return <MessageRow key={msg.id} msg={msg} streaming={isStreaming} />;
})}
</div>
);
};
export default AgentChatMessageList;