Kraft102's picture
fix: sql.js Docker/Alpine compatibility layer for PatternMemory and FailureMemory
5a81b95
/**
* Global AI Chat - Floating chat widget connected to backend AI
* Uses backend MCP chat_completion tool via REST API
* Supports Smart UI visualization rendering via ```ui:ComponentName``` format
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import {
MessageSquare, X, Send, Loader2, Minimize2, Maximize2,
Bot, User, Sparkles, RefreshCw
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { API_URL } from '@/config/api';
import {
SmartComponentRenderer,
parseMessageForComponents,
UIComponentMessage,
getAvailableComponents
} from '@/components/smart-ui/SmartComponentRenderer';
interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: Date;
toolUsed?: string;
}
interface GlobalAIChatProps {
defaultOpen?: boolean;
position?: 'bottom-right' | 'bottom-left';
}
export default function GlobalAIChat({
defaultOpen = false,
position = 'bottom-right'
}: GlobalAIChatProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [isMinimized, setIsMinimized] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: '1',
role: 'assistant',
content: `Hej! Jeg er din AI assistent med adgang til ${getAvailableComponents().length} Smart UI visualiseringskomponenter og systemets MCP tools. Spørg mig om hvad som helst - jeg kan generere diagrammer, grafer, metrics og meget mere!`,
timestamp: new Date()
}
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Check backend connection
const checkConnection = useCallback(async () => {
try {
const response = await fetch(`${API_URL}/health`);
setIsConnected(response.ok);
} catch {
setIsConnected(false);
}
}, []);
useEffect(() => {
checkConnection();
const interval = setInterval(checkConnection, 30000);
return () => clearInterval(interval);
}, [checkConnection]);
// Auto-scroll to bottom
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
// Focus input when opened
useEffect(() => {
if (isOpen && !isMinimized && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen, isMinimized]);
const sendMessage = async () => {
if (!input.trim() || isLoading) return;
const userMessage: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: input.trim(),
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
// Try Autonomous Agent first (AI-driven source selection)
const response = await fetch(`${API_URL}/api/mcp/autonomous/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'natural_language',
params: { query: userMessage.content }
})
});
const data = await response.json();
if (data.success && data.data) {
// Autonomous agent found a result
const assistantMessage: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: typeof data.data === 'string' ? data.data : JSON.stringify(data.data, null, 2),
timestamp: new Date(),
toolUsed: `autonomous (${data.meta?.source || 'unknown'})`
};
setMessages(prev => [...prev, assistantMessage]);
} else {
// Fallback: Use MCP route to call chat_completion tool
const chatResponse = await fetch(`${API_URL}/api/mcp/route`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool: 'chat_completion',
params: {
messages: messages
.filter(m => m.role !== 'system')
.concat(userMessage)
.map(m => ({
role: m.role,
content: m.content
})),
max_tokens: 1000,
temperature: 0.7
}
})
});
const chatData = await chatResponse.json();
// Extract response from various possible formats
let responseText: string | null = null;
let toolUsed = 'chat_completion';
// Format 1: DeepSeek response { result: { content: [{ text: "..." }] } }
if (chatData.result?.content?.[0]?.text) {
responseText = chatData.result.content[0].text;
}
// Format 2: SRAG response { result: { response: "..." } or { answer: "..." } }
else if (chatData.result?.response || chatData.result?.answer) {
responseText = chatData.result.answer || chatData.result.response;
}
// Format 3: success with response
else if (chatData.result?.success && chatData.result?.response) {
responseText = chatData.result.response;
}
if (responseText) {
const assistantMessage: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: responseText,
timestamp: new Date(),
toolUsed
};
setMessages(prev => [...prev, assistantMessage]);
} else {
// Try fallback to srag.query for knowledge-based questions
const fallbackResponse = await fetch(`${API_URL}/api/mcp/route`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool: 'srag.query',
params: {
naturalLanguageQuery: userMessage.content,
model: 'deepseek'
}
})
});
const fallbackData = await fallbackResponse.json();
// Extract from fallback response
const fallbackText = fallbackData.result?.answer
|| fallbackData.result?.response
|| fallbackData.result?.content?.[0]?.text
|| 'Beklager, jeg kunne ikke behandle din forespørgsel. Prøv igen.';
const assistantMessage: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: fallbackText,
timestamp: new Date(),
toolUsed: 'srag.query'
};
setMessages(prev => [...prev, assistantMessage]);
}
}
} catch (error) {
console.error('Chat error:', error);
const errorMessage: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: 'Der opstod en fejl. Tjek at backend kører på port 3001.',
timestamp: new Date()
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
const clearChat = () => {
setMessages([{
id: '1',
role: 'assistant',
content: `Chat ryddet. Jeg kan generere visualiseringer med ${getAvailableComponents().length} komponenter. Hvordan kan jeg hjælpe?`,
timestamp: new Date()
}]);
};
const positionClasses = position === 'bottom-right'
? 'right-4 bottom-4'
: 'left-4 bottom-4';
if (!isOpen) {
return (
<Button
onClick={() => setIsOpen(true)}
className={cn(
"fixed z-50 h-14 w-14 rounded-full shadow-lg",
"bg-gradient-to-br from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70",
"transition-all duration-300 hover:scale-110",
positionClasses
)}
>
<MessageSquare className="h-6 w-6" />
{!isConnected && (
<span className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-destructive animate-pulse" />
)}
</Button>
);
}
return (
<div
className={cn(
"fixed z-50 flex flex-col bg-background border border-border rounded-lg shadow-2xl",
"transition-all duration-300",
isMinimized ? "h-12 w-80" : "h-[500px] w-[380px]",
positionClasses
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/30 rounded-t-lg">
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary" />
<span className="font-semibold text-sm">AI Assistent</span>
<div className={cn(
"h-2 w-2 rounded-full",
isConnected ? "bg-green-500" : "bg-red-500"
)} />
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={clearChat}
title="Ryd chat"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setIsMinimized(!isMinimized)}
>
{isMinimized ? <Maximize2 className="h-3.5 w-3.5" /> : <Minimize2 className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setIsOpen(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{!isMinimized && (
<>
{/* Messages */}
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === 'user' ? "flex-row-reverse" : "flex-row"
)}
>
<div className={cn(
"flex-shrink-0 h-8 w-8 rounded-full flex items-center justify-center",
message.role === 'user'
? "bg-primary text-primary-foreground"
: "bg-secondary"
)}>
{message.role === 'user' ? (
<User className="h-4 w-4" />
) : (
<Sparkles className="h-4 w-4 text-primary" />
)}
</div>
<div className={cn(
"flex-1 max-w-[280px]",
message.role === 'user' ? "text-right" : "text-left"
)}>
<div className={cn(
"inline-block p-3 rounded-lg text-sm",
message.role === 'user'
? "bg-primary text-primary-foreground rounded-br-none"
: "bg-secondary rounded-bl-none"
)}>
{/* Parse and render Smart UI components from message content */}
{message.role === 'assistant' ? (
<div className="space-y-2">
{parseMessageForComponents(message.content).map((part, idx) => (
typeof part === 'string' ? (
<p key={idx} className="whitespace-pre-wrap">{part}</p>
) : (
<SmartComponentRenderer
key={part.id || idx}
message={part as UIComponentMessage}
className="my-2"
/>
)
))}
</div>
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-[10px] text-muted-foreground">
<span>
{message.timestamp.toLocaleTimeString('da-DK', {
hour: '2-digit',
minute: '2-digit'
})}
</span>
{message.toolUsed && (
<Badge variant="outline" className="text-[8px] py-0 h-4">
{message.toolUsed}
</Badge>
)}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="h-8 w-8 rounded-full bg-secondary flex items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
</div>
<div className="bg-secondary p-3 rounded-lg rounded-bl-none">
<div className="flex gap-1">
<span className="w-2 h-2 bg-primary/50 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-primary/50 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-primary/50 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
)}
</div>
</ScrollArea>
{/* Input */}
<div className="p-4 border-t border-border">
<div className="flex gap-2">
<Input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder="Skriv din besked..."
className="flex-1"
disabled={isLoading || !isConnected}
/>
<Button
onClick={sendMessage}
disabled={isLoading || !input.trim() || !isConnected}
size="icon"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
{!isConnected && (
<p className="text-[10px] text-destructive mt-2">
Ikke forbundet til backend. Tjek at serveren kører.
</p>
)}
</div>
</>
)}
</div>
);
}