328 lines
11 KiB
TypeScript
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;
|