Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo, useCallback, useEffect } from 'react'; | |
| import { getTinyLlamaConfig, getDefaultProviderForUI, OLLAMA_BASE, HF_MODEL, SYSTEM_PROMPT } from '../config/tinyllama'; | |
| const providerLabels = { | |
| ollama: 'Local (Ollama)', | |
| hf: 'Hugging Face', | |
| auto: 'Auto', | |
| }; | |
| const TinyLlamaConverter = ({ addToMessage, sharedInput, setSharedInput }) => { | |
| const [output, setOutput] = useState(''); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [error, setError] = useState(''); | |
| const [copied, setCopied] = useState(false); | |
| const defaultProvider = useMemo(getDefaultProviderForUI, []); | |
| const [providerOverride, setProviderOverride] = useState(null); | |
| const config = useMemo(() => getTinyLlamaConfig(providerOverride), [providerOverride]); | |
| const isLocalhost = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); | |
| const handleInputChange = (e) => { | |
| setSharedInput(e.target.value); | |
| }; | |
| const copyOutput = useCallback(async () => { | |
| if (!output) return; | |
| try { | |
| await navigator.clipboard.writeText(output); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| } catch (e) { | |
| setError('Copy failed'); | |
| } | |
| }, [output]); | |
| const addResponseToMessage = useCallback(() => { | |
| if (output && addToMessage) addToMessage(output); | |
| }, [output, addToMessage]); | |
| const generateResponse = useCallback(async () => { | |
| if (!sharedInput.trim()) return; | |
| setIsLoading(true); | |
| setError(''); | |
| setOutput(''); | |
| try { | |
| if (config.provider === 'hf') { | |
| const model = config.model ?? HF_MODEL; | |
| const url = `${config.baseUrl}/v1/chat/completions`; | |
| const res = await fetch(url, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| model, | |
| messages: [ | |
| { role: 'system', content: SYSTEM_PROMPT }, | |
| { role: 'user', content: sharedInput.trim() }, | |
| ], | |
| max_tokens: 512, | |
| }), | |
| }); | |
| if (!res.ok) { | |
| const errBody = await res.text(); | |
| if (res.status === 401) throw new Error('Hugging Face token missing or invalid. Add VITE_HF_TOKEN in Space Settings → Secrets, then restart.'); | |
| if (res.status === 502) throw new Error('Proxy error (502). Check VITE_HF_TOKEN in Space Secrets and restart.'); | |
| throw new Error(errBody?.slice(0, 200) || res.statusText); | |
| } | |
| const raw = await res.text(); | |
| let data; | |
| try { | |
| data = JSON.parse(raw); | |
| } catch { | |
| throw new Error('Invalid response from API.'); | |
| } | |
| const text = data?.choices?.[0]?.message?.content; | |
| setOutput(text ?? (typeof data === 'string' ? data : '')); | |
| return; | |
| } | |
| const response = await fetch(`${config.baseUrl}/api/generate`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| model: config.model ?? 'tinyllama', | |
| prompt: sharedInput.trim(), | |
| system: SYSTEM_PROMPT, | |
| stream: true, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Error: ${response.status} ${response.statusText}`); | |
| } | |
| const reader = response.body?.getReader(); | |
| if (!reader) { | |
| const data = await response.json(); | |
| setOutput(data.response ?? ''); | |
| return; | |
| } | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| let full = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() ?? ''; | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (!trimmed) continue; | |
| try { | |
| const obj = JSON.parse(trimmed); | |
| if (typeof obj.response === 'string') full += obj.response; | |
| } catch (_) {} | |
| } | |
| setOutput(full); | |
| } | |
| if (buffer.trim()) { | |
| try { | |
| const obj = JSON.parse(buffer.trim()); | |
| if (typeof obj.response === 'string') full += obj.response; | |
| } catch (_) {} | |
| setOutput(full); | |
| } | |
| } catch (err) { | |
| console.error("Failed to generate response:", err); | |
| if (err.message.includes("Failed to fetch")) { | |
| const msg = config.provider === 'ollama' && config.baseUrl === OLLAMA_BASE | |
| ? "Failed to connect to Ollama. Ensure it's running on port 11434 and CORS is allowed (OLLAMA_ORIGINS=\"*\")." | |
| : config.provider === 'hf' | |
| ? "CORS or network error. Ensure the latest code is deployed and VITE_HF_TOKEN is set in Space Settings → Secrets, then restart." | |
| : `Failed to connect to ${config.baseUrl}. Check network and CORS.`; | |
| setError(msg); | |
| } else { | |
| setError(err.message); | |
| } | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }, [sharedInput, config]); | |
| useEffect(() => { | |
| const onKey = (e) => { | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| e.preventDefault(); | |
| if (sharedInput.trim() && !isLoading) generateResponse(); | |
| } | |
| }; | |
| window.addEventListener('keydown', onKey); | |
| return () => window.removeEventListener('keydown', onKey); | |
| }, [sharedInput, isLoading, generateResponse]); | |
| return ( | |
| <div className="p-6 bg-gradient-to-br from-gray-900 to-black rounded-lg border border-gray-800 hover-glow transition-all duration-300"> | |
| <div className="flex items-center justify-between mb-6 flex-wrap gap-2"> | |
| <h2 className="text-3xl font-bold text-white flex items-center gap-2"> | |
| <span className="text-invader-green">🦙</span> | |
| TinyLlama Assistant | |
| </h2> | |
| {isLocalhost ? ( | |
| <select | |
| value={providerOverride ?? defaultProvider} | |
| onChange={(e) => setProviderOverride(e.target.value === defaultProvider ? null : e.target.value)} | |
| className="text-sm bg-black border border-invader-green/50 rounded px-3 py-1.5 text-white focus:ring-2 focus:ring-invader-green focus:border-invader-green" | |
| aria-label="Backend provider" | |
| > | |
| <option value="auto">Auto</option> | |
| <option value="ollama">{providerLabels.ollama}</option> | |
| <option value="hf">{providerLabels.hf}</option> | |
| </select> | |
| ) : ( | |
| <span className="text-xs text-white/50" title="Auto-selected by environment"> | |
| {config.provider === 'ollama' && config.baseUrl === OLLAMA_BASE && 'Local (Ollama · CPU/GPU)'} | |
| {config.provider === 'ollama' && config.baseUrl !== OLLAMA_BASE && 'Custom / Vercel'} | |
| {config.provider === 'hf' && 'Hugging Face'} | |
| </span> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] gap-6 items-start"> | |
| <div className="space-y-2 h-full flex flex-col"> | |
| <label htmlFor="llama-input" className="block text-sm font-medium text-gray-300 mb-2">Prompt</label> | |
| <textarea | |
| id="llama-input" | |
| className="w-full flex-grow min-h-[160px] p-4 bg-black border-2 border-invader-green/30 rounded-lg text-white focus:ring-2 focus:ring-invader-green focus:border-invader-green transition-all duration-300 hover:border-invader-green/50 resize-none" | |
| placeholder="Ask something… (Ctrl+Enter to generate)" | |
| value={sharedInput} | |
| onChange={handleInputChange} | |
| /> | |
| </div> | |
| <div className="flex justify-center self-center"> | |
| <button | |
| onClick={generateResponse} | |
| disabled={isLoading || !sharedInput.trim()} | |
| className={`p-4 bg-gradient-to-br from-invader-green to-green-600 text-black rounded-full hover:from-green-400 hover:to-green-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-invader-green transition-all duration-300 hover:scale-110 active:scale-95 shadow-lg hover:shadow-invader-green/50 font-bold ${isLoading ? 'opacity-50 cursor-not-allowed' : ''}`} | |
| title="Generate (Ctrl+Enter)" | |
| > | |
| {isLoading ? ( | |
| <div className="animate-spin h-6 w-6 border-4 border-black border-t-transparent rounded-full"></div> | |
| ) : ( | |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> | |
| </svg> | |
| )} | |
| </button> | |
| </div> | |
| <div className="space-y-2 h-full flex flex-col"> | |
| <div className="flex items-center justify-between"> | |
| <label htmlFor="llama-output" className="block text-sm font-medium text-gray-300 mb-2">Response</label> | |
| {output && ( | |
| <div className="flex gap-2"> | |
| <button | |
| type="button" | |
| onClick={copyOutput} | |
| className="text-xs px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-white transition-colors" | |
| aria-label="Copy response" | |
| > | |
| {copied ? '✓ Copied!' : '📋 Copy'} | |
| </button> | |
| {addToMessage && ( | |
| <button | |
| type="button" | |
| onClick={addResponseToMessage} | |
| className="text-xs px-3 py-1.5 bg-invader-green/20 hover:bg-invader-green/40 text-invader-green rounded transition-colors" | |
| aria-label="Add response to compounded message" | |
| > | |
| Add to Message | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| <div id="llama-output" className="w-full min-h-[160px] max-h-[24rem] p-4 bg-black border-2 border-invader-green/30 rounded-lg text-white overflow-y-auto overflow-x-hidden hover:border-invader-green/50 transition-all duration-300" aria-live="polite" aria-atomic="true"> | |
| {error ? ( | |
| <div className="space-y-2"> | |
| <p className="text-red-500">{error}</p> | |
| <div className="flex gap-2 flex-wrap"> | |
| <button | |
| type="button" | |
| onClick={() => setError('')} | |
| className="text-xs px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-white transition-colors" | |
| aria-label="Dismiss error" | |
| > | |
| Dismiss | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => { setError(''); generateResponse(); }} | |
| className="text-xs px-3 py-1.5 bg-invader-green/30 hover:bg-invader-green/50 text-invader-green rounded transition-colors" | |
| aria-label="Try again" | |
| > | |
| Try again | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="break-words whitespace-pre-wrap">{output || <span className="text-gray-500 italic">Response will appear here...</span>}</div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default TinyLlamaConverter; | |