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
333 lines
18 KiB
JavaScript
333 lines
18 KiB
JavaScript
// 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 = '';
|
|
|
|
// Muestreo. Por defecto VORAZ, que es como venía: mismo prompt, misma salida.
|
|
// Eso hace que generar N veces cueste N y devuelva una sola respuesta distinta,
|
|
// así que cualquier técnica de «genera varias y quédate con la mejor» era gasto
|
|
// puro. Con temperatura y semilla se puede pedir variedad de verdad.
|
|
const TOP_P = 2;
|
|
let muestreo = null; // null = voraz
|
|
export function setSampling(opts) { // {temperature, seed, p} o null
|
|
const antes = JSON.stringify(muestreo);
|
|
// «k» es OBLIGATORIO aunque el tipo sea TOP_P: sin él el wasm aborta en seco
|
|
// con «Aborted()» y se lleva por delante el motor. Comprobado probando formas.
|
|
muestreo = opts && opts.temperature > 0
|
|
? { type: TOP_P, k: opts.k ?? 40, p: opts.p ?? 0.95, temperature: opts.temperature, seed: opts.seed ?? 0 }
|
|
: null;
|
|
// cambiar el muestreo exige rehacer la conversación: la sesión ya está creada
|
|
// con los parámetros de antes y no los relee.
|
|
if (JSON.stringify(muestreo) !== antes) { conversation = null; sentCount = 0; }
|
|
return muestreo;
|
|
}
|
|
export function getSampling() { return muestreo; }
|
|
export function __engine() { return engine; } // solo para el banco de pruebas
|
|
|
|
// 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 = () => {}) {
|
|
// Caché COMPARTIDA + OPFS (runtime propio): un modelo bajado en cualquier web
|
|
// de Elffuss se reutiliza sin re-descargar. Si no está disponible, caemos al
|
|
// Cache Storage de siempre.
|
|
try {
|
|
const { getModelFile } = await import('../runtime/model-store.js');
|
|
const f = await getModelFile(url, onProgress);
|
|
if (f) return f;
|
|
} catch (e) { console.warn('[elffuss] OPFS/broker no disponible, uso Cache Storage:', e); }
|
|
|
|
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). Peor
|
|
// aún: al fallar se devolvía la URL suelta y `Engine.create({model: <URL>})`
|
|
// monta un motor que CARGA pero no genera («Aborted()»). 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 = () => {}, signal = null) {
|
|
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);
|
|
// Una conversación NUEVA llega con el historial reiniciado (más corto que lo
|
|
// ya enviado). Sin esta comprobación, el KV-cache conservaba el chat anterior
|
|
// entero: el modelo seguía «viendo» los ficheros y respuestas del chat de
|
|
// antes y contestaba sobre ellos. Se notaba como alucinación («ese fichero no
|
|
// existe») cuando en realidad era memoria de la conversación previa — y es
|
|
// además una fuga: un chat nuevo no debe ver el contenido del anterior.
|
|
const reiniciada = history.length <= sentCount;
|
|
if (!conversation || sysKey !== sys || reiniciada) {
|
|
sys = sysKey;
|
|
conversation = await crearConversacion(system);
|
|
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');
|
|
const nuevos = fresh.length ? fresh : [history.at(-1)];
|
|
let text = nuevos.map(m => m.content).join('\n');
|
|
|
|
// ¿Cabe? Si no: compactar lo anterior y, si ni así, recortar (ver CONTEXTO).
|
|
let antes = await tokensUsados();
|
|
if (antes != null && estimaTokens(text) > ctxTokens - antes - RESERVA_SALIDA) {
|
|
const ocupaba = estimaTokens(text);
|
|
if (sentCount > 0) { await rehacerCompactada(history, sentCount, system, nuevos); antes = await tokensUsados(); }
|
|
const largo = text.length;
|
|
text = ajustarAlContexto(nuevos, libreEnCaracteres(antes, 0.9));
|
|
console.warn(`[litert] el mensaje nuevo (~${ocupaba} tokens) no cabía en un contexto de ${ctxTokens}: ` +
|
|
`${sentCount > 0 ? 'conversación compactada' : 'conversación vacía'}, mensaje ${text.length < largo ? 'recortado' : 'entero'} ` +
|
|
`· usados ${antes} · se mandan ${text.length} caracteres (${largo} tenía) · ${charsPorToken} caracteres/token`);
|
|
}
|
|
|
|
let out = '';
|
|
const enviar = async t => {
|
|
for await (const chunk of conversation.sendMessageStreaming(t)) {
|
|
if (signal?.aborted) break; // parar: se devuelve lo generado hasta aquí
|
|
for (const item of (chunk.content || []))
|
|
if (item.type === 'text') { out += item.text; onToken(item.text); }
|
|
}
|
|
};
|
|
try {
|
|
await enviar(text);
|
|
} catch (e) {
|
|
// Si falla ANTES de escribir nada, lo normal es que no cupiera (la cuenta de
|
|
// caracteres por token se quedó corta): se rehace compactada, se recorta a la
|
|
// mitad de lo libre y se intenta UNA vez más. Si ya había escrito algo,
|
|
// reintentar lo duplicaría: se deja subir el error.
|
|
if (out || signal?.aborted) throw e;
|
|
console.warn(`[litert] el envío falló sin respuesta (usados ${antes} · ${text.length} caracteres); ` +
|
|
`rehago la conversación compactada y reintento una vez: ${e?.message || e}`);
|
|
await rehacerCompactada(history, sentCount, system, nuevos);
|
|
antes = await tokensUsados();
|
|
text = ajustarAlContexto(nuevos, libreEnCaracteres(antes, 0.5));
|
|
console.warn(`[litert] reintento: usados ${antes} · se mandan ${text.length} caracteres`);
|
|
await enviar(text);
|
|
}
|
|
// Se marca como enviado DESPUÉS de enviarlo. Antes se marcaba al principio, y
|
|
// un envío fallido dejaba el mensaje por enviado sin haber llegado nunca.
|
|
sentCount = history.length;
|
|
const despues = await tokensUsados();
|
|
if (antes != null && despues != null && despues - antes > 64)
|
|
charsPorToken = Math.min(2, Math.max(1.2, (text.length + out.length) / (despues - antes)));
|
|
return out.trim();
|
|
}
|
|
|
|
function crearConversacion(system) {
|
|
return 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,
|
|
...(muestreo ? { sessionConfig: { samplerParams: muestreo } } : {}),
|
|
});
|
|
}
|
|
|
|
// ── CONTEXTO: que un resultado grande no tumbe la conversación ───────────────
|
|
// Los proveedores sin estado pasan el historial por acer-core en cada llamada.
|
|
// Este no: LiteRT guarda la conversación en su caché KV y aquí solo se le manda
|
|
// lo nuevo, así que NADA la empaquetaba. Y lo nuevo puede ser enorme: fs.read
|
|
// devuelve hasta 200.000 caracteres, decenas de miles de tokens, con un
|
|
// contexto de 4.096 a 32.768. Antes de mandar se mira lo que queda
|
|
// (getTokenCount) y, si no cabe:
|
|
// 1. se rehace la conversación con lo anterior EMPAQUETADO por acer-core
|
|
// —puntuado con la pregunta de ahora— dentro del prompt de sistema. Va como
|
|
// texto y no como mensajes con rol a propósito: qué roles acepta el
|
|
// preámbulo de Gemma no está documentado, y el texto no depende de eso;
|
|
// 2. si ni así cabe, se recortan por el medio los resultados de herramienta
|
|
// del mensaje nuevo (cabeza y cola, que es donde suele estar lo que
|
|
// importa), avisando al modelo de que puede pedir un trozo concreto.
|
|
// Si cabe, el mensaje sale exactamente igual que antes.
|
|
//
|
|
// Los caracteres por token no se saben de antemano (el tokenizador vive dentro
|
|
// del wasm): se empieza en 2 y lo medido con getTokenCount solo puede BAJARLO.
|
|
// Medido con Gemma E4B: la prosa sale a ~2,8 y un informe cargado de cifras a
|
|
// 2,35. Dejando que subiera, el saludo calibraba a 2,81 y el informe de después
|
|
// se mandaba contado con esa cifra: 77.112 caracteres que eran ~32.800 tokens, y
|
|
// el envío fallaba («Too many tokens requested») hasta el reintento, pagando dos
|
|
// veces la espera. Una proporción medida con un contenido no vale para otro, y
|
|
// equivocarse por arriba cuesta un fallo; por abajo, solo recortar algo de más.
|
|
const RESERVA_SALIDA = 1024; // tokens que se dejan para que conteste
|
|
const FRAC_HISTORIAL = 0.35; // techo del historial empaquetado al rehacer
|
|
let charsPorToken = 2;
|
|
const estimaTokens = s => Math.ceil(s.length / charsPorToken);
|
|
|
|
async function tokensUsados() {
|
|
try { const n = await conversation.getTokenCount(); return Number.isFinite(n) ? n : null; }
|
|
catch { return null; }
|
|
}
|
|
const libreEnCaracteres = (usados, fraccion) =>
|
|
Math.floor(Math.max(256, ctxTokens - (usados ?? 0) - RESERVA_SALIDA) * charsPorToken * fraccion);
|
|
|
|
async function rehacerCompactada(history, hasta, system, nuevos) {
|
|
let bloque = '';
|
|
const previos = history.slice(0, hasta);
|
|
if (previos.length) {
|
|
const { packHistoryAsync } = await import('../context.js');
|
|
const { estimateTokens } = await import('../acer-core.js');
|
|
// El presupuesto de acer-core va en SUS tokens estimados, no en los del modelo.
|
|
const caracteres = previos.reduce((a, m) => a + m.content.length, 0) || 1;
|
|
const suyos = previos.reduce((a, m) => a + estimateTokens(m.content), 0);
|
|
const presupuesto = Math.max(100, Math.round(ctxTokens * FRAC_HISTORIAL * charsPorToken * suyos / caracteres));
|
|
// La pregunta de AHORA va al final para que acer-core puntúe con ella; luego se quita.
|
|
const noEsResultado = m => m.role === 'user' && !m.content.startsWith('[resultado');
|
|
const pregunta = ([...nuevos].reverse().find(noEsResultado) || [...history].reverse().find(noEsResultado))?.content || '';
|
|
const empaquetado = (await packHistoryAsync([...previos, { role: 'user', content: pregunta }], presupuesto)).slice(0, -1);
|
|
bloque = '\n\nCONVERSACIÓN HASTA AHORA (no cabía entera: va lo más relevante para lo que se pide ahora):\n' +
|
|
empaquetado.map(m => `${m.role === 'assistant' ? 'Elffuss' : 'Usuario'}: ${m.content}`).join('\n');
|
|
}
|
|
try { await conversation?.delete?.(); } catch { /* ya no estaba */ }
|
|
conversation = await crearConversacion(system + bloque);
|
|
}
|
|
|
|
function ajustarAlContexto(nuevos, maxCaracteres) {
|
|
const entero = nuevos.map(m => m.content).join('\n');
|
|
if (entero.length <= maxCaracteres) return entero;
|
|
// Un mensaje puede traer VARIOS resultados seguidos (Elffuss Code junta los de
|
|
// un mismo paso): se recorta cada uno por su lado, o el primero se comería el
|
|
// sitio de los demás.
|
|
const mensajes = nuevos.map(m => m.content.split(/\n\n(?=\[resultado )/));
|
|
const esResultado = b => b.startsWith('[resultado');
|
|
const deResultados = mensajes.flat().reduce((a, b) => a + (esResultado(b) ? b.length : 0), 0);
|
|
const hueco = Math.max(0, maxCaracteres - (entero.length - deResultados));
|
|
const t = deResultados
|
|
? mensajes.map(bs => bs.map(b => (esResultado(b) ? recortarPorElMedio(b, Math.floor(b.length * hueco / deResultados)) : b)).join('\n\n')).join('\n')
|
|
: entero;
|
|
return t.length <= maxCaracteres ? t : recortarPorElMedio(t, maxCaracteres);
|
|
}
|
|
|
|
function recortarPorElMedio(s, max) {
|
|
if (s.length <= max) return s;
|
|
const aviso = `\n… [recortado: ${s.length - max} caracteres no caben en el contexto; si hace falta, pide un fragmento concreto] …\n`;
|
|
const util = Math.max(0, max - aviso.length);
|
|
const cabeza = Math.ceil(util * 0.7);
|
|
return s.slice(0, cabeza) + aviso + s.slice(s.length - (util - cabeza));
|
|
}
|
|
|
|
// Solo para tests/litert-contexto.mjs: probar chat() en node con un motor de mentira.
|
|
export function __usarMotor(motor, contexto) {
|
|
engine = motor; ctxTokens = contexto; conversation = null; sentCount = 0; sys = ''; charsPorToken = 2;
|
|
}
|