Stand-in import of elffuss/code
One parentless commit with only the files that the open seed tasks touch and the modules they import. The full squashed import (SPEC 8.6) is a later step: this tree is not the cutoff tree. T2T-StandIn-Source-Commit: 43f95b7333efe73aeaebf806023f17bffea27aba
This commit is contained in:
1347
web/js/acer-core.js
Normal file
1347
web/js/acer-core.js
Normal file
File diff suppressed because it is too large
Load Diff
351
web/js/agent.js
Normal file
351
web/js/agent.js
Normal file
@@ -0,0 +1,351 @@
|
||||
// Bucle agéntico de Elffuss Code (mismo protocolo que Elffuss SO).
|
||||
import { runTool, toolHelp, snapshot } from './tools/index.js';
|
||||
import { skillsPromptBlock } from './skills.js';
|
||||
import * as telemetry from './telemetry.js';
|
||||
|
||||
const MAX_STEPS = 12; // margen para tareas largas (leer→editar→verificar→arreglar…)
|
||||
|
||||
const LANGS = {
|
||||
es: 'español', en: 'English', uk: 'українська', ru: 'русский', fr: 'français',
|
||||
de: 'Deutsch', it: 'italiano', pt: 'português', pl: 'polski', ca: 'català',
|
||||
};
|
||||
|
||||
export function userLang() {
|
||||
const codeL = (navigator.language || 'es').toLowerCase();
|
||||
return { code: codeL, name: LANGS[codeL.split('-')[0]] || codeL };
|
||||
}
|
||||
|
||||
export function systemPrompt(context = '', { compacto = false } = {}) {
|
||||
const lang = userLang();
|
||||
|
||||
// Versión corta para modelos LENTOS, que no es lo mismo que modelos pequeños:
|
||||
// al 27B propio le CABRÍA el prompt completo si tuviera más contexto, pero
|
||||
// cada token de prompt se paga en segundos antes de la primera letra.
|
||||
//
|
||||
// Y no era solo lentitud. El prompt completo son 2.217 tokens —contados con el
|
||||
// tokenizador del propio modelo, no estimados por caracteres— contra un techo
|
||||
// duro de 1.952: el contexto de 2048 menos la reserva que el motor guarda para
|
||||
// la respuesta. O sea que el motor lanzaba «las instrucciones ocupan N
|
||||
// tokens…» ANTES de procesar nada. Con el 27B, Code no estaba lento: NO
|
||||
// ARRANCABA, y desde fuera las dos cosas se parecen demasiado.
|
||||
//
|
||||
// Esta versión son 828 tokens, y deja 1.124 para el árbol del IDE y la
|
||||
// conversación, que antes se quedaban en negativo.
|
||||
//
|
||||
// El catálogo de herramientas se conserva ENTERO a propósito. Un Code sin
|
||||
// herramientas carga, responde y no sirve para nada, que es peor que fallar
|
||||
// fuerte: lo que se recorta es prosa y ejemplos, no capacidades.
|
||||
if (compacto) {
|
||||
return `Eres Elffuss Code: asistente de programación en un IDE web. Trabajas SOLO dentro del proyecto abierto. Hablas SIEMPRE en ${lang.name} (${lang.code}); el código, en el estilo del proyecto.
|
||||
|
||||
HERRAMIENTAS (las ÚNICAS que existen — no inventes otras):
|
||||
${toolHelp()}
|
||||
|
||||
REGLAS:
|
||||
- LEE antes de opinar (code.read / code.search). Nada de consejos genéricos, y nada de describir el proyecto de memoria aunque reconozcas su nombre: esta copia local puede diferir.
|
||||
- Si preguntan qué hace el proyecto y no has leído nada, tu primera respuesta es una tool-call (code.tree o code.read del README), no una descripción.
|
||||
- MODIFICAR un fichero que ya existe: code.edit, solo las líneas que cambian. CREAR uno nuevo: code.write con el contenido completo. Nunca touch/echo para crear y rellenar luego.
|
||||
- terminal.run recibe la LÍNEA DE COMANDO exacta, nunca lenguaje natural.
|
||||
- NO existen MCP servers, skills, hooks, subagentes ni plugins en este IDE. Si te descubres recomendándolos, para.
|
||||
- Cita rutas y líneas reales del CONTEXTO o de resultados de herramientas.
|
||||
|
||||
Para usar una herramienta responde SOLO con:
|
||||
\`\`\`tool
|
||||
{"tool": "code.read", "args": {"path": "README.md"}}
|
||||
\`\`\`
|
||||
Tras un [resultado], responde breve y concreto citando lo que has leído.${skillsPromptBlock()}${context ? `
|
||||
|
||||
CONTEXTO AHORA (estado real del IDE, úsalo):
|
||||
${context}` : ''}`;
|
||||
}
|
||||
|
||||
return `Eres Elffuss Code: un asistente de programación, cálido pero quirúrgico con el código. Vives en un IDE web y trabajas SOLO dentro del proyecto que el usuario ha abierto. Hablas SIEMPRE en el idioma del navegador del usuario: ${lang.name} (${lang.code}); el código y sus comentarios, en el estilo del proyecto.
|
||||
|
||||
HERRAMIENTAS (las ÚNICAS que existen — no inventes otras):
|
||||
${toolHelp()}
|
||||
|
||||
REGLAS DURAS:
|
||||
- Antes de decir NADA sobre el código, LÉELO con code.read o code.search. PROHIBIDO dar consejos genéricos o suposiciones ("probablemente usas React/Docker…"). Si no lo has leído, léelo primero.
|
||||
- ANTIRRECITACIÓN: aunque RECONOZCAS el proyecto por su nombre (vllm, react, django, next, pytorch…), tienes PROHIBIDO describirlo de memoria («csrc/ es C++/CUDA», «benchmarks/ mide rendimiento»…). Esta copia local puede diferir de lo que crees saber. Descríbelo SOLO por lo que leas AQUÍ con las herramientas.
|
||||
- Si te preguntan «¿qué hace este código/proyecto?» y no has leído nada aún, tu PRIMERA respuesta debe ser una tool-call (code.tree o code.read del README/entrypoint), NUNCA una descripción. Encadena 2-3 lecturas (README + fichero de entrada + un módulo clave) antes de resumir. Nada de listar carpetas de forma genérica.
|
||||
- USA SOLO code.tree / code.read / code.edit / code.write / code.search / terminal.run / web.search / web.fetch. NO existen code.create-plugin, code.create-mcp-server, hooks, CLAUDE.conf ni nada parecido: si lo mencionas, estás alucinando.
|
||||
- ⛔ NUNCA recomiendes «automatizaciones de Claude Code»: MCP servers (context7, «claude mcp add …»), skills, hooks (.claude/settings.json, pre-commit), subagentes (.claude/agents/…), plugins, CLAUDE.conf. NADA de eso existe en este IDE — es conocimiento de tu entrenamiento, no de este proyecto. Si te descubres escribiendo «Recomendaciones de automatización», «MCP Servers», «Skills», «Hooks», «Subagentes» → PARA: es pura alucinación. Ayuda con el CÓDIGO REAL que lees, nada más.
|
||||
- web.search busca en internet DE SERIE (docs, mensajes de error, APIs) y web.fetch lee una URL. PUEDES buscar en internet cuando ayude; NUNCA digas que no puedes.
|
||||
- terminal.run ejecuta comandos de shell REALES (ls, cat, grep, find, mkdir, «echo texto > fichero», git status). Le pasas la LÍNEA DE COMANDO exacta, NUNCA lenguaje natural (mal: terminal.run "crea un proyecto"; bien: terminal.run "mkdir web"). rm SÍ soporta comodines («rm carpeta/mejoras-*.md» borra todos los que coincidan) — si «no existe», es que de verdad no hay ninguno, no lo des por hecho sin comprobarlo con ls/find antes. node/npm/python SÍ son reales si el usuario tiene el Bridge local conectado (⚙ Ajustes → 🔌 Bridge local) — pruébalos con terminal.run y lee el resultado; si el bridge no está conectado, la propia herramienta te lo dice con el mensaje de error, y ENTONCES le pides al usuario que lo conecte (no lo asumas de antemano sin probar).
|
||||
- CREAR ficheros/proyectos: SIEMPRE con code.write (crea también las carpetas necesarias), UNA tool-call por fichero, con el CONTENIDO COMPLETO en cada una. NUNCA uses touch/echo para crear un fichero que luego rellenas (quedan vacíos). PUEDES crear cualquier proyecto aquí mismo; PROHIBIDO responder «no puedo crear un proyecto» o pedir permiso o el framework: si el usuario no lo dice, elige HTML/CSS/JS simple y escríbelo ya. Puedes emitir VARIAS code.write en un mismo mensaje y se ejecutan todas.
|
||||
- MODIFICAR un fichero que YA existe: usa code.edit (search→replace de SOLO las líneas que cambian), NO reescribas el fichero entero con code.write. Reescribir un fichero grande entero es lento, gasta muchísimos tokens y es fácil que se trunque o se altere lo que no debía; code.edit cambia solo el trozo y conserva el resto intacto. code.write es para CREAR ficheros nuevos (o una reescritura total que el usuario pida a propósito), no para un cambio puntual.
|
||||
- Habla SOLO de archivos y contenido que aparezcan en el CONTEXTO o en resultados de herramientas. Cita rutas y líneas reales. Si no lo sabes, léelo, no lo inventes.
|
||||
|
||||
Cómo actuar:
|
||||
1) Para usar una herramienta responde SOLO con:
|
||||
\`\`\`tool
|
||||
{"tool": "code.read", "args": {"path": "README.md"}}
|
||||
\`\`\`
|
||||
2) NUNCA inventes rutas: usa SOLO las del árbol del CONTEXTO o de code.tree/code.search. Relativas a la raíz, sin ./ inicial.
|
||||
3) Antes de tocar código: léelo (code.read). Para MODIFICARLO usa code.edit (cambia solo las líneas necesarias, sin reescribir el resto). Para CREAR un fichero nuevo usa code.write con el contenido completo. El editor se actualiza al instante.
|
||||
4) Tras un [resultado], responde breve y CONCRETO citando lo que has leído.
|
||||
|
||||
Ejemplos:
|
||||
Usuario: ¿qué hace este proyecto/código?
|
||||
Tú:
|
||||
\`\`\`tool
|
||||
{"tool": "code.read", "args": {"path": "README.md"}}
|
||||
\`\`\`
|
||||
Usuario: ¿qué hay en este proyecto?
|
||||
Tú:
|
||||
\`\`\`tool
|
||||
{"tool": "code.tree", "args": {}}
|
||||
\`\`\`
|
||||
Usuario: busca dónde se define handleClick
|
||||
Tú:
|
||||
\`\`\`tool
|
||||
{"tool": "code.search", "args": {"query": "handleClick"}}
|
||||
\`\`\`
|
||||
Usuario: crea una carpeta tests y un fichero vacío dentro
|
||||
Tú:
|
||||
\`\`\`tool
|
||||
{"tool": "terminal.run", "args": {"command": "mkdir tests && touch tests/test_main.py"}}
|
||||
\`\`\`
|
||||
Usuario: crea un mini proyecto web que explique esto
|
||||
Tú: (SIN pedir permiso ni framework — escribe los ficheros con contenido COMPLETO, varias code.write a la vez)
|
||||
\`\`\`tool
|
||||
{"tool": "code.write", "args": {"path": "web/index.html", "content": "<!DOCTYPE html>\\n<html lang=\\"es\\">\\n<head><meta charset=\\"utf-8\\"><title>Notas</title></head>\\n<body><h1>Notas</h1></body>\\n</html>"}}
|
||||
\`\`\`
|
||||
\`\`\`tool
|
||||
{"tool": "code.write", "args": {"path": "web/style.css", "content": "body{font-family:system-ui;margin:0}…"}}
|
||||
\`\`\`
|
||||
Usuario: cambia el puerto 3000 por 8080 en server.js
|
||||
Tú: (NO reescribas el fichero entero — edita SOLO esa línea)
|
||||
\`\`\`tool
|
||||
{"tool": "code.edit", "args": {"path": "server.js", "search": "const PORT = 3000", "replace": "const PORT = 8080"}}
|
||||
\`\`\`
|
||||
Usuario: ¿qué mejorarías del código?
|
||||
Tú:
|
||||
\`\`\`tool
|
||||
{"tool": "code.read", "args": {"path": "src/main.py"}}
|
||||
\`\`\`
|
||||
(y tras leerlo de verdad, propones mejoras CONCRETAS citando líneas — nunca genéricas)${skillsPromptBlock()}${context ? `
|
||||
|
||||
CONTEXTO AHORA (estado real del IDE, úsalo):
|
||||
${context}` : ''}`;
|
||||
}
|
||||
|
||||
// Repara el JSON que emiten los modelos pequeños: coma final + saltos de línea/
|
||||
// tab/CR LITERALES dentro de cadenas (un HTML escrito «tal cual» en content los
|
||||
// lleva y rompen JSON.parse). No resuelve comillas internas sin escapar — de eso
|
||||
// se encarga looseToolCall.
|
||||
function repairJson(raw) {
|
||||
const s = raw.replace(/,(\s*[}\]])/g, '$1'); // coma final
|
||||
let out = '', inStr = false, esc = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (esc) { out += ch; esc = false; continue; }
|
||||
if (ch === '\\') { out += ch; esc = true; continue; }
|
||||
if (ch === '"') { inStr = !inStr; out += ch; continue; }
|
||||
if (inStr && ch === '\n') { out += '\\n'; continue; }
|
||||
if (inStr && ch === '\r') { out += '\\r'; continue; }
|
||||
if (inStr && ch === '\t') { out += '\\t'; continue; }
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Extractor TOLERANTE de una tool-call, dirigido por claves. La forma es fija
|
||||
// ({"tool":"…","args":{…}}), así que localizamos cada clave conocida y leemos su
|
||||
// valor hasta la comilla de cierre REAL (la « seguida de , " o }} »), tolerando
|
||||
// comillas y saltos de línea sin escapar dentro del valor (lo típico en un HTML).
|
||||
function looseToolCall(raw) {
|
||||
const tm = raw.match(/"tool"\s*:\s*"([\w.]+)"/);
|
||||
if (!tm) return null;
|
||||
const am = raw.match(/"args"\s*:\s*\{/);
|
||||
const body = am ? raw.slice(am.index + am[0].length) : raw;
|
||||
const args = {};
|
||||
for (const key of ['path', 'content', 'search', 'replace', 'query', 'ext', 'command']) {
|
||||
const km = body.match(new RegExp('"' + key + '"\\s*:\\s*"'));
|
||||
if (!km) continue;
|
||||
let val = '', closed = false;
|
||||
for (let i = km.index + km[0].length; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch === '\\') { val += ch + (body[i + 1] || ''); i++; continue; }
|
||||
if (ch === '"' && /^\s*(,\s*"|\}\s*[}\]]|\}\s*$|$)/.test(body.slice(i + 1))) { closed = true; break; } // cierre real
|
||||
val += ch;
|
||||
}
|
||||
// Si la cadena NUNCA cierra, el mensaje se truncó a media escritura: NO
|
||||
// recuperar (completarlo sería inventar un content que no existe).
|
||||
if (!closed) return null;
|
||||
try { val = JSON.parse('"' + val.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t') + '"'); } catch { /* crudo */ }
|
||||
args[key] = val;
|
||||
}
|
||||
for (const key of ['offset', 'limit', 'around', 'depth']) {
|
||||
const nm = body.match(new RegExp('"' + key + '"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)'));
|
||||
if (nm) args[key] = Number(nm[1]);
|
||||
}
|
||||
return { tool: tm[1], args };
|
||||
}
|
||||
|
||||
function tryJson(raw) {
|
||||
for (const cand of [raw, repairJson(raw)]) {
|
||||
try {
|
||||
const obj = JSON.parse(cand);
|
||||
if (obj && typeof obj.tool === 'string') return { tool: obj.tool, args: obj.args || {} };
|
||||
} catch { /* siguiente capa */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Formato NATIVO de LFM2.5: <|tool_call_start|>[ code.read(path="x") ]<|tool_call_end|>
|
||||
export function parseNativeCall(text) {
|
||||
const m = text.match(/<\|tool_call_start\|>\s*\[?\s*([\w.]+)\s*\(([\s\S]*?)\)\s*\]?\s*<\|tool_call_end\|>/)
|
||||
|| text.match(/^\s*\[\s*([\w.]+)\s*\(([\s\S]*?)\)\s*\]\s*$/m);
|
||||
if (!m) return null;
|
||||
const args = {};
|
||||
const re = /([\w]+)\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^,)]+)/g;
|
||||
let a;
|
||||
while ((a = re.exec(m[2]))) {
|
||||
let v = a[2].trim();
|
||||
if (/^["']/.test(v)) v = v.slice(1, -1).replace(/\\(.)/g, '$1');
|
||||
else if (/^-?\d+(\.\d+)?$/.test(v)) v = Number(v);
|
||||
else if (v === 'true' || v === 'false') v = v === 'true';
|
||||
args[a[1]] = v;
|
||||
}
|
||||
return { tool: m[1], args };
|
||||
}
|
||||
|
||||
// Extrae UNA tool-call (compat). Prefiere la primera de parseToolCalls.
|
||||
export function parseToolCall(text) {
|
||||
return parseToolCalls(text)[0] || null;
|
||||
}
|
||||
|
||||
// Extrae TODAS las tool-calls de un mensaje, en orden. El modelo pequeño a
|
||||
// menudo emite varias (p. ej. tres code.write para crear tres ficheros) o las
|
||||
// mete en prosa sin un fence ```tool perfecto → antes solo se ejecutaba la
|
||||
// primera (o ninguna) y los ficheros quedaban VACÍOS. Aquí buscamos cada objeto
|
||||
// {"tool":...} por llaves balanceadas, esté donde esté, además del formato nativo.
|
||||
export function parseToolCalls(text) {
|
||||
const calls = [];
|
||||
const seen = new Set();
|
||||
const push = c => {
|
||||
if (!c || typeof c.tool !== 'string') return;
|
||||
const k = c.tool + '|' + JSON.stringify(c.args || {});
|
||||
if (!seen.has(k)) { seen.add(k); calls.push(c); }
|
||||
};
|
||||
// 1) formato nativo LFM (puede haber varios)
|
||||
const nat = /<\|tool_call_start\|>[\s\S]*?<\|tool_call_end\|>/g;
|
||||
let mm;
|
||||
while ((mm = nat.exec(text))) push(parseNativeCall(mm[0]));
|
||||
// 2) cada objeto JSON que empiece por {"tool": … (con o sin fence)
|
||||
const re = /\{\s*"tool"\s*:/g;
|
||||
let m;
|
||||
while ((m = re.exec(text))) {
|
||||
const obj = extractBalanced(text, m.index);
|
||||
let call = obj ? tryJson(obj) : null;
|
||||
// última capa: si ni el JSON estricto ni el reparado valen (comillas internas
|
||||
// sin escapar en el content, etc.), extractor tolerante desde el «{"tool"».
|
||||
if (!call) call = looseToolCall(text.slice(m.index));
|
||||
if (call) push(call);
|
||||
re.lastIndex = m.index + (obj ? obj.length : 1);
|
||||
}
|
||||
// 3) último recurso: nativo suelto de una línea
|
||||
if (!calls.length) push(parseNativeCall(text));
|
||||
return calls;
|
||||
}
|
||||
|
||||
// Desde el '{' en `start`, devuelve la subcadena hasta la '}' que balancea,
|
||||
// respetando cadenas y escapes (para no cortar un content con llaves dentro).
|
||||
function extractBalanced(text, start) {
|
||||
let depth = 0, inStr = false, esc = false;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (esc) { esc = false; continue; }
|
||||
if (ch === '\\') { esc = true; continue; }
|
||||
if (ch === '"') inStr = !inStr;
|
||||
else if (!inStr) {
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}' && --depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
// Recuperación: los modelos pequeños a veces se dejan SIN escribir una
|
||||
// llave de cierre (cierran "args":{…} pero no el objeto exterior) aunque
|
||||
// el resto del JSON esté bien formado — antes esto tiraba el tool-call
|
||||
// entero (ninguna escritura llegaba a ejecutarse, sin aviso). Si al llegar
|
||||
// al final falta cerrar N llaves (fuera de cualquier cadena, así que no es
|
||||
// un truncado a media cadena) probamos a añadirlas — solo se acepta si el
|
||||
// JSON resultante es válido de verdad.
|
||||
if (!inStr && depth > 0 && depth <= 4) {
|
||||
const candidate = text.slice(start).replace(/```[\s\S]*$/, '').trimEnd() + '}'.repeat(depth);
|
||||
try { JSON.parse(candidate); return candidate; } catch { /* no se pudo recuperar */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
constructor(provider) {
|
||||
this.provider = provider;
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
setProvider(p) { this.provider = p; }
|
||||
|
||||
// Parar el agente en marcha: aborta la generación en curso (los proveedores
|
||||
// que reciben la señal la cortan) y hace que el bucle no dé más pasos.
|
||||
stop() { try { this._aborter?.abort(); } catch { /* */ } }
|
||||
|
||||
async handle(userText, onEvent) {
|
||||
const aborter = (this._aborter = new AbortController());
|
||||
const signal = aborter.signal;
|
||||
// La marca temporal se pone AL AÑADIR, que es el único momento en que se
|
||||
// sabe: el gestor de contexto resuelve «ayer» con la fecha del turno en que
|
||||
// se dijo, no con la de ahora (ver annotateDates en acer-core.js). Sin esto
|
||||
// la anotación se calla, que es lo correcto, pero no sirve para nada.
|
||||
this.history.push({ role: 'user', content: userText, ts: Date.now() });
|
||||
for (let step = 0; step < MAX_STEPS; step++) {
|
||||
let out;
|
||||
try {
|
||||
if (signal.aborted) { onEvent({ type: 'aborted', text: '(Parado.)' }); return; }
|
||||
const context = await snapshot().catch(() => '');
|
||||
// Se le PREGUNTA al proveedor si quiere prompt corto en vez de deducirlo
|
||||
// por tamaño de contexto: al 27B le cabría y aun así no debe recibirlo.
|
||||
// Quien no declare `prefiereCompacto` —todos los demás— recibe
|
||||
// exactamente el mismo prompt que antes; para ellos esto es inerte.
|
||||
const compacto = this.provider.prefiereCompacto?.();
|
||||
out = await this.provider.chat(this.history, systemPrompt(context, { compacto }),
|
||||
t => onEvent({ type: 'token', text: t }), signal);
|
||||
} catch (e) {
|
||||
telemetry.reportError('agent.handle: ' + e.message, { stack: e.stack || '' });
|
||||
onEvent({ type: 'error', text: 'El modelo falló: ' + e.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.aborted) { onEvent({ type: 'aborted', text: '(Parado.)' }); return; }
|
||||
const calls = parseToolCalls(out);
|
||||
if (!calls.length) {
|
||||
this.history.push({ role: 'assistant', content: out, ts: Date.now() });
|
||||
onEvent({ type: 'text', text: out });
|
||||
return;
|
||||
}
|
||||
|
||||
// Ejecutar TODAS las tool-calls del mensaje en orden (varios code.write =
|
||||
// varios ficheros con contenido, no vacíos).
|
||||
this.history.push({ role: 'assistant', content: out, ts: Date.now() });
|
||||
const results = [];
|
||||
for (const call of calls) {
|
||||
if (signal.aborted) { onEvent({ type: 'aborted', text: '(Parado.)' }); return; }
|
||||
onEvent({ type: 'tool', call });
|
||||
let result;
|
||||
try { result = await runTool(call.tool, call.args); }
|
||||
catch (e) { result = 'ERROR: ' + e.message; onEvent({ type: 'tool_error', tool: call.tool, text: e.message }); }
|
||||
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
onEvent({ type: 'tool_result', tool: call.tool, result: resultStr });
|
||||
const pista = resultStr.startsWith('ERROR')
|
||||
? '\n(Reanaliza este error y reintenta con la corrección; no te rindas.)' : '';
|
||||
results.push(`[resultado ${call.tool}]\n${resultStr}${pista}`);
|
||||
}
|
||||
this.history.push({ role: 'user', content: results.join('\n\n'), ts: Date.now() });
|
||||
}
|
||||
// Agotar los pasos es un FALLO y hay que decirlo con un evento propio: el
|
||||
// modo Objetivo marcaba la tarea como hecha porque solo miraba 'error', y
|
||||
// un plan entero podía acabar en verde sin haber tocado un solo fichero.
|
||||
onEvent({ type: 'exhausted', text: '(Me quedé sin pasos: demasiadas herramientas seguidas.)' });
|
||||
onEvent({ type: 'text', text: '(Me quedé sin pasos: demasiadas herramientas seguidas.)' });
|
||||
}
|
||||
}
|
||||
101
web/js/bridge.js
Normal file
101
web/js/bridge.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// Cliente del Elffuss Bridge: puente local (proceso que el usuario descarga y
|
||||
// ejecuta) que da ejecución REAL en su máquina (node, npm, python…) sin salir
|
||||
// de ella. Protocolo por WebSocket a 127.0.0.1, con token de un solo arranque.
|
||||
const PORT = 8765;
|
||||
const HOST = '127.0.0.1:' + PORT;
|
||||
|
||||
let ws = null, connected = false, authed = false;
|
||||
let pending = new Map(); // id → { onOut, onErr, resolve }
|
||||
let nextId = 1;
|
||||
let onStatus = () => {};
|
||||
let savedFolder = '';
|
||||
try { savedFolder = localStorage.getItem('elffusscode.bridgeFolder') || ''; } catch { /* */ }
|
||||
let savedToken = '';
|
||||
try { savedToken = localStorage.getItem('elffusscode.bridgeToken') || ''; } catch { /* */ }
|
||||
|
||||
export function isConnected() { return connected && authed; }
|
||||
export function getFolder() { return savedFolder; }
|
||||
export function setFolder(f) {
|
||||
savedFolder = (f || '').trim();
|
||||
try { localStorage.setItem('elffusscode.bridgeFolder', savedFolder); } catch { /* */ }
|
||||
}
|
||||
export function onStatusChange(fn) { onStatus = fn; }
|
||||
|
||||
// sondeo ligero: ¿hay un bridge escuchando en 127.0.0.1, aunque no nos hayamos
|
||||
// autenticado todavía? (para saber si mostrar «detectado» antes de conectar)
|
||||
export async function probe() {
|
||||
try {
|
||||
const r = await fetch('http://' + HOST + '/ping', { mode: 'cors' });
|
||||
if (!r.ok) return false;
|
||||
const j = await r.json();
|
||||
return !!j.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export function connect(token) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try { ws = new WebSocket('ws://' + HOST + '/ws'); }
|
||||
catch (e) { reject(e); return; }
|
||||
const timeout = setTimeout(() => { try { ws.close(); } catch { /* */ } reject(new Error('tiempo agotado — ¿está el bridge arrancado?')); }, 6000);
|
||||
ws.onopen = () => ws.send(JSON.stringify({ type: 'auth', token }));
|
||||
ws.onerror = () => { clearTimeout(timeout); connected = false; authed = false; onStatus('error'); reject(new Error('no pude conectar — ¿está el bridge arrancado en este puerto?')); };
|
||||
ws.onclose = () => { connected = false; authed = false; onStatus('disconnected'); };
|
||||
ws.onmessage = e => {
|
||||
let m; try { m = JSON.parse(e.data); } catch { return; }
|
||||
if (m.type === 'auth-ok') {
|
||||
clearTimeout(timeout);
|
||||
connected = true; authed = true;
|
||||
savedToken = token;
|
||||
try { localStorage.setItem('elffusscode.bridgeToken', token); } catch { /* */ }
|
||||
onStatus('connected');
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (m.type === 'auth-fail') { clearTimeout(timeout); connected = true; authed = false; onStatus('auth-fail'); reject(new Error('token incorrecto')); return; }
|
||||
const job = pending.get(m.id);
|
||||
if (!job) return;
|
||||
if (m.type === 'stdout') job.onOut(m.data);
|
||||
else if (m.type === 'stderr') job.onErr(m.data);
|
||||
else if (m.type === 'error') { job.onErr(m.data); job.resolve(-1); pending.delete(m.id); }
|
||||
else if (m.type === 'exit') { job.resolve(m.code || 0); pending.delete(m.id); }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function disconnect() { try { ws?.close(); } catch { /* */ } connected = false; authed = false; }
|
||||
|
||||
// tratar de reconectar en silencio con el token guardado (al abrir la app)
|
||||
export async function tryAutoConnect() {
|
||||
if (!savedToken) return false;
|
||||
try { await connect(savedToken); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// Detección continua en segundo plano: si el usuario arranca el bridge
|
||||
// DESPUÉS de cargar la página (o lo reinicia tras cerrarlo), esto lo detecta
|
||||
// solo y reconecta con el token guardado — sin que haga falta reabrir Ajustes
|
||||
// y pulsar «Conectar» de nuevo. Se apoya en probe() (sondeo ligero sin auth).
|
||||
let autoDetectTimer = null, connecting = false;
|
||||
export function startAutoDetect(intervalMs = 4000) {
|
||||
if (autoDetectTimer) return () => stopAutoDetect();
|
||||
autoDetectTimer = setInterval(async () => {
|
||||
if (isConnected() || connecting || !savedToken) return;
|
||||
if (!(await probe())) return;
|
||||
connecting = true;
|
||||
try { await connect(savedToken); } catch { /* el usuario aún no ha pegado token válido, o cayó de nuevo */ }
|
||||
finally { connecting = false; }
|
||||
}, intervalMs);
|
||||
return () => stopAutoDetect();
|
||||
}
|
||||
export function stopAutoDetect() { clearInterval(autoDetectTimer); autoDetectTimer = null; }
|
||||
|
||||
// ejecuta un comando REAL en la máquina del usuario, vía el bridge.
|
||||
// onOut/onErr reciben trozos de salida en vivo; devuelve el código de salida.
|
||||
export function exec(cmd, { cwd = savedFolder, onOut = () => {}, onErr = () => {} } = {}) {
|
||||
if (!isConnected()) return Promise.reject(new Error('bridge no conectado'));
|
||||
const id = String(nextId++);
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { onOut, onErr, resolve });
|
||||
try { ws.send(JSON.stringify({ type: 'exec', id, cmd, cwd })); }
|
||||
catch (e) { pending.delete(id); reject(e); }
|
||||
});
|
||||
}
|
||||
245
web/js/ceo.js
Normal file
245
web/js/ceo.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// Cerebro CEO autónomo (compartido por Elffuss Code y Elffuss Claw): cuando el
|
||||
// usuario NO está pidiendo nada (ocioso) y hay un modelo local cargado, la elfa
|
||||
// «trabaja por su cuenta» — revisa el espacio de trabajo, reparte el trabajo en
|
||||
// varios perfiles que piensan EN PARALELO (cada uno una línea de pensamiento en
|
||||
// la vista Mente) y sintetiza propuestas de mejora.
|
||||
//
|
||||
// Agnóstico de herramientas a propósito: cada app inyecta su propio adaptador
|
||||
// de workspace (`init({ workspace, ... })`) — Code usa code.js (proyecto de
|
||||
// código), Claw usa fs.js (carpetas con permiso). El core no sabe ni le importa
|
||||
// cuál es.
|
||||
//
|
||||
// Seguridad: NO modifica tus ficheros. Deja las propuestas en una carpeta
|
||||
// aditiva (nunca toca lo existente) y las hace «flotar» en la Mente. Se PARA en
|
||||
// cuanto detecta actividad del usuario y reanuda al volver a estar ocioso.
|
||||
import { Agent } from './agent.js';
|
||||
import { humanizeTool } from './humanize.js';
|
||||
|
||||
const IDLE_MS = 18000; // 18 s sin actividad → el CEO se pone a trabajar
|
||||
const TICK_MS = 3000; // frecuencia de comprobación
|
||||
// 5 min entre ciclos automáticos: con 45s, una tarde ociosa generaba MILES de
|
||||
// ficheros. «Pensar ahora» (forceCycle) sigue disponible sin esperar esto.
|
||||
const COOLDOWN_MS = 300000;
|
||||
const SOUL_CAP = 25; // ficheros sueltos antes de consolidar en archivo.md
|
||||
|
||||
// perfil por defecto si la app anfitriona no da los suyos
|
||||
const GENERIC_PROFILES = [
|
||||
{ id: 'p1', name: 'Revisión', focus: 'qué mejorar de forma concreta y accionable', color: '#7c5cff' },
|
||||
{ id: 'p2', name: 'Calidad', focus: 'errores, casos borde, cosas que podrían fallar', color: '#49e8ff' },
|
||||
];
|
||||
|
||||
const slug = s => String(s).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 24) || 'perfil';
|
||||
|
||||
// ── namespace (localStorage) y adaptador de workspace: los da la app anfitriona ──
|
||||
let NS = 'elffuss'; // prefijo de las claves de localStorage
|
||||
let ws = null; // { isReady, tree, write, read, list, remove }
|
||||
let getProvider = () => null;
|
||||
let isBusy = () => false; // ¿el usuario tiene trabajo en cola/procesándose? → prioridad
|
||||
let defaultProfiles = GENERIC_PROFILES;
|
||||
const K = suffix => NS + '.' + suffix;
|
||||
|
||||
function loadProfiles() {
|
||||
try { const s = JSON.parse(localStorage.getItem(K('ceoProfiles'))); if (Array.isArray(s) && s.length) return s; } catch { /* */ }
|
||||
return defaultProfiles.map(p => ({ ...p }));
|
||||
}
|
||||
let profiles = null;
|
||||
export function getProfiles() { if (!profiles) profiles = loadProfiles(); return profiles; }
|
||||
export function setProfiles(list) {
|
||||
const used = new Set();
|
||||
profiles = (list || []).filter(p => p && p.name).map(p => {
|
||||
let id = p.id || slug(p.name); let n = id, i = 2;
|
||||
while (used.has(n)) n = id + '-' + i++;
|
||||
used.add(n);
|
||||
return { id: n, name: String(p.name).slice(0, 40), focus: String(p.focus || '').slice(0, 200), color: /^#[0-9a-f]{6}$/i.test(p.color || '') ? p.color : '#7c5cff' };
|
||||
});
|
||||
if (!profiles.length) profiles = defaultProfiles.map(p => ({ ...p }));
|
||||
try { localStorage.setItem(K('ceoProfiles'), JSON.stringify(profiles)); } catch { /* */ }
|
||||
emit('ceo', { type: 'reprogram', text: 'Perfiles actualizados: ' + profiles.map(p => p.name).join(', '), profiles });
|
||||
return profiles;
|
||||
}
|
||||
|
||||
let enabled = false, running = false, lastActivity = Date.now(), timer = null, lastCycleEnd = 0, cycleN = 0;
|
||||
|
||||
// MISIÓN reprogramable: el usuario puede reorientar el cerebro desde la Mente
|
||||
// («céntrate en seguridad», «optimiza mis Excel», «documenta todo»…).
|
||||
let DEFAULT_MISSION = 'Revisar el espacio de trabajo y proponer mejoras concretas y accionables.';
|
||||
let mission = null;
|
||||
function ensureMission() { if (mission == null) { try { mission = localStorage.getItem(K('ceoMission')) || DEFAULT_MISSION; } catch { mission = DEFAULT_MISSION; } } }
|
||||
export function getMission() { ensureMission(); return mission; }
|
||||
export function setMission(text) {
|
||||
mission = (text || '').trim() || DEFAULT_MISSION;
|
||||
try { localStorage.setItem(K('ceoMission'), mission); } catch { /* */ }
|
||||
emit('ceo', { type: 'reprogram', text: 'Nueva misión recibida: ' + mission });
|
||||
lastCycleEnd = 0; lastActivity = Date.now() - IDLE_MS; // que arranque un ciclo pronto con la nueva misión
|
||||
return mission;
|
||||
}
|
||||
|
||||
// Carpeta-«alma» donde el cerebro crea y guarda TODO (configurable).
|
||||
let DEFAULT_SOUL = '.elffuss/soul';
|
||||
let soulDir = null;
|
||||
function ensureSoulDir() { if (soulDir == null) { try { soulDir = localStorage.getItem(K('ceoDir')) || DEFAULT_SOUL; } catch { soulDir = DEFAULT_SOUL; } } }
|
||||
export function getSoulDir() { ensureSoulDir(); return soulDir; }
|
||||
export function setSoulDir(dir) {
|
||||
soulDir = (dir || '').trim().replace(/^\/+|\/+$/g, '') || DEFAULT_SOUL;
|
||||
try { localStorage.setItem(K('ceoDir'), soulDir); } catch { /* */ }
|
||||
emit('ceo', { type: 'reprogram', text: 'Nueva carpeta-alma: ' + soulDir + '/' });
|
||||
return soulDir;
|
||||
}
|
||||
|
||||
// ── semáforo cross-pestaña: UN SOLO cerebro ejecuta, TODAS visualizan ───────
|
||||
// El líder tiene un Web Lock exclusivo (se libera solo al cerrar la pestaña);
|
||||
// difunde sus pensamientos por BroadcastChannel para que el resto los vea.
|
||||
// (Aislado por origen por el propio navegador: Code y Claw nunca se cruzan.)
|
||||
let isLeader = false, bc = null, realEmit = () => {}, crossTabWired = false;
|
||||
function initCrossTab() {
|
||||
if (crossTabWired) return;
|
||||
crossTabWired = true;
|
||||
try {
|
||||
bc = new BroadcastChannel(NS + '-ceo');
|
||||
bc.onmessage = e => { if (e.data && e.data.kind === 'thought') realEmit(e.data.channel, e.data.ev); };
|
||||
} catch { /* sin BroadcastChannel */ }
|
||||
if (navigator.locks && navigator.locks.request) {
|
||||
navigator.locks.request(NS + '-ceo-leader', { mode: 'exclusive' }, () => new Promise(() => { isLeader = true; }))
|
||||
.catch(() => { isLeader = true; });
|
||||
} else { isLeader = true; }
|
||||
}
|
||||
function emit(channel, ev) {
|
||||
realEmit(channel, ev);
|
||||
try { bc && bc.postMessage({ kind: 'thought', channel, ev }); } catch { /* ev no serializable */ }
|
||||
}
|
||||
|
||||
// init({ workspace, provider, onEvent, isBusy, namespace, defaultProfiles, defaultMission, defaultSoulDir })
|
||||
export function init(opts = {}) {
|
||||
if (opts.workspace) ws = opts.workspace;
|
||||
if (opts.provider) getProvider = opts.provider;
|
||||
if (opts.onEvent) realEmit = opts.onEvent;
|
||||
if (opts.isBusy) isBusy = opts.isBusy;
|
||||
if (opts.namespace) NS = opts.namespace;
|
||||
if (opts.defaultProfiles) defaultProfiles = opts.defaultProfiles;
|
||||
if (opts.defaultMission) DEFAULT_MISSION = opts.defaultMission;
|
||||
if (opts.defaultSoulDir) DEFAULT_SOUL = opts.defaultSoulDir;
|
||||
initCrossTab();
|
||||
}
|
||||
export function isThisTabLeader() { return isLeader; }
|
||||
export function noteActivity() { lastActivity = Date.now(); if (running) running = 'interrupt'; }
|
||||
export function isEnabled() { return enabled; }
|
||||
export function isRunning() { return !!running; }
|
||||
// Play/stop: persistido AQUÍ (fuente única) — cualquier botón que lo toque
|
||||
// queda sincronizado, y la elección sobrevive a recargar la página.
|
||||
export function wasEnabledLastSession() { try { return localStorage.getItem(K('ceoEnabled')) === '1'; } catch { return false; } }
|
||||
// ¿el usuario llegó a decidir alguna vez (play o stop)? Distingue «nunca lo
|
||||
// tocó» de «lo pausó a propósito» — solo lo primero debe auto-activarse al
|
||||
// abrir la Mente; lo segundo hay que RESPETARLO, no pisarlo.
|
||||
export function hasDecided() { try { return localStorage.getItem(K('ceoEnabled')) != null; } catch { return false; } }
|
||||
export function enable() {
|
||||
if (enabled) return;
|
||||
enabled = true; lastActivity = Date.now(); schedule();
|
||||
try { localStorage.setItem(K('ceoEnabled'), '1'); } catch { /* */ }
|
||||
emit('sys', { type: 'status', text: 'CEO en guardia — trabajaré cuando estés ocioso' });
|
||||
}
|
||||
export function disable() {
|
||||
enabled = false; running = false; if (timer) clearTimeout(timer);
|
||||
try { localStorage.setItem(K('ceoEnabled'), '0'); } catch { /* */ }
|
||||
emit('sys', { type: 'status', text: 'CEO en pausa' });
|
||||
}
|
||||
|
||||
function schedule() { if (timer) clearTimeout(timer); timer = setTimeout(tick, TICK_MS); }
|
||||
|
||||
// «Pensar ahora» — salta la espera de ociosidad. Sigue respetando el
|
||||
// semáforo: si otra pestaña es la líder, no compite por la GPU.
|
||||
export async function forceCycle() {
|
||||
if (!isLeader) { emit('ceo', { type: 'paused', text: 'Otra pestaña lleva el cerebro — ábrela ahí para forzar un ciclo.' }); return false; }
|
||||
if (running) return false;
|
||||
// isReady() puede ser síncrona (Code: handle en memoria) o async (Claw: consulta IndexedDB) — se espera siempre.
|
||||
if (!(await ws?.isReady()) || !getProvider()) { emit('ceo', { type: 'paused', text: 'Necesito un espacio de trabajo abierto y un modelo cargado.' }); return false; }
|
||||
try { await runCycle(); } finally { lastCycleEnd = Date.now(); }
|
||||
return true;
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (!enabled) return;
|
||||
const idle = Date.now() - lastActivity;
|
||||
const rested = Date.now() - lastCycleEnd > COOLDOWN_MS;
|
||||
const mightRun = isLeader && !running && !isBusy() && idle >= IDLE_MS && rested && getProvider();
|
||||
if (mightRun && await ws?.isReady()) {
|
||||
try { await runCycle(); } catch { /* siguiente ciclo */ }
|
||||
lastCycleEnd = Date.now();
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
// helper: corre el agente con el proveedor actual sobre un prompt, emitiendo
|
||||
// tokens/herramientas a un canal. Devuelve el texto final. Las tool-calls pasan
|
||||
// SIEMPRE por el mismo runTool que usa el chat normal (Agent.handle real).
|
||||
async function think(channel, task) {
|
||||
const prov = getProvider();
|
||||
if (!prov) return '';
|
||||
const a = new Agent({ chat: (h, s, cb) => prov.chat(h, s, cb) });
|
||||
let out = '';
|
||||
await a.handle(task, ev => {
|
||||
if (running === 'interrupt') throw new Error('interrumpido');
|
||||
if (ev.type === 'token') { out += ev.text; emit(channel, { type: 'token', text: ev.text }); }
|
||||
else if (ev.type === 'tool') emit(channel, { type: 'tool', text: humanizeTool(ev.call.tool, ev.call.args), tool: ev.call.tool, path: ev.call.args?.path || null });
|
||||
else if (ev.type === 'tool_result') emit(channel, { type: 'tool_result', tool: ev.tool, text: String(ev.result || '').replace(/\s+/g, ' ').trim().slice(0, 100) });
|
||||
else if (ev.type === 'text') { out = ev.text; }
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runCycle() {
|
||||
running = true;
|
||||
cycleN++;
|
||||
emit('ceo', { type: 'cycle', n: cycleN, text: 'Nuevo ciclo: reviso el espacio de trabajo y reparto el trabajo…' });
|
||||
|
||||
let tree = '';
|
||||
try { tree = await ws.tree({ depth: 2 }); } catch { /* sin workspace */ }
|
||||
emit('ceo', { type: 'survey', text: 'Panorama captado (' + tree.split('\n').length + ' entradas). Delegando…' });
|
||||
|
||||
const brief = (d) => `MISIÓN del equipo (fijada por el usuario): ${getMission()}\n` +
|
||||
`Eres el jefe de ${d.name}. Dentro de esa misión, céntrate en: ${d.focus}. ` +
|
||||
`Explora lo mínimo con tus herramientas y propón UNA mejora CONCRETA y accionable, ` +
|
||||
`entendible por un humano. Sé breve. No modifiques nada existente: solo la propuesta.`;
|
||||
const proposals = await Promise.all(getProfiles().map(async d => {
|
||||
emit(d.id, { type: 'open', name: d.name, focus: d.focus });
|
||||
try { const p = await think(d.id, brief(d)); emit(d.id, { type: 'done', text: p }); return { dept: d.name, text: p }; }
|
||||
catch { emit(d.id, { type: 'done', text: '(interrumpido)' }); return null; }
|
||||
}));
|
||||
if (running === 'interrupt') { running = false; emit('ceo', { type: 'paused', text: 'Vuelves tú — dejo lo mío y te cedo el mando.' }); return; }
|
||||
|
||||
const valid = proposals.filter(Boolean).filter(p => p.text && p.text.length > 8);
|
||||
const md = `# Propuestas de mejora — ciclo ${cycleN}\n\n**Misión:** ${getMission()}\n\n` +
|
||||
valid.map(p => `## ${p.dept}\n${p.text}\n`).join('\n') +
|
||||
`\n_— generado por el cerebro CEO de Elffuss mientras estabas ocioso._\n`;
|
||||
const d = new Date();
|
||||
const stamp = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}-${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
const topic = slug((valid[0]?.text || 'ciclo').split(/[.,;\n]/)[0]) || 'ciclo';
|
||||
const path = `${getSoulDir()}/${stamp}-${topic}.md`;
|
||||
try {
|
||||
await rotateSoul();
|
||||
await ws.write({ path, content: md });
|
||||
emit('ceo', { type: 'built', text: `Propuesta guardada en ${path}`, path, md, proposals: valid });
|
||||
} catch (e) {
|
||||
emit('ceo', { type: 'built', text: 'Propuesta lista (no pude escribir el fichero)', md, proposals: valid });
|
||||
}
|
||||
running = false;
|
||||
}
|
||||
|
||||
// Rotación: si hay demasiados ficheros sueltos, los MÁS ANTIGUOS se consolidan
|
||||
// (recopilan) en un único archivo.md y se borran — para no acabar con miles.
|
||||
async function rotateSoul() {
|
||||
try {
|
||||
const soul = getSoulDir();
|
||||
const names = (await ws.list(soul)).filter(n => n.endsWith('.md') && n !== 'archivo.md');
|
||||
if (names.length < SOUL_CAP) return;
|
||||
names.sort(); // el nombre empieza por fecha → orden cronológico
|
||||
const excess = names.slice(0, names.length - SOUL_CAP + 1);
|
||||
let archive = ''; try { archive = await ws.read({ path: `${soul}/archivo.md` }); } catch { archive = '# Archivo histórico del cerebro\n'; }
|
||||
for (const name of excess) {
|
||||
try { archive += `\n---\n## ${name}\n${await ws.read({ path: `${soul}/${name}` })}\n`; } catch { /* */ }
|
||||
}
|
||||
await ws.write({ path: `${soul}/archivo.md`, content: archive.slice(-80000) });
|
||||
for (const name of excess) { try { await ws.remove(soul, name); } catch { /* */ } }
|
||||
emit('ceo', { type: 'reprogram', text: `Consolidé ${excess.length} propuestas antiguas en archivo.md` });
|
||||
} catch { /* soulDir aún sin crear o sin permiso: nada que rotar */ }
|
||||
}
|
||||
157
web/js/city.js
Normal file
157
web/js/city.js
Normal file
@@ -0,0 +1,157 @@
|
||||
// Vista «Ciudad 3D» — el proyecto abierto renderizado con el MOTOR REAL de
|
||||
// VibeCodeViewer (Sendery/VibeCodeViewer, Apache-2.0), vendorizado en js/vcc.
|
||||
// Aquí solo hacemos de glue: escaneamos la carpeta abierta al árbol empaquetado
|
||||
// que espera buildModel, montamos el render (cámara + OrbitControls + bloom) y
|
||||
// conectamos el raycast → abrir fichero en Monaco. La geometría (ciudad anidada
|
||||
// distrito→edificio→planta→…, cristal fresnel, fachadas con el árbol impreso,
|
||||
// suelo de circuito, leyenda) es la del builder original, sin tocar.
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
||||
import * as code from './tools/code.js';
|
||||
import { buildModel } from './vcc/model.js';
|
||||
import { buildCity } from './vcc/builder.js';
|
||||
import { THEME } from './vcc/theme.js';
|
||||
|
||||
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
||||
const MAX_FILES = 8000; // tope de seguridad para no colgar el navegador en monorepos
|
||||
|
||||
let disposer = null;
|
||||
|
||||
// Escanea el handle del proyecto al formato empaquetado de VibeCodeViewer:
|
||||
// dir = [nombre, [subdirs], [ficheros]] · fichero = [nombre, bytes]
|
||||
async function scanTree(dir, budget) {
|
||||
const dirs = [], files = [];
|
||||
for await (const e of dir.values()) {
|
||||
if (IGNORE.has(e.name)) continue;
|
||||
(e.kind === 'directory' ? dirs : files).push(e);
|
||||
}
|
||||
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const outFiles = [];
|
||||
for (const f of files) {
|
||||
if (budget.n >= MAX_FILES) break;
|
||||
let size = 0; try { size = (await f.getFile()).size; } catch { /* ilegible */ }
|
||||
outFiles.push([f.name, size]); budget.n++;
|
||||
}
|
||||
const outDirs = [];
|
||||
for (const d of dirs) { if (budget.n >= MAX_FILES) break; outDirs.push(await scanTree(d, budget)); }
|
||||
return [dir.name, outDirs, outFiles];
|
||||
}
|
||||
|
||||
export async function renderCity(container, onOpenFile) {
|
||||
disposeCity();
|
||||
const root = code.handle();
|
||||
if (!root) { container.innerHTML = '<div class="view-loading">Abre un proyecto primero.</div>'; return; }
|
||||
container.innerHTML = '<div class="view-loading">Construyendo la metrópolis del proyecto…</div>';
|
||||
|
||||
const budget = { n: 0 };
|
||||
const tree = await scanTree(root, budget);
|
||||
const model = buildModel(tree, null);
|
||||
if (!model.files.length) { container.innerHTML = '<div class="view-loading">Proyecto vacío.</div>'; return; }
|
||||
|
||||
container.innerHTML =
|
||||
'<canvas id="city-canvas"></canvas>' +
|
||||
'<div class="view-head">Ciudad 3D · ' + model.files.length + (budget.n >= MAX_FILES ? '+' : '') +
|
||||
' ficheros en ' + model.districts.length + ' distritos' +
|
||||
'<span class="view-hint">arrastra para orbitar · rueda para zoom · clic en un edificio → abrir</span></div>';
|
||||
const canvas = container.querySelector('#city-canvas');
|
||||
const W = container.clientWidth || 800, H = container.clientHeight || 600;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setSize(W, H, false);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
renderer.setClearColor(THEME.background, 1);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(THEME.background);
|
||||
const extent = Math.max(model.city.box.sx, model.city.box.sz);
|
||||
scene.fog = new THREE.FogExp2(THEME.fog, 0.62 / extent);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(55, W / H, 0.1, extent * 14);
|
||||
camera.position.set(extent * 0.7, extent * 0.6, extent * 0.7);
|
||||
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.maxPolarAngle = Math.PI * 0.495; // no bajar del suelo
|
||||
controls.target.set(0, extent * 0.04, 0);
|
||||
controls.update();
|
||||
|
||||
// pipeline con bloom (la receta del builder: HDR + bloom solo en lo resaltado)
|
||||
const composer = new EffectComposer(renderer);
|
||||
composer.setSize(W, H);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
const bloom = new UnrealBloomPass(new THREE.Vector2(W, H), THEME.bloom.strength, 0.25, THEME.bloom.threshold);
|
||||
composer.addPass(bloom);
|
||||
composer.addPass(new OutputPass());
|
||||
|
||||
const city = buildCity(scene, model, THEME);
|
||||
|
||||
// ── interacción: hover resalta edificios, clic abre el fichero ────────────
|
||||
const ray = new THREE.Raycaster(), ptr = new THREE.Vector2();
|
||||
let hovered = null, lastHover = 0, downXY = null;
|
||||
const setPtr = e => { const r = canvas.getBoundingClientRect(); ptr.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); };
|
||||
const onMove = e => {
|
||||
setPtr(e);
|
||||
const now = performance.now(); if (now - lastHover < 50) return; lastHover = now;
|
||||
ray.setFromCamera(ptr, camera);
|
||||
const b = ray.intersectObjects(city.shells)[0]?.object.userData.building ?? null;
|
||||
if (b !== hovered) {
|
||||
if (hovered) city.paintNode(hovered, 0);
|
||||
hovered = b;
|
||||
if (b) city.paintNode(b, 0.16);
|
||||
canvas.style.cursor = b ? 'pointer' : 'grab';
|
||||
}
|
||||
};
|
||||
const onDown = e => { downXY = [e.clientX, e.clientY]; };
|
||||
const onUp = e => {
|
||||
if (!downXY) return;
|
||||
const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]); downXY = null;
|
||||
if (moved > 5) return; // fue un arrastre de cámara
|
||||
setPtr(e); ray.setFromCamera(ptr, camera);
|
||||
const fileHit = ray.intersectObject(city.filesMesh)[0];
|
||||
const shellHit = ray.intersectObjects(city.shells)[0];
|
||||
if (fileHit && (!shellHit || fileHit.distance < shellHit.distance + 2)) {
|
||||
const f = model.files[fileHit.instanceId];
|
||||
if (f) onOpenFile(f.rel);
|
||||
} else if (shellHit) {
|
||||
// clic en un edificio (carpeta): centra la cámara en él
|
||||
const b = shellHit.object.userData.building;
|
||||
controls.target.set(b.box.cx, b.box.cy, b.box.cz);
|
||||
}
|
||||
};
|
||||
canvas.addEventListener('pointermove', onMove);
|
||||
canvas.addEventListener('pointerdown', onDown);
|
||||
canvas.addEventListener('pointerup', onUp);
|
||||
|
||||
const onResize = () => {
|
||||
const w = container.clientWidth || W, h = container.clientHeight || H;
|
||||
renderer.setSize(w, h, false); composer.setSize(w, h); bloom.resolution.set(w, h);
|
||||
camera.aspect = w / h; camera.updateProjectionMatrix();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
let raf = null;
|
||||
const loop = () => { raf = requestAnimationFrame(loop); controls.update(); composer.render(); };
|
||||
loop();
|
||||
|
||||
disposer = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
canvas.removeEventListener('pointermove', onMove);
|
||||
canvas.removeEventListener('pointerdown', onDown);
|
||||
canvas.removeEventListener('pointerup', onUp);
|
||||
controls.dispose();
|
||||
city.dispose();
|
||||
composer.dispose?.();
|
||||
renderer.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
export function disposeCity() { if (disposer) { disposer(); disposer = null; } }
|
||||
228
web/js/context.js
Normal file
228
web/js/context.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// Gestor de contexto ACE-lite: eviction de historial por relevancia, portado
|
||||
// del attention-context-eviction (ACE_R) de agentic-install a nivel de
|
||||
// mensaje (IDF + BM25-lite; en navegador no hay acceso a las atenciones).
|
||||
// turboquant es cuantización de PESOS — para contexto, esto es lo que aplica.
|
||||
//
|
||||
// Reglas: el presupuesto es en tokens (~4 chars/token). Se conservan SIEMPRE
|
||||
// los últimos RECENT mensajes; los antiguos compiten por el hueco restante
|
||||
// puntuados por BM25-lite contra la consulta actual. Los evictados se
|
||||
// sustituyen por una marca de omisión para que el modelo sepa que falta algo.
|
||||
|
||||
import { packHistoryACER, packHistoryACERHybrid } from './acer-core.js';
|
||||
|
||||
// ── ACE_R unificado ─────────────────────────────────────────────────────────
|
||||
// El empaquetado por MENSAJES de aquí abajo se medió contra sesiones reales de
|
||||
// Elffuss Code (leer/buscar/editar un proyecto durante ~50 turnos) y falla por
|
||||
// dos motivos: (1) los últimos RECENT mensajes por sí solos ya se salen del
|
||||
// presupuesto en el 55% de los turnos (un [resultado code.read] son ~1.5k tokens,
|
||||
// no 68 como en el test sintético), así que NINGÚN mensaje antiguo sobrevive y
|
||||
// se devuelven ~5.1k tokens cuando se piden 3k; (2) al conservar/tirar mensajes
|
||||
// ENTEROS, el dato concreto que el agente vuelve a pedir se pierde aunque BM25
|
||||
// puntúe bien ese mensaje. Medido sobre 32 sesiones reales (4 proyectos × 8
|
||||
// semillas, 53 turnos): recuerdo de un dato antiguo que el agente vuelve a pedir
|
||||
// 9.2% ± 11.3 (esto) vs 67.7% ± 7.9 (acer-core) con el MISMO gasto de tokens;
|
||||
// truncar por la cola da 7.9% ± 11.9. acer-core.js selecciona por LÍNEAS con un
|
||||
// presupuesto global. Banco: agentic-install/lab/gemma-e2b-cli/agent_test/acer_real.
|
||||
// AVISO honesto: la ventaja viene del solape léxico con la PREGUNTA; si la
|
||||
// pregunta no nombra lo que busca (solo el fichero), baja a 12.7% vs 6.8%.
|
||||
//
|
||||
// Para volver al comportamiento anterior (o comparar), en la consola:
|
||||
// localStorage.setItem('elffuss.acer', 'v1') → empaquetado por mensajes
|
||||
// localStorage.removeItem('elffuss.acer') → ACE_R unificado (por defecto)
|
||||
function acerUnificado() {
|
||||
try { return localStorage.getItem('elffuss.acer') !== 'v1'; } catch { return true; }
|
||||
}
|
||||
|
||||
// ── lado SEMÁNTICO (embeddings) ─────────────────────────────────────────────
|
||||
// acer-core.js trae las dos vías: la léxica (BM25, síncrona) y la híbrida (BM25
|
||||
// + embeddings fusionados por rangos, asíncrona). Medido en LoCoMo con el mismo
|
||||
// presupuesto: BM25 23,59 · embeddings 23,95 · fusión de rangos 28,09 · contexto
|
||||
// completo sin comprimir 22,56. La fusión vale +4,50 F1 sobre lo que corría.
|
||||
//
|
||||
// No es que lo semántico sea mejor —empatan en global— sino que cubre el punto
|
||||
// ciego del léxico: cuando la pregunta NO comparte vocabulario con la respuesta,
|
||||
// BM25 cae a 18,60 y los embeddings aguantan 24,28; cuando sí lo comparte manda
|
||||
// BM25 (29,33 vs 23,58). La fusión se queda con los dos (24,05 / 32,73).
|
||||
//
|
||||
// POR DEFECTO APAGADO, y no por prudencia: por lo que salió al MEDIRLO.
|
||||
//
|
||||
// · La primera carga cuesta una descarga de 235 MB (fp16, el camino rápido) o
|
||||
// 118 MB (q8, el de respaldo). Para quien usa un proveedor externo
|
||||
// (providers/api.js) eso es una descarga que hoy NO EXISTE: pasa de 0 a
|
||||
// 235 MB sólo por empaquetar mejor el contexto. Y para quien usa el modelo
|
||||
// local son ~+28 % sobre lo que ya descarga.
|
||||
// · Sólo compensa con adaptador WebGPU. Sin él se cae a wasm, que mide ~9×
|
||||
// más lento y se nota en CADA turno, no sólo en el primero.
|
||||
// · A cambio, con WebGPU el turno en régimen es una fracción de segundo y la
|
||||
// segunda sesión no descarga nada (transformers.js cachea el modelo).
|
||||
//
|
||||
// O sea: la ganancia de recuperación es real y grande (+4,50 F1), pero el precio
|
||||
// no es despreciable y depende del equipo de quien lo usa. Así que se cablea
|
||||
// entero, se deja probado, y se enciende a petición. Con la bandera apagada NI
|
||||
// SIQUIERA se importa embed.js: el coste de tenerlo cableado es exactamente cero.
|
||||
//
|
||||
// localStorage.setItem('elffuss.semantic', 'on') → BM25 + embeddings
|
||||
// localStorage.setItem('elffuss.semantic', 'off') → sólo BM25
|
||||
// localStorage.removeItem('elffuss.semantic') → sólo BM25 (por defecto)
|
||||
function semanticoOn() {
|
||||
try { return localStorage.getItem('elffuss.semantic') === 'on'; } catch { return false; }
|
||||
}
|
||||
|
||||
// ── TARJETA DE RECUENTO (agregación) ────────────────────────────────────────
|
||||
// «¿cuántos ficheros has tocado?» no está en ninguna línea del historial: está
|
||||
// repartida en cuarenta. Ningún top-k la encuentra POR CONSTRUCCIÓN — no es que
|
||||
// puntúe mal, es que no existe la línea que buscar. acer-core lo cuenta al
|
||||
// indexar y lo emite como un bloque protegido (ver buildLedger / renderCard).
|
||||
//
|
||||
// APAGADO por defecto, y por lo que salió al MEDIRLO: la tarjeta ocupa ~150
|
||||
// tokens del presupuesto y en el banco de hechos cuesta −1,1 puntos a
|
||||
// presupuesto 3.000 (el de esta app) y ±0,0 a 16.000. A cambio, con ella el
|
||||
// recuento pasa de estar el 0 % de las veces a estar el 100 %, y la enumeración
|
||||
// de lo editado del 58,9 % al 90,6 %. O sea: si la sesión es de las que acaban
|
||||
// con «hazme la lista de lo que tocaste», compensa; si no, se paga por nada.
|
||||
//
|
||||
// localStorage.setItem('elffuss.resumen', 'on') → con tarjeta
|
||||
// localStorage.removeItem('elffuss.resumen') → sin tarjeta (por defecto)
|
||||
function resumenOn() {
|
||||
try { return localStorage.getItem('elffuss.resumen') === 'on'; } catch { return false; }
|
||||
}
|
||||
|
||||
// SEM_BUDGET alto A PROPÓSITO, y esto es lo contrario de lo que parece.
|
||||
//
|
||||
// acer-core deriva el tamaño de bloque como SB = ceil(nLíneas / SEM_BUDGET) y
|
||||
// rearma los bloques desde el principio del historial EN CADA TURNO. Así que
|
||||
// SEM_BUDGET no acota el coste: acota la RESOLUCIÓN, y de paso decide si la
|
||||
// caché por contenido sirve para algo. Con un SEM_BUDGET bajo, cada vez que el
|
||||
// historial crece lo justo para que SB suba de entero, TODOS los bloques cambian
|
||||
// de texto y la caché falla entera; y como el bloque además engorda con la
|
||||
// sesión, el turno se encarece con el tiempo. Con SEM_BUDGET por encima del
|
||||
// número de líneas, SB vale 1: un bloque = una línea, el texto de una línea ya
|
||||
// codificada NO cambia nunca, y un turno sólo paga las líneas NUEVAS.
|
||||
//
|
||||
// Medido en el navegador sobre una sesión de agente de 8 turnos (cada turno =
|
||||
// una tool-call + 90 líneas de resultado, 752 líneas al final), con la caché
|
||||
// viva entre turnos y el mismo modelo (coste relativo por turno, 1,0 = el mejor
|
||||
// turno observado del barrido):
|
||||
//
|
||||
// SEM_BUDGET codificaciones por turno coste por turno
|
||||
// 64 ~64, casi todo recodificado 5,2× → 12,2× ← y SUBIENDO
|
||||
// 400 49–236, a saltos 0,4× – 8,0× ← picos
|
||||
// 4000 92, sólo lo nuevo 2,9× → 0,8× ← plano
|
||||
//
|
||||
// Con 4000 cada línea se codifica UNA vez en toda la sesión (740 codificaciones
|
||||
// para 752 líneas: las repetidas salen gratis por la caché) y el turno se ABARATA
|
||||
// según avanza la sesión en vez de encarecerse. Es exactamente el «indexar al
|
||||
// escribir» que describe la cabecera de acer-core. El valor está alineado con el
|
||||
// tope de la caché de embed.js para que no se desaloje justo lo que el turno
|
||||
// siguiente va a volver a pedir.
|
||||
const SEM_BUDGET = 4000;
|
||||
|
||||
const RECENT = 6;
|
||||
const MAX_MSG_CHARS = 12000; // ningún mensaje (p.ej. un README enorme) revienta el contexto
|
||||
|
||||
// Trunca por el MEDIO conservando cabeza y cola (útil para código/documentos).
|
||||
function clampMsg(m) {
|
||||
const c = m.content || '';
|
||||
if (c.length <= MAX_MSG_CHARS) return m;
|
||||
const head = Math.floor(MAX_MSG_CHARS * 0.7);
|
||||
const tail = MAX_MSG_CHARS - head - 40;
|
||||
return { ...m, content: c.slice(0, head) + `\n… [recortado ${c.length - MAX_MSG_CHARS} caracteres] …\n` + c.slice(-tail) };
|
||||
}
|
||||
const STOP = new Set(('de la que el en y a los del se las por un para con no una su al lo como más pero sus le ' +
|
||||
'ya o este sí porque esta entre cuando muy sin sobre también me hasta hay donde quien desde todo nos durante ' +
|
||||
'todos uno les ni contra otros ese eso ante ellos e esto mí antes algunos qué unos yo otro otras otra él tanto ' +
|
||||
'esa estos mucho quienes nada muchos cual poco ella estar estas algunas algo nosotros tu te ti mi es son era eres').split(' '));
|
||||
|
||||
const tokens = s => (s.toLowerCase().match(/[a-záéíóúñü0-9_.]{2,}/g) || []).filter(w => !STOP.has(w));
|
||||
const tokEstimate = m => Math.ceil((m.content || '').length / 4) + 4;
|
||||
|
||||
function bm25Scores(messages, query) {
|
||||
const q = [...new Set(tokens(query))];
|
||||
const docs = messages.map(m => tokens(m.content));
|
||||
const N = docs.length || 1;
|
||||
const avgLen = docs.reduce((s, d) => s + d.length, 0) / N || 1;
|
||||
const df = new Map();
|
||||
for (const d of docs) for (const w of new Set(d)) df.set(w, (df.get(w) || 0) + 1);
|
||||
const k = 1.2, b = 0.75;
|
||||
return docs.map(d => {
|
||||
if (!d.length) return 0;
|
||||
const tf = new Map();
|
||||
for (const w of d) tf.set(w, (tf.get(w) || 0) + 1);
|
||||
let score = 0;
|
||||
for (const w of q) {
|
||||
const f = tf.get(w);
|
||||
if (!f) continue;
|
||||
const idf = Math.log(1 + (N - (df.get(w) || 0) + 0.5) / ((df.get(w) || 0) + 0.5));
|
||||
score += idf * (f * (k + 1)) / (f + k * (1 - b + b * (d.length / avgLen)));
|
||||
}
|
||||
return score;
|
||||
});
|
||||
}
|
||||
|
||||
// Recorta resultados de herramientas antiguos: la cola larga rara vez importa.
|
||||
const shrink = m => m.content.startsWith('[resultado') && m.content.length > 600
|
||||
? { ...m, content: m.content.slice(0, 600) + '\n… (recortado por antigüedad)' }
|
||||
: m;
|
||||
|
||||
export function packHistory(history, budgetTokens = 2200) {
|
||||
if (!history.length) return history;
|
||||
if (acerUnificado()) return packHistoryACER(history, budgetTokens, { SUMMARY: resumenOn() }).messages;
|
||||
// los recientes también se recortan por mensaje: un solo tool-result gigante
|
||||
// (README de un repo grande) reventaba el contexto → «Too many tokens».
|
||||
const recent = history.slice(-RECENT).map(clampMsg);
|
||||
let used = recent.reduce((s, m) => s + tokEstimate(m), 0);
|
||||
const old = history.slice(0, -RECENT).map(shrink);
|
||||
if (!old.length || used >= budgetTokens) return recent;
|
||||
|
||||
const query = [...history].reverse().find(m =>
|
||||
m.role === 'user' && !m.content.startsWith('[resultado'))?.content || '';
|
||||
const scores = bm25Scores(old, query);
|
||||
// orden por relevancia; a igualdad gana lo más nuevo
|
||||
const ranked = old.map((m, i) => ({ m, i, s: scores[i] }))
|
||||
.sort((a, b) => (b.s - a.s) || (b.i - a.i));
|
||||
|
||||
const keep = new Set();
|
||||
for (const { m, i } of ranked) {
|
||||
const cost = tokEstimate(m);
|
||||
if (used + cost > budgetTokens) continue;
|
||||
used += cost;
|
||||
keep.add(i);
|
||||
}
|
||||
const packed = [];
|
||||
let dropped = 0;
|
||||
old.forEach((m, i) => {
|
||||
if (keep.has(i)) {
|
||||
if (dropped) { packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` }); dropped = 0; }
|
||||
packed.push(m);
|
||||
} else dropped++;
|
||||
});
|
||||
if (dropped) packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` });
|
||||
return [...packed, ...recent];
|
||||
}
|
||||
|
||||
/**
|
||||
* Igual que packHistory pero por la vía HÍBRIDA (léxica + semántica) cuando la
|
||||
* bandera está encendida. Es la que deben usar los proveedores.
|
||||
*
|
||||
* Degrada al empaquetado de siempre —el mismo, byte a byte— si la bandera está
|
||||
* apagada, si el modelo de embeddings no está o no carga, o si codificar falla.
|
||||
* Ese camino de vuelta es deliberado: que no haya modelo no puede significar que
|
||||
* la app deje de funcionar, sólo que empaqueta como hoy.
|
||||
*
|
||||
* `packHistory` sigue exportada y síncrona por compatibilidad.
|
||||
*/
|
||||
export async function packHistoryAsync(history, budgetTokens = 2200) {
|
||||
if (!history.length) return history;
|
||||
if (!acerUnificado() || !semanticoOn()) return packHistory(history, budgetTokens);
|
||||
try {
|
||||
// Import DINÁMICO: con la bandera apagada, embed.js —y con él
|
||||
// transformers.js y el modelo— no se piden nunca.
|
||||
const { embed, embedCache } = await import('./embed.js');
|
||||
const r = await packHistoryACERHybrid(history, budgetTokens, {
|
||||
embed, cache: embedCache(), SEM_BUDGET, SUMMARY: resumenOn(),
|
||||
});
|
||||
return r.messages;
|
||||
} catch {
|
||||
return packHistory(history, budgetTokens);
|
||||
}
|
||||
}
|
||||
273
web/js/conversations.js
Normal file
273
web/js/conversations.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// Conversaciones: varias a la vez, como pestañas — cada una con su propio
|
||||
// historial (su propio Agent) y su propia cola de mensajes pendientes.
|
||||
// Cerrar una pestaña NO borra la conversación (solo la Historial sí). Todo
|
||||
// persiste en IndexedDB, sobrevive a recargar.
|
||||
//
|
||||
// Procesado: cada conversación acepta y encola mensajes de forma totalmente
|
||||
// independiente (mandar en la pestaña B mientras la A está pensando no
|
||||
// bloquea nada). La generación en sí (la llamada real al modelo) se
|
||||
// serializa con un cerrojo global — solo hay UN modelo cargado en el
|
||||
// navegador y una sesión de inferencia no admite dos generate() a la vez sin
|
||||
// corromperse — así que las conversaciones se turnan para «hablar» con el
|
||||
// modelo, pero cada una sigue funcionando y aceptando mensajes de forma
|
||||
// independiente mientras espera su turno.
|
||||
import { Agent } from './agent.js';
|
||||
import { runGoal, TASK_PREFIX } from './goal.js';
|
||||
import * as db from './db.js';
|
||||
import * as telemetry from './telemetry.js';
|
||||
|
||||
const NS = 'elffusscode';
|
||||
const TABS_KEY = NS + '.openTabs';
|
||||
const ACTIVE_KEY = NS + '.activeConv';
|
||||
const HIST_CAP = 80; // mensajes por conversación que se persisten
|
||||
|
||||
let providerRef = null;
|
||||
const convs = new Map(); // id -> { id, title, agent, queue, pumping, createdAt, updatedAt }
|
||||
let openTabIds = [];
|
||||
let activeId = null;
|
||||
let onChange = () => {};
|
||||
|
||||
const genId = () => 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||
const cleanTitle = s => String(s).replace(/\s+/g, ' ').trim();
|
||||
|
||||
const GOAL_PREFIX = TASK_PREFIX + 'Objetivo: ';
|
||||
export function titleFor(conv) {
|
||||
if (conv.title) return conv.title;
|
||||
const firstUser = conv.agent.history.find(m =>
|
||||
m.role === 'user' && !m.content.startsWith('[resultado') &&
|
||||
(!m.content.startsWith(TASK_PREFIX) || m.content.startsWith(GOAL_PREFIX)));
|
||||
if (!firstUser) return 'Nueva conversación';
|
||||
// el objetivo lleva un prefijo interno ([tarea-objetivo] Objetivo: …) que
|
||||
// no debe filtrarse al título de la pestaña — se muestra el texto limpio.
|
||||
const raw = firstUser.content.startsWith(GOAL_PREFIX) ? firstUser.content.slice(GOAL_PREFIX.length) : firstUser.content;
|
||||
const t = cleanTitle(raw);
|
||||
return t.length > 40 ? t.slice(0, 40) + '…' : t || 'Nueva conversación';
|
||||
}
|
||||
|
||||
export function setProvider(mod) {
|
||||
providerRef = mod;
|
||||
for (const c of convs.values()) c.agent.setProvider(mod);
|
||||
}
|
||||
export function getProvider() { return providerRef; }
|
||||
|
||||
export function isBusy() {
|
||||
for (const c of convs.values()) if (c.pumping || c.queue.length) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getActive() { return activeId ? convs.get(activeId) : null; }
|
||||
|
||||
// Parar la conversación activa mientras trabaja: aborta la generación/bucle del
|
||||
// agente (agent.stop() propaga la señal a los proveedores) y vacía la cola para
|
||||
// que no arranque el siguiente turno. runGoal comprueba conv.stopped para no
|
||||
// seguir con las tareas pendientes del plan.
|
||||
export function stopActive() {
|
||||
const c = getActive();
|
||||
if (!c) return false;
|
||||
c.stopped = true;
|
||||
try { c.agent.stop(); } catch { /* */ }
|
||||
c.queue.length = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getOpenTabs() { return openTabIds.map(id => convs.get(id)).filter(Boolean); }
|
||||
|
||||
async function persistConv(conv) {
|
||||
// NO tragarse el error de lectura: un fallo transitorio devolvía [] y el
|
||||
// db.set de abajo reescribía el store entero con UNA sola conversación,
|
||||
// borrando el resto. Distinguir «no existe aún» (undefined → []) de «no se
|
||||
// pudo leer» (excepción → no escribir nada este turno).
|
||||
let stored;
|
||||
try { stored = await db.get('kv', 'conversations'); }
|
||||
catch (e) { console.warn('[elffuss] no se pudo leer conversations; no sobrescribo', e); return; }
|
||||
const all = stored == null ? [] : stored;
|
||||
if (!Array.isArray(all)) { console.warn('[elffuss] conversations corrupto; no sobrescribo'); return; }
|
||||
const i = all.findIndex(c => c.id === conv.id);
|
||||
const rec = {
|
||||
id: conv.id, title: titleFor(conv), history: conv.agent.history.slice(-HIST_CAP),
|
||||
queue: conv.queue, plan: conv.plan || null, createdAt: conv.createdAt, updatedAt: conv.updatedAt,
|
||||
};
|
||||
if (i >= 0) all[i] = rec; else all.push(rec);
|
||||
await db.set('kv', 'conversations', all).catch(() => {});
|
||||
}
|
||||
function persistMeta() {
|
||||
try { localStorage.setItem(TABS_KEY, JSON.stringify(openTabIds)); } catch { /* lleno */ }
|
||||
try { localStorage.setItem(ACTIVE_KEY, activeId || ''); } catch { /* lleno */ }
|
||||
}
|
||||
|
||||
function makeConv(id, saved) {
|
||||
const conv = {
|
||||
id, title: saved?.title || null, agent: new Agent(providerRef),
|
||||
queue: saved?.queue ? [...saved.queue] : [], pumping: false, plan: saved?.plan || null,
|
||||
createdAt: saved?.createdAt || Date.now(), updatedAt: saved?.updatedAt || Date.now(),
|
||||
};
|
||||
if (saved?.history) conv.agent.history = saved.history;
|
||||
convs.set(id, conv);
|
||||
return conv;
|
||||
}
|
||||
|
||||
export function create() {
|
||||
const conv = makeConv(genId());
|
||||
openTabIds.push(conv.id);
|
||||
activeId = conv.id;
|
||||
persistMeta();
|
||||
onChange('switch', conv.id);
|
||||
return conv;
|
||||
}
|
||||
|
||||
// reabre una conversación guardada (de la Historial) como pestaña, o activa
|
||||
// la pestaña si ya estaba abierta
|
||||
export async function open(id) {
|
||||
if (!convs.has(id)) {
|
||||
const saved = (await db.get('kv', 'conversations').catch(() => null) || []).find(c => c.id === id);
|
||||
if (!saved) return null;
|
||||
makeConv(id, saved);
|
||||
}
|
||||
if (!openTabIds.includes(id)) openTabIds.push(id);
|
||||
activeId = id;
|
||||
persistMeta();
|
||||
onChange('switch', id);
|
||||
return convs.get(id);
|
||||
}
|
||||
|
||||
export function switchTo(id) {
|
||||
if (!convs.has(id) || activeId === id) return;
|
||||
activeId = id;
|
||||
persistMeta();
|
||||
onChange('switch', id);
|
||||
}
|
||||
|
||||
// añade un mensaje fuera del ciclo normal de envío (p.ej. el informe
|
||||
// autónomo del cerebro CEO) al historial REAL de una conversación — si no,
|
||||
// solo viviría en el DOM y desaparecería al cambiar de pestaña o recargar.
|
||||
export async function appendMessage(id, role, content) {
|
||||
const c = convs.get(id);
|
||||
if (!c) return;
|
||||
c.agent.history.push({ role, content, ts: Date.now() });
|
||||
c.updatedAt = Date.now();
|
||||
await persistConv(c);
|
||||
}
|
||||
|
||||
// quita la pestaña de la vista — la conversación sigue viva en la Historial
|
||||
export function closeTab(id) {
|
||||
openTabIds = openTabIds.filter(x => x !== id);
|
||||
if (activeId === id) activeId = openTabIds[openTabIds.length - 1] || null;
|
||||
persistMeta();
|
||||
if (!openTabIds.length) create(); else onChange('tabs');
|
||||
}
|
||||
|
||||
// borra la conversación de verdad (desde la Historial)
|
||||
export async function remove(id) {
|
||||
convs.delete(id);
|
||||
openTabIds = openTabIds.filter(x => x !== id);
|
||||
if (activeId === id) activeId = openTabIds[openTabIds.length - 1] || null;
|
||||
const all = (await db.get('kv', 'conversations').catch(() => null) || []).filter(c => c.id !== id);
|
||||
await db.set('kv', 'conversations', all).catch(() => {});
|
||||
persistMeta();
|
||||
if (!openTabIds.length) create(); else onChange('tabs');
|
||||
}
|
||||
|
||||
export async function listAll() {
|
||||
const all = await db.get('kv', 'conversations').catch(() => null) || [];
|
||||
return all.slice().sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
// ---- envío / procesado ----
|
||||
// items de la cola: {kind:'chat'|'goal', text} — 'chat' es un turno normal
|
||||
// (agent.handle), 'goal' dispara el planificador/ejecutor de goal.js. Las
|
||||
// colas persistidas ANTES de que existiera el modo Objetivo guardaban texto
|
||||
// suelto (strings) — normalizeItem() los sigue aceptando como 'chat'.
|
||||
let inferenceLock = Promise.resolve();
|
||||
const normalizeItem = it => (typeof it === 'string' ? { kind: 'chat', text: it } : it);
|
||||
|
||||
export function send(id, text) {
|
||||
const conv = convs.get(id);
|
||||
if (!conv) return;
|
||||
conv.queue.push({ kind: 'chat', text });
|
||||
persistConv(conv);
|
||||
pump(conv);
|
||||
}
|
||||
|
||||
// 🎯 Modo Objetivo: en vez de un turno de chat normal, el mensaje se trata
|
||||
// como un objetivo — se planifica en tareas y se ejecutan una a una (ver
|
||||
// goal.js, mismo patrón planificador/ejecutor de clonagent).
|
||||
export function startGoal(id, text) {
|
||||
const conv = convs.get(id);
|
||||
if (!conv) return;
|
||||
conv.queue.push({ kind: 'goal', text });
|
||||
persistConv(conv);
|
||||
pump(conv);
|
||||
}
|
||||
|
||||
async function pump(conv) {
|
||||
if (conv.pumping) return;
|
||||
conv.pumping = true;
|
||||
conv.stopped = false;
|
||||
onChange('pumping', conv.id, true);
|
||||
while (conv.queue.length) {
|
||||
const item = normalizeItem(conv.queue[0]);
|
||||
const myTurn = inferenceLock;
|
||||
let release;
|
||||
inferenceLock = new Promise(r => { release = r; });
|
||||
await myTurn; // espera su turno de inferencia (un solo modelo cargado, no admite 2 generate() a la vez)
|
||||
try {
|
||||
// un fallo aquí (del modelo, de una tool, o del propio repintado en
|
||||
// main.js) NUNCA debe dejar la conversación "pumping" para siempre —
|
||||
// eso bloquearía sus futuros mensajes Y al cerebro CEO (isBusy()).
|
||||
const onEvent = ev => {
|
||||
try { onChange('event', conv.id, ev); } catch (e) { console.error('[elffuss] fallo pintando un evento de chat', e); }
|
||||
};
|
||||
if (item.kind === 'goal') await runGoal(conv, item.text, onEvent);
|
||||
else await conv.agent.handle(item.text, onEvent);
|
||||
} catch (e) {
|
||||
console.error('[elffuss] fallo procesando el turno', e);
|
||||
telemetry.reportError('pump: ' + (e?.message || e), { stack: e?.stack || '' });
|
||||
try { onChange('event', conv.id, { type: 'error', text: 'Error interno: ' + (e?.message || e) }); } catch { /* ya está registrado arriba */ }
|
||||
} finally { release(); }
|
||||
conv.queue.shift();
|
||||
conv.updatedAt = Date.now();
|
||||
await persistConv(conv);
|
||||
if (conv.stopped) break;
|
||||
}
|
||||
conv.pumping = false;
|
||||
onChange('pumping', conv.id, false);
|
||||
}
|
||||
|
||||
export async function init({ onEvent }) {
|
||||
onChange = onEvent;
|
||||
|
||||
// migración única: el esquema viejo era UNA sola conversación (kv/history + kv/queue)
|
||||
const already = await db.get('kv', 'conversations').catch(() => null);
|
||||
if (!already) {
|
||||
const oldHistory = await db.get('kv', 'history').catch(() => null);
|
||||
if (oldHistory?.length) {
|
||||
const oldQueue = await db.get('kv', 'queue').catch(() => null);
|
||||
await db.set('kv', 'conversations', [{
|
||||
id: genId(), title: null, history: oldHistory, queue: oldQueue || [],
|
||||
createdAt: Date.now(), updatedAt: Date.now(),
|
||||
}]).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
let tabIds = [], active = null;
|
||||
try { tabIds = JSON.parse(localStorage.getItem(TABS_KEY) || '[]'); } catch { /* corrupto */ }
|
||||
try { active = localStorage.getItem(ACTIVE_KEY) || null; } catch { /* */ }
|
||||
const all = await db.get('kv', 'conversations').catch(() => null) || [];
|
||||
|
||||
for (const id of tabIds) {
|
||||
const saved = all.find(c => c.id === id);
|
||||
if (saved) { makeConv(id, saved); openTabIds.push(id); }
|
||||
}
|
||||
if (active && convs.has(active)) activeId = active;
|
||||
else if (openTabIds.length) activeId = openTabIds[0];
|
||||
|
||||
if (!openTabIds.length) {
|
||||
// nada abierto (primera vez, o se perdió el estado de pestañas): retoma
|
||||
// la conversación más reciente si hay alguna, si no, una nueva vacía
|
||||
const mostRecent = all.slice().sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
||||
if (mostRecent) { makeConv(mostRecent.id, mostRecent); openTabIds.push(mostRecent.id); activeId = mostRecent.id; }
|
||||
else create();
|
||||
}
|
||||
for (const conv of convs.values()) if (conv.queue.length) pump(conv);
|
||||
persistMeta();
|
||||
}
|
||||
32
web/js/db.js
Normal file
32
web/js/db.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// IndexedDB mínima: stores clave→valor.
|
||||
const NAME = 'elffuss-code', VERSION = 2;
|
||||
const STORES = ['kv', 'apps', 'tasks', 'vault', 'fs', 'memory'];
|
||||
let dbPromise = null;
|
||||
|
||||
function open() {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(NAME, VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
for (const s of STORES)
|
||||
if (!req.result.objectStoreNames.contains(s)) req.result.createObjectStore(s);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function tx(store, mode, fn) {
|
||||
return open().then(db => new Promise((resolve, reject) => {
|
||||
const req = fn(db.transaction(store, mode).objectStore(store));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
}));
|
||||
}
|
||||
|
||||
export const get = (store, key) => tx(store, 'readonly', s => s.get(key));
|
||||
export const set = (store, key, val) => tx(store, 'readwrite', s => s.put(val, key));
|
||||
export const del = (store, key) => tx(store, 'readwrite', s => s.delete(key));
|
||||
export const keys = (store) => tx(store, 'readonly', s => s.getAllKeys());
|
||||
export const all = (store) => tx(store, 'readonly', s => s.getAll());
|
||||
191
web/js/embed.js
Normal file
191
web/js/embed.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// Lado SEMÁNTICO del motor de contexto (el compañero de acer-core.js).
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// POR QUÉ EXISTE
|
||||
//
|
||||
// acer-core.js ya trae la vía híbrida completa (`packHistoryACERHybrid`): BM25 y
|
||||
// embeddings fusionados por rangos. Lo que le faltaba era una función `embed`,
|
||||
// así que degradaba a la vía léxica y en producción sólo corría BM25.
|
||||
//
|
||||
// Medido en LoCoMo (200 preguntas, F1 del propio repo, modelo real respondiendo,
|
||||
// mismo presupuesto de tokens):
|
||||
//
|
||||
// contexto COMPLETO sin comprimir ..... 22,56
|
||||
// BM25 (lo que corría hasta ahora) .... 23,59
|
||||
// embeddings solos .................... 23,95
|
||||
// fusión de RANGOS (esto) ............. 28,09 ← +4,50 sobre BM25
|
||||
//
|
||||
// El motivo de que sume no es que lo semántico sea «mejor»: en global EMPATAN
|
||||
// (23,59 vs 23,95). Hacen trabajos DISTINTOS. Partiendo las mismas preguntas por
|
||||
// solape de vocabulario entre la pregunta y la respuesta:
|
||||
//
|
||||
// sin solape (n=107) con solape (n=93)
|
||||
// BM25 ...................... 18,60 29,33
|
||||
// embeddings ................ 24,28 23,58
|
||||
// fusión de rangos .......... 24,05 32,73
|
||||
//
|
||||
// Cuando la pregunta NO nombra literalmente lo que busca, BM25 se hunde (18,60)
|
||||
// y lo semántico aguanta (24,28). Cuando sí lo nombra, manda BM25 (29,33). La
|
||||
// fusión se queda con los dos. Lo semántico no sustituye al léxico: le cubre el
|
||||
// punto ciego. Eso —y no una ganancia global— es lo que se está cableando aquí.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MODELO ELEGIDO — Xenova/multilingual-e5-small, dtype q8 (118 MB)
|
||||
//
|
||||
// Requisitos duros y cómo los cumple:
|
||||
// · transformers.js: es la conversión ONNX oficial del mantenedor de la
|
||||
// librería → carga con el MISMO `pipeline()` que ya usa providers/onnx.js.
|
||||
// · pequeño: 118 MB en q8. Es la variante más liviana del repo (el q4 pesa
|
||||
// MÁS —398 MB— porque la matriz de embeddings de un vocabulario de 250k no
|
||||
// se cuantiza igual de bien; aquí «menos bits» no significa menos disco).
|
||||
// · multilingüe: tokenizador XLM-R, 100 idiomas. Los prompts van en castellano
|
||||
// y el código en inglés, y ese cruce es exactamente el punto ciego léxico.
|
||||
// · licencia: MIT (intfloat/multilingual-e5-small aguas arriba).
|
||||
// · ventana de 512 tokens, que es la que necesitan los bloques que arma
|
||||
// acer-core; no es un modelo de frases sueltas.
|
||||
//
|
||||
// Descartados, y por qué:
|
||||
// · Xenova/all-MiniLM-L6-v2 (23 MB, Apache-2.0) — 5× más barato, pero SÓLO
|
||||
// inglés. Justo el caso que venimos a cubrir es el castellano.
|
||||
// · Xenova/paraphrase-multilingual-MiniLM-L12-v2 (118 MB, Apache-2.0) — mismo
|
||||
// peso, pero está afinado para paráfrasis de FRASES con ventana de 128
|
||||
// tokens: trocearía los bloques por la mitad.
|
||||
// · onnx-community/embeddinggemma-300m-ONNX (175 MB en q4f16 + pesos externos)
|
||||
// — mejor calidad, pero 1,5× de descarga y licencia Gemma (uso comercial
|
||||
// permitido pero con política de uso y obligaciones de redistribución), no
|
||||
// una licencia comercial limpia.
|
||||
// · ibm-granite/granite-embedding-107m-multilingual (Apache-2.0) — sólo
|
||||
// publica ONNX en fp32: 428 MB, 3,6× la descarga.
|
||||
// · minishlab/potion-multilingual-128M (MIT) — estático y rapidísimo, pero su
|
||||
// único ONNX pesa 512 MB y transformers.js no lo soporta de serie.
|
||||
//
|
||||
// DTYPE SEGÚN EL DISPOSITIVO — medido, no supuesto. Mismo modelo, mismo lote de
|
||||
// bloques, sólo cambiando dtype y dispositivo (coste relativo, 1,0 = el mejor):
|
||||
//
|
||||
// fp16 / WebGPU .... 1,0× (235 MB) ← el elegido cuando hay adaptador
|
||||
// q4f16/ WebGPU .... 0,6× (205 MB) con bloques medianos; ver punto 2
|
||||
// q8 / wasm ...... 8,9× (118 MB) ← la red de seguridad
|
||||
// fp32 / wasm ...... 11,0× (470 MB)
|
||||
// q8 / WebGPU .... 14,1× (118 MB) ← PEOR que en CPU
|
||||
//
|
||||
// Dos cosas que contradicen la intuición y por eso van escritas:
|
||||
// 1. Los enteros de 8 bits NO se aceleran en WebGPU: ORT-web no tiene kernels
|
||||
// para esos operadores y acaba yendo y viniendo de la CPU. El fichero más
|
||||
// pequeño resulta ser el MÁS LENTO en la GPU. Elegir dtype por tamaño de
|
||||
// descarga, sin medir, habría dado la peor combinación posible.
|
||||
// 2. Con textos CORTOS —que es nuestro caso, ver SEM_BUDGET en context.js— el
|
||||
// 4-bit se da la vuelta y pierde contra fp16 (2,6× más lento en líneas
|
||||
// sueltas): a esas longitudes se paga más por deshacer la cuantización que
|
||||
// lo que se ahorra en ancho de banda. Por eso fp16 y no q4f16, aunque en
|
||||
// bloques grandes q4f16 gane.
|
||||
//
|
||||
// De ahí la regla: fp16 si hay adaptador WebGPU de verdad, y q8/wasm como red de
|
||||
// seguridad. La red de seguridad FUNCIONA pero es ~9× más lenta, hasta el punto
|
||||
// de notarse en cada turno, y ése es uno de los motivos de que el lado semántico
|
||||
// vaya apagado por defecto (ver context.js).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { createEmbedCache } from './acer-core.js';
|
||||
|
||||
export const EMBED_MODEL = {
|
||||
id: 'Xenova/multilingual-e5-small',
|
||||
dims: 384,
|
||||
label: 'multilingual-e5-small',
|
||||
// dtype → fichero → descarga
|
||||
webgpu: { dtype: 'fp16', sizeMB: 235 },
|
||||
wasm: { dtype: 'q8', sizeMB: 118 },
|
||||
};
|
||||
|
||||
// La ventana del modelo son 512 tokens; recortar antes de tokenizar evita pagar
|
||||
// el troceado de texto que el propio modelo va a descartar.
|
||||
const MAX_CHARS = 2000;
|
||||
|
||||
// Lotes pequeños: transformers.js rellena cada lote hasta el más largo, así que
|
||||
// un lote gigante hace pagar la longitud del peor elemento por todos.
|
||||
const BATCH = 16;
|
||||
|
||||
// e5 se entrenó SIEMPRE con prefijo ('query: ' / 'passage: '), nunca con texto
|
||||
// pelado. acer-core llama a la misma `embed` para la pregunta y para los
|
||||
// bloques, y no hay forma de distinguirlos sin tocar el núcleo; los autores del
|
||||
// modelo documentan usar 'query: ' en AMBOS lados para uso simétrico. Quitarlo
|
||||
// del todo sería peor: el modelo nunca vio esa distribución.
|
||||
const PREFIX = 'query: ';
|
||||
|
||||
let pipePromise = null;
|
||||
export let backend = null; // {device, dtype, sizeMB} una vez resuelto
|
||||
|
||||
/**
|
||||
* Carga PEREZOSA: nada de esto se toca en el arranque. El módulo entero se
|
||||
* importa dinámicamente desde context.js sólo cuando la bandera está encendida,
|
||||
* y el modelo no se descarga hasta la primera petición que de verdad lo use.
|
||||
*/
|
||||
function getPipe(onProgress) {
|
||||
if (!pipePromise) {
|
||||
pipePromise = (async () => {
|
||||
const tf = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4');
|
||||
// navigator.gpu puede EXISTIR sin adaptador real (mismo cuidado que en
|
||||
// providers/onnx.js): comprobar el adaptador, no el objeto. Elegir mal
|
||||
// aquí significa descargar 235 MB para acabar corriendo en CPU.
|
||||
let device = 'wasm';
|
||||
if (navigator.gpu) {
|
||||
try { device = (await navigator.gpu.requestAdapter()) ? 'webgpu' : 'wasm'; }
|
||||
catch { device = 'wasm'; }
|
||||
}
|
||||
const cfg = { device, ...EMBED_MODEL[device] };
|
||||
const pipe = await tf.pipeline('feature-extraction', EMBED_MODEL.id, {
|
||||
device,
|
||||
dtype: cfg.dtype,
|
||||
progress_callback: onProgress,
|
||||
});
|
||||
backend = cfg; // sólo cuando de verdad está listo: isReady() no miente
|
||||
return pipe;
|
||||
})().catch(e => {
|
||||
pipePromise = null; // que un fallo de red no deje el módulo muerto
|
||||
backend = null;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return pipePromise;
|
||||
}
|
||||
|
||||
/** Precarga opcional (p.ej. desde Ajustes) sin bloquear ningún turno. */
|
||||
export function warmup(onProgress) { return getPipe(onProgress); }
|
||||
|
||||
/** ¿Está ya cargado? Sirve para decidir si un turno va a pagar la descarga. */
|
||||
export function isReady() { return backend !== null; }
|
||||
|
||||
/**
|
||||
* embed(textos) → vectores normalizados de 384 dimensiones.
|
||||
* Contrato exacto que espera `packHistoryACERHybrid`: si esto lanza, acer-core
|
||||
* se va por la vía léxica sin romperse ni avisar.
|
||||
*/
|
||||
export async function embed(texts) {
|
||||
if (!texts || !texts.length) return [];
|
||||
const pipe = await getPipe();
|
||||
const out = [];
|
||||
for (let i = 0; i < texts.length; i += BATCH) {
|
||||
const batch = texts.slice(i, i + BATCH)
|
||||
.map(t => PREFIX + String(t == null ? '' : t).slice(0, MAX_CHARS));
|
||||
const t = await pipe(batch, { pooling: 'mean', normalize: true });
|
||||
for (const v of t.tolist()) out.push(Float32Array.from(v));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Caché de sesión POR CONTENIDO. No es una optimización: es lo que hace viable
|
||||
// la idea. acer-core rearma los bloques desde el principio del historial en cada
|
||||
// turno, así que sin caché un texto que ya se codificó en el turno 3 se volvería
|
||||
// a codificar en el 4, el 5 y el 20 — el coste crecería con el CUADRADO de los
|
||||
// turnos. Con caché, cada bloque se codifica una vez por sesión y un turno sólo
|
||||
// paga los bloques nuevos.
|
||||
// El tope es holgado a propósito: acer-core trocea el historial ENTERO en cada
|
||||
// turno, así que si la caché desaloja entradas que el turno siguiente vuelve a
|
||||
// pedir, se recodifica gratis. Son vectores de 384 flotantes: ~12 MB llenos.
|
||||
let cache = null;
|
||||
export function embedCache() {
|
||||
if (!cache) cache = createEmbedCache(embed, { max: 8000 });
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Para diagnóstico/ajustes: cuántos textos lleva cacheados la sesión. */
|
||||
export function embedCacheSize() { return cache ? cache.size() : 0; }
|
||||
163
web/js/goal.js
Normal file
163
web/js/goal.js
Normal file
@@ -0,0 +1,163 @@
|
||||
// Modo Objetivo (🎯 Goal): mismo patrón planificador/ejecutor que clonagent
|
||||
// (~/work2026/clonagent) — un PLANIFICADOR descompone el objetivo en una
|
||||
// lista de tareas con estado (pending/in-progress/done/failed/skipped), y un
|
||||
// EJECUTOR las va ejecutando una a una hasta que no queda ninguna pendiente,
|
||||
// marcando el resultado de cada una. Si una tarea falla, las siguientes se
|
||||
// saltan (cascada) en vez de seguir a ciegas — igual que el fallo→skip de
|
||||
// dependencias de clonagent.
|
||||
//
|
||||
// Aquí el "ejecutor" de cada tarea es el MISMO bucle de tool-calling que ya
|
||||
// usa el chat normal (agent.handle): cada tarea del plan se manda como si
|
||||
// fuera un mensaje de usuario, con todo el historial previo como contexto —
|
||||
// así una tarea puede apoyarse en lo que hizo la anterior, igual que la
|
||||
// sesión del ejecutor de clonagent tiene a la vista el fichero de tareas
|
||||
// entero.
|
||||
import { toolHelp, snapshot } from './tools/index.js';
|
||||
|
||||
const MAX_TASK_RETRIES = 2;
|
||||
const TASK_PREFIX = '[tarea-objetivo] ';
|
||||
|
||||
function plannerPrompt(goalText, context) {
|
||||
return `ROL: PLANIFICADOR
|
||||
|
||||
Objetivo del usuario: "${goalText}"
|
||||
|
||||
CONTEXTO AHORA (estado real del IDE):
|
||||
${context}
|
||||
|
||||
Herramientas disponibles para la fase de EJECUCIÓN (aquí solo planificas, no las llames):
|
||||
${toolHelp()}
|
||||
|
||||
Descompón el objetivo en una lista de tareas CONCRETAS, ORDENADAS y ejecutables una tras otra con esas herramientas. Entre 2 y 6 tareas normalmente basta — no fragmentes de más, cada tarea debe ser un paso real con sentido propio.
|
||||
|
||||
Responde SOLO con este JSON, nada de texto ni fences alrededor:
|
||||
{"plan": "una frase resumiendo el enfoque", "tasks": [{"title": "título corto", "description": "qué hay que hacer, concreto y accionable"}]}`;
|
||||
}
|
||||
|
||||
// Extrae el primer objeto JSON balanceado del texto (el modelo a veces mete
|
||||
// prosa alrededor pese a la instrucción de "SOLO JSON").
|
||||
function extractJson(text) {
|
||||
const start = text.indexOf('{');
|
||||
if (start < 0) return null;
|
||||
let depth = 0, inStr = false, esc = false;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (esc) { esc = false; continue; }
|
||||
if (ch === '\\') { esc = true; continue; }
|
||||
if (ch === '"') inStr = !inStr;
|
||||
else if (!inStr) {
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}' && --depth === 0) {
|
||||
try { return JSON.parse(text.slice(start, i + 1)); } catch { return null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Llamada ÚNICA al modelo con el rol de planificador — no toca agent.history
|
||||
// (el plan resultante se guarda aparte; el objetivo en sí SÍ se añade al
|
||||
// historial en runGoal, para que las tareas puedan apoyarse en él).
|
||||
export async function planGoal(agent, goalText) {
|
||||
const context = await snapshot().catch(() => '');
|
||||
const raw = await agent.provider.chat([{ role: 'user', content: goalText }], plannerPrompt(goalText, context), () => {});
|
||||
const parsed = extractJson(raw);
|
||||
if (!parsed || !Array.isArray(parsed.tasks) || !parsed.tasks.length) {
|
||||
throw new Error('el planificador no devolvió una lista de tareas válida');
|
||||
}
|
||||
return {
|
||||
planText: String(parsed.plan || '').trim(),
|
||||
tasks: parsed.tasks.slice(0, 10).map(t => ({
|
||||
title: String(t.title || 'tarea').trim(),
|
||||
description: String(t.description || t.title || '').trim(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function clonePlan(plan) { return JSON.parse(JSON.stringify(plan)); }
|
||||
|
||||
// Orquesta plan → ejecución. onEvent es el MISMO canal que usa el chat
|
||||
// normal (conversations.js lo conecta a onConvEvent) — los eventos de las
|
||||
// tareas (token/tool/tool_result/text/error) se reenvían tal cual, y además
|
||||
// se emiten 'plan' / 'plan_update' / 'plan_complete' para la tarjeta de plan.
|
||||
export async function runGoal(conv, goalText, onEvent) {
|
||||
conv.agent.history.push({ role: 'user', content: TASK_PREFIX + 'Objetivo: ' + goalText });
|
||||
|
||||
const plan = {
|
||||
id: 'g' + Date.now().toString(36), goal: goalText, planText: '', status: 'planning',
|
||||
tasks: [], createdAt: Date.now(), updatedAt: Date.now(),
|
||||
};
|
||||
conv.plan = plan;
|
||||
onEvent({ type: 'plan', plan: clonePlan(plan) });
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await planGoal(conv.agent, goalText);
|
||||
} catch (e) {
|
||||
plan.status = 'failed';
|
||||
plan.updatedAt = Date.now();
|
||||
onEvent({ type: 'plan_update', plan: clonePlan(plan) });
|
||||
onEvent({ type: 'error', text: 'No pude planificar el objetivo: ' + e.message });
|
||||
return;
|
||||
}
|
||||
|
||||
plan.planText = parsed.planText;
|
||||
plan.tasks = parsed.tasks.map((t, i) => ({
|
||||
id: 't' + (i + 1), title: t.title, description: t.description,
|
||||
status: 'pending', retries: 0, result: '',
|
||||
}));
|
||||
plan.status = 'running';
|
||||
plan.updatedAt = Date.now();
|
||||
onEvent({ type: 'plan_update', plan: clonePlan(plan) });
|
||||
|
||||
let cascadeFail = false;
|
||||
for (const task of plan.tasks) {
|
||||
if (conv.stopped) break; // parado por el usuario: no seguir con el plan
|
||||
if (cascadeFail) { task.status = 'skipped'; plan.updatedAt = Date.now(); onEvent({ type: 'plan_update', plan: clonePlan(plan) }); continue; }
|
||||
|
||||
task.status = 'in-progress';
|
||||
plan.updatedAt = Date.now();
|
||||
onEvent({ type: 'plan_update', plan: clonePlan(plan) });
|
||||
|
||||
const taskPrompt = `${TASK_PREFIX}Tarea ${task.id} del objetivo «${goalText}»: ${task.title}\n${task.description}`;
|
||||
let done = false;
|
||||
while (!done && task.retries <= MAX_TASK_RETRIES) {
|
||||
let sawError = false, errText = '', usoHerramientas = 0;
|
||||
await conv.agent.handle(taskPrompt, ev => {
|
||||
// Un fallo llega de tres formas y antes solo se miraba una: el modelo
|
||||
// reventando ('error'), una herramienta fallando ('tool_error') y
|
||||
// agotar los pasos sin terminar ('exhausted'). Con solo la primera,
|
||||
// una tarea que no tocó nada se daba por hecha y la cascada de fallos
|
||||
// no se activaba nunca.
|
||||
if (ev.type === 'error' || ev.type === 'exhausted') { sawError = true; errText = ev.text; }
|
||||
if (ev.type === 'tool_error') { sawError = true; errText = `${ev.tool}: ${ev.text}`; }
|
||||
if (ev.type === 'tool') usoHerramientas++;
|
||||
onEvent(ev);
|
||||
});
|
||||
// Una tarea de un PLAN es una acción, no una charla. Si el modelo
|
||||
// contestó sin usar ni una herramienta, no hizo nada: el agente termina
|
||||
// en el primer turno (no agota pasos), así que sin esta comprobación la
|
||||
// tarea se marcaba hecha por el mero hecho de haber respondido.
|
||||
if (!sawError && usoHerramientas === 0) {
|
||||
sawError = true;
|
||||
errText = 'no usó ninguna herramienta: respondió sin tocar el proyecto';
|
||||
}
|
||||
if (!sawError) { done = true; }
|
||||
else {
|
||||
task.retries++;
|
||||
task.result = errText;
|
||||
if (task.retries > MAX_TASK_RETRIES) break;
|
||||
}
|
||||
}
|
||||
task.status = done ? 'done' : 'failed';
|
||||
plan.updatedAt = Date.now();
|
||||
onEvent({ type: 'plan_update', plan: clonePlan(plan) });
|
||||
if (!done) cascadeFail = true; // el resto se saltan, igual que la cascada de dependencias fallidas en clonagent
|
||||
}
|
||||
|
||||
plan.status = plan.tasks.some(t => t.status === 'failed') ? 'failed' : 'done';
|
||||
plan.updatedAt = Date.now();
|
||||
onEvent({ type: 'plan_complete', plan: clonePlan(plan) });
|
||||
}
|
||||
|
||||
export { TASK_PREFIX };
|
||||
81
web/js/humanize.js
Normal file
81
web/js/humanize.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Frase legible por humano para una tool-call («leyendo app.js…»). Compartido
|
||||
// por el cerebro CEO (ceo.js) y el streaming en vivo del chat — así nunca se
|
||||
// le enseña JSON crudo al usuario, siempre la misma redacción. Cubre los
|
||||
// nombres de herramienta de Elffuss Code (code.*, terminal.*) y de Elffuss
|
||||
// Claw (fs.*, app.*, web.*, skill.*, memory.*, tasks.*) — un tool desconocido
|
||||
// cae al genérico «nombre argumento» sin romper nada.
|
||||
//
|
||||
// Localizado: ES por defecto (usuarios españoles intactos), EN de fallback.
|
||||
// Autocontenido a propósito — este fichero es IDÉNTICO en Code y en Claw, y
|
||||
// Code no tiene i18n.js, así que la tabla de idioma vive aquí.
|
||||
const HL = {
|
||||
es: {
|
||||
read: p => `leyendo ${p}…`, write: p => `escribiendo ${p}…`,
|
||||
tree: p => `explorando ${p || 'el proyecto'}…`, list: p => `explorando ${p || 'la carpeta'}…`,
|
||||
search: q => `buscando «${q}»…`, pick: () => 'pidiendo acceso a una carpeta…',
|
||||
copy: x => `copiando ${x || 'archivos'}…`, watch: x => `vigilando ${x || 'una carpeta'}…`,
|
||||
run: c => `ejecutando: ${c}`,
|
||||
appCreate: n => `creando la app «${n}»…`, appOpen: n => `abriendo la app «${n}»…`,
|
||||
skillCreate: n => `creando la skill «${n}»…`, memSave: f => `recordando: ${f}…`,
|
||||
taskAdd: () => 'programando una tarea…',
|
||||
webSearch: q => `buscando en internet «${q}»…`, webImages: q => `buscando imágenes de «${q}»…`,
|
||||
webFetch: u => `leyendo ${u}…`, prep: 'preparando una acción…',
|
||||
},
|
||||
en: {
|
||||
read: p => `reading ${p}…`, write: p => `writing ${p}…`,
|
||||
tree: p => `exploring ${p || 'the project'}…`, list: p => `exploring ${p || 'the folder'}…`,
|
||||
search: q => `searching “${q}”…`, pick: () => 'requesting folder access…',
|
||||
copy: x => `copying ${x || 'files'}…`, watch: x => `watching ${x || 'a folder'}…`,
|
||||
run: c => `running: ${c}`,
|
||||
appCreate: n => `creating the app “${n}”…`, appOpen: n => `opening the app “${n}”…`,
|
||||
skillCreate: n => `creating the skill “${n}”…`, memSave: f => `remembering: ${f}…`,
|
||||
taskAdd: () => 'scheduling a task…',
|
||||
webSearch: q => `searching the web for “${q}”…`, webImages: q => `searching images of “${q}”…`,
|
||||
webFetch: u => `reading ${u}…`, prep: 'preparing an action…',
|
||||
},
|
||||
};
|
||||
function hl() { return HL[(navigator.language || 'es').slice(0, 2).toLowerCase()] || HL.en; }
|
||||
|
||||
export function humanizeTool(name, args) {
|
||||
const p = args?.path, q = args?.query, c = args?.command, n = args?.name;
|
||||
const t = hl();
|
||||
switch (name) {
|
||||
case 'code.read': case 'fs.read': return t.read(p);
|
||||
case 'code.write': case 'fs.write': return t.write(p);
|
||||
case 'code.tree': return t.tree(p);
|
||||
case 'fs.list': return t.list(p);
|
||||
case 'code.search': return t.search(q);
|
||||
case 'fs.pick_folder': return t.pick();
|
||||
case 'fs.copy': return t.copy(args?.pattern);
|
||||
case 'fs.watch': return t.watch(args?.from);
|
||||
case 'terminal.run': return t.run(c);
|
||||
case 'app.create': return t.appCreate(n);
|
||||
case 'app.open': return t.appOpen(n);
|
||||
case 'skill.create': return t.skillCreate(n);
|
||||
case 'memory.save': return t.memSave(args?.fact);
|
||||
case 'tasks.add': return t.taskAdd();
|
||||
case 'web.search': return t.webSearch(q);
|
||||
case 'web.images': return t.webImages(q);
|
||||
case 'web.fetch': return t.webFetch(p || args?.url);
|
||||
default: return name + (p || q || c || n ? ' ' + (p || q || c || n) : '');
|
||||
}
|
||||
}
|
||||
|
||||
// Detecta si el buffer que va llegando EN STREAMING ha entrado en un bloque de
|
||||
// tool-call (```tool { … }) y, si es así, devuelve una frase humana en vez del
|
||||
// JSON crudo — «preparando una acción…» hasta que el nombre de la tool sea
|
||||
// legible, luego «leyendo app.js…» tan pronto como el campo aparezca, aunque
|
||||
// el JSON todavía no haya cerrado.
|
||||
export function humanizeStreamPreview(buf) {
|
||||
if (buf.search(/```/) === -1 && !/^\s*\{\s*"tool"/.test(buf)) return null;
|
||||
const toolM = buf.match(/"tool"\s*:\s*"([\w.]+)"/);
|
||||
if (!toolM) return hl().prep;
|
||||
const pathM = buf.match(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
||||
const queryM = buf.match(/"query"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
||||
const cmdM = buf.match(/"command"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
||||
const nameM = buf.match(/"name"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
||||
const out = humanizeTool(toolM[1], { path: pathM?.[1], query: queryM?.[1], command: cmdM?.[1], name: nameM?.[1] });
|
||||
// el nombre de la tool ya se ve pero su campo (path/query/…) aún no llegó:
|
||||
// mejor el genérico que un «leyendo undefined…» a medio streamear
|
||||
return /undefined/.test(out) ? hl().prep : out;
|
||||
}
|
||||
271
web/js/i18n.js
Normal file
271
web/js/i18n.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// i18n ligero del «chrome» de Elffuss Code. El modelo ya responde en el idioma
|
||||
// del navegador (agent.js → userLang); esto localiza los textos estáticos y de
|
||||
// runtime de la UI (placeholder, indicadores «pensando/escribe», estado del
|
||||
// modelo, saludo del proyecto, la Mente). ES por defecto — cero impacto en
|
||||
// usuarios españoles — con inglés de fallback. Sustitución simple {var}.
|
||||
const L = {
|
||||
es: {
|
||||
ph: 'Escribe aquí… (@ adjunta un archivo)',
|
||||
thinking: 'Elffuss está pensando', writing: 'Elffuss escribe · {n} car.', using: 'Elffuss usa {name}',
|
||||
modelLoading: 'Descargando el modelo IA · {pct}% · {loaded}/{total} MB',
|
||||
modelReady: 'Modelo IA listo', modelReadyBadge: 'Modelo IA listo ✳',
|
||||
welcome: '👋 Proyecto «{name}» abierto. Pregúntame por el código, pídeme cambios o dime «árbol». Todo se queda en tu máquina.',
|
||||
mindTitle: 'MENTE DE ELFFUSS', avatarTitle: 'Mente de Elffuss — cerebro autónomo',
|
||||
// landing (estático) + tooltips de la barra/toolbar
|
||||
tagline: 'Abre tu carpeta de código y programa con una elfa al lado.',
|
||||
tagline2: 'Editor tipo VS Code + agente <b>en tu navegador</b>. Nada sale de tu máquina. 😉',
|
||||
openProject: '📁 Abrir carpeta de código', resume: 'Reanudar', discard: 'Descartar', cloneOpen: 'Descargar y abrir',
|
||||
clonePh: 'pega la URL de un repo público (GitHub/Bitbucket)…',
|
||||
searchPh: 'Buscar archivos por nombre (: para ir a línea, @ para símbolo, > para comandos)',
|
||||
tFiles: 'Explorador', tSearch: 'Buscar (Cmd/Ctrl+P)', tArch: 'Arquitectura (grafo de dependencias)',
|
||||
tCity: 'Ciudad 3D', tTerm: 'Terminal (Ctrl+`)', tSkills: 'Skills', tActSettings: 'Ajustes y modelo',
|
||||
tGitBranch: 'rama actual', tHistory: 'Historial de conversaciones', tTopSettings: 'Ajustes, modelo y API keys',
|
||||
tTermClose: 'cerrar (Ctrl+`)', tTermResize: 'arrastra para redimensionar',
|
||||
tCtx: 'contexto acumulado en la conversación', tPlus: 'Adjuntar archivo del proyecto (@)',
|
||||
tAutoedit: 'Editar archivos automáticamente',
|
||||
tHardwork: 'Hard Work: lee tu proyecto entero por partes — audita, resume o busca en código que no cabe en el modelo',
|
||||
tGoal: 'Modo Objetivo: descompone el mensaje en tareas y las ejecuta una a una (planificador + ejecutor)',
|
||||
tFlip: 'cambiar entre chat y editor', tSlash: 'Comandos',
|
||||
// pass-3: chrome profundo del IDE (paleta, menús, historial, ajustes, notifs)
|
||||
tabClose: 'Cerrar pestaña (no borra la conversación)', newConv: 'Nueva conversación',
|
||||
histEmpty: 'Sin conversaciones guardadas todavía.', histDel: 'Eliminar para siempre',
|
||||
confirmDel: '¿Eliminar «{title}» para siempre?', thisConv: 'esta conversación',
|
||||
cmdsSep: 'Comandos', slTree: 'árbol del proyecto', slSearch: 'buscar en el código',
|
||||
slSkills: 'gestionar skills', slModel: 'proveedores y modelo', slClear: 'nueva conversación',
|
||||
pTree: 'árbol', pSearch: 'busca ', pCommit: 'resume los cambios y prepárame el mensaje de commit',
|
||||
pProposal: 'Implementa esta propuesta de mejora con cambios reales en el código:\n\n',
|
||||
attachSep: 'Adjuntar al mensaje (@)', attachOpen: 'archivo abierto', openProjFirst: 'Abre un proyecto primero',
|
||||
autoTxt: 'Auto', reviewTxt: 'Revisar',
|
||||
autoOn: 'Editar archivos automáticamente (clic para revisar antes)',
|
||||
autoOff: 'Pedir confirmación antes de escribir (clic para automático)',
|
||||
pSearchCode: 'Buscar en el código…', pManageSkills: 'Gestionar skills', pToggleExplorer: 'Alternar explorador',
|
||||
pToggleTerm: 'Alternar terminal', pSave: 'Guardar (Ctrl+S)', palCmd: 'comando',
|
||||
palGotoSym: 'Ir a símbolo en el editor…', palEmpty: 'Sin resultados',
|
||||
mNewFile: 'Nuevo archivo…', promptNewPath: 'Ruta del nuevo archivo:', mSave: 'Guardar', mOpenFolder: 'Abrir carpeta…',
|
||||
mSearchCode: 'Buscar en el código', mGotoFile: 'Ir a archivo…', mGotoSym: 'Ir a símbolo…',
|
||||
mTerminal: 'Terminal', mArch: 'Arquitectura', mAbout: 'Acerca de Elffuss Code (GitHub)',
|
||||
gNotRepo: 'No es un repositorio git', gBranch: 'Rama: {b}', gLast: 'Último: {m}', gCommit: 'Pídele a Elffuss que commitee',
|
||||
nExec: '▶ Ejecutar', nView: 'Ver en la Mente',
|
||||
fallbackToast: 'Ese Gemma no cargó (memoria/GPU) — uso Elffuss LM (ligero)…',
|
||||
setStoreTitle: 'Modelo descargado (se cachea en tu navegador)', setClear: 'Vaciar caché', setClearing: 'Vaciando…',
|
||||
setCached: '{gb} en caché', setNone: 'Nada cacheado todavía',
|
||||
setBrToken: 'Token (lo imprime el programa al arrancarlo)', brTokenPh: 'pega aquí el token…',
|
||||
setFeedbackPh: 'Cuéntanos qué ha pasado o qué te gustaría que hiciera…',
|
||||
lblModel: 'Modelo', lblApiKey: 'API key',
|
||||
setCalc: 'Calculando espacio…', setPersist: '✓ almacenamiento persistente (no se borra solo)',
|
||||
setNoPersist: '⚠ sin persistencia: el navegador podría desalojarlo', setLimit: ' · límite ~{gb}',
|
||||
setModelPh: 'modelo (p. ej. gpt-4o-mini)', keyNeeded: 'clave (no necesaria)',
|
||||
wsTitle: "Espacio de trabajo (tu disco)",
|
||||
wsDesc: "Tus conversaciones y skills viven en este navegador, que puede borrarlas. Guárdalas en una carpeta y quedan como ficheros normales.",
|
||||
wsNoSupport: "Este navegador no permite elegir carpeta. Usa «Descargar copia».",
|
||||
wsNoFolder: "Sin carpeta: solo viven en este navegador.",
|
||||
wsFolder: "Carpeta: {name}",
|
||||
wsNeedsPerm: "«{name}» necesita permiso otra vez (pasa al recargar).",
|
||||
wsPick: "Elegir carpeta",
|
||||
wsUseProject: "Usar la del proyecto",
|
||||
wsRegrant: "Conceder permiso",
|
||||
wsSaveNow: "Guardar ahora",
|
||||
wsSaved: "Guardado ({n} almacenes)",
|
||||
wsAuto: "Guardar solo",
|
||||
wsForget: "Olvidar carpeta",
|
||||
wsDownload: "Descargar copia",
|
||||
wsWhat: "Qué hay guardado",
|
||||
setTitle: "Ajustes",
|
||||
close: "cerrar",
|
||||
setBrainTitle: "Cerebro (modelo)",
|
||||
setModelE4bSub: "El mejor · WebGPU local · ~4 GB",
|
||||
setModelE2bSub: "Ligero · WebGPU local · ~2 GB",
|
||||
setModelOnnxSub: "Modelo propio · 850 MB · tool-calls + apps",
|
||||
setModelRulesSub: "Órdenes directas, cero descarga",
|
||||
setBridgeTitle: "🔌 Bridge local (ejecución real en tu máquina)",
|
||||
setBridgeDisconnected: "desconectado",
|
||||
setBridgeFolderPh: "/ruta/completa/a/tu/proyecto",
|
||||
setBridgeConnect: "Conectar",
|
||||
setBridgeOnStatus: "✓ conectado — ejecución real activa",
|
||||
setBridgeNoToken: "⚠️ pega el token que imprimió el programa al arrancarlo",
|
||||
setBridgeConnecting: "Conectando…",
|
||||
setBridgeConnectedMsg: "✔ conectado — ejecución real en tu máquina",
|
||||
setPermTitle: "✅ Permisos de ejecución",
|
||||
setGoalTitle: "🎯 Modo Objetivo",
|
||||
setTelTitle: "📨 Errores y feedback",
|
||||
setTelEmpty: "escribe algo primero",
|
||||
setTelThanks: "¡enviado, gracias!",
|
||||
setProvTitle: "Proveedores externos (opcional · la clave se queda en tu navegador)",
|
||||
setProvUse: "Usar",
|
||||
setManageSkillsBtn: "Gestionar skills de Claude Code",
|
||||
skRemove: "Quitar",
|
||||
skBrowse: "Explorar",
|
||||
skAddRepoPh: "owner/repo o URL de GitHub (p. ej. OpenClaude/…)",
|
||||
skAddRepo: "Añadir repo",
|
||||
hwPlaceholder: "⛏ Hard Work: pregunta algo sobre TODO el proyecto…",
|
||||
hwNeedBrain: "Hard Work necesita un cerebro cargado: elige un modelo en Ajustes y reintenta.",
|
||||
hwNeedProject: "Hard Work trabaja sobre tu proyecto. Abre una carpeta primero y vuelve a pulsar ⛏ Hard Work.",
|
||||
hwReading: "Hard Work · leyendo el proyecto…",
|
||||
hwNoFiles: "No encontré archivos de texto/código legibles en el proyecto.",
|
||||
hwTrimmed: " — recortado",
|
||||
hwPhaseReduce: "Hard Work · sintetizando…",
|
||||
hwResultTrunc: " Proyecto recortado al máximo — acota con una subcarpeta.",
|
||||
planPlanning: "planificando…",
|
||||
planRunning: "ejecutando…",
|
||||
planDone: "completado",
|
||||
planFailed: "con fallos",
|
||||
stModelLoadingAI: "Cargando el modelo IA…",
|
||||
termCapsBridge: "🔌 Bridge local: node/npm/python reales",
|
||||
termCapsNoBridge: "shell del proyecto · node/npm/python → Bridge local (⚙ Ajustes)",
|
||||
setBridgeFolderLabel: "Carpeta de trabajo (opcional — si no, usa una temporal)",
|
||||
setBridgeDesc: "Un pequeño programa que TÚ ejecutas en tu ordenador — le da a la elfa ejecución real (node, npm, python…) sin salir de tu máquina. Nada se instala en el navegador.",
|
||||
setBridgeDownload: "⬇ Descargar para {os}",
|
||||
setBridgeOtherOS: "otro sistema operativo",
|
||||
setModelRulesName: "Básico (sin modelo)",
|
||||
},
|
||||
en: {
|
||||
ph: 'Type here… (@ attaches a file)',
|
||||
thinking: 'Elffuss is thinking', writing: 'Elffuss writes · {n} chars', using: 'Elffuss uses {name}',
|
||||
modelLoading: 'Downloading AI model · {pct}% · {loaded}/{total} MB',
|
||||
modelReady: 'AI model ready', modelReadyBadge: 'AI model ready ✳',
|
||||
welcome: '👋 Project “{name}” open. Ask me about the code, request changes, or say “tree”. Everything stays on your machine.',
|
||||
mindTitle: 'ELFFUSS MIND', avatarTitle: 'Elffuss Mind — autonomous brain',
|
||||
tagline: 'Open your code folder and program with an elf by your side.',
|
||||
tagline2: 'VS Code–style editor + agent <b>in your browser</b>. Nothing leaves your machine. 😉',
|
||||
openProject: '📁 Open code folder', resume: 'Resume', discard: 'Discard', cloneOpen: 'Download & open',
|
||||
clonePh: 'paste the URL of a public repo (GitHub/Bitbucket)…',
|
||||
searchPh: 'Search files by name (: to go to line, @ for symbol, > for commands)',
|
||||
tFiles: 'Explorer', tSearch: 'Search (Cmd/Ctrl+P)', tArch: 'Architecture (dependency graph)',
|
||||
tCity: '3D City', tTerm: 'Terminal (Ctrl+`)', tSkills: 'Skills', tActSettings: 'Settings & model',
|
||||
tGitBranch: 'current branch', tHistory: 'Conversation history', tTopSettings: 'Settings, model & API keys',
|
||||
tTermClose: 'close (Ctrl+`)', tTermResize: 'drag to resize',
|
||||
tCtx: 'context accumulated in the conversation', tPlus: 'Attach a project file (@)',
|
||||
tAutoedit: 'Edit files automatically',
|
||||
tHardwork: 'Hard Work: reads your whole project in chunks — audits, summarises or searches code that does not fit in the model',
|
||||
tGoal: 'Goal mode: breaks the message into tasks and runs them one by one (planner + executor)',
|
||||
tFlip: 'switch between chat and editor', tSlash: 'Commands',
|
||||
tabClose: 'Close tab (doesn’t delete the conversation)', newConv: 'New conversation',
|
||||
histEmpty: 'No saved conversations yet.', histDel: 'Delete forever',
|
||||
confirmDel: 'Delete “{title}” forever?', thisConv: 'this conversation',
|
||||
cmdsSep: 'Commands', slTree: 'project tree', slSearch: 'search the code',
|
||||
slSkills: 'manage skills', slModel: 'providers & model', slClear: 'new conversation',
|
||||
pTree: 'tree', pSearch: 'search ', pCommit: 'summarize the changes and prepare my commit message',
|
||||
pProposal: 'Implement this improvement proposal with real changes to the code:\n\n',
|
||||
attachSep: 'Attach to message (@)', attachOpen: 'open file', openProjFirst: 'Open a project first',
|
||||
autoTxt: 'Auto', reviewTxt: 'Review',
|
||||
autoOn: 'Edit files automatically (click to review first)',
|
||||
autoOff: 'Ask before writing (click for automatic)',
|
||||
pSearchCode: 'Search the code…', pManageSkills: 'Manage skills', pToggleExplorer: 'Toggle explorer',
|
||||
pToggleTerm: 'Toggle terminal', pSave: 'Save (Ctrl+S)', palCmd: 'command',
|
||||
palGotoSym: 'Go to symbol in editor…', palEmpty: 'No results',
|
||||
mNewFile: 'New file…', promptNewPath: 'New file path:', mSave: 'Save', mOpenFolder: 'Open folder…',
|
||||
mSearchCode: 'Search the code', mGotoFile: 'Go to file…', mGotoSym: 'Go to symbol…',
|
||||
mTerminal: 'Terminal', mArch: 'Architecture', mAbout: 'About Elffuss Code (GitHub)',
|
||||
gNotRepo: 'Not a git repository', gBranch: 'Branch: {b}', gLast: 'Latest: {m}', gCommit: 'Ask Elffuss to commit',
|
||||
nExec: '▶ Run', nView: 'View in the Mind',
|
||||
fallbackToast: 'That Gemma didn’t load (memory/GPU) — using Elffuss LM (light)…',
|
||||
setStoreTitle: 'Downloaded model (cached in your browser)', setClear: 'Clear cache', setClearing: 'Clearing…',
|
||||
setCached: '{gb} cached', setNone: 'Nothing cached yet',
|
||||
setBrToken: 'Token (printed by the program on startup)', brTokenPh: 'paste the token here…',
|
||||
setFeedbackPh: 'Tell us what happened or what you’d like it to do…',
|
||||
lblModel: 'Model', lblApiKey: 'API key',
|
||||
setCalc: 'Calculating space…', setPersist: '✓ persistent storage (won’t be auto-cleared)',
|
||||
setNoPersist: '⚠ not persistent: the browser could evict it', setLimit: ' · limit ~{gb}',
|
||||
setModelPh: 'model (e.g. gpt-4o-mini)', keyNeeded: 'key (not needed)',
|
||||
wsTitle: "Workspace (your disk)",
|
||||
wsDesc: "Your conversations and skills live in this browser, which can wipe them. Save them to a folder and they stay as normal files.",
|
||||
wsNoSupport: "This browser can't pick a folder. Use «Download copy».",
|
||||
wsNoFolder: "No folder: they only live in this browser.",
|
||||
wsFolder: "Folder: {name}",
|
||||
wsNeedsPerm: "«{name}» needs permission again (happens after a reload).",
|
||||
wsPick: "Pick a folder",
|
||||
wsUseProject: "Use the project's",
|
||||
wsRegrant: "Grant permission",
|
||||
wsSaveNow: "Save now",
|
||||
wsSaved: "Saved ({n} stores)",
|
||||
wsAuto: "Save automatically",
|
||||
wsForget: "Forget folder",
|
||||
wsDownload: "Download copy",
|
||||
wsWhat: "What's stored",
|
||||
setTitle: "Settings",
|
||||
close: "close",
|
||||
setBrainTitle: "Brain (model)",
|
||||
setModelE4bSub: "The best · local WebGPU · ~4 GB",
|
||||
setModelE2bSub: "Light · local WebGPU · ~2 GB",
|
||||
setModelOnnxSub: "Our own model · 850 MB · tool-calls + apps",
|
||||
setModelRulesSub: "Direct commands, zero download",
|
||||
setBridgeTitle: "🔌 Local bridge (real execution on your machine)",
|
||||
setBridgeDisconnected: "disconnected",
|
||||
setBridgeFolderPh: "/full/path/to/your/project",
|
||||
setBridgeConnect: "Connect",
|
||||
setBridgeOnStatus: "✓ connected — real execution active",
|
||||
setBridgeNoToken: "⚠️ paste the token the program printed on startup",
|
||||
setBridgeConnecting: "Connecting…",
|
||||
setBridgeConnectedMsg: "✔ connected — real execution on your machine",
|
||||
setPermTitle: "✅ Execution permissions",
|
||||
setGoalTitle: "🎯 Goal mode",
|
||||
setTelTitle: "📨 Errors and feedback",
|
||||
setTelEmpty: "type something first",
|
||||
setTelThanks: "sent, thanks!",
|
||||
setProvTitle: "External providers (optional · the key stays in your browser)",
|
||||
setProvUse: "Use",
|
||||
setManageSkillsBtn: "Manage Claude Code skills",
|
||||
skRemove: "Remove",
|
||||
skBrowse: "Browse",
|
||||
skAddRepoPh: "owner/repo or GitHub URL (e.g. OpenClaude/…)",
|
||||
skAddRepo: "Add repo",
|
||||
hwPlaceholder: "⛏ Hard Work: ask something about the WHOLE project…",
|
||||
hwNeedBrain: "Hard Work needs a loaded brain: pick a model in Settings and try again.",
|
||||
hwNeedProject: "Hard Work works on your project. Open a folder first, then press ⛏ Hard Work again.",
|
||||
hwReading: "Hard Work · reading the project…",
|
||||
hwNoFiles: "I found no readable text/code files in the project.",
|
||||
hwTrimmed: " — trimmed",
|
||||
hwPhaseReduce: "Hard Work · synthesizing…",
|
||||
hwResultTrunc: " Project trimmed to the max — narrow it down to a subfolder.",
|
||||
planPlanning: "planning…",
|
||||
planRunning: "running…",
|
||||
planDone: "completed",
|
||||
planFailed: "with failures",
|
||||
stModelLoadingAI: "Loading the AI model…",
|
||||
termCapsBridge: "🔌 Local bridge: real node/npm/python",
|
||||
termCapsNoBridge: "project shell · node/npm/python → Local bridge (⚙ Settings)",
|
||||
setBridgeFolderLabel: "Working folder (optional — otherwise a temporary one is used)",
|
||||
setBridgeDesc: "A small program that YOU run on your computer — it gives the elf real execution (node, npm, python…) without leaving your machine. Nothing is installed in the browser.",
|
||||
setBridgeDownload: "⬇ Download for {os}",
|
||||
setBridgeOtherOS: "another operating system",
|
||||
setModelRulesName: "Basic (no model)",
|
||||
},
|
||||
};
|
||||
|
||||
// Idioma del chrome: 2 letras, ES por defecto.
|
||||
export function uiLang() {
|
||||
return (navigator.language || 'es').slice(0, 2).toLowerCase();
|
||||
}
|
||||
|
||||
// Traduce una clave de runtime con sustitución {var}. Fallback: idioma →
|
||||
// inglés → la propia clave.
|
||||
export function t(key, vars) {
|
||||
const s = (L[uiLang()] || L.en)[key] ?? L.en[key] ?? key;
|
||||
return vars ? s.replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? '')) : s;
|
||||
}
|
||||
|
||||
// Textos estáticos del HTML (landing, placeholders, tooltips) que no pasan por
|
||||
// t() en runtime. Se cablean una vez al arrancar; ES es el HTML por defecto.
|
||||
export function applyI18n() {
|
||||
document.documentElement.lang = uiLang();
|
||||
const set = (sel, prop, key) => { const e = document.querySelector(sel); if (e) e[prop] = t(key); };
|
||||
set('#prompt', 'placeholder', 'ph');
|
||||
const tag = document.querySelector('#landing .tag');
|
||||
if (tag) tag.innerHTML = `${t('tagline')}<br>${t('tagline2')}`;
|
||||
set('#open-project', 'textContent', 'openProject');
|
||||
set('#resume-clone-btn', 'textContent', 'resume');
|
||||
set('#discard-clone-btn', 'textContent', 'discard');
|
||||
set('#clone-btn', 'textContent', 'cloneOpen');
|
||||
set('#clone-url', 'placeholder', 'clonePh');
|
||||
set('#pal-input', 'placeholder', 'searchPh');
|
||||
const titles = {
|
||||
'#act-files': 'tFiles', '#act-search': 'tSearch', '#act-arch': 'tArch', '#act-city': 'tCity',
|
||||
'#act-term': 'tTerm', '#act-skills': 'tSkills', '#act-settings': 'tActSettings',
|
||||
'#git-branch': 'tGitBranch', '#btn-history': 'tHistory', '#btn-settings': 'tTopSettings',
|
||||
'#term-close': 'tTermClose', '#term-resize': 'tTermResize', '#ctx-meter': 'tCtx',
|
||||
'#btn-plus': 'tPlus', '#btn-autoedit': 'tAutoedit', '#btn-goal': 'tGoal',
|
||||
'#btn-hardwork': 'tHardwork',
|
||||
'#code-flip': 'tFlip', '#btn-slash': 'tSlash',
|
||||
};
|
||||
for (const [sel, key] of Object.entries(titles)) set(sel, 'title', key);
|
||||
}
|
||||
67
web/js/icons.js
Normal file
67
web/js/icons.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// Iconos estilo VS Code, en SVG inline propio (sin dependencias).
|
||||
// Archivos: insignia con la abreviatura y el color canónico del lenguaje.
|
||||
const FILE_TYPES = {
|
||||
js: ['JS', '#f1dd3f'], mjs: ['JS', '#f1dd3f'], cjs: ['JS', '#f1dd3f'],
|
||||
ts: ['TS', '#3178c6'], tsx: ['TS', '#3178c6'], jsx: ['JS', '#61dafb'],
|
||||
py: ['PY', '#4b8bbe'], rs: ['RS', '#dea584'], go: ['GO', '#00add8'],
|
||||
java: ['J', '#e76f00'], c: ['C', '#659ad2'], h: ['H', '#659ad2'],
|
||||
cpp: ['C+', '#f34b7d'], cs: ['C#', '#178600'],
|
||||
json: ['{}', '#cbcb41'], yaml: ['Y', '#cb4b4b'], yml: ['Y', '#cb4b4b'],
|
||||
toml: ['T', '#9c4221'], xml: ['<>', '#e37933'],
|
||||
html: ['<>', '#e34c26'], css: ['#', '#9b7cf6'], scss: ['#', '#c6538c'],
|
||||
md: ['M', '#519aba'], txt: ['≡', '#8b93a8'], csv: ['⊞', '#89e051'],
|
||||
sh: ['$', '#89e051'], sql: ['DB', '#e38c00'],
|
||||
svg: ['SV', '#ffb13b'], png: ['IMG', '#a074c4'], jpg: ['IMG', '#a074c4'],
|
||||
gif: ['IMG', '#a074c4'], ico: ['IMG', '#a074c4'],
|
||||
lock: ['🔒', '#8b93a8'], gitignore: ['GIT', '#f05033'],
|
||||
};
|
||||
|
||||
export function fileIcon(name) {
|
||||
const base = name.toLowerCase();
|
||||
const ext = base.startsWith('.git') ? 'gitignore' : (base.split('.').pop() || '');
|
||||
const [label, color] = FILE_TYPES[ext] || ['·', '#6b7391'];
|
||||
const fontSize = label.length > 2 ? 5 : (label.length === 2 ? 6.5 : 8);
|
||||
return `<svg class="fico" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="3" fill="${color}1f" stroke="${color}" stroke-width="1"/>
|
||||
<text x="8" y="${label.length > 2 ? 10.4 : 10.8}" font-size="${fontSize}" text-anchor="middle"
|
||||
fill="${color}" font-family="system-ui" font-weight="700">${label}</text></svg>`;
|
||||
}
|
||||
|
||||
export function folderIcon() {
|
||||
return `<svg class="fico chev" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path class="chevron" d="M6 4.5 L10 8 L6 11.5" fill="none" stroke="#8b93a8" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg><svg class="fico" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M1.5 4 a1 1 0 0 1 1-1 h3.2 l1.6 1.8 h6.2 a1 1 0 0 1 1 1 v6.7 a1 1 0 0 1 -1 1 h-11 a1 1 0 0 1 -1-1 Z"
|
||||
fill="#dcb67a33" stroke="#dcb67a" stroke-width="1"/></svg>`;
|
||||
}
|
||||
|
||||
// Codicons de UI (estilo VS Code, sin emojis). Trazos con currentColor.
|
||||
const ico = (p, sw = 1.6) => `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
|
||||
export const UI = {
|
||||
clear: ico('<path d="M4 7h16"/><path d="M9 7V5h6v2"/><path d="M6 7l1 13h10l1-13"/><path d="M10 11v6M14 11v6"/>'),
|
||||
history: ico('<circle cx="12" cy="13" r="8"/><path d="M12 9v4l3 2"/><path d="M5 3.5l1.5 2.5M19 3.5l-1.5 2.5"/>'),
|
||||
gear: ico('<circle cx="12" cy="12" r="3"/><path d="M12 3v2.5M12 18.5V21M3 12h2.5M18.5 12H21M5.6 5.6l1.8 1.8M16.6 16.6l1.8 1.8M18.4 5.6l-1.8 1.8M7.4 16.6l-1.8 1.8"/>'),
|
||||
send: ico('<path d="M4 12l16-8-6 16-3.5-6.5L4 12z"/>'),
|
||||
add: ico('<path d="M12 5v14M5 12h14"/>', 1.8),
|
||||
slash: ico('<path d="M15 5L9 19"/>', 1.8),
|
||||
editor: ico('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16"/>'),
|
||||
chat: ico('<path d="M4 5h16v11H9l-5 4z"/>'),
|
||||
puzzle: ico('<path d="M10 4h4a1 1 0 0 1 1 1v2a1.5 1.5 0 1 0 3 0V6h2a1 1 0 0 1 1 1v4h-1a1.5 1.5 0 1 0 0 3h1v4a1 1 0 0 1-1 1h-4v-1a1.5 1.5 0 1 0-3 0v1H6a1 1 0 0 1-1-1v-4h1a1.5 1.5 0 1 0 0-3H5V7a1 1 0 0 1 1-1h4z"/>'),
|
||||
close: ico('<path d="M6 6l12 12M18 6L6 18"/>', 1.8),
|
||||
code: ico('<path d="M9 8l-5 4 5 4M15 8l5 4-5 4"/>'),
|
||||
graph: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="8" r="2.4"/><circle cx="12" cy="18" r="2.4"/><path d="M8 7l8 1M7 8l4 8M16 10l-3 6"/></svg>`,
|
||||
city: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 21h18"/><rect x="4" y="10" width="5" height="11"/><rect x="10" y="5" width="5" height="16"/><rect x="16" y="13" width="4" height="8"/></svg>`,
|
||||
terminal: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3M13 15h4"/></svg>`,
|
||||
};
|
||||
|
||||
// Codicons de la barra de actividad (trazos simples estilo VS Code).
|
||||
export const ACTIVITY = {
|
||||
files: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path d="M7 4h7l4 4v12H7z"/><path d="M14 4v4h4"/><path d="M4 8v13h10" opacity=".55"/></svg>`,
|
||||
search: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<circle cx="10.5" cy="10.5" r="5.5"/><path d="M15 15l5 5"/></svg>`,
|
||||
gear: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M12 3v2.5M12 18.5V21M3 12h2.5M18.5 12H21M5.6 5.6l1.8 1.8M16.6 16.6l1.8 1.8M18.4 5.6l-1.8 1.8M7.4 16.6l-1.8 1.8"/></svg>`,
|
||||
chat: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path d="M4 5h16v11H9l-5 4z"/></svg>`,
|
||||
};
|
||||
431
web/js/ide.js
Normal file
431
web/js/ide.js
Normal file
@@ -0,0 +1,431 @@
|
||||
// IDE: Monaco (el editor de VS Code) + árbol de archivos + pestañas.
|
||||
import * as code from './tools/code.js';
|
||||
import { fileIcon, folderIcon } from './icons.js';
|
||||
import { renderMarkdown } from './md.js';
|
||||
|
||||
let editor = null;
|
||||
let monacoRef = null;
|
||||
const tabs = []; // { path, model, dirty, preview }
|
||||
let active = null;
|
||||
let onDirtyChange = () => {};
|
||||
|
||||
const LANG = {
|
||||
js: 'javascript', mjs: 'javascript', ts: 'typescript', tsx: 'typescript',
|
||||
jsx: 'javascript', json: 'json', html: 'html', css: 'css', md: 'markdown',
|
||||
py: 'python', rs: 'rust', go: 'go', java: 'java', c: 'c', h: 'c',
|
||||
cpp: 'cpp', sh: 'shell', yml: 'yaml', yaml: 'yaml', toml: 'ini',
|
||||
svg: 'xml', xml: 'xml', sql: 'sql', txt: 'plaintext',
|
||||
};
|
||||
const langOf = p => LANG[p.split('.').pop().toLowerCase()] || 'plaintext';
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
export async function initEditor() {
|
||||
await new Promise(res => {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
|
||||
s.onload = res;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
window.require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
|
||||
await new Promise(res => window.require(['vs/editor/editor.main'], res));
|
||||
monacoRef = window.monaco;
|
||||
editor = monacoRef.editor.create($('editor'), {
|
||||
theme: 'vs-dark',
|
||||
automaticLayout: true,
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
padding: { top: 8 },
|
||||
});
|
||||
editor.addCommand(monacoRef.KeyMod.CtrlCmd | monacoRef.KeyCode.KeyS, saveActive);
|
||||
editor.onDidChangeModelContent(() => {
|
||||
const tab = tabs.find(t => t.path === active);
|
||||
if (tab && !tab.dirty) { tab.dirty = true; renderTabs(); }
|
||||
});
|
||||
// los cambios del agente se reflejan al instante en el editor
|
||||
code.setOnFileWritten((path, content) => {
|
||||
const tab = tabs.find(t => t.path === path);
|
||||
if (tab && tab.model.getValue() !== content) {
|
||||
tab.model.setValue(content);
|
||||
tab.dirty = false;
|
||||
renderTabs();
|
||||
if (path === active) applyView();
|
||||
}
|
||||
refreshTree();
|
||||
});
|
||||
}
|
||||
|
||||
export function gotoLine(n) {
|
||||
if (!editor || !n) return;
|
||||
editor.revealLineInCenter(n);
|
||||
editor.setPosition({ lineNumber: n, column: 1 });
|
||||
editor.focus();
|
||||
}
|
||||
export function triggerEditor(actionId) {
|
||||
editor?.getAction(actionId)?.run();
|
||||
editor?.focus();
|
||||
}
|
||||
export function hasEditor() { return !!editor; }
|
||||
|
||||
export async function openFile(path) {
|
||||
let tab = tabs.find(t => t.path === path);
|
||||
if (!tab) {
|
||||
let content;
|
||||
try { content = await code.read({ path }); }
|
||||
catch (e) {
|
||||
// la ruta exacta no existe (típico: un enlace del chat solo mencionaba
|
||||
// el NOMBRE, «config.py», y el fichero real vive en una subcarpeta) →
|
||||
// si hay una única coincidencia por nombre en el proyecto, ábrela ella
|
||||
// en vez de rendirte; con varias o ninguna, el error de siempre.
|
||||
const base = path.split('/').pop();
|
||||
const hits = await code.findByName?.(base, 2).catch(() => []) || [];
|
||||
if (hits.length === 1 && hits[0] !== path) return openFile(hits[0]);
|
||||
return alertBar('No pude abrir ' + path + ': ' + e.message);
|
||||
}
|
||||
tab = { path, model: monacoRef.editor.createModel(content, langOf(path)), dirty: false, preview: false };
|
||||
tabs.push(tab);
|
||||
}
|
||||
active = path;
|
||||
code.setCurrentFile(path);
|
||||
editor.setModel(tab.model);
|
||||
setEmptyState(false);
|
||||
renderTabs();
|
||||
await applyView();
|
||||
}
|
||||
|
||||
// modo «Vista previa» de un tab .md/.html: en vez del código fuente en
|
||||
// Monaco, muestra el markdown renderizado (mismo motor que el chat y la
|
||||
// Mente) o el HTML en un iframe real.
|
||||
const PREVIEWABLE = new Set(['markdown', 'html']);
|
||||
|
||||
// resuelve una ruta relativa (href/src) contra la carpeta del propio fichero
|
||||
function resolveRelative(ownPath, rel) {
|
||||
if (/^([a-z][\w+.-]*:)?\/\//i.test(rel) || rel.startsWith('data:') || rel.startsWith('#')) return null; // externa/absoluta: no tocar
|
||||
const baseDir = ownPath.includes('/') ? ownPath.slice(0, ownPath.lastIndexOf('/')) : '';
|
||||
const segs = (baseDir ? baseDir.split('/') : []).concat(rel.split('/'));
|
||||
const out = [];
|
||||
for (const s of segs) { if (!s || s === '.') continue; if (s === '..') out.pop(); else out.push(s); }
|
||||
return out.join('/');
|
||||
}
|
||||
// un iframe con srcdoc no tiene base URL propia: <link href="style.css"> o
|
||||
// <script src="app.js"> del proyecto real NUNCA se resuelven (por eso salía
|
||||
// sin CSS) — se leen del proyecto de verdad y se incrustan antes de mostrarlo.
|
||||
// El iframe de Vista previa va con sandbox="allow-scripts" A PROPÓSITO, SIN
|
||||
// allow-same-origin (aislado de tu proyecto real — ver applyView). Pero eso
|
||||
// hace que referenciar `localStorage`/`sessionStorage` LANCE una excepción
|
||||
// síncrona — y como suele ser la primera línea de un script (guardar datos
|
||||
// es de lo más común), el resto del fichero entero se queda sin ejecutar,
|
||||
// sin aviso: los botones no hacen nada y parece que la app está rota. Se
|
||||
// incrusta un shim en memoria (nunca toca almacenamiento real, se pierde al
|
||||
// cerrar la vista previa) SOLO si de verdad hace falta — si el sandbox
|
||||
// permitiera acceso real, no se toca nada.
|
||||
const STORAGE_SHIM = `<script>(()=>{try{window.localStorage;return}catch(e){}` +
|
||||
`const m=()=>{const d=new Map();return{getItem:k=>d.has(''+k)?d.get(''+k):null,` +
|
||||
`setItem:(k,v)=>{d.set(''+k,''+v)},removeItem:k=>{d.delete(''+k)},clear:()=>{d.clear()},` +
|
||||
`key:i=>[...d.keys()][i]??null,get length(){return d.size}}};` +
|
||||
`for(const p of ['localStorage','sessionStorage'])` +
|
||||
`try{Object.defineProperty(window,p,{value:m(),configurable:true})}catch(e){}})()<\/script>`;
|
||||
|
||||
async function inlineLocalAssets(html, ownPath) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
(doc.head || doc.documentElement).insertAdjacentHTML('afterbegin', STORAGE_SHIM);
|
||||
for (const link of doc.querySelectorAll('link[rel~="stylesheet"][href]')) {
|
||||
const path = resolveRelative(ownPath, link.getAttribute('href'));
|
||||
if (!path) continue;
|
||||
try {
|
||||
const style = doc.createElement('style');
|
||||
style.textContent = await code.read({ path });
|
||||
link.replaceWith(style);
|
||||
} catch { /* no se pudo leer: se deja el link (el navegador lo ignorará sin más) */ }
|
||||
}
|
||||
for (const script of doc.querySelectorAll('script[src]')) {
|
||||
const path = resolveRelative(ownPath, script.getAttribute('src'));
|
||||
if (!path) continue;
|
||||
try {
|
||||
const inline = doc.createElement('script');
|
||||
if (script.type) inline.type = script.type;
|
||||
inline.textContent = await code.read({ path });
|
||||
script.replaceWith(inline);
|
||||
} catch { /* deja el script tal cual */ }
|
||||
}
|
||||
return '<!doctype html>\n' + doc.documentElement.outerHTML;
|
||||
}
|
||||
|
||||
async function applyView() {
|
||||
const tab = tabs.find(t => t.path === active);
|
||||
const lang = tab ? langOf(tab.path) : null;
|
||||
const showPreview = !!(tab && tab.preview && PREVIEWABLE.has(lang));
|
||||
$('editor').style.display = showPreview ? 'none' : '';
|
||||
let pane = document.getElementById('md-preview-pane');
|
||||
if (showPreview) {
|
||||
if (!pane) {
|
||||
pane = document.createElement('div');
|
||||
pane.id = 'md-preview-pane';
|
||||
$('editor').parentElement.appendChild(pane);
|
||||
}
|
||||
pane.classList.toggle('html-preview', lang === 'html');
|
||||
if (lang === 'html') {
|
||||
pane.replaceChildren();
|
||||
const frame = document.createElement('iframe');
|
||||
frame.sandbox = 'allow-scripts'; // sin allow-same-origin: aislado de tu proyecto real
|
||||
frame.srcdoc = await inlineLocalAssets(tab.model.getValue(), tab.path);
|
||||
pane.appendChild(frame);
|
||||
} else {
|
||||
pane.innerHTML = renderMarkdown(tab.model.getValue());
|
||||
}
|
||||
pane.style.display = '';
|
||||
} else if (pane) {
|
||||
pane.style.display = 'none';
|
||||
}
|
||||
}
|
||||
async function togglePreview(path) {
|
||||
// el fichero puede no tener pestaña abierta todavía (p.ej. desde el menú
|
||||
// del EXPLORADOR, no el de una pestaña ya abierta) — se abre primero.
|
||||
if (active !== path || !tabs.find(t => t.path === path)) await openFile(path);
|
||||
const tab = tabs.find(t => t.path === path);
|
||||
if (!tab) return; // openFile no pudo (fichero borrado, etc.)
|
||||
tab.preview = !tab.preview;
|
||||
renderTabs();
|
||||
await applyView();
|
||||
}
|
||||
|
||||
async function saveActive() {
|
||||
const tab = tabs.find(t => t.path === active);
|
||||
if (!tab) return;
|
||||
await code.write({ path: tab.path, content: tab.model.getValue() });
|
||||
tab.dirty = false;
|
||||
renderTabs();
|
||||
alertBar('💾 ' + tab.path + ' guardado');
|
||||
}
|
||||
|
||||
async function closeTab(path) {
|
||||
const i = tabs.findIndex(t => t.path === path);
|
||||
if (i < 0) return;
|
||||
tabs[i].model.dispose();
|
||||
tabs.splice(i, 1);
|
||||
if (active === path) {
|
||||
active = tabs[i - 1]?.path || tabs[0]?.path || null;
|
||||
code.setCurrentFile(active);
|
||||
if (active) { editor.setModel(tabs.find(t => t.path === active).model); setEmptyState(false); }
|
||||
else { editor.setModel(monacoRef.editor.createModel('', 'plaintext')); setEmptyState(true); }
|
||||
}
|
||||
renderTabs();
|
||||
await applyView();
|
||||
}
|
||||
|
||||
// Estado vacío: cuando no hay ningún fichero abierto, en vez de dejar el Monaco
|
||||
// en blanco con un «1», mostramos un placeholder limpio.
|
||||
export function setEmptyState(on) {
|
||||
let el = document.getElementById('editor-empty');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'editor-empty';
|
||||
el.innerHTML = '<img src="img/elffuss-code.svg" alt=""><p>Abre un fichero del explorador,<br>o pídele algo a la elfa.</p>';
|
||||
$('editor').parentElement.appendChild(el);
|
||||
}
|
||||
el.style.display = on ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
const bar = $('tabs-bar');
|
||||
bar.replaceChildren();
|
||||
for (const t of tabs) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'tab' + (t.path === active ? ' active' : '') + (t.preview ? ' tab-preview' : '');
|
||||
el.dataset.path = t.path;
|
||||
const ico = document.createElement('span');
|
||||
ico.className = 'tab-ico';
|
||||
ico.innerHTML = fileIcon(t.path.split('/').pop());
|
||||
const name = document.createElement('span');
|
||||
name.className = 'tab-name';
|
||||
name.textContent = t.path.split('/').pop();
|
||||
name.title = t.path;
|
||||
// como VS Code: punto ● si hay cambios sin guardar; × al pasar el ratón
|
||||
const x = document.createElement('b');
|
||||
x.className = t.dirty ? 'dirty' : '';
|
||||
x.textContent = t.dirty ? '●' : '×';
|
||||
x.onclick = e => { e.stopPropagation(); closeTab(t.path); };
|
||||
el.append(ico, name, x);
|
||||
el.onclick = () => openFile(t.path);
|
||||
el.oncontextmenu = e => { e.preventDefault(); openTabMenu(e.clientX, e.clientY, t.path); };
|
||||
bar.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- menú contextual de las pestañas (botón derecho, estilo VS Code) ----------
|
||||
function openTabMenu(x, y, path) {
|
||||
document.getElementById('tab-menu')?.remove();
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'tab-menu';
|
||||
menu.style.cssText = `position:fixed;left:${x}px;top:${y}px;z-index:200`;
|
||||
const i = tabs.findIndex(t => t.path === path);
|
||||
const tab = tabs[i];
|
||||
const items = [];
|
||||
items.push(['Cerrar', () => closeTab(path)]);
|
||||
items.push(['Cerrar otras', () => { for (const t of tabs.filter(t => t.path !== path)) closeTab(t.path); }]);
|
||||
const toTheRight = tabs.slice(i + 1).map(t => t.path);
|
||||
if (toTheRight.length) items.push(['Cerrar las de la derecha', () => { for (const p of toTheRight) closeTab(p); }]);
|
||||
items.push(['Cerrar todas', () => { for (const t of [...tabs]) closeTab(t.path); }]);
|
||||
items.push(['sep']);
|
||||
items.push(['Copiar ruta', () => navigator.clipboard?.writeText(path).catch(() => {})]);
|
||||
if (PREVIEWABLE.has(langOf(path))) {
|
||||
items.push([tab?.preview ? 'Ver código fuente' : 'Vista previa', () => togglePreview(path)]);
|
||||
}
|
||||
for (const item of items) {
|
||||
if (item[0] === 'sep') { menu.appendChild(Object.assign(document.createElement('div'), { className: 'tm-sep' })); continue; }
|
||||
const [label, fn] = item;
|
||||
const b = document.createElement('button');
|
||||
b.className = 'tm-item';
|
||||
b.textContent = label;
|
||||
b.onclick = () => { menu.remove(); fn(); };
|
||||
menu.appendChild(b);
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
const close = ev => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
||||
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
||||
}
|
||||
|
||||
let alertTimer;
|
||||
function alertBar(msg) {
|
||||
const el = $('statusbar');
|
||||
el.textContent = msg;
|
||||
clearTimeout(alertTimer);
|
||||
alertTimer = setTimeout(() => { el.textContent = ''; }, 4000);
|
||||
}
|
||||
|
||||
// ---------- árbol lateral ----------
|
||||
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
||||
|
||||
export async function refreshTree(handle = null) {
|
||||
const rootHandle = handle || window.__elffussProject;
|
||||
if (!rootHandle) return;
|
||||
window.__elffussProject = rootHandle;
|
||||
const cont = $('tree');
|
||||
cont.replaceChildren(await renderDir(rootHandle, ''));
|
||||
setupTreeMenu();
|
||||
}
|
||||
|
||||
// ---------- menú contextual del árbol (botón derecho, estilo VS Code) ----------
|
||||
let treeMenuWired = false;
|
||||
function setupTreeMenu() {
|
||||
if (treeMenuWired) return;
|
||||
treeMenuWired = true;
|
||||
const tree = $('tree');
|
||||
tree.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
const node = e.target.closest('[data-path]');
|
||||
const path = node ? node.dataset.path : '';
|
||||
const kind = node ? node.dataset.kind : 'directory'; // vacío = raíz
|
||||
openTreeMenu(e.clientX, e.clientY, path, kind);
|
||||
});
|
||||
}
|
||||
|
||||
// resuelve el handle de un directorio por su ruta (segmentos desde la raíz)
|
||||
async function dirByPath(path) {
|
||||
let dir = code.handle();
|
||||
for (const s of (path || '').split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(s);
|
||||
return dir;
|
||||
}
|
||||
const parentOf = p => p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '';
|
||||
const baseOf = p => p.split('/').pop();
|
||||
|
||||
async function fsCreate(parentPath, name, isDir) {
|
||||
const dir = await dirByPath(parentPath);
|
||||
if (isDir) await dir.getDirectoryHandle(name, { create: true });
|
||||
else { const fh = await dir.getFileHandle(name, { create: true }); const w = await fh.createWritable(); await w.close(); }
|
||||
}
|
||||
async function fsDelete(path, kind) {
|
||||
const dir = await dirByPath(parentOf(path));
|
||||
await dir.removeEntry(baseOf(path), { recursive: kind === 'directory' });
|
||||
}
|
||||
// copia recursiva (la File System API no tiene rename nativo)
|
||||
async function fsCopy(srcDir, name, dstDir, newName) {
|
||||
const src = await (srcDir.getDirectoryHandle(name).catch(() => null));
|
||||
if (src) { // directorio
|
||||
const nd = await dstDir.getDirectoryHandle(newName, { create: true });
|
||||
for await (const e of src.values()) await fsCopy(src, e.name, nd, e.name);
|
||||
} else { // fichero
|
||||
const text = await (await (await srcDir.getFileHandle(name)).getFile()).text();
|
||||
const fh = await dstDir.getFileHandle(newName, { create: true });
|
||||
const w = await fh.createWritable(); await w.write(text); await w.close();
|
||||
}
|
||||
}
|
||||
async function fsRename(path, kind, newName) {
|
||||
const parent = await dirByPath(parentOf(path));
|
||||
await fsCopy(parent, baseOf(path), parent, newName);
|
||||
await parent.removeEntry(baseOf(path), { recursive: kind === 'directory' });
|
||||
}
|
||||
|
||||
function openTreeMenu(x, y, path, kind) {
|
||||
document.getElementById('tree-menu')?.remove();
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'tree-menu';
|
||||
menu.style.cssText = `position:fixed;left:${x}px;top:${y}px;z-index:200`;
|
||||
// en un fichero, el "aquí dentro" es su carpeta; en carpeta/raíz, ella misma
|
||||
const container = kind === 'file' ? parentOf(path) : path;
|
||||
const items = [];
|
||||
items.push(['Nuevo archivo…', async () => {
|
||||
const name = prompt('Nombre del nuevo archivo:'); if (!name) return;
|
||||
await fsCreate(container, name.trim(), false); code.invalidateFileList?.();
|
||||
await refreshTree(); openFile(container ? container + '/' + name.trim() : name.trim());
|
||||
}]);
|
||||
items.push(['Nueva carpeta…', async () => {
|
||||
const name = prompt('Nombre de la nueva carpeta:'); if (!name) return;
|
||||
await fsCreate(container, name.trim(), true); code.invalidateFileList?.(); await refreshTree();
|
||||
}]);
|
||||
if (path) {
|
||||
if (kind === 'file') items.push(['Abrir', () => openFile(path)]);
|
||||
if (kind === 'file' && PREVIEWABLE.has(langOf(path))) items.push(['Vista previa', () => togglePreview(path)]);
|
||||
items.push(['Renombrar…', async () => {
|
||||
const name = prompt('Nuevo nombre:', baseOf(path)); if (!name || name === baseOf(path)) return;
|
||||
await fsRename(path, kind, name.trim()); code.invalidateFileList?.(); await refreshTree();
|
||||
}]);
|
||||
items.push(['Eliminar', async () => {
|
||||
if (!confirm(`¿Eliminar «${baseOf(path)}»?`)) return;
|
||||
await fsDelete(path, kind); code.invalidateFileList?.(); await refreshTree();
|
||||
}, 'danger']);
|
||||
}
|
||||
for (const [label, fn, cls] of items) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'tm-item' + (cls ? ' ' + cls : '');
|
||||
b.textContent = label;
|
||||
b.onclick = async () => { menu.remove(); try { await fn(); } catch (err) { alert('No se pudo: ' + err.message); } };
|
||||
menu.appendChild(b);
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
const close = ev => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
||||
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
||||
}
|
||||
|
||||
async function renderDir(dir, prefix) {
|
||||
const ul = document.createElement('ul');
|
||||
const entries = [];
|
||||
for await (const e of dir.values()) if (!IGNORE.has(e.name)) entries.push(e);
|
||||
entries.sort((a, b) => (a.kind !== b.kind) ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name));
|
||||
for (const e of entries.slice(0, 200)) {
|
||||
const li = document.createElement('li');
|
||||
const p = prefix ? prefix + '/' + e.name : e.name;
|
||||
if (e.kind === 'directory') {
|
||||
const det = document.createElement('details');
|
||||
const sum = document.createElement('summary');
|
||||
sum.innerHTML = folderIcon();
|
||||
sum.appendChild(document.createTextNode(e.name));
|
||||
sum.dataset.path = p; sum.dataset.kind = 'directory';
|
||||
det.appendChild(sum);
|
||||
det.addEventListener('toggle', async () => {
|
||||
if (det.open && det.children.length === 1)
|
||||
det.appendChild(await renderDir(e, p));
|
||||
}, { once: false });
|
||||
li.appendChild(det);
|
||||
} else {
|
||||
const a = document.createElement('span');
|
||||
a.className = 'file';
|
||||
a.innerHTML = fileIcon(e.name);
|
||||
a.appendChild(document.createTextNode(e.name));
|
||||
a.dataset.path = p; a.dataset.kind = 'file';
|
||||
a.onclick = () => openFile(p);
|
||||
li.appendChild(a);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
574
web/js/lab.js
Normal file
574
web/js/lab.js
Normal file
@@ -0,0 +1,574 @@
|
||||
// Elffuss Lab — banco de pruebas del propio producto.
|
||||
//
|
||||
// Tres cosas, en la misma mesa:
|
||||
// 1) ELEGIR algoritmo de generación (directo / RLM map-reduce / RLM con
|
||||
// crítica) y ENCENDER o APAGAR cada perilla del compresor de contexto.
|
||||
// 2) LANZAR benchmarks reales y ver el progreso caso a caso, en vivo.
|
||||
// 3) GUARDAR la corrida con su TRAZA en un historial y un ranking LOCALES:
|
||||
// todo se queda en esta máquina, no se envía nada a ningún sitio.
|
||||
//
|
||||
// Nada de esto simula: el banco de contexto llama al `packHistoryACER` de
|
||||
// verdad, el de SWE ejecuta los tests de verdad contra el módulo arreglado, y
|
||||
// el de juegos ejecuta el juego generado y lo puntúa mirándolo correr.
|
||||
import { DEFAULTS, packHistoryACER, estimateTokens } from './acer-core.js';
|
||||
import { TASKS } from './swe-tasks.js';
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
const el = (t, c, x) => { const n = document.createElement(t); if (c) n.className = c; if (x != null) n.textContent = x; return n; };
|
||||
|
||||
// ─────────────────────────── estado ───────────────────────────
|
||||
const S = {
|
||||
brain: localStorage.getItem('elffusscode.model') || 'rules',
|
||||
algo: 'directo',
|
||||
algoOpts: { rounds: 1, chunkTokens: 1800, maxChunks: 24 },
|
||||
ctx: {}, // overrides sobre DEFAULTS
|
||||
bench: 'contexto',
|
||||
provider: null,
|
||||
running: false, abort: false,
|
||||
run: null, // { bench, casos:[], métricas:{} } — la traza
|
||||
};
|
||||
|
||||
// Perillas del compresor que tiene sentido tocar a mano (las demás quedan en su
|
||||
// valor por defecto). El «porqué» sale del propio acer-core.
|
||||
const CTX_KNOBS = [
|
||||
['recuperación', [
|
||||
['SEMANTIC', 'bool', 'fusiona embeddings con BM25 — INERTE en el banco de contexto (ese usa el packer sin embeddings)'],
|
||||
['MMR', 'bool', 'diversifica lo recuperado (evita repetir lo mismo)'],
|
||||
['DEDUP', 'bool', 'quita mensajes casi idénticos'],
|
||||
['PIN_QUERY_TERMS', 'bool', 'clava los términos de la pregunta'],
|
||||
['SUPERSEDE', 'bool', 'lo nuevo pisa a lo viejo cuando se contradicen'],
|
||||
['RECENCY_WEIGHT', 'num', 'peso de lo reciente al puntuar'],
|
||||
]],
|
||||
['presupuesto', [
|
||||
['ELASTIC', 'bool', 'estira/encoge según la presión real'],
|
||||
['RECENT', 'num', 'nº de turnos recientes intocables'],
|
||||
['TAIL_MIN_FRAC', 'num', 'suelo garantizado para la cola'],
|
||||
['HEAD_FRAC', 'num', 'reserva para el arranque (0 = apagada)'],
|
||||
['MAX_MSG_CHARS', 'num', 'recorte por mensaje'],
|
||||
]],
|
||||
['extras', [
|
||||
['DATES', 'bool', 'anota «ayer» con la fecha del turno'],
|
||||
['SUMMARY', 'bool', 'tarjeta de recuento (cuesta presupuesto)'],
|
||||
['AUTO', 'bool', 'deriva las perillas de lo medido en ESTE historial'],
|
||||
]],
|
||||
];
|
||||
|
||||
const ALGOS = [
|
||||
['directo', 'Directo', 'Una sola inferencia. El baremo contra el que se compara todo.'],
|
||||
['rlm-deep', 'RLM · crear y criticar', 'Borrador → crítica → reescritura, N rondas (deepCreate).'],
|
||||
['rlm-hard', 'RLM · Hard Work', 'Trocea el material, pregunta a cada trozo y funde (map-reduce).'],
|
||||
];
|
||||
|
||||
const BENCHES = [
|
||||
['contexto', 'Contexto (ACER)', 'Determinista y sin GPU: mide qué evidencia SOBREVIVE al compresor y a qué precio en tokens. Reacciona a las perillas al instante.'],
|
||||
['swe', 'SWE-bench-style', `${TASKS.length} repos con un bug real: el agente lo arregla con sus tools y se EJECUTA el test. Métrica: resolved/N.`],
|
||||
['juegos', 'Juegos (generar y jugar)', 'Genera un juego con el algoritmo elegido, lo ejecuta y lo puntúa: carga sin errores, arranca, se mueve y aguanta jugando.'],
|
||||
];
|
||||
|
||||
const BRAINS = [
|
||||
['rules', 'Reglas (sin GPU)', 'Determinista. Valida el arnés sin bajar pesos.'],
|
||||
['onnx', 'Elffuss LM (CPU/wasm)', 'LFM2.5-1.2B por transformers.js.'],
|
||||
['litert:gemma-e2b', 'Gemma-4 E2B (~2 GB)', 'WebGPU.'],
|
||||
['litert:gemma-e4b', 'Gemma-4 E4B (~3 GB)', 'WebGPU. El mejor.'],
|
||||
];
|
||||
|
||||
// ─────────────────────────── consola ───────────────────────────
|
||||
function log(msg, cls = '') {
|
||||
const t = new Date().toTimeString().slice(0, 8);
|
||||
const line = el('div');
|
||||
line.appendChild(el('span', 't', t + ' '));
|
||||
line.appendChild(el('span', cls, msg));
|
||||
$('#log').appendChild(line);
|
||||
$('#log').scrollTop = $('#log').scrollHeight;
|
||||
}
|
||||
const setLed = (id, cls, txt) => { const n = $(id); n.className = 'led ' + cls; n.querySelector('span').textContent = txt; };
|
||||
const prog = (done, total, txt) => {
|
||||
$('#prog').style.width = total ? Math.round(done / total * 100) + '%' : '0';
|
||||
$('#prog-txt').textContent = txt || (total ? `${done}/${total}` : '—');
|
||||
};
|
||||
|
||||
function tiles(obj) {
|
||||
const box = $('#tiles'); box.innerHTML = '';
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const t = el('div', 'tile' + (v && v.bad ? ' bad' : v && v.warn ? ' warn' : ''));
|
||||
t.appendChild(el('div', 'k', k));
|
||||
const val = (v && typeof v === 'object') ? v.v : v;
|
||||
t.appendChild(el('div', 'v' + (String(val).length > 7 ? ' small' : ''), String(val)));
|
||||
box.appendChild(t);
|
||||
}
|
||||
}
|
||||
function table(cols, rows) {
|
||||
$('#res-head').innerHTML = '<tr>' + cols.map(c => `<th>${c}</th>`).join('') + '</tr>';
|
||||
$('#res-body').innerHTML = '';
|
||||
rows.forEach(addRow);
|
||||
}
|
||||
function addRow(cells) {
|
||||
const tr = el('tr');
|
||||
cells.forEach(c => {
|
||||
const td = el('td');
|
||||
if (c && typeof c === 'object') { td.className = c.cls || ''; td.textContent = c.t; }
|
||||
else td.textContent = c;
|
||||
tr.appendChild(td);
|
||||
});
|
||||
$('#res-body').appendChild(tr);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// ─────────────────────────── interfaz ───────────────────────────
|
||||
function radio(host, items, current, onPick) {
|
||||
host.innerHTML = '';
|
||||
items.forEach(([id, title, desc]) => {
|
||||
const l = el('label', id === current ? 'sel' : '');
|
||||
const i = el('input'); i.type = 'radio'; i.name = host.id; i.checked = id === current;
|
||||
i.onchange = () => { onPick(id); radio(host, items, id, onPick); };
|
||||
const d = el('div');
|
||||
d.appendChild(el('div', 't', title));
|
||||
d.appendChild(el('div', 'd', desc));
|
||||
l.append(i, d); host.appendChild(l);
|
||||
});
|
||||
}
|
||||
|
||||
function ctxUI() {
|
||||
const host = $('#ctx-knobs'); host.innerHTML = '';
|
||||
for (const [group, knobs] of CTX_KNOBS) {
|
||||
host.appendChild(el('div', 'group-title', group));
|
||||
const box = el('div', 'knobs');
|
||||
for (const [key, type, why] of knobs) {
|
||||
const row = el('div', 'knob');
|
||||
const cur = key in S.ctx ? S.ctx[key] : DEFAULTS[key];
|
||||
const inp = el('input');
|
||||
if (type === 'bool') { inp.type = 'checkbox'; inp.checked = !!cur; }
|
||||
else { inp.type = 'number'; inp.step = 'any'; inp.value = cur ?? 0; }
|
||||
inp.onchange = () => {
|
||||
S.ctx[key] = type === 'bool' ? inp.checked : Number(inp.value);
|
||||
ctxTag();
|
||||
if (S.bench === 'contexto') log('perilla ' + key + ' → ' + S.ctx[key] + ' (relanza para medir)', 'w');
|
||||
};
|
||||
row.append(inp, el('code', '', key), el('div', 'why', why));
|
||||
box.appendChild(row);
|
||||
}
|
||||
host.appendChild(box);
|
||||
}
|
||||
ctxTag();
|
||||
}
|
||||
function ctxTag() {
|
||||
const on = CTX_KNOBS.flatMap(([, k]) => k).filter(([key, t]) => t === 'bool' && (key in S.ctx ? S.ctx[key] : DEFAULTS[key])).length;
|
||||
$('#ctx-tag').textContent = on + ' activas';
|
||||
}
|
||||
function algoKnobs() {
|
||||
const host = $('#algo-knobs'); host.innerHTML = '';
|
||||
const defs = S.algo === 'rlm-hard'
|
||||
? [['chunkTokens', 'tamaño de cada trozo'], ['maxChunks', 'techo de sub-llamadas']]
|
||||
: S.algo === 'rlm-deep' ? [['rounds', 'rondas de crítica + reescritura']] : [];
|
||||
if (!defs.length) { host.appendChild(el('div', 'hint', 'El modo directo no tiene parámetros: una inferencia y ya.')); return; }
|
||||
const box = el('div', 'knobs');
|
||||
defs.forEach(([k, why]) => {
|
||||
const row = el('div', 'knob');
|
||||
const i = el('input'); i.type = 'number'; i.value = S.algoOpts[k]; i.min = 0;
|
||||
i.onchange = () => { S.algoOpts[k] = Number(i.value); };
|
||||
row.append(i, el('code', '', k), el('div', 'why', why));
|
||||
box.appendChild(row);
|
||||
});
|
||||
host.appendChild(box);
|
||||
}
|
||||
|
||||
// ─────────────────────────── cerebro ───────────────────────────
|
||||
async function loadBrain() {
|
||||
const id = S.brain;
|
||||
$('#btn-load').disabled = true;
|
||||
setLed('#led-model', 'busy', 'cargando ' + id + '…');
|
||||
try {
|
||||
let mod;
|
||||
if (id === 'rules') mod = await import('./providers/rules.js');
|
||||
else if (id === 'onnx') mod = await import('./providers/onnx.js');
|
||||
else if (id.startsWith('litert:')) {
|
||||
mod = await import('./providers/litert.js');
|
||||
mod.configure(id.split(':')[1]);
|
||||
}
|
||||
if (mod.load) await mod.load(s => { $('#load-state').textContent = String(s).slice(0, 60); });
|
||||
S.provider = mod;
|
||||
setLed('#led-model', 'on', 'modelo: ' + id);
|
||||
$('#load-state').textContent = '';
|
||||
log('cerebro listo: ' + id, 'a');
|
||||
} catch (e) {
|
||||
setLed('#led-model', 'err', 'fallo al cargar');
|
||||
log('no se pudo cargar el cerebro: ' + e.message, 'e');
|
||||
}
|
||||
$('#btn-load').disabled = false;
|
||||
}
|
||||
|
||||
// ═══════════════ BANCO 1 · contexto (determinista, sin GPU) ═══════════════
|
||||
// Construye un historial largo con HECHOS plantados y ruido alrededor, lo pasa
|
||||
// por el compresor con las perillas elegidas y mide qué evidencia sobrevive y
|
||||
// cuántos tokens cuesta. Sin modelo: mide el COMPRESOR, no al que responde.
|
||||
// Cada caso trae: el dato BUENO, una versión CADUCADA del mismo dato (dicha
|
||||
// antes) y distractores que comparten vocabulario con la pregunta. Así el banco
|
||||
// no premia «meter mucho», sino traer lo correcto y dejar fuera lo viejo — que
|
||||
// es lo que de verdad hacen SUPERSEDE, PIN_QUERY_TERMS o DEDUP.
|
||||
const HECHOS = [
|
||||
{ bueno: 'Ahora el servidor de pagos escucha en el puerto 7421.', viejo: 'El servidor de pagos escuchaba en el puerto 3000.',
|
||||
q: '¿en qué puerto escucha el servidor de pagos?', clave: '7421', claveVieja: '3000',
|
||||
ruido: ['El servidor de correo va por otro puerto distinto.', 'Ese puerto del router no tiene nada que ver con pagos.'] },
|
||||
{ bueno: 'La clave de despliegue caduca el 3 de marzo.', viejo: 'La clave de despliegue caducaba el 9 de enero.',
|
||||
q: '¿cuándo caduca la clave de despliegue?', clave: '3 de marzo', claveVieja: '9 de enero',
|
||||
ruido: ['La clave del wifi la cambiaron en verano.', 'El despliegue de ayer no tocó ninguna clave.'] },
|
||||
{ bueno: 'Nordvik factura ahora en coronas.', viejo: 'Nordvik facturaba en euros.',
|
||||
q: '¿en qué moneda factura Nordvik?', clave: 'coronas', claveVieja: 'facturaba en euros',
|
||||
ruido: ['Nordvik cambió de comercial el trimestre pasado.', 'A otros clientes se les factura en euros.'] },
|
||||
{ bueno: 'El informe trimestral lo firma Beatriz.', viejo: 'El informe trimestral lo firmaba Andrés.',
|
||||
q: '¿quién firma el informe trimestral?', clave: 'firma Beatriz', claveVieja: 'firmaba Andrés',
|
||||
ruido: ['Andrés sigue en la empresa, pero en otro equipo.', 'Beatriz también revisa el informe mensual.'] },
|
||||
{ bueno: 'El backup nocturno corre a las 02:30.', viejo: 'El backup nocturno corría a las 23:00.',
|
||||
q: '¿a qué hora corre el backup nocturno?', clave: '02:30', claveVieja: '23:00',
|
||||
ruido: ['El backup semanal es otra tarea distinta.', 'A las 23:00 lo que hay es el corte de logs.'] },
|
||||
{ bueno: 'El almacén de Lugo pasó a Vigo.', viejo: 'El almacén principal estaba en Lugo.',
|
||||
q: '¿a dónde se movió el almacén de Lugo?', clave: 'pasó a Vigo', claveVieja: 'estaba en Lugo',
|
||||
ruido: ['En Lugo queda solo una oficina comercial.', 'El almacén de Vigo ya existía para otra cosa.'] },
|
||||
];
|
||||
const RUIDO = [
|
||||
'Vale, seguimos mañana con eso.', 'Te paso el enlace en un momento.',
|
||||
'Creo que el diseño nuevo se ve mejor.', 'Nos vemos en la reunión de las cinco.',
|
||||
'Ese ticket lo cerró soporte la semana pasada.', 'Prefiero no tocar eso hoy.',
|
||||
'Lo dejo apuntado para el lunes.', 'Sí, ya lo hemos hablado antes.',
|
||||
];
|
||||
|
||||
// Historial LARGO: el dato bueno queda enterrado y fuera de la ventana reciente.
|
||||
function historialCon(c, largo = 420) {
|
||||
const h = [];
|
||||
const posViejo = Math.floor(largo * 0.10);
|
||||
const posBueno = Math.floor(largo * 0.55);
|
||||
const posRuido = [Math.floor(largo * 0.30), Math.floor(largo * 0.75)];
|
||||
for (let i = 0; i < largo; i++) {
|
||||
let txt = RUIDO[i % RUIDO.length] + ' (' + i + ')';
|
||||
if (i === posViejo) txt = c.viejo;
|
||||
else if (i === posBueno) txt = c.bueno;
|
||||
else if (i === posRuido[0]) txt = c.ruido[0];
|
||||
else if (i === posRuido[1]) txt = c.ruido[1];
|
||||
h.push({ role: i % 2 ? 'assistant' : 'user', content: txt, ts: Date.now() - (largo - i) * 60000 });
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
async function benchContexto(onCase) {
|
||||
const opts = { ...DEFAULTS, ...S.ctx };
|
||||
const budget = 320; // muy apretado: obliga a ELEGIR
|
||||
const filas = [];
|
||||
for (let i = 0; i < HECHOS.length; i++) {
|
||||
if (S.abort) break;
|
||||
const c = HECHOS[i];
|
||||
const hist = historialCon(c);
|
||||
hist.push({ role: 'user', content: c.q, ts: Date.now() });
|
||||
const { messages } = packHistoryACER(hist, budget, opts);
|
||||
const texto = messages.map(m => m.content).join('\n');
|
||||
const sobrevive = texto.includes(c.clave);
|
||||
const arrastraViejo = texto.includes(c.claveVieja);
|
||||
const usados = estimateTokens(texto);
|
||||
filas.push({ pregunta: c.q, sobrevive, arrastraViejo, usados, deN: hist.length, quedan: messages.length });
|
||||
onCase(filas[filas.length - 1], i, HECHOS.length);
|
||||
await new Promise(r => setTimeout(r, 25)); // deja respirar a la UI
|
||||
}
|
||||
const rec = filas.filter(f => f.sobrevive).length;
|
||||
const conf = filas.filter(f => f.arrastraViejo).length;
|
||||
return {
|
||||
filas,
|
||||
resumen: {
|
||||
'evidencia recuperada': `${rec}/${filas.length}`,
|
||||
'recall': (filas.length ? (rec / filas.length * 100).toFixed(0) : 0) + '%',
|
||||
'arrastra dato viejo': { v: `${conf}/${filas.length}`, bad: conf > 0 },
|
||||
'tokens medios': Math.round(filas.reduce((a, f) => a + f.usados, 0) / (filas.length || 1)),
|
||||
'presupuesto': budget,
|
||||
'mensajes que pasan': Math.round(filas.reduce((a, f) => a + f.quedan, 0) / (filas.length || 1)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════ BANCO 2 · SWE-bench-style (ejecuta los tests) ═══════════════
|
||||
async function opfsRoot() { return navigator.storage.getDirectory(); }
|
||||
async function limpiarOPFS() {
|
||||
const o = await opfsRoot();
|
||||
for await (const e of o.values()) await o.removeEntry(e.name, { recursive: true }).catch(() => {});
|
||||
}
|
||||
async function sembrar(files) {
|
||||
const o = await opfsRoot();
|
||||
for (const [path, txt] of Object.entries(files)) {
|
||||
const parts = path.split('/'); const name = parts.pop(); let d = o;
|
||||
for (const x of parts) d = await d.getDirectoryHandle(x, { create: true });
|
||||
const w = await (await d.getFileHandle(name, { create: true })).createWritable();
|
||||
await w.write(txt); await w.close();
|
||||
}
|
||||
}
|
||||
async function leer(path) {
|
||||
const o = await opfsRoot();
|
||||
const parts = path.split('/'); const name = parts.pop(); let d = o;
|
||||
for (const x of parts) d = await d.getDirectoryHandle(x);
|
||||
return (await (await d.getFileHandle(name)).getFile()).text();
|
||||
}
|
||||
// Ejecuta DE VERDAD el módulo arreglado y le pasa el test de la tarea.
|
||||
async function pasaElTest(task) {
|
||||
const src = await leer(task.target);
|
||||
const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
|
||||
try { return !!task.test(await import(/* @vite-ignore */ url)); }
|
||||
catch { return false; }
|
||||
finally { URL.revokeObjectURL(url); }
|
||||
}
|
||||
|
||||
async function benchSWE(onCase) {
|
||||
const code = await import('./tools/code.js');
|
||||
await code.openProject(await opfsRoot());
|
||||
const { Agent } = await import('./agent.js');
|
||||
const filas = [];
|
||||
for (let i = 0; i < TASKS.length; i++) {
|
||||
if (S.abort) break;
|
||||
const t = TASKS[i];
|
||||
await limpiarOPFS(); await sembrar(t.files);
|
||||
code.invalidateFileList();
|
||||
const t0 = performance.now();
|
||||
let pasos = 0, err = null;
|
||||
try {
|
||||
if (S.brain === 'rules') {
|
||||
// Solver de guion: valida el ARNÉS (debe dar N/N) sin gastar GPU.
|
||||
await sembrar({ [t.target]: t.solution });
|
||||
} else {
|
||||
const ag = new Agent(S.provider);
|
||||
await ag.handle(`${t.task}\n\nEl fichero a arreglar es ${t.target}. Usa code.read y code.edit.`,
|
||||
ev => { if (ev.type === 'tool') pasos++; });
|
||||
}
|
||||
} catch (e) { err = e.message; }
|
||||
const ok = err ? false : await pasaElTest(t);
|
||||
filas.push({ id: t.id, ok, pasos, secs: Math.round((performance.now() - t0) / 1000), err });
|
||||
onCase(filas[filas.length - 1], i, TASKS.length);
|
||||
}
|
||||
const res = filas.filter(f => f.ok).length;
|
||||
return { filas, resumen: { resolved: `${res}/${filas.length}`, '% resueltas': (filas.length ? (res / filas.length * 100).toFixed(0) : 0) + '%', 'pasos totales': filas.reduce((a, f) => a + f.pasos, 0) } };
|
||||
}
|
||||
|
||||
// ═══════════════ BANCO 3 · juegos (genera, ejecuta y puntúa) ═══════════════
|
||||
const BRIEF_JUEGO = 'Un juego tipo Flappy Bird: un pájaro que cae por gravedad, sube al pulsar espacio o click, ' +
|
||||
'tuberías que avanzan y hay que esquivar, colisiones, puntuación y pantalla de fin de partida.';
|
||||
|
||||
async function generarJuego() {
|
||||
const rlm = await import('./rlm.js').catch(() => null);
|
||||
if (!rlm) throw new Error('rlm.js no disponible en esta build');
|
||||
let inferencias = 0;
|
||||
const contando = { chat: (...a) => { inferencias++; return S.provider.chat(...a); } };
|
||||
const rondas = S.algo === 'rlm-deep' ? Math.max(1, S.algoOpts.rounds) : 0;
|
||||
const out = await rlm.deepCreate({ brief: BRIEF_JUEGO, provider: contando, rounds: rondas, onProgress: e => log(' ' + JSON.stringify(e)) });
|
||||
return { html: out.html, inferencias, trace: out.trace };
|
||||
}
|
||||
|
||||
// Puntúa el juego EJECUTÁNDOLO en la arena: ¿carga sin errores? ¿arranca?
|
||||
// ¿se mueve? ¿aguanta jugando? Cada criterio suma.
|
||||
async function puntuarJuego(html) {
|
||||
const frame = $('#arena');
|
||||
$('#arena-panel').hidden = false;
|
||||
const errores = [];
|
||||
const onErr = e => errores.push(String(e.message || e).slice(0, 120));
|
||||
frame.srcdoc = html;
|
||||
await new Promise(r => frame.onload = r);
|
||||
const w = frame.contentWindow, d = frame.contentDocument;
|
||||
w.addEventListener('error', onErr);
|
||||
const hash = () => {
|
||||
const c = d.querySelector('canvas'); if (!c) return -1;
|
||||
try { const px = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
|
||||
let h = 7; for (let i = 0; i < px.length; i += 997) h = (h * 31 + px[i]) % 999983; return h;
|
||||
} catch { return -1; }
|
||||
};
|
||||
const esperar = ms => new Promise(r => setTimeout(r, ms));
|
||||
const canvas = !!d.querySelector('canvas');
|
||||
await esperar(600);
|
||||
const h0 = hash();
|
||||
// arrancar: pulsar el botón si lo hay, y quitarle el foco (si no, ESPACIO
|
||||
// re-pulsa el botón en vez de saltar — nos pasó midiéndolo a mano)
|
||||
const btn = d.querySelector('button');
|
||||
if (btn) { btn.click(); btn.blur?.(); }
|
||||
await esperar(400);
|
||||
const arranca = hash() !== h0;
|
||||
// jugar: aletear con click en el canvas, que es el manejador que sí registran
|
||||
const c = d.querySelector('canvas');
|
||||
let vivoMs = 0, puntos = 0;
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
c?.dispatchEvent(new w.MouseEvent('click', { bubbles: true }));
|
||||
d.dispatchEvent(new w.KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }));
|
||||
await esperar(300);
|
||||
const run = (() => { try { return w.gameRunning; } catch { return null; } })();
|
||||
puntos = Math.max(puntos, (() => { try { return w.score || 0; } catch { return 0; } })());
|
||||
if (run === false) break;
|
||||
vivoMs = performance.now() - t0;
|
||||
}
|
||||
const criterios = {
|
||||
'carga sin errores': errores.length === 0,
|
||||
'tiene canvas': canvas,
|
||||
'arranca': arranca,
|
||||
'aguanta ≥3 s jugando': vivoMs >= 3000,
|
||||
'llega a puntuar': puntos > 0,
|
||||
};
|
||||
return { criterios, errores, vivoSegs: +(vivoMs / 1000).toFixed(1), puntos, bytes: html.length };
|
||||
}
|
||||
|
||||
async function benchJuegos(onCase) {
|
||||
if (!S.provider || S.brain === 'rules') throw new Error('este banco necesita un modelo de verdad (Reglas no genera juegos)');
|
||||
const filas = [];
|
||||
log('generando el juego con «' + S.algo + '»… esto tarda', 'w');
|
||||
const g = await generarJuego();
|
||||
log(`generado: ${g.html.length} bytes en ${g.inferencias} inferencias`, 'a');
|
||||
const p = await puntuarJuego(g.html);
|
||||
Object.entries(p.criterios).forEach(([k, v], i) => {
|
||||
filas.push({ criterio: k, ok: v });
|
||||
onCase(filas[filas.length - 1], i, Object.keys(p.criterios).length);
|
||||
});
|
||||
S.lastGame = { html: g.html, trace: g.trace };
|
||||
const pasa = filas.filter(f => f.ok).length;
|
||||
return {
|
||||
filas,
|
||||
resumen: {
|
||||
'criterios superados': `${pasa}/${filas.length}`, inferencias: g.inferencias,
|
||||
'bytes': p.bytes, 'segundos vivo': p.vivoSegs, 'puntuación': p.puntos,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────── orquestación ───────────────────────────
|
||||
async function run() {
|
||||
if (S.running) return;
|
||||
S.running = true; S.abort = false;
|
||||
$('#btn-run').disabled = true; $('#btn-stop').disabled = false;
|
||||
setLed('#led-run', 'busy', 'ejecutando ' + S.bench);
|
||||
tiles({}); prog(0, 1, 'preparando…');
|
||||
log('▶ benchmark «' + S.bench + '» · algoritmo «' + S.algo + '» · cerebro «' + S.brain + '»', 'a');
|
||||
const t0 = Date.now();
|
||||
let out = null, error = null;
|
||||
try {
|
||||
if (S.bench === 'contexto') {
|
||||
table(['pregunta', 'dato bueno', 'dato viejo', 'tokens', 'mensajes'], []);
|
||||
out = await benchContexto((f, i, n) => {
|
||||
addRow([f.pregunta.slice(0, 38),
|
||||
f.sobrevive ? { t: 'SOBREVIVE', cls: 'ok' } : { t: 'PERDIDO', cls: 'no' },
|
||||
f.arrastraViejo ? { t: 'lo arrastra', cls: 'no' } : { t: 'descartado', cls: 'ok' },
|
||||
f.usados, `${f.quedan}/${f.deN}`]);
|
||||
prog(i + 1, n, `caso ${i + 1} de ${n}`);
|
||||
});
|
||||
} else if (S.bench === 'swe') {
|
||||
table(['tarea', 'resultado', 'pasos', 'seg'], []);
|
||||
out = await benchSWE((f, i, n) => {
|
||||
addRow([f.id, f.ok ? { t: 'RESUELTA', cls: 'ok' } : { t: f.err ? 'ERROR' : 'FALLA', cls: 'no' }, f.pasos, f.secs]);
|
||||
prog(i + 1, n, `tarea ${i + 1} de ${n}`);
|
||||
});
|
||||
} else {
|
||||
table(['criterio', 'resultado'], []);
|
||||
out = await benchJuegos((f, i, n) => {
|
||||
addRow([f.criterio, f.ok ? { t: 'SÍ', cls: 'ok' } : { t: 'NO', cls: 'no' }]);
|
||||
prog(i + 1, n, `criterio ${i + 1} de ${n}`);
|
||||
});
|
||||
}
|
||||
} catch (e) { error = e.message; log('fallo: ' + e.message, 'e'); }
|
||||
|
||||
if (out) {
|
||||
tiles(out.resumen);
|
||||
S.run = {
|
||||
bench: S.bench, algo: S.algo, algoOpts: { ...S.algoOpts }, brain: S.brain,
|
||||
ctx: { ...DEFAULTS, ...S.ctx }, resumen: out.resumen, casos: out.filas,
|
||||
duracionSegs: Math.round((Date.now() - t0) / 1000), cuando: new Date().toISOString(),
|
||||
};
|
||||
log('✔ terminado: ' + JSON.stringify(out.resumen), 'a');
|
||||
}
|
||||
setLed('#led-run', error ? 'err' : 'on', error ? 'con fallo' : 'listo');
|
||||
prog(1, 1, error ? 'abortado' : 'completado');
|
||||
S.running = false; $('#btn-run').disabled = false; $('#btn-stop').disabled = true;
|
||||
}
|
||||
|
||||
// ─────────────────── historial y ranking, en local ───────────────────
|
||||
// Todo se queda en esta máquina: ni se envía ni se pide permiso a nadie. La
|
||||
// gracia del ranking es comparar CONFIGURACIONES entre sí — qué combinación de
|
||||
// algoritmo y perillas gana en cada banco — con su traza al lado para revisarla.
|
||||
const STORE = 'elffusscode.lab.runs';
|
||||
const MAX_GUARDADAS = 60;
|
||||
|
||||
// Puntuación comparable (0-100) según el banco, para poder ordenar.
|
||||
function puntuar(r) {
|
||||
const n = (s) => Number(String(s).split('/')[0]) || 0;
|
||||
const d = (s) => Number(String(s).split('/')[1]) || 1;
|
||||
if (r.bench === 'contexto') return Math.round(n(r.resumen['evidencia recuperada']) / d(r.resumen['evidencia recuperada']) * 100);
|
||||
if (r.bench === 'swe') return Math.round(n(r.resumen.resolved) / d(r.resumen.resolved) * 100);
|
||||
return Math.round(n(r.resumen['criterios superados']) / d(r.resumen['criterios superados']) * 100);
|
||||
}
|
||||
// Huella corta de la configuración: lo que de verdad distingue una corrida.
|
||||
function huella(r) {
|
||||
const on = Object.entries(r.ctx).filter(([k, v]) => v === true).map(([k]) => k);
|
||||
const extra = r.algo === 'rlm-deep' ? `·${r.algoOpts.rounds}r` : '';
|
||||
return `${r.algo}${extra} · ${r.brain} · ${on.length} perillas`;
|
||||
}
|
||||
const leerRuns = () => { try { return JSON.parse(localStorage.getItem(STORE)) || []; } catch { return []; } };
|
||||
function guardarCorrida() {
|
||||
if (!S.run) return log('todavía no hay ninguna corrida que guardar', 'w');
|
||||
const runs = leerRuns();
|
||||
runs.push({ ...S.run, score: puntuar(S.run), id: (S.run.cuando || '') + '·' + Math.round(performance.now()) });
|
||||
localStorage.setItem(STORE, JSON.stringify(runs.slice(-MAX_GUARDADAS)));
|
||||
log('guardada en el historial local (' + runs.length + ' corridas)', 'a');
|
||||
pintarRanking();
|
||||
}
|
||||
function pintarRanking() {
|
||||
const host = $('#rank-body'); host.innerHTML = '';
|
||||
const runs = leerRuns().filter(r => r.bench === S.bench).sort((a, b) => b.score - a.score);
|
||||
$('#rank-tag').textContent = runs.length ? runs.length + ' corridas · ' + S.bench : 'sin corridas de ' + S.bench;
|
||||
if (!runs.length) { host.innerHTML = '<tr><td colspan="5" class="pend">Aún no has guardado ninguna corrida de este banco.</td></tr>'; return; }
|
||||
runs.forEach((r, i) => {
|
||||
const tr = el('tr');
|
||||
const medalla = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : String(i + 1);
|
||||
[[medalla], [{ t: r.score + '%', cls: r.score >= 80 ? 'ok' : r.score >= 40 ? '' : 'no' }],
|
||||
[huella(r)], [new Date(r.cuando).toLocaleString()]].forEach(([c]) => {
|
||||
const td = el('td');
|
||||
if (c && typeof c === 'object') { td.className = c.cls || ''; td.textContent = c.t; } else td.textContent = c;
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const acc = el('td');
|
||||
const ver = el('button', 'btn ghost', 'traza');
|
||||
ver.style.padding = '2px 8px'; ver.style.fontSize = '11px';
|
||||
ver.onclick = () => descargarTraza(r);
|
||||
acc.appendChild(ver); tr.appendChild(acc);
|
||||
host.appendChild(tr);
|
||||
});
|
||||
}
|
||||
// Exportar es un fichero en tu disco, no un envío: sirve para revisar la traza
|
||||
// caso a caso o comparar dos corridas fuera del panel.
|
||||
function descargarTraza(r) {
|
||||
const a = el('a');
|
||||
a.href = URL.createObjectURL(new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' }));
|
||||
a.download = `elffuss-lab-${r.bench}-${r.score}.json`;
|
||||
a.click();
|
||||
log('traza exportada a tu disco', 'a');
|
||||
}
|
||||
function borrarHistorial() {
|
||||
if (!confirm('¿Borrar TODAS las corridas guardadas?')) return;
|
||||
localStorage.removeItem(STORE); pintarRanking(); log('historial local borrado', 'w');
|
||||
}
|
||||
|
||||
// ─────────────────────────── arranque ───────────────────────────
|
||||
radio($('#brains'), BRAINS, S.brain, id => { S.brain = id; $('#brain-tag').textContent = id; });
|
||||
radio($('#algos'), ALGOS, S.algo, id => { S.algo = id; $('#algo-tag').textContent = id; algoKnobs(); });
|
||||
radio($('#benches'), BENCHES, S.bench, id => { S.bench = id; $('#bench-tag').textContent = id; });
|
||||
$('#brain-tag').textContent = S.brain;
|
||||
$('#bench-tag').textContent = S.bench;
|
||||
algoKnobs(); ctxUI();
|
||||
|
||||
$('#btn-load').onclick = loadBrain;
|
||||
$('#btn-run').onclick = run;
|
||||
$('#btn-stop').onclick = () => { S.abort = true; log('parando tras el caso en curso…', 'w'); };
|
||||
$('#ctx-all').onclick = () => { CTX_KNOBS.flatMap(([, k]) => k).forEach(([k, t]) => { if (t === 'bool') S.ctx[k] = true; }); ctxUI(); };
|
||||
$('#ctx-none').onclick = () => { CTX_KNOBS.flatMap(([, k]) => k).forEach(([k, t]) => { if (t === 'bool') S.ctx[k] = false; }); ctxUI(); };
|
||||
$('#ctx-def').onclick = () => { S.ctx = {}; ctxUI(); };
|
||||
$('#btn-launch').onclick = () => {
|
||||
localStorage.setItem('elffusscode.model', S.brain);
|
||||
localStorage.setItem('elffusscode.ctxopts', JSON.stringify(S.ctx));
|
||||
localStorage.setItem('elffusscode.algo', JSON.stringify({ algo: S.algo, opts: S.algoOpts }));
|
||||
log('configuración guardada; abriendo el IDE', 'a');
|
||||
window.open('index.html', '_blank');
|
||||
};
|
||||
|
||||
$('#btn-save').onclick = guardarCorrida;
|
||||
$('#btn-clear').onclick = borrarHistorial;
|
||||
pintarRanking();
|
||||
|
||||
log('Elffuss Lab listo. Elige algoritmo, enciende o apaga la compresión y lanza un banco.', 'a');
|
||||
log('El banco de contexto no necesita GPU: mide el compresor y responde al instante.');
|
||||
log('Los resultados se quedan en esta máquina: historial y ranking local, sin enviar nada.');
|
||||
1966
web/js/main.js
Normal file
1966
web/js/main.js
Normal file
File diff suppressed because it is too large
Load Diff
51
web/js/mind-adapter.js
Normal file
51
web/js/mind-adapter.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// Glue específico de Elffuss Code para la Mente genérica (mind.js, core): le
|
||||
// da su ciudad de fondo real (motor VibeCodeViewer sobre el proyecto abierto)
|
||||
// y la semilla de pensamientos ya guardados en la carpeta-alma del cerebro.
|
||||
import * as THREE from 'three';
|
||||
import * as codeTools from './tools/code.js';
|
||||
import * as ceo from './ceo.js';
|
||||
import { buildModel } from './vcc/model.js';
|
||||
import { buildCity } from './vcc/builder.js';
|
||||
import { THEME as VCC_THEME } from './vcc/theme.js';
|
||||
|
||||
const CITY_MAX_FILES = 2600;
|
||||
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
||||
|
||||
async function scanForCity(dir, budget) {
|
||||
const dirs = [], files = [];
|
||||
for await (const e of dir.values()) { if (IGNORE.has(e.name)) continue; (e.kind === 'directory' ? dirs : files).push(e); }
|
||||
dirs.sort((a, b) => a.name.localeCompare(b.name)); files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const outFiles = [];
|
||||
for (const f of files) { if (budget.n >= CITY_MAX_FILES) break; let size = 0; try { size = (await f.getFile()).size; } catch { /* */ } outFiles.push([f.name, size]); budget.n++; }
|
||||
const outDirs = [];
|
||||
for (const d of dirs) { if (budget.n >= CITY_MAX_FILES) break; outDirs.push(await scanForCity(d, budget)); }
|
||||
return [dir.name, outDirs, outFiles];
|
||||
}
|
||||
|
||||
export async function buildCityAdapter(scene) {
|
||||
const root = codeTools.handle();
|
||||
if (!root) return null;
|
||||
const tree = await scanForCity(root, { n: 0 });
|
||||
const model = buildModel(tree, null);
|
||||
if (!model.files.length) return null;
|
||||
const extent = Math.max(model.city.box.sx, model.city.box.sz, 1);
|
||||
const scale = Math.min(1, Math.max(0.05, 170 / extent));
|
||||
const cityWrap = new THREE.Group();
|
||||
cityWrap.position.set(0, -95, 0);
|
||||
cityWrap.scale.setScalar(scale);
|
||||
scene.add(cityWrap);
|
||||
const cityCity = buildCity(cityWrap, model, VCC_THEME);
|
||||
return { cityWrap, cityCity, cityModel: model };
|
||||
}
|
||||
|
||||
export async function loadThoughtsAdapter() {
|
||||
const soul = ceo.getSoulDir();
|
||||
let dir = codeTools.handle();
|
||||
for (const seg of soul.split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(seg);
|
||||
const files = [];
|
||||
for await (const e of dir.values()) if (e.kind === 'file' && e.name.endsWith('.md')) files.push(e);
|
||||
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const out = [];
|
||||
for (const f of files.slice(-24)) out.push({ path: soul + '/' + f.name, md: await (await f.getFile()).text() });
|
||||
return out;
|
||||
}
|
||||
610
web/js/mind.js
Normal file
610
web/js/mind.js
Normal file
@@ -0,0 +1,610 @@
|
||||
// Vista «Mente de Elffuss» — el pensamiento subyacente del cerebro CEO en un
|
||||
// mundo 3D de fantasía psicodélica trance, con la CIUDAD real del proyecto
|
||||
// (motor VibeCodeViewer) flotando por debajo.
|
||||
// · cada línea/tool-call que llega nace como una ESTRELLA de texto (color por
|
||||
// PERFIL, tamaño/tipografía según lo que ocurre) que se desvanece sola.
|
||||
// · los perfiles (nombre, foco, color) se editan desde ⚙ — el usuario los crea
|
||||
// a su gusto.
|
||||
// · «≡ historial»: panel con TODO lo que ha llegado, sin recortar.
|
||||
// · cuando lee/escribe un fichero real, un haz baja hasta él en la ciudad.
|
||||
// · overlay PERSISTENTE (música/animación no reinician al salir y volver).
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
||||
import * as ceo from './ceo.js';
|
||||
import * as db from './db.js';
|
||||
import { renderMarkdown } from './md.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
const SOUNDCLOUD = 'https://soundcloud.com/dekel-official/dekel-baoba-festival-2023';
|
||||
const CEO_COLOR = '#ff4d8d';
|
||||
|
||||
let open = false, built = false, raf = null;
|
||||
let overlay, S, widget, onOpenFile = () => {}, onExecute = () => {};
|
||||
// Ciudad de fondo (vista real del workspace) y semilla de pensamientos previos:
|
||||
// AMBOS opcionales e inyectados por la app anfitriona — Code tiene un motor
|
||||
// VibeCodeViewer sobre su proyecto único; Claw (v1) no lo ofrece y la Mente
|
||||
// funciona igual de bien sin ciudad de fondo.
|
||||
let loadThoughts = async () => []; // () => [{path, md}]
|
||||
let buildCityFn = null; // (scene) => {cityWrap, cityCity, cityModel} | null
|
||||
export function init({ loadThoughts: lt, buildCity: bc } = {}) {
|
||||
if (lt) loadThoughts = lt;
|
||||
if (bc) buildCityFn = bc;
|
||||
}
|
||||
let anchorMap = new Map(); // id de perfil (+ 'ceo') → Vector3 (anclas del mundo)
|
||||
const anchors = []; // { el, pos } — solo etiquetas de propuestas forjadas
|
||||
let nodesGroup, thoughtNodes = [];
|
||||
let starGroup, stars = []; // estrellas de pensamiento efímeras
|
||||
const lineBuf = new Map(); // canal → texto acumulado hasta la línea completa
|
||||
const streamed = new Map(); // canal → ¿llegaron tokens? (evita duplicar el texto final)
|
||||
let cityWrap = null, cityCity = null, cityModel = null;
|
||||
let beams = [], fileActivity = [];
|
||||
|
||||
export function isOpen() { return open; }
|
||||
export function setOpenFile(fn) { onOpenFile = fn; }
|
||||
export function setExecuteProposal(fn) { onExecute = fn; }
|
||||
|
||||
const escHtml = s => String(s).replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||||
const profileOf = channel => channel === 'ceo'
|
||||
? { id: 'ceo', name: 'CEO', color: CEO_COLOR }
|
||||
: ceo.getProfiles().find(p => p.id === channel) || { id: channel, name: channel, color: '#7c5cff' };
|
||||
|
||||
// ── música con Widget API (persistente: pausa al salir, reanuda al volver) ──
|
||||
function mountMusic(root) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'mind-music';
|
||||
const url = 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(SOUNDCLOUD) +
|
||||
'&auto_play=true&hide_related=true&show_comments=false&show_user=false&show_reposts=false&visual=false&color=%23ff4d8d';
|
||||
wrap.innerHTML = '<iframe id="mind-sc" title="música" width="100%" height="120" frameborder="no" allow="autoplay; encrypted-media" src="' + url + '"></iframe>' +
|
||||
'<div class="mind-music-cap">▶ Dekel · Baoba Festival — modo trance</div>';
|
||||
root.appendChild(wrap);
|
||||
const iframe = wrap.querySelector('#mind-sc');
|
||||
const initWidget = () => { try { widget = window.SC.Widget(iframe); } catch { /* aún no */ } };
|
||||
if (window.SC && window.SC.Widget) initWidget();
|
||||
else { const s = document.createElement('script'); s.src = 'https://w.soundcloud.com/player/api.js'; s.onload = initWidget; document.head.appendChild(s); }
|
||||
}
|
||||
|
||||
// ══ ESTRELLAS de pensamiento: cada línea/tool-call nace, brilla y se apaga ══
|
||||
function makeTextSprite(text, color, { fontSize = 16, bold = false, glow = 10 } = {}) {
|
||||
const pad = 8;
|
||||
const probe = document.createElement('canvas').getContext('2d');
|
||||
probe.font = `${bold ? 700 : 500} ${fontSize}px ui-monospace, Menlo, monospace`;
|
||||
const w = Math.ceil(probe.measureText(text).width) + pad * 2;
|
||||
const h = fontSize + pad * 2;
|
||||
const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
|
||||
const g = cv.getContext('2d');
|
||||
g.font = `${bold ? 700 : 500} ${fontSize}px ui-monospace, Menlo, monospace`;
|
||||
g.textBaseline = 'middle';
|
||||
g.shadowColor = color; g.shadowBlur = glow;
|
||||
g.fillStyle = color;
|
||||
g.fillText(text, pad, h / 2 + 1);
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending });
|
||||
const sprite = new THREE.Sprite(mat);
|
||||
const scale = 0.09;
|
||||
sprite.scale.set(w * scale, h * scale, 1);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
// kind: 'line' (charla normal) · 'tool' (tool-call) · 'event' (ciclo/estado) · 'built' (propuesta forjada)
|
||||
const KIND_STYLE = {
|
||||
line: { fontSize: 13, bold: false, glow: 8, life: 6, jitter: 15 },
|
||||
tool: { fontSize: 17, bold: true, glow: 16, life: 9, jitter: 9 },
|
||||
event: { fontSize: 20, bold: true, glow: 14, life: 7, jitter: 6 },
|
||||
built: { fontSize: 26, bold: true, glow: 22, life: 13, jitter: 4 },
|
||||
};
|
||||
function spawnStar(channel, text, kind = 'line') {
|
||||
if (!starGroup || !text) return;
|
||||
const prof = profileOf(channel);
|
||||
const st = KIND_STYLE[kind] || KIND_STYLE.line;
|
||||
const anchor = anchorMap.get(prof.id) || new THREE.Vector3(0, 20, 0);
|
||||
const j = st.jitter;
|
||||
const label = kind === 'tool' ? '⟐ ' + text : kind === 'event' ? '★ ' + text : kind === 'built' ? '✦ ' + text : text;
|
||||
const sprite = makeTextSprite(label.slice(0, kind === 'line' ? 52 : 72), prof.color, st);
|
||||
sprite.position.copy(anchor).add(new THREE.Vector3((Math.random() - 0.5) * j, (Math.random() - 0.5) * j * 0.6, (Math.random() - 0.5) * j));
|
||||
sprite.material.opacity = 0;
|
||||
starGroup.add(sprite);
|
||||
stars.push({ obj: sprite, born: elapsed, life: st.life, seed: Math.random() * 1000, up: kind === 'built' ? 0.5 : 0.22 });
|
||||
if (stars.length > 160) { const old = stars.shift(); starGroup.remove(old.obj); old.obj.material.map.dispose(); old.obj.material.dispose(); }
|
||||
}
|
||||
|
||||
// ── historial COMPLETO (todo lo que llega, sin recortar) — PERSISTENTE: antes
|
||||
// vivía solo en el DOM/memoria y una recarga de página lo borraba entero.
|
||||
const LOG_CAP = 500;
|
||||
let logHistory = []; // [{channel, text}] — se guarda en IndexedDB
|
||||
let logSaveTimer = null;
|
||||
function saveLogSoon() {
|
||||
clearTimeout(logSaveTimer);
|
||||
logSaveTimer = setTimeout(() => db.set('kv', 'mindLog', logHistory).catch(() => {}), 400);
|
||||
}
|
||||
function renderLogRow(channel, text) {
|
||||
const body = document.getElementById('mind-log-body');
|
||||
if (!body) return;
|
||||
const prof = profileOf(channel);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ml-row';
|
||||
row.innerHTML = `<span class="ml-dot" style="background:${prof.color}"></span><b style="color:${prof.color}">${escHtml(prof.name)}</b> <span class="ml-t">${escHtml(text)}</span>`;
|
||||
body.appendChild(row);
|
||||
while (body.children.length > LOG_CAP) body.removeChild(body.firstChild);
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
function logLine(channel, text) {
|
||||
logHistory.push({ channel, text });
|
||||
if (logHistory.length > LOG_CAP) logHistory = logHistory.slice(-LOG_CAP);
|
||||
saveLogSoon();
|
||||
renderLogRow(channel, text);
|
||||
}
|
||||
async function loadPersistedLog() {
|
||||
try {
|
||||
const saved = await db.get('kv', 'mindLog');
|
||||
if (!Array.isArray(saved) || !saved.length) return;
|
||||
logHistory = saved;
|
||||
for (const { channel, text } of saved) renderLogRow(channel, text);
|
||||
} catch { /* aún no hay historial guardado */ }
|
||||
}
|
||||
|
||||
function flushLine(channel, force = false) {
|
||||
const buf = (lineBuf.get(channel) || '').trim();
|
||||
if (!buf && !force) return;
|
||||
if (buf) { spawnStar(channel, buf, 'line'); logLine(channel, buf); }
|
||||
lineBuf.set(channel, '');
|
||||
}
|
||||
|
||||
// alimenta estrellas + historial + haces sobre la ciudad (ceo.js → aquí)
|
||||
export function pushThought(channel, ev) {
|
||||
if (!open) return;
|
||||
if (channel === 'sys') { logLine('ceo', ev.text); return; }
|
||||
if (channel === 'ceo') {
|
||||
if (ev.type === 'cycle') { logLine('ceo', '● ' + ev.text); spawnStar('ceo', ev.text, 'event'); }
|
||||
else if (ev.type === 'survey') { logLine('ceo', ev.text); }
|
||||
else if (ev.type === 'reprogram') { logLine('ceo', '⚙ ' + ev.text); spawnStar('ceo', ev.text, 'event'); if (ev.profiles) rebuildWorld(); }
|
||||
else if (ev.type === 'paused') { logLine('ceo', ev.text); spawnStar('ceo', ev.text, 'event'); }
|
||||
else if (ev.type === 'built') { logLine('ceo', '✦ ' + ev.text); spawnStar('ceo', ev.text, 'built'); addThoughtNode(ev); }
|
||||
else logLine('ceo', ev.text || '');
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'open') { lineBuf.set(channel, ''); streamed.set(channel, false); }
|
||||
else if (ev.type === 'token') {
|
||||
streamed.set(channel, true);
|
||||
lineBuf.set(channel, (lineBuf.get(channel) || '') + ev.text);
|
||||
const buf = lineBuf.get(channel);
|
||||
if (/\n/.test(ev.text) || buf.length > 90) flushLine(channel);
|
||||
} else if (ev.type === 'tool') {
|
||||
flushLine(channel);
|
||||
spawnStar(channel, ev.text, 'tool');
|
||||
logLine(channel, '⟐ ' + ev.text);
|
||||
if (ev.path) fileBeam(ev.path, /escrib/i.test(ev.text) ? 'write' : 'read');
|
||||
} else if (ev.type === 'tool_result') {
|
||||
// el RESULTADO real de la tool (lo que de verdad se leyó/escribió/ejecutó),
|
||||
// enlazado justo debajo de su tool-call — no solo el nombre de la acción.
|
||||
const t = '→ ' + (ev.text || '(sin salida)');
|
||||
spawnStar(channel, t, 'line');
|
||||
logLine(channel, t);
|
||||
} else if (ev.type === 'done') {
|
||||
flushLine(channel, true);
|
||||
// el proveedor puede devolver el texto final SIN pasar por tokens (sin
|
||||
// streaming) → si no vimos ningún token, esto es lo único que lo muestra.
|
||||
if (ev.text && !streamed.get(channel)) { spawnStar(channel, ev.text, 'line'); logLine(channel, ev.text); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── nodos de pensamiento clicables (cada .md forjado = un punto brillante) ──
|
||||
function addThoughtNode({ path, md, text }) {
|
||||
if (!nodesGroup) return;
|
||||
const i = thoughtNodes.length;
|
||||
const a = i * 2.399963; // ángulo áureo
|
||||
const r = 30 + i * 5;
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.IcosahedronGeometry(3.2, 0),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff4d8d, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false }));
|
||||
mesh.position.set(Math.cos(a) * r, 6 + Math.sin(i * 0.7) * 10, Math.sin(a) * r);
|
||||
mesh.userData = { path, md, text };
|
||||
nodesGroup.add(mesh);
|
||||
thoughtNodes.push(mesh);
|
||||
const lab = document.createElement('div');
|
||||
lab.className = 'mind-node-label';
|
||||
lab.textContent = (path || 'pensamiento').split('/').pop();
|
||||
// clicable ella misma — antes SOLO el raycast contra la diminuta malla 3D
|
||||
// abría el panel, fácil de fallar; la etiqueta es el blanco visible real.
|
||||
lab.addEventListener('click', () => selectThoughtNode(mesh));
|
||||
document.getElementById('mind-anchors').appendChild(lab);
|
||||
anchors.push({ el: lab, pos: mesh.position });
|
||||
mesh.userData.label = lab;
|
||||
}
|
||||
|
||||
async function loadExistingThoughts() {
|
||||
try { for (const it of await loadThoughts()) addThoughtNode(it); } catch { /* aún no hay */ }
|
||||
}
|
||||
|
||||
// selección de un nodo de propuesta — desde la malla 3D (raycast) o su etiqueta
|
||||
function selectThoughtNode(mesh) { showThoughtPanel(mesh); focusOn(mesh.position, 26); }
|
||||
|
||||
function showThoughtPanel(node) {
|
||||
const root = document.getElementById('mind-panel');
|
||||
const md = node.userData.md || node.userData.text || '(sin contenido)';
|
||||
const path = node.userData.path || 'pensamiento';
|
||||
root.innerHTML = `<div class="mp-head">${escHtml(path)}<button id="mp-x">✕</button></div>` +
|
||||
`<div class="mp-body">${renderMarkdown(md)}</div>` +
|
||||
`<div class="mp-actions">` +
|
||||
`<button id="mp-exec" class="mp-exec">▶ Ejecutar esta propuesta</button>` +
|
||||
(node.userData.path ? `<button id="mp-open" class="mp-open">Abrir en el editor</button>` : '') +
|
||||
`</div>`;
|
||||
root.classList.add('show');
|
||||
root.querySelector('#mp-x').onclick = () => root.classList.remove('show');
|
||||
root.querySelector('#mp-exec').onclick = () => { onExecute(md); root.classList.remove('show'); };
|
||||
const openBtn = root.querySelector('#mp-open');
|
||||
if (openBtn) openBtn.onclick = () => { onOpenFile(node.userData.path); root.classList.remove('show'); };
|
||||
}
|
||||
|
||||
// ══ perfiles: anclas 3D + leyenda (recalculadas al abrir o al reprogramar) ══
|
||||
function computeAnchors() {
|
||||
const map = new Map();
|
||||
map.set('ceo', new THREE.Vector3(0, 46, 0));
|
||||
const profs = ceo.getProfiles();
|
||||
const n = Math.max(profs.length, 1);
|
||||
profs.forEach((p, i) => {
|
||||
const a = (i / n) * Math.PI * 2;
|
||||
map.set(p.id, new THREE.Vector3(Math.cos(a) * 70, 8 + (i % 2) * 20, Math.sin(a) * 70));
|
||||
});
|
||||
return map;
|
||||
}
|
||||
function renderLegend() {
|
||||
const el = document.getElementById('mind-legend');
|
||||
if (!el) return;
|
||||
const profs = ceo.getProfiles();
|
||||
el.innerHTML = `<div class="ml-item" data-id="ceo"><span class="ml-sw" style="background:${CEO_COLOR}"></span>CEO</div>` +
|
||||
profs.map(p => `<div class="ml-item" data-id="${p.id}"><span class="ml-sw" style="background:${p.color}"></span>${escHtml(p.name)}</div>`).join('');
|
||||
el.querySelectorAll('.ml-item').forEach(row => {
|
||||
row.onclick = () => { const pos = anchorMap.get(row.dataset.id); if (pos) focusOn(pos, 46); };
|
||||
});
|
||||
}
|
||||
// vuelo suave de la cámara hacia un punto — así «clic en algo → la cámara se centra»
|
||||
let flyTo = null;
|
||||
function focusOn(pos, distance = 40) {
|
||||
if (!S) return;
|
||||
const dir = new THREE.Vector3().subVectors(S.camera.position, S.controls.target);
|
||||
if (dir.lengthSq() < 0.001) dir.set(0, 0.3, 1);
|
||||
dir.normalize().multiplyScalar(distance);
|
||||
flyTo = { fromPos: S.camera.position.clone(), toPos: pos.clone().add(dir), fromTarget: S.controls.target.clone(), toTarget: pos.clone(), t0: elapsed, dur: 1.1 };
|
||||
}
|
||||
const easeInOut = k => k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
|
||||
function rebuildWorld() { anchorMap = computeAnchors(); renderLegend(); }
|
||||
|
||||
// ── panel ⚙: reprograma misión, carpeta-alma y PERFILES (editables) ──────
|
||||
function wireConfig(root) {
|
||||
const btn = root.querySelector('#mind-config');
|
||||
const cfg = root.querySelector('#mind-cfg');
|
||||
const profRowHtml = p => `<div class="cfg-prof" data-id="${escHtml(p.id || '')}">
|
||||
<input type="color" class="cp-color" value="${p.color || '#7c5cff'}">
|
||||
<input type="text" class="cp-name" value="${escHtml(p.name || '')}" placeholder="Nombre">
|
||||
<input type="text" class="cp-focus" value="${escHtml(p.focus || '')}" placeholder="En qué se centra">
|
||||
<button class="cp-del" title="quitar">✕</button></div>`;
|
||||
const notifState = () => (!('Notification' in window)) ? { txt: 'no soportadas por este navegador', can: false }
|
||||
: Notification.permission === 'granted' ? { txt: '✓ concedidas — avisaré cuando encuentre algo bueno', can: false }
|
||||
: Notification.permission === 'denied' ? { txt: '✕ bloqueadas — actívalas en los ajustes del sitio del navegador', can: false }
|
||||
: { txt: 'aún no pedidas', can: true };
|
||||
const draw = () => {
|
||||
const profs = ceo.getProfiles();
|
||||
const ns = notifState();
|
||||
cfg.innerHTML =
|
||||
'<div class="cfg-head">⚙ Reprogramar el cerebro<button id="cfg-x">✕</button></div>' +
|
||||
'<div class="cfg-notif">🔔 Notificaciones del navegador: ' + escHtml(ns.txt) + (ns.can ? ' <button id="cfg-notif-ask" class="cfg-notif-btn">Activar</button>' : '') + '</div>' +
|
||||
'<label>Misión (qué quieres que haga por su cuenta)</label>' +
|
||||
'<textarea id="cfg-mission" rows="3">' + escHtml(ceo.getMission()) + '</textarea>' +
|
||||
'<label>Carpeta-alma (dentro del proyecto)</label>' +
|
||||
'<input id="cfg-dir" value="' + escHtml(ceo.getSoulDir()) + '">' +
|
||||
'<label>Perfiles — cada uno piensa en paralelo, con el color de su estrella</label>' +
|
||||
'<div id="cfg-profiles">' + profs.map(profRowHtml).join('') + '</div>' +
|
||||
'<button id="cfg-add-prof" class="cfg-add">+ nuevo perfil</button>' +
|
||||
'<div class="cfg-actions"><button id="cfg-think-now" class="cfg-ghost">Pensar ahora</button>' +
|
||||
'<button id="cfg-save" class="cfg-save">Reprogramar</button></div>' +
|
||||
'<div class="cfg-note">La elfa trabajará según esto cuando estés ocioso y volcará todo en esa carpeta.</div>';
|
||||
cfg.querySelector('#cfg-x').onclick = () => cfg.classList.remove('show');
|
||||
cfg.querySelector('#cfg-notif-ask')?.addEventListener('click', () => { Notification.requestPermission().then(draw); });
|
||||
cfg.querySelector('#cfg-add-prof').onclick = () => {
|
||||
cfg.querySelector('#cfg-profiles').insertAdjacentHTML('beforeend', profRowHtml({ color: '#ff9f45', name: '', focus: '' }));
|
||||
wireRows();
|
||||
};
|
||||
cfg.querySelector('#cfg-think-now').onclick = () => { ceo.forceCycle(); cfg.classList.remove('show'); };
|
||||
cfg.querySelector('#cfg-save').onclick = () => {
|
||||
// preserva el id original de cada perfil (si no, cada guardado los
|
||||
// regeneraría desde el nombre y los ciclos en curso perderían el hilo)
|
||||
const rows = [...cfg.querySelectorAll('.cfg-prof')].map(r => ({
|
||||
id: r.dataset.id || undefined,
|
||||
color: r.querySelector('.cp-color').value,
|
||||
name: r.querySelector('.cp-name').value.trim(),
|
||||
focus: r.querySelector('.cp-focus').value.trim(),
|
||||
}));
|
||||
ceo.setMission(cfg.querySelector('#cfg-mission').value);
|
||||
ceo.setSoulDir(cfg.querySelector('#cfg-dir').value);
|
||||
ceo.setProfiles(rows);
|
||||
if (!ceo.isEnabled()) ceo.enable();
|
||||
cfg.classList.remove('show');
|
||||
};
|
||||
wireRows();
|
||||
function wireRows() { cfg.querySelectorAll('.cp-del').forEach(b => { b.onclick = () => b.closest('.cfg-prof').remove(); }); }
|
||||
};
|
||||
btn.onclick = () => { if (cfg.classList.contains('show')) cfg.classList.remove('show'); else { draw(); cfg.classList.add('show'); } };
|
||||
}
|
||||
function wireHistory(root) {
|
||||
const btn = root.querySelector('#mind-history'), panel = root.querySelector('#mind-log');
|
||||
btn.onclick = () => panel.classList.toggle('show');
|
||||
panel.querySelector('#ml-x').onclick = () => panel.classList.remove('show');
|
||||
}
|
||||
|
||||
// play/stop del cerebro, visible dentro de la Mente. La elección la persiste
|
||||
// ceo.js (misma clave que usa el clic en la elfa) → sobrevive a recargar.
|
||||
function wirePlayStop(root) {
|
||||
const btn = root.querySelector('#mind-playstop');
|
||||
const paint = () => {
|
||||
const on = ceo.isEnabled();
|
||||
btn.textContent = on ? '⏸ pausar cerebro' : '▶ reanudar cerebro';
|
||||
btn.classList.toggle('paused', !on);
|
||||
};
|
||||
btn.onclick = () => { if (ceo.isEnabled()) ceo.disable(); else ceo.enable(); paint(); };
|
||||
paint();
|
||||
}
|
||||
|
||||
// ══ ciudad de fondo (motor de la app anfitriona, por debajo del mundo) ══════
|
||||
async function buildBackgroundCity(scene) {
|
||||
if (!buildCityFn) return;
|
||||
try {
|
||||
const city = await buildCityFn(scene);
|
||||
if (!city) return;
|
||||
cityWrap = city.cityWrap; cityCity = city.cityCity; cityModel = city.cityModel;
|
||||
} catch { /* sigue sin ciudad de fondo */ }
|
||||
}
|
||||
// haz de luz que baja hasta el fichero real en la ciudad + resalta su celda
|
||||
function fileBeam(path, kind) {
|
||||
if (!cityModel || !cityCity || !S) return;
|
||||
const rel = String(path).replace(/^\.?\//, '');
|
||||
const f = cityModel.fileByRel.get(rel);
|
||||
if (!f) return;
|
||||
const local = new THREE.Vector3(f.box.cx, f.box.cy, f.box.cz);
|
||||
const world = cityWrap.localToWorld(local.clone());
|
||||
const top = world.clone(); top.y += 50;
|
||||
const geo = new THREE.BufferGeometry().setFromPoints([top, world]);
|
||||
const mat = new THREE.LineBasicMaterial({ color: kind === 'write' ? 0xff4d8d : 0x49e8ff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false });
|
||||
const line = new THREE.Line(geo, mat);
|
||||
S.scene.add(line);
|
||||
beams.push({ obj: line, born: elapsed, life: 1.4 });
|
||||
fileActivity.push({ id: f.id, born: elapsed, life: 2.2 });
|
||||
}
|
||||
|
||||
// ── mundo psicodélico ───────────────────────────────────────────────────────
|
||||
function buildScene(canvas, W, H) {
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setSize(W, H, false);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(60, W / H, 0.1, 2000);
|
||||
camera.position.set(0, 40, 150);
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
controls.enableDamping = true; controls.dampingFactor = 0.06; controls.autoRotate = false;
|
||||
controls.enablePan = false; controls.minDistance = 60; controls.maxDistance = 420;
|
||||
|
||||
const N = 14000;
|
||||
const pos = new Float32Array(N * 3), rad = new Float32Array(N);
|
||||
let s = 987654321; const rnd = () => (s = (s * 16807) % 2147483647) / 2147483647;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const arm = Math.floor(rnd() * 4);
|
||||
const r = Math.pow(rnd(), 0.6) * 120 + 6;
|
||||
const a = arm * (Math.PI / 2) + r * 0.03 + (rnd() - 0.5) * 0.5;
|
||||
const y = (rnd() - 0.5) * (14 - r * 0.06);
|
||||
pos[i * 3] = Math.cos(a) * r; pos[i * 3 + 1] = y; pos[i * 3 + 2] = Math.sin(a) * r; rad[i] = r;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
geo.setAttribute('aRad', new THREE.BufferAttribute(rad, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
uniforms: { uTime: { value: 0 }, uBass: { value: 0 }, uMid: { value: 0 } },
|
||||
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
|
||||
vertexShader: `uniform float uTime, uBass, uMid; attribute float aRad; varying vec3 vC;
|
||||
vec3 hsv(float h){ vec3 k=vec3(1.,2./3.,1./3.); vec3 p=abs(fract(h+k)*6.-3.); return clamp(p-1.,0.,1.);}
|
||||
float hash(vec3 p){ p=fract(p*0.3183099+0.1); p*=17.0; return fract(p.x*p.y*p.z*(p.x+p.y+p.z)); }
|
||||
float vnoise(vec3 x){ vec3 i=floor(x),f=fract(x); f=f*f*(3.0-2.0*f);
|
||||
return mix(mix(mix(hash(i+vec3(0,0,0)),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y),
|
||||
mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z); }
|
||||
float fbm(vec3 p){ float a=0.5,s=0.0; for(int i=0;i<4;i++){ s+=a*vnoise(p); p*=2.02; a*=0.5;} return s; }
|
||||
void main(){
|
||||
vec3 pos=position;
|
||||
float n=fbm(pos*0.03 + vec3(0.0,uTime*0.18,0.0));
|
||||
pos += normalize(pos+0.001) * (n-0.5) * (5.0 + uMid*4.0 + uBass*7.0);
|
||||
float sw = uTime*0.14 + aRad*0.015 + uBass*0.18;
|
||||
float cs=cos(sw), sn=sin(sw);
|
||||
pos.xz = mat2(cs,-sn,sn,cs) * pos.xz;
|
||||
pos *= (1.0 + uBass*0.05);
|
||||
float h=fract(aRad*0.004+uTime*0.03+n*0.12); vC=hsv(h)*(1.3-aRad*0.004)*(0.95+uMid*0.18+uBass*0.12);
|
||||
vec4 mv=modelViewMatrix*vec4(pos,1.0); gl_PointSize=(2.2+60.0/-mv.z)*(1.0+uBass*0.18); gl_Position=projectionMatrix*mv; }`,
|
||||
fragmentShader: `varying vec3 vC; void main(){ vec2 d=gl_PointCoord-0.5; float m=smoothstep(0.5,0.0,length(d)); gl_FragColor=vec4(vC*2.0,m); }`,
|
||||
});
|
||||
const galaxy = new THREE.Points(geo, mat); scene.add(galaxy);
|
||||
const core = new THREE.LineSegments(new THREE.WireframeGeometry(new THREE.IcosahedronGeometry(18, 1)),
|
||||
new THREE.LineBasicMaterial({ color: 0xff4d8d, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false }));
|
||||
scene.add(core);
|
||||
const rings = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(22, 0.5, 8, 80),
|
||||
new THREE.MeshBasicMaterial({ color: 0x7c5cff, transparent: true, opacity: 0.45, blending: THREE.AdditiveBlending, depthWrite: false }));
|
||||
ring.rotation.x = Math.PI / 2; ring.position.y = -70 + i * 6; ring.userData.i = i;
|
||||
scene.add(ring); rings.push(ring);
|
||||
}
|
||||
nodesGroup = new THREE.Group(); scene.add(nodesGroup);
|
||||
starGroup = new THREE.Group(); scene.add(starGroup);
|
||||
const composer = new EffectComposer(renderer);
|
||||
composer.setSize(W, H); composer.addPass(new RenderPass(scene, camera));
|
||||
const bloom = new UnrealBloomPass(new THREE.Vector2(W, H), 1.1, 0.5, 0.2);
|
||||
composer.addPass(bloom); composer.addPass(new OutputPass());
|
||||
return { renderer, scene, camera, controls, composer, bloom, mat, galaxy, core, rings };
|
||||
}
|
||||
|
||||
export async function openMind() {
|
||||
if (open) return;
|
||||
open = true;
|
||||
if (built) { overlay.style.display = ''; try { widget?.play(); } catch {} startLoop(); return; }
|
||||
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'mind-overlay';
|
||||
overlay.innerHTML =
|
||||
'<canvas id="mind-canvas"></canvas>' +
|
||||
`<div id="mind-title">${t('mindTitle')}</div>` +
|
||||
'<div id="mind-legend"></div>' +
|
||||
'<button id="mind-playstop" title="pausar/reanudar el cerebro"></button>' +
|
||||
'<button id="mind-config" title="reprogramar el cerebro">⚙ cerebro</button>' +
|
||||
'<button id="mind-history" title="ver todo lo que ha llegado">≡ historial</button>' +
|
||||
'<button id="mind-close" title="salir">✕ salir</button>' +
|
||||
'<div id="mind-anchors"></div><div id="mind-panel"></div><div id="mind-cfg"></div>' +
|
||||
'<div id="mind-log"><div class="ml-head">Historial completo<button id="ml-x">✕</button></div><div id="mind-log-body"></div></div>';
|
||||
document.body.appendChild(overlay);
|
||||
wireConfig(overlay); wireHistory(overlay); wirePlayStop(overlay);
|
||||
mountMusic(overlay);
|
||||
anchors.length = 0; thoughtNodes = []; stars = []; lineBuf.clear();
|
||||
rebuildWorld();
|
||||
|
||||
const canvas = overlay.querySelector('#mind-canvas');
|
||||
S = buildScene(canvas, window.innerWidth, window.innerHeight);
|
||||
buildBackgroundCity(S.scene);
|
||||
await loadExistingThoughts();
|
||||
await loadPersistedLog(); // el historial de «≡ historial» sobrevive a recargas
|
||||
|
||||
const ray = new THREE.Raycaster(), ptr = new THREE.Vector2();
|
||||
let downXY = null;
|
||||
canvas.addEventListener('pointerdown', e => { downXY = [e.clientX, e.clientY]; });
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
if (!downXY) return; const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]); downXY = null;
|
||||
if (moved > 5 || !thoughtNodes.length) return;
|
||||
ptr.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
|
||||
ray.setFromCamera(ptr, S.camera);
|
||||
const hit = ray.intersectObjects(thoughtNodes)[0];
|
||||
if (hit) selectThoughtNode(hit.object);
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
S.renderer.setSize(w, h, false); S.composer.setSize(w, h); S.bloom.resolution.set(w, h);
|
||||
S.camera.aspect = w / h; S.camera.updateProjectionMatrix();
|
||||
};
|
||||
window.addEventListener('resize', onResize); S.onResize = onResize;
|
||||
overlay.querySelector('#mind-close').addEventListener('click', closeMind);
|
||||
S.onKey = e => { if (e.key === 'Escape') closeMind(); };
|
||||
document.addEventListener('keydown', S.onKey);
|
||||
built = true;
|
||||
startLoop();
|
||||
}
|
||||
|
||||
// Etiquetas de propuestas: se proyectan del mundo 3D a la pantalla, así que
|
||||
// con muchas acumuladas acaban solapándose ilegibles según el ángulo de
|
||||
// cámara. Aquí: cap a las más cercanas y reparto anti-solape (empuja hacia
|
||||
// abajo la que choca con otra ya colocada), para que se apilen separadas.
|
||||
const MAX_LABELS = 14, LABEL_W = 90, LABEL_H = 20;
|
||||
function projectAnchors() {
|
||||
const w = window.innerWidth, h = window.innerHeight, v = new THREE.Vector3();
|
||||
const projected = [];
|
||||
for (const a of anchors) {
|
||||
v.copy(a.pos).project(S.camera);
|
||||
if (v.z > 1) { a.el.style.display = 'none'; continue; } // detrás de la cámara
|
||||
projected.push({ a, x: (v.x * 0.5 + 0.5) * w, y: (-v.y * 0.5 + 0.5) * h, depth: v.z });
|
||||
}
|
||||
projected.sort((p1, p2) => p1.depth - p2.depth); // las más cercanas a la cámara, prioridad
|
||||
projected.forEach((p, i) => { p.a.el.style.display = i < MAX_LABELS ? '' : 'none'; });
|
||||
const visible = projected.slice(0, MAX_LABELS).sort((p1, p2) => p1.x - p2.x || p1.y - p2.y);
|
||||
const placed = [];
|
||||
for (const p of visible) {
|
||||
let y = p.y;
|
||||
for (const q of placed) if (Math.abs(q.x - p.x) < LABEL_W && Math.abs(q.y - y) < LABEL_H) y = q.y + LABEL_H;
|
||||
placed.push({ x: p.x, y });
|
||||
p.a.el.style.left = p.x + 'px'; p.a.el.style.top = y + 'px';
|
||||
p.a.el.style.transform = 'translate(-50%,-50%)';
|
||||
}
|
||||
}
|
||||
const wob = (t, seed, freq = 0.1) => Math.sin(t * freq + seed) * 0.6 + Math.sin(t * freq * 0.43 + seed * 2.1) * 0.3 + Math.sin(t * freq * 0.19 + seed * 4.7) * 0.1;
|
||||
|
||||
let elapsed = 0, lastMs = 0, smBass = 0, smMid = 0;
|
||||
function startLoop() {
|
||||
if (raf) return;
|
||||
const loop = (ms) => {
|
||||
raf = requestAnimationFrame(loop);
|
||||
const dt = lastMs ? (ms - lastMs) / 1000 : 0;
|
||||
elapsed += dt; lastMs = ms;
|
||||
const t = elapsed;
|
||||
const beat = 60 / 138;
|
||||
const bp = (t % beat) / beat;
|
||||
const bassRaw = Math.pow(1 - bp, 3);
|
||||
const midRaw = 0.5 + 0.5 * Math.sin(t * 6.0);
|
||||
smBass += (bassRaw - smBass) * 0.05;
|
||||
smMid += (midRaw - smMid) * 0.06;
|
||||
S.mat.uniforms.uTime.value = t;
|
||||
S.mat.uniforms.uBass.value = smBass;
|
||||
S.mat.uniforms.uMid.value = smMid;
|
||||
S.galaxy.rotation.y = t * 0.05;
|
||||
S.galaxy.rotation.x = wob(t, 1.7, 0.05) * 0.15;
|
||||
S.galaxy.rotation.z = wob(t, 5.2, 0.04) * 0.1;
|
||||
S.galaxy.scale.setScalar(1 + smBass * 0.02);
|
||||
const pulse = 1 + smBass * 0.05 + Math.sin(t * 0.9) * 0.03;
|
||||
S.core.scale.setScalar(pulse); S.core.rotation.y = t * 0.16; S.core.rotation.x = t * 0.09;
|
||||
S.core.position.x = wob(t, 3.1, 0.11) * 6; S.core.position.z = wob(t, 9.4, 0.09) * 6;
|
||||
S.core.material.color.setHSL((t * 0.04) % 1, 0.9, 0.6);
|
||||
for (const r of S.rings) {
|
||||
r.position.y += 0.14 + smBass * 0.12;
|
||||
if (r.position.y > 74) r.position.y -= 144;
|
||||
r.position.x = wob(t, r.userData.i * 1.3 + 1, 0.08) * 3;
|
||||
r.scale.setScalar(1 + smBass * 0.12 + Math.sin(t * 0.8 + r.userData.i) * 0.04);
|
||||
r.material.opacity = 0.2 + smBass * 0.16;
|
||||
r.material.color.setHSL((t * 0.05 + r.userData.i * 0.03) % 1, 0.85, 0.6);
|
||||
}
|
||||
for (let i = 0; i < thoughtNodes.length; i++) { const n = thoughtNodes[i]; n.rotation.y = t + i; n.scale.setScalar(1 + Math.sin(t * 2 + i) * 0.14); }
|
||||
// estrellas: nacen (fade-in), viven, se apagan solas y se reciclan
|
||||
for (let i = stars.length - 1; i >= 0; i--) {
|
||||
const st = stars[i], age = t - st.born;
|
||||
if (age > st.life) { starGroup.remove(st.obj); st.obj.material.map.dispose(); st.obj.material.dispose(); stars.splice(i, 1); continue; }
|
||||
const fadeIn = Math.min(1, age / 0.5), fadeOut = Math.min(1, (st.life - age) / (st.life * 0.35));
|
||||
st.obj.material.opacity = Math.min(fadeIn, fadeOut);
|
||||
st.obj.position.y += st.up * dt;
|
||||
st.obj.position.x += Math.sin(t * 0.6 + st.seed) * 0.3 * dt;
|
||||
}
|
||||
// haces sobre la ciudad + celdas de fichero resaltadas por actividad
|
||||
for (let i = beams.length - 1; i >= 0; i--) {
|
||||
const b = beams[i], age = t - b.born;
|
||||
if (age > b.life) { S.scene.remove(b.obj); b.obj.geometry.dispose(); b.obj.material.dispose(); beams.splice(i, 1); continue; }
|
||||
b.obj.material.opacity = 0.9 * Math.max(0, 1 - age / b.life);
|
||||
}
|
||||
if (cityCity) for (let i = fileActivity.length - 1; i >= 0; i--) {
|
||||
const a = fileActivity[i], age = t - a.born;
|
||||
if (age > a.life) { cityCity.paintFile(a.id, 0); fileActivity.splice(i, 1); continue; }
|
||||
cityCity.paintFile(a.id, Math.max(0, 1 - age / a.life));
|
||||
}
|
||||
if (flyTo) {
|
||||
const k = Math.min(1, (t - flyTo.t0) / flyTo.dur), e = easeInOut(k);
|
||||
S.camera.position.lerpVectors(flyTo.fromPos, flyTo.toPos, e);
|
||||
S.controls.target.lerpVectors(flyTo.fromTarget, flyTo.toTarget, e);
|
||||
if (k >= 1) flyTo = null;
|
||||
}
|
||||
S.controls.update();
|
||||
projectAnchors();
|
||||
S.composer.render();
|
||||
};
|
||||
loop(performance.now());
|
||||
}
|
||||
|
||||
export function closeMind() {
|
||||
open = false;
|
||||
if (raf) { cancelAnimationFrame(raf); raf = null; }
|
||||
lastMs = 0;
|
||||
try { widget?.pause(); } catch {}
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
// para tests/depuración
|
||||
export function _debug() {
|
||||
return {
|
||||
starCount: stars.length, hasCity: !!cityCity, profileCount: ceo.getProfiles().length,
|
||||
beamCount: beams.length, fileActivityCount: fileActivity.length,
|
||||
cameraPos: S ? [S.camera.position.x, S.camera.position.y, S.camera.position.z] : null,
|
||||
flying: !!flyTo,
|
||||
};
|
||||
}
|
||||
export function _debugFocusOn(id) { const p = anchorMap.get(id); if (p) focusOn(p, 46); }
|
||||
51
web/js/model-cache.js
Normal file
51
web/js/model-cache.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// Arranque de la caché de modelos: (1) pide almacenamiento PERSISTENTE para que
|
||||
// el navegador no desaloje los pesos cacheados, y (2) registra el service worker
|
||||
// (sw.js) y espera a que CONTROLE la página antes de descargar nada — así hasta
|
||||
// la primera descarga queda cacheada. Idempotente y a prueba de fallos.
|
||||
let started = null;
|
||||
|
||||
export function ensureModelCache() {
|
||||
if (started) return started;
|
||||
started = (async () => {
|
||||
try {
|
||||
if (navigator.storage && navigator.storage.persist) {
|
||||
const persisted = navigator.storage.persisted ? await navigator.storage.persisted() : false;
|
||||
const ok = persisted || await navigator.storage.persist();
|
||||
console.log('[elffuss] almacenamiento persistente:', ok);
|
||||
}
|
||||
} catch { /* no soportado */ }
|
||||
|
||||
if (!('serviceWorker' in navigator)) return false;
|
||||
try {
|
||||
await navigator.serviceWorker.register('sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) {
|
||||
await new Promise(res => {
|
||||
const t = setTimeout(res, 1500);
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => { clearTimeout(t); res(); }, { once: true });
|
||||
});
|
||||
}
|
||||
console.log('[elffuss] caché de modelos activa (service worker)');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[elffuss] service worker no disponible:', e.message);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
return started;
|
||||
}
|
||||
|
||||
// Espacio ocupado por la caché (para mostrarlo en Ajustes).
|
||||
export async function cacheEstimate() {
|
||||
try {
|
||||
const e = await navigator.storage.estimate();
|
||||
return { usage: e.usage || 0, quota: e.quota || 0, persisted: navigator.storage.persisted ? await navigator.storage.persisted() : false };
|
||||
} catch { return { usage: 0, quota: 0, persisted: false }; }
|
||||
}
|
||||
|
||||
// Vaciar la caché de modelos (botón en Ajustes).
|
||||
export async function clearModelCache() {
|
||||
if (navigator.serviceWorker && navigator.serviceWorker.controller)
|
||||
navigator.serviceWorker.controller.postMessage('clear-models');
|
||||
try { await caches.delete('elffuss-models-v1'); } catch { /* */ }
|
||||
}
|
||||
42
web/js/model-config.js
Normal file
42
web/js/model-config.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Modelos que puede cargar el proveedor ONNX (transformers.js, WebGPU/wasm).
|
||||
//
|
||||
// Registro, no un solo modelo: el usuario elige, y por defecto se autoelige el
|
||||
// mayor que entre en su máquina (ver pickLocalBrain en main.js).
|
||||
//
|
||||
// Límite práctico medido (ver coordinacion/ERRORES.md E-005): un ONNX de q4
|
||||
// >~1 GB en disco revienta onnxruntime-web (OOM del heap wasm de 4 GB). Por eso
|
||||
// los ONNX de aquí son pequeños; los grandes (Gemma) van por LiteRT-LM.
|
||||
export const ONNX_MODELS = {
|
||||
'elffuss-lm': {
|
||||
key: 'elffuss-lm',
|
||||
label: 'Elffuss LM (healed · LFM2.5-1.2B)',
|
||||
id: 'KikoCis/Elffuss-LM-1.2B-ONNX', // nuestro heal agéntico de LFM2.5
|
||||
dtype: 'q4', // ¡NO q4f16! este modelo genera basura vía WebGPU con q4f16
|
||||
approxMB: 850,
|
||||
selfHosted: false,
|
||||
basePath: '/models/',
|
||||
},
|
||||
'qwen3.5-0.8b': {
|
||||
key: 'qwen3.5-0.8b',
|
||||
label: 'Qwen3.5-0.8B (WebGPU)',
|
||||
id: 'onnx-community/Qwen3.5-0.8B-ONNX', // más nuevo que el 3-0.6B; verificado in-browser
|
||||
dtype: 'q4f16', // q4 peta con bad_alloc en este modelo; q4f16 genera limpio
|
||||
approxMB: 600,
|
||||
selfHosted: false,
|
||||
basePath: '/models/',
|
||||
reasoning: true, // Qwen3.5 híbrido con modo «thinking» (se limpia en onnx.js)
|
||||
},
|
||||
};
|
||||
|
||||
// Modelo ONNX activo. `let` con export = binding vivo: onnx.js ve el cambio
|
||||
// cuando setOnnxModel() reasigna. Por defecto, el nuestro (compatibilidad).
|
||||
export let MODEL = ONNX_MODELS['elffuss-lm'];
|
||||
|
||||
export function setOnnxModel(key) {
|
||||
if (ONNX_MODELS[key]) MODEL = ONNX_MODELS[key];
|
||||
return MODEL;
|
||||
}
|
||||
|
||||
// Los modelos EXTERNOS (OpenAI, Anthropic, Ollama local incl. Qwen3.8-27B, y el
|
||||
// servidor Ornith) son configuración avanzada opt-in → js/settings.js +
|
||||
// js/providers/api.js. Qwen3.8-27B NO cabe en el navegador; se usa por ahí.
|
||||
122
web/js/providers/api.js
Normal file
122
web/js/providers/api.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// Proveedor genérico para APIs externas (configuración avanzada):
|
||||
// - kind 'openai' → /chat/completions (OpenAI, Ollama, llama-server…)
|
||||
// - kind 'anthropic' → /v1/messages (Claude)
|
||||
// Las llamadas salen DIRECTAS del navegador del usuario al proveedor; la clave
|
||||
// no pasa por ningún servidor nuestro. Streaming SSE en ambos dialectos.
|
||||
import { packHistoryAsync } from '../context.js';
|
||||
|
||||
let cfg = null;
|
||||
export let name = 'API';
|
||||
|
||||
export function configure(c) { cfg = c; name = c.label; }
|
||||
|
||||
export async function load() {
|
||||
if (!cfg) throw new Error('proveedor sin configurar');
|
||||
if (cfg.kind !== 'anthropic' && !cfg.apiKey && !cfg.baseURL.includes('localhost') && cfg.baseURL !== '/v1')
|
||||
throw new Error('falta la clave de API (config avanzada)');
|
||||
}
|
||||
|
||||
export async function chat(history, system, onToken = () => {}, signal = null) {
|
||||
return cfg.kind === 'anthropic'
|
||||
? anthropicChat(history, system, onToken)
|
||||
: openaiChat(history, system, onToken);
|
||||
}
|
||||
|
||||
// ---- OpenAI-compatible ----
|
||||
async function openaiChat(history, system, onToken) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (cfg.apiKey) headers.Authorization = 'Bearer ' + cfg.apiKey;
|
||||
// packHistoryAsync: el empaquetado puede tener que codificar embeddings, así
|
||||
// que se resuelve ANTES de armar el cuerpo. Con el lado semántico apagado
|
||||
// devuelve exactamente lo mismo que la versión síncrona de siempre.
|
||||
const packed = await packHistoryAsync(history, 3000);
|
||||
const body = {
|
||||
model: cfg.model,
|
||||
messages: [{ role: 'system', content: system }, ...packed],
|
||||
stream: true,
|
||||
max_tokens: cfg.maxTokens || 1024,
|
||||
};
|
||||
if (cfg.temperature != null) body.temperature = cfg.temperature;
|
||||
if (cfg.top_p != null) body.top_p = cfg.top_p;
|
||||
if (cfg.thinking != null) body.chat_template_kwargs = { enable_thinking: cfg.thinking };
|
||||
|
||||
const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/chat/completions', {
|
||||
signal,
|
||||
method: 'POST', headers, body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
|
||||
|
||||
let out = '';
|
||||
await readSSE(res.body, payload => {
|
||||
if (payload === '[DONE]') return;
|
||||
const d = JSON.parse(payload).choices?.[0]?.delta || {};
|
||||
if (d.reasoning_content) onToken(d.reasoning_content);
|
||||
if (d.content) { out += d.content; onToken(d.content); }
|
||||
});
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
// ---- Anthropic Messages ----
|
||||
async function anthropicChat(history, system, onToken) {
|
||||
const packed = await packHistoryAsync(history, 3000);
|
||||
const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': cfg.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: cfg.model,
|
||||
max_tokens: cfg.maxTokens || 1024,
|
||||
system,
|
||||
stream: true,
|
||||
messages: forAnthropic(packed),
|
||||
}),
|
||||
});
|
||||
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
|
||||
|
||||
let out = '';
|
||||
await readSSE(res.body, payload => {
|
||||
const evt = JSON.parse(payload);
|
||||
if (evt.type === 'content_block_delta' && evt.delta?.text) {
|
||||
out += evt.delta.text; onToken(evt.delta.text);
|
||||
}
|
||||
});
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
// Anthropic exige roles alternos empezando por user; fusiona consecutivos.
|
||||
function forAnthropic(msgs) {
|
||||
const merged = [];
|
||||
for (const m of msgs) {
|
||||
const role = m.role === 'assistant' ? 'assistant' : 'user';
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.role === role) last.content += '\n' + m.content;
|
||||
else merged.push({ role, content: m.content });
|
||||
}
|
||||
if (merged[0]?.role === 'assistant') merged.unshift({ role: 'user', content: '(continúa)' });
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Lector SSE común: invoca fn con el texto tras cada 'data:'.
|
||||
async function readSSE(stream, fn) {
|
||||
const reader = stream.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
try { fn(payload); } catch { /* chunk parcial */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
332
web/js/providers/litert.js
Normal file
332
web/js/providers/litert.js
Normal file
@@ -0,0 +1,332 @@
|
||||
// Gemma-4 vía LiteRT-LM de Google (early preview, solo WebGPU).
|
||||
// Patrón copiado de la demo verificada en agentic-install
|
||||
// (lab/bitacora/posts/08-jspace-live.html).
|
||||
// DECISIÓN 2026-07-14: cerebro = Gemma BASE (builds oficiales litert-community,
|
||||
// formato artisan). NO fine-tune propio: `@litert-lm/core` exige empaquetado
|
||||
// artisan y nuestras conversiones no lo producen (E-010). La agéntica va por el
|
||||
// system prompt (agent.js), no por pesos.
|
||||
export let name = 'Gemma · LiteRT-LM';
|
||||
|
||||
// Builds .litertlm elegibles. Los «-web» OFICIALES de Google (litert-community)
|
||||
// están exportados en formato artisan → SÍ cargan en el navegador (son los que
|
||||
// usaba la demo original). El healed de Elffuss es prefill_decode → hoy no carga
|
||||
// (E-010), por eso está gateado en el selector.
|
||||
export const MODELS = {
|
||||
'gemma-e2b': { url: 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it-web.litertlm', label: 'Gemma-4 E2B', tag: '~2 GB · ligero' },
|
||||
'gemma-e4b': { url: 'https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.litertlm', label: 'Gemma-4 E4B', tag: '~4 GB · el mejor' },
|
||||
'elffuss-e4b': { url: 'https://huggingface.co/KikoCis/Elffuss-Gemma4-E4B-litert/resolve/main/model.litertlm', label: 'Elffuss E4B (healed)', tag: 'modelo propio' },
|
||||
};
|
||||
|
||||
let MODEL_URL = MODELS['gemma-e2b'].url;
|
||||
let curLabel = MODELS['gemma-e2b'].label;
|
||||
export function configure(key) {
|
||||
const m = MODELS[key] || MODELS['gemma-e2b'];
|
||||
MODEL_URL = m.url; curLabel = m.label; name = 'Gemma · LiteRT-LM (' + m.label + ')';
|
||||
}
|
||||
|
||||
let engine = null, conversation = null, sentCount = 0, sys = '';
|
||||
|
||||
// Muestreo. Por defecto VORAZ, que es como venía: mismo prompt, misma salida.
|
||||
// Eso hace que generar N veces cueste N y devuelva una sola respuesta distinta,
|
||||
// así que cualquier técnica de «genera varias y quédate con la mejor» era gasto
|
||||
// puro. Con temperatura y semilla se puede pedir variedad de verdad.
|
||||
const TOP_P = 2;
|
||||
let muestreo = null; // null = voraz
|
||||
export function setSampling(opts) { // {temperature, seed, p} o null
|
||||
const antes = JSON.stringify(muestreo);
|
||||
// «k» es OBLIGATORIO aunque el tipo sea TOP_P: sin él el wasm aborta en seco
|
||||
// con «Aborted()» y se lleva por delante el motor. Comprobado probando formas.
|
||||
muestreo = opts && opts.temperature > 0
|
||||
? { type: TOP_P, k: opts.k ?? 40, p: opts.p ?? 0.95, temperature: opts.temperature, seed: opts.seed ?? 0 }
|
||||
: null;
|
||||
// cambiar el muestreo exige rehacer la conversación: la sesión ya está creada
|
||||
// con los parámetros de antes y no los relee.
|
||||
if (JSON.stringify(muestreo) !== antes) { conversation = null; sentCount = 0; }
|
||||
return muestreo;
|
||||
}
|
||||
export function getSampling() { return muestreo; }
|
||||
export function __engine() { return engine; } // solo para el banco de pruebas
|
||||
|
||||
// Contexto: probamos de mayor a menor hasta el máximo que acepten el bundle y
|
||||
// la memoria GPU — así el contexto queda al tope permitido de serie.
|
||||
const CTX_LADDER = [32768, 16384, 8192, 4096];
|
||||
export let ctxTokens = 4096; // efectivo tras load() (la UI puede leerlo)
|
||||
|
||||
export async function load(onProgress = () => {}) {
|
||||
if (!navigator.gpu) throw new Error('LiteRT-LM necesita WebGPU (Chrome/Edge modernos)');
|
||||
// navigator.gpu puede existir como API sin adaptador real (algunos Linux/
|
||||
// drivers, entornos sandboxed…) — comprobarlo YA evita bajar 2-4 GB para
|
||||
// descubrir el fallo solo al crear el motor, al final de todo.
|
||||
let adapter = null;
|
||||
try { adapter = await navigator.gpu.requestAdapter(); } catch { /* sin adaptador */ }
|
||||
if (!adapter) throw new Error('No hay un adaptador WebGPU real disponible (la API existe pero no hay GPU accesible) — prueba con Elffuss LM, que corre en CPU/wasm.');
|
||||
// VERSIÓN FIJADA a propósito. Sin fijarla, la URL apunta siempre a la última
|
||||
// publicada: el 2026-08-11 salió 0.16.0, jsdelivr NO consigue construirle el
|
||||
// bundle `+esm` (404) y el cerebro Gemma dejó de cargar en producción sin que
|
||||
// nosotros tocáramos una línea. Al subir de versión hay que COMPROBAR que
|
||||
// `https://cdn.jsdelivr.net/npm/@litert-lm/core@<v>/+esm` responde 200.
|
||||
const litertlm = await import('https://cdn.jsdelivr.net/npm/@litert-lm/core@0.15.0/+esm');
|
||||
// El .litertlm lo descargamos NOSOTROS (cache-first en Cache Storage) y se lo
|
||||
// pasamos a Engine.create como Blob (la API acepta string|Blob|ReadableStream).
|
||||
// Motivo: el fetch interno de LiteRT baja el peso con XHR+Range desde un WORKER
|
||||
// que el service worker no intercepta → antes se re-descargaba SIEMPRE. Bajándolo
|
||||
// aquí queda cacheado de verdad y damos progreso real en MB.
|
||||
const model = await cachedModelBlob(MODEL_URL, onProgress);
|
||||
onProgress('Preparando el modelo IA en la GPU…');
|
||||
let lastErr = null;
|
||||
for (const n of CTX_LADDER) {
|
||||
try {
|
||||
engine = await litertlm.Engine.create({ model, mainExecutorSettings: { maxNumTokens: n } });
|
||||
ctxTokens = n;
|
||||
lastErr = null;
|
||||
break;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
// Errores de formato/carga no dependen del contexto: no insistir con la escalera.
|
||||
if (/not supported|tokenizer|format/i.test(String(e?.message))) throw e;
|
||||
onProgress(`Contexto ${n} no cabe, probando ${n / 2}…`);
|
||||
}
|
||||
}
|
||||
if (lastErr) throw lastErr;
|
||||
}
|
||||
|
||||
const MODEL_CACHE = 'elffuss-models-v1';
|
||||
// Devuelve el .litertlm como Blob desde Cache Storage; si no está, lo descarga
|
||||
// con progreso real y lo cachea (persistente). Ante cualquier fallo, devuelve la
|
||||
// URL para que LiteRT lo baje por su cuenta (nunca bloquea la carga del modelo).
|
||||
export async function cachedModelBlob(url, onProgress = () => {}) {
|
||||
// Caché COMPARTIDA + OPFS (runtime propio): un modelo bajado en cualquier web
|
||||
// de Elffuss se reutiliza sin re-descargar. Si no está disponible, caemos al
|
||||
// Cache Storage de siempre.
|
||||
try {
|
||||
const { getModelFile } = await import('../runtime/model-store.js');
|
||||
const f = await getModelFile(url, onProgress);
|
||||
if (f) return f;
|
||||
} catch (e) { console.warn('[elffuss] OPFS/broker no disponible, uso Cache Storage:', e); }
|
||||
|
||||
if (!self.caches) return url;
|
||||
try {
|
||||
const cache = await caches.open(MODEL_CACHE);
|
||||
const hit = await cache.match(url);
|
||||
if (hit) { onProgress('Cargando el modelo IA desde caché (sin descargar)…'); return await hit.blob(); }
|
||||
const net = await fetch(url);
|
||||
if (!net.ok || !net.body) return url;
|
||||
const total = +net.headers.get('content-length') || 0;
|
||||
const t0 = performance.now();
|
||||
// Progreso SIN tee(): con un modelo de gigabytes, tee() crea dos ramas que
|
||||
// se consumen a ritmos distintos y el navegador tiene que bufferizar la
|
||||
// diferencia en memoria → el cache.put acababa reventando y el modelo NO se
|
||||
// cacheaba NUNCA (medido con E4B: 2832 MB bajados y cero guardados). Peor
|
||||
// aún: al fallar se devolvía la URL suelta y `Engine.create({model: <URL>})`
|
||||
// monta un motor que CARGA pero no genera («Aborted()»). Con un
|
||||
// TransformStream hay un solo consumidor: contamos al vuelo y el mismo flujo
|
||||
// va a la caché.
|
||||
let loaded = 0;
|
||||
const counted = net.body.pipeThrough(new TransformStream({
|
||||
transform(chunk, ctrl) {
|
||||
loaded += chunk.byteLength ?? chunk.length;
|
||||
onProgress(fmtBytes(loaded, total, t0));
|
||||
ctrl.enqueue(chunk);
|
||||
},
|
||||
}));
|
||||
const headers = { 'Content-Type': 'application/octet-stream' };
|
||||
if (total) headers['Content-Length'] = String(total);
|
||||
// Cachear GIGABYTES puede fallar de verdad: ventana privada (Cache Storage
|
||||
// en memoria), disco lleno, cuota del origen. Si falla hay que DECIRLO: el
|
||||
// progreso ya ha prometido «se cachea para la próxima vez» y, callándolo,
|
||||
// el usuario se re-baja el modelo entero cada sesión sin saber por qué.
|
||||
try {
|
||||
await cache.put(url, new Response(counted, { headers }));
|
||||
} catch (e) {
|
||||
onProgress(`No se pudo guardar el modelo en caché (${e.name || 'error'}): habrá que descargarlo otra vez la próxima. ` +
|
||||
`Suele ser ventana privada o falta de espacio.`);
|
||||
console.warn('[elffuss] modelo NO cacheado:', e);
|
||||
return url;
|
||||
}
|
||||
const cached = await cache.match(url);
|
||||
if (!cached) { onProgress('No se pudo guardar el modelo en caché: habrá que descargarlo otra vez la próxima.'); return url; }
|
||||
return await cached.blob();
|
||||
} catch (e) {
|
||||
console.warn('[elffuss] caché de modelo no disponible:', e);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
function fmtBytes(loaded, total, t0) {
|
||||
const mb = n => (n / 1048576).toFixed(0);
|
||||
const secs = (performance.now() - t0) / 1000;
|
||||
const spd = secs > 0 ? (loaded / 1048576 / secs).toFixed(1) : '0';
|
||||
return total
|
||||
? `Descargando el modelo IA… ${mb(loaded)}/${mb(total)} MB (${spd} MB/s) · se cachea para la próxima vez`
|
||||
: `Descargando el modelo IA… ${mb(loaded)} MB (${spd} MB/s)`;
|
||||
}
|
||||
|
||||
// Liberar el modelo (vigilante de RAM).
|
||||
export async function unload() {
|
||||
try { engine?.close?.(); } catch { /* mejor esfuerzo */ }
|
||||
engine = null; conversation = null; sentCount = 0;
|
||||
}
|
||||
|
||||
export async function chat(history, system, onToken = () => {}, signal = null) {
|
||||
if (!engine) throw new Error('Modelo no cargado');
|
||||
// Comparar solo la parte estática del prompt: el CONTEXTO AHORA va al final
|
||||
// y cambia cada turno — recrear la conversación tiraría el KV-cache.
|
||||
const sysKey = system.slice(0, 200);
|
||||
// Una conversación NUEVA llega con el historial reiniciado (más corto que lo
|
||||
// ya enviado). Sin esta comprobación, el KV-cache conservaba el chat anterior
|
||||
// entero: el modelo seguía «viendo» los ficheros y respuestas del chat de
|
||||
// antes y contestaba sobre ellos. Se notaba como alucinación («ese fichero no
|
||||
// existe») cuando en realidad era memoria de la conversación previa — y es
|
||||
// además una fuga: un chat nuevo no debe ver el contenido del anterior.
|
||||
const reiniciada = history.length <= sentCount;
|
||||
if (!conversation || sysKey !== sys || reiniciada) {
|
||||
sys = sysKey;
|
||||
conversation = await crearConversacion(system);
|
||||
sentCount = 0;
|
||||
}
|
||||
// La conversación LiteRT mantiene su propio KV-cache: enviamos solo lo nuevo.
|
||||
const fresh = history.slice(sentCount).filter(m => m.role === 'user');
|
||||
const nuevos = fresh.length ? fresh : [history.at(-1)];
|
||||
let text = nuevos.map(m => m.content).join('\n');
|
||||
|
||||
// ¿Cabe? Si no: compactar lo anterior y, si ni así, recortar (ver CONTEXTO).
|
||||
let antes = await tokensUsados();
|
||||
if (antes != null && estimaTokens(text) > ctxTokens - antes - RESERVA_SALIDA) {
|
||||
const ocupaba = estimaTokens(text);
|
||||
if (sentCount > 0) { await rehacerCompactada(history, sentCount, system, nuevos); antes = await tokensUsados(); }
|
||||
const largo = text.length;
|
||||
text = ajustarAlContexto(nuevos, libreEnCaracteres(antes, 0.9));
|
||||
console.warn(`[litert] el mensaje nuevo (~${ocupaba} tokens) no cabía en un contexto de ${ctxTokens}: ` +
|
||||
`${sentCount > 0 ? 'conversación compactada' : 'conversación vacía'}, mensaje ${text.length < largo ? 'recortado' : 'entero'} ` +
|
||||
`· usados ${antes} · se mandan ${text.length} caracteres (${largo} tenía) · ${charsPorToken} caracteres/token`);
|
||||
}
|
||||
|
||||
let out = '';
|
||||
const enviar = async t => {
|
||||
for await (const chunk of conversation.sendMessageStreaming(t)) {
|
||||
if (signal?.aborted) break; // parar: se devuelve lo generado hasta aquí
|
||||
for (const item of (chunk.content || []))
|
||||
if (item.type === 'text') { out += item.text; onToken(item.text); }
|
||||
}
|
||||
};
|
||||
try {
|
||||
await enviar(text);
|
||||
} catch (e) {
|
||||
// Si falla ANTES de escribir nada, lo normal es que no cupiera (la cuenta de
|
||||
// caracteres por token se quedó corta): se rehace compactada, se recorta a la
|
||||
// mitad de lo libre y se intenta UNA vez más. Si ya había escrito algo,
|
||||
// reintentar lo duplicaría: se deja subir el error.
|
||||
if (out || signal?.aborted) throw e;
|
||||
console.warn(`[litert] el envío falló sin respuesta (usados ${antes} · ${text.length} caracteres); ` +
|
||||
`rehago la conversación compactada y reintento una vez: ${e?.message || e}`);
|
||||
await rehacerCompactada(history, sentCount, system, nuevos);
|
||||
antes = await tokensUsados();
|
||||
text = ajustarAlContexto(nuevos, libreEnCaracteres(antes, 0.5));
|
||||
console.warn(`[litert] reintento: usados ${antes} · se mandan ${text.length} caracteres`);
|
||||
await enviar(text);
|
||||
}
|
||||
// Se marca como enviado DESPUÉS de enviarlo. Antes se marcaba al principio, y
|
||||
// un envío fallido dejaba el mensaje por enviado sin haber llegado nunca.
|
||||
sentCount = history.length;
|
||||
const despues = await tokensUsados();
|
||||
if (antes != null && despues != null && despues - antes > 64)
|
||||
charsPorToken = Math.min(2, Math.max(1.2, (text.length + out.length) / (despues - antes)));
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
function crearConversacion(system) {
|
||||
return engine.createConversation({
|
||||
preface: { messages: [{ role: 'system', content: system }] },
|
||||
// Exprimir el navegador: no persistir los tokens de canal (tool-call/thinking)
|
||||
// del modelo en el KV-cache → libera KV → más contexto útil. Y prefill del
|
||||
// system prompt al crear la conversación → primera respuesta más rápida.
|
||||
filterChannelContentFromKvCache: true,
|
||||
prefillPrefaceOnInit: true,
|
||||
...(muestreo ? { sessionConfig: { samplerParams: muestreo } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ── CONTEXTO: que un resultado grande no tumbe la conversación ───────────────
|
||||
// Los proveedores sin estado pasan el historial por acer-core en cada llamada.
|
||||
// Este no: LiteRT guarda la conversación en su caché KV y aquí solo se le manda
|
||||
// lo nuevo, así que NADA la empaquetaba. Y lo nuevo puede ser enorme: fs.read
|
||||
// devuelve hasta 200.000 caracteres, decenas de miles de tokens, con un
|
||||
// contexto de 4.096 a 32.768. Antes de mandar se mira lo que queda
|
||||
// (getTokenCount) y, si no cabe:
|
||||
// 1. se rehace la conversación con lo anterior EMPAQUETADO por acer-core
|
||||
// —puntuado con la pregunta de ahora— dentro del prompt de sistema. Va como
|
||||
// texto y no como mensajes con rol a propósito: qué roles acepta el
|
||||
// preámbulo de Gemma no está documentado, y el texto no depende de eso;
|
||||
// 2. si ni así cabe, se recortan por el medio los resultados de herramienta
|
||||
// del mensaje nuevo (cabeza y cola, que es donde suele estar lo que
|
||||
// importa), avisando al modelo de que puede pedir un trozo concreto.
|
||||
// Si cabe, el mensaje sale exactamente igual que antes.
|
||||
//
|
||||
// Los caracteres por token no se saben de antemano (el tokenizador vive dentro
|
||||
// del wasm): se empieza en 2 y lo medido con getTokenCount solo puede BAJARLO.
|
||||
// Medido con Gemma E4B: la prosa sale a ~2,8 y un informe cargado de cifras a
|
||||
// 2,35. Dejando que subiera, el saludo calibraba a 2,81 y el informe de después
|
||||
// se mandaba contado con esa cifra: 77.112 caracteres que eran ~32.800 tokens, y
|
||||
// el envío fallaba («Too many tokens requested») hasta el reintento, pagando dos
|
||||
// veces la espera. Una proporción medida con un contenido no vale para otro, y
|
||||
// equivocarse por arriba cuesta un fallo; por abajo, solo recortar algo de más.
|
||||
const RESERVA_SALIDA = 1024; // tokens que se dejan para que conteste
|
||||
const FRAC_HISTORIAL = 0.35; // techo del historial empaquetado al rehacer
|
||||
let charsPorToken = 2;
|
||||
const estimaTokens = s => Math.ceil(s.length / charsPorToken);
|
||||
|
||||
async function tokensUsados() {
|
||||
try { const n = await conversation.getTokenCount(); return Number.isFinite(n) ? n : null; }
|
||||
catch { return null; }
|
||||
}
|
||||
const libreEnCaracteres = (usados, fraccion) =>
|
||||
Math.floor(Math.max(256, ctxTokens - (usados ?? 0) - RESERVA_SALIDA) * charsPorToken * fraccion);
|
||||
|
||||
async function rehacerCompactada(history, hasta, system, nuevos) {
|
||||
let bloque = '';
|
||||
const previos = history.slice(0, hasta);
|
||||
if (previos.length) {
|
||||
const { packHistoryAsync } = await import('../context.js');
|
||||
const { estimateTokens } = await import('../acer-core.js');
|
||||
// El presupuesto de acer-core va en SUS tokens estimados, no en los del modelo.
|
||||
const caracteres = previos.reduce((a, m) => a + m.content.length, 0) || 1;
|
||||
const suyos = previos.reduce((a, m) => a + estimateTokens(m.content), 0);
|
||||
const presupuesto = Math.max(100, Math.round(ctxTokens * FRAC_HISTORIAL * charsPorToken * suyos / caracteres));
|
||||
// La pregunta de AHORA va al final para que acer-core puntúe con ella; luego se quita.
|
||||
const noEsResultado = m => m.role === 'user' && !m.content.startsWith('[resultado');
|
||||
const pregunta = ([...nuevos].reverse().find(noEsResultado) || [...history].reverse().find(noEsResultado))?.content || '';
|
||||
const empaquetado = (await packHistoryAsync([...previos, { role: 'user', content: pregunta }], presupuesto)).slice(0, -1);
|
||||
bloque = '\n\nCONVERSACIÓN HASTA AHORA (no cabía entera: va lo más relevante para lo que se pide ahora):\n' +
|
||||
empaquetado.map(m => `${m.role === 'assistant' ? 'Elffuss' : 'Usuario'}: ${m.content}`).join('\n');
|
||||
}
|
||||
try { await conversation?.delete?.(); } catch { /* ya no estaba */ }
|
||||
conversation = await crearConversacion(system + bloque);
|
||||
}
|
||||
|
||||
function ajustarAlContexto(nuevos, maxCaracteres) {
|
||||
const entero = nuevos.map(m => m.content).join('\n');
|
||||
if (entero.length <= maxCaracteres) return entero;
|
||||
// Un mensaje puede traer VARIOS resultados seguidos (Elffuss Code junta los de
|
||||
// un mismo paso): se recorta cada uno por su lado, o el primero se comería el
|
||||
// sitio de los demás.
|
||||
const mensajes = nuevos.map(m => m.content.split(/\n\n(?=\[resultado )/));
|
||||
const esResultado = b => b.startsWith('[resultado');
|
||||
const deResultados = mensajes.flat().reduce((a, b) => a + (esResultado(b) ? b.length : 0), 0);
|
||||
const hueco = Math.max(0, maxCaracteres - (entero.length - deResultados));
|
||||
const t = deResultados
|
||||
? mensajes.map(bs => bs.map(b => (esResultado(b) ? recortarPorElMedio(b, Math.floor(b.length * hueco / deResultados)) : b)).join('\n\n')).join('\n')
|
||||
: entero;
|
||||
return t.length <= maxCaracteres ? t : recortarPorElMedio(t, maxCaracteres);
|
||||
}
|
||||
|
||||
function recortarPorElMedio(s, max) {
|
||||
if (s.length <= max) return s;
|
||||
const aviso = `\n… [recortado: ${s.length - max} caracteres no caben en el contexto; si hace falta, pide un fragmento concreto] …\n`;
|
||||
const util = Math.max(0, max - aviso.length);
|
||||
const cabeza = Math.ceil(util * 0.7);
|
||||
return s.slice(0, cabeza) + aviso + s.slice(s.length - (util - cabeza));
|
||||
}
|
||||
|
||||
// Solo para tests/litert-contexto.mjs: probar chat() en node con un motor de mentira.
|
||||
export function __usarMotor(motor, contexto) {
|
||||
engine = motor; ctxTokens = contexto; conversation = null; sentCount = 0; sys = ''; charsPorToken = 2;
|
||||
}
|
||||
84
web/js/providers/onnx.js
Normal file
84
web/js/providers/onnx.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// Modelo vía ONNX Runtime Web (transformers.js, WebGPU con fallback a wasm).
|
||||
// Patrón copiado de la demo verificada en agentic-install
|
||||
// (lab/bitacora/posts/08-jspace-live.html): dtype 'q4' obligatorio — q4f16
|
||||
// genera basura vía WebGPU incluso con shader-f16.
|
||||
import { MODEL, ONNX_MODELS, setOnnxModel } from '../model-config.js';
|
||||
import { packHistoryAsync } from '../context.js';
|
||||
|
||||
export let name = MODEL.label;
|
||||
|
||||
// Elegir qué ONNX cargar. Si cambia respecto al ya cargado, se descarta la
|
||||
// sesión para que load() cree la nueva (un modelo distinto = otra sesión).
|
||||
let generator = null, TextStreamer = null, cargadoKey = null;
|
||||
export function configure(key) {
|
||||
const antes = MODEL.key;
|
||||
setOnnxModel(key);
|
||||
name = MODEL.label;
|
||||
if (MODEL.key !== cargadoKey && generator) { try { generator?.dispose?.(); } catch {} generator = null; }
|
||||
return MODEL.key !== antes;
|
||||
}
|
||||
export function models() { return Object.values(ONNX_MODELS); }
|
||||
|
||||
export async function load(onProgress = () => {}) {
|
||||
if (generator) return; // un solo modelo: nunca recargar/duplicar la sesión
|
||||
const tf = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4');
|
||||
TextStreamer = tf.TextStreamer;
|
||||
if (MODEL.selfHosted) {
|
||||
tf.env.allowRemoteModels = false;
|
||||
tf.env.localModelPath = MODEL.basePath;
|
||||
}
|
||||
// navigator.gpu puede EXISTIR como API sin que haya un adaptador real
|
||||
// (ciertos Linux/drivers, entornos sandboxed, navegadores headless…) — usar
|
||||
// solo la presencia del objeto como señal hace que pipeline() intente
|
||||
// WebGPU, falle con "Failed to get GPU adapter" DESPUÉS de descargar el
|
||||
// modelo entero, y en preloadModel() eso encadena a probar Gemma (varios
|
||||
// GB) para nada. Comprobar el adaptador de verdad antes de elegir.
|
||||
let device = 'wasm';
|
||||
if (navigator.gpu) {
|
||||
try { device = (await navigator.gpu.requestAdapter()) ? 'webgpu' : 'wasm'; }
|
||||
catch { device = 'wasm'; }
|
||||
}
|
||||
generator = await tf.pipeline('text-generation', MODEL.id, {
|
||||
device,
|
||||
dtype: MODEL.dtype,
|
||||
progress_callback: onProgress,
|
||||
});
|
||||
cargadoKey = MODEL.key;
|
||||
}
|
||||
|
||||
// Liberar el modelo (vigilante de RAM): suelta los buffers wasm/WebGPU.
|
||||
export async function unload() {
|
||||
try { await generator?.dispose?.(); } catch { /* mejor esfuerzo */ }
|
||||
generator = null; cargadoKey = null;
|
||||
}
|
||||
|
||||
export async function chat(history, system, onToken = () => {}, signal = null) {
|
||||
if (!generator) throw new Error('Modelo no cargado');
|
||||
// ACE-lite: eviction por relevancia. Presupuesto amplio (LFM2.5 aguanta
|
||||
// contexto largo); el tope POR MENSAJE (context.js) evita que un README
|
||||
// gigante dispare «Too many tokens requested».
|
||||
const messages = [{ role: 'system', content: system }, ...(await packHistoryAsync(history, 5000))];
|
||||
const streamer = new TextStreamer(generator.tokenizer, {
|
||||
skip_prompt: true,
|
||||
skip_special_tokens: true,
|
||||
callback_function: t => { if (!signal?.aborted) onToken(t); },
|
||||
});
|
||||
const out = await generator(messages, {
|
||||
max_new_tokens: 1024,
|
||||
do_sample: false, // determinista: los tool calls JSON lo agradecen
|
||||
repetition_penalty: 1.1,
|
||||
return_full_text: false,
|
||||
streamer,
|
||||
});
|
||||
const gen = out[0].generated_text;
|
||||
let txt = (typeof gen === 'string' ? gen : gen.at(-1).content);
|
||||
// Modelos de razonamiento (Qwen3): fuera el <think>. Si quedó abierto por el
|
||||
// tope de tokens, nos quedamos con lo de después de la última apertura.
|
||||
if (txt.includes('<think>')) {
|
||||
txt = txt.replace(/<think>[\s\S]*?<\/think>/g, '');
|
||||
const i = txt.lastIndexOf('<think>');
|
||||
if (i !== -1) txt = txt.slice(i + 7);
|
||||
txt = txt.replace(/<\/?think>/g, '');
|
||||
}
|
||||
return txt.trim();
|
||||
}
|
||||
48
web/js/providers/rules.js
Normal file
48
web/js/providers/rules.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Modo básico de Elffuss Code: órdenes deterministas sin modelo.
|
||||
export const name = 'Básico (sin modelo)';
|
||||
export async function load() {}
|
||||
|
||||
const call = obj => '```tool\n' + JSON.stringify(obj) + '\n```';
|
||||
|
||||
const HELP = `Estoy en modo básico (sin modelo). Entiendo:
|
||||
• «árbol» / «qué hay en el proyecto»
|
||||
• «lee <archivo>» · «abre <archivo>»
|
||||
• «busca <texto>»
|
||||
• «escribe <archivo>: <contenido>»
|
||||
Para programar de verdad, carga el modelo local (selector 🧠) o configura uno en ⚙️.`;
|
||||
|
||||
export async function chat(history, systemPrompt) {
|
||||
const last = history[history.length - 1];
|
||||
const text = (last?.content || '').trim();
|
||||
|
||||
// 🎯 Modo Objetivo (goal.js) también funciona en modo básico: un plan fijo
|
||||
// de 2 tareas (explorar + escribir), para poder probar/usar el planificador
|
||||
// sin depender de un modelo real cargado.
|
||||
if (/^ROL: PLANIFICADOR/.test(systemPrompt || '')) {
|
||||
return JSON.stringify({
|
||||
plan: 'Plan básico (sin modelo) para: ' + text.slice(0, 60),
|
||||
tasks: [
|
||||
{ title: 'Explorar el proyecto', description: 'Mira el árbol del proyecto para entender qué hay.' },
|
||||
{ title: 'Escribir el resultado', description: 'escribe objetivo.txt: ' + text },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (text.startsWith('[resultado')) {
|
||||
const body = text.slice(text.indexOf('\n') + 1).trim();
|
||||
return body.startsWith('ERROR:') ? 'No pude: ' + body.slice(6).trim() : body;
|
||||
}
|
||||
|
||||
const t = text.toLowerCase();
|
||||
if (/(árbol|arbol|estructura|qué hay|que hay)/.test(t))
|
||||
return call({ tool: 'code.tree', args: {} });
|
||||
const mRead = text.match(/(?:lee|abre|muestra|cat)\s+([\w./-]+\.\w+)/i);
|
||||
if (mRead) return call({ tool: 'code.read', args: { path: mRead[1] } });
|
||||
const mSearch = text.match(/busca(?:r)?\s+(?:d[oó]nde\s+)?(?:se\s+\w+\s+)?["«']?([^"»']{2,60})["»']?$/i);
|
||||
if (mSearch) return call({ tool: 'code.search', args: { query: mSearch[1].trim() } });
|
||||
const mWrite = text.match(/escribe\s+([\w./-]+\.\w+)\s*[:=]\s*([\s\S]+)/i);
|
||||
if (mWrite) return call({ tool: 'code.write', args: { path: mWrite[1], content: mWrite[2] } });
|
||||
|
||||
if (/^(hola|buenas|hey|hi)\b/.test(t)) return '¡Hola! ' + HELP;
|
||||
return HELP;
|
||||
}
|
||||
684
web/js/rlm.js
Normal file
684
web/js/rlm.js
Normal file
@@ -0,0 +1,684 @@
|
||||
// RLM — Recursive Language Models (el modo «Hard Work» de Elffuss).
|
||||
//
|
||||
// Idea (Zhang et al., MIT, 2025): un modelo PEQUEÑO y local puede manejar
|
||||
// entradas ENORMES si no las mete todas de golpe en su ventana, sino que
|
||||
// TROCEA el material, pregunta a cada trozo (map = una sub-llamada al mismo
|
||||
// modelo) y FUNDE las respuestas (reduce). Si lo fundido aún no cabe, se
|
||||
// recurre. El modelo raíz nunca ve todo el contexto a la vez: trabaja por
|
||||
// partes, como un humano leyendo un tocho capítulo a capítulo.
|
||||
//
|
||||
// Trade-off honesto: cambia LATENCIA por CAPACIDAD. Son muchas inferencias del
|
||||
// modelo local (que es lento), así que es para tareas que PUEDEN esperar —
|
||||
// auditar, resumir o revisar una carpeta/repo entero— no para tiempo real.
|
||||
//
|
||||
// El motor es agnóstico del proveedor: solo necesita algo con
|
||||
// `chat(history, system, onToken)` (lo cumplen rules/onnx/litert/api).
|
||||
|
||||
const CHARS_PER_TOK = 4; // misma estimación que context.js
|
||||
const DEFAULTS = {
|
||||
chunkTokens: 1800, // tamaño de cada trozo (deja hueco al prompt del modelo)
|
||||
overlapTokens: 120, // solape para no cortar una idea a la mitad entre trozos
|
||||
maxDepth: 3, // recursión máxima del reduce jerárquico
|
||||
maxChunks: 80, // techo DURO de sub-llamadas del map (coste/tiempo)
|
||||
callTimeoutMs: 120000, // guarda por sub-llamada (un motor colgado no bloquea todo)
|
||||
};
|
||||
|
||||
const clip = (s, n) => (s.length > n ? s.slice(0, n) + '…' : s);
|
||||
|
||||
// Trocea respetando límites de línea cuando es posible (no parte a mitad de
|
||||
// palabra si hay un salto cerca del corte).
|
||||
function chunk(text, chunkChars, overlapChars) {
|
||||
const step = Math.max(1, chunkChars - overlapChars);
|
||||
const out = [];
|
||||
for (let i = 0; i < text.length; i += step) {
|
||||
let end = Math.min(i + chunkChars, text.length);
|
||||
if (end < text.length) {
|
||||
const nl = text.lastIndexOf('\n', end);
|
||||
if (nl > i + chunkChars * 0.6) end = nl; // corta en salto si cae en el último 40%
|
||||
}
|
||||
out.push(text.slice(i, end));
|
||||
if (end >= text.length) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// sub(): UNA inferencia del modelo local sobre un fragmento. Es la recursión.
|
||||
async function sub(provider, system, prompt, timeoutMs) {
|
||||
const run = provider.chat([{ role: 'user', content: prompt }], system, () => {});
|
||||
if (!timeoutMs) return run;
|
||||
let to;
|
||||
const guard = new Promise((_, rej) => { to = setTimeout(() => rej(new Error('sub-llamada agotó el tiempo')), timeoutMs); });
|
||||
try { return await Promise.race([run, guard]); }
|
||||
finally { clearTimeout(to); }
|
||||
}
|
||||
|
||||
const MAP_SYS = 'Eres un analista minucioso. Te doy UN fragmento de un material más grande y una pregunta. Responde SOLO con lo que ESTE fragmento aporte a la pregunta: datos concretos, en pocas frases, con cita textual breve si procede. No inventes lo que no esté en el fragmento. Si el fragmento no aporta nada a la pregunta, responde exactamente: NADA';
|
||||
const REDUCE_SYS = 'Eres un sintetizador. Te doy una pregunta y varios hallazgos extraídos de fragmentos distintos del MISMO material. Fúndelos en una única respuesta, coherente y completa, a la pregunta. Resuelve solapes, no repitas, no inventes lo que no esté en los hallazgos. Responde en el idioma de la pregunta.';
|
||||
|
||||
// Motor RLM. Devuelve { answer, chunks, kept }.
|
||||
export async function hardWork({ question, context, provider, onProgress = () => {}, opts = {} }) {
|
||||
const o = { ...DEFAULTS, ...opts };
|
||||
if (!provider || typeof provider.chat !== 'function') throw new Error('Hard Work necesita un cerebro cargado (elige un modelo local o remoto arriba).');
|
||||
const q = String(question || '').trim();
|
||||
const ctx = String(context || '').trim();
|
||||
if (!q) throw new Error('Hard Work: dime qué quieres saber del material.');
|
||||
if (!ctx) throw new Error('Hard Work: no encontré material que procesar.');
|
||||
|
||||
const chunkChars = o.chunkTokens * CHARS_PER_TOK;
|
||||
let parts = chunk(ctx, chunkChars, o.overlapTokens * CHARS_PER_TOK);
|
||||
const truncated = parts.length > o.maxChunks;
|
||||
if (truncated) parts = parts.slice(0, o.maxChunks);
|
||||
onProgress({ phase: 'plan', chunks: parts.length, chars: ctx.length, truncated });
|
||||
|
||||
// MAP — un modelo local es UN motor, así que las sub-llamadas van en serie.
|
||||
const findings = [];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
onProgress({ phase: 'map', i: i + 1, n: parts.length });
|
||||
let ans;
|
||||
try { ans = await sub(provider, MAP_SYS, `PREGUNTA:\n${q}\n\nFRAGMENTO ${i + 1}/${parts.length}:\n${parts[i]}`, o.callTimeoutMs); }
|
||||
catch { ans = 'NADA'; }
|
||||
ans = String(ans || '').trim();
|
||||
if (ans && !/^NADA\b/i.test(ans)) findings.push(`[${i + 1}] ${ans}`);
|
||||
}
|
||||
onProgress({ phase: 'mapped', kept: findings.length, of: parts.length });
|
||||
if (!findings.length) return { answer: 'Revisé el material por partes y no encontré nada relevante para eso.', chunks: parts.length, kept: 0, truncated };
|
||||
|
||||
const answer = await reduce(provider, q, findings, o, onProgress, 0);
|
||||
return { answer: String(answer || '').trim(), chunks: parts.length, kept: findings.length, truncated };
|
||||
}
|
||||
|
||||
// REDUCE jerárquico: si los hallazgos caben, funde en uno; si no, agrupa,
|
||||
// funde cada grupo y recurre con las respuestas parciales.
|
||||
async function reduce(provider, q, findings, o, onProgress, depth) {
|
||||
const budget = o.chunkTokens * CHARS_PER_TOK * 2; // el reduce puede ver algo más que un trozo
|
||||
const joined = findings.join('\n');
|
||||
if (joined.length <= budget || depth >= o.maxDepth) {
|
||||
onProgress({ phase: 'reduce', depth, final: true });
|
||||
return sub(provider, REDUCE_SYS, `PREGUNTA:\n${q}\n\nHALLAZGOS:\n${clip(joined, budget)}`, o.callTimeoutMs);
|
||||
}
|
||||
const groups = [];
|
||||
let cur = [];
|
||||
let len = 0;
|
||||
for (const f of findings) {
|
||||
if (len + f.length + 1 > budget && cur.length) { groups.push(cur); cur = []; len = 0; }
|
||||
cur.push(f); len += f.length + 1;
|
||||
}
|
||||
if (cur.length) groups.push(cur);
|
||||
onProgress({ phase: 'reduce', depth, groups: groups.length, final: false });
|
||||
const partials = [];
|
||||
for (let g = 0; g < groups.length; g++) {
|
||||
onProgress({ phase: 'reduce-group', depth, g: g + 1, n: groups.length });
|
||||
const p = await sub(provider, REDUCE_SYS, `PREGUNTA:\n${q}\n\nHALLAZGOS (grupo ${g + 1}/${groups.length}):\n${groups[g].join('\n')}`, o.callTimeoutMs);
|
||||
partials.push(`[g${g + 1}] ${String(p || '').trim()}`);
|
||||
}
|
||||
return reduce(provider, q, partials, o, onProgress, depth + 1);
|
||||
}
|
||||
|
||||
// ── Hard Work · CREACIÓN: borrador → autocrítica → reescritura ──────────────
|
||||
// El hermano creativo de RLM. RLM gasta cómputo LEYENDO material enorme; esto
|
||||
// lo gasta MEJORANDO lo que el modelo crea: en vez de una sola pasada, el
|
||||
// modelo pequeño hace un borrador, se autocritica sin piedad y se reescribe
|
||||
// corrigiendo sus propios fallos. Sube la calidad de un modelo modesto sin
|
||||
// cambiar de modelo — solo pensando más veces.
|
||||
const CREATE_SYS = 'Eres Elffuss creando una app web. Respondes SOLO con un documento HTML completo y autocontenido (CSS y JS inline, fondo oscuro). Si es un juego o algo visual, usa <canvas> y que se vea vivo (color, movimiento, efectos). Nada de texto fuera del HTML.';
|
||||
const CRITIQUE_SYS = 'Eres un crítico de videojuegos y front-end, implacable pero útil. Te doy el código de una app/juego web. Enumera 3 a 6 defectos CONCRETOS y accionables: bugs de lógica, controles que faltan, colisiones mal hechas, falta de condición de fin o de puntuación, jugabilidad sosa y pobreza visual (sin color, sin efectos, estático). Sé específico y breve. NO reescribas el código: solo la lista de defectos.';
|
||||
const REWRITE_SYS = 'Eres Elffuss mejorando tu propia app. Te doy el HTML actual y una crítica. Devuelve SOLO el HTML completo y autocontenido MEJORADO que corrige TODOS los puntos de la crítica, conservando lo que ya funcionaba y subiendo el nivel visual (color, brillo, partículas si encaja). Sin explicaciones fuera del HTML.';
|
||||
|
||||
// Saca el documento HTML de la respuesta del modelo (fence ```html o directo).
|
||||
export function extractHtml(text) {
|
||||
const s = String(text || '');
|
||||
const fence = s.match(/```html\s*([\s\S]*?)```/i) || s.match(/```\s*([\s\S]*?)```/);
|
||||
const body = fence ? fence[1] : s;
|
||||
const m = body.match(/<!doctype[\s\S]*<\/html>/i) || body.match(/<html[\s\S]*<\/html>/i);
|
||||
return (m ? m[0] : body).trim();
|
||||
}
|
||||
|
||||
const CONT_SYS = 'Continúas un documento HTML/JS que se cortó a media escritura. Devuelve SOLO la continuación EXACTA desde donde termina —sin repetir nada de lo ya escrito, sin reabrir <html> ni <script>, sin explicaciones— hasta cerrar todas las etiquetas y </html>.';
|
||||
|
||||
// Genera y, si el modelo se quedó sin tokens a media escritura (el HTML no
|
||||
// cierra </html>), pide la continuación desde la cola y la cose. Así el techo
|
||||
// de 1024 tokens deja de truncar apps grandes: se completan por tramos.
|
||||
async function genComplete(provider, system, prompt, { maxCont = 4 } = {}) {
|
||||
let full = String(await provider.chat([{ role: 'user', content: prompt }], system, () => {}) || '');
|
||||
for (let i = 0; i < maxCont && !/<\/html>/i.test(full); i++) {
|
||||
const tail = full.slice(-1400);
|
||||
let cont = String(await provider.chat(
|
||||
[{ role: 'user', content: `FINAL de lo escrito hasta ahora (continúa desde aquí, sin repetirlo):\n${tail}` }],
|
||||
CONT_SYS, () => {}) || '');
|
||||
// Defensa: si el modelo reabre el documento en vez de continuar, recórtalo.
|
||||
cont = cont.replace(/^[\s\S]*?<!doctype html>/i, '').replace(/^[\s\S]*?<html[^>]*>/i, '');
|
||||
if (!cont.trim()) break;
|
||||
full += cont;
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
|
||||
// ── Jurado deliberante (Reasoning Jury, arXiv 2608.12585) ───────────────────
|
||||
// Un juez único detecta mal los defectos: se inventa unos y se le escapan
|
||||
// otros (lo vimos con el crítico único de Hard Work). El paper sustituye al
|
||||
// juez por un JURADO que delibera: cada jurado propone defectos, luego ve los
|
||||
// de los demás y VOTA cuáles son reales, y un moderador se queda con los que
|
||||
// tienen apoyo. Aquí el jurado es un mismo modelo con lentes distintas —no
|
||||
// modelos distintos, que no caben en el navegador—, así que el efecto es
|
||||
// menor que en el paper; por eso se mide, no se asume.
|
||||
const JURY_LENSES = [
|
||||
{ key: 'logica', name: 'lógica', ask: 'errores de lógica y de estado: variables sin definir, condiciones al revés, bucles que no avanzan, una condición de fin que nunca se cumple, cuentas mal hechas' },
|
||||
{ key: 'interaccion', name: 'interacción', ask: 'controles y respuesta: entradas que no hacen nada, colisiones que no detectan, falta de arranque o de reinicio, nada que indique al usuario qué pasa' },
|
||||
{ key: 'visual', name: 'presentación', ask: 'lo que se ve: elementos con tamaño cero o fuera de pantalla, todo del mismo color, nada dibujado, texto ilegible' },
|
||||
];
|
||||
|
||||
const asLines = t => String(t || '').split('\n')
|
||||
.map(l => l.replace(/^\s*(?:[-*\u2022]|\d+[.)])\s*/, '').trim())
|
||||
.filter(l => l.length > 12 && l.length < 220);
|
||||
|
||||
const normDefect = d => d.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(w => w.length > 3).slice(0, 6).join(' ');
|
||||
|
||||
// Devuelve { defects:[{text,votes,jurors}], rounds } sobre un artefacto.
|
||||
// Normaliza espacios para comparar una cita con el código real.
|
||||
const flat = t => String(t).replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Elige las lentes que TIENEN algo que mirar en este artefacto. Pasarle
|
||||
// «presentación» a una función pura de cálculo no aporta nada: ese jurado o
|
||||
// calla o repite lo que dijo el de lógica, y los repetidos se comían las
|
||||
// plazas de candidatos.
|
||||
function lensesFor(material) {
|
||||
const tieneUI = /<canvas|<button|<input|<form|document\.|addEventListener|innerHTML|querySelector/i.test(material);
|
||||
const tienePintado = /<canvas|getContext|fillRect|drawImage|style\.|css/i.test(material);
|
||||
const out = [JURY_LENSES[0]]; // lógica: siempre
|
||||
if (tieneUI) out.push(JURY_LENSES[1]); // interacción
|
||||
if (tienePintado) out.push(JURY_LENSES[2]); // presentación
|
||||
if (out.length === 1) out.push(JURY_LENSES_EXTRA[0], JURY_LENSES_EXTRA[1]); // código puro
|
||||
else if (out.length === 2) out.push(JURY_LENSES_EXTRA[0]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Lentes para código sin interfaz, donde «interacción» y «presentación» no pintan nada.
|
||||
const JURY_LENSES_EXTRA = [
|
||||
{ key: 'datos', name: 'datos y tipos', ask: 'tipos y datos: conversiones implícitas, valores que llegan como texto cuando se esperan números, undefined/NaN, claves que cambian de tipo' },
|
||||
{ key: 'bordes', name: 'casos límite', ask: 'casos límite y validación: entradas vacías, negativas o fuera de rango, ausencia de comprobaciones, resultados absurdos que nadie detiene' },
|
||||
];
|
||||
|
||||
export async function juryReview({ artifact, provider, onProgress = () => {}, lenses = null, requireEvidence = true }) {
|
||||
const dropped = [];
|
||||
if (!provider?.chat) throw new Error('el jurado necesita un cerebro cargado');
|
||||
const material = String(artifact || '').slice(0, 6000);
|
||||
lenses = lenses || lensesFor(material);
|
||||
onProgress({ phase: 'jury-lenses', lenses: lenses.map(l => l.name) });
|
||||
|
||||
// Ronda 1 — cada jurado propone defectos desde SU lente, sin ver a los demás.
|
||||
const proposals = [];
|
||||
for (const L of lenses) {
|
||||
onProgress({ phase: 'jury-propose', lens: L.name });
|
||||
const sys = `Eres un revisor especializado en ${L.ask}. Te doy el código de una app web. Enumera SOLO los defectos REALES que veas desde tu especialidad. Por cada uno escribe DOS líneas seguidas:
|
||||
DEFECTO: <qué está mal, en una línea>
|
||||
PRUEBA: <copia LITERAL del fragmento de código que lo demuestra, tal cual aparece>
|
||||
Máximo 4 defectos. Si no ves ninguno, escribe NINGUNO. La PRUEBA debe estar copiada del código palabra por palabra; si no puedes copiarla, no incluyas ese defecto.`;
|
||||
const out = await provider.chat([{ role: 'user', content: 'CÓDIGO:\n' + material }], sys, () => {});
|
||||
// Emparejar DEFECTO con su PRUEBA y COMPROBAR que la cita existe de verdad
|
||||
// en el código. Es un filtro determinista: si el modelo no sabe citar, se
|
||||
// lo ha inventado. (El modelo verifica mucho mejor de lo que propone, pero
|
||||
// esto ni siquiera necesita al modelo.)
|
||||
const raw = String(out).split('\n').map(x => x.trim());
|
||||
const found = [];
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const dm = raw[i].match(/^DEFECTO:\s*(.+)/i);
|
||||
if (!dm) continue;
|
||||
const pm = (raw[i + 1] || '').match(/^PRUEBA:\s*(.+)/i);
|
||||
const texto = dm[1].trim();
|
||||
if (texto.length < 12) continue;
|
||||
if (!requireEvidence) { found.push({ texto, cita: pm ? pm[1].trim() : '' }); continue; }
|
||||
const cita = pm ? pm[1].trim().replace(/^[`'"]|[`'"]$/g, '') : '';
|
||||
if (cita.length >= 8 && flat(material).includes(flat(cita))) found.push({ texto, cita });
|
||||
else dropped.push({ lens: L.name, texto, cita: cita.slice(0, 60) });
|
||||
}
|
||||
proposals.push({ lens: L, found });
|
||||
}
|
||||
|
||||
// Candidatos únicos (los jurados repiten el mismo defecto con otras palabras).
|
||||
// Deduplicar por la CITA de código, no por las palabras: dos jurados describen
|
||||
// el mismo fallo con frases distintas («el bucle empieza en 1» y «ignora el
|
||||
// primer elemento») y el deduplicador léxico los dejaba pasar como dos, con lo
|
||||
// que la lista de candidatos se llenaba de repetidos. Si señalan el mismo
|
||||
// fragmento de código, es el mismo defecto.
|
||||
const seen = new Map();
|
||||
for (const p of proposals) for (const d of p.found) {
|
||||
const k = d.cita && d.cita.length >= 8 ? 'c:' + flat(d.cita).slice(0, 80) : 't:' + normDefect(d.texto);
|
||||
if (k && !seen.has(k)) seen.set(k, d.texto);
|
||||
}
|
||||
const candidates = [...seen.values()].slice(0, 12);
|
||||
onProgress({ phase: 'jury-candidates', n: candidates.length });
|
||||
if (!candidates.length) return { defects: [], candidates: 0, dropped };
|
||||
|
||||
// Ronda 2 — DELIBERACIÓN: cada jurado ve TODOS los candidatos (incluidos los
|
||||
// ajenos) y vota cuáles son de verdad. Aquí es donde puede retirar el suyo.
|
||||
const votes = candidates.map(() => 0);
|
||||
for (const L of lenses) {
|
||||
onProgress({ phase: 'jury-vote', lens: L.name });
|
||||
const list = candidates.map((c, i) => `${i + 1}. ${c}`).join('\n');
|
||||
// OJO con el formato: poner "N: SI" como plantilla hacía que el modelo
|
||||
// escribiera literalmente la letra N y no se parseara NINGÚN voto (el
|
||||
// jurado parecía rechazarlo todo). Los modelos pequeños copian el ejemplo
|
||||
// tal cual: hay que darles números de verdad.
|
||||
const sys = `Eres un revisor riguroso (especialidad: ${L.name}). Te doy el código y una lista numerada de defectos propuestos por otros revisores. Para CADA número, mira el código y di si el defecto es REAL. Responde SOLO con una línea por defecto, con su número y SI o NO. Así:
|
||||
1: SI
|
||||
2: NO
|
||||
3: SI
|
||||
Nada más: ni explicaciones ni texto extra. Ante la duda, NO.`;
|
||||
const out = await provider.chat(
|
||||
[{ role: 'user', content: 'CÓDIGO:\n' + material + '\n\nDEFECTOS PROPUESTOS:\n' + list }], sys, () => {});
|
||||
const txt = String(out);
|
||||
let parsed = 0;
|
||||
for (const m of txt.matchAll(/(\d+)\s*[:.)\-]\s*(S[IÍ]|YES|NO)\b/gi)) {
|
||||
const i = +m[1] - 1;
|
||||
parsed++;
|
||||
if (i >= 0 && i < votes.length && /^(s[ií]|yes)$/i.test(m[2])) votes[i]++;
|
||||
}
|
||||
// Respaldo: si el modelo ignoró el formato numerado, leer los SI/NO en orden.
|
||||
if (!parsed) {
|
||||
const seq = [...txt.matchAll(/\b(S[IÍ]|YES|NO)\b/gi)].map(m => m[1]);
|
||||
seq.slice(0, votes.length).forEach((v, i) => { if (/^(s[ií]|yes)$/i.test(v)) votes[i]++; });
|
||||
}
|
||||
onProgress({ phase: 'jury-parsed', lens: L.name, parsed });
|
||||
}
|
||||
|
||||
// Moderador — se queda con los que tienen apoyo de la mayoría del jurado.
|
||||
const need = Math.ceil(lenses.length / 2);
|
||||
const defects = candidates.map((text, i) => ({ text, votes: votes[i] }))
|
||||
.filter(d => d.votes >= need)
|
||||
.sort((a, b) => b.votes - a.votes);
|
||||
onProgress({ phase: 'jury-verdict', kept: defects.length, of: candidates.length });
|
||||
onProgress({ phase: 'jury-evidence', dropped: dropped.length });
|
||||
return { defects, candidates: candidates.length, jurors: lenses.length, dropped };
|
||||
}
|
||||
|
||||
|
||||
// ── Edición QUIRÚRGICA en vez de reescritura completa ───────────────────────
|
||||
// Reescribir el documento entero para corregir tres cosas hace que el modelo
|
||||
// regenere de cero lo que ya funcionaba, y por el camino rompe la mecánica.
|
||||
// Aquí solo se le piden los CAMBIOS, y los aplica este código: si el fragmento
|
||||
// a buscar no está tal cual en el documento, ese cambio se descarta. El modelo
|
||||
// propone, el código decide — igual que con las citas del jurado.
|
||||
const PATCH_SYS = `Eres Elffuss arreglando tu propia app. Te doy el HTML actual y una lista de defectos COMPROBADOS ejecutandola.
|
||||
|
||||
Arregla ESOS defectos y nada mas. No reescribas el documento. Devuelve solo los cambios, asi:
|
||||
|
||||
BUSCAR
|
||||
<las lineas actuales, copiadas LITERALES del HTML de arriba>
|
||||
CAMBIAR
|
||||
<las lineas nuevas que las sustituyen>
|
||||
FIN
|
||||
|
||||
Puedes usar VARIAS lineas en cada parte: si hace falta anadir codigo nuevo (por ejemplo unos addEventListener de teclado), copia en BUSCAR una linea de referencia que exista y ponla en CAMBIAR seguida del codigo nuevo.
|
||||
|
||||
Reglas: lo de BUSCAR debe existir palabra por palabra en el HTML y aparecer una sola vez. BUSCAR y CAMBIAR nunca pueden ser iguales, eso no arregla nada. Maximo 4 parches, todos dirigidos a los defectos de la lista. No toques colores, textos ni nada que ya funcione.`;
|
||||
|
||||
export function applyPatches(html, respuesta) {
|
||||
const texto = String(respuesta || '');
|
||||
let out = html;
|
||||
const aplicados = [], fallidos = [];
|
||||
|
||||
// Los parches se recogen en los DOS formatos. El de una línea era el único
|
||||
// que había, y con él los defectos importantes son INARREGLABLES por
|
||||
// construcción: añadir unos listeners de teclado necesita varias líneas, así
|
||||
// que el modelo solo podía hacer retoques cosméticos (medido: cambiaba el
|
||||
// verde de la serpiente mientras el defecto era que no responde a las teclas).
|
||||
const parches = [];
|
||||
// bloque multilínea: BUSCAR \n …líneas… \n CAMBIAR \n …líneas… \n FIN
|
||||
const bloque = /^[ \t]*BUSCAR[ \t]*$\r?\n([\s\S]*?)^[ \t]*CAMBIAR[ \t]*$\r?\n([\s\S]*?)^[ \t]*FIN[ \t]*$/gim;
|
||||
for (const m of texto.matchAll(bloque))
|
||||
parches.push({ buscar: m[1].replace(/\s+$/, ''), cambiar: m[2].replace(/\s+$/, '') });
|
||||
// de una línea: BUSCAR: … / CAMBIAR: …
|
||||
const lineas = texto.split('\n').map(x => x.trim());
|
||||
for (let i = 0; i < lineas.length; i++) {
|
||||
const b = lineas[i].match(/^BUSCAR:\s*(.+)/i); if (!b) continue;
|
||||
const c = (lineas[i + 1] || '').match(/^CAMBIAR:\s*(.+)/i); if (!c) continue;
|
||||
parches.push({ buscar: b[1].trim(), cambiar: c[1].trim() });
|
||||
}
|
||||
|
||||
const norm = t => t.replace(/[ \t]+/g, ' ').trim();
|
||||
for (const { buscar, cambiar } of parches) {
|
||||
if (buscar.length < 6) continue;
|
||||
// Un parche que no cambia nada se contaba como aplicado y gastaba uno de
|
||||
// los seis huecos. Medido: el modelo los emite (BUSCAR y CAMBIAR idénticos).
|
||||
if (buscar === cambiar) { fallidos.push({ buscar: buscar.slice(0, 60), motivo: 'no cambia nada' }); continue; }
|
||||
|
||||
const veces = out.split(buscar).length - 1;
|
||||
if (veces === 1) { out = out.replace(buscar, cambiar); aplicados.push(buscar.slice(0, 60)); continue; }
|
||||
if (veces > 1) { fallidos.push({ buscar: buscar.slice(0, 60), veces, motivo: 'aparece varias veces' }); continue; }
|
||||
|
||||
// No está literal: puede ser solo la sangría o los espacios, que es el
|
||||
// error de cita más común. Se busca por líneas normalizadas, y solo se
|
||||
// sustituye si el bloque es ÚNICO — nunca una coincidencia dudosa.
|
||||
const bl = buscar.split('\n').map(norm);
|
||||
const hl = out.split('\n'), hn = hl.map(norm);
|
||||
const donde = [];
|
||||
for (let i = 0; i + bl.length <= hn.length; i++) {
|
||||
let igual = true;
|
||||
for (let j = 0; j < bl.length; j++) if (hn[i + j] !== bl[j]) { igual = false; break; }
|
||||
if (igual) { donde.push(i); if (donde.length > 1) break; }
|
||||
}
|
||||
if (donde.length === 1) {
|
||||
hl.splice(donde[0], bl.length, ...cambiar.split('\n'));
|
||||
out = hl.join('\n');
|
||||
aplicados.push(buscar.slice(0, 60));
|
||||
} else fallidos.push({ buscar: buscar.slice(0, 60), veces: donde.length, motivo: donde.length ? 'ambiguo ignorando espacios' : 'no existe en el HTML' });
|
||||
}
|
||||
return { html: out, aplicados, fallidos };
|
||||
}
|
||||
|
||||
|
||||
// ── Evaluador OBJETIVO: ejecuta el artefacto y mide, no opina ───────────────
|
||||
// fast-agent (evalstate/fast-agent) usa un LLM como evaluador y refina hasta
|
||||
// una nota mínima. La idea es buena, pero preguntarle al modelo qué nota se
|
||||
// pone es preguntarle a la parte que ya demostró alucinar. Aquí el artefacto
|
||||
// se EJECUTA en un iframe aislado y se comprueba qué hace de verdad:
|
||||
//
|
||||
// pinta dibuja algo
|
||||
// vivo lo que dibuja cambia solo (hay bucle)
|
||||
// responde cambia al recibir teclas y ratón
|
||||
// arranca no aparece ya terminado
|
||||
// completo el documento cierra
|
||||
//
|
||||
// Medir esto costó descubrir que contar píxeles premiaba una pantalla de
|
||||
// «GAME OVER» a toda página por encima de un juego jugable.
|
||||
export async function rateArtifact(html, { ms = 2600 } = {}) {
|
||||
if (typeof document === 'undefined') return { nota: 0, detalle: {}, medible: false };
|
||||
// El iframe va SIN allow-same-origin a propósito: dentro corre código que ha
|
||||
// escrito el modelo y no debe poder tocar la página que lo aloja. Eso hace su
|
||||
// documento ilegible desde fuera, así que se le inyecta una sonda que se mide
|
||||
// a sí misma y devuelve NÚMEROS por postMessage. Nada entra; solo salen datos.
|
||||
const sonda = `<script>(function(){
|
||||
var W=window, P=function(m){try{parent.postMessage(m,'*')}catch(e){}};
|
||||
// Un juego que revienta al mover el ratón se puntuaba 5/5: la sonda medía
|
||||
// que pintaba y que cambiaba, pero era ciega a las excepciones. Se recogen
|
||||
// aquí dentro porque desde fuera el sandbox no deja verlas.
|
||||
var errores=[];
|
||||
// «responde» se medía como «el dibujo cambió tras pulsar», pero un juego que
|
||||
// anima continuamente cambia igual sin tocar nada: un juego SORDO pasaba.
|
||||
// Ahora se mira si llega a registrar algún escuchador de entrada, que es la
|
||||
// propiedad de verdad. Se puede porque la sonda entra ANTES que su código.
|
||||
var escucha = {};
|
||||
var ENTRADA = /^(keydown|keyup|keypress|mousemove|mousedown|mouseup|click|pointerdown|pointermove|touchstart|touchmove)$/;
|
||||
function espia(obj, nombre){
|
||||
if(!obj || !obj.addEventListener) return;
|
||||
var orig = obj.addEventListener;
|
||||
obj.addEventListener = function(tipo){ if(ENTRADA.test(String(tipo))) escucha[tipo]=1; return orig.apply(this, arguments); };
|
||||
}
|
||||
espia(W,'window'); espia(document,'document');
|
||||
if(W.HTMLElement && W.HTMLElement.prototype) espia(W.HTMLElement.prototype,'elementos');
|
||||
// los manejadores puestos como propiedad (onkeydown = ...) no pasan por ahí
|
||||
['onkeydown','onkeyup','onclick','onmousemove','onmousedown'].forEach(function(k){
|
||||
[W, document].forEach(function(o){
|
||||
try{ var v; Object.defineProperty(o,k,{configurable:true,
|
||||
get:function(){return v;}, set:function(f){ if(f) escucha[k]=1; v=f; }}); }catch(e){}
|
||||
});
|
||||
});
|
||||
W.addEventListener('error',function(e){ errores.push(String((e&&e.message)||e).slice(0,120)); });
|
||||
W.addEventListener('unhandledrejection',function(e){ errores.push('promesa: '+String((e&&e.reason&&e.reason.message)||e.reason).slice(0,110)); });
|
||||
function leer(){ var c=document.querySelector('canvas'); if(!c) return null;
|
||||
var x=c.getContext('2d'); if(!x) return null;
|
||||
try{ var d=x.getImageData(0,0,c.width,c.height).data, nb=0, h=0;
|
||||
for(var i=0;i<d.length;i+=16){ if(d[i]||d[i+1]||d[i+2]) nb++;
|
||||
h=(h*31+d[i]+d[i+1]*3+d[i+2]*7)>>>0; }
|
||||
return {nb:nb,h:h,txt:(document.body.innerText||'').slice(0,400).toLowerCase(),err:errores[0]||null,nerr:errores.length,escucha:Object.keys(escucha)};
|
||||
}catch(e){ return {nb:0,h:0,txt:'',err:errores[0]||('canvas: '+e.message),nerr:errores.length+1,escucha:Object.keys(escucha)}; } }
|
||||
W.addEventListener('message',function(e){
|
||||
if(!e.data) return;
|
||||
if(e.data.__sonda){ P({__sonda:1,r:leer()}); return; }
|
||||
// Las pulsaciones hay que despacharlas DENTRO: desde fuera no cruzan el
|
||||
// sandbox, y sin esto «responde» medía simplemente que seguía animando.
|
||||
if(e.data.__tecla){ var k=e.data.__tecla;
|
||||
try{ var ev={key:k,code:k===' '?'Space':k,keyCode:k===' '?32:0,bubbles:true};
|
||||
document.dispatchEvent(new KeyboardEvent('keydown',ev));
|
||||
W.dispatchEvent(new KeyboardEvent('keydown',ev));
|
||||
var c=document.querySelector('canvas');
|
||||
if(c){ var r=c.getBoundingClientRect();
|
||||
var mv=new MouseEvent('mousemove',{clientX:r.left+r.width*0.7,clientY:r.top+r.height/2,bubbles:true});
|
||||
document.dispatchEvent(mv); c.dispatchEvent(mv);
|
||||
c.dispatchEvent(new MouseEvent('click',{bubbles:true})); }
|
||||
}catch(e2){}
|
||||
}
|
||||
});
|
||||
P({__sonda:'lista'});
|
||||
})();<\/script>`;
|
||||
const marco = document.createElement('iframe');
|
||||
marco.setAttribute('sandbox', 'allow-scripts');
|
||||
// DENTRO del viewport a propósito: fuera de pantalla Chrome estrangula
|
||||
// requestAnimationFrame y el juego no anima, así que se medía como muerto un
|
||||
// juego que funciona. Invisible por opacidad, no por posición.
|
||||
marco.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:600px;border:0;' +
|
||||
'opacity:.01;pointer-events:none;z-index:-1';
|
||||
const doc = String(html || '');
|
||||
// La sonda va lo ANTES posible: puesta antes de </body> se registraba después
|
||||
// de que el artefacto ya hubiera corrido, así que era ciega al fallo más
|
||||
// común —el de arranque o parseo— y esos juegos salían «sin errores».
|
||||
// Detrás de <head> y no antes del doctype: delante, el documento cae en modo
|
||||
// quirks y cambia el tamaño del canvas, o sea que cambiaría lo que medimos.
|
||||
marco.srcdoc = /<head[^>]*>/i.test(doc) ? doc.replace(/<head[^>]*>/i, m => m + sonda)
|
||||
: /<html[^>]*>/i.test(doc) ? doc.replace(/<html[^>]*>/i, m => m + sonda)
|
||||
: sonda + doc;
|
||||
|
||||
const respuestas = [];
|
||||
const oir = e => { if (e.data && e.data.__sonda === 1) respuestas.push(e.data.r); };
|
||||
window.addEventListener('message', oir);
|
||||
document.body.appendChild(marco);
|
||||
|
||||
const esperar = t => new Promise(r => setTimeout(r, t));
|
||||
const pedir = async () => {
|
||||
const n = respuestas.length;
|
||||
try { marco.contentWindow.postMessage({ __sonda: 1 }, '*'); } catch { /* */ }
|
||||
for (let i = 0; i < 40 && respuestas.length === n; i++) await esperar(50);
|
||||
// Al expirar el plazo, respuestas.length sigue valiendo n y esto devolvía
|
||||
// LA LECTURA ANTERIOR. Entonces b===a daba «no está vivo» y c===b daba «no
|
||||
// responde» sobre un juego que funciona. Y como el evaluador se usa MIENTRAS
|
||||
// el modelo genera, el iframe llega tarde justo cuando más se le mide: un
|
||||
// juego jugable se puntuaba 0 por no haber contestado a tiempo.
|
||||
return respuestas.length > n ? respuestas[respuestas.length - 1] : null;
|
||||
};
|
||||
|
||||
await esperar(1300);
|
||||
const a = await pedir();
|
||||
await esperar(ms);
|
||||
const b = await pedir();
|
||||
try {
|
||||
for (const k of ['ArrowLeft', 'ArrowRight', 'ArrowUp', ' ', 'w', 's'])
|
||||
marco.contentWindow.postMessage({ __tecla: k }, '*');
|
||||
} catch { /* */ }
|
||||
await esperar(1200);
|
||||
const c = await pedir();
|
||||
// Cuarta lectura, SIN mandar nada más. Hace falta porque «sobrevive» empuja al
|
||||
// modelo a que el juego no arranque hasta la primera pulsación —que es lo
|
||||
// correcto— y entonces «vivo», medido solo ANTES de tocar nada, lo castigaba
|
||||
// justo por hacerlo bien. Las dos señales se contradecían. Ahora vale con que
|
||||
// se mueva solo en algún momento: antes de la entrada, o después de ella.
|
||||
await esperar(1400);
|
||||
const d = await pedir();
|
||||
|
||||
window.removeEventListener('message', oir);
|
||||
marco.remove();
|
||||
|
||||
if (!a || !b || !c || !d) return { nota: -1, detalle: { medible: false }, medible: false,
|
||||
error: 'el artefacto no contestó a tiempo (no es un fallo suyo: no se pudo medir)' };
|
||||
// Las mismas cinco señales que mide el arnés externo, para que la nota de
|
||||
// dentro y la de fuera signifiquen lo mismo y se puedan contrastar.
|
||||
const fallo = [a, b, c, d].map(x => x && x.err).find(Boolean) || null;
|
||||
// El snake generado arrancaba ya moviéndose y chocaba contra la pared en un
|
||||
// segundo y medio: cuando lo abres ya ha terminado. Gráficamente impecable y
|
||||
// pasaba las cinco señales anteriores. «arranca» mira el primer instante;
|
||||
// esto mira si SIGUE vivo unos segundos después sin que nadie juegue.
|
||||
const seAcabo = t => /game\s*over|has perdido|fin del juego|puntuaci[óo]n final/.test(t || '');
|
||||
const detalle = {
|
||||
pinta: a.nb > 0,
|
||||
vivo: !!(a.h !== b.h || c.h !== d.h),
|
||||
// escuchar Y reaccionar. Solo lo segundo daba por bueno un juego sordo que
|
||||
// simplemente seguía animando; solo lo primero daría por bueno uno que
|
||||
// registra el escuchador y no hace nada con él.
|
||||
responde: !!((c && c.escucha && c.escucha.length) && (c && b && b.h !== c.h)),
|
||||
arranca: !seAcabo(a.txt),
|
||||
// Se comprueba en la ÚLTIMA lectura, no en la segunda. Al pedirle al modelo
|
||||
// que no arranque hasta la primera pulsación, mirar antes de tocar nada
|
||||
// daba «sobrevive» por bueno trivialmente: el juego aún no había empezado.
|
||||
// Snake pasó de morir en segundo y medio a esperar y MORIR IGUAL de rápido
|
||||
// en cuanto arrancaba, y así puntuaba 6/6.
|
||||
sobrevive: !seAcabo(d && d.txt) && !seAcabo(b && b.txt),
|
||||
sinErrores: !fallo,
|
||||
};
|
||||
// «pinta» es puerta: sin dibujar nada, un HTML en blanco se llevaba puntos
|
||||
// gratis por «no muestra game over» y «sin errores», ciertos por vacuidad.
|
||||
const nota = detalle.pinta ? Object.values(detalle).filter(Boolean).length : 0;
|
||||
return { nota, maximo: Object.keys(detalle).length, detalle, medible: true, error: fallo, cerrado: /<\/html>/i.test(doc) };
|
||||
}
|
||||
|
||||
// Pedirle a un modelo de 4B «encuentra los defectos de este HTML» es pedirle lo
|
||||
// que peor hace: leer código largo y juzgarlo. Pero tras ejecutarlo YA sabemos
|
||||
// objetivamente qué falla. Esto convierte la medición en la lista de defectos,
|
||||
// que es información dura y no una opinión del propio modelo sobre sí mismo.
|
||||
function defectosMedidos(detalle, error) {
|
||||
if (!detalle) return [];
|
||||
const d = [];
|
||||
if (!detalle.pinta) d.push('No se dibuja NADA en el canvas: revisa que exista el <canvas>, que se coja su contexto 2d y que algo se pinte al arrancar.');
|
||||
if (!detalle.vivo) d.push('La pantalla no cambia sola: falta el bucle de animación. Tiene que haber un requestAnimationFrame que se llame a sí mismo y repinte cada fotograma.');
|
||||
if (!detalle.responde) d.push('No reacciona a la entrada: al pulsar las flechas, W/S o la barra, y al mover el ratón, no cambia nada. Los listeners de teclado deben estar en document o window (no en el canvas, que no tiene foco).');
|
||||
if (!detalle.arranca) d.push('Arranca ya en pantalla de fin de partida: el estado inicial debe ser «jugando», no «game over».');
|
||||
if (!detalle.sobrevive) d.push('La partida se acaba SOLA a los pocos segundos sin que el jugador haga nada, así que es injugable. Arréglalo de las dos formas: (1) que no empiece a moverse hasta la primera pulsación del jugador, y (2) que empiece lejos de los bordes y con velocidad más lenta, para que dé tiempo a reaccionar.');
|
||||
if (!detalle.sinErrores) d.push('Lanza este error en ejecución y hay que corregirlo: ' + (error || 'error desconocido'));
|
||||
return d;
|
||||
}
|
||||
|
||||
export async function deepCreate({ brief, provider, rounds = 1, onProgress = () => {}, useJury = false,
|
||||
editMode = 'rewrite', notaMinima = null, evaluar = null, bestOf = 1 }) {
|
||||
if (!provider || typeof provider.chat !== 'function') throw new Error('Hard Work necesita un cerebro cargado (elige un modelo arriba).');
|
||||
if (!String(brief || '').trim()) throw new Error('Hard Work: dime qué quieres que cree.');
|
||||
onProgress({ phase: 'draft' });
|
||||
let html = extractHtml(await genComplete(provider, CREATE_SYS, String(brief)));
|
||||
const trace = [{ round: 0, html }];
|
||||
|
||||
// Varias tiradas y nos quedamos con la que MEJOR puntúa al ejecutarla.
|
||||
// A este tamaño de modelo, pedirle que se critique y se parchee empeora
|
||||
// (medido: una ronda dejó un juego jugable sin pintar). Volver a tirar y
|
||||
// juzgar por fuera no depende de que el modelo se juzgue bien a sí mismo.
|
||||
// Requiere muestreo con temperatura: con decodificado voraz las N tiradas
|
||||
// salen idénticas y esto sería pagar N veces por lo mismo.
|
||||
const puedeMuestrear = typeof provider.setSampling === 'function';
|
||||
if (bestOf > 1 && (evaluar || rateArtifact) && puedeMuestrear) {
|
||||
const juzga = evaluar || rateArtifact;
|
||||
let mejorHtml = html, mejorV = await juzga(html);
|
||||
onProgress({ phase: 'candidato', i: 0, nota: mejorV.nota });
|
||||
for (let i = 1; i < bestOf; i++) {
|
||||
provider.setSampling({ temperature: 0.85, seed: 1000 + i * 137 });
|
||||
const otro = extractHtml(await genComplete(provider, CREATE_SYS, String(brief)));
|
||||
const v = otro ? await juzga(otro) : { nota: -1 };
|
||||
onProgress({ phase: 'candidato', i, nota: v.nota });
|
||||
trace.push({ candidato: i, html: otro, nota: v.nota });
|
||||
if (v.nota > mejorV.nota) { mejorHtml = otro; mejorV = v; }
|
||||
}
|
||||
provider.setSampling(null); // dejarlo como estaba: voraz
|
||||
html = mejorHtml; trace[0].html = html; trace[0].nota = mejorV.nota;
|
||||
onProgress({ phase: 'mejorCandidato', nota: mejorV.nota });
|
||||
}
|
||||
// Con criterio de nota: se guarda la MEJOR versión, no la última. Medimos que
|
||||
// una ronda de más puede empeorar (un juego jugable acabó en pantalla de fin),
|
||||
// así que devolver «lo último» es devolver a veces lo peor.
|
||||
const juez = notaMinima != null ? (evaluar || rateArtifact) : null;
|
||||
let mejor = html, mejorNota = -1, ultimoDetalle = null, ultimoError = null;
|
||||
if (juez) {
|
||||
const v = await juez(html);
|
||||
mejorNota = v.nota; ultimoDetalle = v.detalle; ultimoError = v.error;
|
||||
trace[0].nota = v.nota; trace[0].detalle = v.detalle;
|
||||
onProgress({ phase: 'rated', round: 0, nota: v.nota, detalle: v.detalle });
|
||||
if (v.nota >= notaMinima) { onProgress({ phase: 'done', rounds: 0, motivo: 'ya cumplía' }); return { html, trace, nota: v.nota }; }
|
||||
}
|
||||
for (let r = 1; r <= rounds; r++) {
|
||||
onProgress({ phase: 'critique', round: r });
|
||||
let critique;
|
||||
// Si hemos ejecutado el artefacto, lo MEDIDO manda: son defectos
|
||||
// comprobados, no sospechas. La crítica del modelo va detrás, como apoyo.
|
||||
const medidos = juez ? defectosMedidos(ultimoDetalle, ultimoError) : [];
|
||||
if (medidos.length) {
|
||||
critique = 'DEFECTOS COMPROBADOS EJECUTANDO EL JUEGO (corrige estos, son seguros):\n'
|
||||
+ medidos.map(x => '- ' + x).join('\n');
|
||||
onProgress({ phase: 'critique', round: r, medidos: medidos.length });
|
||||
} else if (useJury) {
|
||||
const v = await juryReview({ artifact: html, provider, onProgress });
|
||||
critique = v.defects.length
|
||||
? v.defects.map(d => `- ${d.text} (${d.votes} de ${v.jurors} revisores)`).join('\n')
|
||||
: 'Sin defectos con apoyo suficiente del jurado.';
|
||||
} else {
|
||||
critique = String(await provider.chat([{ role: 'user', content: `APP/JUEGO:\n${html}` }], CRITIQUE_SYS, () => {})).trim();
|
||||
}
|
||||
onProgress({ phase: 'rewrite', round: r, modo: editMode });
|
||||
if (editMode === 'patch') {
|
||||
const resp = await provider.chat(
|
||||
[{ role: 'user', content: `HTML ACTUAL:\n${html}\n\nDEFECTOS A CORREGIR:\n${critique}` }], PATCH_SYS, () => {});
|
||||
const { html: nuevo, aplicados, fallidos } = applyPatches(html, resp);
|
||||
onProgress({ phase: 'patched', round: r, aplicados: aplicados.length, fallidos: fallidos.length });
|
||||
if (aplicados.length) { html = nuevo; trace.push({ round: r, critique, html, aplicados, fallidos }); }
|
||||
else trace.push({ round: r, critique, html, aplicados: [], fallidos });
|
||||
} else {
|
||||
const improved = extractHtml(await genComplete(provider, REWRITE_SYS, `HTML ACTUAL:\n${html}\n\nCRÍTICA A CORREGIR:\n${critique}`));
|
||||
if (improved && /<\w+[\s>]/.test(improved)) { html = improved; trace.push({ round: r, critique, html }); }
|
||||
}
|
||||
if (juez) {
|
||||
const v = await juez(html);
|
||||
ultimoDetalle = v.detalle; ultimoError = v.error;
|
||||
trace[trace.length - 1].nota = v.nota; trace[trace.length - 1].detalle = v.detalle;
|
||||
onProgress({ phase: 'rated', round: r, nota: v.nota, detalle: v.detalle });
|
||||
if (v.nota > mejorNota) { mejorNota = v.nota; mejor = html; }
|
||||
if (v.nota >= notaMinima) { onProgress({ phase: 'done', rounds: r, motivo: 'alcanzó la nota' }); return { html: mejor, trace, nota: mejorNota }; }
|
||||
// Antes se paraba en cuanto una ronda puntuaba por debajo de la mejor. Con
|
||||
// eso una lectura floja tiraba las rondas que quedaban, y como siempre se
|
||||
// devuelve la MEJOR, seguir no puede empeorar el resultado: solo cuesta
|
||||
// tiempo. Se gasta el presupuesto y se elige al final.
|
||||
if (v.nota < mejorNota) onProgress({ phase: 'peor', round: r, nota: v.nota, mejor: mejorNota });
|
||||
}
|
||||
}
|
||||
onProgress({ phase: 'done', rounds: trace.length - 1 });
|
||||
return juez ? { html: mejor, trace, nota: mejorNota } : { html, trace };
|
||||
}
|
||||
|
||||
// ── Recolección de material desde una carpeta autorizada (File System Access) ──
|
||||
// Camina el árbol, concatena ficheros de texto con cabecera de ruta, y corta a
|
||||
// maxBytes para no reventar la memoria. Mismo código sirve a Claw (carpeta
|
||||
// autorizada) y a Code (proyecto abierto): ambos entregan un directory handle.
|
||||
const TEXT_EXT = new Set(('txt md markdown js mjs cjs ts tsx jsx json html htm css scss sass ' +
|
||||
'py rb php go rs java kt swift c h cpp hpp cc cs sh bash zsh yml yaml toml ini cfg conf ' +
|
||||
'xml csv tsv sql vue svelte astro lua r pl pm ex exs erl clj tex org rst gradle properties env').split(' '));
|
||||
const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'target', 'vendor', '.cache', 'coverage', '__pycache__', '.venv', 'venv']);
|
||||
|
||||
export async function gatherFolder(rootHandle, { maxBytes = 1.5e6, maxFiles = 400 } = {}) {
|
||||
if (!rootHandle || typeof rootHandle.entries !== 'function') throw new Error('no hay carpeta que leer');
|
||||
const out = [];
|
||||
let bytes = 0;
|
||||
let files = 0;
|
||||
async function walk(dir, prefix) {
|
||||
if (bytes >= maxBytes || files >= maxFiles) return;
|
||||
const entries = [];
|
||||
for await (const e of dir.entries()) entries.push(e);
|
||||
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
for (const [name, handle] of entries) {
|
||||
if (bytes >= maxBytes || files >= maxFiles) return;
|
||||
if (name.startsWith('.') && name !== '.env') { if (handle.kind === 'directory') continue; }
|
||||
const path = prefix ? prefix + '/' + name : name;
|
||||
if (handle.kind === 'directory') {
|
||||
if (SKIP_DIR.has(name)) continue;
|
||||
await walk(handle, path);
|
||||
} else {
|
||||
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
||||
if (!TEXT_EXT.has(ext)) continue;
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
if (file.size > 400000) continue; // un fichero enorme suelto no monopoliza
|
||||
const text = await file.text();
|
||||
const block = `\n\n===== ${path} =====\n${text}`;
|
||||
out.push(block);
|
||||
bytes += block.length;
|
||||
files++;
|
||||
} catch { /* ilegible: se salta */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(rootHandle, '');
|
||||
return out.join('').slice(0, maxBytes);
|
||||
}
|
||||
68
web/js/runtime/model-broker.js
Normal file
68
web/js/runtime/model-broker.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// Elffuss Runtime · SDK de la caché compartida de modelos.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Embebe un iframe oculto al BROKER (origen compartido de Elffuss) y le pide los
|
||||
// modelos por postMessage. Como el broker vive en un subdominio de utopiaia.com
|
||||
// —igual que todas las webs de Elffuss (mismo «site»)—, comparte UNA sola OPFS:
|
||||
// el modelo se descarga UNA vez y se reutiliza en claw/translator/copilot/code…
|
||||
// Si el broker no está disponible, el llamador cae a su OPFS local (model-store).
|
||||
export const BROKER_URL = 'https://models.elffuss.utopiaia.com/';
|
||||
|
||||
let _iframe = null, _ready = null, _seq = 0, _url = BROKER_URL;
|
||||
|
||||
function ensure(brokerURL) {
|
||||
if (_iframe) return _ready;
|
||||
_url = brokerURL;
|
||||
_iframe = document.createElement('iframe');
|
||||
_iframe.src = brokerURL; _iframe.setAttribute('aria-hidden', 'true');
|
||||
_iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;left:-9999px;visibility:hidden';
|
||||
_ready = new Promise((resolve, reject) => {
|
||||
const to = setTimeout(() => reject(new Error('broker timeout')), 8000);
|
||||
const h = e => { if (e.source === _iframe.contentWindow && e.data?.kind === 'elffuss-broker-ready') { clearTimeout(to); removeEventListener('message', h); resolve(); } };
|
||||
addEventListener('message', h);
|
||||
_iframe.addEventListener('error', () => { clearTimeout(to); reject(new Error('broker no cargó')); });
|
||||
});
|
||||
(document.body || document.documentElement).appendChild(_iframe);
|
||||
return _ready;
|
||||
}
|
||||
const origin = () => new URL(_url).origin;
|
||||
|
||||
// Modelo como Blob (el navegador lo respalda en disco), desde la caché compartida.
|
||||
// Descarga una vez para TODO Elffuss; el resto de webs lo leen sin red.
|
||||
export async function getSharedModel(url, onProgress = () => {}, brokerURL = BROKER_URL) {
|
||||
await ensure(brokerURL);
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++_seq;
|
||||
// Timeout por INACTIVIDAD: sin CUALQUIER mensaje del broker (progreso/file/
|
||||
// error) en IDLE ms, la carga está colgada → rechazar para caer a OPFS local.
|
||||
// No es un tope total (una descarga real tarda minutos): cada mensaje del
|
||||
// broker reinicia el reloj, así que solo salta si el broker enmudece de verdad.
|
||||
const IDLE = 30000;
|
||||
let timer;
|
||||
const arm = () => { clearTimeout(timer); timer = setTimeout(() => { removeEventListener('message', h); reject(new Error('broker sin respuesta (timeout de inactividad)')); }, IDLE); };
|
||||
const done = fn => (...a) => { clearTimeout(timer); removeEventListener('message', h); fn(...a); };
|
||||
const h = e => {
|
||||
if (e.source !== _iframe.contentWindow || e.data?.id !== id) return;
|
||||
const m = e.data;
|
||||
arm(); // cualquier señal del broker reinicia el reloj
|
||||
if (m.kind === 'progress') onProgress(m);
|
||||
// El broker devuelve un File respaldado en disco (structured-clone por
|
||||
// referencia): no copia los GB a RAM. Se lee con .stream() al subirlo a GPU.
|
||||
else if (m.kind === 'file') done(resolve)(m.file);
|
||||
else if (m.kind === 'error') done(reject)(new Error(m.message));
|
||||
};
|
||||
addEventListener('message', h);
|
||||
arm();
|
||||
_iframe.contentWindow.postMessage({ type: 'elffuss-model-get', id, url }, origin());
|
||||
});
|
||||
}
|
||||
|
||||
// ¿ya está en la caché compartida? (para la UI: «cargando desde caché, sin bajar»)
|
||||
export async function isSharedCached(url, brokerURL = BROKER_URL) {
|
||||
try { await ensure(brokerURL); } catch { return false; }
|
||||
return new Promise(resolve => {
|
||||
const id = ++_seq; const to = setTimeout(() => { removeEventListener('message', h); resolve(false); }, 4000);
|
||||
const h = e => { if (e.source !== _iframe.contentWindow || e.data?.id !== id) return; if (e.data.kind === 'has') { clearTimeout(to); removeEventListener('message', h); resolve(!!e.data.cached); } };
|
||||
addEventListener('message', h);
|
||||
_iframe.contentWindow.postMessage({ type: 'elffuss-model-has', id, url }, origin());
|
||||
});
|
||||
}
|
||||
255
web/js/runtime/model-store.js
Normal file
255
web/js/runtime/model-store.js
Normal file
@@ -0,0 +1,255 @@
|
||||
// Elffuss Runtime · almacén de modelos en OPFS (persistente, en disco).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sustituye a Cache Storage para los PESOS del modelo. Motivo real (bug de móvil):
|
||||
// Cache Storage se desaloja en iOS/Android → el modelo se re-descargaba en CADA
|
||||
// visita. OPFS (Origin Private File System) + navigator.storage.persist() aguanta
|
||||
// entre sesiones y se lee como File respaldado por DISCO (no vuelca todo a RAM).
|
||||
//
|
||||
// Además es la capa de almacenamiento del loader por shards: el motor leerá los
|
||||
// pesos por rangos desde el File sin cargar los gigabytes enteros en memoria.
|
||||
//
|
||||
// Licencia: código propio (Apache-2.0). Sin dependencias externas.
|
||||
|
||||
const DIR = 'elffuss-models';
|
||||
|
||||
// Nombre de fichero estable y seguro a partir de la URL (sin barras ni query).
|
||||
function keyFor(url) {
|
||||
return String(url).replace(/[?#].*$/, '').replace(/[^\w.\-]+/g, '_').slice(-180);
|
||||
}
|
||||
|
||||
async function requestPersist() {
|
||||
try {
|
||||
if (navigator.storage?.persist) {
|
||||
const already = navigator.storage.persisted ? await navigator.storage.persisted() : false;
|
||||
return already || await navigator.storage.persist();
|
||||
}
|
||||
} catch { /* no bloquea */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
async function dirHandle() {
|
||||
const root = await navigator.storage.getDirectory(); // lanza si no hay OPFS
|
||||
return await root.getDirectoryHandle(DIR, { create: true });
|
||||
}
|
||||
|
||||
// ¿Hay soporte para escribir en OPFS de forma útil en este navegador?
|
||||
// Chrome/escritorio: createWritable (stream a disco). iOS Safari: solo
|
||||
// createSyncAccessHandle (en worker) → en este primer incremento, si no hay
|
||||
// createWritable en el hilo principal, devolvemos null y el llamador cae a Cache.
|
||||
async function opfsWritableSupported(dir) {
|
||||
try {
|
||||
const test = await dir.getFileHandle('.probe', { create: true });
|
||||
if (typeof test.createWritable !== 'function') { await dir.removeEntry('.probe').catch(() => {}); return false; }
|
||||
const w = await test.createWritable();
|
||||
await w.close();
|
||||
await dir.removeEntry('.probe').catch(() => {});
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Devuelve un File (respaldado en disco) del modelo. Descarga por chunks a OPFS
|
||||
// la primera vez (con progreso real), lo sirve desde disco a partir de entonces.
|
||||
// Un marcador «<key>.done» evita servir una descarga cortada a medias.
|
||||
// Devuelve null si OPFS no está disponible/escribible → el llamador usa su
|
||||
// respaldo (Cache Storage) sin romperse.
|
||||
export async function getModelFile(url, onProgress = () => {}) {
|
||||
// 1) Caché COMPARTIDA (broker en origen Elffuss): un modelo bajado en CUALQUIER
|
||||
// web de Elffuss se reutiliza aquí sin re-descargar. Fast-fail por sesión si
|
||||
// el broker no está disponible → caemos a la OPFS local de este origen.
|
||||
let brokerDown = false;
|
||||
try { brokerDown = sessionStorage.getItem('elffuss.broker.down') === '1'; } catch { /* — */ }
|
||||
if (!brokerDown) {
|
||||
try {
|
||||
const { getSharedModel } = await import('./model-broker.js');
|
||||
const blob = await getSharedModel(url, onProgress);
|
||||
if (blob && blob.size) return blob;
|
||||
} catch { try { sessionStorage.setItem('elffuss.broker.down', '1'); } catch { /* — */ } }
|
||||
}
|
||||
|
||||
if (!navigator.storage?.getDirectory) return null;
|
||||
let dir;
|
||||
try { dir = await dirHandle(); } catch { return null; }
|
||||
await requestPersist();
|
||||
|
||||
const key = keyFor(url);
|
||||
const doneName = key + '.done';
|
||||
|
||||
// ¿ya está entero en disco?
|
||||
try {
|
||||
await dir.getFileHandle(doneName); // lanza si no existe
|
||||
const f = await (await dir.getFileHandle(key)).getFile();
|
||||
if (f.size > 0) { onProgress('Cargando el modelo desde disco (OPFS, sin descargar)…'); return f; }
|
||||
} catch { /* no está o incompleto: se descarga */ }
|
||||
|
||||
if (!(await opfsWritableSupported(dir))) return null; // iOS main-thread: que decida el llamador
|
||||
|
||||
// ── Descarga REANUDABLE ────────────────────────────────────────────────
|
||||
// Antes, un corte borraba lo bajado y la vez siguiente empezaba de cero. Con
|
||||
// un modelo de gigas eso convierte un tropiezo de red —o que el equipo se
|
||||
// duerma— en media hora perdida, y el usuario lo vive como «vuelve a
|
||||
// descargar el modelo». Ahora lo ya escrito se conserva y se pide el resto
|
||||
// con `Range`.
|
||||
//
|
||||
// El peligro de reanudar es empalmar dos ficheros DISTINTOS si el de origen
|
||||
// cambió: saldría un modelo corrupto que carga y devuelve basura, que es
|
||||
// peor que volver a bajarlo. Por eso se guarda la huella del origen (ETag o
|
||||
// Last-Modified) junto a los bytes, y si no coincide se empieza de nuevo.
|
||||
const parteName = key + '.parte'; // huella del intento
|
||||
let ya = 0, huellaPrevia = null;
|
||||
try {
|
||||
huellaPrevia = JSON.parse(await (await (await dir.getFileHandle(parteName)).getFile()).text());
|
||||
ya = (await (await dir.getFileHandle(key)).getFile()).size;
|
||||
} catch { ya = 0; huellaPrevia = null; }
|
||||
|
||||
// Se pregunta primero POR LA CABECERA qué hay al otro lado: es una petición
|
||||
// de un byte, y decide si lo que tenemos sirve.
|
||||
let huella = null, total = 0, aceptaRangos = false;
|
||||
try {
|
||||
const cab = await fetch(url, { headers: { Range: 'bytes=0-0' } });
|
||||
aceptaRangos = cab.status === 206;
|
||||
huella = cab.headers.get('etag') || cab.headers.get('last-modified') || null;
|
||||
const cr = cab.headers.get('content-range');
|
||||
total = cr ? +cr.split('/')[1] : (+cab.headers.get('content-length') || 0);
|
||||
try { await cab.arrayBuffer(); } catch { /* — */ }
|
||||
} catch { /* sin cabecera: se baja entero */ }
|
||||
|
||||
// La huella preferida es el ETag, pero CORS solo expone Content-Range,
|
||||
// Accept-Ranges y Content-Length: desde el navegador `etag` sale NULL y una
|
||||
// reanudación que dependiera de él no se activaría jamás. Se cae al TAMAÑO
|
||||
// TOTAL, que Content-Range sí deja leer y que el registro del motor fija al
|
||||
// byte — si el fichero de origen cambiara, cambiaría de tamaño y la
|
||||
// reanudación se descarta sola. Es más débil que un ETag y suficiente aquí.
|
||||
const mismaHuella = huellaPrevia && (
|
||||
(huella && huellaPrevia.huella === huella) ||
|
||||
(!huella && !huellaPrevia.huella && huellaPrevia.total === total));
|
||||
const puedeReanudar = ya > 0 && aceptaRangos && total > 0 && ya < total && mismaHuella;
|
||||
if (ya > 0 && !puedeReanudar) {
|
||||
// Lo guardado no sirve (otro fichero, o el servidor no da rangos).
|
||||
await dir.removeEntry(key).catch(() => {});
|
||||
ya = 0;
|
||||
}
|
||||
|
||||
const net = await fetch(url, puedeReanudar ? { headers: { Range: `bytes=${ya}-` } } : undefined);
|
||||
const okEstado = puedeReanudar ? net.status === 206 : net.ok;
|
||||
if (!okEstado || !net.body) throw new Error('descarga del modelo falló: HTTP ' + net.status);
|
||||
if (!total) total = (+net.headers.get('content-length') || 0) + ya;
|
||||
|
||||
await dir.removeEntry(doneName).catch(() => {});
|
||||
const fh = await dir.getFileHandle(key, { create: true });
|
||||
// `keepExistingData` conserva lo ya escrito; sin él, createWritable() TRUNCA
|
||||
// el fichero a cero y la reanudación sería una forma cara de empezar de nuevo.
|
||||
// Se confirma POR TRAMOS. `createWritable()` es atómico: lo escrito no existe
|
||||
// en el fichero hasta el `close()`, así que un corte tira TODO aunque nadie
|
||||
// borre nada — que es exactamente lo que medía el banco de reanudación
|
||||
// fallando. Cerrando y reabriendo cada tramo, lo confirmado sobrevive al
|
||||
// corte y la próxima vez se continúa desde ahí.
|
||||
// Proporcional al fichero, no fijo: con un tramo de 64 MB un fichero de 3 MB
|
||||
// no llegaba a confirmar NUNCA y la reanudación no existía para él —lo
|
||||
// destapó el banco, que usa un fichero pequeño a propósito—. Así se confirma
|
||||
// una quincena de veces sea cual sea el tamaño, con suelo para no castigar a
|
||||
// los diminutos y techo para no confirmar cada dos por tres en los de gigas.
|
||||
const TRAMO = Math.max(512 * 1024, Math.min(64 * 1024 * 1024, Math.floor((total || 0) / 16) || (64 * 1024 * 1024)));
|
||||
const anota = async () => {
|
||||
try {
|
||||
const ph = await dir.getFileHandle(parteName, { create: true });
|
||||
const pw = await ph.createWritable();
|
||||
await pw.write(new TextEncoder().encode(JSON.stringify({ huella, total })));
|
||||
await pw.close();
|
||||
} catch { /* sin anotación se pierde la reanudación, no los datos */ }
|
||||
};
|
||||
let writable = await fh.createWritable({ keepExistingData: puedeReanudar });
|
||||
if (puedeReanudar) await writable.seek(ya);
|
||||
const t0 = performance.now();
|
||||
let loaded = ya;
|
||||
if (puedeReanudar) onProgress(`Reanudando la descarga desde ${(ya / 1e9).toFixed(1)} GB…`);
|
||||
await anota();
|
||||
try {
|
||||
const reader = net.body.getReader();
|
||||
let enTramo = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
await writable.write(value);
|
||||
loaded += value.byteLength;
|
||||
enTramo += value.byteLength;
|
||||
if (enTramo >= TRAMO) {
|
||||
enTramo = 0;
|
||||
await writable.close(); // confirma lo del tramo
|
||||
writable = await fh.createWritable({ keepExistingData: true });
|
||||
await writable.seek(loaded);
|
||||
}
|
||||
onProgress(fmt(loaded, total, t0));
|
||||
}
|
||||
await writable.close();
|
||||
} catch (e) {
|
||||
// Se cierra para CONFIRMAR lo del tramo en curso. `abort()` lo descartaría,
|
||||
// que es justo lo contrario de lo que se quiere al reanudar.
|
||||
try { await writable.close(); } catch { /* lo confirmado en tramos ya está */ }
|
||||
throw e;
|
||||
}
|
||||
// Completado: fuera la marca de intento a medias.
|
||||
await dir.removeEntry(parteName).catch(() => {});
|
||||
|
||||
// marcar completado (con el tamaño esperado, para validar en el futuro)
|
||||
const dh = await dir.getFileHandle(doneName, { create: true });
|
||||
const dw = await dh.createWritable();
|
||||
await dw.write(new TextEncoder().encode(JSON.stringify({ size: loaded, total })));
|
||||
await dw.close();
|
||||
|
||||
return await (await dir.getFileHandle(key)).getFile();
|
||||
}
|
||||
|
||||
// Abre un handle de lectura por rangos (para el loader por shards del motor):
|
||||
// devuelve una función slice(offset, length) → Promise<ArrayBuffer> que lee del
|
||||
// disco sin cargar el fichero entero. File.slice() es perezoso en disco.
|
||||
export async function openRanged(url) {
|
||||
const file = await getModelFile(url);
|
||||
if (!file) return null;
|
||||
return {
|
||||
size: file.size,
|
||||
async slice(offset, length) { return await file.slice(offset, offset + length).arrayBuffer(); },
|
||||
};
|
||||
}
|
||||
|
||||
// Borrar un modelo cacheado (para el «liberar espacio» de la UI).
|
||||
export async function removeModel(url) {
|
||||
try {
|
||||
const dir = await dirHandle();
|
||||
const key = keyFor(url);
|
||||
await dir.removeEntry(key).catch(() => {});
|
||||
await dir.removeEntry(key + '.done').catch(() => {});
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Borra TODOS los modelos guardados en OPFS.
|
||||
// Existe porque el botón «liberar espacio» de Ajustes solo vaciaba Cache
|
||||
// Storage: un modelo descargado por este almacén se quedaba ocupando disco sin
|
||||
// forma de borrarlo desde la interfaz. Y como navigator.storage.estimate() SÍ
|
||||
// lo cuenta, el usuario veía gigas que el botón no bajaba nunca.
|
||||
export async function clearAll() {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
await root.removeEntry(DIR, { recursive: true });
|
||||
return true;
|
||||
} catch { return false; } // no existe o no hay OPFS: nada que borrar
|
||||
}
|
||||
|
||||
// Bytes ocupados por los modelos en OPFS (aprox, para diagnóstico).
|
||||
export async function usage() {
|
||||
try {
|
||||
const est = await navigator.storage.estimate();
|
||||
return { usage: est.usage || 0, quota: est.quota || 0 };
|
||||
} catch { return { usage: 0, quota: 0 }; }
|
||||
}
|
||||
|
||||
function fmt(loaded, total, t0) {
|
||||
const mb = n => (n / 1048576).toFixed(0);
|
||||
const secs = (performance.now() - t0) / 1000;
|
||||
const spd = secs > 0 ? (loaded / 1048576 / secs).toFixed(1) : '0';
|
||||
// Incluir el % cuando se conoce el total: el escaparate lo extrae para llenar
|
||||
// la barra, y el texto queda corto (no envuelve en móvil).
|
||||
return total
|
||||
? `Descargando el cerebro · ${mb(loaded)}/${mb(total)} MB · ${Math.round(loaded / total * 100)}%`
|
||||
: `Descargando el cerebro · ${mb(loaded)} MB · ${spd} MB/s`;
|
||||
}
|
||||
59
web/js/settings.js
Normal file
59
web/js/settings.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// Configuración avanzada de proveedores externos (opt-in). Elffuss corre en
|
||||
// LOCAL por defecto; esto solo se usa si el usuario lo activa a mano.
|
||||
// Las claves viven en localStorage de SU navegador y nunca salen de aquí
|
||||
// (las llamadas van directas del navegador al proveedor).
|
||||
const KEY = 'elffusscode.providers';
|
||||
|
||||
// Plantillas de proveedores externos. `kind` decide el dialecto de API.
|
||||
const DEFAULTS = {
|
||||
openai: {
|
||||
kind: 'openai', label: 'OpenAI', enabled: false,
|
||||
baseURL: 'https://api.openai.com/v1', model: 'gpt-4o-mini', apiKey: '',
|
||||
help: 'Tu clave sk-… Se queda en tu navegador. OpenAI permite llamadas directas (CORS).',
|
||||
},
|
||||
anthropic: {
|
||||
kind: 'anthropic', label: 'Anthropic (Claude)', enabled: false,
|
||||
baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-5', apiKey: '',
|
||||
help: 'Tu clave sk-ant-… Se envía la cabecera de acceso directo desde navegador.',
|
||||
},
|
||||
ollama: {
|
||||
kind: 'openai', label: 'Ollama (local)', enabled: false,
|
||||
baseURL: 'http://localhost:11434/v1', model: 'llama3.2', apiKey: 'ollama',
|
||||
help: 'Ollama en tu máquina. Arráncalo con OLLAMA_ORIGINS=* para permitir el navegador.',
|
||||
},
|
||||
server: {
|
||||
kind: 'openai', label: 'Servidor Elffuss (Ornith 9B)', enabled: false,
|
||||
baseURL: 'https://elffuss-claw.utopiaia.com/v1', model: 'ornith-9b', apiKey: '',
|
||||
temperature: 1.0, top_p: 0.95, thinking: false,
|
||||
help: 'El modelo grande en el servidor de UtopiaIA. No es local: los mensajes salen de tu máquina.',
|
||||
},
|
||||
};
|
||||
|
||||
function load() {
|
||||
let saved = {};
|
||||
try { saved = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { /* corrupto */ }
|
||||
const out = {};
|
||||
for (const [id, def] of Object.entries(DEFAULTS)) out[id] = { ...def, ...saved[id] };
|
||||
return out;
|
||||
}
|
||||
|
||||
export function configs() { return load(); }
|
||||
export function get(id) { return load()[id]; }
|
||||
|
||||
export function update(id, patch) {
|
||||
const all = load();
|
||||
all[id] = { ...all[id], ...patch };
|
||||
// no persistir los campos de ayuda estáticos
|
||||
const slim = {};
|
||||
for (const [k, v] of Object.entries(all))
|
||||
slim[k] = { enabled: v.enabled, baseURL: v.baseURL, model: v.model, apiKey: v.apiKey };
|
||||
localStorage.setItem(KEY, JSON.stringify(slim));
|
||||
return all[id];
|
||||
}
|
||||
|
||||
// Externos habilitados → opciones del selector, como ext:<id>.
|
||||
export function enabledExternals() {
|
||||
return Object.entries(load())
|
||||
.filter(([, c]) => c.enabled)
|
||||
.map(([id, c]) => ({ id: 'ext:' + id, label: c.label + (id === 'ollama' || c.baseURL.startsWith('http://localhost') ? ' · local' : ' · nube') }));
|
||||
}
|
||||
412
web/js/shell.js
Normal file
412
web/js/shell.js
Normal file
@@ -0,0 +1,412 @@
|
||||
// Shell del proyecto: un intérprete POSIX-lite que opera SOBRE LOS FICHEROS
|
||||
// REALES de la carpeta abierta (el FileSystemDirectoryHandle de code.js). No es
|
||||
// una emulación: `ls`, `cat`, `grep`, `mkdir`, `rm`, `echo > fichero`… tocan el
|
||||
// disco de verdad y el editor/árbol se refrescan. Es el mismo mundo que ve la
|
||||
// elfa, así que también lo exponemos como herramienta `terminal.run`.
|
||||
//
|
||||
// Runtimes reales (node/npm/python): SOLO con un Bridge local conectado (ver
|
||||
// bridge.js) se ejecutan de VERDAD en tu máquina — ver `capabilities()`.
|
||||
import * as code from './tools/code.js';
|
||||
import * as bridgeClient from './bridge.js';
|
||||
|
||||
let cwd = []; // directorio actual, segmentos relativos a la raíz
|
||||
const history = []; // historial de comandos (para ↑/↓ y `history`)
|
||||
let onFsChange = () => {}; // el IDE refresca árbol/pestañas tras mutaciones
|
||||
let onOpenFile = () => {}; // `open <fichero>` lo abre en Monaco
|
||||
|
||||
export function setHooks({ fsChange, openFile } = {}) {
|
||||
if (fsChange) onFsChange = fsChange;
|
||||
if (openFile) onOpenFile = openFile;
|
||||
}
|
||||
export function reset() { cwd = []; }
|
||||
export function cwdString() { return '/' + cwd.join('/'); }
|
||||
export function getHistory() { return history.slice(); }
|
||||
|
||||
// ¿Podemos lanzar runtimes reales? Solo por Bridge local conectado de verdad
|
||||
// (WebContainers requiere licencia comercial de StackBlitz en producción —
|
||||
// descartado por ahora, ver bridge.js).
|
||||
export function capabilities() {
|
||||
const bridge = bridgeClient.isConnected();
|
||||
return { crossOriginIsolated: !!self.crossOriginIsolated, webcontainers: false, bridge, realRuntime: bridge };
|
||||
}
|
||||
|
||||
// ---- resolución de rutas ----
|
||||
// Combina un argumento con el cwd y normaliza . y .. → segmentos desde la raíz.
|
||||
function resolve(arg = '') {
|
||||
const base = arg.startsWith('/') ? [] : [...cwd];
|
||||
for (const seg of String(arg).split('/')) {
|
||||
if (!seg || seg === '.') continue;
|
||||
if (seg === '..') base.pop();
|
||||
else base.push(seg);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
const root = () => { const h = code.handle(); if (!h) throw new Error('no hay proyecto abierto'); return h; };
|
||||
|
||||
async function dirHandle(segs, { create = false } = {}) {
|
||||
let dir = root();
|
||||
for (const s of segs) dir = await dir.getDirectoryHandle(s, { create });
|
||||
return dir;
|
||||
}
|
||||
async function parentAndName(segs) {
|
||||
const s = [...segs]; const name = s.pop();
|
||||
if (name == null) throw new Error('ruta vacía');
|
||||
return { parent: await dirHandle(s), name };
|
||||
}
|
||||
async function fileHandle(segs, opts) {
|
||||
const { parent, name } = await parentAndName(segs);
|
||||
return parent.getFileHandle(name, opts);
|
||||
}
|
||||
async function readText(segs) { return (await (await fileHandle(segs)).getFile()).text(); }
|
||||
async function writeText(segs, text) {
|
||||
const fh = await fileHandle(segs, { create: true });
|
||||
const w = await fh.createWritable(); await w.write(text); await w.close();
|
||||
code.invalidateFileList?.(); onFsChange();
|
||||
}
|
||||
// ¿ruta es directorio? (para `ls fichero`, `grep dir`, `cd`)
|
||||
async function kindOf(segs) {
|
||||
if (!segs.length) return 'directory';
|
||||
const { parent, name } = await parentAndName(segs);
|
||||
try { await parent.getDirectoryHandle(name); return 'directory'; } catch {}
|
||||
try { await parent.getFileHandle(name); return 'file'; } catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
||||
const CYAN = s => `\x1b[36m${s}\x1b[0m`, RED = s => `\x1b[31m${s}\x1b[0m`, DIM = s => `\x1b[90m${s}\x1b[0m`, GRN = s => `\x1b[32m${s}\x1b[0m`, YEL = s => `\x1b[33m${s}\x1b[0m`;
|
||||
const globToRe = g => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
|
||||
const human = n => n < 1024 ? n + 'B' : n < 1048576 ? (n / 1024).toFixed(1) + 'K' : (n / 1048576).toFixed(1) + 'M';
|
||||
|
||||
// ---- comandos: (args[], stdin) → stdout (string). Lanzan Error en fallo. ----
|
||||
const CMDS = {
|
||||
help() {
|
||||
return 'Elffuss shell — opera sobre los ficheros REALES del proyecto:\n' +
|
||||
' ' + GRN('ls') + ' [-la] [ruta] ' + GRN('cd') + ' [ruta] ' + GRN('pwd') + ' ' + GRN('tree') + ' [ruta]\n' +
|
||||
' ' + GRN('cat') + ' <f...> ' + GRN('head') + '/' + GRN('tail') + ' [-n N] ' + GRN('wc') + ' [-l] ' + GRN('grep') + ' [-in] <patrón> [ruta]\n' +
|
||||
' ' + GRN('find') + ' [ruta] [-name g] ' + GRN('echo') + ' <txt> [> f] ' + GRN('mkdir') + '/' + GRN('touch') + '/' + GRN('rm') + ' [-r]/' + GRN('mv') + '/' + GRN('cp') + '\n' +
|
||||
' ' + GRN('open') + ' <f> (en el editor) ' + GRN('git') + ' status|log|branch ' + GRN('history') + ' ' + GRN('clear') + '\n' +
|
||||
' Soporta tuberías ' + YEL('|') + ' y redirección ' + YEL('>') + ' ' + YEL('>>') + '. ' +
|
||||
(capabilities().bridge ? GRN('node/npm/python disponibles (Bridge local).') : DIM('node/npm/python: conecta el Bridge local (⚙ Ajustes).'));
|
||||
},
|
||||
pwd() { return cwdString(); },
|
||||
async cd(args) {
|
||||
const target = args.find(a => !a.startsWith('-'));
|
||||
if (!target || target === '~') { cwd = []; return ''; }
|
||||
const segs = resolve(target);
|
||||
if (await kindOf(segs) !== 'directory') throw new Error(`cd: no existe el directorio: ${target}`);
|
||||
cwd = segs; return '';
|
||||
},
|
||||
async ls(args) {
|
||||
const long = args.some(a => /^-\w*l/.test(a)), all = args.some(a => /^-\w*a/.test(a));
|
||||
const target = args.find(a => !a.startsWith('-')) || '';
|
||||
const segs = resolve(target);
|
||||
const k = await kindOf(segs);
|
||||
if (k === 'file') return target; // ls de un fichero
|
||||
if (k !== 'directory') throw new Error(`ls: no existe: ${target || '.'}`);
|
||||
const dir = await dirHandle(segs);
|
||||
const rows = [];
|
||||
for await (const e of dir.values()) {
|
||||
if (!all && (e.name.startsWith('.') || IGNORE.has(e.name))) continue;
|
||||
rows.push(e);
|
||||
}
|
||||
rows.sort((a, b) => (a.kind !== b.kind ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name)));
|
||||
if (!long) return rows.map(e => e.kind === 'directory' ? CYAN(e.name + '/') : e.name).join(' ');
|
||||
const out = [];
|
||||
for (const e of rows) {
|
||||
if (e.kind === 'directory') out.push(`${DIM('dir ')} - ${CYAN(e.name + '/')}`);
|
||||
else { const f = await e.getFile(); out.push(`${DIM('file')} ${human(f.size).padStart(6)} ${e.name}`); }
|
||||
}
|
||||
return out.join('\n');
|
||||
},
|
||||
async cat(args, stdin) {
|
||||
const files = args.filter(a => !a.startsWith('-'));
|
||||
if (!files.length) return stdin;
|
||||
const parts = [];
|
||||
for (const f of files) {
|
||||
try { parts.push(await readText(resolve(f))); }
|
||||
catch { throw new Error(`cat: ${f}: no existe`); }
|
||||
}
|
||||
return parts.join('');
|
||||
},
|
||||
async head(args, stdin) {
|
||||
const n = +(args[args.indexOf('-n') + 1]) || 10;
|
||||
const f = args.find(a => !a.startsWith('-') && !/^\d+$/.test(a));
|
||||
const text = f ? await readText(resolve(f)) : stdin;
|
||||
return text.split('\n').slice(0, n).join('\n');
|
||||
},
|
||||
async tail(args, stdin) {
|
||||
const n = +(args[args.indexOf('-n') + 1]) || 10;
|
||||
const f = args.find(a => !a.startsWith('-') && !/^\d+$/.test(a));
|
||||
const text = f ? await readText(resolve(f)) : stdin;
|
||||
const lines = text.split('\n'); return lines.slice(Math.max(0, lines.length - n)).join('\n');
|
||||
},
|
||||
async wc(args, stdin) {
|
||||
const onlyLines = args.includes('-l');
|
||||
const f = args.find(a => !a.startsWith('-'));
|
||||
const text = f ? await readText(resolve(f)) : stdin;
|
||||
const lines = text === '' ? 0 : text.split('\n').length;
|
||||
if (onlyLines) return String(lines);
|
||||
const words = (text.match(/\S+/g) || []).length;
|
||||
return `${lines}\t${words}\t${text.length}${f ? '\t' + f : ''}`;
|
||||
},
|
||||
async tree(args) {
|
||||
const target = args.find(a => !a.startsWith('-')) || '';
|
||||
return code.tree({ path: resolve(target).join('/'), depth: 3 });
|
||||
},
|
||||
async grep(args, stdin) {
|
||||
const insensitive = args.some(a => /^-\w*i/.test(a)), numbers = args.some(a => /^-\w*n/.test(a));
|
||||
const rest = args.filter(a => !a.startsWith('-'));
|
||||
const pattern = rest.shift();
|
||||
if (!pattern) throw new Error('grep: falta el patrón');
|
||||
const re = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), insensitive ? 'i' : '');
|
||||
const hit = (line, i, prefix = '') => (prefix ? CYAN(prefix) + ':' : '') + (numbers ? DIM(i + 1 + ':') : '') + line.replace(re, m => `\x1b[1;31m${m}\x1b[0m`);
|
||||
if (!rest.length) { // desde stdin (tubería)
|
||||
return stdin.split('\n').map((l, i) => re.test(l) ? hit(l, i) : null).filter(x => x != null).join('\n');
|
||||
}
|
||||
const out = []; let scanned = 0;
|
||||
const scanText = (text, label) =>
|
||||
text.split('\n').forEach((l, i) => { if (re.test(l) && out.length < 400) out.push(hit(l, i, label)); });
|
||||
for (const target of rest) {
|
||||
const segs = resolve(target);
|
||||
const k = await kindOf(segs);
|
||||
if (k === 'file') scanText(await readText(segs).catch(() => ''), target);
|
||||
else if (k === 'directory') { // recursivo
|
||||
const walk = async (dir, prefix) => {
|
||||
for await (const e of dir.values()) {
|
||||
if (IGNORE.has(e.name) || out.length >= 400 || scanned > 600) continue;
|
||||
const p = prefix ? prefix + '/' + e.name : e.name;
|
||||
if (e.kind === 'directory') { await walk(e, p); continue; }
|
||||
const f = await e.getFile();
|
||||
if (f.size < 400_000) { scanned++; scanText(await f.text(), (target === '.' ? '' : target.replace(/\/$/, '') + '/') + p); }
|
||||
}
|
||||
};
|
||||
await walk(await dirHandle(segs), '');
|
||||
} else throw new Error(`grep: ${target}: no existe`);
|
||||
}
|
||||
return out.join('\n') || DIM('(sin coincidencias)');
|
||||
},
|
||||
async find(args) {
|
||||
const nameIdx = args.indexOf('-name');
|
||||
const glob = nameIdx >= 0 ? globToRe(args[nameIdx + 1]) : null;
|
||||
const start = args.find(a => !a.startsWith('-') && a !== (nameIdx >= 0 ? args[nameIdx + 1] : null)) || '.';
|
||||
const out = []; let count = 0;
|
||||
const walk = async (dir, prefix) => {
|
||||
for await (const e of dir.values()) {
|
||||
if (IGNORE.has(e.name) || count > 2000) continue;
|
||||
const p = prefix ? prefix + '/' + e.name : e.name;
|
||||
count++;
|
||||
if (!glob || glob.test(e.name)) out.push(e.kind === 'directory' ? CYAN(p) : p);
|
||||
if (e.kind === 'directory') await walk(e, p);
|
||||
}
|
||||
};
|
||||
const segs = resolve(start === '.' ? '' : start);
|
||||
if (await kindOf(segs) !== 'directory') throw new Error(`find: ${start}: no es un directorio`);
|
||||
await walk(await dirHandle(segs), start === '.' ? '' : start);
|
||||
return out.join('\n') || DIM('(nada)');
|
||||
},
|
||||
echo(args) { return args.join(' ').replace(/^["']|["']$/g, ''); },
|
||||
async mkdir(args) {
|
||||
const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('mkdir: falta el nombre');
|
||||
await dirHandle(resolve(t), { create: true }); onFsChange(); return '';
|
||||
},
|
||||
async touch(args) {
|
||||
const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('touch: falta el nombre');
|
||||
const segs = resolve(t);
|
||||
if (await kindOf(segs) == null) await writeText(segs, ''); else onFsChange();
|
||||
return '';
|
||||
},
|
||||
// Soporta comodines (*, ?) y varios objetivos a la vez: `rm mejoras-*.md`.
|
||||
// Sin esto, un patrón con * se buscaba como nombre LITERAL → «no existe»
|
||||
// aunque hubiera decenas de coincidencias reales (bug real, visto en uso).
|
||||
async rm(args) {
|
||||
const recursive = args.some(a => /^-\w*r/.test(a));
|
||||
const targets = args.filter(a => !a.startsWith('-'));
|
||||
if (!targets.length) throw new Error('rm: falta la ruta');
|
||||
const removed = [];
|
||||
for (const t of targets) {
|
||||
if (/[*?]/.test(t)) {
|
||||
const parts = t.split('/'); const pattern = parts.pop();
|
||||
const dirSegs = resolve(parts.join('/'));
|
||||
let dir; try { dir = await dirHandle(dirSegs); } catch { throw new Error(`rm: ${t}: no existe el directorio`); }
|
||||
const re = globToRe(pattern);
|
||||
const matches = [];
|
||||
for await (const e of dir.values()) if (re.test(e.name)) matches.push(e);
|
||||
if (!matches.length) throw new Error(`rm: ${t}: sin coincidencias`);
|
||||
for (const e of matches) {
|
||||
if (e.kind === 'directory' && !recursive) throw new Error(`rm: ${e.name}: es un directorio (usa -r)`);
|
||||
await dir.removeEntry(e.name, { recursive });
|
||||
removed.push((dirSegs.length ? dirSegs.join('/') + '/' : '') + e.name);
|
||||
}
|
||||
} else {
|
||||
const segs = resolve(t); const k = await kindOf(segs);
|
||||
if (k == null) throw new Error(`rm: ${t}: no existe`);
|
||||
if (k === 'directory' && !recursive) throw new Error(`rm: ${t}: es un directorio (usa -r)`);
|
||||
const { parent, name } = await parentAndName(segs);
|
||||
await parent.removeEntry(name, { recursive });
|
||||
removed.push(t);
|
||||
}
|
||||
}
|
||||
code.invalidateFileList?.(); onFsChange();
|
||||
return removed.length > 1 ? `Eliminados ${removed.length}: ${removed.join(', ')}` : '';
|
||||
},
|
||||
async cp(args) {
|
||||
const rest = args.filter(a => !a.startsWith('-'));
|
||||
const [src, dst] = rest; if (!src || !dst) throw new Error('cp: uso: cp <origen> <destino>');
|
||||
await copyPath(resolve(src), resolve(dst)); onFsChange(); return '';
|
||||
},
|
||||
async mv(args) {
|
||||
const rest = args.filter(a => !a.startsWith('-'));
|
||||
const [src, dst] = rest; if (!src || !dst) throw new Error('mv: uso: mv <origen> <destino>');
|
||||
await copyPath(resolve(src), resolve(dst));
|
||||
const { parent, name } = await parentAndName(resolve(src));
|
||||
await parent.removeEntry(name, { recursive: true });
|
||||
code.invalidateFileList?.(); onFsChange(); return '';
|
||||
},
|
||||
async open(args) {
|
||||
const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('open: falta el fichero');
|
||||
const segs = resolve(t);
|
||||
if (await kindOf(segs) !== 'file') throw new Error(`open: ${t}: no es un fichero`);
|
||||
onOpenFile(segs.join('/')); return DIM(`abriendo ${segs.join('/')} en el editor…`);
|
||||
},
|
||||
history() { return history.map((h, i) => `${DIM((i + 1) + '')} ${h}`).join('\n'); },
|
||||
whoami() { return 'elffuss'; },
|
||||
date() { return new Date().toString(); },
|
||||
clear() { return '\f'; }, // la UI lo interpreta
|
||||
async git(args) {
|
||||
const sub = args[0];
|
||||
const info = await code.gitInfo();
|
||||
if (!info.isRepo) return DIM('no es un repositorio git (no hay .git)');
|
||||
if (sub === 'branch' || !sub) return '* ' + GRN(info.branch);
|
||||
if (sub === 'status') return `En la rama ${GRN(info.branch)}\n` + (info.lastCommit ? DIM(`último commit: ${info.lastCommit.msg}`) : DIM('sin commits todavía')) + '\n' + DIM('(git de solo lectura aquí; commit/push reales no disponibles todavía)');
|
||||
if (sub === 'log') return info.lastCommit ? `${YEL('commit ' + info.branch)}\nAutor: ${info.lastCommit.author}\nFecha: ${new Date(info.lastCommit.when).toLocaleString()}\n\n ${info.lastCommit.msg}` : DIM('sin commits');
|
||||
return DIM(`git ${sub}: de solo lectura aquí (branch/status/log).`);
|
||||
},
|
||||
};
|
||||
// tokenize() ya quitó las comillas originales (p.ej. -e "console.log(1)" llega
|
||||
// como el arg SUELTO console.log(1)) — para reenviarlo al bridge (que lo pasa
|
||||
// a `sh -c`) hay que re-entrecomillarlo, si no un espacio o un paréntesis
|
||||
// rompe el comando al otro lado (bug real: -e "console.log('x')" fallaba).
|
||||
const shellQuote = s => `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||
// runtimes REALES solo si hay un Bridge local conectado (⚙ Ajustes) — se
|
||||
// ejecutan en tu máquina de verdad, en la carpeta configurada del bridge (no
|
||||
// en el cwd virtual del proyecto).
|
||||
for (const r of ['node', 'npm', 'npx', 'python', 'python3', 'pip', 'yarn', 'pnpm', 'bun', 'deno', 'cargo', 'go'])
|
||||
CMDS[r] = async (args) => {
|
||||
if (!bridgeClient.isConnected()) {
|
||||
throw new Error(`${r}: runtime real no disponible.\n${DIM('Descarga y arranca el Bridge local (⚙ Ajustes → 🔌 Bridge local) para ejecución real en tu máquina — se conecta solo en cuanto lo detecte. Mientras tanto, la elfa edita ficheros y tú navegas con los comandos de arriba (help).')}`);
|
||||
}
|
||||
const full = r + (args.length ? ' ' + args.map(shellQuote).join(' ') : '');
|
||||
let out = '';
|
||||
const code = await bridgeClient.exec(full, { onOut: t => { out += t; }, onErr: t => { out += RED(t); } });
|
||||
if (code) out += `\n${DIM('(código de salida ' + code + ')')}`;
|
||||
return out || DIM('(sin salida)');
|
||||
};
|
||||
|
||||
// copia recursiva por handles (la File System API no tiene move nativo)
|
||||
async function copyPath(srcSegs, dstSegs) {
|
||||
const k = await kindOf(srcSegs);
|
||||
if (k == null) throw new Error('origen no existe');
|
||||
if (k === 'file') { await writeText(dstSegs, await readText(srcSegs)); return; }
|
||||
const src = await dirHandle(srcSegs);
|
||||
await dirHandle(dstSegs, { create: true });
|
||||
for await (const e of src.values()) await copyPath([...srcSegs, e.name], [...dstSegs, e.name]);
|
||||
}
|
||||
|
||||
// ---- parser: tuberías, redirección, comillas ----
|
||||
function splitTop(line, sep) { // parte por `sep` respetando comillas
|
||||
const parts = []; let cur = '', q = '';
|
||||
for (const ch of line) {
|
||||
if (q) { cur += ch; if (ch === q) q = ''; }
|
||||
else if (ch === '"' || ch === "'") { q = ch; cur += ch; }
|
||||
else if (ch === sep) { parts.push(cur); cur = ''; }
|
||||
else cur += ch;
|
||||
}
|
||||
parts.push(cur); return parts;
|
||||
}
|
||||
function tokenize(seg) {
|
||||
const toks = []; let cur = '', q = '';
|
||||
for (const ch of seg.trim()) {
|
||||
if (q) { if (ch === q) q = ''; else cur += ch; }
|
||||
else if (ch === '"' || ch === "'") q = ch;
|
||||
else if (/\s/.test(ch)) { if (cur) { toks.push(cur); cur = ''; } }
|
||||
else cur += ch;
|
||||
}
|
||||
if (cur) toks.push(cur);
|
||||
return toks;
|
||||
}
|
||||
|
||||
// Ejecuta una línea completa (encadenado &&/;, tuberías, redirección).
|
||||
// `\f` en el retorno = orden de limpiar pantalla (para la UI).
|
||||
export async function exec(line, { record = true } = {}) {
|
||||
line = line.trim();
|
||||
if (!line) return '';
|
||||
if (record) history.push(line);
|
||||
const segments = tokenizeChain(line);
|
||||
if (segments.length === 1) return runPipeline(segments[0].cmd);
|
||||
// encadenado: A && B (B solo si A fue bien) · A ; B (B siempre)
|
||||
const outs = []; let ok = true;
|
||||
for (const { cmd, op } of segments) {
|
||||
if (op === '&&' && !ok) continue;
|
||||
try { outs.push(await runPipeline(cmd)); ok = true; }
|
||||
catch (e) { ok = false; outs.push('\x1b[31m' + (e && e.message || e) + '\x1b[0m'); }
|
||||
}
|
||||
if (outs.every(o => o === '\f')) return '\f';
|
||||
return outs.filter(o => o && o !== '\f').join('\n');
|
||||
}
|
||||
|
||||
// Divide la línea en segmentos con su operador previo (&& / ; ), respetando comillas.
|
||||
function tokenizeChain(line) {
|
||||
const segs = []; let cur = '', q = '', op = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (q) { cur += ch; if (ch === q) q = ''; continue; }
|
||||
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
|
||||
if (ch === '&' && line[i + 1] === '&') { segs.push({ cmd: cur.trim(), op }); op = '&&'; cur = ''; i++; continue; }
|
||||
if (ch === ';') { segs.push({ cmd: cur.trim(), op }); op = ';'; cur = ''; continue; }
|
||||
cur += ch;
|
||||
}
|
||||
if (cur.trim()) segs.push({ cmd: cur.trim(), op });
|
||||
return segs.filter(s => s.cmd);
|
||||
}
|
||||
|
||||
// Ejecuta un pipeline (una etapa o varias unidas por |, con redirección final).
|
||||
async function runPipeline(line) {
|
||||
const stages = splitTop(line, '|').map(s => s.trim()).filter(Boolean);
|
||||
let data = '';
|
||||
for (let i = 0; i < stages.length; i++) {
|
||||
let seg = stages[i], redir = null;
|
||||
if (i === stages.length - 1) {
|
||||
const m = seg.match(/(>>?)\s*(\S+)\s*$/);
|
||||
if (m) { redir = { append: m[1] === '>>', file: m[2] }; seg = seg.slice(0, m.index); }
|
||||
}
|
||||
const toks = tokenize(seg);
|
||||
const name = toks.shift();
|
||||
if (!name) continue;
|
||||
const fn = CMDS[name];
|
||||
if (!fn) throw new Error(`${name}: comando no encontrado (prueba ${GRN('help')})`);
|
||||
data = (await fn(toks, data)) ?? '';
|
||||
if (redir) {
|
||||
const segs = resolve(redir.file);
|
||||
const prev = redir.append ? await readText(segs).catch(() => '') : '';
|
||||
await writeText(segs, prev + stripAnsi(data));
|
||||
data = '';
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const stripAnsi = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
||||
|
||||
// Entradas de un directorio (para el completado con Tab del terminal).
|
||||
export async function listDir(dirPart = '') {
|
||||
const dir = await dirHandle(resolve(dirPart));
|
||||
const out = [];
|
||||
for await (const e of dir.values()) if (!IGNORE.has(e.name)) out.push({ name: e.name, kind: e.kind });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ejecuta una orden desde la elfa (tool terminal.run): devuelve texto plano.
|
||||
export async function runForAgent(command) {
|
||||
const out = await exec(command, { record: true });
|
||||
return out === '\f' ? '(pantalla limpiada)' : (stripAnsi(out) || '(sin salida)');
|
||||
}
|
||||
170
web/js/skills.js
Normal file
170
web/js/skills.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// Skills de Elffuss Code: instrucciones en markdown (formato SKILL.md de
|
||||
// Claude Code) que el modelo sigue cuando aplican. Se instalan desde el
|
||||
// catálogo grande OFICIAL (github.com/anthropics/skills), desde los plugins
|
||||
// oficiales, o desde CUALQUIER repo público (marketplaces de la comunidad
|
||||
// tipo OpenClaude/openclaw). Todo transparente: se ve el repo, la lista y el
|
||||
// SKILL.md que se inyecta. Se guardan en IndexedDB (nada sale del navegador).
|
||||
import * as db from './db.js';
|
||||
|
||||
const KEY = 'skills'; // skills instaladas
|
||||
const SRC_KEY = 'skills.sources'; // repos personalizados añadidos por el usuario
|
||||
const MAX_SKILL = 12_000; // caracteres del cuerpo que se inyectan al modelo
|
||||
|
||||
export const CATALOG_REPO = 'anthropics/skills';
|
||||
export const DEFAULT_SOURCES = [
|
||||
{ repo: 'bbgnsurftech/claude-skills-collection', label: 'Comunidad · Mega-colección (1000+ skills)', official: true },
|
||||
{ repo: 'anthropics/skills', label: 'Anthropic · Agent Skills (oficial de Claude Code)', official: true },
|
||||
{ repo: 'anthropics/claude-plugins-official', label: 'Anthropic · Claude Code Plugins (oficial)', official: true },
|
||||
];
|
||||
|
||||
let cache = []; // instaladas, en memoria (para que el systemPrompt sea síncrono)
|
||||
|
||||
// `loaded` distingue «no hay skills» de «no se pudo leer»: si la lectura
|
||||
// falla, cache queda vacío pero loaded=false, y install() se niega a persistir
|
||||
// (antes, un fallo transitorio de IndexedDB borraba TODAS las skills al
|
||||
// guardar la siguiente).
|
||||
let loaded = false;
|
||||
export async function initSkills() {
|
||||
try {
|
||||
const stored = await db.get('kv', KEY);
|
||||
cache = Array.isArray(stored) ? stored : [];
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
cache = [];
|
||||
loaded = false;
|
||||
console.warn('[elffuss] no se pudieron leer las skills; no las sobrescribiré', e);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
export function skillsLoaded() { return loaded; }
|
||||
|
||||
export async function all() { return cache; }
|
||||
export function installed() { return cache; }
|
||||
export function isInstalled(repo, path) { return cache.some(s => s.repo === repo && s.path === path); }
|
||||
|
||||
export async function install(skill) {
|
||||
// Si la carga inicial falló, cache no representa lo instalado: reintentar
|
||||
// leer antes de escribir. Sin esto, guardar aquí borraría las skills reales.
|
||||
if (!loaded) {
|
||||
await initSkills();
|
||||
if (!loaded) throw new Error('No pude leer tus skills guardadas; no instalo para no perderlas. Recarga la página e inténtalo otra vez.');
|
||||
}
|
||||
cache = cache.filter(s => !(s.repo === skill.repo && s.path === skill.path) && s.name !== skill.name);
|
||||
const entry = { ...skill, content: (skill.content || '').slice(0, MAX_SKILL) };
|
||||
cache.push(entry);
|
||||
await db.set('kv', KEY, cache);
|
||||
return entry; // devuelve la skill completa (name, description…)
|
||||
}
|
||||
|
||||
// Mensaje «cómo usarla» tras instalar: qué hace + un ejemplo de qué pedir.
|
||||
export function usageMessage(skill) {
|
||||
const desc = (skill.description || '').trim();
|
||||
const ejemplo = firstExample(skill) || `algo relacionado con «${skill.name}»`;
|
||||
return `✳ **Skill «${skill.name}» instalada.**\n\n` +
|
||||
(desc ? desc + '\n\n' : '') +
|
||||
`Ya la sigo en cada conversación. Para usarla, pídeme por ejemplo:\n\n> ${ejemplo}`;
|
||||
}
|
||||
|
||||
// Intenta sacar un ejemplo de uso del cuerpo del SKILL.md (líneas de ejemplo/uso).
|
||||
function firstExample(skill) {
|
||||
const body = skill.content || '';
|
||||
const m = body.match(/(?:ejemplo|example|uso|usage|prueba|try)[^\n:]*[::]\s*["“]?([^\n"”]{6,90})/i)
|
||||
|| body.match(/^[-*]\s+([A-ZÁÉÍÓÚ][^\n]{10,80})/m);
|
||||
return m ? m[1].trim().replace(/[.*_`]+$/, '') : null;
|
||||
}
|
||||
|
||||
export async function remove(nameOrPath) {
|
||||
cache = cache.filter(s => s.name !== nameOrPath && s.path !== nameOrPath);
|
||||
await db.set('kv', KEY, cache);
|
||||
}
|
||||
|
||||
export async function get(name) {
|
||||
return cache.find(s => s.name.toLowerCase() === String(name).toLowerCase()) || null;
|
||||
}
|
||||
|
||||
// ---- fuentes (repos) ----
|
||||
export async function sources() {
|
||||
const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
|
||||
return [...DEFAULT_SOURCES, ...custom];
|
||||
}
|
||||
export async function addSource(repoOrUrl, label) {
|
||||
const repo = repoOrUrl.trim().replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/$/, '');
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error('Usa owner/repo (o la URL de GitHub)');
|
||||
const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
|
||||
if (!custom.some(s => s.repo === repo) && !DEFAULT_SOURCES.some(s => s.repo === repo))
|
||||
custom.push({ repo, label: label || repo });
|
||||
await db.set('kv', SRC_KEY, custom);
|
||||
return repo;
|
||||
}
|
||||
export async function removeSource(repo) {
|
||||
const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
|
||||
await db.set('kv', SRC_KEY, custom.filter(s => s.repo !== repo));
|
||||
}
|
||||
|
||||
// ---- catálogo desde GitHub ----
|
||||
// Una sola llamada al árbol git del repo → todos los SKILL.md.
|
||||
export async function listFromRepo(repo) {
|
||||
let tree, branch;
|
||||
for (const b of ['main', 'master']) {
|
||||
const r = await fetch(`https://api.github.com/repos/${repo}/git/trees/${b}?recursive=1`);
|
||||
if (r.ok) { tree = await r.json(); branch = b; break; }
|
||||
if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.');
|
||||
}
|
||||
if (!tree) throw new Error('No pude leer el repo (¿existe y es público?)');
|
||||
return (tree.tree || [])
|
||||
.filter(n => /(^|\/)SKILL\.md$/i.test(n.path))
|
||||
.map(n => ({ repo, branch, path: n.path, dir: n.path.replace(/\/SKILL\.md$/i, ''), name: n.path.replace(/\/SKILL\.md$/i, '').split('/').pop() || repo }))
|
||||
.sort((a, b) => a.dir.localeCompare(b.dir));
|
||||
}
|
||||
|
||||
// Descarga el SKILL.md y lo instala (frontmatter YAML simple).
|
||||
export async function installFromRepo(entry) {
|
||||
let md = null;
|
||||
for (const b of [entry.branch, 'main', 'master'].filter(Boolean)) {
|
||||
const r = await fetch(`https://raw.githubusercontent.com/${entry.repo}/${b}/${entry.path}`);
|
||||
if (r.ok) { md = await r.text(); break; }
|
||||
}
|
||||
if (md == null) throw new Error('No pude descargar el SKILL.md');
|
||||
const skill = parseSkill(md, entry.name);
|
||||
return install({ ...skill, repo: entry.repo, path: entry.path });
|
||||
}
|
||||
|
||||
// SKILL.md → { name, description, content }
|
||||
export function parseSkill(md, fallbackName = 'skill') {
|
||||
const fm = md.match(/^---\n([\s\S]*?)\n---/);
|
||||
const meta = fm?.[1] || '';
|
||||
const name = meta.match(/^name:\s*(.+)$/m)?.[1]?.trim() || fallbackName;
|
||||
const description = (meta.match(/^description:\s*([\s\S]+?)(?:\n\w+:|$)/m)?.[1] || '').replace(/\s+/g, ' ').trim();
|
||||
const content = md.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
|
||||
return { name, description, content };
|
||||
}
|
||||
|
||||
// Creador de skills: Elffuss fabrica una skill propia (SKILL.md) a partir de
|
||||
// lo que el usuario quiere, y la instala al instante. Aparece en la pestaña
|
||||
// Skills y se inyecta en el prompt de los siguientes turnos.
|
||||
export async function createSkill({ name, description, instructions } = {}) {
|
||||
if (!name || !instructions) throw new Error('Faltan name o instructions');
|
||||
const skill = {
|
||||
name: String(name).slice(0, 48),
|
||||
description: (description || '').slice(0, 240),
|
||||
content: String(instructions).slice(0, MAX_SKILL),
|
||||
repo: 'creada por Elffuss',
|
||||
path: 'local/' + Date.now(),
|
||||
};
|
||||
await install(skill);
|
||||
return `Skill «${skill.name}» creada e instalada. Ya la sigo en cada conversación (mírala en la pestaña Skills).`;
|
||||
}
|
||||
|
||||
// Bloque para el systemPrompt (síncrono, desde la caché).
|
||||
export function skillsPromptBlock() {
|
||||
if (!cache.length) return '';
|
||||
const nombres = cache.map(s => `«${s.name}»`).join(', ');
|
||||
const parts = cache.map(s =>
|
||||
`### Skill «${s.name}»${s.repo ? ` (de ${s.repo})` : ''}\n${s.description || ''}\n${(s.content || '').slice(0, MAX_SKILL)}`);
|
||||
return `\n\nSKILLS INSTALADAS — son capacidades que TIENES AHORA MISMO: ${nombres}.\n` +
|
||||
`REGLAS con las skills:\n` +
|
||||
`- Si la petición encaja con una skill (aunque el usuario la nombre con otras palabras o un nombre parecido, empareja por el TEMA de su descripción), ÚSALA siguiendo sus instrucciones al pie de la letra.\n` +
|
||||
`- NUNCA digas que no tienes esa skill o esa capacidad: la tienes, está aquí abajo.\n` +
|
||||
`- Si la skill incluye una plantilla de app (HTML), créala con la herramienta app.create COPIANDO esa plantilla y adaptando solo lo que la skill indique — no inventes una app genérica distinta.\n\n` +
|
||||
parts.join('\n\n');
|
||||
}
|
||||
116
web/js/splash-gl.js
Normal file
116
web/js/splash-gl.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// Galaxia de partículas WebGL para el splash — sin librerías, autocontenida.
|
||||
// Técnica: GL_POINTS + blending aditivo + rotación diferencial calculada por
|
||||
// completo en el vertex shader desde una semilla (la CPU no toca nada por
|
||||
// frame). ~24k partículas en espiral rosa→violeta con motas doradas, parallax
|
||||
// de ratón y explosión al pulsar Entrar.
|
||||
export function startGalaxy(container) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'gl';
|
||||
container.prepend(canvas);
|
||||
const gl = canvas.getContext('webgl', { alpha: true, antialias: false, depth: false });
|
||||
if (!gl) { canvas.remove(); return null; } // fallback: se queda el gradiente CSS
|
||||
|
||||
const N = 24000;
|
||||
const seeds = new Float32Array(N * 2);
|
||||
for (let i = 0; i < seeds.length; i++) seeds[i] = Math.random();
|
||||
|
||||
const VS = `
|
||||
attribute vec2 seed;
|
||||
uniform float t, aspect, burst, dpr;
|
||||
uniform vec2 par;
|
||||
varying vec3 vColor;
|
||||
varying float vGlow;
|
||||
float hash(float n){ return fract(sin(n) * 43758.5453); }
|
||||
void main(){
|
||||
float r = 0.08 + 1.35 * pow(seed.y, 0.65);
|
||||
float arm = floor(hash(seed.x * 7.0 + seed.y) * 3.0);
|
||||
float ang = seed.x * 0.9 + arm * 2.0944 + r * 2.6 + t * (0.12 + 0.25 / (r + 0.2));
|
||||
float wob = (hash(seed.x * 13.1) - 0.5) * 0.22 * r;
|
||||
vec2 p = vec2(cos(ang), sin(ang)) * (r + wob);
|
||||
p.y *= 0.62;
|
||||
p.y += (hash(seed.y * 17.7) - 0.5) * 0.14;
|
||||
p += par * (0.03 + r * 0.06);
|
||||
p *= 1.0 + burst * (0.4 + seed.y * 2.4);
|
||||
gl_Position = vec4(p.x / aspect, p.y, 0.0, 1.0);
|
||||
float tw = 0.55 + 0.45 * sin(t * (1.5 + seed.x * 3.0) + seed.y * 40.0);
|
||||
vGlow = tw * (1.0 - burst * 0.55);
|
||||
float gold = step(0.93, hash(seed.x * 31.3 + seed.y * 7.7));
|
||||
vec3 pink = vec3(0.33, 0.86, 0.77);
|
||||
vec3 violet = vec3(0.51, 0.45, 1.0);
|
||||
vec3 goldc = vec3(0.82, 0.63, 0.42);
|
||||
vColor = mix(mix(pink, violet, smoothstep(0.1, 1.3, r)), goldc, gold);
|
||||
gl_PointSize = (1.3 + 2.8 * tw * max(1.2 - r * 0.5, 0.2)) * dpr;
|
||||
}`;
|
||||
const FS = `
|
||||
precision mediump float;
|
||||
varying vec3 vColor;
|
||||
varying float vGlow;
|
||||
void main(){
|
||||
float m = smoothstep(0.5, 0.0, length(gl_PointCoord - 0.5));
|
||||
gl_FragColor = vec4(vColor * vGlow, m * vGlow);
|
||||
}`;
|
||||
|
||||
const sh = (type, src) => {
|
||||
const s = gl.createShader(type);
|
||||
gl.shaderSource(s, src);
|
||||
gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
||||
return s;
|
||||
};
|
||||
const prog = gl.createProgram();
|
||||
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS));
|
||||
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
|
||||
gl.linkProgram(prog);
|
||||
gl.useProgram(prog);
|
||||
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, seeds, gl.STATIC_DRAW); // una vez; la GPU manda
|
||||
const loc = gl.getAttribLocation(prog, 'seed');
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const U = n => gl.getUniformLocation(prog, n);
|
||||
const uT = U('t'), uAspect = U('aspect'), uBurst = U('burst'), uPar = U('par'), uDpr = U('dpr');
|
||||
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // aditivo = brillo
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
|
||||
let burstAt = 0, alive = true;
|
||||
const mouse = { x: 0, y: 0, tx: 0, ty: 0 };
|
||||
const onMove = e => {
|
||||
mouse.tx = (e.clientX / innerWidth - 0.5) * 2;
|
||||
mouse.ty = -(e.clientY / innerHeight - 0.5) * 2;
|
||||
};
|
||||
addEventListener('pointermove', onMove);
|
||||
|
||||
const dpr = Math.min(devicePixelRatio || 1, 2);
|
||||
function frame(now) {
|
||||
if (!alive) return;
|
||||
const w = container.clientWidth, h = container.clientHeight;
|
||||
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
mouse.x += (mouse.tx - mouse.x) * 0.05;
|
||||
mouse.y += (mouse.ty - mouse.y) * 0.05;
|
||||
const t = now / 1000;
|
||||
const burst = burstAt ? Math.min((now - burstAt) / 900, 1) : 0;
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.uniform1f(uT, t);
|
||||
gl.uniform1f(uAspect, w / h);
|
||||
gl.uniform1f(uBurst, burst);
|
||||
gl.uniform1f(uDpr, dpr);
|
||||
gl.uniform2f(uPar, mouse.x, mouse.y);
|
||||
gl.drawArrays(gl.POINTS, 0, N);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
return {
|
||||
burst() { burstAt = performance.now(); },
|
||||
stop() { alive = false; removeEventListener('pointermove', onMove); canvas.remove(); },
|
||||
};
|
||||
}
|
||||
1564
web/js/swe-tasks.js
Normal file
1564
web/js/swe-tasks.js
Normal file
File diff suppressed because it is too large
Load Diff
63
web/js/telemetry.js
Normal file
63
web/js/telemetry.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// Buzón opt-in de errores/feedback. Apagado por defecto — la promesa de
|
||||
// «nada sale de tu máquina» sigue siendo cierta salvo que el usuario decida
|
||||
// lo contrario en Ajustes. Cuando está activo:
|
||||
// - errores no capturados (window.onerror / unhandledrejection) y los
|
||||
// puntos concretos del código que llaman a reportError() se mandan a
|
||||
// /proxy/report (mismo origen, vía el proxy compartido de Elffuss)
|
||||
// - sendFeedback() manda texto libre que el usuario escriba a propósito
|
||||
// Nunca se manda código ni contenido del proyecto — solo el mensaje técnico,
|
||||
// la pila, la URL y el user-agent. Sin cola ni reintentos: si el envío
|
||||
// falla, se descarta sin más (evita que un fallo de red se convierta en
|
||||
// almacenamiento sin límite).
|
||||
let appName = 'elffuss';
|
||||
let enabled = false;
|
||||
let hooked = false;
|
||||
|
||||
const storageKey = () => 'elffuss.telemetry.' + appName;
|
||||
|
||||
export function isEnabled() { return enabled; }
|
||||
|
||||
export function setEnabled(v) {
|
||||
enabled = !!v;
|
||||
try { localStorage.setItem(storageKey(), enabled ? '1' : '0'); } catch { /* localStorage lleno/bloqueado */ }
|
||||
if (enabled) hookGlobalErrors();
|
||||
}
|
||||
|
||||
async function post(kind, message, opts = {}) {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
await fetch('/proxy/report', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
app: appName, kind, message: String(message ?? '').slice(0, 2000),
|
||||
stack: String(opts.stack || '').slice(0, 4000),
|
||||
url: location.href, userAgent: navigator.userAgent,
|
||||
extra: String(opts.extra || '').slice(0, 2000),
|
||||
}),
|
||||
});
|
||||
} catch { /* sin conexión o proxy caído: se descarta, no hay reintento */ }
|
||||
}
|
||||
|
||||
export function reportError(message, opts = {}) { return post('error', message, opts); }
|
||||
export function sendFeedback(text) { return post('feedback', text); }
|
||||
|
||||
function hookGlobalErrors() {
|
||||
if (hooked) return;
|
||||
hooked = true;
|
||||
window.addEventListener('error', e => {
|
||||
reportError(e.message || String(e.error) || 'error', { stack: e.error?.stack || '' });
|
||||
});
|
||||
window.addEventListener('unhandledrejection', e => {
|
||||
const r = e.reason;
|
||||
reportError('unhandledrejection: ' + (r?.message || r), { stack: r?.stack || '' });
|
||||
});
|
||||
}
|
||||
|
||||
// Llamar UNA vez al arrancar, con el nombre de la app (para distinguir
|
||||
// elffuss-code de elffuss-claw en los informes) — lee la preferencia guardada.
|
||||
export function init(name) {
|
||||
appName = name || appName;
|
||||
try { enabled = localStorage.getItem(storageKey()) === '1'; } catch { enabled = false; }
|
||||
if (enabled) hookGlobalErrors();
|
||||
}
|
||||
140
web/js/terminal.js
Normal file
140
web/js/terminal.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// Terminal de Elffuss Code: xterm.js (VT100 real) + un readline propio que
|
||||
// ejecuta contra shell.js (los ficheros REALES del proyecto). xterm no trae
|
||||
// shell: nosotros gestionamos la línea (edición, historial, cursor) y delegamos
|
||||
// la ejecución en shell.exec. La misma orden la puede lanzar la elfa vía el
|
||||
// tool `terminal.run`, y su salida aparece aquí.
|
||||
import * as shell from './shell.js';
|
||||
|
||||
let term = null, fit = null, ready = false;
|
||||
let Terminal = null, FitAddon = null;
|
||||
let line = '', pos = 0, histIdx = -1, busy = false;
|
||||
const XT = '@xterm/xterm@5.5.0', FIT = '@xterm/addon-fit@0.10.0';
|
||||
|
||||
// IMPORTANTE: cargamos xterm como ESM, no UMD. Monaco trae un loader AMD
|
||||
// (define/require) y la build UMD de xterm se registra como «define anónimo»
|
||||
// en vez de exponer window.Terminal → «Can only have one anonymous define».
|
||||
// El import ESM esquiva por completo el define de Monaco.
|
||||
async function loadXterm() {
|
||||
if (Terminal) return;
|
||||
if (!document.getElementById('xterm-css')) {
|
||||
const l = document.createElement('link');
|
||||
l.id = 'xterm-css'; l.rel = 'stylesheet'; l.href = `https://cdn.jsdelivr.net/npm/${XT}/css/xterm.min.css`;
|
||||
document.head.appendChild(l);
|
||||
}
|
||||
const [xt, ft] = await Promise.all([
|
||||
import(`https://cdn.jsdelivr.net/npm/${XT}/+esm`),
|
||||
import(`https://cdn.jsdelivr.net/npm/${FIT}/+esm`),
|
||||
]);
|
||||
Terminal = xt.Terminal || (xt.default && xt.default.Terminal);
|
||||
FitAddon = ft.FitAddon || (ft.default && ft.default.FitAddon) || ft.default;
|
||||
}
|
||||
|
||||
const prompt = () => `\x1b[36m${shell.cwdString()}\x1b[0m \x1b[35m❯\x1b[0m `;
|
||||
const writePrompt = () => term.write('\r\n' + prompt());
|
||||
// Redibuja la línea de entrada (prompt + buffer) y coloca el cursor en `pos`.
|
||||
function redraw() {
|
||||
term.write('\r\x1b[K' + prompt() + line);
|
||||
const back = line.length - pos;
|
||||
if (back > 0) term.write(`\x1b[${back}D`);
|
||||
}
|
||||
|
||||
async function run(raw) {
|
||||
const cmd = raw.trim();
|
||||
if (!cmd) { writePrompt(); return; }
|
||||
busy = true;
|
||||
try {
|
||||
const out = await shell.exec(cmd);
|
||||
if (out === '\f') term.clear();
|
||||
else if (out) term.write('\r\n' + out.replace(/\n/g, '\r\n'));
|
||||
} catch (e) {
|
||||
term.write('\r\n\x1b[31m' + String(e && e.message || e).replace(/\n/g, '\r\n') + '\x1b[0m');
|
||||
}
|
||||
busy = false;
|
||||
writePrompt();
|
||||
}
|
||||
|
||||
function onKey(data) {
|
||||
if (busy) return;
|
||||
switch (data) {
|
||||
case '\r': // Enter
|
||||
{ const cmd = line; line = ''; pos = 0; histIdx = -1; run(cmd); return; }
|
||||
case '\x7f': // Retroceso
|
||||
if (pos > 0) { line = line.slice(0, pos - 1) + line.slice(pos); pos--; redraw(); } return;
|
||||
case '\x1b[3~': // Supr
|
||||
if (pos < line.length) { line = line.slice(0, pos) + line.slice(pos + 1); redraw(); } return;
|
||||
case '\x03': // Ctrl+C
|
||||
term.write('^C'); line = ''; pos = 0; histIdx = -1; writePrompt(); return;
|
||||
case '\x0c': // Ctrl+L
|
||||
term.clear(); redraw(); return;
|
||||
case '\x1b[A': case '\x1b[B': { // arriba / abajo: historial
|
||||
const h = shell.getHistory(); if (!h.length) return;
|
||||
if (histIdx === -1) histIdx = h.length;
|
||||
histIdx = Math.max(0, Math.min(h.length, histIdx + (data === '\x1b[A' ? -1 : 1)));
|
||||
line = h[histIdx] || ''; pos = line.length; redraw(); return;
|
||||
}
|
||||
case '\x1b[C': if (pos < line.length) { pos++; term.write('\x1b[C'); } return; // derecha
|
||||
case '\x1b[D': if (pos > 0) { pos--; term.write('\x1b[D'); } return; // izquierda
|
||||
case '\x1b[H': case '\x01': pos = 0; redraw(); return; // Inicio / Ctrl+A
|
||||
case '\x1b[F': case '\x05': pos = line.length; redraw(); return; // Fin / Ctrl+E
|
||||
case '\t': complete(); return; // Tab: completar rutas
|
||||
}
|
||||
if (data.length !== 1 || data.charCodeAt(0) < 32) return; // control/escape: ignorar
|
||||
line = line.slice(0, pos) + data + line.slice(pos); pos += data.length; redraw();
|
||||
}
|
||||
|
||||
// Completado de rutas simple: mira el último token contra el directorio actual.
|
||||
async function complete() {
|
||||
const frag = (line.slice(0, pos).split(/\s+/).pop()) || '';
|
||||
const slash = frag.lastIndexOf('/');
|
||||
const dirPart = slash >= 0 ? frag.slice(0, slash + 1) : '';
|
||||
const base = slash >= 0 ? frag.slice(slash + 1) : frag;
|
||||
let names;
|
||||
try { names = await shell.listDir(dirPart); } catch { return; }
|
||||
const matches = names.filter(n => n.name.startsWith(base));
|
||||
if (matches.length === 1) {
|
||||
const add = matches[0].name.slice(base.length) + (matches[0].kind === 'directory' ? '/' : ' ');
|
||||
line = line.slice(0, pos) + add + line.slice(pos); pos += add.length; redraw();
|
||||
} else if (matches.length > 1) {
|
||||
term.write('\r\n' + matches.map(m => m.kind === 'directory' ? '\x1b[36m' + m.name + '/\x1b[0m' : m.name).join(' '));
|
||||
writePrompt(); term.write(line); if (line.length - pos > 0) term.write(`\x1b[${line.length - pos}D`);
|
||||
}
|
||||
}
|
||||
|
||||
// Monta el terminal en `host` (una vez). Idempotente; refoca si ya existe.
|
||||
export async function mount(host) {
|
||||
if (ready) { setTimeout(() => { fit && fit.fit(); term && term.focus(); }, 0); return; }
|
||||
await loadXterm();
|
||||
term = new Terminal({
|
||||
fontSize: 12.5, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
cursorBlink: true, convertEol: false, scrollback: 2000,
|
||||
theme: { background: '#0b0d12', foreground: '#e8ebf4', cursor: '#ff4d8d', selectionBackground: '#7c5cff55',
|
||||
brightBlack: '#6b7280', red: '#ff6b8b', green: '#7ee787', yellow: '#ffd479', blue: '#7c5cff', magenta: '#ff4d8d', cyan: '#56d4dd' },
|
||||
});
|
||||
fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(host);
|
||||
fit.fit();
|
||||
host.addEventListener('mousedown', () => setTimeout(() => term.focus(), 0));
|
||||
term.onData(onKey);
|
||||
const cap = shell.capabilities();
|
||||
term.write('\x1b[1;35mElffuss shell\x1b[0m \x1b[90m— ficheros reales del proyecto, en tu navegador.\x1b[0m');
|
||||
const rtHint = cap.bridge ? '🔌 Bridge local conectado: node/npm/python REALES en tu máquina.'
|
||||
: 'Escribe \x1b[0m\x1b[32mhelp\x1b[0m\x1b[90m. node/npm/python reales → conecta el Bridge local (⚙ Ajustes).';
|
||||
term.write('\r\n\x1b[90m' + rtHint + '\x1b[0m');
|
||||
writePrompt();
|
||||
ready = true;
|
||||
setTimeout(() => term.focus(), 0);
|
||||
}
|
||||
|
||||
export function refit() { if (ready) setTimeout(() => fit && fit.fit(), 0); }
|
||||
export function focus() { if (ready) term.focus(); }
|
||||
export function isReady() { return ready; }
|
||||
export function clearScreen() { if (ready) { term.clear(); line = ''; pos = 0; term.write('\r\x1b[K' + prompt()); term.focus(); } }
|
||||
|
||||
// Eco de una orden lanzada por la elfa (para que el usuario la vea en el terminal).
|
||||
export function echoAgent(cmd, out) {
|
||||
if (!ready) return;
|
||||
term.write('\r\x1b[K' + prompt() + '\x1b[90m' + cmd + ' (elfa)\x1b[0m');
|
||||
if (out) term.write('\r\n' + shell.stripAnsi(out).replace(/\n/g, '\r\n'));
|
||||
writePrompt(); term.write(line);
|
||||
}
|
||||
125
web/js/tools/clone.js
Normal file
125
web/js/tools/clone.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// Descarga un repo PÚBLICO (GitHub/Bitbucket) en una carpeta real elegida por
|
||||
// el usuario, para abrirlo como proyecto sin necesitar git instalado. Mismo
|
||||
// patrón que skills.js: árbol de Git vía API + contenidos por raw CDN (sin
|
||||
// tope de la API, solo la llamada al árbol cuenta contra el límite 60/h).
|
||||
//
|
||||
// RESUMIBLE por diseño: listRepo() (el árbol) y downloadFiles() (los ficheros)
|
||||
// están separados a propósito — quien llama (main.js) persiste la lista y qué
|
||||
// rutas ya se descargaron, así una descarga interrumpida (recarga, pestaña
|
||||
// cerrada) continúa donde se quedó en vez de volver a empezar.
|
||||
const MAX_FILES = 4000;
|
||||
const MAX_FILE_BYTES = 4 * 1024 * 1024; // ficheros más grandes se saltan (binarios enormes)
|
||||
const CONCURRENCY = 10;
|
||||
|
||||
export function parseRepoUrl(raw) {
|
||||
const url = String(raw || '').trim().replace(/\.git$/, '').replace(/\/+$/, '');
|
||||
let m = url.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)(?:\/tree\/([\w./-]+))?/i);
|
||||
if (m) return { host: 'github', owner: m[1], repo: m[2], branch: m[3] || null };
|
||||
m = url.match(/^(?:https?:\/\/)?(?:www\.)?bitbucket\.org\/([\w.-]+)\/([\w.-]+)(?:\/src\/([\w./-]+))?/i);
|
||||
if (m) return { host: 'bitbucket', owner: m[1], repo: m[2], branch: m[3] || null };
|
||||
throw new Error('URL no reconocida. Usa github.com/usuario/repo o bitbucket.org/usuario/repo');
|
||||
}
|
||||
|
||||
// escribe un fichero (texto o binario) en `root`, creando subcarpetas
|
||||
async function writeInto(root, path, blob) {
|
||||
const parts = path.split('/'); const name = parts.pop();
|
||||
let dir = root;
|
||||
for (const seg of parts) dir = await dir.getDirectoryHandle(seg, { create: true });
|
||||
const fh = await dir.getFileHandle(name, { create: true });
|
||||
const w = await fh.createWritable();
|
||||
await w.write(blob);
|
||||
await w.close();
|
||||
}
|
||||
|
||||
// pool de concurrencia simple: corre `items` a través de `worker`, máx N a la vez
|
||||
async function pool(items, n, worker) {
|
||||
let i = 0;
|
||||
const runners = Array.from({ length: Math.min(n, items.length) }, async () => {
|
||||
while (i < items.length) await worker(items[i++]);
|
||||
});
|
||||
await Promise.all(runners);
|
||||
}
|
||||
|
||||
// ── listar (una sola vez, resultado persistible) ─────────────────────────
|
||||
async function listGithubFiles({ owner, repo, branch }) {
|
||||
if (!branch) {
|
||||
const r = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
|
||||
if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.');
|
||||
if (!r.ok) throw new Error(`Repo no encontrado o privado (HTTP ${r.status})`);
|
||||
branch = (await r.json()).default_branch || 'main';
|
||||
}
|
||||
let tree = null;
|
||||
for (const b of [branch, 'main', 'master']) {
|
||||
const r = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${b}?recursive=1`);
|
||||
if (r.ok) { tree = (await r.json()).tree || []; branch = b; break; }
|
||||
if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.');
|
||||
}
|
||||
if (!tree) throw new Error('No pude leer el árbol del repo');
|
||||
const blobs = tree.filter(n => n.type === 'blob');
|
||||
const files = blobs.filter(n => !n.size || n.size <= MAX_FILE_BYTES).slice(0, MAX_FILES).map(n => ({ path: n.path, size: n.size || 0 }));
|
||||
return { branch, files, skipped: blobs.length - files.length };
|
||||
}
|
||||
// Bitbucket no tiene un único endpoint de árbol recursivo: se camina por directorios.
|
||||
async function bitbucketWalk(owner, repo, branch, path, files) {
|
||||
if (files.length >= MAX_FILES) return;
|
||||
let url = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${branch}/${path}?pagelen=100`;
|
||||
while (url && files.length < MAX_FILES) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`Bitbucket: HTTP ${r.status} (¿repo privado o rama incorrecta?)`);
|
||||
const data = await r.json();
|
||||
for (const e of data.values || []) {
|
||||
if (files.length >= MAX_FILES) break;
|
||||
if (e.type === 'commit_directory') await bitbucketWalk(owner, repo, branch, e.path, files);
|
||||
else if (e.type === 'commit_file' && (!e.size || e.size <= MAX_FILE_BYTES)) files.push({ path: e.path, size: e.size || 0 });
|
||||
}
|
||||
url = data.next || null;
|
||||
}
|
||||
}
|
||||
async function listBitbucketFiles({ owner, repo, branch }) {
|
||||
if (!branch) {
|
||||
const r = await fetch(`https://api.bitbucket.org/2.0/repositories/${owner}/${repo}`);
|
||||
if (!r.ok) throw new Error(`Repo no encontrado o privado (HTTP ${r.status})`);
|
||||
branch = (await r.json()).mainbranch?.name || 'main';
|
||||
}
|
||||
const files = [];
|
||||
await bitbucketWalk(owner, repo, branch, '', files);
|
||||
return { branch, files, skipped: 0 };
|
||||
}
|
||||
|
||||
// Lista el repo completo UNA vez. Devuelve todo lo necesario para persistir
|
||||
// el trabajo: { host, owner, repo, branch, files:[{path,size}], skipped }.
|
||||
export async function listRepo(url) {
|
||||
const info = parseRepoUrl(url);
|
||||
const { branch, files, skipped } = info.host === 'github' ? await listGithubFiles(info) : await listBitbucketFiles(info);
|
||||
if (!files.length) throw new Error('El repo está vacío (o no pude listarlo)');
|
||||
return { host: info.host, owner: info.owner, repo: info.repo, branch, files, skipped };
|
||||
}
|
||||
|
||||
// Descarga los ficheros de `job.files` que NO estén en `doneSet` (Set de
|
||||
// rutas). Llama a onFileDone(path) tras cada escritura para que quien invoque
|
||||
// persista el progreso — así una interrupción se retoma sin re-descargar nada.
|
||||
export async function downloadFiles(job, targetHandle, doneSet, onProgress, onFileDone) {
|
||||
const { host, owner, repo, branch, files } = job;
|
||||
const pending = files.filter(f => !doneSet.has(f.path));
|
||||
let done = files.length - pending.length;
|
||||
onProgress?.({ text: `${done}/${files.length}`, done, total: files.length });
|
||||
await pool(pending, CONCURRENCY, async f => {
|
||||
onProgress?.({ text: `Descargando ${f.path}…`, done, total: files.length });
|
||||
const url = host === 'github'
|
||||
? `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${f.path}`
|
||||
: `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${branch}/${f.path}`;
|
||||
const r = await fetch(url);
|
||||
if (r.ok) await writeInto(targetHandle, f.path, await r.blob());
|
||||
done++;
|
||||
onFileDone?.(f.path);
|
||||
onProgress?.({ text: `${done}/${files.length}`, done, total: files.length });
|
||||
});
|
||||
}
|
||||
|
||||
// Conveniencia: listar + descargar todo de una vez (sin resumibilidad; para
|
||||
// quien no necesite persistir progreso entre sesiones).
|
||||
export async function cloneToHandle(url, targetHandle, onProgress) {
|
||||
const job = await listRepo(url);
|
||||
await downloadFiles(job, targetHandle, new Set(), onProgress);
|
||||
return { count: job.files.length, skipped: job.skipped };
|
||||
}
|
||||
638
web/js/tools/code.js
Normal file
638
web/js/tools/code.js
Normal file
@@ -0,0 +1,638 @@
|
||||
// Herramientas de proyecto: el único mundo que toca Elffuss Code es la
|
||||
// carpeta de código que el usuario abre con el picker nativo.
|
||||
import * as db from '../db.js';
|
||||
|
||||
let projectHandle = null;
|
||||
let projectName = '';
|
||||
let currentFile = null; // ruta abierta en el editor
|
||||
let onFileWritten = () => {}; // el IDE refresca pestañas/árbol
|
||||
|
||||
export function setOnFileWritten(fn) { onFileWritten = fn; }
|
||||
export function setCurrentFile(path) { currentFile = path; }
|
||||
export function current() { return { projectName, currentFile }; }
|
||||
export function handle() { return projectHandle; }
|
||||
|
||||
// Lee un archivo de texto dentro de un dir handle por ruta (soporta subdirs).
|
||||
async function readIn(dir, path) {
|
||||
const parts = path.split('/'); const name = parts.pop();
|
||||
for (const p of parts) dir = await dir.getDirectoryHandle(p);
|
||||
return (await (await dir.getFileHandle(name)).getFile()).text();
|
||||
}
|
||||
|
||||
// Integración git SIN dependencias: parsea el .git directamente (rama + último
|
||||
// commit). Suficiente para orientar; commitear se le pide al agente/terminal.
|
||||
export async function gitInfo() {
|
||||
if (!projectHandle) return { isRepo: false };
|
||||
let git;
|
||||
try { git = await projectHandle.getDirectoryHandle('.git'); }
|
||||
catch { return { isRepo: false }; }
|
||||
const out = { isRepo: true, branch: '(detached)', lastCommit: null };
|
||||
try {
|
||||
const head = (await readIn(git, 'HEAD')).trim();
|
||||
out.branch = head.match(/ref:\s*refs\/heads\/(.+)/)?.[1] || head.slice(0, 7);
|
||||
} catch { /* sin HEAD */ }
|
||||
try {
|
||||
const last = (await readIn(git, 'logs/HEAD')).trim().split('\n').pop();
|
||||
const m = last.match(/^\S+ \S+ (.+?) <[^>]*> (\d+)[^\t]*\t(.+)$/);
|
||||
if (m) out.lastCommit = { author: m[1], when: new Date(+m[2] * 1000), msg: m[3] };
|
||||
} catch { /* sin logs (repo recién creado) */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
// Lista plana de archivos (para el command palette). Cacheada por proyecto.
|
||||
let fileListCache = null;
|
||||
export function invalidateFileList() { fileListCache = null; }
|
||||
export async function fileList() {
|
||||
if (fileListCache) return fileListCache;
|
||||
if (!projectHandle) return [];
|
||||
const files = [];
|
||||
async function walk(dir, prefix, depth) {
|
||||
if (depth > 8 || files.length > 4000) return;
|
||||
for await (const e of dir.values()) {
|
||||
if (IGNORE.has(e.name)) continue;
|
||||
const p = prefix ? prefix + '/' + e.name : e.name;
|
||||
if (e.kind === 'directory') await walk(e, p, depth + 1);
|
||||
else files.push(p);
|
||||
}
|
||||
}
|
||||
await walk(projectHandle, '', 0);
|
||||
fileListCache = files;
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function openProject(handle) {
|
||||
projectHandle = handle;
|
||||
projectName = handle.name || 'proyecto';
|
||||
await db.set('kv', 'project', handle).catch(() => {});
|
||||
return projectName;
|
||||
}
|
||||
|
||||
// Reabrir el último proyecto (el navegador puede exigir un gesto para re-conceder).
|
||||
export async function restoreProject() {
|
||||
const h = await db.get('kv', 'project').catch(() => null);
|
||||
if (!h) return null;
|
||||
const q = h.queryPermission ? await h.queryPermission({ mode: 'readwrite' }) : 'granted';
|
||||
if (q === 'granted') {
|
||||
projectHandle = h;
|
||||
projectName = h.name || 'proyecto';
|
||||
return { name: projectName, ready: true };
|
||||
}
|
||||
return { name: h.name, ready: false, handle: h };
|
||||
}
|
||||
|
||||
export async function regrant(h) {
|
||||
if (await h.requestPermission({ mode: 'readwrite' }) !== 'granted')
|
||||
throw new Error('permiso denegado');
|
||||
return openProject(h);
|
||||
}
|
||||
|
||||
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
||||
const MAX_READ = 60_000;
|
||||
|
||||
// Normaliza rutas del modelo: quita ./ y / iniciales y segmentos '.'
|
||||
const normalize = path => (path || '').split('/').filter(p => p && p !== '.');
|
||||
|
||||
async function dirOf(path, { create = false } = {}) {
|
||||
if (!projectHandle) throw new Error('No hay proyecto abierto');
|
||||
const parts = normalize(path);
|
||||
const name = parts.pop();
|
||||
let dir = projectHandle;
|
||||
for (const p of parts) dir = await dir.getDirectoryHandle(p, { create });
|
||||
return { dir, name };
|
||||
}
|
||||
|
||||
// Buscar archivos por nombre (para sugerir cuando el modelo inventa rutas, o
|
||||
// para resolver un enlace del chat que solo mencionaba el nombre del fichero).
|
||||
export async function findByName(basename, limit = 5) {
|
||||
const hits = [];
|
||||
let visited = 0;
|
||||
async function walk(dir, prefix) {
|
||||
if (hits.length >= limit || visited > 500) return;
|
||||
for await (const e of dir.values()) {
|
||||
if (IGNORE.has(e.name)) continue;
|
||||
visited++;
|
||||
const p = prefix ? prefix + '/' + e.name : e.name;
|
||||
if (e.kind === 'directory') await walk(e, p);
|
||||
else if (e.name.toLowerCase() === basename.toLowerCase()) hits.push(p);
|
||||
if (hits.length >= limit || visited > 500) return;
|
||||
}
|
||||
}
|
||||
await walk(projectHandle, '');
|
||||
return hits;
|
||||
}
|
||||
|
||||
// Árbol de texto (para el modelo y el CONTEXTO). Ignora dependencias/binarios.
|
||||
const TREE_MAX = 800;
|
||||
export async function tree({ path = '', depth = 3 } = {}) {
|
||||
if (!projectHandle) throw new Error('No hay proyecto abierto');
|
||||
let root = projectHandle;
|
||||
for (const p of normalize(path)) root = await root.getDirectoryHandle(p);
|
||||
const out = [];
|
||||
let count = 0, truncated = false;
|
||||
async function walk(dir, prefix, d) {
|
||||
if (d > depth || count >= TREE_MAX) return;
|
||||
const entries = [];
|
||||
for await (const e of dir.values()) entries.push(e);
|
||||
entries.sort((a, b) => (a.kind !== b.kind) ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name));
|
||||
for (const e of entries) {
|
||||
if (IGNORE.has(e.name)) continue;
|
||||
if (++count > TREE_MAX) { truncated = true; return; }
|
||||
out.push(prefix + (e.kind === 'directory' ? '📁 ' : '') + e.name);
|
||||
if (e.kind === 'directory') { await walk(e, prefix + ' ', d + 1); if (truncated) return; }
|
||||
}
|
||||
}
|
||||
await walk(root, '', 1);
|
||||
let res = out.join('\n') || '(vacío)';
|
||||
// Corte con AVISO y guía (como read/search): un «…» pelado se lee como «esto
|
||||
// es todo» y el modelo trabaja sobre un árbol incompleto sin saberlo.
|
||||
if (truncated)
|
||||
res += `\n… árbol recortado (>${TREE_MAX} entradas). Acota con code.tree({path:"subcarpeta"}) o localiza con code.search.`;
|
||||
return res;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100, PAGE_MAX = 500;
|
||||
|
||||
// Lectura completa (comportamiento de siempre) O por páginas de líneas — para
|
||||
// ficheros grandes, o para plantarse justo en el número de línea que dio
|
||||
// code.search y traer contexto antes/después sin volcar el fichero entero.
|
||||
// offset: línea 1-based donde empezar (con limit, PAGE_SIZE por defecto)
|
||||
// around: centra la página en esa línea (offset = around - limit/2)
|
||||
// Sin offset/limit/around → fichero completo tal cual (no rompe nada que ya
|
||||
// dependa de leerlo entero, p.ej. code.edit).
|
||||
export async function read({ path, offset, limit, around } = {}) {
|
||||
if (!path) throw new Error('Falta path');
|
||||
let file;
|
||||
try {
|
||||
const { dir, name } = await dirOf(path);
|
||||
file = await (await dir.getFileHandle(name)).getFile();
|
||||
} catch {
|
||||
// error que ENSEÑA: el modelo puede auto-corregirse en el siguiente paso
|
||||
const base = normalize(path).pop() || path;
|
||||
const hits = await findByName(base).catch(() => []);
|
||||
throw new Error(`no existe «${path}» en este proyecto.` +
|
||||
(hits.length ? ` ¿Quizá: ${hits.join(' · ')}?` : '') +
|
||||
' Consulta el árbol (code.tree) o busca (code.search) antes de leer.');
|
||||
}
|
||||
const full = await file.text();
|
||||
|
||||
if (offset == null && limit == null && around == null) {
|
||||
return full.length > MAX_READ ? full.slice(0, MAX_READ) + `\n… (recortado, ${file.size} bytes)` : full;
|
||||
}
|
||||
|
||||
const lines = full.split('\n');
|
||||
const total = lines.length;
|
||||
const size = Math.max(1, Math.min(Math.round(limit) || PAGE_SIZE, PAGE_MAX));
|
||||
const start = around != null
|
||||
? Math.max(1, Math.round(around) - Math.floor(size / 2))
|
||||
: Math.max(1, Math.round(offset) || 1);
|
||||
const startIdx = start - 1;
|
||||
// Pedir más allá del final devolvía un rango imposible («líneas 999-998 de 6»)
|
||||
// y CERO contenido: el modelo se quedaba sin nada que hacer. Mejor decírselo.
|
||||
if (startIdx >= total)
|
||||
return `${path}: solo tiene ${total} líneas y pediste desde la ${start} — usa offset entre 1 y ${total}.`;
|
||||
const slice = lines.slice(startIdx, startIdx + size);
|
||||
const endLine = startIdx + slice.length;
|
||||
const numbered = slice.map((l, i) => `${start + i}→${l}`).join('\n');
|
||||
const more = endLine < total ? `\n… quedan líneas ${endLine + 1}-${total} — pide code.read con offset:${endLine + 1} para seguir` : '';
|
||||
return `${path}: líneas ${start}-${endLine} de ${total}\n${numbered}${more}`;
|
||||
}
|
||||
|
||||
// Aprobación de escritura: si «Auto» está apagado, el IDE pide confirmación
|
||||
// antes de que la elfa toque archivos (como «Edit automatically» del plugin).
|
||||
let approveWrite = async () => true;
|
||||
export function setWriteApprover(fn) { approveWrite = fn; }
|
||||
|
||||
// `write` reescribe el fichero ENTERO, así que es la vía por la que se pierde
|
||||
// código. Medido: tras dos rechazos de la guarda de sintaxis en `edit`, el
|
||||
// modelo se escapó por aquí y reescribió el fichero. En uno de cuatro líneas da
|
||||
// igual; en uno de seiscientas, así es como desaparece medio proyecto.
|
||||
// `_interno` lo pone `edit`, que ya ha comprobado lo suyo sobre el texto exacto.
|
||||
export async function write({ path, content = '', _interno = false } = {}) {
|
||||
if (!path) throw new Error('Falta path');
|
||||
if (!_interno) {
|
||||
let previo = null;
|
||||
try { const { dir, name } = await dirOf(path); previo = await (await (await dir.getFileHandle(name)).getFile()).text(); }
|
||||
catch { /* fichero nuevo: nada que preservar */ }
|
||||
if (previo !== null) {
|
||||
const antes = previo.split('\n').length, ahora = content.split('\n').length;
|
||||
// Un fichero con cuerpo que se queda en menos de la mitad casi nunca es
|
||||
// intencionado: es el modelo reescribiendo de memoria lo que no recuerda.
|
||||
if (antes >= 40 && ahora < antes * 0.5) {
|
||||
anota('truncaria', path, { antes, ahora });
|
||||
throw new Error(`Eso dejaría ${path} en ${ahora} líneas cuando tiene ${antes}: perderías el resto del fichero. ` +
|
||||
`Para cambiar una parte usa code.edit con «search» y «replace»; code.write solo para ficheros nuevos o reescrituras completas de verdad.`);
|
||||
}
|
||||
revisaSintaxis(path, previo, content);
|
||||
}
|
||||
}
|
||||
if (!await approveWrite(path, content)) return `Cambio en ${path} rechazado por el usuario`;
|
||||
const { dir, name } = await dirOf(path, { create: true });
|
||||
const fh = await dir.getFileHandle(name, { create: true });
|
||||
const w = await fh.createWritable();
|
||||
await w.write(content);
|
||||
await w.close();
|
||||
invalidateFileList();
|
||||
onFileWritten(path, content);
|
||||
return `Escrito ${path} (${content.split('\n').length} líneas)`;
|
||||
}
|
||||
|
||||
// Edición PARCIAL (no hace falta reescribir el fichero entero): sustituye
|
||||
// `search` por `replace`. Primero intenta coincidencia EXACTA (rápida, sin
|
||||
// ambigüedad); si no la encuentra tal cual —el modelo puede recordar mal un
|
||||
// espacio o un salto de línea—, cae a un parcheado difuso (diff-match-patch,
|
||||
// la misma librería que usan herramientas como aider) que localiza el punto
|
||||
// más parecido dentro del fichero real. Si ni así hay confianza suficiente,
|
||||
// falla con un mensaje claro para que el agente reintente con más contexto
|
||||
// — nunca escribe una coincidencia dudosa.
|
||||
// Telemetría de edición: por qué vía se resolvió cada intento. Sin esto,
|
||||
// «la edición falla» no es accionable — no sabes si el modelo cita mal, si
|
||||
// cita ambiguo o si no encuentra el sitio.
|
||||
export const editStats = { total: 0, exacta: 0, bloque: 0, nucleo: 0, difusa: 0, ambigua: 0, noEncontrado: 0, sinCambio: 0, noExiste: 0, rompeSintaxis: 0, yaAplicado: 0, truncaria: 0, detalle: [] };
|
||||
export function resetEditStats() {
|
||||
editStats.total = editStats.exacta = editStats.bloque = editStats.nucleo = editStats.difusa = 0;
|
||||
editStats.ambigua = editStats.noEncontrado = editStats.sinCambio = editStats.noExiste = editStats.rompeSintaxis = editStats.yaAplicado = editStats.truncaria = 0;
|
||||
editStats.detalle.length = 0;
|
||||
}
|
||||
const anota = (via, path, extra) => { editStats[via]++; editStats.detalle.push({ via, path, ...extra }); };
|
||||
|
||||
// read() paginado devuelve «12→código» para que el modelo pueda pedir un
|
||||
// offset; el efecto secundario es que el modelo copia ESE texto como «search»,
|
||||
// con el número pegado delante. Entonces la cita no existe en el fichero y la
|
||||
// edición no puede encajar NUNCA — un fichero que pasa de una página se vuelve
|
||||
// ineditable. Se los quitamos, pero solo si TODAS las líneas los llevan: así
|
||||
// una flecha suelta dentro del código de verdad no se toca.
|
||||
const SIN_NUMEROS = /^[ \t]*\d+→/;
|
||||
// Exigir que TODAS las líneas lleven número no valía: un modelo pequeño numera
|
||||
// unas sí y otras no, la limpieza no se disparaba y la cita no podía casar. Con
|
||||
// mayoría basta, y «\d+→» al principio de línea es lo bastante raro en código
|
||||
// real como para no llevarse nada por delante. Se mira search y replace por
|
||||
// separado: si los números se colaran en «replace» acabarían ESCRITOS dentro
|
||||
// del fichero, que es peor que no editar.
|
||||
function quitaNumerosDeLinea(txt) {
|
||||
if (txt == null) return txt;
|
||||
const lineas = txt.split('\n');
|
||||
const conCuerpo = lineas.filter(l => l.trim());
|
||||
if (!conCuerpo.length) return txt;
|
||||
const numeradas = conCuerpo.filter(l => SIN_NUMEROS.test(l)).length;
|
||||
return numeradas / conCuerpo.length >= 0.6 ? lineas.map(l => l.replace(SIN_NUMEROS, '')).join('\n') : txt;
|
||||
}
|
||||
|
||||
// Una edición que deja el fichero sintácticamente roto es peor que una que no
|
||||
// se aplica: el modelo dice «arreglado» y lo que hay es un fichero que ya ni
|
||||
// carga. Caso real medido: el modelo sustituyó la línea de la firma por una
|
||||
// función entera CON su llave de cierre, dejando huérfano el cuerpo de antes
|
||||
// («Illegal return statement»). new Function() solo PARSEA, no ejecuta; para
|
||||
// que trague un módulo hay que quitarle import/export, que son sintaxis de
|
||||
// módulo. Solo se rechaza si el fichero estaba BIEN antes: si ya venía roto,
|
||||
// la edición es justamente lo que viene a arreglarlo.
|
||||
const ES_JS = /\.(js|mjs|cjs|jsx)$/i;
|
||||
function seParsea(codigo) {
|
||||
const sinModulo = codigo
|
||||
.replace(/^\s*import\s+[^;]*;?\s*$/gm, '')
|
||||
.replace(/^\s*export\s+default\s+/gm, 'void ')
|
||||
.replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, '')
|
||||
.replace(/^(\s*)export\s+/gm, '$1');
|
||||
try { new Function(sinModulo); return true; } catch (e) { return e instanceof SyntaxError ? String(e.message) : true; }
|
||||
}
|
||||
function revisaSintaxis(path, antes, despues) {
|
||||
if (!ES_JS.test(path)) return;
|
||||
if (seParsea(antes) !== true) return; // ya estaba roto: no estorbar
|
||||
const mal = seParsea(despues);
|
||||
if (mal !== true) {
|
||||
anota('rompeSintaxis', path, { error: String(mal).slice(0, 80) });
|
||||
throw new Error(`Esa edición dejaría ${path} sin poder cargarse (${mal}). ` +
|
||||
`Suele pasar por cerrar una llave de más: revisa que «replace» encaje con lo que rodea a «search», ` +
|
||||
`o incluye en «search» el bloque completo que vas a sustituir.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function edit({ path, search, replace } = {}) {
|
||||
editStats.total++;
|
||||
if (!path) throw new Error('Falta path');
|
||||
if (search == null || replace == null) throw new Error('Faltan search y replace');
|
||||
// Cada uno por su cuenta: el modelo puede numerar solo uno de los dos.
|
||||
search = quitaNumerosDeLinea(search);
|
||||
replace = quitaNumerosDeLinea(replace);
|
||||
// Contenido COMPLETO, sin el tope de MAX_READ. edit reescribe el fichero
|
||||
// entero, así que leer la vista recortada de read() truncaría todo lo que
|
||||
// hubiese más allá de MAX_READ (bug histórico: editar un fichero >60KB lo
|
||||
// dejaba en 60KB y metía el marcador «… (recortado)» dentro del código).
|
||||
let current;
|
||||
try {
|
||||
const { dir, name } = await dirOf(path);
|
||||
current = await (await (await dir.getFileHandle(name)).getFile()).text();
|
||||
} catch {
|
||||
anota('noExiste', path, {});
|
||||
await read({ path }); // relanza el error «que enseña» (sugiere code.tree/search); nunca retorna
|
||||
throw new Error(`no existe «${path}»`);
|
||||
}
|
||||
|
||||
// Reintentar sobre algo YA hecho era un error igual que no encontrarlo, así
|
||||
// que el modelo volvía a intentarlo en bucle. Si el resultado ya está y la
|
||||
// cita ya no, la edición sobra: decirlo como estado y seguir. Se exige el
|
||||
// «replace» COMPLETO presente y el «search» ausente, para no dar por buena
|
||||
// una edición que no ocurrió.
|
||||
if (replace.trim().length >= 8 && current.includes(replace) && !current.includes(search)) {
|
||||
anota('yaAplicado', path, {});
|
||||
return `Ese cambio ya está en ${path} — no hace falta editar. Sigue con lo siguiente.`;
|
||||
}
|
||||
|
||||
const first = current.indexOf(search);
|
||||
if (first !== -1) {
|
||||
if (current.indexOf(search, first + 1) !== -1) {
|
||||
anota('ambigua', path, { fase: 'exacta' });
|
||||
// decir DÓNDE está, igual que en las vías difusas: pedir «más contexto»
|
||||
// a secas hace que un modelo pequeño cite más largo y peor.
|
||||
const lineas = [];
|
||||
for (let i = current.indexOf(search); i !== -1 && lineas.length < 8; i = current.indexOf(search, i + 1))
|
||||
lineas.push(current.slice(0, i).split('\n').length);
|
||||
throw new Error(`«search» aparece ${lineas.length} veces en ${path} (líneas ${lineas.join(', ')}). ` +
|
||||
`Cita un bloque que incluya alguna línea que solo exista en el sitio que quieres cambiar.`);
|
||||
}
|
||||
const siguiente = current.slice(0, first) + replace + current.slice(first + search.length);
|
||||
// La vía difusa ya comprobaba esto abajo; la exacta no. Un search idéntico
|
||||
// al replace escribía el mismo fichero y devolvía ÉXITO, así que el agente
|
||||
// daba la tarea por arreglada sin haber cambiado una coma.
|
||||
if (siguiente === current) {
|
||||
anota('sinCambio', path, { fase: 'exacta' });
|
||||
throw new Error(`La edición en ${path} no cambió nada: «search» y «replace» son iguales. Escribe en «replace» el código YA corregido.`);
|
||||
}
|
||||
revisaSintaxis(path, current, siguiente);
|
||||
anota('exacta', path, {});
|
||||
return write({ path, content: siguiente, _interno: true });
|
||||
}
|
||||
|
||||
// fallback difuso, LOCAL y por líneas (sin depender de esm.sh en tiempo de
|
||||
// edición — eso rompería la edición sin conexión, justo lo contrario del
|
||||
// producto). El error típico del modelo al recordar «search» es la sangría o
|
||||
// los espacios; localizamos el bloque normalizando espacios y, si hace falta,
|
||||
// por parecido de líneas — y sustituimos ESE bloque, esté donde esté (también
|
||||
// en lo hondo de un fichero grande).
|
||||
const norm = s => s.replace(/[ \t]+/g, ' ').trim(); // .trim() se lleva también el \r final
|
||||
// Partimos guardando el offset de carácter de cada línea: así el bloque se
|
||||
// sustituye por CORTE EXACTO y todo lo de fuera queda byte a byte igual
|
||||
// (incluidos sus finales de línea). Con split/join se reescribía el fichero
|
||||
// entero y un fichero CRLF acababa con finales de línea MEZCLADOS.
|
||||
const curLines = [], lineStart = [];
|
||||
for (let i = 0; i <= current.length;) {
|
||||
const nl = current.indexOf('\n', i);
|
||||
lineStart.push(i);
|
||||
if (nl === -1) { curLines.push(current.slice(i)); break; }
|
||||
curLines.push(current.slice(i, nl)); // puede acabar en \r
|
||||
i = nl + 1;
|
||||
}
|
||||
const crlf = (current.match(/\r\n/g) || []).length;
|
||||
const eol = crlf && crlf * 2 >= (current.match(/\n/g) || []).length ? '\r\n' : '\n';
|
||||
const nCur = curLines.map(norm); // normalizamos UNA vez
|
||||
let seaLines = search.replace(/[\r\n]+$/, '').split(/\r?\n/);
|
||||
let repLines = replace.split(/\r?\n/);
|
||||
|
||||
// Posiciones donde un bloque de líneas encaja (ignorando espacios).
|
||||
const locate = (nBlock) => {
|
||||
const at = [];
|
||||
for (let i = 0; i + nBlock.length <= nCur.length; i++) {
|
||||
let same = true;
|
||||
for (let j = 0; j < nBlock.length; j++) if (nCur[i + j] !== nBlock[j]) { same = false; break; }
|
||||
if (same) { at.push(i); if (at.length > 8) break; }
|
||||
}
|
||||
return at;
|
||||
};
|
||||
// Pedir «más contexto» a un modelo pequeño produce citas más largas y peores.
|
||||
// Decirle en qué líneas está le deja elegir con un bloque MÁS corto y único.
|
||||
const ambiguous = (donde = []) => new Error(
|
||||
`«search» encaja en ${donde.length || 'varios'} sitios de ${path}` +
|
||||
(donde.length ? ` (líneas ${donde.map(i => i + 1).join(', ')})` : '') +
|
||||
`. Cita un bloque que incluya alguna línea que solo exista en el sitio que quieres cambiar.`);
|
||||
|
||||
let start = -1, k = seaLines.length;
|
||||
|
||||
// 1) el bloque ENTERO, idéntico salvo espacios → tiene que ser ÚNICO
|
||||
{
|
||||
const at = locate(seaLines.map(norm));
|
||||
if (at.length > 1) { anota('ambigua', path, { fase: 'bloque' }); throw ambiguous(at); }
|
||||
if (at.length === 1) { start = at[0]; anota('bloque', path, {}); }
|
||||
}
|
||||
|
||||
// 2) si no aparece entero, puede ser que el fichero tenga líneas que el modelo
|
||||
// NO vio (un comentario añadido después, p. ej.) metidas dentro del bloque.
|
||||
// Como search y replace comparten el contexto sin tocar al principio y al
|
||||
// final, recortamos esa parte común y buscamos solo el NÚCLEO que cambia: así
|
||||
// lo que el modelo no vio se queda donde estaba en vez de desaparecer.
|
||||
if (start === -1) {
|
||||
let pre = 0, suf = 0;
|
||||
while (pre < seaLines.length && pre < repLines.length && norm(seaLines[pre]) === norm(repLines[pre])) pre++;
|
||||
while (suf < seaLines.length - pre && suf < repLines.length - pre &&
|
||||
norm(seaLines[seaLines.length - 1 - suf]) === norm(repLines[repLines.length - 1 - suf])) suf++;
|
||||
const core = seaLines.slice(pre, seaLines.length - suf);
|
||||
if (core.length) {
|
||||
let at = locate(core.map(norm));
|
||||
if (at.length > 1) {
|
||||
// desempate por CONTEXTO: gana el candidato con más líneas del contexto
|
||||
// recortado alrededor (y solo si gana en solitario).
|
||||
const ctx = [...seaLines.slice(0, pre), ...seaLines.slice(seaLines.length - suf)].map(norm).filter(Boolean);
|
||||
const near = i => {
|
||||
const from = Math.max(0, i - pre - 3), to = Math.min(nCur.length, i + core.length + suf + 3);
|
||||
const around = nCur.slice(from, to);
|
||||
return ctx.filter(c => around.includes(c)).length;
|
||||
};
|
||||
const scored = at.map(i => ({ i, s: near(i) })).sort((a, b) => b.s - a.s);
|
||||
if (ctx.length && scored[0].s > scored[1].s) at = [scored[0].i];
|
||||
}
|
||||
if (at.length > 1) { anota('ambigua', path, { fase: 'nucleo' }); throw ambiguous(at); }
|
||||
if (at.length === 1) { start = at[0]; k = core.length; repLines = repLines.slice(pre, repLines.length - suf); anota('nucleo', path, { pre, suf }); }
|
||||
}
|
||||
}
|
||||
|
||||
// 3) por parecido: la ventana con más líneas coincidentes, exigiendo ≥70% y
|
||||
// que gane con claridad a la segunda mejor (sin ambigüedad)
|
||||
let mejorVentana = null;
|
||||
if (start === -1) {
|
||||
const nSea = seaLines.map(norm);
|
||||
let best = -1, bestScore = 0, second = 0;
|
||||
for (let i = 0; i + k <= nCur.length; i++) {
|
||||
let hit = 0;
|
||||
for (let j = 0; j < k; j++) if (nCur[i + j] === nSea[j]) hit++;
|
||||
const score = hit / k;
|
||||
if (score > bestScore) { second = bestScore; bestScore = score; best = i; }
|
||||
else if (score > second) second = score;
|
||||
}
|
||||
if (bestScore >= 0.7 && bestScore - second >= 0.2) { start = best; anota('difusa', path, { parecido: +bestScore.toFixed(2) }); }
|
||||
else if (bestScore > 0) anota('noEncontrado', path, { mejorParecido: +bestScore.toFixed(2), segundo: +second.toFixed(2) });
|
||||
// Pedirle que RELEA le cuesta un turno entero y a un modelo pequeño le sale
|
||||
// una cita más larga y peor. Como ya sabemos dónde está lo más parecido,
|
||||
// se lo damos literal y numerado: puede copiar de ahí en el mismo turno.
|
||||
if (best >= 0 && bestScore > 0) {
|
||||
const desde = Math.max(0, best - 2), hasta = Math.min(curLines.length, best + k + 2);
|
||||
mejorVentana = curLines.slice(desde, hasta)
|
||||
.map((l, i) => `${desde + i + 1}→${l}`).slice(0, 12).join('\n');
|
||||
}
|
||||
}
|
||||
if (start === -1)
|
||||
throw new Error(`No encontré en ${path} el bloque que citas. ` +
|
||||
(mejorVentana ? `Lo más parecido que hay es esto (copia «search» LITERAL de aquí, sin los números):\n${mejorVentana}`
|
||||
: `Léelo con code.read y copia «search» literal de esas líneas.`));
|
||||
|
||||
// Corte exacto del bloque [start, start+k): desde el inicio de su primera
|
||||
// línea hasta el inicio de la siguiente (es decir, salto de línea incluido).
|
||||
const cutFrom = lineStart[start];
|
||||
const hasTrailingNL = start + k < lineStart.length;
|
||||
const cutTo = hasTrailingNL ? lineStart[start + k] : current.length;
|
||||
const block = repLines.join(eol) + (hasTrailingNL ? eol : '');
|
||||
const nextContent = current.slice(0, cutFrom) + block + current.slice(cutTo);
|
||||
if (nextContent === current) {
|
||||
anota('sinCambio', path, {});
|
||||
throw new Error(`La edición en ${path} no cambió nada — revisa «replace».`);
|
||||
}
|
||||
revisaSintaxis(path, current, nextContent);
|
||||
return write({ path, content: nextContent, _interno: true });
|
||||
}
|
||||
|
||||
// grep-lite por el proyecto (texto, con límites para no arrasar). Los topes
|
||||
// existen por rendimiento, pero si se alcanzan hay que DECÍRSELO al modelo
|
||||
// (igual que read() avisa «quedan líneas…»); si no, un corte silencioso se lee
|
||||
// como «no hay más» y el modelo da por cerrada una búsqueda incompleta.
|
||||
const SEARCH_MAX_FILES = 1500, SEARCH_MAX_HITS = 80, SEARCH_MAX_BYTES = 2_000_000;
|
||||
// Binarios: NO pueden contener el texto que se busca y leerlos como texto es
|
||||
// caro. Fuera de esta lista quedan .svg/.json/.md/.csv, que son texto y sí se
|
||||
// buscan. (Antes el tope de tamaño hacía de filtro accidental de binarios.)
|
||||
const BINARY_EXT = /\.(png|jpe?g|gif|webp|avif|ico|bmp|tiff?|mp[34]|m4a|wav|ogg|mov|avi|webm|pdf|zip|gz|bz2|xz|tar|7z|rar|woff2?|ttf|eot|otf|wasm|bin|exe|dll|so|dylib|class|jar|pyc|sqlite3?)$/i;
|
||||
export async function search({ query, ext = '' } = {}) {
|
||||
if (!query) throw new Error('Falta query');
|
||||
if (!projectHandle) throw new Error('No hay proyecto abierto');
|
||||
const results = [];
|
||||
const skipped = []; // ficheros de texto NO buscados por tamaño
|
||||
let checked = 0, capped = false;
|
||||
const q = query.toLowerCase();
|
||||
async function walk(dir, prefix) {
|
||||
if (results.length >= SEARCH_MAX_HITS || checked >= SEARCH_MAX_FILES) { capped = true; return; }
|
||||
for await (const e of dir.values()) {
|
||||
if (IGNORE.has(e.name)) continue;
|
||||
const p = prefix ? prefix + '/' + e.name : e.name;
|
||||
if (e.kind === 'directory') { await walk(e, p); if (capped) return; continue; }
|
||||
if (ext && !e.name.endsWith(ext)) continue;
|
||||
if (BINARY_EXT.test(e.name)) continue;
|
||||
const f = await e.getFile();
|
||||
// Un fichero grande es justo donde el modelo NO puede leerlo entero, así
|
||||
// que es el que más falta hace buscar. Se busca hasta SEARCH_MAX_BYTES; y
|
||||
// lo que quede fuera se DICE (antes: >200KB se saltaba en silencio, y
|
||||
// «Sin resultados» sonaba a «ese código no existe» siendo mentira).
|
||||
if (f.size > SEARCH_MAX_BYTES) { skipped.push(p); continue; }
|
||||
checked++;
|
||||
const lines = (await f.text()).split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].toLowerCase().includes(q)) {
|
||||
results.push(`${p}:${i + 1}: ${lines[i].trim().slice(0, 140)}`);
|
||||
if (results.length >= SEARCH_MAX_HITS) { capped = true; break; }
|
||||
}
|
||||
}
|
||||
if (results.length >= SEARCH_MAX_HITS || checked >= SEARCH_MAX_FILES) { capped = true; return; }
|
||||
}
|
||||
}
|
||||
await walk(projectHandle, '');
|
||||
// Todo lo que la búsqueda NO ha mirado se cuenta; un resultado incompleto que
|
||||
// se presenta como completo es peor que no buscar.
|
||||
const notes = [];
|
||||
if (capped) notes.push(`búsqueda cortada (${results.length} resultados, ${checked} ficheros revisados) — puede haber más: afina con ext o un término más concreto`);
|
||||
if (skipped.length) notes.push(`${skipped.length} fichero(s) NO buscados por tamaño (>${SEARCH_MAX_BYTES / 1e6} MB): ${skipped.slice(0, 3).join(', ')}${skipped.length > 3 ? '…' : ''} — mira dentro con code.read`);
|
||||
const tail = notes.length ? '\n… ' + notes.join('. ') + '.' : '';
|
||||
if (!results.length)
|
||||
return `Sin resultados para «${query}»` + (tail || '');
|
||||
return results.join('\n') + tail;
|
||||
}
|
||||
|
||||
// ── code.run: ejecutar el código y ver qué hace de verdad ───────────────────
|
||||
//
|
||||
// Medido con Gemma E4B: arregló un bug DOS veces (cambió la constante Y el
|
||||
// operador) y dejó un fichero perfectamente válido pero incorrecto, rematando
|
||||
// con «aplicado correctamente». Ninguna guarda sintáctica puede ver eso: la
|
||||
// única forma de saberlo es ejecutarlo.
|
||||
//
|
||||
// Corre en un Worker de módulo: sin DOM, sin acceso a la página, y se le corta
|
||||
// el paso por tiempo si se cuelga en un bucle. Los imports relativos se
|
||||
// resuelven a mano contra el proyecto — sin build, un blob no sabe resolver
|
||||
// «./otro.js» por su cuenta, y sin esto solo servirían los ficheros sueltos.
|
||||
const RUN_MS = 4000, RUN_MAX_MODULOS = 40;
|
||||
|
||||
async function moduloComoBlob(path, hechos = new Map(), profundidad = 0) {
|
||||
const clave = normalize(path).join('/');
|
||||
if (hechos.has(clave)) return hechos.get(clave);
|
||||
if (hechos.size >= RUN_MAX_MODULOS || profundidad > 12)
|
||||
throw new Error(`demasiados ficheros encadenados desde ${path}`);
|
||||
hechos.set(clave, null); // marca de ciclo
|
||||
const { dir, name } = await dirOf(clave);
|
||||
let txt = await (await (await dir.getFileHandle(name)).getFile()).text();
|
||||
|
||||
// reescribir cada import/export relativo al blob del fichero al que apunta
|
||||
const relativos = [...txt.matchAll(/(from\s*|import\s*\(\s*)(['"])(\.[^'"]+)\2/g)];
|
||||
for (const m of relativos) {
|
||||
const destino = normalize(clave.split('/').slice(0, -1).join('/') + '/' + m[3]).join('/');
|
||||
let url;
|
||||
try { url = await moduloComoBlob(destino, hechos, profundidad + 1); }
|
||||
catch { continue; } // que falle al importar, no aquí
|
||||
if (url) txt = txt.split(m[0]).join(`${m[1]}${m[2]}${url}${m[2]}`);
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([txt], { type: 'text/javascript' }));
|
||||
hechos.set(clave, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function run({ path, expr } = {}) {
|
||||
if (!path) throw new Error('Falta path');
|
||||
if (!expr) throw new Error('Falta expr: qué quieres evaluar, p.ej. "m.max([])"');
|
||||
if (!ES_JS.test(path)) throw new Error(`code.run solo ejecuta JavaScript, y ${path} no lo es`);
|
||||
if (/^\s*import[\s{]/.test(expr) || /\brequire\s*\(/.test(expr))
|
||||
throw new Error(`«expr» es solo una EXPRESIÓN, no un programa: el módulo ya está importado. ` +
|
||||
`Escribe directamente la llamada, p.ej. «miFuncion(1, 2)» o «m.miFuncion(1, 2)».`);
|
||||
const hechos = new Map();
|
||||
let url;
|
||||
try { url = await moduloComoBlob(path, hechos); }
|
||||
catch (e) { throw new Error(`no pude preparar ${path} para ejecutarlo: ${e.message}`); }
|
||||
|
||||
// Los nombres exportados se ATAN como variables sueltas, además de en «m».
|
||||
// Medido: el modelo escribe «slugify(...)» de forma natural, le saltaba un
|
||||
// «slugify is not defined», y de ahí concluía que su arreglo estaba mal y se
|
||||
// ponía a destrozar código que YA funcionaba — le quitó el export y rompió el
|
||||
// módulo. La herramienta de comprobar le enseñó que lo correcto era incorrecto.
|
||||
// Y si aun así nombra algo que no existe, el error DICE qué hay exportado, en
|
||||
// vez de dejarle deducir que el fichero está roto.
|
||||
const guion = `
|
||||
self.onmessage = async ({ data }) => {
|
||||
try {
|
||||
const m = await import(data.url);
|
||||
const nombres = Object.keys(m).filter(n => /^[A-Za-z_$][\\w$]*$/.test(n));
|
||||
const fn = new Function('m', ...nombres, 'return (' + data.expr + ')');
|
||||
const valor = await fn(m, ...nombres.map(n => m[n]));
|
||||
let texto;
|
||||
try { texto = JSON.stringify(valor); } catch { texto = String(valor); }
|
||||
if (texto === undefined) texto = String(valor);
|
||||
self.postMessage({ ok: true, tipo: typeof valor, texto });
|
||||
} catch (e) {
|
||||
let exporta = [];
|
||||
try { exporta = Object.keys(await import(data.url)); } catch { /* ni carga */ }
|
||||
self.postMessage({ ok: false, error: e.message, exporta });
|
||||
}
|
||||
};`;
|
||||
const wUrl = URL.createObjectURL(new Blob([guion], { type: 'text/javascript' }));
|
||||
const worker = new Worker(wUrl, { type: 'module' });
|
||||
try {
|
||||
const r = await new Promise((resolve) => {
|
||||
// Un bucle infinito en el código del usuario no puede colgar el IDE.
|
||||
const reloj = setTimeout(() => resolve({ ok: false, error: `no terminó en ${RUN_MS} ms (¿bucle infinito?)` }), RUN_MS);
|
||||
worker.onmessage = e => { clearTimeout(reloj); resolve(e.data); };
|
||||
worker.onerror = e => { clearTimeout(reloj); resolve({ ok: false, error: e.message || 'error al cargar el módulo' }); };
|
||||
worker.postMessage({ url, expr });
|
||||
});
|
||||
if (r.ok) return `${expr} → ${r.texto} (${r.tipo})`;
|
||||
// Un «X is not defined» sin más lleva al modelo a «el fichero está mal».
|
||||
// Decirle qué exporta de verdad corta esa deducción en seco.
|
||||
const noDefinido = /(\w+) is not defined/.exec(r.error || '');
|
||||
const pista = noDefinido && r.exporta?.length
|
||||
? ` — «${noDefinido[1]}» no está en ${path}, que exporta: ${r.exporta.join(', ')}. Llámalo por su nombre o con m.<nombre>; el fichero NO tiene por qué estar mal.`
|
||||
: (r.exporta?.length ? ` (${path} exporta: ${r.exporta.join(', ')})` : '');
|
||||
return `${expr} lanzó: ${r.error}${pista}`;
|
||||
} finally {
|
||||
worker.terminate();
|
||||
URL.revokeObjectURL(wUrl);
|
||||
for (const u of hechos.values()) if (u) URL.revokeObjectURL(u);
|
||||
}
|
||||
}
|
||||
58
web/js/tools/index.js
Normal file
58
web/js/tools/index.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Registro de herramientas de Elffuss Code: proyecto abierto + shell + web.
|
||||
import * as code from './code.js';
|
||||
import * as shell from '../shell.js';
|
||||
import * as web from './web.js';
|
||||
|
||||
export { code };
|
||||
|
||||
// El terminal (UI) se engancha aquí para reflejar lo que ejecuta la elfa.
|
||||
let onTerminalEcho = () => {};
|
||||
export function setTerminalEcho(fn) { onTerminalEcho = fn; }
|
||||
|
||||
export const TOOLS = {
|
||||
'code.tree': { desc: 'Ver el árbol de archivos del proyecto', params: { path: 'subcarpeta (opcional)', depth: 'niveles (3)' }, run: a => code.tree(a) },
|
||||
'code.read': { desc: 'Leer un archivo del proyecto. Sin más parámetros, lo lee ENTERO. Para ficheros grandes, pagina de 100 en 100 líneas con offset (offset:101 = siguiente página tras la primera). Para ir directo a donde dio un resultado de code.search (que ya incluye número de línea), usa around:N — trae ~100 líneas centradas en la línea N, con cada línea numerada (N→contenido) para poder citarla o editarla con precisión', params: { path: 'ruta relativa', offset: 'línea 1-based donde empezar (opcional)', limit: 'líneas a devolver, 100 por defecto (opcional)', around: 'centra la página en esta línea, p.ej. la que dio code.search (opcional)' }, run: a => code.read(a) },
|
||||
'code.write': { desc: 'Escribir/crear un archivo (contenido COMPLETO; se refleja al instante en el editor). Para cambios PEQUEÑOS en un fichero YA existente, usa mejor code.edit', params: { path: 'ruta', content: 'contenido íntegro' }, run: a => code.write(a) },
|
||||
'code.edit': { desc: 'Editar PARTE de un fichero existente sin reescribirlo entero: sustituye "search" (copia literal de unas pocas líneas, con contexto suficiente para ser única en el fichero) por "replace". Preferible a code.write para cambios pequeños/medianos — más rápido y sin riesgo de perder el resto del fichero. Si falla (no encuentra el punto con confianza), relee el fichero y reintenta con un search más ajustado', params: { path: 'ruta', search: 'texto exacto a sustituir', replace: 'texto nuevo' }, run: a => code.edit(a) },
|
||||
'code.run': { desc: 'EJECUTAR una expresión contra un módulo JS del proyecto y ver qué devuelve de verdad. Úsalo SIEMPRE después de arreglar algo, para comprobar que el arreglo funciona antes de darlo por bueno: code.run con path del fichero y expr como "max([])" — las funciones exportadas se llaman por su nombre a secas, sin import ni require (también valen como m.max). Corre aislado y con límite de tiempo', params: { path: 'ruta del .js', expr: 'expresión a evaluar, p.ej. m.suma(2,3)' }, run: a => code.run(a) },
|
||||
'code.search': { desc: 'Buscar texto en el proyecto (grep)', params: { query: 'texto', ext: 'filtro extensión (opcional)' }, run: a => code.search(a) },
|
||||
'terminal.run': { desc: 'Ejecutar un comando de shell sobre los ficheros del proyecto (ls, cat, grep, find, mkdir, echo>fichero, git status…). node/npm/python REALES si el usuario tiene un Bridge local conectado (⚙ Ajustes → 🔌 Bridge local); si no está conectado, el propio resultado lo indica', params: { command: 'la línea de comando' }, run: async a => { const out = await shell.runForAgent(a.command || ''); onTerminalEcho(a.command || '', out); return out; } },
|
||||
'web.search': { desc: 'Buscar en internet (docs, errores, APIs) — devuelve títulos, URLs y fragmentos', params: { query: 'qué buscar' }, run: a => web.search(a) },
|
||||
'web.fetch': { desc: 'Leer el contenido de texto de una URL (documentación, referencia)', params: { url: 'https://…' }, run: a => web.fetchUrl(a) },
|
||||
};
|
||||
|
||||
export function toolHelp() {
|
||||
return Object.entries(TOOLS).map(([n, t]) =>
|
||||
`- ${n}(${Object.keys(t.params).join(', ')}): ${t.desc}`).join('\n');
|
||||
}
|
||||
|
||||
export async function runTool(name, args) {
|
||||
const tool = TOOLS[name];
|
||||
if (!tool) throw new Error(`Herramienta desconocida: ${name}`);
|
||||
return tool.run(args || {});
|
||||
}
|
||||
|
||||
// CONTEXTO AHORA del IDE: proyecto, archivo abierto y árbol resumido.
|
||||
export async function snapshot() {
|
||||
const { projectName, currentFile } = code.current();
|
||||
const parts = [
|
||||
'Fecha y hora: ' + new Date().toLocaleString(),
|
||||
'Proyecto abierto: ' + (projectName || 'ninguno'),
|
||||
'Archivo abierto en el editor: ' + (currentFile || 'ninguno'),
|
||||
];
|
||||
try { parts.push('Árbol del proyecto (resumen):\n' + await code.tree({ depth: 2 })); }
|
||||
catch { /* aún sin proyecto */ }
|
||||
// GROUNDING: incluye el contenido REAL de los archivos clave (README, config)
|
||||
// para que el modelo NO alucine sobre el proyecto — el heal no domina las
|
||||
// herramientas code.* así que le damos el material masticado.
|
||||
for (const key of ['README.md', 'readme.md', 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod']) {
|
||||
try { const body = await code.read({ path: key }); parts.push(`Contenido REAL de ${key}:\n${body.slice(0, 1400)}`); break; } catch { /* siguiente */ }
|
||||
}
|
||||
if (currentFile) {
|
||||
try {
|
||||
const body = await code.read({ path: currentFile });
|
||||
parts.push(`Contenido REAL del archivo abierto ${currentFile}:\n` + body.slice(0, 2000));
|
||||
} catch { /* borrado */ }
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
52
web/js/tools/web.js
Normal file
52
web/js/tools/web.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Búsqueda y lectura web para la elfa de Code (docs, errores, APIs). fetch
|
||||
// directo si el sitio permite CORS; si no, cae al proxy del servidor
|
||||
// (/proxy?url=… → mismo backend que Elffuss Claw). Sin gate de permisos: un
|
||||
// agente de código necesita consultar internet de serie.
|
||||
const MAX = 8000;
|
||||
|
||||
function toText(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script,style,noscript,svg,iframe').forEach(n => n.remove());
|
||||
return ((doc.body?.innerText || doc.body?.textContent || '').replace(/\n{3,}/g, '\n\n').trim()) || html.slice(0, MAX);
|
||||
}
|
||||
|
||||
async function proxyGet(url) {
|
||||
let lastErr;
|
||||
for (const target of [url, '/proxy?url=' + encodeURIComponent(url)]) {
|
||||
try { const r = await fetch(target); if (!r.ok) throw new Error('HTTP ' + r.status); return await r.text(); }
|
||||
catch (e) { lastErr = e; }
|
||||
}
|
||||
throw lastErr || new Error('sin respuesta');
|
||||
}
|
||||
|
||||
export async function fetchUrl({ url } = {}) {
|
||||
if (!/^https?:\/\//.test(url || '')) throw new Error('URL inválida (usa https://…)');
|
||||
const text = toText(await proxyGet(url));
|
||||
return `[${url}]\n` + (text.length > MAX ? text.slice(0, MAX) + '\n… (recortado)' : text);
|
||||
}
|
||||
|
||||
// Búsqueda REAL en DuckDuckGo. Parseo tolerante: cada resultado es un enlace
|
||||
// con ?uddg=<url real>. Independiente de clases (robusto a cambios de HTML).
|
||||
export async function search({ query } = {}) {
|
||||
if (!query) throw new Error('Falta query');
|
||||
let html = '';
|
||||
for (const ep of ['https://html.duckduckgo.com/html/?q=', 'https://lite.duckduckgo.com/lite/?q=']) {
|
||||
try { html = await proxyGet(ep + encodeURIComponent(query)); if (html.includes('uddg=') || html.includes('result')) break; }
|
||||
catch { /* siguiente endpoint */ }
|
||||
}
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const seen = new Set(), out = [];
|
||||
for (const a of doc.querySelectorAll('a[href*="uddg="]')) {
|
||||
const m = a.getAttribute('href').match(/uddg=([^&]+)/);
|
||||
if (!m) continue;
|
||||
const href = decodeURIComponent(m[1]);
|
||||
const title = a.textContent.trim();
|
||||
if (!title || title.length < 3 || seen.has(href)) continue;
|
||||
seen.add(href);
|
||||
const block = a.closest('tr, div, article') || a.parentElement;
|
||||
const snip = (block?.textContent || '').replace(title, '').replace(/\s+/g, ' ').trim().slice(0, 160);
|
||||
out.push(`• ${title}\n ${href}${snip ? '\n ' + snip : ''}`);
|
||||
if (out.length >= 8) break;
|
||||
}
|
||||
return out.length ? `Resultados para «${query}»:\n\n` + out.join('\n\n') : `Sin resultados para «${query}».`;
|
||||
}
|
||||
668
web/js/vcc/builder.js
Normal file
668
web/js/vcc/builder.js
Normal file
@@ -0,0 +1,668 @@
|
||||
// Construcción de la escena: ciudad de cristal nítida.
|
||||
// Receta anti-difuminado: aristas finas con colores HDR (el bloom solo se
|
||||
// dispara en lo resaltado), cristal con fresnel de opacidad mínima y nada
|
||||
// de volúmenes aditivos permanentes.
|
||||
// Toda la paleta viene del tema activo (src/themes.js); los temas claros
|
||||
// usan blending normal en lugar de aditivo.
|
||||
import * as THREE from 'three';
|
||||
|
||||
function lerp3(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
|
||||
function configureCanvasTexture(tx) {
|
||||
tx.colorSpace = THREE.SRGBColorSpace;
|
||||
tx.anisotropy = 8;
|
||||
// Las texturas son texto fino sobre fondo transparente. Sin mipmaps, los
|
||||
// píxeles transparentes no se mezclan con negro al orbitar y solaparse.
|
||||
tx.generateMipmaps = false;
|
||||
tx.minFilter = THREE.LinearFilter;
|
||||
tx.magFilter = THREE.LinearFilter;
|
||||
return tx;
|
||||
}
|
||||
|
||||
// 12 aristas de una caja → 24 vértices
|
||||
const EDGE_PAIRS = [
|
||||
[0, 1], [1, 3], [3, 2], [2, 0],
|
||||
[4, 5], [5, 7], [7, 6], [6, 4],
|
||||
[0, 4], [1, 5], [2, 6], [3, 7],
|
||||
];
|
||||
function corners(b) {
|
||||
const x0 = b.cx - b.sx / 2, x1 = b.cx + b.sx / 2;
|
||||
const y0 = b.cy - b.sy / 2, y1 = b.cy + b.sy / 2;
|
||||
const z0 = b.cz - b.sz / 2, z1 = b.cz + b.sz / 2;
|
||||
return [
|
||||
[x0, y0, z0], [x1, y0, z0], [x0, y0, z1], [x1, y0, z1],
|
||||
[x0, y1, z0], [x1, y1, z0], [x0, y1, z1], [x1, y1, z1],
|
||||
];
|
||||
}
|
||||
|
||||
// ── suelo: placa de circuito impreso ──────────────────────────────────────
|
||||
function circuitTexture(theme) {
|
||||
const T = theme.ground;
|
||||
const S = 1024;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const g = cv.getContext('2d');
|
||||
g.fillStyle = T.bg;
|
||||
g.fillRect(0, 0, S, S);
|
||||
g.strokeStyle = T.grid;
|
||||
g.lineWidth = 1;
|
||||
for (let i = 0; i <= S; i += 64) {
|
||||
g.beginPath(); g.moveTo(i, 0); g.lineTo(i, S); g.stroke();
|
||||
g.beginPath(); g.moveTo(0, i); g.lineTo(S, i); g.stroke();
|
||||
}
|
||||
const rnd = (() => { let s = 1234567; return () => (s = (s * 16807) % 2147483647) / 2147483647; })();
|
||||
for (let i = 0; i < 260; i++) {
|
||||
const isA = rnd() < 0.58;
|
||||
const a = 0.18 + rnd() * 0.5;
|
||||
const [tr, tg, tb] = isA ? T.traceA : T.traceB;
|
||||
g.strokeStyle = `rgba(${tr}, ${tg}, ${tb}, ${isA ? a : a * 0.9})`;
|
||||
g.lineWidth = rnd() < 0.12 ? 3 : 1.4;
|
||||
let x = rnd() * S, y = rnd() * S;
|
||||
g.beginPath(); g.moveTo(x, y);
|
||||
const segs = 2 + Math.floor(rnd() * 4);
|
||||
for (let sg = 0; sg < segs; sg++) {
|
||||
const len = 24 + rnd() * 150;
|
||||
if (rnd() < 0.5) x += rnd() < 0.5 ? len : -len; else y += rnd() < 0.5 ? len : -len;
|
||||
g.lineTo(x, y);
|
||||
}
|
||||
g.stroke();
|
||||
const [pr, pg, pb] = isA ? T.padA : T.padB;
|
||||
g.fillStyle = `rgba(${pr}, ${pg}, ${pb}, ${a + 0.2})`;
|
||||
const ps = 2 + rnd() * 3;
|
||||
g.fillRect(x - ps / 2, y - ps / 2, ps, ps);
|
||||
}
|
||||
const tx = new THREE.CanvasTexture(cv);
|
||||
tx.wrapS = tx.wrapT = THREE.RepeatWrapping;
|
||||
tx.colorSpace = THREE.SRGBColorSpace;
|
||||
tx.anisotropy = 8;
|
||||
return tx;
|
||||
}
|
||||
|
||||
// ── cristal con fresnel (bordes algo más vivos, centro casi invisible) ────
|
||||
function glassMaterial(theme, dim = 1, solid = 0) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color(...theme.glass) },
|
||||
uHi: { value: new THREE.Color(...theme.glassHi) },
|
||||
uGlow: { value: 0 },
|
||||
uDim: { value: dim },
|
||||
uSolid: { value: solid },
|
||||
},
|
||||
vertexShader: /* glsl */`
|
||||
varying vec3 vN, vV;
|
||||
void main() {
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
vN = normalMatrix * normal;
|
||||
vV = -mv.xyz;
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}`,
|
||||
fragmentShader: /* glsl */`
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uHi;
|
||||
uniform float uGlow;
|
||||
uniform float uDim;
|
||||
uniform float uSolid;
|
||||
varying vec3 vN, vV;
|
||||
void main() {
|
||||
float fr = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.6);
|
||||
vec3 col = mix(uColor, uHi, uGlow);
|
||||
float alpha = (0.022 + fr * 0.12 + uSolid * 0.12) * uDim + uGlow * 0.05;
|
||||
gl_FragColor = vec4(col * ((0.5 + fr * 1.9) * uDim + uGlow), alpha);
|
||||
}`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
}
|
||||
|
||||
// ── fachada: árbol de ficheros impreso sobre el cristal ───────────────────
|
||||
const FACADE_MAX_LINES = 150;
|
||||
|
||||
function facadeLines(building) {
|
||||
const lines = [];
|
||||
let overflow = false;
|
||||
(function walk(node, depth) {
|
||||
if (lines.length > FACADE_MAX_LINES) { overflow = true; return; }
|
||||
lines.push({ text: node.name + '/', depth, key: 'n' + node.id });
|
||||
for (const c of node.children) walk(c, depth + 1);
|
||||
for (const fid of node.fileIds) {
|
||||
if (lines.length > FACADE_MAX_LINES) { overflow = true; return; }
|
||||
const f = building.modelFiles[fid];
|
||||
lines.push({ text: f.name, depth: depth + 1, key: 'f' + f.id });
|
||||
}
|
||||
})(building, 0);
|
||||
if (overflow) lines.push({ text: '· · ·', depth: 1, key: '' });
|
||||
return lines;
|
||||
}
|
||||
|
||||
function drawFacade(building, highlight, theme) {
|
||||
const cv = building.facadeCanvas;
|
||||
if (!cv) return; // edificio sin fachada (recorte en ciudades enormes)
|
||||
const g = cv.getContext('2d');
|
||||
const W = cv.width, H = cv.height;
|
||||
g.clearRect(0, 0, W, H);
|
||||
const lines = building.facadeLineCache;
|
||||
const fs = Math.max(9, Math.min(30, (H - 60) / (lines.length + 1)));
|
||||
g.font = `${fs}px "JetBrains Mono", "Cascadia Code", monospace`;
|
||||
g.textBaseline = 'top';
|
||||
let y = 26;
|
||||
for (const ln of lines) {
|
||||
const x = 22 + ln.depth * fs * 1.1;
|
||||
const hot = highlight && highlight.has(ln.key);
|
||||
if (hot) {
|
||||
g.shadowColor = theme.facade.hotGlow;
|
||||
g.shadowBlur = 14;
|
||||
g.fillStyle = theme.facade.hotBg;
|
||||
g.fillRect(x - 6, y - 3, g.measureText(ln.text).width + 12, fs + 6);
|
||||
g.fillStyle = theme.facade.hot;
|
||||
} else {
|
||||
g.shadowBlur = 0;
|
||||
g.fillStyle = theme.facade.text;
|
||||
}
|
||||
g.fillText(ln.text, x, y);
|
||||
y += fs * 1.18;
|
||||
if (y > H - fs) break;
|
||||
}
|
||||
g.shadowBlur = 0;
|
||||
building.facadeTexture.needsUpdate = true;
|
||||
}
|
||||
|
||||
function buildFacade(building, model, group, theme) {
|
||||
building.modelFiles = model.files;
|
||||
building.facadeLineCache = facadeLines(building);
|
||||
const b = building.box;
|
||||
const H = 1024;
|
||||
const W = Math.round(Math.min(2048, Math.max(256, H * (b.sx / b.sy))));
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = W; cv.height = H;
|
||||
building.facadeCanvas = cv;
|
||||
const tx = configureCanvasTexture(new THREE.CanvasTexture(cv));
|
||||
building.facadeTexture = tx;
|
||||
drawFacade(building, null, theme);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: tx, transparent: true, alphaTest: 0.025, depthWrite: false, side: THREE.FrontSide,
|
||||
blending: theme.light ? THREE.NormalBlending : THREE.AdditiveBlending,
|
||||
});
|
||||
mat.userData.vccKind = 'facade';
|
||||
const E = 0.14;
|
||||
const faces = [
|
||||
{ w: b.sx, pos: [b.cx, b.cy, b.cz + b.sz / 2 + E], rot: 0 },
|
||||
{ w: b.sx, pos: [b.cx, b.cy, b.cz - b.sz / 2 - E], rot: Math.PI },
|
||||
{ w: b.sz, pos: [b.cx + b.sx / 2 + E, b.cy, b.cz], rot: Math.PI / 2 },
|
||||
{ w: b.sz, pos: [b.cx - b.sx / 2 - E, b.cy, b.cz], rot: -Math.PI / 2 },
|
||||
];
|
||||
for (const f of faces) {
|
||||
const plane = new THREE.Mesh(new THREE.PlaneGeometry(f.w, b.sy), mat);
|
||||
plane.position.set(...f.pos);
|
||||
plane.rotation.y = f.rot;
|
||||
plane.renderOrder = 3;
|
||||
group.add(plane);
|
||||
}
|
||||
}
|
||||
|
||||
// ── leyenda: nombres serigrafiados (suelo de distrito y tejado) ───────────
|
||||
function labelTexture(text, color) {
|
||||
const fs = 96, pad = 40;
|
||||
const probe = document.createElement('canvas').getContext('2d');
|
||||
probe.font = `bold ${fs}px "JetBrains Mono", monospace`;
|
||||
const tw = probe.measureText(text).width;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = Math.ceil(tw + pad * 2 + 220);
|
||||
cv.height = 190;
|
||||
const g = cv.getContext('2d');
|
||||
g.font = `bold ${fs}px "JetBrains Mono", monospace`;
|
||||
g.textBaseline = 'middle';
|
||||
// pista de circuito: línea con pads a ambos lados del texto
|
||||
g.strokeStyle = color; g.globalAlpha = 0.65; g.lineWidth = 5;
|
||||
const y = cv.height / 2;
|
||||
g.beginPath(); g.moveTo(14, y); g.lineTo(pad + 60, y); g.stroke();
|
||||
g.beginPath(); g.moveTo(pad + 100 + tw, y); g.lineTo(cv.width - 14, y); g.stroke();
|
||||
g.fillStyle = color;
|
||||
for (const px of [14, pad + 60, pad + 100 + tw, cv.width - 26]) g.fillRect(px - 7, y - 7, 14, 14);
|
||||
g.globalAlpha = 0.92;
|
||||
g.shadowColor = color; g.shadowBlur = 16;
|
||||
g.fillText(text, pad + 80, y);
|
||||
const tx = configureCanvasTexture(new THREE.CanvasTexture(cv));
|
||||
return { tx, aspect: cv.width / cv.height };
|
||||
}
|
||||
|
||||
const LEGEND_MAX_BUILDINGS = 400;
|
||||
|
||||
function buildLegend(model, labeled, theme) {
|
||||
const legend = new THREE.Group();
|
||||
const add = (text, color, width, pos, flat) => {
|
||||
const { tx, aspect } = labelTexture(text, color);
|
||||
const w = Math.min(width, aspect * 8);
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(w, w / aspect),
|
||||
new THREE.MeshBasicMaterial({
|
||||
map: tx, transparent: true, alphaTest: 0.025, depthWrite: false,
|
||||
blending: theme.light ? THREE.NormalBlending : THREE.AdditiveBlending,
|
||||
}),
|
||||
);
|
||||
mesh.material.userData.vccKind = 'label';
|
||||
if (flat) mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.set(...pos);
|
||||
mesh.renderOrder = 4;
|
||||
legend.add(mesh);
|
||||
};
|
||||
for (const di of model.districts) {
|
||||
add(di.name, theme.labels.district, di.box.sx * 0.85,
|
||||
[di.box.cx, 0.3, di.box.cz + di.box.sz / 2 - 2], true);
|
||||
}
|
||||
// urbanizaciones: mismo serigrafiado de suelo que los distritos, en tono
|
||||
// intermedio; en monorepos se etiquetan las de mayor superficie
|
||||
const urbs = [...model.urbs].sort((a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz);
|
||||
for (const u of urbs.slice(0, LEGEND_MAX_BUILDINGS)) {
|
||||
add(u.name, theme.labels.urb, u.box.sx * 0.8,
|
||||
[u.box.cx, 0.32, u.box.cz + u.box.sz / 2 - 1.4], true);
|
||||
}
|
||||
for (const b of labeled.slice(0, LEGEND_MAX_BUILDINGS)) {
|
||||
if (b.isBasement) continue; // su tejado es la calle: sin etiqueta
|
||||
add(b.name, theme.labels.building, b.box.sx * 0.95,
|
||||
[b.box.cx, b.box.cy + b.box.sy / 2 + 0.12, b.box.cz], true);
|
||||
}
|
||||
return legend;
|
||||
}
|
||||
|
||||
// ── ciudad completa ───────────────────────────────────────────────────────
|
||||
export function buildCity(scene, model, theme) {
|
||||
const group = new THREE.Group();
|
||||
scene.add(group);
|
||||
// paleta del tema; en temas claros el blending aditivo no funciona
|
||||
const ROLE_COLOR = theme.roleColor;
|
||||
const HI = theme.hi;
|
||||
const FILE_BASE = theme.fileBase;
|
||||
const FILE_HI = theme.fileHi;
|
||||
const BLEND = theme.light ? THREE.NormalBlending : THREE.AdditiveBlending;
|
||||
|
||||
// suelo
|
||||
const extent = Math.max(model.city.box.sx, model.city.box.sz);
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(extent * 7, extent * 7),
|
||||
// semitransparente y a doble cara: los sótanos (ficheros ignorados)
|
||||
// se intuyen a través del plano de circuitos. depthWrite: false — era el
|
||||
// único material que escribía profundidad y, con el rango near/far de la
|
||||
// ciudad, las plotas y serigrafías a ras de suelo hacían z-fighting
|
||||
// (parpadeo en franjas) al mover la cámara
|
||||
new THREE.MeshBasicMaterial({
|
||||
map: circuitTexture(theme), transparent: true, opacity: 0.8, side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
// separa el suelo en el z-buffer de plotas y serigrafías a ras de
|
||||
// suelo (evita el z-fighting cuando el modo opaco activa depthWrite)
|
||||
polygonOffset: true, polygonOffsetFactor: 2, polygonOffsetUnits: 2,
|
||||
}),
|
||||
);
|
||||
ground.material.map.repeat.set(7, 7);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -0.05;
|
||||
group.add(ground);
|
||||
|
||||
// aristas de las cajas en un único buffer con rangos por nodo.
|
||||
// LOD por presupuesto para monorepos: decenas de miles de wireframes
|
||||
// aditivos se acumulan en blanco. Distritos y edificios siempre dibujan;
|
||||
// del interior se conservan las cajas más grandes y menos profundas.
|
||||
const LINE_BUDGET = 12000;
|
||||
const ROLE_DEPTH = { distrito: 0, urbanizacion: 0, edificio: 0, planta: 1, apartamento: 2, habitacion: 3, archivador: 4, cajon: 5 };
|
||||
const candidates = model.nodes.filter(n => n.role !== 'ciudad' && n.box);
|
||||
let boxNodes = candidates;
|
||||
if (candidates.length > LINE_BUDGET) {
|
||||
const score = (n) => (n.role === 'distrito' || n.role === 'urbanizacion' || n.role === 'edificio')
|
||||
? Infinity
|
||||
: Math.max(n.box.sx, n.box.sy, n.box.sz) - ROLE_DEPTH[n.role] * 3;
|
||||
boxNodes = candidates.sort((a, b) => score(b) - score(a)).slice(0, LINE_BUDGET);
|
||||
}
|
||||
const positions = new Float32Array(boxNodes.length * 24 * 3);
|
||||
const colors = new Float32Array(boxNodes.length * 24 * 3);
|
||||
// brillo de arista normalizado por edificio: el interior de un edificio
|
||||
// reparte un presupuesto de luz constante (mismo principio que las celdas)
|
||||
const boxesPerBuilding = new Map();
|
||||
for (const n of boxNodes) {
|
||||
if (n.building && n.building !== n) {
|
||||
boxesPerBuilding.set(n.building.id, (boxesPerBuilding.get(n.building.id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
// …y una atenuación global suave en ciudades con miles de edificios:
|
||||
// uniforme (todos los edificios conservan el mismo aspecto entre sí),
|
||||
// solo evita que la acumulación aditiva a vista de pájaro se queme
|
||||
const cityDim = Math.min(1, Math.max(0.45, Math.sqrt(900 / Math.max(model.buildings.length, 1))));
|
||||
for (const n of boxNodes) {
|
||||
const local = (!n.building || n.building === n)
|
||||
? 1
|
||||
: Math.min(1, Math.max(0.12, 30 / (boxesPerBuilding.get(n.building.id) ?? 1)));
|
||||
// las plotas (distrito y urbanización) son solo perímetro: siempre nítidas
|
||||
n.lineDim = (n.role === 'distrito' || n.role === 'urbanizacion') ? 1 : local * cityDim;
|
||||
}
|
||||
|
||||
let v = 0;
|
||||
for (const node of boxNodes) {
|
||||
node.lineStart = v;
|
||||
const cs = corners(node.box);
|
||||
const base = (ROLE_COLOR[node.role] ?? ROLE_COLOR.cajon).map(c => c * node.lineDim);
|
||||
node.baseColor = base;
|
||||
for (const [a, b] of EDGE_PAIRS) {
|
||||
for (const p of [cs[a], cs[b]]) {
|
||||
positions[v * 3] = p[0]; positions[v * 3 + 1] = p[1]; positions[v * 3 + 2] = p[2];
|
||||
colors[v * 3] = base[0]; colors[v * 3 + 1] = base[1]; colors[v * 3 + 2] = base[2];
|
||||
v++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const lineGeo = new THREE.BufferGeometry();
|
||||
lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
lineGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
lineGeo.computeBoundingSphere();
|
||||
// opacidad adaptativa: cuantas más cajas, más tenue cada arista
|
||||
const lineOpacity = Math.min(0.95, Math.max(0.4, Math.sqrt(4000 / Math.max(boxNodes.length, 1))));
|
||||
const lineBaseOpacity = lineOpacity;
|
||||
const lines = new THREE.LineSegments(lineGeo, new THREE.LineBasicMaterial({
|
||||
vertexColors: true, transparent: true, opacity: lineOpacity,
|
||||
blending: BLEND, depthWrite: false,
|
||||
}));
|
||||
lines.renderOrder = 2;
|
||||
group.add(lines);
|
||||
|
||||
// carcasas de cristal por edificio (también sirven para el raycast).
|
||||
// En ciudades enormes las fachadas con texto se reservan a los edificios
|
||||
// más grandes (una textura canvas por edificio no escala a miles).
|
||||
const byArea = [...model.buildings].sort(
|
||||
(a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz);
|
||||
const facadeSet = new Set(byArea.slice(0, 250));
|
||||
const shells = [];
|
||||
for (const b of model.buildings) {
|
||||
const mat = glassMaterial(theme, cityDim, b.onlyBasement ? 1 : 0);
|
||||
mat.userData.baseDim = cityDim;
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(b.box.sx, b.box.sy, b.box.sz), mat);
|
||||
mesh.position.set(b.box.cx, b.box.cy, b.box.cz);
|
||||
mesh.renderOrder = 1;
|
||||
mesh.userData.building = b;
|
||||
b.shell = mesh;
|
||||
shells.push(mesh);
|
||||
group.add(mesh);
|
||||
if (facadeSet.has(b)) buildFacade(b, model, group, theme);
|
||||
}
|
||||
|
||||
// forjados de las plantas (losa fina, casi invisible); con tope en
|
||||
// monorepos, priorizando las plantas de mayor superficie
|
||||
let slabNodes = model.nodes.filter(n => n.role === 'planta');
|
||||
if (slabNodes.length > 4000) {
|
||||
slabNodes = slabNodes
|
||||
.sort((a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz)
|
||||
.slice(0, 4000);
|
||||
}
|
||||
if (slabNodes.length) {
|
||||
const slabGeo = new THREE.BoxGeometry(1, 1, 1);
|
||||
const slabMat = new THREE.MeshBasicMaterial({
|
||||
color: new THREE.Color(...theme.slab), transparent: true,
|
||||
opacity: Math.min(0.045, 0.045 * Math.sqrt(1500 / slabNodes.length)),
|
||||
depthWrite: false, blending: BLEND,
|
||||
});
|
||||
const slabs = new THREE.InstancedMesh(slabGeo, slabMat, slabNodes.length);
|
||||
const m = new THREE.Matrix4();
|
||||
slabNodes.forEach((n, i) => {
|
||||
m.makeScale(n.box.sx, 0.12, n.box.sz);
|
||||
m.setPosition(n.box.cx, n.box.cy - n.box.sy / 2, n.box.cz);
|
||||
slabs.setMatrixAt(i, m);
|
||||
});
|
||||
slabs.computeBoundingSphere();
|
||||
slabs.renderOrder = 1;
|
||||
group.add(slabs);
|
||||
}
|
||||
|
||||
// celdas de fichero (instanciadas)
|
||||
const fileGeo = new THREE.BoxGeometry(1, 1, 1);
|
||||
const fileMat = new THREE.MeshBasicMaterial({
|
||||
transparent: true, opacity: 0.9, depthWrite: false, blending: BLEND,
|
||||
});
|
||||
const fileBaseOpacity = fileMat.opacity;
|
||||
const filesMesh = new THREE.InstancedMesh(fileGeo, fileMat, model.files.length);
|
||||
{
|
||||
const m = new THREE.Matrix4();
|
||||
const c = new THREE.Color();
|
||||
for (const f of model.files) {
|
||||
m.makeScale(f.box.sx, f.box.sy, f.box.sz);
|
||||
m.setPosition(f.box.cx, f.box.cy, f.box.cz);
|
||||
filesMesh.setMatrixAt(f.id, m);
|
||||
filesMesh.setColorAt(f.id, c.setRGB(...FILE_BASE));
|
||||
}
|
||||
filesMesh.instanceColor.needsUpdate = true;
|
||||
filesMesh.computeBoundingSphere();
|
||||
}
|
||||
filesMesh.renderOrder = 2;
|
||||
group.add(filesMesh);
|
||||
|
||||
// cajas de relleno para los niveles encendidos (pool reutilizable)
|
||||
const fillPool = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 1, 1),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: new THREE.Color(...theme.fill), transparent: true, opacity: 0,
|
||||
depthWrite: false, blending: BLEND,
|
||||
}),
|
||||
);
|
||||
mesh.visible = false;
|
||||
mesh.renderOrder = 2;
|
||||
group.add(mesh);
|
||||
fillPool.push(mesh);
|
||||
}
|
||||
|
||||
// ── API de iluminación ──────────────────────────────────────────────
|
||||
const touchedNodes = new Set();
|
||||
const touchedFiles = new Set();
|
||||
const colorAttr = lineGeo.getAttribute('color');
|
||||
const tmpColor = new THREE.Color();
|
||||
// atenuación normalizada por edificio: cada edificio reparte un presupuesto
|
||||
// de luz constante entre sus celdas — así un rascacielos con 50k ficheros
|
||||
// no se quema y uno con 20 sigue viéndose igual que antes
|
||||
const filesPerBuilding = new Map();
|
||||
for (const f of model.files) {
|
||||
const b = f.node.building;
|
||||
if (b) filesPerBuilding.set(b.id, (filesPerBuilding.get(b.id) ?? 0) + 1);
|
||||
}
|
||||
const dimOf = (f) => {
|
||||
const n = filesPerBuilding.get(f.node.building?.id) ?? 1;
|
||||
return Math.min(1, Math.max(0.05, 350 / n)) * cityDim;
|
||||
};
|
||||
// color base por fichero, reescribible por los modos de coloreado
|
||||
const fileBase = model.files.map(f => FILE_BASE.map(c => c * dimOf(f)));
|
||||
|
||||
function paintNode(node, glow, hi = HI) {
|
||||
// los nodos diminutos no tienen aristas (LOD): solo carcasa/relleno
|
||||
if (node.lineStart !== undefined) {
|
||||
const col = lerp3(node.baseColor, hi, Math.pow(glow, 1.35));
|
||||
for (let i = node.lineStart; i < node.lineStart + 24; i++) {
|
||||
colorAttr.array[i * 3] = col[0];
|
||||
colorAttr.array[i * 3 + 1] = col[1];
|
||||
colorAttr.array[i * 3 + 2] = col[2];
|
||||
}
|
||||
// subida parcial: en monorepos el buffer completo pesa decenas de MB
|
||||
colorAttr.addUpdateRange(node.lineStart * 3, 72);
|
||||
colorAttr.needsUpdate = true;
|
||||
}
|
||||
if (node.shell) node.shell.material.uniforms.uGlow.value = glow * 0.3;
|
||||
if (glow > 0.01) touchedNodes.add(node); else touchedNodes.delete(node);
|
||||
}
|
||||
|
||||
function paintFile(fileId, glow, hi = FILE_HI) {
|
||||
const col = lerp3(fileBase[fileId], hi, glow);
|
||||
filesMesh.setColorAt(fileId, tmpColor.setRGB(...col));
|
||||
filesMesh.instanceColor.addUpdateRange(fileId * 3, 3);
|
||||
filesMesh.instanceColor.needsUpdate = true;
|
||||
if (glow > 0.01) touchedFiles.add(fileId); else touchedFiles.delete(fileId);
|
||||
}
|
||||
|
||||
function clearGlows() {
|
||||
for (const n of [...touchedNodes]) paintNode(n, 0);
|
||||
for (const f of [...touchedFiles]) paintFile(f, 0);
|
||||
for (const mesh of fillPool) mesh.visible = false;
|
||||
}
|
||||
|
||||
// haces del modo Blast: del fichero seleccionado a sus relacionados,
|
||||
// con pulsos viajando por la línea como paquetes de datos
|
||||
let blastBeams = null;
|
||||
function setBlastBeams(pairs) {
|
||||
if (blastBeams) {
|
||||
group.remove(blastBeams);
|
||||
blastBeams.geometry.dispose();
|
||||
blastBeams.material.dispose();
|
||||
blastBeams = null;
|
||||
}
|
||||
if (!pairs?.length) return;
|
||||
// pairs: { a, b, impact } — impact=true: b depende del seleccionado
|
||||
// (consecuencias de un cambio); el pulso siempre viaja en el sentido de
|
||||
// la propagación: hacia fuera en impactos, hacia dentro en dependencias
|
||||
const pos = new Float32Array(pairs.length * 6);
|
||||
const dist = new Float32Array(pairs.length * 2);
|
||||
const dir = new Float32Array(pairs.length * 2);
|
||||
pairs.forEach(({ a, b, impact }, i) => {
|
||||
pos.set(a, i * 6);
|
||||
pos.set(b, i * 6 + 3);
|
||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
||||
dist[i * 2] = impact ? 0 : len;
|
||||
dist[i * 2 + 1] = impact ? len : 0;
|
||||
dir[i * 2] = dir[i * 2 + 1] = impact ? 1 : 0;
|
||||
});
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
geo.setAttribute('aDist', new THREE.BufferAttribute(dist, 1));
|
||||
geo.setAttribute('aDir', new THREE.BufferAttribute(dir, 1));
|
||||
blastBeams = new THREE.LineSegments(geo, new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new THREE.Color(...theme.beam) },
|
||||
uColorIn: { value: new THREE.Color(...(theme.beamIn ?? theme.beam)) },
|
||||
},
|
||||
vertexShader: /* glsl */`
|
||||
attribute float aDist;
|
||||
attribute float aDir;
|
||||
varying float vD;
|
||||
varying float vDir;
|
||||
void main() {
|
||||
vD = aDist;
|
||||
vDir = aDir;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: /* glsl */`
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uColorIn;
|
||||
varying float vD;
|
||||
varying float vDir;
|
||||
void main() {
|
||||
// tren de pulsos avanzando por la línea (cada ~8 unidades); la
|
||||
// línea base se mantiene bien visible entre pulsos
|
||||
float w = fract(vD * 0.125 - uTime * 1.4);
|
||||
float pulse = exp(-pow((w - 0.2) * 6.0, 2.0));
|
||||
vec3 col = mix(uColor, uColorIn, vDir);
|
||||
gl_FragColor = vec4(col * (0.6 + pulse * 2.4), 0.5 + pulse * 0.5);
|
||||
}`,
|
||||
transparent: true,
|
||||
blending: BLEND,
|
||||
depthWrite: false,
|
||||
}));
|
||||
blastBeams.renderOrder = 3;
|
||||
group.add(blastBeams);
|
||||
}
|
||||
|
||||
function tickBeams(timeMs) {
|
||||
if (blastBeams) blastBeams.material.uniforms.uTime.value = timeMs / 1000;
|
||||
}
|
||||
|
||||
// modos de coloreado: reescribe los colores base de nodos y ficheros
|
||||
// (fileColorFn/nodeColorFn → [r,g,b] o null para el color por defecto)
|
||||
function setModeColors(fileColorFn, nodeColorFn) {
|
||||
for (const node of boxNodes) {
|
||||
const col = nodeColorFn?.(node) ?? (ROLE_COLOR[node.role] ?? ROLE_COLOR.cajon);
|
||||
node.baseColor = col.map(c => c * (node.lineDim ?? 1));
|
||||
paintNode(node, 0);
|
||||
}
|
||||
for (const f of model.files) {
|
||||
const dim = dimOf(f);
|
||||
const col = fileColorFn?.(f) ?? FILE_BASE;
|
||||
fileBase[f.id] = col.map(c => c * dim);
|
||||
paintFile(f.id, 0);
|
||||
}
|
||||
// se ha tocado todo: mejor una única subida completa que miles de rangos
|
||||
colorAttr.clearUpdateRanges();
|
||||
filesMesh.instanceColor.clearUpdateRanges();
|
||||
}
|
||||
|
||||
function fillBoxes(boxesWithGlow) {
|
||||
fillPool.forEach((mesh, i) => {
|
||||
const entry = boxesWithGlow[i];
|
||||
if (!entry) { mesh.visible = false; return; }
|
||||
const { box, glow } = entry;
|
||||
mesh.scale.set(box.sx, box.sy, box.sz);
|
||||
mesh.position.set(box.cx, box.cy, box.cz);
|
||||
mesh.material.opacity = 0.008 + glow * 0.045;
|
||||
mesh.visible = true;
|
||||
});
|
||||
}
|
||||
|
||||
// leyenda serigrafiada (conmutable)
|
||||
const legend = buildLegend(model, byArea, theme);
|
||||
group.add(legend);
|
||||
|
||||
let groundOpaque = false;
|
||||
const groundBaseOpacity = 0.8;
|
||||
let visualSettings = {};
|
||||
function setVisuals(v = {}) {
|
||||
visualSettings = { ...visualSettings, ...v };
|
||||
const linesMul = visualSettings.lines ?? 1;
|
||||
const filesMul = visualSettings.files ?? 1;
|
||||
const glassMul = visualSettings.glass ?? 1;
|
||||
const facadeMul = visualSettings.facades ?? 1;
|
||||
const labelMul = visualSettings.labels ?? 1;
|
||||
const groundMul = visualSettings.ground ?? 1;
|
||||
|
||||
lines.material.opacity = Math.min(1, lineBaseOpacity * linesMul);
|
||||
fileMat.opacity = Math.min(1, fileBaseOpacity * filesMul);
|
||||
if (!groundOpaque) ground.material.opacity = Math.min(1, groundBaseOpacity * groundMul);
|
||||
|
||||
for (const shell of shells) {
|
||||
const mat = shell.material;
|
||||
mat.uniforms.uDim.value = (mat.userData.baseDim ?? cityDim) * glassMul;
|
||||
}
|
||||
group.traverse((o) => {
|
||||
const mats = Array.isArray(o.material) ? o.material : [o.material];
|
||||
for (const mat of mats) {
|
||||
if (mat?.userData?.vccKind === 'facade') mat.opacity = Math.min(1, facadeMul);
|
||||
if (mat?.userData?.vccKind === 'label') mat.opacity = Math.min(1, labelMul);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
scene.remove(group);
|
||||
const geometries = new Set();
|
||||
const materials = new Set();
|
||||
group.traverse((o) => {
|
||||
if (o.geometry) geometries.add(o.geometry);
|
||||
for (const m of Array.isArray(o.material) ? o.material : [o.material]) {
|
||||
if (m) materials.add(m);
|
||||
}
|
||||
});
|
||||
for (const g of geometries) g.dispose();
|
||||
for (const m of materials) {
|
||||
m.map?.dispose?.();
|
||||
m.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
group, lines, shells, filesMesh, ground, legend,
|
||||
paintNode, paintFile, clearGlows, fillBoxes, dispose, setModeColors, setBlastBeams, tickBeams, setVisuals,
|
||||
setLegend: (v) => { legend.visible = v; },
|
||||
// suelo opaco/translúcido. Opaco = escribe profundidad y se dibuja en la
|
||||
// pasada de opacos: oculta de verdad los sótanos (ficheros ignorados)
|
||||
setGroundOpaque: (v) => {
|
||||
groundOpaque = v;
|
||||
const m = ground.material;
|
||||
m.opacity = v ? 1 : Math.min(1, groundBaseOpacity * (visualSettings.ground ?? 1));
|
||||
m.transparent = !v;
|
||||
m.depthWrite = v;
|
||||
m.needsUpdate = true;
|
||||
},
|
||||
redrawFacade: (building, highlightKeys) => drawFacade(building, highlightKeys, theme),
|
||||
};
|
||||
}
|
||||
360
web/js/vcc/model.js
Normal file
360
web/js/vcc/model.js
Normal file
@@ -0,0 +1,360 @@
|
||||
// Modelo: convierte el árbol del repo en una ciudad con cajas anidadas.
|
||||
// Jerarquía: ciudad → distrito → edificio → planta → apartamento → habitación
|
||||
// → archivador → cajón → fichero (los ficheros pueden vivir en
|
||||
// cualquier nivel: se les reserva un hueco en su contenedor).
|
||||
// Cuando un subárbol es demasiado profundo/poblado para un solo edificio,
|
||||
// se interpone una «urbanización»: una plota dentro del distrito que agrupa
|
||||
// procedualmente a sus subcarpetas como edificios (recursivo), de modo que
|
||||
// la profundidad sobrante se despliega por la ciudad en vez de comprimirse.
|
||||
// Los ficheros ignorados (.gitignore y equivalentes) se separan en un
|
||||
// sótano espejado bajo el plano de circuitos.
|
||||
import { makeOwnerResolver } from './ownership.js';
|
||||
|
||||
export const ROLES = ['ciudad', 'distrito', 'edificio', 'planta', 'apartamento', 'habitacion', 'archivador', 'cajon'];
|
||||
const ROLE_AXIS = { planta: 'y', apartamento: 'x', habitacion: 'z', archivador: 'x', cajon: 'y' };
|
||||
|
||||
const STREET = 14; // separación entre distritos
|
||||
const ALLEY = 4; // separación entre edificios
|
||||
const URB_PAD = 3; // margen interior de una urbanización
|
||||
// un edificio absorbe 5 niveles (planta→…→cajón); más allá, urbanización
|
||||
const URB_DEPTH = 5;
|
||||
const URB_MIN_FILES = 200;
|
||||
const URB_MAX_FILES = 2000; // aun siendo poco profundo, esto no cabe en uno
|
||||
const GROUND_Y = 0;
|
||||
|
||||
let nextNode = 0;
|
||||
let nextFile = 0;
|
||||
|
||||
function hash01(str) {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||
return ((h >>> 0) % 1000) / 1000;
|
||||
}
|
||||
|
||||
function unpack(packed) {
|
||||
const [name, dirs, leaf] = packed;
|
||||
const node = { name, dirs: dirs.map(unpack), leaf: leaf.map(([n, s, ig]) => ({ name: n, size: s, ignored: !!ig })) };
|
||||
node.files = node.leaf.length + node.dirs.reduce((a, d) => a + d.files, 0);
|
||||
node.size = node.leaf.reduce((a, f) => a + f.size, 0) + node.dirs.reduce((a, d) => a + d.size, 0);
|
||||
return node;
|
||||
}
|
||||
|
||||
function recount(node) {
|
||||
node.files = node.leaf.length + node.dirs.reduce((a, d) => a + d.files, 0);
|
||||
node.size = node.leaf.reduce((a, f) => a + f.size, 0) + node.dirs.reduce((a, d) => a + d.size, 0);
|
||||
return node;
|
||||
}
|
||||
|
||||
// separa un subárbol en parte visible y parte ignorada (sótano)
|
||||
function splitSrc(src) {
|
||||
const kept = { name: src.name, dirs: [], leaf: src.leaf.filter(f => !f.ignored) };
|
||||
const ign = { name: src.name, dirs: [], leaf: src.leaf.filter(f => f.ignored) };
|
||||
for (const d of src.dirs) {
|
||||
const s = splitSrc(d);
|
||||
if (s.kept.files) kept.dirs.push(s.kept);
|
||||
if (s.ign.files) ign.dirs.push(s.ign);
|
||||
}
|
||||
return { kept: recount(kept), ign: recount(ign) };
|
||||
}
|
||||
|
||||
function makeNode(model, name, role, parent, { virtual = false } = {}) {
|
||||
const node = {
|
||||
id: nextNode++, name, role, parent, children: [], fileIds: [],
|
||||
virtual, box: null, building: null,
|
||||
path: parent ? (virtual ? parent.path : `${parent.path}/${name}`) : name,
|
||||
};
|
||||
if (parent) parent.children.push(node);
|
||||
model.nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function addFiles(model, node, leaf) {
|
||||
for (const f of leaf) {
|
||||
const file = {
|
||||
id: nextFile++, name: f.name, size: f.size, node,
|
||||
path: `${node.path}/${f.name}`, box: null,
|
||||
};
|
||||
node.fileIds.push(file.id);
|
||||
model.files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ── dimensiones de edificio ───────────────────────────────────────────────
|
||||
function buildingDims(src) {
|
||||
const kb = src.size / 1024;
|
||||
const side = Math.min(22, Math.max(6, 4 + Math.log2(1 + kb) * 1.5));
|
||||
const j = hash01(src.name);
|
||||
const w = side * (0.85 + 0.3 * j);
|
||||
const d = side * (0.85 + 0.3 * (1 - j));
|
||||
const floors = Math.max(1, src.dirs.length + (src.leaf.length ? 1 : 0));
|
||||
const h = Math.max(6, Math.max(floors * 3.0, 6 + src.files * 1.8));
|
||||
return { w, d, h: Math.min(h, 80) };
|
||||
}
|
||||
|
||||
// ── reparto de un eje en franjas ponderadas ───────────────────────────────
|
||||
function splitAxis(box, axis, weights, inset) {
|
||||
const total = weights.reduce((a, b) => a + b, 0);
|
||||
const span = box['s' + axis];
|
||||
const start = box['c' + axis] - span / 2;
|
||||
const gap = Math.min(inset, span * 0.04);
|
||||
const usable = span - gap * (weights.length + 1);
|
||||
const out = [];
|
||||
let cursor = start + gap;
|
||||
for (const w of weights) {
|
||||
const s = usable * (w / total);
|
||||
out.push({ ...box, ['c' + axis]: cursor + s / 2, ['s' + axis]: s });
|
||||
cursor += s + gap;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function shrink(box, f) {
|
||||
return { ...box, sx: box.sx * f, sy: box.sy * f, sz: box.sz * f };
|
||||
}
|
||||
|
||||
// ── celdas de fichero dentro de su contenedor ─────────────────────────────
|
||||
function placeFiles(model, node, box, files) {
|
||||
const n = files.length;
|
||||
if (!n) return;
|
||||
if (node.role === 'cajon') {
|
||||
// carpetas colgantes: láminas verticales en fila
|
||||
const slabs = splitAxis({ ...box, sy: box.sy * 0.72, cy: box.cy - box.sy * 0.1 }, 'x',
|
||||
files.map(() => 1), 0.05);
|
||||
files.forEach((fid, i) => {
|
||||
const s = slabs[i];
|
||||
model.files[fid].box = { ...s, sx: Math.min(s.sx * 0.55, 0.5), sz: s.sz * 0.78 };
|
||||
});
|
||||
return;
|
||||
}
|
||||
// rejilla de cubos apoyada en el suelo del contenedor
|
||||
const aspect = box.sx / box.sz || 1;
|
||||
const cols = Math.max(1, Math.round(Math.sqrt(n * aspect)));
|
||||
const rows = Math.ceil(n / cols);
|
||||
const cw = box.sx / cols, cd = box.sz / rows;
|
||||
const base = Math.min(cw, cd, box.sy) * 0.5;
|
||||
files.forEach((fid, i) => {
|
||||
const f = model.files[fid];
|
||||
const col = i % cols, row = Math.floor(i / cols);
|
||||
const k = 0.65 + 0.45 * Math.min(1, Math.log2(2 + f.size / 1024) / 8);
|
||||
const s = Math.max(0.25, base * k);
|
||||
f.box = {
|
||||
cx: box.cx - box.sx / 2 + cw * (col + 0.5),
|
||||
cz: box.cz - box.sz / 2 + cd * (row + 0.5),
|
||||
cy: box.cy - box.sy / 2 + s / 2 + 0.05,
|
||||
sx: s, sy: s, sz: s,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── subdivisión recursiva del interior del edificio ───────────────────────
|
||||
// flipY: en los sótanos las plantas crecen hacia abajo (dirección inversa)
|
||||
function layoutInterior(model, node, src, box, role, flipY = false) {
|
||||
node.box = box;
|
||||
const dirs = src.dirs;
|
||||
const hasFiles = src.leaf.length > 0;
|
||||
addFiles(model, node, src.leaf);
|
||||
|
||||
if (!dirs.length) {
|
||||
placeFiles(model, node, shrink(box, 0.9), node.fileIds);
|
||||
return;
|
||||
}
|
||||
const childRole = ROLES[Math.min(ROLES.indexOf(role) + 1, ROLES.length - 1)];
|
||||
const axis = ROLE_AXIS[childRole] ?? 'y';
|
||||
const weights = dirs.map(d => 1 + Math.sqrt(d.files));
|
||||
if (hasFiles) weights.push(0.8 + Math.sqrt(src.leaf.length) * 0.7);
|
||||
const slices = splitAxis(box, axis, weights, 0.5);
|
||||
if (flipY && axis === 'y') slices.reverse();
|
||||
dirs.forEach((d, i) => {
|
||||
const child = makeNode(model, d.name, childRole, node);
|
||||
child.building = node.building;
|
||||
layoutInterior(model, child, d, shrink(slices[i], 0.94), childRole, flipY);
|
||||
});
|
||||
if (hasFiles) placeFiles(model, node, shrink(slices[slices.length - 1], 0.9), node.fileIds);
|
||||
}
|
||||
|
||||
// ── empaquetado en rejilla (edificios en distrito, distritos en ciudad) ───
|
||||
function packGrid(items, gap) {
|
||||
// items: { w, d } → coloca en filas; devuelve posiciones y tamaño del plot
|
||||
const area = items.reduce((a, it) => a + (it.w + gap) * (it.d + gap), 0);
|
||||
const targetW = Math.max(Math.sqrt(area) * 1.15, ...items.map(it => it.w + gap));
|
||||
let x = 0, z = 0, rowD = 0, plotW = 0;
|
||||
const pos = [];
|
||||
for (const it of items) {
|
||||
if (x > 0 && x + it.w > targetW) { x = 0; z += rowD + gap; rowD = 0; }
|
||||
pos.push({ x: x + it.w / 2, z: z + it.d / 2 });
|
||||
x += it.w + gap;
|
||||
rowD = Math.max(rowD, it.d);
|
||||
plotW = Math.max(plotW, x - gap);
|
||||
}
|
||||
return { pos, w: plotW, d: z + rowD };
|
||||
}
|
||||
|
||||
function subtreeDepth(src) {
|
||||
let d = 0;
|
||||
for (const s of src.dirs) d = Math.max(d, 1 + subtreeDepth(s));
|
||||
return d;
|
||||
}
|
||||
|
||||
// ¿demasiado profundo y poblado para un solo edificio? → urbanización
|
||||
function needsUrb(src) {
|
||||
if (src.virtual || !src.dirs.length) return false;
|
||||
if (src.files > URB_MAX_FILES) return true;
|
||||
return src.files > URB_MIN_FILES && subtreeDepth(src) > URB_DEPTH;
|
||||
}
|
||||
|
||||
function fileBlockSrc(leaf) {
|
||||
return { name: '(ficheros)', dirs: [], leaf, files: leaf.length, size: leaf.reduce((a, f) => a + f.size, 0), virtual: true };
|
||||
}
|
||||
|
||||
// un «bloque» es lo que ocupa una parcela: un edificio o una urbanización.
|
||||
// Devuelve { w, d, place(cx, cz) } para el empaquetado en rejilla.
|
||||
function buildBlock(model, parent, src) {
|
||||
if (needsUrb(src)) return buildUrb(model, parent, src);
|
||||
const split = splitSrc(src);
|
||||
const b = makeNode(model, src.name, 'edificio', parent, { virtual: !!src.virtual });
|
||||
b.building = b;
|
||||
b.dims = buildingDims(split.kept.files ? split.kept : src);
|
||||
// sin nada en superficie y todo en el sótano: búnker bajo y más opaco
|
||||
// que señala que la información está bajo el suelo
|
||||
if (!split.kept.files && split.ign.files) {
|
||||
b.onlyBasement = true;
|
||||
b.dims.h = 3;
|
||||
}
|
||||
b.srcKept = split.kept;
|
||||
b.srcIgn = split.ign;
|
||||
model.buildings.push(b);
|
||||
return { w: b.dims.w, d: b.dims.d, place: (cx, cz) => placeBuilding(model, b, cx, cz) };
|
||||
}
|
||||
|
||||
function buildUrb(model, parent, src) {
|
||||
const urb = makeNode(model, src.name, 'urbanizacion', parent);
|
||||
const childSrcs = [...src.dirs];
|
||||
if (src.leaf.length) childSrcs.push(fileBlockSrc(src.leaf));
|
||||
const blocks = childSrcs.map(s => buildBlock(model, urb, s));
|
||||
const grid = packGrid(blocks, ALLEY);
|
||||
const w = grid.w + URB_PAD * 2, d = grid.d + URB_PAD * 2;
|
||||
model.urbs.push(urb);
|
||||
return {
|
||||
w, d,
|
||||
place: (cx, cz) => {
|
||||
urb.box = { cx, cy: GROUND_Y + 0.26, cz, sx: w, sy: 0.14, sz: d };
|
||||
const ox = cx - w / 2 + URB_PAD, oz = cz - d / 2 + URB_PAD;
|
||||
blocks.forEach((blk, i) => blk.place(ox + grid.pos[i].x, oz + grid.pos[i].z));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function placeBuilding(model, b, cx, cz) {
|
||||
const { w, d, h } = b.dims;
|
||||
layoutInterior(model, b, b.srcKept, { cx, cz, cy: GROUND_Y + h / 2, sx: w, sy: h, sz: d }, 'edificio');
|
||||
// sótano: los ficheros ignorados crecen hacia abajo, mismo patrón invertido
|
||||
if (b.srcIgn.files) {
|
||||
const hb = Math.min(60, Math.max(5, Math.max(
|
||||
(b.srcIgn.dirs.length + (b.srcIgn.leaf.length ? 1 : 0)) * 3.0,
|
||||
5 + b.srcIgn.files * 1.6,
|
||||
)));
|
||||
const bb = makeNode(model, b.name, 'edificio', b.parent, { virtual: b.virtual });
|
||||
bb.building = bb;
|
||||
bb.isBasement = true;
|
||||
model.buildings.push(bb);
|
||||
layoutInterior(model, bb, b.srcIgn,
|
||||
{ cx, cz, cy: GROUND_Y - 0.7 - hb / 2, sx: w, sy: hb, sz: d }, 'edificio', true);
|
||||
}
|
||||
delete b.srcKept; delete b.srcIgn;
|
||||
}
|
||||
|
||||
function buildDistrict(model, city, srcDir, files) {
|
||||
const district = makeNode(model, srcDir ? srcDir.name : '(raíz)', 'distrito', city, { virtual: !srcDir });
|
||||
const blockSrcs = [];
|
||||
if (srcDir) {
|
||||
for (const d of srcDir.dirs) blockSrcs.push(d);
|
||||
if (srcDir.leaf.length) blockSrcs.push(fileBlockSrc(srcDir.leaf));
|
||||
} else {
|
||||
blockSrcs.push(fileBlockSrc(files));
|
||||
}
|
||||
const blocks = blockSrcs.map(s => buildBlock(model, district, s));
|
||||
const grid = packGrid(blocks, ALLEY);
|
||||
district.plot = { w: grid.w + ALLEY * 2, d: grid.d + ALLEY * 2 };
|
||||
district.blocks = blocks;
|
||||
district.grid = grid;
|
||||
model.districts.push(district);
|
||||
return district;
|
||||
}
|
||||
|
||||
export function buildModel(packedTree, meta = null) {
|
||||
nextNode = 0; nextFile = 0;
|
||||
const model = { nodes: [], files: [], buildings: [], districts: [], urbs: [], city: null };
|
||||
const root = unpack(packedTree);
|
||||
const city = makeNode(model, root.name, 'ciudad', null);
|
||||
model.city = city;
|
||||
|
||||
for (const d of root.dirs) buildDistrict(model, city, d, []);
|
||||
if (root.leaf.length) buildDistrict(model, city, null, root.leaf);
|
||||
|
||||
// colocar distritos en la ciudad
|
||||
const plots = model.districts.map(di => ({ w: di.plot.w, d: di.plot.d }));
|
||||
const cityGrid = packGrid(plots, STREET);
|
||||
const ox = -cityGrid.w / 2, oz = -cityGrid.d / 2;
|
||||
model.districts.forEach((di, i) => {
|
||||
const p = cityGrid.pos[i];
|
||||
const px = ox + p.x, pz = oz + p.z;
|
||||
di.box = { cx: px, cy: GROUND_Y + 0.12, cz: pz, sx: di.plot.w, sy: 0.24, sz: di.plot.d };
|
||||
const bx = px - di.plot.w / 2 + ALLEY, bz = pz - di.plot.d / 2 + ALLEY;
|
||||
di.blocks.forEach((blk, j) => blk.place(bx + di.grid.pos[j].x, bz + di.grid.pos[j].z));
|
||||
delete di.blocks; delete di.grid;
|
||||
});
|
||||
city.box = { cx: 0, cy: 0, cz: 0, sx: cityGrid.w + STREET * 2, sy: 1, sz: cityGrid.d + STREET * 2 };
|
||||
|
||||
// cadena de ancestros por fichero (para la iluminación jerárquica)
|
||||
// + metadatos git y code ownership por fichero
|
||||
const rootPrefix = city.name + '/';
|
||||
const resolveOwner = makeOwnerResolver(meta?.owners);
|
||||
const ownerIndex = new Map();
|
||||
for (const f of model.files) {
|
||||
const chain = [];
|
||||
for (let n = f.node; n; n = n.parent) chain.push(n);
|
||||
f.chain = chain; // del contenedor inmediato hacia la ciudad
|
||||
f.rel = f.path.startsWith(rootPrefix) ? f.path.slice(rootPrefix.length) : f.path;
|
||||
const g = meta?.git?.[f.rel];
|
||||
f.git = g ? { st: g[0], a: g[1], d: g[2] } : null;
|
||||
const owner = resolveOwner(f.rel);
|
||||
if (owner != null) {
|
||||
if (!ownerIndex.has(owner)) ownerIndex.set(owner, ownerIndex.size);
|
||||
f.owner = ownerIndex.get(owner);
|
||||
} else {
|
||||
f.owner = null;
|
||||
}
|
||||
}
|
||||
model.owners = [...ownerIndex.keys()];
|
||||
model.hasGit = !!meta?.git;
|
||||
|
||||
applyCodeMeta(model, meta?.links, meta?.codeIndex);
|
||||
model.fileByRel = new Map(model.files.map(f => [f.rel, f]));
|
||||
model.dirByRel = new Map();
|
||||
for (const n of model.nodes) {
|
||||
if (n.role === 'ciudad' || n.virtual || n.isBasement || n.building?.isBasement) continue;
|
||||
const rel = n.path.startsWith(rootPrefix) ? n.path.slice(rootPrefix.length) : n.path;
|
||||
if (!model.dirByRel.has(rel)) model.dirByRel.set(rel, n);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
// enlaces de código (modo Blast): adyacencia saliente y entrante. Es una
|
||||
// función aparte porque el análisis perezoso los refresca por lotes sobre
|
||||
// el modelo ya construido, sin reconstruir la ciudad.
|
||||
export function applyCodeMeta(model, links, codeIndex) {
|
||||
model.linksOut = links ?? {};
|
||||
model.linksIn = {};
|
||||
for (const [src, toks] of Object.entries(model.linksOut)) {
|
||||
for (const tok of toks) {
|
||||
if (tok.startsWith('F:')) (model.linksIn[tok.slice(2)] ??= []).push(src);
|
||||
}
|
||||
}
|
||||
// índice de símbolos (funciones/clases/tipos): aristas "src usa sym de target"
|
||||
model.defs = codeIndex?.defs ?? {};
|
||||
model.symOut = codeIndex?.symLinks ?? {};
|
||||
model.symIn = {};
|
||||
for (const [src, edges] of Object.entries(model.symOut)) {
|
||||
for (const [target, sym] of edges) (model.symIn[target] ??= []).push([src, sym]);
|
||||
}
|
||||
}
|
||||
5
web/js/vcc/ownership.js
Normal file
5
web/js/vcc/ownership.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// Stub de code-ownership: Elffuss Code no alimenta metadatos de propiedad, así
|
||||
// que makeOwnerResolver devuelve siempre null (file.owner = null en model.js).
|
||||
// El original (Sendery/VibeCodeViewer, Apache-2.0) parsea CODEOWNERS/package
|
||||
// meta; aquí no hace falta para dibujar la ciudad.
|
||||
export function makeOwnerResolver() { return () => null; }
|
||||
28
web/js/vcc/theme.js
Normal file
28
web/js/vcc/theme.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Tema «hacker» de VibeCodeViewer (Sendery/VibeCodeViewer, Apache-2.0),
|
||||
// copiado de src/themes.js. Es la paleta que consume builder.js.
|
||||
export const THEME = {
|
||||
label: 'Hacker',
|
||||
light: false,
|
||||
background: 0x020309, fog: 0x030510,
|
||||
bloom: { strength: 0.65, threshold: 0.85 },
|
||||
roleColor: {
|
||||
distrito: [0.16, 0.07, 0.30], urbanizacion: [0.13, 0.09, 0.30],
|
||||
edificio: [0.10, 0.30, 0.36], planta: [0.06, 0.20, 0.26],
|
||||
apartamento: [0.05, 0.16, 0.21], habitacion: [0.045, 0.13, 0.18],
|
||||
archivador: [0.04, 0.11, 0.15], cajon: [0.035, 0.10, 0.13],
|
||||
},
|
||||
hi: [0.28, 3.0, 3.6],
|
||||
fileBase: [0.05, 0.22, 0.28], fileHi: [1.0, 5.0, 6.0],
|
||||
glass: [0.12, 0.55, 0.65], glassHi: [0.3, 2.4, 2.8],
|
||||
ground: {
|
||||
bg: '#04050b', grid: 'rgba(50, 80, 140, 0.07)',
|
||||
traceA: [112, 60, 235], traceB: [40, 200, 235],
|
||||
padA: [170, 120, 255], padB: [120, 240, 255],
|
||||
},
|
||||
facade: {
|
||||
text: 'rgba(140, 232, 250, 0.72)', hot: '#c8ffff',
|
||||
hotGlow: 'rgba(0,255,255,0.9)', hotBg: 'rgba(0,255,255,0.16)',
|
||||
},
|
||||
labels: { district: '#a86cff', urb: '#8a7bff', building: '#49e8ff' },
|
||||
beam: [2.6, 1.1, 0.25], beamIn: [3.4, 0.5, 0.8], fill: [0.08, 0.75, 0.95], slab: [0.1, 0.5, 0.6],
|
||||
};
|
||||
289
web/js/workspace.js
Normal file
289
web/js/workspace.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// workspace.js — carpeta de trabajo, copia legible en disco e inventario.
|
||||
//
|
||||
// CORE compartido por Claw y Code. El módulo no sabe qué es una «app generada»
|
||||
// ni una «conversación»: cada app le pasa un MANIFIESTO de almacenes y él sabe
|
||||
// leerlos, medirlos, escribirlos a disco, reimportarlos y borrarlos.
|
||||
//
|
||||
// Por qué existe: todo vive en IndexedDB/localStorage, que el navegador puede
|
||||
// desalojar (o el usuario borrar con «limpiar datos del sitio») sin avisar. Con
|
||||
// una carpeta de trabajo, tu trabajo queda en ficheros normales que puedes ver,
|
||||
// copiar y respaldar tú.
|
||||
//
|
||||
// Lo que NUNCA sale a disco en claro: el vault (secretos cifrados). Se marca
|
||||
// `sensitive` en el manifiesto y se excluye salvo petición explícita.
|
||||
|
||||
const HANDLE_KEY = 'workspace-handle';
|
||||
const ROOT = '.elffuss';
|
||||
|
||||
let cfg = null; // { app, ns, db, stores }
|
||||
let dirHandle = null; // FileSystemDirectoryHandle de la carpeta de trabajo
|
||||
let ready = false; // permiso concedido AHORA
|
||||
let dirty = new Set();
|
||||
let listeners = [];
|
||||
let lastSaveAt = null, lastError = null;
|
||||
let autosaveCfg = { enabled: false, debounceMs: 3000 };
|
||||
let autosaveTimer = null;
|
||||
|
||||
export function init({ app, ns, db, stores }) {
|
||||
cfg = { app, ns, db, stores: stores || [] };
|
||||
}
|
||||
|
||||
export function supported() {
|
||||
return {
|
||||
pick: typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function',
|
||||
persist: !!(navigator.storage && navigator.storage.persist),
|
||||
};
|
||||
}
|
||||
|
||||
const emit = () => { const s = statusSync(); listeners.forEach(f => { try { f(s); } catch { /* */ } }); };
|
||||
export function onChange(fn) { listeners.push(fn); return () => { listeners = listeners.filter(f => f !== fn); }; }
|
||||
|
||||
function statusSync() {
|
||||
return {
|
||||
supported: supported(), folder: dirHandle ? dirHandle.name : null, ready,
|
||||
dirty: [...dirty], lastSaveAt, lastError, autosave: autosaveCfg.enabled,
|
||||
};
|
||||
}
|
||||
export function status() { return statusSync(); }
|
||||
|
||||
// ── carpeta de trabajo ───────────────────────────────────────────────────────
|
||||
async function perm(handle, mode = 'readwrite') {
|
||||
if (!handle?.queryPermission) return true; // OPFS u otros: dados
|
||||
if ((await handle.queryPermission({ mode })) === 'granted') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// En el arranque: recupera el handle SIN pedir permiso ni abrir diálogos.
|
||||
export async function restore() {
|
||||
if (!cfg) throw new Error('workspace.init() primero');
|
||||
let h = null;
|
||||
try { h = await cfg.db.get('kv', HANDLE_KEY); } catch { return null; }
|
||||
if (!h) return null;
|
||||
dirHandle = h;
|
||||
ready = await perm(h);
|
||||
emit();
|
||||
return { name: h.name, ready, reason: ready ? null : 'prompt' };
|
||||
}
|
||||
|
||||
// Elegir carpeta (requiere gesto de usuario).
|
||||
export async function pick() {
|
||||
if (!supported().pick) throw new Error('Este navegador no permite elegir una carpeta. Usa «Descargar copia» para guardar tu trabajo.');
|
||||
const h = await window.showDirectoryPicker({ mode: 'readwrite', id: 'elffuss-workspace', startIn: 'documents' });
|
||||
dirHandle = h;
|
||||
ready = true;
|
||||
await cfg.db.set('kv', HANDLE_KEY, h);
|
||||
emit();
|
||||
return { name: h.name, ready: true };
|
||||
}
|
||||
|
||||
// Reutilizar un handle que la app ya tiene (Code: la carpeta del proyecto).
|
||||
export async function adopt(handle) {
|
||||
if (!handle) throw new Error('sin handle');
|
||||
dirHandle = handle;
|
||||
ready = await perm(handle);
|
||||
await cfg.db.set('kv', HANDLE_KEY, handle);
|
||||
emit();
|
||||
return { name: handle.name, ready };
|
||||
}
|
||||
|
||||
// Re-conceder permiso tras recargar (requiere gesto de usuario).
|
||||
export async function regrant() {
|
||||
if (!dirHandle) throw new Error('no hay carpeta elegida');
|
||||
const ok = dirHandle.requestPermission
|
||||
? (await dirHandle.requestPermission({ mode: 'readwrite' })) === 'granted'
|
||||
: true;
|
||||
ready = ok;
|
||||
emit();
|
||||
if (!ok) throw new Error('permiso denegado');
|
||||
return { name: dirHandle.name, ready: true };
|
||||
}
|
||||
|
||||
export async function forget() {
|
||||
const name = dirHandle?.name || null;
|
||||
dirHandle = null; ready = false;
|
||||
try { await cfg.db.del('kv', HANDLE_KEY); } catch { /* */ }
|
||||
emit();
|
||||
return { forgot: name, deleted: false }; // nunca borra los ficheros del disco
|
||||
}
|
||||
|
||||
export async function ensurePersistence() {
|
||||
if (!navigator.storage?.persist) return { persisted: false, asked: false };
|
||||
const already = navigator.storage.persisted ? await navigator.storage.persisted() : false;
|
||||
if (already) return { persisted: true, asked: false };
|
||||
const got = await navigator.storage.persist().catch(() => false);
|
||||
return { persisted: got, asked: true };
|
||||
}
|
||||
|
||||
// ── inventario: «ver qué hay» ────────────────────────────────────────────────
|
||||
const sizeOf = v => { try { return new Blob([typeof v === 'string' ? v : JSON.stringify(v)]).size; } catch { return 0; } };
|
||||
|
||||
async function readStore(st) {
|
||||
if (st.kind === 'ls') {
|
||||
const items = [];
|
||||
for (const k of st.keys || []) {
|
||||
const v = localStorage.getItem(k);
|
||||
if (v != null) items.push({ key: k, value: v });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
if (st.kind === 'idb-store') { // store entero (apps, tasks…)
|
||||
const [ks, vs] = await Promise.all([cfg.db.keys(st.store), cfg.db.all(st.store)]);
|
||||
return ks.map((k, i) => ({ key: String(k), value: vs[i] }));
|
||||
}
|
||||
// 'idb-key': una clave concreta dentro de kv (history, conversations, skills…)
|
||||
const v = await cfg.db.get(st.store || 'kv', st.key);
|
||||
if (v == null) return [];
|
||||
return Array.isArray(v) ? v.map((x, i) => ({ key: String(x.id ?? x.name ?? i), value: x })) : [{ key: st.key, value: v }];
|
||||
}
|
||||
|
||||
export async function inventory() {
|
||||
const out = [];
|
||||
for (const st of cfg.stores) {
|
||||
let items = [];
|
||||
let err = null;
|
||||
try { items = await readStore(st); } catch (e) { err = e.message; }
|
||||
out.push({
|
||||
id: st.id, label: st.label, icon: st.icon || '•', sensitive: !!st.sensitive,
|
||||
count: items.length, bytes: items.reduce((a, b) => a + sizeOf(b.value), 0),
|
||||
note: st.note || '', error: err,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function peek(id, { limit = 50 } = {}) {
|
||||
const st = cfg.stores.find(s => s.id === id);
|
||||
if (!st) throw new Error('almacén desconocido: ' + id);
|
||||
const items = await readStore(st);
|
||||
return {
|
||||
id, truncated: items.length > limit,
|
||||
items: items.slice(0, limit).map(it => ({
|
||||
key: it.key, bytes: sizeOf(it.value),
|
||||
preview: st.sensitive ? '(cifrado — no se muestra)'
|
||||
: (typeof it.value === 'string' ? it.value : JSON.stringify(it.value)).slice(0, 240),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Espacio real del origen, incluidas TODAS las caches (los modelos son GB).
|
||||
export async function storageReport() {
|
||||
const est = navigator.storage?.estimate ? await navigator.storage.estimate().catch(() => ({})) : {};
|
||||
const persisted = navigator.storage?.persisted ? await navigator.storage.persisted().catch(() => false) : false;
|
||||
const out = { usage: est.usage || 0, quota: est.quota || 0, persisted, caches: [] };
|
||||
if (typeof caches !== 'undefined') {
|
||||
for (const name of await caches.keys().catch(() => [])) {
|
||||
const c = await caches.open(name);
|
||||
const reqs = await c.keys();
|
||||
let bytes = 0;
|
||||
for (const r of reqs) {
|
||||
const res = await c.match(r);
|
||||
const len = +(res?.headers.get('content-length') || 0);
|
||||
bytes += len;
|
||||
}
|
||||
out.caches.push({ name, entries: reqs.length, bytes });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── guardar en disco ─────────────────────────────────────────────────────────
|
||||
export function touch(id) {
|
||||
dirty.add(id);
|
||||
emit();
|
||||
if (autosaveCfg.enabled) {
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = setTimeout(() => { save({ reason: 'auto' }).catch(() => {}); }, autosaveCfg.debounceMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function autosave({ enabled, debounceMs }) {
|
||||
autosaveCfg = { enabled: !!enabled, debounceMs: debounceMs || autosaveCfg.debounceMs };
|
||||
try { localStorage.setItem(cfg.ns + '.workspace.autosave', enabled ? '1' : '0'); } catch { /* */ }
|
||||
emit();
|
||||
return autosaveCfg;
|
||||
}
|
||||
export function autosaveEnabled() {
|
||||
try { return localStorage.getItem(cfg.ns + '.workspace.autosave') === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
async function dirFor(path) { // crea .elffuss/<app>/<sub> y devuelve el handle
|
||||
let d = dirHandle;
|
||||
for (const part of path.split('/').filter(Boolean)) d = await d.getDirectoryHandle(part, { create: true });
|
||||
return d;
|
||||
}
|
||||
async function writeFile(dir, name, text) {
|
||||
const fh = await dir.getFileHandle(name, { create: true });
|
||||
const w = await fh.createWritable();
|
||||
await w.write(text);
|
||||
await w.close();
|
||||
}
|
||||
const safeName = s => String(s).replace(/[^\w.\-]+/g, '_').slice(0, 60) || 'item';
|
||||
|
||||
export async function save({ only = null, reason = 'manual' } = {}) {
|
||||
if (!dirHandle) throw new Error('no-folder');
|
||||
if (!(await perm(dirHandle))) { ready = false; emit(); throw new Error('needs-regrant'); }
|
||||
const targets = cfg.stores.filter(st => !st.sensitive && (only ? only.includes(st.id) : true));
|
||||
const wrote = [], errors = [];
|
||||
for (const st of targets) {
|
||||
try {
|
||||
const items = await readStore(st);
|
||||
const base = `${ROOT}/${cfg.app}/${st.id}`;
|
||||
if (st.files) { // uno por fichero (apps .html, skills .md)
|
||||
const d = await dirFor(base);
|
||||
for (const it of items) {
|
||||
const body = st.files.body ? st.files.body(it.value) : JSON.stringify(it.value, null, 1);
|
||||
await writeFile(d, safeName(st.files.name ? st.files.name(it.value, it.key) : it.key) + (st.files.ext || '.json'), body);
|
||||
}
|
||||
} else { // uno solo (historial, ajustes)
|
||||
const d = await dirFor(`${ROOT}/${cfg.app}`);
|
||||
await writeFile(d, st.id + '.json', JSON.stringify(items.map(i => i.value), null, 1));
|
||||
}
|
||||
wrote.push({ id: st.id, files: items.length });
|
||||
dirty.delete(st.id);
|
||||
} catch (e) { errors.push({ id: st.id, error: e.message }); }
|
||||
}
|
||||
lastSaveAt = Date.now();
|
||||
lastError = errors.length ? errors[0].error : null;
|
||||
// índice legible con lo que hay
|
||||
try {
|
||||
const d = await dirFor(`${ROOT}/${cfg.app}`);
|
||||
await writeFile(d, 'index.json', JSON.stringify({ app: cfg.app, at: lastSaveAt, reason, wrote }, null, 1));
|
||||
} catch { /* */ }
|
||||
emit();
|
||||
return { ok: !errors.length, at: lastSaveAt, wrote, errors };
|
||||
}
|
||||
|
||||
// ── copia descargable (Safari/Firefox o backup manual) ───────────────────────
|
||||
export async function bundle({ only = null, includeSensitive = false } = {}) {
|
||||
const data = { app: cfg.app, at: Date.now(), stores: {} };
|
||||
for (const st of cfg.stores) {
|
||||
if (st.sensitive && !includeSensitive) continue;
|
||||
if (only && !only.includes(st.id)) continue;
|
||||
try { data.stores[st.id] = (await readStore(st)).map(i => i.value); } catch { /* */ }
|
||||
}
|
||||
return new Blob([JSON.stringify(data, null, 1)], { type: 'application/json' });
|
||||
}
|
||||
|
||||
// ── borrar ───────────────────────────────────────────────────────────────────
|
||||
export async function wipe({ only = null, confirm } = {}) {
|
||||
if (confirm !== 'BORRAR') throw new Error('confirmación requerida');
|
||||
const removed = [];
|
||||
for (const st of cfg.stores) {
|
||||
if (only && !only.includes(st.id)) continue;
|
||||
try {
|
||||
if (st.kind === 'ls') { for (const k of st.keys || []) localStorage.removeItem(k); removed.push({ id: st.id }); }
|
||||
else if (st.kind === 'idb-store') { for (const k of await cfg.db.keys(st.store)) await cfg.db.del(st.store, k); removed.push({ id: st.id }); }
|
||||
else { await cfg.db.del(st.store || 'kv', st.key); removed.push({ id: st.id }); }
|
||||
} catch (e) { removed.push({ id: st.id, error: e.message }); }
|
||||
}
|
||||
emit();
|
||||
return { removed };
|
||||
}
|
||||
|
||||
export async function clearCaches({ names } = {}) {
|
||||
if (typeof caches === 'undefined') return { deleted: [] };
|
||||
const target = names || await caches.keys();
|
||||
const deleted = [];
|
||||
for (const n of target) if (await caches.delete(n)) deleted.push(n);
|
||||
return { deleted };
|
||||
}
|
||||
Reference in New Issue
Block a user