import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Menu, Settings, Plus, Trash2, Send, Paperclip, X, RefreshCw, Bot, ChevronDown, Database, Wrench, Loader2, Search, } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; import { useAppStore, type ChatParams, DEFAULT_CHAT_PARAMS } from '@/stores/app'; import { useI18n } from '@/i18n'; import { chatApi, type ChatSession, type ChatSendBody, } from '@/api/endpoints/chat'; /* ── Local types ── */ interface UIMessage { role: 'user' | 'assistant'; content: string; timestamp: string; failed?: boolean; thinking?: boolean; } interface AttachedFile { file: File; name: string; type: string; preview?: string; } const ACCEPT = 'image/*,.pdf,.txt,.csv,.json,.log,.xml,.yaml,.yml,.md,.py,.js,.ts,.html,.css,.sql,.tf,.hcl'; /* ── Helpers ── */ function nowHHmm(): string { return new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); } function timeAgo(dateStr: string): string { const tFn = useI18n.getState().t; const d = new Date(dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'); const diff = (Date.now() - d.getTime()) / 1000; if (diff < 60) return tFn('chat.timeNow'); if (diff < 3600) return `${Math.floor(diff / 60)}min`; if (diff < 86400) return `${Math.floor(diff / 3600)}h`; return `${Math.floor(diff / 86400)}d`; } function dateGroup(dateStr: string): string { const tFn = useI18n.getState().t; if (!dateStr) return tFn('chat.other'); const d = new Date(dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'); const diff = (Date.now() - d.getTime()) / 86400000; if (diff < 1) return tFn('chat.today'); if (diff < 2) return tFn('chat.yesterday'); if (diff < 7) return tFn('chat.last7'); if (diff < 30) return tFn('chat.last30'); return tFn('chat.older'); } function groupSessions(sessions: ChatSession[]): Record { const groups: Record = {}; for (const s of sessions) { const g = dateGroup(s.updated_at || s.created_at); (groups[g] ??= []).push(s); } return groups; } /* ── Component ── */ export default function ChatPage() { /* ── Store ── */ const { ociCfg, genaiCfg, models, mcpSvr, adbCfg, chatModel: model, setChatModel: setModel, chatOciConfig: ociConfig, setChatOciConfig: setOciConfig, chatParams, setChatParams, chatUseTools: useTools, setChatUseTools: setUseTools, chatMessages, setChatMessages, chatSessionId, setChatSessionId, chatSessions, setChatSessions, } = useAppStore(); const { t } = useI18n(); // Aliases for store state (keep local names for minimal code changes) const messages = chatMessages as UIMessage[]; const setMessages = setChatMessages as (v: UIMessage[] | ((prev: UIMessage[]) => UIMessage[])) => void; const sessionId = chatSessionId; const setSessionId = setChatSessionId; const sessions = chatSessions as ChatSession[]; const setSessions = setChatSessions as (v: ChatSession[] | ((prev: ChatSession[]) => ChatSession[])) => void; /* ── State ── */ const [files, setFiles] = useState([]); const [historyOpen, setHistoryOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [sending, setSending] = useState(false); const [modelDropOpen, setModelDropOpen] = useState(false); const [modelSearch, setModelSearch] = useState(''); const inputRef = useRef(null); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const modelDropRef = useRef(null); const pollRef = useRef | null>(null); /* ── Derived ── */ const isDirect = !!model && !model.startsWith('cfg:'); const modelInfo = useMemo(() => { let mid = ''; if (model.startsWith('cfg:')) { const g = genaiCfg.find((x) => x.id === model.slice(4)); if (g) mid = g.model_id; } else { mid = model; } return models[mid] || ({} as { provider?: string; reasoning?: boolean; max_tokens?: number; penalties?: boolean }); }, [model, models, genaiCfg]); const isReasoning = !!(modelInfo as any).reasoning; const maxTokenLimit = (modelInfo as any).max_tokens || 128000; const hasTopK = ['meta', 'google', ''].includes((modelInfo as any).provider || ''); const hasPenalties = !!(modelInfo as any).penalties; const toolCount = useMemo( () => mcpSvr .filter((m) => m.is_active && Array.isArray(m.tools) && m.tools.length) .reduce((a, m) => a + (m.tools?.length || 0), 0), [mcpSvr], ); // Auto-select default model: prefer saved GenAI config, fallback to first direct model useEffect(() => { if (model) return; if (genaiCfg.length > 0) { setModel(`cfg:${genaiCfg[0].id}`); return; } const ids = Object.keys(models); const preferred = ids.find((m) => m.includes('gpt-4.1') && !m.includes('mini')); if (preferred) { setModel(preferred); return; } if (ids.length > 0) setModel(ids[0]); }, [genaiCfg, models, model, setModel]); const hasRag = useMemo( () => adbCfg.some((c) => c.is_active) && (genaiCfg.length > 0 || ociCfg.length > 0), [adbCfg, genaiCfg, ociCfg], ); const modelLabel = useMemo(() => { if (!model) return t('chat.selectModel'); if (model.startsWith('cfg:')) { const g = genaiCfg.find((x) => x.id === model.slice(4)); if (!g) return 'Config...'; const mi = models[g.model_id]; return `${g.name || mi?.name || g.model_id} (${g.genai_region})`; } const mi = models[model]; return mi?.name || model; }, [model, genaiCfg, models, t]); /* Model dropdown items grouped by provider */ const modelGroups = useMemo(() => { const groups: { label: string; items: { value: string; name: string }[] }[] = []; const q = modelSearch.toLowerCase(); // GenAI configs if (genaiCfg.length) { const items = genaiCfg .map((g) => { const mi = models[g.model_id]; const name = `${g.name || mi?.name || g.model_id} (${g.genai_region})`; return { value: `cfg:${g.id}`, name }; }) .filter((i) => !q || i.name.toLowerCase().includes(q)); if (items.length) groups.push({ label: t('chat.savedConfigs'), items }); } // Direct models grouped by provider const provs: Record = {}; for (const [mid, info] of Object.entries(models)) { const p = info.provider || 'other'; (provs[p] ??= []).push({ value: mid, name: info.name }); } const order = ['openai', 'google', 'meta', 'xai', 'cohere']; const seen = new Set(); for (const p of [...order, ...Object.keys(provs)]) { if (seen.has(p) || !provs[p]) continue; seen.add(p); const items = provs[p].filter((i) => !q || i.name.toLowerCase().includes(q)); if (items.length) { groups.push({ label: p.charAt(0).toUpperCase() + p.slice(1), items }); } } return groups; }, [models, genaiCfg, modelSearch, t]); const sessionGroups = useMemo(() => groupSessions(sessions), [sessions]); /* ── Effects ── */ // Load sessions on mount useEffect(() => { chatApi.listSessions('chat', 50).then(setSessions).catch(() => {}); }, []); // Close model dropdown on outside click useEffect(() => { function handleClick(e: MouseEvent) { if (modelDropRef.current && !modelDropRef.current.contains(e.target as Node)) { setModelDropOpen(false); } } document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, []); // Keyboard shortcuts useEffect(() => { function handleKey(e: KeyboardEvent) { if (e.key === 'Escape') { setHistoryOpen(false); setSettingsOpen(false); setModelDropOpen(false); } } document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, []); // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Cleanup poll on unmount useEffect(() => { return () => { if (pollRef.current) clearTimeout(pollRef.current); }; }, []); /* ── Actions ── */ const refreshSessions = useCallback(() => { chatApi.listSessions('chat', 50).then(setSessions).catch(() => {}); }, []); const loadSession = useCallback(async (sid: string) => { try { const data = await chatApi.loadSession(sid); setSessionId(sid); setMessages( data.messages.map((m) => ({ role: m.role, content: m.content, timestamp: m.created_at?.slice(11, 16) || '', })), ); } catch { /* ignore */ } }, []); const deleteSession = useCallback( async (sid: string) => { try { await chatApi.deleteSession(sid); } catch { /* ignore */ } const currentSid = useAppStore.getState().chatSessionId; if (currentSid === sid) { setSessionId(null); setMessages([]); } setSessions((prev) => prev.filter((s) => s.id !== sid)); }, [], ); const newChat = useCallback(() => { setSessionId(null); setMessages([]); setFiles([]); if (inputRef.current) inputRef.current.value = ''; }, []); const pickModel = useCallback( (value: string) => { setModel(value); setModelDropOpen(false); // Auto-select first OCI config for direct models if (value && !value.startsWith('cfg:') && !ociConfig && ociCfg.length) { setOciConfig(ociCfg[0].id); } }, [ociConfig, ociCfg], ); const handleFiles = useCallback( (fileList: FileList | null) => { if (!fileList) return; const newFiles: AttachedFile[] = []; for (const f of Array.from(fileList)) { if (files.length + newFiles.length >= 5) break; const entry: AttachedFile = { file: f, name: f.name, type: f.type.startsWith('image/') ? 'image' : 'file' }; if (entry.type === 'image') { entry.preview = URL.createObjectURL(f); } newFiles.push(entry); } setFiles((prev) => [...prev, ...newFiles].slice(0, 5)); if (fileInputRef.current) fileInputRef.current.value = ''; }, [files.length], ); const removeFile = useCallback((idx: number) => { setFiles((prev) => { const f = prev[idx]; if (f.preview) URL.revokeObjectURL(f.preview); return prev.filter((_, i) => i !== idx); }); }, []); /* ── Send ── */ const sendMessage = useCallback(async () => { const text = inputRef.current?.value.trim() || ''; const hasFiles = files.length > 0; if (!text && !hasFiles) return; if (!model) return; // Validate OCI config for direct models if (isDirect && !ociConfig) { setMessages((prev) => [ ...prev, { role: 'assistant', content: t('chat.selectOciCred'), timestamp: nowHHmm(), failed: true }, ]); return; } setSending(true); if (inputRef.current) inputRef.current.value = ''; // User message display let userDisplay = text; if (hasFiles) { const fileList = files.map((f) => f.name).join(', '); userDisplay += (userDisplay ? '\n' : '') + '\u{1F4CE} ' + fileList; } setMessages((prev) => [ ...prev, { role: 'user', content: userDisplay, timestamp: nowHHmm() }, { role: 'assistant', content: '', timestamp: '', thinking: true }, ]); try { let data: { message_id: string; session_id: string; status: string }; if (hasFiles) { const fd = new FormData(); fd.append('message', text || t('chat.analyzeFiles')); if (sessionId) fd.append('session_id', sessionId); fd.append('use_tools', String(useTools)); if (model.startsWith('cfg:')) { fd.append('genai_config_id', model.slice(4)); } else { fd.append('model_id', model); fd.append('oci_config_id', ociConfig); const oc = ociCfg.find((c) => c.id === ociConfig); if (oc) { fd.append('genai_region', oc.region); fd.append('compartment_id', oc.compartment_id || ''); } } fd.append('temperature', String(chatParams.temperature)); fd.append('max_tokens', String(chatParams.max_tokens)); fd.append('top_p', String(chatParams.top_p)); fd.append('top_k', String(chatParams.top_k)); fd.append('frequency_penalty', String(chatParams.frequency_penalty)); fd.append('presence_penalty', String(chatParams.presence_penalty)); if (isReasoning && chatParams.reasoning_effort) { fd.append('reasoning_effort', chatParams.reasoning_effort); } files.forEach((f) => fd.append('files', f.file)); data = await chatApi.upload(fd); setFiles([]); } else { const body: ChatSendBody = { message: text, session_id: sessionId || undefined, use_tools: useTools, }; if (model.startsWith('cfg:')) { body.genai_config_id = model.slice(4); } else { body.model_id = model; body.oci_config_id = ociConfig; const oc = ociCfg.find((c) => c.id === ociConfig); if (oc) { body.genai_region = oc.region; body.compartment_id = oc.compartment_id || ''; } } body.temperature = chatParams.temperature; body.max_tokens = chatParams.max_tokens; body.top_p = chatParams.top_p; body.top_k = chatParams.top_k; body.frequency_penalty = chatParams.frequency_penalty; body.presence_penalty = chatParams.presence_penalty; if (isReasoning && chatParams.reasoning_effort) { body.reasoning_effort = chatParams.reasoning_effort; } data = await chatApi.send(body); } setSessionId(data.session_id); if (data.status === 'processing' && data.message_id) { await pollResult(data.message_id); } else { setMessages((prev) => prev.filter((m) => !m.thinking)); } refreshSessions(); } catch (err: any) { setMessages((prev) => [ ...prev.filter((m) => !m.thinking), { role: 'assistant', content: `${t('common.errorPrefix')}${err.message || t('chat.sendError')}`, timestamp: nowHHmm(), failed: true }, ]); } finally { setSending(false); inputRef.current?.focus(); } }, [model, sessionId, ociConfig, files, chatParams, useTools, isDirect, isReasoning, ociCfg, refreshSessions, t]); /* ── Poll ── */ const pollResult = useCallback( async (mid: string) => { for (let i = 0; i < 1200; i++) { await new Promise((resolve) => { pollRef.current = setTimeout(resolve, i < 10 ? 1000 : 3000); }); try { const r = await chatApi.pollStatus(mid); if (r.status === 'done') { setMessages((prev) => [ ...prev.filter((m) => !m.thinking), { role: 'assistant', content: r.content, timestamp: nowHHmm() }, ]); return; } if (r.status === 'failed') { setMessages((prev) => [ ...prev.filter((m) => !m.thinking), { role: 'assistant', content: r.content || t('chat.requestFailed'), timestamp: nowHHmm(), failed: true }, ]); return; } } catch { /* continue polling */ } } // Timeout setMessages((prev) => [ ...prev.filter((m) => !m.thinking), { role: 'assistant', content: t('chat.timeout'), timestamp: nowHHmm(), failed: true }, ]); }, [t], ); /* ── Retry ── */ const retryMessage = useCallback( (idx: number) => { // Find last user message before this failed one let userMsg = ''; for (let i = idx - 1; i >= 0; i--) { if (messages[i].role === 'user') { userMsg = messages[i].content; break; } } if (!userMsg) return; // Remove failed message setMessages((prev) => prev.filter((_, i) => i !== idx)); // Put text back in input and send if (inputRef.current) inputRef.current.value = userMsg; // Auto-send after state update setTimeout(() => sendMessage(), 50); }, [messages, sendMessage], ); /* ── Auto-grow textarea ── */ const autoGrow = useCallback((el: HTMLTextAreaElement) => { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 200) + 'px'; }, []); /* ── Render ── */ return (
{/* ── Left Panel: History ── */}
{t('chat.history')}
{Object.entries(sessionGroups).map(([group, items]) => (
{group}
{items.map((s) => (
loadSession(s.id)} className="group flex items-center justify-between px-2 py-1.5 rounded cursor-pointer" style={{ background: sessionId === s.id ? 'var(--bg3)' : 'transparent', color: sessionId === s.id ? 'var(--t1)' : 'var(--t3)', }} onMouseEnter={(e) => (e.currentTarget.style.background = sessionId === s.id ? 'var(--bg3)' : 'var(--bg2)')} onMouseLeave={(e) => (e.currentTarget.style.background = sessionId === s.id ? 'var(--bg3)' : 'transparent')} >
{s.title || t('chat.untitled')}
{timeAgo(s.updated_at || s.created_at)}
))}
))} {sessions.length === 0 && (
{t('chat.noConversations')}
)}
{/* ── Center Panel: Chat ── */}
{/* Toolbar */}
{/* History toggle */} {/* Model dropdown */}
{modelDropOpen && (
setModelSearch(e.target.value)} className="flex-1 bg-transparent text-xs outline-none" style={{ color: 'var(--t2)', border: 'none' }} autoFocus />
{modelGroups.map((group) => (
{group.label}
{group.items.map((item) => (
pickModel(item.value)} className="px-3 py-1.5 text-xs cursor-pointer" style={{ color: model === item.value ? 'var(--ac)' : 'var(--t2)', background: model === item.value ? 'var(--bg2)' : 'transparent', }} onMouseEnter={(e) => { if (model !== item.value) e.currentTarget.style.background = 'var(--bg2)'; }} onMouseLeave={(e) => { if (model !== item.value) e.currentTarget.style.background = 'transparent'; }} > {item.name}
))}
))} {modelGroups.length === 0 && (
{t('chat.noModels')}
)}
)}
{/* OCI config selector for direct models */} {isDirect && ( )} {/* RAG badge */} {hasRag && ( RAG )}
{/* Settings toggle */} {/* New chat */}
{/* Messages area */}
{messages.length === 0 ? (

{t('chat.emptyTitle')}

{t('chat.emptySubtitle')}

) : (
{messages.map((msg, idx) => { if (msg.thinking) { return (
{t('chat.thinking')}
); } const isUser = msg.role === 'user'; return (
{isUser ? (
{msg.content}
) : (
{msg.content}
)}
{msg.timestamp && ( {msg.timestamp} )} {msg.failed && ( )}
); })}
)}
{/* File preview chips */} {files.length > 0 && (
{files.map((f, i) => f.type === 'image' && f.preview ? (
{f.name}
) : (
{f.name}
), )}
)} {/* Input area */}
handleFiles(e.target.files)} />