From 6379adfd761b0a6cddd03d42dd96f1a619cda2c0 Mon Sep 17 00:00:00 2001 From: hongjli <3117313295@qq.com> Date: 星期四, 24 四月 2025 16:48:42 +0800 Subject: [PATCH] 聊天页面 --- src/app/chat/page.tsx | 510 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 files changed, 463 insertions(+), 47 deletions(-) diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 415bf60..21ff564 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,70 +1,486 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } 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'; + +interface Message { + role: 'user' | 'assistant'; + content: string; + 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"; 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 messagesEndRef = useRef<HTMLDivElement>(null); + const errorTimeoutRef = useRef<any>(null); 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; + }, []); + + 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(''); + + 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: [] + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || `Error: ${response.status}`; + } catch { + errorMessage = `Error: ${response.status}`; + } + throw new Error(errorMessage); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No reader available'); + + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + setIsMessageComplete(true); + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim() === '') continue; + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + + // 蹇界暐ping浜嬩欢 + if (data.event === 'ping') continue; + + switch (data.event) { + case 'message': + setMessages(prev => { + const newMessages = [...prev]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage?.role === 'assistant' && lastMessage.id === currentMessageId) { + lastMessage.content = data.answer || lastMessage.content; + if (data.message_id) { + lastMessage.id = data.message_id; + setCurrentMessageId(data.message_id); + } + } + return newMessages; + }); + break; + + case 'message_end': + if (data.conversation_id) { + setConversationId(data.conversation_id); + setMessages(prev => { + const newMessages = [...prev]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage?.role === 'assistant' && lastMessage.id === currentMessageId) { + lastMessage.conversation_id = data.conversation_id; + if (data.metadata) { + lastMessage.metadata = data.metadata; + } + } + return newMessages; + }); + } + setIsMessageComplete(true); + break; + + case 'error': + console.error('Error event received:', data); + setIsMessageComplete(true); + throw new Error(data.message || '鍙戦�佹秷鎭椂鍑洪敊'); + } + } catch (e) { + console.error('Error parsing SSE data:', e); + if (!messages[messages.length - 1]?.content) { + setIsMessageComplete(true); + throw e; + } + } + } + } + } + } catch (err) { + console.error('Chat error:', err); + showErrorMessage(err instanceof Error ? err.message : '鍙戦�佹秷鎭椂鍑洪敊'); + + setMessages(prev => { + const newMessages = [...prev]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage?.role === 'assistant') { + lastMessage.content = '鎶辨瓑锛屾秷鎭彂閫佸け璐ワ紝璇风◢鍚庨噸璇曘��'; + } + return newMessages; + }); + } finally { + setIsStreaming(false); + } + }; + + // 鏇存柊鍙戦�佹寜閽殑绂佺敤鐘舵�� + 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); + }, []); 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> - </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> + <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"> + <input + type="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" + /> + </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> + )} - {/* 杈撳叆鍖哄煙 */} - <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> + {/* 閿欒鎻愮ず */} + {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.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> + {msg.role === 'assistant' && ( + <div className="absolute -bottom-1 -right-1 w-3 h-3 bg-green-400 rounded-full border-2 border-white"></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 min-h-[42px]`}> + {msg.role === 'assistant' && !isMessageComplete && currentMessageId === msg.id && ( + <div className="absolute left-0 top-0 w-full h-full flex items-center justify-center"> + <div className="w-5 h-5 border-2 border-blue-500/30 border-t-blue-500/80 rounded-full animate-spin"></div> + </div> + )} + <div className={`flex items-center px-4 min-h-[42px] ${ + msg.role === 'assistant' && !isMessageComplete && currentMessageId === msg.id ? 'invisible' : '' + }`}> + <div className="prose prose-sm max-w-none text-[14px] leading-[1.3] + prose-p:my-0 prose-p:leading-[1.3] + prose-headings:font-medium prose-headings:text-gray-800 + prose-h1:text-lg prose-h1:my-2 + prose-h2:text-base prose-h2:my-2 + prose-h3:text-base prose-h3:my-1.5 + prose-ul:my-1.5 prose-ul:pl-4 prose-li:my-0.5 + prose-ol:my-1.5 prose-ol:pl-4 + prose-code:px-1 prose-code:py-0.5 prose-code:bg-gray-100 prose-code:rounded prose-code:text-gray-800 prose-code:before:content-[''] prose-code:after:content-[''] + prose-pre:my-2 prose-pre:p-2.5 prose-pre:bg-gray-800 prose-pre:rounded-lg + prose-a:text-blue-500 prose-a:no-underline hover:prose-a:underline + prose-blockquote:my-2 prose-blockquote:pl-3 prose-blockquote:border-l-4 prose-blockquote:border-gray-200 + prose-strong:font-medium prose-strong:text-gray-800 + prose-table:my-2 prose-tr:border-gray-200 prose-td:py-1 prose-td:px-2"> + {msg.content.split('\n').map((line, i) => ( + <div key={i} className="my-0"> + {line.trim() && ( + <ReactMarkdown + remarkPlugins={[remarkGfm]} + rehypePlugins={[rehypeRaw, rehypeSanitize]} + > + {line.trim()} + </ReactMarkdown> + )} + </div> + ))} + </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> ); } + -- Gitblit v1.9.3