Files
A-Team-Security-Infra-Agent…/frontend/src/pages/ChatPage.tsx

1145 lines
42 KiB
TypeScript

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<string, ChatSession[]> {
const groups: Record<string, ChatSession[]> = {};
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<AttachedFile[]>([]);
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<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const modelDropRef = useRef<HTMLDivElement>(null);
const pollRef = useRef<ReturnType<typeof setTimeout> | 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<string, { value: string; name: string }[]> = {};
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<string>();
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<void>((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 (
<div className="flex" style={{ height: 'calc(100vh - 49px)', background: 'var(--bg)' }}>
{/* ── Left Panel: History ── */}
<div
className="flex flex-col flex-shrink-0 transition-all duration-200 overflow-hidden"
style={{
width: historyOpen ? 260 : 0,
borderRight: historyOpen ? '1px solid var(--bd)' : 'none',
background: 'var(--bg1)',
}}
>
<div
className="flex items-center justify-between px-3 py-2 flex-shrink-0"
style={{ borderBottom: '1px solid var(--bd)' }}
>
<span className="text-xs font-semibold" style={{ color: 'var(--t2)' }}>
{t('chat.history')}
</span>
<button
onClick={newChat}
className="flex items-center gap-1 px-2 py-1 rounded text-xs cursor-pointer"
style={{ background: 'var(--ac)', color: '#fff' }}
>
<Plus size={14} /> {t('chat.newChat')}
</button>
</div>
<div className="flex-1 overflow-y-auto px-1 py-2" style={{ fontSize: '0.75rem' }}>
{Object.entries(sessionGroups).map(([group, items]) => (
<div key={group} className="mb-2">
<div
className="px-2 py-1 text-xs font-semibold uppercase"
style={{ color: 'var(--t4)', fontSize: '0.62rem', letterSpacing: '0.05em' }}
>
{group}
</div>
{items.map((s) => (
<div
key={s.id}
onClick={() => 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')}
>
<div className="flex-1 min-w-0">
<div className="truncate text-xs">{s.title || t('chat.untitled')}</div>
<div className="text-xs" style={{ color: 'var(--t4)', fontSize: '0.6rem' }}>
{timeAgo(s.updated_at || s.created_at)}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
deleteSession(s.id);
}}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded cursor-pointer"
style={{ color: 'var(--rd)' }}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
))}
{sessions.length === 0 && (
<div className="px-3 py-4 text-center text-xs" style={{ color: 'var(--t4)' }}>
{t('chat.noConversations')}
</div>
)}
</div>
</div>
{/* ── Center Panel: Chat ── */}
<div className="flex flex-col flex-1 min-w-0">
{/* Toolbar */}
<div
className="flex items-center gap-2 px-3 py-2 flex-shrink-0 flex-wrap"
style={{ borderBottom: '1px solid var(--bd)', background: 'var(--bg1)' }}
>
{/* History toggle */}
<button
onClick={() => {
setHistoryOpen((v) => !v);
if (!historyOpen) refreshSessions();
}}
className="p-1.5 rounded cursor-pointer"
style={{ background: historyOpen ? 'var(--bg3)' : 'transparent', color: 'var(--t2)' }}
title={t('chat.history')}
aria-label={t('chat.history')}
>
<Menu size={18} />
</button>
{/* Model dropdown */}
<div className="relative" ref={modelDropRef}>
<button
onClick={() => setModelDropOpen((v) => !v)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs cursor-pointer"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t2)', maxWidth: 280 }}
>
<span className="truncate">{modelLabel}</span>
<ChevronDown size={14} className="flex-shrink-0" />
</button>
{modelDropOpen && (
<div
className="absolute left-0 top-full mt-1 rounded-lg overflow-hidden z-50"
style={{
width: 300,
background: 'var(--bg1)',
border: '1px solid var(--bd)',
boxShadow: 'var(--sh1)',
maxHeight: 360,
}}
>
<div className="p-2" style={{ borderBottom: '1px solid var(--bd)' }}>
<div className="flex items-center gap-2 px-2 py-1 rounded" style={{ background: 'var(--bg2)' }}>
<Search size={14} style={{ color: 'var(--t4)' }} />
<input
type="text"
placeholder={t('chat.searchModel')}
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
className="flex-1 bg-transparent text-xs outline-none"
style={{ color: 'var(--t2)', border: 'none' }}
autoFocus
/>
</div>
</div>
<div className="overflow-y-auto" style={{ maxHeight: 300 }}>
{modelGroups.map((group) => (
<div key={group.label}>
<div
className="px-3 py-1 text-xs font-semibold uppercase"
style={{ color: 'var(--t4)', fontSize: '0.6rem', background: 'var(--bg)', letterSpacing: '0.05em' }}
>
{group.label}
</div>
{group.items.map((item) => (
<div
key={item.value}
onClick={() => 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}
</div>
))}
</div>
))}
{modelGroups.length === 0 && (
<div className="px-3 py-3 text-xs text-center" style={{ color: 'var(--t4)' }}>
{t('chat.noModels')}
</div>
)}
</div>
</div>
)}
</div>
{/* OCI config selector for direct models */}
{isDirect && (
<select
value={ociConfig}
onChange={(e) => setOciConfig(e.target.value)}
className="px-2 py-1.5 rounded text-xs"
style={{
background: 'var(--bg2)',
border: '1px solid var(--bd)',
color: 'var(--t2)',
maxWidth: 200,
}}
>
<option value="">{t('chat.ociCred')}</option>
{ociCfg.map((c) => (
<option key={c.id} value={c.id}>
{c.tenancy_name} ({c.region})
</option>
))}
</select>
)}
{/* RAG badge */}
{hasRag && (
<span
className="px-1.5 py-0.5 rounded text-xs font-semibold"
style={{ background: 'rgba(16,185,129,0.15)', color: 'var(--gn)', fontSize: '0.62rem' }}
>
<Database size={10} className="inline mr-0.5" style={{ verticalAlign: '-1px' }} />
RAG
</span>
)}
<div className="flex-1" />
{/* Settings toggle */}
<button
onClick={() => setSettingsOpen((v) => !v)}
className="p-1.5 rounded cursor-pointer"
style={{ background: settingsOpen ? 'var(--bg3)' : 'transparent', color: 'var(--t2)' }}
title={t('chat.settings')}
aria-label={t('chat.settings')}
>
<Settings size={18} />
</button>
{/* New chat */}
<button
onClick={newChat}
className="p-1.5 rounded cursor-pointer"
style={{ color: 'var(--t2)' }}
title={t('chat.newChatTitle')}
aria-label={t('chat.newChatTitle')}
>
<Plus size={18} />
</button>
</div>
{/* Messages area */}
<div className="flex-1 overflow-y-auto px-4 py-4">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Bot size={52} style={{ color: 'var(--t4)', opacity: 0.5 }} />
<p className="text-sm" style={{ color: 'var(--t4)' }}>
{t('chat.emptyTitle')}
</p>
<p className="text-xs" style={{ color: 'var(--t4)', opacity: 0.7 }}>
{t('chat.emptySubtitle')}
</p>
</div>
) : (
<div className="flex flex-col gap-3 max-w-3xl mx-auto">
{messages.map((msg, idx) => {
if (msg.thinking) {
return (
<div key={idx} className="flex items-center gap-2 self-start px-4 py-3 rounded-xl" style={{ background: 'var(--bg1)' }}>
<Loader2 size={16} className="animate-spin" style={{ color: 'var(--ac)' }} />
<span className="text-sm italic" style={{ color: 'var(--t3)' }}>
{t('chat.thinking')}
</span>
</div>
);
}
const isUser = msg.role === 'user';
return (
<div
key={idx}
className={`flex flex-col ${isUser ? 'items-end' : 'items-start'}`}
>
<div
className="px-4 py-2.5 rounded-xl text-sm max-w-full"
style={{
background: isUser
? 'linear-gradient(135deg, var(--ac), var(--ach))'
: 'var(--bg1)',
color: isUser ? '#fff' : 'var(--t2)',
border: msg.failed ? '1px solid var(--rd)' : isUser ? 'none' : '1px solid var(--bd)',
maxWidth: '85%',
wordBreak: 'break-word',
}}
>
{isUser ? (
<div style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</div>
) : (
<div className="chat-markdown prose prose-sm max-w-none [&_pre]:overflow-x-auto [&_table]:overflow-x-auto" style={{ overflowWrap: 'break-word', wordBreak: 'break-word' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{msg.content}</ReactMarkdown>
</div>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 px-1">
{msg.timestamp && (
<span className="text-xs" style={{ color: 'var(--t4)', fontSize: '0.62rem' }}>
{msg.timestamp}
</span>
)}
{msg.failed && (
<button
onClick={() => retryMessage(idx)}
className="flex items-center gap-1 text-xs cursor-pointer px-1.5 py-0.5 rounded"
style={{ color: 'var(--rd)', fontSize: '0.65rem' }}
>
<RefreshCw size={12} /> {t('chat.retry')}
</button>
)}
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* File preview chips */}
{files.length > 0 && (
<div
className="flex items-center gap-2 flex-wrap px-4 py-2"
style={{ borderTop: '1px solid var(--bd)', background: 'var(--bg1)' }}
>
{files.map((f, i) =>
f.type === 'image' && f.preview ? (
<div key={i} className="relative" style={{ width: 48, height: 48 }}>
<img
src={f.preview}
alt={f.name}
className="rounded"
style={{ width: 48, height: 48, objectFit: 'cover', border: '1px solid var(--bd)' }}
/>
<button
onClick={() => removeFile(i)}
className="absolute flex items-center justify-center rounded-full cursor-pointer"
style={{
top: -4,
right: -4,
width: 16,
height: 16,
background: 'var(--rd)',
color: '#fff',
fontSize: '0.55rem',
}}
>
<X size={10} />
</button>
</div>
) : (
<div
key={i}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t3)' }}
>
<Paperclip size={12} />
<span className="truncate" style={{ maxWidth: 120 }}>
{f.name}
</span>
<button onClick={() => removeFile(i)} className="cursor-pointer" style={{ color: 'var(--rd)' }}>
<X size={12} />
</button>
</div>
),
)}
</div>
)}
{/* Input area */}
<div
className="flex items-end gap-2 px-4 py-3 flex-shrink-0"
style={{ borderTop: '1px solid var(--bd)', background: 'var(--bg1)' }}
>
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPT}
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 rounded cursor-pointer flex-shrink-0"
style={{ color: 'var(--t3)', background: 'var(--bg2)', border: '1px solid var(--bd)' }}
title={t('chat.attachFile')}
aria-label={t('chat.attachFile')}
>
<Paperclip size={18} />
</button>
<textarea
ref={inputRef}
placeholder={t('chat.placeholder')}
rows={1}
disabled={sending}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
onInput={(e) => autoGrow(e.currentTarget)}
className="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
style={{
background: 'var(--bg2)',
border: '1px solid var(--bd)',
color: 'var(--t2)',
resize: 'none',
overflow: 'hidden',
maxHeight: 200,
fontFamily: 'var(--fm)',
}}
/>
<button
onClick={sendMessage}
disabled={sending}
className="p-2 rounded cursor-pointer flex-shrink-0"
style={{
background: sending ? 'var(--bg3)' : 'var(--ac)',
color: '#fff',
opacity: sending ? 0.6 : 1,
}}
title={t('chat.send')}
aria-label={t('chat.send')}
>
{sending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
</div>
</div>
{/* ── Right Panel: Settings ── */}
<div
className="flex flex-col flex-shrink-0 transition-all duration-200 overflow-hidden"
style={{
width: settingsOpen ? 280 : 0,
borderLeft: settingsOpen ? '1px solid var(--bd)' : 'none',
background: 'var(--bg1)',
}}
>
<div
className="flex items-center gap-2 px-3 py-2 flex-shrink-0"
style={{ borderBottom: '1px solid var(--bd)' }}
>
<Settings size={16} style={{ color: 'var(--t3)' }} />
<span className="text-xs font-semibold" style={{ color: 'var(--t2)' }}>
{t('chat.settings')}
</span>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3">
<div className="flex flex-col gap-3">
{/* Reasoning models: only max_tokens + reasoning_effort */}
{isReasoning ? (
<>
<SettingField label={`Max Tokens (max ${maxTokenLimit.toLocaleString()})`}>
<input
type="number"
value={chatParams.max_tokens}
min={1}
max={maxTokenLimit}
onChange={(e) => setChatParams((p) => ({ ...p, max_tokens: +e.target.value }))}
className="w-full px-2 py-1.5 rounded text-xs"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t2)' }}
/>
</SettingField>
<SettingField label="Reasoning Effort">
<select
value={chatParams.reasoning_effort}
onChange={(e) => setChatParams((p) => ({ ...p, reasoning_effort: e.target.value }))}
className="w-full px-2 py-1.5 rounded text-xs"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t2)' }}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</SettingField>
</>
) : (
<>
<SettingField label="Temperature">
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={2}
step={0.1}
value={chatParams.temperature}
onChange={(e) => setChatParams((p) => ({ ...p, temperature: +e.target.value }))}
className="flex-1"
style={{ accentColor: 'var(--ac)' }}
/>
<span className="text-xs w-8 text-right" style={{ color: 'var(--t3)' }}>
{chatParams.temperature.toFixed(1)}
</span>
</div>
</SettingField>
<SettingField label={`Max Tokens (max ${maxTokenLimit.toLocaleString()})`}>
<input
type="number"
value={chatParams.max_tokens}
min={1}
max={maxTokenLimit}
onChange={(e) => setChatParams((p) => ({ ...p, max_tokens: +e.target.value }))}
className="w-full px-2 py-1.5 rounded text-xs"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t2)' }}
/>
</SettingField>
<SettingField label="Top P">
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.05}
value={chatParams.top_p}
onChange={(e) => setChatParams((p) => ({ ...p, top_p: +e.target.value }))}
className="flex-1"
style={{ accentColor: 'var(--ac)' }}
/>
<span className="text-xs w-8 text-right" style={{ color: 'var(--t3)' }}>
{chatParams.top_p.toFixed(2)}
</span>
</div>
</SettingField>
{hasTopK && (
<SettingField label="Top K">
<input
type="number"
value={chatParams.top_k}
min={-1}
max={500}
onChange={(e) => setChatParams((p) => ({ ...p, top_k: +e.target.value }))}
className="w-full px-2 py-1.5 rounded text-xs"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t2)' }}
/>
</SettingField>
)}
{hasPenalties && (
<>
<SettingField label="Frequency Penalty">
<div className="flex items-center gap-2">
<input
type="range"
min={-2}
max={2}
step={0.1}
value={chatParams.frequency_penalty}
onChange={(e) => setChatParams((p) => ({ ...p, frequency_penalty: +e.target.value }))}
className="flex-1"
style={{ accentColor: 'var(--ac)' }}
/>
<span className="text-xs w-8 text-right" style={{ color: 'var(--t3)' }}>
{chatParams.frequency_penalty.toFixed(1)}
</span>
</div>
</SettingField>
<SettingField label="Presence Penalty">
<div className="flex items-center gap-2">
<input
type="range"
min={-2}
max={2}
step={0.1}
value={chatParams.presence_penalty}
onChange={(e) => setChatParams((p) => ({ ...p, presence_penalty: +e.target.value }))}
className="flex-1"
style={{ accentColor: 'var(--ac)' }}
/>
<span className="text-xs w-8 text-right" style={{ color: 'var(--t3)' }}>
{chatParams.presence_penalty.toFixed(1)}
</span>
</div>
</SettingField>
</>
)}
</>
)}
{/* MCP Tools */}
<div className="pt-3 mt-1" style={{ borderTop: '1px solid var(--bd)' }}>
<label
className="flex items-center gap-2 cursor-pointer text-xs"
style={{ color: 'var(--t2)' }}
>
<input
type="checkbox"
checked={useTools}
onChange={(e) => setUseTools(e.target.checked)}
style={{ accentColor: 'var(--ac)' }}
/>
<Wrench size={14} />
MCP Tools
</label>
{toolCount > 0 && (
<span className="block mt-1 text-xs" style={{ color: 'var(--t4)', fontSize: '0.66rem' }}>
{toolCount} {t('chat.toolsAvailable')}
</span>
)}
</div>
{/* OCI config in settings for direct models */}
{isDirect && (
<div className="pt-3 mt-1" style={{ borderTop: '1px solid var(--bd)' }}>
<SettingField label={t('chat.ociCredLabel')}>
<select
value={ociConfig}
onChange={(e) => setOciConfig(e.target.value)}
className="w-full px-2 py-1.5 rounded text-xs"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t2)' }}
>
<option value="">{t('chat.select')}</option>
{ociCfg.map((c) => (
<option key={c.id} value={c.id}>
{c.tenancy_name} ({c.region})
</option>
))}
</select>
</SettingField>
</div>
)}
</div>
</div>
</div>
</div>
);
}
/* ── Tiny sub-component ── */
function SettingField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--t3)', fontSize: '0.7rem' }}>
{label}
</label>
{children}
</div>
);
}