|
|
import React, { useState, useRef, useEffect } from 'react'; |
|
|
import { modelCompanies, allModels, findModelById } from './models/modelConfig'; |
|
|
import { HuggingFaceService } from './services/huggingfaceService'; |
|
|
import './App.css'; |
|
|
|
|
|
|
|
|
import { createIcons, Brain, Key, Sun, Moon, X, ChevronDown, Cpu, BrainCircuit, Atom, Check, Send, AlertCircle, Sparkles } from 'lucide-react'; |
|
|
|
|
|
function App() { |
|
|
const [messages, setMessages] = useState([]); |
|
|
const [inputValue, setInputValue] = useState(''); |
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
const [isDarkMode, setIsDarkMode] = useState(false); |
|
|
const [showModelDropdown, setShowModelDropdown] = useState(false); |
|
|
const [selectedModel, setSelectedModel] = useState('deepseek-v3.2-exp'); |
|
|
const [hfToken, setHfToken] = useState(''); |
|
|
const [showAuth, setShowAuth] = useState(true); |
|
|
const [error, setError] = useState(''); |
|
|
|
|
|
const messagesEndRef = useRef(null); |
|
|
const textareaRef = useRef(null); |
|
|
const dropdownRef = useRef(null); |
|
|
const currentMessageRef = useRef(null); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
createIcons({ |
|
|
icons: { |
|
|
Brain, |
|
|
Key, |
|
|
Sun, |
|
|
Moon, |
|
|
X, |
|
|
ChevronDown, |
|
|
Cpu, |
|
|
BrainCircuit, |
|
|
Atom, |
|
|
Check, |
|
|
Send, |
|
|
AlertCircle, |
|
|
Sparkles |
|
|
} |
|
|
}); |
|
|
}, []); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const storedToken = localStorage.getItem('hf_token'); |
|
|
if (storedToken) { |
|
|
setHfToken(storedToken); |
|
|
setShowAuth(false); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
scrollToBottom(); |
|
|
}, [messages]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const handleClickOutside = (event) => { |
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { |
|
|
setShowModelDropdown(false); |
|
|
} |
|
|
}; |
|
|
document.addEventListener('mousedown', handleClickOutside); |
|
|
return () => document.removeEventListener('mousedown', handleClickOutside); |
|
|
}, []); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (textareaRef.current) { |
|
|
textareaRef.current.style.height = 'auto'; |
|
|
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'; |
|
|
} |
|
|
}, [inputValue]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (isDarkMode) { |
|
|
document.body.classList.add('dark'); |
|
|
} else { |
|
|
document.body.classList.remove('dark'); |
|
|
} |
|
|
}, [isDarkMode]); |
|
|
|
|
|
const scrollToBottom = () => { |
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
|
|
}; |
|
|
|
|
|
const handleTokenSubmit = () => { |
|
|
if (!hfToken.trim()) { |
|
|
setError('Please enter your Hugging Face token'); |
|
|
return; |
|
|
} |
|
|
if (!hfToken.startsWith('hf_')) { |
|
|
setError('Please enter a valid Hugging Face token'); |
|
|
return; |
|
|
} |
|
|
localStorage.setItem('hf_token', hfToken.trim()); |
|
|
setShowAuth(false); |
|
|
setError(''); |
|
|
}; |
|
|
|
|
|
const handleClearToken = () => { |
|
|
localStorage.removeItem('hf_token'); |
|
|
setHfToken(''); |
|
|
setShowAuth(true); |
|
|
setMessages([]); |
|
|
}; |
|
|
|
|
|
const handleSendMessage = async () => { |
|
|
if (!inputValue.trim() || isLoading || !hfToken) return; |
|
|
|
|
|
const userMessage = { |
|
|
id: Date.now(), |
|
|
content: inputValue.trim(), |
|
|
role: 'user', |
|
|
timestamp: new Date() |
|
|
}; |
|
|
|
|
|
setMessages(prev => [...prev, userMessage]); |
|
|
setInputValue(''); |
|
|
setIsLoading(true); |
|
|
setError(''); |
|
|
|
|
|
|
|
|
const assistantMessageId = Date.now() + 1; |
|
|
const assistantMessage = { |
|
|
id: assistantMessageId, |
|
|
content: '', |
|
|
role: 'assistant', |
|
|
timestamp: new Date() |
|
|
}; |
|
|
|
|
|
setMessages(prev => [...prev, assistantMessage]); |
|
|
currentMessageRef.current = assistantMessageId; |
|
|
|
|
|
try { |
|
|
const currentModelConfig = findModelById(selectedModel); |
|
|
const hfService = new HuggingFaceService(hfToken); |
|
|
|
|
|
const chatMessages = [ |
|
|
...messages.filter(msg => msg.role !== 'assistant' || msg.content), |
|
|
userMessage |
|
|
].map(msg => ({ |
|
|
role: msg.role, |
|
|
content: msg.content |
|
|
})); |
|
|
|
|
|
await hfService.streamChatCompletion( |
|
|
chatMessages, |
|
|
currentModelConfig, |
|
|
(chunk) => { |
|
|
setMessages(prev => prev.map(msg => |
|
|
msg.id === assistantMessageId |
|
|
? { ...msg, content: msg.content + (chunk || '') } |
|
|
: msg |
|
|
)); |
|
|
}, |
|
|
() => { |
|
|
setIsLoading(false); |
|
|
currentMessageRef.current = null; |
|
|
}, |
|
|
(errorMsg) => { |
|
|
setError(`Model error: ${errorMsg}`); |
|
|
setIsLoading(false); |
|
|
currentMessageRef.current = null; |
|
|
setMessages(prev => prev.filter(msg => msg.id !== assistantMessageId)); |
|
|
} |
|
|
); |
|
|
|
|
|
} catch (err) { |
|
|
console.error('Chat error:', err); |
|
|
setError(`Failed to connect to AI model: ${err.message}`); |
|
|
setIsLoading(false); |
|
|
currentMessageRef.current = null; |
|
|
setMessages(prev => prev.filter(msg => msg.id !== assistantMessageId)); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleKeyPress = (e) => { |
|
|
if (e.key === 'Enter' && !e.shiftKey) { |
|
|
e.preventDefault(); |
|
|
handleSendMessage(); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleModelSelect = (modelId) => { |
|
|
setSelectedModel(modelId); |
|
|
setShowModelDropdown(false); |
|
|
}; |
|
|
|
|
|
const currentModel = findModelById(selectedModel); |
|
|
const groupedModels = modelCompanies; |
|
|
|
|
|
|
|
|
if (showAuth) { |
|
|
return ( |
|
|
<div className={`App ${isDarkMode ? 'dark' : ''}`}> |
|
|
<div className="auth-modal"> |
|
|
<div className="auth-content"> |
|
|
<div className="logo" style={{ justifyContent: 'center', marginBottom: '24px' }}> |
|
|
<i data-lucide="brain"></i> |
|
|
<span style={{ fontSize: '28px' }}>SynapseAI</span> |
|
|
</div> |
|
|
|
|
|
<h2 className="auth-title">Welcome to SynapseAI</h2> |
|
|
<p className="auth-description"> |
|
|
Enter your Hugging Face token to start chatting with AI models |
|
|
</p> |
|
|
{error && ( |
|
|
<div className="error-message"> |
|
|
<i data-lucide="alert-circle"></i> |
|
|
{error} |
|
|
</div> |
|
|
)} |
|
|
<div className="auth-input"> |
|
|
<input |
|
|
type="password" |
|
|
className="input" |
|
|
placeholder="Enter your Hugging Face token (hf_...)" |
|
|
value={hfToken} |
|
|
onChange={(e) => setHfToken(e.target.value)} |
|
|
onKeyPress={(e) => e.key === 'Enter' && handleTokenSubmit()} |
|
|
/> |
|
|
</div> |
|
|
<div className="auth-actions"> |
|
|
<button className="btn primary" onClick={handleTokenSubmit}> |
|
|
<i data-lucide="key"></i> |
|
|
Start Chatting |
|
|
</button> |
|
|
</div> |
|
|
<div className="token-info"> |
|
|
<h4>How to get your Hugging Face token:</h4> |
|
|
<ol> |
|
|
<li>Go to <a href="https://huggingface.co" target="_blank" rel="noopener noreferrer">huggingface.co</a></li> |
|
|
<li>Sign in to your account</li> |
|
|
<li>Go to Settings → Access Tokens</li> |
|
|
<li>Create a new token with read permissions</li> |
|
|
<li>Copy and paste it here</li> |
|
|
</ol> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
return ( |
|
|
<div className={`App ${isDarkMode ? 'dark' : ''}`}> |
|
|
<div className="chat-container"> |
|
|
{/* Header */} |
|
|
<header className="header"> |
|
|
<div className="logo"> |
|
|
<i data-lucide="brain"></i> |
|
|
<span>SynapseAI</span> |
|
|
</div> |
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> |
|
|
<div className="token-display"> |
|
|
<i data-lucide="key"></i> |
|
|
<span className="token-text">Token: {hfToken.substring(0, 10)}...</span> |
|
|
<div className="clear-token" onClick={handleClearToken} title="Clear token"> |
|
|
<i data-lucide="x"></i> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<button |
|
|
className="btn ghost theme-toggle" |
|
|
onClick={() => setIsDarkMode(!isDarkMode)} |
|
|
> |
|
|
<i data-lucide={isDarkMode ? "sun" : "moon"}></i> |
|
|
</button> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
{/* Chat Messages */} |
|
|
<div className="chat-messages"> |
|
|
{messages.length === 0 && ( |
|
|
<div className="welcome-message"> |
|
|
<div className="card" style={{ maxWidth: '600px', margin: '0 auto' }}> |
|
|
<i data-lucide="sparkles"></i> |
|
|
<h2 style={{ marginBottom: '8px', fontSize: '24px', fontWeight: '600' }}>Welcome to SynapseAI</h2> |
|
|
<p style={{ color: '#71717a', lineHeight: '1.5', marginBottom: '16px' }}> |
|
|
Start a conversation with AI models. Select your preferred model below. |
|
|
</p> |
|
|
<p style={{ fontSize: '14px', color: '#a1a1aa' }}> |
|
|
Current Model: <strong>{currentModel?.name}</strong> by {currentModel?.company} |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
{messages.map((message) => ( |
|
|
<div key={message.id} className={`message ${message.role}`}> |
|
|
<div className="message-content"> |
|
|
{message.content || (message.role === 'assistant' && isLoading && '...')} |
|
|
</div> |
|
|
</div> |
|
|
))} |
|
|
{isLoading && !currentMessageRef.current && ( |
|
|
<div className="message assistant"> |
|
|
<div className="typing-indicator"> |
|
|
<div className="typing-dot"></div> |
|
|
<div className="typing-dot"></div> |
|
|
<div className="typing-dot"></div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<div ref={messagesEndRef} /> |
|
|
</div> |
|
|
|
|
|
{/* Error Display */} |
|
|
{error && ( |
|
|
<div className="error-message"> |
|
|
<i data-lucide="alert-circle"></i> |
|
|
{error} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Input Area */} |
|
|
<div className="input-container"> |
|
|
<div className="chat-input-wrapper"> |
|
|
{/* Model Selector */} |
|
|
<div className="dropdown" ref={dropdownRef}> |
|
|
<button |
|
|
className="btn model-selector" |
|
|
onClick={() => setShowModelDropdown(!showModelDropdown)} |
|
|
> |
|
|
<i data-lucide={currentModel?.companyLogo || "cpu"}></i> |
|
|
<span style={{ flex: 1, textAlign: 'left' }}>{currentModel?.name}</span> |
|
|
<i data-lucide="chevron-down"></i> |
|
|
</button> |
|
|
{showModelDropdown && ( |
|
|
<div className="dropdown-content"> |
|
|
{groupedModels.map((company) => ( |
|
|
<div key={company.id} className="company-section"> |
|
|
<div className="company-header"> |
|
|
<i data-lucide={company.logo}></i> |
|
|
{company.name} |
|
|
</div> |
|
|
{company.models.map((model) => ( |
|
|
<div |
|
|
key={model.id} |
|
|
className={`dropdown-item ${selectedModel === model.id ? 'active' : ''}`} |
|
|
onClick={() => handleModelSelect(model.id)} |
|
|
> |
|
|
<div className="model-info"> |
|
|
<div className="model-name">{model.name}</div> |
|
|
<div className="model-description">{model.description}</div> |
|
|
</div> |
|
|
<div className="model-check"> |
|
|
{selectedModel === model.id && <i data-lucide="check"></i>} |
|
|
</div> |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
{/* Chat Input */} |
|
|
<textarea |
|
|
ref={textareaRef} |
|
|
className="input chat-input" |
|
|
placeholder="Message SynapseAI..." |
|
|
value={inputValue} |
|
|
onChange={(e) => setInputValue(e.target.value)} |
|
|
onKeyPress={handleKeyPress} |
|
|
disabled={isLoading} |
|
|
rows={1} |
|
|
/> |
|
|
|
|
|
{/* Send Button */} |
|
|
<button |
|
|
className="send-button" |
|
|
onClick={handleSendMessage} |
|
|
disabled={!inputValue.trim() || isLoading} |
|
|
> |
|
|
<i data-lucide="send"></i> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
export default App; |