// Conversaciones: varias a la vez, como pestañas — cada una con su propio // historial (su propio Agent) y su propia cola de mensajes pendientes. // Cerrar una pestaña NO borra la conversación (solo la Historial sí). Todo // persiste en IndexedDB, sobrevive a recargar. // // Procesado: cada conversación acepta y encola mensajes de forma totalmente // independiente (mandar en la pestaña B mientras la A está pensando no // bloquea nada). La generación en sí (la llamada real al modelo) se // serializa con un cerrojo global — solo hay UN modelo cargado en el // navegador y una sesión de inferencia no admite dos generate() a la vez sin // corromperse — así que las conversaciones se turnan para «hablar» con el // modelo, pero cada una sigue funcionando y aceptando mensajes de forma // independiente mientras espera su turno. import { Agent } from './agent.js'; import { runGoal, TASK_PREFIX } from './goal.js'; import * as db from './db.js'; import * as telemetry from './telemetry.js'; const NS = 'elffusscode'; const TABS_KEY = NS + '.openTabs'; const ACTIVE_KEY = NS + '.activeConv'; const HIST_CAP = 80; // mensajes por conversación que se persisten let providerRef = null; const convs = new Map(); // id -> { id, title, agent, queue, pumping, createdAt, updatedAt } let openTabIds = []; let activeId = null; let onChange = () => {}; const genId = () => 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); const cleanTitle = s => String(s).replace(/\s+/g, ' ').trim(); const GOAL_PREFIX = TASK_PREFIX + 'Objetivo: '; export function titleFor(conv) { if (conv.title) return conv.title; const firstUser = conv.agent.history.find(m => m.role === 'user' && !m.content.startsWith('[resultado') && (!m.content.startsWith(TASK_PREFIX) || m.content.startsWith(GOAL_PREFIX))); if (!firstUser) return 'Nueva conversación'; // el objetivo lleva un prefijo interno ([tarea-objetivo] Objetivo: …) que // no debe filtrarse al título de la pestaña — se muestra el texto limpio. const raw = firstUser.content.startsWith(GOAL_PREFIX) ? firstUser.content.slice(GOAL_PREFIX.length) : firstUser.content; const t = cleanTitle(raw); return t.length > 40 ? t.slice(0, 40) + '…' : t || 'Nueva conversación'; } export function setProvider(mod) { providerRef = mod; for (const c of convs.values()) c.agent.setProvider(mod); } export function getProvider() { return providerRef; } export function isBusy() { for (const c of convs.values()) if (c.pumping || c.queue.length) return true; return false; } export function getActive() { return activeId ? convs.get(activeId) : null; } export function getOpenTabs() { return openTabIds.map(id => convs.get(id)).filter(Boolean); } async function persistConv(conv) { const all = await db.get('kv', 'conversations').catch(() => null) || []; const i = all.findIndex(c => c.id === conv.id); const rec = { id: conv.id, title: titleFor(conv), history: conv.agent.history.slice(-HIST_CAP), queue: conv.queue, plan: conv.plan || null, createdAt: conv.createdAt, updatedAt: conv.updatedAt, }; if (i >= 0) all[i] = rec; else all.push(rec); await db.set('kv', 'conversations', all).catch(() => {}); } function persistMeta() { try { localStorage.setItem(TABS_KEY, JSON.stringify(openTabIds)); } catch { /* lleno */ } try { localStorage.setItem(ACTIVE_KEY, activeId || ''); } catch { /* lleno */ } } function makeConv(id, saved) { const conv = { id, title: saved?.title || null, agent: new Agent(providerRef), queue: saved?.queue ? [...saved.queue] : [], pumping: false, plan: saved?.plan || null, createdAt: saved?.createdAt || Date.now(), updatedAt: saved?.updatedAt || Date.now(), }; if (saved?.history) conv.agent.history = saved.history; convs.set(id, conv); return conv; } export function create() { const conv = makeConv(genId()); openTabIds.push(conv.id); activeId = conv.id; persistMeta(); onChange('switch', conv.id); return conv; } // reabre una conversación guardada (de la Historial) como pestaña, o activa // la pestaña si ya estaba abierta export async function open(id) { if (!convs.has(id)) { const saved = (await db.get('kv', 'conversations').catch(() => null) || []).find(c => c.id === id); if (!saved) return null; makeConv(id, saved); } if (!openTabIds.includes(id)) openTabIds.push(id); activeId = id; persistMeta(); onChange('switch', id); return convs.get(id); } export function switchTo(id) { if (!convs.has(id) || activeId === id) return; activeId = id; persistMeta(); onChange('switch', id); } // añade un mensaje fuera del ciclo normal de envío (p.ej. el informe // autónomo del cerebro CEO) al historial REAL de una conversación — si no, // solo viviría en el DOM y desaparecería al cambiar de pestaña o recargar. export async function appendMessage(id, role, content) { const c = convs.get(id); if (!c) return; c.agent.history.push({ role, content, ts: Date.now() }); c.updatedAt = Date.now(); await persistConv(c); } // quita la pestaña de la vista — la conversación sigue viva en la Historial export function closeTab(id) { openTabIds = openTabIds.filter(x => x !== id); if (activeId === id) activeId = openTabIds[openTabIds.length - 1] || null; persistMeta(); if (!openTabIds.length) create(); else onChange('tabs'); } // borra la conversación de verdad (desde la Historial) export async function remove(id) { convs.delete(id); openTabIds = openTabIds.filter(x => x !== id); if (activeId === id) activeId = openTabIds[openTabIds.length - 1] || null; const all = (await db.get('kv', 'conversations').catch(() => null) || []).filter(c => c.id !== id); await db.set('kv', 'conversations', all).catch(() => {}); persistMeta(); if (!openTabIds.length) create(); else onChange('tabs'); } export async function listAll() { const all = await db.get('kv', 'conversations').catch(() => null) || []; return all.slice().sort((a, b) => b.updatedAt - a.updatedAt); } // ---- envío / procesado ---- // items de la cola: {kind:'chat'|'goal', text} — 'chat' es un turno normal // (agent.handle), 'goal' dispara el planificador/ejecutor de goal.js. Las // colas persistidas ANTES de que existiera el modo Objetivo guardaban texto // suelto (strings) — normalizeItem() los sigue aceptando como 'chat'. let inferenceLock = Promise.resolve(); const normalizeItem = it => (typeof it === 'string' ? { kind: 'chat', text: it } : it); export function send(id, text) { const conv = convs.get(id); if (!conv) return; conv.queue.push({ kind: 'chat', text }); persistConv(conv); pump(conv); } // 🎯 Modo Objetivo: en vez de un turno de chat normal, el mensaje se trata // como un objetivo — se planifica en tareas y se ejecutan una a una (ver // goal.js, mismo patrón planificador/ejecutor de clonagent). export function startGoal(id, text) { const conv = convs.get(id); if (!conv) return; conv.queue.push({ kind: 'goal', text }); persistConv(conv); pump(conv); } async function pump(conv) { if (conv.pumping) return; conv.pumping = true; onChange('pumping', conv.id, true); while (conv.queue.length) { const item = normalizeItem(conv.queue[0]); const myTurn = inferenceLock; let release; inferenceLock = new Promise(r => { release = r; }); await myTurn; // espera su turno de inferencia (un solo modelo cargado, no admite 2 generate() a la vez) try { // un fallo aquí (del modelo, de una tool, o del propio repintado en // main.js) NUNCA debe dejar la conversación "pumping" para siempre — // eso bloquearía sus futuros mensajes Y al cerebro CEO (isBusy()). const onEvent = ev => { try { onChange('event', conv.id, ev); } catch (e) { console.error('[elffuss] fallo pintando un evento de chat', e); } }; if (item.kind === 'goal') await runGoal(conv, item.text, onEvent); else await conv.agent.handle(item.text, onEvent); } catch (e) { console.error('[elffuss] fallo procesando el turno', e); telemetry.reportError('pump: ' + (e?.message || e), { stack: e?.stack || '' }); try { onChange('event', conv.id, { type: 'error', text: 'Error interno: ' + (e?.message || e) }); } catch { /* ya está registrado arriba */ } } finally { release(); } conv.queue.shift(); conv.updatedAt = Date.now(); await persistConv(conv); } conv.pumping = false; onChange('pumping', conv.id, false); } export async function init({ onEvent }) { onChange = onEvent; // migración única: el esquema viejo era UNA sola conversación (kv/history + kv/queue) const already = await db.get('kv', 'conversations').catch(() => null); if (!already) { const oldHistory = await db.get('kv', 'history').catch(() => null); if (oldHistory?.length) { const oldQueue = await db.get('kv', 'queue').catch(() => null); await db.set('kv', 'conversations', [{ id: genId(), title: null, history: oldHistory, queue: oldQueue || [], createdAt: Date.now(), updatedAt: Date.now(), }]).catch(() => {}); } } let tabIds = [], active = null; try { tabIds = JSON.parse(localStorage.getItem(TABS_KEY) || '[]'); } catch { /* corrupto */ } try { active = localStorage.getItem(ACTIVE_KEY) || null; } catch { /* */ } const all = await db.get('kv', 'conversations').catch(() => null) || []; for (const id of tabIds) { const saved = all.find(c => c.id === id); if (saved) { makeConv(id, saved); openTabIds.push(id); } } if (active && convs.has(active)) activeId = active; else if (openTabIds.length) activeId = openTabIds[0]; if (!openTabIds.length) { // nada abierto (primera vez, o se perdió el estado de pestañas): retoma // la conversación más reciente si hay alguna, si no, una nueva vacía const mostRecent = all.slice().sort((a, b) => b.updatedAt - a.updatedAt)[0]; if (mostRecent) { makeConv(mostRecent.id, mostRecent); openTabIds.push(mostRecent.id); activeId = mostRecent.id; } else create(); } for (const conv of convs.values()) if (conv.queue.length) pump(conv); persistMeta(); }