Spaces:
Paused
Paused
| import { useState, useEffect, useCallback } from 'react'; | |
| import { | |
| Settings, Server, Cpu, Activity, MessageSquare, | |
| Send, RefreshCw, Check, X, Zap, Database, | |
| ChevronDown, ChevronUp, Loader2, AlertTriangle | |
| } from 'lucide-react'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; | |
| import { ScrollArea } from '@/components/ui/scroll-area'; | |
| import { cn } from '@/lib/utils'; | |
| import { useToast } from '@/hooks/use-toast'; | |
| import { | |
| SmartComponentRenderer, | |
| parseMessageForComponents, | |
| isUIComponentMessage, | |
| UIComponentMessage, | |
| } from '@/components/smart-ui'; | |
| interface LocalAIModel { | |
| id: string; | |
| object: string; | |
| owned_by?: string; | |
| } | |
| interface ChatMessage { | |
| role: 'user' | 'assistant'; | |
| content: string; | |
| uiComponents?: UIComponentMessage[]; | |
| } | |
| interface LocalAIStatus { | |
| connected: boolean; | |
| status?: string; | |
| error?: string; | |
| } | |
| export default function LocalAIWidget() { | |
| const { toast } = useToast(); | |
| const [endpoint, setEndpoint] = useState(() => | |
| localStorage.getItem('localai-endpoint') || 'http://localhost:8080' | |
| ); | |
| const [tempEndpoint, setTempEndpoint] = useState(endpoint); | |
| const [showSettings, setShowSettings] = useState(false); | |
| const [status, setStatus] = useState<LocalAIStatus>({ connected: false }); | |
| const [models, setModels] = useState<LocalAIModel[]>([]); | |
| const [selectedModel, setSelectedModel] = useState<string>(''); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [activeTab, setActiveTab] = useState('status'); | |
| // Chat state | |
| const [messages, setMessages] = useState<ChatMessage[]>([]); | |
| const [inputMessage, setInputMessage] = useState(''); | |
| const [isSending, setIsSending] = useState(false); | |
| // Check LocalAI status | |
| const checkStatus = useCallback(async () => { | |
| setIsLoading(true); | |
| try { | |
| // Try health endpoint first | |
| const healthResponse = await fetch(`${endpoint}/readyz`, { | |
| method: 'GET', | |
| signal: AbortSignal.timeout(5000), | |
| }); | |
| if (healthResponse.ok) { | |
| setStatus({ connected: true, status: 'healthy' }); | |
| } else { | |
| // Try alternative health check | |
| const altResponse = await fetch(`${endpoint}/v1/models`, { | |
| method: 'GET', | |
| signal: AbortSignal.timeout(5000), | |
| }); | |
| if (altResponse.ok) { | |
| setStatus({ connected: true, status: 'healthy' }); | |
| } else { | |
| setStatus({ connected: false, error: 'Server unhealthy' }); | |
| } | |
| } | |
| } catch (error) { | |
| setStatus({ | |
| connected: false, | |
| error: error instanceof Error ? error.message : 'Connection failed' | |
| }); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }, [endpoint]); | |
| // Fetch available models | |
| const fetchModels = useCallback(async () => { | |
| try { | |
| const response = await fetch(`${endpoint}/v1/models`, { | |
| method: 'GET', | |
| signal: AbortSignal.timeout(10000), | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| const modelList = data.data || []; | |
| setModels(modelList); | |
| if (modelList.length > 0 && !selectedModel) { | |
| setSelectedModel(modelList[0].id); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Failed to fetch models:', error); | |
| } | |
| }, [endpoint, selectedModel]); | |
| // Send chat message | |
| const sendMessage = async () => { | |
| if (!inputMessage.trim() || !selectedModel || isSending) return; | |
| const userMessage: ChatMessage = { role: 'user', content: inputMessage }; | |
| setMessages(prev => [...prev, userMessage]); | |
| setInputMessage(''); | |
| setIsSending(true); | |
| try { | |
| const response = await fetch(`${endpoint}/v1/chat/completions`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| model: selectedModel, | |
| messages: [...messages, userMessage].map(m => ({ | |
| role: m.role, | |
| content: m.content | |
| })), | |
| max_tokens: 500, | |
| }), | |
| signal: AbortSignal.timeout(30000), | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| const assistantContent = data.choices?.[0]?.message?.content || 'No response'; | |
| setMessages(prev => [...prev, { role: 'assistant', content: assistantContent }]); | |
| } else { | |
| throw new Error('Failed to get response'); | |
| } | |
| } catch (error) { | |
| toast({ | |
| title: "Error", | |
| description: error instanceof Error ? error.message : 'Failed to send message', | |
| variant: "destructive", | |
| }); | |
| // Remove the user message if it failed | |
| setMessages(prev => prev.slice(0, -1)); | |
| } finally { | |
| setIsSending(false); | |
| } | |
| }; | |
| // Save endpoint | |
| const saveEndpoint = () => { | |
| setEndpoint(tempEndpoint); | |
| localStorage.setItem('localai-endpoint', tempEndpoint); | |
| setShowSettings(false); | |
| setStatus({ connected: false }); | |
| setModels([]); | |
| toast({ | |
| title: "Endpoint opdateret", | |
| description: `Forbinder til ${tempEndpoint}`, | |
| }); | |
| }; | |
| // Initial check | |
| useEffect(() => { | |
| checkStatus(); | |
| fetchModels(); | |
| }, [checkStatus, fetchModels]); | |
| return ( | |
| <div className="h-full flex flex-col"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between mb-3"> | |
| <div className="flex items-center gap-2"> | |
| <div className={cn( | |
| "w-2 h-2 rounded-full", | |
| status.connected ? "bg-green-500 animate-pulse" : "bg-red-500" | |
| )} /> | |
| <span className="text-xs font-mono text-muted-foreground"> | |
| {status.connected ? 'CONNECTED' : 'DISCONNECTED'} | |
| </span> | |
| </div> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => setShowSettings(!showSettings)} | |
| className="h-6 w-6 p-0" | |
| > | |
| <Settings className={cn("w-3 h-3 transition-transform", showSettings && "rotate-90")} /> | |
| </Button> | |
| </div> | |
| {/* Settings Panel */} | |
| {showSettings && ( | |
| <div className="mb-3 p-2 bg-secondary/30 rounded border border-border/30 space-y-2"> | |
| <label className="text-[10px] text-muted-foreground">LocalAI Endpoint</label> | |
| <div className="flex gap-2"> | |
| <Input | |
| value={tempEndpoint} | |
| onChange={(e) => setTempEndpoint(e.target.value)} | |
| placeholder="http://localhost:8080" | |
| className="h-7 text-xs font-mono bg-background/50" | |
| /> | |
| <Button size="sm" onClick={saveEndpoint} className="h-7 px-2"> | |
| <Check className="w-3 h-3" /> | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Main Content */} | |
| <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col"> | |
| <TabsList className="grid grid-cols-3 h-7 bg-secondary/30"> | |
| <TabsTrigger value="status" className="text-[10px] data-[state=active]:bg-primary/20"> | |
| <Server className="w-3 h-3 mr-1" /> | |
| Status | |
| </TabsTrigger> | |
| <TabsTrigger value="models" className="text-[10px] data-[state=active]:bg-primary/20"> | |
| <Database className="w-3 h-3 mr-1" /> | |
| Models | |
| </TabsTrigger> | |
| <TabsTrigger value="chat" className="text-[10px] data-[state=active]:bg-primary/20"> | |
| <MessageSquare className="w-3 h-3 mr-1" /> | |
| Chat | |
| </TabsTrigger> | |
| </TabsList> | |
| {/* Status Tab */} | |
| <TabsContent value="status" className="flex-1 mt-2"> | |
| <div className="space-y-2"> | |
| <div className="flex items-center justify-between p-2 bg-background/50 rounded"> | |
| <span className="text-xs text-muted-foreground">Server</span> | |
| <Badge variant={status.connected ? "default" : "destructive"} className="text-[9px]"> | |
| {status.connected ? 'Online' : 'Offline'} | |
| </Badge> | |
| </div> | |
| <div className="flex items-center justify-between p-2 bg-background/50 rounded"> | |
| <span className="text-xs text-muted-foreground">Endpoint</span> | |
| <span className="text-[10px] font-mono text-primary truncate max-w-[120px]">{endpoint}</span> | |
| </div> | |
| <div className="flex items-center justify-between p-2 bg-background/50 rounded"> | |
| <span className="text-xs text-muted-foreground">Models</span> | |
| <span className="text-xs font-mono text-primary">{models.length}</span> | |
| </div> | |
| {status.error && ( | |
| <div className="p-2 bg-destructive/10 border border-destructive/30 rounded"> | |
| <div className="flex items-center gap-1 text-destructive text-[10px]"> | |
| <AlertTriangle className="w-3 h-3" /> | |
| {status.error} | |
| </div> | |
| </div> | |
| )} | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => { checkStatus(); fetchModels(); }} | |
| disabled={isLoading} | |
| className="w-full h-7 text-xs" | |
| > | |
| {isLoading ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <RefreshCw className="w-3 h-3 mr-1" />} | |
| Refresh | |
| </Button> | |
| </div> | |
| </TabsContent> | |
| {/* Models Tab */} | |
| <TabsContent value="models" className="flex-1 mt-2"> | |
| {models.length === 0 ? ( | |
| <div className="text-center py-4 text-xs text-muted-foreground"> | |
| {status.connected ? 'Ingen modeller fundet' : 'Forbind til LocalAI først'} | |
| </div> | |
| ) : ( | |
| <ScrollArea className="h-32"> | |
| <div className="space-y-1"> | |
| {models.map((model) => ( | |
| <div | |
| key={model.id} | |
| onClick={() => setSelectedModel(model.id)} | |
| className={cn( | |
| "p-2 rounded cursor-pointer transition-colors text-xs font-mono", | |
| selectedModel === model.id | |
| ? "bg-primary/20 border border-primary/30" | |
| : "bg-background/50 hover:bg-background/80" | |
| )} | |
| > | |
| <div className="flex items-center justify-between"> | |
| <span className="truncate">{model.id}</span> | |
| {selectedModel === model.id && <Check className="w-3 h-3 text-primary" />} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </ScrollArea> | |
| )} | |
| </TabsContent> | |
| {/* Chat Tab */} | |
| <TabsContent value="chat" className="flex-1 mt-2 flex flex-col"> | |
| {!status.connected ? ( | |
| <div className="text-center py-4 text-xs text-muted-foreground"> | |
| Forbind til LocalAI først | |
| </div> | |
| ) : !selectedModel ? ( | |
| <div className="text-center py-4 text-xs text-muted-foreground"> | |
| Vælg en model først | |
| </div> | |
| ) : ( | |
| <> | |
| <ScrollArea className="flex-1 h-24 mb-2"> | |
| <div className="space-y-2 pr-2"> | |
| {messages.length === 0 ? ( | |
| <div className="text-center py-2 text-[10px] text-muted-foreground"> | |
| Start en samtale med {selectedModel} | |
| </div> | |
| ) : ( | |
| messages.map((msg, i) => { | |
| // Parse message for UI components | |
| const parts = parseMessageForComponents(msg.content); | |
| const hasComponents = parts.some(p => isUIComponentMessage(p)); | |
| return ( | |
| <div | |
| key={i} | |
| className={cn( | |
| "p-2 rounded text-[10px]", | |
| msg.role === 'user' | |
| ? "bg-primary/20 ml-4" | |
| : "bg-secondary/50 mr-4" | |
| )} | |
| > | |
| <div className="text-[8px] text-muted-foreground mb-1 uppercase"> | |
| {msg.role} | |
| {hasComponents && ( | |
| <Badge variant="outline" className="ml-2 text-[7px] py-0"> | |
| UI | |
| </Badge> | |
| )} | |
| </div> | |
| <div className="space-y-2"> | |
| {parts.map((part, j) => { | |
| if (isUIComponentMessage(part)) { | |
| return ( | |
| <SmartComponentRenderer | |
| key={`${i}-${j}`} | |
| message={part} | |
| className="my-2" | |
| /> | |
| ); | |
| } | |
| // Render text parts | |
| return part ? ( | |
| <div key={`${i}-${j}`} className="whitespace-pre-wrap"> | |
| {part} | |
| </div> | |
| ) : null; | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| }) | |
| )} | |
| {isSending && ( | |
| <div className="flex items-center gap-1 text-[10px] text-muted-foreground"> | |
| <Loader2 className="w-3 h-3 animate-spin" /> | |
| Tænker... | |
| </div> | |
| )} | |
| </div> | |
| </ScrollArea> | |
| <div className="flex gap-1"> | |
| <Input | |
| value={inputMessage} | |
| onChange={(e) => setInputMessage(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && sendMessage()} | |
| placeholder="Skriv besked..." | |
| className="h-7 text-xs bg-background/50" | |
| disabled={isSending} | |
| /> | |
| <Button | |
| size="sm" | |
| onClick={sendMessage} | |
| disabled={isSending || !inputMessage.trim()} | |
| className="h-7 w-7 p-0" | |
| > | |
| <Send className="w-3 h-3" /> | |
| </Button> | |
| </div> | |
| </> | |
| )} | |
| </TabsContent> | |
| </Tabs> | |
| </div> | |
| ); | |
| } | |