File size: 8,708 Bytes
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73063cc
 
 
 
 
 
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73063cc
 
 
 
 
 
 
 
 
 
 
 
 
 
b30b7c5
 
73063cc
 
 
 
 
 
 
 
 
 
 
 
b30b7c5
73063cc
 
 
 
 
 
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
// Gemma-4 vía LiteRT-LM de Google (early preview, solo WebGPU).
// Patrón copiado de la demo verificada en agentic-install
// (lab/bitacora/posts/08-jspace-live.html).
// DECISIÓN 2026-07-14: cerebro = Gemma BASE (builds oficiales litert-community,
// formato artisan). NO fine-tune propio: `@litert-lm/core` exige empaquetado
// artisan y nuestras conversiones no lo producen (E-010). La agéntica va por el
// system prompt (agent.js), no por pesos.
export let name = 'Gemma · LiteRT-LM';

// Builds .litertlm elegibles. Los «-web» OFICIALES de Google (litert-community)
// están exportados en formato artisan → SÍ cargan en el navegador (son los que
// usaba la demo original). El healed de Elffuss es prefill_decode → hoy no carga
// (E-010), por eso está gateado en el selector.
export const MODELS = {
  'gemma-e2b': { url: 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it-web.litertlm', label: 'Gemma-4 E2B', tag: '~2 GB · ligero' },
  'gemma-e4b': { url: 'https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.litertlm', label: 'Gemma-4 E4B', tag: '~4 GB · el mejor' },
  'elffuss-e4b': { url: 'https://huggingface.co/KikoCis/Elffuss-Gemma4-E4B-litert/resolve/main/model.litertlm', label: 'Elffuss E4B (healed)', tag: 'modelo propio' },
};

let MODEL_URL = MODELS['gemma-e2b'].url;
let curLabel = MODELS['gemma-e2b'].label;
export function configure(key) {
  const m = MODELS[key] || MODELS['gemma-e2b'];
  MODEL_URL = m.url; curLabel = m.label; name = 'Gemma · LiteRT-LM (' + m.label + ')';
}

let engine = null, conversation = null, sentCount = 0, sys = '';

// Contexto: probamos de mayor a menor hasta el máximo que acepten el bundle y
// la memoria GPU — así el contexto queda al tope permitido de serie.
const CTX_LADDER = [32768, 16384, 8192, 4096];
export let ctxTokens = 4096; // efectivo tras load() (la UI puede leerlo)

export async function load(onProgress = () => {}) {
  if (!navigator.gpu) throw new Error('LiteRT-LM necesita WebGPU (Chrome/Edge modernos)');
  // navigator.gpu puede existir como API sin adaptador real (algunos Linux/
  // drivers, entornos sandboxed…) — comprobarlo YA evita bajar 2-4 GB para
  // descubrir el fallo solo al crear el motor, al final de todo.
  let adapter = null;
  try { adapter = await navigator.gpu.requestAdapter(); } catch { /* sin adaptador */ }
  if (!adapter) throw new Error('No hay un adaptador WebGPU real disponible (la API existe pero no hay GPU accesible) — prueba con Elffuss LM, que corre en CPU/wasm.');
  // VERSIÓN FIJADA a propósito. Sin fijarla, la URL apunta siempre a la última
  // publicada: el 2026-08-11 salió 0.16.0, jsdelivr NO consigue construirle el
  // bundle `+esm` (404) y el cerebro Gemma dejó de cargar en producción sin que
  // nosotros tocáramos una línea. Al subir de versión hay que COMPROBAR que
  // `https://cdn.jsdelivr.net/npm/@litert-lm/core@<v>/+esm` responde 200.
  const litertlm = await import('https://cdn.jsdelivr.net/npm/@litert-lm/core@0.15.0/+esm');
  // El .litertlm lo descargamos NOSOTROS (cache-first en Cache Storage) y se lo
  // pasamos a Engine.create como Blob (la API acepta string|Blob|ReadableStream).
  // Motivo: el fetch interno de LiteRT baja el peso con XHR+Range desde un WORKER
  // que el service worker no intercepta → antes se re-descargaba SIEMPRE. Bajándolo
  // aquí queda cacheado de verdad y damos progreso real en MB.
  const model = await cachedModelBlob(MODEL_URL, onProgress);
  onProgress('Preparando el modelo IA en la GPU…');
  let lastErr = null;
  for (const n of CTX_LADDER) {
    try {
      engine = await litertlm.Engine.create({ model, mainExecutorSettings: { maxNumTokens: n } });
      ctxTokens = n;
      lastErr = null;
      break;
    } catch (e) {
      lastErr = e;
      // Errores de formato/carga no dependen del contexto: no insistir con la escalera.
      if (/not supported|tokenizer|format/i.test(String(e?.message))) throw e;
      onProgress(`Contexto ${n} no cabe, probando ${n / 2}…`);
    }
  }
  if (lastErr) throw lastErr;
}

const MODEL_CACHE = 'elffuss-models-v1';
// Devuelve el .litertlm como Blob desde Cache Storage; si no está, lo descarga
// con progreso real y lo cachea (persistente). Ante cualquier fallo, devuelve la
// URL para que LiteRT lo baje por su cuenta (nunca bloquea la carga del modelo).
export async function cachedModelBlob(url, onProgress = () => {}) {
  if (!self.caches) return url;
  try {
    const cache = await caches.open(MODEL_CACHE);
    const hit = await cache.match(url);
    if (hit) { onProgress('Cargando el modelo IA desde caché (sin descargar)…'); return await hit.blob(); }
    const net = await fetch(url);
    if (!net.ok || !net.body) return url;
    const total = +net.headers.get('content-length') || 0;
    const t0 = performance.now();
    // Progreso SIN tee(): con un modelo de gigabytes, tee() crea dos ramas que
    // se consumen a ritmos distintos y el navegador tiene que bufferizar la
    // diferencia en memoria → el cache.put acababa reventando y el modelo NO se
    // cacheaba NUNCA (medido con E4B: 2832 MB bajados y cero guardados; el
    // usuario se los re-bajaba en cada sesión). Con un TransformStream hay un
    // solo consumidor: contamos al vuelo y el mismo flujo va a la caché.
    let loaded = 0;
    const counted = net.body.pipeThrough(new TransformStream({
      transform(chunk, ctrl) {
        loaded += chunk.byteLength ?? chunk.length;
        onProgress(fmtBytes(loaded, total, t0));
        ctrl.enqueue(chunk);
      },
    }));
    const headers = { 'Content-Type': 'application/octet-stream' };
    if (total) headers['Content-Length'] = String(total);
    // Cachear GIGABYTES puede fallar de verdad: ventana privada (Cache Storage
    // en memoria), disco lleno, cuota del origen. Si falla hay que DECIRLO: el
    // progreso ya ha prometido «se cachea para la próxima vez» y, callándolo,
    // el usuario se re-baja el modelo entero cada sesión sin saber por qué.
    try {
      await cache.put(url, new Response(counted, { headers }));
    } catch (e) {
      onProgress(`No se pudo guardar el modelo en caché (${e.name || 'error'}): habrá que descargarlo otra vez la próxima. ` +
        `Suele ser ventana privada o falta de espacio.`);
      console.warn('[elffuss] modelo NO cacheado:', e);
      return url;
    }
    const cached = await cache.match(url);
    if (!cached) { onProgress('No se pudo guardar el modelo en caché: habrá que descargarlo otra vez la próxima.'); return url; }
    return await cached.blob();
  } catch (e) {
    console.warn('[elffuss] caché de modelo no disponible:', e);
    return url;
  }
}
function fmtBytes(loaded, total, t0) {
  const mb = n => (n / 1048576).toFixed(0);
  const secs = (performance.now() - t0) / 1000;
  const spd = secs > 0 ? (loaded / 1048576 / secs).toFixed(1) : '0';
  return total
    ? `Descargando el modelo IA… ${mb(loaded)}/${mb(total)} MB (${spd} MB/s) · se cachea para la próxima vez`
    : `Descargando el modelo IA… ${mb(loaded)} MB (${spd} MB/s)`;
}

// Liberar el modelo (vigilante de RAM).
export async function unload() {
  try { engine?.close?.(); } catch { /* mejor esfuerzo */ }
  engine = null; conversation = null; sentCount = 0;
}

export async function chat(history, system, onToken = () => {}) {
  if (!engine) throw new Error('Modelo no cargado');
  // Comparar solo la parte estática del prompt: el CONTEXTO AHORA va al final
  // y cambia cada turno — recrear la conversación tiraría el KV-cache.
  const sysKey = system.slice(0, 200);
  if (!conversation || sysKey !== sys) {
    sys = sysKey;
    conversation = await engine.createConversation({
      preface: { messages: [{ role: 'system', content: system }] },
      // Exprimir el navegador: no persistir los tokens de canal (tool-call/thinking)
      // del modelo en el KV-cache → libera KV → más contexto útil. Y prefill del
      // system prompt al crear la conversación → primera respuesta más rápida.
      filterChannelContentFromKvCache: true,
      prefillPrefaceOnInit: true,
    });
    sentCount = 0;
  }
  // La conversación LiteRT mantiene su propio KV-cache: enviamos solo lo nuevo.
  const fresh = history.slice(sentCount).filter(m => m.role === 'user');
  sentCount = history.length;
  const text = fresh.map(m => m.content).join('\n') || history.at(-1).content;

  let out = '';
  for await (const chunk of conversation.sendMessageStreaming(text)) {
    for (const item of (chunk.content || []))
      if (item.type === 'text') { out += item.text; onToken(item.text); }
  }
  return out.trim();
}