From d2ecbec16ab9d7768b8d07a93d22816507d81833 Mon Sep 17 00:00:00 2001 From: hongjli <3117313295@qq.com> Date: 星期日, 27 四月 2025 20:52:14 +0800 Subject: [PATCH] 渲染echarts图优化 --- src/app/chat/page.tsx | 1154 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 files changed, 1,107 insertions(+), 47 deletions(-) diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 415bf60..5530946 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,70 +1,1130 @@ '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'; + +// 鍒涘缓涓�涓秷鎭畬鎴愮姸鎬佺殑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"; + +// 娣诲姞鐢ㄤ簬瑙f瀽鍜屾覆鏌� 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); + + // 鏇存柊浠g爜寮曠敤 + 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; + + // 闃叉浠g爜鎵ц涓殑鍙橀噺鍐茬獊 + const safeCode = codeRef.current.replace(/window\.option/g, '_uniqueOptionVar'); + + // 绛夊緟DOM鏇存柊瀹屾垚 + setTimeout(() => { + let chartInstance: any = null; + + const initChart = async () => { + try { + // 鍔ㄦ�佸鍏charts + 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('鎵ц鍥捐〃浠g爜閿欒:', e); + setError(e instanceof Error ? e.message : '鍥捐〃浠g爜鎵ц閿欒'); + 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: '(鍘熷浠g爜瑙f瀽澶辫触锛屾樉绀哄鐢ㄥ浘琛�)' + }, + 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: '椋庨櫓鍊�', // 娣诲姞缂哄け鐨刵ame灞炴�� + 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 = '鏁版嵁瑙f瀽澶辫触锛屾樉绀虹ず渚嬬儹鍔涘浘'; + + // 娣诲姞鐑姏鍥炬墍闇�鐨勫叾浠栭厤缃� + 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(); + + // 瀹屽叏閲嶅啓娓呯悊鍑芥暟閫昏緫锛岄伩鍏嶄娇鐢≒romise鐨勪笉纭畾杩斿洖绫诲瀷 + // 鍒濆鍖栧浘琛� + 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> + ); +} + +// 淇敼浠g爜鍧楁覆鏌撶粍浠讹紝娣诲姞鏇村ソ鐨勭被鍨嬫娴� +function CodeBlockRenderer({ language, value }: { language: string; value: string }) { + // 鍒ゆ柇鏄惁鏄疛avaScript浠g爜锛屾娴嬫槸鍚﹀寘鍚浘琛ㄧ浉鍏崇壒寰� + const isEchartsCode = useCallback(() => { + if (language !== 'javascript') return false; + + // 妫�鏌ユ槸鍚﹀寘鍚獷Charts鐗规湁鐨勯厤缃」 + 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"> + <div className="flex flex-col items-center justify-center"> + <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">浠g爜鍔犺浇涓�...</p> + <p className="text-xs text-gray-400 mt-2">绛夊緟娑堟伅瀹屾垚鍚庡皢娓叉煋鍥捐〃</p> + </div> + </div> + ); + } + + // 娑堟伅瀹屾垚鍚庯紝濡傛灉鏄浘琛ㄤ唬鐮佸垯娓叉煋涓哄浘琛� + if (isEchartsCode() && isComplete) { + return <EchartsRenderer code={value} />; + } + + // 鏅�欽avaScript浠g爜鎴栧叾浠栬瑷�鐨勪唬鐮佸潡锛岀洿鎺ユ樉绀轰唬鐮� + 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(); - const [token, setToken] = useState<string | null>(null); + 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); useEffect(() => { - // 鑾峰彇token - const storedToken = localStorage.getItem('token'); - setToken(storedToken); - - // 濡傛灉娌℃湁token锛岀洿鎺ヨ烦杞埌鐧诲綍椤甸潰 - if (!storedToken) { - router.push('/login'); + // 鑾峰彇瀛樺偍鐨凙PI Key + const storedApiKey = localStorage.getItem('api-key'); + if (storedApiKey) { + setApiKey(storedApiKey); + } else { + setShowApiKeyInput(true); } }, []); - // 濡傛灉娌℃湁token锛屼笉娓叉煋浠讳綍鍐呭 - if (!token) { - return null; - } + useEffect(() => { + // 婊氬姩鍒版渶鏂版秷鎭� + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // 澶勭悊閿欒鏄剧ず鍜岃嚜鍔ㄦ秷澶� + const showErrorMessage = useCallback((message: string) => { + setError(message); + // 鍏堣缃负false纭繚鍔ㄧ敾鑳介噸鏂拌Е鍙� + setShowError(false); + // 浣跨敤 requestAnimationFrame 纭繚鐘舵�佸彉鍖栬姝g‘娓叉煋 + 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(() => { + // 缁勪欢鎸傝浇鏃讹紝璁剧疆涓簍rue + isMountedRef.current = true; + + // 缁勪欢鍗歌浇鏃讹紝璁剧疆涓篺alse + 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 () => { + if (!message.trim() || !isMessageComplete) return; + if (!apiKey) { + showErrorMessage('璇峰厛璁剧疆API Key'); + return; + } + + setIsStreaming(true); + setIsMessageComplete(false); + + // 鍒涘缓鏂版秷鎭� + const userMessage: Message = { + role: 'user', + content: message.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(''); + + 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("瑙f瀽JSON瀛楃涓�:", jsonStr); + const data = JSON.parse(jsonStr); + console.log("瑙f瀽鍚庣殑鏁版嵁瀵硅薄:", 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('浠巃nswer鑺傜偣鑾峰彇绛旀:', 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': + // 杩欎簺浜嬩欢鍙渶璁板綍锛屼笉闇�瑕佹洿鏂癠I + console.log(`宸ヤ綔娴佷簨浠� ${data.event}:`, data); + break; + + case 'error': + console.error('鏈嶅姟鍣ㄨ繑鍥為敊璇簨浠�:', data); + setIsMessageComplete(true); + throw new Error(data.message || '鍙戦�佹秷鎭椂鍑洪敊'); + } + } catch (e) { + console.error('瑙f瀽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 : '鍙戦�佹秷鎭椂鍑洪敊'; + + // 鍙湁鍦ㄧ粍浠朵粛鐒舵寕杞芥椂鎵嶆洿鏂癠I + 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; + + // 鍙湁鍦ㄧ粍浠朵粛鐒舵寕杞芥椂鎵嶆洿鏂癠I + 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; // 涓嶉渶瑕佹洿鏂� + } + + // 鏇存柊娑堟伅鍐呭鍜孖D + const updatedContent = (lastMessage.content || '') + answerChunk; + + // 鍒涘缓娑堟伅鐨勬柊鍓湰浠ョ‘淇漅eact妫�娴嬪埌鍙樺寲 + const updatedMessage = { + ...lastMessage, + id: messageId, + content: updatedContent, + timestamp: Date.now() + }; + + if (convId) { + updatedMessage.conversation_id = convId; + } + + // 鏇存柊褰撳墠姝e湪澶勭悊鐨勬秷鎭疘D + 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; + }); + }, []); + + // 鏇存柊鍙戦�佹寜閽殑绂佺敤鐘舵�� + const isSendDisabled = isStreaming || !message.trim() || !isMessageComplete; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + useEffect(() => { + // 椤甸潰鍔犺浇鍚庡欢杩熸樉绀烘秷鎭垪琛紝閬垮厤闂儊 + const timer = setTimeout(() => { + setShowMessages(true); + }, 100); + return () => clearTimeout(timer); + }, []); + + // 娣诲姞鑷畾涔塁SS鍔ㄧ敾鏍峰紡 + 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; } + `; + 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-[#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="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> - </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="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> + <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`}> + + {/* 璋冭瘯淇℃伅 - 绉婚櫎杩欓儴鍒� */} + {/* + {process.env.NODE_ENV !== 'production' && ( + <div className="bg-gray-100 px-2 py-1 text-xs text-gray-500"> + 闀垮害: {(msg.content || '').length} | 璁℃暟: {forceUpdateCounter} + </div> + )} + */} + + {msg.role === 'assistant' && ( + <>{(() => { + // 瑙f瀽娑堟伅鍐呭锛屾彁鍙栨�濊�冮儴鍒� + 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={isMessageComplete && msg.id === currentMessageId}> + <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 && ( + <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> + + {/* 杈撳叆鍖哄煙 */} + <div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-white/95 pt-4 pb-6"> + <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 + value={message} + onChange={(e) => setMessage(e.target.value)} + onKeyDown={handleKeyPress} + 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"> + 鎸塃nter鍙戦�侊紝Shift+Enter鎹㈣ + </div> + </div> + <button + onClick={handleSendMessage} + disabled={isSendDisabled} + 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" + > + <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> </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> - </div> - </div> </div> ); -} +} \ No newline at end of file -- Gitblit v1.9.3