elffuss-code / js /conversations.js
KikoCis's picture
feat: fechas absolutas al escribir + tarjeta de agregacion (apagada)
1ff195c verified
Raw
History Blame Contribute Delete
10.2 kB
// 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();
}