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:
68
web/js/runtime/model-broker.js
Normal file
68
web/js/runtime/model-broker.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// Elffuss Runtime · SDK de la caché compartida de modelos.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Embebe un iframe oculto al BROKER (origen compartido de Elffuss) y le pide los
|
||||
// modelos por postMessage. Como el broker vive en un subdominio de utopiaia.com
|
||||
// —igual que todas las webs de Elffuss (mismo «site»)—, comparte UNA sola OPFS:
|
||||
// el modelo se descarga UNA vez y se reutiliza en claw/translator/copilot/code…
|
||||
// Si el broker no está disponible, el llamador cae a su OPFS local (model-store).
|
||||
export const BROKER_URL = 'https://models.elffuss.utopiaia.com/';
|
||||
|
||||
let _iframe = null, _ready = null, _seq = 0, _url = BROKER_URL;
|
||||
|
||||
function ensure(brokerURL) {
|
||||
if (_iframe) return _ready;
|
||||
_url = brokerURL;
|
||||
_iframe = document.createElement('iframe');
|
||||
_iframe.src = brokerURL; _iframe.setAttribute('aria-hidden', 'true');
|
||||
_iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;left:-9999px;visibility:hidden';
|
||||
_ready = new Promise((resolve, reject) => {
|
||||
const to = setTimeout(() => reject(new Error('broker timeout')), 8000);
|
||||
const h = e => { if (e.source === _iframe.contentWindow && e.data?.kind === 'elffuss-broker-ready') { clearTimeout(to); removeEventListener('message', h); resolve(); } };
|
||||
addEventListener('message', h);
|
||||
_iframe.addEventListener('error', () => { clearTimeout(to); reject(new Error('broker no cargó')); });
|
||||
});
|
||||
(document.body || document.documentElement).appendChild(_iframe);
|
||||
return _ready;
|
||||
}
|
||||
const origin = () => new URL(_url).origin;
|
||||
|
||||
// Modelo como Blob (el navegador lo respalda en disco), desde la caché compartida.
|
||||
// Descarga una vez para TODO Elffuss; el resto de webs lo leen sin red.
|
||||
export async function getSharedModel(url, onProgress = () => {}, brokerURL = BROKER_URL) {
|
||||
await ensure(brokerURL);
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++_seq;
|
||||
// Timeout por INACTIVIDAD: sin CUALQUIER mensaje del broker (progreso/file/
|
||||
// error) en IDLE ms, la carga está colgada → rechazar para caer a OPFS local.
|
||||
// No es un tope total (una descarga real tarda minutos): cada mensaje del
|
||||
// broker reinicia el reloj, así que solo salta si el broker enmudece de verdad.
|
||||
const IDLE = 30000;
|
||||
let timer;
|
||||
const arm = () => { clearTimeout(timer); timer = setTimeout(() => { removeEventListener('message', h); reject(new Error('broker sin respuesta (timeout de inactividad)')); }, IDLE); };
|
||||
const done = fn => (...a) => { clearTimeout(timer); removeEventListener('message', h); fn(...a); };
|
||||
const h = e => {
|
||||
if (e.source !== _iframe.contentWindow || e.data?.id !== id) return;
|
||||
const m = e.data;
|
||||
arm(); // cualquier señal del broker reinicia el reloj
|
||||
if (m.kind === 'progress') onProgress(m);
|
||||
// El broker devuelve un File respaldado en disco (structured-clone por
|
||||
// referencia): no copia los GB a RAM. Se lee con .stream() al subirlo a GPU.
|
||||
else if (m.kind === 'file') done(resolve)(m.file);
|
||||
else if (m.kind === 'error') done(reject)(new Error(m.message));
|
||||
};
|
||||
addEventListener('message', h);
|
||||
arm();
|
||||
_iframe.contentWindow.postMessage({ type: 'elffuss-model-get', id, url }, origin());
|
||||
});
|
||||
}
|
||||
|
||||
// ¿ya está en la caché compartida? (para la UI: «cargando desde caché, sin bajar»)
|
||||
export async function isSharedCached(url, brokerURL = BROKER_URL) {
|
||||
try { await ensure(brokerURL); } catch { return false; }
|
||||
return new Promise(resolve => {
|
||||
const id = ++_seq; const to = setTimeout(() => { removeEventListener('message', h); resolve(false); }, 4000);
|
||||
const h = e => { if (e.source !== _iframe.contentWindow || e.data?.id !== id) return; if (e.data.kind === 'has') { clearTimeout(to); removeEventListener('message', h); resolve(!!e.data.cached); } };
|
||||
addEventListener('message', h);
|
||||
_iframe.contentWindow.postMessage({ type: 'elffuss-model-has', id, url }, origin());
|
||||
});
|
||||
}
|
||||
255
web/js/runtime/model-store.js
Normal file
255
web/js/runtime/model-store.js
Normal file
@@ -0,0 +1,255 @@
|
||||
// Elffuss Runtime · almacén de modelos en OPFS (persistente, en disco).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sustituye a Cache Storage para los PESOS del modelo. Motivo real (bug de móvil):
|
||||
// Cache Storage se desaloja en iOS/Android → el modelo se re-descargaba en CADA
|
||||
// visita. OPFS (Origin Private File System) + navigator.storage.persist() aguanta
|
||||
// entre sesiones y se lee como File respaldado por DISCO (no vuelca todo a RAM).
|
||||
//
|
||||
// Además es la capa de almacenamiento del loader por shards: el motor leerá los
|
||||
// pesos por rangos desde el File sin cargar los gigabytes enteros en memoria.
|
||||
//
|
||||
// Licencia: código propio (Apache-2.0). Sin dependencias externas.
|
||||
|
||||
const DIR = 'elffuss-models';
|
||||
|
||||
// Nombre de fichero estable y seguro a partir de la URL (sin barras ni query).
|
||||
function keyFor(url) {
|
||||
return String(url).replace(/[?#].*$/, '').replace(/[^\w.\-]+/g, '_').slice(-180);
|
||||
}
|
||||
|
||||
async function requestPersist() {
|
||||
try {
|
||||
if (navigator.storage?.persist) {
|
||||
const already = navigator.storage.persisted ? await navigator.storage.persisted() : false;
|
||||
return already || await navigator.storage.persist();
|
||||
}
|
||||
} catch { /* no bloquea */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
async function dirHandle() {
|
||||
const root = await navigator.storage.getDirectory(); // lanza si no hay OPFS
|
||||
return await root.getDirectoryHandle(DIR, { create: true });
|
||||
}
|
||||
|
||||
// ¿Hay soporte para escribir en OPFS de forma útil en este navegador?
|
||||
// Chrome/escritorio: createWritable (stream a disco). iOS Safari: solo
|
||||
// createSyncAccessHandle (en worker) → en este primer incremento, si no hay
|
||||
// createWritable en el hilo principal, devolvemos null y el llamador cae a Cache.
|
||||
async function opfsWritableSupported(dir) {
|
||||
try {
|
||||
const test = await dir.getFileHandle('.probe', { create: true });
|
||||
if (typeof test.createWritable !== 'function') { await dir.removeEntry('.probe').catch(() => {}); return false; }
|
||||
const w = await test.createWritable();
|
||||
await w.close();
|
||||
await dir.removeEntry('.probe').catch(() => {});
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Devuelve un File (respaldado en disco) del modelo. Descarga por chunks a OPFS
|
||||
// la primera vez (con progreso real), lo sirve desde disco a partir de entonces.
|
||||
// Un marcador «<key>.done» evita servir una descarga cortada a medias.
|
||||
// Devuelve null si OPFS no está disponible/escribible → el llamador usa su
|
||||
// respaldo (Cache Storage) sin romperse.
|
||||
export async function getModelFile(url, onProgress = () => {}) {
|
||||
// 1) Caché COMPARTIDA (broker en origen Elffuss): un modelo bajado en CUALQUIER
|
||||
// web de Elffuss se reutiliza aquí sin re-descargar. Fast-fail por sesión si
|
||||
// el broker no está disponible → caemos a la OPFS local de este origen.
|
||||
let brokerDown = false;
|
||||
try { brokerDown = sessionStorage.getItem('elffuss.broker.down') === '1'; } catch { /* — */ }
|
||||
if (!brokerDown) {
|
||||
try {
|
||||
const { getSharedModel } = await import('./model-broker.js');
|
||||
const blob = await getSharedModel(url, onProgress);
|
||||
if (blob && blob.size) return blob;
|
||||
} catch { try { sessionStorage.setItem('elffuss.broker.down', '1'); } catch { /* — */ } }
|
||||
}
|
||||
|
||||
if (!navigator.storage?.getDirectory) return null;
|
||||
let dir;
|
||||
try { dir = await dirHandle(); } catch { return null; }
|
||||
await requestPersist();
|
||||
|
||||
const key = keyFor(url);
|
||||
const doneName = key + '.done';
|
||||
|
||||
// ¿ya está entero en disco?
|
||||
try {
|
||||
await dir.getFileHandle(doneName); // lanza si no existe
|
||||
const f = await (await dir.getFileHandle(key)).getFile();
|
||||
if (f.size > 0) { onProgress('Cargando el modelo desde disco (OPFS, sin descargar)…'); return f; }
|
||||
} catch { /* no está o incompleto: se descarga */ }
|
||||
|
||||
if (!(await opfsWritableSupported(dir))) return null; // iOS main-thread: que decida el llamador
|
||||
|
||||
// ── Descarga REANUDABLE ────────────────────────────────────────────────
|
||||
// Antes, un corte borraba lo bajado y la vez siguiente empezaba de cero. Con
|
||||
// un modelo de gigas eso convierte un tropiezo de red —o que el equipo se
|
||||
// duerma— en media hora perdida, y el usuario lo vive como «vuelve a
|
||||
// descargar el modelo». Ahora lo ya escrito se conserva y se pide el resto
|
||||
// con `Range`.
|
||||
//
|
||||
// El peligro de reanudar es empalmar dos ficheros DISTINTOS si el de origen
|
||||
// cambió: saldría un modelo corrupto que carga y devuelve basura, que es
|
||||
// peor que volver a bajarlo. Por eso se guarda la huella del origen (ETag o
|
||||
// Last-Modified) junto a los bytes, y si no coincide se empieza de nuevo.
|
||||
const parteName = key + '.parte'; // huella del intento
|
||||
let ya = 0, huellaPrevia = null;
|
||||
try {
|
||||
huellaPrevia = JSON.parse(await (await (await dir.getFileHandle(parteName)).getFile()).text());
|
||||
ya = (await (await dir.getFileHandle(key)).getFile()).size;
|
||||
} catch { ya = 0; huellaPrevia = null; }
|
||||
|
||||
// Se pregunta primero POR LA CABECERA qué hay al otro lado: es una petición
|
||||
// de un byte, y decide si lo que tenemos sirve.
|
||||
let huella = null, total = 0, aceptaRangos = false;
|
||||
try {
|
||||
const cab = await fetch(url, { headers: { Range: 'bytes=0-0' } });
|
||||
aceptaRangos = cab.status === 206;
|
||||
huella = cab.headers.get('etag') || cab.headers.get('last-modified') || null;
|
||||
const cr = cab.headers.get('content-range');
|
||||
total = cr ? +cr.split('/')[1] : (+cab.headers.get('content-length') || 0);
|
||||
try { await cab.arrayBuffer(); } catch { /* — */ }
|
||||
} catch { /* sin cabecera: se baja entero */ }
|
||||
|
||||
// La huella preferida es el ETag, pero CORS solo expone Content-Range,
|
||||
// Accept-Ranges y Content-Length: desde el navegador `etag` sale NULL y una
|
||||
// reanudación que dependiera de él no se activaría jamás. Se cae al TAMAÑO
|
||||
// TOTAL, que Content-Range sí deja leer y que el registro del motor fija al
|
||||
// byte — si el fichero de origen cambiara, cambiaría de tamaño y la
|
||||
// reanudación se descarta sola. Es más débil que un ETag y suficiente aquí.
|
||||
const mismaHuella = huellaPrevia && (
|
||||
(huella && huellaPrevia.huella === huella) ||
|
||||
(!huella && !huellaPrevia.huella && huellaPrevia.total === total));
|
||||
const puedeReanudar = ya > 0 && aceptaRangos && total > 0 && ya < total && mismaHuella;
|
||||
if (ya > 0 && !puedeReanudar) {
|
||||
// Lo guardado no sirve (otro fichero, o el servidor no da rangos).
|
||||
await dir.removeEntry(key).catch(() => {});
|
||||
ya = 0;
|
||||
}
|
||||
|
||||
const net = await fetch(url, puedeReanudar ? { headers: { Range: `bytes=${ya}-` } } : undefined);
|
||||
const okEstado = puedeReanudar ? net.status === 206 : net.ok;
|
||||
if (!okEstado || !net.body) throw new Error('descarga del modelo falló: HTTP ' + net.status);
|
||||
if (!total) total = (+net.headers.get('content-length') || 0) + ya;
|
||||
|
||||
await dir.removeEntry(doneName).catch(() => {});
|
||||
const fh = await dir.getFileHandle(key, { create: true });
|
||||
// `keepExistingData` conserva lo ya escrito; sin él, createWritable() TRUNCA
|
||||
// el fichero a cero y la reanudación sería una forma cara de empezar de nuevo.
|
||||
// Se confirma POR TRAMOS. `createWritable()` es atómico: lo escrito no existe
|
||||
// en el fichero hasta el `close()`, así que un corte tira TODO aunque nadie
|
||||
// borre nada — que es exactamente lo que medía el banco de reanudación
|
||||
// fallando. Cerrando y reabriendo cada tramo, lo confirmado sobrevive al
|
||||
// corte y la próxima vez se continúa desde ahí.
|
||||
// Proporcional al fichero, no fijo: con un tramo de 64 MB un fichero de 3 MB
|
||||
// no llegaba a confirmar NUNCA y la reanudación no existía para él —lo
|
||||
// destapó el banco, que usa un fichero pequeño a propósito—. Así se confirma
|
||||
// una quincena de veces sea cual sea el tamaño, con suelo para no castigar a
|
||||
// los diminutos y techo para no confirmar cada dos por tres en los de gigas.
|
||||
const TRAMO = Math.max(512 * 1024, Math.min(64 * 1024 * 1024, Math.floor((total || 0) / 16) || (64 * 1024 * 1024)));
|
||||
const anota = async () => {
|
||||
try {
|
||||
const ph = await dir.getFileHandle(parteName, { create: true });
|
||||
const pw = await ph.createWritable();
|
||||
await pw.write(new TextEncoder().encode(JSON.stringify({ huella, total })));
|
||||
await pw.close();
|
||||
} catch { /* sin anotación se pierde la reanudación, no los datos */ }
|
||||
};
|
||||
let writable = await fh.createWritable({ keepExistingData: puedeReanudar });
|
||||
if (puedeReanudar) await writable.seek(ya);
|
||||
const t0 = performance.now();
|
||||
let loaded = ya;
|
||||
if (puedeReanudar) onProgress(`Reanudando la descarga desde ${(ya / 1e9).toFixed(1)} GB…`);
|
||||
await anota();
|
||||
try {
|
||||
const reader = net.body.getReader();
|
||||
let enTramo = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
await writable.write(value);
|
||||
loaded += value.byteLength;
|
||||
enTramo += value.byteLength;
|
||||
if (enTramo >= TRAMO) {
|
||||
enTramo = 0;
|
||||
await writable.close(); // confirma lo del tramo
|
||||
writable = await fh.createWritable({ keepExistingData: true });
|
||||
await writable.seek(loaded);
|
||||
}
|
||||
onProgress(fmt(loaded, total, t0));
|
||||
}
|
||||
await writable.close();
|
||||
} catch (e) {
|
||||
// Se cierra para CONFIRMAR lo del tramo en curso. `abort()` lo descartaría,
|
||||
// que es justo lo contrario de lo que se quiere al reanudar.
|
||||
try { await writable.close(); } catch { /* lo confirmado en tramos ya está */ }
|
||||
throw e;
|
||||
}
|
||||
// Completado: fuera la marca de intento a medias.
|
||||
await dir.removeEntry(parteName).catch(() => {});
|
||||
|
||||
// marcar completado (con el tamaño esperado, para validar en el futuro)
|
||||
const dh = await dir.getFileHandle(doneName, { create: true });
|
||||
const dw = await dh.createWritable();
|
||||
await dw.write(new TextEncoder().encode(JSON.stringify({ size: loaded, total })));
|
||||
await dw.close();
|
||||
|
||||
return await (await dir.getFileHandle(key)).getFile();
|
||||
}
|
||||
|
||||
// Abre un handle de lectura por rangos (para el loader por shards del motor):
|
||||
// devuelve una función slice(offset, length) → Promise<ArrayBuffer> que lee del
|
||||
// disco sin cargar el fichero entero. File.slice() es perezoso en disco.
|
||||
export async function openRanged(url) {
|
||||
const file = await getModelFile(url);
|
||||
if (!file) return null;
|
||||
return {
|
||||
size: file.size,
|
||||
async slice(offset, length) { return await file.slice(offset, offset + length).arrayBuffer(); },
|
||||
};
|
||||
}
|
||||
|
||||
// Borrar un modelo cacheado (para el «liberar espacio» de la UI).
|
||||
export async function removeModel(url) {
|
||||
try {
|
||||
const dir = await dirHandle();
|
||||
const key = keyFor(url);
|
||||
await dir.removeEntry(key).catch(() => {});
|
||||
await dir.removeEntry(key + '.done').catch(() => {});
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Borra TODOS los modelos guardados en OPFS.
|
||||
// Existe porque el botón «liberar espacio» de Ajustes solo vaciaba Cache
|
||||
// Storage: un modelo descargado por este almacén se quedaba ocupando disco sin
|
||||
// forma de borrarlo desde la interfaz. Y como navigator.storage.estimate() SÍ
|
||||
// lo cuenta, el usuario veía gigas que el botón no bajaba nunca.
|
||||
export async function clearAll() {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
await root.removeEntry(DIR, { recursive: true });
|
||||
return true;
|
||||
} catch { return false; } // no existe o no hay OPFS: nada que borrar
|
||||
}
|
||||
|
||||
// Bytes ocupados por los modelos en OPFS (aprox, para diagnóstico).
|
||||
export async function usage() {
|
||||
try {
|
||||
const est = await navigator.storage.estimate();
|
||||
return { usage: est.usage || 0, quota: est.quota || 0 };
|
||||
} catch { return { usage: 0, quota: 0 }; }
|
||||
}
|
||||
|
||||
function fmt(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';
|
||||
// Incluir el % cuando se conoce el total: el escaparate lo extrae para llenar
|
||||
// la barra, y el texto queda corto (no envuelve en móvil).
|
||||
return total
|
||||
? `Descargando el cerebro · ${mb(loaded)}/${mb(total)} MB · ${Math.round(loaded / total * 100)}%`
|
||||
: `Descargando el cerebro · ${mb(loaded)} MB · ${spd} MB/s`;
|
||||
}
|
||||
Reference in New Issue
Block a user