| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- import React, { useEffect } from 'react';
- import './style.less';
- import ReactMarkdown from 'react-markdown';
- import { createFromIconfontCN, LoadingOutlined } from '@ant-design/icons';
- import { Spin, Tooltip } from 'antd';
- import { Column, Line, Pie } from '@ant-design/charts';
- export interface Message {
- id?: string; // 创建消息时赋予唯一 id
- sender: 'user' | 'bot';
- thinkContent?: string;
- content: string; // 可能包含纯文本或 HTML
- loading?: boolean;
- duration?: number; // 回答耗时(秒)
- collapse?: boolean; // 保存折叠状态
- complete?: boolean;
- contentType?: string;
- charts?: {
- type: 'LineChart' | 'ColumnChart' | 'PieChart';
- xField: string;
- yField: string;
- angleField: string;
- colorField: string;
- data: any[];
- };
- }
- const IconFont = createFromIconfontCN({
- scriptUrl: '', // 请填写图标脚本地址
- });
- const antIcon = <LoadingOutlined style={{ fontSize: 14, color: '#17181A' }} spin />;
- // 单独封装一个 memoized 的 Spinner 组件,避免频繁重绘导致动画重置
- const SpinnerComponent: React.FC<{ loading: boolean }> = React.memo(({ loading }) => {
- return loading ? <Spin indicator={antIcon} style={{ paddingRight: 8 }} /> : null;
- });
- // 拆分机器人消息中的状态部分(头像旁边那块)
- // 新增属性 hasDeepThinking,根据该属性显示不同的文案
- interface BotMessageStatusProps {
- loading: boolean;
- duration?: number;
- collapse: boolean;
- onToggleCollapse: () => void;
- hasDeepThinking: boolean;
- }
- const BotMessageStatus: React.FC<BotMessageStatusProps> = React.memo(
- ({ loading, duration, collapse, onToggleCollapse, hasDeepThinking }) => {
- return (
- <div className="bot-message-status">
- {loading ? (
- <>
- <SpinnerComponent loading={loading} />
- {hasDeepThinking ? '深度思考中…' : '内容输出中…'}
- </>
- ) : (
- <>
- {hasDeepThinking ? `已深度思考${duration ? `(用时${duration}秒)` : ''}` : `内容已输出完毕${duration ? `(用时${duration}秒)` : ''}`}
- {hasDeepThinking && <IconFont type={collapse ? 'iconxiangxia' : 'iconxiangshang'} onClick={onToggleCollapse} style={{ cursor: 'pointer', paddingLeft: 8, fontSize: 16 }} />}
- </>
- )}
- </div>
- );
- },
- (prevProps, nextProps) =>
- prevProps.loading === nextProps.loading && prevProps.duration === nextProps.duration && prevProps.collapse === nextProps.collapse && prevProps.hasDeepThinking === nextProps.hasDeepThinking,
- );
- // 拆分机器人消息中的文本内容部分,同时处理图表展示
- interface BotMessageContentProps {
- thinkContent?: string;
- content: string;
- msg: Message;
- charts?: {
- type: string;
- xField: string;
- yField: string;
- angleField: string;
- colorField: string;
- data: any[];
- };
- // 新增 onShowDetail 回调属性,用于点击详情按钮时触发
- onShowDetail?: (charts: any, content: string) => void;
- }
- const BotMessageContent: React.FC<BotMessageContentProps> = React.memo(({ thinkContent, content, charts, onShowDetail, msg }) => {
-
- return (
- <div className="bot-message-text">
- <div className="think-text">{thinkContent}</div>
- <div className="msg-content">
- {/* 判断是否存在图表数据 */}
- {charts && charts.data && charts.data.length > 0 && (
- <div className={msg.content.length > 0 && msg.complete ? 'charts-content canReadDetail' : 'charts-content'}>
- <div className="charts-detail-btn" onClick={() => onShowDetail && onShowDetail(charts, content)}>
- <IconFont type={'iconchakan'} style={{ fontSize: 16, paddingRight: 4 }} />
- 详情
- </div>
- {(charts.type === 'LineChart' && charts) && <Line {...charts} />}
- {charts.type === 'ColumnChart' && <Column {...charts} />}
- {charts.type === 'PieChart' && <Pie {...charts} />}
- </div>
- )}
- <ReactMarkdown skipHtml={false}>{content}</ReactMarkdown>
- </div>
- </div>
- );
- });
- // 整体的机器人消息组件
- interface BotMessageProps {
- msg: Message;
- onToggleCollapse: () => void;
- handleCopy: (text: string) => void;
- ifCopied: boolean;
- // 新增 onShowDetail 回调属性
- onShowDetail?: (charts: any, content: string) => void;
- }
- const BotMessage: React.FC<BotMessageProps> = ({ msg, onToggleCollapse, handleCopy, ifCopied, onShowDetail }) => {
- const isCollapsed = msg.collapse || false;
- // 通过判断 thinkContent 是否存在且非空来确定是否存在深度思考内容
- const hasDeepThinking = !!(msg.thinkContent && msg.thinkContent.trim().length > 0);
- return (
- <div className="bot-message-content">
- <div className="bot-message-avatar">
- <img src={require('../images/ai-avator.png')} alt="" />
- <BotMessageStatus loading={msg.loading || false} duration={msg.duration} collapse={isCollapsed} onToggleCollapse={onToggleCollapse} hasDeepThinking={hasDeepThinking} />
- </div>
- <BotMessageContent
- thinkContent={isCollapsed ? '' : msg.thinkContent}
- content={msg.content}
- msg={msg}
- charts={msg.charts} // 传递图表数据
- onShowDetail={onShowDetail}
- />
- {msg.content.length > 0 && msg.complete && (
- <Tooltip title={ifCopied ? '已复制' : '点击复制'}>
- <div className="copyBtn" onClick={() => handleCopy(msg.content)}>
- <IconFont type={'iconfuzhi'} style={{ cursor: 'pointer', fontSize: 16 }} />
- </div>
- </Tooltip>
- )}
- </div>
- );
- };
- interface ChatProps {
- messages: Message[];
- chatContainerRef: React.RefObject<HTMLDivElement>;
- setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
- // 新增 onShowDetail 回调属性,用于将详情数据传递到上层组件(例如 AiChat)
- onShowDetail?: (charts: any, content: string) => void;
- }
- const Chat: React.FC<ChatProps> = ({ messages, chatContainerRef, setMessages, onShowDetail }) => {
- const [ifCopied, setIfCopied] = React.useState<boolean>(false);
- const fallbackCopyTextToClipboard = (text: string) => {
- const textArea = document.createElement('textarea');
- textArea.value = text;
- textArea.style.position = 'fixed';
- textArea.style.top = '0';
- textArea.style.left = '0';
- textArea.style.width = '2em';
- textArea.style.height = '2em';
- textArea.style.padding = '0';
- textArea.style.border = 'none';
- textArea.style.outline = 'none';
- textArea.style.boxShadow = 'none';
- textArea.style.background = 'transparent';
- document.body.appendChild(textArea);
- textArea.select();
- try {
- const successful = document.execCommand('copy');
- if (successful) setIfCopied(true);
- } catch (err) {
- console.error('复制失败', err);
- }
- document.body.removeChild(textArea);
- };
- const handleCopy = (text: string) => {
- if (!text) {
- console.error('未获取到文本内容');
- return;
- }
- if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
- navigator.clipboard
- .writeText(text)
- .then(() => {
- setIfCopied(true);
- })
- .catch((err) => {
- console.error('使用 Clipboard API 复制失败,尝试备用方案:', err);
- fallbackCopyTextToClipboard(text);
- });
- } else {
- fallbackCopyTextToClipboard(text);
- }
- };
- useEffect(() => {
- if (ifCopied) {
- const timer = setTimeout(() => {
- setIfCopied(false);
- }, 2000);
- return () => clearTimeout(timer);
- }
- }, [ifCopied]);
- return (
- <div className="chat-container" ref={chatContainerRef}>
- {messages.map((msg, idx) => {
- const key = msg.id || idx;
- if (msg.sender === 'user') {
- return (
- <div key={key} className="chat-message user-message">
- <div>{msg.content}</div>
- </div>
- );
- } else {
- return (
- <div key={key} className="chat-message bot-message">
- <BotMessage
- msg={msg}
- onToggleCollapse={() => {
- setMessages((prevMessages) => prevMessages.map((m, i) => (i === idx ? { ...m, collapse: !m.collapse } : m)));
- }}
- handleCopy={handleCopy}
- ifCopied={ifCopied}
- onShowDetail={onShowDetail}
- />
- </div>
- );
- }
- })}
- </div>
- );
- };
- export default Chat;
|