// Elffuss Code: landing («abre tu carpeta») → IDE con la elfa integrada.
import { parseToolCall } from './agent.js';
import * as rules from './providers/rules.js';
import * as codeTools from './tools/code.js';
import * as db from './db.js';
import * as settings from './settings.js';
import { initEditor, refreshTree, openFile, gotoLine, triggerEditor, hasEditor } from './ide.js';
import { ACTIVITY, UI } from './icons.js';
import { renderMarkdown } from './md.js';
import * as skills from './skills.js';
import * as terminal from './terminal.js';
import * as shell from './shell.js';
import { setTerminalEcho } from './tools/index.js';
import { ensureModelCache, cacheEstimate, clearModelCache } from './model-cache.js';
import * as ceo from './ceo.js';
import * as mind from './mind.js';
import { buildCityAdapter, loadThoughtsAdapter } from './mind-adapter.js';
import { humanizeStreamPreview } from './humanize.js';
import { t, applyI18n } from './i18n.js';
import * as bridge from './bridge.js';
import * as conv from './conversations.js';
import { TASK_PREFIX } from './goal.js';
import { hardWork, gatherFolder } from './rlm.js';
import * as workspace from './workspace.js';
// Espacio de trabajo (core): qué de Code puede guardarse en tu disco.
workspace.init({
app: 'code', ns: 'elffusscode', db,
stores: [
{ id: 'conversations', label: 'Conversaciones', icon: '💬', kind: 'idb-key', store: 'kv', key: 'conversations',
files: { ext: '.json', name: c => c.title || c.id } },
{ id: 'skills', label: 'Skills', icon: '🧩', kind: 'idb-key', store: 'kv', key: 'skills',
files: { ext: '.md', name: s => s.name, body: s => `---\nname: ${s.name}\ndescription: ${s.description || ''}\n---\n\n${s.content || ''}` } },
{ id: 'prefs', label: 'Preferencias', icon: '⚙️', kind: 'ls',
keys: ['elffusscode.model', 'elffusscode.autoedit', 'elffusscode.goalmode', 'elffuss.acer', 'elffuss.resumen', 'elffuss.semantic'] },
],
});
workspace.restore().catch(() => {});
if (workspace.autosaveEnabled()) workspace.autosave({ enabled: true });
import * as telemetry from './telemetry.js';
const $ = id => document.getElementById(id);
telemetry.init('elffuss-code'); // opt-in, apagado por defecto — ver Ajustes
conv.setProvider(rules); // proveedor por defecto para cualquier conversación que se cree
let activeModel = 'rules';
// ---------- chat ----------
// Los `paths` entre backticks del chat (README.md, .elffuss/soul/x.md…) se
// vuelven clicables → abren el fichero en el editor directamente, sin tener
// que ir a buscarlos a mano.
function linkifyFilePaths(root) {
root.querySelectorAll('code').forEach(el => {
const t = el.textContent.trim();
if (!/^[\w.-]+(\/[\w.-]+)*\.[a-z0-9]{1,10}$/i.test(t) || /[{}()<>]/.test(t)) return;
el.classList.add('file-link');
el.title = 'abrir ' + t;
el.onclick = () => openFile(t);
});
}
function addMsg(cls, text) {
const div = document.createElement('div');
div.className = 'msg ' + cls;
// las respuestas se renderizan como markdown (estilo plugin de Claude Code);
// lo del usuario y los errores, como texto plano
if (/^(assistant$|sys)/.test(cls)) {
div.classList.add('md');
// cap defensivo: no volcar miles de nodos si una respuesta trae un archivo entero
div.innerHTML = renderMarkdown(text.length > 8000 ? text.slice(0, 8000) + '\n… (recortado)' : text);
linkifyFilePaths(div);
} else {
div.textContent = text;
}
$('chat-log').appendChild(div);
$('chat-log').scrollTop = $('chat-log').scrollHeight;
return div;
}
// estilo plugin de Claude Code: ● herramienta en negrita + detalle plegable
function addTool(tool, arg) {
const div = document.createElement('div');
div.className = 'msg tool';
const dot = document.createElement('span');
dot.className = 'tdot';
const b = document.createElement('b');
b.textContent = tool;
div.append(dot, b);
if (arg) {
const a = document.createElement('span');
a.className = 'targ';
a.textContent = String(arg).slice(0, 60);
div.appendChild(a);
}
$('chat-log').appendChild(div);
$('chat-log').scrollTop = $('chat-log').scrollHeight;
return div;
}
function addToolResult(result) {
const err = result.startsWith('ERROR');
const det = document.createElement('details');
det.className = 'msg tool-result' + (err ? ' err' : '');
const sum = document.createElement('summary');
sum.textContent = (err ? '⚠️ ' : '') + result.split('\n')[0].slice(0, 90);
const pre = document.createElement('pre');
pre.textContent = result.slice(0, 1500);
det.append(sum, pre);
$('chat-log').appendChild(det);
$('chat-log').scrollTop = $('chat-log').scrollHeight;
return det;
}
const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
// 🎯 Modo Objetivo: tarjeta de plan (planificador → lista de tareas →
// ejecutor). Un solo nodo por plan (mismo id), repintado in-place según
// llegan los eventos 'plan'/'plan_update'/'plan_complete' — así se ve la
// lista evolucionar en vivo en vez de reconstruirse entera cada vez.
const PLAN_ICO = { pending: '⏳', 'in-progress': '🔄', done: '✅', failed: '❌', skipped: '⏭️' };
const PLAN_STATUS_LABEL = { planning: t("planPlanning"), running: t("planRunning"), done: t("planDone"), failed: t("planFailed") };
function renderPlanCard(plan) {
const domId = 'plan-' + plan.id;
let div = document.getElementById(domId);
if (!div) {
div = document.createElement('div');
div.id = domId;
div.className = 'msg plan-card';
$('chat-log').appendChild(div);
}
div.innerHTML =
`${escapeHtml(PLAN_STATUS_LABEL[plan.status] || plan.status)}` +
`
🎯 Objetivo: ${escapeHtml(plan.goal)}
` +
(plan.planText ? `
${escapeHtml(plan.planText)}
` : '') +
`
` +
plan.tasks.map(t =>
`
${PLAN_ICO[t.status] || '⏳'}` +
`
${escapeHtml(t.title)}
${escapeHtml(t.description)}
`
).join('') +
`
`;
$('chat-log').scrollTop = $('chat-log').scrollHeight;
return div;
}
export function thinkingBubble() {
const div = addMsg('thinking', '');
const label = document.createElement('span');
label.textContent = t('thinking');
const gen = document.createElement('div');
gen.className = 'gen';
div.append(label, gen);
let buf = '';
return {
tick(tok) {
buf += tok;
label.textContent = t('writing', { n: buf.length }); // caracteres, no tokens
// si el buffer entró en un bloque de tool-call, no enseñes el JSON crudo
// según va llegando: una frase humana («leyendo app.js…») en su lugar.
const preview = humanizeStreamPreview(buf);
gen.classList.toggle('tool-preview', !!preview);
if (preview) { gen.textContent = '⟐ ' + preview; $('chat-log').scrollTop = $('chat-log').scrollHeight; }
else {
// texto normal: crece y se puede leer (antes solo los últimos 200 y lo
// de arriba desaparecía); se respeta tu scroll si subes a leer.
const pegado = gen.scrollHeight - gen.scrollTop - gen.clientHeight < 28;
gen.textContent = buf;
if (pegado) gen.scrollTop = gen.scrollHeight;
}
},
tool(name) { buf = ''; gen.textContent = ''; label.textContent = t('using', { name }); },
remove() { div.remove(); },
};
}
// Conversaciones (pestañas, en paralelo, con su propia cola): la lógica de
// estado vive en conversations.js — aquí solo se pinta lo que corresponde.
let activeThinking = null;
function renderActiveLog() {
$('chat-log').replaceChildren();
const active = conv.getActive();
activeThinking = null;
if (!active) return;
for (const m of active.agent.history) {
if (m.role === 'user') {
if (m.content.startsWith('[resultado ')) {
const nl = m.content.indexOf('\n');
addToolResult(nl > 0 ? m.content.slice(nl + 1) : '');
} else if (m.content.startsWith(TASK_PREFIX)) {
// objetivo/tareas internas del modo Goal: no son burbujas sueltas —
// la tarjeta de plan las muestra todas. Se pinta justo en el punto
// donde se lanzó el objetivo, igual que aparece en directo.
if (m.content.startsWith(TASK_PREFIX + 'Objetivo: ') && active.plan) renderPlanCard(active.plan);
} else if (!m.content.startsWith('[…')) {
addMsg('user', m.content);
}
} else {
const call = parseToolCall(m.content);
if (call) addTool(call.tool, call.args?.path || call.args?.query || '');
else addMsg('assistant', m.content);
}
}
if (active.pumping) activeThinking = thinkingBubble();
}
// evento del módulo de conversaciones: 'switch' (cambiaste de pestaña o se
// creó una), 'tabs' (se abrió/cerró una pestaña), 'pumping' (empezó/terminó
// de procesar), 'event' (token/texto/tool/resultado/error de un turno real).
// Las conversaciones EN SEGUNDO PLANO nunca tocan el DOM visible — solo se
// refleja su punto de «ocupada» en la barra de pestañas.
function onConvEvent(kind, id, payload) {
if (kind !== 'event') renderTabsBar();
if (kind === 'switch') { renderActiveLog(); return; }
const active = conv.getActive();
if (!active || id !== active.id) return;
if (kind === 'pumping') {
// NO deshabilitar el botón mientras genera: se convierte en PARAR. Antes se
// ponía disabled=payload, y por eso no había forma de cortar un run largo.
$('btn-send').disabled = false;
$('btn-send').classList.toggle('sending', payload);
paintSendButton();
if (payload) { if (!activeThinking) activeThinking = thinkingBubble(); }
else { activeThinking?.remove(); activeThinking = null; }
return;
}
if (kind === 'event') {
const ev = payload;
if (ev.type === 'token') activeThinking?.tick(ev.text);
if (ev.type === 'text') addMsg('assistant', ev.text);
if (ev.type === 'tool') { activeThinking?.tool(ev.call.tool); addTool(ev.call.tool, ev.call.args?.path || ev.call.args?.query || ''); }
if (ev.type === 'tool_result') addToolResult(ev.result);
if (ev.type === 'error') addMsg('assistant err', ev.text);
if (ev.type === 'aborted') addMsg('assistant muted', ev.text);
// 🎯 Modo Objetivo: la tarjeta de plan se crea con 'plan' y se repinta
// in-place en cada 'plan_update'/'plan_complete' (mismo nodo, por id).
if (ev.type === 'plan' || ev.type === 'plan_update' || ev.type === 'plan_complete') renderPlanCard(ev.plan);
}
}
function send(text) {
const active = conv.getActive();
if (!active) return;
addMsg('user', text);
if (goalMode) conv.startGoal(active.id, text);
else conv.send(active.id, text);
}
function renderTabsBar() {
const bar = $('conv-tabs');
if (!bar) return;
bar.replaceChildren();
const active = conv.getActive();
for (const c of conv.getOpenTabs()) {
const el = document.createElement('div');
el.className = 'tab' + (active && c.id === active.id ? ' active' : '') + (c.pumping ? ' tab-busy' : '');
el.dataset.id = c.id;
const name = document.createElement('span');
name.className = 'tab-name';
const title = conv.titleFor(c);
name.textContent = title;
name.title = title;
const x = document.createElement('b');
x.textContent = '×';
x.title = t('tabClose');
x.onclick = e => { e.stopPropagation(); conv.closeTab(c.id); };
el.append(name, x);
el.onclick = () => conv.switchTo(c.id);
bar.appendChild(el);
}
const plus = document.createElement('button');
plus.className = 'tab-add';
plus.title = t('newConv');
plus.textContent = '+';
plus.onclick = () => conv.create();
bar.appendChild(plus);
}
async function renderHistoryPanel() {
const panel = $('history-panel');
panel.replaceChildren();
const head = document.createElement('div');
head.className = 'hp-head';
head.append(document.createTextNode(t('tHistory')));
const xBtn = document.createElement('button');
xBtn.textContent = '✕';
xBtn.onclick = () => { panel.hidden = true; };
head.appendChild(xBtn);
panel.appendChild(head);
const list = document.createElement('div');
list.id = 'hp-list';
panel.appendChild(list);
const all = await conv.listAll();
if (!all.length) {
const empty = document.createElement('div');
empty.className = 'hp-empty';
empty.textContent = t('histEmpty');
list.appendChild(empty);
return;
}
for (const c of all) {
const row = document.createElement('div');
row.className = 'hp-row';
const titEl = document.createElement('div');
titEl.className = 'hp-title';
titEl.textContent = c.title || t('newConv');
const d = new Date(c.updatedAt);
const date = document.createElement('div');
date.className = 'hp-date';
date.textContent = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const del = document.createElement('button');
del.className = 'hp-del';
del.title = t('histDel');
del.textContent = '🗑';
del.onclick = async e => {
e.stopPropagation();
if (!confirm(t('confirmDel', { title: c.title || t('thisConv') }))) return;
await conv.remove(c.id);
renderHistoryPanel();
};
row.append(titEl, date, del);
row.onclick = async () => { await conv.open(c.id); panel.hidden = true; };
list.appendChild(row);
}
}
// ---------- modelos (local primero; externos = ⚙️ opt-in) ----------
const localProviders = { litert: () => import('./providers/litert.js'), onnx: () => import('./providers/onnx.js') };
async function resolveProvider(id) {
if (localProviders[id]) return localProviders[id]();
if (id.startsWith('litert:')) { // Gemma vía LiteRT-LM (build elegido)
const mod = await import('./providers/litert.js');
mod.configure(id.slice(7));
return mod;
}
if (id.startsWith('onnx:')) { // ONNX concreto (Elffuss LM, Qwen3…)
const mod = await import('./providers/onnx.js');
mod.configure(id.slice(5));
return mod;
}
if (id.startsWith('engine:')) { // runtime WebGPU propio (GGUF)
// El motor no se versiona en este repo: llega a web/js/engine/ por rsync,
// igual que en Claw. Si no está, el import falla y se trata como cualquier
// otro fallo de carga.
const mod = await import('./engine/provider.js');
mod.configure(id.slice(7));
return mod;
}
if (id.startsWith('ext:')) {
const cfg = settings.get(id.slice(4));
const mod = await import('./providers/api.js');
mod.configure(cfg);
return mod;
}
return null;
}
// El E4B healed (4.12 GB) NO carga aún en navegador (export prefill_decode, no
// artisan) — descargarlo y fallar deja la GPU en estado inválido y TUMBA la
// pestaña. Se oculta hasta el reexport artisan. Poner true para reactivarlo.
const LITERT_READY = false;
const isMobile = () => matchMedia('(max-width: 820px)').matches || matchMedia('(pointer: coarse)').matches;
// navigator.gpu puede EXISTIR como API sin que haya un adaptador real (ciertos
// Linux/drivers, entornos sandboxed/headless…) — comprobarlo UNA vez al
// arrancar evita ofrecer/elegir Gemma (2-4 GB) cuando de todos modos va a
// fallar al crear el motor (onnx.js y litert.js ya comprueban el adaptador
// por su cuenta como defensa adicional, pero esto evita hasta OFRECERLO).
let realGPU = !!navigator.gpu;
const realGPUCheck = (async () => {
if (!navigator.gpu) return (realGPU = false);
try { return (realGPU = !!(await navigator.gpu.requestAdapter())); }
catch { return (realGPU = false); }
})();
// Capacidad real de la GPU: no basta con que exista navigator.gpu, hay que
// mirar los límites del adaptador — un buffer máximo pequeño no aguanta Gemma.
async function gpuCapacity() {
const mem = navigator.deviceMemory || 8; // GB (grueso, tope 8 en Chrome)
if (!navigator.gpu) return { gpu: false, maxBuf: 0, mem };
try {
const a = await navigator.gpu.requestAdapter();
if (!a) return { gpu: false, maxBuf: 0, mem };
return { gpu: true, maxBuf: a.limits?.maxBufferSize || 0, mem };
} catch { return { gpu: false, maxBuf: 0, mem }; }
}
// Auto-selección: el modelo MÁS GRANDE que entre en esta máquina. Gemma por
// LiteRT si la GPU aguanta sus buffers; si no, un ONNX pequeño (WebGPU o wasm).
async function pickLocalBrain() {
const c = await gpuCapacity();
if (c.gpu && !isMobile()) {
if (c.maxBuf >= 2 ** 31 && c.mem >= 8) return 'litert:gemma-e4b'; // ~4 GB
if (c.maxBuf >= 2 ** 30 && c.mem >= 6) return 'litert:gemma-e2b'; // ~2 GB
}
if (c.gpu && isMobile()) return 'litert:gemma-e2b';
return 'onnx'; // ONNX pequeño: Elffuss LM (1.2B) por defecto; Qwen3 seleccionable
}
// heurística vieja como último recurso síncrono
const defaultBrain = () => !realGPU ? 'onnx' : (isMobile() ? 'litert:gemma-e2b' : 'litert:gemma-e4b');
// El runtime propio y su modelo grande se SONDEAN, no se cablean: el motor
// llega por rsync y el modelo son gigabytes que pueden no estar alojados.
// Ofrecer algo que no está sería prometer un fallo que llega DESPUÉS de que el
// usuario haya esperado, que es la peor forma de fallar.
let ENGINE_READY = false, MODEL27_READY = false;
let MODEL27_LABEL = 'Modelo grande';
let MODEL27_GB = 0;
const engineCheck = (async () => {
try { return (ENGINE_READY = (await fetch('js/engine/provider.js', { method: 'HEAD', cache: 'no-store' })).ok); }
catch { return (ENGINE_READY = false); }
})();
// Se le pregunta AL MOTOR por sus propios ficheros. Aquí había cableada la URL
// del 27B de un solo trozo; cuando pasó a dos fragmentos en dos hosts, esta
// línea siguió sondeando el fichero viejo y dando 200 de milagro, porque aún
// estaba en el servidor. El día que se borre, la opción desaparece del selector
// y nadie ata los dos cabos. Se importa el registro (unos kilobytes) y no el
// proveedor, que arrastra el motor entero.
const modelo27Check = (async () => {
try {
const reg = await import('./engine/registro.js');
// El NOMBRE y el TAMAÑO salen del registro del motor, no de un literal aquí.
// Estaban a mano y al cambiar de modelo mintieron en tres cosas a la vez:
// decían «Qwen3.8-27B IQ1» y «~7,6 GB» cuando lo que se sirve es el ternario
// de Prism y son 7,2 GB. Un nombre duplicado es un nombre que se queda viejo.
const m27 = reg.MODELS && reg.MODELS['qwen38-27b'];
if (m27) { MODEL27_LABEL = m27.label || MODEL27_LABEL; MODEL27_GB = (m27.bytes || 0) / 1e9; }
return (MODEL27_READY = await reg.disponible('qwen38-27b'));
} catch { return (MODEL27_READY = false); }
})();
// Los modelos LOCALES, en un solo sitio. Los usan el desplegable y las tarjetas
// de los ajustes: hasta ahora eran dos listas escritas a mano y la de ajustes se
// quedó vieja — no ofrecía NI el motor propio ni el 27B, así que desde ahí era
// imposible elegir Bonsai por mucho que el motor lo sirviera. Es justo el modo
// de fallo que avisa el comentario de `modelo27Check`: un nombre duplicado es un
// nombre que se queda viejo.
function modelOptionsLocales() {
const opts = [];
if (realGPU) opts.push({ id: 'litert:gemma-e4b', label: 'Gemma-4 E4B · LiteRT-LM (~4 GB) ★' });
if (realGPU) opts.push({ id: 'litert:gemma-e2b', label: 'Gemma-4 E2B · LiteRT-LM (~2 GB)' });
opts.push({ id: 'onnx', label: 'Elffuss LM (healed · 850 MB) — ligero' });
opts.push({ id: 'onnx:qwen3.5-0.8b', label: 'Qwen3.5-0.8B · WebGPU (~600 MB)' });
if (realGPU && ENGINE_READY) opts.push({ id: 'engine:qwen35-0.8b', label: 'Qwen3.5-0.8B · Elffuss Engine (~800 MB)' });
// El 27B se guarda tras la primera descarga (repartido en dos servidores y
// troceado, porque el navegador corta un fichero suelto sobre 1,94 GB), así
// que son «gigas una vez», no «gigas cada vez». Sigue siendo lento: la espera
// cae entera antes de la primera palabra.
if (realGPU && ENGINE_READY && MODEL27_READY) opts.push({ id: 'engine:qwen38-27b', label: `${MODEL27_LABEL} · Elffuss Engine (${MODEL27_GB.toFixed(1)} GB, se guarda: solo se baja la primera vez) — lento`, group: '⚠ Avanzado' });
opts.push({ id: 'rules', label: t('setModelRulesName') });
return opts;
}
function modelOptions() {
return [...modelOptionsLocales(), ...settings.enabledExternals()];
}
function rebuildSelect() {
const sel = $('model-select');
sel.replaceChildren();
// Los grupos avisados («⚠ Avanzado») van SIEMPRE al final. Si se crean según
// aparecen, basta una opción agrupada en mitad de la lista para que las
// siguientes queden colgando bajo el encabezado de aviso — le pasó a «Básico
// (sin modelo)» en Claw, que es la opción más segura que hay.
const grupos = new Map();
for (const o of modelOptions()) {
const opt = document.createElement('option');
opt.value = o.id;
opt.textContent = o.label;
if (o.group) {
let g = grupos.get(o.group);
if (!g) { g = document.createElement('optgroup'); g.label = o.group; grupos.set(o.group, g); }
g.appendChild(opt);
} else {
sel.appendChild(opt);
}
}
for (const g of grupos.values()) sel.appendChild(g);
sel.value = activeModel;
}
// progreso del modelo: píldora prominente visible sobre landing e IDE
function showModelProgress(text, pct = null) {
const box = $('model-progress');
if (text == null) { box.hidden = true; return; }
box.hidden = false;
$('model-progress-text').textContent = text;
const bar = $('model-bar');
if (pct == null) {
bar.classList.add('indet'); // sin % conocido → barra animada
bar.style.width = '';
} else {
bar.classList.remove('indet');
bar.style.width = pct + '%';
}
}
let loadingId = null;
async function changeModel(id) {
if (id === 'rules') {
conv.setProvider(rules);
activeModel = 'rules';
localStorage.setItem('elffusscode.model', 'rules');
$('model-dot').className = 'dot off';
return true;
}
if (loadingId === id || activeModel === id) return true; // un solo modelo, una sola carga
loadingId = id;
$('model-dot').className = 'dot loading';
showModelProgress(t("stModelLoadingAI"), 4);
try {
const mod = await resolveProvider(id);
await mod.load(p => {
if (typeof p === 'string') return showModelProgress(p);
if (p?.status === 'progress' && p.total) {
const pct = Math.round(p.loaded / p.total * 100);
showModelProgress(t('modelLoading', { pct, loaded: (p.loaded / 1e6 | 0), total: (p.total / 1e6 | 0) }), pct);
}
});
conv.setProvider(mod);
// El medidor de contexto pasa a hablar del modelo QUE ESTÁ CARGADO. Quien no
// sepa decir su contexto deja el valor de reserva, así que esto no puede
// romper a un proveedor que no lo implemente.
try { if (typeof mod.contextTokens === 'function') setCtxBudget(mod.contextTokens()); }
catch { /* el medidor no vale un fallo de carga */ }
activeModel = id;
localStorage.setItem('elffusscode.model', id);
$('model-dot').className = 'dot on';
showModelProgress(t('modelReadyBadge'), 100);
setTimeout(() => showModelProgress(null), 2500);
$('statusbar').textContent = t('modelReady');
rebuildSelect();
return true;
} catch (e) {
console.error('[elffuss-code] fallo cargando', e);
// Un Gemma (LiteRT) que no cabe → cae al Elffuss LM healed (onnx, ligero).
if (id.startsWith('litert') && !_fellBack) {
_fellBack = true;
sessionStorage.setItem('elffusscode.skipGemma', '1');
showModelProgress(t('fallbackToast'));
loadingId = null;
const okc = await changeModel('onnx');
_fellBack = false;
if (okc) return true;
}
conv.setProvider(rules);
activeModel = 'rules';
$('model-dot').className = 'dot off';
showModelProgress('⚠️ No pude cargar el modelo: ' + (e?.message || e));
setTimeout(() => showModelProgress(null), 6000);
rebuildSelect();
return false;
} finally {
loadingId = null;
}
}
let _fellBack = false;
// El cerebro empieza a descargarse desde el PRIMER segundo, en segundo plano,
// mientras el usuario elige la carpeta. Solo el modelo local — los externos
// jamás se activan solos.
// Cadena de respaldo: el modelo preferido → LFM2.5 local → básico. Si el E4B
// healed falla (p. ej. «HF_Tokenizer_Zlib not supported» del runtime LiteRT),
// cae solo a LFM2.5 en vez de dejar al usuario sin cerebro.
async function preloadModel() {
const saved = localStorage.getItem('elffusscode.model');
if (saved === 'rules') return;
await realGPUCheck; // que defaultBrain()/modelOptions() vean el adaptador real, no solo la API
// Las sondas del motor y del modelo grande resuelven DESPUÉS de que se pinte
// el selector, así que hay que repintarlo: sin esto la opción no aparece
// hasta que algo más fuerce un refresco, y el usuario no la ve nunca.
const [hayMotor, hay27] = await Promise.all([engineCheck, modelo27Check]);
if (hayMotor || hay27) rebuildSelect();
const avail = new Set(modelOptions().map(o => o.id));
const skipGemma = sessionStorage.getItem('elffusscode.skipGemma') === '1';
const def = skipGemma ? 'onnx' : await pickLocalBrain();
const chain = [...new Set([saved, def, 'onnx']
.filter(id => id && id !== 'rules' && avail.has(id)))];
// MÓVIL: no descargar ~1,9 GB sin permiso. defaultBrain() elige gemma-e2b en
// móvil, y medido el 2026-09-08 con un iPhone emulado contra producción: al
// abrir la página arrancaban OCHO peticiones de pesos sin que el usuario
// pidiera nada. Son casi dos gigas de datos de alguien que solo entró a
// mirar, y encima el navegador de móvil mata la pestaña bastante antes de
// sostener ese modelo, así que se gastaban para nada. Se avisa y se espera.
if (isMobile() && localStorage.getItem('elffusscode.movil.ok') !== '1') {
avisoMovil('~1,9 GB', () => {
(async () => { for (const id of chain) if (await changeModel(id)) return; })();
});
return;
}
for (const id of chain) if (await changeModel(id)) return;
}
// ---------- panel ⚙️ Ajustes: modelo (cerebro) + API keys ----------
const el = (t, cls, txt) => { const e = document.createElement(t); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; };
function settingsShell(title) {
const box = $('settings-panel');
box.hidden = false;
box.replaceChildren();
const close = el('button', 'panel-close'); close.innerHTML = UI.close; close.title = t("close");
close.onclick = () => { box.hidden = true; };
box.append(close, el('h3', 'panel-title', title));
return box;
}
function showBridgeMsg(card, text, isErr) {
let el2 = card.querySelector('.br-msg');
if (!el2) { el2 = document.createElement('div'); el2.className = 'br-msg muted'; el2.style.cssText = 'font-size:.7rem;margin-top:6px'; card.appendChild(el2); }
el2.textContent = text;
el2.style.color = isErr ? '#ff6b8b' : '';
}
// pequeño estallido de fuegos artificiales (canvas, un par de segundos) para
// que se entienda de un vistazo que el bridge quedó conectado.
function fireworks() {
const cv = document.createElement('canvas');
cv.id = 'fireworks-fx';
cv.width = innerWidth; cv.height = innerHeight;
document.body.appendChild(cv);
const ctx = cv.getContext('2d');
const colors = ['#ff4d8d', '#7c5cff', '#49e8ff', '#3fb970', '#ffd479'];
const bursts = Array.from({ length: 5 }, () => ({
x: innerWidth * (0.2 + Math.random() * 0.6), y: innerHeight * (0.2 + Math.random() * 0.4),
t0: performance.now() + Math.random() * 500,
particles: Array.from({ length: 40 }, (_, i) => { const a = (i / 40) * Math.PI * 2; const sp = 2 + Math.random() * 3; return { a, sp, color: colors[i % colors.length] }; }),
}));
const start = performance.now();
function frame(now) {
ctx.clearRect(0, 0, cv.width, cv.height);
let alive = false;
for (const b of bursts) {
const age = (now - b.t0) / 1000;
if (age < 0) { alive = true; continue; }
if (age > 1.4) continue;
alive = true;
for (const p of b.particles) {
const r = p.sp * age * 40;
const x = b.x + Math.cos(p.a) * r, y = b.y + Math.sin(p.a) * r + age * age * 60;
ctx.globalAlpha = Math.max(0, 1 - age / 1.4);
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(x, y, 2.4, 0, Math.PI * 2); ctx.fill();
}
}
if (alive && now - start < 3000) requestAnimationFrame(frame);
else cv.remove();
}
requestAnimationFrame(frame);
}
function renderSettings() {
const box = settingsShell(t("setTitle"));
// --- Cerebro (modelo) ---
box.append(el('div', 'sk-h', t("setBrainTitle")));
// De `modelOptionsLocales()`, no de una copia: así lo que se puede elegir aquí
// es exactamente lo que se puede elegir en el desplegable, hoy y cuando se
// añada el siguiente modelo. El texto de debajo sigue siendo a medida cuando
// lo hay; si no, se saca de la propia etiqueta.
const SUBS = {
'litert:gemma-e4b': t("setModelE4bSub"),
'litert:gemma-e2b': t("setModelE2bSub"),
'onnx': t("setModelOnnxSub"),
'rules': t("setModelRulesSub"),
};
const LOCAL = modelOptionsLocales().map((o) => {
const partes = String(o.label).split(' · ');
return {
id: o.id,
name: partes[0].trim(),
sub: SUBS[o.id] || partes.slice(1).join(' · ').trim(),
};
});
const grid = el('div', 'model-grid');
for (const m of LOCAL) {
if (m.need === 'gpu' && !realGPU) continue;
const card = el('button', 'model-card' + (activeModel === m.id ? ' active' : '') + (m.disabled ? ' disabled' : ''));
if (m.disabled) card.disabled = true;
card.innerHTML = `${m.name}${m.sub}`;
card.onclick = () => { changeModel(m.id); renderSettings(); };
grid.appendChild(card);
}
box.appendChild(grid);
// --- Espacio de trabajo: guardar conversaciones/skills en disco ---
box.append(el('div', 'sk-h', '💾 ' + t('wsTitle')));
const wsCard = el('div', 'prov-card');
const wsState = el('div', 'muted'); wsState.style.fontSize = '.78rem';
const wsRow = el('div', 'field'); wsRow.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;margin-top:8px';
const wsInv = el('div'); wsInv.style.cssText = 'margin-top:10px;font-size:.78rem';
wsCard.append(el('span', 'muted', t('wsDesc')), wsState, wsRow, wsInv);
box.appendChild(wsCard);
const wsKb = n => n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n > 1024 ? Math.round(n / 1024) + ' KB' : n + ' B';
async function paintWs() {
const st = workspace.status();
wsRow.replaceChildren();
wsState.textContent = !st.supported.pick ? t('wsNoSupport')
: !st.folder ? t('wsNoFolder')
: st.ready ? t('wsFolder', { name: st.folder }) : t('wsNeedsPerm', { name: st.folder });
const mk = (label, fn) => { const b = el('button', 'prov-use', label); b.onclick = fn; wsRow.appendChild(b); };
if (st.supported.pick) {
if (!st.folder) {
mk(t('wsPick'), async () => { try { await workspace.pick(); await paintWs(); } catch (e) { wsState.textContent = '⚠️ ' + e.message; } });
// Code ya tiene una carpeta abierta: reutilizarla es un clic
if (codeTools.handle()) mk(t('wsUseProject'), async () => { await workspace.adopt(codeTools.handle()); await paintWs(); });
} else if (!st.ready) mk(t('wsRegrant'), async () => { try { await workspace.regrant(); await paintWs(); } catch (e) { wsState.textContent = '⚠️ ' + e.message; } });
else {
mk(t('wsSaveNow'), async () => { try { const r = await workspace.save(); wsState.textContent = r.ok ? t('wsSaved', { n: r.wrote.length }) : '⚠️ ' + (r.errors[0]?.error || ''); } catch (e) { wsState.textContent = '⚠️ ' + e.message; await paintWs(); } });
const lbl = el('label', null); lbl.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:.78rem';
const chk = el('input'); chk.type = 'checkbox'; chk.checked = workspace.autosaveEnabled();
chk.onchange = () => workspace.autosave({ enabled: chk.checked });
lbl.append(chk, el('span', 'muted', t('wsAuto'))); wsRow.appendChild(lbl);
mk(t('wsForget'), async () => { await workspace.forget(); await paintWs(); });
}
}
mk(t('wsDownload'), async () => {
const blob = await workspace.bundle();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `elffuss-code-${new Date().toISOString().slice(0, 10)}.json`;
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000);
});
wsInv.replaceChildren(el('b', null, t('wsWhat')));
for (const it of await workspace.inventory()) {
const line = el('div'); line.style.cssText = 'display:flex;justify-content:space-between;padding:3px 0';
line.append(el('span', null, `${it.icon} ${it.label}`), el('span', 'muted', it.error ? '⚠️' : `${it.count} · ${wsKb(it.bytes)}`));
wsInv.appendChild(line);
}
}
paintWs().catch(() => {});
// --- Almacenamiento del modelo (caché persistente) ---
box.append(el('div', 'sk-h', t('setStoreTitle')));
const storeCard = el('div', 'prov-card');
const storeInfo = el('span', null, t('setCalc')); storeInfo.style.cssText = 'font-size:.8rem;color:var(--fg)';
const storeSub = el('div', 'field'); storeSub.style.marginTop = '2px';
const storeMuted = el('span', 'muted', ''); storeMuted.style.fontSize = '.72rem';
const clearBtn = el('button', 'prov-use', t('setClear'));
clearBtn.style.marginTop = '8px';
clearBtn.onclick = async () => { clearBtn.textContent = t('setClearing'); await clearModelCache(); await paintStorage(); clearBtn.textContent = t('setClear'); };
const storeHead = el('div', 'prov-head'); storeHead.append(storeInfo);
storeCard.append(storeHead, storeMuted, clearBtn);
box.appendChild(storeCard);
async function paintStorage() {
const { usage, quota, persisted } = await cacheEstimate();
const gb = n => (n / 1073741824).toFixed(2) + ' GB';
storeInfo.textContent = usage ? t('setCached', { gb: gb(usage) }) : t('setNone');
storeMuted.textContent = (persisted ? t('setPersist') : t('setNoPersist'))
+ (quota ? t('setLimit', { gb: gb(quota) }) : '');
}
paintStorage();
// --- Bridge local (ejecución REAL en tu máquina: node, npm, python…) ---
box.append(el('div', 'sk-h', t("setBridgeTitle")));
const brCard = el('div', 'prov-card bridge-card');
const guessOS = () => {
const ua = navigator.userAgent;
if (/Mac/.test(ua)) return /Intel/.test(navigator.platform) && !/arm/i.test(ua) ? 'elffuss-bridge-mac-intel' : 'elffuss-bridge-mac-arm64';
if (/Win/.test(ua)) return 'elffuss-bridge-windows.exe';
if (/Linux/.test(ua)) return /aarch64|arm64/i.test(ua) ? 'elffuss-bridge-linux-arm64' : 'elffuss-bridge-linux';
return 'elffuss-bridge-mac-arm64';
};
const OTHER = { 'elffuss-bridge-mac-arm64': 'Mac (Apple Silicon)', 'elffuss-bridge-mac-intel': 'Mac (Intel)', 'elffuss-bridge-windows.exe': 'Windows', 'elffuss-bridge-linux': 'Linux (x64)', 'elffuss-bridge-linux-arm64': 'Linux (ARM)' };
const primary = guessOS();
brCard.innerHTML =
`
`).join('') +
`` +
`` +
`` +
``;
box.appendChild(brCard);
brCard.querySelector('#br-folder').value = bridge.getFolder();
brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
const paintBridge = () => {
// el panel puede haberse cerrado entre sondeos de fondo — si ya no está
// en el DOM, nos desuscribimos en vez de tocar nodos huérfanos.
if (!document.body.contains(brCard)) { bridge.onStatusChange(() => {}); return; }
const on = bridge.isConnected();
brCard.querySelector('#br-dot').className = 'dot ' + (on ? 'on' : 'off');
brCard.querySelector('#br-status').textContent = on ? t("setBridgeOnStatus") : t("setBridgeDisconnected");
if (on) brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
};
paintBridge();
bridge.onStatusChange(paintBridge); // repinta solo si el bridge conecta/cae mientras Ajustes está abierto
brCard.querySelector('#br-connect').onclick = async () => {
const btn = brCard.querySelector('#br-connect');
bridge.setFolder(brCard.querySelector('#br-folder').value);
const token = brCard.querySelector('#br-token').value.trim();
if (!token) return showBridgeMsg(brCard, t("setBridgeNoToken"), true);
btn.disabled = true; btn.textContent = t("setBridgeConnecting");
try { await bridge.connect(token); paintBridge(); showBridgeMsg(brCard, t("setBridgeConnectedMsg")); fireworks(); }
catch (e) { showBridgeMsg(brCard, '⚠️ ' + e.message, true); }
finally { btn.disabled = false; btn.textContent = t("setBridgeConnect"); }
};
// --- Permisos de ejecución (mismo interruptor que > Auto de la barra) ---
box.append(el('div', 'sk-h', t("setPermTitle")));
const permCard = el('div', 'prov-card');
permCard.innerHTML =
`` +
`
terminal.run ya se ejecuta siempre sin preguntar. Esto controla solo la escritura de ficheros (code.write/code.edit) — desmárcalo si prefieres revisar cada cambio antes de aplicarlo.
Igual que el botón 🎯 Goal de la barra — un planificador crea la lista de tareas y un ejecutor las va cumpliendo, marcando cada una como hecha o fallida (si una falla, las siguientes se saltan en vez de seguir a ciegas).