File size: 10,174 Bytes
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ff195c
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
// 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();
}