diff --git a/apps/frontend/src/components/ai/AIChat.tsx b/apps/frontend/src/components/ai/AIChat.tsx new file mode 100644 index 00000000..d4f73a2e --- /dev/null +++ b/apps/frontend/src/components/ai/AIChat.tsx @@ -0,0 +1,232 @@ +import { useState, useRef, useEffect, FormEvent } from 'react'; +import { Send, Trash2, Settings2, Loader2 } from 'lucide-react'; +import clsx from 'clsx'; +import { ChatMessage, ChatMessageProps } from './ChatMessage'; +import { useAIChat, useAIConfig, useAIModels } from '@/hooks/useAI'; +import { ChatMessage as ChatMessageType } from '@/services/api'; + +interface Message extends ChatMessageProps { + id: string; +} + +interface AIChatProps { + systemPrompt?: string; + placeholder?: string; + className?: string; + showModelSelector?: boolean; + onUsageUpdate?: () => void; +} + +export function AIChat({ + systemPrompt, + placeholder = 'Type your message...', + className, + showModelSelector = false, + onUsageUpdate, +}: AIChatProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [selectedModel, setSelectedModel] = useState(); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + const { data: config } = useAIConfig(); + const { data: models } = useAIModels(); + const chatMutation = useAIChat(); + + // Set default model from config + useEffect(() => { + if (config?.default_model && !selectedModel) { + setSelectedModel(config.default_model); + } + }, [config, selectedModel]); + + // Auto scroll to bottom + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Auto resize textarea + const handleInputChange = (e: React.ChangeEvent) => { + setInput(e.target.value); + e.target.style.height = 'auto'; + e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`; + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const trimmedInput = input.trim(); + if (!trimmedInput || chatMutation.isPending) return; + + const userMessage: Message = { + id: `user-${Date.now()}`, + role: 'user', + content: trimmedInput, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(''); + + // Reset textarea height + if (inputRef.current) { + inputRef.current.style.height = 'auto'; + } + + // Build message history for API + const apiMessages: ChatMessageType[] = []; + + // Add system prompt if provided + if (systemPrompt) { + apiMessages.push({ role: 'system', content: systemPrompt }); + } else if (config?.system_prompt) { + apiMessages.push({ role: 'system', content: config.system_prompt }); + } + + // Add conversation history + messages.forEach((msg) => { + if (msg.role !== 'system') { + apiMessages.push({ role: msg.role, content: msg.content }); + } + }); + + // Add current user message + apiMessages.push({ role: 'user', content: trimmedInput }); + + // Add loading message + const loadingId = `assistant-${Date.now()}`; + setMessages((prev) => [ + ...prev, + { id: loadingId, role: 'assistant', content: '', isLoading: true }, + ]); + + try { + const response = await chatMutation.mutateAsync({ + messages: apiMessages, + model: selectedModel, + temperature: config?.temperature, + max_tokens: config?.max_tokens, + }); + + // Replace loading message with actual response + setMessages((prev) => + prev.map((msg) => + msg.id === loadingId + ? { + ...msg, + content: response.choices[0]?.message?.content || 'No response', + isLoading: false, + timestamp: new Date(), + } + : msg + ) + ); + + onUsageUpdate?.(); + } catch { + // Remove loading message on error + setMessages((prev) => prev.filter((msg) => msg.id !== loadingId)); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + const clearChat = () => { + setMessages([]); + }; + + return ( +
+ {/* Header */} +
+
+ + + AI Assistant + +
+ +
+ {showModelSelector && models && models.length > 0 && ( + + )} + + +
+
+ + {/* Messages Area */} +
+ {messages.length === 0 ? ( +
+ +

Start a conversation

+

Type a message below to begin

+
+ ) : ( +
+ {messages.map((message) => ( + + ))} +
+
+ )} +
+ + {/* Input Area */} +
+
+