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
1967 lines
94 KiB
JavaScript
1967 lines
94 KiB
JavaScript
// 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 =
|
||
`<span class="plan-status">${escapeHtml(PLAN_STATUS_LABEL[plan.status] || plan.status)}</span>` +
|
||
`<div class="plan-head">🎯 Objetivo: ${escapeHtml(plan.goal)}</div>` +
|
||
(plan.planText ? `<div class="plan-summary">${escapeHtml(plan.planText)}</div>` : '') +
|
||
`<ul class="plan-tasks">` +
|
||
plan.tasks.map(t =>
|
||
`<li class="task-${t.status}"><span class="task-ico">${PLAN_ICO[t.status] || '⏳'}</span>` +
|
||
`<div><div class="task-title">${escapeHtml(t.title)}</div><div class="task-desc">${escapeHtml(t.description)}</div></div></li>`
|
||
).join('') +
|
||
`</ul>`;
|
||
$('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 = `<b>${m.name}</b><span>${m.sub}</span>`;
|
||
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 =
|
||
`<div class="prov-head"><span id="br-dot" class="dot off"></span><b>Bridge local</b><span id="br-status" class="muted" style="margin-left:auto;font-size:.72rem">desconectado</span></div>` +
|
||
`<p class="muted" style="font-size:.72rem;margin:6px 0">${t('setBridgeDesc')}</p>` +
|
||
`<a class="prov-use" style="text-decoration:none;display:inline-block" href="bridge-dl/${primary}" download>${t('setBridgeDownload',{os:OTHER[primary]})}</a>` +
|
||
`<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">${t('setBridgeOtherOS')}</summary>` +
|
||
Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
|
||
`</details>` +
|
||
`<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">${t('setBrToken')}</label><input id="br-token" placeholder="${t('brTokenPh')}"></div>` +
|
||
`<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">${t('setBridgeFolderLabel')}</label><input id="br-folder" placeholder="${t('setBridgeFolderPh')}"></div>` +
|
||
`<button id="br-connect" class="prov-use" style="margin-top:8px">${t('setBridgeConnect')}</button>`;
|
||
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 =
|
||
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
||
`<input type="checkbox" id="perm-autoedit"> ` +
|
||
`<span>Ejecutar todo automáticamente (escribir/editar ficheros y comandos de terminal), sin pedir confirmación</span>` +
|
||
`</label>` +
|
||
`<p class="muted" style="font-size:.7rem;margin:6px 0 0">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.</p>`;
|
||
box.appendChild(permCard);
|
||
const permCheckbox = permCard.querySelector('#perm-autoedit');
|
||
permCheckbox.checked = autoEdit;
|
||
permCheckbox.onchange = () => {
|
||
autoEdit = permCheckbox.checked;
|
||
localStorage.setItem('elffusscode.autoedit', autoEdit ? '1' : '0');
|
||
paintAuto();
|
||
};
|
||
|
||
// --- 🎯 Modo Objetivo (planificador + ejecutor, mismo patrón que Auto) ---
|
||
box.append(el('div', 'sk-h', t("setGoalTitle")));
|
||
const goalCard = el('div', 'prov-card');
|
||
goalCard.innerHTML =
|
||
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
||
`<input type="checkbox" id="perm-goalmode"> ` +
|
||
`<span>Tratar el próximo mensaje como un objetivo: lo descompone en tareas y las ejecuta una a una</span>` +
|
||
`</label>` +
|
||
`<p class="muted" style="font-size:.7rem;margin:6px 0 0">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).</p>`;
|
||
box.appendChild(goalCard);
|
||
const goalCheckbox = goalCard.querySelector('#perm-goalmode');
|
||
goalCheckbox.checked = goalMode;
|
||
goalCheckbox.onchange = () => {
|
||
goalMode = goalCheckbox.checked;
|
||
localStorage.setItem('elffusscode.goalmode', goalMode ? '1' : '0');
|
||
paintGoal();
|
||
};
|
||
|
||
// --- 📨 Errores y feedback (opt-in — apagado no sale NADA de tu máquina) ---
|
||
box.append(el('div', 'sk-h', t("setTelTitle")));
|
||
const telCard = el('div', 'prov-card');
|
||
telCard.innerHTML =
|
||
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
||
`<input type="checkbox" id="tel-enabled"> ` +
|
||
`<span>Enviar automáticamente los errores técnicos que ocurran, para poder arreglarlos</span>` +
|
||
`</label>` +
|
||
`<p class="muted" style="font-size:.7rem;margin:6px 0 10px">Apagado por defecto — tu código y el contenido de tus proyectos NUNCA se incluyen, solo el mensaje de error técnico, la pila y datos del navegador.</p>` +
|
||
`<label class="muted" style="font-size:.68rem">O manda algo tú directamente (un fallo que viste, algo que eches en falta…)</label>` +
|
||
`<textarea id="tel-feedback" rows="2" style="width:100%;margin-top:4px;resize:vertical;background:var(--bg2);border:1px solid var(--line);border-radius:8px;color:var(--fg);padding:6px 8px;font:inherit" placeholder="${t('setFeedbackPh')}"></textarea>` +
|
||
`<button id="tel-send" class="prov-use" style="margin-top:6px">Enviar</button>` +
|
||
`<span id="tel-msg" class="muted" style="font-size:.7rem;margin-left:8px"></span>`;
|
||
box.appendChild(telCard);
|
||
const telCheckbox = telCard.querySelector('#tel-enabled');
|
||
telCheckbox.checked = telemetry.isEnabled();
|
||
telCheckbox.onchange = () => telemetry.setEnabled(telCheckbox.checked);
|
||
telCard.querySelector('#tel-send').onclick = async () => {
|
||
const ta = telCard.querySelector('#tel-feedback');
|
||
const msg = telCard.querySelector('#tel-msg');
|
||
const text = ta.value.trim();
|
||
if (!text) { msg.textContent = t("setTelEmpty"); return; }
|
||
const wasEnabled = telemetry.isEnabled();
|
||
if (!wasEnabled) telemetry.setEnabled(true); // el envío manual es explícito, se permite aunque el automático esté apagado
|
||
await telemetry.sendFeedback(text);
|
||
if (!wasEnabled) telemetry.setEnabled(false); // no activa el automático de fondo si no lo pidió
|
||
ta.value = '';
|
||
msg.textContent = t("setTelThanks");
|
||
setTimeout(() => { msg.textContent = ''; }, 4000);
|
||
};
|
||
|
||
// --- Proveedores externos (API keys) ---
|
||
box.append(el('div', 'sk-h', t("setProvTitle")));
|
||
for (const [id, c] of Object.entries(settings.configs())) {
|
||
const card = el('div', 'prov-card' + (c.enabled ? ' on' : ''));
|
||
const head = el('div', 'prov-head');
|
||
const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = c.enabled;
|
||
const title = el('b', null, c.label);
|
||
const use = el('button', 'prov-use', t("setProvUse"));
|
||
use.hidden = !c.enabled || activeModel === 'ext:' + id;
|
||
use.onclick = () => { changeModel('ext:' + id); renderSettings(); };
|
||
head.append(toggle, title, use);
|
||
const fields = el('div', 'prov-fields' + (c.enabled ? '' : ' hide'));
|
||
const model = document.createElement('input'); model.value = c.model || ''; model.placeholder = t('setModelPh');
|
||
const key = document.createElement('input'); key.type = 'password'; key.value = c.apiKey || '';
|
||
key.placeholder = c.kind === 'anthropic' ? 'sk-ant-…' : (id === 'ollama' || id === 'server' ? t('keyNeeded') : 'sk-…');
|
||
fields.append(labeled(t('lblModel'), model), labeled(t('lblApiKey'), key));
|
||
const save = () => {
|
||
settings.update(id, { enabled: toggle.checked, model: model.value.trim(), apiKey: key.value });
|
||
rebuildSelect();
|
||
};
|
||
toggle.onchange = () => { save(); renderSettings(); };
|
||
model.onchange = key.onchange = save;
|
||
card.append(head, fields);
|
||
box.appendChild(card);
|
||
}
|
||
|
||
// --- Skills ---
|
||
box.append(el('div', 'sk-h', 'Skills'));
|
||
const skBtn = el('button', 'primary wide', '🧩 Gestionar skills de Claude Code');
|
||
skBtn.textContent = t("setManageSkillsBtn");
|
||
skBtn.onclick = openSkillsPanel;
|
||
box.appendChild(skBtn);
|
||
}
|
||
function labeled(label, input) {
|
||
const w = el('label', 'field'); w.append(el('span', 'muted', label), input); return w;
|
||
}
|
||
|
||
// ---------- arranque ----------
|
||
let galaxy = null;
|
||
async function enterIDE(handle) {
|
||
const name = await codeTools.openProject(handle);
|
||
shell.reset(); // el terminal arranca en la raíz del proyecto
|
||
galaxy?.stop();
|
||
$('landing').hidden = true;
|
||
$('ide').hidden = false;
|
||
$('project-name').textContent = name;
|
||
$('explorer-proj').textContent = name.toUpperCase();
|
||
await initEditor();
|
||
await refreshTree(handle);
|
||
// el sistema de conversaciones primero: renderActiveLog() REEMPLAZA todo
|
||
// #chat-log con el historial replay de la conversación activa — si el
|
||
// saludo se añadiera antes, desaparecería sin dejar rastro.
|
||
await conv.init({ onEvent: onConvEvent });
|
||
renderTabsBar();
|
||
renderActiveLog();
|
||
addMsg('sys', t('welcome', { name }));
|
||
refreshGit();
|
||
preloadModel(); // por si el arranque en la landing no llegó a dispararse
|
||
}
|
||
|
||
async function boot() {
|
||
applyI18n(); // localiza el chrome estático (placeholder) antes de pintar nada
|
||
// Caché de modelos (persistente + service worker) ANTES de descargar nada,
|
||
// para que hasta la primera descarga de pesos quede cacheada y no se repita.
|
||
ensureModelCache().finally(() => preloadModel());
|
||
bridge.tryAutoConnect(); // reconecta en silencio si ya se conectó antes (mismo token)
|
||
bridge.startAutoDetect(); // sigue sondeando en 2º plano: detecta el bridge si se arranca/reinicia más tarde
|
||
// galaxia en la landing (misma técnica que Elffuss)
|
||
import('./splash-gl.js').then(m => { galaxy = m.startGalaxy($('landing')); }).catch(() => {});
|
||
|
||
$('open-project').onclick = async () => {
|
||
try { await enterIDE(await window.showDirectoryPicker({ mode: 'readwrite' })); }
|
||
catch { /* cancelado */ }
|
||
};
|
||
|
||
// clonar un repo público (GitHub/Bitbucket): elige carpeta destino, se
|
||
// descarga ahí (fichero a fichero, sin git) y se abre como proyecto.
|
||
// RESUMIBLE: el trabajo (lista de ficheros + cuáles ya se bajaron + el
|
||
// handle de la carpeta) se persiste en IndexedDB — si el usuario navega
|
||
// atrás, cierra la pestaña o recarga a medias, al volver se ofrece
|
||
// «Reanudar» en vez de forzar a empezar de cero.
|
||
const cloneUrl = $('clone-url'), cloneBtn = $('clone-btn'), cloneStatus = $('clone-status');
|
||
const showCloneStatus = (text, isErr) => { cloneStatus.textContent = text; cloneStatus.hidden = !text; cloneStatus.classList.toggle('err', !!isErr); };
|
||
const saveCloneJob = job => db.set('kv', 'cloneJob', job).catch(() => {});
|
||
const loadCloneJob = () => db.get('kv', 'cloneJob').catch(() => null);
|
||
const clearCloneJob = () => db.del('kv', 'cloneJob').catch(() => {});
|
||
|
||
async function runDownload(job) {
|
||
cloneBtn.disabled = true;
|
||
const doneSet = new Set(job.done);
|
||
const clone = await import('./tools/clone.js');
|
||
try {
|
||
await clone.downloadFiles(job, job.handle, doneSet,
|
||
p => showCloneStatus(p.text || ''),
|
||
async path => {
|
||
job.done.push(path);
|
||
// persistir el progreso poco a poco (no en cada fichero: de sobra
|
||
// para no perder más de un puñado si se interrumpe a media descarga)
|
||
if (job.done.length % 5 === 0) await saveCloneJob(job);
|
||
});
|
||
await saveCloneJob(job);
|
||
showCloneStatus(`✔ ${job.files.length} ficheros descargados` + (job.skipped ? ` (${job.skipped} omitidos por tamaño)` : ''));
|
||
await clearCloneJob();
|
||
await enterIDE(job.handle);
|
||
} catch (e) {
|
||
await saveCloneJob(job); // que lo ya bajado no se pierda aunque falle a mitad
|
||
showCloneStatus('⚠️ ' + (e?.message || e) + ' — puedes reintentar, retoma donde se quedó', true);
|
||
} finally { cloneBtn.disabled = false; }
|
||
}
|
||
|
||
const runClone = async () => {
|
||
const url = cloneUrl.value.trim();
|
||
if (!url) return;
|
||
let handle;
|
||
try { handle = await window.showDirectoryPicker({ mode: 'readwrite' }); }
|
||
catch { return; } // cancelado, sin error
|
||
showCloneStatus('Listando el repo…');
|
||
try {
|
||
const clone = await import('./tools/clone.js');
|
||
const info = await clone.listRepo(url);
|
||
const job = { url, handle, done: [], ...info };
|
||
await saveCloneJob(job);
|
||
await runDownload(job);
|
||
} catch (e) {
|
||
showCloneStatus('⚠️ ' + (e?.message || e), true);
|
||
}
|
||
};
|
||
cloneBtn.addEventListener('click', runClone);
|
||
cloneUrl.addEventListener('keydown', e => { if (e.key === 'Enter') runClone(); });
|
||
|
||
// ¿había una descarga a medias de una sesión anterior? ofrece reanudarla
|
||
const job = await loadCloneJob();
|
||
if (job && job.handle && job.files && job.done.length < job.files.length) {
|
||
const banner = $('resume-clone');
|
||
banner.hidden = false;
|
||
banner.querySelector('.rc-text').textContent = `Descarga a medias de ${job.owner}/${job.repo} (${job.done.length}/${job.files.length} ficheros)`;
|
||
$('resume-clone-btn').onclick = async () => {
|
||
banner.hidden = true;
|
||
try {
|
||
const q = job.handle.queryPermission ? await job.handle.queryPermission({ mode: 'readwrite' }) : 'granted';
|
||
if (q !== 'granted' && await job.handle.requestPermission({ mode: 'readwrite' }) !== 'granted') throw new Error('permiso denegado para la carpeta');
|
||
showCloneStatus(`Reanudando ${job.owner}/${job.repo}…`);
|
||
await runDownload(job);
|
||
} catch (e) { showCloneStatus('⚠️ ' + (e?.message || e), true); }
|
||
};
|
||
$('discard-clone-btn').onclick = async () => { await clearCloneJob(); banner.hidden = true; };
|
||
} else if (job) {
|
||
await clearCloneJob(); // job corrupto o ya completo: limpiar
|
||
}
|
||
|
||
// gancho de test: ?test-opfs usa el almacenamiento del navegador como proyecto
|
||
if (location.search.includes('test-opfs')) {
|
||
const opfs = await navigator.storage.getDirectory();
|
||
return enterIDE(opfs);
|
||
}
|
||
|
||
// reabrir el último proyecto (un clic si el navegador exige gesto)
|
||
const prev = await codeTools.restoreProject();
|
||
if (prev?.ready) return enterIDE(await db.get('kv', 'project'));
|
||
if (prev?.handle) {
|
||
const btn = $('reopen-project');
|
||
btn.hidden = false;
|
||
btn.textContent = `Reabrir «${prev.name}»`;
|
||
btn.onclick = async () => {
|
||
try { await codeTools.regrant(prev.handle); await enterIDE(prev.handle); }
|
||
catch (e) { $('statusbar').textContent = '⚠️ ' + e.message; }
|
||
};
|
||
}
|
||
}
|
||
|
||
// Historial del prompt del chat: ↑/↓ recuperan lo enviado (como una shell).
|
||
// Persistente entre recargas (localStorage, últimos 100).
|
||
const PROMPT_HIST_KEY = 'elffusscode.promptHistory';
|
||
let promptHistory = [];
|
||
try { promptHistory = JSON.parse(localStorage.getItem(PROMPT_HIST_KEY) || '[]'); } catch { /* corrupto */ }
|
||
let promptHistIdx = -1, promptDraft = '';
|
||
function pushPromptHistory(text) {
|
||
if (promptHistory[promptHistory.length - 1] !== text) promptHistory.push(text);
|
||
if (promptHistory.length > 100) promptHistory = promptHistory.slice(-100);
|
||
try { localStorage.setItem(PROMPT_HIST_KEY, JSON.stringify(promptHistory)); } catch { /* lleno */ }
|
||
promptHistIdx = -1; promptDraft = '';
|
||
}
|
||
$('prompt').addEventListener('keydown', e => {
|
||
if (!$('menu').hidden) return; // el menú «/» ya usa ↑/↓
|
||
const inp = e.target;
|
||
if (e.key === 'ArrowUp') {
|
||
if (!promptHistory.length) return;
|
||
if (promptHistIdx === -1) { promptDraft = inp.value; promptHistIdx = promptHistory.length; }
|
||
promptHistIdx = Math.max(0, promptHistIdx - 1);
|
||
inp.value = promptHistory[promptHistIdx];
|
||
e.preventDefault();
|
||
requestAnimationFrame(() => inp.setSelectionRange(inp.value.length, inp.value.length));
|
||
} else if (e.key === 'ArrowDown') {
|
||
if (promptHistIdx === -1) return;
|
||
promptHistIdx++;
|
||
inp.value = promptHistIdx >= promptHistory.length ? (promptHistIdx = -1, promptDraft) : promptHistory[promptHistIdx];
|
||
e.preventDefault();
|
||
} else if (e.key === 'Enter' && !e.isComposing && !e.shiftKey) {
|
||
// envío explícito: no depender del submit-on-Enter nativo del <form>,
|
||
// que algún navegador puede "tragarse" el primer Enter (autocompletar/
|
||
// sugerencias) justo después de rellenar el valor por código (↑ del
|
||
// historial) en vez de por tecleo real.
|
||
e.preventDefault();
|
||
$('composer').requestSubmit();
|
||
}
|
||
});
|
||
$('composer').addEventListener('submit', e => {
|
||
e.preventDefault();
|
||
// Mientras el agente trabaja, el mismo botón PARA en vez de enviar.
|
||
const active = conv.getActive();
|
||
if (active && active.pumping) { conv.stopActive(); return; }
|
||
const text = $('prompt').value.trim();
|
||
if (!text) return;
|
||
$('prompt').value = '';
|
||
pushPromptHistory(text);
|
||
send(text);
|
||
});
|
||
// Reflejar en el botón si estamos generando (▶ enviar / ■ parar). Se llama desde
|
||
// el onChange de conversaciones cuando cambia «pumping».
|
||
const STOP_ICON = '<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><rect x="2" y="2" width="10" height="10" rx="2" fill="currentColor"/></svg>';
|
||
function paintSendButton() {
|
||
const b = document.getElementById('btn-send');
|
||
if (!b) return;
|
||
const busy = !!conv.getActive()?.pumping;
|
||
b.classList.toggle('is-stop', busy);
|
||
b.innerHTML = busy ? STOP_ICON : UI.send;
|
||
b.title = busy ? 'Parar' : 'Enviar';
|
||
b.setAttribute('aria-label', b.title);
|
||
}
|
||
$('model-select').addEventListener('change', e => changeModel(e.target.value));
|
||
// móvil: conmutar chat ↔ editor
|
||
$('code-flip').addEventListener('click', () => {
|
||
const showEditor = !document.body.classList.contains('show-editor');
|
||
document.body.classList.toggle('show-editor', showEditor);
|
||
paintFlip(showEditor);
|
||
});
|
||
|
||
// vistas Arquitectura (grafo) y Ciudad 3D — leen el proyecto abierto y al
|
||
// hacer clic abren el fichero en Monaco (inspirado en CodeFlow + VibeCodeViewer)
|
||
$('act-arch').innerHTML = UI.graph || UI.code;
|
||
$('act-city').innerHTML = UI.city || UI.code;
|
||
let viewRenderSeq = 0;
|
||
async function openView(kind) {
|
||
const overlay = $('view-overlay'), body = $('view-body');
|
||
const seq = ++viewRenderSeq;
|
||
// destruir CUALQUIER vista previa (su RAF/canvas seguía vivo al cambiar → la
|
||
// nueva no cargaba bien). Ambas, no solo la del mismo tipo.
|
||
try { (await import('./city.js')).disposeCity(); } catch { /* */ }
|
||
try { (await import('./arch.js')).disposeArch(); } catch { /* */ }
|
||
body.innerHTML = '<div class="view-loading">Construyendo…</div>';
|
||
overlay.hidden = false;
|
||
document.querySelectorAll('#activity button').forEach(b => b.classList.toggle('on', b.id === 'act-' + kind));
|
||
// esperar a que el layout dé tamaño real al overlay (si no, canvas 0×0/mal aspect)
|
||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||
if (seq !== viewRenderSeq) return; // el usuario cambió de vista mientras tanto
|
||
try {
|
||
if (kind === 'arch') { const m = await import('./arch.js'); await m.renderArchitecture(body, p => { closeView(); openFile(p); }); }
|
||
else { const m = await import('./city.js'); await m.renderCity(body, p => { closeView(); openFile(p); }); }
|
||
} catch (e) { body.innerHTML = '<div class="view-loading">No pude construir la vista: ' + (e?.message || e) + '</div>'; }
|
||
}
|
||
function closeView() {
|
||
$('view-overlay').hidden = true;
|
||
import('./city.js').then(m => m.disposeCity()).catch(() => {});
|
||
import('./arch.js').then(m => m.disposeArch()).catch(() => {});
|
||
document.querySelectorAll('#activity button').forEach(b => b.classList.toggle('on', b.id === 'act-files'));
|
||
}
|
||
$('act-arch').addEventListener('click', () => openView('arch'));
|
||
$('act-city').addEventListener('click', () => openView('city'));
|
||
$('view-close').addEventListener('click', closeView);
|
||
|
||
// ── Cerebro CEO autónomo + Mente de Elffuss ───────────────────────────────
|
||
// Cuando NO estás pidiendo nada, la elfa trabaja: revisa el proyecto y propone
|
||
// mejoras (en elffuss-mind/, sin tocar tu código). Clic en la elfa → abre la
|
||
// «Mente» (mundo trance + pensamientos paralelos + música) y activa el cerebro.
|
||
mind.setOpenFile(openFile);
|
||
mind.init({ buildCity: buildCityAdapter, loadThoughts: loadThoughtsAdapter });
|
||
// «Ejecutar esta propuesta»: la manda a la MISMA cola/agente del chat normal
|
||
// (mismas herramientas reales, mismo gate de Auto-edit) — no un auto-apply
|
||
// aparte sin supervisión.
|
||
mind.setExecuteProposal(md => { mind.closeMind(); send(t('pProposal') + md); });
|
||
// «perfiles»: cada uno es una línea de pensamiento paralela (foco + color de
|
||
// su estrella) del cerebro CEO (compartido, core/ceo.js). El usuario los
|
||
// edita/crea/borra desde ⚙ en la Mente.
|
||
const CEO_DEFAULT_PROFILES = [
|
||
{ id: 'arq', name: 'Arquitectura', focus: 'estructura, acoplamiento y dependencias; qué módulo conviene dividir o unificar', color: '#7c5cff' },
|
||
{ id: 'cal', name: 'Calidad', focus: 'bugs latentes, casos borde sin cubrir, validaciones que faltan', color: '#49e8ff' },
|
||
{ id: 'rend', name: 'Rendimiento', focus: 'cuellos de botella, trabajo repetido, estructuras de datos mejorables', color: '#ffb454' },
|
||
{ id: 'dx', name: 'Producto/DX', focus: 'legibilidad, nombres, documentación y ergonomía para quien lo usa', color: '#3fb970' },
|
||
];
|
||
// adaptador de workspace para el cerebro CEO genérico (core/ceo.js): traduce
|
||
// sus operaciones a las herramientas de code.js (proyecto de código único).
|
||
async function ceoDirHandle(dirPath) {
|
||
let dir = codeTools.handle();
|
||
for (const seg of dirPath.split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(seg, { create: true });
|
||
return dir;
|
||
}
|
||
const ceoWorkspace = {
|
||
isReady: () => !!codeTools.handle(),
|
||
tree: (opts) => codeTools.tree(opts),
|
||
write: (opts) => codeTools.write(opts),
|
||
read: (opts) => codeTools.read(opts),
|
||
list: async (dirPath) => {
|
||
const dir = await ceoDirHandle(dirPath);
|
||
const names = [];
|
||
for await (const e of dir.values()) if (e.kind === 'file') names.push(e.name);
|
||
return names;
|
||
},
|
||
remove: async (dirPath, name) => (await ceoDirHandle(dirPath)).removeEntry(name),
|
||
};
|
||
ceo.init({
|
||
namespace: 'elffusscode',
|
||
workspace: ceoWorkspace,
|
||
defaultProfiles: CEO_DEFAULT_PROFILES,
|
||
defaultMission: 'Revisar el proyecto y proponer mejoras concretas y accionables (código, procesos, datos o docs).',
|
||
provider: () => conv.getProvider(),
|
||
// el usuario tiene PRIORIDAD: si CUALQUIER conversación está en cola o procesándose, el cerebro espera
|
||
isBusy: () => conv.isBusy(),
|
||
onEvent: (ch, ev) => {
|
||
mind.pushThought(ch, ev);
|
||
if (ch === 'ceo' && ev.type === 'built') reportImprovements(ev);
|
||
// la elfa se anima mientras el cerebro trabaja (invita a hacer clic para ver la Mente)
|
||
const av = document.querySelector('#activity img');
|
||
if (av) {
|
||
if (ch === 'ceo' && ev.type === 'cycle') av.classList.add('working');
|
||
if (ch === 'ceo' && (ev.type === 'built' || ev.type === 'paused')) av.classList.remove('working');
|
||
}
|
||
},
|
||
});
|
||
// Reporta las mejoras SIEMPRE en el chat (visual, entendible); la notificación
|
||
// del navegador solo salta si de verdad hay algo interesante y bien trabajado
|
||
// — nada de avisar por un ciclo flojo con una propuesta de una línea.
|
||
const WEAK_RE = /^(no (encontr|hay|se me ocurre)|nada que|sin cambios|todo (está|esta) bien|no aplica)/i;
|
||
export function isNoteworthy(ev) {
|
||
const solid = (ev.proposals || []).filter(p => p.text && p.text.trim().length >= 60 && !WEAK_RE.test(p.text.trim()));
|
||
return solid.length >= 2; // al menos 2 departamentos con algo sustancioso, no una ocurrencia suelta
|
||
}
|
||
// manda una propuesta (el .md completo del ciclo) a la MISMA cola/agente real
|
||
// del chat — usado tanto por el botón del chat como por el de la notificación.
|
||
function executeProposal(md) { send(t('pProposal') + md); }
|
||
|
||
export function reportImprovements(ev) {
|
||
const props = ev.proposals || [];
|
||
if (!props.length) return;
|
||
const firstLine = t => (t || '').split('\n').find(l => l.trim())?.trim().slice(0, 160) || '';
|
||
const body = `💡 **Mientras estabas fuera revisé el proyecto y encontré ${props.length} mejora${props.length === 1 ? '' : 's'}:**\n\n` +
|
||
props.map(p => `**${p.dept}** — ${firstLine(p.text)}`).join('\n\n') +
|
||
(ev.path ? `\n\n_Guardado en \`${ev.path}\`. Dale a la elfa para verlo en la Mente, o ábrelo en el editor._` : '');
|
||
const div = addMsg('assistant', body);
|
||
// se guarda en el historial REAL de la conversación activa — si no, el
|
||
// aviso vive solo en el DOM y desaparece al cambiar de pestaña o recargar
|
||
const active = conv.getActive();
|
||
if (active) conv.appendMessage(active.id, 'assistant', body);
|
||
// botón REAL para ejecutar la propuesta desde el propio chat (antes solo
|
||
// se podía desde el panel de la Mente, había que ir a buscarlo)
|
||
const md = ev.md || body;
|
||
if (div) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'proposal-exec-btn';
|
||
btn.textContent = '▶ Ejecutar esta propuesta';
|
||
btn.onclick = () => { btn.disabled = true; btn.textContent = '▶ enviado a la cola…'; executeProposal(md); };
|
||
div.appendChild(btn);
|
||
}
|
||
if (isNoteworthy(ev)) notify(`Elffuss encontró ${props.length} mejora${props.length === 1 ? '' : 's'}`, props.map(p => p.dept).join(' · '), md);
|
||
}
|
||
// SOLO notifica si el permiso YA está concedido. Nunca pide permiso aquí:
|
||
// fuera de un gesto de usuario, Chrome/Firefox lo bloquean en silencio —
|
||
// pedirlo vive únicamente en el clic de la elfa y en ⚙ de la Mente.
|
||
// Vía el service worker (registration.showNotification) para poder incluir
|
||
// un botón «▶ Ejecutar» de verdad en la propia notificación del sistema
|
||
// (new Notification() normal no soporta acciones); si el SW no está listo,
|
||
// cae a la notificación simple sin botón.
|
||
async function notify(title, body, md) {
|
||
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||
try {
|
||
if ('serviceWorker' in navigator) {
|
||
const reg = await navigator.serviceWorker.ready;
|
||
if (reg.showNotification) {
|
||
await reg.showNotification(title, {
|
||
body, icon: 'img/elffuss-code.svg',
|
||
actions: [{ action: 'execute', title: t('nExec') }, { action: 'open', title: t('nView') }],
|
||
data: { md },
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
} catch { /* cae a la notificación simple */ }
|
||
try { new Notification(title, { body, icon: 'img/elffuss-code.svg' }); } catch { /* */ }
|
||
}
|
||
// clic en el botón de la notificación del sistema → lo mismo que en el chat
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.addEventListener('message', e => {
|
||
if (e.data?.type !== 'notif-action') return;
|
||
if (e.data.action === 'execute' && e.data.md) executeProposal(e.data.md);
|
||
else mind.openMind();
|
||
});
|
||
}
|
||
// cualquier interacción FUERA de la Mente cuenta como actividad → pausa el CEO
|
||
const noteAct = e => { if (!e.target.closest?.('#mind-overlay')) ceo.noteActivity(); };
|
||
document.addEventListener('pointerdown', noteAct, true);
|
||
document.addEventListener('keydown', noteAct, true);
|
||
const elfAvatar = document.querySelector('#activity img');
|
||
if (elfAvatar) {
|
||
elfAvatar.style.cursor = 'pointer';
|
||
elfAvatar.title = t('avatarTitle');
|
||
elfAvatar.addEventListener('click', () => {
|
||
// auto-activa SOLO la primera vez (nunca decidiste play/stop); si lo
|
||
// pausaste a propósito, abrir la Mente NO debe reactivarlo por sorpresa.
|
||
if (!ceo.isEnabled() && !ceo.hasDecided()) ceo.enable();
|
||
try { if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission(); } catch { /* */ }
|
||
mind.openMind();
|
||
});
|
||
}
|
||
// restaura tu última elección (play o stop) tal cual la dejaste
|
||
if (ceo.wasEnabledLastSession()) ceo.enable();
|
||
|
||
// terminal integrado: xterm.js + shell sobre los ficheros REALES del proyecto.
|
||
// La elfa comparte este shell (tool terminal.run) y su salida se refleja aquí.
|
||
$('act-term').innerHTML = UI.terminal;
|
||
shell.setHooks({ fsChange: () => refreshTree().catch(() => {}), openFile });
|
||
setTerminalEcho((cmd, out) => terminal.echoAgent(cmd, out));
|
||
async function toggleTerminal(force) {
|
||
const show = force != null ? force : $('terminal-panel').hidden;
|
||
$('terminal-panel').hidden = !show;
|
||
$('act-term').classList.toggle('on', show);
|
||
if (show) {
|
||
const cap = shell.capabilities();
|
||
$('term-caps').textContent = cap.bridge ? t("termCapsBridge") : t("termCapsNoBridge");
|
||
await terminal.mount($('terminal-host'));
|
||
terminal.refit();
|
||
}
|
||
}
|
||
$('act-term').addEventListener('click', () => toggleTerminal());
|
||
$('term-close').addEventListener('click', () => toggleTerminal(false));
|
||
$('term-clear').addEventListener('click', () => terminal.clearScreen());
|
||
// Ctrl+` (o Cmd+`) alterna el terminal, como en VS Code
|
||
document.addEventListener('keydown', e => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === '`') { e.preventDefault(); toggleTerminal(); }
|
||
});
|
||
// redimensionar arrastrando la barra superior del panel
|
||
(() => {
|
||
const panel = $('terminal-panel'), handle = $('term-resize');
|
||
let sy = 0, sh = 0;
|
||
const move = e => { const h = Math.max(120, Math.min(window.innerHeight - 160, sh + (sy - e.clientY))); panel.style.height = h + 'px'; terminal.refit(); };
|
||
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
|
||
handle.addEventListener('mousedown', e => { sy = e.clientY; sh = panel.offsetHeight; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); e.preventDefault(); });
|
||
})();
|
||
window.addEventListener('resize', () => terminal.refit());
|
||
|
||
// iconos VS Code (sin emojis) en cabecera, composer y flip
|
||
$('btn-history').innerHTML = UI.history;
|
||
$('btn-settings').innerHTML = UI.gear;
|
||
$('btn-send').innerHTML = UI.send;
|
||
$('btn-plus').innerHTML = UI.add;
|
||
$('btn-slash').innerHTML = UI.slash;
|
||
const paintFlip = editor => { $('code-flip').innerHTML = (editor ? UI.chat : UI.editor) + `<span>${editor ? 'Chat' : 'Editor'}</span>`; };
|
||
paintFlip(false);
|
||
|
||
// barra de actividad estilo VS Code
|
||
$('act-files').innerHTML = ACTIVITY.files;
|
||
$('act-search').innerHTML = ACTIVITY.search;
|
||
$('act-settings').innerHTML = ACTIVITY.gear;
|
||
$('act-files').addEventListener('click', () => {
|
||
document.body.classList.toggle('hide-tree');
|
||
$('act-files').classList.toggle('on');
|
||
});
|
||
$('act-search').addEventListener('click', () => {
|
||
$('prompt').value = 'busca ';
|
||
$('prompt').focus();
|
||
});
|
||
$('act-settings').addEventListener('click', () => $('btn-settings').click());
|
||
|
||
$('btn-history').addEventListener('click', () => {
|
||
const panel = $('history-panel');
|
||
panel.hidden = !panel.hidden;
|
||
if (!panel.hidden) renderHistoryPanel();
|
||
});
|
||
document.addEventListener('pointerdown', e => {
|
||
const p = $('history-panel');
|
||
if (p.hidden) return;
|
||
if (p.contains(e.target) || e.target.closest('#btn-history')) return;
|
||
p.hidden = true;
|
||
}, true);
|
||
$('btn-settings').addEventListener('click', () => {
|
||
const p = $('settings-panel');
|
||
p.hidden = !p.hidden;
|
||
if (!p.hidden) renderSettings();
|
||
});
|
||
// clic fuera del panel (o ir a Archivo/chat) lo cierra — antes solo la X lo hacía
|
||
document.addEventListener('pointerdown', e => {
|
||
const p = $('settings-panel');
|
||
if (p.hidden) return;
|
||
if (p.contains(e.target) || e.target.closest('#btn-settings, #act-settings')) return;
|
||
p.hidden = true;
|
||
}, true);
|
||
|
||
// ---------- composer estilo plugin: +, /, medidor de contexto, Auto ----------
|
||
|
||
// medidor de contexto: chars del historial / presupuesto (~4 chars/token)
|
||
//
|
||
// El presupuesto se le PREGUNTA al proveedor cargado, no se escribe aquí. Era un
|
||
// 28.000 fijo, calculado para el LFM2.5 de serie, y enseñaba «ctx 0 / 28k» con
|
||
// cualquier modelo: con el motor propio a 65.536 mentía por más del doble, y al
|
||
// revés con uno pequeño habría dicho que cabe lo que no cabe. Un número escrito
|
||
// a mano en la interfaz es un número que se queda viejo — el mismo fallo que la
|
||
// lista de modelos de los ajustes.
|
||
const CTX_BUDGET_FALLBACK = 28000; // solo si el proveedor no sabe decirlo
|
||
let ctxBudgetTok = CTX_BUDGET_FALLBACK;
|
||
export function setCtxBudget(tokens) {
|
||
// Se reserva sitio para el prompt de sistema y para la respuesta: el techo del
|
||
// modelo NO es lo que cabe de conversación.
|
||
const RESERVA = 4000;
|
||
if (Number.isFinite(tokens) && tokens > RESERVA) ctxBudgetTok = tokens - RESERVA;
|
||
}
|
||
function updateCtxMeter() {
|
||
if (!$('ctx-text')) return; // aún en la landing
|
||
const CTX_BUDGET_TOK = ctxBudgetTok;
|
||
const chars = (conv.getActive()?.agent.history || []).reduce((s, m) => s + (m.content || '').length, 0);
|
||
const tok = Math.round(chars / 4);
|
||
const pct = Math.min(100, Math.round(tok / CTX_BUDGET_TOK * 100));
|
||
const k = n => n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'k' : n;
|
||
$('ctx-text').textContent = `ctx ${k(tok)} / ${k(CTX_BUDGET_TOK)}`;
|
||
$('ctx-bar').style.width = pct + '%';
|
||
$('ctx-bar').className = pct > 85 ? 'hot' : '';
|
||
$('ctx-meter').title = pct > 85
|
||
? `Contexto casi lleno (${pct}%). Los mensajes viejos se comprimen solos; usa 🧹 para empezar limpio.`
|
||
: `Contexto: ${tok} de ~${CTX_BUDGET_TOK} tokens (${100 - pct}% libre)`;
|
||
}
|
||
const _origAdd = addMsg; // refrescar el medidor cuando cambia la conversación
|
||
addMsg = (...a) => { const r = _origAdd(...a); updateCtxMeter(); return r; };
|
||
|
||
// menú flotante genérico sobre el composer
|
||
function openMenu(items) {
|
||
const menu = $('menu');
|
||
menu.replaceChildren();
|
||
for (const it of items) {
|
||
if (it.sep) { const s = document.createElement('div'); s.className = 'sep'; s.textContent = it.sep; menu.appendChild(s); continue; }
|
||
const row = document.createElement('button');
|
||
row.className = 'menu-item';
|
||
row.innerHTML = `<b>${it.label}</b>${it.hint ? `<span>${it.hint}</span>` : ''}`;
|
||
row.onclick = () => { menu.hidden = true; it.run(); };
|
||
menu.appendChild(row);
|
||
}
|
||
menu.hidden = false;
|
||
}
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('#menu, #btn-slash, #btn-plus')) $('menu').hidden = true;
|
||
});
|
||
|
||
// [/] comandos
|
||
$('btn-slash').addEventListener('click', () => openMenu([
|
||
{ sep: t('cmdsSep') },
|
||
{ label: '/tree', hint: t('slTree'), run: () => send(t('pTree')) },
|
||
{ label: '/search', hint: t('slSearch'), run: () => { $('prompt').value = t('pSearch'); $('prompt').focus(); } },
|
||
{ label: '/skills', hint: t('slSkills'), run: () => openSkillsPanel() },
|
||
{ label: '/model', hint: t('slModel'), run: () => $('btn-settings').click() },
|
||
{ label: '/clear', hint: t('slClear'), run: () => conv.create() },
|
||
]));
|
||
|
||
// [+] adjuntar archivo del proyecto como @ruta
|
||
// El menú de ficheros, en una función: lo abren el botón `+` Y escribir `@` en
|
||
// la conversación, que es lo natural y lo que ya prometía el título del botón
|
||
// («Adjuntar archivo del proyecto (@)») sin que nada lo implementara.
|
||
async function abreMenuFicheros() {
|
||
let tree = '';
|
||
try { tree = await codeTools.tree({ depth: 3 }); } catch { /* sin proyecto */ }
|
||
const files = tree.split('\n').filter(l => l.trim() && !l.includes('📁')).map(l => l.trim()).slice(0, 40);
|
||
const { currentFile } = codeTools.current();
|
||
const items = [{ sep: t('attachSep') }];
|
||
if (currentFile) items.push({ label: '@' + currentFile, hint: t('attachOpen'), run: () => attach(currentFile) });
|
||
for (const f of files.filter(f => f !== currentFile).slice(0, 20))
|
||
items.push({ label: '@' + f, run: () => attach(f) });
|
||
openMenu(items.length > 1 ? items : [{ sep: t('openProjFirst') }]);
|
||
}
|
||
$('btn-plus').addEventListener('click', abreMenuFicheros);
|
||
|
||
// Escribir `@` abre la lista de ficheros. Se mira el carácter ANTERIOR al
|
||
// cursor: así `correo@dominio` no dispara el menú a media palabra, y `@` al
|
||
// principio o tras un espacio sí. La condición es sobre lo que el usuario
|
||
// acaba de teclear, no sobre el valor entero, para no reabrirlo cada vez que
|
||
// toca una tecla con un `@` ya escrito antes.
|
||
$('prompt').addEventListener('input', (e) => {
|
||
if (e.inputType !== 'insertText' || e.data !== '@') return;
|
||
const v = e.target.value, i = e.target.selectionStart - 1;
|
||
const previo = i > 0 ? v[i - 1] : '';
|
||
if (previo === '' || /\s/.test(previo)) abreMenuFicheros();
|
||
});
|
||
|
||
function attach(path) {
|
||
const p = $('prompt');
|
||
p.value = (p.value + ' @' + path).trim() + ' ';
|
||
p.focus();
|
||
}
|
||
|
||
// </> Auto (Edit automatically): si off, pide confirmación antes de escribir
|
||
let autoEdit = localStorage.getItem('elffusscode.autoedit') !== '0';
|
||
function paintAuto() {
|
||
const b = $('btn-autoedit');
|
||
b.classList.toggle('on', autoEdit);
|
||
b.querySelector('.ae-txt').textContent = autoEdit ? t('autoTxt') : t('reviewTxt');
|
||
b.title = autoEdit ? t('autoOn') : t('autoOff');
|
||
}
|
||
$('btn-autoedit').addEventListener('click', () => {
|
||
autoEdit = !autoEdit;
|
||
localStorage.setItem('elffusscode.autoedit', autoEdit ? '1' : '0');
|
||
paintAuto();
|
||
});
|
||
paintAuto();
|
||
codeTools.setWriteApprover(async (path, content) => {
|
||
if (autoEdit) return true;
|
||
return confirm(`Elffuss quiere escribir en «${path}» (${content.split('\n').length} líneas).\n\n¿Aplicar el cambio?`);
|
||
});
|
||
|
||
// 🎯 Goal: el siguiente mensaje que mandes se trata como un OBJETIVO — se
|
||
// planifica en tareas y se ejecutan una a una (goal.js), en vez del turno de
|
||
// chat normal. Mismo patrón (interruptor persistente en localStorage) que
|
||
// </> Auto de arriba.
|
||
let goalMode = localStorage.getItem('elffusscode.goalmode') === '1';
|
||
function paintGoal() {
|
||
$('btn-goal').classList.toggle('on', goalMode);
|
||
}
|
||
$('btn-goal').addEventListener('click', () => {
|
||
goalMode = !goalMode;
|
||
localStorage.setItem('elffusscode.goalmode', goalMode ? '1' : '0');
|
||
paintGoal();
|
||
});
|
||
paintGoal();
|
||
|
||
// ── Hard Work (RLM): lee el proyecto ENTERO por partes ──
|
||
let hardMode = false;
|
||
$('btn-hardwork').addEventListener('click', () => {
|
||
hardMode = !hardMode;
|
||
$('btn-hardwork').classList.toggle('on', hardMode);
|
||
$('prompt').placeholder = hardMode ? t("hwPlaceholder") : 'Pídele código a Elffuss…';
|
||
});
|
||
|
||
// Trabaja el proyecto abierto por partes: trocea, pregunta a cada parte y funde.
|
||
async function runHardWork(question) {
|
||
const active = conv.getActive();
|
||
if (!active) return;
|
||
addMsg('user', question);
|
||
const thinking = thinkingBubble();
|
||
const fail = msg => { thinking.remove(); addMsg('assistant', msg); };
|
||
try {
|
||
const provider = conv.getProvider();
|
||
if (!provider || provider === rules) return fail(t("hwNeedBrain"));
|
||
const root = codeTools.handle();
|
||
if (!root) return fail(t("hwNeedProject"));
|
||
thinking.tool(t("hwReading"));
|
||
const context = await gatherFolder(root, { maxBytes: 1.5e6 });
|
||
if (!context.trim()) return fail(t("hwNoFiles"));
|
||
const res = await hardWork({
|
||
question, context, provider,
|
||
onProgress: p => {
|
||
if (p.phase === 'plan') thinking.tool(`Hard Work · ${p.chunks} partes (${Math.round(p.chars / 1000)}k)${p.truncated ? t("hwTrimmed") : ''}`);
|
||
else if (p.phase === 'map') thinking.tool(`Hard Work · leyendo ${p.i}/${p.n}`);
|
||
else if (p.phase === 'reduce-group') thinking.tool(`Hard Work · fundiendo ${p.g}/${p.n}`);
|
||
else if (p.phase === 'reduce') thinking.tool(t("hwPhaseReduce"));
|
||
},
|
||
});
|
||
thinking.remove();
|
||
addMsg('assistant', res.answer + `\n\n_⛏ Hard Work (RLM): ${res.chunks} partes, ${res.kept} con señal.${res.truncated ? t("hwResultTrunc") : ''}_`);
|
||
} catch (e) {
|
||
thinking.remove();
|
||
addMsg('assistant err', 'Hard Work falló: ' + (e?.message || String(e)));
|
||
}
|
||
}
|
||
|
||
// resolver @rutas del mensaje: se leen y se adjuntan como contexto
|
||
const _send = send;
|
||
send = async (text) => {
|
||
if (hardMode) return runHardWork(text);
|
||
const refs = [...text.matchAll(/@([\w./-]+\.\w+)/g)].map(m => m[1]);
|
||
if (refs.length) {
|
||
let ctx = '';
|
||
for (const r of [...new Set(refs)].slice(0, 4)) {
|
||
try { ctx += `\n\n--- ${r} ---\n${await codeTools.read({ path: r })}`; } catch { /* ignora */ }
|
||
}
|
||
if (ctx) text += '\n\n[archivos adjuntos]' + ctx;
|
||
}
|
||
_send(text);
|
||
};
|
||
|
||
// ---------- panel de skills (transparente: repo + lista + SKILL.md) ----------
|
||
async function openSkillsPanel() {
|
||
const box = $('settings-panel');
|
||
box.hidden = false;
|
||
box.replaceChildren();
|
||
const close = document.createElement('button');
|
||
close.id = 'settings-close'; close.textContent = '✕'; close.onclick = () => { box.hidden = true; };
|
||
box.appendChild(close);
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'skills-panel';
|
||
wrap.innerHTML = `<h3>🧩 Skills de Claude Code</h3>
|
||
<p class="muted">Instala instrucciones especializadas (formato SKILL.md) desde repos públicos.
|
||
Todo se guarda en tu navegador. Se ve el repo y lo que se inyecta.</p>`;
|
||
box.appendChild(wrap);
|
||
|
||
// instaladas
|
||
const inst = skills.installed();
|
||
const instBox = document.createElement('div');
|
||
instBox.innerHTML = `<div class="sk-h">Instaladas (${inst.length})</div>`;
|
||
for (const s of inst) {
|
||
const row = document.createElement('div');
|
||
row.className = 'sk-row';
|
||
row.innerHTML = `<b>${s.name}</b><span class="muted">${s.repo || 'local'}</span>`;
|
||
const rm = document.createElement('button');
|
||
rm.className = 'ghost'; rm.textContent = t("skRemove");
|
||
rm.onclick = async () => { await skills.remove(s.name); openSkillsPanel(); };
|
||
row.appendChild(rm);
|
||
instBox.appendChild(row);
|
||
}
|
||
if (!inst.length) instBox.innerHTML += '<p class="muted">Ninguna todavía.</p>';
|
||
box.appendChild(instBox);
|
||
|
||
// fuentes + añadir repo
|
||
const srcs = await skills.sources();
|
||
const srcBox = document.createElement('div');
|
||
srcBox.innerHTML = `<div class="sk-h">Catálogos (repos)</div>`;
|
||
for (const s of srcs) {
|
||
const row = document.createElement('div');
|
||
row.className = 'sk-row';
|
||
row.innerHTML = `<b>${s.label}</b><a href="https://github.com/${s.repo}" target="_blank" rel="noopener">${s.repo} ↗</a>`;
|
||
const browse = document.createElement('button');
|
||
browse.className = 'primary'; browse.textContent = t("skBrowse");
|
||
browse.onclick = () => browseRepo(s.repo, box);
|
||
row.appendChild(browse);
|
||
if (!s.official) {
|
||
const rm = document.createElement('button');
|
||
rm.className = 'ghost'; rm.textContent = '🗑';
|
||
rm.onclick = async () => { await skills.removeSource(s.repo); openSkillsPanel(); };
|
||
row.appendChild(rm);
|
||
}
|
||
srcBox.appendChild(row);
|
||
}
|
||
const addRow = document.createElement('div');
|
||
addRow.className = 'sk-row';
|
||
const inp = document.createElement('input');
|
||
inp.placeholder = t("skAddRepoPh");
|
||
const add = document.createElement('button');
|
||
add.className = 'primary'; add.textContent = t("skAddRepo");
|
||
add.onclick = async () => {
|
||
try { await skills.addSource(inp.value); openSkillsPanel(); }
|
||
catch (e) { alert(e.message); }
|
||
};
|
||
addRow.append(inp, add);
|
||
srcBox.appendChild(addRow);
|
||
box.appendChild(srcBox);
|
||
}
|
||
|
||
async function browseRepo(repo, box) {
|
||
const list = document.createElement('div');
|
||
list.innerHTML = `<div class="sk-h">Cargando ${repo}…</div>`;
|
||
box.appendChild(list);
|
||
try {
|
||
const found = await skills.listFromRepo(repo);
|
||
list.innerHTML = `<div class="sk-h">${repo} — ${found.length} skills</div>`;
|
||
for (const sk of found.slice(0, 120)) {
|
||
const row = document.createElement('div');
|
||
row.className = 'sk-row';
|
||
row.innerHTML = `<b>${sk.name}</b><span class="muted">${sk.dir}</span>`;
|
||
const btn = document.createElement('button');
|
||
const on = skills.isInstalled(sk.repo, sk.path);
|
||
btn.className = on ? 'ghost' : 'primary';
|
||
btn.textContent = on ? 'Instalada ✓' : 'Instalar';
|
||
btn.onclick = async () => {
|
||
btn.textContent = '…';
|
||
try {
|
||
const entry = await skills.installFromRepo(sk);
|
||
btn.textContent = 'Instalada ✓'; btn.className = 'ghost';
|
||
$('settings-panel').hidden = true; // cierra el panel
|
||
addMsg('assistant', skills.usageMessage(entry)); // «cómo usarla» en el chat
|
||
} catch (e) { btn.textContent = 'Instalar'; alert(e.message); }
|
||
};
|
||
row.appendChild(btn);
|
||
list.appendChild(row);
|
||
}
|
||
} catch (e) {
|
||
list.innerHTML = `<div class="sk-h">⚠️ ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
// ---------- command palette (Cmd/Ctrl+P) ----------
|
||
let palItems = [], palSel = 0;
|
||
const COMMANDS = [
|
||
{ label: t('newConv'), run: () => conv.create() },
|
||
{ label: t('tHistory'), run: () => $('btn-history').click() },
|
||
{ label: t('pSearchCode'), run: () => { closePalette(); $('prompt').value = t('pSearch'); $('prompt').focus(); } },
|
||
{ label: t('pManageSkills'), run: () => { closePalette(); openSkillsPanel(); } },
|
||
{ label: t('tActSettings'), run: () => { closePalette(); renderSettings(); } },
|
||
{ label: t('pToggleExplorer'), run: () => $('act-files').click() },
|
||
{ label: t('pToggleTerm'), hint: 'Ctrl+`', run: () => { closePalette(); toggleTerminal(); } },
|
||
{ label: t('pSave'), run: () => triggerEditor('') || (hasEditor() && triggerEditor('workbench.action.files.save')) },
|
||
];
|
||
|
||
function openPalette(prefix = '') {
|
||
$('palette').hidden = false;
|
||
const inp = $('pal-input');
|
||
inp.value = prefix;
|
||
inp.focus();
|
||
renderPalette();
|
||
}
|
||
function closePalette() { $('palette').hidden = true; }
|
||
|
||
function fuzzy(q, s) {
|
||
q = q.toLowerCase(); s = s.toLowerCase();
|
||
let i = 0, score = 0, streak = 0;
|
||
for (const ch of s) {
|
||
if (i < q.length && ch === q[i]) { i++; streak++; score += streak; }
|
||
else streak = 0;
|
||
}
|
||
return i === q.length ? score + (s.endsWith(q) ? 20 : 0) : -1;
|
||
}
|
||
|
||
async function renderPalette() {
|
||
const raw = $('pal-input').value;
|
||
const list = $('pal-list');
|
||
list.replaceChildren();
|
||
palItems = [];
|
||
|
||
if (raw.startsWith('>')) { // comandos
|
||
const q = raw.slice(1).trim().toLowerCase();
|
||
palItems = COMMANDS.filter(c => c.label.toLowerCase().includes(q)).map(c => ({ label: c.label, hint: t('palCmd'), run: c.run }));
|
||
} else if (raw.startsWith('@')) { // símbolos del archivo abierto (Monaco)
|
||
palItems = [{ label: t('palGotoSym'), hint: '@', run: () => { closePalette(); triggerEditor('editor.action.quickOutline'); } }];
|
||
} else { // archivos (con :línea opcional)
|
||
const [pathQ, lineQ] = raw.split(':');
|
||
const files = await codeTools.fileList().catch(() => []);
|
||
const scored = pathQ.trim()
|
||
? files.map(f => ({ f, s: fuzzy(pathQ.trim(), f.split('/').pop()) * 2 + fuzzy(pathQ.trim(), f) })).filter(x => x.s > 0).sort((a, b) => b.s - a.s)
|
||
: files.slice(0, 40).map(f => ({ f, s: 0 }));
|
||
palItems = scored.slice(0, 40).map(({ f }) => ({
|
||
label: f.split('/').pop(), hint: f, icon: true,
|
||
run: () => { closePalette(); openFile(f); if (lineQ && +lineQ) setTimeout(() => gotoLine(+lineQ), 300); },
|
||
}));
|
||
}
|
||
palSel = 0;
|
||
palItems.forEach((it, i) => {
|
||
const row = el('button', 'pal-item' + (i === 0 ? ' sel' : ''));
|
||
row.innerHTML = `<span class="pi-name">${it.label}</span><span class="pi-hint">${it.hint || ''}</span>`;
|
||
row.onmouseenter = () => { palSel = i; paintPalSel(); };
|
||
row.onclick = () => it.run();
|
||
list.appendChild(row);
|
||
});
|
||
if (!palItems.length) list.appendChild(el('div', 'pal-empty', t('palEmpty')));
|
||
}
|
||
function paintPalSel() {
|
||
[...$('pal-list').children].forEach((c, i) => c.classList.toggle('sel', i === palSel));
|
||
$('pal-list').children[palSel]?.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
$('pal-input').addEventListener('input', renderPalette);
|
||
$('pal-input').addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') return closePalette();
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); palSel = Math.min(palSel + 1, palItems.length - 1); paintPalSel(); }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); palSel = Math.max(palSel - 1, 0); paintPalSel(); }
|
||
if (e.key === 'Enter') { e.preventDefault(); palItems[palSel]?.run(); }
|
||
});
|
||
document.addEventListener('keydown', e => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') { e.preventDefault(); openPalette(e.shiftKey ? '>' : ''); }
|
||
if (e.key === 'Escape' && !$('palette').hidden) closePalette();
|
||
});
|
||
document.addEventListener('click', e => { if (!e.target.closest('#palette')) closePalette(); });
|
||
|
||
// ---------- barra de menú File/Edit/View/Git ----------
|
||
async function menuFor(which) {
|
||
if (which === 'file') return [
|
||
{ label: t('mNewFile'), run: () => { const p = prompt(t('promptNewPath')); if (p) codeTools.write({ path: p, content: '' }).then(() => { openFile(p); refreshTree(); }); } },
|
||
{ label: t('mSave'), hint: 'Ctrl+S', run: () => triggerEditor('workbench.action.files.save') },
|
||
{ label: t('mOpenFolder'), run: async () => { try { await enterIDE(await window.showDirectoryPicker({ mode: 'readwrite' })); } catch {} } },
|
||
];
|
||
if (which === 'edit') return [
|
||
{ label: t('mSearchCode'), run: () => { $('prompt').value = t('pSearch'); $('prompt').focus(); } },
|
||
{ label: t('mGotoFile'), hint: 'Cmd+P', run: () => openPalette('') },
|
||
{ label: t('mGotoSym'), run: () => triggerEditor('editor.action.quickOutline') },
|
||
];
|
||
if (which === 'view') return [
|
||
{ label: t('tFiles'), run: () => $('act-files').click() },
|
||
{ label: t('mTerminal'), hint: 'Ctrl+`', run: () => toggleTerminal(true) },
|
||
{ label: t('mArch'), run: () => openView('arch') },
|
||
{ label: t('tCity'), run: () => openView('city') },
|
||
{ label: 'Command Palette', hint: 'Cmd+Shift+P', run: () => openPalette('>') },
|
||
{ label: 'Skills', run: () => openSkillsPanel() },
|
||
{ label: t('mAbout'), hint: '↗', run: () => window.open('https://github.com/KikoCis/elffuss-code', '_blank', 'noopener') },
|
||
];
|
||
if (which === 'git') {
|
||
const g = await codeTools.gitInfo();
|
||
if (!g.isRepo) return [{ label: t('gNotRepo'), hint: '', run: () => {} }];
|
||
const items = [{ label: t('gBranch', { b: g.branch }), hint: 'git', run: () => {} }];
|
||
if (g.lastCommit) items.push({ label: t('gLast', { m: g.lastCommit.msg.slice(0, 40) }), hint: g.lastCommit.author, run: () => {} });
|
||
items.push({ label: t('gCommit'), run: () => { $('prompt').value = t('pCommit'); $('prompt').focus(); } });
|
||
return items;
|
||
}
|
||
return [];
|
||
}
|
||
document.querySelectorAll('#menubar button').forEach(b => {
|
||
b.addEventListener('click', async () => {
|
||
document.querySelectorAll('#menubar button').forEach(x => x.classList.toggle('open', x === b));
|
||
openMenuAt(await menuFor(b.dataset.menu), b);
|
||
});
|
||
});
|
||
function openMenuAt(items, anchor) {
|
||
const menu = $('topmenu');
|
||
const r = anchor.getBoundingClientRect();
|
||
menu.style.left = r.left + 'px';
|
||
menu.style.top = (r.bottom + 3) + 'px';
|
||
menu.replaceChildren();
|
||
for (const it of items) {
|
||
const row = el('button', 'menu-item');
|
||
row.innerHTML = `<b>${it.label}</b>${it.hint ? `<span>${it.hint}</span>` : ''}`;
|
||
row.onclick = () => { menu.hidden = true; it.run(); };
|
||
menu.appendChild(row);
|
||
}
|
||
menu.hidden = false;
|
||
}
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('#menubar, #topmenu')) {
|
||
document.querySelectorAll('#menubar button').forEach(x => x.classList.remove('open'));
|
||
$('topmenu').hidden = true;
|
||
}
|
||
});
|
||
|
||
// ---------- git en el header ----------
|
||
async function refreshGit() {
|
||
const g = await codeTools.gitInfo().catch(() => ({ isRepo: false }));
|
||
const chip = $('git-branch');
|
||
if (g.isRepo) { chip.hidden = false; chip.innerHTML = UI.code + ' ' + g.branch; }
|
||
else chip.hidden = true;
|
||
}
|
||
$('git-branch').addEventListener('click', () => document.querySelector('#menubar button[data-menu="git"]').click());
|
||
|
||
// icono de skills en la barra de actividad
|
||
$('act-skills').innerHTML = UI.puzzle;
|
||
$('act-skills').addEventListener('click', () => openSkillsPanel());
|
||
|
||
rebuildSelect();
|
||
skills.initSkills();
|
||
boot();
|
||
window.elffussClaw = { conv, get agent() { return conv.getActive()?.agent; }, send, openFile, parseToolCall, skills };
|
||
|
||
|
||
// ---------- puerta de móvil y chip de disco ----------
|
||
// Declaradas con `function` para que se icen: preloadModel() las usa y está
|
||
// escrita más arriba en el fichero.
|
||
function avisoMovil(tam, onAccept) {
|
||
// Se pinta FIJO sobre la pantalla, no dentro del layout: cuando esto corre,
|
||
// #chat todavía no existe (Code construye su interfaz después), así que caía
|
||
// al body y el propio layout lo tapaba — presente en el DOM y invisible para
|
||
// el usuario, que es la peor variante de un aviso.
|
||
const box = document.createElement('div');
|
||
box.style.cssText = 'position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:99999;'
|
||
+ 'width:min(92vw,520px);padding:16px;border:1px solid #3a3a4a;border-radius:14px;'
|
||
+ 'background:#15161f;color:#eae8f2;box-shadow:0 18px 50px -18px rgba(0,0,0,.8);'
|
||
+ 'display:flex;flex-direction:column;gap:8px;font:14px/1.5 system-ui,sans-serif';
|
||
const h = document.createElement('b'); h.textContent = '📱 En el móvil todavía no funciona';
|
||
const p1 = document.createElement('span'); p1.style.opacity = '.75';
|
||
p1.textContent = 'Elffuss Code ejecuta el modelo dentro de tu propio navegador. En el móvil eso hoy no sale bien: '
|
||
+ 'habría que descargar ' + tam + ' y el navegador suele cerrar la pestaña antes de terminar. '
|
||
+ 'Así que no te lo descargo sin preguntar.';
|
||
const p2 = document.createElement('span'); p2.style.opacity = '.75';
|
||
p2.textContent = 'Ábrelo en un ordenador y sí funciona: ahí es donde vive.';
|
||
const fila = document.createElement('div');
|
||
fila.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap';
|
||
const b = document.createElement('button');
|
||
b.style.cssText = 'flex:1;padding:9px 12px;border-radius:9px;border:1px solid #4a4a5e;background:#22232f;color:inherit;font:inherit;cursor:pointer';
|
||
b.textContent = 'Descargarlo igualmente (' + tam + ')';
|
||
b.onclick = () => { try { localStorage.setItem('elffusscode.movil.ok', '1'); } catch {} box.remove(); onAccept(); };
|
||
const x = document.createElement('button');
|
||
x.style.cssText = 'padding:9px 12px;border-radius:9px;border:1px solid #4a4a5e;background:transparent;color:inherit;font:inherit;cursor:pointer';
|
||
x.textContent = 'Entendido';
|
||
x.onclick = () => box.remove();
|
||
fila.append(b, x);
|
||
box.append(h, p1, p2, fila);
|
||
document.body.appendChild(box);
|
||
}
|
||
|
||
function mountDiskChip() {
|
||
const chip = document.getElementById('disk-chip');
|
||
if (!chip) return;
|
||
const gb = n => n >= 1073741824 ? (n / 1073741824).toFixed(1) + ' GB' : Math.round(n / 1048576) + ' MB';
|
||
async function paint() {
|
||
const { usage, quota } = await cacheEstimate();
|
||
if (!usage) { chip.hidden = true; return; }
|
||
chip.hidden = false;
|
||
chip.textContent = '💾 ' + gb(usage);
|
||
chip.title = `Modelos guardados en este navegador: ${gb(usage)}`
|
||
+ (quota ? ` de ${gb(quota)} disponibles` : '') + '. Pulsa para vaciarlo.';
|
||
}
|
||
chip.addEventListener('click', async () => {
|
||
const { usage } = await cacheEstimate();
|
||
if (!confirm(`Vas a borrar ${gb(usage)} de modelos guardados en este navegador.\n\n`
|
||
+ `No se pierde nada tuyo: solo los pesos descargados. La próxima vez habrá que bajarlos otra vez.\n\n¿Seguir?`)) return;
|
||
chip.textContent = '💾 …';
|
||
try { await clearModelCache(); } catch (e) { alert('No se pudo vaciar: ' + (e.message || e)); }
|
||
paint();
|
||
});
|
||
paint();
|
||
setInterval(paint, 20000);
|
||
}
|
||
mountDiskChip();
|
||
|
||
// ── Compositor: menú de modos, ancho del chat y caja que crece ──────────────
|
||
// Los tres botones de modo siguen siendo los mismos elementos con los mismos
|
||
// ids: lo único que cambia es que viven dentro de un desplegable. Así el JS que
|
||
// ya los escuchaba no se entera de nada.
|
||
{
|
||
const modos = $('btn-modos'), menu = $('modos-menu');
|
||
if (modos && menu) {
|
||
// Cada modo lleva DEBAJO lo que hace. El texto sale del `title` que ya
|
||
// tenía el botón: estaba escrito y bien, pero solo lo veía quien dejara el
|
||
// ratón quieto encima — o sea casi nadie, y en móvil nadie. «Auto», «Goal»
|
||
// y «Hard Work» sueltos no dicen nada a quien llega nuevo.
|
||
for (const b of menu.querySelectorAll('.cbtn')) {
|
||
const que = b.getAttribute('title');
|
||
if (!que || b.querySelector('.modo-que')) continue;
|
||
const span = document.createElement('span');
|
||
span.className = 'modo-que';
|
||
// Cortar por el primer paréntesis dejaba «Hard Work (RLM): lee tu
|
||
// proyecto…» en «Hard Work», o sea una explicación que repite el nombre
|
||
// del botón y no explica nada. Lo que sobra es el PREFIJO —«Nombre
|
||
// (SIGLAS):»— no lo que viene detrás, que es justo la frase útil.
|
||
span.textContent = que
|
||
.replace(/^[^:]{0,40}:\s*/, '') // fuera «Hard Work (RLM): »
|
||
.split('.')[0] // la primera frase
|
||
.replace(/\s*\([^)]*\)\s*/g, ' ') // sin incisos entre paréntesis
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.slice(0, 120);
|
||
b.appendChild(span);
|
||
// Ya está a la vista: dejar el title duplicaría el texto en un tooltip.
|
||
b.removeAttribute('title');
|
||
}
|
||
modos.addEventListener('click', (e) => { e.stopPropagation(); menu.hidden = !menu.hidden; });
|
||
// Cerrar al elegir y al pulsar fuera. Sin lo segundo, el menú se queda
|
||
// abierto tapando la conversación y hay que adivinar cómo cerrarlo.
|
||
menu.addEventListener('click', () => { menu.hidden = true; });
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.closest('#modos')) menu.hidden = true;
|
||
});
|
||
}
|
||
|
||
// El chat a toda la ventana. Al editor se le da cero de ancho en vez de
|
||
// quitarlo: volver es instantáneo y no se pierde lo que hubiera abierto.
|
||
const ancho = $('btn-ancho');
|
||
if (ancho) {
|
||
const pinta = () => {
|
||
const full = document.body.classList.contains('chat-full');
|
||
ancho.textContent = full ? '⇥' : '⇔';
|
||
ancho.title = full ? 'Recuperar el editor' : 'Ensanchar el chat a toda la ventana';
|
||
};
|
||
try { if (localStorage.getItem('elffusscode.chatfull') === '1') document.body.classList.add('chat-full'); }
|
||
catch { /* sin localStorage se queda como está */ }
|
||
pinta();
|
||
ancho.addEventListener('click', () => {
|
||
document.body.classList.toggle('chat-full');
|
||
try { localStorage.setItem('elffusscode.chatfull', document.body.classList.contains('chat-full') ? '1' : '0'); }
|
||
catch { /* — */ }
|
||
pinta();
|
||
});
|
||
}
|
||
|
||
// La caja crece con el texto hasta el tope que pone el CSS. Se recalcula
|
||
// desde cero (`auto`) antes de medir: si no, una vez crecida ya no encoge al
|
||
// borrar, y acaba ocupando media pantalla vacía.
|
||
const caja = $('prompt');
|
||
if (caja && caja.tagName === 'TEXTAREA') {
|
||
const crece = () => {
|
||
caja.style.height = 'auto';
|
||
caja.style.height = Math.min(caja.scrollHeight, window.innerHeight * 0.4) + 'px';
|
||
};
|
||
caja.addEventListener('input', crece);
|
||
// Al enviar, el valor se vacía desde fuera y no salta `input`: sin esto la
|
||
// caja se queda alta y vacía después de cada mensaje.
|
||
new MutationObserver(crece).observe(caja, { attributes: true, attributeFilter: ['value'] });
|
||
$('composer')?.addEventListener('submit', () => setTimeout(crece, 0));
|
||
}
|
||
}
|
||
|
||
// ── Instalable: es lo único que consigue almacenamiento persistente ─────────
|
||
// Medido en este proyecto: `navigator.storage.persist()` devuelve false en un
|
||
// perfil normal, así que Chrome puede desalojar los gigas del modelo cuando le
|
||
// falte disco — y lo hizo. Chrome concede la persistencia a los sitios
|
||
// instalados, y solo ofrece instalar si hay manifiesto Y un service worker con
|
||
// manejador de `fetch`. De ahí `sw.js`, que no cachea nada a propósito.
|
||
//
|
||
// El registro va tras `load` para no competir con la carga del modelo, que es
|
||
// lo que el usuario está esperando de verdad.
|
||
if ('serviceWorker' in navigator) {
|
||
addEventListener('load', () => {
|
||
navigator.serviceWorker.register('sw.js').then(async () => {
|
||
// Se vuelve a pedir la persistencia DESPUÉS de registrar: si el usuario ya
|
||
// tenía el sitio instalado de una visita anterior, aquí es donde se
|
||
// concede, y sin pedirla otra vez nadie se enteraría.
|
||
try {
|
||
if (navigator.storage?.persist && !(await navigator.storage.persisted())) {
|
||
const ok = await navigator.storage.persist();
|
||
console.log('[elffuss] almacenamiento persistente: ' + (ok ? 'concedido' : 'denegado — instala la app para que el navegador no borre el modelo'));
|
||
}
|
||
} catch { /* el diagnóstico no vale un error */ }
|
||
}).catch(e => console.warn('[elffuss] service worker no registrado: ' + e.message));
|
||
});
|
||
}
|