Spaces:
Running
Running
Download js/kernel.js from KikoCis/elffuss-claw: direct link, hf CLI and curl.
- Browser
- Download file 55.9 kB
-
https://huggingface.co/spaces/KikoCis/elffuss-claw/resolve/main/js/kernel.js
- Command line
-
hf download hf://spaces/KikoCis/elffuss-claw/js/kernel.js
-
curl -L -o kernel.js https://huggingface.co/spaces/KikoCis/elffuss-claw/resolve/main/js/kernel.js
55.9 kB
| // Núcleo: arranca UI, agente, planificador y el proveedor de modelo activo. | |
| import { Agent, parseToolCall } from './agent.js'; | |
| import * as ui from './ui.js'; | |
| import * as rules from './providers/rules.js'; | |
| import * as db from './db.js'; | |
| import * as settings from './settings.js'; | |
| import * as skills from './skills.js'; | |
| import { applyI18n, t } from './i18n.js'; | |
| import { ensureModelCache } from './model-cache.js'; | |
| import { tasks, watch, fs, apps } from './tools/index.js'; | |
| import { hardWork, gatherFolder, deepCreate } from './rlm.js'; | |
| import * as workspace from './workspace.js'; | |
| import * as ceo from './ceo.js'; | |
| import * as mind from './mind.js'; | |
| import { ceoWorkspace, CEO_DEFAULT_PROFILES } from './ceo-adapter.js'; | |
| import * as telemetry from './telemetry.js'; | |
| import * as gram from './copilot-grammar.js'; | |
| applyI18n(); // traduce el chrome al idioma del navegador antes de nada | |
| telemetry.init('elffuss-claw'); // opt-in, apagado por defecto — ver Ajustes | |
| // Proveedores LOCALES (corren en TU navegador). Los externos (OpenAI, | |
| // Anthropic, Ollama, servidor) son configuración avanzada y se resuelven | |
| // aparte, bajo el id `ext:<nombre>`. | |
| const localProviders = { | |
| onnx: () => import('./providers/onnx.js'), | |
| litert: () => import('./providers/litert.js'), | |
| }; | |
| // Devuelve el módulo del proveedor ya configurado, o null si es 'rules'. | |
| async function resolveProvider(id) { | |
| if (id === 'rules') return null; | |
| 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 aquí: llega a web/js/engine/ por rsync, igual que | |
| // los pesos de web/models/. Si el directorio no está, el import falla y | |
| // changeModel lo 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)); | |
| if (!cfg) throw new Error('proveedor externo desconocido'); | |
| const mod = await import('./providers/api.js'); | |
| mod.configure(cfg); | |
| return mod; | |
| } | |
| throw new Error('proveedor desconocido: ' + id); | |
| } | |
| // El Elffuss E4B HEALED no carga aún en navegador (export prefill_decode, E-010): | |
| // se oculta hasta el reexport artisan. Los Gemma E2B/E4B OFICIALES (build -web | |
| // de Google) SÍ cargan y se ofrecen como elegibles (no default: se bajan GB solo | |
| // si el usuario los elige). Poner true cuando el healed sea artisan. | |
| const ELFFUSS_LITERT_READY = false; | |
| // 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); } | |
| })(); | |
| // El runtime propio NO se versiona en este repo: llega a web/js/engine/ por el | |
| // rsync del deploy, igual que los pesos. Se SONDEA en vez de cablear un flag que | |
| // haya que acordarse de cambiar — así aparece en el selector solo donde está | |
| // desplegado de verdad, y ofrecerlo nunca es una promesa que falle al elegirlo. | |
| let ENGINE_READY = false; | |
| const engineCheck = (async () => { | |
| try { | |
| const r = await fetch('js/engine/provider.js', { method: 'HEAD', cache: 'no-store' }); | |
| return (ENGINE_READY = r.ok); | |
| } catch { return (ENGINE_READY = false); } | |
| })(); | |
| // El 27B se comprueba APARTE del motor, porque son dos cosas independientes: el | |
| // motor llega por rsync y el modelo es un fichero de gigabytes que puede no | |
| // estar alojado. Con el motor puesto y el modelo no, la opción aparecería y | |
| // fallaría al elegirla — y esta cuesta más que ninguna, porque el fallo llega | |
| // después de esperar. | |
| // | |
| // Un HEAD basta y no descarga nada. Si el modelo se muda a otro alojamiento hay | |
| // que cambiar esta ruta también; el precio de tenerla aquí es ese, y a cambio no | |
| // se importa el motor entero en cada carga de página solo para preguntárselo. | |
| // (RETIRADO Y DEVUELTO). Estuvo fuera porque colgaba el ordenador. La causa se | |
| // encontró y se arregló: el motor pedía a la GPU buffers temporales enormes y no | |
| // los soltaba hasta el final del forward, y sus núcleos corrían tanto sin ceder | |
| // que el sistema no llegaba a refrescar la pantalla. | |
| // · reciclado entre capas → el pico de memoria de GPU bajó unas 16 veces | |
| // · lotes de 128 → ningún despacho retiene la GPU tanto rato | |
| // Con eso, una carga completa con generación dejó de colgar nada. | |
| // | |
| // Se devuelve al selector porque el fallo tiene causa medida y corregida, no | |
| // porque haya dejado de reproducirse por suerte. Si vuelve a colgar una | |
| // máquina, el sitio correcto para mirar es el pico de memoria de GPU | |
| // (ops.ctx.picoBytes) y la duración de los despachos, no otra vez el modelo. | |
| // | |
| // El síntoma, por si hay que volver a retirarlo: despachos de GPU tan largos que | |
| // el escritorio dejaba de refrescarse y el sistema acababa reiniciándose. Colgarle | |
| // el ordenador a alguien es peor que cualquier respuesta mala. La regla que lo | |
| // evita: los núcleos troceán su trabajo en despachos cortos para que la GPU pueda | |
| // atender al escritorio entre uno y otro. | |
| const RETIRADO_27B_CUELGA_LA_MAQUINA = false; // arreglado: ver arriba | |
| // Se le pregunta AL MOTOR por sus propios ficheros, en vez de repetir aquí una | |
| // URL. Aquí había cableada la del 27B de un solo trozo; cuando pasó a dos | |
| // fragmentos en dos hosts, esta línea se quedó apuntando al fichero viejo y | |
| // seguía dando 200 de milagro, porque el fichero aún estaba en el servidor. O | |
| // sea: el selector decidía si ofrecer el modelo mirando un fichero que ya no se | |
| // usa. El día que se borre para recuperar disco, la opción desaparece y nadie | |
| // relaciona las dos cosas. | |
| // | |
| // Se importa `registro.js` y no `provider.js` a propósito: el registro son unos | |
| // kilobytes y el proveedor arrastra el motor entero, que no tiene por qué | |
| // bajarse quien nunca lo elija. | |
| let MODEL27_READY = false; | |
| const modelo27Check = (async () => { | |
| try { | |
| const reg = await import('./engine/registro.js'); | |
| return (MODEL27_READY = await reg.disponible('qwen38-27b')); | |
| } catch { return (MODEL27_READY = false); } | |
| })(); | |
| // Opciones del selector: locales siempre; externos solo si están activados. | |
| function modelOptions() { | |
| const local = []; | |
| if (realGPU) local.push({ id: 'litert:gemma-e4b', label: 'Gemma-4 E4B · LiteRT-LM (~2.8 GB) ★ — por defecto' }); | |
| if (realGPU) local.push({ id: 'litert:gemma-e2b', label: 'Gemma-4 E2B · LiteRT-LM (~2 GB) — más ligero, carga antes' }); | |
| local.push({ id: 'onnx:qwen3.5-0.8b', label: 'Qwen3.5-0.8B · WebGPU (~600 MB) — ligero' }); | |
| // Runtime propio: solo se ofrece si el motor está desplegado (llega por rsync, | |
| // no por git). Ofrecerlo cuando no está sería prometer algo que falla al | |
| // elegirlo. Ver ENGINE_READY. | |
| if (realGPU && ENGINE_READY) local.push({ id: 'engine:qwen35-0.8b', label: 'Qwen3.5-0.8B · motor propio (~800 MB)' }); | |
| // El 27B no está aquí por dudoso: su forward está verificado contra llama.cpp | |
| // capa a capa y genera texto coherente. Está aquí por lo que CUESTA, y el coste | |
| // ya está MEDIDO, no supuesto: además de la descarga, leer un prompt largo | |
| // —persona, herramientas y contexto, que es todo prompt real de Claw— lo deja | |
| // muy por debajo de cualquier otro cerebro de la lista, y el retraso cae entero | |
| // ANTES del primer token, que es donde peor se lleva. | |
| // | |
| // Se sigue ofreciendo porque verlo correr tiene valor por sí mismo, pero la | |
| // etiqueta lo dice: quien lo elija para trabajar se va a arrepentir, y eso no | |
| // puede descubrirse después de bajar los gigas. | |
| // | |
| // Ya SÍ se guarda, y costó entender por qué no podía. El navegador corta un | |
| // fichero suelto sobre los 1,94 GB, y el broker guardaba cada modelo en uno: | |
| // 3,8 GB fallaban con «exceed its storage quota», que suena a falta de | |
| // espacio y no lo era. Troceando en partes de 1 GiB entra, y medido con el | |
| // registro del servidor la segunda carga son CERO peticiones. La etiqueta | |
| // pasa de «gigas cada vez» a «gigas una vez», que cambia la decisión. | |
| // | |
| // Aquí decía «solo conversa», y era cierto mientras el modelo tuvo 512 tokens | |
| // de contexto: no le cabía el catálogo de herramientas. Ya tiene 2048, que | |
| // supera CTX_MINIMO_COMPLETO, así que recibe el prompt completo y el catálogo | |
| // con él. La afirmación caducó y se quita. | |
| // | |
| // NO se sustituye por «puede usar herramientas»: que reciba el catálogo es un | |
| // hecho del código, pero que un modelo a 1 bit emita un bloque ```tool bien | |
| // formado no lo ha comprobado nadie. Prometer eso en una etiqueta es | |
| // exactamente el error que esta etiqueta existe para evitar. | |
| if (realGPU && ENGINE_READY && MODEL27_READY && !RETIRADO_27B_CUELGA_LA_MAQUINA) local.push({ id: 'engine:qwen38-27b', label: 'Qwen3.8-27B IQ1 · motor propio (~7,6 GB, se guarda: solo se baja la primera vez) — lento', group: '⚠ Avanzado' }); | |
| if (realGPU && ELFFUSS_LITERT_READY) local.push({ id: 'litert:elffuss-e4b', label: 'Local · Elffuss E4B (healed) ★' }); | |
| local.push({ id: 'rules', label: 'Básico (sin modelo)' }); | |
| // Cerebros de bajo rendimiento: fuera del flujo normal, en un grupo avanzado y | |
| // avisados. Elffuss LM (LFM2.5-1.2B) medía flojo en el copiloto (no rastrea). | |
| const weak = [{ id: 'onnx:elffuss-lm', label: 'Elffuss LM (LFM2.5-1.2B) — poco rendimiento', group: '⚠ Avanzado · sin garantía de rendimiento' }]; | |
| return [...local, ...settings.enabledExternals(), ...weak]; | |
| } | |
| const isLocal = id => id === 'onnx' || id === 'litert' || id.startsWith('litert:') || | |
| id.startsWith('onnx:') || id.startsWith('engine:'); | |
| // Por defecto: Gemma-4 grande vía LiteRT-LM (build oficial -web, VERIFICADO que | |
| // carga y hace tool-calls) SOLO en escritorio (E4B). En MÓVIL no: E2B pesa ~1.9 GB | |
| // y el navegador móvil (mata la pestaña por encima de ~1-2 GB de datos vivos) no lo | |
| // sostiene, así que se re-descargaba y fallaba. Móvil → Elffuss LM (onnx, ~850 MB): | |
| // entra y funciona. E2B/E4B siguen elegibles a mano para quien tenga músculo. | |
| const isMobile = () => matchMedia('(max-width: 820px)').matches || matchMedia('(pointer: coarse)').matches; | |
| async function gpuCapacity() { | |
| const mem = navigator.deviceMemory || 8; | |
| if (!navigator.gpu) return { gpu: false, maxBuf: 0, mem }; | |
| try { const a = await navigator.gpu.requestAdapter(); | |
| return a ? { gpu: true, maxBuf: a.limits?.maxBufferSize || 0, mem } : { gpu: false, maxBuf: 0, mem }; | |
| } catch { return { gpu: false, maxBuf: 0, mem }; } | |
| } | |
| async function pickLocalBrain() { | |
| const c = await gpuCapacity(); | |
| if (c.gpu && !isMobile()) { | |
| if (c.maxBuf >= 2 ** 31 && c.mem >= 8) return 'litert:gemma-e4b'; | |
| if (c.maxBuf >= 2 ** 30 && c.mem >= 6) return 'litert:gemma-e2b'; | |
| } | |
| // Móvil: aunque el navegador móvil tenga WebGPU, NO cargar Gemma grande | |
| // —no cabe en su presupuesto de memoria—. Cerebro ligero: Qwen3.5-0.8B. | |
| return 'onnx:qwen3.5-0.8b'; | |
| } | |
| const defaultBrain = () => (!realGPU || isMobile()) ? 'onnx:qwen3.5-0.8b' : 'litert:gemma-e4b'; | |
| const agent = new Agent(rules); | |
| let busy = false; | |
| let hardMode = false; // Hard Work (RLM): la próxima petición se procesa por partes | |
| let activeModel = 'rules'; // qué proveedor está cargado (para el vigilante de RAM) | |
| let activeMod = null; | |
| // ---- cola de mensajes ---- | |
| // Los mensajes se apilan y se procesan en orden. La cola persiste en | |
| // IndexedDB: un refresco a media respuesta NO pierde lo pendiente (el mensaje | |
| // en vuelo solo sale de la cola cuando su turno TERMINA; el histórico también | |
| // se guarda al terminar, así que reprocesarlo tras un refresco no duplica). | |
| const queue = []; | |
| let pumping = false; | |
| let bootReady; | |
| const bootPromise = new Promise(r => { bootReady = r; }); | |
| const persistQueue = () => | |
| db.set('kv', 'queue', queue.map(q => q.text)).catch(() => {}); | |
| function send(text) { | |
| const el = ui.addMsg('user queued', text); | |
| queue.push({ text, el }); | |
| persistQueue(); | |
| if (queue.length > 1) ui.toast(`En cola (${queue.length - 1} por delante) — no se pierde ni refrescando.`); | |
| pump(); | |
| } | |
| async function pump() { | |
| if (pumping) return; | |
| pumping = true; | |
| await bootPromise; // no procesar hasta que el cerebro inicial esté resuelto | |
| while (queue.length) { | |
| const item = queue[0]; | |
| item.el?.classList.remove('queued'); | |
| await process(item.text); | |
| // Orden anti-pérdida: el histórico ya está COMMITEADO dentro de process() | |
| // (incluye este turno como lastDone). Solo entonces sacamos de la cola. | |
| // Si el refresco cae entre ambos commits, el mensaje aparece en histórico | |
| // Y en cola → restoreQueue lo deduplica; nunca se pierde. | |
| queue.shift(); | |
| await persistQueue(); | |
| } | |
| pumping = false; | |
| } | |
| async function process(text) { | |
| if (hardMode) return processHardWork(text); | |
| busy = true; | |
| const thinking = ui.thinkingBubble(); | |
| try { | |
| await agent.handle(text, ev => { | |
| if (ev.type === 'token') thinking.tick(ev.text); | |
| if (ev.type === 'text') { ui.addMsg('assistant', ev.text); if (window.__copilotOpener) { try { window.__copilotEmit(ev.text); } catch { /* */ } } } | |
| if (ev.type === 'tool') { thinking.tool(ev.call.tool); ui.addTool(ev.call); } | |
| if (ev.type === 'tool_result') ui.addToolResult(ev.tool, ev.result); | |
| if (ev.type === 'error') ui.addMsg('assistant err', ev.text); | |
| }); | |
| } finally { | |
| thinking.remove(); | |
| busy = false; | |
| // Histórico persistente COMMITEADO (await) antes de vaciar la cola: al | |
| // refrescar, la conversación sigue ahí y ningún mensaje se pierde. | |
| await db.set('kv', 'history', agent.history.slice(-80)).catch(() => {}); | |
| await db.set('kv', 'lastDone', text).catch(() => {}); | |
| workspace.touch('history'); | |
| } | |
| } | |
| // Hard Work (RLM): trabaja la carpeta autorizada ENTERA por partes. El modelo | |
| // no ve todo de golpe — se trocea, se pregunta a cada parte y se funde. Tarda, | |
| // pero abarca material que no cabe en la ventana del modelo. | |
| async function processHardWork(question) { | |
| busy = true; | |
| const thinking = ui.thinkingBubble(); | |
| const fail = msg => { thinking.remove(); ui.addMsg('assistant', msg); busy = false; }; | |
| try { | |
| if (activeModel === 'rules') return fail('Hard Work necesita un cerebro cargado: elige un modelo local o remoto en el selector de arriba y vuelve a intentarlo.'); | |
| const names = await fs.folders().catch(() => []); | |
| if (!names.length) { | |
| // Sin carpeta que leer → modo CREACIÓN: el modelo hace un borrador, se | |
| // autocritica y se reescribe. Misma petición, dos pasadas → mejor app. | |
| const res = await deepCreate({ | |
| brief: question, provider: agent.provider, rounds: 1, | |
| onProgress: p => thinking.tool( | |
| p.phase === 'draft' ? 'Hard Work · borrador…' | |
| : p.phase === 'critique' ? `Hard Work · autocrítica ${p.round}` | |
| : p.phase === 'rewrite' ? `Hard Work · reescribiendo ${p.round}` | |
| : 'Hard Work · listo'), | |
| }); | |
| thinking.remove(); | |
| const name = (question.trim().slice(0, 24) || 'app'); | |
| try { await apps.create({ name, html: res.html }); } catch { /* si falla, al menos responde */ } | |
| ui.addMsg('assistant', `Listo. Lo trabajé en dos pasadas — borrador → autocrítica → reescritura — y lo dejé en el visualizador.\n\n_⛏ Hard Work (creación): ${res.trace.length - 1} ronda de mejora._`); | |
| agent.history.push({ role: 'user', content: question, ts: Date.now() }); | |
| agent.history.push({ role: 'assistant', content: '(app creada con Hard Work)', ts: Date.now() }); | |
| return; // el finally resetea busy y guarda el histórico | |
| } | |
| thinking.tool('Hard Work · abriendo carpeta…'); | |
| const root = await fs.root(); | |
| const context = await gatherFolder(root, { maxBytes: 1.5e6 }); | |
| if (!context.trim()) return fail('Esa carpeta no tiene archivos de texto que pueda leer. Autoriza otra con documentos/código y reintenta.'); | |
| const res = await hardWork({ | |
| question, context, provider: agent.provider, | |
| onProgress: p => { | |
| if (p.phase === 'plan') thinking.tool(`Hard Work · ${p.chunks} partes (${Math.round(p.chars / 1000)}k)${p.truncated ? ' — recorté al máximo' : ''}`); | |
| 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 grupo ${p.g}/${p.n}`); | |
| else if (p.phase === 'reduce') thinking.tool('Hard Work · sintetizando…'); | |
| }, | |
| }); | |
| thinking.remove(); | |
| ui.addMsg('assistant', res.answer + `\n\n_⛏ Hard Work (RLM): leí ${names[0]} en ${res.chunks} partes, ${res.kept} con señal.${res.truncated ? ' Material recortado al máximo — acota la carpeta para abarcarlo entero.' : ''}_`); | |
| agent.history.push({ role: 'user', content: question, ts: Date.now() }); | |
| agent.history.push({ role: 'assistant', content: res.answer, ts: Date.now() }); | |
| } catch (e) { | |
| thinking.remove(); | |
| ui.addMsg('assistant err', 'Hard Work falló: ' + (e?.message || String(e))); | |
| } finally { | |
| busy = false; | |
| await db.set('kv', 'history', agent.history.slice(-80)).catch(() => {}); | |
| await db.set('kv', 'lastDone', question).catch(() => {}); | |
| } | |
| } | |
| // Recuperar mensajes que quedaron en cola antes de un refresco. Deduplica el | |
| // posible solapamiento con el último turno ya completado (ver pump()). | |
| async function restoreQueue() { | |
| let pending = await db.get('kv', 'queue').catch(() => null); | |
| if (!pending?.length) return; | |
| const lastDone = await db.get('kv', 'lastDone').catch(() => null); | |
| if (lastDone && pending[0] === lastDone) pending = pending.slice(1); // ya se procesó | |
| if (!pending.length) { db.set('kv', 'queue', []).catch(() => {}); return; } | |
| for (const text of pending) { | |
| const el = ui.addMsg('user queued', text); | |
| queue.push({ text, el }); | |
| } | |
| ui.toast(`${pending.length} mensaje(s) pendientes recuperados — los proceso en cuanto cargue el cerebro.`); | |
| pump(); | |
| } | |
| // Reconstruir la conversación guardada (burbujas + memoria del agente). | |
| async function restoreHistory() { | |
| const saved = await db.get('kv', 'history').catch(() => null); | |
| if (!saved?.length) return; | |
| agent.history = saved; | |
| for (const m of saved) { | |
| if (m.role === 'user') { | |
| if (m.content.startsWith('[resultado ')) { | |
| const nl = m.content.indexOf('\n'); | |
| ui.addToolResult(m.content.slice(11, nl > 0 ? nl - 1 : undefined), nl > 0 ? m.content.slice(nl + 1) : ''); | |
| } else if (!m.content.startsWith('[…')) { | |
| ui.addMsg('user', m.content); | |
| } | |
| } else { | |
| const call = parseToolCall(m.content); | |
| if (call) ui.addTool(call); | |
| else ui.addMsg('assistant', m.content); | |
| } | |
| } | |
| ui.toast('Conversación restaurada (para empezar de cero)'); | |
| } | |
| document.getElementById('btn-clear').addEventListener('click', async () => { | |
| await db.del('kv', 'history').catch(() => {}); | |
| await db.del('kv', 'queue').catch(() => {}); | |
| agent.history = []; | |
| location.reload(); | |
| }); | |
| async function changeModel(id) { | |
| if (id === 'rules') { // modo básico | |
| agent.setProvider(rules); | |
| activeModel = 'rules'; | |
| activeMod = null; | |
| ui.modelStatus('off'); | |
| localStorage.setItem('elffuss.model', 'rules'); | |
| ui.setModel('rules'); | |
| ui.toast('Modo básico: sin modelo, órdenes directas.'); | |
| return true; | |
| } | |
| ui.modelStatus('loading'); | |
| try { | |
| const mod = await resolveProvider(id); | |
| ui.modelProgress('Preparando modelo…'); | |
| await mod.load(p => ui.modelProgress(p)); | |
| ui.modelProgress(null); | |
| agent.setProvider(mod); | |
| activeModel = id; | |
| activeMod = mod; | |
| localStorage.setItem('elffuss.model', id); | |
| ui.setModel(id); | |
| const where = isLocal(id) ? (realGPU ? t('whereGpu') : t('whereCpu')) : t('whereExt'); | |
| ui.modelStatus(isLocal(id) && realGPU ? 'gpu' : 'on'); | |
| ui.toast(t('modelReady', { where })); | |
| if (id === 'onnx:elffuss-lm') ui.toast('⚠ Cerebro de bajo rendimiento: puede no rastrear bien ni dar buenos consejos. Para ventas/precisión usa Gemma (escritorio).'); | |
| if (window.__copilotOpener) copilotPostState('Listo. Habla con el cliente y te voy soplando.'); | |
| return true; | |
| } catch (e) { | |
| ui.modelProgress(null); | |
| console.error('[elffuss] fallo cargando modelo', e); | |
| // Si un Gemma (LiteRT) no cabe/ falla, cae a LFM2.5 en vez de dejar sin cerebro. | |
| if (id.startsWith('litert') && !_fellBack) { | |
| _fellBack = true; | |
| sessionStorage.setItem('elffuss.skipGemma', '1'); // no reintentar el pesado esta sesión | |
| ui.toast('Ese Gemma no cargó aquí (memoria/GPU) — uso Qwen3.5-0.8B (ligero).'); | |
| const ok = await changeModel('onnx:qwen3.5-0.8b'); | |
| _fellBack = false; | |
| if (ok) return true; | |
| } | |
| ui.modelStatus('off'); | |
| agent.setProvider(rules); | |
| activeModel = 'rules'; | |
| ui.setModel('rules'); | |
| ui.toast('⚠️ No se pudo cargar ' + id + ': ' + (e?.message || String(e))); | |
| return false; | |
| } | |
| } | |
| let _fellBack = false; | |
| // Rehacer el selector (locales + externos activados) tras tocar la config. | |
| function refreshModelOptions() { | |
| ui.rebuildModelSelect(modelOptions(), activeModel); | |
| } | |
| // Splash de primera visita: galaxia WebGL + promesa de privacidad + guiño. | |
| const splash = document.getElementById('splash'); | |
| if (!localStorage.getItem('elffuss.welcomed')) { | |
| splash.hidden = false; | |
| let galaxy = null; | |
| import('./splash-gl.js') | |
| .then(m => { galaxy = m.startGalaxy(splash); }) | |
| .catch(() => {}); // sin WebGL queda el gradiente CSS | |
| document.getElementById('splash-enter').addEventListener('click', () => { | |
| localStorage.setItem('elffuss.welcomed', '1'); | |
| galaxy?.burst(); // las estrellas salen despedidas al entrar | |
| splash.style.opacity = '0'; | |
| setTimeout(() => { splash.hidden = true; galaxy?.stop(); }, 800); | |
| }); | |
| } | |
| skills.initSkills(); | |
| ui.init({ onSend: send, onModelChange: changeModel, onSettingsChanged: refreshModelOptions }); | |
| ui.mountDiskChip(); // cuánto ocupan los modelos en este navegador, y cómo vaciarlo | |
| // Botón «liberar el modelo» del panel de consumo: suelta la RAM sin recargar. | |
| ui.setOnFreeModel(async () => { | |
| const mod = activeMod; | |
| agent.setProvider(rules); activeModel = 'rules'; activeMod = null; | |
| ui.setModel('rules'); ui.modelStatus('off'); | |
| try { await mod?.unload?.(); } catch { /* mejor esfuerzo */ } | |
| }); | |
| refreshModelOptions(); | |
| restoreHistory().then(restoreQueue); | |
| // ── Cerebro CEO autónomo + Mente de Elffuss ─────────────────────────────── | |
| // Cuando NO le pides nada, Elffuss trabaja por su cuenta: revisa tus carpetas | |
| // autorizadas, tareas, apps y memoria, y propone mejoras (sin tocar nada por | |
| // su cuenta — la propuesta se ejecuta con el mismo camino real que el chat). | |
| // «Abrir en el editor» de un pensamiento no aplica aquí (no hay editor de | |
| // código) — en su lugar, pide en el chat el contenido de ese fichero. | |
| mind.setOpenFile(path => send('Muéstrame el contenido de ' + path)); | |
| mind.setExecuteProposal(md => { mind.closeMind(); send('Implementa esta propuesta de mejora usando las herramientas disponibles:\n\n' + md); }); | |
| mind.init({}); // sin ciudad de fondo (Claw v1): la Mente funciona igual sin ella | |
| const WEAK_RE = /^(no (encontr|hay|se me ocurre)|nada que|sin cambios|todo (está|esta) bien|no aplica)/i; | |
| 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 líneas de pensamiento con algo sustancioso | |
| } | |
| function executeProposal(md) { send('Implementa esta propuesta de mejora usando las herramientas disponibles:\n\n' + md); } | |
| // SOLO notifica si el permiso YA está concedido — nunca lo pide aquí (fuera | |
| // de un gesto de usuario los navegadores lo bloquean en silencio de todos | |
| // modos); pedirlo vive únicamente en el clic del botón 🧠. | |
| function notify(title, body) { | |
| if (!('Notification' in window) || Notification.permission !== 'granted') return; | |
| try { new Notification(title, { body, icon: 'img/elffuss.svg' }); } catch { /* */ } | |
| } | |
| 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é tu espacio 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}\`. Pulsa 🧠 para verlo en la Mente._` : ''); | |
| const div = ui.addMsg('assistant', body); | |
| 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(' · ')); | |
| } | |
| ceo.init({ | |
| namespace: 'elffuss', | |
| workspace: ceoWorkspace, | |
| defaultProfiles: CEO_DEFAULT_PROFILES, | |
| defaultMission: 'Revisar tu espacio (carpetas, apps, tareas, memoria) y proponer mejoras concretas y accionables.', | |
| provider: () => agent.provider, | |
| // el usuario tiene PRIORIDAD: si hay algo en cola o procesándose, el cerebro espera | |
| isBusy: () => pumping || queue.length > 0, | |
| onEvent: (ch, ev) => { | |
| mind.pushThought(ch, ev); | |
| if (ch === 'ceo' && ev.type === 'built') reportImprovements(ev); | |
| }, | |
| }); | |
| // 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); | |
| document.getElementById('btn-mind').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. EXCEPTO en | |
| // modo copiloto: ahí el modelo es para soplarte argumentos en vivo, no para | |
| // ponerse a inventar mejoras (y de paso robarle el visor al tablero). | |
| const IS_COPILOT = /[?&]copilot=1/.test(location.search); | |
| if (!IS_COPILOT && ceo.wasEnabledLastSession()) ceo.enable(); | |
| // Autocarga SOLO LOCAL. Por defecto Gemma-4 E4B (escritorio) o E2B (móvil/GPU | |
| // débil), vía LiteRT-LM; si no cabe, cae a LFM2.5. Respeta lo último elegido. | |
| (async () => { | |
| try { | |
| // Caché de modelos (persistente + service worker) ANTES de descargar nada, | |
| // para que hasta la primera descarga de pesos quede cacheada y no se repita. | |
| await ensureModelCache(); | |
| await realGPUCheck; // que defaultBrain()/modelOptions() vean el adaptador real, no solo la API | |
| // El sondeo del motor propio resuelve después de que ui.init() pintara el | |
| // selector, así que hay que repintarlo: si no, la opción no aparece hasta | |
| // que algo más fuerce un refresco, y el usuario no la ve nunca. | |
| // Los dos sondeos se esperan JUNTOS. Repintar solo con el del motor dejaría | |
| // fuera el 27B cuando su comprobación tarda un poco más, y esa opción no | |
| // volvería a aparecer hasta que algo forzara otro refresco. | |
| const [hayMotor, hay27] = await Promise.all([engineCheck, modelo27Check]); | |
| if (hayMotor || hay27) refreshModelOptions(); | |
| const saved = localStorage.getItem('elffuss.model'); | |
| if (saved === 'rules') return; // elección explícita | |
| // En modo copiloto el translator YA está usando la máquina (Whisper + | |
| // diarización), así que si NUNCA has elegido cerebro se arranca con el | |
| // ligero para no competir. Pero tu elección manda: si escogiste Gemma, | |
| // se respeta — pisarla era un error. | |
| if (/[?&]copilot=1/.test(location.search) && !saved) { | |
| ui.toast('Copiloto: arranco con el cerebro ligero para no competir con el traductor. Para ventas/precisión, elige Gemma arriba (escritorio).'); | |
| if (await changeModel('onnx:qwen3.5-0.8b')) return; | |
| } | |
| const available = new Set(modelOptions().map(o => o.id)); | |
| // si un Gemma pesado ya falló esta sesión, no reintentar (evita re-crash) | |
| const skipGemma = sessionStorage.getItem('elffuss.skipGemma') === '1'; | |
| const def = skipGemma ? 'onnx' : await pickLocalBrain(); | |
| const chain = [...new Set([saved, def, realGPU ? 'onnx' : null] | |
| .filter(id => id && available.has(id)))]; | |
| if (!chain.length) return; | |
| // MÓVIL: no descargar medio giga sin permiso. Hasta hoy, abrir esto en un | |
| // teléfono arrancaba SOLO la descarga del cerebro (~600 MB) al cargar la | |
| // página. Dos motivos para no hacerlo: son los datos de alguien que no ha | |
| // pedido nada, y encima el navegador de móvil mata la pestaña bastante | |
| // antes de sostener el modelo, así que muchas veces se gastaban para nada. | |
| // Se avisa, se deja el botón por si quiere insistir, y se recuerda su | |
| // elección. El modo básico sigue atendiendo mientras tanto. | |
| if (isMobile() && localStorage.getItem('elffuss.movil.ok') !== '1') { | |
| ui.mobileGate('~600 MB', () => { | |
| (async () => { for (const id of chain) if (await changeModel(id)) return; })(); | |
| }); | |
| return; | |
| } | |
| const first = chain[0]; | |
| ui.toast(first.startsWith('litert') | |
| ? 'Cargando Gemma (varios GB la 1ª vez)… mientras, el modo básico te atiende.' | |
| : 'Cargando el cerebro local… mientras, el modo básico te atiende.'); | |
| for (const id of chain) | |
| if (await changeModel(id)) return; | |
| } finally { | |
| bootReady(); // la cola puede empezar a procesar (con el cerebro que haya) | |
| } | |
| })(); | |
| // Vigilante de RAM: si el heap del navegador va muy lleno (los modelos | |
| // locales viven ahí), primero avisa y, si sigue subiendo, para y LIBERA el | |
| // modelo automáticamente antes de que la pestaña se caiga. El modelo remoto | |
| // no gasta RAM local, así que no se toca. | |
| let ramWarned = false; | |
| setInterval(async () => { | |
| const m = performance.memory; | |
| if (!m?.jsHeapSizeLimit) return; // solo Chrome | |
| const ratio = m.usedJSHeapSize / m.jsHeapSizeLimit; | |
| if (ratio < 0.7) { ramWarned = false; return; } | |
| if (ratio > 0.92 && isLocal(activeModel)) { | |
| const mod = activeMod; | |
| agent.setProvider(rules); | |
| activeModel = 'rules'; | |
| activeMod = null; | |
| ui.setModel('rules'); | |
| ui.modelStatus('off'); | |
| try { await mod?.unload?.(); } catch { /* mejor esfuerzo */ } | |
| ui.addMsg('assistant', | |
| '⚠️ Ojo: tu ordenador está muy a full de memoria — puedes experimentar caídas ' + | |
| 'o rendimiento lento si sigues usándolo. Por el momento me he parado y he ' + | |
| 'liberado el modelo. Si quieres, me arrancas de nuevo desde el selector ' + | |
| 'de arriba. '); | |
| ui.toast('⚠️ RAM muy llena: modelo local liberado automáticamente'); | |
| } else if (ratio > 0.82 && !ramWarned) { | |
| ramWarned = true; | |
| ui.toast(`⚠️ La memoria del navegador va al ${Math.round(ratio * 100)}% — si sigue subiendo, liberaré el modelo yo sola.`); | |
| } | |
| }, 15000); | |
| // Las tareas vencidas se auto-envían como prompts al agente. | |
| tasks.startScheduler(t => { | |
| ui.toast('Tarea programada: ' + t.prompt); | |
| send('[tarea programada] ' + t.prompt); | |
| }); | |
| // Automatizaciones de carpetas: dejar un archivo en la de origen → Elffuss lo | |
| // procesa y lo deja en la de destino, avisando por chat. | |
| watch.startWatcher(msg => { | |
| ui.toast(msg); | |
| ui.addMsg('assistant', msg); | |
| }); | |
| // ── Espacio de trabajo: qué se puede guardar en tu disco / ver / borrar ── | |
| // El manifiesto es lo ÚNICO que Claw le cuenta al core sobre sus almacenes. | |
| workspace.init({ | |
| app: 'claw', ns: 'elffuss', db, | |
| stores: [ | |
| { id: 'apps', label: 'Apps generadas', icon: '🎨', kind: 'idb-store', store: 'apps', | |
| files: { ext: '.html', name: a => a.name, body: a => a.html } }, | |
| { 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: 'history', label: 'Conversación', icon: '💬', kind: 'idb-key', store: 'kv', key: 'history' }, | |
| { id: 'memory', label: 'Memoria', icon: '🧠', kind: 'idb-store', store: 'memory' }, | |
| { id: 'tasks', label: 'Tareas programadas', icon: '⏰', kind: 'idb-store', store: 'tasks' }, | |
| { id: 'vault', label: 'Bóveda (cifrada)', icon: '🔐', kind: 'idb-store', store: 'vault', sensitive: true }, | |
| { id: 'prefs', label: 'Preferencias', icon: '⚙️', kind: 'ls', | |
| keys: ['elffuss.model', 'elffuss.acer', 'elffuss.resumen', 'elffuss.semantic'] }, | |
| ], | |
| }); | |
| workspace.restore().catch(() => {}); | |
| if (workspace.autosaveEnabled()) workspace.autosave({ enabled: true }); | |
| window.elffuss = { agent, send, workspace }; // consola de depuración | |
| // ── Hard Work (RLM): trabaja la carpeta autorizada entera por partes ── | |
| const hwBtn = document.getElementById('btn-hardwork'); | |
| hwBtn?.addEventListener('click', () => { | |
| hardMode = !hardMode; | |
| hwBtn.classList.toggle('on', hardMode); | |
| const prompt = document.getElementById('prompt'); | |
| if (hardMode) { | |
| prompt.placeholder = '⛏ Hard Work: crea algo, o pregunta sobre TODA tu carpeta…'; | |
| ui.toast('Hard Work activado. Con carpeta autorizada: la leo entera por partes y respondo. Sin carpeta: creo lo que pidas en dos pasadas (borrador → autocrítica → mejora). Tarda más, sale mejor.'); | |
| } else { | |
| prompt.placeholder = 'Pídeme lo que necesites… (/ para comandos)'; | |
| ui.toast('Hard Work desactivado: vuelvo al modo normal.'); | |
| } | |
| }); | |
| // ── Skills de audio en vivo + superficies (SSP v0.1) ──────────────────────── | |
| // Una skill de audio escucha la conversación que llega del Translator y pinta | |
| // su propia SUPERFICIE: una vista viva, aparte de las notificaciones, que el | |
| // host monta, actualiza y gestiona. La superficie se proyecta también a la app | |
| // que la abrió (pantalla partida real). | |
| // | |
| // Cada skill declara: qué ranuras rellena (objetivos o campos), qué instruye al | |
| // modelo y cómo se parsea su respuesta. | |
| const AUDIO_SKILLS = { | |
| ventas: { | |
| id: 'ventas', icon: '🎯', name: 'Copiloto de ventas', | |
| desc: 'Te sopla el argumento exacto y marca los objetivos de la llamada.', | |
| kind: 'goals', tag: 'OBJETIVOS', | |
| slots: [ | |
| { key: 'nombre', label: 'El cliente da su nombre' }, | |
| { key: 'interes', label: 'El cliente muestra interés' }, | |
| { key: 'telefono', label: 'El cliente da su teléfono' }, | |
| { key: 'compra', label: 'El cliente quiere comprar' }, | |
| ], | |
| rule: 'OBJETIVOS: nombre=si|no; interes=si|no; telefono=si|no; compra=si|no', | |
| role: 'Eres mi copiloto de ventas EN VIVO. Persigue los objetivos en orden y dime el argumento exacto, la objeción a rebatir o el siguiente paso.', | |
| }, | |
| ficha: { | |
| id: 'ficha', icon: '📇', name: 'Ficha de cliente', | |
| desc: 'Rellena sola la ficha con lo que el cliente va diciendo. Se exporta a Excel.', | |
| kind: 'fields', tag: 'DATOS', exportable: true, | |
| slots: [ | |
| { key: 'nombre', label: 'Nombre' }, { key: 'empresa', label: 'Empresa' }, | |
| { key: 'cargo', label: 'Cargo' }, { key: 'telefono', label: 'Teléfono' }, | |
| { key: 'email', label: 'Email' }, { key: 'necesidad', label: 'Necesidad' }, | |
| { key: 'presupuesto', label: 'Presupuesto' }, { key: 'plazo', label: 'Plazo' }, | |
| { key: 'siguiente', label: 'Siguiente paso' }, | |
| ], | |
| rule: 'DATOS: solo los campos que se hayan dicho, separados por punto y coma. Ejemplo: DATOS: nombre=Ana Ruiz; empresa=Acme; telefono=600123456. Omite los que no se hayan dicho; no escribas puntos suspensivos.', | |
| role: 'Eres mi asistente de ficha de cliente. Extraes los datos que el cliente dice y me avisas de qué falta por preguntar.', | |
| }, | |
| facilitador: { | |
| id: 'facilitador', icon: '🧑🏫', name: 'Facilitador de reunión', | |
| desc: 'Temas, decisiones y tareas de la reunión, en vivo. Se exporta.', | |
| kind: 'fields', tag: 'DATOS', exportable: true, | |
| slots: [ | |
| { key: 'tema', label: 'Tema actual' }, { key: 'decisiones', label: 'Decisiones' }, | |
| { key: 'tareas', label: 'Tareas (quién/qué)' }, { key: 'riesgos', label: 'Riesgos' }, | |
| { key: 'siguiente', label: 'Siguientes pasos' }, | |
| ], | |
| rule: 'DATOS: solo lo que se haya dicho. Ejemplo: DATOS: tema=presupuesto 2027; decisiones=aprobado el piloto; tareas=Ana envía la propuesta. Acumula, no borres lo anterior; no escribas puntos suspensivos.', | |
| role: 'Eres el facilitador de la reunión. Sigues el hilo, anotas decisiones y tareas, y avisas si se desvía o si algo queda sin dueño.', | |
| }, | |
| resumen: { | |
| id: 'resumen', icon: '📝', name: 'Resumen e ideas', | |
| desc: 'Resumen progresivo y las ideas que van saliendo.', | |
| kind: 'fields', tag: 'DATOS', exportable: true, | |
| slots: [ | |
| { key: 'resumen', label: 'Resumen hasta ahora' }, { key: 'ideas', label: 'Ideas' }, | |
| { key: 'dudas', label: 'Dudas abiertas' }, | |
| ], | |
| rule: 'DATOS: resumen=<reescríbelo entero y breve cada vez>; ideas=<las que hayan salido>; dudas=<las abiertas>. No escribas puntos suspensivos.', | |
| role: 'Resumes la conversación según avanza y capturas las ideas y dudas que surgen.', | |
| }, | |
| }; | |
| function audioSkillMd(sk) { | |
| return `Esta skill SOLO actúa cuando llegan intervenciones marcadas con "[Hablante N]" (Hablante 1 = el usuario; Hablante 2 = el interlocutor). En una conversación normal de Claw, ignórala salvo que el usuario te pida editarla. | |
| ${sk.role} | |
| POR CADA intervención responde EXACTAMENTE en tres líneas, nada más: | |
| ${gram.contrato(sk)} | |
| ACCION es UNA de esas palabras, tal cual. En ${sk.tag} solo lo que se haya DICHO: un dato que nadie ha dicho se descarta. | |
| Si no hay nada útil que aportar ahora, usa "CONSEJO: —". Nunca borres un dato ya capturado. | |
| AUTO-EDICIÓN: puedes cambiarte a ti misma. Si el usuario te pide por el chat de Claw algo como "añade un campo email", "quita el presupuesto" o "sé más breve", edita ESTA misma skill con skill.edit y sigue la nueva versión desde ya.`; | |
| } | |
| // Superficie genérica: pinta las ranuras de la skill (objetivos o campos), | |
| // el consejo en vivo y, si procede, el botón de exportar a Excel. | |
| function surfaceHtml(sk) { | |
| return `<!doctype html><html lang="es"><head><meta charset="utf-8"> | |
| <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:"> | |
| <style> | |
| :root{--bg:#0b0c14;--card:#12141f;--line:#232838;--brass:#c8a06a;--brass2:#e6c294;--indigo:#8272ff;--fg:#eae8f2;--dim:#8b91a3;--ok:#54dcc6} | |
| *{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:radial-gradient(130% 90% at 50% -10%,#181630,#0b0c14);color:var(--fg);padding:20px;min-height:100vh;display:flex;flex-direction:column} | |
| h1{font-family:"Iowan Old Style",Georgia,serif;font-size:1.12rem;margin:0 0 3px;background:linear-gradient(96deg,var(--brass2),var(--indigo));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent} | |
| .sub{color:var(--dim);font-size:.6rem;letter-spacing:.15em;text-transform:uppercase;margin-bottom:14px;font-family:ui-monospace,monospace} | |
| .sub b{color:var(--brass2);float:right;font-weight:500} | |
| .bar{height:7px;border-radius:99px;background:var(--line);overflow:hidden;margin-bottom:16px} | |
| .bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--brass),var(--indigo));transition:width .7s cubic-bezier(.2,.7,.2,1)} | |
| .slot{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:11px;background:var(--card);margin-bottom:8px;transition:border-color .4s,background .4s,transform .3s} | |
| .slot .dot{width:20px;height:20px;border-radius:50%;border:2px solid var(--line);flex:none;display:grid;place-items:center;font-size:.65rem;color:transparent;transition:all .4s;margin-top:1px} | |
| .slot .k{font-size:.62rem;letter-spacing:.1em;text-transform:uppercase;color:var(--dim);font-family:ui-monospace,monospace} | |
| .slot .v{font-size:.9rem;color:var(--fg);line-height:1.35;word-break:break-word} | |
| .slot .lbl{font-size:.88rem;color:var(--dim);transition:color .4s} | |
| .slot.done{border-color:var(--brass);background:linear-gradient(120deg,rgba(200,160,106,.10),rgba(130,114,255,.05))} | |
| .slot.done .dot{background:var(--ok);border-color:var(--ok);color:#06210b} | |
| .slot.done .lbl{color:var(--fg)} | |
| .slot.just{animation:pop .6s cubic-bezier(.2,.8,.2,1)} | |
| @keyframes pop{0%{transform:scale(1)}40%{transform:scale(1.03)}100%{transform:scale(1)}} | |
| .advice{margin-top:auto;padding:13px 15px;border:1px solid var(--line);border-left:3px solid var(--brass);border-radius:10px;background:rgba(200,160,106,.05);font-size:.9rem;line-height:1.4;color:var(--brass2)} | |
| .advice .k{display:block;font-family:ui-monospace,monospace;font-size:.56rem;letter-spacing:.16em;text-transform:uppercase;color:var(--dim);margin-bottom:5px} | |
| .advice.flash{animation:advin .4s ease} | |
| @keyframes advin{0%{opacity:.4;transform:translateY(-3px)}100%{opacity:1;transform:none}} | |
| button{margin-top:10px;width:100%;background:transparent;color:var(--brass);border:1px solid var(--line);border-radius:8px;padding:9px;font:inherit;font-size:.78rem;cursor:pointer} | |
| button:hover{border-color:var(--brass)} | |
| </style></head><body> | |
| <h1>${sk.icon} ${sk.name}</h1> | |
| <div class="sub">${sk.kind === 'goals' ? 'Objetivos' : 'Datos capturados'} <b id="count"></b></div> | |
| <div class="bar"><i id="fill"></i></div> | |
| <div id="slots"></div> | |
| <div class="advice" id="advice"><span class="k">En vivo</span><span id="atext">Esperando a que empiece la conversación…</span></div> | |
| ${sk.exportable ? '<button id="exp">⬇ Exportar a Excel (CSV)</button>' : ''} | |
| <script> | |
| const KIND=${JSON.stringify(sk.kind)}, SLOTS=${JSON.stringify(sk.slots)}, NAME=${JSON.stringify(sk.name)}; | |
| let data={}; | |
| function render(justKey){ | |
| const box=document.getElementById('slots'); box.innerHTML=''; let filled=0; | |
| for(const s of SLOTS){ | |
| const v=data[s.key]; const has=KIND==='goals' ? v===true : !!(v&&String(v).trim()); | |
| if(has) filled++; | |
| const el=document.createElement('div'); | |
| el.className='slot'+(has?' done':'')+(s.key===justKey?' just':''); | |
| if(KIND==='goals'){ el.innerHTML='<div class="dot">✓</div><div class="lbl"></div>'; el.querySelector('.lbl').textContent=s.label; } | |
| else { el.innerHTML='<div class="dot">✓</div><div><div class="k"></div><div class="v"></div></div>'; | |
| el.querySelector('.k').textContent=s.label; el.querySelector('.v').textContent=has?v:'—'; } | |
| box.appendChild(el); | |
| } | |
| document.getElementById('count').textContent=filled+' / '+SLOTS.length; | |
| document.getElementById('fill').style.width=(SLOTS.length?filled/SLOTS.length*100:0)+'%'; | |
| } | |
| render(null); | |
| addEventListener('message',e=>{ | |
| const d=e.data||{}; if(d.type!=='surface-state') return; | |
| let just=null; | |
| if(d.data) for(const [k,v] of Object.entries(d.data)){ if(data[k]!==v&&v) just=k; data[k]=v; } | |
| render(just); | |
| if(typeof d.advice==='string'&&d.advice){ const a=document.getElementById('advice'); | |
| document.getElementById('atext').textContent=d.advice; | |
| a.classList.remove('flash'); void a.offsetWidth; a.classList.add('flash'); } | |
| }); | |
| const exp=document.getElementById('exp'); | |
| if(exp) exp.onclick=()=>{ | |
| const rows=[['Campo','Valor']].concat(SLOTS.map(s=>[s.label,(data[s.key]==null?'':String(data[s.key]))])); | |
| const csv='\\ufeff'+rows.map(r=>r.map(c=>'"'+String(c).replace(/"/g,'""')+'"').join(';')).join('\\r\\n'); | |
| const a=document.createElement('a'); | |
| a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(csv); | |
| a.download=NAME.toLowerCase().replace(/[^a-z0-9]+/g,'-')+'.csv'; a.click(); | |
| }; | |
| try{parent.postMessage({type:'surface-ready'},'*');}catch(e){} | |
| <\/script></body></html>`; | |
| } | |
| let copilotSkill = AUDIO_SKILLS.ventas; | |
| let copilotData = {}; | |
| function copilotInitState() { copilotData = {}; copilotTranscript = []; } | |
| function renderCopilotBoard() { | |
| try { ui.renderApp(copilotSkill.icon + ' ' + copilotSkill.name, surfaceHtml(copilotSkill)); } catch { /* */ } | |
| } | |
| function copilotPostState(advice) { | |
| // El visor de Claw es uno solo: si otra cosa (una app del agente, el cerebro | |
| // autónomo…) lo ocupa, la superficie desaparecía. Se re-monta sola. | |
| const f0 = document.getElementById('appframe'); | |
| if (!f0 || f0.hidden || !(f0.srcdoc || '').includes(copilotSkill.name)) renderCopilotBoard(); | |
| const frame = document.getElementById('appframe'); | |
| const msg = { type: 'surface-state', id: copilotSkill.id, data: copilotData, advice: advice || '' }; | |
| try { frame?.contentWindow?.postMessage(msg, '*'); } catch { /* */ } | |
| // Proyección a la app que abrió el copiloto: pantalla partida de verdad. | |
| if (window.__copilotOpener && window.opener) { | |
| try { window.opener.postMessage({ kind: 'surface-state', ...msg }, window.__copilotOpener); } catch { /* */ } | |
| } | |
| } | |
| // Parsea la respuesta del modelo contra la GRAMÁTICA del copiloto | |
| // (copilot-grammar.js): consejo libre pero verificado, acción de vocabulario | |
| // cerrado, y datos que solo entran si pasan el validador de su ranura y están | |
| // anclados en lo que se ha dicho. Guardia: tests/copilot_gramatica.mjs. | |
| function copilotParse(text) { | |
| return gram.parse(text, copilotSkill, { transcript: copilotTranscript.join('\n') }); | |
| } | |
| // El consejo se emite DESPUÉS de aplicar los datos (los del modelo y, si toca, | |
| // los deterministas): cuando el modelo no da uno utilizable, el que se compone | |
| // desde la acción + el tablero tiene que mirar el tablero YA corregido. | |
| function copilotEmit(text, { applyRules = false, isGemma = false } = {}) { | |
| const { advice, action, updates, rejected } = copilotParse(text); | |
| for (const [k, v] of Object.entries(updates)) copilotData[k] = v; | |
| if (applyRules) copilotHybrid(isGemma); | |
| // Qué ha filtrado la gramática, a mano para depurar en una llamada real. | |
| if (rejected.length) { window.__copilotRejected = rejected; console.debug('[copiloto] gramática rechazó:', rejected.join(', ')); } | |
| // Sin consejo utilizable el copiloto NO se calla: la acción es de vocabulario | |
| // cerrado (fiable incluso en cerebro ligero) y con el tablero da uno real. | |
| // Solo en un turno de copiloto (applyRules): por aquí pasan TAMBIÉN las | |
| // respuestas normales del chat de Claw, y esas no deben pisar el consejo con | |
| // un «pregunta ahora por…» que nadie ha pedido. | |
| const show = advice || (applyRules ? gram.fallbackAdvice(copilotSkill, copilotData, action) : ''); | |
| copilotPostState(show); | |
| if (show && window.__copilotOpener && window.opener) { | |
| try { window.opener.postMessage({ kind: 'copilot-advice', text: show }, window.__copilotOpener); } catch { /* */ } | |
| } | |
| } | |
| window.__copilotEmit = copilotEmit; | |
| // ── Extracción DETERMINISTA de datos del cliente ────────────────────────────── | |
| // El cerebro ligero NO rastrea fiable: Qwen marca todos los objetivos «sí» desde | |
| // el turno 1 (falsos positivos) y LFM2.5 no marca nada; además alucinan teléfonos. | |
| // Los datos regex-ables —teléfono (también HABLADO: «seis uno uno…»), email, | |
| // nombre— se sacan de la transcripción con reglas y MANDAN sobre el modelo. | |
| const _NUM = { cero: 0, uno: 1, una: 1, dos: 2, tres: 3, cuatro: 4, cinco: 5, seis: 6, siete: 7, ocho: 8, nueve: 9 }; | |
| function _spelledDigits(t) { | |
| const ws = t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').split(/[^a-z0-9]+/); | |
| let run = [], best = ''; | |
| for (const w of ws) { | |
| if (w in _NUM) run.push(_NUM[w]); | |
| else if (/^\d+$/.test(w)) { for (const d of w) run.push(+d); } | |
| else { if (run.length > best.length) best = run.join(''); run = []; } | |
| } | |
| if (run.length > best.length) best = run.join(''); | |
| return best; | |
| } | |
| function _phone(t) { | |
| const m = t.replace(/[\s.\-()]/g, '').match(/\+?\d{7,}/); | |
| if (m) return m[0]; | |
| const sp = _spelledDigits(t); | |
| return sp.length >= 7 ? sp : null; | |
| } | |
| const _EMAIL_RE = /[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/i; | |
| const _NAME_RE = /\b(?:me llamo|mi nombre es|soy|le habla|habla)\s+([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+){0,2})/; | |
| const _NAME_STOP = /^(de|del|la|el|un|una|responsable|comercial|gerente|director|jefe)\b/i; | |
| const _INTERES_RE = /\b(me interesa|nos interesa|interesad|me viene bien|suena bien|me gustar[ií]a)\b/i; | |
| const _COMPRA_RE = /\b(quiero contratar|queremos contratar|quiero comprar|lo contrato|me lo quedo|adelante|cerramos|firmamos|hacemos el pedido)\b/i; | |
| // Solo lo que dice el CLIENTE (Hablante 2); el vendedor (Hablante 1) no es el fichado. | |
| function _clientText(lines) { | |
| return lines.filter(l => /^\[Hablante 2|^\[interlocutor/i.test(l)) | |
| .map(l => l.replace(/^\[[^\]]*\]\s*/, '')).join('\n'); | |
| } | |
| function deterministicFields(lines) { | |
| const t = _clientText(lines), out = {}; | |
| const ph = _phone(t); if (ph) out.telefono = ph; | |
| const em = (t.match(_EMAIL_RE) || [])[0]; if (em) out.email = em; | |
| const nm = (t.match(_NAME_RE) || [])[1]; if (nm && !_NAME_STOP.test(nm)) out.nombre = nm.trim(); | |
| return out; | |
| } | |
| function deterministicGoals(lines) { | |
| const t = _clientText(lines), out = {}; | |
| const f = deterministicFields(lines); | |
| if (f.telefono) out.telefono = true; | |
| if (f.nombre) out.nombre = true; | |
| if (_INTERES_RE.test(t)) out.interes = true; | |
| if (_COMPRA_RE.test(t)) out.compra = true; | |
| return out; | |
| } | |
| let copilotTranscript = []; | |
| // Corrige copilotData con las reglas tras la respuesta del modelo: | |
| // · ficha → teléfono/email/nombre MANDAN (más fiables que el modelo pequeño). | |
| // · ventas en cerebro LIGERO → objetivos SOLO por reglas (mata el «todo sí»). | |
| // en Gemma → se suma como respaldo (Gemma ya rastrea bien). | |
| function copilotHybrid(isGemma) { | |
| const sk = copilotSkill; | |
| if (sk.id === 'ficha') { | |
| Object.assign(copilotData, deterministicFields(copilotTranscript)); | |
| } else if (sk.id === 'ventas') { | |
| const dg = deterministicGoals(copilotTranscript); | |
| if (isGemma) { for (const k in dg) copilotData[k] = true; } | |
| else { for (const s of sk.slots) copilotData[s.key] = !!dg[s.key]; } | |
| } | |
| } | |
| // Turno de copiloto por la VÍA RÁPIDA: no pasa por el bucle agéntico (que | |
| // mete todas las herramientas, las skills y el estado del sistema en el | |
| // prompt). Un modelo pequeño se pierde en ese muro y se pone a conversar en | |
| // vez de aconsejar — además de tardar mucho más. Aquí: prompt mínimo, | |
| // historial corto y el formato recordado en cada turno. | |
| let copilotHist = []; | |
| let copilotBusy = false; | |
| async function copilotTurn(line) { | |
| if (copilotBusy) return; // un turno a la vez: el modelo es uno | |
| copilotBusy = true; | |
| const sk = copilotSkill; | |
| // El contrato lo escribe la gramática (copilot-grammar.js), no este prompt: si | |
| // el formato cambia aquí y allí no, el validador tira todo lo que llegue. | |
| const sys = `Eres el copiloto de ${sk.name}. Escuchas una conversación ajena y AYUDAS a quien la mantiene. NUNCA respondas como si fueras un interlocutor ni continúes el diálogo. | |
| Responde SIEMPRE exactamente en tres líneas, sin comillas y sin nada más: | |
| ${gram.contrato(sk)} | |
| ACCION es UNA de esas palabras, tal cual. En ${sk.tag} solo lo que se haya DICHO: nada inventado. | |
| Si no hay nada útil que decir: CONSEJO: —`; | |
| copilotHist.push({ role: 'user', content: line }); | |
| if (copilotHist.length > 8) copilotHist = copilotHist.slice(-8); | |
| const recordatorio = `\n\n(Responde SOLO con las tres líneas: CONSEJO: … / ACCION: … / ${sk.tag}: …)`; | |
| const msgs = copilotHist.slice(0, -1).concat([{ role: 'user', content: line + recordatorio }]); | |
| try { | |
| const out = await agent.provider.chat(msgs, sys, () => {}); | |
| copilotHist.push({ role: 'assistant', content: String(out).slice(0, 400) }); | |
| ui.addMsg('assistant', String(out).slice(0, 300)); | |
| // La transcripción se apunta ANTES de emitir: la gramática la usa para anclar | |
| // los datos (lo que nadie dijo no entra) y para cazar el consejo que solo es | |
| // eco del interlocutor. | |
| copilotTranscript.push(line); | |
| // applyRules → corrección determinista dentro del emit: los datos | |
| // regex-ables mandan sobre el modelo y, en cerebro ligero, los objetivos | |
| // salen SOLO de reglas. El consejo se compone ya con el tablero corregido. | |
| copilotEmit(out, { applyRules: true, isGemma: activeModel.startsWith('litert') }); | |
| } catch (e) { | |
| ui.addMsg('assistant err', 'copiloto: ' + (e?.message || e)); | |
| } finally { copilotBusy = false; } | |
| } | |
| // ── Pipe cross-origin: skills de audio en vivo desde el Translator ── | |
| (function copilotPipe() { | |
| try { if (!/[?&]copilot=1/.test(location.search)) return; } catch { return; } | |
| const ALLOWED = 'https://translator.elffuss.utopiaia.com'; | |
| // qué skill de audio se ha pedido (?skill=ficha, etc.) | |
| const want = (location.search.match(/[?&]skill=([\w-]+)/) || [])[1]; | |
| if (want && AUDIO_SKILLS[want]) copilotSkill = AUDIO_SKILLS[want]; | |
| let started = false; | |
| window.addEventListener('message', e => { if (e.data && e.data.type === 'surface-ready') copilotPostState(''); }); | |
| window.addEventListener('message', async e => { | |
| if (e.origin !== ALLOWED) return; | |
| window.__copilotOpener = ALLOWED; | |
| const m = e.data || {}; | |
| if (m.kind === 'copilot-init' && !started) { | |
| started = true; | |
| if (m.skill && AUDIO_SKILLS[m.skill]) copilotSkill = AUDIO_SKILLS[m.skill]; | |
| const sk = copilotSkill; | |
| const sellerLang = m.sellerLangName || 'el idioma del usuario'; | |
| try { | |
| if (!skills.installed().some(s => s.name === sk.name)) { | |
| await skills.createSkill({ name: sk.name, description: sk.desc, instructions: audioSkillMd(sk) }); | |
| } | |
| } catch { /* */ } | |
| copilotInitState(); | |
| renderCopilotBoard(); | |
| // Proyectar la superficie a la app que nos abrió → pantalla partida real. | |
| try { window.opener?.postMessage({ kind: 'surface', v: 1, id: sk.id, title: sk.icon + ' ' + sk.name, html: surfaceHtml(sk) }, ALLOWED); } catch { /* */ } | |
| clearInterval(window.__copilotBoardWatch); | |
| window.__copilotBoardWatch = setInterval(() => { | |
| const f = document.getElementById('appframe'); | |
| if (document.hidden) return; | |
| if (!f || f.hidden || !(f.srcdoc || '').includes(sk.name)) { renderCopilotBoard(); copilotPostState(''); } | |
| }, 5000); | |
| setTimeout(() => { if (activeModel === 'rules') copilotPostState('Cargando el cerebro… empiezo en cuanto esté listo.'); }, 800); | |
| copilotHist = []; | |
| } else if (m.kind === 'transcript' && m.text) { | |
| const line = '[' + (m.speaker || 'interlocutor') + '] ' + String(m.text).slice(0, 400); | |
| ui.addMsg('user', line); | |
| copilotTurn(line); | |
| } | |
| }); | |
| try { if (window.opener) window.opener.postMessage({ kind: 'copilot-ready' }, ALLOWED); } catch { /* */ } | |
| })(); | |