Spaces:
Running
Fix the brain that stopped loading, plus workspace, Hard Work and i18n
Browse files- litert.js imported the runtime from an unpinned CDN URL. On 11 Aug version
0.16.0 shipped, jsdelivr cannot build its +esm bundle (404), and the Gemma
brain stopped loading in production without a line of our code changing.
The version is now pinned; check the URL returns 200 before bumping it.
- Workspace: pick a folder on disk and keep sessions, skills and generated
apps as normal files. Inventory of what is stored, one-click wipe, and a
downloadable copy for browsers without File System Access. The encrypted
vault is never exported in the clear.
- Hard Work (RLM): work a whole folder in parts — map over chunks, merge the
findings, recurse when the merge still doesn't fit.
- Settings reordered: live usage (memory, disk, loaded brain) and cleanup come
first; external cloud models move to a collapsed section at the end.
- Data-loss fixes: a transient IndexedDB read error used to wipe every skill
and collapse the conversation store to a single entry.
- Claw UI completed in Ukrainian, French, German and Portuguese.
- css/code.css +5 -2
- css/lab.css +131 -0
- index.html +4 -4
- js/challenge.js +0 -74
- js/conversations.js +9 -1
- js/i18n.js +126 -2
- js/lab.js +574 -0
- js/main.js +140 -34
- js/providers/litert.js +38 -9
- js/rlm.js +404 -0
- js/skills.js +21 -1
- js/splash-gl.js +3 -3
- js/swe-tasks.js +74 -0
- js/tools/code.js +103 -26
- js/workspace.js +289 -0
- lab.html +123 -0
- style.css +0 -28
|
@@ -1,7 +1,8 @@
|
|
| 1 |
/* Elffuss Code — VS Code oscuro con alma élfica */
|
| 2 |
:root {
|
| 3 |
-
--bg: #
|
| 4 |
-
--fg: #
|
|
|
|
| 5 |
}
|
| 6 |
* { box-sizing: border-box; }
|
| 7 |
html, body { height: 100%; margin: 0; }
|
|
@@ -301,6 +302,8 @@ body.hide-tree #sidebar { display: none; }
|
|
| 301 |
/* 🎯 Modo Objetivo: botón + tarjeta de plan (planificador/ejecutor) */
|
| 302 |
.cbtn.goal { font-size: .74rem; }
|
| 303 |
.cbtn.goal.on { color: #fff; border-color: var(--accent); background: linear-gradient(135deg, #3a2050, #2a1840); }
|
|
|
|
|
|
|
| 304 |
.msg.plan-card { background: var(--bg3); border: 1px solid var(--line); border-radius: 11px; padding: 10px 12px; font-size: .82rem; align-self: stretch; max-width: 100%; }
|
| 305 |
.plan-card .plan-head { font-weight: 600; margin-bottom: 2px; }
|
| 306 |
.plan-card .plan-summary { color: var(--muted); font-size: .78rem; margin-bottom: 8px; }
|
|
|
|
| 1 |
/* Elffuss Code — VS Code oscuro con alma élfica */
|
| 2 |
:root {
|
| 3 |
+
--bg: #090a11; --bg2: #101317; --bg3: #161a24; --line: #20252c;
|
| 4 |
+
--fg: #eae8f2; --muted: #8b929c; --accent: #c8a06a; --accent2: #8272ff;
|
| 5 |
+
--serif: "Iowan Old Style", Georgia, "Times New Roman", serif;
|
| 6 |
}
|
| 7 |
* { box-sizing: border-box; }
|
| 8 |
html, body { height: 100%; margin: 0; }
|
|
|
|
| 302 |
/* 🎯 Modo Objetivo: botón + tarjeta de plan (planificador/ejecutor) */
|
| 303 |
.cbtn.goal { font-size: .74rem; }
|
| 304 |
.cbtn.goal.on { color: #fff; border-color: var(--accent); background: linear-gradient(135deg, #3a2050, #2a1840); }
|
| 305 |
+
.cbtn.hardwork { font-size: .74rem; }
|
| 306 |
+
.cbtn.hardwork.on { color: #fff; border-color: var(--accent); background: linear-gradient(135deg, #3a2a18, #2a1f40); box-shadow: 0 0 0 1px var(--accent) inset; }
|
| 307 |
.msg.plan-card { background: var(--bg3); border: 1px solid var(--line); border-radius: 11px; padding: 10px 12px; font-size: .82rem; align-self: stretch; max-width: 100%; }
|
| 308 |
.plan-card .plan-head { font-weight: 600; margin-bottom: 2px; }
|
| 309 |
.plan-card .plan-summary { color: var(--muted); font-size: .78rem; margin-bottom: 8px; }
|
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Elffuss Lab — panel de banco de pruebas. Estética de instrumental: oscuro,
|
| 2 |
+
datos en monoespaciada, acentos fosforescentes y nada de adorno que no diga
|
| 3 |
+
un dato. Independiente de code.css para no pisar el IDE. */
|
| 4 |
+
:root {
|
| 5 |
+
--bg: #070b0e;
|
| 6 |
+
--panel: #0d141a;
|
| 7 |
+
--panel-2: #111b23;
|
| 8 |
+
--line: #1d2c37;
|
| 9 |
+
--ink: #d7e6ef;
|
| 10 |
+
--dim: #7d94a3;
|
| 11 |
+
--accent: #2af5c0;
|
| 12 |
+
--accent-2: #35b8ff;
|
| 13 |
+
--warn: #ffb648;
|
| 14 |
+
--bad: #ff5c72;
|
| 15 |
+
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
| 16 |
+
}
|
| 17 |
+
* { box-sizing: border-box; }
|
| 18 |
+
body {
|
| 19 |
+
margin: 0; background: var(--bg); color: var(--ink);
|
| 20 |
+
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
| 21 |
+
background-image:
|
| 22 |
+
radial-gradient(1200px 600px at 15% -10%, #0d2b33 0%, transparent 60%),
|
| 23 |
+
radial-gradient(900px 500px at 110% 10%, #101f36 0%, transparent 55%);
|
| 24 |
+
min-height: 100vh;
|
| 25 |
+
}
|
| 26 |
+
a { color: var(--accent-2); }
|
| 27 |
+
|
| 28 |
+
/* ── cabecera ── */
|
| 29 |
+
header {
|
| 30 |
+
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
| 31 |
+
padding: 14px 20px; border-bottom: 1px solid var(--line);
|
| 32 |
+
background: linear-gradient(180deg, rgba(13,20,26,.95), rgba(13,20,26,.6));
|
| 33 |
+
position: sticky; top: 0; z-index: 20; backdrop-filter: blur(8px);
|
| 34 |
+
}
|
| 35 |
+
header h1 { margin: 0; font-size: 17px; letter-spacing: .06em; text-transform: uppercase; }
|
| 36 |
+
header h1 span { color: var(--accent); }
|
| 37 |
+
.sub { color: var(--dim); font-size: 12px; font-family: var(--mono); }
|
| 38 |
+
.spacer { flex: 1; }
|
| 39 |
+
|
| 40 |
+
/* piloto de estado */
|
| 41 |
+
.led { display: inline-flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 12px; color: var(--dim); }
|
| 42 |
+
.led i {
|
| 43 |
+
width: 9px; height: 9px; border-radius: 50%; background: #33454f; display: block;
|
| 44 |
+
box-shadow: 0 0 0 0 rgba(42,245,192,.5);
|
| 45 |
+
}
|
| 46 |
+
.led.on i { background: var(--accent); animation: pulse 1.8s infinite; }
|
| 47 |
+
.led.busy i { background: var(--warn); animation: pulse .9s infinite; }
|
| 48 |
+
.led.err i { background: var(--bad); }
|
| 49 |
+
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(42,245,192,.45); } 70% { box-shadow: 0 0 0 7px rgba(42,245,192,0); } 100% { box-shadow: 0 0 0 0 rgba(42,245,192,0); } }
|
| 50 |
+
|
| 51 |
+
/* ── rejilla ── */
|
| 52 |
+
main { display: grid; grid-template-columns: minmax(300px, 380px) 1fr; gap: 16px; padding: 16px 20px 40px; align-items: start; }
|
| 53 |
+
@media (max-width: 940px) { main { grid-template-columns: 1fr; } }
|
| 54 |
+
|
| 55 |
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
| 56 |
+
.panel + .panel { margin-top: 14px; }
|
| 57 |
+
.panel > h2 {
|
| 58 |
+
margin: 0; padding: 11px 14px; font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
|
| 59 |
+
color: var(--dim); border-bottom: 1px solid var(--line); background: var(--panel-2);
|
| 60 |
+
display: flex; align-items: center; gap: 8px;
|
| 61 |
+
}
|
| 62 |
+
.panel > h2 .tag { margin-left: auto; font-family: var(--mono); font-size: 10px; color: var(--accent); letter-spacing: .04em; }
|
| 63 |
+
.panel .body { padding: 12px 14px; }
|
| 64 |
+
|
| 65 |
+
/* ── controles ── */
|
| 66 |
+
.seg { display: grid; gap: 6px; }
|
| 67 |
+
.seg label {
|
| 68 |
+
display: flex; gap: 10px; align-items: flex-start; padding: 9px 11px; cursor: pointer;
|
| 69 |
+
border: 1px solid var(--line); border-radius: 9px; background: #0a1116; transition: .15s;
|
| 70 |
+
}
|
| 71 |
+
.seg label:hover { border-color: #2b4250; }
|
| 72 |
+
.seg input { margin: 3px 0 0; accent-color: var(--accent); }
|
| 73 |
+
.seg label.sel { border-color: var(--accent); background: rgba(42,245,192,.07); }
|
| 74 |
+
.seg .t { font-weight: 600; }
|
| 75 |
+
.seg .d { color: var(--dim); font-size: 12px; }
|
| 76 |
+
|
| 77 |
+
.knobs { display: grid; gap: 4px; }
|
| 78 |
+
.knob { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 7px; }
|
| 79 |
+
.knob:hover { background: #0a1116; }
|
| 80 |
+
.knob code { font-family: var(--mono); font-size: 12px; color: var(--ink); flex: 1; }
|
| 81 |
+
.knob .why { color: var(--dim); font-size: 11px; flex: 2; }
|
| 82 |
+
.knob input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; }
|
| 83 |
+
.knob input[type=number] { width: 68px; background: #0a1116; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px; font-family: var(--mono); }
|
| 84 |
+
.group-title { font-family: var(--mono); font-size: 10px; letter-spacing: .12em; color: var(--dim); text-transform: uppercase; margin: 10px 0 4px; }
|
| 85 |
+
|
| 86 |
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
| 87 |
+
.btn {
|
| 88 |
+
border: 1px solid var(--line); background: #0e1a21; color: var(--ink);
|
| 89 |
+
padding: 9px 14px; border-radius: 9px; cursor: pointer; font: inherit; transition: .15s;
|
| 90 |
+
}
|
| 91 |
+
.btn:hover:not(:disabled) { border-color: var(--accent); color: #fff; }
|
| 92 |
+
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
| 93 |
+
.btn.primary { background: rgba(42,245,192,.14); border-color: var(--accent); color: var(--accent); font-weight: 600; }
|
| 94 |
+
.btn.ghost { background: transparent; }
|
| 95 |
+
|
| 96 |
+
/* ── métricas ── */
|
| 97 |
+
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
|
| 98 |
+
.tile { background: #0a1116; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
|
| 99 |
+
.tile .k { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--dim); }
|
| 100 |
+
.tile .v { font-family: var(--mono); font-size: 22px; margin-top: 3px; color: var(--accent); }
|
| 101 |
+
.tile .v.small { font-size: 15px; }
|
| 102 |
+
.tile.bad .v { color: var(--bad); }
|
| 103 |
+
.tile.warn .v { color: var(--warn); }
|
| 104 |
+
|
| 105 |
+
/* barra de progreso */
|
| 106 |
+
.bar { height: 6px; background: #0a1116; border: 1px solid var(--line); border-radius: 99px; overflow: hidden; }
|
| 107 |
+
.bar > i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--accent-2), var(--accent)); transition: width .25s; }
|
| 108 |
+
|
| 109 |
+
/* ── tabla de resultados ── */
|
| 110 |
+
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
|
| 111 |
+
th { text-align: left; color: var(--dim); font-weight: 500; font-size: 10px; letter-spacing: .1em; text-transform: uppercase; padding: 6px 8px; border-bottom: 1px solid var(--line); }
|
| 112 |
+
td { padding: 6px 8px; border-bottom: 1px solid #131f27; }
|
| 113 |
+
tr:last-child td { border-bottom: 0; }
|
| 114 |
+
.ok { color: var(--accent); }
|
| 115 |
+
.no { color: var(--bad); }
|
| 116 |
+
.pend { color: var(--dim); }
|
| 117 |
+
|
| 118 |
+
/* ── consola ── */
|
| 119 |
+
.log {
|
| 120 |
+
font-family: var(--mono); font-size: 11.5px; line-height: 1.6; color: #9fb6c4;
|
| 121 |
+
background: #060a0d; border: 1px solid var(--line); border-radius: 9px;
|
| 122 |
+
padding: 10px 12px; height: 220px; overflow: auto; white-space: pre-wrap; word-break: break-word;
|
| 123 |
+
}
|
| 124 |
+
.log .t { color: #4d6572; }
|
| 125 |
+
.log .a { color: var(--accent); }
|
| 126 |
+
.log .w { color: var(--warn); }
|
| 127 |
+
.log .e { color: var(--bad); }
|
| 128 |
+
|
| 129 |
+
iframe.arena { width: 100%; height: 320px; border: 1px solid var(--line); border-radius: 9px; background: #000; }
|
| 130 |
+
.hint { color: var(--dim); font-size: 12px; margin: 8px 0 0; }
|
| 131 |
+
.pill { font-family: var(--mono); font-size: 10px; padding: 2px 7px; border-radius: 99px; border: 1px solid var(--line); color: var(--dim); }
|
|
@@ -8,14 +8,14 @@
|
|
| 8 |
<meta property="og:site_name" content="Elffuss">
|
| 9 |
<meta property="og:title" content="Elffuss Code — a VS Code-style IDE with an AI soul, in your browser">
|
| 10 |
<meta property="og:description" content="A web IDE with an AI coding elf that reads, searches and edits your project. Open a folder and code with a local model on WebGPU — nothing leaves your machine.">
|
| 11 |
-
<meta property="og:url" content="https://
|
| 12 |
-
<meta property="og:image" content="https://
|
| 13 |
<meta property="og:image:width" content="1200">
|
| 14 |
<meta property="og:image:height" content="630">
|
| 15 |
<meta name="twitter:card" content="summary_large_image">
|
| 16 |
<meta name="twitter:title" content="Elffuss Code — a VS Code-style IDE with an AI soul, in your browser">
|
| 17 |
<meta name="twitter:description" content="A web IDE with an AI coding elf that reads, searches and edits your project. Open a folder and code with a local model on WebGPU — nothing leaves your machine.">
|
| 18 |
-
<meta name="twitter:image" content="https://
|
| 19 |
<meta name="description" content="Open a local folder and code with Elffuss: a web IDE plus an AI agent that runs on your own GPU (WebGPU). Nothing leaves your machine.">
|
| 20 |
<link rel="icon" href="img/elffuss-code.svg">
|
| 21 |
<link rel="stylesheet" href="css/code.css">
|
|
@@ -129,6 +129,7 @@
|
|
| 129 |
<input id="prompt" autocomplete="off" placeholder="Pídele código a Elffuss…">
|
| 130 |
<button type="button" id="btn-autoedit" class="cbtn autoedit on" title="Editar archivos automáticamente"><span class="ae-ico"></></span> <span class="ae-txt">Auto</span></button>
|
| 131 |
<button type="button" id="btn-goal" class="cbtn goal" title="Modo Objetivo: descompone el mensaje en tareas y las ejecuta una a una (planificador + ejecutor)">🎯 <span class="goal-txt">Goal</span></button>
|
|
|
|
| 132 |
<button type="submit" id="btn-send" title="Enviar"></button>
|
| 133 |
</form>
|
| 134 |
</div>
|
|
@@ -147,6 +148,5 @@
|
|
| 147 |
</div>
|
| 148 |
|
| 149 |
<script type="module" src="js/main.js"></script>
|
| 150 |
-
<script type="module" src="js/challenge.js"></script>
|
| 151 |
</body>
|
| 152 |
</html>
|
|
|
|
| 8 |
<meta property="og:site_name" content="Elffuss">
|
| 9 |
<meta property="og:title" content="Elffuss Code — a VS Code-style IDE with an AI soul, in your browser">
|
| 10 |
<meta property="og:description" content="A web IDE with an AI coding elf that reads, searches and edits your project. Open a folder and code with a local model on WebGPU — nothing leaves your machine.">
|
| 11 |
+
<meta property="og:url" content="https://code.elffuss.utopiaia.com/">
|
| 12 |
+
<meta property="og:image" content="https://code.elffuss.utopiaia.com/og.png">
|
| 13 |
<meta property="og:image:width" content="1200">
|
| 14 |
<meta property="og:image:height" content="630">
|
| 15 |
<meta name="twitter:card" content="summary_large_image">
|
| 16 |
<meta name="twitter:title" content="Elffuss Code — a VS Code-style IDE with an AI soul, in your browser">
|
| 17 |
<meta name="twitter:description" content="A web IDE with an AI coding elf that reads, searches and edits your project. Open a folder and code with a local model on WebGPU — nothing leaves your machine.">
|
| 18 |
+
<meta name="twitter:image" content="https://code.elffuss.utopiaia.com/og.png">
|
| 19 |
<meta name="description" content="Open a local folder and code with Elffuss: a web IDE plus an AI agent that runs on your own GPU (WebGPU). Nothing leaves your machine.">
|
| 20 |
<link rel="icon" href="img/elffuss-code.svg">
|
| 21 |
<link rel="stylesheet" href="css/code.css">
|
|
|
|
| 129 |
<input id="prompt" autocomplete="off" placeholder="Pídele código a Elffuss…">
|
| 130 |
<button type="button" id="btn-autoedit" class="cbtn autoedit on" title="Editar archivos automáticamente"><span class="ae-ico"></></span> <span class="ae-txt">Auto</span></button>
|
| 131 |
<button type="button" id="btn-goal" class="cbtn goal" title="Modo Objetivo: descompone el mensaje en tareas y las ejecuta una a una (planificador + ejecutor)">🎯 <span class="goal-txt">Goal</span></button>
|
| 132 |
+
<button type="button" id="btn-hardwork" class="cbtn hardwork" title="Hard Work (RLM): lee tu proyecto ENTERO por partes — audita, resume o busca en código que no cabe en el modelo. Tarda, pero lo abarca todo.">⛏ <span class="hw-txt">Hard Work</span></button>
|
| 133 |
<button type="submit" id="btn-send" title="Enviar"></button>
|
| 134 |
</form>
|
| 135 |
</div>
|
|
|
|
| 148 |
</div>
|
| 149 |
|
| 150 |
<script type="module" src="js/main.js"></script>
|
|
|
|
| 151 |
</body>
|
| 152 |
</html>
|
|
@@ -1,74 +0,0 @@
|
|
| 1 |
-
// challenge.js — top bar announcing the 15-minute challenge, with the two places to post
|
| 2 |
-
// the result. Self-contained (injects its own CSS), dismissible, and it removes itself
|
| 3 |
-
// after the deadline so nobody has to remember to take it down.
|
| 4 |
-
import { uiLang } from './i18n.js';
|
| 5 |
-
|
| 6 |
-
const DEADLINE = Date.parse('2026-08-10T00:00:00Z'); // winner announced Sun Aug 9
|
| 7 |
-
const KEY = 'elffuss_challenge_v1_dismissed';
|
| 8 |
-
const X_URL = 'https://x.com/KikoCisneros/status/2085358719190130963';
|
| 9 |
-
const LI_URL = 'https://www.linkedin.com/feed/update/urn:li:activity:7491125059768516608/';
|
| 10 |
-
|
| 11 |
-
const COPY = {
|
| 12 |
-
es: {
|
| 13 |
-
text: '⚡ Reto de 15 min: abre una carpeta tuya y deja que Elffuss arregle un bug real. Gana el comentario con más likes — ganador el domingo 9.',
|
| 14 |
-
x: 'Publicar en X', li: 'En LinkedIn', close: 'Cerrar aviso',
|
| 15 |
-
},
|
| 16 |
-
en: {
|
| 17 |
-
text: '⚡ 15-min challenge: open one of your folders and let Elffuss fix a real bug. Most-liked comment wins — winner on Sun Aug 9.',
|
| 18 |
-
x: 'Post on X', li: 'On LinkedIn', close: 'Dismiss',
|
| 19 |
-
},
|
| 20 |
-
};
|
| 21 |
-
|
| 22 |
-
export function mountChallengeBar() {
|
| 23 |
-
if (Date.now() > DEADLINE) return;
|
| 24 |
-
try { if (localStorage.getItem(KEY)) return; } catch (e) {}
|
| 25 |
-
const c = COPY[uiLang() === 'es' ? 'es' : 'en'];
|
| 26 |
-
|
| 27 |
-
const css = document.createElement('style');
|
| 28 |
-
css.textContent = `
|
| 29 |
-
body.elf-ch { padding-top: 38px; }
|
| 30 |
-
body.elf-ch #ide { height: calc(100vh - 38px); }
|
| 31 |
-
.elf-ch-bar {
|
| 32 |
-
position: fixed; inset: 0 0 auto 0; z-index: 400; height: 38px;
|
| 33 |
-
display: flex; align-items: center; justify-content: center; gap: 10px;
|
| 34 |
-
padding: 0 42px 0 12px; font-size: .82rem; color: #fff;
|
| 35 |
-
background: linear-gradient(135deg, var(--accent, #7c5cff), var(--accent2, #ff5cd6));
|
| 36 |
-
box-shadow: 0 2px 12px rgba(0,0,0,.35);
|
| 37 |
-
}
|
| 38 |
-
.elf-ch-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 39 |
-
.elf-ch-bar a {
|
| 40 |
-
flex: none; padding: 3px 10px; border-radius: 999px; text-decoration: none;
|
| 41 |
-
color: #fff; background: rgba(0,0,0,.28); font-weight: 600;
|
| 42 |
-
}
|
| 43 |
-
.elf-ch-bar a:hover { background: rgba(0,0,0,.45); }
|
| 44 |
-
.elf-ch-bar button {
|
| 45 |
-
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
| 46 |
-
background: none; border: none; color: #fff; font-size: 1.1rem; cursor: pointer; opacity: .8;
|
| 47 |
-
}
|
| 48 |
-
.elf-ch-bar button:hover { opacity: 1; }
|
| 49 |
-
@media (max-width: 620px) { .elf-ch-bar span { font-size: .74rem; } }
|
| 50 |
-
`;
|
| 51 |
-
document.head.appendChild(css);
|
| 52 |
-
|
| 53 |
-
const bar = document.createElement('div');
|
| 54 |
-
bar.className = 'elf-ch-bar';
|
| 55 |
-
bar.innerHTML = `<span></span>
|
| 56 |
-
<a target="_blank" rel="noopener" href="${X_URL}"></a>
|
| 57 |
-
<a target="_blank" rel="noopener" href="${LI_URL}"></a>
|
| 58 |
-
<button type="button" aria-label=""></button>`;
|
| 59 |
-
bar.querySelector('span').textContent = c.text;
|
| 60 |
-
const [ax, ali] = bar.querySelectorAll('a');
|
| 61 |
-
ax.textContent = c.x; ali.textContent = c.li;
|
| 62 |
-
const btn = bar.querySelector('button');
|
| 63 |
-
btn.textContent = '✕'; btn.setAttribute('aria-label', c.close);
|
| 64 |
-
btn.addEventListener('click', () => {
|
| 65 |
-
try { localStorage.setItem(KEY, '1'); } catch (e) {}
|
| 66 |
-
bar.remove(); document.body.classList.remove('elf-ch');
|
| 67 |
-
});
|
| 68 |
-
|
| 69 |
-
document.body.classList.add('elf-ch');
|
| 70 |
-
document.body.appendChild(bar);
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountChallengeBar);
|
| 74 |
-
else mountChallengeBar();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -59,7 +59,15 @@ export function getActive() { return activeId ? convs.get(activeId) : null; }
|
|
| 59 |
export function getOpenTabs() { return openTabIds.map(id => convs.get(id)).filter(Boolean); }
|
| 60 |
|
| 61 |
async function persistConv(conv) {
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
const i = all.findIndex(c => c.id === conv.id);
|
| 64 |
const rec = {
|
| 65 |
id: conv.id, title: titleFor(conv), history: conv.agent.history.slice(-HIST_CAP),
|
|
|
|
| 59 |
export function getOpenTabs() { return openTabIds.map(id => convs.get(id)).filter(Boolean); }
|
| 60 |
|
| 61 |
async function persistConv(conv) {
|
| 62 |
+
// NO tragarse el error de lectura: un fallo transitorio devolvía [] y el
|
| 63 |
+
// db.set de abajo reescribía el store entero con UNA sola conversación,
|
| 64 |
+
// borrando el resto. Distinguir «no existe aún» (undefined → []) de «no se
|
| 65 |
+
// pudo leer» (excepción → no escribir nada este turno).
|
| 66 |
+
let stored;
|
| 67 |
+
try { stored = await db.get('kv', 'conversations'); }
|
| 68 |
+
catch (e) { console.warn('[elffuss] no se pudo leer conversations; no sobrescribo', e); return; }
|
| 69 |
+
const all = stored == null ? [] : stored;
|
| 70 |
+
if (!Array.isArray(all)) { console.warn('[elffuss] conversations corrupto; no sobrescribo'); return; }
|
| 71 |
const i = all.findIndex(c => c.id === conv.id);
|
| 72 |
const rec = {
|
| 73 |
id: conv.id, title: titleFor(conv), history: conv.agent.history.slice(-HIST_CAP),
|
|
@@ -54,7 +54,69 @@ const L = {
|
|
| 54 |
setCalc: 'Calculando espacio…', setPersist: '✓ almacenamiento persistente (no se borra solo)',
|
| 55 |
setNoPersist: '⚠ sin persistencia: el navegador podría desalojarlo', setLimit: ' · límite ~{gb}',
|
| 56 |
setModelPh: 'modelo (p. ej. gpt-4o-mini)', keyNeeded: 'clave (no necesaria)',
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
en: {
|
| 59 |
ph: 'Ask Elffuss for code…',
|
| 60 |
thinking: 'Elffuss is thinking', writing: 'Elffuss writes · {n} chars', using: 'Elffuss uses {name}',
|
|
@@ -103,7 +165,69 @@ const L = {
|
|
| 103 |
setCalc: 'Calculating space…', setPersist: '✓ persistent storage (won’t be auto-cleared)',
|
| 104 |
setNoPersist: '⚠ not persistent: the browser could evict it', setLimit: ' · limit ~{gb}',
|
| 105 |
setModelPh: 'model (e.g. gpt-4o-mini)', keyNeeded: 'key (not needed)',
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
};
|
| 108 |
|
| 109 |
// Idioma del chrome: 2 letras, ES por defecto.
|
|
|
|
| 54 |
setCalc: 'Calculando espacio…', setPersist: '✓ almacenamiento persistente (no se borra solo)',
|
| 55 |
setNoPersist: '⚠ sin persistencia: el navegador podría desalojarlo', setLimit: ' · límite ~{gb}',
|
| 56 |
setModelPh: 'modelo (p. ej. gpt-4o-mini)', keyNeeded: 'clave (no necesaria)',
|
| 57 |
+
wsTitle: "Espacio de trabajo (tu disco)",
|
| 58 |
+
wsDesc: "Tus conversaciones y skills viven en este navegador, que puede borrarlas. Guárdalas en una carpeta y quedan como ficheros normales.",
|
| 59 |
+
wsNoSupport: "Este navegador no permite elegir carpeta. Usa «Descargar copia».",
|
| 60 |
+
wsNoFolder: "Sin carpeta: solo viven en este navegador.",
|
| 61 |
+
wsFolder: "Carpeta: {name}",
|
| 62 |
+
wsNeedsPerm: "«{name}» necesita permiso otra vez (pasa al recargar).",
|
| 63 |
+
wsPick: "Elegir carpeta",
|
| 64 |
+
wsUseProject: "Usar la del proyecto",
|
| 65 |
+
wsRegrant: "Conceder permiso",
|
| 66 |
+
wsSaveNow: "Guardar ahora",
|
| 67 |
+
wsSaved: "Guardado ({n} almacenes)",
|
| 68 |
+
wsAuto: "Guardar solo",
|
| 69 |
+
wsForget: "Olvidar carpeta",
|
| 70 |
+
wsDownload: "Descargar copia",
|
| 71 |
+
wsWhat: "Qué hay guardado",
|
| 72 |
+
setTitle: "Ajustes",
|
| 73 |
+
close: "cerrar",
|
| 74 |
+
setBrainTitle: "Cerebro (modelo)",
|
| 75 |
+
setModelE4bSub: "El mejor · WebGPU local · ~4 GB",
|
| 76 |
+
setModelE2bSub: "Ligero · WebGPU local · ~2 GB",
|
| 77 |
+
setModelOnnxSub: "Modelo propio · 850 MB · tool-calls + apps",
|
| 78 |
+
setModelRulesSub: "Órdenes directas, cero descarga",
|
| 79 |
+
setBridgeTitle: "🔌 Bridge local (ejecución real en tu máquina)",
|
| 80 |
+
setBridgeDisconnected: "desconectado",
|
| 81 |
+
setBridgeFolderPh: "/ruta/completa/a/tu/proyecto",
|
| 82 |
+
setBridgeConnect: "Conectar",
|
| 83 |
+
setBridgeOnStatus: "✓ conectado — ejecución real activa",
|
| 84 |
+
setBridgeNoToken: "⚠️ pega el token que imprimió el programa al arrancarlo",
|
| 85 |
+
setBridgeConnecting: "Conectando…",
|
| 86 |
+
setBridgeConnectedMsg: "✔ conectado — ejecución real en tu máquina",
|
| 87 |
+
setPermTitle: "✅ Permisos de ejecución",
|
| 88 |
+
setGoalTitle: "🎯 Modo Objetivo",
|
| 89 |
+
setTelTitle: "📨 Errores y feedback",
|
| 90 |
+
setTelEmpty: "escribe algo primero",
|
| 91 |
+
setTelThanks: "¡enviado, gracias!",
|
| 92 |
+
setProvTitle: "Proveedores externos (opcional · la clave se queda en tu navegador)",
|
| 93 |
+
setProvUse: "Usar",
|
| 94 |
+
setManageSkillsBtn: "Gestionar skills de Claude Code",
|
| 95 |
+
skRemove: "Quitar",
|
| 96 |
+
skBrowse: "Explorar",
|
| 97 |
+
skAddRepoPh: "owner/repo o URL de GitHub (p. ej. OpenClaude/…)",
|
| 98 |
+
skAddRepo: "Añadir repo",
|
| 99 |
+
hwPlaceholder: "⛏ Hard Work: pregunta algo sobre TODO el proyecto…",
|
| 100 |
+
hwNeedBrain: "Hard Work necesita un cerebro cargado: elige un modelo en Ajustes y reintenta.",
|
| 101 |
+
hwNeedProject: "Hard Work trabaja sobre tu proyecto. Abre una carpeta primero y vuelve a pulsar ⛏ Hard Work.",
|
| 102 |
+
hwReading: "Hard Work · leyendo el proyecto…",
|
| 103 |
+
hwNoFiles: "No encontré archivos de texto/código legibles en el proyecto.",
|
| 104 |
+
hwTrimmed: " — recortado",
|
| 105 |
+
hwPhaseReduce: "Hard Work · sintetizando…",
|
| 106 |
+
hwResultTrunc: " Proyecto recortado al máximo — acota con una subcarpeta.",
|
| 107 |
+
planPlanning: "planificando…",
|
| 108 |
+
planRunning: "ejecutando…",
|
| 109 |
+
planDone: "completado",
|
| 110 |
+
planFailed: "con fallos",
|
| 111 |
+
stModelLoadingAI: "Cargando el modelo IA…",
|
| 112 |
+
termCapsBridge: "🔌 Bridge local: node/npm/python reales",
|
| 113 |
+
termCapsNoBridge: "shell del proyecto · node/npm/python → Bridge local (⚙ Ajustes)",
|
| 114 |
+
setBridgeFolderLabel: "Carpeta de trabajo (opcional — si no, usa una temporal)",
|
| 115 |
+
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.",
|
| 116 |
+
setBridgeDownload: "⬇ Descargar para {os}",
|
| 117 |
+
setBridgeOtherOS: "otro sistema operativo",
|
| 118 |
+
setModelRulesName: "Básico (sin modelo)",
|
| 119 |
+
},
|
| 120 |
en: {
|
| 121 |
ph: 'Ask Elffuss for code…',
|
| 122 |
thinking: 'Elffuss is thinking', writing: 'Elffuss writes · {n} chars', using: 'Elffuss uses {name}',
|
|
|
|
| 165 |
setCalc: 'Calculating space…', setPersist: '✓ persistent storage (won’t be auto-cleared)',
|
| 166 |
setNoPersist: '⚠ not persistent: the browser could evict it', setLimit: ' · limit ~{gb}',
|
| 167 |
setModelPh: 'model (e.g. gpt-4o-mini)', keyNeeded: 'key (not needed)',
|
| 168 |
+
wsTitle: "Workspace (your disk)",
|
| 169 |
+
wsDesc: "Your conversations and skills live in this browser, which can wipe them. Save them to a folder and they stay as normal files.",
|
| 170 |
+
wsNoSupport: "This browser can't pick a folder. Use «Download copy».",
|
| 171 |
+
wsNoFolder: "No folder: they only live in this browser.",
|
| 172 |
+
wsFolder: "Folder: {name}",
|
| 173 |
+
wsNeedsPerm: "«{name}» needs permission again (happens after a reload).",
|
| 174 |
+
wsPick: "Pick a folder",
|
| 175 |
+
wsUseProject: "Use the project's",
|
| 176 |
+
wsRegrant: "Grant permission",
|
| 177 |
+
wsSaveNow: "Save now",
|
| 178 |
+
wsSaved: "Saved ({n} stores)",
|
| 179 |
+
wsAuto: "Save automatically",
|
| 180 |
+
wsForget: "Forget folder",
|
| 181 |
+
wsDownload: "Download copy",
|
| 182 |
+
wsWhat: "What's stored",
|
| 183 |
+
setTitle: "Settings",
|
| 184 |
+
close: "close",
|
| 185 |
+
setBrainTitle: "Brain (model)",
|
| 186 |
+
setModelE4bSub: "The best · local WebGPU · ~4 GB",
|
| 187 |
+
setModelE2bSub: "Light · local WebGPU · ~2 GB",
|
| 188 |
+
setModelOnnxSub: "Our own model · 850 MB · tool-calls + apps",
|
| 189 |
+
setModelRulesSub: "Direct commands, zero download",
|
| 190 |
+
setBridgeTitle: "🔌 Local bridge (real execution on your machine)",
|
| 191 |
+
setBridgeDisconnected: "disconnected",
|
| 192 |
+
setBridgeFolderPh: "/full/path/to/your/project",
|
| 193 |
+
setBridgeConnect: "Connect",
|
| 194 |
+
setBridgeOnStatus: "✓ connected — real execution active",
|
| 195 |
+
setBridgeNoToken: "⚠️ paste the token the program printed on startup",
|
| 196 |
+
setBridgeConnecting: "Connecting…",
|
| 197 |
+
setBridgeConnectedMsg: "✔ connected — real execution on your machine",
|
| 198 |
+
setPermTitle: "✅ Execution permissions",
|
| 199 |
+
setGoalTitle: "🎯 Goal mode",
|
| 200 |
+
setTelTitle: "📨 Errors and feedback",
|
| 201 |
+
setTelEmpty: "type something first",
|
| 202 |
+
setTelThanks: "sent, thanks!",
|
| 203 |
+
setProvTitle: "External providers (optional · the key stays in your browser)",
|
| 204 |
+
setProvUse: "Use",
|
| 205 |
+
setManageSkillsBtn: "Manage Claude Code skills",
|
| 206 |
+
skRemove: "Remove",
|
| 207 |
+
skBrowse: "Browse",
|
| 208 |
+
skAddRepoPh: "owner/repo or GitHub URL (e.g. OpenClaude/…)",
|
| 209 |
+
skAddRepo: "Add repo",
|
| 210 |
+
hwPlaceholder: "⛏ Hard Work: ask something about the WHOLE project…",
|
| 211 |
+
hwNeedBrain: "Hard Work needs a loaded brain: pick a model in Settings and try again.",
|
| 212 |
+
hwNeedProject: "Hard Work works on your project. Open a folder first, then press ⛏ Hard Work again.",
|
| 213 |
+
hwReading: "Hard Work · reading the project…",
|
| 214 |
+
hwNoFiles: "I found no readable text/code files in the project.",
|
| 215 |
+
hwTrimmed: " — trimmed",
|
| 216 |
+
hwPhaseReduce: "Hard Work · synthesizing…",
|
| 217 |
+
hwResultTrunc: " Project trimmed to the max — narrow it down to a subfolder.",
|
| 218 |
+
planPlanning: "planning…",
|
| 219 |
+
planRunning: "running…",
|
| 220 |
+
planDone: "completed",
|
| 221 |
+
planFailed: "with failures",
|
| 222 |
+
stModelLoadingAI: "Loading the AI model…",
|
| 223 |
+
termCapsBridge: "🔌 Local bridge: real node/npm/python",
|
| 224 |
+
termCapsNoBridge: "project shell · node/npm/python → Local bridge (⚙ Settings)",
|
| 225 |
+
setBridgeFolderLabel: "Working folder (optional — otherwise a temporary one is used)",
|
| 226 |
+
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.",
|
| 227 |
+
setBridgeDownload: "⬇ Download for {os}",
|
| 228 |
+
setBridgeOtherOS: "another operating system",
|
| 229 |
+
setModelRulesName: "Basic (no model)",
|
| 230 |
+
},
|
| 231 |
};
|
| 232 |
|
| 233 |
// Idioma del chrome: 2 letras, ES por defecto.
|
|
@@ -0,0 +1,574 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Elffuss Lab — banco de pruebas del propio producto.
|
| 2 |
+
//
|
| 3 |
+
// Tres cosas, en la misma mesa:
|
| 4 |
+
// 1) ELEGIR algoritmo de generación (directo / RLM map-reduce / RLM con
|
| 5 |
+
// crítica) y ENCENDER o APAGAR cada perilla del compresor de contexto.
|
| 6 |
+
// 2) LANZAR benchmarks reales y ver el progreso caso a caso, en vivo.
|
| 7 |
+
// 3) GUARDAR la corrida con su TRAZA en un historial y un ranking LOCALES:
|
| 8 |
+
// todo se queda en esta máquina, no se envía nada a ningún sitio.
|
| 9 |
+
//
|
| 10 |
+
// Nada de esto simula: el banco de contexto llama al `packHistoryACER` de
|
| 11 |
+
// verdad, el de SWE ejecuta los tests de verdad contra el módulo arreglado, y
|
| 12 |
+
// el de juegos ejecuta el juego generado y lo puntúa mirándolo correr.
|
| 13 |
+
import { DEFAULTS, packHistoryACER, estimateTokens } from './acer-core.js';
|
| 14 |
+
import { TASKS } from './swe-tasks.js';
|
| 15 |
+
|
| 16 |
+
const $ = s => document.querySelector(s);
|
| 17 |
+
const el = (t, c, x) => { const n = document.createElement(t); if (c) n.className = c; if (x != null) n.textContent = x; return n; };
|
| 18 |
+
|
| 19 |
+
// ─────────────────────────── estado ───────────────────────────
|
| 20 |
+
const S = {
|
| 21 |
+
brain: localStorage.getItem('elffusscode.model') || 'rules',
|
| 22 |
+
algo: 'directo',
|
| 23 |
+
algoOpts: { rounds: 1, chunkTokens: 1800, maxChunks: 24 },
|
| 24 |
+
ctx: {}, // overrides sobre DEFAULTS
|
| 25 |
+
bench: 'contexto',
|
| 26 |
+
provider: null,
|
| 27 |
+
running: false, abort: false,
|
| 28 |
+
run: null, // { bench, casos:[], métricas:{} } — la traza
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
// Perillas del compresor que tiene sentido tocar a mano (las demás quedan en su
|
| 32 |
+
// valor por defecto). El «porqué» sale del propio acer-core.
|
| 33 |
+
const CTX_KNOBS = [
|
| 34 |
+
['recuperación', [
|
| 35 |
+
['SEMANTIC', 'bool', 'fusiona embeddings con BM25 — INERTE en el banco de contexto (ese usa el packer sin embeddings)'],
|
| 36 |
+
['MMR', 'bool', 'diversifica lo recuperado (evita repetir lo mismo)'],
|
| 37 |
+
['DEDUP', 'bool', 'quita mensajes casi idénticos'],
|
| 38 |
+
['PIN_QUERY_TERMS', 'bool', 'clava los términos de la pregunta'],
|
| 39 |
+
['SUPERSEDE', 'bool', 'lo nuevo pisa a lo viejo cuando se contradicen'],
|
| 40 |
+
['RECENCY_WEIGHT', 'num', 'peso de lo reciente al puntuar'],
|
| 41 |
+
]],
|
| 42 |
+
['presupuesto', [
|
| 43 |
+
['ELASTIC', 'bool', 'estira/encoge según la presión real'],
|
| 44 |
+
['RECENT', 'num', 'nº de turnos recientes intocables'],
|
| 45 |
+
['TAIL_MIN_FRAC', 'num', 'suelo garantizado para la cola'],
|
| 46 |
+
['HEAD_FRAC', 'num', 'reserva para el arranque (0 = apagada)'],
|
| 47 |
+
['MAX_MSG_CHARS', 'num', 'recorte por mensaje'],
|
| 48 |
+
]],
|
| 49 |
+
['extras', [
|
| 50 |
+
['DATES', 'bool', 'anota «ayer» con la fecha del turno'],
|
| 51 |
+
['SUMMARY', 'bool', 'tarjeta de recuento (cuesta presupuesto)'],
|
| 52 |
+
['AUTO', 'bool', 'deriva las perillas de lo medido en ESTE historial'],
|
| 53 |
+
]],
|
| 54 |
+
];
|
| 55 |
+
|
| 56 |
+
const ALGOS = [
|
| 57 |
+
['directo', 'Directo', 'Una sola inferencia. El baremo contra el que se compara todo.'],
|
| 58 |
+
['rlm-deep', 'RLM · crear y criticar', 'Borrador → crítica → reescritura, N rondas (deepCreate).'],
|
| 59 |
+
['rlm-hard', 'RLM · Hard Work', 'Trocea el material, pregunta a cada trozo y funde (map-reduce).'],
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
const BENCHES = [
|
| 63 |
+
['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.'],
|
| 64 |
+
['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.`],
|
| 65 |
+
['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.'],
|
| 66 |
+
];
|
| 67 |
+
|
| 68 |
+
const BRAINS = [
|
| 69 |
+
['rules', 'Reglas (sin GPU)', 'Determinista. Valida el arnés sin bajar pesos.'],
|
| 70 |
+
['onnx', 'Elffuss LM (CPU/wasm)', 'LFM2.5-1.2B por transformers.js.'],
|
| 71 |
+
['litert:gemma-e2b', 'Gemma-4 E2B (~2 GB)', 'WebGPU.'],
|
| 72 |
+
['litert:gemma-e4b', 'Gemma-4 E4B (~3 GB)', 'WebGPU. El mejor.'],
|
| 73 |
+
];
|
| 74 |
+
|
| 75 |
+
// ─────────────────────────── consola ───────────────────────────
|
| 76 |
+
function log(msg, cls = '') {
|
| 77 |
+
const t = new Date().toTimeString().slice(0, 8);
|
| 78 |
+
const line = el('div');
|
| 79 |
+
line.appendChild(el('span', 't', t + ' '));
|
| 80 |
+
line.appendChild(el('span', cls, msg));
|
| 81 |
+
$('#log').appendChild(line);
|
| 82 |
+
$('#log').scrollTop = $('#log').scrollHeight;
|
| 83 |
+
}
|
| 84 |
+
const setLed = (id, cls, txt) => { const n = $(id); n.className = 'led ' + cls; n.querySelector('span').textContent = txt; };
|
| 85 |
+
const prog = (done, total, txt) => {
|
| 86 |
+
$('#prog').style.width = total ? Math.round(done / total * 100) + '%' : '0';
|
| 87 |
+
$('#prog-txt').textContent = txt || (total ? `${done}/${total}` : '—');
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
function tiles(obj) {
|
| 91 |
+
const box = $('#tiles'); box.innerHTML = '';
|
| 92 |
+
for (const [k, v] of Object.entries(obj)) {
|
| 93 |
+
const t = el('div', 'tile' + (v && v.bad ? ' bad' : v && v.warn ? ' warn' : ''));
|
| 94 |
+
t.appendChild(el('div', 'k', k));
|
| 95 |
+
const val = (v && typeof v === 'object') ? v.v : v;
|
| 96 |
+
t.appendChild(el('div', 'v' + (String(val).length > 7 ? ' small' : ''), String(val)));
|
| 97 |
+
box.appendChild(t);
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
function table(cols, rows) {
|
| 101 |
+
$('#res-head').innerHTML = '<tr>' + cols.map(c => `<th>${c}</th>`).join('') + '</tr>';
|
| 102 |
+
$('#res-body').innerHTML = '';
|
| 103 |
+
rows.forEach(addRow);
|
| 104 |
+
}
|
| 105 |
+
function addRow(cells) {
|
| 106 |
+
const tr = el('tr');
|
| 107 |
+
cells.forEach(c => {
|
| 108 |
+
const td = el('td');
|
| 109 |
+
if (c && typeof c === 'object') { td.className = c.cls || ''; td.textContent = c.t; }
|
| 110 |
+
else td.textContent = c;
|
| 111 |
+
tr.appendChild(td);
|
| 112 |
+
});
|
| 113 |
+
$('#res-body').appendChild(tr);
|
| 114 |
+
return tr;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// ─────────────────────────── interfaz ───────────────────────────
|
| 118 |
+
function radio(host, items, current, onPick) {
|
| 119 |
+
host.innerHTML = '';
|
| 120 |
+
items.forEach(([id, title, desc]) => {
|
| 121 |
+
const l = el('label', id === current ? 'sel' : '');
|
| 122 |
+
const i = el('input'); i.type = 'radio'; i.name = host.id; i.checked = id === current;
|
| 123 |
+
i.onchange = () => { onPick(id); radio(host, items, id, onPick); };
|
| 124 |
+
const d = el('div');
|
| 125 |
+
d.appendChild(el('div', 't', title));
|
| 126 |
+
d.appendChild(el('div', 'd', desc));
|
| 127 |
+
l.append(i, d); host.appendChild(l);
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function ctxUI() {
|
| 132 |
+
const host = $('#ctx-knobs'); host.innerHTML = '';
|
| 133 |
+
for (const [group, knobs] of CTX_KNOBS) {
|
| 134 |
+
host.appendChild(el('div', 'group-title', group));
|
| 135 |
+
const box = el('div', 'knobs');
|
| 136 |
+
for (const [key, type, why] of knobs) {
|
| 137 |
+
const row = el('div', 'knob');
|
| 138 |
+
const cur = key in S.ctx ? S.ctx[key] : DEFAULTS[key];
|
| 139 |
+
const inp = el('input');
|
| 140 |
+
if (type === 'bool') { inp.type = 'checkbox'; inp.checked = !!cur; }
|
| 141 |
+
else { inp.type = 'number'; inp.step = 'any'; inp.value = cur ?? 0; }
|
| 142 |
+
inp.onchange = () => {
|
| 143 |
+
S.ctx[key] = type === 'bool' ? inp.checked : Number(inp.value);
|
| 144 |
+
ctxTag();
|
| 145 |
+
if (S.bench === 'contexto') log('perilla ' + key + ' → ' + S.ctx[key] + ' (relanza para medir)', 'w');
|
| 146 |
+
};
|
| 147 |
+
row.append(inp, el('code', '', key), el('div', 'why', why));
|
| 148 |
+
box.appendChild(row);
|
| 149 |
+
}
|
| 150 |
+
host.appendChild(box);
|
| 151 |
+
}
|
| 152 |
+
ctxTag();
|
| 153 |
+
}
|
| 154 |
+
function ctxTag() {
|
| 155 |
+
const on = CTX_KNOBS.flatMap(([, k]) => k).filter(([key, t]) => t === 'bool' && (key in S.ctx ? S.ctx[key] : DEFAULTS[key])).length;
|
| 156 |
+
$('#ctx-tag').textContent = on + ' activas';
|
| 157 |
+
}
|
| 158 |
+
function algoKnobs() {
|
| 159 |
+
const host = $('#algo-knobs'); host.innerHTML = '';
|
| 160 |
+
const defs = S.algo === 'rlm-hard'
|
| 161 |
+
? [['chunkTokens', 'tamaño de cada trozo'], ['maxChunks', 'techo de sub-llamadas']]
|
| 162 |
+
: S.algo === 'rlm-deep' ? [['rounds', 'rondas de crítica + reescritura']] : [];
|
| 163 |
+
if (!defs.length) { host.appendChild(el('div', 'hint', 'El modo directo no tiene parámetros: una inferencia y ya.')); return; }
|
| 164 |
+
const box = el('div', 'knobs');
|
| 165 |
+
defs.forEach(([k, why]) => {
|
| 166 |
+
const row = el('div', 'knob');
|
| 167 |
+
const i = el('input'); i.type = 'number'; i.value = S.algoOpts[k]; i.min = 0;
|
| 168 |
+
i.onchange = () => { S.algoOpts[k] = Number(i.value); };
|
| 169 |
+
row.append(i, el('code', '', k), el('div', 'why', why));
|
| 170 |
+
box.appendChild(row);
|
| 171 |
+
});
|
| 172 |
+
host.appendChild(box);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// ─────────────────────────── cerebro ───────────────────────────
|
| 176 |
+
async function loadBrain() {
|
| 177 |
+
const id = S.brain;
|
| 178 |
+
$('#btn-load').disabled = true;
|
| 179 |
+
setLed('#led-model', 'busy', 'cargando ' + id + '…');
|
| 180 |
+
try {
|
| 181 |
+
let mod;
|
| 182 |
+
if (id === 'rules') mod = await import('./providers/rules.js');
|
| 183 |
+
else if (id === 'onnx') mod = await import('./providers/onnx.js');
|
| 184 |
+
else if (id.startsWith('litert:')) {
|
| 185 |
+
mod = await import('./providers/litert.js');
|
| 186 |
+
mod.configure(id.split(':')[1]);
|
| 187 |
+
}
|
| 188 |
+
if (mod.load) await mod.load(s => { $('#load-state').textContent = String(s).slice(0, 60); });
|
| 189 |
+
S.provider = mod;
|
| 190 |
+
setLed('#led-model', 'on', 'modelo: ' + id);
|
| 191 |
+
$('#load-state').textContent = '';
|
| 192 |
+
log('cerebro listo: ' + id, 'a');
|
| 193 |
+
} catch (e) {
|
| 194 |
+
setLed('#led-model', 'err', 'fallo al cargar');
|
| 195 |
+
log('no se pudo cargar el cerebro: ' + e.message, 'e');
|
| 196 |
+
}
|
| 197 |
+
$('#btn-load').disabled = false;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// ═══════════════ BANCO 1 · contexto (determinista, sin GPU) ═══════════════
|
| 201 |
+
// Construye un historial largo con HECHOS plantados y ruido alrededor, lo pasa
|
| 202 |
+
// por el compresor con las perillas elegidas y mide qué evidencia sobrevive y
|
| 203 |
+
// cuántos tokens cuesta. Sin modelo: mide el COMPRESOR, no al que responde.
|
| 204 |
+
// Cada caso trae: el dato BUENO, una versión CADUCADA del mismo dato (dicha
|
| 205 |
+
// antes) y distractores que comparten vocabulario con la pregunta. Así el banco
|
| 206 |
+
// no premia «meter mucho», sino traer lo correcto y dejar fuera lo viejo — que
|
| 207 |
+
// es lo que de verdad hacen SUPERSEDE, PIN_QUERY_TERMS o DEDUP.
|
| 208 |
+
const HECHOS = [
|
| 209 |
+
{ bueno: 'Ahora el servidor de pagos escucha en el puerto 7421.', viejo: 'El servidor de pagos escuchaba en el puerto 3000.',
|
| 210 |
+
q: '¿en qué puerto escucha el servidor de pagos?', clave: '7421', claveVieja: '3000',
|
| 211 |
+
ruido: ['El servidor de correo va por otro puerto distinto.', 'Ese puerto del router no tiene nada que ver con pagos.'] },
|
| 212 |
+
{ bueno: 'La clave de despliegue caduca el 3 de marzo.', viejo: 'La clave de despliegue caducaba el 9 de enero.',
|
| 213 |
+
q: '¿cuándo caduca la clave de despliegue?', clave: '3 de marzo', claveVieja: '9 de enero',
|
| 214 |
+
ruido: ['La clave del wifi la cambiaron en verano.', 'El despliegue de ayer no tocó ninguna clave.'] },
|
| 215 |
+
{ bueno: 'Nordvik factura ahora en coronas.', viejo: 'Nordvik facturaba en euros.',
|
| 216 |
+
q: '¿en qué moneda factura Nordvik?', clave: 'coronas', claveVieja: 'facturaba en euros',
|
| 217 |
+
ruido: ['Nordvik cambió de comercial el trimestre pasado.', 'A otros clientes se les factura en euros.'] },
|
| 218 |
+
{ bueno: 'El informe trimestral lo firma Beatriz.', viejo: 'El informe trimestral lo firmaba Andrés.',
|
| 219 |
+
q: '¿quién firma el informe trimestral?', clave: 'firma Beatriz', claveVieja: 'firmaba Andrés',
|
| 220 |
+
ruido: ['Andrés sigue en la empresa, pero en otro equipo.', 'Beatriz también revisa el informe mensual.'] },
|
| 221 |
+
{ bueno: 'El backup nocturno corre a las 02:30.', viejo: 'El backup nocturno corría a las 23:00.',
|
| 222 |
+
q: '¿a qué hora corre el backup nocturno?', clave: '02:30', claveVieja: '23:00',
|
| 223 |
+
ruido: ['El backup semanal es otra tarea distinta.', 'A las 23:00 lo que hay es el corte de logs.'] },
|
| 224 |
+
{ bueno: 'El almacén de Lugo pasó a Vigo.', viejo: 'El almacén principal estaba en Lugo.',
|
| 225 |
+
q: '¿a dónde se movió el almacén de Lugo?', clave: 'pasó a Vigo', claveVieja: 'estaba en Lugo',
|
| 226 |
+
ruido: ['En Lugo queda solo una oficina comercial.', 'El almacén de Vigo ya existía para otra cosa.'] },
|
| 227 |
+
];
|
| 228 |
+
const RUIDO = [
|
| 229 |
+
'Vale, seguimos mañana con eso.', 'Te paso el enlace en un momento.',
|
| 230 |
+
'Creo que el diseño nuevo se ve mejor.', 'Nos vemos en la reunión de las cinco.',
|
| 231 |
+
'Ese ticket lo cerró soporte la semana pasada.', 'Prefiero no tocar eso hoy.',
|
| 232 |
+
'Lo dejo apuntado para el lunes.', 'Sí, ya lo hemos hablado antes.',
|
| 233 |
+
];
|
| 234 |
+
|
| 235 |
+
// Historial LARGO: el dato bueno queda enterrado y fuera de la ventana reciente.
|
| 236 |
+
function historialCon(c, largo = 420) {
|
| 237 |
+
const h = [];
|
| 238 |
+
const posViejo = Math.floor(largo * 0.10);
|
| 239 |
+
const posBueno = Math.floor(largo * 0.55);
|
| 240 |
+
const posRuido = [Math.floor(largo * 0.30), Math.floor(largo * 0.75)];
|
| 241 |
+
for (let i = 0; i < largo; i++) {
|
| 242 |
+
let txt = RUIDO[i % RUIDO.length] + ' (' + i + ')';
|
| 243 |
+
if (i === posViejo) txt = c.viejo;
|
| 244 |
+
else if (i === posBueno) txt = c.bueno;
|
| 245 |
+
else if (i === posRuido[0]) txt = c.ruido[0];
|
| 246 |
+
else if (i === posRuido[1]) txt = c.ruido[1];
|
| 247 |
+
h.push({ role: i % 2 ? 'assistant' : 'user', content: txt, ts: Date.now() - (largo - i) * 60000 });
|
| 248 |
+
}
|
| 249 |
+
return h;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
async function benchContexto(onCase) {
|
| 253 |
+
const opts = { ...DEFAULTS, ...S.ctx };
|
| 254 |
+
const budget = 320; // muy apretado: obliga a ELEGIR
|
| 255 |
+
const filas = [];
|
| 256 |
+
for (let i = 0; i < HECHOS.length; i++) {
|
| 257 |
+
if (S.abort) break;
|
| 258 |
+
const c = HECHOS[i];
|
| 259 |
+
const hist = historialCon(c);
|
| 260 |
+
hist.push({ role: 'user', content: c.q, ts: Date.now() });
|
| 261 |
+
const { messages } = packHistoryACER(hist, budget, opts);
|
| 262 |
+
const texto = messages.map(m => m.content).join('\n');
|
| 263 |
+
const sobrevive = texto.includes(c.clave);
|
| 264 |
+
const arrastraViejo = texto.includes(c.claveVieja);
|
| 265 |
+
const usados = estimateTokens(texto);
|
| 266 |
+
filas.push({ pregunta: c.q, sobrevive, arrastraViejo, usados, deN: hist.length, quedan: messages.length });
|
| 267 |
+
onCase(filas[filas.length - 1], i, HECHOS.length);
|
| 268 |
+
await new Promise(r => setTimeout(r, 25)); // deja respirar a la UI
|
| 269 |
+
}
|
| 270 |
+
const rec = filas.filter(f => f.sobrevive).length;
|
| 271 |
+
const conf = filas.filter(f => f.arrastraViejo).length;
|
| 272 |
+
return {
|
| 273 |
+
filas,
|
| 274 |
+
resumen: {
|
| 275 |
+
'evidencia recuperada': `${rec}/${filas.length}`,
|
| 276 |
+
'recall': (filas.length ? (rec / filas.length * 100).toFixed(0) : 0) + '%',
|
| 277 |
+
'arrastra dato viejo': { v: `${conf}/${filas.length}`, bad: conf > 0 },
|
| 278 |
+
'tokens medios': Math.round(filas.reduce((a, f) => a + f.usados, 0) / (filas.length || 1)),
|
| 279 |
+
'presupuesto': budget,
|
| 280 |
+
'mensajes que pasan': Math.round(filas.reduce((a, f) => a + f.quedan, 0) / (filas.length || 1)),
|
| 281 |
+
},
|
| 282 |
+
};
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// ═══════════════ BANCO 2 · SWE-bench-style (ejecuta los tests) ═══════════════
|
| 286 |
+
async function opfsRoot() { return navigator.storage.getDirectory(); }
|
| 287 |
+
async function limpiarOPFS() {
|
| 288 |
+
const o = await opfsRoot();
|
| 289 |
+
for await (const e of o.values()) await o.removeEntry(e.name, { recursive: true }).catch(() => {});
|
| 290 |
+
}
|
| 291 |
+
async function sembrar(files) {
|
| 292 |
+
const o = await opfsRoot();
|
| 293 |
+
for (const [path, txt] of Object.entries(files)) {
|
| 294 |
+
const parts = path.split('/'); const name = parts.pop(); let d = o;
|
| 295 |
+
for (const x of parts) d = await d.getDirectoryHandle(x, { create: true });
|
| 296 |
+
const w = await (await d.getFileHandle(name, { create: true })).createWritable();
|
| 297 |
+
await w.write(txt); await w.close();
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
async function leer(path) {
|
| 301 |
+
const o = await opfsRoot();
|
| 302 |
+
const parts = path.split('/'); const name = parts.pop(); let d = o;
|
| 303 |
+
for (const x of parts) d = await d.getDirectoryHandle(x);
|
| 304 |
+
return (await (await d.getFileHandle(name)).getFile()).text();
|
| 305 |
+
}
|
| 306 |
+
// Ejecuta DE VERDAD el módulo arreglado y le pasa el test de la tarea.
|
| 307 |
+
async function pasaElTest(task) {
|
| 308 |
+
const src = await leer(task.target);
|
| 309 |
+
const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
|
| 310 |
+
try { return !!task.test(await import(/* @vite-ignore */ url)); }
|
| 311 |
+
catch { return false; }
|
| 312 |
+
finally { URL.revokeObjectURL(url); }
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
async function benchSWE(onCase) {
|
| 316 |
+
const code = await import('./tools/code.js');
|
| 317 |
+
await code.openProject(await opfsRoot());
|
| 318 |
+
const { Agent } = await import('./agent.js');
|
| 319 |
+
const filas = [];
|
| 320 |
+
for (let i = 0; i < TASKS.length; i++) {
|
| 321 |
+
if (S.abort) break;
|
| 322 |
+
const t = TASKS[i];
|
| 323 |
+
await limpiarOPFS(); await sembrar(t.files);
|
| 324 |
+
code.invalidateFileList();
|
| 325 |
+
const t0 = performance.now();
|
| 326 |
+
let pasos = 0, err = null;
|
| 327 |
+
try {
|
| 328 |
+
if (S.brain === 'rules') {
|
| 329 |
+
// Solver de guion: valida el ARNÉS (debe dar N/N) sin gastar GPU.
|
| 330 |
+
await sembrar({ [t.target]: t.solution });
|
| 331 |
+
} else {
|
| 332 |
+
const ag = new Agent(S.provider);
|
| 333 |
+
await ag.handle(`${t.task}\n\nEl fichero a arreglar es ${t.target}. Usa code.read y code.edit.`,
|
| 334 |
+
ev => { if (ev.type === 'tool') pasos++; });
|
| 335 |
+
}
|
| 336 |
+
} catch (e) { err = e.message; }
|
| 337 |
+
const ok = err ? false : await pasaElTest(t);
|
| 338 |
+
filas.push({ id: t.id, ok, pasos, secs: Math.round((performance.now() - t0) / 1000), err });
|
| 339 |
+
onCase(filas[filas.length - 1], i, TASKS.length);
|
| 340 |
+
}
|
| 341 |
+
const res = filas.filter(f => f.ok).length;
|
| 342 |
+
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) } };
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// ═══════════════ BANCO 3 · juegos (genera, ejecuta y puntúa) ═══════════════
|
| 346 |
+
const BRIEF_JUEGO = 'Un juego tipo Flappy Bird: un pájaro que cae por gravedad, sube al pulsar espacio o click, ' +
|
| 347 |
+
'tuberías que avanzan y hay que esquivar, colisiones, puntuación y pantalla de fin de partida.';
|
| 348 |
+
|
| 349 |
+
async function generarJuego() {
|
| 350 |
+
const rlm = await import('./rlm.js').catch(() => null);
|
| 351 |
+
if (!rlm) throw new Error('rlm.js no disponible en esta build');
|
| 352 |
+
let inferencias = 0;
|
| 353 |
+
const contando = { chat: (...a) => { inferencias++; return S.provider.chat(...a); } };
|
| 354 |
+
const rondas = S.algo === 'rlm-deep' ? Math.max(1, S.algoOpts.rounds) : 0;
|
| 355 |
+
const out = await rlm.deepCreate({ brief: BRIEF_JUEGO, provider: contando, rounds: rondas, onProgress: e => log(' ' + JSON.stringify(e)) });
|
| 356 |
+
return { html: out.html, inferencias, trace: out.trace };
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
// Puntúa el juego EJECUTÁNDOLO en la arena: ¿carga sin errores? ¿arranca?
|
| 360 |
+
// ¿se mueve? ¿aguanta jugando? Cada criterio suma.
|
| 361 |
+
async function puntuarJuego(html) {
|
| 362 |
+
const frame = $('#arena');
|
| 363 |
+
$('#arena-panel').hidden = false;
|
| 364 |
+
const errores = [];
|
| 365 |
+
const onErr = e => errores.push(String(e.message || e).slice(0, 120));
|
| 366 |
+
frame.srcdoc = html;
|
| 367 |
+
await new Promise(r => frame.onload = r);
|
| 368 |
+
const w = frame.contentWindow, d = frame.contentDocument;
|
| 369 |
+
w.addEventListener('error', onErr);
|
| 370 |
+
const hash = () => {
|
| 371 |
+
const c = d.querySelector('canvas'); if (!c) return -1;
|
| 372 |
+
try { const px = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
|
| 373 |
+
let h = 7; for (let i = 0; i < px.length; i += 997) h = (h * 31 + px[i]) % 999983; return h;
|
| 374 |
+
} catch { return -1; }
|
| 375 |
+
};
|
| 376 |
+
const esperar = ms => new Promise(r => setTimeout(r, ms));
|
| 377 |
+
const canvas = !!d.querySelector('canvas');
|
| 378 |
+
await esperar(600);
|
| 379 |
+
const h0 = hash();
|
| 380 |
+
// arrancar: pulsar el botón si lo hay, y quitarle el foco (si no, ESPACIO
|
| 381 |
+
// re-pulsa el botón en vez de saltar — nos pasó midiéndolo a mano)
|
| 382 |
+
const btn = d.querySelector('button');
|
| 383 |
+
if (btn) { btn.click(); btn.blur?.(); }
|
| 384 |
+
await esperar(400);
|
| 385 |
+
const arranca = hash() !== h0;
|
| 386 |
+
// jugar: aletear con click en el canvas, que es el manejador que sí registran
|
| 387 |
+
const c = d.querySelector('canvas');
|
| 388 |
+
let vivoMs = 0, puntos = 0;
|
| 389 |
+
const t0 = performance.now();
|
| 390 |
+
for (let i = 0; i < 20; i++) {
|
| 391 |
+
c?.dispatchEvent(new w.MouseEvent('click', { bubbles: true }));
|
| 392 |
+
d.dispatchEvent(new w.KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }));
|
| 393 |
+
await esperar(300);
|
| 394 |
+
const run = (() => { try { return w.gameRunning; } catch { return null; } })();
|
| 395 |
+
puntos = Math.max(puntos, (() => { try { return w.score || 0; } catch { return 0; } })());
|
| 396 |
+
if (run === false) break;
|
| 397 |
+
vivoMs = performance.now() - t0;
|
| 398 |
+
}
|
| 399 |
+
const criterios = {
|
| 400 |
+
'carga sin errores': errores.length === 0,
|
| 401 |
+
'tiene canvas': canvas,
|
| 402 |
+
'arranca': arranca,
|
| 403 |
+
'aguanta ≥3 s jugando': vivoMs >= 3000,
|
| 404 |
+
'llega a puntuar': puntos > 0,
|
| 405 |
+
};
|
| 406 |
+
return { criterios, errores, vivoSegs: +(vivoMs / 1000).toFixed(1), puntos, bytes: html.length };
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
async function benchJuegos(onCase) {
|
| 410 |
+
if (!S.provider || S.brain === 'rules') throw new Error('este banco necesita un modelo de verdad (Reglas no genera juegos)');
|
| 411 |
+
const filas = [];
|
| 412 |
+
log('generando el juego con «' + S.algo + '»… esto tarda', 'w');
|
| 413 |
+
const g = await generarJuego();
|
| 414 |
+
log(`generado: ${g.html.length} bytes en ${g.inferencias} inferencias`, 'a');
|
| 415 |
+
const p = await puntuarJuego(g.html);
|
| 416 |
+
Object.entries(p.criterios).forEach(([k, v], i) => {
|
| 417 |
+
filas.push({ criterio: k, ok: v });
|
| 418 |
+
onCase(filas[filas.length - 1], i, Object.keys(p.criterios).length);
|
| 419 |
+
});
|
| 420 |
+
S.lastGame = { html: g.html, trace: g.trace };
|
| 421 |
+
const pasa = filas.filter(f => f.ok).length;
|
| 422 |
+
return {
|
| 423 |
+
filas,
|
| 424 |
+
resumen: {
|
| 425 |
+
'criterios superados': `${pasa}/${filas.length}`, inferencias: g.inferencias,
|
| 426 |
+
'bytes': p.bytes, 'segundos vivo': p.vivoSegs, 'puntuación': p.puntos,
|
| 427 |
+
},
|
| 428 |
+
};
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
// ─────────────────────────── orquestación ───────────────────────────
|
| 432 |
+
async function run() {
|
| 433 |
+
if (S.running) return;
|
| 434 |
+
S.running = true; S.abort = false;
|
| 435 |
+
$('#btn-run').disabled = true; $('#btn-stop').disabled = false;
|
| 436 |
+
setLed('#led-run', 'busy', 'ejecutando ' + S.bench);
|
| 437 |
+
tiles({}); prog(0, 1, 'preparando…');
|
| 438 |
+
log('▶ benchmark «' + S.bench + '» · algoritmo «' + S.algo + '» · cerebro «' + S.brain + '»', 'a');
|
| 439 |
+
const t0 = Date.now();
|
| 440 |
+
let out = null, error = null;
|
| 441 |
+
try {
|
| 442 |
+
if (S.bench === 'contexto') {
|
| 443 |
+
table(['pregunta', 'dato bueno', 'dato viejo', 'tokens', 'mensajes'], []);
|
| 444 |
+
out = await benchContexto((f, i, n) => {
|
| 445 |
+
addRow([f.pregunta.slice(0, 38),
|
| 446 |
+
f.sobrevive ? { t: 'SOBREVIVE', cls: 'ok' } : { t: 'PERDIDO', cls: 'no' },
|
| 447 |
+
f.arrastraViejo ? { t: 'lo arrastra', cls: 'no' } : { t: 'descartado', cls: 'ok' },
|
| 448 |
+
f.usados, `${f.quedan}/${f.deN}`]);
|
| 449 |
+
prog(i + 1, n, `caso ${i + 1} de ${n}`);
|
| 450 |
+
});
|
| 451 |
+
} else if (S.bench === 'swe') {
|
| 452 |
+
table(['tarea', 'resultado', 'pasos', 'seg'], []);
|
| 453 |
+
out = await benchSWE((f, i, n) => {
|
| 454 |
+
addRow([f.id, f.ok ? { t: 'RESUELTA', cls: 'ok' } : { t: f.err ? 'ERROR' : 'FALLA', cls: 'no' }, f.pasos, f.secs]);
|
| 455 |
+
prog(i + 1, n, `tarea ${i + 1} de ${n}`);
|
| 456 |
+
});
|
| 457 |
+
} else {
|
| 458 |
+
table(['criterio', 'resultado'], []);
|
| 459 |
+
out = await benchJuegos((f, i, n) => {
|
| 460 |
+
addRow([f.criterio, f.ok ? { t: 'SÍ', cls: 'ok' } : { t: 'NO', cls: 'no' }]);
|
| 461 |
+
prog(i + 1, n, `criterio ${i + 1} de ${n}`);
|
| 462 |
+
});
|
| 463 |
+
}
|
| 464 |
+
} catch (e) { error = e.message; log('fallo: ' + e.message, 'e'); }
|
| 465 |
+
|
| 466 |
+
if (out) {
|
| 467 |
+
tiles(out.resumen);
|
| 468 |
+
S.run = {
|
| 469 |
+
bench: S.bench, algo: S.algo, algoOpts: { ...S.algoOpts }, brain: S.brain,
|
| 470 |
+
ctx: { ...DEFAULTS, ...S.ctx }, resumen: out.resumen, casos: out.filas,
|
| 471 |
+
duracionSegs: Math.round((Date.now() - t0) / 1000), cuando: new Date().toISOString(),
|
| 472 |
+
};
|
| 473 |
+
log('✔ terminado: ' + JSON.stringify(out.resumen), 'a');
|
| 474 |
+
}
|
| 475 |
+
setLed('#led-run', error ? 'err' : 'on', error ? 'con fallo' : 'listo');
|
| 476 |
+
prog(1, 1, error ? 'abortado' : 'completado');
|
| 477 |
+
S.running = false; $('#btn-run').disabled = false; $('#btn-stop').disabled = true;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
// ─────────────────── historial y ranking, en local ───────────────────
|
| 481 |
+
// Todo se queda en esta máquina: ni se envía ni se pide permiso a nadie. La
|
| 482 |
+
// gracia del ranking es comparar CONFIGURACIONES entre sí — qué combinación de
|
| 483 |
+
// algoritmo y perillas gana en cada banco — con su traza al lado para revisarla.
|
| 484 |
+
const STORE = 'elffusscode.lab.runs';
|
| 485 |
+
const MAX_GUARDADAS = 60;
|
| 486 |
+
|
| 487 |
+
// Puntuación comparable (0-100) según el banco, para poder ordenar.
|
| 488 |
+
function puntuar(r) {
|
| 489 |
+
const n = (s) => Number(String(s).split('/')[0]) || 0;
|
| 490 |
+
const d = (s) => Number(String(s).split('/')[1]) || 1;
|
| 491 |
+
if (r.bench === 'contexto') return Math.round(n(r.resumen['evidencia recuperada']) / d(r.resumen['evidencia recuperada']) * 100);
|
| 492 |
+
if (r.bench === 'swe') return Math.round(n(r.resumen.resolved) / d(r.resumen.resolved) * 100);
|
| 493 |
+
return Math.round(n(r.resumen['criterios superados']) / d(r.resumen['criterios superados']) * 100);
|
| 494 |
+
}
|
| 495 |
+
// Huella corta de la configuración: lo que de verdad distingue una corrida.
|
| 496 |
+
function huella(r) {
|
| 497 |
+
const on = Object.entries(r.ctx).filter(([k, v]) => v === true).map(([k]) => k);
|
| 498 |
+
const extra = r.algo === 'rlm-deep' ? `·${r.algoOpts.rounds}r` : '';
|
| 499 |
+
return `${r.algo}${extra} · ${r.brain} · ${on.length} perillas`;
|
| 500 |
+
}
|
| 501 |
+
const leerRuns = () => { try { return JSON.parse(localStorage.getItem(STORE)) || []; } catch { return []; } };
|
| 502 |
+
function guardarCorrida() {
|
| 503 |
+
if (!S.run) return log('todavía no hay ninguna corrida que guardar', 'w');
|
| 504 |
+
const runs = leerRuns();
|
| 505 |
+
runs.push({ ...S.run, score: puntuar(S.run), id: (S.run.cuando || '') + '·' + Math.round(performance.now()) });
|
| 506 |
+
localStorage.setItem(STORE, JSON.stringify(runs.slice(-MAX_GUARDADAS)));
|
| 507 |
+
log('guardada en el historial local (' + runs.length + ' corridas)', 'a');
|
| 508 |
+
pintarRanking();
|
| 509 |
+
}
|
| 510 |
+
function pintarRanking() {
|
| 511 |
+
const host = $('#rank-body'); host.innerHTML = '';
|
| 512 |
+
const runs = leerRuns().filter(r => r.bench === S.bench).sort((a, b) => b.score - a.score);
|
| 513 |
+
$('#rank-tag').textContent = runs.length ? runs.length + ' corridas · ' + S.bench : 'sin corridas de ' + S.bench;
|
| 514 |
+
if (!runs.length) { host.innerHTML = '<tr><td colspan="5" class="pend">Aún no has guardado ninguna corrida de este banco.</td></tr>'; return; }
|
| 515 |
+
runs.forEach((r, i) => {
|
| 516 |
+
const tr = el('tr');
|
| 517 |
+
const medalla = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : String(i + 1);
|
| 518 |
+
[[medalla], [{ t: r.score + '%', cls: r.score >= 80 ? 'ok' : r.score >= 40 ? '' : 'no' }],
|
| 519 |
+
[huella(r)], [new Date(r.cuando).toLocaleString()]].forEach(([c]) => {
|
| 520 |
+
const td = el('td');
|
| 521 |
+
if (c && typeof c === 'object') { td.className = c.cls || ''; td.textContent = c.t; } else td.textContent = c;
|
| 522 |
+
tr.appendChild(td);
|
| 523 |
+
});
|
| 524 |
+
const acc = el('td');
|
| 525 |
+
const ver = el('button', 'btn ghost', 'traza');
|
| 526 |
+
ver.style.padding = '2px 8px'; ver.style.fontSize = '11px';
|
| 527 |
+
ver.onclick = () => descargarTraza(r);
|
| 528 |
+
acc.appendChild(ver); tr.appendChild(acc);
|
| 529 |
+
host.appendChild(tr);
|
| 530 |
+
});
|
| 531 |
+
}
|
| 532 |
+
// Exportar es un fichero en tu disco, no un envío: sirve para revisar la traza
|
| 533 |
+
// caso a caso o comparar dos corridas fuera del panel.
|
| 534 |
+
function descargarTraza(r) {
|
| 535 |
+
const a = el('a');
|
| 536 |
+
a.href = URL.createObjectURL(new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' }));
|
| 537 |
+
a.download = `elffuss-lab-${r.bench}-${r.score}.json`;
|
| 538 |
+
a.click();
|
| 539 |
+
log('traza exportada a tu disco', 'a');
|
| 540 |
+
}
|
| 541 |
+
function borrarHistorial() {
|
| 542 |
+
if (!confirm('¿Borrar TODAS las corridas guardadas?')) return;
|
| 543 |
+
localStorage.removeItem(STORE); pintarRanking(); log('historial local borrado', 'w');
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
// ─────────────────────────── arranque ───────────────────────────
|
| 547 |
+
radio($('#brains'), BRAINS, S.brain, id => { S.brain = id; $('#brain-tag').textContent = id; });
|
| 548 |
+
radio($('#algos'), ALGOS, S.algo, id => { S.algo = id; $('#algo-tag').textContent = id; algoKnobs(); });
|
| 549 |
+
radio($('#benches'), BENCHES, S.bench, id => { S.bench = id; $('#bench-tag').textContent = id; });
|
| 550 |
+
$('#brain-tag').textContent = S.brain;
|
| 551 |
+
$('#bench-tag').textContent = S.bench;
|
| 552 |
+
algoKnobs(); ctxUI();
|
| 553 |
+
|
| 554 |
+
$('#btn-load').onclick = loadBrain;
|
| 555 |
+
$('#btn-run').onclick = run;
|
| 556 |
+
$('#btn-stop').onclick = () => { S.abort = true; log('parando tras el caso en curso…', 'w'); };
|
| 557 |
+
$('#ctx-all').onclick = () => { CTX_KNOBS.flatMap(([, k]) => k).forEach(([k, t]) => { if (t === 'bool') S.ctx[k] = true; }); ctxUI(); };
|
| 558 |
+
$('#ctx-none').onclick = () => { CTX_KNOBS.flatMap(([, k]) => k).forEach(([k, t]) => { if (t === 'bool') S.ctx[k] = false; }); ctxUI(); };
|
| 559 |
+
$('#ctx-def').onclick = () => { S.ctx = {}; ctxUI(); };
|
| 560 |
+
$('#btn-launch').onclick = () => {
|
| 561 |
+
localStorage.setItem('elffusscode.model', S.brain);
|
| 562 |
+
localStorage.setItem('elffusscode.ctxopts', JSON.stringify(S.ctx));
|
| 563 |
+
localStorage.setItem('elffusscode.algo', JSON.stringify({ algo: S.algo, opts: S.algoOpts }));
|
| 564 |
+
log('configuración guardada; abriendo el IDE', 'a');
|
| 565 |
+
window.open('index.html', '_blank');
|
| 566 |
+
};
|
| 567 |
+
|
| 568 |
+
$('#btn-save').onclick = guardarCorrida;
|
| 569 |
+
$('#btn-clear').onclick = borrarHistorial;
|
| 570 |
+
pintarRanking();
|
| 571 |
+
|
| 572 |
+
log('Elffuss Lab listo. Elige algoritmo, enciende o apaga la compresión y lanza un banco.', 'a');
|
| 573 |
+
log('El banco de contexto no necesita GPU: mide el compresor y responde al instante.');
|
| 574 |
+
log('Los resultados se quedan en esta máquina: historial y ranking local, sin enviar nada.');
|
|
@@ -20,6 +20,24 @@ import { t, applyI18n } from './i18n.js';
|
|
| 20 |
import * as bridge from './bridge.js';
|
| 21 |
import * as conv from './conversations.js';
|
| 22 |
import { TASK_PREFIX } from './goal.js';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
import * as telemetry from './telemetry.js';
|
| 24 |
|
| 25 |
const $ = id => document.getElementById(id);
|
|
@@ -100,7 +118,7 @@ const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<':
|
|
| 100 |
// llegan los eventos 'plan'/'plan_update'/'plan_complete' — así se ve la
|
| 101 |
// lista evolucionar en vivo en vez de reconstruirse entera cada vez.
|
| 102 |
const PLAN_ICO = { pending: '⏳', 'in-progress': '🔄', done: '✅', failed: '❌', skipped: '⏭️' };
|
| 103 |
-
const PLAN_STATUS_LABEL = { planning:
|
| 104 |
function renderPlanCard(plan) {
|
| 105 |
const domId = 'plan-' + plan.id;
|
| 106 |
let div = document.getElementById(domId);
|
|
@@ -338,7 +356,7 @@ function modelOptions() {
|
|
| 338 |
if (realGPU) opts.push({ id: 'litert:gemma-e4b', label: 'Gemma-4 E4B · LiteRT-LM (~4 GB) ★' });
|
| 339 |
if (realGPU) opts.push({ id: 'litert:gemma-e2b', label: 'Gemma-4 E2B · LiteRT-LM (~2 GB)' });
|
| 340 |
opts.push({ id: 'onnx', label: 'Elffuss LM (healed · 850 MB) — ligero' });
|
| 341 |
-
opts.push({ id: 'rules', label:
|
| 342 |
return [...opts, ...settings.enabledExternals()];
|
| 343 |
}
|
| 344 |
|
|
@@ -382,7 +400,7 @@ async function changeModel(id) {
|
|
| 382 |
if (loadingId === id || activeModel === id) return true; // un solo modelo, una sola carga
|
| 383 |
loadingId = id;
|
| 384 |
$('model-dot').className = 'dot loading';
|
| 385 |
-
showModelProgress(
|
| 386 |
try {
|
| 387 |
const mod = await resolveProvider(id);
|
| 388 |
await mod.load(p => {
|
|
@@ -451,7 +469,7 @@ function settingsShell(title) {
|
|
| 451 |
const box = $('settings-panel');
|
| 452 |
box.hidden = false;
|
| 453 |
box.replaceChildren();
|
| 454 |
-
const close = el('button', 'panel-close'); close.innerHTML = UI.close; close.title =
|
| 455 |
close.onclick = () => { box.hidden = true; };
|
| 456 |
box.append(close, el('h3', 'panel-title', title));
|
| 457 |
return box;
|
|
@@ -502,15 +520,15 @@ function fireworks() {
|
|
| 502 |
}
|
| 503 |
|
| 504 |
function renderSettings() {
|
| 505 |
-
const box = settingsShell(
|
| 506 |
|
| 507 |
// --- Cerebro (modelo) ---
|
| 508 |
-
box.append(el('div', 'sk-h',
|
| 509 |
const LOCAL = [
|
| 510 |
-
{ id: 'litert:gemma-e4b', name: 'Gemma-4 E4B ★', sub:
|
| 511 |
-
{ id: 'litert:gemma-e2b', name: 'Gemma-4 E2B', sub:
|
| 512 |
-
{ id: 'onnx', name: 'Elffuss LM (healed)', sub:
|
| 513 |
-
{ id: 'rules', name:
|
| 514 |
];
|
| 515 |
const grid = el('div', 'model-grid');
|
| 516 |
for (const m of LOCAL) {
|
|
@@ -523,6 +541,53 @@ function renderSettings() {
|
|
| 523 |
}
|
| 524 |
box.appendChild(grid);
|
| 525 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
// --- Almacenamiento del modelo (caché persistente) ---
|
| 527 |
box.append(el('div', 'sk-h', t('setStoreTitle')));
|
| 528 |
const storeCard = el('div', 'prov-card');
|
|
@@ -545,7 +610,7 @@ function renderSettings() {
|
|
| 545 |
paintStorage();
|
| 546 |
|
| 547 |
// --- Bridge local (ejecución REAL en tu máquina: node, npm, python…) ---
|
| 548 |
-
box.append(el('div', 'sk-h',
|
| 549 |
const brCard = el('div', 'prov-card bridge-card');
|
| 550 |
const guessOS = () => {
|
| 551 |
const ua = navigator.userAgent;
|
|
@@ -558,14 +623,14 @@ function renderSettings() {
|
|
| 558 |
const primary = guessOS();
|
| 559 |
brCard.innerHTML =
|
| 560 |
`<div class="prov-head"><span id="br-dot" class="dot off"></span><b>Bridge local</b><span id="br-status" class="muted" style="margin-left:auto;font-size:.72rem">desconectado</span></div>` +
|
| 561 |
-
`<p class="muted" style="font-size:.72rem;margin:6px 0">
|
| 562 |
-
`<a class="prov-use" style="text-decoration:none;display:inline-block" href="bridge-dl/${primary}" download>
|
| 563 |
-
`<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">
|
| 564 |
Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
|
| 565 |
`</details>` +
|
| 566 |
`<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">${t('setBrToken')}</label><input id="br-token" placeholder="${t('brTokenPh')}"></div>` +
|
| 567 |
-
`<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">
|
| 568 |
-
`<button id="br-connect" class="prov-use" style="margin-top:8px">
|
| 569 |
box.appendChild(brCard);
|
| 570 |
brCard.querySelector('#br-folder').value = bridge.getFolder();
|
| 571 |
brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
|
|
@@ -575,7 +640,7 @@ function renderSettings() {
|
|
| 575 |
if (!document.body.contains(brCard)) { bridge.onStatusChange(() => {}); return; }
|
| 576 |
const on = bridge.isConnected();
|
| 577 |
brCard.querySelector('#br-dot').className = 'dot ' + (on ? 'on' : 'off');
|
| 578 |
-
brCard.querySelector('#br-status').textContent = on ?
|
| 579 |
if (on) brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
|
| 580 |
};
|
| 581 |
paintBridge();
|
|
@@ -584,15 +649,15 @@ function renderSettings() {
|
|
| 584 |
const btn = brCard.querySelector('#br-connect');
|
| 585 |
bridge.setFolder(brCard.querySelector('#br-folder').value);
|
| 586 |
const token = brCard.querySelector('#br-token').value.trim();
|
| 587 |
-
if (!token) return showBridgeMsg(brCard,
|
| 588 |
-
btn.disabled = true; btn.textContent =
|
| 589 |
-
try { await bridge.connect(token); paintBridge(); showBridgeMsg(brCard,
|
| 590 |
catch (e) { showBridgeMsg(brCard, '⚠️ ' + e.message, true); }
|
| 591 |
-
finally { btn.disabled = false; btn.textContent =
|
| 592 |
};
|
| 593 |
|
| 594 |
// --- Permisos de ejecución (mismo interruptor que </> Auto de la barra) ---
|
| 595 |
-
box.append(el('div', 'sk-h',
|
| 596 |
const permCard = el('div', 'prov-card');
|
| 597 |
permCard.innerHTML =
|
| 598 |
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
|
@@ -610,7 +675,7 @@ function renderSettings() {
|
|
| 610 |
};
|
| 611 |
|
| 612 |
// --- 🎯 Modo Objetivo (planificador + ejecutor, mismo patrón que Auto) ---
|
| 613 |
-
box.append(el('div', 'sk-h',
|
| 614 |
const goalCard = el('div', 'prov-card');
|
| 615 |
goalCard.innerHTML =
|
| 616 |
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
|
@@ -628,7 +693,7 @@ function renderSettings() {
|
|
| 628 |
};
|
| 629 |
|
| 630 |
// --- 📨 Errores y feedback (opt-in — apagado no sale NADA de tu máquina) ---
|
| 631 |
-
box.append(el('div', 'sk-h',
|
| 632 |
const telCard = el('div', 'prov-card');
|
| 633 |
telCard.innerHTML =
|
| 634 |
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
|
@@ -648,24 +713,24 @@ function renderSettings() {
|
|
| 648 |
const ta = telCard.querySelector('#tel-feedback');
|
| 649 |
const msg = telCard.querySelector('#tel-msg');
|
| 650 |
const text = ta.value.trim();
|
| 651 |
-
if (!text) { msg.textContent =
|
| 652 |
const wasEnabled = telemetry.isEnabled();
|
| 653 |
if (!wasEnabled) telemetry.setEnabled(true); // el envío manual es explícito, se permite aunque el automático esté apagado
|
| 654 |
await telemetry.sendFeedback(text);
|
| 655 |
if (!wasEnabled) telemetry.setEnabled(false); // no activa el automático de fondo si no lo pidió
|
| 656 |
ta.value = '';
|
| 657 |
-
msg.textContent =
|
| 658 |
setTimeout(() => { msg.textContent = ''; }, 4000);
|
| 659 |
};
|
| 660 |
|
| 661 |
// --- Proveedores externos (API keys) ---
|
| 662 |
-
box.append(el('div', 'sk-h',
|
| 663 |
for (const [id, c] of Object.entries(settings.configs())) {
|
| 664 |
const card = el('div', 'prov-card' + (c.enabled ? ' on' : ''));
|
| 665 |
const head = el('div', 'prov-head');
|
| 666 |
const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = c.enabled;
|
| 667 |
const title = el('b', null, c.label);
|
| 668 |
-
const use = el('button', 'prov-use',
|
| 669 |
use.hidden = !c.enabled || activeModel === 'ext:' + id;
|
| 670 |
use.onclick = () => { changeModel('ext:' + id); renderSettings(); };
|
| 671 |
head.append(toggle, title, use);
|
|
@@ -687,7 +752,7 @@ function renderSettings() {
|
|
| 687 |
// --- Skills ---
|
| 688 |
box.append(el('div', 'sk-h', 'Skills'));
|
| 689 |
const skBtn = el('button', 'primary wide', '🧩 Gestionar skills de Claude Code');
|
| 690 |
-
skBtn.textContent =
|
| 691 |
skBtn.onclick = openSkillsPanel;
|
| 692 |
box.appendChild(skBtn);
|
| 693 |
}
|
|
@@ -1069,7 +1134,7 @@ async function toggleTerminal(force) {
|
|
| 1069 |
$('act-term').classList.toggle('on', show);
|
| 1070 |
if (show) {
|
| 1071 |
const cap = shell.capabilities();
|
| 1072 |
-
$('term-caps').textContent = cap.bridge ?
|
| 1073 |
await terminal.mount($('terminal-host'));
|
| 1074 |
terminal.refit();
|
| 1075 |
}
|
|
@@ -1240,9 +1305,50 @@ $('btn-goal').addEventListener('click', () => {
|
|
| 1240 |
});
|
| 1241 |
paintGoal();
|
| 1242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1243 |
// resolver @rutas del mensaje: se leen y se adjuntan como contexto
|
| 1244 |
const _send = send;
|
| 1245 |
send = async (text) => {
|
|
|
|
| 1246 |
const refs = [...text.matchAll(/@([\w./-]+\.\w+)/g)].map(m => m[1]);
|
| 1247 |
if (refs.length) {
|
| 1248 |
let ctx = '';
|
|
@@ -1279,7 +1385,7 @@ async function openSkillsPanel() {
|
|
| 1279 |
row.className = 'sk-row';
|
| 1280 |
row.innerHTML = `<b>${s.name}</b><span class="muted">${s.repo || 'local'}</span>`;
|
| 1281 |
const rm = document.createElement('button');
|
| 1282 |
-
rm.className = 'ghost'; rm.textContent =
|
| 1283 |
rm.onclick = async () => { await skills.remove(s.name); openSkillsPanel(); };
|
| 1284 |
row.appendChild(rm);
|
| 1285 |
instBox.appendChild(row);
|
|
@@ -1296,7 +1402,7 @@ async function openSkillsPanel() {
|
|
| 1296 |
row.className = 'sk-row';
|
| 1297 |
row.innerHTML = `<b>${s.label}</b><a href="https://github.com/${s.repo}" target="_blank" rel="noopener">${s.repo} ↗</a>`;
|
| 1298 |
const browse = document.createElement('button');
|
| 1299 |
-
browse.className = 'primary'; browse.textContent =
|
| 1300 |
browse.onclick = () => browseRepo(s.repo, box);
|
| 1301 |
row.appendChild(browse);
|
| 1302 |
if (!s.official) {
|
|
@@ -1310,9 +1416,9 @@ async function openSkillsPanel() {
|
|
| 1310 |
const addRow = document.createElement('div');
|
| 1311 |
addRow.className = 'sk-row';
|
| 1312 |
const inp = document.createElement('input');
|
| 1313 |
-
inp.placeholder =
|
| 1314 |
const add = document.createElement('button');
|
| 1315 |
-
add.className = 'primary'; add.textContent =
|
| 1316 |
add.onclick = async () => {
|
| 1317 |
try { await skills.addSource(inp.value); openSkillsPanel(); }
|
| 1318 |
catch (e) { alert(e.message); }
|
|
|
|
| 20 |
import * as bridge from './bridge.js';
|
| 21 |
import * as conv from './conversations.js';
|
| 22 |
import { TASK_PREFIX } from './goal.js';
|
| 23 |
+
import { hardWork, gatherFolder } from './rlm.js';
|
| 24 |
+
import * as workspace from './workspace.js';
|
| 25 |
+
|
| 26 |
+
// Espacio de trabajo (core): qué de Code puede guardarse en tu disco.
|
| 27 |
+
workspace.init({
|
| 28 |
+
app: 'code', ns: 'elffusscode', db,
|
| 29 |
+
stores: [
|
| 30 |
+
{ id: 'conversations', label: 'Conversaciones', icon: '💬', kind: 'idb-key', store: 'kv', key: 'conversations',
|
| 31 |
+
files: { ext: '.json', name: c => c.title || c.id } },
|
| 32 |
+
{ id: 'skills', label: 'Skills', icon: '🧩', kind: 'idb-key', store: 'kv', key: 'skills',
|
| 33 |
+
files: { ext: '.md', name: s => s.name, body: s => `---\nname: ${s.name}\ndescription: ${s.description || ''}\n---\n\n${s.content || ''}` } },
|
| 34 |
+
{ id: 'prefs', label: 'Preferencias', icon: '⚙️', kind: 'ls',
|
| 35 |
+
keys: ['elffusscode.model', 'elffusscode.autoedit', 'elffusscode.goalmode', 'elffuss.acer', 'elffuss.resumen', 'elffuss.semantic'] },
|
| 36 |
+
],
|
| 37 |
+
});
|
| 38 |
+
workspace.restore().catch(() => {});
|
| 39 |
+
if (workspace.autosaveEnabled()) workspace.autosave({ enabled: true });
|
| 40 |
+
|
| 41 |
import * as telemetry from './telemetry.js';
|
| 42 |
|
| 43 |
const $ = id => document.getElementById(id);
|
|
|
|
| 118 |
// llegan los eventos 'plan'/'plan_update'/'plan_complete' — así se ve la
|
| 119 |
// lista evolucionar en vivo en vez de reconstruirse entera cada vez.
|
| 120 |
const PLAN_ICO = { pending: '⏳', 'in-progress': '🔄', done: '✅', failed: '❌', skipped: '⏭️' };
|
| 121 |
+
const PLAN_STATUS_LABEL = { planning: t("planPlanning"), running: t("planRunning"), done: t("planDone"), failed: t("planFailed") };
|
| 122 |
function renderPlanCard(plan) {
|
| 123 |
const domId = 'plan-' + plan.id;
|
| 124 |
let div = document.getElementById(domId);
|
|
|
|
| 356 |
if (realGPU) opts.push({ id: 'litert:gemma-e4b', label: 'Gemma-4 E4B · LiteRT-LM (~4 GB) ★' });
|
| 357 |
if (realGPU) opts.push({ id: 'litert:gemma-e2b', label: 'Gemma-4 E2B · LiteRT-LM (~2 GB)' });
|
| 358 |
opts.push({ id: 'onnx', label: 'Elffuss LM (healed · 850 MB) — ligero' });
|
| 359 |
+
opts.push({ id: 'rules', label: t('setModelRulesName') });
|
| 360 |
return [...opts, ...settings.enabledExternals()];
|
| 361 |
}
|
| 362 |
|
|
|
|
| 400 |
if (loadingId === id || activeModel === id) return true; // un solo modelo, una sola carga
|
| 401 |
loadingId = id;
|
| 402 |
$('model-dot').className = 'dot loading';
|
| 403 |
+
showModelProgress(t("stModelLoadingAI"), 4);
|
| 404 |
try {
|
| 405 |
const mod = await resolveProvider(id);
|
| 406 |
await mod.load(p => {
|
|
|
|
| 469 |
const box = $('settings-panel');
|
| 470 |
box.hidden = false;
|
| 471 |
box.replaceChildren();
|
| 472 |
+
const close = el('button', 'panel-close'); close.innerHTML = UI.close; close.title = t("close");
|
| 473 |
close.onclick = () => { box.hidden = true; };
|
| 474 |
box.append(close, el('h3', 'panel-title', title));
|
| 475 |
return box;
|
|
|
|
| 520 |
}
|
| 521 |
|
| 522 |
function renderSettings() {
|
| 523 |
+
const box = settingsShell(t("setTitle"));
|
| 524 |
|
| 525 |
// --- Cerebro (modelo) ---
|
| 526 |
+
box.append(el('div', 'sk-h', t("setBrainTitle")));
|
| 527 |
const LOCAL = [
|
| 528 |
+
{ id: 'litert:gemma-e4b', name: 'Gemma-4 E4B ★', sub: t("setModelE4bSub"), need: 'gpu' },
|
| 529 |
+
{ id: 'litert:gemma-e2b', name: 'Gemma-4 E2B', sub: t("setModelE2bSub"), need: 'gpu' },
|
| 530 |
+
{ id: 'onnx', name: 'Elffuss LM (healed)', sub: t("setModelOnnxSub") },
|
| 531 |
+
{ id: 'rules', name: t('setModelRulesName'), sub: t("setModelRulesSub") },
|
| 532 |
];
|
| 533 |
const grid = el('div', 'model-grid');
|
| 534 |
for (const m of LOCAL) {
|
|
|
|
| 541 |
}
|
| 542 |
box.appendChild(grid);
|
| 543 |
|
| 544 |
+
// --- Espacio de trabajo: guardar conversaciones/skills en disco ---
|
| 545 |
+
box.append(el('div', 'sk-h', '💾 ' + t('wsTitle')));
|
| 546 |
+
const wsCard = el('div', 'prov-card');
|
| 547 |
+
const wsState = el('div', 'muted'); wsState.style.fontSize = '.78rem';
|
| 548 |
+
const wsRow = el('div', 'field'); wsRow.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;margin-top:8px';
|
| 549 |
+
const wsInv = el('div'); wsInv.style.cssText = 'margin-top:10px;font-size:.78rem';
|
| 550 |
+
wsCard.append(el('span', 'muted', t('wsDesc')), wsState, wsRow, wsInv);
|
| 551 |
+
box.appendChild(wsCard);
|
| 552 |
+
const wsKb = n => n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n > 1024 ? Math.round(n / 1024) + ' KB' : n + ' B';
|
| 553 |
+
async function paintWs() {
|
| 554 |
+
const st = workspace.status();
|
| 555 |
+
wsRow.replaceChildren();
|
| 556 |
+
wsState.textContent = !st.supported.pick ? t('wsNoSupport')
|
| 557 |
+
: !st.folder ? t('wsNoFolder')
|
| 558 |
+
: st.ready ? t('wsFolder', { name: st.folder }) : t('wsNeedsPerm', { name: st.folder });
|
| 559 |
+
const mk = (label, fn) => { const b = el('button', 'prov-use', label); b.onclick = fn; wsRow.appendChild(b); };
|
| 560 |
+
if (st.supported.pick) {
|
| 561 |
+
if (!st.folder) {
|
| 562 |
+
mk(t('wsPick'), async () => { try { await workspace.pick(); await paintWs(); } catch (e) { wsState.textContent = '⚠️ ' + e.message; } });
|
| 563 |
+
// Code ya tiene una carpeta abierta: reutilizarla es un clic
|
| 564 |
+
if (codeTools.handle()) mk(t('wsUseProject'), async () => { await workspace.adopt(codeTools.handle()); await paintWs(); });
|
| 565 |
+
} else if (!st.ready) mk(t('wsRegrant'), async () => { try { await workspace.regrant(); await paintWs(); } catch (e) { wsState.textContent = '⚠️ ' + e.message; } });
|
| 566 |
+
else {
|
| 567 |
+
mk(t('wsSaveNow'), async () => { try { const r = await workspace.save(); wsState.textContent = r.ok ? t('wsSaved', { n: r.wrote.length }) : '⚠️ ' + (r.errors[0]?.error || ''); } catch (e) { wsState.textContent = '⚠️ ' + e.message; await paintWs(); } });
|
| 568 |
+
const lbl = el('label', null); lbl.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:.78rem';
|
| 569 |
+
const chk = el('input'); chk.type = 'checkbox'; chk.checked = workspace.autosaveEnabled();
|
| 570 |
+
chk.onchange = () => workspace.autosave({ enabled: chk.checked });
|
| 571 |
+
lbl.append(chk, el('span', 'muted', t('wsAuto'))); wsRow.appendChild(lbl);
|
| 572 |
+
mk(t('wsForget'), async () => { await workspace.forget(); await paintWs(); });
|
| 573 |
+
}
|
| 574 |
+
}
|
| 575 |
+
mk(t('wsDownload'), async () => {
|
| 576 |
+
const blob = await workspace.bundle();
|
| 577 |
+
const a = document.createElement('a');
|
| 578 |
+
a.href = URL.createObjectURL(blob);
|
| 579 |
+
a.download = `elffuss-code-${new Date().toISOString().slice(0, 10)}.json`;
|
| 580 |
+
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
| 581 |
+
});
|
| 582 |
+
wsInv.replaceChildren(el('b', null, t('wsWhat')));
|
| 583 |
+
for (const it of await workspace.inventory()) {
|
| 584 |
+
const line = el('div'); line.style.cssText = 'display:flex;justify-content:space-between;padding:3px 0';
|
| 585 |
+
line.append(el('span', null, `${it.icon} ${it.label}`), el('span', 'muted', it.error ? '⚠️' : `${it.count} · ${wsKb(it.bytes)}`));
|
| 586 |
+
wsInv.appendChild(line);
|
| 587 |
+
}
|
| 588 |
+
}
|
| 589 |
+
paintWs().catch(() => {});
|
| 590 |
+
|
| 591 |
// --- Almacenamiento del modelo (caché persistente) ---
|
| 592 |
box.append(el('div', 'sk-h', t('setStoreTitle')));
|
| 593 |
const storeCard = el('div', 'prov-card');
|
|
|
|
| 610 |
paintStorage();
|
| 611 |
|
| 612 |
// --- Bridge local (ejecución REAL en tu máquina: node, npm, python…) ---
|
| 613 |
+
box.append(el('div', 'sk-h', t("setBridgeTitle")));
|
| 614 |
const brCard = el('div', 'prov-card bridge-card');
|
| 615 |
const guessOS = () => {
|
| 616 |
const ua = navigator.userAgent;
|
|
|
|
| 623 |
const primary = guessOS();
|
| 624 |
brCard.innerHTML =
|
| 625 |
`<div class="prov-head"><span id="br-dot" class="dot off"></span><b>Bridge local</b><span id="br-status" class="muted" style="margin-left:auto;font-size:.72rem">desconectado</span></div>` +
|
| 626 |
+
`<p class="muted" style="font-size:.72rem;margin:6px 0">${t('setBridgeDesc')}</p>` +
|
| 627 |
+
`<a class="prov-use" style="text-decoration:none;display:inline-block" href="bridge-dl/${primary}" download>${t('setBridgeDownload',{os:OTHER[primary]})}</a>` +
|
| 628 |
+
`<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">${t('setBridgeOtherOS')}</summary>` +
|
| 629 |
Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
|
| 630 |
`</details>` +
|
| 631 |
`<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">${t('setBrToken')}</label><input id="br-token" placeholder="${t('brTokenPh')}"></div>` +
|
| 632 |
+
`<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">${t('setBridgeFolderLabel')}</label><input id="br-folder" placeholder="${t('setBridgeFolderPh')}"></div>` +
|
| 633 |
+
`<button id="br-connect" class="prov-use" style="margin-top:8px">${t('setBridgeConnect')}</button>`;
|
| 634 |
box.appendChild(brCard);
|
| 635 |
brCard.querySelector('#br-folder').value = bridge.getFolder();
|
| 636 |
brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
|
|
|
|
| 640 |
if (!document.body.contains(brCard)) { bridge.onStatusChange(() => {}); return; }
|
| 641 |
const on = bridge.isConnected();
|
| 642 |
brCard.querySelector('#br-dot').className = 'dot ' + (on ? 'on' : 'off');
|
| 643 |
+
brCard.querySelector('#br-status').textContent = on ? t("setBridgeOnStatus") : t("setBridgeDisconnected");
|
| 644 |
if (on) brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
|
| 645 |
};
|
| 646 |
paintBridge();
|
|
|
|
| 649 |
const btn = brCard.querySelector('#br-connect');
|
| 650 |
bridge.setFolder(brCard.querySelector('#br-folder').value);
|
| 651 |
const token = brCard.querySelector('#br-token').value.trim();
|
| 652 |
+
if (!token) return showBridgeMsg(brCard, t("setBridgeNoToken"), true);
|
| 653 |
+
btn.disabled = true; btn.textContent = t("setBridgeConnecting");
|
| 654 |
+
try { await bridge.connect(token); paintBridge(); showBridgeMsg(brCard, t("setBridgeConnectedMsg")); fireworks(); }
|
| 655 |
catch (e) { showBridgeMsg(brCard, '⚠️ ' + e.message, true); }
|
| 656 |
+
finally { btn.disabled = false; btn.textContent = t("setBridgeConnect"); }
|
| 657 |
};
|
| 658 |
|
| 659 |
// --- Permisos de ejecución (mismo interruptor que </> Auto de la barra) ---
|
| 660 |
+
box.append(el('div', 'sk-h', t("setPermTitle")));
|
| 661 |
const permCard = el('div', 'prov-card');
|
| 662 |
permCard.innerHTML =
|
| 663 |
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
|
|
|
| 675 |
};
|
| 676 |
|
| 677 |
// --- 🎯 Modo Objetivo (planificador + ejecutor, mismo patrón que Auto) ---
|
| 678 |
+
box.append(el('div', 'sk-h', t("setGoalTitle")));
|
| 679 |
const goalCard = el('div', 'prov-card');
|
| 680 |
goalCard.innerHTML =
|
| 681 |
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
|
|
|
| 693 |
};
|
| 694 |
|
| 695 |
// --- 📨 Errores y feedback (opt-in — apagado no sale NADA de tu máquina) ---
|
| 696 |
+
box.append(el('div', 'sk-h', t("setTelTitle")));
|
| 697 |
const telCard = el('div', 'prov-card');
|
| 698 |
telCard.innerHTML =
|
| 699 |
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
|
|
|
| 713 |
const ta = telCard.querySelector('#tel-feedback');
|
| 714 |
const msg = telCard.querySelector('#tel-msg');
|
| 715 |
const text = ta.value.trim();
|
| 716 |
+
if (!text) { msg.textContent = t("setTelEmpty"); return; }
|
| 717 |
const wasEnabled = telemetry.isEnabled();
|
| 718 |
if (!wasEnabled) telemetry.setEnabled(true); // el envío manual es explícito, se permite aunque el automático esté apagado
|
| 719 |
await telemetry.sendFeedback(text);
|
| 720 |
if (!wasEnabled) telemetry.setEnabled(false); // no activa el automático de fondo si no lo pidió
|
| 721 |
ta.value = '';
|
| 722 |
+
msg.textContent = t("setTelThanks");
|
| 723 |
setTimeout(() => { msg.textContent = ''; }, 4000);
|
| 724 |
};
|
| 725 |
|
| 726 |
// --- Proveedores externos (API keys) ---
|
| 727 |
+
box.append(el('div', 'sk-h', t("setProvTitle")));
|
| 728 |
for (const [id, c] of Object.entries(settings.configs())) {
|
| 729 |
const card = el('div', 'prov-card' + (c.enabled ? ' on' : ''));
|
| 730 |
const head = el('div', 'prov-head');
|
| 731 |
const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = c.enabled;
|
| 732 |
const title = el('b', null, c.label);
|
| 733 |
+
const use = el('button', 'prov-use', t("setProvUse"));
|
| 734 |
use.hidden = !c.enabled || activeModel === 'ext:' + id;
|
| 735 |
use.onclick = () => { changeModel('ext:' + id); renderSettings(); };
|
| 736 |
head.append(toggle, title, use);
|
|
|
|
| 752 |
// --- Skills ---
|
| 753 |
box.append(el('div', 'sk-h', 'Skills'));
|
| 754 |
const skBtn = el('button', 'primary wide', '🧩 Gestionar skills de Claude Code');
|
| 755 |
+
skBtn.textContent = t("setManageSkillsBtn");
|
| 756 |
skBtn.onclick = openSkillsPanel;
|
| 757 |
box.appendChild(skBtn);
|
| 758 |
}
|
|
|
|
| 1134 |
$('act-term').classList.toggle('on', show);
|
| 1135 |
if (show) {
|
| 1136 |
const cap = shell.capabilities();
|
| 1137 |
+
$('term-caps').textContent = cap.bridge ? t("termCapsBridge") : t("termCapsNoBridge");
|
| 1138 |
await terminal.mount($('terminal-host'));
|
| 1139 |
terminal.refit();
|
| 1140 |
}
|
|
|
|
| 1305 |
});
|
| 1306 |
paintGoal();
|
| 1307 |
|
| 1308 |
+
// ── Hard Work (RLM): lee el proyecto ENTERO por partes ──
|
| 1309 |
+
let hardMode = false;
|
| 1310 |
+
$('btn-hardwork').addEventListener('click', () => {
|
| 1311 |
+
hardMode = !hardMode;
|
| 1312 |
+
$('btn-hardwork').classList.toggle('on', hardMode);
|
| 1313 |
+
$('prompt').placeholder = hardMode ? t("hwPlaceholder") : 'Pídele código a Elffuss…';
|
| 1314 |
+
});
|
| 1315 |
+
|
| 1316 |
+
// Trabaja el proyecto abierto por partes: trocea, pregunta a cada parte y funde.
|
| 1317 |
+
async function runHardWork(question) {
|
| 1318 |
+
const active = conv.getActive();
|
| 1319 |
+
if (!active) return;
|
| 1320 |
+
addMsg('user', question);
|
| 1321 |
+
const thinking = thinkingBubble();
|
| 1322 |
+
const fail = msg => { thinking.remove(); addMsg('assistant', msg); };
|
| 1323 |
+
try {
|
| 1324 |
+
const provider = conv.getProvider();
|
| 1325 |
+
if (!provider || provider === rules) return fail(t("hwNeedBrain"));
|
| 1326 |
+
const root = codeTools.handle();
|
| 1327 |
+
if (!root) return fail(t("hwNeedProject"));
|
| 1328 |
+
thinking.tool(t("hwReading"));
|
| 1329 |
+
const context = await gatherFolder(root, { maxBytes: 1.5e6 });
|
| 1330 |
+
if (!context.trim()) return fail(t("hwNoFiles"));
|
| 1331 |
+
const res = await hardWork({
|
| 1332 |
+
question, context, provider,
|
| 1333 |
+
onProgress: p => {
|
| 1334 |
+
if (p.phase === 'plan') thinking.tool(`Hard Work · ${p.chunks} partes (${Math.round(p.chars / 1000)}k)${p.truncated ? t("hwTrimmed") : ''}`);
|
| 1335 |
+
else if (p.phase === 'map') thinking.tool(`Hard Work · leyendo ${p.i}/${p.n}`);
|
| 1336 |
+
else if (p.phase === 'reduce-group') thinking.tool(`Hard Work · fundiendo ${p.g}/${p.n}`);
|
| 1337 |
+
else if (p.phase === 'reduce') thinking.tool(t("hwPhaseReduce"));
|
| 1338 |
+
},
|
| 1339 |
+
});
|
| 1340 |
+
thinking.remove();
|
| 1341 |
+
addMsg('assistant', res.answer + `\n\n_⛏ Hard Work (RLM): ${res.chunks} partes, ${res.kept} con señal.${res.truncated ? t("hwResultTrunc") : ''}_`);
|
| 1342 |
+
} catch (e) {
|
| 1343 |
+
thinking.remove();
|
| 1344 |
+
addMsg('assistant err', 'Hard Work falló: ' + (e?.message || String(e)));
|
| 1345 |
+
}
|
| 1346 |
+
}
|
| 1347 |
+
|
| 1348 |
// resolver @rutas del mensaje: se leen y se adjuntan como contexto
|
| 1349 |
const _send = send;
|
| 1350 |
send = async (text) => {
|
| 1351 |
+
if (hardMode) return runHardWork(text);
|
| 1352 |
const refs = [...text.matchAll(/@([\w./-]+\.\w+)/g)].map(m => m[1]);
|
| 1353 |
if (refs.length) {
|
| 1354 |
let ctx = '';
|
|
|
|
| 1385 |
row.className = 'sk-row';
|
| 1386 |
row.innerHTML = `<b>${s.name}</b><span class="muted">${s.repo || 'local'}</span>`;
|
| 1387 |
const rm = document.createElement('button');
|
| 1388 |
+
rm.className = 'ghost'; rm.textContent = t("skRemove");
|
| 1389 |
rm.onclick = async () => { await skills.remove(s.name); openSkillsPanel(); };
|
| 1390 |
row.appendChild(rm);
|
| 1391 |
instBox.appendChild(row);
|
|
|
|
| 1402 |
row.className = 'sk-row';
|
| 1403 |
row.innerHTML = `<b>${s.label}</b><a href="https://github.com/${s.repo}" target="_blank" rel="noopener">${s.repo} ↗</a>`;
|
| 1404 |
const browse = document.createElement('button');
|
| 1405 |
+
browse.className = 'primary'; browse.textContent = t("skBrowse");
|
| 1406 |
browse.onclick = () => browseRepo(s.repo, box);
|
| 1407 |
row.appendChild(browse);
|
| 1408 |
if (!s.official) {
|
|
|
|
| 1416 |
const addRow = document.createElement('div');
|
| 1417 |
addRow.className = 'sk-row';
|
| 1418 |
const inp = document.createElement('input');
|
| 1419 |
+
inp.placeholder = t("skAddRepoPh");
|
| 1420 |
const add = document.createElement('button');
|
| 1421 |
+
add.className = 'primary'; add.textContent = t("skAddRepo");
|
| 1422 |
add.onclick = async () => {
|
| 1423 |
try { await skills.addSource(inp.value); openSkillsPanel(); }
|
| 1424 |
catch (e) { alert(e.message); }
|
|
@@ -39,7 +39,12 @@ export async function load(onProgress = () => {}) {
|
|
| 39 |
let adapter = null;
|
| 40 |
try { adapter = await navigator.gpu.requestAdapter(); } catch { /* sin adaptador */ }
|
| 41 |
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.');
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
// El .litertlm lo descargamos NOSOTROS (cache-first en Cache Storage) y se lo
|
| 44 |
// pasamos a Engine.create como Blob (la API acepta string|Blob|ReadableStream).
|
| 45 |
// Motivo: el fetch interno de LiteRT baja el peso con XHR+Range desde un WORKER
|
|
@@ -78,17 +83,41 @@ export async function cachedModelBlob(url, onProgress = () => {}) {
|
|
| 78 |
if (!net.ok || !net.body) return url;
|
| 79 |
const total = +net.headers.get('content-length') || 0;
|
| 80 |
const t0 = performance.now();
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
const headers = { 'Content-Type': 'application/octet-stream' };
|
| 87 |
if (total) headers['Content-Length'] = String(total);
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
const cached = await cache.match(url);
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
function fmtBytes(loaded, total, t0) {
|
| 94 |
const mb = n => (n / 1048576).toFixed(0);
|
|
|
|
| 39 |
let adapter = null;
|
| 40 |
try { adapter = await navigator.gpu.requestAdapter(); } catch { /* sin adaptador */ }
|
| 41 |
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.');
|
| 42 |
+
// VERSIÓN FIJADA a propósito. Sin fijarla, la URL apunta siempre a la última
|
| 43 |
+
// publicada: el 2026-08-11 salió 0.16.0, jsdelivr NO consigue construirle el
|
| 44 |
+
// bundle `+esm` (404) y el cerebro Gemma dejó de cargar en producción sin que
|
| 45 |
+
// nosotros tocáramos una línea. Al subir de versión hay que COMPROBAR que
|
| 46 |
+
// `https://cdn.jsdelivr.net/npm/@litert-lm/core@<v>/+esm` responde 200.
|
| 47 |
+
const litertlm = await import('https://cdn.jsdelivr.net/npm/@litert-lm/core@0.15.0/+esm');
|
| 48 |
// El .litertlm lo descargamos NOSOTROS (cache-first en Cache Storage) y se lo
|
| 49 |
// pasamos a Engine.create como Blob (la API acepta string|Blob|ReadableStream).
|
| 50 |
// Motivo: el fetch interno de LiteRT baja el peso con XHR+Range desde un WORKER
|
|
|
|
| 83 |
if (!net.ok || !net.body) return url;
|
| 84 |
const total = +net.headers.get('content-length') || 0;
|
| 85 |
const t0 = performance.now();
|
| 86 |
+
// Progreso SIN tee(): con un modelo de gigabytes, tee() crea dos ramas que
|
| 87 |
+
// se consumen a ritmos distintos y el navegador tiene que bufferizar la
|
| 88 |
+
// diferencia en memoria → el cache.put acababa reventando y el modelo NO se
|
| 89 |
+
// cacheaba NUNCA (medido con E4B: 2832 MB bajados y cero guardados; el
|
| 90 |
+
// usuario se los re-bajaba en cada sesión). Con un TransformStream hay un
|
| 91 |
+
// solo consumidor: contamos al vuelo y el mismo flujo va a la caché.
|
| 92 |
+
let loaded = 0;
|
| 93 |
+
const counted = net.body.pipeThrough(new TransformStream({
|
| 94 |
+
transform(chunk, ctrl) {
|
| 95 |
+
loaded += chunk.byteLength ?? chunk.length;
|
| 96 |
+
onProgress(fmtBytes(loaded, total, t0));
|
| 97 |
+
ctrl.enqueue(chunk);
|
| 98 |
+
},
|
| 99 |
+
}));
|
| 100 |
const headers = { 'Content-Type': 'application/octet-stream' };
|
| 101 |
if (total) headers['Content-Length'] = String(total);
|
| 102 |
+
// Cachear GIGABYTES puede fallar de verdad: ventana privada (Cache Storage
|
| 103 |
+
// en memoria), disco lleno, cuota del origen. Si falla hay que DECIRLO: el
|
| 104 |
+
// progreso ya ha prometido «se cachea para la próxima vez» y, callándolo,
|
| 105 |
+
// el usuario se re-baja el modelo entero cada sesión sin saber por qué.
|
| 106 |
+
try {
|
| 107 |
+
await cache.put(url, new Response(counted, { headers }));
|
| 108 |
+
} catch (e) {
|
| 109 |
+
onProgress(`No se pudo guardar el modelo en caché (${e.name || 'error'}): habrá que descargarlo otra vez la próxima. ` +
|
| 110 |
+
`Suele ser ventana privada o falta de espacio.`);
|
| 111 |
+
console.warn('[elffuss] modelo NO cacheado:', e);
|
| 112 |
+
return url;
|
| 113 |
+
}
|
| 114 |
const cached = await cache.match(url);
|
| 115 |
+
if (!cached) { onProgress('No se pudo guardar el modelo en caché: habrá que descargarlo otra vez la próxima.'); return url; }
|
| 116 |
+
return await cached.blob();
|
| 117 |
+
} catch (e) {
|
| 118 |
+
console.warn('[elffuss] caché de modelo no disponible:', e);
|
| 119 |
+
return url;
|
| 120 |
+
}
|
| 121 |
}
|
| 122 |
function fmtBytes(loaded, total, t0) {
|
| 123 |
const mb = n => (n / 1048576).toFixed(0);
|
|
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// RLM — Recursive Language Models (el modo «Hard Work» de Elffuss).
|
| 2 |
+
//
|
| 3 |
+
// Idea (Zhang et al., MIT, 2025): un modelo PEQUEÑO y local puede manejar
|
| 4 |
+
// entradas ENORMES si no las mete todas de golpe en su ventana, sino que
|
| 5 |
+
// TROCEA el material, pregunta a cada trozo (map = una sub-llamada al mismo
|
| 6 |
+
// modelo) y FUNDE las respuestas (reduce). Si lo fundido aún no cabe, se
|
| 7 |
+
// recurre. El modelo raíz nunca ve todo el contexto a la vez: trabaja por
|
| 8 |
+
// partes, como un humano leyendo un tocho capítulo a capítulo.
|
| 9 |
+
//
|
| 10 |
+
// Trade-off honesto: cambia LATENCIA por CAPACIDAD. Son muchas inferencias del
|
| 11 |
+
// modelo local (que es lento), así que es para tareas que PUEDEN esperar —
|
| 12 |
+
// auditar, resumir o revisar una carpeta/repo entero— no para tiempo real.
|
| 13 |
+
//
|
| 14 |
+
// El motor es agnóstico del proveedor: solo necesita algo con
|
| 15 |
+
// `chat(history, system, onToken)` (lo cumplen rules/onnx/litert/api).
|
| 16 |
+
|
| 17 |
+
const CHARS_PER_TOK = 4; // misma estimación que context.js
|
| 18 |
+
const DEFAULTS = {
|
| 19 |
+
chunkTokens: 1800, // tamaño de cada trozo (deja hueco al prompt del modelo)
|
| 20 |
+
overlapTokens: 120, // solape para no cortar una idea a la mitad entre trozos
|
| 21 |
+
maxDepth: 3, // recursión máxima del reduce jerárquico
|
| 22 |
+
maxChunks: 80, // techo DURO de sub-llamadas del map (coste/tiempo)
|
| 23 |
+
callTimeoutMs: 120000, // guarda por sub-llamada (un motor colgado no bloquea todo)
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const clip = (s, n) => (s.length > n ? s.slice(0, n) + '…' : s);
|
| 27 |
+
|
| 28 |
+
// Trocea respetando límites de línea cuando es posible (no parte a mitad de
|
| 29 |
+
// palabra si hay un salto cerca del corte).
|
| 30 |
+
function chunk(text, chunkChars, overlapChars) {
|
| 31 |
+
const step = Math.max(1, chunkChars - overlapChars);
|
| 32 |
+
const out = [];
|
| 33 |
+
for (let i = 0; i < text.length; i += step) {
|
| 34 |
+
let end = Math.min(i + chunkChars, text.length);
|
| 35 |
+
if (end < text.length) {
|
| 36 |
+
const nl = text.lastIndexOf('\n', end);
|
| 37 |
+
if (nl > i + chunkChars * 0.6) end = nl; // corta en salto si cae en el último 40%
|
| 38 |
+
}
|
| 39 |
+
out.push(text.slice(i, end));
|
| 40 |
+
if (end >= text.length) break;
|
| 41 |
+
}
|
| 42 |
+
return out;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// sub(): UNA inferencia del modelo local sobre un fragmento. Es la recursión.
|
| 46 |
+
async function sub(provider, system, prompt, timeoutMs) {
|
| 47 |
+
const run = provider.chat([{ role: 'user', content: prompt }], system, () => {});
|
| 48 |
+
if (!timeoutMs) return run;
|
| 49 |
+
let to;
|
| 50 |
+
const guard = new Promise((_, rej) => { to = setTimeout(() => rej(new Error('sub-llamada agotó el tiempo')), timeoutMs); });
|
| 51 |
+
try { return await Promise.race([run, guard]); }
|
| 52 |
+
finally { clearTimeout(to); }
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
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';
|
| 56 |
+
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.';
|
| 57 |
+
|
| 58 |
+
// Motor RLM. Devuelve { answer, chunks, kept }.
|
| 59 |
+
export async function hardWork({ question, context, provider, onProgress = () => {}, opts = {} }) {
|
| 60 |
+
const o = { ...DEFAULTS, ...opts };
|
| 61 |
+
if (!provider || typeof provider.chat !== 'function') throw new Error('Hard Work necesita un cerebro cargado (elige un modelo local o remoto arriba).');
|
| 62 |
+
const q = String(question || '').trim();
|
| 63 |
+
const ctx = String(context || '').trim();
|
| 64 |
+
if (!q) throw new Error('Hard Work: dime qué quieres saber del material.');
|
| 65 |
+
if (!ctx) throw new Error('Hard Work: no encontré material que procesar.');
|
| 66 |
+
|
| 67 |
+
const chunkChars = o.chunkTokens * CHARS_PER_TOK;
|
| 68 |
+
let parts = chunk(ctx, chunkChars, o.overlapTokens * CHARS_PER_TOK);
|
| 69 |
+
const truncated = parts.length > o.maxChunks;
|
| 70 |
+
if (truncated) parts = parts.slice(0, o.maxChunks);
|
| 71 |
+
onProgress({ phase: 'plan', chunks: parts.length, chars: ctx.length, truncated });
|
| 72 |
+
|
| 73 |
+
// MAP — un modelo local es UN motor, así que las sub-llamadas van en serie.
|
| 74 |
+
const findings = [];
|
| 75 |
+
for (let i = 0; i < parts.length; i++) {
|
| 76 |
+
onProgress({ phase: 'map', i: i + 1, n: parts.length });
|
| 77 |
+
let ans;
|
| 78 |
+
try { ans = await sub(provider, MAP_SYS, `PREGUNTA:\n${q}\n\nFRAGMENTO ${i + 1}/${parts.length}:\n${parts[i]}`, o.callTimeoutMs); }
|
| 79 |
+
catch { ans = 'NADA'; }
|
| 80 |
+
ans = String(ans || '').trim();
|
| 81 |
+
if (ans && !/^NADA\b/i.test(ans)) findings.push(`[${i + 1}] ${ans}`);
|
| 82 |
+
}
|
| 83 |
+
onProgress({ phase: 'mapped', kept: findings.length, of: parts.length });
|
| 84 |
+
if (!findings.length) return { answer: 'Revisé el material por partes y no encontré nada relevante para eso.', chunks: parts.length, kept: 0, truncated };
|
| 85 |
+
|
| 86 |
+
const answer = await reduce(provider, q, findings, o, onProgress, 0);
|
| 87 |
+
return { answer: String(answer || '').trim(), chunks: parts.length, kept: findings.length, truncated };
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// REDUCE jerárquico: si los hallazgos caben, funde en uno; si no, agrupa,
|
| 91 |
+
// funde cada grupo y recurre con las respuestas parciales.
|
| 92 |
+
async function reduce(provider, q, findings, o, onProgress, depth) {
|
| 93 |
+
const budget = o.chunkTokens * CHARS_PER_TOK * 2; // el reduce puede ver algo más que un trozo
|
| 94 |
+
const joined = findings.join('\n');
|
| 95 |
+
if (joined.length <= budget || depth >= o.maxDepth) {
|
| 96 |
+
onProgress({ phase: 'reduce', depth, final: true });
|
| 97 |
+
return sub(provider, REDUCE_SYS, `PREGUNTA:\n${q}\n\nHALLAZGOS:\n${clip(joined, budget)}`, o.callTimeoutMs);
|
| 98 |
+
}
|
| 99 |
+
const groups = [];
|
| 100 |
+
let cur = [];
|
| 101 |
+
let len = 0;
|
| 102 |
+
for (const f of findings) {
|
| 103 |
+
if (len + f.length + 1 > budget && cur.length) { groups.push(cur); cur = []; len = 0; }
|
| 104 |
+
cur.push(f); len += f.length + 1;
|
| 105 |
+
}
|
| 106 |
+
if (cur.length) groups.push(cur);
|
| 107 |
+
onProgress({ phase: 'reduce', depth, groups: groups.length, final: false });
|
| 108 |
+
const partials = [];
|
| 109 |
+
for (let g = 0; g < groups.length; g++) {
|
| 110 |
+
onProgress({ phase: 'reduce-group', depth, g: g + 1, n: groups.length });
|
| 111 |
+
const p = await sub(provider, REDUCE_SYS, `PREGUNTA:\n${q}\n\nHALLAZGOS (grupo ${g + 1}/${groups.length}):\n${groups[g].join('\n')}`, o.callTimeoutMs);
|
| 112 |
+
partials.push(`[g${g + 1}] ${String(p || '').trim()}`);
|
| 113 |
+
}
|
| 114 |
+
return reduce(provider, q, partials, o, onProgress, depth + 1);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// ── Hard Work · CREACIÓN: borrador → autocrítica → reescritura ──────────────
|
| 118 |
+
// El hermano creativo de RLM. RLM gasta cómputo LEYENDO material enorme; esto
|
| 119 |
+
// lo gasta MEJORANDO lo que el modelo crea: en vez de una sola pasada, el
|
| 120 |
+
// modelo pequeño hace un borrador, se autocritica sin piedad y se reescribe
|
| 121 |
+
// corrigiendo sus propios fallos. Sube la calidad de un modelo modesto sin
|
| 122 |
+
// cambiar de modelo — solo pensando más veces.
|
| 123 |
+
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.';
|
| 124 |
+
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.';
|
| 125 |
+
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.';
|
| 126 |
+
|
| 127 |
+
// Saca el documento HTML de la respuesta del modelo (fence ```html o directo).
|
| 128 |
+
export function extractHtml(text) {
|
| 129 |
+
const s = String(text || '');
|
| 130 |
+
const fence = s.match(/```html\s*([\s\S]*?)```/i) || s.match(/```\s*([\s\S]*?)```/);
|
| 131 |
+
const body = fence ? fence[1] : s;
|
| 132 |
+
const m = body.match(/<!doctype[\s\S]*<\/html>/i) || body.match(/<html[\s\S]*<\/html>/i);
|
| 133 |
+
return (m ? m[0] : body).trim();
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
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>.';
|
| 137 |
+
|
| 138 |
+
// Genera y, si el modelo se quedó sin tokens a media escritura (el HTML no
|
| 139 |
+
// cierra </html>), pide la continuación desde la cola y la cose. Así el techo
|
| 140 |
+
// de 1024 tokens deja de truncar apps grandes: se completan por tramos.
|
| 141 |
+
async function genComplete(provider, system, prompt, { maxCont = 4 } = {}) {
|
| 142 |
+
let full = String(await provider.chat([{ role: 'user', content: prompt }], system, () => {}) || '');
|
| 143 |
+
for (let i = 0; i < maxCont && !/<\/html>/i.test(full); i++) {
|
| 144 |
+
const tail = full.slice(-1400);
|
| 145 |
+
let cont = String(await provider.chat(
|
| 146 |
+
[{ role: 'user', content: `FINAL de lo escrito hasta ahora (continúa desde aquí, sin repetirlo):\n${tail}` }],
|
| 147 |
+
CONT_SYS, () => {}) || '');
|
| 148 |
+
// Defensa: si el modelo reabre el documento en vez de continuar, recórtalo.
|
| 149 |
+
cont = cont.replace(/^[\s\S]*?<!doctype html>/i, '').replace(/^[\s\S]*?<html[^>]*>/i, '');
|
| 150 |
+
if (!cont.trim()) break;
|
| 151 |
+
full += cont;
|
| 152 |
+
}
|
| 153 |
+
return full;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
// ── Jurado deliberante (Reasoning Jury, arXiv 2608.12585) ───────────────────
|
| 158 |
+
// Un juez único detecta mal los defectos: se inventa unos y se le escapan
|
| 159 |
+
// otros (lo vimos con el crítico único de Hard Work). El paper sustituye al
|
| 160 |
+
// juez por un JURADO que delibera: cada jurado propone defectos, luego ve los
|
| 161 |
+
// de los demás y VOTA cuáles son reales, y un moderador se queda con los que
|
| 162 |
+
// tienen apoyo. Aquí el jurado es un mismo modelo con lentes distintas —no
|
| 163 |
+
// modelos distintos, que no caben en el navegador—, así que el efecto es
|
| 164 |
+
// menor que en el paper; por eso se mide, no se asume.
|
| 165 |
+
const JURY_LENSES = [
|
| 166 |
+
{ 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' },
|
| 167 |
+
{ 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' },
|
| 168 |
+
{ 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' },
|
| 169 |
+
];
|
| 170 |
+
|
| 171 |
+
const asLines = t => String(t || '').split('\n')
|
| 172 |
+
.map(l => l.replace(/^\s*(?:[-*\u2022]|\d+[.)])\s*/, '').trim())
|
| 173 |
+
.filter(l => l.length > 12 && l.length < 220);
|
| 174 |
+
|
| 175 |
+
const normDefect = d => d.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
| 176 |
+
.replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(w => w.length > 3).slice(0, 6).join(' ');
|
| 177 |
+
|
| 178 |
+
// Devuelve { defects:[{text,votes,jurors}], rounds } sobre un artefacto.
|
| 179 |
+
// Normaliza espacios para comparar una cita con el código real.
|
| 180 |
+
const flat = t => String(t).replace(/\s+/g, ' ').trim();
|
| 181 |
+
|
| 182 |
+
// Elige las lentes que TIENEN algo que mirar en este artefacto. Pasarle
|
| 183 |
+
// «presentación» a una función pura de cálculo no aporta nada: ese jurado o
|
| 184 |
+
// calla o repite lo que dijo el de lógica, y los repetidos se comían las
|
| 185 |
+
// plazas de candidatos.
|
| 186 |
+
function lensesFor(material) {
|
| 187 |
+
const tieneUI = /<canvas|<button|<input|<form|document\.|addEventListener|innerHTML|querySelector/i.test(material);
|
| 188 |
+
const tienePintado = /<canvas|getContext|fillRect|drawImage|style\.|css/i.test(material);
|
| 189 |
+
const out = [JURY_LENSES[0]]; // lógica: siempre
|
| 190 |
+
if (tieneUI) out.push(JURY_LENSES[1]); // interacción
|
| 191 |
+
if (tienePintado) out.push(JURY_LENSES[2]); // presentación
|
| 192 |
+
if (out.length === 1) out.push(JURY_LENSES_EXTRA[0], JURY_LENSES_EXTRA[1]); // código puro
|
| 193 |
+
else if (out.length === 2) out.push(JURY_LENSES_EXTRA[0]);
|
| 194 |
+
return out;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// Lentes para código sin interfaz, donde «interacción» y «presentación» no pintan nada.
|
| 198 |
+
const JURY_LENSES_EXTRA = [
|
| 199 |
+
{ 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' },
|
| 200 |
+
{ 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' },
|
| 201 |
+
];
|
| 202 |
+
|
| 203 |
+
export async function juryReview({ artifact, provider, onProgress = () => {}, lenses = null, requireEvidence = true }) {
|
| 204 |
+
const dropped = [];
|
| 205 |
+
if (!provider?.chat) throw new Error('el jurado necesita un cerebro cargado');
|
| 206 |
+
const material = String(artifact || '').slice(0, 6000);
|
| 207 |
+
lenses = lenses || lensesFor(material);
|
| 208 |
+
onProgress({ phase: 'jury-lenses', lenses: lenses.map(l => l.name) });
|
| 209 |
+
|
| 210 |
+
// Ronda 1 — cada jurado propone defectos desde SU lente, sin ver a los demás.
|
| 211 |
+
const proposals = [];
|
| 212 |
+
for (const L of lenses) {
|
| 213 |
+
onProgress({ phase: 'jury-propose', lens: L.name });
|
| 214 |
+
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:
|
| 215 |
+
DEFECTO: <qué está mal, en una línea>
|
| 216 |
+
PRUEBA: <copia LITERAL del fragmento de código que lo demuestra, tal cual aparece>
|
| 217 |
+
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.`;
|
| 218 |
+
const out = await provider.chat([{ role: 'user', content: 'CÓDIGO:\n' + material }], sys, () => {});
|
| 219 |
+
// Emparejar DEFECTO con su PRUEBA y COMPROBAR que la cita existe de verdad
|
| 220 |
+
// en el código. Es un filtro determinista: si el modelo no sabe citar, se
|
| 221 |
+
// lo ha inventado. (El modelo verifica mucho mejor de lo que propone, pero
|
| 222 |
+
// esto ni siquiera necesita al modelo.)
|
| 223 |
+
const raw = String(out).split('\n').map(x => x.trim());
|
| 224 |
+
const found = [];
|
| 225 |
+
for (let i = 0; i < raw.length; i++) {
|
| 226 |
+
const dm = raw[i].match(/^DEFECTO:\s*(.+)/i);
|
| 227 |
+
if (!dm) continue;
|
| 228 |
+
const pm = (raw[i + 1] || '').match(/^PRUEBA:\s*(.+)/i);
|
| 229 |
+
const texto = dm[1].trim();
|
| 230 |
+
if (texto.length < 12) continue;
|
| 231 |
+
if (!requireEvidence) { found.push({ texto, cita: pm ? pm[1].trim() : '' }); continue; }
|
| 232 |
+
const cita = pm ? pm[1].trim().replace(/^[`'"]|[`'"]$/g, '') : '';
|
| 233 |
+
if (cita.length >= 8 && flat(material).includes(flat(cita))) found.push({ texto, cita });
|
| 234 |
+
else dropped.push({ lens: L.name, texto, cita: cita.slice(0, 60) });
|
| 235 |
+
}
|
| 236 |
+
proposals.push({ lens: L, found });
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// Candidatos únicos (los jurados repiten el mismo defecto con otras palabras).
|
| 240 |
+
// Deduplicar por la CITA de código, no por las palabras: dos jurados describen
|
| 241 |
+
// el mismo fallo con frases distintas («el bucle empieza en 1» y «ignora el
|
| 242 |
+
// primer elemento») y el deduplicador léxico los dejaba pasar como dos, con lo
|
| 243 |
+
// que la lista de candidatos se llenaba de repetidos. Si señalan el mismo
|
| 244 |
+
// fragmento de código, es el mismo defecto.
|
| 245 |
+
const seen = new Map();
|
| 246 |
+
for (const p of proposals) for (const d of p.found) {
|
| 247 |
+
const k = d.cita && d.cita.length >= 8 ? 'c:' + flat(d.cita).slice(0, 80) : 't:' + normDefect(d.texto);
|
| 248 |
+
if (k && !seen.has(k)) seen.set(k, d.texto);
|
| 249 |
+
}
|
| 250 |
+
const candidates = [...seen.values()].slice(0, 12);
|
| 251 |
+
onProgress({ phase: 'jury-candidates', n: candidates.length });
|
| 252 |
+
if (!candidates.length) return { defects: [], candidates: 0, dropped };
|
| 253 |
+
|
| 254 |
+
// Ronda 2 — DELIBERACIÓN: cada jurado ve TODOS los candidatos (incluidos los
|
| 255 |
+
// ajenos) y vota cuáles son de verdad. Aquí es donde puede retirar el suyo.
|
| 256 |
+
const votes = candidates.map(() => 0);
|
| 257 |
+
for (const L of lenses) {
|
| 258 |
+
onProgress({ phase: 'jury-vote', lens: L.name });
|
| 259 |
+
const list = candidates.map((c, i) => `${i + 1}. ${c}`).join('\n');
|
| 260 |
+
// OJO con el formato: poner "N: SI" como plantilla hacía que el modelo
|
| 261 |
+
// escribiera literalmente la letra N y no se parseara NINGÚN voto (el
|
| 262 |
+
// jurado parecía rechazarlo todo). Los modelos pequeños copian el ejemplo
|
| 263 |
+
// tal cual: hay que darles números de verdad.
|
| 264 |
+
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í:
|
| 265 |
+
1: SI
|
| 266 |
+
2: NO
|
| 267 |
+
3: SI
|
| 268 |
+
Nada más: ni explicaciones ni texto extra. Ante la duda, NO.`;
|
| 269 |
+
const out = await provider.chat(
|
| 270 |
+
[{ role: 'user', content: 'CÓDIGO:\n' + material + '\n\nDEFECTOS PROPUESTOS:\n' + list }], sys, () => {});
|
| 271 |
+
const txt = String(out);
|
| 272 |
+
let parsed = 0;
|
| 273 |
+
for (const m of txt.matchAll(/(\d+)\s*[:.)\-]\s*(S[IÍ]|YES|NO)\b/gi)) {
|
| 274 |
+
const i = +m[1] - 1;
|
| 275 |
+
parsed++;
|
| 276 |
+
if (i >= 0 && i < votes.length && /^(s[ií]|yes)$/i.test(m[2])) votes[i]++;
|
| 277 |
+
}
|
| 278 |
+
// Respaldo: si el modelo ignoró el formato numerado, leer los SI/NO en orden.
|
| 279 |
+
if (!parsed) {
|
| 280 |
+
const seq = [...txt.matchAll(/\b(S[IÍ]|YES|NO)\b/gi)].map(m => m[1]);
|
| 281 |
+
seq.slice(0, votes.length).forEach((v, i) => { if (/^(s[ií]|yes)$/i.test(v)) votes[i]++; });
|
| 282 |
+
}
|
| 283 |
+
onProgress({ phase: 'jury-parsed', lens: L.name, parsed });
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// Moderador — se queda con los que tienen apoyo de la mayoría del jurado.
|
| 287 |
+
const need = Math.ceil(lenses.length / 2);
|
| 288 |
+
const defects = candidates.map((text, i) => ({ text, votes: votes[i] }))
|
| 289 |
+
.filter(d => d.votes >= need)
|
| 290 |
+
.sort((a, b) => b.votes - a.votes);
|
| 291 |
+
onProgress({ phase: 'jury-verdict', kept: defects.length, of: candidates.length });
|
| 292 |
+
onProgress({ phase: 'jury-evidence', dropped: dropped.length });
|
| 293 |
+
return { defects, candidates: candidates.length, jurors: lenses.length, dropped };
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
// ── Edición QUIRÚRGICA en vez de reescritura completa ───────────────────────
|
| 298 |
+
// Reescribir el documento entero para corregir tres cosas hace que el modelo
|
| 299 |
+
// regenere de cero lo que ya funcionaba, y por el camino rompe la mecánica.
|
| 300 |
+
// Aquí solo se le piden los CAMBIOS, y los aplica este código: si el fragmento
|
| 301 |
+
// a buscar no está tal cual en el documento, ese cambio se descarta. El modelo
|
| 302 |
+
// propone, el código decide — igual que con las citas del jurado.
|
| 303 |
+
const PATCH_SYS = `Eres Elffuss arreglando tu propia app. Te doy el HTML actual y una lista de defectos. NO reescribas el documento. Devuelve SOLO los cambios mínimos, en bloques de exactamente tres líneas:
|
| 304 |
+
BUSCAR: <fragmento LITERAL del código actual, copiado tal cual, en una línea>
|
| 305 |
+
CAMBIAR: <con qué se sustituye, en una línea>
|
| 306 |
+
MOTIVO: <qué defecto arregla>
|
| 307 |
+
Repite el bloque por cada cambio. Máximo 6. El texto de BUSCAR debe existir palabra por palabra en el HTML y ser único; si no puedes copiarlo exacto, omite ese cambio. No toques nada que ya funcione.`;
|
| 308 |
+
|
| 309 |
+
export function applyPatches(html, respuesta) {
|
| 310 |
+
const lineas = String(respuesta || '').split('\n').map(x => x.trim());
|
| 311 |
+
let out = html;
|
| 312 |
+
const aplicados = [], fallidos = [];
|
| 313 |
+
for (let i = 0; i < lineas.length; i++) {
|
| 314 |
+
const b = lineas[i].match(/^BUSCAR:\s*(.+)/i);
|
| 315 |
+
if (!b) continue;
|
| 316 |
+
const c = (lineas[i + 1] || '').match(/^CAMBIAR:\s*(.+)/i);
|
| 317 |
+
if (!c) continue;
|
| 318 |
+
const buscar = b[1].trim(), cambiar = c[1].trim();
|
| 319 |
+
if (buscar.length < 6) continue;
|
| 320 |
+
const veces = out.split(buscar).length - 1;
|
| 321 |
+
if (veces === 1) { out = out.replace(buscar, cambiar); aplicados.push(buscar.slice(0, 60)); }
|
| 322 |
+
else fallidos.push({ buscar: buscar.slice(0, 60), veces }); // 0 = inventado, >1 = ambiguo
|
| 323 |
+
}
|
| 324 |
+
return { html: out, aplicados, fallidos };
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
export async function deepCreate({ brief, provider, rounds = 1, onProgress = () => {}, useJury = false, editMode = 'rewrite' }) {
|
| 328 |
+
if (!provider || typeof provider.chat !== 'function') throw new Error('Hard Work necesita un cerebro cargado (elige un modelo arriba).');
|
| 329 |
+
if (!String(brief || '').trim()) throw new Error('Hard Work: dime qué quieres que cree.');
|
| 330 |
+
onProgress({ phase: 'draft' });
|
| 331 |
+
let html = extractHtml(await genComplete(provider, CREATE_SYS, String(brief)));
|
| 332 |
+
const trace = [{ round: 0, html }];
|
| 333 |
+
for (let r = 1; r <= rounds; r++) {
|
| 334 |
+
onProgress({ phase: 'critique', round: r });
|
| 335 |
+
let critique;
|
| 336 |
+
if (useJury) {
|
| 337 |
+
const v = await juryReview({ artifact: html, provider, onProgress });
|
| 338 |
+
critique = v.defects.length
|
| 339 |
+
? v.defects.map(d => `- ${d.text} (${d.votes} de ${v.jurors} revisores)`).join('\n')
|
| 340 |
+
: 'Sin defectos con apoyo suficiente del jurado.';
|
| 341 |
+
} else {
|
| 342 |
+
critique = String(await provider.chat([{ role: 'user', content: `APP/JUEGO:\n${html}` }], CRITIQUE_SYS, () => {})).trim();
|
| 343 |
+
}
|
| 344 |
+
onProgress({ phase: 'rewrite', round: r, modo: editMode });
|
| 345 |
+
if (editMode === 'patch') {
|
| 346 |
+
const resp = await provider.chat(
|
| 347 |
+
[{ role: 'user', content: `HTML ACTUAL:\n${html}\n\nDEFECTOS A CORREGIR:\n${critique}` }], PATCH_SYS, () => {});
|
| 348 |
+
const { html: nuevo, aplicados, fallidos } = applyPatches(html, resp);
|
| 349 |
+
onProgress({ phase: 'patched', round: r, aplicados: aplicados.length, fallidos: fallidos.length });
|
| 350 |
+
if (aplicados.length) { html = nuevo; trace.push({ round: r, critique, html, aplicados, fallidos }); }
|
| 351 |
+
else trace.push({ round: r, critique, html, aplicados: [], fallidos });
|
| 352 |
+
} else {
|
| 353 |
+
const improved = extractHtml(await genComplete(provider, REWRITE_SYS, `HTML ACTUAL:\n${html}\n\nCRÍTICA A CORREGIR:\n${critique}`));
|
| 354 |
+
if (improved && /<\w+[\s>]/.test(improved)) { html = improved; trace.push({ round: r, critique, html }); }
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
onProgress({ phase: 'done', rounds: trace.length - 1 });
|
| 358 |
+
return { html, trace };
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// ── Recolección de material desde una carpeta autorizada (File System Access) ──
|
| 362 |
+
// Camina el árbol, concatena ficheros de texto con cabecera de ruta, y corta a
|
| 363 |
+
// maxBytes para no reventar la memoria. Mismo código sirve a Claw (carpeta
|
| 364 |
+
// autorizada) y a Code (proyecto abierto): ambos entregan un directory handle.
|
| 365 |
+
const TEXT_EXT = new Set(('txt md markdown js mjs cjs ts tsx jsx json html htm css scss sass ' +
|
| 366 |
+
'py rb php go rs java kt swift c h cpp hpp cc cs sh bash zsh yml yaml toml ini cfg conf ' +
|
| 367 |
+
'xml csv tsv sql vue svelte astro lua r pl pm ex exs erl clj tex org rst gradle properties env').split(' '));
|
| 368 |
+
const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'target', 'vendor', '.cache', 'coverage', '__pycache__', '.venv', 'venv']);
|
| 369 |
+
|
| 370 |
+
export async function gatherFolder(rootHandle, { maxBytes = 1.5e6, maxFiles = 400 } = {}) {
|
| 371 |
+
if (!rootHandle || typeof rootHandle.entries !== 'function') throw new Error('no hay carpeta que leer');
|
| 372 |
+
const out = [];
|
| 373 |
+
let bytes = 0;
|
| 374 |
+
let files = 0;
|
| 375 |
+
async function walk(dir, prefix) {
|
| 376 |
+
if (bytes >= maxBytes || files >= maxFiles) return;
|
| 377 |
+
const entries = [];
|
| 378 |
+
for await (const e of dir.entries()) entries.push(e);
|
| 379 |
+
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
| 380 |
+
for (const [name, handle] of entries) {
|
| 381 |
+
if (bytes >= maxBytes || files >= maxFiles) return;
|
| 382 |
+
if (name.startsWith('.') && name !== '.env') { if (handle.kind === 'directory') continue; }
|
| 383 |
+
const path = prefix ? prefix + '/' + name : name;
|
| 384 |
+
if (handle.kind === 'directory') {
|
| 385 |
+
if (SKIP_DIR.has(name)) continue;
|
| 386 |
+
await walk(handle, path);
|
| 387 |
+
} else {
|
| 388 |
+
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
| 389 |
+
if (!TEXT_EXT.has(ext)) continue;
|
| 390 |
+
try {
|
| 391 |
+
const file = await handle.getFile();
|
| 392 |
+
if (file.size > 400000) continue; // un fichero enorme suelto no monopoliza
|
| 393 |
+
const text = await file.text();
|
| 394 |
+
const block = `\n\n===== ${path} =====\n${text}`;
|
| 395 |
+
out.push(block);
|
| 396 |
+
bytes += block.length;
|
| 397 |
+
files++;
|
| 398 |
+
} catch { /* ilegible: se salta */ }
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
await walk(rootHandle, '');
|
| 403 |
+
return out.join('').slice(0, maxBytes);
|
| 404 |
+
}
|
|
@@ -19,16 +19,36 @@ export const DEFAULT_SOURCES = [
|
|
| 19 |
|
| 20 |
let cache = []; // instaladas, en memoria (para que el systemPrompt sea síncrono)
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
export async function initSkills() {
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
return cache;
|
| 25 |
}
|
|
|
|
| 26 |
|
| 27 |
export async function all() { return cache; }
|
| 28 |
export function installed() { return cache; }
|
| 29 |
export function isInstalled(repo, path) { return cache.some(s => s.repo === repo && s.path === path); }
|
| 30 |
|
| 31 |
export async function install(skill) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
cache = cache.filter(s => !(s.repo === skill.repo && s.path === skill.path) && s.name !== skill.name);
|
| 33 |
const entry = { ...skill, content: (skill.content || '').slice(0, MAX_SKILL) };
|
| 34 |
cache.push(entry);
|
|
|
|
| 19 |
|
| 20 |
let cache = []; // instaladas, en memoria (para que el systemPrompt sea síncrono)
|
| 21 |
|
| 22 |
+
// `loaded` distingue «no hay skills» de «no se pudo leer»: si la lectura
|
| 23 |
+
// falla, cache queda vacío pero loaded=false, y install() se niega a persistir
|
| 24 |
+
// (antes, un fallo transitorio de IndexedDB borraba TODAS las skills al
|
| 25 |
+
// guardar la siguiente).
|
| 26 |
+
let loaded = false;
|
| 27 |
export async function initSkills() {
|
| 28 |
+
try {
|
| 29 |
+
const stored = await db.get('kv', KEY);
|
| 30 |
+
cache = Array.isArray(stored) ? stored : [];
|
| 31 |
+
loaded = true;
|
| 32 |
+
} catch (e) {
|
| 33 |
+
cache = [];
|
| 34 |
+
loaded = false;
|
| 35 |
+
console.warn('[elffuss] no se pudieron leer las skills; no las sobrescribiré', e);
|
| 36 |
+
}
|
| 37 |
return cache;
|
| 38 |
}
|
| 39 |
+
export function skillsLoaded() { return loaded; }
|
| 40 |
|
| 41 |
export async function all() { return cache; }
|
| 42 |
export function installed() { return cache; }
|
| 43 |
export function isInstalled(repo, path) { return cache.some(s => s.repo === repo && s.path === path); }
|
| 44 |
|
| 45 |
export async function install(skill) {
|
| 46 |
+
// Si la carga inicial falló, cache no representa lo instalado: reintentar
|
| 47 |
+
// leer antes de escribir. Sin esto, guardar aquí borraría las skills reales.
|
| 48 |
+
if (!loaded) {
|
| 49 |
+
await initSkills();
|
| 50 |
+
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.');
|
| 51 |
+
}
|
| 52 |
cache = cache.filter(s => !(s.repo === skill.repo && s.path === skill.path) && s.name !== skill.name);
|
| 53 |
const entry = { ...skill, content: (skill.content || '').slice(0, MAX_SKILL) };
|
| 54 |
cache.push(entry);
|
|
@@ -35,9 +35,9 @@ void main(){
|
|
| 35 |
float tw = 0.55 + 0.45 * sin(t * (1.5 + seed.x * 3.0) + seed.y * 40.0);
|
| 36 |
vGlow = tw * (1.0 - burst * 0.55);
|
| 37 |
float gold = step(0.93, hash(seed.x * 31.3 + seed.y * 7.7));
|
| 38 |
-
vec3 pink = vec3(
|
| 39 |
-
vec3 violet = vec3(0.
|
| 40 |
-
vec3 goldc = vec3(
|
| 41 |
vColor = mix(mix(pink, violet, smoothstep(0.1, 1.3, r)), goldc, gold);
|
| 42 |
gl_PointSize = (1.3 + 2.8 * tw * max(1.2 - r * 0.5, 0.2)) * dpr;
|
| 43 |
}`;
|
|
|
|
| 35 |
float tw = 0.55 + 0.45 * sin(t * (1.5 + seed.x * 3.0) + seed.y * 40.0);
|
| 36 |
vGlow = tw * (1.0 - burst * 0.55);
|
| 37 |
float gold = step(0.93, hash(seed.x * 31.3 + seed.y * 7.7));
|
| 38 |
+
vec3 pink = vec3(0.33, 0.86, 0.77);
|
| 39 |
+
vec3 violet = vec3(0.51, 0.45, 1.0);
|
| 40 |
+
vec3 goldc = vec3(0.82, 0.63, 0.42);
|
| 41 |
vColor = mix(mix(pink, violet, smoothstep(0.1, 1.3, r)), goldc, gold);
|
| 42 |
gl_PointSize = (1.3 + 2.8 * tw * max(1.2 - r * 0.5, 0.2)) * dpr;
|
| 43 |
}`;
|
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Batería de tareas estilo SWE-bench, autocontenidas y REPRODUCIBLES en el
|
| 2 |
+
// navegador: cada una siembra un repo con un BUG + una especificación (lo que el
|
| 3 |
+
// test espera), y un `test(mod)` que se EJECUTA de verdad contra el módulo
|
| 4 |
+
// arreglado (métrica `resolved`, como SWE-bench). `solution` es el contenido
|
| 5 |
+
// correcto del fichero objetivo (lo usa el solver determinista del arnés; el
|
| 6 |
+
// modelo real no lo ve, tiene que deducirlo leyendo `target` y la spec).
|
| 7 |
+
export const TASKS = [
|
| 8 |
+
{
|
| 9 |
+
id: 'add-sub',
|
| 10 |
+
target: 'src/math.js',
|
| 11 |
+
files: {
|
| 12 |
+
'src/math.js': 'export function add(a, b) {\n return a - b; // BUG\n}\n',
|
| 13 |
+
'spec/math.md': '# add(a, b)\nDebe SUMAR: add(2,3) → 5, add(10,-4) → 6.',
|
| 14 |
+
},
|
| 15 |
+
task: 'add() en src/math.js resta en vez de sumar; arréglalo para que sume.',
|
| 16 |
+
solution: 'export function add(a, b) {\n return a + b;\n}\n',
|
| 17 |
+
test: m => m.add(2, 3) === 5 && m.add(10, -4) === 6 && m.add(0, 0) === 0,
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
id: 'max-empty',
|
| 21 |
+
target: 'src/max.js',
|
| 22 |
+
files: {
|
| 23 |
+
'src/max.js': 'export function max(arr) {\n return arr.reduce((a, b) => a > b ? a : b); // BUG: peta con []\n}\n',
|
| 24 |
+
'spec/max.md': '# max(arr)\nmax([3,1,2]) → 3. Con array vacío NO debe petar: max([]) → undefined.',
|
| 25 |
+
},
|
| 26 |
+
task: 'max() peta con un array vacío; haz que devuelva undefined en ese caso.',
|
| 27 |
+
solution: 'export function max(arr) {\n if (!arr.length) return undefined;\n return arr.reduce((a, b) => a > b ? a : b);\n}\n',
|
| 28 |
+
test: m => m.max([3, 1, 2]) === 3 && m.max([]) === undefined && m.max([-1, -5]) === -1,
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: 'unique',
|
| 32 |
+
target: 'src/unique.js',
|
| 33 |
+
files: {
|
| 34 |
+
'src/unique.js': 'export function unique(arr) {\n return arr; // BUG: no deduplica\n}\n',
|
| 35 |
+
'spec/unique.md': '# unique(arr)\nDevuelve el array SIN duplicados: unique([1,1,2,3,3]) → [1,2,3].',
|
| 36 |
+
},
|
| 37 |
+
task: 'unique() no elimina duplicados; arréglalo.',
|
| 38 |
+
solution: 'export function unique(arr) {\n return [...new Set(arr)];\n}\n',
|
| 39 |
+
test: m => { const r = m.unique([1, 1, 2, 3, 3]); return r.length === 3 && r.join(',') === '1,2,3'; },
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
id: 'slugify',
|
| 43 |
+
target: 'src/slug.js',
|
| 44 |
+
files: {
|
| 45 |
+
'src/slug.js': 'export function slugify(s) {\n return s.replace(/ /g, "-"); // BUG: no pasa a minúsculas\n}\n',
|
| 46 |
+
'spec/slug.md': '# slugify(s)\nMinúsculas y guiones: slugify("Hola Mundo") → "hola-mundo".',
|
| 47 |
+
},
|
| 48 |
+
task: 'slugify() no pasa a minúsculas; debe devolver minúsculas con guiones.',
|
| 49 |
+
solution: 'export function slugify(s) {\n return s.toLowerCase().replace(/ /g, "-");\n}\n',
|
| 50 |
+
test: m => m.slugify('Hola Mundo') === 'hola-mundo' && m.slugify('A B C') === 'a-b-c',
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
id: 'clamp',
|
| 54 |
+
target: 'src/clamp.js',
|
| 55 |
+
files: {
|
| 56 |
+
'src/clamp.js': 'export function clamp(v, lo, hi) {\n return v; // BUG: no acota\n}\n',
|
| 57 |
+
'spec/clamp.md': '# clamp(v, lo, hi)\nAcota v al rango [lo,hi]: clamp(15,0,10) → 10, clamp(-5,0,10) → 0.',
|
| 58 |
+
},
|
| 59 |
+
task: 'clamp() no acota el valor al rango [lo,hi]; arréglalo.',
|
| 60 |
+
solution: 'export function clamp(v, lo, hi) {\n return Math.min(hi, Math.max(lo, v));\n}\n',
|
| 61 |
+
test: m => m.clamp(15, 0, 10) === 10 && m.clamp(-5, 0, 10) === 0 && m.clamp(5, 0, 10) === 5,
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
id: 'fizzbuzz',
|
| 65 |
+
target: 'src/fizzbuzz.js',
|
| 66 |
+
files: {
|
| 67 |
+
'src/fizzbuzz.js': 'export function fizzbuzz(n) {\n if (n % 3 === 0) return "Fizz";\n if (n % 5 === 0) return "Buzz";\n if (n % 15 === 0) return "FizzBuzz"; // BUG: inalcanzable\n return String(n);\n}\n',
|
| 68 |
+
'spec/fizzbuzz.md': '# fizzbuzz(n)\nMúltiplo de 15 → "FizzBuzz", de 3 → "Fizz", de 5 → "Buzz", si no el número.',
|
| 69 |
+
},
|
| 70 |
+
task: 'fizzbuzz(15) debería dar "FizzBuzz" pero da "Fizz"; el caso de 15 es inalcanzable, arréglalo.',
|
| 71 |
+
solution: 'export function fizzbuzz(n) {\n if (n % 15 === 0) return "FizzBuzz";\n if (n % 3 === 0) return "Fizz";\n if (n % 5 === 0) return "Buzz";\n return String(n);\n}\n',
|
| 72 |
+
test: m => m.fizzbuzz(15) === 'FizzBuzz' && m.fizzbuzz(9) === 'Fizz' && m.fizzbuzz(10) === 'Buzz' && m.fizzbuzz(7) === '7',
|
| 73 |
+
},
|
| 74 |
+
];
|
|
@@ -186,6 +186,10 @@ export async function read({ path, offset, limit, around } = {}) {
|
|
| 186 |
? Math.max(1, Math.round(around) - Math.floor(size / 2))
|
| 187 |
: Math.max(1, Math.round(offset) || 1);
|
| 188 |
const startIdx = start - 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
const slice = lines.slice(startIdx, startIdx + size);
|
| 190 |
const endLine = startIdx + slice.length;
|
| 191 |
const numbered = slice.map((l, i) => `${start + i}→${l}`).join('\n');
|
|
@@ -249,30 +253,84 @@ export async function edit({ path, search, replace } = {}) {
|
|
| 249 |
// los espacios; localizamos el bloque normalizando espacios y, si hace falta,
|
| 250 |
// por parecido de líneas — y sustituimos ESE bloque, esté donde esté (también
|
| 251 |
// en lo hondo de un fichero grande).
|
| 252 |
-
const norm = s => s.replace(/[ \t]+/g, ' ').trim();
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
}
|
| 265 |
-
if (exactHits > 1)
|
| 266 |
-
throw new Error(`«search» coincide (ignorando espacios) en más de un sitio de ${path} — añade líneas de contexto para que sea inequívoco.`);
|
| 267 |
|
| 268 |
-
|
|
|
|
| 269 |
if (start === -1) {
|
| 270 |
-
|
| 271 |
-
// exigiendo ≥70% y que gane con claridad a la segunda mejor (sin ambigüedad)
|
| 272 |
let best = -1, bestScore = 0, second = 0;
|
| 273 |
-
for (let i = 0; i + k <=
|
| 274 |
let hit = 0;
|
| 275 |
-
for (let j = 0; j < k; j++) if (
|
| 276 |
const score = hit / k;
|
| 277 |
if (score > bestScore) { second = bestScore; bestScore = score; best = i; }
|
| 278 |
else if (score > second) second = score;
|
|
@@ -283,8 +341,13 @@ export async function edit({ path, search, replace } = {}) {
|
|
| 283 |
throw new Error(`No encontré con suficiente confianza el punto exacto a editar en ${path} (ni exacto ni aproximado). ` +
|
| 284 |
`Vuelve a leerlo (code.read) y copia «search» literal de esas líneas, más corto si hace falta.`);
|
| 285 |
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
if (nextContent === current)
|
| 289 |
throw new Error(`La edición en ${path} no cambió nada — revisa «replace».`);
|
| 290 |
return write({ path, content: nextContent });
|
|
@@ -294,11 +357,16 @@ export async function edit({ path, search, replace } = {}) {
|
|
| 294 |
// existen por rendimiento, pero si se alcanzan hay que DECÍRSELO al modelo
|
| 295 |
// (igual que read() avisa «quedan líneas…»); si no, un corte silencioso se lee
|
| 296 |
// como «no hay más» y el modelo da por cerrada una búsqueda incompleta.
|
| 297 |
-
const SEARCH_MAX_FILES = 1500, SEARCH_MAX_HITS = 80;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
export async function search({ query, ext = '' } = {}) {
|
| 299 |
if (!query) throw new Error('Falta query');
|
| 300 |
if (!projectHandle) throw new Error('No hay proyecto abierto');
|
| 301 |
const results = [];
|
|
|
|
| 302 |
let checked = 0, capped = false;
|
| 303 |
const q = query.toLowerCase();
|
| 304 |
async function walk(dir, prefix) {
|
|
@@ -308,8 +376,13 @@ export async function search({ query, ext = '' } = {}) {
|
|
| 308 |
const p = prefix ? prefix + '/' + e.name : e.name;
|
| 309 |
if (e.kind === 'directory') { await walk(e, p); if (capped) return; continue; }
|
| 310 |
if (ext && !e.name.endsWith(ext)) continue;
|
|
|
|
| 311 |
const f = await e.getFile();
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
checked++;
|
| 314 |
const lines = (await f.text()).split('\n');
|
| 315 |
for (let i = 0; i < lines.length; i++) {
|
|
@@ -322,9 +395,13 @@ export async function search({ query, ext = '' } = {}) {
|
|
| 322 |
}
|
| 323 |
}
|
| 324 |
await walk(projectHandle, '');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
if (!results.length)
|
| 326 |
-
return `Sin resultados para «${query}»` + (
|
| 327 |
-
return results.join('\n') +
|
| 328 |
-
? `\n… búsqueda cortada (${results.length} resultados, ${checked} ficheros revisados) — puede haber más: afina con ext o un término más concreto.`
|
| 329 |
-
: '');
|
| 330 |
}
|
|
|
|
| 186 |
? Math.max(1, Math.round(around) - Math.floor(size / 2))
|
| 187 |
: Math.max(1, Math.round(offset) || 1);
|
| 188 |
const startIdx = start - 1;
|
| 189 |
+
// Pedir más allá del final devolvía un rango imposible («líneas 999-998 de 6»)
|
| 190 |
+
// y CERO contenido: el modelo se quedaba sin nada que hacer. Mejor decírselo.
|
| 191 |
+
if (startIdx >= total)
|
| 192 |
+
return `${path}: solo tiene ${total} líneas y pediste desde la ${start} — usa offset entre 1 y ${total}.`;
|
| 193 |
const slice = lines.slice(startIdx, startIdx + size);
|
| 194 |
const endLine = startIdx + slice.length;
|
| 195 |
const numbered = slice.map((l, i) => `${start + i}→${l}`).join('\n');
|
|
|
|
| 253 |
// los espacios; localizamos el bloque normalizando espacios y, si hace falta,
|
| 254 |
// por parecido de líneas — y sustituimos ESE bloque, esté donde esté (también
|
| 255 |
// en lo hondo de un fichero grande).
|
| 256 |
+
const norm = s => s.replace(/[ \t]+/g, ' ').trim(); // .trim() se lleva también el \r final
|
| 257 |
+
// Partimos guardando el offset de carácter de cada línea: así el bloque se
|
| 258 |
+
// sustituye por CORTE EXACTO y todo lo de fuera queda byte a byte igual
|
| 259 |
+
// (incluidos sus finales de línea). Con split/join se reescribía el fichero
|
| 260 |
+
// entero y un fichero CRLF acababa con finales de línea MEZCLADOS.
|
| 261 |
+
const curLines = [], lineStart = [];
|
| 262 |
+
for (let i = 0; i <= current.length;) {
|
| 263 |
+
const nl = current.indexOf('\n', i);
|
| 264 |
+
lineStart.push(i);
|
| 265 |
+
if (nl === -1) { curLines.push(current.slice(i)); break; }
|
| 266 |
+
curLines.push(current.slice(i, nl)); // puede acabar en \r
|
| 267 |
+
i = nl + 1;
|
| 268 |
+
}
|
| 269 |
+
const crlf = (current.match(/\r\n/g) || []).length;
|
| 270 |
+
const eol = crlf && crlf * 2 >= (current.match(/\n/g) || []).length ? '\r\n' : '\n';
|
| 271 |
+
const nCur = curLines.map(norm); // normalizamos UNA vez
|
| 272 |
+
let seaLines = search.replace(/[\r\n]+$/, '').split(/\r?\n/);
|
| 273 |
+
let repLines = replace.split(/\r?\n/);
|
| 274 |
+
|
| 275 |
+
// Posiciones donde un bloque de líneas encaja (ignorando espacios).
|
| 276 |
+
const locate = (nBlock) => {
|
| 277 |
+
const at = [];
|
| 278 |
+
for (let i = 0; i + nBlock.length <= nCur.length; i++) {
|
| 279 |
+
let same = true;
|
| 280 |
+
for (let j = 0; j < nBlock.length; j++) if (nCur[i + j] !== nBlock[j]) { same = false; break; }
|
| 281 |
+
if (same) { at.push(i); if (at.length > 8) break; }
|
| 282 |
+
}
|
| 283 |
+
return at;
|
| 284 |
+
};
|
| 285 |
+
const ambiguous = () => new Error(`«search» coincide (ignorando espacios) en más de un sitio de ${path} — añade líneas de contexto para que sea inequívoco.`);
|
| 286 |
|
| 287 |
+
let start = -1, k = seaLines.length;
|
| 288 |
+
|
| 289 |
+
// 1) el bloque ENTERO, idéntico salvo espacios → tiene que ser ÚNICO
|
| 290 |
+
{
|
| 291 |
+
const at = locate(seaLines.map(norm));
|
| 292 |
+
if (at.length > 1) throw ambiguous();
|
| 293 |
+
if (at.length === 1) start = at[0];
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
// 2) si no aparece entero, puede ser que el fichero tenga líneas que el modelo
|
| 297 |
+
// NO vio (un comentario añadido después, p. ej.) metidas dentro del bloque.
|
| 298 |
+
// Como search y replace comparten el contexto sin tocar al principio y al
|
| 299 |
+
// final, recortamos esa parte común y buscamos solo el NÚCLEO que cambia: así
|
| 300 |
+
// lo que el modelo no vio se queda donde estaba en vez de desaparecer.
|
| 301 |
+
if (start === -1) {
|
| 302 |
+
let pre = 0, suf = 0;
|
| 303 |
+
while (pre < seaLines.length && pre < repLines.length && norm(seaLines[pre]) === norm(repLines[pre])) pre++;
|
| 304 |
+
while (suf < seaLines.length - pre && suf < repLines.length - pre &&
|
| 305 |
+
norm(seaLines[seaLines.length - 1 - suf]) === norm(repLines[repLines.length - 1 - suf])) suf++;
|
| 306 |
+
const core = seaLines.slice(pre, seaLines.length - suf);
|
| 307 |
+
if (core.length) {
|
| 308 |
+
let at = locate(core.map(norm));
|
| 309 |
+
if (at.length > 1) {
|
| 310 |
+
// desempate por CONTEXTO: gana el candidato con más líneas del contexto
|
| 311 |
+
// recortado alrededor (y solo si gana en solitario).
|
| 312 |
+
const ctx = [...seaLines.slice(0, pre), ...seaLines.slice(seaLines.length - suf)].map(norm).filter(Boolean);
|
| 313 |
+
const near = i => {
|
| 314 |
+
const from = Math.max(0, i - pre - 3), to = Math.min(nCur.length, i + core.length + suf + 3);
|
| 315 |
+
const around = nCur.slice(from, to);
|
| 316 |
+
return ctx.filter(c => around.includes(c)).length;
|
| 317 |
+
};
|
| 318 |
+
const scored = at.map(i => ({ i, s: near(i) })).sort((a, b) => b.s - a.s);
|
| 319 |
+
if (ctx.length && scored[0].s > scored[1].s) at = [scored[0].i];
|
| 320 |
+
}
|
| 321 |
+
if (at.length > 1) throw ambiguous();
|
| 322 |
+
if (at.length === 1) { start = at[0]; k = core.length; repLines = repLines.slice(pre, repLines.length - suf); }
|
| 323 |
+
}
|
| 324 |
}
|
|
|
|
|
|
|
| 325 |
|
| 326 |
+
// 3) por parecido: la ventana con más líneas coincidentes, exigiendo ≥70% y
|
| 327 |
+
// que gane con claridad a la segunda mejor (sin ambigüedad)
|
| 328 |
if (start === -1) {
|
| 329 |
+
const nSea = seaLines.map(norm);
|
|
|
|
| 330 |
let best = -1, bestScore = 0, second = 0;
|
| 331 |
+
for (let i = 0; i + k <= nCur.length; i++) {
|
| 332 |
let hit = 0;
|
| 333 |
+
for (let j = 0; j < k; j++) if (nCur[i + j] === nSea[j]) hit++;
|
| 334 |
const score = hit / k;
|
| 335 |
if (score > bestScore) { second = bestScore; bestScore = score; best = i; }
|
| 336 |
else if (score > second) second = score;
|
|
|
|
| 341 |
throw new Error(`No encontré con suficiente confianza el punto exacto a editar en ${path} (ni exacto ni aproximado). ` +
|
| 342 |
`Vuelve a leerlo (code.read) y copia «search» literal de esas líneas, más corto si hace falta.`);
|
| 343 |
|
| 344 |
+
// Corte exacto del bloque [start, start+k): desde el inicio de su primera
|
| 345 |
+
// línea hasta el inicio de la siguiente (es decir, salto de línea incluido).
|
| 346 |
+
const cutFrom = lineStart[start];
|
| 347 |
+
const hasTrailingNL = start + k < lineStart.length;
|
| 348 |
+
const cutTo = hasTrailingNL ? lineStart[start + k] : current.length;
|
| 349 |
+
const block = repLines.join(eol) + (hasTrailingNL ? eol : '');
|
| 350 |
+
const nextContent = current.slice(0, cutFrom) + block + current.slice(cutTo);
|
| 351 |
if (nextContent === current)
|
| 352 |
throw new Error(`La edición en ${path} no cambió nada — revisa «replace».`);
|
| 353 |
return write({ path, content: nextContent });
|
|
|
|
| 357 |
// existen por rendimiento, pero si se alcanzan hay que DECÍRSELO al modelo
|
| 358 |
// (igual que read() avisa «quedan líneas…»); si no, un corte silencioso se lee
|
| 359 |
// como «no hay más» y el modelo da por cerrada una búsqueda incompleta.
|
| 360 |
+
const SEARCH_MAX_FILES = 1500, SEARCH_MAX_HITS = 80, SEARCH_MAX_BYTES = 2_000_000;
|
| 361 |
+
// Binarios: NO pueden contener el texto que se busca y leerlos como texto es
|
| 362 |
+
// caro. Fuera de esta lista quedan .svg/.json/.md/.csv, que son texto y sí se
|
| 363 |
+
// buscan. (Antes el tope de tamaño hacía de filtro accidental de binarios.)
|
| 364 |
+
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;
|
| 365 |
export async function search({ query, ext = '' } = {}) {
|
| 366 |
if (!query) throw new Error('Falta query');
|
| 367 |
if (!projectHandle) throw new Error('No hay proyecto abierto');
|
| 368 |
const results = [];
|
| 369 |
+
const skipped = []; // ficheros de texto NO buscados por tamaño
|
| 370 |
let checked = 0, capped = false;
|
| 371 |
const q = query.toLowerCase();
|
| 372 |
async function walk(dir, prefix) {
|
|
|
|
| 376 |
const p = prefix ? prefix + '/' + e.name : e.name;
|
| 377 |
if (e.kind === 'directory') { await walk(e, p); if (capped) return; continue; }
|
| 378 |
if (ext && !e.name.endsWith(ext)) continue;
|
| 379 |
+
if (BINARY_EXT.test(e.name)) continue;
|
| 380 |
const f = await e.getFile();
|
| 381 |
+
// Un fichero grande es justo donde el modelo NO puede leerlo entero, así
|
| 382 |
+
// que es el que más falta hace buscar. Se busca hasta SEARCH_MAX_BYTES; y
|
| 383 |
+
// lo que quede fuera se DICE (antes: >200KB se saltaba en silencio, y
|
| 384 |
+
// «Sin resultados» sonaba a «ese código no existe» siendo mentira).
|
| 385 |
+
if (f.size > SEARCH_MAX_BYTES) { skipped.push(p); continue; }
|
| 386 |
checked++;
|
| 387 |
const lines = (await f.text()).split('\n');
|
| 388 |
for (let i = 0; i < lines.length; i++) {
|
|
|
|
| 395 |
}
|
| 396 |
}
|
| 397 |
await walk(projectHandle, '');
|
| 398 |
+
// Todo lo que la búsqueda NO ha mirado se cuenta; un resultado incompleto que
|
| 399 |
+
// se presenta como completo es peor que no buscar.
|
| 400 |
+
const notes = [];
|
| 401 |
+
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`);
|
| 402 |
+
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`);
|
| 403 |
+
const tail = notes.length ? '\n… ' + notes.join('. ') + '.' : '';
|
| 404 |
if (!results.length)
|
| 405 |
+
return `Sin resultados para «${query}»` + (tail || '');
|
| 406 |
+
return results.join('\n') + tail;
|
|
|
|
|
|
|
| 407 |
}
|
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// workspace.js — carpeta de trabajo, copia legible en disco e inventario.
|
| 2 |
+
//
|
| 3 |
+
// CORE compartido por Claw y Code. El módulo no sabe qué es una «app generada»
|
| 4 |
+
// ni una «conversación»: cada app le pasa un MANIFIESTO de almacenes y él sabe
|
| 5 |
+
// leerlos, medirlos, escribirlos a disco, reimportarlos y borrarlos.
|
| 6 |
+
//
|
| 7 |
+
// Por qué existe: todo vive en IndexedDB/localStorage, que el navegador puede
|
| 8 |
+
// desalojar (o el usuario borrar con «limpiar datos del sitio») sin avisar. Con
|
| 9 |
+
// una carpeta de trabajo, tu trabajo queda en ficheros normales que puedes ver,
|
| 10 |
+
// copiar y respaldar tú.
|
| 11 |
+
//
|
| 12 |
+
// Lo que NUNCA sale a disco en claro: el vault (secretos cifrados). Se marca
|
| 13 |
+
// `sensitive` en el manifiesto y se excluye salvo petición explícita.
|
| 14 |
+
|
| 15 |
+
const HANDLE_KEY = 'workspace-handle';
|
| 16 |
+
const ROOT = '.elffuss';
|
| 17 |
+
|
| 18 |
+
let cfg = null; // { app, ns, db, stores }
|
| 19 |
+
let dirHandle = null; // FileSystemDirectoryHandle de la carpeta de trabajo
|
| 20 |
+
let ready = false; // permiso concedido AHORA
|
| 21 |
+
let dirty = new Set();
|
| 22 |
+
let listeners = [];
|
| 23 |
+
let lastSaveAt = null, lastError = null;
|
| 24 |
+
let autosaveCfg = { enabled: false, debounceMs: 3000 };
|
| 25 |
+
let autosaveTimer = null;
|
| 26 |
+
|
| 27 |
+
export function init({ app, ns, db, stores }) {
|
| 28 |
+
cfg = { app, ns, db, stores: stores || [] };
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function supported() {
|
| 32 |
+
return {
|
| 33 |
+
pick: typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function',
|
| 34 |
+
persist: !!(navigator.storage && navigator.storage.persist),
|
| 35 |
+
};
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const emit = () => { const s = statusSync(); listeners.forEach(f => { try { f(s); } catch { /* */ } }); };
|
| 39 |
+
export function onChange(fn) { listeners.push(fn); return () => { listeners = listeners.filter(f => f !== fn); }; }
|
| 40 |
+
|
| 41 |
+
function statusSync() {
|
| 42 |
+
return {
|
| 43 |
+
supported: supported(), folder: dirHandle ? dirHandle.name : null, ready,
|
| 44 |
+
dirty: [...dirty], lastSaveAt, lastError, autosave: autosaveCfg.enabled,
|
| 45 |
+
};
|
| 46 |
+
}
|
| 47 |
+
export function status() { return statusSync(); }
|
| 48 |
+
|
| 49 |
+
// ── carpeta de trabajo ───────────────────────────────────────────────────────
|
| 50 |
+
async function perm(handle, mode = 'readwrite') {
|
| 51 |
+
if (!handle?.queryPermission) return true; // OPFS u otros: dados
|
| 52 |
+
if ((await handle.queryPermission({ mode })) === 'granted') return true;
|
| 53 |
+
return false;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// En el arranque: recupera el handle SIN pedir permiso ni abrir diálogos.
|
| 57 |
+
export async function restore() {
|
| 58 |
+
if (!cfg) throw new Error('workspace.init() primero');
|
| 59 |
+
let h = null;
|
| 60 |
+
try { h = await cfg.db.get('kv', HANDLE_KEY); } catch { return null; }
|
| 61 |
+
if (!h) return null;
|
| 62 |
+
dirHandle = h;
|
| 63 |
+
ready = await perm(h);
|
| 64 |
+
emit();
|
| 65 |
+
return { name: h.name, ready, reason: ready ? null : 'prompt' };
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Elegir carpeta (requiere gesto de usuario).
|
| 69 |
+
export async function pick() {
|
| 70 |
+
if (!supported().pick) throw new Error('Este navegador no permite elegir una carpeta. Usa «Descargar copia» para guardar tu trabajo.');
|
| 71 |
+
const h = await window.showDirectoryPicker({ mode: 'readwrite', id: 'elffuss-workspace', startIn: 'documents' });
|
| 72 |
+
dirHandle = h;
|
| 73 |
+
ready = true;
|
| 74 |
+
await cfg.db.set('kv', HANDLE_KEY, h);
|
| 75 |
+
emit();
|
| 76 |
+
return { name: h.name, ready: true };
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Reutilizar un handle que la app ya tiene (Code: la carpeta del proyecto).
|
| 80 |
+
export async function adopt(handle) {
|
| 81 |
+
if (!handle) throw new Error('sin handle');
|
| 82 |
+
dirHandle = handle;
|
| 83 |
+
ready = await perm(handle);
|
| 84 |
+
await cfg.db.set('kv', HANDLE_KEY, handle);
|
| 85 |
+
emit();
|
| 86 |
+
return { name: handle.name, ready };
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Re-conceder permiso tras recargar (requiere gesto de usuario).
|
| 90 |
+
export async function regrant() {
|
| 91 |
+
if (!dirHandle) throw new Error('no hay carpeta elegida');
|
| 92 |
+
const ok = dirHandle.requestPermission
|
| 93 |
+
? (await dirHandle.requestPermission({ mode: 'readwrite' })) === 'granted'
|
| 94 |
+
: true;
|
| 95 |
+
ready = ok;
|
| 96 |
+
emit();
|
| 97 |
+
if (!ok) throw new Error('permiso denegado');
|
| 98 |
+
return { name: dirHandle.name, ready: true };
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export async function forget() {
|
| 102 |
+
const name = dirHandle?.name || null;
|
| 103 |
+
dirHandle = null; ready = false;
|
| 104 |
+
try { await cfg.db.del('kv', HANDLE_KEY); } catch { /* */ }
|
| 105 |
+
emit();
|
| 106 |
+
return { forgot: name, deleted: false }; // nunca borra los ficheros del disco
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export async function ensurePersistence() {
|
| 110 |
+
if (!navigator.storage?.persist) return { persisted: false, asked: false };
|
| 111 |
+
const already = navigator.storage.persisted ? await navigator.storage.persisted() : false;
|
| 112 |
+
if (already) return { persisted: true, asked: false };
|
| 113 |
+
const got = await navigator.storage.persist().catch(() => false);
|
| 114 |
+
return { persisted: got, asked: true };
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// ── inventario: «ver qué hay» ────────────────────────────────────────────────
|
| 118 |
+
const sizeOf = v => { try { return new Blob([typeof v === 'string' ? v : JSON.stringify(v)]).size; } catch { return 0; } };
|
| 119 |
+
|
| 120 |
+
async function readStore(st) {
|
| 121 |
+
if (st.kind === 'ls') {
|
| 122 |
+
const items = [];
|
| 123 |
+
for (const k of st.keys || []) {
|
| 124 |
+
const v = localStorage.getItem(k);
|
| 125 |
+
if (v != null) items.push({ key: k, value: v });
|
| 126 |
+
}
|
| 127 |
+
return items;
|
| 128 |
+
}
|
| 129 |
+
if (st.kind === 'idb-store') { // store entero (apps, tasks…)
|
| 130 |
+
const [ks, vs] = await Promise.all([cfg.db.keys(st.store), cfg.db.all(st.store)]);
|
| 131 |
+
return ks.map((k, i) => ({ key: String(k), value: vs[i] }));
|
| 132 |
+
}
|
| 133 |
+
// 'idb-key': una clave concreta dentro de kv (history, conversations, skills…)
|
| 134 |
+
const v = await cfg.db.get(st.store || 'kv', st.key);
|
| 135 |
+
if (v == null) return [];
|
| 136 |
+
return Array.isArray(v) ? v.map((x, i) => ({ key: String(x.id ?? x.name ?? i), value: x })) : [{ key: st.key, value: v }];
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
export async function inventory() {
|
| 140 |
+
const out = [];
|
| 141 |
+
for (const st of cfg.stores) {
|
| 142 |
+
let items = [];
|
| 143 |
+
let err = null;
|
| 144 |
+
try { items = await readStore(st); } catch (e) { err = e.message; }
|
| 145 |
+
out.push({
|
| 146 |
+
id: st.id, label: st.label, icon: st.icon || '•', sensitive: !!st.sensitive,
|
| 147 |
+
count: items.length, bytes: items.reduce((a, b) => a + sizeOf(b.value), 0),
|
| 148 |
+
note: st.note || '', error: err,
|
| 149 |
+
});
|
| 150 |
+
}
|
| 151 |
+
return out;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
export async function peek(id, { limit = 50 } = {}) {
|
| 155 |
+
const st = cfg.stores.find(s => s.id === id);
|
| 156 |
+
if (!st) throw new Error('almacén desconocido: ' + id);
|
| 157 |
+
const items = await readStore(st);
|
| 158 |
+
return {
|
| 159 |
+
id, truncated: items.length > limit,
|
| 160 |
+
items: items.slice(0, limit).map(it => ({
|
| 161 |
+
key: it.key, bytes: sizeOf(it.value),
|
| 162 |
+
preview: st.sensitive ? '(cifrado — no se muestra)'
|
| 163 |
+
: (typeof it.value === 'string' ? it.value : JSON.stringify(it.value)).slice(0, 240),
|
| 164 |
+
})),
|
| 165 |
+
};
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// Espacio real del origen, incluidas TODAS las caches (los modelos son GB).
|
| 169 |
+
export async function storageReport() {
|
| 170 |
+
const est = navigator.storage?.estimate ? await navigator.storage.estimate().catch(() => ({})) : {};
|
| 171 |
+
const persisted = navigator.storage?.persisted ? await navigator.storage.persisted().catch(() => false) : false;
|
| 172 |
+
const out = { usage: est.usage || 0, quota: est.quota || 0, persisted, caches: [] };
|
| 173 |
+
if (typeof caches !== 'undefined') {
|
| 174 |
+
for (const name of await caches.keys().catch(() => [])) {
|
| 175 |
+
const c = await caches.open(name);
|
| 176 |
+
const reqs = await c.keys();
|
| 177 |
+
let bytes = 0;
|
| 178 |
+
for (const r of reqs) {
|
| 179 |
+
const res = await c.match(r);
|
| 180 |
+
const len = +(res?.headers.get('content-length') || 0);
|
| 181 |
+
bytes += len;
|
| 182 |
+
}
|
| 183 |
+
out.caches.push({ name, entries: reqs.length, bytes });
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
return out;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// ── guardar en disco ─────────────────────────────────────────────────────────
|
| 190 |
+
export function touch(id) {
|
| 191 |
+
dirty.add(id);
|
| 192 |
+
emit();
|
| 193 |
+
if (autosaveCfg.enabled) {
|
| 194 |
+
clearTimeout(autosaveTimer);
|
| 195 |
+
autosaveTimer = setTimeout(() => { save({ reason: 'auto' }).catch(() => {}); }, autosaveCfg.debounceMs);
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
export function autosave({ enabled, debounceMs }) {
|
| 200 |
+
autosaveCfg = { enabled: !!enabled, debounceMs: debounceMs || autosaveCfg.debounceMs };
|
| 201 |
+
try { localStorage.setItem(cfg.ns + '.workspace.autosave', enabled ? '1' : '0'); } catch { /* */ }
|
| 202 |
+
emit();
|
| 203 |
+
return autosaveCfg;
|
| 204 |
+
}
|
| 205 |
+
export function autosaveEnabled() {
|
| 206 |
+
try { return localStorage.getItem(cfg.ns + '.workspace.autosave') === '1'; } catch { return false; }
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
async function dirFor(path) { // crea .elffuss/<app>/<sub> y devuelve el handle
|
| 210 |
+
let d = dirHandle;
|
| 211 |
+
for (const part of path.split('/').filter(Boolean)) d = await d.getDirectoryHandle(part, { create: true });
|
| 212 |
+
return d;
|
| 213 |
+
}
|
| 214 |
+
async function writeFile(dir, name, text) {
|
| 215 |
+
const fh = await dir.getFileHandle(name, { create: true });
|
| 216 |
+
const w = await fh.createWritable();
|
| 217 |
+
await w.write(text);
|
| 218 |
+
await w.close();
|
| 219 |
+
}
|
| 220 |
+
const safeName = s => String(s).replace(/[^\w.\-]+/g, '_').slice(0, 60) || 'item';
|
| 221 |
+
|
| 222 |
+
export async function save({ only = null, reason = 'manual' } = {}) {
|
| 223 |
+
if (!dirHandle) throw new Error('no-folder');
|
| 224 |
+
if (!(await perm(dirHandle))) { ready = false; emit(); throw new Error('needs-regrant'); }
|
| 225 |
+
const targets = cfg.stores.filter(st => !st.sensitive && (only ? only.includes(st.id) : true));
|
| 226 |
+
const wrote = [], errors = [];
|
| 227 |
+
for (const st of targets) {
|
| 228 |
+
try {
|
| 229 |
+
const items = await readStore(st);
|
| 230 |
+
const base = `${ROOT}/${cfg.app}/${st.id}`;
|
| 231 |
+
if (st.files) { // uno por fichero (apps .html, skills .md)
|
| 232 |
+
const d = await dirFor(base);
|
| 233 |
+
for (const it of items) {
|
| 234 |
+
const body = st.files.body ? st.files.body(it.value) : JSON.stringify(it.value, null, 1);
|
| 235 |
+
await writeFile(d, safeName(st.files.name ? st.files.name(it.value, it.key) : it.key) + (st.files.ext || '.json'), body);
|
| 236 |
+
}
|
| 237 |
+
} else { // uno solo (historial, ajustes)
|
| 238 |
+
const d = await dirFor(`${ROOT}/${cfg.app}`);
|
| 239 |
+
await writeFile(d, st.id + '.json', JSON.stringify(items.map(i => i.value), null, 1));
|
| 240 |
+
}
|
| 241 |
+
wrote.push({ id: st.id, files: items.length });
|
| 242 |
+
dirty.delete(st.id);
|
| 243 |
+
} catch (e) { errors.push({ id: st.id, error: e.message }); }
|
| 244 |
+
}
|
| 245 |
+
lastSaveAt = Date.now();
|
| 246 |
+
lastError = errors.length ? errors[0].error : null;
|
| 247 |
+
// índice legible con lo que hay
|
| 248 |
+
try {
|
| 249 |
+
const d = await dirFor(`${ROOT}/${cfg.app}`);
|
| 250 |
+
await writeFile(d, 'index.json', JSON.stringify({ app: cfg.app, at: lastSaveAt, reason, wrote }, null, 1));
|
| 251 |
+
} catch { /* */ }
|
| 252 |
+
emit();
|
| 253 |
+
return { ok: !errors.length, at: lastSaveAt, wrote, errors };
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// ── copia descargable (Safari/Firefox o backup manual) ───────────────────────
|
| 257 |
+
export async function bundle({ only = null, includeSensitive = false } = {}) {
|
| 258 |
+
const data = { app: cfg.app, at: Date.now(), stores: {} };
|
| 259 |
+
for (const st of cfg.stores) {
|
| 260 |
+
if (st.sensitive && !includeSensitive) continue;
|
| 261 |
+
if (only && !only.includes(st.id)) continue;
|
| 262 |
+
try { data.stores[st.id] = (await readStore(st)).map(i => i.value); } catch { /* */ }
|
| 263 |
+
}
|
| 264 |
+
return new Blob([JSON.stringify(data, null, 1)], { type: 'application/json' });
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// ── borrar ───────────────────────────────────────────────────────────────────
|
| 268 |
+
export async function wipe({ only = null, confirm } = {}) {
|
| 269 |
+
if (confirm !== 'BORRAR') throw new Error('confirmación requerida');
|
| 270 |
+
const removed = [];
|
| 271 |
+
for (const st of cfg.stores) {
|
| 272 |
+
if (only && !only.includes(st.id)) continue;
|
| 273 |
+
try {
|
| 274 |
+
if (st.kind === 'ls') { for (const k of st.keys || []) localStorage.removeItem(k); removed.push({ id: st.id }); }
|
| 275 |
+
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 }); }
|
| 276 |
+
else { await cfg.db.del(st.store || 'kv', st.key); removed.push({ id: st.id }); }
|
| 277 |
+
} catch (e) { removed.push({ id: st.id, error: e.message }); }
|
| 278 |
+
}
|
| 279 |
+
emit();
|
| 280 |
+
return { removed };
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
export async function clearCaches({ names } = {}) {
|
| 284 |
+
if (typeof caches === 'undefined') return { deleted: [] };
|
| 285 |
+
const target = names || await caches.keys();
|
| 286 |
+
const deleted = [];
|
| 287 |
+
for (const n of target) if (await caches.delete(n)) deleted.push(n);
|
| 288 |
+
return { deleted };
|
| 289 |
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="es">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Elffuss Lab — banco de pruebas</title>
|
| 7 |
+
<link rel="icon" href="data:,">
|
| 8 |
+
<link rel="stylesheet" href="css/lab.css">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
|
| 12 |
+
<header>
|
| 13 |
+
<h1>Elffuss <span>Lab</span></h1>
|
| 14 |
+
<div class="sub">banco de pruebas · algoritmos y compresión de contexto</div>
|
| 15 |
+
<div class="spacer"></div>
|
| 16 |
+
<div class="led" id="led-model"><i></i><span>modelo: sin cargar</span></div>
|
| 17 |
+
<div class="led" id="led-run"><i></i><span>en reposo</span></div>
|
| 18 |
+
<a class="sub" href="index.html">← volver al IDE</a>
|
| 19 |
+
</header>
|
| 20 |
+
|
| 21 |
+
<main>
|
| 22 |
+
<!-- ══════════ IZQUIERDA: configuración del experimento ══════════ -->
|
| 23 |
+
<section>
|
| 24 |
+
<div class="panel">
|
| 25 |
+
<h2>Cerebro <span class="tag" id="brain-tag">—</span></h2>
|
| 26 |
+
<div class="body">
|
| 27 |
+
<div class="seg" id="brains"></div>
|
| 28 |
+
<p class="hint">El banco usa el mismo proveedor que el IDE. «Reglas» no
|
| 29 |
+
gasta GPU y sirve para validar el arnés; los benchmarks que exigen
|
| 30 |
+
generación real necesitan un modelo de verdad.</p>
|
| 31 |
+
<div class="row" style="margin-top:10px">
|
| 32 |
+
<button class="btn" id="btn-load">Cargar cerebro</button>
|
| 33 |
+
<span class="sub" id="load-state"></span>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div class="panel">
|
| 39 |
+
<h2>Algoritmo de generación <span class="tag" id="algo-tag">directo</span></h2>
|
| 40 |
+
<div class="body">
|
| 41 |
+
<div class="seg" id="algos"></div>
|
| 42 |
+
<div class="group-title">parámetros</div>
|
| 43 |
+
<div class="knobs" id="algo-knobs"></div>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div class="panel">
|
| 48 |
+
<h2>Compresión de contexto (ACER) <span class="tag" id="ctx-tag">—</span></h2>
|
| 49 |
+
<div class="body">
|
| 50 |
+
<div class="row" style="margin-bottom:8px">
|
| 51 |
+
<button class="btn ghost" id="ctx-all">Todo ON</button>
|
| 52 |
+
<button class="btn ghost" id="ctx-none">Todo OFF</button>
|
| 53 |
+
<button class="btn ghost" id="ctx-def">Por defecto</button>
|
| 54 |
+
</div>
|
| 55 |
+
<div id="ctx-knobs"></div>
|
| 56 |
+
<p class="hint">Cada perilla es la del motor real (<code>acer-core.js</code>).
|
| 57 |
+
Lo que marques aquí es lo que se le pasa a <code>packHistoryACER</code>
|
| 58 |
+
en el banco de contexto y lo que se guarda al lanzar el IDE.</p>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
</section>
|
| 62 |
+
|
| 63 |
+
<!-- ══════════ DERECHA: ejecución y resultados ══════════ -->
|
| 64 |
+
<section>
|
| 65 |
+
<div class="panel">
|
| 66 |
+
<h2>Banco de pruebas <span class="tag" id="bench-tag">—</span></h2>
|
| 67 |
+
<div class="body">
|
| 68 |
+
<div class="seg" id="benches"></div>
|
| 69 |
+
<div class="row" style="margin-top:12px">
|
| 70 |
+
<button class="btn primary" id="btn-run">▶ Lanzar benchmark</button>
|
| 71 |
+
<button class="btn" id="btn-stop" disabled>■ Parar</button>
|
| 72 |
+
<button class="btn" id="btn-save">★ Guardar en el ranking</button>
|
| 73 |
+
<button class="btn ghost" id="btn-launch">Lanzar el IDE con esta configuración</button>
|
| 74 |
+
</div>
|
| 75 |
+
<div class="bar" style="margin-top:12px"><i id="prog"></i></div>
|
| 76 |
+
<div class="sub" id="prog-txt" style="margin-top:6px">—</div>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div class="panel">
|
| 81 |
+
<h2>Métricas en vivo</h2>
|
| 82 |
+
<div class="body"><div class="tiles" id="tiles"></div></div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div class="panel">
|
| 86 |
+
<h2>Resultados por caso</h2>
|
| 87 |
+
<div class="body" style="padding:0">
|
| 88 |
+
<table><thead id="res-head"></thead><tbody id="res-body"></tbody></table>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div class="panel" id="arena-panel" hidden>
|
| 93 |
+
<h2>Arena <span class="tag">lo generado, ejecutándose</span></h2>
|
| 94 |
+
<div class="body"><iframe class="arena" id="arena" sandbox="allow-scripts allow-same-origin"></iframe></div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<div class="panel">
|
| 98 |
+
<h2>Ranking local <span class="tag" id="rank-tag">—</span></h2>
|
| 99 |
+
<div class="body" style="padding:0">
|
| 100 |
+
<table>
|
| 101 |
+
<thead><tr><th>#</th><th>score</th><th>configuración</th><th>cuándo</th><th></th></tr></thead>
|
| 102 |
+
<tbody id="rank-body"></tbody>
|
| 103 |
+
</table>
|
| 104 |
+
</div>
|
| 105 |
+
<div class="body" style="border-top:1px solid var(--line)">
|
| 106 |
+
<div class="row">
|
| 107 |
+
<span class="pill">todo en esta máquina · no se envía nada</span>
|
| 108 |
+
<div class="spacer" style="flex:1"></div>
|
| 109 |
+
<button class="btn ghost" id="btn-clear">Borrar historial</button>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<div class="panel">
|
| 115 |
+
<h2>Consola</h2>
|
| 116 |
+
<div class="body"><div class="log" id="log"></div></div>
|
| 117 |
+
</div>
|
| 118 |
+
</section>
|
| 119 |
+
</main>
|
| 120 |
+
|
| 121 |
+
<script type="module" src="js/lab.js"></script>
|
| 122 |
+
</body>
|
| 123 |
+
</html>
|
|
@@ -1,28 +0,0 @@
|
|
| 1 |
-
body {
|
| 2 |
-
padding: 2rem;
|
| 3 |
-
font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
h1 {
|
| 7 |
-
font-size: 16px;
|
| 8 |
-
margin-top: 0;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
p {
|
| 12 |
-
color: rgb(107, 114, 128);
|
| 13 |
-
font-size: 15px;
|
| 14 |
-
margin-bottom: 10px;
|
| 15 |
-
margin-top: 5px;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.card {
|
| 19 |
-
max-width: 620px;
|
| 20 |
-
margin: 0 auto;
|
| 21 |
-
padding: 16px;
|
| 22 |
-
border: 1px solid lightgray;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
.card p:last-child {
|
| 27 |
-
margin-bottom: 0;
|
| 28 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|