Spaces:
Runtime error
Runtime error
| import { useState, useEffect, useRef } from 'react' | |
| import ReactMarkdown from 'react-markdown' | |
| import remarkGfm from 'remark-gfm' | |
| import { Upload, Send, X, FileText, MessageCircle, Key, CheckCircle, AlertCircle, Loader2, Zap } from 'lucide-react' | |
| // Generate session ID once | |
| const SESSION_ID = crypto.randomUUID() | |
| // Star Background Component | |
| function StarField() { | |
| return ( | |
| <div className="star-field"> | |
| {Array.from({ length: 100 }, (_, i) => ( | |
| <div key={i} className="star" /> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| // Main App | |
| export default function App() { | |
| // Load initial state from sessionStorage | |
| const [documents, setDocuments] = useState(() => { | |
| const saved = sessionStorage.getItem('pa_documents') | |
| return saved ? JSON.parse(saved) : [] | |
| }) | |
| const [messages, setMessages] = useState(() => { | |
| const saved = sessionStorage.getItem('pa_messages') | |
| return saved ? JSON.parse(saved) : [] | |
| }) | |
| const [input, setInput] = useState('') | |
| const [loading, setLoading] = useState(false) | |
| const [uploading, setUploading] = useState(false) | |
| const [dragOver, setDragOver] = useState(false) | |
| const [provider, setProvider] = useState(() => { | |
| return sessionStorage.getItem('pa_provider') || 'groq' | |
| }) | |
| const [models, setModels] = useState([]) | |
| const [selectedModel, setSelectedModel] = useState(() => { | |
| return sessionStorage.getItem('pa_model') || '' | |
| }) | |
| const [loadingModels, setLoadingModels] = useState(false) | |
| const [apiKey, setApiKey] = useState(() => { | |
| return sessionStorage.getItem('pa_apikey') || '' | |
| }) | |
| const [connectionStatus, setConnectionStatus] = useState(null) | |
| const [apiKeyError, setApiKeyError] = useState(null) | |
| const messagesEndRef = useRef(null) | |
| const fileInputRef = useRef(null) | |
| // Test connection handler | |
| const testConnection = async () => { | |
| if (!apiKey) return | |
| setConnectionStatus('testing') | |
| setApiKeyError(null) | |
| try { | |
| const res = await fetch('/assistant/models', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ provider, api_key: apiKey }) | |
| }) | |
| const data = await res.json() | |
| if (data.models?.length > 0) { | |
| setModels(data.models) | |
| setSelectedModel(data.models[0].id) | |
| setConnectionStatus('success') | |
| localStorage.setItem(`${provider}_api_key`, apiKey) | |
| } else { | |
| setConnectionStatus('error') | |
| setApiKeyError('No models found. Check your API key.') | |
| } | |
| } catch (err) { | |
| setConnectionStatus('error') | |
| setApiKeyError('Connection failed: ' + err.message) | |
| } | |
| } | |
| // Persist to sessionStorage | |
| useEffect(() => { | |
| sessionStorage.setItem('pa_messages', JSON.stringify(messages)) | |
| }, [messages]) | |
| useEffect(() => { | |
| sessionStorage.setItem('pa_documents', JSON.stringify(documents)) | |
| }, [documents]) | |
| useEffect(() => { | |
| sessionStorage.setItem('pa_provider', provider) | |
| }, [provider]) | |
| useEffect(() => { | |
| if (selectedModel) sessionStorage.setItem('pa_model', selectedModel) | |
| }, [selectedModel]) | |
| useEffect(() => { | |
| if (apiKey) sessionStorage.setItem('pa_apikey', apiKey) | |
| }, [apiKey]) | |
| // Reset models when provider changes | |
| useEffect(() => { | |
| setModels([]) | |
| setSelectedModel('') | |
| setConnectionStatus(null) | |
| setApiKeyError(null) | |
| }, [provider]) | |
| // Scroll to bottom on new messages | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) | |
| }, [messages]) | |
| // API helpers | |
| const headers = { | |
| 'X-Session-ID': SESSION_ID, | |
| 'Content-Type': 'application/json' | |
| } | |
| // Upload document | |
| const handleUpload = async (file) => { | |
| const formData = new FormData() | |
| formData.append('file', file) | |
| setUploading(true) | |
| try { | |
| const res = await fetch('/assistant/upload', { | |
| method: 'POST', | |
| headers: { 'X-Session-ID': SESSION_ID }, | |
| body: formData | |
| }) | |
| if (!res.ok) { | |
| const error = await res.json() | |
| alert(error.detail || 'Upload failed') | |
| return | |
| } | |
| const doc = await res.json() | |
| setDocuments(prev => [...prev, doc]) | |
| } catch (err) { | |
| console.error('Upload error:', err) | |
| alert('Failed to upload file') | |
| } finally { | |
| setUploading(false) | |
| } | |
| } | |
| // Delete document | |
| const handleDelete = async (docId) => { | |
| try { | |
| await fetch(`/assistant/documents/${docId}`, { | |
| method: 'DELETE', | |
| headers | |
| }) | |
| setDocuments(prev => prev.filter(d => d.id !== docId)) | |
| } catch (err) { | |
| console.error('Delete error:', err) | |
| } | |
| } | |
| // Send message | |
| const handleSend = async () => { | |
| if (!input.trim() || loading) return | |
| const userMessage = input.trim() | |
| setInput('') | |
| setMessages(prev => [...prev, { role: 'user', content: userMessage }]) | |
| setLoading(true) | |
| try { | |
| const res = await fetch('/assistant/chat', { | |
| method: 'POST', | |
| headers, | |
| body: JSON.stringify({ | |
| message: userMessage, | |
| provider: provider, | |
| model: selectedModel || null, | |
| api_key: apiKey || null | |
| }) | |
| }) | |
| const data = await res.json() | |
| setMessages(prev => [...prev, { | |
| role: 'assistant', | |
| content: data.answer, | |
| sources: data.sources | |
| }]) | |
| } catch (err) { | |
| console.error('Chat error:', err) | |
| setMessages(prev => [...prev, { | |
| role: 'assistant', | |
| content: 'Sorry, something went wrong. Please try again.' | |
| }]) | |
| } finally { | |
| setLoading(false) | |
| } | |
| } | |
| // Handle key press | |
| const handleKeyDown = (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault() | |
| handleSend() | |
| } | |
| } | |
| // Drag and drop handlers | |
| const handleDragOver = (e) => { | |
| e.preventDefault() | |
| setDragOver(true) | |
| } | |
| const handleDragLeave = () => setDragOver(false) | |
| const handleDrop = (e) => { | |
| e.preventDefault() | |
| setDragOver(false) | |
| const file = e.dataTransfer.files[0] | |
| if (file) handleUpload(file) | |
| } | |
| const handleFileSelect = (e) => { | |
| const file = e.target.files[0] | |
| if (file) handleUpload(file) | |
| } | |
| return ( | |
| <> | |
| <StarField /> | |
| <div className="app-container"> | |
| {/* Header */} | |
| <header className="header"> | |
| <h1>🤖 Portfolio Assistant</h1> | |
| <p>Upload your documents and ask questions</p> | |
| </header> | |
| {/* Settings Panel */} | |
| <div className="settings-panel"> | |
| {/* Provider Card */} | |
| <div className="settings-card"> | |
| <div className="card-header"> | |
| <Zap size={16} /> | |
| <span>Provider</span> | |
| </div> | |
| <select | |
| className="form-select" | |
| value={provider} | |
| onChange={(e) => { | |
| setProvider(e.target.value) | |
| setConnectionStatus(null) | |
| setModels([]) | |
| }} | |
| > | |
| <option value="groq">Groq (Fast)</option> | |
| <option value="huggingface">HuggingFace</option> | |
| </select> | |
| <div className="form-group"> | |
| <label className="form-label"> | |
| Model | |
| {loadingModels && <Loader2 size={12} className="spinner" />} | |
| {models.length > 0 && <span className="model-count">({models.length})</span>} | |
| </label> | |
| <select | |
| className="form-select" | |
| value={selectedModel} | |
| onChange={(e) => setSelectedModel(e.target.value)} | |
| disabled={models.length === 0} | |
| > | |
| {models.length > 0 ? ( | |
| models.map(m => ( | |
| <option key={m.id} value={m.id}>{m.name}</option> | |
| )) | |
| ) : ( | |
| <option value="">Enter API key to load models</option> | |
| )} | |
| </select> | |
| </div> | |
| </div> | |
| {/* API Key Card */} | |
| <div className="settings-card"> | |
| <div className="card-header"> | |
| <Key size={16} /> | |
| <span>API Key</span> | |
| {connectionStatus === 'success' && ( | |
| <span className="status-badge success"> | |
| <CheckCircle size={12} /> Connected | |
| </span> | |
| )} | |
| </div> | |
| <input | |
| type="password" | |
| className={`form-input ${apiKeyError ? 'error' : ''}`} | |
| value={apiKey} | |
| onChange={(e) => { | |
| setApiKey(e.target.value) | |
| setConnectionStatus(null) | |
| }} | |
| placeholder={`Paste your ${provider === 'groq' ? 'Groq' : 'HuggingFace'} API key`} | |
| /> | |
| {apiKeyError ? ( | |
| <span className="status-text error"> | |
| <AlertCircle size={12} /> {apiKeyError} | |
| </span> | |
| ) : ( | |
| <span className={`status-text ${models.length > 0 ? 'success' : ''}`}> | |
| {models.length > 0 | |
| ? `✓ ${models.length} models loaded` | |
| : 'Models will load when you test connection'} | |
| </span> | |
| )} | |
| <button | |
| className="test-connection-btn" | |
| onClick={testConnection} | |
| disabled={!apiKey || connectionStatus === 'testing'} | |
| > | |
| {connectionStatus === 'testing' ? ( | |
| <><Loader2 size={14} className="spinner" /> Testing...</> | |
| ) : connectionStatus === 'error' ? ( | |
| <><AlertCircle size={14} /> Retry</> | |
| ) : connectionStatus === 'success' ? ( | |
| <><CheckCircle size={14} /> Connected!</> | |
| ) : ( | |
| <><Zap size={14} /> Test Connection</> | |
| )} | |
| </button> | |
| <a | |
| className="api-key-link" | |
| href={provider === 'groq' ? 'https://console.groq.com/keys' : 'https://huggingface.co/settings/tokens'} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| > | |
| 🔑 Get your {provider === 'groq' ? 'Groq' : 'HuggingFace'} API key → | |
| </a> | |
| </div> | |
| </div> | |
| {/* Upload Zone */} | |
| <div | |
| className={`upload-zone ${dragOver ? 'dragover' : ''}`} | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| onClick={() => fileInputRef.current?.click()} | |
| > | |
| <Upload size={32} /> | |
| <p>{uploading ? 'Uploading...' : 'Drag & drop files here or click to browse'}</p> | |
| <p className="formats">Supported: PDF, DOCX, TXT, Markdown, JSON</p> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept=".pdf,.docx,.txt,.md,.json" | |
| onChange={handleFileSelect} | |
| style={{ display: 'none' }} | |
| /> | |
| </div> | |
| {/* Document List */} | |
| {documents.length > 0 && ( | |
| <div className="documents"> | |
| {documents.map(doc => ( | |
| <div key={doc.id} className="doc-chip"> | |
| <FileText size={14} /> | |
| <span className="type">{doc.type}</span> | |
| <span>{doc.filename}</span> | |
| <button onClick={() => handleDelete(doc.id)}> | |
| <X size={14} /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Chat Container */} | |
| <div className="chat-container"> | |
| <div className="messages"> | |
| {messages.length === 0 ? ( | |
| <div className="welcome-message"> | |
| <MessageCircle size={48} /> | |
| <h3>Start a conversation</h3> | |
| <p>Upload documents above, then ask questions about them.</p> | |
| </div> | |
| ) : ( | |
| messages.map((msg, idx) => ( | |
| <div key={idx} className={`message ${msg.role}`}> | |
| {msg.role === 'assistant' ? ( | |
| <> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}> | |
| {msg.content} | |
| </ReactMarkdown> | |
| {msg.sources?.length > 0 && ( | |
| <div className="sources"> | |
| Sources: | |
| {[...new Set(msg.sources.map(s => s.source))].map((source, i) => ( | |
| <span key={i} className="source-chip">{source}</span> | |
| ))} | |
| </div> | |
| )} | |
| </> | |
| ) : ( | |
| msg.content | |
| )} | |
| </div> | |
| )) | |
| )} | |
| {loading && ( | |
| <div className="message assistant"> | |
| <div className="loading-dots"> | |
| <span></span> | |
| <span></span> | |
| <span></span> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* Input */} | |
| <div className="chat-input-container"> | |
| <textarea | |
| className="chat-input" | |
| placeholder="Ask a question about your documents..." | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| rows={1} | |
| /> | |
| <button | |
| className="send-button" | |
| onClick={handleSend} | |
| disabled={!input.trim() || loading} | |
| > | |
| <Send size={18} /> | |
| Send | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </> | |
| ) | |
| } | |