deerflow-code/frontend-web/src/strategy-components/components/qa/QACommon.tsx
2026-09-07 18:24:55 +08:00

784 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState } from 'react';
import { Textarea } from 'tdesign-react';
import {
Bookmark,
BrainCircuit,
Bot,
ChevronDown,
FileSearch,
Loader2,
RotateCcw,
Search,
Send,
Settings,
Sparkles,
Square,
Copy,
Trash2,
Edit3,
Upload,
UserRound,
} from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import MarkdownRenderer from '../../MarkdownRenderer';
import './qa-animations.css';
/**
* 单条检索结果。
*
* 多轮检索过程中每一轮可能返回多条资料,这里只保留 UI 展示所需的标题、摘要和来源。
*/
export interface QARetrievalItem {
id: string;
title: string;
content: string;
source: string;
}
/**
* 多轮检索中的单轮思考节点。
*
* `queryLoading` 控制该轮检索结果是否仍显示 loading;retrievals 到达后再渲染资料列表。
*/
export interface QAThinkingRound {
id: string; // 唯一标识符,用于列表渲染时的 key
title: string; // 该轮检索的标题,如"第1轮检索"或"背景资料收集"
thoughtMarkdown: string; // 模型思考过程的 Markdown 内容,展示思考步骤和推理逻辑
query: string; // 该轮发送给检索系统的查询语句
queryLoading?: boolean; // 该轮检索是否仍在进行中,true 时显示加载动画
retrievals: QARetrievalItem[]; // 该轮返回的检索结果列表
}
/**
* 多轮/深度检索的完整思考链。
*
* rounds 展示检索过程,finalAnswerMarkdown 展示最终回答;两者分开便于流式过程中逐步更新。
*/
export interface QAThinkingTrace {
title?: string; // 整体思考链的标题
rounds: QAThinkingRound[]; // 多轮检索过程中的所有轮次,每轮包含思考和检索结果
finalAnswerMarkdown: string; // 基于多轮检索结果生成的最终回答内容
}
/**
* 单轮 RAG 的回答结构。
*
* 后端可能把 `<think>...</think>` 和答案混在同一个 answer 流中,解析后分别放到 thinkingMarkdown 与 answerMarkdown。
*/
export interface QASingleRagTrace {
title?: string; // 思考过程的标题,如"单轮 RAG 思考过程"
thinkingMarkdown: string; // 模型在生成回答前的思考过程内容,使用 Markdown 格式
answerMarkdown: string; // 最终生成的回答内容,使用 Markdown 格式
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
}
export interface QACommonMessage {
id: string; // 消息唯一标识符
role: 'user' | 'assistant'; // 消息发送者角色:user 为用户消息,assistant 为 AI 回复
content: string; // 消息的纯文本内容(当使用 thinkingTrace 或 singleRagTrace 时为空)
timestamp?: Date; // 消息发送时间
thinkingTrace?: QAThinkingTrace; // 多轮/深度检索模式的思考链,包含多轮检索过程和最终回答
sources?: { // 消息引用的参考资料列表
title: string; // 参考资料标题
type: string; // 资料类型,如"文档"、"网页"等
snippet: string; // 资料摘要内容
source?: string; // 来源名称或 URL
time?: string; // 发布时间
url?: string; // 资料链接
content?: string; // 完整内容(可能用于详情弹窗)
}[];
}
export interface QACommonMessage {
singleRagTrace?: QASingleRagTrace; // 单轮 RAG 模式的思考过程和回答结构
}
interface QAAvatarProps {
role: 'user' | 'assistant';
isActive?: boolean;
label?: string;
variant?: 'default' | 'blue' | 'purple';
}
const getAssistantAvatarClass = (variant: QAAvatarProps['variant'], isDark: boolean) => {
switch (variant) {
case 'blue':
return isDark
? 'bg-gradient-to-br from-blue-400 via-blue-600 to-cyan-700 text-white'
: 'bg-gradient-to-br from-blue-300 via-blue-500 to-cyan-500 text-white';
case 'purple':
return isDark
? 'bg-gradient-to-br from-purple-400 via-violet-600 to-fuchsia-700 text-white'
: 'bg-gradient-to-br from-purple-300 via-violet-500 to-fuchsia-500 text-white';
default:
return 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';
}
};
/**
* 统一头像组件。
*
* 单一模式使用默认助手色;对比模式可通过 variant 指定蓝/紫色系,让头像与左右面板标签保持一致。
* `isActive` 会触发呼吸和星光动画,用来提示当前助手正在生成。
*/
export const QAAvatar: React.FC<QAAvatarProps> = ({ role, isActive = false, label, variant = 'default' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
return (
<div
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 overflow-hidden shadow-sm ${
role === 'assistant'
? getAssistantAvatarClass(variant, isDark)
: 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'
} ${isActive ? 'qa-avatar-active' : ''}`}
title={label}
>
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
<Icon className={`w-5 h-5 relative z-10 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
{role === 'assistant' && isActive && (
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200 qa-avatar-spark" />
)}
</div>
);
};
interface TypingIndicatorProps {
label?: string;
assistantLabel?: string;
}
/**
* 助手预连接/生成中的占位提示。
*
* 用于请求已经发出但还没有生成可展示消息时,给用户明确的等待反馈。
*/
export const TypingIndicator: React.FC<TypingIndicatorProps> = ({ label = '正在思考并生成回复', assistantLabel = 'AI 助手' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="flex gap-3">
<QAAvatar role="assistant" isActive label={assistantLabel} />
<div className={`max-w-[70%] rounded-2xl px-4 py-3 shadow-sm border ${
isDark ? 'bg-slate-700/60 border-slate-600/50 text-slate-200' : 'bg-white border-red-100 text-gray-700'
}`}>
<div className="flex items-center gap-3">
<Loader2 className={`w-4 h-4 animate-spin ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span
className={`text-sm font-medium qa-generating-marquee ${
isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light'
}`}
>
{label}
</span>
<div className="flex gap-1">
{[0, 1, 2].map((idx) => (
<span
key={idx}
className={`w-1.5 h-1.5 rounded-full qa-typing-dot ${isDark ? 'bg-blue-300' : 'bg-red-400'}`}
style={{ animationDelay: `${idx * 160}ms` }}
/>
))}
</div>
</div>
</div>
</div>
);
};
interface ChatMessageProps<T extends QACommonMessage> {
message: T;
showSources?: boolean;
assistantLabel?: string;
assistantActive?: boolean;
onCitationClick?: (message: T, citationIndex: number) => void;
onCopyAssistant?: (message: T) => void;
onDeleteAssistant?: (message: T) => void;
onEditAssistant?: (message: T) => void;
onResendAssistant?: (message: T) => void;
renderAssistantExtra?: (message: T) => React.ReactNode;
}
/**
* 聊天消息渲染器。
*
* 根据消息类型自动选择普通文本、单轮 RAG 回答或多轮检索思考链。
* sources 区域独立折叠展示,避免引用资料过长时挤占主回答阅读空间。
*/
export function ChatMessage<T extends QACommonMessage>({
message,
showSources = true,
assistantLabel = 'AI 助手',
assistantActive = false,
onCitationClick,
onCopyAssistant,
onDeleteAssistant,
onEditAssistant,
onResendAssistant,
renderAssistantExtra,
}: ChatMessageProps<T>) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isUser = message.role === 'user';
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
return (
<div className="space-y-3">
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
{!isUser && <QAAvatar role="assistant" isActive={assistantActive} label={assistantLabel} />}
<div className={`${hasTrace ? 'max-w-[85%] flex-1' : 'max-w-[72%]'} rounded-2xl px-4 py-3 shadow-sm ${
isUser
? isDark
? 'bg-blue-600/70 text-white'
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
: message.singleRagTrace || message.thinkingTrace
? isDark
? 'bg-slate-800/40 text-slate-100 border border-slate-600/40'
: 'bg-transparent text-gray-800 border border-transparent shadow-none px-0 py-0'
: isDark
? 'bg-slate-700/60 text-slate-100 border border-slate-600/40'
: 'bg-white text-gray-800 border border-gray-100'
}`}>
{message.singleRagTrace && !isUser ? (
<SingleRagAnswer
trace={message.singleRagTrace}
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
/>
) : message.thinkingTrace && !isUser ? (
<RetrievalThinkingTrace
trace={message.thinkingTrace}
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
/>
) : (
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
)}
{!isUser && (onCopyAssistant || onDeleteAssistant || onEditAssistant || onResendAssistant) && (
<div className={`mt-2 flex items-center justify-end gap-1.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{onCopyAssistant && (
<button type="button" onClick={() => onCopyAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-slate-100' : 'hover:bg-gray-100 hover:text-gray-700'}`} title="复制回答">
<Copy className="w-3.5 h-3.5" />
</button>
)}
{onDeleteAssistant && (
<button type="button" onClick={() => onDeleteAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-rose-300' : 'hover:bg-red-50 hover:text-red-600'}`} title="删除问答">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{onEditAssistant && (
<button type="button" onClick={() => onEditAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-blue-300' : 'hover:bg-blue-50 hover:text-blue-600'}`} title="编辑回答和思考过程">
<Edit3 className="w-3.5 h-3.5" />
</button>
)}
{onResendAssistant && (
<button type="button" onClick={() => onResendAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-emerald-300' : 'hover:bg-emerald-50 hover:text-emerald-600'}`} title="重新发送">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
</div>
{isUser && <QAAvatar role="user" label="用户" />}
</div>
{!isUser && message.sources && showSources && (
<CollapsibleSection
defaultOpen={false}
className={`ml-12 ${hasTrace ? 'max-w-[85%]' : 'max-w-[72%]'} ${isDark ? 'bg-slate-800/50 border-slate-700/50' : 'bg-gray-50 border-gray-200'}`}
title={(
<div className="flex items-center gap-2">
<BrainCircuit className={`w-3.5 h-3.5 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span className={`text-xs font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>引用来源</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>({message.sources.length})</span>
</div>
)}
>
<div className="space-y-2">
{message.sources.map((source, idx) => (
<div key={idx} className={`text-xs p-2 rounded-[4px] border shadow-sm transition-all duration-200 ease-out cursor-pointer hover:-translate-y-0.5 hover:scale-[1.01] ${isDark ? 'bg-slate-700/50 border-slate-600/50 shadow-black/10 hover:bg-slate-700/80 hover:border-blue-400/40 hover:shadow-lg hover:shadow-blue-950/20' : 'bg-white border-gray-100 shadow-gray-200/60 hover:border-red-200 hover:shadow-md hover:shadow-red-100/70'}`}>
<div className="flex items-center gap-2 mb-1 min-w-0">
<BrainCircuit className={`w-3 h-3 shrink-0 ${isDark ? 'text-blue-400' : 'text-red-400'}`} />
<span className={`font-medium truncate min-w-0 flex-1 ${isDark ? 'text-slate-300' : 'text-gray-700'}`} title={source.title}>{source.title}</span>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs ${isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-200 text-gray-500'}`}>
{source.type}
</span>
</div>
<p className={`line-clamp-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{source.snippet}</p>
</div>
))}
</div>
</CollapsibleSection>
)}
{!isUser && renderAssistantExtra?.(message)}
</div>
);
}
interface CollapsibleSectionProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
headerClassName?: string;
}
/**
* 通用折叠面板。
*
* 思考过程、检索结果、引用来源都复用该组件,保证展开/收起动画和深浅色主题一致。
*/
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
defaultOpen = true,
className = '',
headerClassName = '',
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`rounded-2xl border overflow-hidden ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={`w-full flex 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'
} ${headerClassName}`}
>
<div className="flex-1 min-w-0">{title}</div>
<ChevronDown
className={`w-4 h-4 flex-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 SingleRagAnswerProps {
trace: QASingleRagTrace;
onCitationClick?: (citationIndex: number) => void;
}
/**
* 单轮 RAG 回答展示。
*
* 思考内容和最终回答分成两个区域:思考区可折叠,回答区始终展示。
* 这样既保留模型推理过程,也不会影响用户优先阅读最终答案。
*/
export const SingleRagAnswer: React.FC<SingleRagAnswerProps> = ({ trace, onCitationClick }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<CollapsibleSection
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={`w-9 h-9 rounded-2xl flex items-center justify-center ${
isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
}`}>
<Sparkles className="w-5 h-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>{trace.title || '思考过程'}</div>
{trace.thinkingComplete ? (
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
思考已完成,可展开查看
</div>
) : (
<div className={`text-xs qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'}`}>
正在接收 &lt;think&gt; 流式思考内容
</div>
)}
</div>
</div>
}
>
{trace.thinkingMarkdown ? (
<MarkdownRenderer
content={trace.thinkingMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
) : (
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
等待模型输出思考内容...
</div>
)}
</CollapsibleSection>
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
<div className={`text-sm font-semibold mb-3 ${isDark ? 'text-white' : 'text-gray-900'}`}>生成的回答</div>
{trace.answerMarkdown ? (
<MarkdownRenderer
content={trace.answerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
) : (
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
{trace.thinkingComplete ? '正在流式生成回答...' : '思考结束后开始生成回答...'}
</div>
)}
</div>
</div>
);
};
interface RetrievalListProps {
items: QARetrievalItem[];
}
/**
* 检索结果列表。
*
* 每条资料默认折叠,首条默认展开,便于用户快速看到最相关资料,同时保留完整资料明细。
*/
const RetrievalList: React.FC<RetrievalListProps> = ({ items }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="space-y-2">
{items.map((item, index) => (
<CollapsibleSection
key={item.id}
defaultOpen={index === 0}
className={isDark ? 'bg-slate-800/40' : 'bg-gray-50/70'}
title={
<div className="flex items-center gap-2">
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-xs font-semibold ${
isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-red-100 text-red-600'
}`}>{index + 1}</span>
<div className="min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{item.title}</div>
<div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>来源:{item.source}</div>
</div>
</div>
}
>
<p className={`text-sm leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{item.content}</p>
</CollapsibleSection>
))}
</div>
);
};
interface SearchQueryBadgeProps {
query: string;
loading?: boolean;
}
/**
* 检索词徽标。
*
* 多轮检索每一轮都会产生一个 query;loading 状态下展示旋转图标,表示该 query 正在召回资料。
*/
const SearchQueryBadge: React.FC<SearchQueryBadgeProps> = ({ query, loading = false }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1.5 border ${
isDark ? 'bg-blue-500/10 border-blue-400/20 text-blue-200' : 'bg-red-50 border-red-100 text-red-600'
}`}>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
<span className="text-xs flex-shrink-0">检索词</span>
<span className={`text-xs font-medium truncate qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light'}`}>
{query}
</span>
</div>
);
};
interface RetrievalThinkingTraceProps {
trace: QAThinkingTrace;
onCitationClick?: (citationIndex: number) => void;
}
/**
* 多轮/深度检索思考链展示。
*
* 组件按轮次渲染思考内容、检索词和检索结果,底部展示最终回答。
* 它只消费已经整理好的 trace 数据,不关心数据来自真实 SSE 还是本地模拟。
*/
export const RetrievalThinkingTrace: React.FC<RetrievalThinkingTraceProps> = ({ trace, onCitationClick }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<CollapsibleSection
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={`w-9 h-9 rounded-2xl flex items-center justify-center ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
}`}>
<BrainCircuit className="w-5 h-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{trace.title || '思考过程'}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
共 {trace.rounds.length} 轮推理与检索,可展开查看细节
</div>
</div>
</div>
}
>
<div className="space-y-3 pt-1">
{trace.rounds.map((round, index) => (
<div key={round.id} className={`relative pl-5 ${index < trace.rounds.length - 1 ? 'pb-2' : ''}`}>
{index < trace.rounds.length - 1 && (
<div className={`absolute left-[7px] top-8 bottom-0 w-px ${isDark ? 'bg-slate-700' : 'bg-red-100'}`} />
)}
<div className={`absolute left-0 top-4 w-3.5 h-3.5 rounded-full border-2 ${
isDark ? 'bg-slate-900 border-cyan-400' : 'bg-white border-red-400'
}`} />
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-900/40' : 'bg-red-50/30'}
title={
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-100 text-red-600'
}`}>第 {index + 1} 轮</span>
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{round.title}</span>
</div>
}
>
<div className="space-y-3">
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
title={
<div className="flex items-center gap-2">
<Sparkles className={`w-4 h-4 ${isDark ? 'text-violet-300' : 'text-orange-500'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>思考内容</span>
</div>
}
>
<MarkdownRenderer
content={round.thoughtMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
</CollapsibleSection>
<div className="flex items-center">
<SearchQueryBadge query={round.query} loading={round.queryLoading} />
</div>
<CollapsibleSection
defaultOpen={!round.queryLoading}
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
title={
<div className="flex items-center gap-2">
<FileSearch className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>检索内容</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{round.retrievals.length} 条</span>
</div>
}
>
{round.queryLoading ? (
<div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
正在召回相关内容...
</div>
) : (
<RetrievalList items={round.retrievals} />
)}
</CollapsibleSection>
</div>
</CollapsibleSection>
</div>
))}
</div>
</CollapsibleSection>
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
<div className={`text-sm font-semibold mb-3 ${isDark ? 'text-white' : 'text-gray-900'}`}>最终回答</div>
<MarkdownRenderer
content={trace.finalAnswerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
</div>
</div>
);
};
interface EmptyChatStateProps {
icon?: React.ReactNode;
title: string;
description: string;
}
/**
* 空状态展示。
*
* 当当前模式没有任何消息时使用,提示用户可以开始输入问题。
*/
export const EmptyChatState: React.FC<EmptyChatStateProps> = ({ icon, title, description }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`h-full flex flex-col items-center justify-center ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${
isDark ? 'bg-slate-700/40' : 'bg-gradient-to-br from-red-50 to-orange-50'
}`}>
{icon ?? <Bot className="w-11 h-11 opacity-60" />}
</div>
<p className="text-lg font-medium">{title}</p>
<p className="text-sm mt-2">{description}</p>
</div>
);
};
interface QAComposerProps {
value: string;
placeholder: string;
isLoading: boolean;
canSubmit?: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onUpload?: () => void;
onReset?: () => void;
onSave?: () => void;
onCollect?: () => void;
onOpenSettings?: () => void;
showUpload?: boolean;
showReset?: boolean;
showSave?: boolean;
showCollect?: boolean;
}
/**
* 问答输入区。
*
* 负责输入、发送/停止、附件、重置、保存、收藏和模型参数设置入口。
* 具体业务动作通过 props 回调交给父组件处理,保证输入区本身不依赖某个问答模式。
*/
export const QAComposer: React.FC<QAComposerProps> = ({
value,
placeholder,
isLoading,
canSubmit,
onChange,
onSubmit,
onStop,
onKeyDown,
onUpload,
onReset,
onSave,
onCollect,
onOpenSettings,
showUpload = true,
showReset = false,
showSave = true,
showCollect = true,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const submitEnabled = canSubmit ?? Boolean(value.trim());
const iconButtonClass = `p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-600 text-slate-400' : 'hover:bg-gray-200 text-gray-500'}`;
return (
<div className={`border-t p-4 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDark ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
<Textarea
value={value}
onChange={(nextValue) => onChange(String(nextValue))}
onKeyDown={onKeyDown}
placeholder={placeholder}
autosize={{ minRows: 1, maxRows: 15 }}
disabled={isLoading}
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${
isDark
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
}`}
/>
<div className="flex items-center gap-2">
{showUpload && (
<button onClick={onUpload} className={iconButtonClass} title="上传附件" type="button">
<Upload className="w-5 h-5" />
</button>
)}
{showReset && (
<button onClick={onReset} className={iconButtonClass} title="重新提问" type="button">
<RotateCcw className="w-5 h-5" />
</button>
)}
{showSave && (
<button onClick={onSave} className={iconButtonClass} title="保存记录" type="button">
<Bookmark className="w-5 h-5" />
</button>
)}
{showCollect && (
<button onClick={onCollect} className={iconButtonClass} title="加入收藏" type="button">
<Bookmark className="w-5 h-5" />
</button>
)}
{onOpenSettings && (
<button onClick={onOpenSettings} className={iconButtonClass} title="模型参数设置" type="button">
<Settings className="w-5 h-5" />
</button>
)}
<button
onClick={isLoading ? onStop : onSubmit}
disabled={!isLoading && !submitEnabled}
className={`px-4 py-2 rounded-xl font-medium transition-all flex items-center gap-2 shadow-sm ${
isLoading
? isDark
? 'bg-rose-600 text-white qa-stop-breathing'
: 'bg-red-600 text-white qa-stop-breathing'
: submitEnabled
? isDark
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
: isDark
? 'bg-slate-600 text-slate-400 cursor-not-allowed'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
type="button"
>
{isLoading ? <Square className="w-4 h-4 fill-current" /> : <Send className="w-4 h-4" />}
{isLoading ? '停止' : '发送'}
</button>
</div>
</div>
</div>
);
};