Stand-in import of elffuss/code
One parentless commit with only the files that the open seed tasks touch and the modules they import. The full squashed import (SPEC 8.6) is a later step: this tree is not the cutoff tree. T2T-StandIn-Source-Commit: 43f95b7333efe73aeaebf806023f17bffea27aba
This commit is contained in:
273
web/js/conversations.js
Normal file
273
web/js/conversations.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// 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; }
|
||||
|
||||
// Parar la conversación activa mientras trabaja: aborta la generación/bucle del
|
||||
// agente (agent.stop() propaga la señal a los proveedores) y vacía la cola para
|
||||
// que no arranque el siguiente turno. runGoal comprueba conv.stopped para no
|
||||
// seguir con las tareas pendientes del plan.
|
||||
export function stopActive() {
|
||||
const c = getActive();
|
||||
if (!c) return false;
|
||||
c.stopped = true;
|
||||
try { c.agent.stop(); } catch { /* */ }
|
||||
c.queue.length = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getOpenTabs() { return openTabIds.map(id => convs.get(id)).filter(Boolean); }
|
||||
|
||||
async function persistConv(conv) {
|
||||
// NO tragarse el error de lectura: un fallo transitorio devolvía [] y el
|
||||
// db.set de abajo reescribía el store entero con UNA sola conversación,
|
||||
// borrando el resto. Distinguir «no existe aún» (undefined → []) de «no se
|
||||
// pudo leer» (excepción → no escribir nada este turno).
|
||||
let stored;
|
||||
try { stored = await db.get('kv', 'conversations'); }
|
||||
catch (e) { console.warn('[elffuss] no se pudo leer conversations; no sobrescribo', e); return; }
|
||||
const all = stored == null ? [] : stored;
|
||||
if (!Array.isArray(all)) { console.warn('[elffuss] conversations corrupto; no sobrescribo'); return; }
|
||||
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;
|
||||
conv.stopped = false;
|
||||
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);
|
||||
if (conv.stopped) break;
|
||||
}
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user