1145 lines
42 KiB
TypeScript
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>
|
|
);
|
|
}
|