Spaces:
Paused
Paused
| /** | |
| * 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> | |
| ); | |
| } | |