Kraft102's picture
fix: sql.js Docker/Alpine compatibility layer for PatternMemory and FailureMemory
5a81b95
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>
);
}