text-transformer / src /components /TinyLlamaConverter.jsx
OnyxlMunkey's picture
chore: update Dockerfile, TinyLlama converter, vite config for Space
ecfb822
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;