784 lines
32 KiB
TypeScript
784 lines
32 KiB
TypeScript
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'}`}>
|
||
正在接收 <think> 流式思考内容
|
||
</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>
|
||
);
|
||
};
|