// ─── Knowledge Base Page ───────────────────────────────────────────────────── const JOB_STATUS_C = { completed: 'green', processing: 'blue', failed: 'red', pending: 'gray', queued: 'amber' }; const SRC_STATUS_C = { active: 'green', pending: 'amber', error: 'red', inactive: 'gray', processing: 'blue' }; function KBPage() { const [kbTab, setKbTab] = React.useState('Overview'); const [sources, setSources] = React.useState([]); const [jobs, setJobs] = React.useState([]); const [status, setStatus] = React.useState(null); const [loading, setLoading] = React.useState(true); const [searchQ, setSearchQ] = React.useState(''); const [searchResult, setSearchResult] = React.useState(null); const [newUrl, setNewUrl] = React.useState(''); const [newUrlTitle, setNewUrlTitle] = React.useState(''); const { toasts, addToast } = C.useToast(); const [cfg, setCfg] = React.useState({ leadrat_enabled: false, leadrat_tenant: '', leadrat_api_key: '', leadrat_secret_key: '', leadrat_base_url: 'https://connect.leadrat.com', leadrat_sync_interval_minutes: 5, kb_backend: 'local_faiss', kb_data_dir: 'data/kb', kb_embedding_provider: 'local', kb_embedding_model: 'BAAI/bge-small-en-v1.5', kb_embedding_fallback_provider: 'gemini', kb_embedding_fallback_model: 'gemini-embedding-001', kb_index_kind: 'flat_ip', kb_rerank_enabled: false, kb_top_k: 4, kb_inventory_top_k: 3, kb_similarity_threshold: 0.18, kb_context_char_budget: 2800, kb_live_timeout_ms: 150, kb_live_context_char_budget: 900, kb_cache_ttl_seconds: 45, kb_chunk_size: 400, kb_chunk_overlap: 60, kb_worker_poll_seconds: 20, }); const set = k => v => setCfg(p => ({ ...p, [k]: v })); const reload = () => Promise.all([ fetch('/api/kb/sources').then(r => r.json()).catch(() => ({ items: [] })), fetch('/api/kb/jobs').then(r => r.json()).catch(() => ({ items: [] })), fetch('/api/kb/status').then(r => r.json()).catch(() => null), ]).then(([s, j, st]) => { setSources(Array.isArray(s) ? s : (Array.isArray(s?.items) ? s.items : [])); setJobs(Array.isArray(j) ? j : (Array.isArray(j?.items) ? j.items : [])); setStatus(st); setLoading(false); }); React.useEffect(() => { reload(); fetch('/api/config').then(r => r.json()).then(data => { setCfg(p => ({ ...p, leadrat_enabled: data.leadrat_enabled ?? p.leadrat_enabled, leadrat_tenant: data.leadrat_tenant || p.leadrat_tenant, leadrat_api_key: data.leadrat_api_key || p.leadrat_api_key, leadrat_secret_key: data.leadrat_secret_key || p.leadrat_secret_key, leadrat_base_url: data.leadrat_base_url || p.leadrat_base_url, leadrat_sync_interval_minutes: data.leadrat_sync_interval_minutes || p.leadrat_sync_interval_minutes, kb_backend: data.kb_backend || p.kb_backend, kb_data_dir: data.kb_data_dir || p.kb_data_dir, kb_embedding_provider: data.kb_embedding_provider || p.kb_embedding_provider, kb_embedding_model: data.kb_embedding_model || p.kb_embedding_model, kb_embedding_fallback_provider: data.kb_embedding_fallback_provider || p.kb_embedding_fallback_provider, kb_embedding_fallback_model: data.kb_embedding_fallback_model || p.kb_embedding_fallback_model, kb_index_kind: data.kb_index_kind || p.kb_index_kind, kb_rerank_enabled: data.kb_rerank_enabled ?? p.kb_rerank_enabled, kb_top_k: data.kb_top_k || p.kb_top_k, kb_inventory_top_k: data.kb_inventory_top_k || p.kb_inventory_top_k, kb_similarity_threshold: data.kb_similarity_threshold || p.kb_similarity_threshold, kb_context_char_budget: data.kb_context_char_budget || p.kb_context_char_budget, kb_live_timeout_ms: data.kb_live_timeout_ms || p.kb_live_timeout_ms, kb_live_context_char_budget: data.kb_live_context_char_budget || p.kb_live_context_char_budget, kb_cache_ttl_seconds: data.kb_cache_ttl_seconds || p.kb_cache_ttl_seconds, kb_chunk_size: data.kb_chunk_size || p.kb_chunk_size, kb_chunk_overlap: data.kb_chunk_overlap || p.kb_chunk_overlap, kb_worker_poll_seconds: data.kb_worker_poll_seconds || p.kb_worker_poll_seconds, })); }).catch(() => {}); }, []); const handleSearch = async () => { if (!searchQ.trim()) return; try { const res = await fetch('/api/kb/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: searchQ, top_k: 5 }), }); const data = await res.json(); // The API returns {status, result, grounding} — result can be an object or array let items = []; if (Array.isArray(data)) items = data; else if (Array.isArray(data.results)) items = data.results; else if (Array.isArray(data.result)) items = data.result; else if (data.result && typeof data.result === 'object') { items = [].concat(data.result.chunk_hits || data.result.chunks || [], data.result.inventory_hits || data.result.inventory || [], data.result.items || []); } setSearchResult({ query: searchQ, results: items, grounding: data.grounding || '' }); } catch { setSearchResult({ query: searchQ, results: [], grounding: '' }); } }; const handleAddUrl = async () => { if (!newUrl) return; try { const res = await fetch('/api/kb/sources', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source_type: 'web_url', source_url: newUrl, title: newUrlTitle || newUrl }), }); const data = await res.json(); if (data.source) setSources(p => [...p, data.source]); else reload(); setNewUrl(''); setNewUrlTitle(''); addToast('URL source added successfully', 'success'); } catch (e) { addToast('Failed to add URL source: ' + (e.message || 'Unknown error'), 'error'); } }; const handleDeleteSource = async id => { try { await fetch(`/api/kb/sources/${id}`, { method: 'DELETE' }); setSources(p => p.filter(s => s.id !== id)); addToast('Source deleted', 'success'); } catch (e) { addToast('Failed to delete source', 'error'); } }; const handleSyncSource = async id => { addToast('Sync started — processing in background…', 'info'); try { const res = await fetch(`/api/kb/sources/${id}/sync`, { method: 'POST' }); const data = await res.json(); if (res.ok) { addToast('Sync job queued successfully', 'success'); } else { addToast(data.message || 'Sync failed', 'error'); } reload(); } catch (e) { addToast('Failed to sync source: ' + (e.message || 'Unknown error'), 'error'); } }; const totalChunks = status?.counts?.chunks ?? sources.reduce((a, s) => a + (s.chunks || 0), 0); const crmRecords = status?.counts?.entities ?? jobs.filter(j => j.status === 'completed').reduce((a, j) => Math.max(a, (j.last_result?.entity_count || 0)), 0); const activeJobs = jobs.filter(j => j.status === 'processing').length; const lrStatus = status?.leadrat || {}; // Build a lookup map for source titles by source_id const sourceMap = {}; sources.forEach(s => { sourceMap[s.id] = s.title; }); return (