import React, { useEffect, useState } from 'react'; import { Dialog, Textarea } from 'tdesign-react'; import { Bookmark, BrainCircuit, Bot, ChevronDown, FileSearch, Loader2, RotateCcw, Search, Send, Settings, Sparkles, Square, Copy, Trash2, Edit3, Upload, UserRound, ExternalLink, } from 'lucide-react'; import { useTheme } from '../../contexts/ThemeContext'; import MarkdownRenderer from '../MarkdownRenderer'; import { SourceItem } from './GeneralQA.types'; /** * 单条检索结果。 * * 多轮检索过程中每一轮可能返回多条资料,这里只保留 UI 展示所需的标题、摘要和来源。 */ export interface QARetrievalItem { id?: string; title: string; content: string; keywords?: string; taskDirection?: string; type?: string; time?: string; source?: string; recUuid?: string; platform?: string; fans?: number; comment_count?: number; message_id?: string; hot_num?: number; } /** * 多轮检索中的单轮思考节点。 * * `queryLoading` 控制该轮检索结果是否仍显示 loading;retrievals 到达后再渲染资料列表。 */ export interface QAThinkingRound { id: string; // 唯一标识符,用于列表渲染时的 key title: string; // 该轮检索的标题,如"第1轮检索"或"背景资料收集" thoughtMarkdown: string; // 模型思考过程的 Markdown 内容,展示思考步骤和推理逻辑 queryTitle?: string; // 检索内容 query: string; // 该轮发送给检索系统的查询语句 queryLoading?: boolean; // 该轮检索是否仍在进行中,true 时显示加载动画 textidContent?: string; // 新闻详细内容 retrievals: QARetrievalItem[]; // 该轮返回的检索结果列表 queryShow: boolean; // 检索列表为空时是否显示 } /** * 多轮/深度检索的完整思考链。 * * rounds 展示检索过程,finalAnswerMarkdown 展示最终回答;两者分开便于流式过程中逐步更新。 */ export interface QAThinkingTrace { title?: string; // 整体思考链的标题 rounds: QAThinkingRound[]; // 多轮检索过程中的所有轮次,每轮包含思考和检索结果 finalAnswerMarkdown: string; // 基于多轮检索结果生成的最终回答内容 thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠 } /** * 单轮 RAG 的回答结构。 * * 后端可能把 `...` 和答案混在同一个 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 = ({ role, isActive = false, label, variant = 'default' }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; const Icon = role === 'assistant' ? Bot : UserRound; return (
{role === 'assistant' && isActive && ( )}
); }; interface TypingIndicatorProps { label?: string; assistantLabel?: string; } /** * 助手预连接/生成中的占位提示。 * * 用于请求已经发出但还没有生成可展示消息时,给用户明确的等待反馈。 */ export const TypingIndicator: React.FC = ({ label = '正在思考并生成回复', assistantLabel = 'AI 助手' }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; return (
{label} {/*
{[0, 1, 2].map((idx) => ( ))}
*/}
); }; interface ChatMessageProps { 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({ message, showSources = true, assistantLabel = 'AI 助手', assistantActive = false, onCitationClick, onCopyAssistant, onDeleteAssistant, onEditAssistant, onResendAssistant, renderAssistantExtra, }: ChatMessageProps) { const { theme } = useTheme(); const isDark = theme === 'dark'; const isUser = message.role === 'user'; const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace); const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL; const [visible, setVisible] = useState(false); // 打开详情对话框 const [listData, setListData] = useState({ title: '', type: '', time: '', snippet: '', source: '', content: '', }); const showDialog = (item: SourceItem) => { console.log('item', item) if (item.url) { window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.url}`); } else { setListData(item); setVisible(true); } }; return (
{!isUser && }
{message.singleRagTrace && !isUser ? ( onCitationClick?.(message, citationIndex)} /> ) : message.thinkingTrace && !isUser ? ( onCitationClick?.(message, citationIndex)} /> ) : (

{message.content}

)} {!isUser && (onCopyAssistant || onDeleteAssistant || onEditAssistant || onResendAssistant) && (
{onCopyAssistant && ( )} {onDeleteAssistant && ( )} {onEditAssistant && ( )} {onResendAssistant && ( )}
)}
{isUser && }
{!isUser && message.sources && message.sources.length > 0 && showSources && ( 引用来源 ({message.sources.length})
)} >
{message.sources.map((source, idx) => (
showDialog(source)}>
{source.title} {source.type}

{source.snippet}

))}
)} setVisible(false)} header="提示" footer={null} >

{listData.title || '引用内容'}

