/* ============================================================ FLOWSHIELD — Consola IA (chat multi-modelo: DeepSeek / GLM) Mismo estándar visual que la Consola de scripts: chrome de terminal, prompt zsh, streaming en vivo. Historial persistente. ============================================================ */ function ConsolaIA() { const [data, setData] = useStateL(null); // {providers, budget_eur, used_eur, period} const [provider, setProvider] = useStateL(''); const [convs, setConvs] = useStateL([]); const [convId, setConvId] = useStateL(null); const [msgs, setMsgs] = useStateL([]); // [{role, content}] const [input, setInput] = useStateL(''); const [streaming, setStreaming] = useStateL(false); const [live, setLive] = useStateL(''); // texto del turno en curso const [toolInfo, setToolInfo] = useStateL(''); // herramientas en uso (tool-calling) const [err, setErr] = useStateL(null); const abortRef = useRefL(null); const termRef = useRefL(null); const liveRef = useRefL(''); const runRef = useRefL(null); // run_id del turno en curso (para parar/reenganchar) const esRef = useRefL(null); // EventSource de reenganche const [proposals, setProposals] = useStateL([]); // escaneos propuestos por la IA (a confirmar) const [scanRun, setScanRun] = useStateL(null); // escaneo en curso/último {label,target,state,findings,error} const [chain, setChain] = useStateL(null); // cadena en curso {steps:[{label,target,state,findings,message}],state} const [autoMode, setAutoMode] = useStateL(false); // modo autónomo: la IA ejecuta recon/scan en alcance sin confirmar const [autoSteps, setAutoSteps] = useStateL([]); // pasos auto-ejecutados por la IA en el turno [{tool,target,tier,state,findings}] const scanEsRef = useRefL(null); // EventSource del escaneo const scanRunIdRef = useRefL(null); const chainEsRef = useRefL(null); const chainRunIdRef = useRefL(null); // Panel "Conectar modelo" (configurar la clave desde la UI). const [cfgOpen, setCfgOpen] = useStateL(false); const [cfgForm, setCfgForm] = useStateL({ api_key: '', connection_type: 'payg', model: '', base_url: '' }); const [cfgBusy, setCfgBusy] = useStateL(false); const [cfgMsg, setCfgMsg] = useStateL(null); // Evals de la Consola IA (owner/admin): batería offline + opcional live. const [evalBusy, setEvalBusy] = useStateL(false); const [evalRes, setEvalRes] = useStateL(null); // Contexto de proyecto: al elegir uno, el chat recibe sus datos reales. const [projects, setProjects] = useStateL([]); const [projId, setProjId] = useStateL(''); // RAG: índice local de hallazgos (la IA busca en tus datos). const [rag, setRag] = useStateL(null); const [ragBusy, setRagBusy] = useStateL(false); const [intel, setIntel] = useStateL(null); // {available, items:[{id,title,preview}]} const [intelOpen, setIntelOpen] = useStateL(false); const [intelText, setIntelText] = useStateL(''); const [intelBusy, setIntelBusy] = useStateL(false); const loadOverview = () => window.fsApi('/api/aiconsole', { method: 'GET' }) .then(d => { setData(d); setProvider(p => p || ((d.providers || []).find(x => x.enabled) || {}).id || ''); }).catch(() => setData({ providers: [], budget_eur: 0, used_eur: 0 })); const loadConvs = () => window.fsApi('/api/aiconsole/conversations', { method: 'GET' }) .then(d => setConvs(d.conversations || [])).catch(() => {}); const openCfg = () => { const cur = ((data && data.providers) || []).find(p => p.id === provider) || {}; setCfgForm({ api_key: '', connection_type: cur.connection_type || 'payg', model: cur.model || '', base_url: '' }); setCfgMsg(null); setCfgOpen(o => !o); }; const saveCfg = async (local) => { if (!provider) { setCfgMsg('Elige un proveedor.'); return; } setCfgBusy(true); setCfgMsg(null); try { await window.fsApi('/api/aiconsole/providers/' + provider + '/credentials', { body: { api_key: local ? '' : cfgForm.api_key, connection_type: local ? 'local' : cfgForm.connection_type, model: cfgForm.model, base_url: cfgForm.base_url } }); setCfgForm(f => ({ ...f, api_key: '' })); setCfgMsg('Conectado ✓'); await loadOverview(); } catch (e) { setCfgMsg(e.message || 'No se pudo guardar.'); } finally { setCfgBusy(false); } }; const delCfg = async () => { if (!provider || !confirm('¿Desconectar este modelo? Volverá a usar la clave del .env si existe.')) return; setCfgBusy(true); setCfgMsg(null); try { await window.fsApi('/api/aiconsole/providers/' + provider + '/credentials', { method: 'DELETE' }); setCfgForm({ api_key: '', connection_type: 'payg', model: '', base_url: '' }); setCfgMsg('Desconectado'); await loadOverview(); } catch (e) { setCfgMsg(e.message || 'No se pudo desconectar.'); } finally { setCfgBusy(false); } }; // Lanza los evals. live=true puntúa además contra el modelo seleccionado. const runEvals = async (live) => { setEvalBusy(true); setEvalRes(null); try { const d = await window.fsApi('/api/aiconsole/evals/run', { body: live ? { live: true, provider } : {} }); setEvalRes(d); } catch (e) { setEvalRes({ error: e.message || 'No se pudieron ejecutar los evals.' }); } finally { setEvalBusy(false); } }; useEffectL(() => { loadOverview(); loadConvs(); window.fsApi('/api/projects', { method: 'GET' }).then(d => setProjects(d.projects || [])).catch(() => {}); window.fsApi('/api/aiconsole/rag/status', { method: 'GET' }).then(setRag).catch(() => {}); loadIntel(); // Al entrar (o volver de otra sección) reabrimos la última conversación; si // tenía un turno en curso, openConv se reengancha a él en vivo. let saved = null; try { saved = sessionStorage.getItem('fs_aic_conv'); } catch (x) {} if (saved) openConv(saved); return () => { if (abortRef.current) abortRef.current.abort(); if (esRef.current) { esRef.current.close(); esRef.current = null; } if (scanEsRef.current) { scanEsRef.current.close(); scanEsRef.current = null; } if (chainEsRef.current) { chainEsRef.current.close(); chainEsRef.current = null; } }; }, []); const loadIntel = () => window.fsApi('/api/aiconsole/intel', { method: 'GET' }) .then(setIntel).catch(() => {}); const addIntel = async () => { const t = intelText.trim(); if (!t) return; setIntelBusy(true); setErr(null); // Primera línea como título (si parece un CVE/encabezado corto); resto = texto. const nl = t.indexOf('\n'); const title = (nl > 0 ? t.slice(0, nl) : t).slice(0, 120); const cveM = t.match(/CVE-\d{4}-\d{3,7}/i); try { await window.fsApi('/api/aiconsole/intel', { body: { title, text: t, cve: cveM ? cveM[0] : '' } }); setIntelText(''); await loadIntel(); } catch (e) { setErr(e.message || 'No se pudo añadir el aviso.'); } finally { setIntelBusy(false); } }; const delIntel = async (id) => { try { await window.fsApi('/api/aiconsole/intel/' + id, { method: 'DELETE' }); await loadIntel(); } catch (e) { setErr(e.message || 'No se pudo borrar.'); } }; const fetchIntel = async () => { setIntelBusy(true); setErr(null); try { const d = await window.fsApi('/api/aiconsole/intel/fetch', { body: {} }); if (d.error && !d.added) setErr(d.error); await loadIntel(); } catch (e) { setErr(e.message || 'No se pudo traer CVEs.'); } finally { setIntelBusy(false); } }; const reindex = async () => { setRagBusy(true); setErr(null); try { const d = await window.fsApi('/api/aiconsole/rag/reindex', { body: {} }); setRag({ available: true, indexed: d.indexed }); } catch (e) { setErr(e.message || 'No se pudo indexar.'); } finally { setRagBusy(false); } }; useEffectL(() => { if (termRef.current) termRef.current.scrollTop = termRef.current.scrollHeight; }, [msgs, live]); const openConv = async (id) => { try { const d = await window.fsApi('/api/aiconsole/conversations/' + id, { method: 'GET' }); setConvId(d.id); try { sessionStorage.setItem('fs_aic_conv', d.id); } catch (x) {} setMsgs((d.messages || []).map(m => ({ role: m.role, content: m.content }))); if (d.provider) setProvider(d.provider); setLive(''); liveRef.current = ''; setErr(null); // Si hay un turno generándose en segundo plano, nos reenganchamos a él. if (d.active_run) attach(d.active_run); } catch (e) { setErr(e.message || 'No se pudo abrir la conversación.'); } }; const newConv = () => { stop(); setConvId(null); setMsgs([]); setLive(''); liveRef.current = ''; setErr(null); try { sessionStorage.removeItem('fs_aic_conv'); } catch (x) {} }; // --- Eventos del turno (compartido entre envío por fetch y reenganche SSE) --- const applyEvent = (ev, d) => { if (ev === 'error') { setErr(d.message || 'error del proveedor'); return 'error'; } if (ev === 'tool') { const names = { search_findings: 'buscando hallazgos', count_findings: 'contando hallazgos', list_findings: 'listando hallazgos', list_scans: 'listando escaneos', get_project_summary: 'resumen del proyecto' }; setToolInfo('🔧 ' + (d.tools || []).map(t => names[t] || t).join(' · ')); return; } if (ev === 'proposal') { setProposals(d.proposals || []); return; } if (ev === 'auto_scan') { setAutoSteps(st => { if (d.state === 'running') return [...st, { tool: d.tool, target: d.target, label: d.label, tier: d.tier, state: 'running' }]; const copy = st.slice(); for (let i = copy.length - 1; i >= 0; i--) { if (copy[i].tool === d.tool && copy[i].target === d.target && copy[i].state === 'running') { copy[i] = { ...copy[i], state: d.state, findings: d.findings, message: d.message }; break; } } return copy; }); return; } if (d.conversation_id) { setConvId(d.conversation_id); try { sessionStorage.setItem('fs_aic_conv', d.conversation_id); } catch (x) {} } if (d.run_id) runRef.current = d.run_id; if (ev === 'done') return 'done'; if (d.delta != null) { liveRef.current += d.delta; setLive(liveRef.current); } }; // --- Escaneos: la IA propone, el usuario confirma, FlowShield ejecuta --------- const launchScan = async (p) => { setProposals(ps => ps.filter(x => x !== p)); setScanRun({ label: p.label, target: p.target, state: 'running' }); try { const r = await window.fsApi('/api/aiconsole/scan/launch', { body: { tool: p.tool, target: p.target, options: p.params || {}, project_id: projId || undefined } }); attachScan(r.run_id, p); } catch (e) { setScanRun({ label: p.label, target: p.target, state: 'error', error: e.message || 'No se pudo lanzar.' }); } }; const dismissProposal = (p) => setProposals(ps => ps.filter(x => x !== p)); const attachScan = (runId, p) => { if (scanEsRef.current) { scanEsRef.current.close(); scanEsRef.current = null; } scanRunIdRef.current = runId; const es = new EventSource('/api/aiconsole/runs/' + runId + '/stream?after=0'); scanEsRef.current = es; es.addEventListener('scan', (e) => { try { const d = JSON.parse(e.data); setScanRun({ ...d, state: 'running' }); } catch (x) {} }); es.addEventListener('done', (e) => { let d = {}; try { d = JSON.parse(e.data); } catch (x) {} es.close(); scanEsRef.current = null; scanRunIdRef.current = null; setScanRun({ label: d.label || p.label, target: d.target || p.target, state: 'done', findings: d.findings }); // La IA interpreta los resultados que acaba de generar (end-to-end). const n = (d.findings != null) ? d.findings : '?'; sendMessage('El escaneo ' + (d.label || p.label) + ' sobre ' + (d.target || p.target) + ' terminó con ' + n + ' hallazgo(s), ya en el workspace. Interprétalos priorizando por ' + 'explotabilidad y dime el siguiente movimiento.'); }); es.addEventListener('error', (e) => { if (e && e.data) { let d = {}; try { d = JSON.parse(e.data); } catch (x) {} es.close(); scanEsRef.current = null; scanRunIdRef.current = null; setScanRun({ label: p.label, target: p.target, state: 'error', error: d.message }); } }); }; const cancelScan = () => { const rid = scanRunIdRef.current; if (rid) window.fsApi('/api/aiconsole/scan/' + rid + '/cancel', { body: {} }).catch(() => {}); if (scanEsRef.current) { scanEsRef.current.close(); scanEsRef.current = null; } scanRunIdRef.current = null; setScanRun(s => s ? { ...s, state: 'cancelled' } : null); }; // --- Cadena: lanzar todo el plan propuesto en orden (una sola confirmación) --- const launchChain = async (ps) => { if (!ps || !ps.length) return; setProposals([]); const steps = ps.map(p => ({ label: p.label, target: p.target, tool: p.tool, state: 'pending' })); setChain({ steps, state: 'running' }); try { const r = await window.fsApi('/api/aiconsole/scan/chain', { body: { steps: ps.map(p => ({ tool: p.tool, target: p.target, options: p.params || {} })), project_id: projId || undefined } }); attachChain(r.run_id, r.steps || steps); } catch (e) { setChain({ steps, state: 'error', error: e.message || 'No se pudo lanzar la cadena.' }); } }; const attachChain = (runId, steps) => { if (chainEsRef.current) { chainEsRef.current.close(); chainEsRef.current = null; } chainRunIdRef.current = runId; setChain({ steps: steps.map(s => ({ ...s, state: s.state || 'pending' })), state: 'running' }); const es = new EventSource('/api/aiconsole/runs/' + runId + '/stream?after=0'); chainEsRef.current = es; es.addEventListener('step', (e) => { try { const d = JSON.parse(e.data); setChain(c => { if (!c) return c; const st = [...c.steps]; st[d.i] = { label: d.label, target: d.target, tool: d.tool, state: d.state, findings: d.findings, message: d.message }; return { ...c, steps: st }; }); } catch (x) {} }); es.addEventListener('done', (e) => { let d = {}; try { d = JSON.parse(e.data); } catch (x) {} es.close(); chainEsRef.current = null; chainRunIdRef.current = null; setChain(c => c ? { ...c, state: 'done', findings: d.findings } : null); sendMessage('La cadena de escaneos terminó: ' + (d.steps || steps.length) + ' paso(s) y ' + ((d.findings != null) ? d.findings : '?') + ' hallazgo(s) nuevos en el workspace. ' + 'Interprétalos priorizando por explotabilidad y propón la siguiente fase del pentest.'); }); es.addEventListener('cancelled', (e) => { es.close(); chainEsRef.current = null; chainRunIdRef.current = null; setChain(c => c ? { ...c, state: 'cancelled' } : null); }); es.addEventListener('error', (e) => { if (e && e.data) { let d = {}; try { d = JSON.parse(e.data); } catch (x) {} es.close(); chainEsRef.current = null; chainRunIdRef.current = null; setChain(c => c ? { ...c, state: 'error', error: d.message } : null); } }); }; const cancelChain = () => { const rid = chainRunIdRef.current; if (rid) window.fsApi('/api/aiconsole/scan/' + rid + '/cancel', { body: {} }).catch(() => {}); if (chainEsRef.current) { chainEsRef.current.close(); chainEsRef.current = null; } chainRunIdRef.current = null; setChain(c => c ? { ...c, state: 'cancelled' } : null); }; const commitTurn = () => { const finalText = liveRef.current; if (finalText) setMsgs(m => [...m, { role: 'assistant', content: finalText }]); setLive(''); setToolInfo(''); liveRef.current = ''; setStreaming(false); runRef.current = null; loadOverview(); loadConvs(); }; // Reengancha (o engancha) a un run por su id vía EventSource: la generación // vive en el servidor, así que al volver de otra sección se recupera en vivo. const attach = (runId) => { if (!runId) return; if (esRef.current) { esRef.current.close(); esRef.current = null; } runRef.current = runId; setStreaming(true); setErr(null); const es = new EventSource('/api/aiconsole/runs/' + runId + '/stream?after=0'); esRef.current = es; const feed = (ev) => (e) => { let d; try { d = JSON.parse(e.data); } catch (x) { return; } const r = applyEvent(ev, d); if (r === 'done') { es.close(); esRef.current = null; commitTurn(); } else if (r === 'error') { es.close(); esRef.current = null; setStreaming(false); } }; es.onmessage = feed('message'); es.addEventListener('tool', feed('tool')); es.addEventListener('auto_scan', feed('auto_scan')); es.addEventListener('done', feed('done')); es.addEventListener('error', (e) => { if (e && e.data) feed('error')(e); }); // Un 'error' SIN data es un corte de red: EventSource reintenta solo // (reenviando Last-Event-ID), así que no cerramos. }; const delConv = async (id, e) => { e.stopPropagation(); if (!confirm('¿Borrar esta conversación?')) return; try { await window.fsApi('/api/aiconsole/conversations/' + id, { method: 'DELETE' }); if (convId === id) newConv(); loadConvs(); } catch (x) { setErr(x.message || 'No se pudo borrar.'); } }; // Parar = cancelar en el SERVIDOR (no basta con abortar la conexión local, // porque la generación sigue viva en segundo plano) + cerrar los streams. const stop = () => { const rid = runRef.current; if (rid) window.fsApi('/api/aiconsole/runs/' + rid + '/cancel', { body: {} }).catch(() => {}); if (esRef.current) { esRef.current.close(); esRef.current = null; } if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } // Conserva lo generado hasta el momento como mensaje del asistente. if (liveRef.current) commitTurn(); else { runRef.current = null; setStreaming(false); } }; const send = () => { const text = input.trim(); if (!text) return; setInput(''); sendMessage(text); }; const sendMessage = async (text) => { if (!text || streaming) return; if (!provider) { setErr('Selecciona un modelo.'); return; } setErr(null); setProposals([]); setAutoSteps([]); setMsgs(m => [...m, { role: 'user', content: text }]); setStreaming(true); setLive(''); setToolInfo(''); liveRef.current = ''; const ctrl = new AbortController(); abortRef.current = ctrl; try { const res = await fetch('/api/aiconsole/chat', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, conversation_id: convId, message: text, project_id: projId || undefined, autonomous: autoMode || undefined }), signal: ctrl.signal, }); if (!res.ok) { let m = 'Error ' + res.status; try { m = (await res.json()).detail || m; } catch (x) {} setErr(m); setStreaming(false); abortRef.current = null; return; } const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ''; let doneSeen = false; const handle = (chunk) => { let ev = 'message', dataStr = ''; chunk.split('\n').forEach(l => { if (l.startsWith('event:')) ev = l.slice(6).trim(); else if (l.startsWith('data:')) dataStr += l.slice(5).trim(); }); if (!dataStr) return; let d; try { d = JSON.parse(dataStr); } catch (x) { return; } if (applyEvent(ev, d) === 'done') doneSeen = true; }; while (true) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let i; while ((i = buf.indexOf('\n\n')) >= 0) { handle(buf.slice(0, i)); buf = buf.slice(i + 2); } } // Si la conexión se cerró sin 'done' (el usuario abortó o cayó la red), la // generación SIGUE en el servidor: no cerramos el turno aquí. if (doneSeen) commitTurn(); } catch (e) { if (e.name !== 'AbortError') setErr(e.message || 'Fallo de conexión.'); // AbortError = navegó/cerró: el run continúa en segundo plano y se // reenganchará al volver; no tocamos el turno. } finally { abortRef.current = null; } }; const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }; const provTone = { deepseek: 'var(--blue)', glm: 'var(--fs)', local: 'var(--amber)' }; const provLabel = (p) => ({ deepseek: 'DeepSeek', glm: 'GLM', local: 'IA local' }[p] || p); if (data === null) return