Spaces:
Running
Running
| 'use client' | |
| import React, { useState, useRef, useEffect } from 'react' | |
| import Window from './Window' | |
| import ReactMarkdown from 'react-markdown' | |
| import remarkGfm from 'remark-gfm' | |
| import rehypeHighlight from 'rehype-highlight' | |
| import { | |
| PaperPlaneRight, | |
| Sparkle, | |
| ArrowUp | |
| } from '@phosphor-icons/react' | |
| interface GeminiChatProps { | |
| onClose: () => void | |
| onMinimize?: () => void | |
| onMaximize?: () => void | |
| onFocus?: () => void | |
| zIndex?: number | |
| } | |
| interface Message { | |
| id: string | |
| role: 'user' | 'assistant' | |
| content: string | |
| thought?: string | |
| timestamp: number | |
| } | |
| export function GeminiChat({ onClose, onMinimize, onMaximize, onFocus, zIndex }: GeminiChatProps) { | |
| const [messages, setMessages] = useState<Message[]>([ | |
| { | |
| id: '1', | |
| role: 'assistant', | |
| content: "Hello! I'm Gemini. How can I help you today?", | |
| timestamp: Date.now() | |
| } | |
| ]) | |
| const [input, setInput] = useState('') | |
| const [isLoading, setIsLoading] = useState(false) | |
| const scrollRef = useRef<HTMLDivElement>(null) | |
| const inputRef = useRef<HTMLInputElement>(null) | |
| useEffect(() => { | |
| // Load messages from localStorage | |
| const savedMessages = localStorage.getItem('gemini-chat-messages') | |
| if (savedMessages) { | |
| try { | |
| const parsed = JSON.parse(savedMessages) | |
| if (parsed.length > 0) { | |
| setMessages(parsed) | |
| } | |
| } catch (e) { | |
| console.error('Failed to load messages') | |
| } | |
| } | |
| }, []) | |
| useEffect(() => { | |
| // Save messages to localStorage | |
| if (messages.length > 1) { | |
| localStorage.setItem('gemini-chat-messages', JSON.stringify(messages.slice(-20))) // Keep last 20 messages | |
| } | |
| }, [messages]) | |
| useEffect(() => { | |
| if (scrollRef.current) { | |
| scrollRef.current.scrollTop = scrollRef.current.scrollHeight | |
| } | |
| }, [messages]) | |
| const handleSend = async () => { | |
| if (!input.trim() || isLoading) return | |
| const userMessage: Message = { | |
| id: Date.now().toString(), | |
| role: 'user', | |
| content: input.trim(), | |
| timestamp: Date.now() | |
| } | |
| setMessages(prev => [...prev, userMessage]) | |
| setInput('') | |
| setIsLoading(true) | |
| try { | |
| // Get conversation history (last 10 messages) | |
| const history = messages.slice(-10).map(msg => ({ | |
| role: msg.role, | |
| content: msg.content | |
| })) | |
| const response = await fetch('/api/gemini/chat', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| message: userMessage.content, | |
| history | |
| }), | |
| }) | |
| if (!response.ok) throw new Error('Network response was not ok') | |
| const reader = response.body?.getReader() | |
| const decoder = new TextDecoder() | |
| // Create a placeholder message | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: 'assistant', | |
| content: '', | |
| thought: '', | |
| timestamp: Date.now() | |
| } | |
| setMessages(prev => [...prev, assistantMessage]) | |
| if (reader) { | |
| while (true) { | |
| const { done, value } = await reader.read() | |
| if (done) break | |
| const chunk = decoder.decode(value, { stream: true }) | |
| const lines = chunk.split('\n\n') | |
| for (const line of lines) { | |
| if (line.startsWith('data: ')) { | |
| try { | |
| const data = JSON.parse(line.slice(6)) | |
| setMessages(prev => { | |
| const newMessages = [...prev] | |
| const lastMsg = newMessages[newMessages.length - 1] | |
| if (lastMsg.id === assistantMessage.id) { | |
| return [ | |
| ...newMessages.slice(0, -1), | |
| { | |
| ...lastMsg, | |
| content: lastMsg.content + (data.text || ''), | |
| thought: (lastMsg.thought || '') + (data.thought || '') | |
| } | |
| ] | |
| } | |
| return newMessages | |
| }) | |
| } catch (e) { | |
| console.error('Error parsing chunk:', e) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error sending message:', error) | |
| const errorMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: 'assistant', | |
| content: "I'm currently running in demo mode. For full functionality, please ensure the API is configured.", | |
| timestamp: Date.now() | |
| } | |
| setMessages(prev => [...prev, errorMessage]) | |
| } | |
| setIsLoading(false) | |
| } | |
| const handleKeyPress = (e: React.KeyboardEvent) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault() | |
| handleSend() | |
| } | |
| } | |
| const clearChat = () => { | |
| setMessages([ | |
| { | |
| id: Date.now().toString(), | |
| role: 'assistant', | |
| content: "Chat history cleared. How can I help you today?", | |
| timestamp: Date.now() | |
| } | |
| ]) | |
| localStorage.removeItem('gemini-chat-messages') | |
| } | |
| return ( | |
| <Window | |
| id="gemini" | |
| title="Gemini" | |
| isOpen={true} | |
| onClose={onClose} | |
| onMinimize={onMinimize} | |
| onMaximize={onMaximize} | |
| width={700} | |
| height={500} | |
| x={100} | |
| y={100} | |
| className="gemini-window" | |
| headerClassName="bg-white border-b border-gray-100" | |
| > | |
| <div className="flex flex-col h-full bg-white"> | |
| {/* Chat Header */} | |
| <div className="px-4 py-2 border-b border-gray-100 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-pink-600 flex items-center justify-center"> | |
| <Sparkle size={18} weight="fill" className="text-white" /> | |
| </div> | |
| <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-pink-600 font-bold"> | |
| Gemini | |
| </span> | |
| </div> | |
| <button | |
| onClick={clearChat} | |
| className="text-xs text-gray-500 hover:text-gray-700" | |
| > | |
| Clear chat | |
| </button> | |
| </div> | |
| {/* Messages Area */} | |
| <div | |
| ref={scrollRef} | |
| className="flex-1 overflow-y-auto p-4 space-y-4" | |
| > | |
| {messages.map(message => ( | |
| <div | |
| key={message.id} | |
| className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`} | |
| > | |
| <div | |
| className={`max-w-[80%] select-text ${message.role === 'user' | |
| ? 'bg-blue-600 text-white rounded-2xl rounded-tr-none' | |
| : 'bg-gray-100 text-gray-800 rounded-2xl rounded-tl-none' | |
| } px-4 py-2 text-sm`} | |
| > | |
| {message.role === 'assistant' && ( | |
| <p className="font-semibold text-blue-600 mb-1 text-xs">Gemini</p> | |
| )} | |
| {message.thought && ( | |
| <div className="mb-2 p-2 bg-white/50 rounded text-xs text-gray-600 border border-gray-200/50 italic"> | |
| <div className="font-semibold mb-1 not-italic flex items-center gap-1"> | |
| <Sparkle size={12} /> Thinking Process: | |
| </div> | |
| <div className="markdown-content"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}> | |
| {message.thought} | |
| </ReactMarkdown> | |
| </div> | |
| </div> | |
| )} | |
| <div className="markdown-content"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}> | |
| {message.content} | |
| </ReactMarkdown> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| {isLoading && messages[messages.length - 1].role === 'user' && ( | |
| <div className="flex justify-start"> | |
| <div className="bg-gray-100 rounded-2xl rounded-tl-none px-4 py-3 text-sm"> | |
| <p className="font-semibold text-blue-600 mb-1 text-xs">Gemini</p> | |
| <div className="flex gap-1"> | |
| <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></span> | |
| <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></span> | |
| <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></span> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Input Area */} | |
| <div className="border-t border-gray-100 p-4"> | |
| <div className="flex items-center gap-2"> | |
| <input | |
| ref={inputRef} | |
| type="text" | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyPress} | |
| onPaste={(e) => { | |
| // Allow paste - default behavior | |
| e.stopPropagation() | |
| }} | |
| placeholder="Ask Gemini..." | |
| className="flex-1 bg-gray-100 rounded-full px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 transition-all select-text" | |
| disabled={isLoading} | |
| /> | |
| <button | |
| onClick={handleSend} | |
| disabled={!input.trim() || isLoading} | |
| className={`w-8 h-8 rounded-full flex items-center justify-center transition-colors ${input.trim() && !isLoading | |
| ? 'bg-blue-600 hover:bg-blue-700 text-white' | |
| : 'bg-gray-200 text-gray-400 cursor-not-allowed' | |
| }`} | |
| > | |
| <ArrowUp size={16} weight="bold" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </Window> | |
| ) | |
| } |