来源:{listData.source || '未知来源'}
时间:{listData.time || '未知'}
{listData.url && ( )}
{listData.content || '暂无引用内容'}
{!isUser && renderAssistantExtra?.(message)}
); } interface CollapsibleSectionProps { title: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; className?: string; headerClassName?: string; } /** * 通用折叠面板。 * * 思考过程、检索结果、引用来源都复用该组件,保证展开/收起动画和深浅色主题一致。 */ const CollapsibleSection: React.FC = ({ title, children, defaultOpen = true, className = '', headerClassName = '', }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; const [open, setOpen] = useState(defaultOpen); // 当 defaultOpen 改变时,更新 open 状态 useEffect(() => { setOpen(defaultOpen); }, [defaultOpen]); return (
{children}
); }; interface SingleRagAnswerProps { trace: QASingleRagTrace; onCitationClick?: (citationIndex: number) => void; } /** * 单轮 RAG 回答展示。 * * 思考内容和最终回答分成两个区域:思考区可折叠,回答区始终展示。 * 这样既保留模型推理过程,也不会影响用户优先阅读最终答案。 */ export const SingleRagAnswer: React.FC = ({ trace, onCitationClick }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; return (
{trace.title || '思考过程'}
{trace.thinkingComplete ? (
思考已完成,可展开查看
) : (
正在接收 <think> 流式思考内容
)}
} > {trace.thinkingMarkdown ? ( ) : (
等待模型输出思考内容...
)}
{trace.answerMarkdown ? ( ) : (
{trace.thinkingComplete ? '正在流式生成回答...' : '思考结束后开始生成回答...'}
)}
); }; interface RetrievalListProps { items: QARetrievalItem[]; } /** * 检索结果列表。 * * 每条资料默认折叠,首条默认展开,便于用户快速看到最相关资料,同时保留完整资料明细。 */ const RetrievalList: React.FC = ({ items }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; const [visible, setVisible] = useState(false); // 打开详情对话框 const [listData, setListData] = useState({ title: '', content: '', }); const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL; const b1SearchUrl = import.meta.env.VITE_AIPI_B1COH_URL; const b2SearchUrl = import.meta.env.VITE_AIPI_B2ACTOR_URL; const showDialog = (item: QARetrievalItem) => { console.log('item', item) if (item.type === 'more') { if (item.recUuid) { window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.recUuid}`); } else { setListData(item); setVisible(true); } } else if (item.type === 'task') { window.open(`${b1SearchUrl}/#/task/item-aicoh/step-info-search?id=${item.id}`) } else if (item.type === 'hx') { window.open(`${b2SearchUrl}/fromB/toDetail?user_id=${item.id}&platform=${item.platform}`); } else { setListData(item); setVisible(true); } }; return (
{items.map((item, index) => (
{ item.type !== 'user_behavior' ? {index + 1}
{item.title}
{ item.type === 'more' &&
时间:{item.time || '-'}
} { item.type === 'task' &&
关键词:{item.keywords || '-'}
} { item.type === 'hx' &&
平台:{item.platform || '-'}粉丝数:{item.fans || '-'}
} { (item.type === 'taiwan_news' || item.type === 'world_news' || item.type === 'messaage_comment' || item.type === 'america_news') &&
平台:{item.platform || '-'}{item.comment_count && 评论数:{item.comment_count || '-'}}
} { item.type === 'get_time_data' &&
时间:{item.time || '-'}热度:{item.hot_num || '-'}
}
} >

showDialog(item)}>{item.content}

:
{index + 1}. {item.title}
}
))} setVisible(false)} header="提示" footer={null} >

{listData.title}

{listData.time && 时间:{listData.time || '未知'}} {listData.platform && 平台:{listData.platform || '未知'}} {listData.comment_count && 评论数:{listData.comment_count || '未知'}}
{listData.content || '暂无引用内容'}
); }; interface SearchQueryBadgeProps { query: string; loading?: boolean; queryTitle?: string; } /** * 检索词徽标。 * * 多轮检索每一轮都会产生一个 query;loading 状态下展示旋转图标,表示该 query 正在召回资料。 */ const SearchQueryBadge: React.FC = ({ query, loading = false, queryTitle = '检索词' }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; return (
{loading ? : } {queryTitle} {query}
); }; interface RetrievalThinkingTraceProps { trace: QAThinkingTrace; onCitationClick?: (citationIndex: number) => void; } /** * 多轮/深度检索思考链展示。 * * 组件按轮次渲染思考内容、检索词和检索结果,底部展示最终回答。 * 它只消费已经整理好的 trace 数据,不关心数据来自真实 SSE 还是本地模拟。 */ export const RetrievalThinkingTrace: React.FC = ({ trace, onCitationClick }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; return (
{trace.title || '思考过程'}
共 {trace.rounds.length} 轮推理与检索,可展开查看细节
} >
{trace.rounds.map((round, index) => (
{index < trace.rounds.length - 1 && (
)}
第 {index + 1} 轮 {round.title}
} >
思考内容
} > { round.queryShow &&
} { round.queryShow && 检索内容 {round.retrievals.length} 条
} > {round.queryLoading ? (
正在召回相关内容...
) : ( )} } { round.textidContent &&
{round.textidContent}
}
))}
最终回答
{ !trace.finalAnswerMarkdown &&
正在生成思考过程与检索内容...
}
); }; interface EmptyChatStateProps { icon?: React.ReactNode; title: string; description: string; } /** * 空状态展示。 * * 当当前模式没有任何消息时使用,提示用户可以开始输入问题。 */ export const EmptyChatState: React.FC = ({ icon, title, description }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; return (
{icon ?? }

{title}

{description}

); }; interface QAComposerProps { value: string; placeholder: string; isLoading: boolean; canSubmit?: boolean; onChange: (value: string) => void; onSubmit: () => void; onStop: () => void; onKeyDown?: (e) => 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 = ({ 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'}`; const handleKeyDown = (val, context) => { if (context?.e?.key === 'Enter') { // 阻止默认的换行行为(如果是 Textarea) context.e.preventDefault(); // 模拟发送请求 onKeyDown(val) } }; return (