| | |
| | | import remarkGfm from 'remark-gfm'; |
| | | import rehypeRaw from 'rehype-raw'; |
| | | import rehypeSanitize from 'rehype-sanitize'; |
| | | import dynamic from 'next/dynamic'; |
| | | import { ReactNode } from 'react'; |
| | | |
| | | // 动态导入 ECharts,确保它只在客户端渲染 |
| | | const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false }); |
| | | |
| | | interface Message { |
| | | role: 'user' | 'assistant'; |
| | |
| | | // 默认用户头像 |
| | | 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 [isLoaded, setIsLoaded] = useState(false); |
| | | const chartContainerRef = useRef<HTMLDivElement>(null); |
| | | const codeRef = useRef<string>(code); |
| | | const renderAttemptedRef = useRef(false); |
| | | |
| | | // 更新代码引用 |
| | | useEffect(() => { |
| | | codeRef.current = code; |
| | | }, [code]); |
| | | |
| | | // 延迟初始化直到消息稳定 |
| | | useEffect(() => { |
| | | // 为确保流式内容稳定,设置延迟 |
| | | const timer = setTimeout(() => { |
| | | setIsLoaded(true); |
| | | }, 500); |
| | | |
| | | return () => clearTimeout(timer); |
| | | }, []); |
| | | |
| | | // 主要渲染逻辑 - 仅在组件标记为已加载后执行 |
| | | useEffect(() => { |
| | | // 如果组件未标记为已加载或已经尝试过渲染,则退出 |
| | | if (!isLoaded || renderAttemptedRef.current) return; |
| | | |
| | | // 确保只在客户端执行 |
| | | if (typeof window === 'undefined' || !chartContainerRef.current) return; |
| | | |
| | | // 标记为已尝试渲染,防止重复渲染 |
| | | renderAttemptedRef.current = true; |
| | | |
| | | // 防止代码执行中的变量冲突 |
| | | const safeCode = codeRef.current.replace(/window\.option/g, '_uniqueOptionVar'); |
| | | |
| | | // 等待DOM更新完成 |
| | | setTimeout(() => { |
| | | let chartInstance: any = null; |
| | | |
| | | const initChart = async () => { |
| | | try { |
| | | // 动态导入echarts |
| | | const echarts = await import('echarts'); |
| | | |
| | | if (!chartContainerRef.current) { |
| | | console.warn('图表容器已不存在'); |
| | | return; |
| | | } |
| | | |
| | | // 初始化图表 |
| | | chartInstance = echarts.init(chartContainerRef.current); |
| | | |
| | | // 使用Function构造器,但添加额外的保护 |
| | | try { |
| | | const getFinalOption = new Function(` |
| | | "use strict"; |
| | | let option; |
| | | let _uniqueOptionVar; |
| | | try { |
| | | ${safeCode} |
| | | return option || _uniqueOptionVar; |
| | | } catch (e) { |
| | | console.error("ECharts内部执行错误:", e); |
| | | return null; |
| | | } |
| | | `); |
| | | |
| | | const chartOption = getFinalOption(); |
| | | |
| | | if (chartOption) { |
| | | chartInstance.setOption(chartOption); |
| | | setError(null); |
| | | } else { |
| | | console.warn('未获取到有效的图表配置,使用备用配置'); |
| | | useFallbackOption(chartInstance); |
| | | } |
| | | } catch (e) { |
| | | console.error('执行图表代码错误:', e); |
| | | setError(e instanceof Error ? e.message : '图表代码执行错误'); |
| | | useFallbackOption(chartInstance); |
| | | } |
| | | |
| | | // 添加响应式调整 |
| | | const handleResize = () => { |
| | | chartInstance && chartInstance.resize(); |
| | | }; |
| | | |
| | | window.addEventListener('resize', handleResize); |
| | | |
| | | // 确保组件卸载时清理资源 |
| | | return () => { |
| | | window.removeEventListener('resize', handleResize); |
| | | if (chartInstance) { |
| | | try { |
| | | chartInstance.dispose(); |
| | | } catch (e) { |
| | | console.warn('图表实例销毁失败', e); |
| | | } |
| | | } |
| | | }; |
| | | } catch (e) { |
| | | console.error('图表初始化失败:', e); |
| | | setError('加载图表库失败'); |
| | | return () => {}; |
| | | } |
| | | }; |
| | | |
| | | // 备用配置函数 |
| | | const useFallbackOption = (instance: any) => { |
| | | try { |
| | | // 简单的备用配置 |
| | | const fallbackOption = { |
| | | title: { |
| | | text: '数据可视化图表', |
| | | subtext: '(原始代码解析失败,显示备用图表)' |
| | | }, |
| | | tooltip: { |
| | | trigger: 'axis' |
| | | }, |
| | | legend: { |
| | | data: ['数据'] |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: ['项目1', '项目2', '项目3', '项目4', '项目5'] |
| | | }, |
| | | yAxis: { |
| | | type: 'value' |
| | | }, |
| | | series: [{ |
| | | name: '数据', |
| | | type: 'bar', |
| | | data: [5, 20, 36, 10, 10] |
| | | }] |
| | | }; |
| | | |
| | | // 检测是否是热力图代码 |
| | | if (safeCode.includes('heatmap') || |
| | | safeCode.includes('visualMap') || |
| | | safeCode.includes('风险')) { |
| | | (fallbackOption.series as any) = [{ |
| | | type: 'heatmap', |
| | | name: '风险值', // 添加缺失的name属性 |
| | | data: [ |
| | | [0, 0, 5], [0, 1, 7], [0, 2, 3], |
| | | [1, 0, 7], [1, 1, 8], [1, 2, 6], |
| | | [2, 0, 9], [2, 1, 10], [2, 2, 8] |
| | | ] as any[] |
| | | }]; |
| | | |
| | | fallbackOption.title.text = 'VIP客户订单交付风险热力图'; |
| | | fallbackOption.title.subtext = '数据解析失败,显示示例热力图'; |
| | | |
| | | // 添加热力图所需的其他配置 |
| | | Object.assign(fallbackOption, { |
| | | tooltip: { |
| | | position: 'top' |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: ['低', '中', '高'], |
| | | name: '延迟风险' |
| | | }, |
| | | yAxis: { |
| | | type: 'category', |
| | | data: ['低', '中', '高'], |
| | | name: '违约成本' |
| | | }, |
| | | visualMap: { |
| | | min: 1, |
| | | max: 10, |
| | | calculable: true, |
| | | orient: 'horizontal', |
| | | left: 'center', |
| | | bottom: '15%' |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // 设置备用配置 |
| | | instance.setOption(fallbackOption); |
| | | } catch (e) { |
| | | console.error('应用备用配置失败:', e); |
| | | } |
| | | }; |
| | | |
| | | // 初始化图表并获取清理函数 |
| | | const cleanupPromise = initChart(); |
| | | |
| | | // 完全重写清理函数逻辑,避免使用Promise的不确定返回类型 |
| | | // 初始化图表 |
| | | initChart().then(cleanupFn => { |
| | | // 存储清理函数供以后使用 |
| | | if (typeof cleanupFn === 'function') { |
| | | // 使用ref存储清理函数 |
| | | const currentCleanup = cleanupFn; |
| | | // 组件卸载时执行 |
| | | return () => currentCleanup(); |
| | | } |
| | | }).catch(e => { |
| | | console.error('初始化图表失败:', e); |
| | | }); |
| | | |
| | | // 返回一个空的清理函数,避免类型错误 |
| | | return () => {}; |
| | | }, 100); |
| | | }, [isLoaded]); |
| | | |
| | | // 渲染图表容器 |
| | | return ( |
| | | <div className="relative my-4"> |
| | | {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} |
| | | <button |
| | | className="ml-2 text-red-700 hover:text-red-900" |
| | | onClick={() => setError(null)} |
| | | > |
| | | × |
| | | </button> |
| | | </div> |
| | | )} |
| | | <div |
| | | ref={chartContainerRef} |
| | | className="w-full bg-white border border-gray-200 rounded-lg overflow-hidden" |
| | | style={{ height: '400px' }} |
| | | /> |
| | | </div> |
| | | ); |
| | | } |
| | | |
| | | // 修改代码块渲染组件,添加更好的类型检测 |
| | | function CodeBlockRenderer({ language, value }: { language: string; value: string }) { |
| | | // 更精确地检测ECharts代码 |
| | | const isEchartsCode = () => { |
| | | if (language !== 'javascript') return false; |
| | | |
| | | // 检查是否包含ECharts特有的配置项 |
| | | const hasEchartsConfig = |
| | | value.includes('option =') || |
| | | value.includes('const option') || |
| | | value.includes('let option') || |
| | | value.includes('series') && |
| | | (value.includes('type:') || value.includes('tooltip:') || value.includes('xAxis:')); |
| | | |
| | | return hasEchartsConfig; |
| | | }; |
| | | |
| | | // 如果是 ECharts 代码则渲染图表 |
| | | if (isEchartsCode()) { |
| | | return <EchartsRenderer code={value} />; |
| | | } |
| | | |
| | | // 否则按普通代码块渲染 |
| | | 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> |
| | | ); |
| | | } |
| | | |
| | | export default function Page() { |
| | | const router = useRouter(); |
| | |
| | | |
| | | {/* 主要内容 */} |
| | | <div className="p-3"> |
| | | <div className="text-gray-800 leading-relaxed whitespace-pre-wrap"> |
| | | {mainContent || (msg.role === 'assistant' && !isMessageComplete ? '正在思考...' : '')} |
| | | <div className="text-gray-800 leading-relaxed"> |
| | | {mainContent ? ( |
| | | <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> |
| | | ) : ( |
| | | msg.role === 'assistant' && !isMessageComplete ? '处理回复中...' : '' |
| | | )} |
| | | </div> |
| | | </div> |
| | | |
| | |
| | | </div> |
| | | </div> |
| | | ); |
| | | } |
| | | |
| | | } |