| | |
| | | 'use client'; |
| | | |
| | | import { useState, useEffect } from 'react'; |
| | | import { useState, useEffect, useRef, useCallback, useContext, createContext } from 'react'; |
| | | import Link from 'next/link'; |
| | | import { useRouter } from 'next/navigation'; |
| | | import Image from 'next/image'; |
| | | import ReactMarkdown from 'react-markdown'; |
| | | import remarkGfm from 'remark-gfm'; |
| | | import rehypeRaw from 'rehype-raw'; |
| | | import rehypeSanitize from 'rehype-sanitize'; |
| | | import dynamic from 'next/dynamic'; |
| | | import { ReactNode } from 'react'; |
| | | |
| | | export default function Page() { |
| | | const router = useRouter(); |
| | | const [token, setToken] = useState<string | null>(null); |
| | | // 创建一个消息完成状态的Context |
| | | const MessageCompletionContext = createContext<boolean>(true); |
| | | |
| | | // 动态导入 ECharts,确保它只在客户端渲染 |
| | | const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false }); |
| | | |
| | | interface Message { |
| | | role: 'user' | 'assistant'; |
| | | content: string | null; |
| | | timestamp: number; |
| | | id: string; |
| | | conversation_id?: string; |
| | | feedback?: 'like' | 'dislike' | null; |
| | | metadata?: { |
| | | usage?: { |
| | | prompt_tokens: number; |
| | | completion_tokens: number; |
| | | total_tokens: number; |
| | | total_price: string; |
| | | }; |
| | | }; |
| | | } |
| | | |
| | | const BASE_URL = 'http://121.43.139.99:7000'; |
| | | |
| | | // 默认用户头像 |
| | | const DEFAULT_USER_AVATAR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23999999'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/%3E%3C/svg%3E"; |
| | | const AI_AVATAR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%234F46E5'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 1c4.96 0 9 4.04 9 9s-4.04 9-9 9-9-4.04-9-9 4.04-9 9-9zm0 3.5c-2.48 0-4.5 2.02-4.5 4.5h1.5c0-1.66 1.34-3 3-3s3 1.34 3 3h1.5c0-2.48-2.02-4.5-4.5-4.5zM8 13h2v2H8v-2zm6 0h2v2h-2v-2z'/%3E%3C/svg%3E"; |
| | | |
| | | // 添加用于解析和渲染 ECharts 的组件 |
| | | function EchartsRenderer({ code }: { code: string }) { |
| | | const [error, setError] = useState<string | null>(null); |
| | | const [isLoading, setIsLoading] = useState(true); |
| | | const [isModalOpen, setIsModalOpen] = useState(false); |
| | | const chartContainerRef = useRef<HTMLDivElement>(null); |
| | | const modalChartRef = useRef<HTMLDivElement>(null); |
| | | const hasRenderedRef = useRef<boolean>(false); |
| | | const chartInstanceRef = useRef<any>(null); |
| | | |
| | | // 初始化DOM容器 - 提前设置固定高度 |
| | | useEffect(() => { |
| | | // 获取token |
| | | const storedToken = localStorage.getItem('token'); |
| | | setToken(storedToken); |
| | | |
| | | // 如果没有token,直接跳转到登录页面 |
| | | if (!storedToken) { |
| | | router.push('/login'); |
| | | // 确保容器准备好了并且有确定的高度 |
| | | if (chartContainerRef.current) { |
| | | // 设置一个固定的高度,避免渲染后变化 |
| | | const container = chartContainerRef.current; |
| | | container.style.width = '100%'; |
| | | container.style.height = '400px'; // 固定高度400px |
| | | container.style.minHeight = '400px'; // 防止高度变小 |
| | | |
| | | // 添加临时内容,确保DOM渲染完成 |
| | | container.innerHTML = '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#f5f7f9;"><span>正在准备图表...</span></div>'; |
| | | } |
| | | }, []); |
| | | |
| | | // 一次性加载图表,不进行动态更新 |
| | | useEffect(() => { |
| | | // 如果已经渲染过,跳过 |
| | | if (hasRenderedRef.current) { |
| | | return; |
| | | } |
| | | |
| | | setIsLoading(true); |
| | | |
| | | // 确保DOM元素已经完全挂载和渲染 |
| | | const timer = setTimeout(() => { |
| | | // 确保DOM元素仍然存在 |
| | | if (!chartContainerRef.current) { |
| | | console.error('图表容器不存在,跳过初始化'); |
| | | setIsLoading(false); |
| | | return; |
| | | } |
| | | |
| | | // 清除临时内容 |
| | | chartContainerRef.current.innerHTML = ''; |
| | | |
| | | // 标记为已渲染 |
| | | hasRenderedRef.current = true; |
| | | |
| | | // 初始化图表 |
| | | const initializeChart = async () => { |
| | | try { |
| | | const echarts = await import('echarts'); |
| | | |
| | | // 初始化图表 |
| | | const chartInstance = echarts.init(chartContainerRef.current); |
| | | chartInstanceRef.current = chartInstance; |
| | | |
| | | // 尝试解析和设置图表选项 |
| | | try { |
| | | const safeCode = code.replace(/window\.option/g, 'option'); |
| | | const safeFunc = new Function(` |
| | | "use strict"; |
| | | let option; |
| | | try { |
| | | ${safeCode} |
| | | return option; |
| | | } catch (e) { |
| | | console.error("图表代码执行错误:", e); |
| | | return null; |
| | | } |
| | | `); |
| | | |
| | | const chartOption = safeFunc(); |
| | | |
| | | if (chartOption) { |
| | | // 禁用动画,避免渲染时的布局变化 |
| | | chartOption.animation = false; |
| | | |
| | | // 修改tooltip配置,确保显示在适当位置 |
| | | if (chartOption.tooltip) { |
| | | chartOption.tooltip = { |
| | | ...chartOption.tooltip, |
| | | confine: false, // 不限制在图表区域内 |
| | | extraCssText: 'z-index:9999; pointer-events:auto; margin-top:0;' |
| | | }; |
| | | } |
| | | |
| | | chartInstance.setOption(chartOption); |
| | | setError(null); |
| | | } else { |
| | | throw new Error("无法获取图表配置"); |
| | | } |
| | | } catch (e) { |
| | | console.error('图表代码执行错误:', e); |
| | | setError('图表配置错误'); |
| | | |
| | | // 设置一个默认图表以显示错误 |
| | | chartInstance.setOption({ |
| | | title: { text: '图表配置错误' }, |
| | | xAxis: { type: 'category', data: ['错误'] }, |
| | | yAxis: { type: 'value' }, |
| | | series: [{ data: [0], type: 'bar' }] |
| | | }); |
| | | } |
| | | |
| | | // 添加窗口大小变化监听器 |
| | | const handleResize = () => chartInstance.resize(); |
| | | window.addEventListener('resize', handleResize); |
| | | |
| | | // 清理函数 |
| | | return () => { |
| | | window.removeEventListener('resize', handleResize); |
| | | chartInstance.dispose(); |
| | | }; |
| | | } catch (e) { |
| | | console.error('ECharts加载失败:', e); |
| | | setError('图表库加载失败'); |
| | | } finally { |
| | | setIsLoading(false); |
| | | } |
| | | }; |
| | | |
| | | initializeChart(); |
| | | }, 500); // 增加延迟,确保DOM完全渲染 |
| | | |
| | | return () => clearTimeout(timer); |
| | | }, [code]); |
| | | |
| | | // 当全屏状态变化时重新调整图表大小 |
| | | useEffect(() => { |
| | | if (chartInstanceRef.current) { |
| | | setTimeout(() => { |
| | | chartInstanceRef.current.resize(); |
| | | }, 300); // 给DOM一些时间来更新 |
| | | } |
| | | }, [isModalOpen]); |
| | | |
| | | // 处理全屏切换 |
| | | const toggleModal = useCallback(() => { |
| | | setIsModalOpen(prev => !prev); |
| | | }, []); |
| | | |
| | | // 如果没有token,不渲染任何内容 |
| | | if (!token) { |
| | | return null; |
| | | } |
| | | |
| | | // 处理模态窗口的图表 |
| | | useEffect(() => { |
| | | if (!isModalOpen || !modalChartRef.current) return; |
| | | |
| | | const initModalChart = async () => { |
| | | try { |
| | | const echarts = await import('echarts'); |
| | | |
| | | // 初始化模态窗口中的图表 |
| | | const modalChartInstance = echarts.init(modalChartRef.current); |
| | | |
| | | // 如果主图表已经初始化,复用其配置 |
| | | if (chartInstanceRef.current) { |
| | | const option = chartInstanceRef.current.getOption(); |
| | | |
| | | // 直接使用主图表的完整配置,不做额外修改 |
| | | modalChartInstance.setOption(option); |
| | | } else { |
| | | // 如果主图表没有初始化,尝试从代码初始化 |
| | | try { |
| | | const safeCode = code.replace(/window\.option/g, 'option'); |
| | | const safeFunc = new Function(` |
| | | "use strict"; |
| | | let option; |
| | | try { |
| | | ${safeCode} |
| | | return option; |
| | | } catch (e) { |
| | | console.error("模态图表代码执行错误:", e); |
| | | return null; |
| | | } |
| | | `); |
| | | |
| | | const chartOption = safeFunc(); |
| | | |
| | | if (chartOption) { |
| | | // 与主图表使用完全相同的配置 |
| | | modalChartInstance.setOption(chartOption); |
| | | } else { |
| | | throw new Error("无法获取模态图表配置"); |
| | | } |
| | | } catch (e) { |
| | | console.error('模态图表代码执行错误:', e); |
| | | |
| | | // 设置一个默认图表 |
| | | modalChartInstance.setOption({ |
| | | title: { text: '图表配置错误' }, |
| | | xAxis: { type: 'category', data: ['错误'] }, |
| | | yAxis: { type: 'value' }, |
| | | series: [{ data: [0], type: 'bar' }] |
| | | }); |
| | | } |
| | | } |
| | | |
| | | // 添加窗口大小变化监听器 |
| | | const handleResize = () => modalChartInstance.resize(); |
| | | window.addEventListener('resize', handleResize); |
| | | |
| | | // 返回清理函数 |
| | | return () => { |
| | | window.removeEventListener('resize', handleResize); |
| | | modalChartInstance.dispose(); |
| | | }; |
| | | } catch (e) { |
| | | console.error('模态图表初始化失败:', e); |
| | | } |
| | | }; |
| | | |
| | | // 延迟一点执行,确保DOM已更新完成 |
| | | const timer = setTimeout(initModalChart, 100); |
| | | return () => clearTimeout(timer); |
| | | }, [isModalOpen, code]); |
| | | |
| | | // 简化的渲染逻辑,确保容器有明确的尺寸 |
| | | return ( |
| | | <div className="min-h-screen bg-[#0A1033] text-white"> |
| | | {/* 顶部导航 */} |
| | | <div className="bg-[#131C41] p-4 border-b border-[#6ADBFF]/20"> |
| | | <div className="max-w-4xl mx-auto flex justify-between items-center"> |
| | | <Link href="/" className="text-[#6ADBFF] hover:underline flex items-center gap-2"> |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> |
| | | <path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" /> |
| | | </svg> |
| | | 返回首页 |
| | | </Link> |
| | | <h1 className="text-xl font-bold">AI助手对话</h1> |
| | | <div className="relative my-4"> |
| | | {isLoading && ( |
| | | <div className="absolute inset-0 flex items-center justify-center bg-white/70 z-10"> |
| | | <div className="flex flex-col items-center"> |
| | | <div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-blue-500 mb-3"></div> |
| | | <p className="text-gray-500">图表加载中...</p> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 聊天区域 */} |
| | | <div className="max-w-4xl mx-auto p-4"> |
| | | <div className="space-y-4 mb-20"> |
| | | <div className="flex justify-start"> |
| | | <div className="max-w-[80%] rounded-lg p-4 bg-[#131C41]"> |
| | | <p>你好!欢迎来到聊天室。</p> |
| | | )} |
| | | |
| | | {error && ( |
| | | <div className="absolute top-0 left-0 right-0 bg-red-50 border border-red-200 text-red-600 px-3 py-2 rounded-md text-xs z-10"> |
| | | {error} |
| | | </div> |
| | | )} |
| | | |
| | | {/* 图表容器 - 固定高度 */} |
| | | <div |
| | | ref={chartContainerRef} |
| | | className="w-full bg-white border border-gray-200 rounded-lg overflow-hidden chart-container" |
| | | style={{ |
| | | height: '400px', |
| | | minHeight: '400px', |
| | | visibility: 'visible', |
| | | position: 'relative' |
| | | }} |
| | | data-echarts-container="true" |
| | | /> |
| | | |
| | | {/* 打开弹窗按钮 */} |
| | | <button |
| | | onClick={() => setIsModalOpen(true)} |
| | | className="absolute top-2 right-2 bg-white/90 hover:bg-white p-2 rounded-full shadow-md z-20 transition-all duration-300 hover:scale-110 cursor-pointer" |
| | | title="在弹窗中查看" |
| | | > |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 3h6v6m0-6L14 9M9 21H3v-6m0 6l7-7" /> |
| | | </svg> |
| | | </button> |
| | | |
| | | {/* 弹出式对话框 */} |
| | | {isModalOpen && ( |
| | | <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"> |
| | | <div className="bg-white rounded-lg w-[90vw] max-w-6xl h-[80vh] relative flex flex-col"> |
| | | {/* 对话框头部 */} |
| | | <div className="flex justify-between items-center p-4 border-b"> |
| | | <h3 className="text-xl font-semibold text-gray-800">数据可视化图表</h3> |
| | | <button |
| | | onClick={() => setIsModalOpen(false)} |
| | | className="p-1 rounded-full hover:bg-gray-100 cursor-pointer" |
| | | aria-label="关闭" |
| | | > |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> |
| | | </svg> |
| | | </button> |
| | | </div> |
| | | |
| | | {/* 对话框主体 - 图表 */} |
| | | <div className="flex-1 overflow-hidden p-4"> |
| | | <div |
| | | ref={modalChartRef} |
| | | className="w-full h-full" |
| | | data-echarts-modal |
| | | ></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | )} |
| | | </div> |
| | | ); |
| | | } |
| | | |
| | | {/* 输入区域 */} |
| | | <div className="fixed bottom-0 left-0 right-0 bg-[#131C41] border-t border-[#6ADBFF]/20 p-4"> |
| | | <div className="max-w-4xl mx-auto flex gap-4"> |
| | | <input |
| | | type="text" |
| | | placeholder="输入消息..." |
| | | className="flex-1 bg-[#1E2B63] rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#6ADBFF]/50" |
| | | /> |
| | | <button |
| | | className="bg-gradient-to-r from-[#6ADBFF] to-[#5E72EB] text-white px-6 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity" |
| | | > |
| | | 发送 |
| | | </button> |
| | | // 修改代码块渲染组件,添加更好的类型检测 |
| | | function CodeBlockRenderer({ language, value }: { language: string; value: string }) { |
| | | // 判断是否是JavaScript代码,检测是否包含图表相关特征 |
| | | const isEchartsCode = useCallback(() => { |
| | | if (language !== 'javascript') return false; |
| | | |
| | | // 检查是否包含ECharts特有的配置项 |
| | | return value.includes('option') && |
| | | (value.includes('series') || |
| | | value.includes('chart') || |
| | | value.includes('echarts') || |
| | | value.includes('xAxis') || |
| | | value.includes('yAxis')); |
| | | }, [language, value]); |
| | | |
| | | // 检查消息是否完整 - 通过父组件传递的isMessageComplete状态 |
| | | const isComplete = useContext(MessageCompletionContext); |
| | | |
| | | // 在消息未完成时显示加载动画 - 固定高度匹配图表 |
| | | if (language === 'javascript' && !isComplete) { |
| | | return ( |
| | | <div className="w-full bg-gray-50 rounded-md my-4 p-6 text-center" style={{height: '400px'}}> |
| | | <div className="flex flex-col items-center justify-center h-full"> |
| | | <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-gray-400 mb-4"></div> |
| | | <p className="text-gray-500">加载中...</p> |
| | | <p className="text-xs text-gray-400 mt-2">等待消息完成后将渲染图表</p> |
| | | </div> |
| | | </div> |
| | | ); |
| | | } |
| | | |
| | | // 消息完成后,如果是图表代码则渲染为图表 - 在外部包装一层固定高度容器 |
| | | if (isEchartsCode() && isComplete) { |
| | | return ( |
| | | <div style={{height: '400px', minHeight: '400px', position: 'relative'}}> |
| | | <EchartsRenderer code={value} /> |
| | | </div> |
| | | ); |
| | | } |
| | | |
| | | // 普通JavaScript代码或其他语言的代码块,直接显示代码 |
| | | return ( |
| | | <div className="bg-gray-800 rounded-md my-2 overflow-hidden"> |
| | | <div className="flex items-center justify-between px-4 py-2 border-b border-gray-700"> |
| | | <span className="text-xs text-gray-400">{language}</span> |
| | | </div> |
| | | <pre className="p-4 text-gray-300 overflow-x-auto"> |
| | | <code>{value}</code> |
| | | </pre> |
| | | </div> |
| | | ); |
| | | } |
| | | |
| | | // 创建独立的输入组件,避免状态共享导致的重渲染 |
| | | interface ChatInputProps { |
| | | onSendMessage: () => void; |
| | | isStreaming: boolean; |
| | | isMessageComplete: boolean; |
| | | } |
| | | |
| | | function ChatInput({ onSendMessage, isStreaming, isMessageComplete }: ChatInputProps) { |
| | | // 内部状态,与外部完全隔离 |
| | | const [inputText, setInputText] = useState(''); |
| | | const inputRef = useRef<HTMLTextAreaElement>(null); |
| | | |
| | | // 处理输入变化 |
| | | const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
| | | setInputText(e.target.value); |
| | | }; |
| | | |
| | | // 处理按键事件 |
| | | const handleKeyDown = (e: React.KeyboardEvent) => { |
| | | if (e.key === 'Enter' && !e.shiftKey) { |
| | | e.preventDefault(); |
| | | handleSend(); |
| | | } |
| | | }; |
| | | |
| | | // 处理发送 |
| | | const handleSend = () => { |
| | | if (isStreaming || !inputText.trim() || !isMessageComplete) return; |
| | | |
| | | // 通知父组件发送消息前更新消息内容 |
| | | (window as any).messageToSend = inputText.trim(); |
| | | |
| | | // 清空输入 |
| | | setInputText(''); |
| | | |
| | | // 调用父组件的发送方法 |
| | | onSendMessage(); |
| | | }; |
| | | |
| | | return ( |
| | | <div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-white/95 pt-4 pb-6" style={{ zIndex: 50 }}> |
| | | <div className="max-w-4xl mx-auto px-6"> |
| | | <div className="relative"> |
| | | <div className="absolute -top-6 left-1/2 -translate-x-1/2 w-48 h-[1px] bg-gradient-to-r from-transparent via-gray-200 to-transparent"></div> |
| | | |
| | | <div className="flex gap-4 items-start"> |
| | | <div className="flex-1 relative"> |
| | | <textarea |
| | | ref={inputRef} |
| | | value={inputText} |
| | | onChange={handleInputChange} |
| | | onKeyDown={handleKeyDown} |
| | | placeholder="输入消息..." |
| | | disabled={isStreaming} |
| | | className="w-full resize-none rounded-xl border-0 bg-gray-50 px-4 py-3 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-100/50 focus:bg-white min-h-[48px] max-h-32 shadow-inner transition-all duration-300 ease-in-out hover:bg-gray-100/70 disabled:opacity-50 disabled:cursor-not-allowed" |
| | | style={{ height: '48px' }} |
| | | /> |
| | | <div className="absolute right-4 bottom-2 text-xs text-gray-400 bg-gray-50 px-2"> |
| | | 按Enter发送,Shift+Enter换行 |
| | | </div> |
| | | </div> |
| | | <button |
| | | onClick={handleSend} |
| | | disabled={isStreaming || !inputText.trim() || !isMessageComplete} |
| | | className="h-12 px-5 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-medium rounded-xl transition-all duration-300 flex items-center gap-2 shadow-lg shadow-blue-500/20 hover:shadow-blue-500/30 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none cursor-pointer" |
| | | > |
| | | <span>{isStreaming ? '回复中...' : (isMessageComplete ? '发送' : '处理中...')}</span> |
| | | <svg xmlns="http://www.w3.org/2000/svg" className={`h-4 w-4 transform rotate-45 ${isStreaming ? 'animate-pulse' : ''}`} viewBox="0 0 20 20" fill="currentColor"> |
| | | <path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" /> |
| | | </svg> |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | ); |
| | | } |
| | | |
| | | export default function Page() { |
| | | const router = useRouter(); |
| | | const [apiKey, setApiKey] = useState<string>(''); |
| | | const [showApiKeyInput, setShowApiKeyInput] = useState(false); |
| | | const [message, setMessage] = useState(''); |
| | | const [messages, setMessages] = useState<Message[]>([]); |
| | | const [isStreaming, setIsStreaming] = useState(false); |
| | | const [error, setError] = useState<string | null>(null); |
| | | const [showError, setShowError] = useState(false); |
| | | const [conversationId, setConversationId] = useState<string | null>(null); |
| | | const [isMessageComplete, setIsMessageComplete] = useState(true); |
| | | const [currentMessageId, setCurrentMessageId] = useState<string | null>(null); |
| | | |
| | | // 添加一个新的状态来控制消息的显示 |
| | | const [showMessages, setShowMessages] = useState(false); |
| | | |
| | | // 在组件顶部增加一个强制更新计数器 |
| | | const [forceUpdateCounter, setForceUpdateCounter] = useState(0); |
| | | |
| | | // 添加状态来控制思考内容的显示/隐藏 |
| | | const [expandedThinkMessages, setExpandedThinkMessages] = useState<Record<string, boolean>>({}); |
| | | |
| | | // 在组件顶部添加显示/隐藏密码的状态 |
| | | const [showApiKey, setShowApiKey] = useState(false); |
| | | |
| | | const messagesEndRef = useRef<HTMLDivElement>(null); |
| | | const errorTimeoutRef = useRef<any>(null); |
| | | |
| | | // 在组件顶部添加一个引用,用于跟踪组件是否已卸载 |
| | | const isMountedRef = useRef(true); |
| | | |
| | | // 分离消息输入状态,避免触发不必要的重渲染 |
| | | const messageInputRef = useRef<HTMLTextAreaElement>(null); |
| | | const [localMessage, setLocalMessage] = useState(''); |
| | | |
| | | const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
| | | // 只更新本地状态,不触发全局重渲染 |
| | | setLocalMessage(e.target.value); |
| | | }; |
| | | |
| | | // 仅在发送时更新全局消息状态 |
| | | const handleKeyPress = (e: React.KeyboardEvent) => { |
| | | if (e.key === 'Enter' && !e.shiftKey) { |
| | | e.preventDefault(); |
| | | // 更新全局状态并发送 |
| | | setMessage(localMessage); |
| | | setTimeout(() => handleSendMessage(), 10); |
| | | } |
| | | }; |
| | | |
| | | // 点击发送按钮时 |
| | | const handleSendButtonClick = () => { |
| | | // 检查消息是否为空 |
| | | if (!localMessage.trim() || !isMessageComplete) return; |
| | | |
| | | // 更新全局状态并发送 |
| | | setMessage(localMessage); |
| | | setTimeout(() => handleSendMessage(), 10); |
| | | }; |
| | | |
| | | // 同步消息状态到本地状态 |
| | | useEffect(() => { |
| | | setLocalMessage(message); |
| | | }, [message]); |
| | | |
| | | useEffect(() => { |
| | | // 获取存储的API Key |
| | | const storedApiKey = localStorage.getItem('api-key'); |
| | | if (storedApiKey) { |
| | | setApiKey(storedApiKey); |
| | | } else { |
| | | setShowApiKeyInput(true); |
| | | } |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | // 滚动到最新消息 |
| | | messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); |
| | | }, [messages]); |
| | | |
| | | // 处理错误显示和自动消失 |
| | | const showErrorMessage = useCallback((message: string) => { |
| | | setError(message); |
| | | // 先设置为false确保动画能重新触发 |
| | | setShowError(false); |
| | | // 使用 requestAnimationFrame 确保状态变化被正确渲染 |
| | | requestAnimationFrame(() => { |
| | | requestAnimationFrame(() => { |
| | | setShowError(true); |
| | | }); |
| | | }); |
| | | |
| | | if (errorTimeoutRef.current) { |
| | | clearTimeout(errorTimeoutRef.current); |
| | | } |
| | | |
| | | errorTimeoutRef.current = setTimeout(() => { |
| | | setShowError(false); |
| | | setTimeout(() => { |
| | | setError(null); |
| | | }, 400); |
| | | }, 3000); |
| | | }, []); |
| | | |
| | | // 组件卸载时清除定时器 |
| | | useEffect(() => { |
| | | const cleanup = () => { |
| | | if (errorTimeoutRef.current) { |
| | | clearTimeout(errorTimeoutRef.current); |
| | | } |
| | | }; |
| | | return cleanup; |
| | | }, []); |
| | | |
| | | // 添加组件卸载时的清理工作 |
| | | useEffect(() => { |
| | | // 组件挂载时,设置为true |
| | | isMountedRef.current = true; |
| | | |
| | | // 组件卸载时,设置为false |
| | | return () => { |
| | | isMountedRef.current = false; |
| | | |
| | | // 清除所有定时器 |
| | | if (errorTimeoutRef.current) { |
| | | clearTimeout(errorTimeoutRef.current); |
| | | } |
| | | }; |
| | | }, []); |
| | | |
| | | const handleApiKeySubmit = () => { |
| | | if (apiKey.trim()) { |
| | | localStorage.setItem('api-key', apiKey); |
| | | setShowApiKeyInput(false); |
| | | setError(null); |
| | | } |
| | | }; |
| | | |
| | | const handleSendMessage = async () => { |
| | | // 从window对象获取消息内容 |
| | | const inputMessage = (window as any).messageToSend || ''; |
| | | if (!inputMessage.trim() || !isMessageComplete) return; |
| | | if (!apiKey) { |
| | | showErrorMessage('请先设置API Key'); |
| | | return; |
| | | } |
| | | |
| | | setIsStreaming(true); |
| | | setIsMessageComplete(false); |
| | | |
| | | // 创建新消息 |
| | | const userMessage: Message = { |
| | | role: 'user', |
| | | content: inputMessage.trim(), |
| | | timestamp: Date.now(), |
| | | id: 'user-' + Date.now().toString() |
| | | }; |
| | | |
| | | const assistantMessage: Message = { |
| | | role: 'assistant', |
| | | content: '', |
| | | timestamp: Date.now(), |
| | | id: 'temp-' + Date.now().toString() |
| | | }; |
| | | |
| | | // 使用函数式更新确保状态更新的一致性 |
| | | setMessages(prevMessages => { |
| | | const newMessages = [...prevMessages]; |
| | | // 如果存在未完成的助手消息,先移除它 |
| | | if (!isMessageComplete && currentMessageId) { |
| | | const lastMessage = newMessages[newMessages.length - 1]; |
| | | if (lastMessage && lastMessage.role === 'assistant' && lastMessage.id === currentMessageId) { |
| | | newMessages.pop(); |
| | | } |
| | | } |
| | | return [...newMessages, userMessage, assistantMessage]; |
| | | }); |
| | | |
| | | setCurrentMessageId(assistantMessage.id); |
| | | setMessage(''); |
| | | |
| | | // 清除输入内容 |
| | | (window as any).messageToSend = ''; |
| | | |
| | | let controller: AbortController | null = new AbortController(); |
| | | |
| | | try { |
| | | const response = await fetch(`${BASE_URL}/v1/chat-messages`, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Authorization': `Bearer ${apiKey}`, |
| | | 'Content-Type': 'application/json', |
| | | }, |
| | | mode: 'cors', |
| | | body: JSON.stringify({ |
| | | inputs: {}, |
| | | query: userMessage.content, |
| | | response_mode: 'streaming', |
| | | conversation_id: conversationId || '', |
| | | user: 'abc-123', |
| | | files: [] |
| | | }), |
| | | signal: controller.signal // 添加中止信号 |
| | | }); |
| | | |
| | | console.log("API响应状态:", response.status, response.statusText); |
| | | |
| | | if (!response.ok) { |
| | | const errorText = await response.text(); |
| | | console.error("API错误响应:", errorText); |
| | | let errorMessage; |
| | | try { |
| | | const errorJson = JSON.parse(errorText); |
| | | errorMessage = errorJson.error || `错误: ${response.status}`; |
| | | } catch { |
| | | errorMessage = `错误: ${response.status}`; |
| | | } |
| | | throw new Error(errorMessage); |
| | | } |
| | | |
| | | // 确保response.body存在 |
| | | if (!response.body) { |
| | | throw new Error('响应没有提供数据流'); |
| | | } |
| | | |
| | | const reader = response.body.getReader(); |
| | | const decoder = new TextDecoder(); |
| | | let buffer = ''; |
| | | |
| | | while (true) { |
| | | try { |
| | | const { done, value } = await reader.read(); |
| | | if (done) { |
| | | console.log("流式响应接收完毕"); |
| | | setIsMessageComplete(true); |
| | | break; |
| | | } |
| | | |
| | | const chunk = decoder.decode(value, { stream: true }); |
| | | console.log("接收到数据块:", chunk); |
| | | buffer += chunk; |
| | | const lines = buffer.split('\n'); |
| | | buffer = lines.pop() || ''; |
| | | |
| | | for (const line of lines) { |
| | | if (line.trim() === '') continue; |
| | | |
| | | console.log("处理行数据:", line); |
| | | |
| | | if (line.startsWith('data: ')) { |
| | | try { |
| | | const jsonStr = line.slice(6); |
| | | console.log("完整原始数据:", line); |
| | | console.log("解析JSON字符串:", jsonStr); |
| | | const data = JSON.parse(jsonStr); |
| | | console.log("解析后的数据对象:", data); |
| | | |
| | | // 忽略ping事件 |
| | | if (data.event === 'ping') { |
| | | console.log("忽略ping事件"); |
| | | continue; |
| | | } |
| | | |
| | | switch (data.event) { |
| | | case 'message': |
| | | console.log(`收到message事件:`, data); |
| | | |
| | | // 提取message数据 |
| | | const messageId = data.message_id; |
| | | const answerChunk = data.answer || ''; |
| | | const convId = data.conversation_id; |
| | | |
| | | // 如果不在循环中,直接调用更新函数 |
| | | handleMessageChunk(messageId, answerChunk, convId); |
| | | break; |
| | | |
| | | case 'message_end': |
| | | console.log('收到message_end事件:', data); |
| | | |
| | | // 检查是否有最终答案 |
| | | if (data.metadata && data.id) { |
| | | setIsMessageComplete(true); |
| | | setConversationId(data.conversation_id || null); |
| | | } |
| | | break; |
| | | |
| | | case 'workflow_finished': |
| | | console.log('工作流完成:', data); |
| | | |
| | | // 检查工作流输出是否有答案 |
| | | if (data.data && data.data.outputs && data.data.outputs.answer) { |
| | | // 如果工作流有最终答案,更新消息内容 |
| | | const finalAnswer = data.data.outputs.answer; |
| | | const messageId = data.message_id; |
| | | |
| | | if (finalAnswer) { |
| | | console.log('从工作流获取最终答案:', finalAnswer); |
| | | handleFinalAnswer(messageId, finalAnswer, data.conversation_id); |
| | | } |
| | | } |
| | | break; |
| | | |
| | | case 'node_finished': |
| | | // 检查节点类型是否为answer节点 |
| | | if (data.data && data.data.node_type === 'answer' && data.data.outputs && data.data.outputs.answer) { |
| | | console.log('从answer节点获取答案:', data.data.outputs.answer); |
| | | const answer = data.data.outputs.answer; |
| | | if (answer) { |
| | | handleFinalAnswer(data.message_id, answer, data.conversation_id); |
| | | } |
| | | } |
| | | break; |
| | | |
| | | // 处理其他工作流事件 |
| | | case 'workflow_started': |
| | | case 'node_started': |
| | | // 这些事件只需记录,不需要更新UI |
| | | console.log(`工作流事件 ${data.event}:`, data); |
| | | break; |
| | | |
| | | case 'error': |
| | | console.error('服务器返回错误事件:', data); |
| | | setIsMessageComplete(true); |
| | | throw new Error(data.message || '发送消息时出错'); |
| | | } |
| | | } catch (e) { |
| | | console.error('解析SSE数据出错:', e, '原始行:', line); |
| | | setIsMessageComplete(true); |
| | | throw e; |
| | | } |
| | | } |
| | | } |
| | | } catch (err) { |
| | | console.error('处理流式响应时出错:', err); |
| | | throw err; |
| | | } |
| | | } |
| | | } catch (err) { |
| | | // 检查是否是中止错误 |
| | | if (err instanceof Error && err.name === 'AbortError') { |
| | | console.log('请求被中止,可能是组件卸载导致的'); |
| | | return; // 中止错误不需要显示给用户 |
| | | } |
| | | |
| | | console.error('聊天请求错误:', err); |
| | | let errorMsg = err instanceof Error ? err.message : '发送消息时出错'; |
| | | |
| | | // 只有在组件仍然挂载时才更新UI |
| | | if (isMountedRef.current) { |
| | | showErrorMessage(errorMsg); |
| | | |
| | | setMessages(prev => { |
| | | const newMessages = [...prev]; |
| | | const lastMessage = newMessages[newMessages.length - 1]; |
| | | if (lastMessage?.role === 'assistant') { |
| | | lastMessage.content = `抱歉,无法获取回复: ${errorMsg}`; |
| | | } |
| | | return newMessages; |
| | | }); |
| | | } |
| | | } finally { |
| | | // 清除控制器 |
| | | controller = null; |
| | | |
| | | // 只有在组件仍然挂载时才更新UI |
| | | if (isMountedRef.current) { |
| | | setIsStreaming(false); |
| | | setIsMessageComplete(true); |
| | | } |
| | | } |
| | | }; |
| | | |
| | | // 修改handleMessageChunk函数,添加组件挂载检查 |
| | | const handleMessageChunk = useCallback((messageId: string, answerChunk: string, convId?: string) => { |
| | | // 如果组件已卸载,则不执行更新 |
| | | if (!isMountedRef.current) return; |
| | | |
| | | console.log(`处理消息片段: ID=${messageId}, 片段="${answerChunk}"`); |
| | | |
| | | // 使用函数式更新确保获取最新状态 |
| | | setMessages(prevMessages => { |
| | | // 如果组件已卸载,则不执行更新 |
| | | if (!isMountedRef.current) return prevMessages; |
| | | |
| | | // 创建消息数组的拷贝 |
| | | const newMessages = [...prevMessages]; |
| | | |
| | | // 查找最后一条助手消息 |
| | | const lastMessage = newMessages[newMessages.length - 1]; |
| | | if (lastMessage?.role !== 'assistant') { |
| | | console.warn('找不到助手消息来更新'); |
| | | return prevMessages; // 不需要更新 |
| | | } |
| | | |
| | | // 更新消息内容和ID |
| | | const updatedContent = (lastMessage.content || '') + answerChunk; |
| | | |
| | | // 创建消息的新副本以确保React检测到变化 |
| | | const updatedMessage = { |
| | | ...lastMessage, |
| | | id: messageId, |
| | | content: updatedContent, |
| | | timestamp: Date.now() |
| | | }; |
| | | |
| | | if (convId) { |
| | | updatedMessage.conversation_id = convId; |
| | | } |
| | | |
| | | // 更新当前正在处理的消息ID |
| | | if (isMountedRef.current) { |
| | | setCurrentMessageId(messageId); |
| | | } |
| | | |
| | | // 替换最后一条消息 |
| | | newMessages[newMessages.length - 1] = updatedMessage; |
| | | |
| | | console.log(`更新后的内容长度: ${updatedContent.length}`); |
| | | |
| | | // 触发强制更新 |
| | | if (isMountedRef.current) { |
| | | setTimeout(() => { |
| | | if (isMountedRef.current) { |
| | | setForceUpdateCounter(count => count + 1); |
| | | } |
| | | }, 0); |
| | | } |
| | | |
| | | return newMessages; |
| | | }); |
| | | }, []); |
| | | |
| | | // 修改handleFinalAnswer函数,添加组件挂载检查 |
| | | const handleFinalAnswer = useCallback((messageId: string, answer: string, convId?: string) => { |
| | | // 如果组件已卸载,则不执行更新 |
| | | if (!isMountedRef.current) return; |
| | | |
| | | console.log(`设置最终答案: ID=${messageId}, 内容="${answer}"`); |
| | | |
| | | setMessages(prevMessages => { |
| | | // 如果组件已卸载,则不执行更新 |
| | | if (!isMountedRef.current) return prevMessages; |
| | | |
| | | const newMessages = [...prevMessages]; |
| | | const lastMessage = newMessages[newMessages.length - 1]; |
| | | |
| | | if (lastMessage?.role !== 'assistant') { |
| | | console.warn('找不到助手消息来更新最终答案'); |
| | | return prevMessages; |
| | | } |
| | | |
| | | // 创建消息的新副本,设置最终答案 |
| | | const updatedMessage = { |
| | | ...lastMessage, |
| | | id: messageId, |
| | | content: answer, |
| | | timestamp: Date.now() |
| | | }; |
| | | |
| | | if (convId) { |
| | | updatedMessage.conversation_id = convId; |
| | | } |
| | | |
| | | // 更新状态 |
| | | if (isMountedRef.current) { |
| | | setIsMessageComplete(true); |
| | | setCurrentMessageId(messageId); |
| | | } |
| | | |
| | | // 替换最后一条消息 |
| | | newMessages[newMessages.length - 1] = updatedMessage; |
| | | |
| | | // 触发强制更新 |
| | | if (isMountedRef.current) { |
| | | setTimeout(() => { |
| | | if (isMountedRef.current) { |
| | | setForceUpdateCounter(count => count + 1); |
| | | } |
| | | }, 0); |
| | | } |
| | | |
| | | return newMessages; |
| | | }); |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | // 页面加载后延迟显示消息列表,避免闪烁 |
| | | const timer = setTimeout(() => { |
| | | setShowMessages(true); |
| | | }, 100); |
| | | return () => clearTimeout(timer); |
| | | }, []); |
| | | |
| | | // 添加自定义CSS动画样式 |
| | | useEffect(() => { |
| | | const style = document.createElement('style'); |
| | | style.innerHTML = ` |
| | | @keyframes dotBounce { |
| | | 0%, 80%, 100% { transform: translateY(0); } |
| | | 40% { transform: translateY(-6px); } |
| | | } |
| | | .dot-animation span:nth-child(1) { animation: dotBounce 1.4s -0.32s infinite ease-in-out; } |
| | | .dot-animation span:nth-child(2) { animation: dotBounce 1.4s -0.16s infinite ease-in-out; } |
| | | .dot-animation span:nth-child(3) { animation: dotBounce 1.4s 0s infinite ease-in-out; } |
| | | |
| | | /* 优化图表容器样式,不再使用isolation和will-change */ |
| | | .echart-wrapper { |
| | | position: relative; |
| | | z-index: auto; |
| | | } |
| | | |
| | | /* 使用更温和的性能优化属性,避免层级错误 */ |
| | | canvas { |
| | | transition: none !important; |
| | | } |
| | | |
| | | /* 修复输入框遮挡问题 */ |
| | | .fixed.bottom-0 { |
| | | z-index: 10; |
| | | } |
| | | |
| | | /* 防止图表闪烁但不影响其他元素 */ |
| | | .echart-wrapper > div { |
| | | transform: translateZ(0); |
| | | will-change: transform; |
| | | transition: none !important; |
| | | } |
| | | |
| | | /* 修复echarts渲染问题 */ |
| | | [data-echarts-container] { |
| | | visibility: visible !important; |
| | | opacity: 1 !important; |
| | | width: 100% !important; |
| | | contain: none !important; |
| | | } |
| | | `; |
| | | document.head.appendChild(style); |
| | | |
| | | return () => { |
| | | document.head.removeChild(style); |
| | | }; |
| | | }, []); |
| | | |
| | | // 添加处理思考内容的辅助函数 |
| | | const parseMessageContent = (content: string | null) => { |
| | | if (!content) return { mainContent: '', thinkContent: null }; |
| | | |
| | | // 匹配<think>标签内容 (不区分大小写) |
| | | const thinkMatch = content.match(/<think>([\s\S]*?)<\/think>/i); |
| | | |
| | | if (thinkMatch) { |
| | | // 提取思考内容 |
| | | const thinkContent = thinkMatch[1].trim(); |
| | | // 移除<think>标签,保留主要内容 |
| | | const mainContent = content.replace(/<think>[\s\S]*?<\/think>/i, '').trim(); |
| | | return { mainContent, thinkContent }; |
| | | } |
| | | |
| | | return { mainContent: content, thinkContent: null }; |
| | | }; |
| | | |
| | | // 切换思考内容的显示/隐藏 |
| | | const toggleThinkContent = (messageId: string) => { |
| | | setExpandedThinkMessages(prev => ({ |
| | | ...prev, |
| | | [messageId]: !prev[messageId] |
| | | })); |
| | | }; |
| | | |
| | | // 添加组件卸载时的清理函数 |
| | | useEffect(() => { |
| | | return () => { |
| | | // 组件卸载时的清理工作 |
| | | console.log('聊天组件卸载,清理资源'); |
| | | }; |
| | | }, []); |
| | | |
| | | return ( |
| | | <div className="min-h-screen bg-gradient-to-b from-gray-50 to-white text-gray-900 flex flex-col"> |
| | | {/* API Key Modal */} |
| | | {showApiKeyInput && ( |
| | | <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> |
| | | <div className="bg-white rounded-2xl p-6 w-[400px] space-y-4 shadow-xl"> |
| | | <div className="flex justify-between items-center"> |
| | | <h2 className="text-xl font-semibold">设置 API Key</h2> |
| | | <button |
| | | onClick={() => setShowApiKeyInput(false)} |
| | | className="text-gray-400 hover:text-gray-600" |
| | | > |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> |
| | | <path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" /> |
| | | </svg> |
| | | </button> |
| | | </div> |
| | | <div className="space-y-2"> |
| | | <div className="relative"> |
| | | <input |
| | | type={showApiKey ? "text" : "password"} |
| | | value={apiKey} |
| | | onChange={(e) => setApiKey(e.target.value)} |
| | | placeholder="请输入您的 API Key" |
| | | className="w-full px-4 py-2 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-100/50" |
| | | /> |
| | | <button |
| | | type="button" |
| | | onClick={() => setShowApiKey(!showApiKey)} |
| | | className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" |
| | | > |
| | | {showApiKey ? ( |
| | | // 眼睛关闭图标 |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> |
| | | <path fillRule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clipRule="evenodd" /> |
| | | <path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" /> |
| | | </svg> |
| | | ) : ( |
| | | // 眼睛图标 |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> |
| | | <path d="M10 12a2 2 0 100-4 2 2 0 000 4z" /> |
| | | <path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" /> |
| | | </svg> |
| | | )} |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <div className="flex justify-end gap-2"> |
| | | <button |
| | | onClick={() => setShowApiKeyInput(false)} |
| | | className="px-4 py-2 text-gray-600 hover:text-gray-900" |
| | | > |
| | | 取消 |
| | | </button> |
| | | <button |
| | | onClick={handleApiKeySubmit} |
| | | className="px-4 py-2 bg-blue-500 text-white rounded-xl hover:bg-blue-600" |
| | | > |
| | | 确定 |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | )} |
| | | |
| | | {/* 错误提示 */} |
| | | {error && ( |
| | | <div |
| | | className={`fixed top-20 left-1/2 transform -translate-x-1/2 |
| | | flex items-center gap-2 px-4 py-2 text-sm text-white |
| | | transition-all duration-400 ease-out |
| | | ${showError |
| | | ? 'opacity-100 translate-y-0 scale-100' |
| | | : 'opacity-0 -translate-y-2 scale-95' |
| | | } |
| | | before:content-[''] before:absolute before:inset-0 before:bg-red-500 |
| | | before:rounded-lg before:opacity-90 before:-z-10 |
| | | after:content-[''] after:absolute after:inset-0 after:bg-red-500/50 |
| | | after:blur-md after:rounded-lg after:-z-20`} |
| | | > |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> |
| | | <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> |
| | | </svg> |
| | | <span className="relative">{error}</span> |
| | | </div> |
| | | )} |
| | | |
| | | {/* API Key 按钮 */} |
| | | <button |
| | | onClick={() => setShowApiKeyInput(true)} |
| | | className="fixed top-24 right-6 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-white/80 hover:bg-white shadow-sm border border-gray-100 transition-colors" |
| | | title="设置API Key" |
| | | > |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> |
| | | <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" /> |
| | | </svg> |
| | | </button> |
| | | |
| | | {/* 聊天区域 */} |
| | | <div className="flex-1 flex flex-col h-screen"> |
| | | <div className="flex-1 overflow-y-auto pt-20 pb-32"> |
| | | <div className="max-w-4xl mx-auto px-6"> |
| | | <div className={`space-y-6 ${showMessages ? 'opacity-100' : 'opacity-0'} transition-opacity duration-200`}> |
| | | {messages.length === 0 ? ( |
| | | <div className="flex items-center justify-center h-[200px] text-gray-400"> |
| | | <p>发送消息开始对话</p> |
| | | </div> |
| | | ) : ( |
| | | messages.map((msg, index) => ( |
| | | <div |
| | | key={msg.id} |
| | | className={`flex items-start gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`} |
| | | > |
| | | <div className="relative flex-shrink-0"> |
| | | <div className="w-8 h-8 rounded-lg overflow-hidden shadow-inner bg-gray-50"> |
| | | <Image |
| | | src={msg.role === 'assistant' ? "/images/logo.jpg" : DEFAULT_USER_AVATAR} |
| | | alt={msg.role === 'assistant' ? "AI助手" : "用户"} |
| | | width={32} |
| | | height={32} |
| | | className="w-full h-full object-cover" |
| | | /> |
| | | </div> |
| | | </div> |
| | | <div className={`flex-1 min-w-0 ${msg.role === 'user' ? 'text-right' : ''}`}> |
| | | <div className={`${ |
| | | msg.role === 'assistant' |
| | | ? 'bg-white rounded-xl shadow-sm border border-gray-100' |
| | | : 'bg-[#E8F4FF] rounded-xl' |
| | | } inline-block max-w-[85%] relative overflow-hidden`}> |
| | | |
| | | {msg.role === 'assistant' && ( |
| | | <>{(() => { |
| | | // 解析消息内容,提取思考部分 |
| | | const { mainContent, thinkContent } = parseMessageContent(msg.content); |
| | | const isExpanded = expandedThinkMessages[msg.id] || false; |
| | | const thinkingDuration = 8; // 思考时间(秒) |
| | | |
| | | return ( |
| | | <> |
| | | {/* 思考内容区域 (如果存在) */} |
| | | {thinkContent && ( |
| | | <div className="border-b border-gray-200"> |
| | | <button |
| | | onClick={() => toggleThinkContent(msg.id)} |
| | | className="w-full flex items-center justify-between px-3 py-2 bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700" |
| | | > |
| | | <div className="flex items-center space-x-2"> |
| | | <span>已深度思考 (用时 {thinkingDuration} 秒)</span> |
| | | </div> |
| | | <svg |
| | | className={`w-4 h-4 transform transition-transform ${isExpanded ? 'rotate-180' : ''}`} |
| | | fill="none" |
| | | stroke="currentColor" |
| | | viewBox="0 0 24 24" |
| | | > |
| | | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> |
| | | </svg> |
| | | </button> |
| | | |
| | | {/* 可折叠的思考内容 */} |
| | | {isExpanded && ( |
| | | <div className="bg-gray-50 px-4 py-3 text-sm text-gray-700 border-t border-gray-200 whitespace-pre-wrap"> |
| | | {thinkContent} |
| | | </div> |
| | | )} |
| | | </div> |
| | | )} |
| | | |
| | | {/* 主要内容 */} |
| | | <div className="p-3"> |
| | | <div className="text-gray-800 leading-relaxed"> |
| | | {mainContent ? ( |
| | | <MessageCompletionContext.Provider |
| | | value={msg.id !== currentMessageId || isMessageComplete} |
| | | > |
| | | <ReactMarkdown |
| | | remarkPlugins={[remarkGfm]} |
| | | rehypePlugins={[rehypeRaw, rehypeSanitize]} |
| | | components={{ |
| | | // @ts-ignore - ReactMarkdown 组件类型定义的兼容性问题 |
| | | code: ({ node, inline, className, children, ...props }) => { |
| | | const match = /language-(\w+)/.exec(className || ''); |
| | | const language = match ? match[1] : ''; |
| | | const value = String(children).replace(/\n$/, ''); |
| | | |
| | | if (!inline && match) { |
| | | return <CodeBlockRenderer language={language} value={value} />; |
| | | } |
| | | |
| | | return ( |
| | | <code className={className} {...props}> |
| | | {children} |
| | | </code> |
| | | ); |
| | | } |
| | | }} |
| | | > |
| | | {mainContent} |
| | | </ReactMarkdown> |
| | | </MessageCompletionContext.Provider> |
| | | ) : ( |
| | | msg.role === 'assistant' && !isMessageComplete ? '处理回复中...' : '' |
| | | )} |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 加载指示器 */} |
| | | {msg.role === 'assistant' && |
| | | !isMessageComplete && |
| | | msg.id === currentMessageId && ( // 只在当前处理的消息显示加载指示器 |
| | | <div className="absolute bottom-1 right-2"> |
| | | <div className="flex space-x-1"> |
| | | <div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse delay-0"></div> |
| | | <div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse delay-150"></div> |
| | | <div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse delay-300"></div> |
| | | </div> |
| | | </div> |
| | | )} |
| | | </> |
| | | ); |
| | | })()}</> |
| | | )} |
| | | |
| | | {/* 用户消息简单显示 */} |
| | | {msg.role === 'user' && ( |
| | | <div className="p-3"> |
| | | <div className="text-gray-800 leading-relaxed whitespace-pre-wrap"> |
| | | {msg.content} |
| | | </div> |
| | | </div> |
| | | )} |
| | | </div> |
| | | <div className="mt-0.5 text-xs text-gray-400"> |
| | | {msg.role === 'user' && new Date(msg.timestamp).toLocaleTimeString()} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | )) |
| | | )} |
| | | <div ref={messagesEndRef} /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 输入区域 - 抽取为独立组件 */} |
| | | <ChatInput |
| | | onSendMessage={handleSendMessage} |
| | | isStreaming={isStreaming} |
| | | isMessageComplete={isMessageComplete} |
| | | /> |
| | | </div> |
| | | </div> |
| | | ); |
| | | } |