Spaces:
Running
Running
Elffuss Code — browser IDE with a local AI agent (WebGPU)
Browse files- .gitattributes +1 -0
- README.md +42 -4
- css/code.css +518 -0
- img/elffuss-code.svg +44 -0
- index.html +149 -17
- js/agent.js +219 -0
- js/arch.js +0 -0
- js/bridge.js +101 -0
- js/ceo.js +245 -0
- js/city.js +157 -0
- js/context.js +91 -0
- js/conversations.js +249 -0
- js/db.js +32 -0
- js/goal.js +147 -0
- js/humanize.js +48 -0
- js/icons.js +67 -0
- js/ide.js +431 -0
- js/main.js +1510 -0
- js/md.js +0 -0
- js/mind-adapter.js +51 -0
- js/mind.js +609 -0
- js/model-cache.js +51 -0
- js/model-config.js +23 -0
- js/providers/api.js +116 -0
- js/providers/litert.js +136 -0
- js/providers/onnx.js +63 -0
- js/providers/rules.js +48 -0
- js/settings.js +59 -0
- js/shell.js +412 -0
- js/skills.js +144 -0
- js/splash-gl.js +116 -0
- js/telemetry.js +63 -0
- js/terminal.js +140 -0
- js/tools/clone.js +125 -0
- js/tools/code.js +269 -0
- js/tools/index.js +57 -0
- js/tools/web.js +52 -0
- js/vcc/NOTICE.md +8 -0
- js/vcc/builder.js +668 -0
- js/vcc/model.js +360 -0
- js/vcc/ownership.js +5 -0
- js/vcc/theme.js +28 -0
- og.png +3 -0
- package.json +6 -0
- sw.js +43 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
og.png filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,10 +1,48 @@
|
|
| 1 |
---
|
| 2 |
title: Elffuss Code
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Elffuss Code
|
| 3 |
+
emoji: 🧑💻
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
short_description: A VS Code-style IDE with a local AI agent, in-browser
|
| 11 |
+
tags:
|
| 12 |
+
- webgpu
|
| 13 |
+
- transformers.js
|
| 14 |
+
- local-llm
|
| 15 |
+
- agent
|
| 16 |
+
- code
|
| 17 |
+
- gemma
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# 🧑💻 Elffuss Code — a VS Code-style IDE with an AI soul, 100% in your browser
|
| 21 |
+
|
| 22 |
+
Open a local folder and a full web IDE unfolds around it — real Monaco (the VS Code editor),
|
| 23 |
+
a file tree, tabs — with an AI agent that **reads, searches and edits your project for real**.
|
| 24 |
+
The model runs on **your** GPU via WebGPU. Your code never leaves your machine.
|
| 25 |
+
|
| 26 |
+
> ⚠️ **Open it full-screen** ([direct link](https://kikocis-elffuss-code.static.hf.space)) — the
|
| 27 |
+
> File System Access API (opening a local folder) is blocked inside the embedded iframe.
|
| 28 |
+
|
| 29 |
+

|
| 30 |
+
|
| 31 |
+
In the demo a **small model running entirely in the browser** reads `calc.py`, finds a real
|
| 32 |
+
bug and fixes it with a minimal edit — genuine tool-calling, not autocomplete.
|
| 33 |
+
|
| 34 |
+
## Why it's different
|
| 35 |
+
|
| 36 |
+
- **Your code stays local.** The folder is opened via the File System Access API — no upload, no account.
|
| 37 |
+
- **The model runs on your GPU** (WebGPU, Gemma-4 via LiteRT-LM; ONNX/transformers.js + Qwen2.5-0.5B as fallback).
|
| 38 |
+
- **Real tool-calling**: `code.tree`, `code.read`, `code.search`, `code.edit`, `code.write` — you watch every call.
|
| 39 |
+
- **Real editor**: Monaco, file tree, tabs, `Ctrl+S`.
|
| 40 |
+
- **Zero install, zero build.** Vanilla ES modules.
|
| 41 |
+
- External providers (OpenAI, Anthropic, Ollama) are **opt-in**; keys stay in your browser.
|
| 42 |
+
|
| 43 |
+
## Links
|
| 44 |
+
|
| 45 |
+
- 🌐 Full app: <https://elffuss-code.utopiaia.com>
|
| 46 |
+
- 💻 Source (Apache-2.0): <https://github.com/KikoCis/elffuss-code>
|
| 47 |
+
- ✳️ Sibling project — Elffuss Claw (agentic OS): <https://github.com/KikoCis/elffuss-claw>
|
| 48 |
+
- 🧬 Shared core: <https://github.com/KikoCis/elffuss>
|
css/code.css
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Elffuss Code — VS Code oscuro con alma élfica */
|
| 2 |
+
:root {
|
| 3 |
+
--bg: #0b0d12; --bg2: #11141c; --bg3: #171b26; --line: #262c3d;
|
| 4 |
+
--fg: #e8ebf4; --muted: #8b93a8; --accent: #ff4d8d; --accent2: #7c5cff;
|
| 5 |
+
}
|
| 6 |
+
* { box-sizing: border-box; }
|
| 7 |
+
html, body { height: 100%; margin: 0; }
|
| 8 |
+
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); overflow: hidden; }
|
| 9 |
+
|
| 10 |
+
/* ---------- landing ---------- */
|
| 11 |
+
#landing {
|
| 12 |
+
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
| 13 |
+
background: radial-gradient(1100px 700px at 50% 30%, #221740, #0b0d12 72%); text-align: center;
|
| 14 |
+
}
|
| 15 |
+
#landing[hidden] { display: none; }
|
| 16 |
+
#landing canvas.gl { position: absolute; inset: 0; width: 100%; height: 100%; }
|
| 17 |
+
#landing .l-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px; }
|
| 18 |
+
#landing .hero { width: 170px; filter: drop-shadow(0 12px 40px rgba(124,92,255,.35)); animation: float 5s ease-in-out infinite; }
|
| 19 |
+
@keyframes float { 50% { transform: translateY(-9px); } }
|
| 20 |
+
#landing h1 { margin: 4px 0 0; font-size: 2rem; }
|
| 21 |
+
#landing h1 b { background: linear-gradient(90deg, var(--accent), var(--accent2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
| 22 |
+
#landing .tag { color: var(--muted); margin: 0; line-height: 1.5; }
|
| 23 |
+
#landing .tag b { color: var(--accent); }
|
| 24 |
+
#open-project {
|
| 25 |
+
margin-top: 12px; padding: 14px 34px; font-size: 1.08rem; cursor: pointer; color: #fff;
|
| 26 |
+
border: none; border-radius: 999px; background: linear-gradient(135deg, var(--accent), var(--accent2));
|
| 27 |
+
box-shadow: 0 8px 30px rgba(255,77,141,.35);
|
| 28 |
+
}
|
| 29 |
+
#open-project:hover { filter: brightness(1.12); }
|
| 30 |
+
#landing .ghost, .ghost {
|
| 31 |
+
background: none; color: var(--muted); border: 1px solid var(--line);
|
| 32 |
+
border-radius: 8px; padding: 8px 14px; cursor: pointer;
|
| 33 |
+
}
|
| 34 |
+
#landing .mini { font-size: .75rem; color: #5a6480; }
|
| 35 |
+
.resume-clone { display: flex; align-items: center; gap: 8px; margin-top: 8px; background: rgba(124,92,255,.12); border: 1px solid var(--accent2); border-radius: 999px; padding: 7px 8px 7px 16px; font-size: .78rem; color: var(--fg); }
|
| 36 |
+
#resume-clone-btn { background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; color: #fff; border-radius: 999px; padding: 6px 14px; font-size: .76rem; cursor: pointer; }
|
| 37 |
+
.clone-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
| 38 |
+
.clone-or { font-size: .72rem; color: #5a6480; }
|
| 39 |
+
#clone-url { width: 300px; max-width: 60vw; background: var(--bg3); border: 1px solid var(--line); color: var(--fg); border-radius: 999px; padding: 9px 16px; font-size: .82rem; }
|
| 40 |
+
#clone-url:focus { outline: none; border-color: var(--accent2); }
|
| 41 |
+
#clone-btn { background: none; color: var(--fg); border: 1px solid var(--accent2); border-radius: 999px; padding: 9px 16px; font-size: .82rem; cursor: pointer; white-space: nowrap; }
|
| 42 |
+
#clone-btn:hover { background: rgba(124,92,255,.15); }
|
| 43 |
+
#clone-btn:disabled { opacity: .5; cursor: default; }
|
| 44 |
+
.clone-status { min-height: 1.1em; }
|
| 45 |
+
.clone-status.err { color: #ff6b8b; }
|
| 46 |
+
@media (max-width: 560px) { .clone-row { flex-direction: column; } #clone-url { width: 78vw; } }
|
| 47 |
+
|
| 48 |
+
/* ---------- IDE ---------- */
|
| 49 |
+
#ide { display: flex; flex-direction: column; height: 100vh; }
|
| 50 |
+
#ide[hidden] { display: none; } /* el display:flex le ganaba a [hidden] → el IDE asomaba tras la landing */
|
| 51 |
+
header {
|
| 52 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 53 |
+
padding: 6px 12px; background: var(--bg2); border-bottom: 1px solid var(--line);
|
| 54 |
+
}
|
| 55 |
+
.brand { display: flex; align-items: center; gap: 8px; }
|
| 56 |
+
.brand img { width: 26px; height: 26px; }
|
| 57 |
+
.proj { color: var(--muted); font-size: .85rem; }
|
| 58 |
+
.controls { display: flex; align-items: center; gap: 8px; font-size: .9rem; }
|
| 59 |
+
select { background: var(--bg3); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 4px 6px; }
|
| 60 |
+
.dot { width: 9px; height: 9px; border-radius: 50%; }
|
| 61 |
+
.dot.off { background: #4a5062; } .dot.on { background: #3fb970; }
|
| 62 |
+
.dot.loading { background: #e5a53a; animation: pulse 1.2s infinite; }
|
| 63 |
+
@keyframes pulse { 50% { opacity: .35; } }
|
| 64 |
+
|
| 65 |
+
#settings-panel { display: flex; gap: 10px; padding: 10px 12px; background: var(--bg2); border-bottom: 1px solid var(--line); overflow-x: auto; position: relative; }
|
| 66 |
+
#settings-panel[hidden] { display: none; } /* ¡el display:flex le ganaba a hidden! */
|
| 67 |
+
#settings-close { position: absolute; top: 6px; right: 10px; background: none; border: none; color: var(--muted); font-size: 1.1rem; cursor: pointer; }
|
| 68 |
+
#settings-close:hover { color: var(--accent); }
|
| 69 |
+
#settings-panel .card { background: var(--bg3); border: 1px solid var(--line); border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 6px; font-size: .8rem; min-width: 200px; }
|
| 70 |
+
#settings-panel input[type=text], #settings-panel input:not([type]) , #settings-panel input[type=password] { background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 5px 8px; font-size: .78rem; }
|
| 71 |
+
|
| 72 |
+
main { flex: 1; display: grid; grid-template-columns: 46px 235px minmax(0, 1fr) minmax(440px, 34%); grid-template-rows: minmax(0, 1fr); min-height: 0; overflow: hidden; }
|
| 73 |
+
body.hide-tree main { grid-template-columns: 46px 0 minmax(0, 1fr) minmax(440px, 34%); }
|
| 74 |
+
body.hide-tree #sidebar { display: none; }
|
| 75 |
+
|
| 76 |
+
/* barra de actividad estilo VS Code */
|
| 77 |
+
#activity {
|
| 78 |
+
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
| 79 |
+
background: #0d0f15; border-right: 1px solid var(--line); padding: 8px 0;
|
| 80 |
+
}
|
| 81 |
+
#activity button {
|
| 82 |
+
width: 44px; height: 42px; background: none; border: none; cursor: pointer;
|
| 83 |
+
color: #6b7391; display: flex; align-items: center; justify-content: center;
|
| 84 |
+
border-left: 2px solid transparent;
|
| 85 |
+
}
|
| 86 |
+
#activity button:hover { color: var(--fg); }
|
| 87 |
+
#activity button.on { color: var(--fg); border-left-color: var(--accent); }
|
| 88 |
+
#activity img { width: 26px; height: 26px; margin-top: auto; margin-bottom: 6px; opacity: .85; }
|
| 89 |
+
|
| 90 |
+
/* explorador */
|
| 91 |
+
#sidebar { display: flex; flex-direction: column; min-height: 0; background: var(--bg2); border-right: 1px solid var(--line); }
|
| 92 |
+
#explorer-head {
|
| 93 |
+
padding: 8px 12px 6px; font-size: .68rem; letter-spacing: .08em; color: var(--muted);
|
| 94 |
+
display: flex; gap: 8px; align-items: baseline;
|
| 95 |
+
}
|
| 96 |
+
#explorer-head span { color: var(--fg); font-weight: 700; }
|
| 97 |
+
#tree { flex: 1; overflow: auto; padding: 0 4px 8px; font-size: .82rem; }
|
| 98 |
+
#tree ul { list-style: none; margin: 0; padding-left: 10px; border-left: 1px solid #1d2230; }
|
| 99 |
+
#tree > ul { border-left: none; padding-left: 4px; }
|
| 100 |
+
#tree summary { cursor: pointer; color: #c8cede; padding: 2px 0; display: flex; align-items: center; gap: 4px; user-select: none; }
|
| 101 |
+
#tree summary::-webkit-details-marker, #tree summary::marker { display: none; content: ''; }
|
| 102 |
+
#tree details[open] > summary .chevron { transform: rotate(90deg); transform-origin: 8px 8px; }
|
| 103 |
+
#tree .chevron { transition: transform .12s; }
|
| 104 |
+
#tree summary:hover, #tree .file:hover { color: #fff; background: #ffffff0a; border-radius: 4px; }
|
| 105 |
+
#tree .file { cursor: pointer; display: flex; align-items: center; gap: 5px; padding: 2px 0 2px 16px; color: #b8c0d4; }
|
| 106 |
+
.fico { flex: 0 0 auto; vertical-align: -3px; }
|
| 107 |
+
|
| 108 |
+
#editor-wrap { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
| 109 |
+
#tabs-bar { display: flex; background: #0d0f15; border-bottom: 1px solid var(--line); overflow-x: auto; min-height: 34px; }
|
| 110 |
+
.tab { display: flex; align-items: center; gap: 6px; padding: 6px 10px; font-size: .8rem; color: var(--muted); border-right: 1px solid var(--line); cursor: pointer; white-space: nowrap; }
|
| 111 |
+
.tab.active { background: var(--bg); color: var(--fg); box-shadow: inset 0 2px 0 var(--accent); }
|
| 112 |
+
.tab.tab-preview .tab-name { font-style: italic; }
|
| 113 |
+
.tab .tab-ico { display: flex; }
|
| 114 |
+
.tab b { cursor: pointer; opacity: .55; width: 14px; text-align: center; }
|
| 115 |
+
.tab b:hover { opacity: 1; color: var(--accent); }
|
| 116 |
+
.tab b.dirty { opacity: 1; color: var(--fg); }
|
| 117 |
+
.tab b.dirty:hover::before { content: ''; }
|
| 118 |
+
#editor { flex: 1; min-height: 0; }
|
| 119 |
+
#editor-empty { position: absolute; inset: 34px 0 22px 0; display: none; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: var(--bg); color: var(--muted); z-index: 2; text-align: center; }
|
| 120 |
+
#editor-empty img { width: 84px; opacity: .5; }
|
| 121 |
+
#editor-empty p { font-size: .9rem; line-height: 1.5; }
|
| 122 |
+
|
| 123 |
+
/* la elfa de la barra: animada cuando el cerebro trabaja (invita a hacer clic) */
|
| 124 |
+
#activity img { transition: transform .2s; cursor: pointer; }
|
| 125 |
+
#activity img:hover { transform: scale(1.12); }
|
| 126 |
+
#activity img.working { animation: elfWorking 1.5s ease-in-out infinite; }
|
| 127 |
+
@keyframes elfWorking {
|
| 128 |
+
0%, 100% { transform: scale(1); filter: drop-shadow(0 0 3px rgba(124,92,255,.5)); }
|
| 129 |
+
50% { transform: scale(1.14); filter: drop-shadow(0 0 12px rgba(255,77,141,.95)); }
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/* ── Mente de Elffuss (cerebro CEO · mundo trance) ─────────────────────── */
|
| 133 |
+
#mind-overlay { position: fixed; inset: 0; z-index: 300; background: radial-gradient(1200px 800px at 50% 45%, #1a0a2e, #05030c 78%); overflow: hidden; }
|
| 134 |
+
#mind-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
| 135 |
+
#mind-title { position: absolute; top: 16px; left: 50%; transform: translateX(-50%); text-align: center; font-size: 1.1rem; font-weight: 800; letter-spacing: .18em; color: #fff; text-shadow: 0 0 22px #ff4d8d; pointer-events: none; }
|
| 136 |
+
#mind-title span { display: block; font-size: .66rem; font-weight: 500; letter-spacing: .06em; color: #b9a7ff; margin-top: 4px; text-shadow: none; }
|
| 137 |
+
#mind-close { position: absolute; top: 14px; right: 16px; z-index: 6; background: rgba(255,77,141,.14); border: 1px solid #ff4d8d; color: #ffd7e6; border-radius: 999px; padding: 7px 14px; font-size: .8rem; cursor: pointer; backdrop-filter: blur(6px); }
|
| 138 |
+
#mind-close:hover { background: rgba(255,77,141,.28); }
|
| 139 |
+
#mind-history { position: absolute; top: 14px; right: 182px; z-index: 6; background: rgba(73,232,221,.14); border: 1px solid #49e8ff; color: #bfeef6; border-radius: 999px; padding: 7px 12px; font-size: .78rem; cursor: pointer; backdrop-filter: blur(6px); }
|
| 140 |
+
#mind-history:hover { background: rgba(73,232,221,.28); }
|
| 141 |
+
#mind-playstop { position: absolute; top: 14px; right: 278px; z-index: 6; background: rgba(63,185,112,.16); border: 1px solid #3fb970; color: #b7f2cf; border-radius: 999px; padding: 7px 12px; font-size: .78rem; cursor: pointer; backdrop-filter: blur(6px); }
|
| 142 |
+
#mind-playstop:hover { background: rgba(63,185,112,.3); }
|
| 143 |
+
#mind-playstop.paused { background: rgba(255,180,70,.16); border-color: #ffb446; color: #ffe0b0; }
|
| 144 |
+
#mind-playstop.paused:hover { background: rgba(255,180,70,.3); }
|
| 145 |
+
#mind-config { position: absolute; top: 14px; right: 96px; z-index: 6; background: rgba(124,92,255,.16); border: 1px solid #7c5cff; color: #d8ccff; border-radius: 999px; padding: 7px 12px; font-size: .78rem; cursor: pointer; backdrop-filter: blur(6px); }
|
| 146 |
+
#mind-config:hover { background: rgba(124,92,255,.3); }
|
| 147 |
+
/* leyenda de perfiles: fija, no flota (siempre legible) */
|
| 148 |
+
#mind-legend { position: absolute; top: 60px; left: 14px; z-index: 5; display: flex; flex-direction: column; gap: 5px; background: rgba(8,5,18,.5); border: 1px solid rgba(124,92,255,.3); border-radius: 10px; padding: 8px 12px; backdrop-filter: blur(6px); pointer-events: auto; }
|
| 149 |
+
.ml-item { font-size: .68rem; color: #cfe8f0; display: flex; align-items: center; gap: 7px; cursor: pointer; padding: 2px 4px; border-radius: 5px; }
|
| 150 |
+
.ml-item:hover { background: rgba(255,255,255,.08); }
|
| 151 |
+
.ml-sw { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 6px currentColor; flex: none; }
|
| 152 |
+
/* solo etiquetas de propuestas forjadas flotan ancladas al mundo */
|
| 153 |
+
#mind-anchors { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
|
| 154 |
+
.mind-node-label { position: absolute; font-size: .6rem; color: #ffd7e6; background: rgba(255,77,141,.18); border: 1px solid rgba(255,77,141,.5); border-radius: 6px; padding: 1px 6px; pointer-events: none; white-space: nowrap; text-shadow: 0 0 8px #ff4d8d; }
|
| 155 |
+
/* historial completo: TODO lo que ha llegado, sin recortar */
|
| 156 |
+
#mind-log { position: absolute; top: 0; right: -380px; width: 360px; height: 100%; z-index: 8; display: flex; flex-direction: column; background: rgba(6,4,14,.92); border-left: 1px solid rgba(73,232,221,.35); backdrop-filter: blur(10px); transition: right .25s ease; }
|
| 157 |
+
#mind-log.show { right: 0; }
|
| 158 |
+
.ml-head { display: flex; align-items: center; padding: 12px 14px; font-size: .82rem; font-weight: 700; color: #bfeef6; border-bottom: 1px solid rgba(73,232,221,.25); }
|
| 159 |
+
.ml-head button { margin-left: auto; background: none; border: none; color: #7fd9d0; cursor: pointer; font-size: .9rem; }
|
| 160 |
+
#mind-log-body { flex: 1; overflow-y: auto; padding: 8px 10px; }
|
| 161 |
+
.ml-row { font-size: .68rem; line-height: 1.6; padding: 3px 4px; border-radius: 5px; display: flex; gap: 6px; align-items: baseline; }
|
| 162 |
+
.ml-row:hover { background: rgba(255,255,255,.04); }
|
| 163 |
+
.ml-row .ml-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }
|
| 164 |
+
.ml-row .ml-t { color: #dfeef2; word-break: break-word; }
|
| 165 |
+
/* panel flotante con el .md del pensamiento (clic en un nodo) */
|
| 166 |
+
#mind-panel { position: absolute; top: 70px; right: 16px; width: min(420px, 40vw); max-height: 70vh; z-index: 6; display: none; flex-direction: column; background: rgba(8,5,18,.9); border: 1px solid #7c5cff; border-radius: 12px; overflow: hidden; box-shadow: 0 12px 50px rgba(0,0,0,.6); }
|
| 167 |
+
#mind-panel.show { display: flex; }
|
| 168 |
+
#mind-panel .mp-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: .74rem; color: #d8ccff; background: rgba(124,92,255,.14); border-bottom: 1px solid rgba(124,92,255,.3); }
|
| 169 |
+
#mind-panel .mp-head button { margin-left: auto; background: none; border: none; color: #b9a7ff; cursor: pointer; font-size: .9rem; }
|
| 170 |
+
#mind-panel .mp-body { flex: 1; overflow: auto; margin: 0; padding: 12px; font-size: .78rem; line-height: 1.55; color: #cfe8f0; }
|
| 171 |
+
#mind-panel .mp-body h1, #mind-panel .mp-body h2, #mind-panel .mp-body h3 { color: #fff; margin: 12px 0 6px; }
|
| 172 |
+
#mind-panel .mp-body code { background: rgba(124,92,255,.18); padding: 1px 5px; border-radius: 4px; font-size: .82em; }
|
| 173 |
+
#mind-panel .mp-body pre { background: #0b0716; border: 1px solid rgba(124,92,255,.3); border-radius: 8px; padding: 8px; overflow-x: auto; }
|
| 174 |
+
#mind-panel .mp-body ul, #mind-panel .mp-body ol { padding-left: 1.2em; }
|
| 175 |
+
.mp-actions { display: flex; gap: 8px; padding: 0 12px 12px; }
|
| 176 |
+
.mp-exec { flex: 1; background: linear-gradient(135deg, #ff4d8d, #7c5cff); border: none; color: #fff; border-radius: 8px; padding: 9px; cursor: pointer; font-size: .78rem; font-weight: 600; }
|
| 177 |
+
.mp-open { flex: 1; background: rgba(124,92,255,.15); border: 1px solid #7c5cff; color: #d8ccff; border-radius: 8px; padding: 9px; cursor: pointer; font-size: .78rem; }
|
| 178 |
+
/* panel de configuración del cerebro */
|
| 179 |
+
#mind-cfg { position: absolute; top: 60px; left: 50%; transform: translateX(-50%); width: min(520px, 92vw); z-index: 7; display: none; flex-direction: column; gap: 6px; background: rgba(8,5,18,.94); border: 1px solid #7c5cff; border-radius: 14px; padding: 14px 16px; box-shadow: 0 16px 60px rgba(0,0,0,.6); }
|
| 180 |
+
#mind-cfg.show { display: flex; }
|
| 181 |
+
.cfg-head { display: flex; align-items: center; font-size: .9rem; font-weight: 700; color: #fff; margin-bottom: 4px; }
|
| 182 |
+
.cfg-head button { margin-left: auto; background: none; border: none; color: #b9a7ff; cursor: pointer; }
|
| 183 |
+
.cfg-notif { font-size: .68rem; color: #9fb; background: rgba(63,185,112,.1); border: 1px solid rgba(63,185,112,.3); border-radius: 8px; padding: 6px 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
| 184 |
+
.bridge-card #br-token, .bridge-card #br-folder { background: var(--bg); border: 1px solid var(--line); color: var(--fg); border-radius: 6px; padding: 6px 9px; font-size: .78rem; width: 100%; }
|
| 185 |
+
.bridge-card details { color: var(--muted); }
|
| 186 |
+
.bridge-card details summary::marker { color: var(--accent2); }
|
| 187 |
+
#fireworks-fx { position: fixed; inset: 0; z-index: 999; pointer-events: none; }
|
| 188 |
+
.cfg-notif-btn { background: #3fb970; border: none; color: #06180d; border-radius: 6px; padding: 3px 10px; font-size: .68rem; font-weight: 700; cursor: pointer; }
|
| 189 |
+
#mind-cfg label { font-size: .68rem; color: #b9a7ff; margin-top: 4px; }
|
| 190 |
+
#mind-cfg textarea, #mind-cfg input:not([type=color]) { background: #0b0716; border: 1px solid rgba(124,92,255,.4); color: #e8ebf4; border-radius: 8px; padding: 8px 10px; font-size: .78rem; font-family: inherit; resize: vertical; }
|
| 191 |
+
#cfg-profiles { display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow-y: auto; }
|
| 192 |
+
.cfg-prof { display: grid; grid-template-columns: 30px 1fr 1.4fr 26px; gap: 6px; align-items: center; }
|
| 193 |
+
.cfg-prof input[type=color] { width: 30px; height: 30px; padding: 0; border: 1px solid rgba(124,92,255,.4); border-radius: 6px; background: none; cursor: pointer; }
|
| 194 |
+
.cfg-prof input[type=text] { background: #0b0716; border: 1px solid rgba(124,92,255,.4); color: #e8ebf4; border-radius: 6px; padding: 6px 8px; font-size: .72rem; }
|
| 195 |
+
.cp-del { background: none; border: none; color: #ff8fb0; cursor: pointer; font-size: .8rem; }
|
| 196 |
+
.cfg-add { align-self: flex-start; background: rgba(124,92,255,.15); border: 1px dashed rgba(124,92,255,.5); color: #d8ccff; border-radius: 8px; padding: 6px 12px; font-size: .74rem; cursor: pointer; margin-top: 4px; }
|
| 197 |
+
.cfg-actions { display: flex; gap: 8px; margin-top: 8px; }
|
| 198 |
+
.cfg-ghost { flex: 1; background: rgba(73,232,221,.14); border: 1px solid #49e8ff; color: #bfeef6; border-radius: 8px; padding: 9px; cursor: pointer; font-size: .78rem; }
|
| 199 |
+
.cfg-save { flex: 1; margin-top: 0; background: linear-gradient(135deg, #ff4d8d, #7c5cff); border: none; color: #fff; border-radius: 8px; padding: 9px; cursor: pointer; font-size: .8rem; font-weight: 600; }
|
| 200 |
+
.cfg-note { font-size: .64rem; color: #8a7bb8; margin-top: 4px; }
|
| 201 |
+
.mind-music { position: absolute; bottom: 14px; right: 14px; z-index: 5; width: 300px; border-radius: 12px; overflow: hidden; border: 1px solid rgba(255,77,141,.4); box-shadow: 0 8px 30px rgba(0,0,0,.5); }
|
| 202 |
+
.mind-music-cap { font-size: .62rem; color: #ffd7e6; background: rgba(10,6,24,.8); padding: 4px 8px; text-align: center; }
|
| 203 |
+
@media (max-width: 820px) { #mind-log { width: 88vw; right: -90vw; } .mind-music { width: 180px; } }
|
| 204 |
+
|
| 205 |
+
/* menú contextual del árbol y de las pestañas (botón derecho) */
|
| 206 |
+
#tree-menu, #tab-menu { background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; padding: 4px; min-width: 168px; box-shadow: 0 10px 34px rgba(0,0,0,.5); display: flex; flex-direction: column; }
|
| 207 |
+
.tm-item { text-align: left; background: none; border: none; color: var(--fg); font-size: .82rem; padding: 6px 10px; border-radius: 6px; cursor: pointer; }
|
| 208 |
+
.tm-item:hover { background: var(--bg3); }
|
| 209 |
+
.tm-item.danger { color: #ff6b8b; }
|
| 210 |
+
.tm-item.danger:hover { background: #3a1420; }
|
| 211 |
+
.tm-sep { height: 1px; background: var(--line); margin: 4px 2px; }
|
| 212 |
+
|
| 213 |
+
/* vista previa de markdown (pestaña en modo Vista previa, en vez del código fuente) */
|
| 214 |
+
#md-preview-pane { position: absolute; inset: 34px 0 22px 0; overflow-y: auto; background: var(--bg); padding: 18px 26px; z-index: 2; }
|
| 215 |
+
#md-preview-pane h1, #md-preview-pane h2, #md-preview-pane h3 { color: #fff; margin: 18px 0 8px; }
|
| 216 |
+
#md-preview-pane code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; font-size: .88em; }
|
| 217 |
+
#md-preview-pane pre { background: var(--bg3); border: 1px solid var(--line); border-radius: 8px; padding: 10px; overflow-x: auto; }
|
| 218 |
+
#md-preview-pane a { color: var(--accent2); }
|
| 219 |
+
#md-preview-pane.html-preview { padding: 0; overflow: hidden; }
|
| 220 |
+
#md-preview-pane.html-preview iframe { display: block; width: 100%; height: 100%; border: none; background: #fff; }
|
| 221 |
+
|
| 222 |
+
/* panel del terminal (abajo del editor, estilo VS Code) */
|
| 223 |
+
#terminal-panel { display: flex; flex-direction: column; height: 260px; flex: 0 0 auto; background: #0b0d12; border-top: 1px solid var(--line); position: relative; }
|
| 224 |
+
#terminal-panel[hidden] { display: none; }
|
| 225 |
+
#term-resize { position: absolute; top: -3px; left: 0; right: 0; height: 6px; cursor: ns-resize; z-index: 3; }
|
| 226 |
+
#term-resize:hover { background: var(--accent2); opacity: .4; }
|
| 227 |
+
#term-head { display: flex; align-items: center; gap: 10px; padding: 3px 10px; background: #0d0f15; border-bottom: 1px solid var(--line); }
|
| 228 |
+
#term-head .term-title { font-size: .68rem; letter-spacing: .08em; color: var(--fg); font-weight: 700; }
|
| 229 |
+
#term-caps { font-size: .68rem; color: var(--muted); flex: 1; }
|
| 230 |
+
.term-btn { background: none; border: none; color: var(--muted); font-size: .72rem; cursor: pointer; padding: 2px 6px; border-radius: 5px; }
|
| 231 |
+
.term-btn:hover { color: var(--fg); background: var(--bg3); }
|
| 232 |
+
#terminal-host { flex: 1; min-height: 0; padding: 4px 6px; overflow: hidden; }
|
| 233 |
+
#terminal-host .xterm { height: 100%; }
|
| 234 |
+
|
| 235 |
+
#statusbar {
|
| 236 |
+
padding: 3px 12px; font-size: .74rem; color: #fff; min-height: 22px;
|
| 237 |
+
background: linear-gradient(90deg, #3d2a68, #4c2050);
|
| 238 |
+
border-top: 1px solid var(--line); display: flex; align-items: center; gap: 14px;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/* carga del modelo: visible desde el primer segundo, sobre landing e IDE */
|
| 242 |
+
#model-progress {
|
| 243 |
+
position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%);
|
| 244 |
+
z-index: 120; display: flex; align-items: center; gap: 10px;
|
| 245 |
+
width: min(440px, 92vw); padding: 10px 14px;
|
| 246 |
+
background: var(--bg3); border: 1px solid var(--accent2); border-radius: 14px;
|
| 247 |
+
box-shadow: 0 10px 40px rgba(124, 92, 255, .35);
|
| 248 |
+
}
|
| 249 |
+
#model-progress[hidden] { display: none; }
|
| 250 |
+
#model-progress img { width: 34px; height: 34px; }
|
| 251 |
+
#model-progress .mp-body { flex: 1; display: flex; flex-direction: column; gap: 5px; }
|
| 252 |
+
#model-progress span { font-size: .82rem; color: var(--fg); }
|
| 253 |
+
#model-progress .track { height: 6px; background: var(--bg); border-radius: 3px; overflow: hidden; }
|
| 254 |
+
#model-bar { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent), var(--accent2)); transition: width .25s; }
|
| 255 |
+
|
| 256 |
+
/* ---------- chat ---------- */
|
| 257 |
+
#chat { display: flex; flex-direction: column; border-left: 1px solid var(--line); background: var(--bg2); min-height: 0; }
|
| 258 |
+
#chat-log { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
| 259 |
+
|
| 260 |
+
/* pestañas de conversación (varias en paralelo — cerrar NO borra, solo quita de la vista) */
|
| 261 |
+
#conv-tabs { display: flex; flex: 0 0 auto; background: #0d0f15; border-bottom: 1px solid var(--line); overflow-x: auto; min-height: 34px; align-items: center; }
|
| 262 |
+
#conv-tabs .tab-add { flex: 0 0 auto; background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; padding: 4px 12px; align-self: stretch; }
|
| 263 |
+
#conv-tabs .tab-add:hover { color: var(--fg); background: var(--bg3); }
|
| 264 |
+
.tab.tab-busy .tab-name::after { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-left: 6px; vertical-align: middle; animation: tab-busy-pulse 1s ease-in-out infinite; }
|
| 265 |
+
@keyframes tab-busy-pulse { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
|
| 266 |
+
|
| 267 |
+
/* panel de historial de conversaciones (todas las guardadas, no solo abiertas) */
|
| 268 |
+
#history-panel { position: fixed; top: 46px; right: 12px; width: 320px; max-height: 60vh; overflow-y: auto; background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 10px 34px rgba(0,0,0,.5); z-index: 90; display: flex; flex-direction: column; }
|
| 269 |
+
#history-panel[hidden] { display: none; }
|
| 270 |
+
.hp-head { display: flex; align-items: center; padding: 10px 12px; font-size: .85rem; font-weight: 700; color: var(--fg); border-bottom: 1px solid var(--line); }
|
| 271 |
+
.hp-head button { margin-left: auto; background: none; border: none; color: var(--muted); cursor: pointer; font-size: .9rem; }
|
| 272 |
+
#hp-list { overflow-y: auto; padding: 4px; }
|
| 273 |
+
.hp-empty { padding: 16px; color: var(--muted); font-size: .8rem; text-align: center; }
|
| 274 |
+
.hp-row { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
|
| 275 |
+
.hp-row:hover { background: var(--bg3); }
|
| 276 |
+
.hp-title { font-size: .82rem; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 277 |
+
.hp-date { font-size: .68rem; color: var(--muted); white-space: nowrap; }
|
| 278 |
+
.hp-del { background: none; border: none; cursor: pointer; font-size: .8rem; opacity: .6; }
|
| 279 |
+
.hp-del:hover { opacity: 1; }
|
| 280 |
+
.msg { padding: 8px 11px; border-radius: 11px; font-size: .85rem; line-height: 1.45; white-space: pre-wrap; word-break: break-word; max-width: 95%; }
|
| 281 |
+
.msg.sys { background: linear-gradient(135deg, #1c1430, #14202e); border: 1px solid var(--line); }
|
| 282 |
+
.msg.user { background: var(--accent2); color: #fff; align-self: flex-end; }
|
| 283 |
+
.msg.assistant { background: var(--bg3); border: 1px solid var(--line); align-self: flex-start; }
|
| 284 |
+
.msg.err { border-color: #a03050; }
|
| 285 |
+
/* herramientas estilo plugin de Claude Code: ● nombre + args + resultado plegable */
|
| 286 |
+
.msg.tool { display: flex; align-items: center; gap: 7px; background: none; padding: 2px 6px; font-size: .8rem; }
|
| 287 |
+
.msg.tool .tdot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; }
|
| 288 |
+
.msg.tool b { color: var(--fg); font-weight: 600; font-family: ui-monospace, monospace; font-size: .76rem; }
|
| 289 |
+
.msg.tool .targ { color: var(--muted); font-family: ui-monospace, monospace; font-size: .74rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 290 |
+
.msg.tool-result { color: var(--muted); font-size: .74rem; background: none; padding: 0 6px 0 19px; }
|
| 291 |
+
.msg.tool-result summary { cursor: pointer; list-style: none; }
|
| 292 |
+
.msg.tool-result summary::before { content: '⎿ '; color: #4a5266; }
|
| 293 |
+
.msg.tool-result pre { margin: 4px 0 0; background: #0d1117; border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; white-space: pre-wrap; word-break: break-word; max-height: 220px; overflow: auto; font-size: .72rem; }
|
| 294 |
+
.msg.tool-result.err summary { color: #e5a53a; }
|
| 295 |
+
.msg.user.queued { opacity: .55; }
|
| 296 |
+
.msg.user.queued::after { content: ' ⏳'; }
|
| 297 |
+
.msg.thinking { background: var(--bg3); border: 1px dashed var(--line); color: var(--muted); font-size: .8rem; }
|
| 298 |
+
.msg.thinking .gen { font-family: ui-monospace, monospace; font-size: .7rem; color: #5f6880; max-height: 60px; overflow: hidden; white-space: pre-wrap; }
|
| 299 |
+
.msg.thinking .gen.tool-preview { font-family: inherit; font-size: .78rem; color: var(--accent2); font-style: italic; }
|
| 300 |
+
|
| 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; }
|
| 307 |
+
.plan-card .plan-status { float: right; font-size: .7rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
| 308 |
+
.plan-card ul.plan-tasks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
|
| 309 |
+
.plan-card ul.plan-tasks li { display: flex; align-items: baseline; gap: 7px; padding: 5px 7px; border-radius: 7px; background: var(--bg2); border: 1px solid var(--line); }
|
| 310 |
+
.plan-card .task-ico { flex: 0 0 auto; }
|
| 311 |
+
.plan-card .task-title { font-weight: 600; }
|
| 312 |
+
.plan-card .task-desc { color: var(--muted); font-size: .76rem; }
|
| 313 |
+
.plan-card li.task-in-progress { border-color: var(--accent2); }
|
| 314 |
+
.plan-card li.task-done .task-title { color: #7ee787; }
|
| 315 |
+
.plan-card li.task-failed { border-color: #a03050; }
|
| 316 |
+
.plan-card li.task-failed .task-title { color: #e5a53a; }
|
| 317 |
+
.plan-card li.task-skipped { opacity: .5; }
|
| 318 |
+
.plan-card li.task-skipped .task-title { text-decoration: line-through; }
|
| 319 |
+
#composer { display: flex; gap: 6px; padding: 10px; }
|
| 320 |
+
#prompt { flex: 1; background: var(--bg3); border: 1px solid var(--line); border-radius: 9px; color: var(--fg); padding: 9px 11px; outline: none; }
|
| 321 |
+
#prompt:focus { border-color: var(--accent2); }
|
| 322 |
+
#composer button { background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; border-radius: 9px; color: #fff; padding: 0 14px; cursor: pointer; }
|
| 323 |
+
|
| 324 |
+
/* ---------- markdown en el chat (estilo plugin de Claude Code) ---------- */
|
| 325 |
+
.msg.md { white-space: normal; }
|
| 326 |
+
.proposal-exec-btn { display: block; margin-top: 10px; background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; color: #fff; border-radius: 8px; padding: 8px 14px; font-size: .78rem; font-weight: 600; cursor: pointer; }
|
| 327 |
+
.proposal-exec-btn:hover { filter: brightness(1.1); }
|
| 328 |
+
.proposal-exec-btn:disabled { opacity: .6; cursor: default; filter: none; }
|
| 329 |
+
.msg.md code {
|
| 330 |
+
background: #0d1117; border: 1px solid var(--line); border-radius: 5px;
|
| 331 |
+
padding: 1px 6px; font-family: ui-monospace, monospace; font-size: .8em; color: #ffb3c9;
|
| 332 |
+
}
|
| 333 |
+
.msg.md code.file-link { cursor: pointer; color: var(--accent2); border-color: var(--accent2); text-decoration: underline dotted; }
|
| 334 |
+
.msg.md code.file-link:hover { background: rgba(124,92,255,.18); }
|
| 335 |
+
.msg.md pre.codeblock {
|
| 336 |
+
position: relative; background: #0d1117; border: 1px solid var(--line);
|
| 337 |
+
border-radius: 8px; padding: 10px 12px; margin: 8px 0; overflow-x: auto;
|
| 338 |
+
}
|
| 339 |
+
.msg.md pre.codeblock code {
|
| 340 |
+
background: none; border: none; padding: 0; color: #c9d1d9;
|
| 341 |
+
font-size: .78rem; line-height: 1.5; white-space: pre;
|
| 342 |
+
}
|
| 343 |
+
.msg.md pre.codeblock .lang {
|
| 344 |
+
position: absolute; top: 4px; right: 8px; font-size: .64rem;
|
| 345 |
+
color: var(--muted); text-transform: uppercase; letter-spacing: .06em;
|
| 346 |
+
}
|
| 347 |
+
.msg.md h4 { margin: 10px 0 4px; font-size: .92rem; color: var(--fg); }
|
| 348 |
+
.msg.md ul { margin: 4px 0; padding-left: 18px; }
|
| 349 |
+
.msg.md li { margin: 2px 0; }
|
| 350 |
+
.msg.md b { color: #fff; }
|
| 351 |
+
|
| 352 |
+
/* botón flotante chat↔editor: solo en móvil */
|
| 353 |
+
#code-flip {
|
| 354 |
+
display: none; position: fixed; bottom: 78px; right: 14px; z-index: 60;
|
| 355 |
+
padding: 10px 16px; font-size: .9rem; color: #fff; border: none;
|
| 356 |
+
border-radius: 999px; background: linear-gradient(135deg, var(--accent), var(--accent2));
|
| 357 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, .5); cursor: pointer;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
/* ---------- móvil: cabecera fija + chat/editor conmutables ---------- */
|
| 361 |
+
@media (max-width: 760px) {
|
| 362 |
+
main { grid-template-columns: 1fr; }
|
| 363 |
+
#activity, #sidebar { display: none; }
|
| 364 |
+
#code-flip { display: block; }
|
| 365 |
+
#editor-wrap { display: none; }
|
| 366 |
+
body.show-editor #editor-wrap { display: flex; }
|
| 367 |
+
body.show-editor #chat { display: none; }
|
| 368 |
+
#chat { border-left: none; min-width: 0; }
|
| 369 |
+
header { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
|
| 370 |
+
.proj { max-width: 30vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 371 |
+
select { max-width: 38vw; font-size: .78rem; }
|
| 372 |
+
#prompt { font-size: 16px; } /* sin auto-zoom iOS */
|
| 373 |
+
#settings-panel { flex-direction: column; }
|
| 374 |
+
/* nada puede tapar el composer: píldora y botón flotante, por encima de él */
|
| 375 |
+
#model-progress { bottom: 132px; }
|
| 376 |
+
#code-flip { bottom: 76px; }
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
/* ---------- composer estilo plugin de Claude Code ---------- */
|
| 380 |
+
#composer-wrap { border-top: 1px solid var(--line); background: var(--bg2); }
|
| 381 |
+
#ctx-meter { display: flex; align-items: center; gap: 8px; padding: 5px 12px 0; font-size: .68rem; color: var(--muted); }
|
| 382 |
+
#ctx-meter .ctx-track { flex: 1; height: 3px; background: var(--bg); border-radius: 2px; overflow: hidden; }
|
| 383 |
+
#ctx-bar { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent2), var(--accent)); transition: width .3s; }
|
| 384 |
+
#ctx-bar.hot { background: linear-gradient(90deg, #e5a53a, #e5533a); }
|
| 385 |
+
#composer { display: flex; align-items: center; gap: 6px; padding: 8px 10px 10px; }
|
| 386 |
+
.cbtn {
|
| 387 |
+
flex: 0 0 auto; background: var(--bg3); border: 1px solid var(--line); color: var(--muted);
|
| 388 |
+
border-radius: 8px; min-width: 30px; height: 32px; padding: 0 8px; cursor: pointer;
|
| 389 |
+
font-size: .95rem; display: inline-flex; align-items: center; gap: 4px;
|
| 390 |
+
}
|
| 391 |
+
.cbtn:hover { color: var(--fg); border-color: var(--accent2); }
|
| 392 |
+
.cbtn.autoedit { font-size: .74rem; }
|
| 393 |
+
.cbtn.autoedit .ae-ico { font-family: ui-monospace, monospace; }
|
| 394 |
+
.cbtn.autoedit.on { color: #fff; border-color: var(--accent); background: linear-gradient(135deg, #3a2050, #2a1840); }
|
| 395 |
+
#btn-send { position: relative; flex: 0 0 auto; background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; border-radius: 8px; color: #fff; height: 32px; padding: 0 14px; cursor: pointer; }
|
| 396 |
+
/* mientras se procesa: la elfa está "pensando" — feedback claro en el propio botón */
|
| 397 |
+
#btn-send.sending { cursor: not-allowed; }
|
| 398 |
+
#btn-send.sending svg { opacity: .25; }
|
| 399 |
+
#btn-send.sending::after {
|
| 400 |
+
content: ''; position: absolute; inset: 9px; border: 2px solid rgba(255,255,255,.35);
|
| 401 |
+
border-top-color: #fff; border-radius: 50%; animation: btn-send-spin .7s linear infinite;
|
| 402 |
+
}
|
| 403 |
+
@keyframes btn-send-spin { to { transform: rotate(360deg); } }
|
| 404 |
+
#composer #prompt { flex: 1; min-width: 0; }
|
| 405 |
+
|
| 406 |
+
/* menú flotante de +/ */
|
| 407 |
+
#menu {
|
| 408 |
+
position: absolute; bottom: 96px; left: 10px; right: 10px; z-index: 40;
|
| 409 |
+
background: var(--bg3); border: 1px solid var(--accent2); border-radius: 10px;
|
| 410 |
+
box-shadow: 0 -8px 30px rgba(0,0,0,.5); max-height: 320px; overflow-y: auto; padding: 4px;
|
| 411 |
+
}
|
| 412 |
+
#menu[hidden] { display: none; }
|
| 413 |
+
#menu .sep { font-size: .66rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; padding: 8px 10px 3px; }
|
| 414 |
+
#menu .menu-item { display: flex; justify-content: space-between; gap: 10px; width: 100%; text-align: left; background: none; border: none; color: var(--fg); padding: 7px 10px; border-radius: 7px; cursor: pointer; font-size: .82rem; }
|
| 415 |
+
#menu .menu-item:hover { background: #ffffff0f; }
|
| 416 |
+
#menu .menu-item b { font-family: ui-monospace, monospace; font-weight: 600; }
|
| 417 |
+
#menu .menu-item span { color: var(--muted); font-size: .74rem; }
|
| 418 |
+
#chat { position: relative; }
|
| 419 |
+
|
| 420 |
+
/* panel de skills */
|
| 421 |
+
.skills-panel h3 { margin: 0 0 4px; font-size: 1rem; }
|
| 422 |
+
.sk-h { font-size: .72rem; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin: 12px 0 6px; }
|
| 423 |
+
.sk-row { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--bg3); border: 1px solid var(--line); border-radius: 8px; margin-bottom: 6px; font-size: .82rem; }
|
| 424 |
+
.sk-row b { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 425 |
+
.sk-row .muted { color: var(--muted); font-size: .74rem; max-width: 40%; overflow: hidden; text-overflow: ellipsis; }
|
| 426 |
+
.sk-row a { color: var(--accent2); font-size: .74rem; text-decoration: none; }
|
| 427 |
+
.sk-row input { flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--fg); border-radius: 6px; padding: 6px 9px; font-size: .78rem; }
|
| 428 |
+
.sk-row button.primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; color: #fff; border-radius: 6px; padding: 5px 11px; cursor: pointer; font-size: .76rem; }
|
| 429 |
+
.sk-row button.ghost { background: none; border: 1px solid var(--line); color: var(--muted); border-radius: 6px; padding: 5px 9px; cursor: pointer; font-size: .76rem; }
|
| 430 |
+
#settings-panel { max-height: 55vh; overflow-y: auto; flex-direction: column; }
|
| 431 |
+
|
| 432 |
+
/* el panel de ajustes/skills se SUPERPONE (no empuja el IDE) */
|
| 433 |
+
#settings-panel { position: fixed; top: 46px; left: 46px; right: 0; z-index: 90; max-height: 78vh; overflow-y: auto; box-shadow: 0 12px 40px rgba(0,0,0,.6); }
|
| 434 |
+
@media (max-width: 760px) { #settings-panel { left: 0; } }
|
| 435 |
+
|
| 436 |
+
/* barra indeterminada (descargas sin loaded/total, p. ej. Gemma vía LiteRT-LM) */
|
| 437 |
+
#model-progress .track { overflow: hidden; }
|
| 438 |
+
#model-bar.indet { width: 35%; animation: indet 1.1s ease-in-out infinite; }
|
| 439 |
+
@keyframes indet { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
|
| 440 |
+
|
| 441 |
+
/* ===== chrome estilo VS Code: menubar + palette + settings + git ===== */
|
| 442 |
+
header { position: relative; }
|
| 443 |
+
#model-select[hidden] { display: none; }
|
| 444 |
+
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 28px; padding: 0; }
|
| 445 |
+
.icon-btn svg { width: 16px; height: 16px; }
|
| 446 |
+
.icon-inline svg { vertical-align: -3px; }
|
| 447 |
+
|
| 448 |
+
/* barra de menú File/Edit/View/Git en la MISMA línea */
|
| 449 |
+
#menubar { display: flex; gap: 1px; margin-left: 6px; }
|
| 450 |
+
#menubar button { background: none; border: none; color: var(--muted); padding: 4px 9px; font-size: .8rem; cursor: pointer; border-radius: 5px; }
|
| 451 |
+
#menubar button:hover, #menubar button.open { color: var(--fg); background: #ffffff10; }
|
| 452 |
+
@media (max-width: 900px) { #menubar { display: none; } }
|
| 453 |
+
|
| 454 |
+
/* dropdown del menú (fixed, anclado al botón) */
|
| 455 |
+
#topmenu { position: fixed; z-index: 110; min-width: 220px; background: var(--bg3); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 10px 34px rgba(0,0,0,.55); padding: 4px; }
|
| 456 |
+
#topmenu[hidden] { display: none; }
|
| 457 |
+
#topmenu .menu-item { display: flex; justify-content: space-between; gap: 12px; width: 100%; text-align: left; background: none; border: none; color: var(--fg); padding: 7px 10px; border-radius: 6px; cursor: pointer; font-size: .82rem; }
|
| 458 |
+
#topmenu .menu-item:hover { background: #ffffff10; }
|
| 459 |
+
#topmenu .menu-item span { color: var(--muted); font-size: .74rem; }
|
| 460 |
+
|
| 461 |
+
/* chip de rama git */
|
| 462 |
+
.chip { display: inline-flex; align-items: center; gap: 4px; background: var(--bg3); border: 1px solid var(--line); color: var(--muted); border-radius: 999px; padding: 3px 10px; font-size: .74rem; cursor: pointer; }
|
| 463 |
+
.chip[hidden] { display: none; }
|
| 464 |
+
.chip:hover { color: var(--fg); border-color: var(--accent2); }
|
| 465 |
+
.chip svg { width: 12px; height: 12px; }
|
| 466 |
+
|
| 467 |
+
/* command palette */
|
| 468 |
+
#palette { position: fixed; top: 52px; left: 50%; transform: translateX(-50%); z-index: 130; width: min(640px, 92vw); background: var(--bg3); border: 1px solid var(--accent2); border-radius: 10px; box-shadow: 0 20px 60px rgba(0,0,0,.6); overflow: hidden; }
|
| 469 |
+
#palette[hidden] { display: none; }
|
| 470 |
+
#pal-input { width: 100%; background: var(--bg); border: none; border-bottom: 1px solid var(--line); color: var(--fg); padding: 12px 14px; font-size: .92rem; outline: none; }
|
| 471 |
+
#pal-list { max-height: 50vh; overflow-y: auto; padding: 4px; }
|
| 472 |
+
.pal-item { display: flex; align-items: baseline; gap: 10px; width: 100%; text-align: left; background: none; border: none; color: var(--fg); padding: 7px 10px; border-radius: 6px; cursor: pointer; font-size: .84rem; }
|
| 473 |
+
.pal-item.sel, .pal-item:hover { background: #7c5cff33; }
|
| 474 |
+
.pal-item .pi-name { font-weight: 500; }
|
| 475 |
+
.pal-item .pi-hint { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 476 |
+
.pal-empty { padding: 12px 14px; color: var(--muted); font-size: .82rem; }
|
| 477 |
+
|
| 478 |
+
/* settings: título, cierre y tarjetas de modelo/proveedor */
|
| 479 |
+
.panel-title { margin: 2px 0 8px; font-size: 1rem; }
|
| 480 |
+
.panel-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--muted); cursor: pointer; padding: 4px; }
|
| 481 |
+
.panel-close:hover { color: var(--accent); }
|
| 482 |
+
.panel-close svg { width: 16px; height: 16px; }
|
| 483 |
+
.model-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 8px; margin-bottom: 6px; }
|
| 484 |
+
.model-card { text-align: left; background: var(--bg3); border: 1px solid var(--line); border-radius: 9px; padding: 9px 11px; cursor: pointer; display: flex; flex-direction: column; gap: 2px; color: var(--fg); }
|
| 485 |
+
.model-card:hover { border-color: var(--accent2); }
|
| 486 |
+
.model-card.active { border-color: var(--accent); background: linear-gradient(135deg, #2a1840, #1a1428); }
|
| 487 |
+
.model-card b { font-size: .84rem; color: var(--fg); }
|
| 488 |
+
.model-card.active b { color: #fff; }
|
| 489 |
+
.model-card span { color: #aab2c8; font-size: .72rem; }
|
| 490 |
+
.prov-card { background: var(--bg3); border: 1px solid var(--line); border-radius: 9px; padding: 9px 11px; margin-bottom: 7px; }
|
| 491 |
+
.prov-card.on { border-color: var(--accent2); }
|
| 492 |
+
.prov-head { display: flex; align-items: center; gap: 8px; }
|
| 493 |
+
.prov-head b { flex: 1; font-size: .84rem; }
|
| 494 |
+
.prov-use { background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; color: #fff; border-radius: 6px; padding: 4px 10px; font-size: .74rem; cursor: pointer; }
|
| 495 |
+
.prov-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
|
| 496 |
+
.prov-fields.hide { display: none; }
|
| 497 |
+
.prov-fields input { background: var(--bg); border: 1px solid var(--line); color: var(--fg); border-radius: 6px; padding: 6px 9px; font-size: .78rem; width: 100%; }
|
| 498 |
+
.field { display: flex; flex-direction: column; gap: 3px; }
|
| 499 |
+
.field .muted { font-size: .7rem; }
|
| 500 |
+
button.wide { width: 100%; padding: 9px; border-radius: 8px; }
|
| 501 |
+
|
| 502 |
+
.model-card.disabled { opacity: .5; cursor: not-allowed; }
|
| 503 |
+
.model-card.disabled:hover { border-color: var(--line); }
|
| 504 |
+
|
| 505 |
+
/* ---------- vistas Arquitectura / Ciudad 3D (overlay sobre el editor) ---------- */
|
| 506 |
+
#view-overlay { position: absolute; inset: 34px 0 22px 0; z-index: 30; background: #0b0d12; overflow: hidden; }
|
| 507 |
+
#view-overlay[hidden] { display: none; }
|
| 508 |
+
#editor-wrap { position: relative; }
|
| 509 |
+
#view-close { position: absolute; top: 10px; right: 12px; z-index: 32; background: var(--bg3); border: 1px solid var(--line); color: var(--muted); width: 30px; height: 28px; border-radius: 7px; cursor: pointer; }
|
| 510 |
+
#view-close:hover { color: var(--accent); }
|
| 511 |
+
#view-body { width: 100%; height: 100%; position: relative; }
|
| 512 |
+
#view-body svg#arch-svg { width: 100%; height: 100%; display: block; cursor: grab; }
|
| 513 |
+
.arch-node { cursor: pointer; }
|
| 514 |
+
.arch-node:hover circle { stroke-width: 3; }
|
| 515 |
+
.view-head { position: absolute; top: 10px; left: 14px; z-index: 31; font-size: .8rem; color: #c8cede; background: #0d1117cc; padding: 5px 10px; border-radius: 8px; border: 1px solid var(--line); }
|
| 516 |
+
.view-hint { color: var(--muted); font-size: .72rem; margin-left: 8px; }
|
| 517 |
+
.view-loading { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: .9rem; }
|
| 518 |
+
#city-canvas { width: 100%; height: 100%; display: block; }
|
img/elffuss-code.svg
ADDED
|
|
index.html
CHANGED
|
@@ -1,19 +1,151 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 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 Code — tu IDE con alma, en el navegador</title>
|
| 7 |
+
<meta property="og:type" content="website">
|
| 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://elffuss-code.utopiaia.com/">
|
| 12 |
+
<meta property="og:image" content="https://elffuss-code.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://elffuss-code.utopiaia.com/og.png">
|
| 19 |
+
<meta name="description" content="Abre una carpeta de código y programa con Elffuss: IDE web + agente local. Nada sale de tu máquina.">
|
| 20 |
+
<link rel="icon" href="img/elffuss-code.svg">
|
| 21 |
+
<link rel="stylesheet" href="css/code.css">
|
| 22 |
+
<!-- import map: la ciudad 3D (VibeCodeViewer vendorizado en js/vcc) usa import 'three' -->
|
| 23 |
+
<script type="importmap">
|
| 24 |
+
{ "imports": {
|
| 25 |
+
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
| 26 |
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
| 27 |
+
}}
|
| 28 |
+
</script>
|
| 29 |
+
</head>
|
| 30 |
+
<body>
|
| 31 |
+
|
| 32 |
+
<!-- landing: primero se abre la carpeta -->
|
| 33 |
+
<div id="landing">
|
| 34 |
+
<div class="l-inner">
|
| 35 |
+
<img src="img/elffuss-code.svg" alt="Elffuss Code" class="hero">
|
| 36 |
+
<h1><b>Elffuss</b> Code</h1>
|
| 37 |
+
<p class="tag">Abre tu carpeta de código y programa con una elfa al lado.<br>
|
| 38 |
+
Editor tipo VS Code + agente <b>en tu navegador</b>. Nada sale de tu máquina. 😉</p>
|
| 39 |
+
<button id="open-project">📁 Abrir carpeta de código</button>
|
| 40 |
+
<button id="reopen-project" class="ghost" hidden></button>
|
| 41 |
+
<div id="resume-clone" class="resume-clone" hidden>
|
| 42 |
+
<span class="rc-text"></span>
|
| 43 |
+
<button id="resume-clone-btn">Reanudar</button>
|
| 44 |
+
<button id="discard-clone-btn" class="ghost">Descartar</button>
|
| 45 |
+
</div>
|
| 46 |
+
<div class="clone-row">
|
| 47 |
+
<span class="clone-or">o</span>
|
| 48 |
+
<input id="clone-url" type="text" placeholder="pega la URL de un repo público (GitHub/Bitbucket)…" autocomplete="off" spellcheck="false">
|
| 49 |
+
<button id="clone-btn">Descargar y abrir</button>
|
| 50 |
+
</div>
|
| 51 |
+
<p id="clone-status" class="mini clone-status" hidden></p>
|
| 52 |
+
<p class="mini">Chrome/Edge · File System Access · modelo local WebGPU</p>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<!-- IDE -->
|
| 57 |
+
<div id="ide" hidden>
|
| 58 |
+
<header>
|
| 59 |
+
<div class="brand"><img src="img/elffuss-code.svg" alt=""><b>Elffuss Code</b>
|
| 60 |
+
<span id="project-name" class="proj"></span><span id="build" class="proj">__BUILD__</span></div>
|
| 61 |
+
<nav id="menubar">
|
| 62 |
+
<button data-menu="file">File</button>
|
| 63 |
+
<button data-menu="edit">Edit</button>
|
| 64 |
+
<button data-menu="view">View</button>
|
| 65 |
+
<button data-menu="git">Git</button>
|
| 66 |
+
</nav>
|
| 67 |
+
<div class="controls">
|
| 68 |
+
<button id="git-branch" class="chip" title="rama actual" hidden></button>
|
| 69 |
+
<span id="model-dot" class="dot off" title="modelo"></span>
|
| 70 |
+
<select id="model-select" hidden></select>
|
| 71 |
+
<button id="btn-history" class="ghost icon-btn" title="Historial de conversaciones"></button>
|
| 72 |
+
<button id="btn-settings" class="ghost icon-btn" title="Ajustes, modelo y API keys"></button>
|
| 73 |
+
</div>
|
| 74 |
+
</header>
|
| 75 |
+
<div id="settings-panel" hidden></div>
|
| 76 |
+
<div id="topmenu" hidden></div>
|
| 77 |
+
<div id="palette" hidden>
|
| 78 |
+
<input id="pal-input" autocomplete="off" spellcheck="false"
|
| 79 |
+
placeholder="Buscar archivos por nombre (: para ir a línea, @ para símbolo, > para comandos)">
|
| 80 |
+
<div id="pal-list"></div>
|
| 81 |
+
</div>
|
| 82 |
+
<main>
|
| 83 |
+
<nav id="activity">
|
| 84 |
+
<button id="act-files" class="on" title="Explorador"></button>
|
| 85 |
+
<button id="act-search" title="Buscar (Cmd/Ctrl+P)"></button>
|
| 86 |
+
<button id="act-arch" title="Arquitectura (grafo de dependencias)"></button>
|
| 87 |
+
<button id="act-city" title="Ciudad 3D"></button>
|
| 88 |
+
<button id="act-term" title="Terminal (Ctrl+`)"></button>
|
| 89 |
+
<button id="act-skills" title="Skills"></button>
|
| 90 |
+
<button id="act-settings" title="Ajustes y modelo"></button>
|
| 91 |
+
<img src="img/elffuss-code.svg" alt="Elffuss" title="Elffuss Code">
|
| 92 |
+
</nav>
|
| 93 |
+
<aside id="sidebar">
|
| 94 |
+
<div id="explorer-head">EXPLORER<span id="explorer-proj"></span></div>
|
| 95 |
+
<div id="tree"></div>
|
| 96 |
+
</aside>
|
| 97 |
+
<section id="editor-wrap">
|
| 98 |
+
<div id="tabs-bar"></div>
|
| 99 |
+
<div id="editor"></div>
|
| 100 |
+
<div id="view-overlay" hidden>
|
| 101 |
+
<button id="view-close" title="cerrar">✕</button>
|
| 102 |
+
<div id="view-body"></div>
|
| 103 |
+
</div>
|
| 104 |
+
<div id="terminal-panel" hidden>
|
| 105 |
+
<div id="term-resize" title="arrastra para redimensionar"></div>
|
| 106 |
+
<div id="term-head">
|
| 107 |
+
<span class="term-title">TERMINAL</span>
|
| 108 |
+
<span id="term-caps"></span>
|
| 109 |
+
<button id="term-clear" class="term-btn" title="limpiar">limpiar</button>
|
| 110 |
+
<button id="term-close" class="term-btn" title="cerrar (Ctrl+`)">✕</button>
|
| 111 |
+
</div>
|
| 112 |
+
<div id="terminal-host"></div>
|
| 113 |
+
</div>
|
| 114 |
+
<div id="statusbar"></div>
|
| 115 |
+
</section>
|
| 116 |
+
<section id="chat">
|
| 117 |
+
<div id="conv-tabs"></div>
|
| 118 |
+
<div id="history-panel" hidden></div>
|
| 119 |
+
<div id="chat-log"></div>
|
| 120 |
+
<div id="menu" hidden></div>
|
| 121 |
+
<div id="composer-wrap">
|
| 122 |
+
<div id="ctx-meter" title="contexto acumulado en la conversación">
|
| 123 |
+
<span id="ctx-text">ctx 0 / 6k</span>
|
| 124 |
+
<div class="ctx-track"><div id="ctx-bar"></div></div>
|
| 125 |
+
</div>
|
| 126 |
+
<form id="composer">
|
| 127 |
+
<button type="button" id="btn-plus" class="cbtn" title="Adjuntar archivo del proyecto (@)"></button>
|
| 128 |
+
<button type="button" id="btn-slash" class="cbtn" title="Comandos"></button>
|
| 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>
|
| 135 |
+
</section>
|
| 136 |
+
</main>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<button id="code-flip" title="cambiar entre chat y editor"></button>
|
| 140 |
+
|
| 141 |
+
<div id="model-progress" hidden>
|
| 142 |
+
<img src="img/elffuss-code.svg" alt="">
|
| 143 |
+
<div class="mp-body">
|
| 144 |
+
<span id="model-progress-text">Preparando el cerebro local…</span>
|
| 145 |
+
<div class="track"><div id="model-bar"></div></div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<script type="module" src="js/main.js"></script>
|
| 150 |
+
</body>
|
| 151 |
</html>
|
js/agent.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Bucle agéntico de Elffuss Code (mismo protocolo que Elffuss SO).
|
| 2 |
+
import { runTool, toolHelp, snapshot } from './tools/index.js';
|
| 3 |
+
import { skillsPromptBlock } from './skills.js';
|
| 4 |
+
import * as telemetry from './telemetry.js';
|
| 5 |
+
|
| 6 |
+
const MAX_STEPS = 12; // margen para tareas largas (leer→editar→verificar→arreglar…)
|
| 7 |
+
|
| 8 |
+
const LANGS = {
|
| 9 |
+
es: 'español', en: 'English', uk: 'українська', ru: 'русский', fr: 'français',
|
| 10 |
+
de: 'Deutsch', it: 'italiano', pt: 'português', pl: 'polski', ca: 'català',
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export function userLang() {
|
| 14 |
+
const codeL = (navigator.language || 'es').toLowerCase();
|
| 15 |
+
return { code: codeL, name: LANGS[codeL.split('-')[0]] || codeL };
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function systemPrompt(context = '') {
|
| 19 |
+
const lang = userLang();
|
| 20 |
+
return `Eres Elffuss Code: un asistente de programación, cálido pero quirúrgico con el código. Vives en un IDE web y trabajas SOLO dentro del proyecto que el usuario ha abierto. Hablas SIEMPRE en el idioma del navegador del usuario: ${lang.name} (${lang.code}); el código y sus comentarios, en el estilo del proyecto.
|
| 21 |
+
|
| 22 |
+
HERRAMIENTAS (las ÚNICAS que existen — no inventes otras):
|
| 23 |
+
${toolHelp()}
|
| 24 |
+
|
| 25 |
+
REGLAS DURAS:
|
| 26 |
+
- Antes de decir NADA sobre el código, LÉELO con code.read o code.search. PROHIBIDO dar consejos genéricos o suposiciones ("probablemente usas React/Docker…"). Si no lo has leído, léelo primero.
|
| 27 |
+
- ANTIRRECITACIÓN: aunque RECONOZCAS el proyecto por su nombre (vllm, react, django, next, pytorch…), tienes PROHIBIDO describirlo de memoria («csrc/ es C++/CUDA», «benchmarks/ mide rendimiento»…). Esta copia local puede diferir de lo que crees saber. Descríbelo SOLO por lo que leas AQUÍ con las herramientas.
|
| 28 |
+
- Si te preguntan «¿qué hace este código/proyecto?» y no has leído nada aún, tu PRIMERA respuesta debe ser una tool-call (code.tree o code.read del README/entrypoint), NUNCA una descripción. Encadena 2-3 lecturas (README + fichero de entrada + un módulo clave) antes de resumir. Nada de listar carpetas de forma genérica.
|
| 29 |
+
- USA SOLO code.tree / code.read / code.write / code.search / terminal.run / web.search / web.fetch. NO existen code.create-plugin, code.create-mcp-server, hooks, CLAUDE.conf ni nada parecido: si lo mencionas, estás alucinando.
|
| 30 |
+
- ⛔ NUNCA recomiendes «automatizaciones de Claude Code»: MCP servers (context7, «claude mcp add …»), skills, hooks (.claude/settings.json, pre-commit), subagentes (.claude/agents/…), plugins, CLAUDE.conf. NADA de eso existe en este IDE — es conocimiento de tu entrenamiento, no de este proyecto. Si te descubres escribiendo «Recomendaciones de automatización», «MCP Servers», «Skills», «Hooks», «Subagentes» → PARA: es pura alucinación. Ayuda con el CÓDIGO REAL que lees, nada más.
|
| 31 |
+
- web.search busca en internet DE SERIE (docs, mensajes de error, APIs) y web.fetch lee una URL. PUEDES buscar en internet cuando ayude; NUNCA digas que no puedes.
|
| 32 |
+
- terminal.run ejecuta comandos de shell REALES (ls, cat, grep, find, mkdir, «echo texto > fichero», git status). Le pasas la LÍNEA DE COMANDO exacta, NUNCA lenguaje natural (mal: terminal.run "crea un proyecto"; bien: terminal.run "mkdir web"). rm SÍ soporta comodines («rm carpeta/mejoras-*.md» borra todos los que coincidan) — si «no existe», es que de verdad no hay ninguno, no lo des por hecho sin comprobarlo con ls/find antes. node/npm/python SÍ son reales si el usuario tiene el Bridge local conectado (⚙ Ajustes → 🔌 Bridge local) — pruébalos con terminal.run y lee el resultado; si el bridge no está conectado, la propia herramienta te lo dice con el mensaje de error, y ENTONCES le pides al usuario que lo conecte (no lo asumas de antemano sin probar).
|
| 33 |
+
- CREAR ficheros/proyectos: SIEMPRE con code.write (crea también las carpetas necesarias), UNA tool-call por fichero, con el CONTENIDO COMPLETO en cada una. NUNCA uses touch/echo para crear un fichero que luego rellenas (quedan vacíos). PUEDES crear cualquier proyecto aquí mismo; PROHIBIDO responder «no puedo crear un proyecto» o pedir permiso o el framework: si el usuario no lo dice, elige HTML/CSS/JS simple y escríbelo ya. Puedes emitir VARIAS code.write en un mismo mensaje y se ejecutan todas.
|
| 34 |
+
- Habla SOLO de archivos y contenido que aparezcan en el CONTEXTO o en resultados de herramientas. Cita rutas y líneas reales. Si no lo sabes, léelo, no lo inventes.
|
| 35 |
+
|
| 36 |
+
Cómo actuar:
|
| 37 |
+
1) Para usar una herramienta responde SOLO con:
|
| 38 |
+
\`\`\`tool
|
| 39 |
+
{"tool": "code.read", "args": {"path": "README.md"}}
|
| 40 |
+
\`\`\`
|
| 41 |
+
2) NUNCA inventes rutas: usa SOLO las del árbol del CONTEXTO o de code.tree/code.search. Relativas a la raíz, sin ./ inicial.
|
| 42 |
+
3) Antes de tocar código: lee el archivo. Al escribir con code.write pasa el contenido COMPLETO. El editor se actualiza al instante.
|
| 43 |
+
4) Tras un [resultado], responde breve y CONCRETO citando lo que has leído.
|
| 44 |
+
|
| 45 |
+
Ejemplos:
|
| 46 |
+
Usuario: ¿qué hace este proyecto/código?
|
| 47 |
+
Tú:
|
| 48 |
+
\`\`\`tool
|
| 49 |
+
{"tool": "code.read", "args": {"path": "README.md"}}
|
| 50 |
+
\`\`\`
|
| 51 |
+
Usuario: ¿qué hay en este proyecto?
|
| 52 |
+
Tú:
|
| 53 |
+
\`\`\`tool
|
| 54 |
+
{"tool": "code.tree", "args": {}}
|
| 55 |
+
\`\`\`
|
| 56 |
+
Usuario: busca dónde se define handleClick
|
| 57 |
+
Tú:
|
| 58 |
+
\`\`\`tool
|
| 59 |
+
{"tool": "code.search", "args": {"query": "handleClick"}}
|
| 60 |
+
\`\`\`
|
| 61 |
+
Usuario: crea una carpeta tests y un fichero vacío dentro
|
| 62 |
+
Tú:
|
| 63 |
+
\`\`\`tool
|
| 64 |
+
{"tool": "terminal.run", "args": {"command": "mkdir tests && touch tests/test_main.py"}}
|
| 65 |
+
\`\`\`
|
| 66 |
+
Usuario: crea un mini proyecto web que explique esto
|
| 67 |
+
Tú: (SIN pedir permiso ni framework — escribe los ficheros con contenido COMPLETO, varias code.write a la vez)
|
| 68 |
+
\`\`\`tool
|
| 69 |
+
{"tool": "code.write", "args": {"path": "web/index.html", "content": "<!DOCTYPE html>\\n<html>…página completa…</html>"}}
|
| 70 |
+
\`\`\`
|
| 71 |
+
\`\`\`tool
|
| 72 |
+
{"tool": "code.write", "args": {"path": "web/style.css", "content": "body{font-family:system-ui;margin:0}…"}}
|
| 73 |
+
\`\`\`
|
| 74 |
+
Usuario: ¿qué mejorarías del código?
|
| 75 |
+
Tú:
|
| 76 |
+
\`\`\`tool
|
| 77 |
+
{"tool": "code.read", "args": {"path": "src/main.py"}}
|
| 78 |
+
\`\`\`
|
| 79 |
+
(y tras leerlo de verdad, propones mejoras CONCRETAS citando líneas — nunca genéricas)${skillsPromptBlock()}${context ? `
|
| 80 |
+
|
| 81 |
+
CONTEXTO AHORA (estado real del IDE, úsalo):
|
| 82 |
+
${context}` : ''}`;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function tryJson(raw) {
|
| 86 |
+
try {
|
| 87 |
+
const obj = JSON.parse(raw);
|
| 88 |
+
if (obj && typeof obj.tool === 'string') return { tool: obj.tool, args: obj.args || {} };
|
| 89 |
+
} catch { /* no era JSON */ }
|
| 90 |
+
return null;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Formato NATIVO de LFM2.5: <|tool_call_start|>[ code.read(path="x") ]<|tool_call_end|>
|
| 94 |
+
export function parseNativeCall(text) {
|
| 95 |
+
const m = text.match(/<\|tool_call_start\|>\s*\[?\s*([\w.]+)\s*\(([\s\S]*?)\)\s*\]?\s*<\|tool_call_end\|>/)
|
| 96 |
+
|| text.match(/^\s*\[\s*([\w.]+)\s*\(([\s\S]*?)\)\s*\]\s*$/m);
|
| 97 |
+
if (!m) return null;
|
| 98 |
+
const args = {};
|
| 99 |
+
const re = /([\w]+)\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^,)]+)/g;
|
| 100 |
+
let a;
|
| 101 |
+
while ((a = re.exec(m[2]))) {
|
| 102 |
+
let v = a[2].trim();
|
| 103 |
+
if (/^["']/.test(v)) v = v.slice(1, -1).replace(/\\(.)/g, '$1');
|
| 104 |
+
else if (/^-?\d+(\.\d+)?$/.test(v)) v = Number(v);
|
| 105 |
+
else if (v === 'true' || v === 'false') v = v === 'true';
|
| 106 |
+
args[a[1]] = v;
|
| 107 |
+
}
|
| 108 |
+
return { tool: m[1], args };
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Extrae UNA tool-call (compat). Prefiere la primera de parseToolCalls.
|
| 112 |
+
export function parseToolCall(text) {
|
| 113 |
+
return parseToolCalls(text)[0] || null;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Extrae TODAS las tool-calls de un mensaje, en orden. El modelo pequeño a
|
| 117 |
+
// menudo emite varias (p. ej. tres code.write para crear tres ficheros) o las
|
| 118 |
+
// mete en prosa sin un fence ```tool perfecto → antes solo se ejecutaba la
|
| 119 |
+
// primera (o ninguna) y los ficheros quedaban VACÍOS. Aquí buscamos cada objeto
|
| 120 |
+
// {"tool":...} por llaves balanceadas, esté donde esté, además del formato nativo.
|
| 121 |
+
export function parseToolCalls(text) {
|
| 122 |
+
const calls = [];
|
| 123 |
+
const seen = new Set();
|
| 124 |
+
const push = c => {
|
| 125 |
+
if (!c || typeof c.tool !== 'string') return;
|
| 126 |
+
const k = c.tool + '|' + JSON.stringify(c.args || {});
|
| 127 |
+
if (!seen.has(k)) { seen.add(k); calls.push(c); }
|
| 128 |
+
};
|
| 129 |
+
// 1) formato nativo LFM (puede haber varios)
|
| 130 |
+
const nat = /<\|tool_call_start\|>[\s\S]*?<\|tool_call_end\|>/g;
|
| 131 |
+
let mm;
|
| 132 |
+
while ((mm = nat.exec(text))) push(parseNativeCall(mm[0]));
|
| 133 |
+
// 2) cada objeto JSON que empiece por {"tool": … (con o sin fence)
|
| 134 |
+
const re = /\{\s*"tool"\s*:/g;
|
| 135 |
+
let m;
|
| 136 |
+
while ((m = re.exec(text))) {
|
| 137 |
+
const obj = extractBalanced(text, m.index);
|
| 138 |
+
if (obj) { push(tryJson(obj)); re.lastIndex = m.index + obj.length; }
|
| 139 |
+
}
|
| 140 |
+
// 3) último recurso: nativo suelto de una línea
|
| 141 |
+
if (!calls.length) push(parseNativeCall(text));
|
| 142 |
+
return calls;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// Desde el '{' en `start`, devuelve la subcadena hasta la '}' que balancea,
|
| 146 |
+
// respetando cadenas y escapes (para no cortar un content con llaves dentro).
|
| 147 |
+
function extractBalanced(text, start) {
|
| 148 |
+
let depth = 0, inStr = false, esc = false;
|
| 149 |
+
for (let i = start; i < text.length; i++) {
|
| 150 |
+
const ch = text[i];
|
| 151 |
+
if (esc) { esc = false; continue; }
|
| 152 |
+
if (ch === '\\') { esc = true; continue; }
|
| 153 |
+
if (ch === '"') inStr = !inStr;
|
| 154 |
+
else if (!inStr) {
|
| 155 |
+
if (ch === '{') depth++;
|
| 156 |
+
else if (ch === '}' && --depth === 0) return text.slice(start, i + 1);
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
// Recuperación: los modelos pequeños a veces se dejan SIN escribir una
|
| 160 |
+
// llave de cierre (cierran "args":{…} pero no el objeto exterior) aunque
|
| 161 |
+
// el resto del JSON esté bien formado — antes esto tiraba el tool-call
|
| 162 |
+
// entero (ninguna escritura llegaba a ejecutarse, sin aviso). Si al llegar
|
| 163 |
+
// al final falta cerrar N llaves (fuera de cualquier cadena, así que no es
|
| 164 |
+
// un truncado a media cadena) probamos a añadirlas — solo se acepta si el
|
| 165 |
+
// JSON resultante es válido de verdad.
|
| 166 |
+
if (!inStr && depth > 0 && depth <= 4) {
|
| 167 |
+
const candidate = text.slice(start).replace(/```[\s\S]*$/, '').trimEnd() + '}'.repeat(depth);
|
| 168 |
+
try { JSON.parse(candidate); return candidate; } catch { /* no se pudo recuperar */ }
|
| 169 |
+
}
|
| 170 |
+
return null;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
export class Agent {
|
| 174 |
+
constructor(provider) {
|
| 175 |
+
this.provider = provider;
|
| 176 |
+
this.history = [];
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
setProvider(p) { this.provider = p; }
|
| 180 |
+
|
| 181 |
+
async handle(userText, onEvent) {
|
| 182 |
+
this.history.push({ role: 'user', content: userText });
|
| 183 |
+
for (let step = 0; step < MAX_STEPS; step++) {
|
| 184 |
+
let out;
|
| 185 |
+
try {
|
| 186 |
+
const context = await snapshot().catch(() => '');
|
| 187 |
+
out = await this.provider.chat(this.history, systemPrompt(context),
|
| 188 |
+
t => onEvent({ type: 'token', text: t }));
|
| 189 |
+
} catch (e) {
|
| 190 |
+
telemetry.reportError('agent.handle: ' + e.message, { stack: e.stack || '' });
|
| 191 |
+
onEvent({ type: 'error', text: 'El modelo falló: ' + e.message });
|
| 192 |
+
return;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
const calls = parseToolCalls(out);
|
| 196 |
+
if (!calls.length) {
|
| 197 |
+
this.history.push({ role: 'assistant', content: out });
|
| 198 |
+
onEvent({ type: 'text', text: out });
|
| 199 |
+
return;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// Ejecutar TODAS las tool-calls del mensaje en orden (varios code.write =
|
| 203 |
+
// varios ficheros con contenido, no vacíos).
|
| 204 |
+
this.history.push({ role: 'assistant', content: out });
|
| 205 |
+
const results = [];
|
| 206 |
+
for (const call of calls) {
|
| 207 |
+
onEvent({ type: 'tool', call });
|
| 208 |
+
let result;
|
| 209 |
+
try { result = await runTool(call.tool, call.args); }
|
| 210 |
+
catch (e) { result = 'ERROR: ' + e.message; }
|
| 211 |
+
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
| 212 |
+
onEvent({ type: 'tool_result', tool: call.tool, result: resultStr });
|
| 213 |
+
results.push(`[resultado ${call.tool}]\n${resultStr}`);
|
| 214 |
+
}
|
| 215 |
+
this.history.push({ role: 'user', content: results.join('\n\n') });
|
| 216 |
+
}
|
| 217 |
+
onEvent({ type: 'text', text: '(Me quedé sin pasos: demasiadas herramientas seguidas.)' });
|
| 218 |
+
}
|
| 219 |
+
}
|
js/arch.js
ADDED
|
Binary file (5.83 kB). View file
|
|
|
js/bridge.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Cliente del Elffuss Bridge: puente local (proceso que el usuario descarga y
|
| 2 |
+
// ejecuta) que da ejecución REAL en su máquina (node, npm, python…) sin salir
|
| 3 |
+
// de ella. Protocolo por WebSocket a 127.0.0.1, con token de un solo arranque.
|
| 4 |
+
const PORT = 8765;
|
| 5 |
+
const HOST = '127.0.0.1:' + PORT;
|
| 6 |
+
|
| 7 |
+
let ws = null, connected = false, authed = false;
|
| 8 |
+
let pending = new Map(); // id → { onOut, onErr, resolve }
|
| 9 |
+
let nextId = 1;
|
| 10 |
+
let onStatus = () => {};
|
| 11 |
+
let savedFolder = '';
|
| 12 |
+
try { savedFolder = localStorage.getItem('elffusscode.bridgeFolder') || ''; } catch { /* */ }
|
| 13 |
+
let savedToken = '';
|
| 14 |
+
try { savedToken = localStorage.getItem('elffusscode.bridgeToken') || ''; } catch { /* */ }
|
| 15 |
+
|
| 16 |
+
export function isConnected() { return connected && authed; }
|
| 17 |
+
export function getFolder() { return savedFolder; }
|
| 18 |
+
export function setFolder(f) {
|
| 19 |
+
savedFolder = (f || '').trim();
|
| 20 |
+
try { localStorage.setItem('elffusscode.bridgeFolder', savedFolder); } catch { /* */ }
|
| 21 |
+
}
|
| 22 |
+
export function onStatusChange(fn) { onStatus = fn; }
|
| 23 |
+
|
| 24 |
+
// sondeo ligero: ¿hay un bridge escuchando en 127.0.0.1, aunque no nos hayamos
|
| 25 |
+
// autenticado todavía? (para saber si mostrar «detectado» antes de conectar)
|
| 26 |
+
export async function probe() {
|
| 27 |
+
try {
|
| 28 |
+
const r = await fetch('http://' + HOST + '/ping', { mode: 'cors' });
|
| 29 |
+
if (!r.ok) return false;
|
| 30 |
+
const j = await r.json();
|
| 31 |
+
return !!j.ok;
|
| 32 |
+
} catch { return false; }
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function connect(token) {
|
| 36 |
+
return new Promise((resolve, reject) => {
|
| 37 |
+
try { ws = new WebSocket('ws://' + HOST + '/ws'); }
|
| 38 |
+
catch (e) { reject(e); return; }
|
| 39 |
+
const timeout = setTimeout(() => { try { ws.close(); } catch { /* */ } reject(new Error('tiempo agotado — ¿está el bridge arrancado?')); }, 6000);
|
| 40 |
+
ws.onopen = () => ws.send(JSON.stringify({ type: 'auth', token }));
|
| 41 |
+
ws.onerror = () => { clearTimeout(timeout); connected = false; authed = false; onStatus('error'); reject(new Error('no pude conectar — ¿está el bridge arrancado en este puerto?')); };
|
| 42 |
+
ws.onclose = () => { connected = false; authed = false; onStatus('disconnected'); };
|
| 43 |
+
ws.onmessage = e => {
|
| 44 |
+
let m; try { m = JSON.parse(e.data); } catch { return; }
|
| 45 |
+
if (m.type === 'auth-ok') {
|
| 46 |
+
clearTimeout(timeout);
|
| 47 |
+
connected = true; authed = true;
|
| 48 |
+
savedToken = token;
|
| 49 |
+
try { localStorage.setItem('elffusscode.bridgeToken', token); } catch { /* */ }
|
| 50 |
+
onStatus('connected');
|
| 51 |
+
resolve(true);
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
if (m.type === 'auth-fail') { clearTimeout(timeout); connected = true; authed = false; onStatus('auth-fail'); reject(new Error('token incorrecto')); return; }
|
| 55 |
+
const job = pending.get(m.id);
|
| 56 |
+
if (!job) return;
|
| 57 |
+
if (m.type === 'stdout') job.onOut(m.data);
|
| 58 |
+
else if (m.type === 'stderr') job.onErr(m.data);
|
| 59 |
+
else if (m.type === 'error') { job.onErr(m.data); job.resolve(-1); pending.delete(m.id); }
|
| 60 |
+
else if (m.type === 'exit') { job.resolve(m.code || 0); pending.delete(m.id); }
|
| 61 |
+
};
|
| 62 |
+
});
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function disconnect() { try { ws?.close(); } catch { /* */ } connected = false; authed = false; }
|
| 66 |
+
|
| 67 |
+
// tratar de reconectar en silencio con el token guardado (al abrir la app)
|
| 68 |
+
export async function tryAutoConnect() {
|
| 69 |
+
if (!savedToken) return false;
|
| 70 |
+
try { await connect(savedToken); return true; } catch { return false; }
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Detección continua en segundo plano: si el usuario arranca el bridge
|
| 74 |
+
// DESPUÉS de cargar la página (o lo reinicia tras cerrarlo), esto lo detecta
|
| 75 |
+
// solo y reconecta con el token guardado — sin que haga falta reabrir Ajustes
|
| 76 |
+
// y pulsar «Conectar» de nuevo. Se apoya en probe() (sondeo ligero sin auth).
|
| 77 |
+
let autoDetectTimer = null, connecting = false;
|
| 78 |
+
export function startAutoDetect(intervalMs = 4000) {
|
| 79 |
+
if (autoDetectTimer) return () => stopAutoDetect();
|
| 80 |
+
autoDetectTimer = setInterval(async () => {
|
| 81 |
+
if (isConnected() || connecting || !savedToken) return;
|
| 82 |
+
if (!(await probe())) return;
|
| 83 |
+
connecting = true;
|
| 84 |
+
try { await connect(savedToken); } catch { /* el usuario aún no ha pegado token válido, o cayó de nuevo */ }
|
| 85 |
+
finally { connecting = false; }
|
| 86 |
+
}, intervalMs);
|
| 87 |
+
return () => stopAutoDetect();
|
| 88 |
+
}
|
| 89 |
+
export function stopAutoDetect() { clearInterval(autoDetectTimer); autoDetectTimer = null; }
|
| 90 |
+
|
| 91 |
+
// ejecuta un comando REAL en la máquina del usuario, vía el bridge.
|
| 92 |
+
// onOut/onErr reciben trozos de salida en vivo; devuelve el código de salida.
|
| 93 |
+
export function exec(cmd, { cwd = savedFolder, onOut = () => {}, onErr = () => {} } = {}) {
|
| 94 |
+
if (!isConnected()) return Promise.reject(new Error('bridge no conectado'));
|
| 95 |
+
const id = String(nextId++);
|
| 96 |
+
return new Promise((resolve, reject) => {
|
| 97 |
+
pending.set(id, { onOut, onErr, resolve });
|
| 98 |
+
try { ws.send(JSON.stringify({ type: 'exec', id, cmd, cwd })); }
|
| 99 |
+
catch (e) { pending.delete(id); reject(e); }
|
| 100 |
+
});
|
| 101 |
+
}
|
js/ceo.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Cerebro CEO autónomo (compartido por Elffuss Code y Elffuss Claw): cuando el
|
| 2 |
+
// usuario NO está pidiendo nada (ocioso) y hay un modelo local cargado, la elfa
|
| 3 |
+
// «trabaja por su cuenta» — revisa el espacio de trabajo, reparte el trabajo en
|
| 4 |
+
// varios perfiles que piensan EN PARALELO (cada uno una línea de pensamiento en
|
| 5 |
+
// la vista Mente) y sintetiza propuestas de mejora.
|
| 6 |
+
//
|
| 7 |
+
// Agnóstico de herramientas a propósito: cada app inyecta su propio adaptador
|
| 8 |
+
// de workspace (`init({ workspace, ... })`) — Code usa code.js (proyecto de
|
| 9 |
+
// código), Claw usa fs.js (carpetas con permiso). El core no sabe ni le importa
|
| 10 |
+
// cuál es.
|
| 11 |
+
//
|
| 12 |
+
// Seguridad: NO modifica tus ficheros. Deja las propuestas en una carpeta
|
| 13 |
+
// aditiva (nunca toca lo existente) y las hace «flotar» en la Mente. Se PARA en
|
| 14 |
+
// cuanto detecta actividad del usuario y reanuda al volver a estar ocioso.
|
| 15 |
+
import { Agent } from './agent.js';
|
| 16 |
+
import { humanizeTool } from './humanize.js';
|
| 17 |
+
|
| 18 |
+
const IDLE_MS = 18000; // 18 s sin actividad → el CEO se pone a trabajar
|
| 19 |
+
const TICK_MS = 3000; // frecuencia de comprobación
|
| 20 |
+
// 5 min entre ciclos automáticos: con 45s, una tarde ociosa generaba MILES de
|
| 21 |
+
// ficheros. «Pensar ahora» (forceCycle) sigue disponible sin esperar esto.
|
| 22 |
+
const COOLDOWN_MS = 300000;
|
| 23 |
+
const SOUL_CAP = 25; // ficheros sueltos antes de consolidar en archivo.md
|
| 24 |
+
|
| 25 |
+
// perfil por defecto si la app anfitriona no da los suyos
|
| 26 |
+
const GENERIC_PROFILES = [
|
| 27 |
+
{ id: 'p1', name: 'Revisión', focus: 'qué mejorar de forma concreta y accionable', color: '#7c5cff' },
|
| 28 |
+
{ id: 'p2', name: 'Calidad', focus: 'errores, casos borde, cosas que podrían fallar', color: '#49e8ff' },
|
| 29 |
+
];
|
| 30 |
+
|
| 31 |
+
const slug = s => String(s).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
| 32 |
+
.replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 24) || 'perfil';
|
| 33 |
+
|
| 34 |
+
// ── namespace (localStorage) y adaptador de workspace: los da la app anfitriona ──
|
| 35 |
+
let NS = 'elffuss'; // prefijo de las claves de localStorage
|
| 36 |
+
let ws = null; // { isReady, tree, write, read, list, remove }
|
| 37 |
+
let getProvider = () => null;
|
| 38 |
+
let isBusy = () => false; // ¿el usuario tiene trabajo en cola/procesándose? → prioridad
|
| 39 |
+
let defaultProfiles = GENERIC_PROFILES;
|
| 40 |
+
const K = suffix => NS + '.' + suffix;
|
| 41 |
+
|
| 42 |
+
function loadProfiles() {
|
| 43 |
+
try { const s = JSON.parse(localStorage.getItem(K('ceoProfiles'))); if (Array.isArray(s) && s.length) return s; } catch { /* */ }
|
| 44 |
+
return defaultProfiles.map(p => ({ ...p }));
|
| 45 |
+
}
|
| 46 |
+
let profiles = null;
|
| 47 |
+
export function getProfiles() { if (!profiles) profiles = loadProfiles(); return profiles; }
|
| 48 |
+
export function setProfiles(list) {
|
| 49 |
+
const used = new Set();
|
| 50 |
+
profiles = (list || []).filter(p => p && p.name).map(p => {
|
| 51 |
+
let id = p.id || slug(p.name); let n = id, i = 2;
|
| 52 |
+
while (used.has(n)) n = id + '-' + i++;
|
| 53 |
+
used.add(n);
|
| 54 |
+
return { id: n, name: String(p.name).slice(0, 40), focus: String(p.focus || '').slice(0, 200), color: /^#[0-9a-f]{6}$/i.test(p.color || '') ? p.color : '#7c5cff' };
|
| 55 |
+
});
|
| 56 |
+
if (!profiles.length) profiles = defaultProfiles.map(p => ({ ...p }));
|
| 57 |
+
try { localStorage.setItem(K('ceoProfiles'), JSON.stringify(profiles)); } catch { /* */ }
|
| 58 |
+
emit('ceo', { type: 'reprogram', text: 'Perfiles actualizados: ' + profiles.map(p => p.name).join(', '), profiles });
|
| 59 |
+
return profiles;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
let enabled = false, running = false, lastActivity = Date.now(), timer = null, lastCycleEnd = 0, cycleN = 0;
|
| 63 |
+
|
| 64 |
+
// MISIÓN reprogramable: el usuario puede reorientar el cerebro desde la Mente
|
| 65 |
+
// («céntrate en seguridad», «optimiza mis Excel», «documenta todo»…).
|
| 66 |
+
let DEFAULT_MISSION = 'Revisar el espacio de trabajo y proponer mejoras concretas y accionables.';
|
| 67 |
+
let mission = null;
|
| 68 |
+
function ensureMission() { if (mission == null) { try { mission = localStorage.getItem(K('ceoMission')) || DEFAULT_MISSION; } catch { mission = DEFAULT_MISSION; } } }
|
| 69 |
+
export function getMission() { ensureMission(); return mission; }
|
| 70 |
+
export function setMission(text) {
|
| 71 |
+
mission = (text || '').trim() || DEFAULT_MISSION;
|
| 72 |
+
try { localStorage.setItem(K('ceoMission'), mission); } catch { /* */ }
|
| 73 |
+
emit('ceo', { type: 'reprogram', text: 'Nueva misión recibida: ' + mission });
|
| 74 |
+
lastCycleEnd = 0; lastActivity = Date.now() - IDLE_MS; // que arranque un ciclo pronto con la nueva misión
|
| 75 |
+
return mission;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Carpeta-«alma» donde el cerebro crea y guarda TODO (configurable).
|
| 79 |
+
let DEFAULT_SOUL = '.elffuss/soul';
|
| 80 |
+
let soulDir = null;
|
| 81 |
+
function ensureSoulDir() { if (soulDir == null) { try { soulDir = localStorage.getItem(K('ceoDir')) || DEFAULT_SOUL; } catch { soulDir = DEFAULT_SOUL; } } }
|
| 82 |
+
export function getSoulDir() { ensureSoulDir(); return soulDir; }
|
| 83 |
+
export function setSoulDir(dir) {
|
| 84 |
+
soulDir = (dir || '').trim().replace(/^\/+|\/+$/g, '') || DEFAULT_SOUL;
|
| 85 |
+
try { localStorage.setItem(K('ceoDir'), soulDir); } catch { /* */ }
|
| 86 |
+
emit('ceo', { type: 'reprogram', text: 'Nueva carpeta-alma: ' + soulDir + '/' });
|
| 87 |
+
return soulDir;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// ── semáforo cross-pestaña: UN SOLO cerebro ejecuta, TODAS visualizan ───────
|
| 91 |
+
// El líder tiene un Web Lock exclusivo (se libera solo al cerrar la pestaña);
|
| 92 |
+
// difunde sus pensamientos por BroadcastChannel para que el resto los vea.
|
| 93 |
+
// (Aislado por origen por el propio navegador: Code y Claw nunca se cruzan.)
|
| 94 |
+
let isLeader = false, bc = null, realEmit = () => {}, crossTabWired = false;
|
| 95 |
+
function initCrossTab() {
|
| 96 |
+
if (crossTabWired) return;
|
| 97 |
+
crossTabWired = true;
|
| 98 |
+
try {
|
| 99 |
+
bc = new BroadcastChannel(NS + '-ceo');
|
| 100 |
+
bc.onmessage = e => { if (e.data && e.data.kind === 'thought') realEmit(e.data.channel, e.data.ev); };
|
| 101 |
+
} catch { /* sin BroadcastChannel */ }
|
| 102 |
+
if (navigator.locks && navigator.locks.request) {
|
| 103 |
+
navigator.locks.request(NS + '-ceo-leader', { mode: 'exclusive' }, () => new Promise(() => { isLeader = true; }))
|
| 104 |
+
.catch(() => { isLeader = true; });
|
| 105 |
+
} else { isLeader = true; }
|
| 106 |
+
}
|
| 107 |
+
function emit(channel, ev) {
|
| 108 |
+
realEmit(channel, ev);
|
| 109 |
+
try { bc && bc.postMessage({ kind: 'thought', channel, ev }); } catch { /* ev no serializable */ }
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// init({ workspace, provider, onEvent, isBusy, namespace, defaultProfiles, defaultMission, defaultSoulDir })
|
| 113 |
+
export function init(opts = {}) {
|
| 114 |
+
if (opts.workspace) ws = opts.workspace;
|
| 115 |
+
if (opts.provider) getProvider = opts.provider;
|
| 116 |
+
if (opts.onEvent) realEmit = opts.onEvent;
|
| 117 |
+
if (opts.isBusy) isBusy = opts.isBusy;
|
| 118 |
+
if (opts.namespace) NS = opts.namespace;
|
| 119 |
+
if (opts.defaultProfiles) defaultProfiles = opts.defaultProfiles;
|
| 120 |
+
if (opts.defaultMission) DEFAULT_MISSION = opts.defaultMission;
|
| 121 |
+
if (opts.defaultSoulDir) DEFAULT_SOUL = opts.defaultSoulDir;
|
| 122 |
+
initCrossTab();
|
| 123 |
+
}
|
| 124 |
+
export function isThisTabLeader() { return isLeader; }
|
| 125 |
+
export function noteActivity() { lastActivity = Date.now(); if (running) running = 'interrupt'; }
|
| 126 |
+
export function isEnabled() { return enabled; }
|
| 127 |
+
export function isRunning() { return !!running; }
|
| 128 |
+
// Play/stop: persistido AQUÍ (fuente única) — cualquier botón que lo toque
|
| 129 |
+
// queda sincronizado, y la elección sobrevive a recargar la página.
|
| 130 |
+
export function wasEnabledLastSession() { try { return localStorage.getItem(K('ceoEnabled')) === '1'; } catch { return false; } }
|
| 131 |
+
// ¿el usuario llegó a decidir alguna vez (play o stop)? Distingue «nunca lo
|
| 132 |
+
// tocó» de «lo pausó a propósito» — solo lo primero debe auto-activarse al
|
| 133 |
+
// abrir la Mente; lo segundo hay que RESPETARLO, no pisarlo.
|
| 134 |
+
export function hasDecided() { try { return localStorage.getItem(K('ceoEnabled')) != null; } catch { return false; } }
|
| 135 |
+
export function enable() {
|
| 136 |
+
if (enabled) return;
|
| 137 |
+
enabled = true; lastActivity = Date.now(); schedule();
|
| 138 |
+
try { localStorage.setItem(K('ceoEnabled'), '1'); } catch { /* */ }
|
| 139 |
+
emit('sys', { type: 'status', text: 'CEO en guardia — trabajaré cuando estés ocioso' });
|
| 140 |
+
}
|
| 141 |
+
export function disable() {
|
| 142 |
+
enabled = false; running = false; if (timer) clearTimeout(timer);
|
| 143 |
+
try { localStorage.setItem(K('ceoEnabled'), '0'); } catch { /* */ }
|
| 144 |
+
emit('sys', { type: 'status', text: 'CEO en pausa' });
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
function schedule() { if (timer) clearTimeout(timer); timer = setTimeout(tick, TICK_MS); }
|
| 148 |
+
|
| 149 |
+
// «Pensar ahora» — salta la espera de ociosidad. Sigue respetando el
|
| 150 |
+
// semáforo: si otra pestaña es la líder, no compite por la GPU.
|
| 151 |
+
export async function forceCycle() {
|
| 152 |
+
if (!isLeader) { emit('ceo', { type: 'paused', text: 'Otra pestaña lleva el cerebro — ábrela ahí para forzar un ciclo.' }); return false; }
|
| 153 |
+
if (running) return false;
|
| 154 |
+
// isReady() puede ser síncrona (Code: handle en memoria) o async (Claw: consulta IndexedDB) — se espera siempre.
|
| 155 |
+
if (!(await ws?.isReady()) || !getProvider()) { emit('ceo', { type: 'paused', text: 'Necesito un espacio de trabajo abierto y un modelo cargado.' }); return false; }
|
| 156 |
+
try { await runCycle(); } finally { lastCycleEnd = Date.now(); }
|
| 157 |
+
return true;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
async function tick() {
|
| 161 |
+
if (!enabled) return;
|
| 162 |
+
const idle = Date.now() - lastActivity;
|
| 163 |
+
const rested = Date.now() - lastCycleEnd > COOLDOWN_MS;
|
| 164 |
+
const mightRun = isLeader && !running && !isBusy() && idle >= IDLE_MS && rested && getProvider();
|
| 165 |
+
if (mightRun && await ws?.isReady()) {
|
| 166 |
+
try { await runCycle(); } catch { /* siguiente ciclo */ }
|
| 167 |
+
lastCycleEnd = Date.now();
|
| 168 |
+
}
|
| 169 |
+
schedule();
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// helper: corre el agente con el proveedor actual sobre un prompt, emitiendo
|
| 173 |
+
// tokens/herramientas a un canal. Devuelve el texto final. Las tool-calls pasan
|
| 174 |
+
// SIEMPRE por el mismo runTool que usa el chat normal (Agent.handle real).
|
| 175 |
+
async function think(channel, task) {
|
| 176 |
+
const prov = getProvider();
|
| 177 |
+
if (!prov) return '';
|
| 178 |
+
const a = new Agent({ chat: (h, s, cb) => prov.chat(h, s, cb) });
|
| 179 |
+
let out = '';
|
| 180 |
+
await a.handle(task, ev => {
|
| 181 |
+
if (running === 'interrupt') throw new Error('interrumpido');
|
| 182 |
+
if (ev.type === 'token') { out += ev.text; emit(channel, { type: 'token', text: ev.text }); }
|
| 183 |
+
else if (ev.type === 'tool') emit(channel, { type: 'tool', text: humanizeTool(ev.call.tool, ev.call.args), tool: ev.call.tool, path: ev.call.args?.path || null });
|
| 184 |
+
else if (ev.type === 'tool_result') emit(channel, { type: 'tool_result', tool: ev.tool, text: String(ev.result || '').replace(/\s+/g, ' ').trim().slice(0, 100) });
|
| 185 |
+
else if (ev.type === 'text') { out = ev.text; }
|
| 186 |
+
});
|
| 187 |
+
return out;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
async function runCycle() {
|
| 191 |
+
running = true;
|
| 192 |
+
cycleN++;
|
| 193 |
+
emit('ceo', { type: 'cycle', n: cycleN, text: 'Nuevo ciclo: reviso el espacio de trabajo y reparto el trabajo…' });
|
| 194 |
+
|
| 195 |
+
let tree = '';
|
| 196 |
+
try { tree = await ws.tree({ depth: 2 }); } catch { /* sin workspace */ }
|
| 197 |
+
emit('ceo', { type: 'survey', text: 'Panorama captado (' + tree.split('\n').length + ' entradas). Delegando…' });
|
| 198 |
+
|
| 199 |
+
const brief = (d) => `MISIÓN del equipo (fijada por el usuario): ${getMission()}\n` +
|
| 200 |
+
`Eres el jefe de ${d.name}. Dentro de esa misión, céntrate en: ${d.focus}. ` +
|
| 201 |
+
`Explora lo mínimo con tus herramientas y propón UNA mejora CONCRETA y accionable, ` +
|
| 202 |
+
`entendible por un humano. Sé breve. No modifiques nada existente: solo la propuesta.`;
|
| 203 |
+
const proposals = await Promise.all(getProfiles().map(async d => {
|
| 204 |
+
emit(d.id, { type: 'open', name: d.name, focus: d.focus });
|
| 205 |
+
try { const p = await think(d.id, brief(d)); emit(d.id, { type: 'done', text: p }); return { dept: d.name, text: p }; }
|
| 206 |
+
catch { emit(d.id, { type: 'done', text: '(interrumpido)' }); return null; }
|
| 207 |
+
}));
|
| 208 |
+
if (running === 'interrupt') { running = false; emit('ceo', { type: 'paused', text: 'Vuelves tú — dejo lo mío y te cedo el mando.' }); return; }
|
| 209 |
+
|
| 210 |
+
const valid = proposals.filter(Boolean).filter(p => p.text && p.text.length > 8);
|
| 211 |
+
const md = `# Propuestas de mejora — ciclo ${cycleN}\n\n**Misión:** ${getMission()}\n\n` +
|
| 212 |
+
valid.map(p => `## ${p.dept}\n${p.text}\n`).join('\n') +
|
| 213 |
+
`\n_— generado por el cerebro CEO de Elffuss mientras estabas ocioso._\n`;
|
| 214 |
+
const d = new Date();
|
| 215 |
+
const stamp = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}-${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`;
|
| 216 |
+
const topic = slug((valid[0]?.text || 'ciclo').split(/[.,;\n]/)[0]) || 'ciclo';
|
| 217 |
+
const path = `${getSoulDir()}/${stamp}-${topic}.md`;
|
| 218 |
+
try {
|
| 219 |
+
await rotateSoul();
|
| 220 |
+
await ws.write({ path, content: md });
|
| 221 |
+
emit('ceo', { type: 'built', text: `Propuesta guardada en ${path}`, path, md, proposals: valid });
|
| 222 |
+
} catch (e) {
|
| 223 |
+
emit('ceo', { type: 'built', text: 'Propuesta lista (no pude escribir el fichero)', md, proposals: valid });
|
| 224 |
+
}
|
| 225 |
+
running = false;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// Rotación: si hay demasiados ficheros sueltos, los MÁS ANTIGUOS se consolidan
|
| 229 |
+
// (recopilan) en un único archivo.md y se borran — para no acabar con miles.
|
| 230 |
+
async function rotateSoul() {
|
| 231 |
+
try {
|
| 232 |
+
const soul = getSoulDir();
|
| 233 |
+
const names = (await ws.list(soul)).filter(n => n.endsWith('.md') && n !== 'archivo.md');
|
| 234 |
+
if (names.length < SOUL_CAP) return;
|
| 235 |
+
names.sort(); // el nombre empieza por fecha → orden cronológico
|
| 236 |
+
const excess = names.slice(0, names.length - SOUL_CAP + 1);
|
| 237 |
+
let archive = ''; try { archive = await ws.read({ path: `${soul}/archivo.md` }); } catch { archive = '# Archivo histórico del cerebro\n'; }
|
| 238 |
+
for (const name of excess) {
|
| 239 |
+
try { archive += `\n---\n## ${name}\n${await ws.read({ path: `${soul}/${name}` })}\n`; } catch { /* */ }
|
| 240 |
+
}
|
| 241 |
+
await ws.write({ path: `${soul}/archivo.md`, content: archive.slice(-80000) });
|
| 242 |
+
for (const name of excess) { try { await ws.remove(soul, name); } catch { /* */ } }
|
| 243 |
+
emit('ceo', { type: 'reprogram', text: `Consolidé ${excess.length} propuestas antiguas en archivo.md` });
|
| 244 |
+
} catch { /* soulDir aún sin crear o sin permiso: nada que rotar */ }
|
| 245 |
+
}
|
js/city.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Vista «Ciudad 3D» — el proyecto abierto renderizado con el MOTOR REAL de
|
| 2 |
+
// VibeCodeViewer (Sendery/VibeCodeViewer, Apache-2.0), vendorizado en js/vcc.
|
| 3 |
+
// Aquí solo hacemos de glue: escaneamos la carpeta abierta al árbol empaquetado
|
| 4 |
+
// que espera buildModel, montamos el render (cámara + OrbitControls + bloom) y
|
| 5 |
+
// conectamos el raycast → abrir fichero en Monaco. La geometría (ciudad anidada
|
| 6 |
+
// distrito→edificio→planta→…, cristal fresnel, fachadas con el árbol impreso,
|
| 7 |
+
// suelo de circuito, leyenda) es la del builder original, sin tocar.
|
| 8 |
+
import * as THREE from 'three';
|
| 9 |
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
| 10 |
+
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
| 11 |
+
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
| 12 |
+
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
| 13 |
+
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
| 14 |
+
import * as code from './tools/code.js';
|
| 15 |
+
import { buildModel } from './vcc/model.js';
|
| 16 |
+
import { buildCity } from './vcc/builder.js';
|
| 17 |
+
import { THEME } from './vcc/theme.js';
|
| 18 |
+
|
| 19 |
+
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
| 20 |
+
const MAX_FILES = 8000; // tope de seguridad para no colgar el navegador en monorepos
|
| 21 |
+
|
| 22 |
+
let disposer = null;
|
| 23 |
+
|
| 24 |
+
// Escanea el handle del proyecto al formato empaquetado de VibeCodeViewer:
|
| 25 |
+
// dir = [nombre, [subdirs], [ficheros]] · fichero = [nombre, bytes]
|
| 26 |
+
async function scanTree(dir, budget) {
|
| 27 |
+
const dirs = [], files = [];
|
| 28 |
+
for await (const e of dir.values()) {
|
| 29 |
+
if (IGNORE.has(e.name)) continue;
|
| 30 |
+
(e.kind === 'directory' ? dirs : files).push(e);
|
| 31 |
+
}
|
| 32 |
+
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
| 33 |
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
| 34 |
+
const outFiles = [];
|
| 35 |
+
for (const f of files) {
|
| 36 |
+
if (budget.n >= MAX_FILES) break;
|
| 37 |
+
let size = 0; try { size = (await f.getFile()).size; } catch { /* ilegible */ }
|
| 38 |
+
outFiles.push([f.name, size]); budget.n++;
|
| 39 |
+
}
|
| 40 |
+
const outDirs = [];
|
| 41 |
+
for (const d of dirs) { if (budget.n >= MAX_FILES) break; outDirs.push(await scanTree(d, budget)); }
|
| 42 |
+
return [dir.name, outDirs, outFiles];
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export async function renderCity(container, onOpenFile) {
|
| 46 |
+
disposeCity();
|
| 47 |
+
const root = code.handle();
|
| 48 |
+
if (!root) { container.innerHTML = '<div class="view-loading">Abre un proyecto primero.</div>'; return; }
|
| 49 |
+
container.innerHTML = '<div class="view-loading">Construyendo la metrópolis del proyecto…</div>';
|
| 50 |
+
|
| 51 |
+
const budget = { n: 0 };
|
| 52 |
+
const tree = await scanTree(root, budget);
|
| 53 |
+
const model = buildModel(tree, null);
|
| 54 |
+
if (!model.files.length) { container.innerHTML = '<div class="view-loading">Proyecto vacío.</div>'; return; }
|
| 55 |
+
|
| 56 |
+
container.innerHTML =
|
| 57 |
+
'<canvas id="city-canvas"></canvas>' +
|
| 58 |
+
'<div class="view-head">Ciudad 3D · ' + model.files.length + (budget.n >= MAX_FILES ? '+' : '') +
|
| 59 |
+
' ficheros en ' + model.districts.length + ' distritos' +
|
| 60 |
+
'<span class="view-hint">arrastra para orbitar · rueda para zoom · clic en un edificio → abrir</span></div>';
|
| 61 |
+
const canvas = container.querySelector('#city-canvas');
|
| 62 |
+
const W = container.clientWidth || 800, H = container.clientHeight || 600;
|
| 63 |
+
|
| 64 |
+
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
|
| 65 |
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
| 66 |
+
renderer.setSize(W, H, false);
|
| 67 |
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
| 68 |
+
renderer.toneMappingExposure = 1.0;
|
| 69 |
+
renderer.setClearColor(THEME.background, 1);
|
| 70 |
+
|
| 71 |
+
const scene = new THREE.Scene();
|
| 72 |
+
scene.background = new THREE.Color(THEME.background);
|
| 73 |
+
const extent = Math.max(model.city.box.sx, model.city.box.sz);
|
| 74 |
+
scene.fog = new THREE.FogExp2(THEME.fog, 0.62 / extent);
|
| 75 |
+
|
| 76 |
+
const camera = new THREE.PerspectiveCamera(55, W / H, 0.1, extent * 14);
|
| 77 |
+
camera.position.set(extent * 0.7, extent * 0.6, extent * 0.7);
|
| 78 |
+
|
| 79 |
+
const controls = new OrbitControls(camera, canvas);
|
| 80 |
+
controls.enableDamping = true;
|
| 81 |
+
controls.dampingFactor = 0.08;
|
| 82 |
+
controls.maxPolarAngle = Math.PI * 0.495; // no bajar del suelo
|
| 83 |
+
controls.target.set(0, extent * 0.04, 0);
|
| 84 |
+
controls.update();
|
| 85 |
+
|
| 86 |
+
// pipeline con bloom (la receta del builder: HDR + bloom solo en lo resaltado)
|
| 87 |
+
const composer = new EffectComposer(renderer);
|
| 88 |
+
composer.setSize(W, H);
|
| 89 |
+
composer.addPass(new RenderPass(scene, camera));
|
| 90 |
+
const bloom = new UnrealBloomPass(new THREE.Vector2(W, H), THEME.bloom.strength, 0.25, THEME.bloom.threshold);
|
| 91 |
+
composer.addPass(bloom);
|
| 92 |
+
composer.addPass(new OutputPass());
|
| 93 |
+
|
| 94 |
+
const city = buildCity(scene, model, THEME);
|
| 95 |
+
|
| 96 |
+
// ── interacción: hover resalta edificios, clic abre el fichero ────────────
|
| 97 |
+
const ray = new THREE.Raycaster(), ptr = new THREE.Vector2();
|
| 98 |
+
let hovered = null, lastHover = 0, downXY = null;
|
| 99 |
+
const setPtr = e => { const r = canvas.getBoundingClientRect(); ptr.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); };
|
| 100 |
+
const onMove = e => {
|
| 101 |
+
setPtr(e);
|
| 102 |
+
const now = performance.now(); if (now - lastHover < 50) return; lastHover = now;
|
| 103 |
+
ray.setFromCamera(ptr, camera);
|
| 104 |
+
const b = ray.intersectObjects(city.shells)[0]?.object.userData.building ?? null;
|
| 105 |
+
if (b !== hovered) {
|
| 106 |
+
if (hovered) city.paintNode(hovered, 0);
|
| 107 |
+
hovered = b;
|
| 108 |
+
if (b) city.paintNode(b, 0.16);
|
| 109 |
+
canvas.style.cursor = b ? 'pointer' : 'grab';
|
| 110 |
+
}
|
| 111 |
+
};
|
| 112 |
+
const onDown = e => { downXY = [e.clientX, e.clientY]; };
|
| 113 |
+
const onUp = e => {
|
| 114 |
+
if (!downXY) return;
|
| 115 |
+
const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]); downXY = null;
|
| 116 |
+
if (moved > 5) return; // fue un arrastre de cámara
|
| 117 |
+
setPtr(e); ray.setFromCamera(ptr, camera);
|
| 118 |
+
const fileHit = ray.intersectObject(city.filesMesh)[0];
|
| 119 |
+
const shellHit = ray.intersectObjects(city.shells)[0];
|
| 120 |
+
if (fileHit && (!shellHit || fileHit.distance < shellHit.distance + 2)) {
|
| 121 |
+
const f = model.files[fileHit.instanceId];
|
| 122 |
+
if (f) onOpenFile(f.rel);
|
| 123 |
+
} else if (shellHit) {
|
| 124 |
+
// clic en un edificio (carpeta): centra la cámara en él
|
| 125 |
+
const b = shellHit.object.userData.building;
|
| 126 |
+
controls.target.set(b.box.cx, b.box.cy, b.box.cz);
|
| 127 |
+
}
|
| 128 |
+
};
|
| 129 |
+
canvas.addEventListener('pointermove', onMove);
|
| 130 |
+
canvas.addEventListener('pointerdown', onDown);
|
| 131 |
+
canvas.addEventListener('pointerup', onUp);
|
| 132 |
+
|
| 133 |
+
const onResize = () => {
|
| 134 |
+
const w = container.clientWidth || W, h = container.clientHeight || H;
|
| 135 |
+
renderer.setSize(w, h, false); composer.setSize(w, h); bloom.resolution.set(w, h);
|
| 136 |
+
camera.aspect = w / h; camera.updateProjectionMatrix();
|
| 137 |
+
};
|
| 138 |
+
window.addEventListener('resize', onResize);
|
| 139 |
+
|
| 140 |
+
let raf = null;
|
| 141 |
+
const loop = () => { raf = requestAnimationFrame(loop); controls.update(); composer.render(); };
|
| 142 |
+
loop();
|
| 143 |
+
|
| 144 |
+
disposer = () => {
|
| 145 |
+
if (raf) cancelAnimationFrame(raf);
|
| 146 |
+
window.removeEventListener('resize', onResize);
|
| 147 |
+
canvas.removeEventListener('pointermove', onMove);
|
| 148 |
+
canvas.removeEventListener('pointerdown', onDown);
|
| 149 |
+
canvas.removeEventListener('pointerup', onUp);
|
| 150 |
+
controls.dispose();
|
| 151 |
+
city.dispose();
|
| 152 |
+
composer.dispose?.();
|
| 153 |
+
renderer.dispose();
|
| 154 |
+
};
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
export function disposeCity() { if (disposer) { disposer(); disposer = null; } }
|
js/context.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Gestor de contexto ACE-lite: eviction de historial por relevancia, portado
|
| 2 |
+
// del attention-context-eviction (ACE_R) de agentic-install a nivel de
|
| 3 |
+
// mensaje (IDF + BM25-lite; en navegador no hay acceso a las atenciones).
|
| 4 |
+
// turboquant es cuantización de PESOS — para contexto, esto es lo que aplica.
|
| 5 |
+
//
|
| 6 |
+
// Reglas: el presupuesto es en tokens (~4 chars/token). Se conservan SIEMPRE
|
| 7 |
+
// los últimos RECENT mensajes; los antiguos compiten por el hueco restante
|
| 8 |
+
// puntuados por BM25-lite contra la consulta actual. Los evictados se
|
| 9 |
+
// sustituyen por una marca de omisión para que el modelo sepa que falta algo.
|
| 10 |
+
|
| 11 |
+
const RECENT = 6;
|
| 12 |
+
const MAX_MSG_CHARS = 12000; // ningún mensaje (p.ej. un README enorme) revienta el contexto
|
| 13 |
+
|
| 14 |
+
// Trunca por el MEDIO conservando cabeza y cola (útil para código/documentos).
|
| 15 |
+
function clampMsg(m) {
|
| 16 |
+
const c = m.content || '';
|
| 17 |
+
if (c.length <= MAX_MSG_CHARS) return m;
|
| 18 |
+
const head = Math.floor(MAX_MSG_CHARS * 0.7);
|
| 19 |
+
const tail = MAX_MSG_CHARS - head - 40;
|
| 20 |
+
return { ...m, content: c.slice(0, head) + `\n… [recortado ${c.length - MAX_MSG_CHARS} caracteres] …\n` + c.slice(-tail) };
|
| 21 |
+
}
|
| 22 |
+
const STOP = new Set(('de la que el en y a los del se las por un para con no una su al lo como más pero sus le ' +
|
| 23 |
+
'ya o este sí porque esta entre cuando muy sin sobre también me hasta hay donde quien desde todo nos durante ' +
|
| 24 |
+
'todos uno les ni contra otros ese eso ante ellos e esto mí antes algunos qué unos yo otro otras otra él tanto ' +
|
| 25 |
+
'esa estos mucho quienes nada muchos cual poco ella estar estas algunas algo nosotros tu te ti mi es son era eres').split(' '));
|
| 26 |
+
|
| 27 |
+
const tokens = s => (s.toLowerCase().match(/[a-záéíóúñü0-9_.]{2,}/g) || []).filter(w => !STOP.has(w));
|
| 28 |
+
const tokEstimate = m => Math.ceil((m.content || '').length / 4) + 4;
|
| 29 |
+
|
| 30 |
+
function bm25Scores(messages, query) {
|
| 31 |
+
const q = [...new Set(tokens(query))];
|
| 32 |
+
const docs = messages.map(m => tokens(m.content));
|
| 33 |
+
const N = docs.length || 1;
|
| 34 |
+
const avgLen = docs.reduce((s, d) => s + d.length, 0) / N || 1;
|
| 35 |
+
const df = new Map();
|
| 36 |
+
for (const d of docs) for (const w of new Set(d)) df.set(w, (df.get(w) || 0) + 1);
|
| 37 |
+
const k = 1.2, b = 0.75;
|
| 38 |
+
return docs.map(d => {
|
| 39 |
+
if (!d.length) return 0;
|
| 40 |
+
const tf = new Map();
|
| 41 |
+
for (const w of d) tf.set(w, (tf.get(w) || 0) + 1);
|
| 42 |
+
let score = 0;
|
| 43 |
+
for (const w of q) {
|
| 44 |
+
const f = tf.get(w);
|
| 45 |
+
if (!f) continue;
|
| 46 |
+
const idf = Math.log(1 + (N - (df.get(w) || 0) + 0.5) / ((df.get(w) || 0) + 0.5));
|
| 47 |
+
score += idf * (f * (k + 1)) / (f + k * (1 - b + b * (d.length / avgLen)));
|
| 48 |
+
}
|
| 49 |
+
return score;
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Recorta resultados de herramientas antiguos: la cola larga rara vez importa.
|
| 54 |
+
const shrink = m => m.content.startsWith('[resultado') && m.content.length > 600
|
| 55 |
+
? { ...m, content: m.content.slice(0, 600) + '\n… (recortado por antigüedad)' }
|
| 56 |
+
: m;
|
| 57 |
+
|
| 58 |
+
export function packHistory(history, budgetTokens = 2200) {
|
| 59 |
+
if (!history.length) return history;
|
| 60 |
+
// los recientes también se recortan por mensaje: un solo tool-result gigante
|
| 61 |
+
// (README de un repo grande) reventaba el contexto → «Too many tokens».
|
| 62 |
+
const recent = history.slice(-RECENT).map(clampMsg);
|
| 63 |
+
let used = recent.reduce((s, m) => s + tokEstimate(m), 0);
|
| 64 |
+
const old = history.slice(0, -RECENT).map(shrink);
|
| 65 |
+
if (!old.length || used >= budgetTokens) return recent;
|
| 66 |
+
|
| 67 |
+
const query = [...history].reverse().find(m =>
|
| 68 |
+
m.role === 'user' && !m.content.startsWith('[resultado'))?.content || '';
|
| 69 |
+
const scores = bm25Scores(old, query);
|
| 70 |
+
// orden por relevancia; a igualdad gana lo más nuevo
|
| 71 |
+
const ranked = old.map((m, i) => ({ m, i, s: scores[i] }))
|
| 72 |
+
.sort((a, b) => (b.s - a.s) || (b.i - a.i));
|
| 73 |
+
|
| 74 |
+
const keep = new Set();
|
| 75 |
+
for (const { m, i } of ranked) {
|
| 76 |
+
const cost = tokEstimate(m);
|
| 77 |
+
if (used + cost > budgetTokens) continue;
|
| 78 |
+
used += cost;
|
| 79 |
+
keep.add(i);
|
| 80 |
+
}
|
| 81 |
+
const packed = [];
|
| 82 |
+
let dropped = 0;
|
| 83 |
+
old.forEach((m, i) => {
|
| 84 |
+
if (keep.has(i)) {
|
| 85 |
+
if (dropped) { packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` }); dropped = 0; }
|
| 86 |
+
packed.push(m);
|
| 87 |
+
} else dropped++;
|
| 88 |
+
});
|
| 89 |
+
if (dropped) packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` });
|
| 90 |
+
return [...packed, ...recent];
|
| 91 |
+
}
|
js/conversations.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Conversaciones: varias a la vez, como pestañas — cada una con su propio
|
| 2 |
+
// historial (su propio Agent) y su propia cola de mensajes pendientes.
|
| 3 |
+
// Cerrar una pestaña NO borra la conversación (solo la Historial sí). Todo
|
| 4 |
+
// persiste en IndexedDB, sobrevive a recargar.
|
| 5 |
+
//
|
| 6 |
+
// Procesado: cada conversación acepta y encola mensajes de forma totalmente
|
| 7 |
+
// independiente (mandar en la pestaña B mientras la A está pensando no
|
| 8 |
+
// bloquea nada). La generación en sí (la llamada real al modelo) se
|
| 9 |
+
// serializa con un cerrojo global — solo hay UN modelo cargado en el
|
| 10 |
+
// navegador y una sesión de inferencia no admite dos generate() a la vez sin
|
| 11 |
+
// corromperse — así que las conversaciones se turnan para «hablar» con el
|
| 12 |
+
// modelo, pero cada una sigue funcionando y aceptando mensajes de forma
|
| 13 |
+
// independiente mientras espera su turno.
|
| 14 |
+
import { Agent } from './agent.js';
|
| 15 |
+
import { runGoal, TASK_PREFIX } from './goal.js';
|
| 16 |
+
import * as db from './db.js';
|
| 17 |
+
import * as telemetry from './telemetry.js';
|
| 18 |
+
|
| 19 |
+
const NS = 'elffusscode';
|
| 20 |
+
const TABS_KEY = NS + '.openTabs';
|
| 21 |
+
const ACTIVE_KEY = NS + '.activeConv';
|
| 22 |
+
const HIST_CAP = 80; // mensajes por conversación que se persisten
|
| 23 |
+
|
| 24 |
+
let providerRef = null;
|
| 25 |
+
const convs = new Map(); // id -> { id, title, agent, queue, pumping, createdAt, updatedAt }
|
| 26 |
+
let openTabIds = [];
|
| 27 |
+
let activeId = null;
|
| 28 |
+
let onChange = () => {};
|
| 29 |
+
|
| 30 |
+
const genId = () => 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
| 31 |
+
const cleanTitle = s => String(s).replace(/\s+/g, ' ').trim();
|
| 32 |
+
|
| 33 |
+
const GOAL_PREFIX = TASK_PREFIX + 'Objetivo: ';
|
| 34 |
+
export function titleFor(conv) {
|
| 35 |
+
if (conv.title) return conv.title;
|
| 36 |
+
const firstUser = conv.agent.history.find(m =>
|
| 37 |
+
m.role === 'user' && !m.content.startsWith('[resultado') &&
|
| 38 |
+
(!m.content.startsWith(TASK_PREFIX) || m.content.startsWith(GOAL_PREFIX)));
|
| 39 |
+
if (!firstUser) return 'Nueva conversación';
|
| 40 |
+
// el objetivo lleva un prefijo interno ([tarea-objetivo] Objetivo: …) que
|
| 41 |
+
// no debe filtrarse al título de la pestaña — se muestra el texto limpio.
|
| 42 |
+
const raw = firstUser.content.startsWith(GOAL_PREFIX) ? firstUser.content.slice(GOAL_PREFIX.length) : firstUser.content;
|
| 43 |
+
const t = cleanTitle(raw);
|
| 44 |
+
return t.length > 40 ? t.slice(0, 40) + '…' : t || 'Nueva conversación';
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function setProvider(mod) {
|
| 48 |
+
providerRef = mod;
|
| 49 |
+
for (const c of convs.values()) c.agent.setProvider(mod);
|
| 50 |
+
}
|
| 51 |
+
export function getProvider() { return providerRef; }
|
| 52 |
+
|
| 53 |
+
export function isBusy() {
|
| 54 |
+
for (const c of convs.values()) if (c.pumping || c.queue.length) return true;
|
| 55 |
+
return false;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
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 |
+
const all = await db.get('kv', 'conversations').catch(() => null) || [];
|
| 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),
|
| 66 |
+
queue: conv.queue, plan: conv.plan || null, createdAt: conv.createdAt, updatedAt: conv.updatedAt,
|
| 67 |
+
};
|
| 68 |
+
if (i >= 0) all[i] = rec; else all.push(rec);
|
| 69 |
+
await db.set('kv', 'conversations', all).catch(() => {});
|
| 70 |
+
}
|
| 71 |
+
function persistMeta() {
|
| 72 |
+
try { localStorage.setItem(TABS_KEY, JSON.stringify(openTabIds)); } catch { /* lleno */ }
|
| 73 |
+
try { localStorage.setItem(ACTIVE_KEY, activeId || ''); } catch { /* lleno */ }
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function makeConv(id, saved) {
|
| 77 |
+
const conv = {
|
| 78 |
+
id, title: saved?.title || null, agent: new Agent(providerRef),
|
| 79 |
+
queue: saved?.queue ? [...saved.queue] : [], pumping: false, plan: saved?.plan || null,
|
| 80 |
+
createdAt: saved?.createdAt || Date.now(), updatedAt: saved?.updatedAt || Date.now(),
|
| 81 |
+
};
|
| 82 |
+
if (saved?.history) conv.agent.history = saved.history;
|
| 83 |
+
convs.set(id, conv);
|
| 84 |
+
return conv;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
export function create() {
|
| 88 |
+
const conv = makeConv(genId());
|
| 89 |
+
openTabIds.push(conv.id);
|
| 90 |
+
activeId = conv.id;
|
| 91 |
+
persistMeta();
|
| 92 |
+
onChange('switch', conv.id);
|
| 93 |
+
return conv;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// reabre una conversación guardada (de la Historial) como pestaña, o activa
|
| 97 |
+
// la pestaña si ya estaba abierta
|
| 98 |
+
export async function open(id) {
|
| 99 |
+
if (!convs.has(id)) {
|
| 100 |
+
const saved = (await db.get('kv', 'conversations').catch(() => null) || []).find(c => c.id === id);
|
| 101 |
+
if (!saved) return null;
|
| 102 |
+
makeConv(id, saved);
|
| 103 |
+
}
|
| 104 |
+
if (!openTabIds.includes(id)) openTabIds.push(id);
|
| 105 |
+
activeId = id;
|
| 106 |
+
persistMeta();
|
| 107 |
+
onChange('switch', id);
|
| 108 |
+
return convs.get(id);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export function switchTo(id) {
|
| 112 |
+
if (!convs.has(id) || activeId === id) return;
|
| 113 |
+
activeId = id;
|
| 114 |
+
persistMeta();
|
| 115 |
+
onChange('switch', id);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// añade un mensaje fuera del ciclo normal de envío (p.ej. el informe
|
| 119 |
+
// autónomo del cerebro CEO) al historial REAL de una conversación — si no,
|
| 120 |
+
// solo viviría en el DOM y desaparecería al cambiar de pestaña o recargar.
|
| 121 |
+
export async function appendMessage(id, role, content) {
|
| 122 |
+
const c = convs.get(id);
|
| 123 |
+
if (!c) return;
|
| 124 |
+
c.agent.history.push({ role, content });
|
| 125 |
+
c.updatedAt = Date.now();
|
| 126 |
+
await persistConv(c);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// quita la pestaña de la vista — la conversación sigue viva en la Historial
|
| 130 |
+
export function closeTab(id) {
|
| 131 |
+
openTabIds = openTabIds.filter(x => x !== id);
|
| 132 |
+
if (activeId === id) activeId = openTabIds[openTabIds.length - 1] || null;
|
| 133 |
+
persistMeta();
|
| 134 |
+
if (!openTabIds.length) create(); else onChange('tabs');
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// borra la conversación de verdad (desde la Historial)
|
| 138 |
+
export async function remove(id) {
|
| 139 |
+
convs.delete(id);
|
| 140 |
+
openTabIds = openTabIds.filter(x => x !== id);
|
| 141 |
+
if (activeId === id) activeId = openTabIds[openTabIds.length - 1] || null;
|
| 142 |
+
const all = (await db.get('kv', 'conversations').catch(() => null) || []).filter(c => c.id !== id);
|
| 143 |
+
await db.set('kv', 'conversations', all).catch(() => {});
|
| 144 |
+
persistMeta();
|
| 145 |
+
if (!openTabIds.length) create(); else onChange('tabs');
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
export async function listAll() {
|
| 149 |
+
const all = await db.get('kv', 'conversations').catch(() => null) || [];
|
| 150 |
+
return all.slice().sort((a, b) => b.updatedAt - a.updatedAt);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// ---- envío / procesado ----
|
| 154 |
+
// items de la cola: {kind:'chat'|'goal', text} — 'chat' es un turno normal
|
| 155 |
+
// (agent.handle), 'goal' dispara el planificador/ejecutor de goal.js. Las
|
| 156 |
+
// colas persistidas ANTES de que existiera el modo Objetivo guardaban texto
|
| 157 |
+
// suelto (strings) — normalizeItem() los sigue aceptando como 'chat'.
|
| 158 |
+
let inferenceLock = Promise.resolve();
|
| 159 |
+
const normalizeItem = it => (typeof it === 'string' ? { kind: 'chat', text: it } : it);
|
| 160 |
+
|
| 161 |
+
export function send(id, text) {
|
| 162 |
+
const conv = convs.get(id);
|
| 163 |
+
if (!conv) return;
|
| 164 |
+
conv.queue.push({ kind: 'chat', text });
|
| 165 |
+
persistConv(conv);
|
| 166 |
+
pump(conv);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// 🎯 Modo Objetivo: en vez de un turno de chat normal, el mensaje se trata
|
| 170 |
+
// como un objetivo — se planifica en tareas y se ejecutan una a una (ver
|
| 171 |
+
// goal.js, mismo patrón planificador/ejecutor de clonagent).
|
| 172 |
+
export function startGoal(id, text) {
|
| 173 |
+
const conv = convs.get(id);
|
| 174 |
+
if (!conv) return;
|
| 175 |
+
conv.queue.push({ kind: 'goal', text });
|
| 176 |
+
persistConv(conv);
|
| 177 |
+
pump(conv);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
async function pump(conv) {
|
| 181 |
+
if (conv.pumping) return;
|
| 182 |
+
conv.pumping = true;
|
| 183 |
+
onChange('pumping', conv.id, true);
|
| 184 |
+
while (conv.queue.length) {
|
| 185 |
+
const item = normalizeItem(conv.queue[0]);
|
| 186 |
+
const myTurn = inferenceLock;
|
| 187 |
+
let release;
|
| 188 |
+
inferenceLock = new Promise(r => { release = r; });
|
| 189 |
+
await myTurn; // espera su turno de inferencia (un solo modelo cargado, no admite 2 generate() a la vez)
|
| 190 |
+
try {
|
| 191 |
+
// un fallo aquí (del modelo, de una tool, o del propio repintado en
|
| 192 |
+
// main.js) NUNCA debe dejar la conversación "pumping" para siempre —
|
| 193 |
+
// eso bloquearía sus futuros mensajes Y al cerebro CEO (isBusy()).
|
| 194 |
+
const onEvent = ev => {
|
| 195 |
+
try { onChange('event', conv.id, ev); } catch (e) { console.error('[elffuss] fallo pintando un evento de chat', e); }
|
| 196 |
+
};
|
| 197 |
+
if (item.kind === 'goal') await runGoal(conv, item.text, onEvent);
|
| 198 |
+
else await conv.agent.handle(item.text, onEvent);
|
| 199 |
+
} catch (e) {
|
| 200 |
+
console.error('[elffuss] fallo procesando el turno', e);
|
| 201 |
+
telemetry.reportError('pump: ' + (e?.message || e), { stack: e?.stack || '' });
|
| 202 |
+
try { onChange('event', conv.id, { type: 'error', text: 'Error interno: ' + (e?.message || e) }); } catch { /* ya está registrado arriba */ }
|
| 203 |
+
} finally { release(); }
|
| 204 |
+
conv.queue.shift();
|
| 205 |
+
conv.updatedAt = Date.now();
|
| 206 |
+
await persistConv(conv);
|
| 207 |
+
}
|
| 208 |
+
conv.pumping = false;
|
| 209 |
+
onChange('pumping', conv.id, false);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
export async function init({ onEvent }) {
|
| 213 |
+
onChange = onEvent;
|
| 214 |
+
|
| 215 |
+
// migración única: el esquema viejo era UNA sola conversación (kv/history + kv/queue)
|
| 216 |
+
const already = await db.get('kv', 'conversations').catch(() => null);
|
| 217 |
+
if (!already) {
|
| 218 |
+
const oldHistory = await db.get('kv', 'history').catch(() => null);
|
| 219 |
+
if (oldHistory?.length) {
|
| 220 |
+
const oldQueue = await db.get('kv', 'queue').catch(() => null);
|
| 221 |
+
await db.set('kv', 'conversations', [{
|
| 222 |
+
id: genId(), title: null, history: oldHistory, queue: oldQueue || [],
|
| 223 |
+
createdAt: Date.now(), updatedAt: Date.now(),
|
| 224 |
+
}]).catch(() => {});
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
let tabIds = [], active = null;
|
| 229 |
+
try { tabIds = JSON.parse(localStorage.getItem(TABS_KEY) || '[]'); } catch { /* corrupto */ }
|
| 230 |
+
try { active = localStorage.getItem(ACTIVE_KEY) || null; } catch { /* */ }
|
| 231 |
+
const all = await db.get('kv', 'conversations').catch(() => null) || [];
|
| 232 |
+
|
| 233 |
+
for (const id of tabIds) {
|
| 234 |
+
const saved = all.find(c => c.id === id);
|
| 235 |
+
if (saved) { makeConv(id, saved); openTabIds.push(id); }
|
| 236 |
+
}
|
| 237 |
+
if (active && convs.has(active)) activeId = active;
|
| 238 |
+
else if (openTabIds.length) activeId = openTabIds[0];
|
| 239 |
+
|
| 240 |
+
if (!openTabIds.length) {
|
| 241 |
+
// nada abierto (primera vez, o se perdió el estado de pestañas): retoma
|
| 242 |
+
// la conversación más reciente si hay alguna, si no, una nueva vacía
|
| 243 |
+
const mostRecent = all.slice().sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
| 244 |
+
if (mostRecent) { makeConv(mostRecent.id, mostRecent); openTabIds.push(mostRecent.id); activeId = mostRecent.id; }
|
| 245 |
+
else create();
|
| 246 |
+
}
|
| 247 |
+
for (const conv of convs.values()) if (conv.queue.length) pump(conv);
|
| 248 |
+
persistMeta();
|
| 249 |
+
}
|
js/db.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// IndexedDB mínima: stores clave→valor.
|
| 2 |
+
const NAME = 'elffuss-code', VERSION = 2;
|
| 3 |
+
const STORES = ['kv', 'apps', 'tasks', 'vault', 'fs', 'memory'];
|
| 4 |
+
let dbPromise = null;
|
| 5 |
+
|
| 6 |
+
function open() {
|
| 7 |
+
if (dbPromise) return dbPromise;
|
| 8 |
+
dbPromise = new Promise((resolve, reject) => {
|
| 9 |
+
const req = indexedDB.open(NAME, VERSION);
|
| 10 |
+
req.onupgradeneeded = () => {
|
| 11 |
+
for (const s of STORES)
|
| 12 |
+
if (!req.result.objectStoreNames.contains(s)) req.result.createObjectStore(s);
|
| 13 |
+
};
|
| 14 |
+
req.onsuccess = () => resolve(req.result);
|
| 15 |
+
req.onerror = () => reject(req.error);
|
| 16 |
+
});
|
| 17 |
+
return dbPromise;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function tx(store, mode, fn) {
|
| 21 |
+
return open().then(db => new Promise((resolve, reject) => {
|
| 22 |
+
const req = fn(db.transaction(store, mode).objectStore(store));
|
| 23 |
+
req.onsuccess = () => resolve(req.result);
|
| 24 |
+
req.onerror = () => reject(req.error);
|
| 25 |
+
}));
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export const get = (store, key) => tx(store, 'readonly', s => s.get(key));
|
| 29 |
+
export const set = (store, key, val) => tx(store, 'readwrite', s => s.put(val, key));
|
| 30 |
+
export const del = (store, key) => tx(store, 'readwrite', s => s.delete(key));
|
| 31 |
+
export const keys = (store) => tx(store, 'readonly', s => s.getAllKeys());
|
| 32 |
+
export const all = (store) => tx(store, 'readonly', s => s.getAll());
|
js/goal.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Modo Objetivo (🎯 Goal): mismo patrón planificador/ejecutor que clonagent
|
| 2 |
+
// (~/work2026/clonagent) — un PLANIFICADOR descompone el objetivo en una
|
| 3 |
+
// lista de tareas con estado (pending/in-progress/done/failed/skipped), y un
|
| 4 |
+
// EJECUTOR las va ejecutando una a una hasta que no queda ninguna pendiente,
|
| 5 |
+
// marcando el resultado de cada una. Si una tarea falla, las siguientes se
|
| 6 |
+
// saltan (cascada) en vez de seguir a ciegas — igual que el fallo→skip de
|
| 7 |
+
// dependencias de clonagent.
|
| 8 |
+
//
|
| 9 |
+
// Aquí el "ejecutor" de cada tarea es el MISMO bucle de tool-calling que ya
|
| 10 |
+
// usa el chat normal (agent.handle): cada tarea del plan se manda como si
|
| 11 |
+
// fuera un mensaje de usuario, con todo el historial previo como contexto —
|
| 12 |
+
// así una tarea puede apoyarse en lo que hizo la anterior, igual que la
|
| 13 |
+
// sesión del ejecutor de clonagent tiene a la vista el fichero de tareas
|
| 14 |
+
// entero.
|
| 15 |
+
import { toolHelp, snapshot } from './tools/index.js';
|
| 16 |
+
|
| 17 |
+
const MAX_TASK_RETRIES = 2;
|
| 18 |
+
const TASK_PREFIX = '[tarea-objetivo] ';
|
| 19 |
+
|
| 20 |
+
function plannerPrompt(goalText, context) {
|
| 21 |
+
return `ROL: PLANIFICADOR
|
| 22 |
+
|
| 23 |
+
Objetivo del usuario: "${goalText}"
|
| 24 |
+
|
| 25 |
+
CONTEXTO AHORA (estado real del IDE):
|
| 26 |
+
${context}
|
| 27 |
+
|
| 28 |
+
Herramientas disponibles para la fase de EJECUCIÓN (aquí solo planificas, no las llames):
|
| 29 |
+
${toolHelp()}
|
| 30 |
+
|
| 31 |
+
Descompón el objetivo en una lista de tareas CONCRETAS, ORDENADAS y ejecutables una tras otra con esas herramientas. Entre 2 y 6 tareas normalmente basta — no fragmentes de más, cada tarea debe ser un paso real con sentido propio.
|
| 32 |
+
|
| 33 |
+
Responde SOLO con este JSON, nada de texto ni fences alrededor:
|
| 34 |
+
{"plan": "una frase resumiendo el enfoque", "tasks": [{"title": "título corto", "description": "qué hay que hacer, concreto y accionable"}]}`;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Extrae el primer objeto JSON balanceado del texto (el modelo a veces mete
|
| 38 |
+
// prosa alrededor pese a la instrucción de "SOLO JSON").
|
| 39 |
+
function extractJson(text) {
|
| 40 |
+
const start = text.indexOf('{');
|
| 41 |
+
if (start < 0) return null;
|
| 42 |
+
let depth = 0, inStr = false, esc = false;
|
| 43 |
+
for (let i = start; i < text.length; i++) {
|
| 44 |
+
const ch = text[i];
|
| 45 |
+
if (esc) { esc = false; continue; }
|
| 46 |
+
if (ch === '\\') { esc = true; continue; }
|
| 47 |
+
if (ch === '"') inStr = !inStr;
|
| 48 |
+
else if (!inStr) {
|
| 49 |
+
if (ch === '{') depth++;
|
| 50 |
+
else if (ch === '}' && --depth === 0) {
|
| 51 |
+
try { return JSON.parse(text.slice(start, i + 1)); } catch { return null; }
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
return null;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// Llamada ÚNICA al modelo con el rol de planificador — no toca agent.history
|
| 59 |
+
// (el plan resultante se guarda aparte; el objetivo en sí SÍ se añade al
|
| 60 |
+
// historial en runGoal, para que las tareas puedan apoyarse en él).
|
| 61 |
+
export async function planGoal(agent, goalText) {
|
| 62 |
+
const context = await snapshot().catch(() => '');
|
| 63 |
+
const raw = await agent.provider.chat([{ role: 'user', content: goalText }], plannerPrompt(goalText, context), () => {});
|
| 64 |
+
const parsed = extractJson(raw);
|
| 65 |
+
if (!parsed || !Array.isArray(parsed.tasks) || !parsed.tasks.length) {
|
| 66 |
+
throw new Error('el planificador no devolvió una lista de tareas válida');
|
| 67 |
+
}
|
| 68 |
+
return {
|
| 69 |
+
planText: String(parsed.plan || '').trim(),
|
| 70 |
+
tasks: parsed.tasks.slice(0, 10).map(t => ({
|
| 71 |
+
title: String(t.title || 'tarea').trim(),
|
| 72 |
+
description: String(t.description || t.title || '').trim(),
|
| 73 |
+
})),
|
| 74 |
+
};
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function clonePlan(plan) { return JSON.parse(JSON.stringify(plan)); }
|
| 78 |
+
|
| 79 |
+
// Orquesta plan → ejecución. onEvent es el MISMO canal que usa el chat
|
| 80 |
+
// normal (conversations.js lo conecta a onConvEvent) — los eventos de las
|
| 81 |
+
// tareas (token/tool/tool_result/text/error) se reenvían tal cual, y además
|
| 82 |
+
// se emiten 'plan' / 'plan_update' / 'plan_complete' para la tarjeta de plan.
|
| 83 |
+
export async function runGoal(conv, goalText, onEvent) {
|
| 84 |
+
conv.agent.history.push({ role: 'user', content: TASK_PREFIX + 'Objetivo: ' + goalText });
|
| 85 |
+
|
| 86 |
+
const plan = {
|
| 87 |
+
id: 'g' + Date.now().toString(36), goal: goalText, planText: '', status: 'planning',
|
| 88 |
+
tasks: [], createdAt: Date.now(), updatedAt: Date.now(),
|
| 89 |
+
};
|
| 90 |
+
conv.plan = plan;
|
| 91 |
+
onEvent({ type: 'plan', plan: clonePlan(plan) });
|
| 92 |
+
|
| 93 |
+
let parsed;
|
| 94 |
+
try {
|
| 95 |
+
parsed = await planGoal(conv.agent, goalText);
|
| 96 |
+
} catch (e) {
|
| 97 |
+
plan.status = 'failed';
|
| 98 |
+
plan.updatedAt = Date.now();
|
| 99 |
+
onEvent({ type: 'plan_update', plan: clonePlan(plan) });
|
| 100 |
+
onEvent({ type: 'error', text: 'No pude planificar el objetivo: ' + e.message });
|
| 101 |
+
return;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
plan.planText = parsed.planText;
|
| 105 |
+
plan.tasks = parsed.tasks.map((t, i) => ({
|
| 106 |
+
id: 't' + (i + 1), title: t.title, description: t.description,
|
| 107 |
+
status: 'pending', retries: 0, result: '',
|
| 108 |
+
}));
|
| 109 |
+
plan.status = 'running';
|
| 110 |
+
plan.updatedAt = Date.now();
|
| 111 |
+
onEvent({ type: 'plan_update', plan: clonePlan(plan) });
|
| 112 |
+
|
| 113 |
+
let cascadeFail = false;
|
| 114 |
+
for (const task of plan.tasks) {
|
| 115 |
+
if (cascadeFail) { task.status = 'skipped'; plan.updatedAt = Date.now(); onEvent({ type: 'plan_update', plan: clonePlan(plan) }); continue; }
|
| 116 |
+
|
| 117 |
+
task.status = 'in-progress';
|
| 118 |
+
plan.updatedAt = Date.now();
|
| 119 |
+
onEvent({ type: 'plan_update', plan: clonePlan(plan) });
|
| 120 |
+
|
| 121 |
+
const taskPrompt = `${TASK_PREFIX}Tarea ${task.id} del objetivo «${goalText}»: ${task.title}\n${task.description}`;
|
| 122 |
+
let done = false;
|
| 123 |
+
while (!done && task.retries <= MAX_TASK_RETRIES) {
|
| 124 |
+
let sawError = false, errText = '';
|
| 125 |
+
await conv.agent.handle(taskPrompt, ev => {
|
| 126 |
+
if (ev.type === 'error') { sawError = true; errText = ev.text; }
|
| 127 |
+
onEvent(ev);
|
| 128 |
+
});
|
| 129 |
+
if (!sawError) { done = true; }
|
| 130 |
+
else {
|
| 131 |
+
task.retries++;
|
| 132 |
+
task.result = errText;
|
| 133 |
+
if (task.retries > MAX_TASK_RETRIES) break;
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
task.status = done ? 'done' : 'failed';
|
| 137 |
+
plan.updatedAt = Date.now();
|
| 138 |
+
onEvent({ type: 'plan_update', plan: clonePlan(plan) });
|
| 139 |
+
if (!done) cascadeFail = true; // el resto se saltan, igual que la cascada de dependencias fallidas en clonagent
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
plan.status = plan.tasks.some(t => t.status === 'failed') ? 'failed' : 'done';
|
| 143 |
+
plan.updatedAt = Date.now();
|
| 144 |
+
onEvent({ type: 'plan_complete', plan: clonePlan(plan) });
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export { TASK_PREFIX };
|
js/humanize.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Frase legible por humano para una tool-call («leyendo app.js…»). Compartido
|
| 2 |
+
// por el cerebro CEO (ceo.js) y el streaming en vivo del chat — así nunca se
|
| 3 |
+
// le enseña JSON crudo al usuario, siempre la misma redacción. Cubre los
|
| 4 |
+
// nombres de herramienta de Elffuss Code (code.*, terminal.*) y de Elffuss
|
| 5 |
+
// Claw (fs.*, app.*, web.*, skill.*, memory.*, tasks.*) — un tool desconocido
|
| 6 |
+
// cae al genérico «nombre argumento» sin romper nada.
|
| 7 |
+
export function humanizeTool(name, args) {
|
| 8 |
+
const p = args?.path, q = args?.query, c = args?.command, n = args?.name;
|
| 9 |
+
switch (name) {
|
| 10 |
+
case 'code.read': case 'fs.read': return `leyendo ${p}…`;
|
| 11 |
+
case 'code.write': case 'fs.write': return `escribiendo ${p}…`;
|
| 12 |
+
case 'code.tree': return `explorando ${p || 'el proyecto'}…`;
|
| 13 |
+
case 'fs.list': return `explorando ${p || 'la carpeta'}…`;
|
| 14 |
+
case 'code.search': return `buscando «${q}»…`;
|
| 15 |
+
case 'fs.pick_folder': return 'pidiendo acceso a una carpeta…';
|
| 16 |
+
case 'fs.copy': return `copiando ${args?.pattern || 'archivos'}…`;
|
| 17 |
+
case 'fs.watch': return `vigilando ${args?.from || 'una carpeta'}…`;
|
| 18 |
+
case 'terminal.run': return `ejecutando: ${c}`;
|
| 19 |
+
case 'app.create': return `creando la app «${n}»…`;
|
| 20 |
+
case 'app.open': return `abriendo la app «${n}»…`;
|
| 21 |
+
case 'skill.create': return `creando la skill «${n}»…`;
|
| 22 |
+
case 'memory.save': return `recordando: ${args?.fact}…`;
|
| 23 |
+
case 'tasks.add': return 'programando una tarea…';
|
| 24 |
+
case 'web.search': return `buscando en internet «${q}»…`;
|
| 25 |
+
case 'web.images': return `buscando imágenes de «${q}»…`;
|
| 26 |
+
case 'web.fetch': return `leyendo ${p || args?.url}…`;
|
| 27 |
+
default: return name + (p || q || c || n ? ' ' + (p || q || c || n) : '');
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// Detecta si el buffer que va llegando EN STREAMING ha entrado en un bloque de
|
| 32 |
+
// tool-call (```tool { … }) y, si es así, devuelve una frase humana en vez del
|
| 33 |
+
// JSON crudo — «preparando una acción…» hasta que el nombre de la tool sea
|
| 34 |
+
// legible, luego «leyendo app.js…» tan pronto como el campo aparezca, aunque
|
| 35 |
+
// el JSON todavía no haya cerrado.
|
| 36 |
+
export function humanizeStreamPreview(buf) {
|
| 37 |
+
if (buf.search(/```/) === -1 && !/^\s*\{\s*"tool"/.test(buf)) return null;
|
| 38 |
+
const toolM = buf.match(/"tool"\s*:\s*"([\w.]+)"/);
|
| 39 |
+
if (!toolM) return 'preparando una acción…';
|
| 40 |
+
const pathM = buf.match(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
| 41 |
+
const queryM = buf.match(/"query"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
| 42 |
+
const cmdM = buf.match(/"command"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
| 43 |
+
const nameM = buf.match(/"name"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
| 44 |
+
const out = humanizeTool(toolM[1], { path: pathM?.[1], query: queryM?.[1], command: cmdM?.[1], name: nameM?.[1] });
|
| 45 |
+
// el nombre de la tool ya se ve pero su campo (path/query/…) aún no llegó:
|
| 46 |
+
// mejor el genérico que un «leyendo undefined…» a medio streamear
|
| 47 |
+
return /undefined/.test(out) ? 'preparando una acción…' : out;
|
| 48 |
+
}
|
js/icons.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Iconos estilo VS Code, en SVG inline propio (sin dependencias).
|
| 2 |
+
// Archivos: insignia con la abreviatura y el color canónico del lenguaje.
|
| 3 |
+
const FILE_TYPES = {
|
| 4 |
+
js: ['JS', '#f1dd3f'], mjs: ['JS', '#f1dd3f'], cjs: ['JS', '#f1dd3f'],
|
| 5 |
+
ts: ['TS', '#3178c6'], tsx: ['TS', '#3178c6'], jsx: ['JS', '#61dafb'],
|
| 6 |
+
py: ['PY', '#4b8bbe'], rs: ['RS', '#dea584'], go: ['GO', '#00add8'],
|
| 7 |
+
java: ['J', '#e76f00'], c: ['C', '#659ad2'], h: ['H', '#659ad2'],
|
| 8 |
+
cpp: ['C+', '#f34b7d'], cs: ['C#', '#178600'],
|
| 9 |
+
json: ['{}', '#cbcb41'], yaml: ['Y', '#cb4b4b'], yml: ['Y', '#cb4b4b'],
|
| 10 |
+
toml: ['T', '#9c4221'], xml: ['<>', '#e37933'],
|
| 11 |
+
html: ['<>', '#e34c26'], css: ['#', '#9b7cf6'], scss: ['#', '#c6538c'],
|
| 12 |
+
md: ['M', '#519aba'], txt: ['≡', '#8b93a8'], csv: ['⊞', '#89e051'],
|
| 13 |
+
sh: ['$', '#89e051'], sql: ['DB', '#e38c00'],
|
| 14 |
+
svg: ['SV', '#ffb13b'], png: ['IMG', '#a074c4'], jpg: ['IMG', '#a074c4'],
|
| 15 |
+
gif: ['IMG', '#a074c4'], ico: ['IMG', '#a074c4'],
|
| 16 |
+
lock: ['🔒', '#8b93a8'], gitignore: ['GIT', '#f05033'],
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export function fileIcon(name) {
|
| 20 |
+
const base = name.toLowerCase();
|
| 21 |
+
const ext = base.startsWith('.git') ? 'gitignore' : (base.split('.').pop() || '');
|
| 22 |
+
const [label, color] = FILE_TYPES[ext] || ['·', '#6b7391'];
|
| 23 |
+
const fontSize = label.length > 2 ? 5 : (label.length === 2 ? 6.5 : 8);
|
| 24 |
+
return `<svg class="fico" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
| 25 |
+
<rect x="1.5" y="1.5" width="13" height="13" rx="3" fill="${color}1f" stroke="${color}" stroke-width="1"/>
|
| 26 |
+
<text x="8" y="${label.length > 2 ? 10.4 : 10.8}" font-size="${fontSize}" text-anchor="middle"
|
| 27 |
+
fill="${color}" font-family="system-ui" font-weight="700">${label}</text></svg>`;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export function folderIcon() {
|
| 31 |
+
return `<svg class="fico chev" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
| 32 |
+
<path class="chevron" d="M6 4.5 L10 8 L6 11.5" fill="none" stroke="#8b93a8" stroke-width="1.6" stroke-linecap="round"/>
|
| 33 |
+
</svg><svg class="fico" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
| 34 |
+
<path d="M1.5 4 a1 1 0 0 1 1-1 h3.2 l1.6 1.8 h6.2 a1 1 0 0 1 1 1 v6.7 a1 1 0 0 1 -1 1 h-11 a1 1 0 0 1 -1-1 Z"
|
| 35 |
+
fill="#dcb67a33" stroke="#dcb67a" stroke-width="1"/></svg>`;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Codicons de UI (estilo VS Code, sin emojis). Trazos con currentColor.
|
| 39 |
+
const ico = (p, sw = 1.6) => `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
|
| 40 |
+
export const UI = {
|
| 41 |
+
clear: ico('<path d="M4 7h16"/><path d="M9 7V5h6v2"/><path d="M6 7l1 13h10l1-13"/><path d="M10 11v6M14 11v6"/>'),
|
| 42 |
+
history: ico('<circle cx="12" cy="13" r="8"/><path d="M12 9v4l3 2"/><path d="M5 3.5l1.5 2.5M19 3.5l-1.5 2.5"/>'),
|
| 43 |
+
gear: ico('<circle cx="12" cy="12" r="3"/><path d="M12 3v2.5M12 18.5V21M3 12h2.5M18.5 12H21M5.6 5.6l1.8 1.8M16.6 16.6l1.8 1.8M18.4 5.6l-1.8 1.8M7.4 16.6l-1.8 1.8"/>'),
|
| 44 |
+
send: ico('<path d="M4 12l16-8-6 16-3.5-6.5L4 12z"/>'),
|
| 45 |
+
add: ico('<path d="M12 5v14M5 12h14"/>', 1.8),
|
| 46 |
+
slash: ico('<path d="M15 5L9 19"/>', 1.8),
|
| 47 |
+
editor: ico('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16"/>'),
|
| 48 |
+
chat: ico('<path d="M4 5h16v11H9l-5 4z"/>'),
|
| 49 |
+
puzzle: ico('<path d="M10 4h4a1 1 0 0 1 1 1v2a1.5 1.5 0 1 0 3 0V6h2a1 1 0 0 1 1 1v4h-1a1.5 1.5 0 1 0 0 3h1v4a1 1 0 0 1-1 1h-4v-1a1.5 1.5 0 1 0-3 0v1H6a1 1 0 0 1-1-1v-4h1a1.5 1.5 0 1 0 0-3H5V7a1 1 0 0 1 1-1h4z"/>'),
|
| 50 |
+
close: ico('<path d="M6 6l12 12M18 6L6 18"/>', 1.8),
|
| 51 |
+
code: ico('<path d="M9 8l-5 4 5 4M15 8l5 4-5 4"/>'),
|
| 52 |
+
graph: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="8" r="2.4"/><circle cx="12" cy="18" r="2.4"/><path d="M8 7l8 1M7 8l4 8M16 10l-3 6"/></svg>`,
|
| 53 |
+
city: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 21h18"/><rect x="4" y="10" width="5" height="11"/><rect x="10" y="5" width="5" height="16"/><rect x="16" y="13" width="4" height="8"/></svg>`,
|
| 54 |
+
terminal: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3M13 15h4"/></svg>`,
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
// Codicons de la barra de actividad (trazos simples estilo VS Code).
|
| 58 |
+
export const ACTIVITY = {
|
| 59 |
+
files: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
| 60 |
+
<path d="M7 4h7l4 4v12H7z"/><path d="M14 4v4h4"/><path d="M4 8v13h10" opacity=".55"/></svg>`,
|
| 61 |
+
search: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
| 62 |
+
<circle cx="10.5" cy="10.5" r="5.5"/><path d="M15 15l5 5"/></svg>`,
|
| 63 |
+
gear: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
| 64 |
+
<circle cx="12" cy="12" r="3"/><path d="M12 3v2.5M12 18.5V21M3 12h2.5M18.5 12H21M5.6 5.6l1.8 1.8M16.6 16.6l1.8 1.8M18.4 5.6l-1.8 1.8M7.4 16.6l-1.8 1.8"/></svg>`,
|
| 65 |
+
chat: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
| 66 |
+
<path d="M4 5h16v11H9l-5 4z"/></svg>`,
|
| 67 |
+
};
|
js/ide.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// IDE: Monaco (el editor de VS Code) + árbol de archivos + pestañas.
|
| 2 |
+
import * as code from './tools/code.js';
|
| 3 |
+
import { fileIcon, folderIcon } from './icons.js';
|
| 4 |
+
import { renderMarkdown } from './md.js';
|
| 5 |
+
|
| 6 |
+
let editor = null;
|
| 7 |
+
let monacoRef = null;
|
| 8 |
+
const tabs = []; // { path, model, dirty, preview }
|
| 9 |
+
let active = null;
|
| 10 |
+
let onDirtyChange = () => {};
|
| 11 |
+
|
| 12 |
+
const LANG = {
|
| 13 |
+
js: 'javascript', mjs: 'javascript', ts: 'typescript', tsx: 'typescript',
|
| 14 |
+
jsx: 'javascript', json: 'json', html: 'html', css: 'css', md: 'markdown',
|
| 15 |
+
py: 'python', rs: 'rust', go: 'go', java: 'java', c: 'c', h: 'c',
|
| 16 |
+
cpp: 'cpp', sh: 'shell', yml: 'yaml', yaml: 'yaml', toml: 'ini',
|
| 17 |
+
svg: 'xml', xml: 'xml', sql: 'sql', txt: 'plaintext',
|
| 18 |
+
};
|
| 19 |
+
const langOf = p => LANG[p.split('.').pop().toLowerCase()] || 'plaintext';
|
| 20 |
+
const $ = id => document.getElementById(id);
|
| 21 |
+
|
| 22 |
+
export async function initEditor() {
|
| 23 |
+
await new Promise(res => {
|
| 24 |
+
const s = document.createElement('script');
|
| 25 |
+
s.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
|
| 26 |
+
s.onload = res;
|
| 27 |
+
document.head.appendChild(s);
|
| 28 |
+
});
|
| 29 |
+
window.require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
|
| 30 |
+
await new Promise(res => window.require(['vs/editor/editor.main'], res));
|
| 31 |
+
monacoRef = window.monaco;
|
| 32 |
+
editor = monacoRef.editor.create($('editor'), {
|
| 33 |
+
theme: 'vs-dark',
|
| 34 |
+
automaticLayout: true,
|
| 35 |
+
fontSize: 13,
|
| 36 |
+
minimap: { enabled: false },
|
| 37 |
+
padding: { top: 8 },
|
| 38 |
+
});
|
| 39 |
+
editor.addCommand(monacoRef.KeyMod.CtrlCmd | monacoRef.KeyCode.KeyS, saveActive);
|
| 40 |
+
editor.onDidChangeModelContent(() => {
|
| 41 |
+
const tab = tabs.find(t => t.path === active);
|
| 42 |
+
if (tab && !tab.dirty) { tab.dirty = true; renderTabs(); }
|
| 43 |
+
});
|
| 44 |
+
// los cambios del agente se reflejan al instante en el editor
|
| 45 |
+
code.setOnFileWritten((path, content) => {
|
| 46 |
+
const tab = tabs.find(t => t.path === path);
|
| 47 |
+
if (tab && tab.model.getValue() !== content) {
|
| 48 |
+
tab.model.setValue(content);
|
| 49 |
+
tab.dirty = false;
|
| 50 |
+
renderTabs();
|
| 51 |
+
if (path === active) applyView();
|
| 52 |
+
}
|
| 53 |
+
refreshTree();
|
| 54 |
+
});
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export function gotoLine(n) {
|
| 58 |
+
if (!editor || !n) return;
|
| 59 |
+
editor.revealLineInCenter(n);
|
| 60 |
+
editor.setPosition({ lineNumber: n, column: 1 });
|
| 61 |
+
editor.focus();
|
| 62 |
+
}
|
| 63 |
+
export function triggerEditor(actionId) {
|
| 64 |
+
editor?.getAction(actionId)?.run();
|
| 65 |
+
editor?.focus();
|
| 66 |
+
}
|
| 67 |
+
export function hasEditor() { return !!editor; }
|
| 68 |
+
|
| 69 |
+
export async function openFile(path) {
|
| 70 |
+
let tab = tabs.find(t => t.path === path);
|
| 71 |
+
if (!tab) {
|
| 72 |
+
let content;
|
| 73 |
+
try { content = await code.read({ path }); }
|
| 74 |
+
catch (e) {
|
| 75 |
+
// la ruta exacta no existe (típico: un enlace del chat solo mencionaba
|
| 76 |
+
// el NOMBRE, «config.py», y el fichero real vive en una subcarpeta) →
|
| 77 |
+
// si hay una única coincidencia por nombre en el proyecto, ábrela ella
|
| 78 |
+
// en vez de rendirte; con varias o ninguna, el error de siempre.
|
| 79 |
+
const base = path.split('/').pop();
|
| 80 |
+
const hits = await code.findByName?.(base, 2).catch(() => []) || [];
|
| 81 |
+
if (hits.length === 1 && hits[0] !== path) return openFile(hits[0]);
|
| 82 |
+
return alertBar('No pude abrir ' + path + ': ' + e.message);
|
| 83 |
+
}
|
| 84 |
+
tab = { path, model: monacoRef.editor.createModel(content, langOf(path)), dirty: false, preview: false };
|
| 85 |
+
tabs.push(tab);
|
| 86 |
+
}
|
| 87 |
+
active = path;
|
| 88 |
+
code.setCurrentFile(path);
|
| 89 |
+
editor.setModel(tab.model);
|
| 90 |
+
setEmptyState(false);
|
| 91 |
+
renderTabs();
|
| 92 |
+
await applyView();
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// modo «Vista previa» de un tab .md/.html: en vez del código fuente en
|
| 96 |
+
// Monaco, muestra el markdown renderizado (mismo motor que el chat y la
|
| 97 |
+
// Mente) o el HTML en un iframe real.
|
| 98 |
+
const PREVIEWABLE = new Set(['markdown', 'html']);
|
| 99 |
+
|
| 100 |
+
// resuelve una ruta relativa (href/src) contra la carpeta del propio fichero
|
| 101 |
+
function resolveRelative(ownPath, rel) {
|
| 102 |
+
if (/^([a-z][\w+.-]*:)?\/\//i.test(rel) || rel.startsWith('data:') || rel.startsWith('#')) return null; // externa/absoluta: no tocar
|
| 103 |
+
const baseDir = ownPath.includes('/') ? ownPath.slice(0, ownPath.lastIndexOf('/')) : '';
|
| 104 |
+
const segs = (baseDir ? baseDir.split('/') : []).concat(rel.split('/'));
|
| 105 |
+
const out = [];
|
| 106 |
+
for (const s of segs) { if (!s || s === '.') continue; if (s === '..') out.pop(); else out.push(s); }
|
| 107 |
+
return out.join('/');
|
| 108 |
+
}
|
| 109 |
+
// un iframe con srcdoc no tiene base URL propia: <link href="style.css"> o
|
| 110 |
+
// <script src="app.js"> del proyecto real NUNCA se resuelven (por eso salía
|
| 111 |
+
// sin CSS) — se leen del proyecto de verdad y se incrustan antes de mostrarlo.
|
| 112 |
+
// El iframe de Vista previa va con sandbox="allow-scripts" A PROPÓSITO, SIN
|
| 113 |
+
// allow-same-origin (aislado de tu proyecto real — ver applyView). Pero eso
|
| 114 |
+
// hace que referenciar `localStorage`/`sessionStorage` LANCE una excepción
|
| 115 |
+
// síncrona — y como suele ser la primera línea de un script (guardar datos
|
| 116 |
+
// es de lo más común), el resto del fichero entero se queda sin ejecutar,
|
| 117 |
+
// sin aviso: los botones no hacen nada y parece que la app está rota. Se
|
| 118 |
+
// incrusta un shim en memoria (nunca toca almacenamiento real, se pierde al
|
| 119 |
+
// cerrar la vista previa) SOLO si de verdad hace falta — si el sandbox
|
| 120 |
+
// permitiera acceso real, no se toca nada.
|
| 121 |
+
const STORAGE_SHIM = `<script>(()=>{try{window.localStorage;return}catch(e){}` +
|
| 122 |
+
`const m=()=>{const d=new Map();return{getItem:k=>d.has(''+k)?d.get(''+k):null,` +
|
| 123 |
+
`setItem:(k,v)=>{d.set(''+k,''+v)},removeItem:k=>{d.delete(''+k)},clear:()=>{d.clear()},` +
|
| 124 |
+
`key:i=>[...d.keys()][i]??null,get length(){return d.size}}};` +
|
| 125 |
+
`for(const p of ['localStorage','sessionStorage'])` +
|
| 126 |
+
`try{Object.defineProperty(window,p,{value:m(),configurable:true})}catch(e){}})()<\/script>`;
|
| 127 |
+
|
| 128 |
+
async function inlineLocalAssets(html, ownPath) {
|
| 129 |
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
| 130 |
+
(doc.head || doc.documentElement).insertAdjacentHTML('afterbegin', STORAGE_SHIM);
|
| 131 |
+
for (const link of doc.querySelectorAll('link[rel~="stylesheet"][href]')) {
|
| 132 |
+
const path = resolveRelative(ownPath, link.getAttribute('href'));
|
| 133 |
+
if (!path) continue;
|
| 134 |
+
try {
|
| 135 |
+
const style = doc.createElement('style');
|
| 136 |
+
style.textContent = await code.read({ path });
|
| 137 |
+
link.replaceWith(style);
|
| 138 |
+
} catch { /* no se pudo leer: se deja el link (el navegador lo ignorará sin más) */ }
|
| 139 |
+
}
|
| 140 |
+
for (const script of doc.querySelectorAll('script[src]')) {
|
| 141 |
+
const path = resolveRelative(ownPath, script.getAttribute('src'));
|
| 142 |
+
if (!path) continue;
|
| 143 |
+
try {
|
| 144 |
+
const inline = doc.createElement('script');
|
| 145 |
+
if (script.type) inline.type = script.type;
|
| 146 |
+
inline.textContent = await code.read({ path });
|
| 147 |
+
script.replaceWith(inline);
|
| 148 |
+
} catch { /* deja el script tal cual */ }
|
| 149 |
+
}
|
| 150 |
+
return '<!doctype html>\n' + doc.documentElement.outerHTML;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
async function applyView() {
|
| 154 |
+
const tab = tabs.find(t => t.path === active);
|
| 155 |
+
const lang = tab ? langOf(tab.path) : null;
|
| 156 |
+
const showPreview = !!(tab && tab.preview && PREVIEWABLE.has(lang));
|
| 157 |
+
$('editor').style.display = showPreview ? 'none' : '';
|
| 158 |
+
let pane = document.getElementById('md-preview-pane');
|
| 159 |
+
if (showPreview) {
|
| 160 |
+
if (!pane) {
|
| 161 |
+
pane = document.createElement('div');
|
| 162 |
+
pane.id = 'md-preview-pane';
|
| 163 |
+
$('editor').parentElement.appendChild(pane);
|
| 164 |
+
}
|
| 165 |
+
pane.classList.toggle('html-preview', lang === 'html');
|
| 166 |
+
if (lang === 'html') {
|
| 167 |
+
pane.replaceChildren();
|
| 168 |
+
const frame = document.createElement('iframe');
|
| 169 |
+
frame.sandbox = 'allow-scripts'; // sin allow-same-origin: aislado de tu proyecto real
|
| 170 |
+
frame.srcdoc = await inlineLocalAssets(tab.model.getValue(), tab.path);
|
| 171 |
+
pane.appendChild(frame);
|
| 172 |
+
} else {
|
| 173 |
+
pane.innerHTML = renderMarkdown(tab.model.getValue());
|
| 174 |
+
}
|
| 175 |
+
pane.style.display = '';
|
| 176 |
+
} else if (pane) {
|
| 177 |
+
pane.style.display = 'none';
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
async function togglePreview(path) {
|
| 181 |
+
// el fichero puede no tener pestaña abierta todavía (p.ej. desde el menú
|
| 182 |
+
// del EXPLORADOR, no el de una pestaña ya abierta) — se abre primero.
|
| 183 |
+
if (active !== path || !tabs.find(t => t.path === path)) await openFile(path);
|
| 184 |
+
const tab = tabs.find(t => t.path === path);
|
| 185 |
+
if (!tab) return; // openFile no pudo (fichero borrado, etc.)
|
| 186 |
+
tab.preview = !tab.preview;
|
| 187 |
+
renderTabs();
|
| 188 |
+
await applyView();
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
async function saveActive() {
|
| 192 |
+
const tab = tabs.find(t => t.path === active);
|
| 193 |
+
if (!tab) return;
|
| 194 |
+
await code.write({ path: tab.path, content: tab.model.getValue() });
|
| 195 |
+
tab.dirty = false;
|
| 196 |
+
renderTabs();
|
| 197 |
+
alertBar('💾 ' + tab.path + ' guardado');
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
async function closeTab(path) {
|
| 201 |
+
const i = tabs.findIndex(t => t.path === path);
|
| 202 |
+
if (i < 0) return;
|
| 203 |
+
tabs[i].model.dispose();
|
| 204 |
+
tabs.splice(i, 1);
|
| 205 |
+
if (active === path) {
|
| 206 |
+
active = tabs[i - 1]?.path || tabs[0]?.path || null;
|
| 207 |
+
code.setCurrentFile(active);
|
| 208 |
+
if (active) { editor.setModel(tabs.find(t => t.path === active).model); setEmptyState(false); }
|
| 209 |
+
else { editor.setModel(monacoRef.editor.createModel('', 'plaintext')); setEmptyState(true); }
|
| 210 |
+
}
|
| 211 |
+
renderTabs();
|
| 212 |
+
await applyView();
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// Estado vacío: cuando no hay ningún fichero abierto, en vez de dejar el Monaco
|
| 216 |
+
// en blanco con un «1», mostramos un placeholder limpio.
|
| 217 |
+
export function setEmptyState(on) {
|
| 218 |
+
let el = document.getElementById('editor-empty');
|
| 219 |
+
if (!el) {
|
| 220 |
+
el = document.createElement('div');
|
| 221 |
+
el.id = 'editor-empty';
|
| 222 |
+
el.innerHTML = '<img src="img/elffuss-code.svg" alt=""><p>Abre un fichero del explorador,<br>o pídele algo a la elfa.</p>';
|
| 223 |
+
$('editor').parentElement.appendChild(el);
|
| 224 |
+
}
|
| 225 |
+
el.style.display = on ? 'flex' : 'none';
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function renderTabs() {
|
| 229 |
+
const bar = $('tabs-bar');
|
| 230 |
+
bar.replaceChildren();
|
| 231 |
+
for (const t of tabs) {
|
| 232 |
+
const el = document.createElement('div');
|
| 233 |
+
el.className = 'tab' + (t.path === active ? ' active' : '') + (t.preview ? ' tab-preview' : '');
|
| 234 |
+
el.dataset.path = t.path;
|
| 235 |
+
const ico = document.createElement('span');
|
| 236 |
+
ico.className = 'tab-ico';
|
| 237 |
+
ico.innerHTML = fileIcon(t.path.split('/').pop());
|
| 238 |
+
const name = document.createElement('span');
|
| 239 |
+
name.className = 'tab-name';
|
| 240 |
+
name.textContent = t.path.split('/').pop();
|
| 241 |
+
name.title = t.path;
|
| 242 |
+
// como VS Code: punto ● si hay cambios sin guardar; × al pasar el ratón
|
| 243 |
+
const x = document.createElement('b');
|
| 244 |
+
x.className = t.dirty ? 'dirty' : '';
|
| 245 |
+
x.textContent = t.dirty ? '●' : '×';
|
| 246 |
+
x.onclick = e => { e.stopPropagation(); closeTab(t.path); };
|
| 247 |
+
el.append(ico, name, x);
|
| 248 |
+
el.onclick = () => openFile(t.path);
|
| 249 |
+
el.oncontextmenu = e => { e.preventDefault(); openTabMenu(e.clientX, e.clientY, t.path); };
|
| 250 |
+
bar.appendChild(el);
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// ---------- menú contextual de las pestañas (botón derecho, estilo VS Code) ----------
|
| 255 |
+
function openTabMenu(x, y, path) {
|
| 256 |
+
document.getElementById('tab-menu')?.remove();
|
| 257 |
+
const menu = document.createElement('div');
|
| 258 |
+
menu.id = 'tab-menu';
|
| 259 |
+
menu.style.cssText = `position:fixed;left:${x}px;top:${y}px;z-index:200`;
|
| 260 |
+
const i = tabs.findIndex(t => t.path === path);
|
| 261 |
+
const tab = tabs[i];
|
| 262 |
+
const items = [];
|
| 263 |
+
items.push(['Cerrar', () => closeTab(path)]);
|
| 264 |
+
items.push(['Cerrar otras', () => { for (const t of tabs.filter(t => t.path !== path)) closeTab(t.path); }]);
|
| 265 |
+
const toTheRight = tabs.slice(i + 1).map(t => t.path);
|
| 266 |
+
if (toTheRight.length) items.push(['Cerrar las de la derecha', () => { for (const p of toTheRight) closeTab(p); }]);
|
| 267 |
+
items.push(['Cerrar todas', () => { for (const t of [...tabs]) closeTab(t.path); }]);
|
| 268 |
+
items.push(['sep']);
|
| 269 |
+
items.push(['Copiar ruta', () => navigator.clipboard?.writeText(path).catch(() => {})]);
|
| 270 |
+
if (PREVIEWABLE.has(langOf(path))) {
|
| 271 |
+
items.push([tab?.preview ? 'Ver código fuente' : 'Vista previa', () => togglePreview(path)]);
|
| 272 |
+
}
|
| 273 |
+
for (const item of items) {
|
| 274 |
+
if (item[0] === 'sep') { menu.appendChild(Object.assign(document.createElement('div'), { className: 'tm-sep' })); continue; }
|
| 275 |
+
const [label, fn] = item;
|
| 276 |
+
const b = document.createElement('button');
|
| 277 |
+
b.className = 'tm-item';
|
| 278 |
+
b.textContent = label;
|
| 279 |
+
b.onclick = () => { menu.remove(); fn(); };
|
| 280 |
+
menu.appendChild(b);
|
| 281 |
+
}
|
| 282 |
+
document.body.appendChild(menu);
|
| 283 |
+
const close = ev => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
| 284 |
+
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
let alertTimer;
|
| 288 |
+
function alertBar(msg) {
|
| 289 |
+
const el = $('statusbar');
|
| 290 |
+
el.textContent = msg;
|
| 291 |
+
clearTimeout(alertTimer);
|
| 292 |
+
alertTimer = setTimeout(() => { el.textContent = ''; }, 4000);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
// ---------- árbol lateral ----------
|
| 296 |
+
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
| 297 |
+
|
| 298 |
+
export async function refreshTree(handle = null) {
|
| 299 |
+
const rootHandle = handle || window.__elffussProject;
|
| 300 |
+
if (!rootHandle) return;
|
| 301 |
+
window.__elffussProject = rootHandle;
|
| 302 |
+
const cont = $('tree');
|
| 303 |
+
cont.replaceChildren(await renderDir(rootHandle, ''));
|
| 304 |
+
setupTreeMenu();
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// ---------- menú contextual del árbol (botón derecho, estilo VS Code) ----------
|
| 308 |
+
let treeMenuWired = false;
|
| 309 |
+
function setupTreeMenu() {
|
| 310 |
+
if (treeMenuWired) return;
|
| 311 |
+
treeMenuWired = true;
|
| 312 |
+
const tree = $('tree');
|
| 313 |
+
tree.addEventListener('contextmenu', e => {
|
| 314 |
+
e.preventDefault();
|
| 315 |
+
const node = e.target.closest('[data-path]');
|
| 316 |
+
const path = node ? node.dataset.path : '';
|
| 317 |
+
const kind = node ? node.dataset.kind : 'directory'; // vacío = raíz
|
| 318 |
+
openTreeMenu(e.clientX, e.clientY, path, kind);
|
| 319 |
+
});
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// resuelve el handle de un directorio por su ruta (segmentos desde la raíz)
|
| 323 |
+
async function dirByPath(path) {
|
| 324 |
+
let dir = code.handle();
|
| 325 |
+
for (const s of (path || '').split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(s);
|
| 326 |
+
return dir;
|
| 327 |
+
}
|
| 328 |
+
const parentOf = p => p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '';
|
| 329 |
+
const baseOf = p => p.split('/').pop();
|
| 330 |
+
|
| 331 |
+
async function fsCreate(parentPath, name, isDir) {
|
| 332 |
+
const dir = await dirByPath(parentPath);
|
| 333 |
+
if (isDir) await dir.getDirectoryHandle(name, { create: true });
|
| 334 |
+
else { const fh = await dir.getFileHandle(name, { create: true }); const w = await fh.createWritable(); await w.close(); }
|
| 335 |
+
}
|
| 336 |
+
async function fsDelete(path, kind) {
|
| 337 |
+
const dir = await dirByPath(parentOf(path));
|
| 338 |
+
await dir.removeEntry(baseOf(path), { recursive: kind === 'directory' });
|
| 339 |
+
}
|
| 340 |
+
// copia recursiva (la File System API no tiene rename nativo)
|
| 341 |
+
async function fsCopy(srcDir, name, dstDir, newName) {
|
| 342 |
+
const src = await (srcDir.getDirectoryHandle(name).catch(() => null));
|
| 343 |
+
if (src) { // directorio
|
| 344 |
+
const nd = await dstDir.getDirectoryHandle(newName, { create: true });
|
| 345 |
+
for await (const e of src.values()) await fsCopy(src, e.name, nd, e.name);
|
| 346 |
+
} else { // fichero
|
| 347 |
+
const text = await (await (await srcDir.getFileHandle(name)).getFile()).text();
|
| 348 |
+
const fh = await dstDir.getFileHandle(newName, { create: true });
|
| 349 |
+
const w = await fh.createWritable(); await w.write(text); await w.close();
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
async function fsRename(path, kind, newName) {
|
| 353 |
+
const parent = await dirByPath(parentOf(path));
|
| 354 |
+
await fsCopy(parent, baseOf(path), parent, newName);
|
| 355 |
+
await parent.removeEntry(baseOf(path), { recursive: kind === 'directory' });
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
function openTreeMenu(x, y, path, kind) {
|
| 359 |
+
document.getElementById('tree-menu')?.remove();
|
| 360 |
+
const menu = document.createElement('div');
|
| 361 |
+
menu.id = 'tree-menu';
|
| 362 |
+
menu.style.cssText = `position:fixed;left:${x}px;top:${y}px;z-index:200`;
|
| 363 |
+
// en un fichero, el "aquí dentro" es su carpeta; en carpeta/raíz, ella misma
|
| 364 |
+
const container = kind === 'file' ? parentOf(path) : path;
|
| 365 |
+
const items = [];
|
| 366 |
+
items.push(['Nuevo archivo…', async () => {
|
| 367 |
+
const name = prompt('Nombre del nuevo archivo:'); if (!name) return;
|
| 368 |
+
await fsCreate(container, name.trim(), false); code.invalidateFileList?.();
|
| 369 |
+
await refreshTree(); openFile(container ? container + '/' + name.trim() : name.trim());
|
| 370 |
+
}]);
|
| 371 |
+
items.push(['Nueva carpeta…', async () => {
|
| 372 |
+
const name = prompt('Nombre de la nueva carpeta:'); if (!name) return;
|
| 373 |
+
await fsCreate(container, name.trim(), true); code.invalidateFileList?.(); await refreshTree();
|
| 374 |
+
}]);
|
| 375 |
+
if (path) {
|
| 376 |
+
if (kind === 'file') items.push(['Abrir', () => openFile(path)]);
|
| 377 |
+
if (kind === 'file' && PREVIEWABLE.has(langOf(path))) items.push(['Vista previa', () => togglePreview(path)]);
|
| 378 |
+
items.push(['Renombrar…', async () => {
|
| 379 |
+
const name = prompt('Nuevo nombre:', baseOf(path)); if (!name || name === baseOf(path)) return;
|
| 380 |
+
await fsRename(path, kind, name.trim()); code.invalidateFileList?.(); await refreshTree();
|
| 381 |
+
}]);
|
| 382 |
+
items.push(['Eliminar', async () => {
|
| 383 |
+
if (!confirm(`¿Eliminar «${baseOf(path)}»?`)) return;
|
| 384 |
+
await fsDelete(path, kind); code.invalidateFileList?.(); await refreshTree();
|
| 385 |
+
}, 'danger']);
|
| 386 |
+
}
|
| 387 |
+
for (const [label, fn, cls] of items) {
|
| 388 |
+
const b = document.createElement('button');
|
| 389 |
+
b.className = 'tm-item' + (cls ? ' ' + cls : '');
|
| 390 |
+
b.textContent = label;
|
| 391 |
+
b.onclick = async () => { menu.remove(); try { await fn(); } catch (err) { alert('No se pudo: ' + err.message); } };
|
| 392 |
+
menu.appendChild(b);
|
| 393 |
+
}
|
| 394 |
+
document.body.appendChild(menu);
|
| 395 |
+
const close = ev => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
| 396 |
+
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
async function renderDir(dir, prefix) {
|
| 400 |
+
const ul = document.createElement('ul');
|
| 401 |
+
const entries = [];
|
| 402 |
+
for await (const e of dir.values()) if (!IGNORE.has(e.name)) entries.push(e);
|
| 403 |
+
entries.sort((a, b) => (a.kind !== b.kind) ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name));
|
| 404 |
+
for (const e of entries.slice(0, 200)) {
|
| 405 |
+
const li = document.createElement('li');
|
| 406 |
+
const p = prefix ? prefix + '/' + e.name : e.name;
|
| 407 |
+
if (e.kind === 'directory') {
|
| 408 |
+
const det = document.createElement('details');
|
| 409 |
+
const sum = document.createElement('summary');
|
| 410 |
+
sum.innerHTML = folderIcon();
|
| 411 |
+
sum.appendChild(document.createTextNode(e.name));
|
| 412 |
+
sum.dataset.path = p; sum.dataset.kind = 'directory';
|
| 413 |
+
det.appendChild(sum);
|
| 414 |
+
det.addEventListener('toggle', async () => {
|
| 415 |
+
if (det.open && det.children.length === 1)
|
| 416 |
+
det.appendChild(await renderDir(e, p));
|
| 417 |
+
}, { once: false });
|
| 418 |
+
li.appendChild(det);
|
| 419 |
+
} else {
|
| 420 |
+
const a = document.createElement('span');
|
| 421 |
+
a.className = 'file';
|
| 422 |
+
a.innerHTML = fileIcon(e.name);
|
| 423 |
+
a.appendChild(document.createTextNode(e.name));
|
| 424 |
+
a.dataset.path = p; a.dataset.kind = 'file';
|
| 425 |
+
a.onclick = () => openFile(p);
|
| 426 |
+
li.appendChild(a);
|
| 427 |
+
}
|
| 428 |
+
ul.appendChild(li);
|
| 429 |
+
}
|
| 430 |
+
return ul;
|
| 431 |
+
}
|
js/main.js
ADDED
|
@@ -0,0 +1,1510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Elffuss Code: landing («abre tu carpeta») → IDE con la elfa integrada.
|
| 2 |
+
import { parseToolCall } from './agent.js';
|
| 3 |
+
import * as rules from './providers/rules.js';
|
| 4 |
+
import * as codeTools from './tools/code.js';
|
| 5 |
+
import * as db from './db.js';
|
| 6 |
+
import * as settings from './settings.js';
|
| 7 |
+
import { initEditor, refreshTree, openFile, gotoLine, triggerEditor, hasEditor } from './ide.js';
|
| 8 |
+
import { ACTIVITY, UI } from './icons.js';
|
| 9 |
+
import { renderMarkdown } from './md.js';
|
| 10 |
+
import * as skills from './skills.js';
|
| 11 |
+
import * as terminal from './terminal.js';
|
| 12 |
+
import * as shell from './shell.js';
|
| 13 |
+
import { setTerminalEcho } from './tools/index.js';
|
| 14 |
+
import { ensureModelCache, cacheEstimate, clearModelCache } from './model-cache.js';
|
| 15 |
+
import * as ceo from './ceo.js';
|
| 16 |
+
import * as mind from './mind.js';
|
| 17 |
+
import { buildCityAdapter, loadThoughtsAdapter } from './mind-adapter.js';
|
| 18 |
+
import { humanizeStreamPreview } from './humanize.js';
|
| 19 |
+
import * as bridge from './bridge.js';
|
| 20 |
+
import * as conv from './conversations.js';
|
| 21 |
+
import { TASK_PREFIX } from './goal.js';
|
| 22 |
+
import * as telemetry from './telemetry.js';
|
| 23 |
+
|
| 24 |
+
const $ = id => document.getElementById(id);
|
| 25 |
+
telemetry.init('elffuss-code'); // opt-in, apagado por defecto — ver Ajustes
|
| 26 |
+
conv.setProvider(rules); // proveedor por defecto para cualquier conversación que se cree
|
| 27 |
+
let activeModel = 'rules';
|
| 28 |
+
|
| 29 |
+
// ---------- chat ----------
|
| 30 |
+
// Los `paths` entre backticks del chat (README.md, .elffuss/soul/x.md…) se
|
| 31 |
+
// vuelven clicables → abren el fichero en el editor directamente, sin tener
|
| 32 |
+
// que ir a buscarlos a mano.
|
| 33 |
+
function linkifyFilePaths(root) {
|
| 34 |
+
root.querySelectorAll('code').forEach(el => {
|
| 35 |
+
const t = el.textContent.trim();
|
| 36 |
+
if (!/^[\w.-]+(\/[\w.-]+)*\.[a-z0-9]{1,10}$/i.test(t) || /[{}()<>]/.test(t)) return;
|
| 37 |
+
el.classList.add('file-link');
|
| 38 |
+
el.title = 'abrir ' + t;
|
| 39 |
+
el.onclick = () => openFile(t);
|
| 40 |
+
});
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function addMsg(cls, text) {
|
| 44 |
+
const div = document.createElement('div');
|
| 45 |
+
div.className = 'msg ' + cls;
|
| 46 |
+
// las respuestas se renderizan como markdown (estilo plugin de Claude Code);
|
| 47 |
+
// lo del usuario y los errores, como texto plano
|
| 48 |
+
if (/^(assistant$|sys)/.test(cls)) {
|
| 49 |
+
div.classList.add('md');
|
| 50 |
+
// cap defensivo: no volcar miles de nodos si una respuesta trae un archivo entero
|
| 51 |
+
div.innerHTML = renderMarkdown(text.length > 8000 ? text.slice(0, 8000) + '\n… (recortado)' : text);
|
| 52 |
+
linkifyFilePaths(div);
|
| 53 |
+
} else {
|
| 54 |
+
div.textContent = text;
|
| 55 |
+
}
|
| 56 |
+
$('chat-log').appendChild(div);
|
| 57 |
+
$('chat-log').scrollTop = $('chat-log').scrollHeight;
|
| 58 |
+
return div;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// estilo plugin de Claude Code: ● herramienta en negrita + detalle plegable
|
| 62 |
+
function addTool(tool, arg) {
|
| 63 |
+
const div = document.createElement('div');
|
| 64 |
+
div.className = 'msg tool';
|
| 65 |
+
const dot = document.createElement('span');
|
| 66 |
+
dot.className = 'tdot';
|
| 67 |
+
const b = document.createElement('b');
|
| 68 |
+
b.textContent = tool;
|
| 69 |
+
div.append(dot, b);
|
| 70 |
+
if (arg) {
|
| 71 |
+
const a = document.createElement('span');
|
| 72 |
+
a.className = 'targ';
|
| 73 |
+
a.textContent = String(arg).slice(0, 60);
|
| 74 |
+
div.appendChild(a);
|
| 75 |
+
}
|
| 76 |
+
$('chat-log').appendChild(div);
|
| 77 |
+
$('chat-log').scrollTop = $('chat-log').scrollHeight;
|
| 78 |
+
return div;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function addToolResult(result) {
|
| 82 |
+
const err = result.startsWith('ERROR');
|
| 83 |
+
const det = document.createElement('details');
|
| 84 |
+
det.className = 'msg tool-result' + (err ? ' err' : '');
|
| 85 |
+
const sum = document.createElement('summary');
|
| 86 |
+
sum.textContent = (err ? '⚠️ ' : '') + result.split('\n')[0].slice(0, 90);
|
| 87 |
+
const pre = document.createElement('pre');
|
| 88 |
+
pre.textContent = result.slice(0, 1500);
|
| 89 |
+
det.append(sum, pre);
|
| 90 |
+
$('chat-log').appendChild(det);
|
| 91 |
+
$('chat-log').scrollTop = $('chat-log').scrollHeight;
|
| 92 |
+
return det;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
| 96 |
+
|
| 97 |
+
// 🎯 Modo Objetivo: tarjeta de plan (planificador → lista de tareas →
|
| 98 |
+
// ejecutor). Un solo nodo por plan (mismo id), repintado in-place según
|
| 99 |
+
// llegan los eventos 'plan'/'plan_update'/'plan_complete' — así se ve la
|
| 100 |
+
// lista evolucionar en vivo en vez de reconstruirse entera cada vez.
|
| 101 |
+
const PLAN_ICO = { pending: '⏳', 'in-progress': '🔄', done: '✅', failed: '❌', skipped: '⏭️' };
|
| 102 |
+
const PLAN_STATUS_LABEL = { planning: 'planificando…', running: 'ejecutando…', done: 'completado', failed: 'con fallos' };
|
| 103 |
+
function renderPlanCard(plan) {
|
| 104 |
+
const domId = 'plan-' + plan.id;
|
| 105 |
+
let div = document.getElementById(domId);
|
| 106 |
+
if (!div) {
|
| 107 |
+
div = document.createElement('div');
|
| 108 |
+
div.id = domId;
|
| 109 |
+
div.className = 'msg plan-card';
|
| 110 |
+
$('chat-log').appendChild(div);
|
| 111 |
+
}
|
| 112 |
+
div.innerHTML =
|
| 113 |
+
`<span class="plan-status">${escapeHtml(PLAN_STATUS_LABEL[plan.status] || plan.status)}</span>` +
|
| 114 |
+
`<div class="plan-head">🎯 Objetivo: ${escapeHtml(plan.goal)}</div>` +
|
| 115 |
+
(plan.planText ? `<div class="plan-summary">${escapeHtml(plan.planText)}</div>` : '') +
|
| 116 |
+
`<ul class="plan-tasks">` +
|
| 117 |
+
plan.tasks.map(t =>
|
| 118 |
+
`<li class="task-${t.status}"><span class="task-ico">${PLAN_ICO[t.status] || '⏳'}</span>` +
|
| 119 |
+
`<div><div class="task-title">${escapeHtml(t.title)}</div><div class="task-desc">${escapeHtml(t.description)}</div></div></li>`
|
| 120 |
+
).join('') +
|
| 121 |
+
`</ul>`;
|
| 122 |
+
$('chat-log').scrollTop = $('chat-log').scrollHeight;
|
| 123 |
+
return div;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
export function thinkingBubble() {
|
| 127 |
+
const div = addMsg('thinking', '');
|
| 128 |
+
const label = document.createElement('span');
|
| 129 |
+
label.textContent = 'Elffuss está pensando';
|
| 130 |
+
const gen = document.createElement('div');
|
| 131 |
+
gen.className = 'gen';
|
| 132 |
+
div.append(label, gen);
|
| 133 |
+
let buf = '';
|
| 134 |
+
return {
|
| 135 |
+
tick(t) {
|
| 136 |
+
buf += t;
|
| 137 |
+
label.textContent = `Elffuss escribe · ${buf.length} car.`; // caracteres, no tokens
|
| 138 |
+
// si el buffer entró en un bloque de tool-call, no enseñes el JSON crudo
|
| 139 |
+
// según va llegando: una frase humana («leyendo app.js…») en su lugar.
|
| 140 |
+
const preview = humanizeStreamPreview(buf);
|
| 141 |
+
gen.classList.toggle('tool-preview', !!preview);
|
| 142 |
+
gen.textContent = preview ? '⟐ ' + preview : (buf.length > 200 ? '…' : '') + buf.slice(-200);
|
| 143 |
+
$('chat-log').scrollTop = $('chat-log').scrollHeight;
|
| 144 |
+
},
|
| 145 |
+
tool(name) { buf = ''; gen.textContent = ''; label.textContent = `Elffuss usa ${name}`; },
|
| 146 |
+
remove() { div.remove(); },
|
| 147 |
+
};
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// Conversaciones (pestañas, en paralelo, con su propia cola): la lógica de
|
| 151 |
+
// estado vive en conversations.js — aquí solo se pinta lo que corresponde.
|
| 152 |
+
let activeThinking = null;
|
| 153 |
+
|
| 154 |
+
function renderActiveLog() {
|
| 155 |
+
$('chat-log').replaceChildren();
|
| 156 |
+
const active = conv.getActive();
|
| 157 |
+
activeThinking = null;
|
| 158 |
+
if (!active) return;
|
| 159 |
+
for (const m of active.agent.history) {
|
| 160 |
+
if (m.role === 'user') {
|
| 161 |
+
if (m.content.startsWith('[resultado ')) {
|
| 162 |
+
const nl = m.content.indexOf('\n');
|
| 163 |
+
addToolResult(nl > 0 ? m.content.slice(nl + 1) : '');
|
| 164 |
+
} else if (m.content.startsWith(TASK_PREFIX)) {
|
| 165 |
+
// objetivo/tareas internas del modo Goal: no son burbujas sueltas —
|
| 166 |
+
// la tarjeta de plan las muestra todas. Se pinta justo en el punto
|
| 167 |
+
// donde se lanzó el objetivo, igual que aparece en directo.
|
| 168 |
+
if (m.content.startsWith(TASK_PREFIX + 'Objetivo: ') && active.plan) renderPlanCard(active.plan);
|
| 169 |
+
} else if (!m.content.startsWith('[…')) {
|
| 170 |
+
addMsg('user', m.content);
|
| 171 |
+
}
|
| 172 |
+
} else {
|
| 173 |
+
const call = parseToolCall(m.content);
|
| 174 |
+
if (call) addTool(call.tool, call.args?.path || call.args?.query || '');
|
| 175 |
+
else addMsg('assistant', m.content);
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
if (active.pumping) activeThinking = thinkingBubble();
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// evento del módulo de conversaciones: 'switch' (cambiaste de pestaña o se
|
| 182 |
+
// creó una), 'tabs' (se abrió/cerró una pestaña), 'pumping' (empezó/terminó
|
| 183 |
+
// de procesar), 'event' (token/texto/tool/resultado/error de un turno real).
|
| 184 |
+
// Las conversaciones EN SEGUNDO PLANO nunca tocan el DOM visible — solo se
|
| 185 |
+
// refleja su punto de «ocupada» en la barra de pestañas.
|
| 186 |
+
function onConvEvent(kind, id, payload) {
|
| 187 |
+
if (kind !== 'event') renderTabsBar();
|
| 188 |
+
if (kind === 'switch') { renderActiveLog(); return; }
|
| 189 |
+
const active = conv.getActive();
|
| 190 |
+
if (!active || id !== active.id) return;
|
| 191 |
+
if (kind === 'pumping') {
|
| 192 |
+
$('btn-send').disabled = payload;
|
| 193 |
+
$('btn-send').classList.toggle('sending', payload);
|
| 194 |
+
if (payload) { if (!activeThinking) activeThinking = thinkingBubble(); }
|
| 195 |
+
else { activeThinking?.remove(); activeThinking = null; }
|
| 196 |
+
return;
|
| 197 |
+
}
|
| 198 |
+
if (kind === 'event') {
|
| 199 |
+
const ev = payload;
|
| 200 |
+
if (ev.type === 'token') activeThinking?.tick(ev.text);
|
| 201 |
+
if (ev.type === 'text') addMsg('assistant', ev.text);
|
| 202 |
+
if (ev.type === 'tool') { activeThinking?.tool(ev.call.tool); addTool(ev.call.tool, ev.call.args?.path || ev.call.args?.query || ''); }
|
| 203 |
+
if (ev.type === 'tool_result') addToolResult(ev.result);
|
| 204 |
+
if (ev.type === 'error') addMsg('assistant err', ev.text);
|
| 205 |
+
// 🎯 Modo Objetivo: la tarjeta de plan se crea con 'plan' y se repinta
|
| 206 |
+
// in-place en cada 'plan_update'/'plan_complete' (mismo nodo, por id).
|
| 207 |
+
if (ev.type === 'plan' || ev.type === 'plan_update' || ev.type === 'plan_complete') renderPlanCard(ev.plan);
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
function send(text) {
|
| 212 |
+
const active = conv.getActive();
|
| 213 |
+
if (!active) return;
|
| 214 |
+
addMsg('user', text);
|
| 215 |
+
if (goalMode) conv.startGoal(active.id, text);
|
| 216 |
+
else conv.send(active.id, text);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function renderTabsBar() {
|
| 220 |
+
const bar = $('conv-tabs');
|
| 221 |
+
if (!bar) return;
|
| 222 |
+
bar.replaceChildren();
|
| 223 |
+
const active = conv.getActive();
|
| 224 |
+
for (const c of conv.getOpenTabs()) {
|
| 225 |
+
const el = document.createElement('div');
|
| 226 |
+
el.className = 'tab' + (active && c.id === active.id ? ' active' : '') + (c.pumping ? ' tab-busy' : '');
|
| 227 |
+
el.dataset.id = c.id;
|
| 228 |
+
const name = document.createElement('span');
|
| 229 |
+
name.className = 'tab-name';
|
| 230 |
+
const title = conv.titleFor(c);
|
| 231 |
+
name.textContent = title;
|
| 232 |
+
name.title = title;
|
| 233 |
+
const x = document.createElement('b');
|
| 234 |
+
x.textContent = '×';
|
| 235 |
+
x.title = 'Cerrar pestaña (no borra la conversación)';
|
| 236 |
+
x.onclick = e => { e.stopPropagation(); conv.closeTab(c.id); };
|
| 237 |
+
el.append(name, x);
|
| 238 |
+
el.onclick = () => conv.switchTo(c.id);
|
| 239 |
+
bar.appendChild(el);
|
| 240 |
+
}
|
| 241 |
+
const plus = document.createElement('button');
|
| 242 |
+
plus.className = 'tab-add';
|
| 243 |
+
plus.title = 'Nueva conversación';
|
| 244 |
+
plus.textContent = '+';
|
| 245 |
+
plus.onclick = () => conv.create();
|
| 246 |
+
bar.appendChild(plus);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
async function renderHistoryPanel() {
|
| 250 |
+
const panel = $('history-panel');
|
| 251 |
+
panel.replaceChildren();
|
| 252 |
+
const head = document.createElement('div');
|
| 253 |
+
head.className = 'hp-head';
|
| 254 |
+
head.append(document.createTextNode('Historial de conversaciones'));
|
| 255 |
+
const xBtn = document.createElement('button');
|
| 256 |
+
xBtn.textContent = '✕';
|
| 257 |
+
xBtn.onclick = () => { panel.hidden = true; };
|
| 258 |
+
head.appendChild(xBtn);
|
| 259 |
+
panel.appendChild(head);
|
| 260 |
+
const list = document.createElement('div');
|
| 261 |
+
list.id = 'hp-list';
|
| 262 |
+
panel.appendChild(list);
|
| 263 |
+
const all = await conv.listAll();
|
| 264 |
+
if (!all.length) {
|
| 265 |
+
const empty = document.createElement('div');
|
| 266 |
+
empty.className = 'hp-empty';
|
| 267 |
+
empty.textContent = 'Sin conversaciones guardadas todavía.';
|
| 268 |
+
list.appendChild(empty);
|
| 269 |
+
return;
|
| 270 |
+
}
|
| 271 |
+
for (const c of all) {
|
| 272 |
+
const row = document.createElement('div');
|
| 273 |
+
row.className = 'hp-row';
|
| 274 |
+
const t = document.createElement('div');
|
| 275 |
+
t.className = 'hp-title';
|
| 276 |
+
t.textContent = c.title || 'Nueva conversación';
|
| 277 |
+
const d = new Date(c.updatedAt);
|
| 278 |
+
const date = document.createElement('div');
|
| 279 |
+
date.className = 'hp-date';
|
| 280 |
+
date.textContent = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
| 281 |
+
const del = document.createElement('button');
|
| 282 |
+
del.className = 'hp-del';
|
| 283 |
+
del.title = 'Eliminar para siempre';
|
| 284 |
+
del.textContent = '🗑';
|
| 285 |
+
del.onclick = async e => {
|
| 286 |
+
e.stopPropagation();
|
| 287 |
+
if (!confirm(`¿Eliminar «${c.title || 'esta conversación'}» para siempre?`)) return;
|
| 288 |
+
await conv.remove(c.id);
|
| 289 |
+
renderHistoryPanel();
|
| 290 |
+
};
|
| 291 |
+
row.append(t, date, del);
|
| 292 |
+
row.onclick = async () => { await conv.open(c.id); panel.hidden = true; };
|
| 293 |
+
list.appendChild(row);
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
// ---------- modelos (local primero; externos = ⚙️ opt-in) ----------
|
| 298 |
+
const localProviders = { litert: () => import('./providers/litert.js'), onnx: () => import('./providers/onnx.js') };
|
| 299 |
+
|
| 300 |
+
async function resolveProvider(id) {
|
| 301 |
+
if (localProviders[id]) return localProviders[id]();
|
| 302 |
+
if (id.startsWith('litert:')) { // Gemma vía LiteRT-LM (build elegido)
|
| 303 |
+
const mod = await import('./providers/litert.js');
|
| 304 |
+
mod.configure(id.slice(7));
|
| 305 |
+
return mod;
|
| 306 |
+
}
|
| 307 |
+
if (id.startsWith('ext:')) {
|
| 308 |
+
const cfg = settings.get(id.slice(4));
|
| 309 |
+
const mod = await import('./providers/api.js');
|
| 310 |
+
mod.configure(cfg);
|
| 311 |
+
return mod;
|
| 312 |
+
}
|
| 313 |
+
return null;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// El E4B healed (4.12 GB) NO carga aún en navegador (export prefill_decode, no
|
| 317 |
+
// artisan) — descargarlo y fallar deja la GPU en estado inválido y TUMBA la
|
| 318 |
+
// pestaña. Se oculta hasta el reexport artisan. Poner true para reactivarlo.
|
| 319 |
+
const LITERT_READY = false;
|
| 320 |
+
|
| 321 |
+
const isMobile = () => matchMedia('(max-width: 820px)').matches || matchMedia('(pointer: coarse)').matches;
|
| 322 |
+
// navigator.gpu puede EXISTIR como API sin que haya un adaptador real (ciertos
|
| 323 |
+
// Linux/drivers, entornos sandboxed/headless…) — comprobarlo UNA vez al
|
| 324 |
+
// arrancar evita ofrecer/elegir Gemma (2-4 GB) cuando de todos modos va a
|
| 325 |
+
// fallar al crear el motor (onnx.js y litert.js ya comprueban el adaptador
|
| 326 |
+
// por su cuenta como defensa adicional, pero esto evita hasta OFRECERLO).
|
| 327 |
+
let realGPU = !!navigator.gpu;
|
| 328 |
+
const realGPUCheck = (async () => {
|
| 329 |
+
if (!navigator.gpu) return (realGPU = false);
|
| 330 |
+
try { return (realGPU = !!(await navigator.gpu.requestAdapter())); }
|
| 331 |
+
catch { return (realGPU = false); }
|
| 332 |
+
})();
|
| 333 |
+
const defaultBrain = () => !realGPU ? 'onnx' : (isMobile() ? 'litert:gemma-e2b' : 'litert:gemma-e4b');
|
| 334 |
+
|
| 335 |
+
function modelOptions() {
|
| 336 |
+
const opts = [];
|
| 337 |
+
if (realGPU) opts.push({ id: 'litert:gemma-e4b', label: 'Gemma-4 E4B · LiteRT-LM (~4 GB) ★' });
|
| 338 |
+
if (realGPU) opts.push({ id: 'litert:gemma-e2b', label: 'Gemma-4 E2B · LiteRT-LM (~2 GB)' });
|
| 339 |
+
opts.push({ id: 'onnx', label: 'Elffuss LM (healed · 850 MB) — ligero' });
|
| 340 |
+
opts.push({ id: 'rules', label: 'Básico (sin modelo)' });
|
| 341 |
+
return [...opts, ...settings.enabledExternals()];
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
function rebuildSelect() {
|
| 345 |
+
const sel = $('model-select');
|
| 346 |
+
sel.replaceChildren();
|
| 347 |
+
for (const o of modelOptions()) {
|
| 348 |
+
const opt = document.createElement('option');
|
| 349 |
+
opt.value = o.id;
|
| 350 |
+
opt.textContent = o.label;
|
| 351 |
+
sel.appendChild(opt);
|
| 352 |
+
}
|
| 353 |
+
sel.value = activeModel;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
// progreso del modelo: píldora prominente visible sobre landing e IDE
|
| 357 |
+
function showModelProgress(text, pct = null) {
|
| 358 |
+
const box = $('model-progress');
|
| 359 |
+
if (text == null) { box.hidden = true; return; }
|
| 360 |
+
box.hidden = false;
|
| 361 |
+
$('model-progress-text').textContent = text;
|
| 362 |
+
const bar = $('model-bar');
|
| 363 |
+
if (pct == null) {
|
| 364 |
+
bar.classList.add('indet'); // sin % conocido → barra animada
|
| 365 |
+
bar.style.width = '';
|
| 366 |
+
} else {
|
| 367 |
+
bar.classList.remove('indet');
|
| 368 |
+
bar.style.width = pct + '%';
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
let loadingId = null;
|
| 373 |
+
async function changeModel(id) {
|
| 374 |
+
if (id === 'rules') {
|
| 375 |
+
conv.setProvider(rules);
|
| 376 |
+
activeModel = 'rules';
|
| 377 |
+
localStorage.setItem('elffusscode.model', 'rules');
|
| 378 |
+
$('model-dot').className = 'dot off';
|
| 379 |
+
return true;
|
| 380 |
+
}
|
| 381 |
+
if (loadingId === id || activeModel === id) return true; // un solo modelo, una sola carga
|
| 382 |
+
loadingId = id;
|
| 383 |
+
$('model-dot').className = 'dot loading';
|
| 384 |
+
showModelProgress('Cargando el modelo IA…', 4);
|
| 385 |
+
try {
|
| 386 |
+
const mod = await resolveProvider(id);
|
| 387 |
+
await mod.load(p => {
|
| 388 |
+
if (typeof p === 'string') return showModelProgress(p);
|
| 389 |
+
if (p?.status === 'progress' && p.total) {
|
| 390 |
+
const pct = Math.round(p.loaded / p.total * 100);
|
| 391 |
+
showModelProgress(`Descargando el modelo IA · ${pct}% · ${(p.loaded / 1e6 | 0)}/${(p.total / 1e6 | 0)} MB`, pct);
|
| 392 |
+
}
|
| 393 |
+
});
|
| 394 |
+
conv.setProvider(mod);
|
| 395 |
+
activeModel = id;
|
| 396 |
+
localStorage.setItem('elffusscode.model', id);
|
| 397 |
+
$('model-dot').className = 'dot on';
|
| 398 |
+
showModelProgress('Modelo IA listo · готово ✳', 100);
|
| 399 |
+
setTimeout(() => showModelProgress(null), 2500);
|
| 400 |
+
$('statusbar').textContent = 'Modelo IA listo';
|
| 401 |
+
rebuildSelect();
|
| 402 |
+
return true;
|
| 403 |
+
} catch (e) {
|
| 404 |
+
console.error('[elffuss-code] fallo cargando', e);
|
| 405 |
+
// Un Gemma (LiteRT) que no cabe → cae al Elffuss LM healed (onnx, ligero).
|
| 406 |
+
if (id.startsWith('litert') && !_fellBack) {
|
| 407 |
+
_fellBack = true;
|
| 408 |
+
sessionStorage.setItem('elffusscode.skipGemma', '1');
|
| 409 |
+
showModelProgress('Ese Gemma no cargó (memoria/GPU) — uso Elffuss LM (ligero)…');
|
| 410 |
+
loadingId = null;
|
| 411 |
+
const okc = await changeModel('onnx');
|
| 412 |
+
_fellBack = false;
|
| 413 |
+
if (okc) return true;
|
| 414 |
+
}
|
| 415 |
+
conv.setProvider(rules);
|
| 416 |
+
activeModel = 'rules';
|
| 417 |
+
$('model-dot').className = 'dot off';
|
| 418 |
+
showModelProgress('⚠️ No pude cargar el modelo: ' + (e?.message || e));
|
| 419 |
+
setTimeout(() => showModelProgress(null), 6000);
|
| 420 |
+
rebuildSelect();
|
| 421 |
+
return false;
|
| 422 |
+
} finally {
|
| 423 |
+
loadingId = null;
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
let _fellBack = false;
|
| 427 |
+
|
| 428 |
+
// El cerebro empieza a descargarse desde el PRIMER segundo, en segundo plano,
|
| 429 |
+
// mientras el usuario elige la carpeta. Solo el modelo local — los externos
|
| 430 |
+
// jamás se activan solos.
|
| 431 |
+
// Cadena de respaldo: el modelo preferido → LFM2.5 local → básico. Si el E4B
|
| 432 |
+
// healed falla (p. ej. «HF_Tokenizer_Zlib not supported» del runtime LiteRT),
|
| 433 |
+
// cae solo a LFM2.5 en vez de dejar al usuario sin cerebro.
|
| 434 |
+
async function preloadModel() {
|
| 435 |
+
const saved = localStorage.getItem('elffusscode.model');
|
| 436 |
+
if (saved === 'rules') return;
|
| 437 |
+
await realGPUCheck; // que defaultBrain()/modelOptions() vean el adaptador real, no solo la API
|
| 438 |
+
const avail = new Set(modelOptions().map(o => o.id));
|
| 439 |
+
const skipGemma = sessionStorage.getItem('elffusscode.skipGemma') === '1';
|
| 440 |
+
const def = skipGemma ? 'onnx' : defaultBrain();
|
| 441 |
+
const chain = [...new Set([saved, def, 'onnx']
|
| 442 |
+
.filter(id => id && id !== 'rules' && avail.has(id)))];
|
| 443 |
+
for (const id of chain) if (await changeModel(id)) return;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
// ---------- panel ⚙️ Ajustes: modelo (cerebro) + API keys ----------
|
| 447 |
+
const el = (t, cls, txt) => { const e = document.createElement(t); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; };
|
| 448 |
+
|
| 449 |
+
function settingsShell(title) {
|
| 450 |
+
const box = $('settings-panel');
|
| 451 |
+
box.hidden = false;
|
| 452 |
+
box.replaceChildren();
|
| 453 |
+
const close = el('button', 'panel-close'); close.innerHTML = UI.close; close.title = 'cerrar';
|
| 454 |
+
close.onclick = () => { box.hidden = true; };
|
| 455 |
+
box.append(close, el('h3', 'panel-title', title));
|
| 456 |
+
return box;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
function showBridgeMsg(card, text, isErr) {
|
| 460 |
+
let el2 = card.querySelector('.br-msg');
|
| 461 |
+
if (!el2) { el2 = document.createElement('div'); el2.className = 'br-msg muted'; el2.style.cssText = 'font-size:.7rem;margin-top:6px'; card.appendChild(el2); }
|
| 462 |
+
el2.textContent = text;
|
| 463 |
+
el2.style.color = isErr ? '#ff6b8b' : '';
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
// pequeño estallido de fuegos artificiales (canvas, un par de segundos) para
|
| 467 |
+
// que se entienda de un vistazo que el bridge quedó conectado.
|
| 468 |
+
function fireworks() {
|
| 469 |
+
const cv = document.createElement('canvas');
|
| 470 |
+
cv.id = 'fireworks-fx';
|
| 471 |
+
cv.width = innerWidth; cv.height = innerHeight;
|
| 472 |
+
document.body.appendChild(cv);
|
| 473 |
+
const ctx = cv.getContext('2d');
|
| 474 |
+
const colors = ['#ff4d8d', '#7c5cff', '#49e8ff', '#3fb970', '#ffd479'];
|
| 475 |
+
const bursts = Array.from({ length: 5 }, () => ({
|
| 476 |
+
x: innerWidth * (0.2 + Math.random() * 0.6), y: innerHeight * (0.2 + Math.random() * 0.4),
|
| 477 |
+
t0: performance.now() + Math.random() * 500,
|
| 478 |
+
particles: Array.from({ length: 40 }, (_, i) => { const a = (i / 40) * Math.PI * 2; const sp = 2 + Math.random() * 3; return { a, sp, color: colors[i % colors.length] }; }),
|
| 479 |
+
}));
|
| 480 |
+
const start = performance.now();
|
| 481 |
+
function frame(now) {
|
| 482 |
+
ctx.clearRect(0, 0, cv.width, cv.height);
|
| 483 |
+
let alive = false;
|
| 484 |
+
for (const b of bursts) {
|
| 485 |
+
const age = (now - b.t0) / 1000;
|
| 486 |
+
if (age < 0) { alive = true; continue; }
|
| 487 |
+
if (age > 1.4) continue;
|
| 488 |
+
alive = true;
|
| 489 |
+
for (const p of b.particles) {
|
| 490 |
+
const r = p.sp * age * 40;
|
| 491 |
+
const x = b.x + Math.cos(p.a) * r, y = b.y + Math.sin(p.a) * r + age * age * 60;
|
| 492 |
+
ctx.globalAlpha = Math.max(0, 1 - age / 1.4);
|
| 493 |
+
ctx.fillStyle = p.color;
|
| 494 |
+
ctx.beginPath(); ctx.arc(x, y, 2.4, 0, Math.PI * 2); ctx.fill();
|
| 495 |
+
}
|
| 496 |
+
}
|
| 497 |
+
if (alive && now - start < 3000) requestAnimationFrame(frame);
|
| 498 |
+
else cv.remove();
|
| 499 |
+
}
|
| 500 |
+
requestAnimationFrame(frame);
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
function renderSettings() {
|
| 504 |
+
const box = settingsShell('Ajustes');
|
| 505 |
+
|
| 506 |
+
// --- Cerebro (modelo) ---
|
| 507 |
+
box.append(el('div', 'sk-h', 'Cerebro (modelo)'));
|
| 508 |
+
const LOCAL = [
|
| 509 |
+
{ id: 'litert:gemma-e4b', name: 'Gemma-4 E4B ★', sub: 'El mejor · WebGPU local · ~4 GB', need: 'gpu' },
|
| 510 |
+
{ id: 'litert:gemma-e2b', name: 'Gemma-4 E2B', sub: 'Ligero · WebGPU local · ~2 GB', need: 'gpu' },
|
| 511 |
+
{ id: 'onnx', name: 'Elffuss LM (healed)', sub: 'Modelo propio · 850 MB · tool-calls + apps' },
|
| 512 |
+
{ id: 'rules', name: 'Básico (sin modelo)', sub: 'Órdenes directas, cero descarga' },
|
| 513 |
+
];
|
| 514 |
+
const grid = el('div', 'model-grid');
|
| 515 |
+
for (const m of LOCAL) {
|
| 516 |
+
if (m.need === 'gpu' && !realGPU) continue;
|
| 517 |
+
const card = el('button', 'model-card' + (activeModel === m.id ? ' active' : '') + (m.disabled ? ' disabled' : ''));
|
| 518 |
+
if (m.disabled) card.disabled = true;
|
| 519 |
+
card.innerHTML = `<b>${m.name}</b><span>${m.sub}</span>`;
|
| 520 |
+
card.onclick = () => { changeModel(m.id); renderSettings(); };
|
| 521 |
+
grid.appendChild(card);
|
| 522 |
+
}
|
| 523 |
+
box.appendChild(grid);
|
| 524 |
+
|
| 525 |
+
// --- Almacenamiento del modelo (caché persistente) ---
|
| 526 |
+
box.append(el('div', 'sk-h', 'Modelo descargado (se cachea en tu navegador)'));
|
| 527 |
+
const storeCard = el('div', 'prov-card');
|
| 528 |
+
const storeInfo = el('span', null, 'Calculando espacio…'); storeInfo.style.cssText = 'font-size:.8rem;color:var(--fg)';
|
| 529 |
+
const storeSub = el('div', 'field'); storeSub.style.marginTop = '2px';
|
| 530 |
+
const storeMuted = el('span', 'muted', ''); storeMuted.style.fontSize = '.72rem';
|
| 531 |
+
const clearBtn = el('button', 'prov-use', 'Vaciar caché');
|
| 532 |
+
clearBtn.style.marginTop = '8px';
|
| 533 |
+
clearBtn.onclick = async () => { clearBtn.textContent = 'Vaciando…'; await clearModelCache(); await paintStorage(); clearBtn.textContent = 'Vaciar caché'; };
|
| 534 |
+
const storeHead = el('div', 'prov-head'); storeHead.append(storeInfo);
|
| 535 |
+
storeCard.append(storeHead, storeMuted, clearBtn);
|
| 536 |
+
box.appendChild(storeCard);
|
| 537 |
+
async function paintStorage() {
|
| 538 |
+
const { usage, quota, persisted } = await cacheEstimate();
|
| 539 |
+
const gb = n => (n / 1073741824).toFixed(2) + ' GB';
|
| 540 |
+
storeInfo.textContent = usage ? `${gb(usage)} en caché` : 'Nada cacheado todavía';
|
| 541 |
+
storeMuted.textContent = (persisted ? '✓ almacenamiento persistente (no se borra solo)' : '⚠ sin persistencia: el navegador podría desalojarlo')
|
| 542 |
+
+ (quota ? ` · límite ~${gb(quota)}` : '');
|
| 543 |
+
}
|
| 544 |
+
paintStorage();
|
| 545 |
+
|
| 546 |
+
// --- Bridge local (ejecución REAL en tu máquina: node, npm, python…) ---
|
| 547 |
+
box.append(el('div', 'sk-h', '🔌 Bridge local (ejecución real en tu máquina)'));
|
| 548 |
+
const brCard = el('div', 'prov-card bridge-card');
|
| 549 |
+
const guessOS = () => {
|
| 550 |
+
const ua = navigator.userAgent;
|
| 551 |
+
if (/Mac/.test(ua)) return /Intel/.test(navigator.platform) && !/arm/i.test(ua) ? 'elffuss-bridge-mac-intel' : 'elffuss-bridge-mac-arm64';
|
| 552 |
+
if (/Win/.test(ua)) return 'elffuss-bridge-windows.exe';
|
| 553 |
+
if (/Linux/.test(ua)) return /aarch64|arm64/i.test(ua) ? 'elffuss-bridge-linux-arm64' : 'elffuss-bridge-linux';
|
| 554 |
+
return 'elffuss-bridge-mac-arm64';
|
| 555 |
+
};
|
| 556 |
+
const OTHER = { 'elffuss-bridge-mac-arm64': 'Mac (Apple Silicon)', 'elffuss-bridge-mac-intel': 'Mac (Intel)', 'elffuss-bridge-windows.exe': 'Windows', 'elffuss-bridge-linux': 'Linux (x64)', 'elffuss-bridge-linux-arm64': 'Linux (ARM)' };
|
| 557 |
+
const primary = guessOS();
|
| 558 |
+
brCard.innerHTML =
|
| 559 |
+
`<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>` +
|
| 560 |
+
`<p class="muted" style="font-size:.72rem;margin:6px 0">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.</p>` +
|
| 561 |
+
`<a class="prov-use" style="text-decoration:none;display:inline-block" href="https://elffuss-code.utopiaia.com/bridge-dl/${primary}" download>⬇ Descargar para ${OTHER[primary]}</a>` +
|
| 562 |
+
`<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">otro sistema operativo</summary>` +
|
| 563 |
+
Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="https://elffuss-code.utopiaia.com/bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
|
| 564 |
+
`</details>` +
|
| 565 |
+
`<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">Token (lo imprime el programa al arrancarlo)</label><input id="br-token" placeholder="pega aquí el token…"></div>` +
|
| 566 |
+
`<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">Carpeta de trabajo (opcional — si no, usa una temporal)</label><input id="br-folder" placeholder="/ruta/completa/a/tu/proyecto"></div>` +
|
| 567 |
+
`<button id="br-connect" class="prov-use" style="margin-top:8px">Conectar</button>`;
|
| 568 |
+
box.appendChild(brCard);
|
| 569 |
+
brCard.querySelector('#br-folder').value = bridge.getFolder();
|
| 570 |
+
brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
|
| 571 |
+
const paintBridge = () => {
|
| 572 |
+
// el panel puede haberse cerrado entre sondeos de fondo — si ya no está
|
| 573 |
+
// en el DOM, nos desuscribimos en vez de tocar nodos huérfanos.
|
| 574 |
+
if (!document.body.contains(brCard)) { bridge.onStatusChange(() => {}); return; }
|
| 575 |
+
const on = bridge.isConnected();
|
| 576 |
+
brCard.querySelector('#br-dot').className = 'dot ' + (on ? 'on' : 'off');
|
| 577 |
+
brCard.querySelector('#br-status').textContent = on ? '✓ conectado — ejecución real activa' : 'desconectado';
|
| 578 |
+
if (on) brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
|
| 579 |
+
};
|
| 580 |
+
paintBridge();
|
| 581 |
+
bridge.onStatusChange(paintBridge); // repinta solo si el bridge conecta/cae mientras Ajustes está abierto
|
| 582 |
+
brCard.querySelector('#br-connect').onclick = async () => {
|
| 583 |
+
const btn = brCard.querySelector('#br-connect');
|
| 584 |
+
bridge.setFolder(brCard.querySelector('#br-folder').value);
|
| 585 |
+
const token = brCard.querySelector('#br-token').value.trim();
|
| 586 |
+
if (!token) return showBridgeMsg(brCard, '⚠️ pega el token que imprimió el programa al arrancarlo', true);
|
| 587 |
+
btn.disabled = true; btn.textContent = 'Conectando…';
|
| 588 |
+
try { await bridge.connect(token); paintBridge(); showBridgeMsg(brCard, '✔ conectado — ejecución real en tu máquina'); fireworks(); }
|
| 589 |
+
catch (e) { showBridgeMsg(brCard, '⚠️ ' + e.message, true); }
|
| 590 |
+
finally { btn.disabled = false; btn.textContent = 'Conectar'; }
|
| 591 |
+
};
|
| 592 |
+
|
| 593 |
+
// --- Permisos de ejecución (mismo interruptor que </> Auto de la barra) ---
|
| 594 |
+
box.append(el('div', 'sk-h', '✅ Permisos de ejecución'));
|
| 595 |
+
const permCard = el('div', 'prov-card');
|
| 596 |
+
permCard.innerHTML =
|
| 597 |
+
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
| 598 |
+
`<input type="checkbox" id="perm-autoedit"> ` +
|
| 599 |
+
`<span>Ejecutar todo automáticamente (escribir/editar ficheros y comandos de terminal), sin pedir confirmación</span>` +
|
| 600 |
+
`</label>` +
|
| 601 |
+
`<p class="muted" style="font-size:.7rem;margin:6px 0 0">terminal.run ya se ejecuta siempre sin preguntar. Esto controla solo la escritura de ficheros (code.write/code.edit) — desmárcalo si prefieres revisar cada cambio antes de aplicarlo.</p>`;
|
| 602 |
+
box.appendChild(permCard);
|
| 603 |
+
const permCheckbox = permCard.querySelector('#perm-autoedit');
|
| 604 |
+
permCheckbox.checked = autoEdit;
|
| 605 |
+
permCheckbox.onchange = () => {
|
| 606 |
+
autoEdit = permCheckbox.checked;
|
| 607 |
+
localStorage.setItem('elffusscode.autoedit', autoEdit ? '1' : '0');
|
| 608 |
+
paintAuto();
|
| 609 |
+
};
|
| 610 |
+
|
| 611 |
+
// --- 🎯 Modo Objetivo (planificador + ejecutor, mismo patrón que Auto) ---
|
| 612 |
+
box.append(el('div', 'sk-h', '🎯 Modo Objetivo'));
|
| 613 |
+
const goalCard = el('div', 'prov-card');
|
| 614 |
+
goalCard.innerHTML =
|
| 615 |
+
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
| 616 |
+
`<input type="checkbox" id="perm-goalmode"> ` +
|
| 617 |
+
`<span>Tratar el próximo mensaje como un objetivo: lo descompone en tareas y las ejecuta una a una</span>` +
|
| 618 |
+
`</label>` +
|
| 619 |
+
`<p class="muted" style="font-size:.7rem;margin:6px 0 0">Igual que el botón 🎯 Goal de la barra — un planificador crea la lista de tareas y un ejecutor las va cumpliendo, marcando cada una como hecha o fallida (si una falla, las siguientes se saltan en vez de seguir a ciegas).</p>`;
|
| 620 |
+
box.appendChild(goalCard);
|
| 621 |
+
const goalCheckbox = goalCard.querySelector('#perm-goalmode');
|
| 622 |
+
goalCheckbox.checked = goalMode;
|
| 623 |
+
goalCheckbox.onchange = () => {
|
| 624 |
+
goalMode = goalCheckbox.checked;
|
| 625 |
+
localStorage.setItem('elffusscode.goalmode', goalMode ? '1' : '0');
|
| 626 |
+
paintGoal();
|
| 627 |
+
};
|
| 628 |
+
|
| 629 |
+
// --- 📨 Errores y feedback (opt-in — apagado no sale NADA de tu máquina) ---
|
| 630 |
+
box.append(el('div', 'sk-h', '📨 Errores y feedback'));
|
| 631 |
+
const telCard = el('div', 'prov-card');
|
| 632 |
+
telCard.innerHTML =
|
| 633 |
+
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
|
| 634 |
+
`<input type="checkbox" id="tel-enabled"> ` +
|
| 635 |
+
`<span>Enviar automáticamente los errores técnicos que ocurran, para poder arreglarlos</span>` +
|
| 636 |
+
`</label>` +
|
| 637 |
+
`<p class="muted" style="font-size:.7rem;margin:6px 0 10px">Apagado por defecto — tu código y el contenido de tus proyectos NUNCA se incluyen, solo el mensaje de error técnico, la pila y datos del navegador.</p>` +
|
| 638 |
+
`<label class="muted" style="font-size:.68rem">O manda algo tú directamente (un fallo que viste, algo que eches en falta…)</label>` +
|
| 639 |
+
`<textarea id="tel-feedback" rows="2" style="width:100%;margin-top:4px;resize:vertical;background:var(--bg2);border:1px solid var(--line);border-radius:8px;color:var(--fg);padding:6px 8px;font:inherit" placeholder="Cuéntanos qué ha pasado o qué te gustaría que hiciera…"></textarea>` +
|
| 640 |
+
`<button id="tel-send" class="prov-use" style="margin-top:6px">Enviar</button>` +
|
| 641 |
+
`<span id="tel-msg" class="muted" style="font-size:.7rem;margin-left:8px"></span>`;
|
| 642 |
+
box.appendChild(telCard);
|
| 643 |
+
const telCheckbox = telCard.querySelector('#tel-enabled');
|
| 644 |
+
telCheckbox.checked = telemetry.isEnabled();
|
| 645 |
+
telCheckbox.onchange = () => telemetry.setEnabled(telCheckbox.checked);
|
| 646 |
+
telCard.querySelector('#tel-send').onclick = async () => {
|
| 647 |
+
const ta = telCard.querySelector('#tel-feedback');
|
| 648 |
+
const msg = telCard.querySelector('#tel-msg');
|
| 649 |
+
const text = ta.value.trim();
|
| 650 |
+
if (!text) { msg.textContent = 'escribe algo primero'; return; }
|
| 651 |
+
const wasEnabled = telemetry.isEnabled();
|
| 652 |
+
if (!wasEnabled) telemetry.setEnabled(true); // el envío manual es explícito, se permite aunque el automático esté apagado
|
| 653 |
+
await telemetry.sendFeedback(text);
|
| 654 |
+
if (!wasEnabled) telemetry.setEnabled(false); // no activa el automático de fondo si no lo pidió
|
| 655 |
+
ta.value = '';
|
| 656 |
+
msg.textContent = '¡enviado, gracias!';
|
| 657 |
+
setTimeout(() => { msg.textContent = ''; }, 4000);
|
| 658 |
+
};
|
| 659 |
+
|
| 660 |
+
// --- Proveedores externos (API keys) ---
|
| 661 |
+
box.append(el('div', 'sk-h', 'Proveedores externos (opcional · la clave se queda en tu navegador)'));
|
| 662 |
+
for (const [id, c] of Object.entries(settings.configs())) {
|
| 663 |
+
const card = el('div', 'prov-card' + (c.enabled ? ' on' : ''));
|
| 664 |
+
const head = el('div', 'prov-head');
|
| 665 |
+
const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = c.enabled;
|
| 666 |
+
const title = el('b', null, c.label);
|
| 667 |
+
const use = el('button', 'prov-use', 'Usar');
|
| 668 |
+
use.hidden = !c.enabled || activeModel === 'ext:' + id;
|
| 669 |
+
use.onclick = () => { changeModel('ext:' + id); renderSettings(); };
|
| 670 |
+
head.append(toggle, title, use);
|
| 671 |
+
const fields = el('div', 'prov-fields' + (c.enabled ? '' : ' hide'));
|
| 672 |
+
const model = document.createElement('input'); model.value = c.model || ''; model.placeholder = 'modelo (p. ej. gpt-4o-mini)';
|
| 673 |
+
const key = document.createElement('input'); key.type = 'password'; key.value = c.apiKey || '';
|
| 674 |
+
key.placeholder = c.kind === 'anthropic' ? 'sk-ant-…' : (id === 'ollama' || id === 'server' ? 'clave (no necesaria)' : 'sk-…');
|
| 675 |
+
fields.append(labeled('Modelo', model), labeled('API key', key));
|
| 676 |
+
const save = () => {
|
| 677 |
+
settings.update(id, { enabled: toggle.checked, model: model.value.trim(), apiKey: key.value });
|
| 678 |
+
rebuildSelect();
|
| 679 |
+
};
|
| 680 |
+
toggle.onchange = () => { save(); renderSettings(); };
|
| 681 |
+
model.onchange = key.onchange = save;
|
| 682 |
+
card.append(head, fields);
|
| 683 |
+
box.appendChild(card);
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
// --- Skills ---
|
| 687 |
+
box.append(el('div', 'sk-h', 'Skills'));
|
| 688 |
+
const skBtn = el('button', 'primary wide', '🧩 Gestionar skills de Claude Code');
|
| 689 |
+
skBtn.textContent = 'Gestionar skills de Claude Code';
|
| 690 |
+
skBtn.onclick = openSkillsPanel;
|
| 691 |
+
box.appendChild(skBtn);
|
| 692 |
+
}
|
| 693 |
+
function labeled(label, input) {
|
| 694 |
+
const w = el('label', 'field'); w.append(el('span', 'muted', label), input); return w;
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
// ---------- arranque ----------
|
| 698 |
+
let galaxy = null;
|
| 699 |
+
async function enterIDE(handle) {
|
| 700 |
+
const name = await codeTools.openProject(handle);
|
| 701 |
+
shell.reset(); // el terminal arranca en la raíz del proyecto
|
| 702 |
+
galaxy?.stop();
|
| 703 |
+
$('landing').hidden = true;
|
| 704 |
+
$('ide').hidden = false;
|
| 705 |
+
$('project-name').textContent = name;
|
| 706 |
+
$('explorer-proj').textContent = name.toUpperCase();
|
| 707 |
+
await initEditor();
|
| 708 |
+
await refreshTree(handle);
|
| 709 |
+
// el sistema de conversaciones primero: renderActiveLog() REEMPLAZA todo
|
| 710 |
+
// #chat-log con el historial replay de la conversación activa — si el
|
| 711 |
+
// saludo se añadiera antes, desaparecería sin dejar rastro.
|
| 712 |
+
await conv.init({ onEvent: onConvEvent });
|
| 713 |
+
renderTabsBar();
|
| 714 |
+
renderActiveLog();
|
| 715 |
+
addMsg('sys', `Привіт 👋 Proyecto «${name}» abierto. Pregúntame por el código, pídeme cambios o dime «árbol». Todo se queda en tu máquina.`);
|
| 716 |
+
refreshGit();
|
| 717 |
+
preloadModel(); // por si el arranque en la landing no llegó a dispararse
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
async function boot() {
|
| 721 |
+
// Caché de modelos (persistente + service worker) ANTES de descargar nada,
|
| 722 |
+
// para que hasta la primera descarga de pesos quede cacheada y no se repita.
|
| 723 |
+
ensureModelCache().finally(() => preloadModel());
|
| 724 |
+
bridge.tryAutoConnect(); // reconecta en silencio si ya se conectó antes (mismo token)
|
| 725 |
+
bridge.startAutoDetect(); // sigue sondeando en 2º plano: detecta el bridge si se arranca/reinicia más tarde
|
| 726 |
+
// galaxia en la landing (misma técnica que Elffuss)
|
| 727 |
+
import('./splash-gl.js').then(m => { galaxy = m.startGalaxy($('landing')); }).catch(() => {});
|
| 728 |
+
|
| 729 |
+
$('open-project').onclick = async () => {
|
| 730 |
+
try { await enterIDE(await window.showDirectoryPicker({ mode: 'readwrite' })); }
|
| 731 |
+
catch { /* cancelado */ }
|
| 732 |
+
};
|
| 733 |
+
|
| 734 |
+
// clonar un repo público (GitHub/Bitbucket): elige carpeta destino, se
|
| 735 |
+
// descarga ahí (fichero a fichero, sin git) y se abre como proyecto.
|
| 736 |
+
// RESUMIBLE: el trabajo (lista de ficheros + cuáles ya se bajaron + el
|
| 737 |
+
// handle de la carpeta) se persiste en IndexedDB — si el usuario navega
|
| 738 |
+
// atrás, cierra la pestaña o recarga a medias, al volver se ofrece
|
| 739 |
+
// «Reanudar» en vez de forzar a empezar de cero.
|
| 740 |
+
const cloneUrl = $('clone-url'), cloneBtn = $('clone-btn'), cloneStatus = $('clone-status');
|
| 741 |
+
const showCloneStatus = (text, isErr) => { cloneStatus.textContent = text; cloneStatus.hidden = !text; cloneStatus.classList.toggle('err', !!isErr); };
|
| 742 |
+
const saveCloneJob = job => db.set('kv', 'cloneJob', job).catch(() => {});
|
| 743 |
+
const loadCloneJob = () => db.get('kv', 'cloneJob').catch(() => null);
|
| 744 |
+
const clearCloneJob = () => db.del('kv', 'cloneJob').catch(() => {});
|
| 745 |
+
|
| 746 |
+
async function runDownload(job) {
|
| 747 |
+
cloneBtn.disabled = true;
|
| 748 |
+
const doneSet = new Set(job.done);
|
| 749 |
+
const clone = await import('./tools/clone.js');
|
| 750 |
+
try {
|
| 751 |
+
await clone.downloadFiles(job, job.handle, doneSet,
|
| 752 |
+
p => showCloneStatus(p.text || ''),
|
| 753 |
+
async path => {
|
| 754 |
+
job.done.push(path);
|
| 755 |
+
// persistir el progreso poco a poco (no en cada fichero: de sobra
|
| 756 |
+
// para no perder más de un puñado si se interrumpe a media descarga)
|
| 757 |
+
if (job.done.length % 5 === 0) await saveCloneJob(job);
|
| 758 |
+
});
|
| 759 |
+
await saveCloneJob(job);
|
| 760 |
+
showCloneStatus(`✔ ${job.files.length} ficheros descargados` + (job.skipped ? ` (${job.skipped} omitidos por tamaño)` : ''));
|
| 761 |
+
await clearCloneJob();
|
| 762 |
+
await enterIDE(job.handle);
|
| 763 |
+
} catch (e) {
|
| 764 |
+
await saveCloneJob(job); // que lo ya bajado no se pierda aunque falle a mitad
|
| 765 |
+
showCloneStatus('⚠️ ' + (e?.message || e) + ' — puedes reintentar, retoma donde se quedó', true);
|
| 766 |
+
} finally { cloneBtn.disabled = false; }
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
const runClone = async () => {
|
| 770 |
+
const url = cloneUrl.value.trim();
|
| 771 |
+
if (!url) return;
|
| 772 |
+
let handle;
|
| 773 |
+
try { handle = await window.showDirectoryPicker({ mode: 'readwrite' }); }
|
| 774 |
+
catch { return; } // cancelado, sin error
|
| 775 |
+
showCloneStatus('Listando el repo…');
|
| 776 |
+
try {
|
| 777 |
+
const clone = await import('./tools/clone.js');
|
| 778 |
+
const info = await clone.listRepo(url);
|
| 779 |
+
const job = { url, handle, done: [], ...info };
|
| 780 |
+
await saveCloneJob(job);
|
| 781 |
+
await runDownload(job);
|
| 782 |
+
} catch (e) {
|
| 783 |
+
showCloneStatus('⚠️ ' + (e?.message || e), true);
|
| 784 |
+
}
|
| 785 |
+
};
|
| 786 |
+
cloneBtn.addEventListener('click', runClone);
|
| 787 |
+
cloneUrl.addEventListener('keydown', e => { if (e.key === 'Enter') runClone(); });
|
| 788 |
+
|
| 789 |
+
// ¿había una descarga a medias de una sesión anterior? ofrece reanudarla
|
| 790 |
+
const job = await loadCloneJob();
|
| 791 |
+
if (job && job.handle && job.files && job.done.length < job.files.length) {
|
| 792 |
+
const banner = $('resume-clone');
|
| 793 |
+
banner.hidden = false;
|
| 794 |
+
banner.querySelector('.rc-text').textContent = `Descarga a medias de ${job.owner}/${job.repo} (${job.done.length}/${job.files.length} ficheros)`;
|
| 795 |
+
$('resume-clone-btn').onclick = async () => {
|
| 796 |
+
banner.hidden = true;
|
| 797 |
+
try {
|
| 798 |
+
const q = job.handle.queryPermission ? await job.handle.queryPermission({ mode: 'readwrite' }) : 'granted';
|
| 799 |
+
if (q !== 'granted' && await job.handle.requestPermission({ mode: 'readwrite' }) !== 'granted') throw new Error('permiso denegado para la carpeta');
|
| 800 |
+
showCloneStatus(`Reanudando ${job.owner}/${job.repo}…`);
|
| 801 |
+
await runDownload(job);
|
| 802 |
+
} catch (e) { showCloneStatus('⚠️ ' + (e?.message || e), true); }
|
| 803 |
+
};
|
| 804 |
+
$('discard-clone-btn').onclick = async () => { await clearCloneJob(); banner.hidden = true; };
|
| 805 |
+
} else if (job) {
|
| 806 |
+
await clearCloneJob(); // job corrupto o ya completo: limpiar
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
// gancho de test: ?test-opfs usa el almacenamiento del navegador como proyecto
|
| 810 |
+
if (location.search.includes('test-opfs')) {
|
| 811 |
+
const opfs = await navigator.storage.getDirectory();
|
| 812 |
+
return enterIDE(opfs);
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
// reabrir el último proyecto (un clic si el navegador exige gesto)
|
| 816 |
+
const prev = await codeTools.restoreProject();
|
| 817 |
+
if (prev?.ready) return enterIDE(await db.get('kv', 'project'));
|
| 818 |
+
if (prev?.handle) {
|
| 819 |
+
const btn = $('reopen-project');
|
| 820 |
+
btn.hidden = false;
|
| 821 |
+
btn.textContent = `Reabrir «${prev.name}»`;
|
| 822 |
+
btn.onclick = async () => {
|
| 823 |
+
try { await codeTools.regrant(prev.handle); await enterIDE(prev.handle); }
|
| 824 |
+
catch (e) { $('statusbar').textContent = '⚠️ ' + e.message; }
|
| 825 |
+
};
|
| 826 |
+
}
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
// Historial del prompt del chat: ↑/↓ recuperan lo enviado (como una shell).
|
| 830 |
+
// Persistente entre recargas (localStorage, últimos 100).
|
| 831 |
+
const PROMPT_HIST_KEY = 'elffusscode.promptHistory';
|
| 832 |
+
let promptHistory = [];
|
| 833 |
+
try { promptHistory = JSON.parse(localStorage.getItem(PROMPT_HIST_KEY) || '[]'); } catch { /* corrupto */ }
|
| 834 |
+
let promptHistIdx = -1, promptDraft = '';
|
| 835 |
+
function pushPromptHistory(text) {
|
| 836 |
+
if (promptHistory[promptHistory.length - 1] !== text) promptHistory.push(text);
|
| 837 |
+
if (promptHistory.length > 100) promptHistory = promptHistory.slice(-100);
|
| 838 |
+
try { localStorage.setItem(PROMPT_HIST_KEY, JSON.stringify(promptHistory)); } catch { /* lleno */ }
|
| 839 |
+
promptHistIdx = -1; promptDraft = '';
|
| 840 |
+
}
|
| 841 |
+
$('prompt').addEventListener('keydown', e => {
|
| 842 |
+
if (!$('menu').hidden) return; // el menú «/» ya usa ↑/↓
|
| 843 |
+
const inp = e.target;
|
| 844 |
+
if (e.key === 'ArrowUp') {
|
| 845 |
+
if (!promptHistory.length) return;
|
| 846 |
+
if (promptHistIdx === -1) { promptDraft = inp.value; promptHistIdx = promptHistory.length; }
|
| 847 |
+
promptHistIdx = Math.max(0, promptHistIdx - 1);
|
| 848 |
+
inp.value = promptHistory[promptHistIdx];
|
| 849 |
+
e.preventDefault();
|
| 850 |
+
requestAnimationFrame(() => inp.setSelectionRange(inp.value.length, inp.value.length));
|
| 851 |
+
} else if (e.key === 'ArrowDown') {
|
| 852 |
+
if (promptHistIdx === -1) return;
|
| 853 |
+
promptHistIdx++;
|
| 854 |
+
inp.value = promptHistIdx >= promptHistory.length ? (promptHistIdx = -1, promptDraft) : promptHistory[promptHistIdx];
|
| 855 |
+
e.preventDefault();
|
| 856 |
+
} else if (e.key === 'Enter' && !e.isComposing && !e.shiftKey) {
|
| 857 |
+
// envío explícito: no depender del submit-on-Enter nativo del <form>,
|
| 858 |
+
// que algún navegador puede "tragarse" el primer Enter (autocompletar/
|
| 859 |
+
// sugerencias) justo después de rellenar el valor por código (↑ del
|
| 860 |
+
// historial) en vez de por tecleo real.
|
| 861 |
+
e.preventDefault();
|
| 862 |
+
$('composer').requestSubmit();
|
| 863 |
+
}
|
| 864 |
+
});
|
| 865 |
+
$('composer').addEventListener('submit', e => {
|
| 866 |
+
e.preventDefault();
|
| 867 |
+
const text = $('prompt').value.trim();
|
| 868 |
+
if (!text) return;
|
| 869 |
+
$('prompt').value = '';
|
| 870 |
+
pushPromptHistory(text);
|
| 871 |
+
send(text);
|
| 872 |
+
});
|
| 873 |
+
$('model-select').addEventListener('change', e => changeModel(e.target.value));
|
| 874 |
+
// móvil: conmutar chat ↔ editor
|
| 875 |
+
$('code-flip').addEventListener('click', () => {
|
| 876 |
+
const showEditor = !document.body.classList.contains('show-editor');
|
| 877 |
+
document.body.classList.toggle('show-editor', showEditor);
|
| 878 |
+
paintFlip(showEditor);
|
| 879 |
+
});
|
| 880 |
+
|
| 881 |
+
// vistas Arquitectura (grafo) y Ciudad 3D — leen el proyecto abierto y al
|
| 882 |
+
// hacer clic abren el fichero en Monaco (inspirado en CodeFlow + VibeCodeViewer)
|
| 883 |
+
$('act-arch').innerHTML = UI.graph || UI.code;
|
| 884 |
+
$('act-city').innerHTML = UI.city || UI.code;
|
| 885 |
+
let viewRenderSeq = 0;
|
| 886 |
+
async function openView(kind) {
|
| 887 |
+
const overlay = $('view-overlay'), body = $('view-body');
|
| 888 |
+
const seq = ++viewRenderSeq;
|
| 889 |
+
// destruir CUALQUIER vista previa (su RAF/canvas seguía vivo al cambiar → la
|
| 890 |
+
// nueva no cargaba bien). Ambas, no solo la del mismo tipo.
|
| 891 |
+
try { (await import('./city.js')).disposeCity(); } catch { /* */ }
|
| 892 |
+
try { (await import('./arch.js')).disposeArch(); } catch { /* */ }
|
| 893 |
+
body.innerHTML = '<div class="view-loading">Construyendo…</div>';
|
| 894 |
+
overlay.hidden = false;
|
| 895 |
+
document.querySelectorAll('#activity button').forEach(b => b.classList.toggle('on', b.id === 'act-' + kind));
|
| 896 |
+
// esperar a que el layout dé tamaño real al overlay (si no, canvas 0×0/mal aspect)
|
| 897 |
+
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
| 898 |
+
if (seq !== viewRenderSeq) return; // el usuario cambió de vista mientras tanto
|
| 899 |
+
try {
|
| 900 |
+
if (kind === 'arch') { const m = await import('./arch.js'); await m.renderArchitecture(body, p => { closeView(); openFile(p); }); }
|
| 901 |
+
else { const m = await import('./city.js'); await m.renderCity(body, p => { closeView(); openFile(p); }); }
|
| 902 |
+
} catch (e) { body.innerHTML = '<div class="view-loading">No pude construir la vista: ' + (e?.message || e) + '</div>'; }
|
| 903 |
+
}
|
| 904 |
+
function closeView() {
|
| 905 |
+
$('view-overlay').hidden = true;
|
| 906 |
+
import('./city.js').then(m => m.disposeCity()).catch(() => {});
|
| 907 |
+
import('./arch.js').then(m => m.disposeArch()).catch(() => {});
|
| 908 |
+
document.querySelectorAll('#activity button').forEach(b => b.classList.toggle('on', b.id === 'act-files'));
|
| 909 |
+
}
|
| 910 |
+
$('act-arch').addEventListener('click', () => openView('arch'));
|
| 911 |
+
$('act-city').addEventListener('click', () => openView('city'));
|
| 912 |
+
$('view-close').addEventListener('click', closeView);
|
| 913 |
+
|
| 914 |
+
// ── Cerebro CEO autónomo + Mente de Elffuss ───────────────────────────────
|
| 915 |
+
// Cuando NO estás pidiendo nada, la elfa trabaja: revisa el proyecto y propone
|
| 916 |
+
// mejoras (en elffuss-mind/, sin tocar tu código). Clic en la elfa → abre la
|
| 917 |
+
// «Mente» (mundo trance + pensamientos paralelos + música) y activa el cerebro.
|
| 918 |
+
mind.setOpenFile(openFile);
|
| 919 |
+
mind.init({ buildCity: buildCityAdapter, loadThoughts: loadThoughtsAdapter });
|
| 920 |
+
// «Ejecutar esta propuesta»: la manda a la MISMA cola/agente del chat normal
|
| 921 |
+
// (mismas herramientas reales, mismo gate de Auto-edit) — no un auto-apply
|
| 922 |
+
// aparte sin supervisión.
|
| 923 |
+
mind.setExecuteProposal(md => { mind.closeMind(); send('Implementa esta propuesta de mejora con cambios reales en el código:\n\n' + md); });
|
| 924 |
+
// «perfiles»: cada uno es una línea de pensamiento paralela (foco + color de
|
| 925 |
+
// su estrella) del cerebro CEO (compartido, core/ceo.js). El usuario los
|
| 926 |
+
// edita/crea/borra desde ⚙ en la Mente.
|
| 927 |
+
const CEO_DEFAULT_PROFILES = [
|
| 928 |
+
{ id: 'arq', name: 'Arquitectura', focus: 'estructura, acoplamiento y dependencias; qué módulo conviene dividir o unificar', color: '#7c5cff' },
|
| 929 |
+
{ id: 'cal', name: 'Calidad', focus: 'bugs latentes, casos borde sin cubrir, validaciones que faltan', color: '#49e8ff' },
|
| 930 |
+
{ id: 'rend', name: 'Rendimiento', focus: 'cuellos de botella, trabajo repetido, estructuras de datos mejorables', color: '#ffb454' },
|
| 931 |
+
{ id: 'dx', name: 'Producto/DX', focus: 'legibilidad, nombres, documentación y ergonomía para quien lo usa', color: '#3fb970' },
|
| 932 |
+
];
|
| 933 |
+
// adaptador de workspace para el cerebro CEO genérico (core/ceo.js): traduce
|
| 934 |
+
// sus operaciones a las herramientas de code.js (proyecto de código único).
|
| 935 |
+
async function ceoDirHandle(dirPath) {
|
| 936 |
+
let dir = codeTools.handle();
|
| 937 |
+
for (const seg of dirPath.split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(seg, { create: true });
|
| 938 |
+
return dir;
|
| 939 |
+
}
|
| 940 |
+
const ceoWorkspace = {
|
| 941 |
+
isReady: () => !!codeTools.handle(),
|
| 942 |
+
tree: (opts) => codeTools.tree(opts),
|
| 943 |
+
write: (opts) => codeTools.write(opts),
|
| 944 |
+
read: (opts) => codeTools.read(opts),
|
| 945 |
+
list: async (dirPath) => {
|
| 946 |
+
const dir = await ceoDirHandle(dirPath);
|
| 947 |
+
const names = [];
|
| 948 |
+
for await (const e of dir.values()) if (e.kind === 'file') names.push(e.name);
|
| 949 |
+
return names;
|
| 950 |
+
},
|
| 951 |
+
remove: async (dirPath, name) => (await ceoDirHandle(dirPath)).removeEntry(name),
|
| 952 |
+
};
|
| 953 |
+
ceo.init({
|
| 954 |
+
namespace: 'elffusscode',
|
| 955 |
+
workspace: ceoWorkspace,
|
| 956 |
+
defaultProfiles: CEO_DEFAULT_PROFILES,
|
| 957 |
+
defaultMission: 'Revisar el proyecto y proponer mejoras concretas y accionables (código, procesos, datos o docs).',
|
| 958 |
+
provider: () => conv.getProvider(),
|
| 959 |
+
// el usuario tiene PRIORIDAD: si CUALQUIER conversación está en cola o procesándose, el cerebro espera
|
| 960 |
+
isBusy: () => conv.isBusy(),
|
| 961 |
+
onEvent: (ch, ev) => {
|
| 962 |
+
mind.pushThought(ch, ev);
|
| 963 |
+
if (ch === 'ceo' && ev.type === 'built') reportImprovements(ev);
|
| 964 |
+
// la elfa se anima mientras el cerebro trabaja (invita a hacer clic para ver la Mente)
|
| 965 |
+
const av = document.querySelector('#activity img');
|
| 966 |
+
if (av) {
|
| 967 |
+
if (ch === 'ceo' && ev.type === 'cycle') av.classList.add('working');
|
| 968 |
+
if (ch === 'ceo' && (ev.type === 'built' || ev.type === 'paused')) av.classList.remove('working');
|
| 969 |
+
}
|
| 970 |
+
},
|
| 971 |
+
});
|
| 972 |
+
// Reporta las mejoras SIEMPRE en el chat (visual, entendible); la notificación
|
| 973 |
+
// del navegador solo salta si de verdad hay algo interesante y bien trabajado
|
| 974 |
+
// — nada de avisar por un ciclo flojo con una propuesta de una línea.
|
| 975 |
+
const WEAK_RE = /^(no (encontr|hay|se me ocurre)|nada que|sin cambios|todo (está|esta) bien|no aplica)/i;
|
| 976 |
+
export function isNoteworthy(ev) {
|
| 977 |
+
const solid = (ev.proposals || []).filter(p => p.text && p.text.trim().length >= 60 && !WEAK_RE.test(p.text.trim()));
|
| 978 |
+
return solid.length >= 2; // al menos 2 departamentos con algo sustancioso, no una ocurrencia suelta
|
| 979 |
+
}
|
| 980 |
+
// manda una propuesta (el .md completo del ciclo) a la MISMA cola/agente real
|
| 981 |
+
// del chat — usado tanto por el botón del chat como por el de la notificación.
|
| 982 |
+
function executeProposal(md) { send('Implementa esta propuesta de mejora con cambios reales en el código:\n\n' + md); }
|
| 983 |
+
|
| 984 |
+
export function reportImprovements(ev) {
|
| 985 |
+
const props = ev.proposals || [];
|
| 986 |
+
if (!props.length) return;
|
| 987 |
+
const firstLine = t => (t || '').split('\n').find(l => l.trim())?.trim().slice(0, 160) || '';
|
| 988 |
+
const body = `💡 **Mientras estabas fuera revisé el proyecto y encontré ${props.length} mejora${props.length === 1 ? '' : 's'}:**\n\n` +
|
| 989 |
+
props.map(p => `**${p.dept}** — ${firstLine(p.text)}`).join('\n\n') +
|
| 990 |
+
(ev.path ? `\n\n_Guardado en \`${ev.path}\`. Dale a la elfa para verlo en la Mente, o ábrelo en el editor._` : '');
|
| 991 |
+
const div = addMsg('assistant', body);
|
| 992 |
+
// se guarda en el historial REAL de la conversación activa — si no, el
|
| 993 |
+
// aviso vive solo en el DOM y desaparece al cambiar de pestaña o recargar
|
| 994 |
+
const active = conv.getActive();
|
| 995 |
+
if (active) conv.appendMessage(active.id, 'assistant', body);
|
| 996 |
+
// botón REAL para ejecutar la propuesta desde el propio chat (antes solo
|
| 997 |
+
// se podía desde el panel de la Mente, había que ir a buscarlo)
|
| 998 |
+
const md = ev.md || body;
|
| 999 |
+
if (div) {
|
| 1000 |
+
const btn = document.createElement('button');
|
| 1001 |
+
btn.className = 'proposal-exec-btn';
|
| 1002 |
+
btn.textContent = '▶ Ejecutar esta propuesta';
|
| 1003 |
+
btn.onclick = () => { btn.disabled = true; btn.textContent = '▶ enviado a la cola…'; executeProposal(md); };
|
| 1004 |
+
div.appendChild(btn);
|
| 1005 |
+
}
|
| 1006 |
+
if (isNoteworthy(ev)) notify(`Elffuss encontró ${props.length} mejora${props.length === 1 ? '' : 's'}`, props.map(p => p.dept).join(' · '), md);
|
| 1007 |
+
}
|
| 1008 |
+
// SOLO notifica si el permiso YA está concedido. Nunca pide permiso aquí:
|
| 1009 |
+
// fuera de un gesto de usuario, Chrome/Firefox lo bloquean en silencio —
|
| 1010 |
+
// pedirlo vive únicamente en el clic de la elfa y en ⚙ de la Mente.
|
| 1011 |
+
// Vía el service worker (registration.showNotification) para poder incluir
|
| 1012 |
+
// un botón «▶ Ejecutar» de verdad en la propia notificación del sistema
|
| 1013 |
+
// (new Notification() normal no soporta acciones); si el SW no está listo,
|
| 1014 |
+
// cae a la notificación simple sin botón.
|
| 1015 |
+
async function notify(title, body, md) {
|
| 1016 |
+
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
| 1017 |
+
try {
|
| 1018 |
+
if ('serviceWorker' in navigator) {
|
| 1019 |
+
const reg = await navigator.serviceWorker.ready;
|
| 1020 |
+
if (reg.showNotification) {
|
| 1021 |
+
await reg.showNotification(title, {
|
| 1022 |
+
body, icon: 'img/elffuss-code.svg',
|
| 1023 |
+
actions: [{ action: 'execute', title: '▶ Ejecutar' }, { action: 'open', title: 'Ver en la Mente' }],
|
| 1024 |
+
data: { md },
|
| 1025 |
+
});
|
| 1026 |
+
return;
|
| 1027 |
+
}
|
| 1028 |
+
}
|
| 1029 |
+
} catch { /* cae a la notificación simple */ }
|
| 1030 |
+
try { new Notification(title, { body, icon: 'img/elffuss-code.svg' }); } catch { /* */ }
|
| 1031 |
+
}
|
| 1032 |
+
// clic en el botón de la notificación del sistema → lo mismo que en el chat
|
| 1033 |
+
if ('serviceWorker' in navigator) {
|
| 1034 |
+
navigator.serviceWorker.addEventListener('message', e => {
|
| 1035 |
+
if (e.data?.type !== 'notif-action') return;
|
| 1036 |
+
if (e.data.action === 'execute' && e.data.md) executeProposal(e.data.md);
|
| 1037 |
+
else mind.openMind();
|
| 1038 |
+
});
|
| 1039 |
+
}
|
| 1040 |
+
// cualquier interacción FUERA de la Mente cuenta como actividad → pausa el CEO
|
| 1041 |
+
const noteAct = e => { if (!e.target.closest?.('#mind-overlay')) ceo.noteActivity(); };
|
| 1042 |
+
document.addEventListener('pointerdown', noteAct, true);
|
| 1043 |
+
document.addEventListener('keydown', noteAct, true);
|
| 1044 |
+
const elfAvatar = document.querySelector('#activity img');
|
| 1045 |
+
if (elfAvatar) {
|
| 1046 |
+
elfAvatar.style.cursor = 'pointer';
|
| 1047 |
+
elfAvatar.title = 'Mente de Elffuss — cerebro autónomo';
|
| 1048 |
+
elfAvatar.addEventListener('click', () => {
|
| 1049 |
+
// auto-activa SOLO la primera vez (nunca decidiste play/stop); si lo
|
| 1050 |
+
// pausaste a propósito, abrir la Mente NO debe reactivarlo por sorpresa.
|
| 1051 |
+
if (!ceo.isEnabled() && !ceo.hasDecided()) ceo.enable();
|
| 1052 |
+
try { if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission(); } catch { /* */ }
|
| 1053 |
+
mind.openMind();
|
| 1054 |
+
});
|
| 1055 |
+
}
|
| 1056 |
+
// restaura tu última elección (play o stop) tal cual la dejaste
|
| 1057 |
+
if (ceo.wasEnabledLastSession()) ceo.enable();
|
| 1058 |
+
|
| 1059 |
+
// terminal integrado: xterm.js + shell sobre los ficheros REALES del proyecto.
|
| 1060 |
+
// La elfa comparte este shell (tool terminal.run) y su salida se refleja aquí.
|
| 1061 |
+
$('act-term').innerHTML = UI.terminal;
|
| 1062 |
+
shell.setHooks({ fsChange: () => refreshTree().catch(() => {}), openFile });
|
| 1063 |
+
setTerminalEcho((cmd, out) => terminal.echoAgent(cmd, out));
|
| 1064 |
+
async function toggleTerminal(force) {
|
| 1065 |
+
const show = force != null ? force : $('terminal-panel').hidden;
|
| 1066 |
+
$('terminal-panel').hidden = !show;
|
| 1067 |
+
$('act-term').classList.toggle('on', show);
|
| 1068 |
+
if (show) {
|
| 1069 |
+
const cap = shell.capabilities();
|
| 1070 |
+
$('term-caps').textContent = cap.bridge ? '🔌 Bridge local: node/npm/python reales' : 'shell del proyecto · node/npm/python → Bridge local (⚙ Ajustes)';
|
| 1071 |
+
await terminal.mount($('terminal-host'));
|
| 1072 |
+
terminal.refit();
|
| 1073 |
+
}
|
| 1074 |
+
}
|
| 1075 |
+
$('act-term').addEventListener('click', () => toggleTerminal());
|
| 1076 |
+
$('term-close').addEventListener('click', () => toggleTerminal(false));
|
| 1077 |
+
$('term-clear').addEventListener('click', () => terminal.clearScreen());
|
| 1078 |
+
// Ctrl+` (o Cmd+`) alterna el terminal, como en VS Code
|
| 1079 |
+
document.addEventListener('keydown', e => {
|
| 1080 |
+
if ((e.ctrlKey || e.metaKey) && e.key === '`') { e.preventDefault(); toggleTerminal(); }
|
| 1081 |
+
});
|
| 1082 |
+
// redimensionar arrastrando la barra superior del panel
|
| 1083 |
+
(() => {
|
| 1084 |
+
const panel = $('terminal-panel'), handle = $('term-resize');
|
| 1085 |
+
let sy = 0, sh = 0;
|
| 1086 |
+
const move = e => { const h = Math.max(120, Math.min(window.innerHeight - 160, sh + (sy - e.clientY))); panel.style.height = h + 'px'; terminal.refit(); };
|
| 1087 |
+
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
|
| 1088 |
+
handle.addEventListener('mousedown', e => { sy = e.clientY; sh = panel.offsetHeight; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); e.preventDefault(); });
|
| 1089 |
+
})();
|
| 1090 |
+
window.addEventListener('resize', () => terminal.refit());
|
| 1091 |
+
|
| 1092 |
+
// iconos VS Code (sin emojis) en cabecera, composer y flip
|
| 1093 |
+
$('btn-history').innerHTML = UI.history;
|
| 1094 |
+
$('btn-settings').innerHTML = UI.gear;
|
| 1095 |
+
$('btn-send').innerHTML = UI.send;
|
| 1096 |
+
$('btn-plus').innerHTML = UI.add;
|
| 1097 |
+
$('btn-slash').innerHTML = UI.slash;
|
| 1098 |
+
const paintFlip = editor => { $('code-flip').innerHTML = (editor ? UI.chat : UI.editor) + `<span>${editor ? 'Chat' : 'Editor'}</span>`; };
|
| 1099 |
+
paintFlip(false);
|
| 1100 |
+
|
| 1101 |
+
// barra de actividad estilo VS Code
|
| 1102 |
+
$('act-files').innerHTML = ACTIVITY.files;
|
| 1103 |
+
$('act-search').innerHTML = ACTIVITY.search;
|
| 1104 |
+
$('act-settings').innerHTML = ACTIVITY.gear;
|
| 1105 |
+
$('act-files').addEventListener('click', () => {
|
| 1106 |
+
document.body.classList.toggle('hide-tree');
|
| 1107 |
+
$('act-files').classList.toggle('on');
|
| 1108 |
+
});
|
| 1109 |
+
$('act-search').addEventListener('click', () => {
|
| 1110 |
+
$('prompt').value = 'busca ';
|
| 1111 |
+
$('prompt').focus();
|
| 1112 |
+
});
|
| 1113 |
+
$('act-settings').addEventListener('click', () => $('btn-settings').click());
|
| 1114 |
+
|
| 1115 |
+
$('btn-history').addEventListener('click', () => {
|
| 1116 |
+
const panel = $('history-panel');
|
| 1117 |
+
panel.hidden = !panel.hidden;
|
| 1118 |
+
if (!panel.hidden) renderHistoryPanel();
|
| 1119 |
+
});
|
| 1120 |
+
document.addEventListener('pointerdown', e => {
|
| 1121 |
+
const p = $('history-panel');
|
| 1122 |
+
if (p.hidden) return;
|
| 1123 |
+
if (p.contains(e.target) || e.target.closest('#btn-history')) return;
|
| 1124 |
+
p.hidden = true;
|
| 1125 |
+
}, true);
|
| 1126 |
+
$('btn-settings').addEventListener('click', () => {
|
| 1127 |
+
const p = $('settings-panel');
|
| 1128 |
+
p.hidden = !p.hidden;
|
| 1129 |
+
if (!p.hidden) renderSettings();
|
| 1130 |
+
});
|
| 1131 |
+
// clic fuera del panel (o ir a Archivo/chat) lo cierra — antes solo la X lo hacía
|
| 1132 |
+
document.addEventListener('pointerdown', e => {
|
| 1133 |
+
const p = $('settings-panel');
|
| 1134 |
+
if (p.hidden) return;
|
| 1135 |
+
if (p.contains(e.target) || e.target.closest('#btn-settings, #act-settings')) return;
|
| 1136 |
+
p.hidden = true;
|
| 1137 |
+
}, true);
|
| 1138 |
+
|
| 1139 |
+
// ---------- composer estilo plugin: +, /, medidor de contexto, Auto ----------
|
| 1140 |
+
|
| 1141 |
+
// medidor de contexto: chars del historial / presupuesto (~4 chars/token)
|
| 1142 |
+
// Al máximo del modelo de serie: LFM2.5-1.2B soporta 32K nativo; reservamos
|
| 1143 |
+
// ~2K de sistema + 2K de generación → 28K para historial.
|
| 1144 |
+
const CTX_BUDGET_TOK = 28000;
|
| 1145 |
+
function updateCtxMeter() {
|
| 1146 |
+
if (!$('ctx-text')) return; // aún en la landing
|
| 1147 |
+
const chars = (conv.getActive()?.agent.history || []).reduce((s, m) => s + (m.content || '').length, 0);
|
| 1148 |
+
const tok = Math.round(chars / 4);
|
| 1149 |
+
const pct = Math.min(100, Math.round(tok / CTX_BUDGET_TOK * 100));
|
| 1150 |
+
const k = n => n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'k' : n;
|
| 1151 |
+
$('ctx-text').textContent = `ctx ${k(tok)} / ${k(CTX_BUDGET_TOK)}`;
|
| 1152 |
+
$('ctx-bar').style.width = pct + '%';
|
| 1153 |
+
$('ctx-bar').className = pct > 85 ? 'hot' : '';
|
| 1154 |
+
$('ctx-meter').title = pct > 85
|
| 1155 |
+
? `Contexto casi lleno (${pct}%). Los mensajes viejos se comprimen solos; usa 🧹 para empezar limpio.`
|
| 1156 |
+
: `Contexto: ${tok} de ~${CTX_BUDGET_TOK} tokens (${100 - pct}% libre)`;
|
| 1157 |
+
}
|
| 1158 |
+
const _origAdd = addMsg; // refrescar el medidor cuando cambia la conversación
|
| 1159 |
+
addMsg = (...a) => { const r = _origAdd(...a); updateCtxMeter(); return r; };
|
| 1160 |
+
|
| 1161 |
+
// menú flotante genérico sobre el composer
|
| 1162 |
+
function openMenu(items) {
|
| 1163 |
+
const menu = $('menu');
|
| 1164 |
+
menu.replaceChildren();
|
| 1165 |
+
for (const it of items) {
|
| 1166 |
+
if (it.sep) { const s = document.createElement('div'); s.className = 'sep'; s.textContent = it.sep; menu.appendChild(s); continue; }
|
| 1167 |
+
const row = document.createElement('button');
|
| 1168 |
+
row.className = 'menu-item';
|
| 1169 |
+
row.innerHTML = `<b>${it.label}</b>${it.hint ? `<span>${it.hint}</span>` : ''}`;
|
| 1170 |
+
row.onclick = () => { menu.hidden = true; it.run(); };
|
| 1171 |
+
menu.appendChild(row);
|
| 1172 |
+
}
|
| 1173 |
+
menu.hidden = false;
|
| 1174 |
+
}
|
| 1175 |
+
document.addEventListener('click', e => {
|
| 1176 |
+
if (!e.target.closest('#menu, #btn-slash, #btn-plus')) $('menu').hidden = true;
|
| 1177 |
+
});
|
| 1178 |
+
|
| 1179 |
+
// [/] comandos
|
| 1180 |
+
$('btn-slash').addEventListener('click', () => openMenu([
|
| 1181 |
+
{ sep: 'Comandos' },
|
| 1182 |
+
{ label: '/tree', hint: 'árbol del proyecto', run: () => send('árbol') },
|
| 1183 |
+
{ label: '/search', hint: 'buscar en el código', run: () => { $('prompt').value = 'busca '; $('prompt').focus(); } },
|
| 1184 |
+
{ label: '/skills', hint: 'gestionar skills', run: () => openSkillsPanel() },
|
| 1185 |
+
{ label: '/model', hint: 'proveedores y modelo', run: () => $('btn-settings').click() },
|
| 1186 |
+
{ label: '/clear', hint: 'nueva conversación', run: () => conv.create() },
|
| 1187 |
+
]));
|
| 1188 |
+
|
| 1189 |
+
// [+] adjuntar archivo del proyecto como @ruta
|
| 1190 |
+
$('btn-plus').addEventListener('click', async () => {
|
| 1191 |
+
let tree = '';
|
| 1192 |
+
try { tree = await codeTools.tree({ depth: 3 }); } catch { /* sin proyecto */ }
|
| 1193 |
+
const files = tree.split('\n').filter(l => l.trim() && !l.includes('📁')).map(l => l.trim()).slice(0, 40);
|
| 1194 |
+
const { currentFile } = codeTools.current();
|
| 1195 |
+
const items = [{ sep: 'Adjuntar al mensaje (@)' }];
|
| 1196 |
+
if (currentFile) items.push({ label: '@' + currentFile, hint: 'archivo abierto', run: () => attach(currentFile) });
|
| 1197 |
+
for (const f of files.filter(f => f !== currentFile).slice(0, 20))
|
| 1198 |
+
items.push({ label: '@' + f, run: () => attach(f) });
|
| 1199 |
+
openMenu(items.length > 1 ? items : [{ sep: 'Abre un proyecto primero' }]);
|
| 1200 |
+
});
|
| 1201 |
+
function attach(path) {
|
| 1202 |
+
const p = $('prompt');
|
| 1203 |
+
p.value = (p.value + ' @' + path).trim() + ' ';
|
| 1204 |
+
p.focus();
|
| 1205 |
+
}
|
| 1206 |
+
|
| 1207 |
+
// </> Auto (Edit automatically): si off, pide confirmación antes de escribir
|
| 1208 |
+
let autoEdit = localStorage.getItem('elffusscode.autoedit') !== '0';
|
| 1209 |
+
function paintAuto() {
|
| 1210 |
+
const b = $('btn-autoedit');
|
| 1211 |
+
b.classList.toggle('on', autoEdit);
|
| 1212 |
+
b.querySelector('.ae-txt').textContent = autoEdit ? 'Auto' : 'Revisar';
|
| 1213 |
+
b.title = autoEdit ? 'Editar archivos automáticamente (clic para revisar antes)' : 'Pedir confirmación antes de escribir (clic para automático)';
|
| 1214 |
+
}
|
| 1215 |
+
$('btn-autoedit').addEventListener('click', () => {
|
| 1216 |
+
autoEdit = !autoEdit;
|
| 1217 |
+
localStorage.setItem('elffusscode.autoedit', autoEdit ? '1' : '0');
|
| 1218 |
+
paintAuto();
|
| 1219 |
+
});
|
| 1220 |
+
paintAuto();
|
| 1221 |
+
codeTools.setWriteApprover(async (path, content) => {
|
| 1222 |
+
if (autoEdit) return true;
|
| 1223 |
+
return confirm(`Elffuss quiere escribir en «${path}» (${content.split('\n').length} líneas).\n\n¿Aplicar el cambio?`);
|
| 1224 |
+
});
|
| 1225 |
+
|
| 1226 |
+
// 🎯 Goal: el siguiente mensaje que mandes se trata como un OBJETIVO — se
|
| 1227 |
+
// planifica en tareas y se ejecutan una a una (goal.js), en vez del turno de
|
| 1228 |
+
// chat normal. Mismo patrón (interruptor persistente en localStorage) que
|
| 1229 |
+
// </> Auto de arriba.
|
| 1230 |
+
let goalMode = localStorage.getItem('elffusscode.goalmode') === '1';
|
| 1231 |
+
function paintGoal() {
|
| 1232 |
+
$('btn-goal').classList.toggle('on', goalMode);
|
| 1233 |
+
}
|
| 1234 |
+
$('btn-goal').addEventListener('click', () => {
|
| 1235 |
+
goalMode = !goalMode;
|
| 1236 |
+
localStorage.setItem('elffusscode.goalmode', goalMode ? '1' : '0');
|
| 1237 |
+
paintGoal();
|
| 1238 |
+
});
|
| 1239 |
+
paintGoal();
|
| 1240 |
+
|
| 1241 |
+
// resolver @rutas del mensaje: se leen y se adjuntan como contexto
|
| 1242 |
+
const _send = send;
|
| 1243 |
+
send = async (text) => {
|
| 1244 |
+
const refs = [...text.matchAll(/@([\w./-]+\.\w+)/g)].map(m => m[1]);
|
| 1245 |
+
if (refs.length) {
|
| 1246 |
+
let ctx = '';
|
| 1247 |
+
for (const r of [...new Set(refs)].slice(0, 4)) {
|
| 1248 |
+
try { ctx += `\n\n--- ${r} ---\n${await codeTools.read({ path: r })}`; } catch { /* ignora */ }
|
| 1249 |
+
}
|
| 1250 |
+
if (ctx) text += '\n\n[archivos adjuntos]' + ctx;
|
| 1251 |
+
}
|
| 1252 |
+
_send(text);
|
| 1253 |
+
};
|
| 1254 |
+
|
| 1255 |
+
// ---------- panel de skills (transparente: repo + lista + SKILL.md) ----------
|
| 1256 |
+
async function openSkillsPanel() {
|
| 1257 |
+
const box = $('settings-panel');
|
| 1258 |
+
box.hidden = false;
|
| 1259 |
+
box.replaceChildren();
|
| 1260 |
+
const close = document.createElement('button');
|
| 1261 |
+
close.id = 'settings-close'; close.textContent = '✕'; close.onclick = () => { box.hidden = true; };
|
| 1262 |
+
box.appendChild(close);
|
| 1263 |
+
|
| 1264 |
+
const wrap = document.createElement('div');
|
| 1265 |
+
wrap.className = 'skills-panel';
|
| 1266 |
+
wrap.innerHTML = `<h3>🧩 Skills de Claude Code</h3>
|
| 1267 |
+
<p class="muted">Instala instrucciones especializadas (formato SKILL.md) desde repos públicos.
|
| 1268 |
+
Todo se guarda en tu navegador. Se ve el repo y lo que se inyecta.</p>`;
|
| 1269 |
+
box.appendChild(wrap);
|
| 1270 |
+
|
| 1271 |
+
// instaladas
|
| 1272 |
+
const inst = skills.installed();
|
| 1273 |
+
const instBox = document.createElement('div');
|
| 1274 |
+
instBox.innerHTML = `<div class="sk-h">Instaladas (${inst.length})</div>`;
|
| 1275 |
+
for (const s of inst) {
|
| 1276 |
+
const row = document.createElement('div');
|
| 1277 |
+
row.className = 'sk-row';
|
| 1278 |
+
row.innerHTML = `<b>${s.name}</b><span class="muted">${s.repo || 'local'}</span>`;
|
| 1279 |
+
const rm = document.createElement('button');
|
| 1280 |
+
rm.className = 'ghost'; rm.textContent = 'Quitar';
|
| 1281 |
+
rm.onclick = async () => { await skills.remove(s.name); openSkillsPanel(); };
|
| 1282 |
+
row.appendChild(rm);
|
| 1283 |
+
instBox.appendChild(row);
|
| 1284 |
+
}
|
| 1285 |
+
if (!inst.length) instBox.innerHTML += '<p class="muted">Ninguna todavía.</p>';
|
| 1286 |
+
box.appendChild(instBox);
|
| 1287 |
+
|
| 1288 |
+
// fuentes + añadir repo
|
| 1289 |
+
const srcs = await skills.sources();
|
| 1290 |
+
const srcBox = document.createElement('div');
|
| 1291 |
+
srcBox.innerHTML = `<div class="sk-h">Catálogos (repos)</div>`;
|
| 1292 |
+
for (const s of srcs) {
|
| 1293 |
+
const row = document.createElement('div');
|
| 1294 |
+
row.className = 'sk-row';
|
| 1295 |
+
row.innerHTML = `<b>${s.label}</b><a href="https://github.com/${s.repo}" target="_blank" rel="noopener">${s.repo} ↗</a>`;
|
| 1296 |
+
const browse = document.createElement('button');
|
| 1297 |
+
browse.className = 'primary'; browse.textContent = 'Explorar';
|
| 1298 |
+
browse.onclick = () => browseRepo(s.repo, box);
|
| 1299 |
+
row.appendChild(browse);
|
| 1300 |
+
if (!s.official) {
|
| 1301 |
+
const rm = document.createElement('button');
|
| 1302 |
+
rm.className = 'ghost'; rm.textContent = '🗑';
|
| 1303 |
+
rm.onclick = async () => { await skills.removeSource(s.repo); openSkillsPanel(); };
|
| 1304 |
+
row.appendChild(rm);
|
| 1305 |
+
}
|
| 1306 |
+
srcBox.appendChild(row);
|
| 1307 |
+
}
|
| 1308 |
+
const addRow = document.createElement('div');
|
| 1309 |
+
addRow.className = 'sk-row';
|
| 1310 |
+
const inp = document.createElement('input');
|
| 1311 |
+
inp.placeholder = 'owner/repo o URL de GitHub (p. ej. OpenClaude/…)';
|
| 1312 |
+
const add = document.createElement('button');
|
| 1313 |
+
add.className = 'primary'; add.textContent = 'Añadir repo';
|
| 1314 |
+
add.onclick = async () => {
|
| 1315 |
+
try { await skills.addSource(inp.value); openSkillsPanel(); }
|
| 1316 |
+
catch (e) { alert(e.message); }
|
| 1317 |
+
};
|
| 1318 |
+
addRow.append(inp, add);
|
| 1319 |
+
srcBox.appendChild(addRow);
|
| 1320 |
+
box.appendChild(srcBox);
|
| 1321 |
+
}
|
| 1322 |
+
|
| 1323 |
+
async function browseRepo(repo, box) {
|
| 1324 |
+
const list = document.createElement('div');
|
| 1325 |
+
list.innerHTML = `<div class="sk-h">Cargando ${repo}…</div>`;
|
| 1326 |
+
box.appendChild(list);
|
| 1327 |
+
try {
|
| 1328 |
+
const found = await skills.listFromRepo(repo);
|
| 1329 |
+
list.innerHTML = `<div class="sk-h">${repo} — ${found.length} skills</div>`;
|
| 1330 |
+
for (const sk of found.slice(0, 120)) {
|
| 1331 |
+
const row = document.createElement('div');
|
| 1332 |
+
row.className = 'sk-row';
|
| 1333 |
+
row.innerHTML = `<b>${sk.name}</b><span class="muted">${sk.dir}</span>`;
|
| 1334 |
+
const btn = document.createElement('button');
|
| 1335 |
+
const on = skills.isInstalled(sk.repo, sk.path);
|
| 1336 |
+
btn.className = on ? 'ghost' : 'primary';
|
| 1337 |
+
btn.textContent = on ? 'Instalada ✓' : 'Instalar';
|
| 1338 |
+
btn.onclick = async () => {
|
| 1339 |
+
btn.textContent = '…';
|
| 1340 |
+
try {
|
| 1341 |
+
const entry = await skills.installFromRepo(sk);
|
| 1342 |
+
btn.textContent = 'Instalada ✓'; btn.className = 'ghost';
|
| 1343 |
+
$('settings-panel').hidden = true; // cierra el panel
|
| 1344 |
+
addMsg('assistant', skills.usageMessage(entry)); // «cómo usarla» en el chat
|
| 1345 |
+
} catch (e) { btn.textContent = 'Instalar'; alert(e.message); }
|
| 1346 |
+
};
|
| 1347 |
+
row.appendChild(btn);
|
| 1348 |
+
list.appendChild(row);
|
| 1349 |
+
}
|
| 1350 |
+
} catch (e) {
|
| 1351 |
+
list.innerHTML = `<div class="sk-h">⚠️ ${e.message}</div>`;
|
| 1352 |
+
}
|
| 1353 |
+
}
|
| 1354 |
+
|
| 1355 |
+
// ---------- command palette (Cmd/Ctrl+P) ----------
|
| 1356 |
+
let palItems = [], palSel = 0;
|
| 1357 |
+
const COMMANDS = [
|
| 1358 |
+
{ label: 'Nueva conversación', run: () => conv.create() },
|
| 1359 |
+
{ label: 'Historial de conversaciones', run: () => $('btn-history').click() },
|
| 1360 |
+
{ label: 'Buscar en el código…', run: () => { closePalette(); $('prompt').value = 'busca '; $('prompt').focus(); } },
|
| 1361 |
+
{ label: 'Gestionar skills', run: () => { closePalette(); openSkillsPanel(); } },
|
| 1362 |
+
{ label: 'Ajustes y modelo', run: () => { closePalette(); renderSettings(); } },
|
| 1363 |
+
{ label: 'Alternar explorador', run: () => $('act-files').click() },
|
| 1364 |
+
{ label: 'Alternar terminal', hint: 'Ctrl+`', run: () => { closePalette(); toggleTerminal(); } },
|
| 1365 |
+
{ label: 'Guardar (Ctrl+S)', run: () => triggerEditor('') || (hasEditor() && triggerEditor('workbench.action.files.save')) },
|
| 1366 |
+
];
|
| 1367 |
+
|
| 1368 |
+
function openPalette(prefix = '') {
|
| 1369 |
+
$('palette').hidden = false;
|
| 1370 |
+
const inp = $('pal-input');
|
| 1371 |
+
inp.value = prefix;
|
| 1372 |
+
inp.focus();
|
| 1373 |
+
renderPalette();
|
| 1374 |
+
}
|
| 1375 |
+
function closePalette() { $('palette').hidden = true; }
|
| 1376 |
+
|
| 1377 |
+
function fuzzy(q, s) {
|
| 1378 |
+
q = q.toLowerCase(); s = s.toLowerCase();
|
| 1379 |
+
let i = 0, score = 0, streak = 0;
|
| 1380 |
+
for (const ch of s) {
|
| 1381 |
+
if (i < q.length && ch === q[i]) { i++; streak++; score += streak; }
|
| 1382 |
+
else streak = 0;
|
| 1383 |
+
}
|
| 1384 |
+
return i === q.length ? score + (s.endsWith(q) ? 20 : 0) : -1;
|
| 1385 |
+
}
|
| 1386 |
+
|
| 1387 |
+
async function renderPalette() {
|
| 1388 |
+
const raw = $('pal-input').value;
|
| 1389 |
+
const list = $('pal-list');
|
| 1390 |
+
list.replaceChildren();
|
| 1391 |
+
palItems = [];
|
| 1392 |
+
|
| 1393 |
+
if (raw.startsWith('>')) { // comandos
|
| 1394 |
+
const q = raw.slice(1).trim().toLowerCase();
|
| 1395 |
+
palItems = COMMANDS.filter(c => c.label.toLowerCase().includes(q)).map(c => ({ label: c.label, hint: 'comando', run: c.run }));
|
| 1396 |
+
} else if (raw.startsWith('@')) { // símbolos del archivo abierto (Monaco)
|
| 1397 |
+
palItems = [{ label: 'Ir a símbolo en el editor…', hint: '@', run: () => { closePalette(); triggerEditor('editor.action.quickOutline'); } }];
|
| 1398 |
+
} else { // archivos (con :línea opcional)
|
| 1399 |
+
const [pathQ, lineQ] = raw.split(':');
|
| 1400 |
+
const files = await codeTools.fileList().catch(() => []);
|
| 1401 |
+
const scored = pathQ.trim()
|
| 1402 |
+
? files.map(f => ({ f, s: fuzzy(pathQ.trim(), f.split('/').pop()) * 2 + fuzzy(pathQ.trim(), f) })).filter(x => x.s > 0).sort((a, b) => b.s - a.s)
|
| 1403 |
+
: files.slice(0, 40).map(f => ({ f, s: 0 }));
|
| 1404 |
+
palItems = scored.slice(0, 40).map(({ f }) => ({
|
| 1405 |
+
label: f.split('/').pop(), hint: f, icon: true,
|
| 1406 |
+
run: () => { closePalette(); openFile(f); if (lineQ && +lineQ) setTimeout(() => gotoLine(+lineQ), 300); },
|
| 1407 |
+
}));
|
| 1408 |
+
}
|
| 1409 |
+
palSel = 0;
|
| 1410 |
+
palItems.forEach((it, i) => {
|
| 1411 |
+
const row = el('button', 'pal-item' + (i === 0 ? ' sel' : ''));
|
| 1412 |
+
row.innerHTML = `<span class="pi-name">${it.label}</span><span class="pi-hint">${it.hint || ''}</span>`;
|
| 1413 |
+
row.onmouseenter = () => { palSel = i; paintPalSel(); };
|
| 1414 |
+
row.onclick = () => it.run();
|
| 1415 |
+
list.appendChild(row);
|
| 1416 |
+
});
|
| 1417 |
+
if (!palItems.length) list.appendChild(el('div', 'pal-empty', 'Sin resultados'));
|
| 1418 |
+
}
|
| 1419 |
+
function paintPalSel() {
|
| 1420 |
+
[...$('pal-list').children].forEach((c, i) => c.classList.toggle('sel', i === palSel));
|
| 1421 |
+
$('pal-list').children[palSel]?.scrollIntoView({ block: 'nearest' });
|
| 1422 |
+
}
|
| 1423 |
+
$('pal-input').addEventListener('input', renderPalette);
|
| 1424 |
+
$('pal-input').addEventListener('keydown', e => {
|
| 1425 |
+
if (e.key === 'Escape') return closePalette();
|
| 1426 |
+
if (e.key === 'ArrowDown') { e.preventDefault(); palSel = Math.min(palSel + 1, palItems.length - 1); paintPalSel(); }
|
| 1427 |
+
if (e.key === 'ArrowUp') { e.preventDefault(); palSel = Math.max(palSel - 1, 0); paintPalSel(); }
|
| 1428 |
+
if (e.key === 'Enter') { e.preventDefault(); palItems[palSel]?.run(); }
|
| 1429 |
+
});
|
| 1430 |
+
document.addEventListener('keydown', e => {
|
| 1431 |
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') { e.preventDefault(); openPalette(e.shiftKey ? '>' : ''); }
|
| 1432 |
+
if (e.key === 'Escape' && !$('palette').hidden) closePalette();
|
| 1433 |
+
});
|
| 1434 |
+
document.addEventListener('click', e => { if (!e.target.closest('#palette')) closePalette(); });
|
| 1435 |
+
|
| 1436 |
+
// ---------- barra de menú File/Edit/View/Git ----------
|
| 1437 |
+
async function menuFor(which) {
|
| 1438 |
+
if (which === 'file') return [
|
| 1439 |
+
{ label: 'Nuevo archivo…', run: () => { const p = prompt('Ruta del nuevo archivo:'); if (p) codeTools.write({ path: p, content: '' }).then(() => { openFile(p); refreshTree(); }); } },
|
| 1440 |
+
{ label: 'Guardar', hint: 'Ctrl+S', run: () => triggerEditor('workbench.action.files.save') },
|
| 1441 |
+
{ label: 'Abrir carpeta…', run: async () => { try { await enterIDE(await window.showDirectoryPicker({ mode: 'readwrite' })); } catch {} } },
|
| 1442 |
+
];
|
| 1443 |
+
if (which === 'edit') return [
|
| 1444 |
+
{ label: 'Buscar en el código', run: () => { $('prompt').value = 'busca '; $('prompt').focus(); } },
|
| 1445 |
+
{ label: 'Ir a archivo…', hint: 'Cmd+P', run: () => openPalette('') },
|
| 1446 |
+
{ label: 'Ir a símbolo…', run: () => triggerEditor('editor.action.quickOutline') },
|
| 1447 |
+
];
|
| 1448 |
+
if (which === 'view') return [
|
| 1449 |
+
{ label: 'Explorador', run: () => $('act-files').click() },
|
| 1450 |
+
{ label: 'Terminal', hint: 'Ctrl+`', run: () => toggleTerminal(true) },
|
| 1451 |
+
{ label: 'Arquitectura', run: () => openView('arch') },
|
| 1452 |
+
{ label: 'Ciudad 3D', run: () => openView('city') },
|
| 1453 |
+
{ label: 'Command Palette', hint: 'Cmd+Shift+P', run: () => openPalette('>') },
|
| 1454 |
+
{ label: 'Skills', run: () => openSkillsPanel() },
|
| 1455 |
+
{ label: 'Acerca de Elffuss Code (GitHub)', hint: '↗', run: () => window.open('https://github.com/KikoCis/elffuss-code', '_blank', 'noopener') },
|
| 1456 |
+
];
|
| 1457 |
+
if (which === 'git') {
|
| 1458 |
+
const g = await codeTools.gitInfo();
|
| 1459 |
+
if (!g.isRepo) return [{ label: 'No es un repositorio git', hint: '', run: () => {} }];
|
| 1460 |
+
const items = [{ label: 'Rama: ' + g.branch, hint: 'git', run: () => {} }];
|
| 1461 |
+
if (g.lastCommit) items.push({ label: 'Último: ' + g.lastCommit.msg.slice(0, 40), hint: g.lastCommit.author, run: () => {} });
|
| 1462 |
+
items.push({ label: 'Pídele a Elffuss que commitee', run: () => { $('prompt').value = 'resume los cambios y prepárame el mensaje de commit'; $('prompt').focus(); } });
|
| 1463 |
+
return items;
|
| 1464 |
+
}
|
| 1465 |
+
return [];
|
| 1466 |
+
}
|
| 1467 |
+
document.querySelectorAll('#menubar button').forEach(b => {
|
| 1468 |
+
b.addEventListener('click', async () => {
|
| 1469 |
+
document.querySelectorAll('#menubar button').forEach(x => x.classList.toggle('open', x === b));
|
| 1470 |
+
openMenuAt(await menuFor(b.dataset.menu), b);
|
| 1471 |
+
});
|
| 1472 |
+
});
|
| 1473 |
+
function openMenuAt(items, anchor) {
|
| 1474 |
+
const menu = $('topmenu');
|
| 1475 |
+
const r = anchor.getBoundingClientRect();
|
| 1476 |
+
menu.style.left = r.left + 'px';
|
| 1477 |
+
menu.style.top = (r.bottom + 3) + 'px';
|
| 1478 |
+
menu.replaceChildren();
|
| 1479 |
+
for (const it of items) {
|
| 1480 |
+
const row = el('button', 'menu-item');
|
| 1481 |
+
row.innerHTML = `<b>${it.label}</b>${it.hint ? `<span>${it.hint}</span>` : ''}`;
|
| 1482 |
+
row.onclick = () => { menu.hidden = true; it.run(); };
|
| 1483 |
+
menu.appendChild(row);
|
| 1484 |
+
}
|
| 1485 |
+
menu.hidden = false;
|
| 1486 |
+
}
|
| 1487 |
+
document.addEventListener('click', e => {
|
| 1488 |
+
if (!e.target.closest('#menubar, #topmenu')) {
|
| 1489 |
+
document.querySelectorAll('#menubar button').forEach(x => x.classList.remove('open'));
|
| 1490 |
+
$('topmenu').hidden = true;
|
| 1491 |
+
}
|
| 1492 |
+
});
|
| 1493 |
+
|
| 1494 |
+
// ---------- git en el header ----------
|
| 1495 |
+
async function refreshGit() {
|
| 1496 |
+
const g = await codeTools.gitInfo().catch(() => ({ isRepo: false }));
|
| 1497 |
+
const chip = $('git-branch');
|
| 1498 |
+
if (g.isRepo) { chip.hidden = false; chip.innerHTML = UI.code + ' ' + g.branch; }
|
| 1499 |
+
else chip.hidden = true;
|
| 1500 |
+
}
|
| 1501 |
+
$('git-branch').addEventListener('click', () => document.querySelector('#menubar button[data-menu="git"]').click());
|
| 1502 |
+
|
| 1503 |
+
// icono de skills en la barra de actividad
|
| 1504 |
+
$('act-skills').innerHTML = UI.puzzle;
|
| 1505 |
+
$('act-skills').addEventListener('click', () => openSkillsPanel());
|
| 1506 |
+
|
| 1507 |
+
rebuildSelect();
|
| 1508 |
+
skills.initSkills();
|
| 1509 |
+
boot();
|
| 1510 |
+
window.elffussClaw = { conv, get agent() { return conv.getActive()?.agent; }, send, openFile, parseToolCall, skills };
|
js/md.js
ADDED
|
Binary file (1.71 kB). View file
|
|
|
js/mind-adapter.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Glue específico de Elffuss Code para la Mente genérica (mind.js, core): le
|
| 2 |
+
// da su ciudad de fondo real (motor VibeCodeViewer sobre el proyecto abierto)
|
| 3 |
+
// y la semilla de pensamientos ya guardados en la carpeta-alma del cerebro.
|
| 4 |
+
import * as THREE from 'three';
|
| 5 |
+
import * as codeTools from './tools/code.js';
|
| 6 |
+
import * as ceo from './ceo.js';
|
| 7 |
+
import { buildModel } from './vcc/model.js';
|
| 8 |
+
import { buildCity } from './vcc/builder.js';
|
| 9 |
+
import { THEME as VCC_THEME } from './vcc/theme.js';
|
| 10 |
+
|
| 11 |
+
const CITY_MAX_FILES = 2600;
|
| 12 |
+
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
| 13 |
+
|
| 14 |
+
async function scanForCity(dir, budget) {
|
| 15 |
+
const dirs = [], files = [];
|
| 16 |
+
for await (const e of dir.values()) { if (IGNORE.has(e.name)) continue; (e.kind === 'directory' ? dirs : files).push(e); }
|
| 17 |
+
dirs.sort((a, b) => a.name.localeCompare(b.name)); files.sort((a, b) => a.name.localeCompare(b.name));
|
| 18 |
+
const outFiles = [];
|
| 19 |
+
for (const f of files) { if (budget.n >= CITY_MAX_FILES) break; let size = 0; try { size = (await f.getFile()).size; } catch { /* */ } outFiles.push([f.name, size]); budget.n++; }
|
| 20 |
+
const outDirs = [];
|
| 21 |
+
for (const d of dirs) { if (budget.n >= CITY_MAX_FILES) break; outDirs.push(await scanForCity(d, budget)); }
|
| 22 |
+
return [dir.name, outDirs, outFiles];
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export async function buildCityAdapter(scene) {
|
| 26 |
+
const root = codeTools.handle();
|
| 27 |
+
if (!root) return null;
|
| 28 |
+
const tree = await scanForCity(root, { n: 0 });
|
| 29 |
+
const model = buildModel(tree, null);
|
| 30 |
+
if (!model.files.length) return null;
|
| 31 |
+
const extent = Math.max(model.city.box.sx, model.city.box.sz, 1);
|
| 32 |
+
const scale = Math.min(1, Math.max(0.05, 170 / extent));
|
| 33 |
+
const cityWrap = new THREE.Group();
|
| 34 |
+
cityWrap.position.set(0, -95, 0);
|
| 35 |
+
cityWrap.scale.setScalar(scale);
|
| 36 |
+
scene.add(cityWrap);
|
| 37 |
+
const cityCity = buildCity(cityWrap, model, VCC_THEME);
|
| 38 |
+
return { cityWrap, cityCity, cityModel: model };
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export async function loadThoughtsAdapter() {
|
| 42 |
+
const soul = ceo.getSoulDir();
|
| 43 |
+
let dir = codeTools.handle();
|
| 44 |
+
for (const seg of soul.split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(seg);
|
| 45 |
+
const files = [];
|
| 46 |
+
for await (const e of dir.values()) if (e.kind === 'file' && e.name.endsWith('.md')) files.push(e);
|
| 47 |
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
| 48 |
+
const out = [];
|
| 49 |
+
for (const f of files.slice(-24)) out.push({ path: soul + '/' + f.name, md: await (await f.getFile()).text() });
|
| 50 |
+
return out;
|
| 51 |
+
}
|
js/mind.js
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Vista «Mente de Elffuss» — el pensamiento subyacente del cerebro CEO en un
|
| 2 |
+
// mundo 3D de fantasía psicodélica trance, con la CIUDAD real del proyecto
|
| 3 |
+
// (motor VibeCodeViewer) flotando por debajo.
|
| 4 |
+
// · cada línea/tool-call que llega nace como una ESTRELLA de texto (color por
|
| 5 |
+
// PERFIL, tamaño/tipografía según lo que ocurre) que se desvanece sola.
|
| 6 |
+
// · los perfiles (nombre, foco, color) se editan desde ⚙ — el usuario los crea
|
| 7 |
+
// a su gusto.
|
| 8 |
+
// · «≡ historial»: panel con TODO lo que ha llegado, sin recortar.
|
| 9 |
+
// · cuando lee/escribe un fichero real, un haz baja hasta él en la ciudad.
|
| 10 |
+
// · overlay PERSISTENTE (música/animación no reinician al salir y volver).
|
| 11 |
+
import * as THREE from 'three';
|
| 12 |
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
| 13 |
+
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
| 14 |
+
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
| 15 |
+
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
| 16 |
+
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
| 17 |
+
import * as ceo from './ceo.js';
|
| 18 |
+
import * as db from './db.js';
|
| 19 |
+
import { renderMarkdown } from './md.js';
|
| 20 |
+
|
| 21 |
+
const SOUNDCLOUD = 'https://soundcloud.com/dekel-official/dekel-baoba-festival-2023';
|
| 22 |
+
const CEO_COLOR = '#ff4d8d';
|
| 23 |
+
|
| 24 |
+
let open = false, built = false, raf = null;
|
| 25 |
+
let overlay, S, widget, onOpenFile = () => {}, onExecute = () => {};
|
| 26 |
+
// Ciudad de fondo (vista real del workspace) y semilla de pensamientos previos:
|
| 27 |
+
// AMBOS opcionales e inyectados por la app anfitriona — Code tiene un motor
|
| 28 |
+
// VibeCodeViewer sobre su proyecto único; Claw (v1) no lo ofrece y la Mente
|
| 29 |
+
// funciona igual de bien sin ciudad de fondo.
|
| 30 |
+
let loadThoughts = async () => []; // () => [{path, md}]
|
| 31 |
+
let buildCityFn = null; // (scene) => {cityWrap, cityCity, cityModel} | null
|
| 32 |
+
export function init({ loadThoughts: lt, buildCity: bc } = {}) {
|
| 33 |
+
if (lt) loadThoughts = lt;
|
| 34 |
+
if (bc) buildCityFn = bc;
|
| 35 |
+
}
|
| 36 |
+
let anchorMap = new Map(); // id de perfil (+ 'ceo') → Vector3 (anclas del mundo)
|
| 37 |
+
const anchors = []; // { el, pos } — solo etiquetas de propuestas forjadas
|
| 38 |
+
let nodesGroup, thoughtNodes = [];
|
| 39 |
+
let starGroup, stars = []; // estrellas de pensamiento efímeras
|
| 40 |
+
const lineBuf = new Map(); // canal → texto acumulado hasta la línea completa
|
| 41 |
+
const streamed = new Map(); // canal → ¿llegaron tokens? (evita duplicar el texto final)
|
| 42 |
+
let cityWrap = null, cityCity = null, cityModel = null;
|
| 43 |
+
let beams = [], fileActivity = [];
|
| 44 |
+
|
| 45 |
+
export function isOpen() { return open; }
|
| 46 |
+
export function setOpenFile(fn) { onOpenFile = fn; }
|
| 47 |
+
export function setExecuteProposal(fn) { onExecute = fn; }
|
| 48 |
+
|
| 49 |
+
const escHtml = s => String(s).replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
| 50 |
+
const profileOf = channel => channel === 'ceo'
|
| 51 |
+
? { id: 'ceo', name: 'CEO', color: CEO_COLOR }
|
| 52 |
+
: ceo.getProfiles().find(p => p.id === channel) || { id: channel, name: channel, color: '#7c5cff' };
|
| 53 |
+
|
| 54 |
+
// ── música con Widget API (persistente: pausa al salir, reanuda al volver) ──
|
| 55 |
+
function mountMusic(root) {
|
| 56 |
+
const wrap = document.createElement('div');
|
| 57 |
+
wrap.className = 'mind-music';
|
| 58 |
+
const url = 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(SOUNDCLOUD) +
|
| 59 |
+
'&auto_play=true&hide_related=true&show_comments=false&show_user=false&show_reposts=false&visual=false&color=%23ff4d8d';
|
| 60 |
+
wrap.innerHTML = '<iframe id="mind-sc" title="música" width="100%" height="120" frameborder="no" allow="autoplay; encrypted-media" src="' + url + '"></iframe>' +
|
| 61 |
+
'<div class="mind-music-cap">▶ Dekel · Baoba Festival — modo trance</div>';
|
| 62 |
+
root.appendChild(wrap);
|
| 63 |
+
const iframe = wrap.querySelector('#mind-sc');
|
| 64 |
+
const initWidget = () => { try { widget = window.SC.Widget(iframe); } catch { /* aún no */ } };
|
| 65 |
+
if (window.SC && window.SC.Widget) initWidget();
|
| 66 |
+
else { const s = document.createElement('script'); s.src = 'https://w.soundcloud.com/player/api.js'; s.onload = initWidget; document.head.appendChild(s); }
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// ══ ESTRELLAS de pensamiento: cada línea/tool-call nace, brilla y se apaga ══
|
| 70 |
+
function makeTextSprite(text, color, { fontSize = 16, bold = false, glow = 10 } = {}) {
|
| 71 |
+
const pad = 8;
|
| 72 |
+
const probe = document.createElement('canvas').getContext('2d');
|
| 73 |
+
probe.font = `${bold ? 700 : 500} ${fontSize}px ui-monospace, Menlo, monospace`;
|
| 74 |
+
const w = Math.ceil(probe.measureText(text).width) + pad * 2;
|
| 75 |
+
const h = fontSize + pad * 2;
|
| 76 |
+
const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
|
| 77 |
+
const g = cv.getContext('2d');
|
| 78 |
+
g.font = `${bold ? 700 : 500} ${fontSize}px ui-monospace, Menlo, monospace`;
|
| 79 |
+
g.textBaseline = 'middle';
|
| 80 |
+
g.shadowColor = color; g.shadowBlur = glow;
|
| 81 |
+
g.fillStyle = color;
|
| 82 |
+
g.fillText(text, pad, h / 2 + 1);
|
| 83 |
+
const tex = new THREE.CanvasTexture(cv);
|
| 84 |
+
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending });
|
| 85 |
+
const sprite = new THREE.Sprite(mat);
|
| 86 |
+
const scale = 0.09;
|
| 87 |
+
sprite.scale.set(w * scale, h * scale, 1);
|
| 88 |
+
return sprite;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// kind: 'line' (charla normal) · 'tool' (tool-call) · 'event' (ciclo/estado) · 'built' (propuesta forjada)
|
| 92 |
+
const KIND_STYLE = {
|
| 93 |
+
line: { fontSize: 13, bold: false, glow: 8, life: 6, jitter: 15 },
|
| 94 |
+
tool: { fontSize: 17, bold: true, glow: 16, life: 9, jitter: 9 },
|
| 95 |
+
event: { fontSize: 20, bold: true, glow: 14, life: 7, jitter: 6 },
|
| 96 |
+
built: { fontSize: 26, bold: true, glow: 22, life: 13, jitter: 4 },
|
| 97 |
+
};
|
| 98 |
+
function spawnStar(channel, text, kind = 'line') {
|
| 99 |
+
if (!starGroup || !text) return;
|
| 100 |
+
const prof = profileOf(channel);
|
| 101 |
+
const st = KIND_STYLE[kind] || KIND_STYLE.line;
|
| 102 |
+
const anchor = anchorMap.get(prof.id) || new THREE.Vector3(0, 20, 0);
|
| 103 |
+
const j = st.jitter;
|
| 104 |
+
const label = kind === 'tool' ? '⟐ ' + text : kind === 'event' ? '★ ' + text : kind === 'built' ? '✦ ' + text : text;
|
| 105 |
+
const sprite = makeTextSprite(label.slice(0, kind === 'line' ? 52 : 72), prof.color, st);
|
| 106 |
+
sprite.position.copy(anchor).add(new THREE.Vector3((Math.random() - 0.5) * j, (Math.random() - 0.5) * j * 0.6, (Math.random() - 0.5) * j));
|
| 107 |
+
sprite.material.opacity = 0;
|
| 108 |
+
starGroup.add(sprite);
|
| 109 |
+
stars.push({ obj: sprite, born: elapsed, life: st.life, seed: Math.random() * 1000, up: kind === 'built' ? 0.5 : 0.22 });
|
| 110 |
+
if (stars.length > 160) { const old = stars.shift(); starGroup.remove(old.obj); old.obj.material.map.dispose(); old.obj.material.dispose(); }
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// ── historial COMPLETO (todo lo que llega, sin recortar) — PERSISTENTE: antes
|
| 114 |
+
// vivía solo en el DOM/memoria y una recarga de página lo borraba entero.
|
| 115 |
+
const LOG_CAP = 500;
|
| 116 |
+
let logHistory = []; // [{channel, text}] — se guarda en IndexedDB
|
| 117 |
+
let logSaveTimer = null;
|
| 118 |
+
function saveLogSoon() {
|
| 119 |
+
clearTimeout(logSaveTimer);
|
| 120 |
+
logSaveTimer = setTimeout(() => db.set('kv', 'mindLog', logHistory).catch(() => {}), 400);
|
| 121 |
+
}
|
| 122 |
+
function renderLogRow(channel, text) {
|
| 123 |
+
const body = document.getElementById('mind-log-body');
|
| 124 |
+
if (!body) return;
|
| 125 |
+
const prof = profileOf(channel);
|
| 126 |
+
const row = document.createElement('div');
|
| 127 |
+
row.className = 'ml-row';
|
| 128 |
+
row.innerHTML = `<span class="ml-dot" style="background:${prof.color}"></span><b style="color:${prof.color}">${escHtml(prof.name)}</b> <span class="ml-t">${escHtml(text)}</span>`;
|
| 129 |
+
body.appendChild(row);
|
| 130 |
+
while (body.children.length > LOG_CAP) body.removeChild(body.firstChild);
|
| 131 |
+
body.scrollTop = body.scrollHeight;
|
| 132 |
+
}
|
| 133 |
+
function logLine(channel, text) {
|
| 134 |
+
logHistory.push({ channel, text });
|
| 135 |
+
if (logHistory.length > LOG_CAP) logHistory = logHistory.slice(-LOG_CAP);
|
| 136 |
+
saveLogSoon();
|
| 137 |
+
renderLogRow(channel, text);
|
| 138 |
+
}
|
| 139 |
+
async function loadPersistedLog() {
|
| 140 |
+
try {
|
| 141 |
+
const saved = await db.get('kv', 'mindLog');
|
| 142 |
+
if (!Array.isArray(saved) || !saved.length) return;
|
| 143 |
+
logHistory = saved;
|
| 144 |
+
for (const { channel, text } of saved) renderLogRow(channel, text);
|
| 145 |
+
} catch { /* aún no hay historial guardado */ }
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function flushLine(channel, force = false) {
|
| 149 |
+
const buf = (lineBuf.get(channel) || '').trim();
|
| 150 |
+
if (!buf && !force) return;
|
| 151 |
+
if (buf) { spawnStar(channel, buf, 'line'); logLine(channel, buf); }
|
| 152 |
+
lineBuf.set(channel, '');
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// alimenta estrellas + historial + haces sobre la ciudad (ceo.js → aquí)
|
| 156 |
+
export function pushThought(channel, ev) {
|
| 157 |
+
if (!open) return;
|
| 158 |
+
if (channel === 'sys') { logLine('ceo', ev.text); return; }
|
| 159 |
+
if (channel === 'ceo') {
|
| 160 |
+
if (ev.type === 'cycle') { logLine('ceo', '● ' + ev.text); spawnStar('ceo', ev.text, 'event'); }
|
| 161 |
+
else if (ev.type === 'survey') { logLine('ceo', ev.text); }
|
| 162 |
+
else if (ev.type === 'reprogram') { logLine('ceo', '⚙ ' + ev.text); spawnStar('ceo', ev.text, 'event'); if (ev.profiles) rebuildWorld(); }
|
| 163 |
+
else if (ev.type === 'paused') { logLine('ceo', ev.text); spawnStar('ceo', ev.text, 'event'); }
|
| 164 |
+
else if (ev.type === 'built') { logLine('ceo', '✦ ' + ev.text); spawnStar('ceo', ev.text, 'built'); addThoughtNode(ev); }
|
| 165 |
+
else logLine('ceo', ev.text || '');
|
| 166 |
+
return;
|
| 167 |
+
}
|
| 168 |
+
if (ev.type === 'open') { lineBuf.set(channel, ''); streamed.set(channel, false); }
|
| 169 |
+
else if (ev.type === 'token') {
|
| 170 |
+
streamed.set(channel, true);
|
| 171 |
+
lineBuf.set(channel, (lineBuf.get(channel) || '') + ev.text);
|
| 172 |
+
const buf = lineBuf.get(channel);
|
| 173 |
+
if (/\n/.test(ev.text) || buf.length > 90) flushLine(channel);
|
| 174 |
+
} else if (ev.type === 'tool') {
|
| 175 |
+
flushLine(channel);
|
| 176 |
+
spawnStar(channel, ev.text, 'tool');
|
| 177 |
+
logLine(channel, '⟐ ' + ev.text);
|
| 178 |
+
if (ev.path) fileBeam(ev.path, /escrib/i.test(ev.text) ? 'write' : 'read');
|
| 179 |
+
} else if (ev.type === 'tool_result') {
|
| 180 |
+
// el RESULTADO real de la tool (lo que de verdad se leyó/escribió/ejecutó),
|
| 181 |
+
// enlazado justo debajo de su tool-call — no solo el nombre de la acción.
|
| 182 |
+
const t = '→ ' + (ev.text || '(sin salida)');
|
| 183 |
+
spawnStar(channel, t, 'line');
|
| 184 |
+
logLine(channel, t);
|
| 185 |
+
} else if (ev.type === 'done') {
|
| 186 |
+
flushLine(channel, true);
|
| 187 |
+
// el proveedor puede devolver el texto final SIN pasar por tokens (sin
|
| 188 |
+
// streaming) → si no vimos ningún token, esto es lo único que lo muestra.
|
| 189 |
+
if (ev.text && !streamed.get(channel)) { spawnStar(channel, ev.text, 'line'); logLine(channel, ev.text); }
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// ── nodos de pensamiento clicables (cada .md forjado = un punto brillante) ──
|
| 194 |
+
function addThoughtNode({ path, md, text }) {
|
| 195 |
+
if (!nodesGroup) return;
|
| 196 |
+
const i = thoughtNodes.length;
|
| 197 |
+
const a = i * 2.399963; // ángulo áureo
|
| 198 |
+
const r = 30 + i * 5;
|
| 199 |
+
const mesh = new THREE.Mesh(
|
| 200 |
+
new THREE.IcosahedronGeometry(3.2, 0),
|
| 201 |
+
new THREE.MeshBasicMaterial({ color: 0xff4d8d, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false }));
|
| 202 |
+
mesh.position.set(Math.cos(a) * r, 6 + Math.sin(i * 0.7) * 10, Math.sin(a) * r);
|
| 203 |
+
mesh.userData = { path, md, text };
|
| 204 |
+
nodesGroup.add(mesh);
|
| 205 |
+
thoughtNodes.push(mesh);
|
| 206 |
+
const lab = document.createElement('div');
|
| 207 |
+
lab.className = 'mind-node-label';
|
| 208 |
+
lab.textContent = (path || 'pensamiento').split('/').pop();
|
| 209 |
+
// clicable ella misma — antes SOLO el raycast contra la diminuta malla 3D
|
| 210 |
+
// abría el panel, fácil de fallar; la etiqueta es el blanco visible real.
|
| 211 |
+
lab.addEventListener('click', () => selectThoughtNode(mesh));
|
| 212 |
+
document.getElementById('mind-anchors').appendChild(lab);
|
| 213 |
+
anchors.push({ el: lab, pos: mesh.position });
|
| 214 |
+
mesh.userData.label = lab;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
async function loadExistingThoughts() {
|
| 218 |
+
try { for (const it of await loadThoughts()) addThoughtNode(it); } catch { /* aún no hay */ }
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// selección de un nodo de propuesta — desde la malla 3D (raycast) o su etiqueta
|
| 222 |
+
function selectThoughtNode(mesh) { showThoughtPanel(mesh); focusOn(mesh.position, 26); }
|
| 223 |
+
|
| 224 |
+
function showThoughtPanel(node) {
|
| 225 |
+
const root = document.getElementById('mind-panel');
|
| 226 |
+
const md = node.userData.md || node.userData.text || '(sin contenido)';
|
| 227 |
+
const path = node.userData.path || 'pensamiento';
|
| 228 |
+
root.innerHTML = `<div class="mp-head">${escHtml(path)}<button id="mp-x">✕</button></div>` +
|
| 229 |
+
`<div class="mp-body">${renderMarkdown(md)}</div>` +
|
| 230 |
+
`<div class="mp-actions">` +
|
| 231 |
+
`<button id="mp-exec" class="mp-exec">▶ Ejecutar esta propuesta</button>` +
|
| 232 |
+
(node.userData.path ? `<button id="mp-open" class="mp-open">Abrir en el editor</button>` : '') +
|
| 233 |
+
`</div>`;
|
| 234 |
+
root.classList.add('show');
|
| 235 |
+
root.querySelector('#mp-x').onclick = () => root.classList.remove('show');
|
| 236 |
+
root.querySelector('#mp-exec').onclick = () => { onExecute(md); root.classList.remove('show'); };
|
| 237 |
+
const openBtn = root.querySelector('#mp-open');
|
| 238 |
+
if (openBtn) openBtn.onclick = () => { onOpenFile(node.userData.path); root.classList.remove('show'); };
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// ══ perfiles: anclas 3D + leyenda (recalculadas al abrir o al reprogramar) ══
|
| 242 |
+
function computeAnchors() {
|
| 243 |
+
const map = new Map();
|
| 244 |
+
map.set('ceo', new THREE.Vector3(0, 46, 0));
|
| 245 |
+
const profs = ceo.getProfiles();
|
| 246 |
+
const n = Math.max(profs.length, 1);
|
| 247 |
+
profs.forEach((p, i) => {
|
| 248 |
+
const a = (i / n) * Math.PI * 2;
|
| 249 |
+
map.set(p.id, new THREE.Vector3(Math.cos(a) * 70, 8 + (i % 2) * 20, Math.sin(a) * 70));
|
| 250 |
+
});
|
| 251 |
+
return map;
|
| 252 |
+
}
|
| 253 |
+
function renderLegend() {
|
| 254 |
+
const el = document.getElementById('mind-legend');
|
| 255 |
+
if (!el) return;
|
| 256 |
+
const profs = ceo.getProfiles();
|
| 257 |
+
el.innerHTML = `<div class="ml-item" data-id="ceo"><span class="ml-sw" style="background:${CEO_COLOR}"></span>CEO</div>` +
|
| 258 |
+
profs.map(p => `<div class="ml-item" data-id="${p.id}"><span class="ml-sw" style="background:${p.color}"></span>${escHtml(p.name)}</div>`).join('');
|
| 259 |
+
el.querySelectorAll('.ml-item').forEach(row => {
|
| 260 |
+
row.onclick = () => { const pos = anchorMap.get(row.dataset.id); if (pos) focusOn(pos, 46); };
|
| 261 |
+
});
|
| 262 |
+
}
|
| 263 |
+
// vuelo suave de la cámara hacia un punto — así «clic en algo → la cámara se centra»
|
| 264 |
+
let flyTo = null;
|
| 265 |
+
function focusOn(pos, distance = 40) {
|
| 266 |
+
if (!S) return;
|
| 267 |
+
const dir = new THREE.Vector3().subVectors(S.camera.position, S.controls.target);
|
| 268 |
+
if (dir.lengthSq() < 0.001) dir.set(0, 0.3, 1);
|
| 269 |
+
dir.normalize().multiplyScalar(distance);
|
| 270 |
+
flyTo = { fromPos: S.camera.position.clone(), toPos: pos.clone().add(dir), fromTarget: S.controls.target.clone(), toTarget: pos.clone(), t0: elapsed, dur: 1.1 };
|
| 271 |
+
}
|
| 272 |
+
const easeInOut = k => k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
|
| 273 |
+
function rebuildWorld() { anchorMap = computeAnchors(); renderLegend(); }
|
| 274 |
+
|
| 275 |
+
// ── panel ⚙: reprograma misión, carpeta-alma y PERFILES (editables) ──────
|
| 276 |
+
function wireConfig(root) {
|
| 277 |
+
const btn = root.querySelector('#mind-config');
|
| 278 |
+
const cfg = root.querySelector('#mind-cfg');
|
| 279 |
+
const profRowHtml = p => `<div class="cfg-prof" data-id="${escHtml(p.id || '')}">
|
| 280 |
+
<input type="color" class="cp-color" value="${p.color || '#7c5cff'}">
|
| 281 |
+
<input type="text" class="cp-name" value="${escHtml(p.name || '')}" placeholder="Nombre">
|
| 282 |
+
<input type="text" class="cp-focus" value="${escHtml(p.focus || '')}" placeholder="En qué se centra">
|
| 283 |
+
<button class="cp-del" title="quitar">✕</button></div>`;
|
| 284 |
+
const notifState = () => (!('Notification' in window)) ? { txt: 'no soportadas por este navegador', can: false }
|
| 285 |
+
: Notification.permission === 'granted' ? { txt: '✓ concedidas — avisaré cuando encuentre algo bueno', can: false }
|
| 286 |
+
: Notification.permission === 'denied' ? { txt: '✕ bloqueadas — actívalas en los ajustes del sitio del navegador', can: false }
|
| 287 |
+
: { txt: 'aún no pedidas', can: true };
|
| 288 |
+
const draw = () => {
|
| 289 |
+
const profs = ceo.getProfiles();
|
| 290 |
+
const ns = notifState();
|
| 291 |
+
cfg.innerHTML =
|
| 292 |
+
'<div class="cfg-head">⚙ Reprogramar el cerebro<button id="cfg-x">✕</button></div>' +
|
| 293 |
+
'<div class="cfg-notif">🔔 Notificaciones del navegador: ' + escHtml(ns.txt) + (ns.can ? ' <button id="cfg-notif-ask" class="cfg-notif-btn">Activar</button>' : '') + '</div>' +
|
| 294 |
+
'<label>Misión (qué quieres que haga por su cuenta)</label>' +
|
| 295 |
+
'<textarea id="cfg-mission" rows="3">' + escHtml(ceo.getMission()) + '</textarea>' +
|
| 296 |
+
'<label>Carpeta-alma (dentro del proyecto)</label>' +
|
| 297 |
+
'<input id="cfg-dir" value="' + escHtml(ceo.getSoulDir()) + '">' +
|
| 298 |
+
'<label>Perfiles — cada uno piensa en paralelo, con el color de su estrella</label>' +
|
| 299 |
+
'<div id="cfg-profiles">' + profs.map(profRowHtml).join('') + '</div>' +
|
| 300 |
+
'<button id="cfg-add-prof" class="cfg-add">+ nuevo perfil</button>' +
|
| 301 |
+
'<div class="cfg-actions"><button id="cfg-think-now" class="cfg-ghost">Pensar ahora</button>' +
|
| 302 |
+
'<button id="cfg-save" class="cfg-save">Reprogramar</button></div>' +
|
| 303 |
+
'<div class="cfg-note">La elfa trabajará según esto cuando estés ocioso y volcará todo en esa carpeta.</div>';
|
| 304 |
+
cfg.querySelector('#cfg-x').onclick = () => cfg.classList.remove('show');
|
| 305 |
+
cfg.querySelector('#cfg-notif-ask')?.addEventListener('click', () => { Notification.requestPermission().then(draw); });
|
| 306 |
+
cfg.querySelector('#cfg-add-prof').onclick = () => {
|
| 307 |
+
cfg.querySelector('#cfg-profiles').insertAdjacentHTML('beforeend', profRowHtml({ color: '#ff9f45', name: '', focus: '' }));
|
| 308 |
+
wireRows();
|
| 309 |
+
};
|
| 310 |
+
cfg.querySelector('#cfg-think-now').onclick = () => { ceo.forceCycle(); cfg.classList.remove('show'); };
|
| 311 |
+
cfg.querySelector('#cfg-save').onclick = () => {
|
| 312 |
+
// preserva el id original de cada perfil (si no, cada guardado los
|
| 313 |
+
// regeneraría desde el nombre y los ciclos en curso perderían el hilo)
|
| 314 |
+
const rows = [...cfg.querySelectorAll('.cfg-prof')].map(r => ({
|
| 315 |
+
id: r.dataset.id || undefined,
|
| 316 |
+
color: r.querySelector('.cp-color').value,
|
| 317 |
+
name: r.querySelector('.cp-name').value.trim(),
|
| 318 |
+
focus: r.querySelector('.cp-focus').value.trim(),
|
| 319 |
+
}));
|
| 320 |
+
ceo.setMission(cfg.querySelector('#cfg-mission').value);
|
| 321 |
+
ceo.setSoulDir(cfg.querySelector('#cfg-dir').value);
|
| 322 |
+
ceo.setProfiles(rows);
|
| 323 |
+
if (!ceo.isEnabled()) ceo.enable();
|
| 324 |
+
cfg.classList.remove('show');
|
| 325 |
+
};
|
| 326 |
+
wireRows();
|
| 327 |
+
function wireRows() { cfg.querySelectorAll('.cp-del').forEach(b => { b.onclick = () => b.closest('.cfg-prof').remove(); }); }
|
| 328 |
+
};
|
| 329 |
+
btn.onclick = () => { if (cfg.classList.contains('show')) cfg.classList.remove('show'); else { draw(); cfg.classList.add('show'); } };
|
| 330 |
+
}
|
| 331 |
+
function wireHistory(root) {
|
| 332 |
+
const btn = root.querySelector('#mind-history'), panel = root.querySelector('#mind-log');
|
| 333 |
+
btn.onclick = () => panel.classList.toggle('show');
|
| 334 |
+
panel.querySelector('#ml-x').onclick = () => panel.classList.remove('show');
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// play/stop del cerebro, visible dentro de la Mente. La elección la persiste
|
| 338 |
+
// ceo.js (misma clave que usa el clic en la elfa) → sobrevive a recargar.
|
| 339 |
+
function wirePlayStop(root) {
|
| 340 |
+
const btn = root.querySelector('#mind-playstop');
|
| 341 |
+
const paint = () => {
|
| 342 |
+
const on = ceo.isEnabled();
|
| 343 |
+
btn.textContent = on ? '⏸ pausar cerebro' : '▶ reanudar cerebro';
|
| 344 |
+
btn.classList.toggle('paused', !on);
|
| 345 |
+
};
|
| 346 |
+
btn.onclick = () => { if (ceo.isEnabled()) ceo.disable(); else ceo.enable(); paint(); };
|
| 347 |
+
paint();
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// ══ ciudad de fondo (motor de la app anfitriona, por debajo del mundo) ══════
|
| 351 |
+
async function buildBackgroundCity(scene) {
|
| 352 |
+
if (!buildCityFn) return;
|
| 353 |
+
try {
|
| 354 |
+
const city = await buildCityFn(scene);
|
| 355 |
+
if (!city) return;
|
| 356 |
+
cityWrap = city.cityWrap; cityCity = city.cityCity; cityModel = city.cityModel;
|
| 357 |
+
} catch { /* sigue sin ciudad de fondo */ }
|
| 358 |
+
}
|
| 359 |
+
// haz de luz que baja hasta el fichero real en la ciudad + resalta su celda
|
| 360 |
+
function fileBeam(path, kind) {
|
| 361 |
+
if (!cityModel || !cityCity || !S) return;
|
| 362 |
+
const rel = String(path).replace(/^\.?\//, '');
|
| 363 |
+
const f = cityModel.fileByRel.get(rel);
|
| 364 |
+
if (!f) return;
|
| 365 |
+
const local = new THREE.Vector3(f.box.cx, f.box.cy, f.box.cz);
|
| 366 |
+
const world = cityWrap.localToWorld(local.clone());
|
| 367 |
+
const top = world.clone(); top.y += 50;
|
| 368 |
+
const geo = new THREE.BufferGeometry().setFromPoints([top, world]);
|
| 369 |
+
const mat = new THREE.LineBasicMaterial({ color: kind === 'write' ? 0xff4d8d : 0x49e8ff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false });
|
| 370 |
+
const line = new THREE.Line(geo, mat);
|
| 371 |
+
S.scene.add(line);
|
| 372 |
+
beams.push({ obj: line, born: elapsed, life: 1.4 });
|
| 373 |
+
fileActivity.push({ id: f.id, born: elapsed, life: 2.2 });
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
// ── mundo psicodélico ───────────────────────────────────────────────────────
|
| 377 |
+
function buildScene(canvas, W, H) {
|
| 378 |
+
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
| 379 |
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
| 380 |
+
renderer.setSize(W, H, false);
|
| 381 |
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
| 382 |
+
const scene = new THREE.Scene();
|
| 383 |
+
const camera = new THREE.PerspectiveCamera(60, W / H, 0.1, 2000);
|
| 384 |
+
camera.position.set(0, 40, 150);
|
| 385 |
+
const controls = new OrbitControls(camera, canvas);
|
| 386 |
+
controls.enableDamping = true; controls.dampingFactor = 0.06; controls.autoRotate = false;
|
| 387 |
+
controls.enablePan = false; controls.minDistance = 60; controls.maxDistance = 420;
|
| 388 |
+
|
| 389 |
+
const N = 14000;
|
| 390 |
+
const pos = new Float32Array(N * 3), rad = new Float32Array(N);
|
| 391 |
+
let s = 987654321; const rnd = () => (s = (s * 16807) % 2147483647) / 2147483647;
|
| 392 |
+
for (let i = 0; i < N; i++) {
|
| 393 |
+
const arm = Math.floor(rnd() * 4);
|
| 394 |
+
const r = Math.pow(rnd(), 0.6) * 120 + 6;
|
| 395 |
+
const a = arm * (Math.PI / 2) + r * 0.03 + (rnd() - 0.5) * 0.5;
|
| 396 |
+
const y = (rnd() - 0.5) * (14 - r * 0.06);
|
| 397 |
+
pos[i * 3] = Math.cos(a) * r; pos[i * 3 + 1] = y; pos[i * 3 + 2] = Math.sin(a) * r; rad[i] = r;
|
| 398 |
+
}
|
| 399 |
+
const geo = new THREE.BufferGeometry();
|
| 400 |
+
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
| 401 |
+
geo.setAttribute('aRad', new THREE.BufferAttribute(rad, 1));
|
| 402 |
+
const mat = new THREE.ShaderMaterial({
|
| 403 |
+
uniforms: { uTime: { value: 0 }, uBass: { value: 0 }, uMid: { value: 0 } },
|
| 404 |
+
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
|
| 405 |
+
vertexShader: `uniform float uTime, uBass, uMid; attribute float aRad; varying vec3 vC;
|
| 406 |
+
vec3 hsv(float h){ vec3 k=vec3(1.,2./3.,1./3.); vec3 p=abs(fract(h+k)*6.-3.); return clamp(p-1.,0.,1.);}
|
| 407 |
+
float hash(vec3 p){ p=fract(p*0.3183099+0.1); p*=17.0; return fract(p.x*p.y*p.z*(p.x+p.y+p.z)); }
|
| 408 |
+
float vnoise(vec3 x){ vec3 i=floor(x),f=fract(x); f=f*f*(3.0-2.0*f);
|
| 409 |
+
return mix(mix(mix(hash(i+vec3(0,0,0)),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y),
|
| 410 |
+
mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z); }
|
| 411 |
+
float fbm(vec3 p){ float a=0.5,s=0.0; for(int i=0;i<4;i++){ s+=a*vnoise(p); p*=2.02; a*=0.5;} return s; }
|
| 412 |
+
void main(){
|
| 413 |
+
vec3 pos=position;
|
| 414 |
+
float n=fbm(pos*0.03 + vec3(0.0,uTime*0.18,0.0));
|
| 415 |
+
pos += normalize(pos+0.001) * (n-0.5) * (5.0 + uMid*4.0 + uBass*7.0);
|
| 416 |
+
float sw = uTime*0.14 + aRad*0.015 + uBass*0.18;
|
| 417 |
+
float cs=cos(sw), sn=sin(sw);
|
| 418 |
+
pos.xz = mat2(cs,-sn,sn,cs) * pos.xz;
|
| 419 |
+
pos *= (1.0 + uBass*0.05);
|
| 420 |
+
float h=fract(aRad*0.004+uTime*0.03+n*0.12); vC=hsv(h)*(1.3-aRad*0.004)*(0.95+uMid*0.18+uBass*0.12);
|
| 421 |
+
vec4 mv=modelViewMatrix*vec4(pos,1.0); gl_PointSize=(2.2+60.0/-mv.z)*(1.0+uBass*0.18); gl_Position=projectionMatrix*mv; }`,
|
| 422 |
+
fragmentShader: `varying vec3 vC; void main(){ vec2 d=gl_PointCoord-0.5; float m=smoothstep(0.5,0.0,length(d)); gl_FragColor=vec4(vC*2.0,m); }`,
|
| 423 |
+
});
|
| 424 |
+
const galaxy = new THREE.Points(geo, mat); scene.add(galaxy);
|
| 425 |
+
const core = new THREE.LineSegments(new THREE.WireframeGeometry(new THREE.IcosahedronGeometry(18, 1)),
|
| 426 |
+
new THREE.LineBasicMaterial({ color: 0xff4d8d, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false }));
|
| 427 |
+
scene.add(core);
|
| 428 |
+
const rings = [];
|
| 429 |
+
for (let i = 0; i < 24; i++) {
|
| 430 |
+
const ring = new THREE.Mesh(new THREE.TorusGeometry(22, 0.5, 8, 80),
|
| 431 |
+
new THREE.MeshBasicMaterial({ color: 0x7c5cff, transparent: true, opacity: 0.45, blending: THREE.AdditiveBlending, depthWrite: false }));
|
| 432 |
+
ring.rotation.x = Math.PI / 2; ring.position.y = -70 + i * 6; ring.userData.i = i;
|
| 433 |
+
scene.add(ring); rings.push(ring);
|
| 434 |
+
}
|
| 435 |
+
nodesGroup = new THREE.Group(); scene.add(nodesGroup);
|
| 436 |
+
starGroup = new THREE.Group(); scene.add(starGroup);
|
| 437 |
+
const composer = new EffectComposer(renderer);
|
| 438 |
+
composer.setSize(W, H); composer.addPass(new RenderPass(scene, camera));
|
| 439 |
+
const bloom = new UnrealBloomPass(new THREE.Vector2(W, H), 1.1, 0.5, 0.2);
|
| 440 |
+
composer.addPass(bloom); composer.addPass(new OutputPass());
|
| 441 |
+
return { renderer, scene, camera, controls, composer, bloom, mat, galaxy, core, rings };
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
export async function openMind() {
|
| 445 |
+
if (open) return;
|
| 446 |
+
open = true;
|
| 447 |
+
if (built) { overlay.style.display = ''; try { widget?.play(); } catch {} startLoop(); return; }
|
| 448 |
+
|
| 449 |
+
overlay = document.createElement('div');
|
| 450 |
+
overlay.id = 'mind-overlay';
|
| 451 |
+
overlay.innerHTML =
|
| 452 |
+
'<canvas id="mind-canvas"></canvas>' +
|
| 453 |
+
'<div id="mind-title">MENTE DE ELFFUSS</div>' +
|
| 454 |
+
'<div id="mind-legend"></div>' +
|
| 455 |
+
'<button id="mind-playstop" title="pausar/reanudar el cerebro"></button>' +
|
| 456 |
+
'<button id="mind-config" title="reprogramar el cerebro">⚙ cerebro</button>' +
|
| 457 |
+
'<button id="mind-history" title="ver todo lo que ha llegado">≡ historial</button>' +
|
| 458 |
+
'<button id="mind-close" title="salir">✕ salir</button>' +
|
| 459 |
+
'<div id="mind-anchors"></div><div id="mind-panel"></div><div id="mind-cfg"></div>' +
|
| 460 |
+
'<div id="mind-log"><div class="ml-head">Historial completo<button id="ml-x">✕</button></div><div id="mind-log-body"></div></div>';
|
| 461 |
+
document.body.appendChild(overlay);
|
| 462 |
+
wireConfig(overlay); wireHistory(overlay); wirePlayStop(overlay);
|
| 463 |
+
mountMusic(overlay);
|
| 464 |
+
anchors.length = 0; thoughtNodes = []; stars = []; lineBuf.clear();
|
| 465 |
+
rebuildWorld();
|
| 466 |
+
|
| 467 |
+
const canvas = overlay.querySelector('#mind-canvas');
|
| 468 |
+
S = buildScene(canvas, window.innerWidth, window.innerHeight);
|
| 469 |
+
buildBackgroundCity(S.scene);
|
| 470 |
+
await loadExistingThoughts();
|
| 471 |
+
await loadPersistedLog(); // el historial de «≡ historial» sobrevive a recargas
|
| 472 |
+
|
| 473 |
+
const ray = new THREE.Raycaster(), ptr = new THREE.Vector2();
|
| 474 |
+
let downXY = null;
|
| 475 |
+
canvas.addEventListener('pointerdown', e => { downXY = [e.clientX, e.clientY]; });
|
| 476 |
+
canvas.addEventListener('pointerup', e => {
|
| 477 |
+
if (!downXY) return; const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]); downXY = null;
|
| 478 |
+
if (moved > 5 || !thoughtNodes.length) return;
|
| 479 |
+
ptr.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
|
| 480 |
+
ray.setFromCamera(ptr, S.camera);
|
| 481 |
+
const hit = ray.intersectObjects(thoughtNodes)[0];
|
| 482 |
+
if (hit) selectThoughtNode(hit.object);
|
| 483 |
+
});
|
| 484 |
+
|
| 485 |
+
const onResize = () => {
|
| 486 |
+
const w = window.innerWidth, h = window.innerHeight;
|
| 487 |
+
S.renderer.setSize(w, h, false); S.composer.setSize(w, h); S.bloom.resolution.set(w, h);
|
| 488 |
+
S.camera.aspect = w / h; S.camera.updateProjectionMatrix();
|
| 489 |
+
};
|
| 490 |
+
window.addEventListener('resize', onResize); S.onResize = onResize;
|
| 491 |
+
overlay.querySelector('#mind-close').addEventListener('click', closeMind);
|
| 492 |
+
S.onKey = e => { if (e.key === 'Escape') closeMind(); };
|
| 493 |
+
document.addEventListener('keydown', S.onKey);
|
| 494 |
+
built = true;
|
| 495 |
+
startLoop();
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
// Etiquetas de propuestas: se proyectan del mundo 3D a la pantalla, así que
|
| 499 |
+
// con muchas acumuladas acaban solapándose ilegibles según el ángulo de
|
| 500 |
+
// cámara. Aquí: cap a las más cercanas y reparto anti-solape (empuja hacia
|
| 501 |
+
// abajo la que choca con otra ya colocada), para que se apilen separadas.
|
| 502 |
+
const MAX_LABELS = 14, LABEL_W = 90, LABEL_H = 20;
|
| 503 |
+
function projectAnchors() {
|
| 504 |
+
const w = window.innerWidth, h = window.innerHeight, v = new THREE.Vector3();
|
| 505 |
+
const projected = [];
|
| 506 |
+
for (const a of anchors) {
|
| 507 |
+
v.copy(a.pos).project(S.camera);
|
| 508 |
+
if (v.z > 1) { a.el.style.display = 'none'; continue; } // detrás de la cámara
|
| 509 |
+
projected.push({ a, x: (v.x * 0.5 + 0.5) * w, y: (-v.y * 0.5 + 0.5) * h, depth: v.z });
|
| 510 |
+
}
|
| 511 |
+
projected.sort((p1, p2) => p1.depth - p2.depth); // las más cercanas a la cámara, prioridad
|
| 512 |
+
projected.forEach((p, i) => { p.a.el.style.display = i < MAX_LABELS ? '' : 'none'; });
|
| 513 |
+
const visible = projected.slice(0, MAX_LABELS).sort((p1, p2) => p1.x - p2.x || p1.y - p2.y);
|
| 514 |
+
const placed = [];
|
| 515 |
+
for (const p of visible) {
|
| 516 |
+
let y = p.y;
|
| 517 |
+
for (const q of placed) if (Math.abs(q.x - p.x) < LABEL_W && Math.abs(q.y - y) < LABEL_H) y = q.y + LABEL_H;
|
| 518 |
+
placed.push({ x: p.x, y });
|
| 519 |
+
p.a.el.style.left = p.x + 'px'; p.a.el.style.top = y + 'px';
|
| 520 |
+
p.a.el.style.transform = 'translate(-50%,-50%)';
|
| 521 |
+
}
|
| 522 |
+
}
|
| 523 |
+
const wob = (t, seed, freq = 0.1) => Math.sin(t * freq + seed) * 0.6 + Math.sin(t * freq * 0.43 + seed * 2.1) * 0.3 + Math.sin(t * freq * 0.19 + seed * 4.7) * 0.1;
|
| 524 |
+
|
| 525 |
+
let elapsed = 0, lastMs = 0, smBass = 0, smMid = 0;
|
| 526 |
+
function startLoop() {
|
| 527 |
+
if (raf) return;
|
| 528 |
+
const loop = (ms) => {
|
| 529 |
+
raf = requestAnimationFrame(loop);
|
| 530 |
+
const dt = lastMs ? (ms - lastMs) / 1000 : 0;
|
| 531 |
+
elapsed += dt; lastMs = ms;
|
| 532 |
+
const t = elapsed;
|
| 533 |
+
const beat = 60 / 138;
|
| 534 |
+
const bp = (t % beat) / beat;
|
| 535 |
+
const bassRaw = Math.pow(1 - bp, 3);
|
| 536 |
+
const midRaw = 0.5 + 0.5 * Math.sin(t * 6.0);
|
| 537 |
+
smBass += (bassRaw - smBass) * 0.05;
|
| 538 |
+
smMid += (midRaw - smMid) * 0.06;
|
| 539 |
+
S.mat.uniforms.uTime.value = t;
|
| 540 |
+
S.mat.uniforms.uBass.value = smBass;
|
| 541 |
+
S.mat.uniforms.uMid.value = smMid;
|
| 542 |
+
S.galaxy.rotation.y = t * 0.05;
|
| 543 |
+
S.galaxy.rotation.x = wob(t, 1.7, 0.05) * 0.15;
|
| 544 |
+
S.galaxy.rotation.z = wob(t, 5.2, 0.04) * 0.1;
|
| 545 |
+
S.galaxy.scale.setScalar(1 + smBass * 0.02);
|
| 546 |
+
const pulse = 1 + smBass * 0.05 + Math.sin(t * 0.9) * 0.03;
|
| 547 |
+
S.core.scale.setScalar(pulse); S.core.rotation.y = t * 0.16; S.core.rotation.x = t * 0.09;
|
| 548 |
+
S.core.position.x = wob(t, 3.1, 0.11) * 6; S.core.position.z = wob(t, 9.4, 0.09) * 6;
|
| 549 |
+
S.core.material.color.setHSL((t * 0.04) % 1, 0.9, 0.6);
|
| 550 |
+
for (const r of S.rings) {
|
| 551 |
+
r.position.y += 0.14 + smBass * 0.12;
|
| 552 |
+
if (r.position.y > 74) r.position.y -= 144;
|
| 553 |
+
r.position.x = wob(t, r.userData.i * 1.3 + 1, 0.08) * 3;
|
| 554 |
+
r.scale.setScalar(1 + smBass * 0.12 + Math.sin(t * 0.8 + r.userData.i) * 0.04);
|
| 555 |
+
r.material.opacity = 0.2 + smBass * 0.16;
|
| 556 |
+
r.material.color.setHSL((t * 0.05 + r.userData.i * 0.03) % 1, 0.85, 0.6);
|
| 557 |
+
}
|
| 558 |
+
for (let i = 0; i < thoughtNodes.length; i++) { const n = thoughtNodes[i]; n.rotation.y = t + i; n.scale.setScalar(1 + Math.sin(t * 2 + i) * 0.14); }
|
| 559 |
+
// estrellas: nacen (fade-in), viven, se apagan solas y se reciclan
|
| 560 |
+
for (let i = stars.length - 1; i >= 0; i--) {
|
| 561 |
+
const st = stars[i], age = t - st.born;
|
| 562 |
+
if (age > st.life) { starGroup.remove(st.obj); st.obj.material.map.dispose(); st.obj.material.dispose(); stars.splice(i, 1); continue; }
|
| 563 |
+
const fadeIn = Math.min(1, age / 0.5), fadeOut = Math.min(1, (st.life - age) / (st.life * 0.35));
|
| 564 |
+
st.obj.material.opacity = Math.min(fadeIn, fadeOut);
|
| 565 |
+
st.obj.position.y += st.up * dt;
|
| 566 |
+
st.obj.position.x += Math.sin(t * 0.6 + st.seed) * 0.3 * dt;
|
| 567 |
+
}
|
| 568 |
+
// haces sobre la ciudad + celdas de fichero resaltadas por actividad
|
| 569 |
+
for (let i = beams.length - 1; i >= 0; i--) {
|
| 570 |
+
const b = beams[i], age = t - b.born;
|
| 571 |
+
if (age > b.life) { S.scene.remove(b.obj); b.obj.geometry.dispose(); b.obj.material.dispose(); beams.splice(i, 1); continue; }
|
| 572 |
+
b.obj.material.opacity = 0.9 * Math.max(0, 1 - age / b.life);
|
| 573 |
+
}
|
| 574 |
+
if (cityCity) for (let i = fileActivity.length - 1; i >= 0; i--) {
|
| 575 |
+
const a = fileActivity[i], age = t - a.born;
|
| 576 |
+
if (age > a.life) { cityCity.paintFile(a.id, 0); fileActivity.splice(i, 1); continue; }
|
| 577 |
+
cityCity.paintFile(a.id, Math.max(0, 1 - age / a.life));
|
| 578 |
+
}
|
| 579 |
+
if (flyTo) {
|
| 580 |
+
const k = Math.min(1, (t - flyTo.t0) / flyTo.dur), e = easeInOut(k);
|
| 581 |
+
S.camera.position.lerpVectors(flyTo.fromPos, flyTo.toPos, e);
|
| 582 |
+
S.controls.target.lerpVectors(flyTo.fromTarget, flyTo.toTarget, e);
|
| 583 |
+
if (k >= 1) flyTo = null;
|
| 584 |
+
}
|
| 585 |
+
S.controls.update();
|
| 586 |
+
projectAnchors();
|
| 587 |
+
S.composer.render();
|
| 588 |
+
};
|
| 589 |
+
loop(performance.now());
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
export function closeMind() {
|
| 593 |
+
open = false;
|
| 594 |
+
if (raf) { cancelAnimationFrame(raf); raf = null; }
|
| 595 |
+
lastMs = 0;
|
| 596 |
+
try { widget?.pause(); } catch {}
|
| 597 |
+
if (overlay) overlay.style.display = 'none';
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
// para tests/depuración
|
| 601 |
+
export function _debug() {
|
| 602 |
+
return {
|
| 603 |
+
starCount: stars.length, hasCity: !!cityCity, profileCount: ceo.getProfiles().length,
|
| 604 |
+
beamCount: beams.length, fileActivityCount: fileActivity.length,
|
| 605 |
+
cameraPos: S ? [S.camera.position.x, S.camera.position.y, S.camera.position.z] : null,
|
| 606 |
+
flying: !!flyTo,
|
| 607 |
+
};
|
| 608 |
+
}
|
| 609 |
+
export function _debugFocusOn(id) { const p = anchorMap.get(id); if (p) focusOn(p, 46); }
|
js/model-cache.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Arranque de la caché de modelos: (1) pide almacenamiento PERSISTENTE para que
|
| 2 |
+
// el navegador no desaloje los pesos cacheados, y (2) registra el service worker
|
| 3 |
+
// (sw.js) y espera a que CONTROLE la página antes de descargar nada — así hasta
|
| 4 |
+
// la primera descarga queda cacheada. Idempotente y a prueba de fallos.
|
| 5 |
+
let started = null;
|
| 6 |
+
|
| 7 |
+
export function ensureModelCache() {
|
| 8 |
+
if (started) return started;
|
| 9 |
+
started = (async () => {
|
| 10 |
+
try {
|
| 11 |
+
if (navigator.storage && navigator.storage.persist) {
|
| 12 |
+
const persisted = navigator.storage.persisted ? await navigator.storage.persisted() : false;
|
| 13 |
+
const ok = persisted || await navigator.storage.persist();
|
| 14 |
+
console.log('[elffuss] almacenamiento persistente:', ok);
|
| 15 |
+
}
|
| 16 |
+
} catch { /* no soportado */ }
|
| 17 |
+
|
| 18 |
+
if (!('serviceWorker' in navigator)) return false;
|
| 19 |
+
try {
|
| 20 |
+
await navigator.serviceWorker.register('sw.js');
|
| 21 |
+
await navigator.serviceWorker.ready;
|
| 22 |
+
if (!navigator.serviceWorker.controller) {
|
| 23 |
+
await new Promise(res => {
|
| 24 |
+
const t = setTimeout(res, 1500);
|
| 25 |
+
navigator.serviceWorker.addEventListener('controllerchange', () => { clearTimeout(t); res(); }, { once: true });
|
| 26 |
+
});
|
| 27 |
+
}
|
| 28 |
+
console.log('[elffuss] caché de modelos activa (service worker)');
|
| 29 |
+
return true;
|
| 30 |
+
} catch (e) {
|
| 31 |
+
console.warn('[elffuss] service worker no disponible:', e.message);
|
| 32 |
+
return false;
|
| 33 |
+
}
|
| 34 |
+
})();
|
| 35 |
+
return started;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Espacio ocupado por la caché (para mostrarlo en Ajustes).
|
| 39 |
+
export async function cacheEstimate() {
|
| 40 |
+
try {
|
| 41 |
+
const e = await navigator.storage.estimate();
|
| 42 |
+
return { usage: e.usage || 0, quota: e.quota || 0, persisted: navigator.storage.persisted ? await navigator.storage.persisted() : false };
|
| 43 |
+
} catch { return { usage: 0, quota: 0, persisted: false }; }
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Vaciar la caché de modelos (botón en Ajustes).
|
| 47 |
+
export async function clearModelCache() {
|
| 48 |
+
if (navigator.serviceWorker && navigator.serviceWorker.controller)
|
| 49 |
+
navigator.serviceWorker.controller.postMessage('clear-models');
|
| 50 |
+
try { await caches.delete('elffuss-models-v1'); } catch { /* */ }
|
| 51 |
+
}
|
js/model-config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Qué modelo carga el proveedor ONNX (transformers.js).
|
| 2 |
+
//
|
| 3 |
+
// Hoy: LFM2.5-1.2B-Instruct de Liquid (ago 850 MB q4, agentic-first) — ganador
|
| 4 |
+
// del análisis empírico de coordinacion/CANDIDATOS-MODELO.md. Requiere tf.js v4.
|
| 5 |
+
//
|
| 6 |
+
// Mañana («modelo propio»): exporta tu fine-tune a ONNX con optimum,
|
| 7 |
+
// sube las carpetas a web/models/<id>/ y pon selfHosted: true.
|
| 8 |
+
// Ver README § «Modelo propio».
|
| 9 |
+
export const MODEL = {
|
| 10 |
+
label: 'Elffuss LM (healed · LFM2.5-1.2B)',
|
| 11 |
+
id: 'KikoCis/Elffuss-LM-1.2B-ONNX', // nuestro heal agéntico de LFM2.5 (tool-calls + apps)
|
| 12 |
+
dtype: 'q4', // ¡NO q4f16! genera basura vía WebGPU (verificado en la bitácora)
|
| 13 |
+
selfHosted: false,
|
| 14 |
+
basePath: '/models/', // solo se usa con selfHosted: true
|
| 15 |
+
};
|
| 16 |
+
// NO subir a Qwen2.5-1.5B: su q4 (1.8 GB) revienta onnxruntime-web al crear la
|
| 17 |
+
// sesión (OOM del heap wasm de 4 GB, throw numérico ~3.3e9). Límite práctico
|
| 18 |
+
// medido: ≤~1 GB en disco → ver coordinacion/ERRORES.md E-005 y NECESIDADES N-002.
|
| 19 |
+
|
| 20 |
+
// Los modelos EXTERNOS (OpenAI, Anthropic, Ollama local y el servidor Ornith)
|
| 21 |
+
// son configuración avanzada opt-in → js/settings.js + js/providers/api.js.
|
| 22 |
+
// Ornith DEGENERA con temperatura baja (temp 1.0 / top_p 0.95 obligatorios) y
|
| 23 |
+
// en la CPU del servidor razona lento → thinking desactivado por defecto.
|
js/providers/api.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Proveedor genérico para APIs externas (configuración avanzada):
|
| 2 |
+
// - kind 'openai' → /chat/completions (OpenAI, Ollama, llama-server…)
|
| 3 |
+
// - kind 'anthropic' → /v1/messages (Claude)
|
| 4 |
+
// Las llamadas salen DIRECTAS del navegador del usuario al proveedor; la clave
|
| 5 |
+
// no pasa por ningún servidor nuestro. Streaming SSE en ambos dialectos.
|
| 6 |
+
import { packHistory } from '../context.js';
|
| 7 |
+
|
| 8 |
+
let cfg = null;
|
| 9 |
+
export let name = 'API';
|
| 10 |
+
|
| 11 |
+
export function configure(c) { cfg = c; name = c.label; }
|
| 12 |
+
|
| 13 |
+
export async function load() {
|
| 14 |
+
if (!cfg) throw new Error('proveedor sin configurar');
|
| 15 |
+
if (cfg.kind !== 'anthropic' && !cfg.apiKey && !cfg.baseURL.includes('localhost') && cfg.baseURL !== '/v1')
|
| 16 |
+
throw new Error('falta la clave de API (config avanzada)');
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export async function chat(history, system, onToken = () => {}) {
|
| 20 |
+
return cfg.kind === 'anthropic'
|
| 21 |
+
? anthropicChat(history, system, onToken)
|
| 22 |
+
: openaiChat(history, system, onToken);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// ---- OpenAI-compatible ----
|
| 26 |
+
async function openaiChat(history, system, onToken) {
|
| 27 |
+
const headers = { 'Content-Type': 'application/json' };
|
| 28 |
+
if (cfg.apiKey) headers.Authorization = 'Bearer ' + cfg.apiKey;
|
| 29 |
+
const body = {
|
| 30 |
+
model: cfg.model,
|
| 31 |
+
messages: [{ role: 'system', content: system }, ...packHistory(history, 3000)],
|
| 32 |
+
stream: true,
|
| 33 |
+
max_tokens: cfg.maxTokens || 1024,
|
| 34 |
+
};
|
| 35 |
+
if (cfg.temperature != null) body.temperature = cfg.temperature;
|
| 36 |
+
if (cfg.top_p != null) body.top_p = cfg.top_p;
|
| 37 |
+
if (cfg.thinking != null) body.chat_template_kwargs = { enable_thinking: cfg.thinking };
|
| 38 |
+
|
| 39 |
+
const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/chat/completions', {
|
| 40 |
+
method: 'POST', headers, body: JSON.stringify(body),
|
| 41 |
+
});
|
| 42 |
+
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
|
| 43 |
+
|
| 44 |
+
let out = '';
|
| 45 |
+
await readSSE(res.body, payload => {
|
| 46 |
+
if (payload === '[DONE]') return;
|
| 47 |
+
const d = JSON.parse(payload).choices?.[0]?.delta || {};
|
| 48 |
+
if (d.reasoning_content) onToken(d.reasoning_content);
|
| 49 |
+
if (d.content) { out += d.content; onToken(d.content); }
|
| 50 |
+
});
|
| 51 |
+
return out.trim();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// ---- Anthropic Messages ----
|
| 55 |
+
async function anthropicChat(history, system, onToken) {
|
| 56 |
+
const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/v1/messages', {
|
| 57 |
+
method: 'POST',
|
| 58 |
+
headers: {
|
| 59 |
+
'Content-Type': 'application/json',
|
| 60 |
+
'x-api-key': cfg.apiKey,
|
| 61 |
+
'anthropic-version': '2023-06-01',
|
| 62 |
+
'anthropic-dangerous-direct-browser-access': 'true',
|
| 63 |
+
},
|
| 64 |
+
body: JSON.stringify({
|
| 65 |
+
model: cfg.model,
|
| 66 |
+
max_tokens: cfg.maxTokens || 1024,
|
| 67 |
+
system,
|
| 68 |
+
stream: true,
|
| 69 |
+
messages: forAnthropic(packHistory(history, 3000)),
|
| 70 |
+
}),
|
| 71 |
+
});
|
| 72 |
+
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
|
| 73 |
+
|
| 74 |
+
let out = '';
|
| 75 |
+
await readSSE(res.body, payload => {
|
| 76 |
+
const evt = JSON.parse(payload);
|
| 77 |
+
if (evt.type === 'content_block_delta' && evt.delta?.text) {
|
| 78 |
+
out += evt.delta.text; onToken(evt.delta.text);
|
| 79 |
+
}
|
| 80 |
+
});
|
| 81 |
+
return out.trim();
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Anthropic exige roles alternos empezando por user; fusiona consecutivos.
|
| 85 |
+
function forAnthropic(msgs) {
|
| 86 |
+
const merged = [];
|
| 87 |
+
for (const m of msgs) {
|
| 88 |
+
const role = m.role === 'assistant' ? 'assistant' : 'user';
|
| 89 |
+
const last = merged[merged.length - 1];
|
| 90 |
+
if (last && last.role === role) last.content += '\n' + m.content;
|
| 91 |
+
else merged.push({ role, content: m.content });
|
| 92 |
+
}
|
| 93 |
+
if (merged[0]?.role === 'assistant') merged.unshift({ role: 'user', content: '(continúa)' });
|
| 94 |
+
return merged;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Lector SSE común: invoca fn con el texto tras cada 'data:'.
|
| 98 |
+
async function readSSE(stream, fn) {
|
| 99 |
+
const reader = stream.getReader();
|
| 100 |
+
const dec = new TextDecoder();
|
| 101 |
+
let buf = '';
|
| 102 |
+
for (;;) {
|
| 103 |
+
const { done, value } = await reader.read();
|
| 104 |
+
if (done) break;
|
| 105 |
+
buf += dec.decode(value, { stream: true });
|
| 106 |
+
let nl;
|
| 107 |
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
| 108 |
+
const line = buf.slice(0, nl).trim();
|
| 109 |
+
buf = buf.slice(nl + 1);
|
| 110 |
+
if (!line.startsWith('data:')) continue;
|
| 111 |
+
const payload = line.slice(5).trim();
|
| 112 |
+
if (!payload) continue;
|
| 113 |
+
try { fn(payload); } catch { /* chunk parcial */ }
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
}
|
js/providers/litert.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Gemma-4 vía LiteRT-LM de Google (early preview, solo WebGPU).
|
| 2 |
+
// Patrón copiado de la demo verificada en agentic-install
|
| 3 |
+
// (lab/bitacora/posts/08-jspace-live.html).
|
| 4 |
+
// DECISIÓN 2026-07-14: cerebro = Gemma BASE (builds oficiales litert-community,
|
| 5 |
+
// formato artisan). NO fine-tune propio: `@litert-lm/core` exige empaquetado
|
| 6 |
+
// artisan y nuestras conversiones no lo producen (E-010). La agéntica va por el
|
| 7 |
+
// system prompt (agent.js), no por pesos.
|
| 8 |
+
export let name = 'Gemma · LiteRT-LM';
|
| 9 |
+
|
| 10 |
+
// Builds .litertlm elegibles. Los «-web» OFICIALES de Google (litert-community)
|
| 11 |
+
// están exportados en formato artisan → SÍ cargan en el navegador (son los que
|
| 12 |
+
// usaba la demo original). El healed de Elffuss es prefill_decode → hoy no carga
|
| 13 |
+
// (E-010), por eso está gateado en el selector.
|
| 14 |
+
export const MODELS = {
|
| 15 |
+
'gemma-e2b': { url: 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it-web.litertlm', label: 'Gemma-4 E2B', tag: '~2 GB · ligero' },
|
| 16 |
+
'gemma-e4b': { url: 'https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.litertlm', label: 'Gemma-4 E4B', tag: '~4 GB · el mejor' },
|
| 17 |
+
'elffuss-e4b': { url: 'https://huggingface.co/KikoCis/Elffuss-Gemma4-E4B-litert/resolve/main/model.litertlm', label: 'Elffuss E4B (healed)', tag: 'modelo propio' },
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
let MODEL_URL = MODELS['gemma-e2b'].url;
|
| 21 |
+
let curLabel = MODELS['gemma-e2b'].label;
|
| 22 |
+
export function configure(key) {
|
| 23 |
+
const m = MODELS[key] || MODELS['gemma-e2b'];
|
| 24 |
+
MODEL_URL = m.url; curLabel = m.label; name = 'Gemma · LiteRT-LM (' + m.label + ')';
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
let engine = null, conversation = null, sentCount = 0, sys = '';
|
| 28 |
+
|
| 29 |
+
// Contexto: probamos de mayor a menor hasta el máximo que acepten el bundle y
|
| 30 |
+
// la memoria GPU — así el contexto queda al tope permitido de serie.
|
| 31 |
+
const CTX_LADDER = [32768, 16384, 8192, 4096];
|
| 32 |
+
export let ctxTokens = 4096; // efectivo tras load() (la UI puede leerlo)
|
| 33 |
+
|
| 34 |
+
export async function load(onProgress = () => {}) {
|
| 35 |
+
if (!navigator.gpu) throw new Error('LiteRT-LM necesita WebGPU (Chrome/Edge modernos)');
|
| 36 |
+
// navigator.gpu puede existir como API sin adaptador real (algunos Linux/
|
| 37 |
+
// drivers, entornos sandboxed…) — comprobarlo YA evita bajar 2-4 GB para
|
| 38 |
+
// descubrir el fallo solo al crear el motor, al final de todo.
|
| 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 |
+
const litertlm = await import('https://cdn.jsdelivr.net/npm/@litert-lm/core/+esm');
|
| 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
|
| 46 |
+
// que el service worker no intercepta → antes se re-descargaba SIEMPRE. Bajándolo
|
| 47 |
+
// aquí queda cacheado de verdad y damos progreso real en MB.
|
| 48 |
+
const model = await cachedModelBlob(MODEL_URL, onProgress);
|
| 49 |
+
onProgress('Preparando el modelo IA en la GPU…');
|
| 50 |
+
let lastErr = null;
|
| 51 |
+
for (const n of CTX_LADDER) {
|
| 52 |
+
try {
|
| 53 |
+
engine = await litertlm.Engine.create({ model, mainExecutorSettings: { maxNumTokens: n } });
|
| 54 |
+
ctxTokens = n;
|
| 55 |
+
lastErr = null;
|
| 56 |
+
break;
|
| 57 |
+
} catch (e) {
|
| 58 |
+
lastErr = e;
|
| 59 |
+
// Errores de formato/carga no dependen del contexto: no insistir con la escalera.
|
| 60 |
+
if (/not supported|tokenizer|format/i.test(String(e?.message))) throw e;
|
| 61 |
+
onProgress(`Contexto ${n} no cabe, probando ${n / 2}…`);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
if (lastErr) throw lastErr;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const MODEL_CACHE = 'elffuss-models-v1';
|
| 68 |
+
// Devuelve el .litertlm como Blob desde Cache Storage; si no está, lo descarga
|
| 69 |
+
// con progreso real y lo cachea (persistente). Ante cualquier fallo, devuelve la
|
| 70 |
+
// URL para que LiteRT lo baje por su cuenta (nunca bloquea la carga del modelo).
|
| 71 |
+
export async function cachedModelBlob(url, onProgress = () => {}) {
|
| 72 |
+
if (!self.caches) return url;
|
| 73 |
+
try {
|
| 74 |
+
const cache = await caches.open(MODEL_CACHE);
|
| 75 |
+
const hit = await cache.match(url);
|
| 76 |
+
if (hit) { onProgress('Cargando el modelo IA desde caché (sin descargar)…'); return await hit.blob(); }
|
| 77 |
+
const net = await fetch(url);
|
| 78 |
+
if (!net.ok || !net.body) return url;
|
| 79 |
+
const total = +net.headers.get('content-length') || 0;
|
| 80 |
+
const t0 = performance.now();
|
| 81 |
+
const [prog, toCache] = net.body.tee();
|
| 82 |
+
(async () => {
|
| 83 |
+
const r = prog.getReader(); let loaded = 0;
|
| 84 |
+
for (;;) { const { done, value } = await r.read(); if (done) break; loaded += value.length; onProgress(fmtBytes(loaded, total, t0)); }
|
| 85 |
+
})().catch(() => {});
|
| 86 |
+
const headers = { 'Content-Type': 'application/octet-stream' };
|
| 87 |
+
if (total) headers['Content-Length'] = String(total);
|
| 88 |
+
await cache.put(url, new Response(toCache, { headers }));
|
| 89 |
+
const cached = await cache.match(url);
|
| 90 |
+
return cached ? await cached.blob() : url;
|
| 91 |
+
} catch { return url; }
|
| 92 |
+
}
|
| 93 |
+
function fmtBytes(loaded, total, t0) {
|
| 94 |
+
const mb = n => (n / 1048576).toFixed(0);
|
| 95 |
+
const secs = (performance.now() - t0) / 1000;
|
| 96 |
+
const spd = secs > 0 ? (loaded / 1048576 / secs).toFixed(1) : '0';
|
| 97 |
+
return total
|
| 98 |
+
? `Descargando el modelo IA… ${mb(loaded)}/${mb(total)} MB (${spd} MB/s) · se cachea para la próxima vez`
|
| 99 |
+
: `Descargando el modelo IA… ${mb(loaded)} MB (${spd} MB/s)`;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Liberar el modelo (vigilante de RAM).
|
| 103 |
+
export async function unload() {
|
| 104 |
+
try { engine?.close?.(); } catch { /* mejor esfuerzo */ }
|
| 105 |
+
engine = null; conversation = null; sentCount = 0;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
export async function chat(history, system, onToken = () => {}) {
|
| 109 |
+
if (!engine) throw new Error('Modelo no cargado');
|
| 110 |
+
// Comparar solo la parte estática del prompt: el CONTEXTO AHORA va al final
|
| 111 |
+
// y cambia cada turno — recrear la conversación tiraría el KV-cache.
|
| 112 |
+
const sysKey = system.slice(0, 200);
|
| 113 |
+
if (!conversation || sysKey !== sys) {
|
| 114 |
+
sys = sysKey;
|
| 115 |
+
conversation = await engine.createConversation({
|
| 116 |
+
preface: { messages: [{ role: 'system', content: system }] },
|
| 117 |
+
// Exprimir el navegador: no persistir los tokens de canal (tool-call/thinking)
|
| 118 |
+
// del modelo en el KV-cache → libera KV → más contexto útil. Y prefill del
|
| 119 |
+
// system prompt al crear la conversación → primera respuesta más rápida.
|
| 120 |
+
filterChannelContentFromKvCache: true,
|
| 121 |
+
prefillPrefaceOnInit: true,
|
| 122 |
+
});
|
| 123 |
+
sentCount = 0;
|
| 124 |
+
}
|
| 125 |
+
// La conversación LiteRT mantiene su propio KV-cache: enviamos solo lo nuevo.
|
| 126 |
+
const fresh = history.slice(sentCount).filter(m => m.role === 'user');
|
| 127 |
+
sentCount = history.length;
|
| 128 |
+
const text = fresh.map(m => m.content).join('\n') || history.at(-1).content;
|
| 129 |
+
|
| 130 |
+
let out = '';
|
| 131 |
+
for await (const chunk of conversation.sendMessageStreaming(text)) {
|
| 132 |
+
for (const item of (chunk.content || []))
|
| 133 |
+
if (item.type === 'text') { out += item.text; onToken(item.text); }
|
| 134 |
+
}
|
| 135 |
+
return out.trim();
|
| 136 |
+
}
|
js/providers/onnx.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Modelo vía ONNX Runtime Web (transformers.js, WebGPU con fallback a wasm).
|
| 2 |
+
// Patrón copiado de la demo verificada en agentic-install
|
| 3 |
+
// (lab/bitacora/posts/08-jspace-live.html): dtype 'q4' obligatorio — q4f16
|
| 4 |
+
// genera basura vía WebGPU incluso con shader-f16.
|
| 5 |
+
import { MODEL } from '../model-config.js';
|
| 6 |
+
import { packHistory } from '../context.js';
|
| 7 |
+
|
| 8 |
+
export const name = MODEL.label;
|
| 9 |
+
let generator = null, TextStreamer = null;
|
| 10 |
+
|
| 11 |
+
export async function load(onProgress = () => {}) {
|
| 12 |
+
if (generator) return; // un solo modelo: nunca recargar/duplicar la sesión
|
| 13 |
+
const tf = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4');
|
| 14 |
+
TextStreamer = tf.TextStreamer;
|
| 15 |
+
if (MODEL.selfHosted) {
|
| 16 |
+
tf.env.allowRemoteModels = false;
|
| 17 |
+
tf.env.localModelPath = MODEL.basePath;
|
| 18 |
+
}
|
| 19 |
+
// navigator.gpu puede EXISTIR como API sin que haya un adaptador real
|
| 20 |
+
// (ciertos Linux/drivers, entornos sandboxed, navegadores headless…) — usar
|
| 21 |
+
// solo la presencia del objeto como señal hace que pipeline() intente
|
| 22 |
+
// WebGPU, falle con "Failed to get GPU adapter" DESPUÉS de descargar el
|
| 23 |
+
// modelo entero, y en preloadModel() eso encadena a probar Gemma (varios
|
| 24 |
+
// GB) para nada. Comprobar el adaptador de verdad antes de elegir.
|
| 25 |
+
let device = 'wasm';
|
| 26 |
+
if (navigator.gpu) {
|
| 27 |
+
try { device = (await navigator.gpu.requestAdapter()) ? 'webgpu' : 'wasm'; }
|
| 28 |
+
catch { device = 'wasm'; }
|
| 29 |
+
}
|
| 30 |
+
generator = await tf.pipeline('text-generation', MODEL.id, {
|
| 31 |
+
device,
|
| 32 |
+
dtype: MODEL.dtype,
|
| 33 |
+
progress_callback: onProgress,
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Liberar el modelo (vigilante de RAM): suelta los buffers wasm/WebGPU.
|
| 38 |
+
export async function unload() {
|
| 39 |
+
try { await generator?.dispose?.(); } catch { /* mejor esfuerzo */ }
|
| 40 |
+
generator = null;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export async function chat(history, system, onToken = () => {}) {
|
| 44 |
+
if (!generator) throw new Error('Modelo no cargado');
|
| 45 |
+
// ACE-lite: eviction por relevancia. Presupuesto amplio (LFM2.5 aguanta
|
| 46 |
+
// contexto largo); el tope POR MENSAJE (context.js) evita que un README
|
| 47 |
+
// gigante dispare «Too many tokens requested».
|
| 48 |
+
const messages = [{ role: 'system', content: system }, ...packHistory(history, 5000)];
|
| 49 |
+
const streamer = new TextStreamer(generator.tokenizer, {
|
| 50 |
+
skip_prompt: true,
|
| 51 |
+
skip_special_tokens: true,
|
| 52 |
+
callback_function: onToken,
|
| 53 |
+
});
|
| 54 |
+
const out = await generator(messages, {
|
| 55 |
+
max_new_tokens: 1024,
|
| 56 |
+
do_sample: false, // determinista: los tool calls JSON lo agradecen
|
| 57 |
+
repetition_penalty: 1.1,
|
| 58 |
+
return_full_text: false,
|
| 59 |
+
streamer,
|
| 60 |
+
});
|
| 61 |
+
const gen = out[0].generated_text;
|
| 62 |
+
return (typeof gen === 'string' ? gen : gen.at(-1).content).trim();
|
| 63 |
+
}
|
js/providers/rules.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Modo básico de Elffuss Code: órdenes deterministas sin modelo.
|
| 2 |
+
export const name = 'Básico (sin modelo)';
|
| 3 |
+
export async function load() {}
|
| 4 |
+
|
| 5 |
+
const call = obj => '```tool\n' + JSON.stringify(obj) + '\n```';
|
| 6 |
+
|
| 7 |
+
const HELP = `Estoy en modo básico (sin modelo). Entiendo:
|
| 8 |
+
• «árbol» / «qué hay en el proyecto»
|
| 9 |
+
• «lee <archivo>» · «abre <archivo>»
|
| 10 |
+
• «busca <texto>»
|
| 11 |
+
• «escribe <archivo>: <contenido>»
|
| 12 |
+
Para programar de verdad, carga el modelo local (selector 🧠) o configura uno en ⚙️.`;
|
| 13 |
+
|
| 14 |
+
export async function chat(history, systemPrompt) {
|
| 15 |
+
const last = history[history.length - 1];
|
| 16 |
+
const text = (last?.content || '').trim();
|
| 17 |
+
|
| 18 |
+
// 🎯 Modo Objetivo (goal.js) también funciona en modo básico: un plan fijo
|
| 19 |
+
// de 2 tareas (explorar + escribir), para poder probar/usar el planificador
|
| 20 |
+
// sin depender de un modelo real cargado.
|
| 21 |
+
if (/^ROL: PLANIFICADOR/.test(systemPrompt || '')) {
|
| 22 |
+
return JSON.stringify({
|
| 23 |
+
plan: 'Plan básico (sin modelo) para: ' + text.slice(0, 60),
|
| 24 |
+
tasks: [
|
| 25 |
+
{ title: 'Explorar el proyecto', description: 'Mira el árbol del proyecto para entender qué hay.' },
|
| 26 |
+
{ title: 'Escribir el resultado', description: 'escribe objetivo.txt: ' + text },
|
| 27 |
+
],
|
| 28 |
+
});
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
if (text.startsWith('[resultado')) {
|
| 32 |
+
const body = text.slice(text.indexOf('\n') + 1).trim();
|
| 33 |
+
return body.startsWith('ERROR:') ? 'No pude: ' + body.slice(6).trim() : body;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const t = text.toLowerCase();
|
| 37 |
+
if (/(árbol|arbol|estructura|qué hay|que hay)/.test(t))
|
| 38 |
+
return call({ tool: 'code.tree', args: {} });
|
| 39 |
+
const mRead = text.match(/(?:lee|abre|muestra|cat)\s+([\w./-]+\.\w+)/i);
|
| 40 |
+
if (mRead) return call({ tool: 'code.read', args: { path: mRead[1] } });
|
| 41 |
+
const mSearch = text.match(/busca(?:r)?\s+(?:d[oó]nde\s+)?(?:se\s+\w+\s+)?["«']?([^"»']{2,60})["»']?$/i);
|
| 42 |
+
if (mSearch) return call({ tool: 'code.search', args: { query: mSearch[1].trim() } });
|
| 43 |
+
const mWrite = text.match(/escribe\s+([\w./-]+\.\w+)\s*[:=]\s*([\s\S]+)/i);
|
| 44 |
+
if (mWrite) return call({ tool: 'code.write', args: { path: mWrite[1], content: mWrite[2] } });
|
| 45 |
+
|
| 46 |
+
if (/^(hola|buenas|hey|hi)\b/.test(t)) return '¡Hola! ' + HELP;
|
| 47 |
+
return HELP;
|
| 48 |
+
}
|
js/settings.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Configuración avanzada de proveedores externos (opt-in). Elffuss corre en
|
| 2 |
+
// LOCAL por defecto; esto solo se usa si el usuario lo activa a mano.
|
| 3 |
+
// Las claves viven en localStorage de SU navegador y nunca salen de aquí
|
| 4 |
+
// (las llamadas van directas del navegador al proveedor).
|
| 5 |
+
const KEY = 'elffusscode.providers';
|
| 6 |
+
|
| 7 |
+
// Plantillas de proveedores externos. `kind` decide el dialecto de API.
|
| 8 |
+
const DEFAULTS = {
|
| 9 |
+
openai: {
|
| 10 |
+
kind: 'openai', label: 'OpenAI', enabled: false,
|
| 11 |
+
baseURL: 'https://api.openai.com/v1', model: 'gpt-4o-mini', apiKey: '',
|
| 12 |
+
help: 'Tu clave sk-… Se queda en tu navegador. OpenAI permite llamadas directas (CORS).',
|
| 13 |
+
},
|
| 14 |
+
anthropic: {
|
| 15 |
+
kind: 'anthropic', label: 'Anthropic (Claude)', enabled: false,
|
| 16 |
+
baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-5', apiKey: '',
|
| 17 |
+
help: 'Tu clave sk-ant-… Se envía la cabecera de acceso directo desde navegador.',
|
| 18 |
+
},
|
| 19 |
+
ollama: {
|
| 20 |
+
kind: 'openai', label: 'Ollama (local)', enabled: false,
|
| 21 |
+
baseURL: 'http://localhost:11434/v1', model: 'llama3.2', apiKey: 'ollama',
|
| 22 |
+
help: 'Ollama en tu máquina. Arráncalo con OLLAMA_ORIGINS=* para permitir el navegador.',
|
| 23 |
+
},
|
| 24 |
+
server: {
|
| 25 |
+
kind: 'openai', label: 'Servidor Elffuss (Ornith 9B)', enabled: false,
|
| 26 |
+
baseURL: 'https://elffuss-claw.utopiaia.com/v1', model: 'ornith-9b', apiKey: '',
|
| 27 |
+
temperature: 1.0, top_p: 0.95, thinking: false,
|
| 28 |
+
help: 'El modelo grande en el servidor de UtopiaIA. No es local: los mensajes salen de tu máquina.',
|
| 29 |
+
},
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
function load() {
|
| 33 |
+
let saved = {};
|
| 34 |
+
try { saved = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { /* corrupto */ }
|
| 35 |
+
const out = {};
|
| 36 |
+
for (const [id, def] of Object.entries(DEFAULTS)) out[id] = { ...def, ...saved[id] };
|
| 37 |
+
return out;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export function configs() { return load(); }
|
| 41 |
+
export function get(id) { return load()[id]; }
|
| 42 |
+
|
| 43 |
+
export function update(id, patch) {
|
| 44 |
+
const all = load();
|
| 45 |
+
all[id] = { ...all[id], ...patch };
|
| 46 |
+
// no persistir los campos de ayuda estáticos
|
| 47 |
+
const slim = {};
|
| 48 |
+
for (const [k, v] of Object.entries(all))
|
| 49 |
+
slim[k] = { enabled: v.enabled, baseURL: v.baseURL, model: v.model, apiKey: v.apiKey };
|
| 50 |
+
localStorage.setItem(KEY, JSON.stringify(slim));
|
| 51 |
+
return all[id];
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Externos habilitados → opciones del selector, como ext:<id>.
|
| 55 |
+
export function enabledExternals() {
|
| 56 |
+
return Object.entries(load())
|
| 57 |
+
.filter(([, c]) => c.enabled)
|
| 58 |
+
.map(([id, c]) => ({ id: 'ext:' + id, label: c.label + (id === 'ollama' || c.baseURL.startsWith('http://localhost') ? ' · local' : ' · nube') }));
|
| 59 |
+
}
|
js/shell.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Shell del proyecto: un intérprete POSIX-lite que opera SOBRE LOS FICHEROS
|
| 2 |
+
// REALES de la carpeta abierta (el FileSystemDirectoryHandle de code.js). No es
|
| 3 |
+
// una emulación: `ls`, `cat`, `grep`, `mkdir`, `rm`, `echo > fichero`… tocan el
|
| 4 |
+
// disco de verdad y el editor/árbol se refrescan. Es el mismo mundo que ve la
|
| 5 |
+
// elfa, así que también lo exponemos como herramienta `terminal.run`.
|
| 6 |
+
//
|
| 7 |
+
// Runtimes reales (node/npm/python): SOLO con un Bridge local conectado (ver
|
| 8 |
+
// bridge.js) se ejecutan de VERDAD en tu máquina — ver `capabilities()`.
|
| 9 |
+
import * as code from './tools/code.js';
|
| 10 |
+
import * as bridgeClient from './bridge.js';
|
| 11 |
+
|
| 12 |
+
let cwd = []; // directorio actual, segmentos relativos a la raíz
|
| 13 |
+
const history = []; // historial de comandos (para ↑/↓ y `history`)
|
| 14 |
+
let onFsChange = () => {}; // el IDE refresca árbol/pestañas tras mutaciones
|
| 15 |
+
let onOpenFile = () => {}; // `open <fichero>` lo abre en Monaco
|
| 16 |
+
|
| 17 |
+
export function setHooks({ fsChange, openFile } = {}) {
|
| 18 |
+
if (fsChange) onFsChange = fsChange;
|
| 19 |
+
if (openFile) onOpenFile = openFile;
|
| 20 |
+
}
|
| 21 |
+
export function reset() { cwd = []; }
|
| 22 |
+
export function cwdString() { return '/' + cwd.join('/'); }
|
| 23 |
+
export function getHistory() { return history.slice(); }
|
| 24 |
+
|
| 25 |
+
// ¿Podemos lanzar runtimes reales? Solo por Bridge local conectado de verdad
|
| 26 |
+
// (WebContainers requiere licencia comercial de StackBlitz en producción —
|
| 27 |
+
// descartado por ahora, ver bridge.js).
|
| 28 |
+
export function capabilities() {
|
| 29 |
+
const bridge = bridgeClient.isConnected();
|
| 30 |
+
return { crossOriginIsolated: !!self.crossOriginIsolated, webcontainers: false, bridge, realRuntime: bridge };
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// ---- resolución de rutas ----
|
| 34 |
+
// Combina un argumento con el cwd y normaliza . y .. → segmentos desde la raíz.
|
| 35 |
+
function resolve(arg = '') {
|
| 36 |
+
const base = arg.startsWith('/') ? [] : [...cwd];
|
| 37 |
+
for (const seg of String(arg).split('/')) {
|
| 38 |
+
if (!seg || seg === '.') continue;
|
| 39 |
+
if (seg === '..') base.pop();
|
| 40 |
+
else base.push(seg);
|
| 41 |
+
}
|
| 42 |
+
return base;
|
| 43 |
+
}
|
| 44 |
+
const root = () => { const h = code.handle(); if (!h) throw new Error('no hay proyecto abierto'); return h; };
|
| 45 |
+
|
| 46 |
+
async function dirHandle(segs, { create = false } = {}) {
|
| 47 |
+
let dir = root();
|
| 48 |
+
for (const s of segs) dir = await dir.getDirectoryHandle(s, { create });
|
| 49 |
+
return dir;
|
| 50 |
+
}
|
| 51 |
+
async function parentAndName(segs) {
|
| 52 |
+
const s = [...segs]; const name = s.pop();
|
| 53 |
+
if (name == null) throw new Error('ruta vacía');
|
| 54 |
+
return { parent: await dirHandle(s), name };
|
| 55 |
+
}
|
| 56 |
+
async function fileHandle(segs, opts) {
|
| 57 |
+
const { parent, name } = await parentAndName(segs);
|
| 58 |
+
return parent.getFileHandle(name, opts);
|
| 59 |
+
}
|
| 60 |
+
async function readText(segs) { return (await (await fileHandle(segs)).getFile()).text(); }
|
| 61 |
+
async function writeText(segs, text) {
|
| 62 |
+
const fh = await fileHandle(segs, { create: true });
|
| 63 |
+
const w = await fh.createWritable(); await w.write(text); await w.close();
|
| 64 |
+
code.invalidateFileList?.(); onFsChange();
|
| 65 |
+
}
|
| 66 |
+
// ¿ruta es directorio? (para `ls fichero`, `grep dir`, `cd`)
|
| 67 |
+
async function kindOf(segs) {
|
| 68 |
+
if (!segs.length) return 'directory';
|
| 69 |
+
const { parent, name } = await parentAndName(segs);
|
| 70 |
+
try { await parent.getDirectoryHandle(name); return 'directory'; } catch {}
|
| 71 |
+
try { await parent.getFileHandle(name); return 'file'; } catch {}
|
| 72 |
+
return null;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
| 76 |
+
const CYAN = s => `\x1b[36m${s}\x1b[0m`, RED = s => `\x1b[31m${s}\x1b[0m`, DIM = s => `\x1b[90m${s}\x1b[0m`, GRN = s => `\x1b[32m${s}\x1b[0m`, YEL = s => `\x1b[33m${s}\x1b[0m`;
|
| 77 |
+
const globToRe = g => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
|
| 78 |
+
const human = n => n < 1024 ? n + 'B' : n < 1048576 ? (n / 1024).toFixed(1) + 'K' : (n / 1048576).toFixed(1) + 'M';
|
| 79 |
+
|
| 80 |
+
// ---- comandos: (args[], stdin) → stdout (string). Lanzan Error en fallo. ----
|
| 81 |
+
const CMDS = {
|
| 82 |
+
help() {
|
| 83 |
+
return 'Elffuss shell — opera sobre los ficheros REALES del proyecto:\n' +
|
| 84 |
+
' ' + GRN('ls') + ' [-la] [ruta] ' + GRN('cd') + ' [ruta] ' + GRN('pwd') + ' ' + GRN('tree') + ' [ruta]\n' +
|
| 85 |
+
' ' + GRN('cat') + ' <f...> ' + GRN('head') + '/' + GRN('tail') + ' [-n N] ' + GRN('wc') + ' [-l] ' + GRN('grep') + ' [-in] <patrón> [ruta]\n' +
|
| 86 |
+
' ' + GRN('find') + ' [ruta] [-name g] ' + GRN('echo') + ' <txt> [> f] ' + GRN('mkdir') + '/' + GRN('touch') + '/' + GRN('rm') + ' [-r]/' + GRN('mv') + '/' + GRN('cp') + '\n' +
|
| 87 |
+
' ' + GRN('open') + ' <f> (en el editor) ' + GRN('git') + ' status|log|branch ' + GRN('history') + ' ' + GRN('clear') + '\n' +
|
| 88 |
+
' Soporta tuberías ' + YEL('|') + ' y redirección ' + YEL('>') + ' ' + YEL('>>') + '. ' +
|
| 89 |
+
(capabilities().bridge ? GRN('node/npm/python disponibles (Bridge local).') : DIM('node/npm/python: conecta el Bridge local (⚙ Ajustes).'));
|
| 90 |
+
},
|
| 91 |
+
pwd() { return cwdString(); },
|
| 92 |
+
async cd(args) {
|
| 93 |
+
const target = args.find(a => !a.startsWith('-'));
|
| 94 |
+
if (!target || target === '~') { cwd = []; return ''; }
|
| 95 |
+
const segs = resolve(target);
|
| 96 |
+
if (await kindOf(segs) !== 'directory') throw new Error(`cd: no existe el directorio: ${target}`);
|
| 97 |
+
cwd = segs; return '';
|
| 98 |
+
},
|
| 99 |
+
async ls(args) {
|
| 100 |
+
const long = args.some(a => /^-\w*l/.test(a)), all = args.some(a => /^-\w*a/.test(a));
|
| 101 |
+
const target = args.find(a => !a.startsWith('-')) || '';
|
| 102 |
+
const segs = resolve(target);
|
| 103 |
+
const k = await kindOf(segs);
|
| 104 |
+
if (k === 'file') return target; // ls de un fichero
|
| 105 |
+
if (k !== 'directory') throw new Error(`ls: no existe: ${target || '.'}`);
|
| 106 |
+
const dir = await dirHandle(segs);
|
| 107 |
+
const rows = [];
|
| 108 |
+
for await (const e of dir.values()) {
|
| 109 |
+
if (!all && (e.name.startsWith('.') || IGNORE.has(e.name))) continue;
|
| 110 |
+
rows.push(e);
|
| 111 |
+
}
|
| 112 |
+
rows.sort((a, b) => (a.kind !== b.kind ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name)));
|
| 113 |
+
if (!long) return rows.map(e => e.kind === 'directory' ? CYAN(e.name + '/') : e.name).join(' ');
|
| 114 |
+
const out = [];
|
| 115 |
+
for (const e of rows) {
|
| 116 |
+
if (e.kind === 'directory') out.push(`${DIM('dir ')} - ${CYAN(e.name + '/')}`);
|
| 117 |
+
else { const f = await e.getFile(); out.push(`${DIM('file')} ${human(f.size).padStart(6)} ${e.name}`); }
|
| 118 |
+
}
|
| 119 |
+
return out.join('\n');
|
| 120 |
+
},
|
| 121 |
+
async cat(args, stdin) {
|
| 122 |
+
const files = args.filter(a => !a.startsWith('-'));
|
| 123 |
+
if (!files.length) return stdin;
|
| 124 |
+
const parts = [];
|
| 125 |
+
for (const f of files) {
|
| 126 |
+
try { parts.push(await readText(resolve(f))); }
|
| 127 |
+
catch { throw new Error(`cat: ${f}: no existe`); }
|
| 128 |
+
}
|
| 129 |
+
return parts.join('');
|
| 130 |
+
},
|
| 131 |
+
async head(args, stdin) {
|
| 132 |
+
const n = +(args[args.indexOf('-n') + 1]) || 10;
|
| 133 |
+
const f = args.find(a => !a.startsWith('-') && !/^\d+$/.test(a));
|
| 134 |
+
const text = f ? await readText(resolve(f)) : stdin;
|
| 135 |
+
return text.split('\n').slice(0, n).join('\n');
|
| 136 |
+
},
|
| 137 |
+
async tail(args, stdin) {
|
| 138 |
+
const n = +(args[args.indexOf('-n') + 1]) || 10;
|
| 139 |
+
const f = args.find(a => !a.startsWith('-') && !/^\d+$/.test(a));
|
| 140 |
+
const text = f ? await readText(resolve(f)) : stdin;
|
| 141 |
+
const lines = text.split('\n'); return lines.slice(Math.max(0, lines.length - n)).join('\n');
|
| 142 |
+
},
|
| 143 |
+
async wc(args, stdin) {
|
| 144 |
+
const onlyLines = args.includes('-l');
|
| 145 |
+
const f = args.find(a => !a.startsWith('-'));
|
| 146 |
+
const text = f ? await readText(resolve(f)) : stdin;
|
| 147 |
+
const lines = text === '' ? 0 : text.split('\n').length;
|
| 148 |
+
if (onlyLines) return String(lines);
|
| 149 |
+
const words = (text.match(/\S+/g) || []).length;
|
| 150 |
+
return `${lines}\t${words}\t${text.length}${f ? '\t' + f : ''}`;
|
| 151 |
+
},
|
| 152 |
+
async tree(args) {
|
| 153 |
+
const target = args.find(a => !a.startsWith('-')) || '';
|
| 154 |
+
return code.tree({ path: resolve(target).join('/'), depth: 3 });
|
| 155 |
+
},
|
| 156 |
+
async grep(args, stdin) {
|
| 157 |
+
const insensitive = args.some(a => /^-\w*i/.test(a)), numbers = args.some(a => /^-\w*n/.test(a));
|
| 158 |
+
const rest = args.filter(a => !a.startsWith('-'));
|
| 159 |
+
const pattern = rest.shift();
|
| 160 |
+
if (!pattern) throw new Error('grep: falta el patrón');
|
| 161 |
+
const re = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), insensitive ? 'i' : '');
|
| 162 |
+
const hit = (line, i, prefix = '') => (prefix ? CYAN(prefix) + ':' : '') + (numbers ? DIM(i + 1 + ':') : '') + line.replace(re, m => `\x1b[1;31m${m}\x1b[0m`);
|
| 163 |
+
if (!rest.length) { // desde stdin (tubería)
|
| 164 |
+
return stdin.split('\n').map((l, i) => re.test(l) ? hit(l, i) : null).filter(x => x != null).join('\n');
|
| 165 |
+
}
|
| 166 |
+
const out = []; let scanned = 0;
|
| 167 |
+
const scanText = (text, label) =>
|
| 168 |
+
text.split('\n').forEach((l, i) => { if (re.test(l) && out.length < 400) out.push(hit(l, i, label)); });
|
| 169 |
+
for (const target of rest) {
|
| 170 |
+
const segs = resolve(target);
|
| 171 |
+
const k = await kindOf(segs);
|
| 172 |
+
if (k === 'file') scanText(await readText(segs).catch(() => ''), target);
|
| 173 |
+
else if (k === 'directory') { // recursivo
|
| 174 |
+
const walk = async (dir, prefix) => {
|
| 175 |
+
for await (const e of dir.values()) {
|
| 176 |
+
if (IGNORE.has(e.name) || out.length >= 400 || scanned > 600) continue;
|
| 177 |
+
const p = prefix ? prefix + '/' + e.name : e.name;
|
| 178 |
+
if (e.kind === 'directory') { await walk(e, p); continue; }
|
| 179 |
+
const f = await e.getFile();
|
| 180 |
+
if (f.size < 400_000) { scanned++; scanText(await f.text(), (target === '.' ? '' : target.replace(/\/$/, '') + '/') + p); }
|
| 181 |
+
}
|
| 182 |
+
};
|
| 183 |
+
await walk(await dirHandle(segs), '');
|
| 184 |
+
} else throw new Error(`grep: ${target}: no existe`);
|
| 185 |
+
}
|
| 186 |
+
return out.join('\n') || DIM('(sin coincidencias)');
|
| 187 |
+
},
|
| 188 |
+
async find(args) {
|
| 189 |
+
const nameIdx = args.indexOf('-name');
|
| 190 |
+
const glob = nameIdx >= 0 ? globToRe(args[nameIdx + 1]) : null;
|
| 191 |
+
const start = args.find(a => !a.startsWith('-') && a !== (nameIdx >= 0 ? args[nameIdx + 1] : null)) || '.';
|
| 192 |
+
const out = []; let count = 0;
|
| 193 |
+
const walk = async (dir, prefix) => {
|
| 194 |
+
for await (const e of dir.values()) {
|
| 195 |
+
if (IGNORE.has(e.name) || count > 2000) continue;
|
| 196 |
+
const p = prefix ? prefix + '/' + e.name : e.name;
|
| 197 |
+
count++;
|
| 198 |
+
if (!glob || glob.test(e.name)) out.push(e.kind === 'directory' ? CYAN(p) : p);
|
| 199 |
+
if (e.kind === 'directory') await walk(e, p);
|
| 200 |
+
}
|
| 201 |
+
};
|
| 202 |
+
const segs = resolve(start === '.' ? '' : start);
|
| 203 |
+
if (await kindOf(segs) !== 'directory') throw new Error(`find: ${start}: no es un directorio`);
|
| 204 |
+
await walk(await dirHandle(segs), start === '.' ? '' : start);
|
| 205 |
+
return out.join('\n') || DIM('(nada)');
|
| 206 |
+
},
|
| 207 |
+
echo(args) { return args.join(' ').replace(/^["']|["']$/g, ''); },
|
| 208 |
+
async mkdir(args) {
|
| 209 |
+
const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('mkdir: falta el nombre');
|
| 210 |
+
await dirHandle(resolve(t), { create: true }); onFsChange(); return '';
|
| 211 |
+
},
|
| 212 |
+
async touch(args) {
|
| 213 |
+
const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('touch: falta el nombre');
|
| 214 |
+
const segs = resolve(t);
|
| 215 |
+
if (await kindOf(segs) == null) await writeText(segs, ''); else onFsChange();
|
| 216 |
+
return '';
|
| 217 |
+
},
|
| 218 |
+
// Soporta comodines (*, ?) y varios objetivos a la vez: `rm mejoras-*.md`.
|
| 219 |
+
// Sin esto, un patrón con * se buscaba como nombre LITERAL → «no existe»
|
| 220 |
+
// aunque hubiera decenas de coincidencias reales (bug real, visto en uso).
|
| 221 |
+
async rm(args) {
|
| 222 |
+
const recursive = args.some(a => /^-\w*r/.test(a));
|
| 223 |
+
const targets = args.filter(a => !a.startsWith('-'));
|
| 224 |
+
if (!targets.length) throw new Error('rm: falta la ruta');
|
| 225 |
+
const removed = [];
|
| 226 |
+
for (const t of targets) {
|
| 227 |
+
if (/[*?]/.test(t)) {
|
| 228 |
+
const parts = t.split('/'); const pattern = parts.pop();
|
| 229 |
+
const dirSegs = resolve(parts.join('/'));
|
| 230 |
+
let dir; try { dir = await dirHandle(dirSegs); } catch { throw new Error(`rm: ${t}: no existe el directorio`); }
|
| 231 |
+
const re = globToRe(pattern);
|
| 232 |
+
const matches = [];
|
| 233 |
+
for await (const e of dir.values()) if (re.test(e.name)) matches.push(e);
|
| 234 |
+
if (!matches.length) throw new Error(`rm: ${t}: sin coincidencias`);
|
| 235 |
+
for (const e of matches) {
|
| 236 |
+
if (e.kind === 'directory' && !recursive) throw new Error(`rm: ${e.name}: es un directorio (usa -r)`);
|
| 237 |
+
await dir.removeEntry(e.name, { recursive });
|
| 238 |
+
removed.push((dirSegs.length ? dirSegs.join('/') + '/' : '') + e.name);
|
| 239 |
+
}
|
| 240 |
+
} else {
|
| 241 |
+
const segs = resolve(t); const k = await kindOf(segs);
|
| 242 |
+
if (k == null) throw new Error(`rm: ${t}: no existe`);
|
| 243 |
+
if (k === 'directory' && !recursive) throw new Error(`rm: ${t}: es un directorio (usa -r)`);
|
| 244 |
+
const { parent, name } = await parentAndName(segs);
|
| 245 |
+
await parent.removeEntry(name, { recursive });
|
| 246 |
+
removed.push(t);
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
code.invalidateFileList?.(); onFsChange();
|
| 250 |
+
return removed.length > 1 ? `Eliminados ${removed.length}: ${removed.join(', ')}` : '';
|
| 251 |
+
},
|
| 252 |
+
async cp(args) {
|
| 253 |
+
const rest = args.filter(a => !a.startsWith('-'));
|
| 254 |
+
const [src, dst] = rest; if (!src || !dst) throw new Error('cp: uso: cp <origen> <destino>');
|
| 255 |
+
await copyPath(resolve(src), resolve(dst)); onFsChange(); return '';
|
| 256 |
+
},
|
| 257 |
+
async mv(args) {
|
| 258 |
+
const rest = args.filter(a => !a.startsWith('-'));
|
| 259 |
+
const [src, dst] = rest; if (!src || !dst) throw new Error('mv: uso: mv <origen> <destino>');
|
| 260 |
+
await copyPath(resolve(src), resolve(dst));
|
| 261 |
+
const { parent, name } = await parentAndName(resolve(src));
|
| 262 |
+
await parent.removeEntry(name, { recursive: true });
|
| 263 |
+
code.invalidateFileList?.(); onFsChange(); return '';
|
| 264 |
+
},
|
| 265 |
+
async open(args) {
|
| 266 |
+
const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('open: falta el fichero');
|
| 267 |
+
const segs = resolve(t);
|
| 268 |
+
if (await kindOf(segs) !== 'file') throw new Error(`open: ${t}: no es un fichero`);
|
| 269 |
+
onOpenFile(segs.join('/')); return DIM(`abriendo ${segs.join('/')} en el editor…`);
|
| 270 |
+
},
|
| 271 |
+
history() { return history.map((h, i) => `${DIM((i + 1) + '')} ${h}`).join('\n'); },
|
| 272 |
+
whoami() { return 'elffuss'; },
|
| 273 |
+
date() { return new Date().toString(); },
|
| 274 |
+
clear() { return '\f'; }, // la UI lo interpreta
|
| 275 |
+
async git(args) {
|
| 276 |
+
const sub = args[0];
|
| 277 |
+
const info = await code.gitInfo();
|
| 278 |
+
if (!info.isRepo) return DIM('no es un repositorio git (no hay .git)');
|
| 279 |
+
if (sub === 'branch' || !sub) return '* ' + GRN(info.branch);
|
| 280 |
+
if (sub === 'status') return `En la rama ${GRN(info.branch)}\n` + (info.lastCommit ? DIM(`último commit: ${info.lastCommit.msg}`) : DIM('sin commits todavía')) + '\n' + DIM('(git de solo lectura aquí; commit/push reales no disponibles todavía)');
|
| 281 |
+
if (sub === 'log') return info.lastCommit ? `${YEL('commit ' + info.branch)}\nAutor: ${info.lastCommit.author}\nFecha: ${new Date(info.lastCommit.when).toLocaleString()}\n\n ${info.lastCommit.msg}` : DIM('sin commits');
|
| 282 |
+
return DIM(`git ${sub}: de solo lectura aquí (branch/status/log).`);
|
| 283 |
+
},
|
| 284 |
+
};
|
| 285 |
+
// tokenize() ya quitó las comillas originales (p.ej. -e "console.log(1)" llega
|
| 286 |
+
// como el arg SUELTO console.log(1)) — para reenviarlo al bridge (que lo pasa
|
| 287 |
+
// a `sh -c`) hay que re-entrecomillarlo, si no un espacio o un paréntesis
|
| 288 |
+
// rompe el comando al otro lado (bug real: -e "console.log('x')" fallaba).
|
| 289 |
+
const shellQuote = s => `'${String(s).replace(/'/g, "'\\''")}'`;
|
| 290 |
+
// runtimes REALES solo si hay un Bridge local conectado (⚙ Ajustes) — se
|
| 291 |
+
// ejecutan en tu máquina de verdad, en la carpeta configurada del bridge (no
|
| 292 |
+
// en el cwd virtual del proyecto).
|
| 293 |
+
for (const r of ['node', 'npm', 'npx', 'python', 'python3', 'pip', 'yarn', 'pnpm', 'bun', 'deno', 'cargo', 'go'])
|
| 294 |
+
CMDS[r] = async (args) => {
|
| 295 |
+
if (!bridgeClient.isConnected()) {
|
| 296 |
+
throw new Error(`${r}: runtime real no disponible.\n${DIM('Descarga y arranca el Bridge local (⚙ Ajustes → 🔌 Bridge local) para ejecución real en tu máquina — se conecta solo en cuanto lo detecte. Mientras tanto, la elfa edita ficheros y tú navegas con los comandos de arriba (help).')}`);
|
| 297 |
+
}
|
| 298 |
+
const full = r + (args.length ? ' ' + args.map(shellQuote).join(' ') : '');
|
| 299 |
+
let out = '';
|
| 300 |
+
const code = await bridgeClient.exec(full, { onOut: t => { out += t; }, onErr: t => { out += RED(t); } });
|
| 301 |
+
if (code) out += `\n${DIM('(código de salida ' + code + ')')}`;
|
| 302 |
+
return out || DIM('(sin salida)');
|
| 303 |
+
};
|
| 304 |
+
|
| 305 |
+
// copia recursiva por handles (la File System API no tiene move nativo)
|
| 306 |
+
async function copyPath(srcSegs, dstSegs) {
|
| 307 |
+
const k = await kindOf(srcSegs);
|
| 308 |
+
if (k == null) throw new Error('origen no existe');
|
| 309 |
+
if (k === 'file') { await writeText(dstSegs, await readText(srcSegs)); return; }
|
| 310 |
+
const src = await dirHandle(srcSegs);
|
| 311 |
+
await dirHandle(dstSegs, { create: true });
|
| 312 |
+
for await (const e of src.values()) await copyPath([...srcSegs, e.name], [...dstSegs, e.name]);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
// ---- parser: tuberías, redirección, comillas ----
|
| 316 |
+
function splitTop(line, sep) { // parte por `sep` respetando comillas
|
| 317 |
+
const parts = []; let cur = '', q = '';
|
| 318 |
+
for (const ch of line) {
|
| 319 |
+
if (q) { cur += ch; if (ch === q) q = ''; }
|
| 320 |
+
else if (ch === '"' || ch === "'") { q = ch; cur += ch; }
|
| 321 |
+
else if (ch === sep) { parts.push(cur); cur = ''; }
|
| 322 |
+
else cur += ch;
|
| 323 |
+
}
|
| 324 |
+
parts.push(cur); return parts;
|
| 325 |
+
}
|
| 326 |
+
function tokenize(seg) {
|
| 327 |
+
const toks = []; let cur = '', q = '';
|
| 328 |
+
for (const ch of seg.trim()) {
|
| 329 |
+
if (q) { if (ch === q) q = ''; else cur += ch; }
|
| 330 |
+
else if (ch === '"' || ch === "'") q = ch;
|
| 331 |
+
else if (/\s/.test(ch)) { if (cur) { toks.push(cur); cur = ''; } }
|
| 332 |
+
else cur += ch;
|
| 333 |
+
}
|
| 334 |
+
if (cur) toks.push(cur);
|
| 335 |
+
return toks;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
// Ejecuta una línea completa (encadenado &&/;, tuberías, redirección).
|
| 339 |
+
// `\f` en el retorno = orden de limpiar pantalla (para la UI).
|
| 340 |
+
export async function exec(line, { record = true } = {}) {
|
| 341 |
+
line = line.trim();
|
| 342 |
+
if (!line) return '';
|
| 343 |
+
if (record) history.push(line);
|
| 344 |
+
const segments = tokenizeChain(line);
|
| 345 |
+
if (segments.length === 1) return runPipeline(segments[0].cmd);
|
| 346 |
+
// encadenado: A && B (B solo si A fue bien) · A ; B (B siempre)
|
| 347 |
+
const outs = []; let ok = true;
|
| 348 |
+
for (const { cmd, op } of segments) {
|
| 349 |
+
if (op === '&&' && !ok) continue;
|
| 350 |
+
try { outs.push(await runPipeline(cmd)); ok = true; }
|
| 351 |
+
catch (e) { ok = false; outs.push('\x1b[31m' + (e && e.message || e) + '\x1b[0m'); }
|
| 352 |
+
}
|
| 353 |
+
if (outs.every(o => o === '\f')) return '\f';
|
| 354 |
+
return outs.filter(o => o && o !== '\f').join('\n');
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
// Divide la línea en segmentos con su operador previo (&& / ; ), respetando comillas.
|
| 358 |
+
function tokenizeChain(line) {
|
| 359 |
+
const segs = []; let cur = '', q = '', op = null;
|
| 360 |
+
for (let i = 0; i < line.length; i++) {
|
| 361 |
+
const ch = line[i];
|
| 362 |
+
if (q) { cur += ch; if (ch === q) q = ''; continue; }
|
| 363 |
+
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
|
| 364 |
+
if (ch === '&' && line[i + 1] === '&') { segs.push({ cmd: cur.trim(), op }); op = '&&'; cur = ''; i++; continue; }
|
| 365 |
+
if (ch === ';') { segs.push({ cmd: cur.trim(), op }); op = ';'; cur = ''; continue; }
|
| 366 |
+
cur += ch;
|
| 367 |
+
}
|
| 368 |
+
if (cur.trim()) segs.push({ cmd: cur.trim(), op });
|
| 369 |
+
return segs.filter(s => s.cmd);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
// Ejecuta un pipeline (una etapa o varias unidas por |, con redirección final).
|
| 373 |
+
async function runPipeline(line) {
|
| 374 |
+
const stages = splitTop(line, '|').map(s => s.trim()).filter(Boolean);
|
| 375 |
+
let data = '';
|
| 376 |
+
for (let i = 0; i < stages.length; i++) {
|
| 377 |
+
let seg = stages[i], redir = null;
|
| 378 |
+
if (i === stages.length - 1) {
|
| 379 |
+
const m = seg.match(/(>>?)\s*(\S+)\s*$/);
|
| 380 |
+
if (m) { redir = { append: m[1] === '>>', file: m[2] }; seg = seg.slice(0, m.index); }
|
| 381 |
+
}
|
| 382 |
+
const toks = tokenize(seg);
|
| 383 |
+
const name = toks.shift();
|
| 384 |
+
if (!name) continue;
|
| 385 |
+
const fn = CMDS[name];
|
| 386 |
+
if (!fn) throw new Error(`${name}: comando no encontrado (prueba ${GRN('help')})`);
|
| 387 |
+
data = (await fn(toks, data)) ?? '';
|
| 388 |
+
if (redir) {
|
| 389 |
+
const segs = resolve(redir.file);
|
| 390 |
+
const prev = redir.append ? await readText(segs).catch(() => '') : '';
|
| 391 |
+
await writeText(segs, prev + stripAnsi(data));
|
| 392 |
+
data = '';
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
return data;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
export const stripAnsi = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
| 399 |
+
|
| 400 |
+
// Entradas de un directorio (para el completado con Tab del terminal).
|
| 401 |
+
export async function listDir(dirPart = '') {
|
| 402 |
+
const dir = await dirHandle(resolve(dirPart));
|
| 403 |
+
const out = [];
|
| 404 |
+
for await (const e of dir.values()) if (!IGNORE.has(e.name)) out.push({ name: e.name, kind: e.kind });
|
| 405 |
+
return out;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
// Ejecuta una orden desde la elfa (tool terminal.run): devuelve texto plano.
|
| 409 |
+
export async function runForAgent(command) {
|
| 410 |
+
const out = await exec(command, { record: true });
|
| 411 |
+
return out === '\f' ? '(pantalla limpiada)' : (stripAnsi(out) || '(sin salida)');
|
| 412 |
+
}
|
js/skills.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Skills de Elffuss Code: instrucciones en markdown (formato SKILL.md de
|
| 2 |
+
// Claude Code) que el modelo sigue cuando aplican. Se instalan desde el
|
| 3 |
+
// catálogo grande OFICIAL (github.com/anthropics/skills), desde los plugins
|
| 4 |
+
// oficiales, o desde CUALQUIER repo público (marketplaces de la comunidad
|
| 5 |
+
// tipo OpenClaude/openclaw). Todo transparente: se ve el repo, la lista y el
|
| 6 |
+
// SKILL.md que se inyecta. Se guardan en IndexedDB (nada sale del navegador).
|
| 7 |
+
import * as db from './db.js';
|
| 8 |
+
|
| 9 |
+
const KEY = 'skills'; // skills instaladas
|
| 10 |
+
const SRC_KEY = 'skills.sources'; // repos personalizados añadidos por el usuario
|
| 11 |
+
const MAX_SKILL = 12_000; // caracteres del cuerpo que se inyectan al modelo
|
| 12 |
+
|
| 13 |
+
export const CATALOG_REPO = 'anthropics/skills';
|
| 14 |
+
export const DEFAULT_SOURCES = [
|
| 15 |
+
{ repo: 'bbgnsurftech/claude-skills-collection', label: 'Comunidad · Mega-colección (1000+ skills)', official: true },
|
| 16 |
+
{ repo: 'anthropics/skills', label: 'Anthropic · Agent Skills (oficial de Claude Code)', official: true },
|
| 17 |
+
{ repo: 'anthropics/claude-plugins-official', label: 'Anthropic · Claude Code Plugins (oficial)', official: true },
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
let cache = []; // instaladas, en memoria (para que el systemPrompt sea síncrono)
|
| 21 |
+
|
| 22 |
+
export async function initSkills() {
|
| 23 |
+
cache = (await db.get('kv', KEY).catch(() => null)) || [];
|
| 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);
|
| 35 |
+
await db.set('kv', KEY, cache);
|
| 36 |
+
return entry; // devuelve la skill completa (name, description…)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Mensaje «cómo usarla» tras instalar: qué hace + un ejemplo de qué pedir.
|
| 40 |
+
export function usageMessage(skill) {
|
| 41 |
+
const desc = (skill.description || '').trim();
|
| 42 |
+
const ejemplo = firstExample(skill) || `algo relacionado con «${skill.name}»`;
|
| 43 |
+
return `✳ **Skill «${skill.name}» instalada.**\n\n` +
|
| 44 |
+
(desc ? desc + '\n\n' : '') +
|
| 45 |
+
`Ya la sigo en cada conversación. Para usarla, pídeme por ejemplo:\n\n> ${ejemplo}`;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Intenta sacar un ejemplo de uso del cuerpo del SKILL.md (líneas de ejemplo/uso).
|
| 49 |
+
function firstExample(skill) {
|
| 50 |
+
const body = skill.content || '';
|
| 51 |
+
const m = body.match(/(?:ejemplo|example|uso|usage|prueba|try)[^\n:]*[::]\s*["“]?([^\n"”]{6,90})/i)
|
| 52 |
+
|| body.match(/^[-*]\s+([A-ZÁÉÍÓÚ][^\n]{10,80})/m);
|
| 53 |
+
return m ? m[1].trim().replace(/[.*_`]+$/, '') : null;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export async function remove(nameOrPath) {
|
| 57 |
+
cache = cache.filter(s => s.name !== nameOrPath && s.path !== nameOrPath);
|
| 58 |
+
await db.set('kv', KEY, cache);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export async function get(name) {
|
| 62 |
+
return cache.find(s => s.name.toLowerCase() === String(name).toLowerCase()) || null;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// ---- fuentes (repos) ----
|
| 66 |
+
export async function sources() {
|
| 67 |
+
const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
|
| 68 |
+
return [...DEFAULT_SOURCES, ...custom];
|
| 69 |
+
}
|
| 70 |
+
export async function addSource(repoOrUrl, label) {
|
| 71 |
+
const repo = repoOrUrl.trim().replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/$/, '');
|
| 72 |
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error('Usa owner/repo (o la URL de GitHub)');
|
| 73 |
+
const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
|
| 74 |
+
if (!custom.some(s => s.repo === repo) && !DEFAULT_SOURCES.some(s => s.repo === repo))
|
| 75 |
+
custom.push({ repo, label: label || repo });
|
| 76 |
+
await db.set('kv', SRC_KEY, custom);
|
| 77 |
+
return repo;
|
| 78 |
+
}
|
| 79 |
+
export async function removeSource(repo) {
|
| 80 |
+
const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
|
| 81 |
+
await db.set('kv', SRC_KEY, custom.filter(s => s.repo !== repo));
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// ---- catálogo desde GitHub ----
|
| 85 |
+
// Una sola llamada al árbol git del repo → todos los SKILL.md.
|
| 86 |
+
export async function listFromRepo(repo) {
|
| 87 |
+
let tree, branch;
|
| 88 |
+
for (const b of ['main', 'master']) {
|
| 89 |
+
const r = await fetch(`https://api.github.com/repos/${repo}/git/trees/${b}?recursive=1`);
|
| 90 |
+
if (r.ok) { tree = await r.json(); branch = b; break; }
|
| 91 |
+
if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.');
|
| 92 |
+
}
|
| 93 |
+
if (!tree) throw new Error('No pude leer el repo (¿existe y es público?)');
|
| 94 |
+
return (tree.tree || [])
|
| 95 |
+
.filter(n => /(^|\/)SKILL\.md$/i.test(n.path))
|
| 96 |
+
.map(n => ({ repo, branch, path: n.path, dir: n.path.replace(/\/SKILL\.md$/i, ''), name: n.path.replace(/\/SKILL\.md$/i, '').split('/').pop() || repo }))
|
| 97 |
+
.sort((a, b) => a.dir.localeCompare(b.dir));
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Descarga el SKILL.md y lo instala (frontmatter YAML simple).
|
| 101 |
+
export async function installFromRepo(entry) {
|
| 102 |
+
let md = null;
|
| 103 |
+
for (const b of [entry.branch, 'main', 'master'].filter(Boolean)) {
|
| 104 |
+
const r = await fetch(`https://raw.githubusercontent.com/${entry.repo}/${b}/${entry.path}`);
|
| 105 |
+
if (r.ok) { md = await r.text(); break; }
|
| 106 |
+
}
|
| 107 |
+
if (md == null) throw new Error('No pude descargar el SKILL.md');
|
| 108 |
+
const skill = parseSkill(md, entry.name);
|
| 109 |
+
return install({ ...skill, repo: entry.repo, path: entry.path });
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// SKILL.md → { name, description, content }
|
| 113 |
+
export function parseSkill(md, fallbackName = 'skill') {
|
| 114 |
+
const fm = md.match(/^---\n([\s\S]*?)\n---/);
|
| 115 |
+
const meta = fm?.[1] || '';
|
| 116 |
+
const name = meta.match(/^name:\s*(.+)$/m)?.[1]?.trim() || fallbackName;
|
| 117 |
+
const description = (meta.match(/^description:\s*([\s\S]+?)(?:\n\w+:|$)/m)?.[1] || '').replace(/\s+/g, ' ').trim();
|
| 118 |
+
const content = md.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
|
| 119 |
+
return { name, description, content };
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// Creador de skills: Elffuss fabrica una skill propia (SKILL.md) a partir de
|
| 123 |
+
// lo que el usuario quiere, y la instala al instante. Aparece en la pestaña
|
| 124 |
+
// Skills y se inyecta en el prompt de los siguientes turnos.
|
| 125 |
+
export async function createSkill({ name, description, instructions } = {}) {
|
| 126 |
+
if (!name || !instructions) throw new Error('Faltan name o instructions');
|
| 127 |
+
const skill = {
|
| 128 |
+
name: String(name).slice(0, 48),
|
| 129 |
+
description: (description || '').slice(0, 240),
|
| 130 |
+
content: String(instructions).slice(0, MAX_SKILL),
|
| 131 |
+
repo: 'creada por Elffuss',
|
| 132 |
+
path: 'local/' + Date.now(),
|
| 133 |
+
};
|
| 134 |
+
await install(skill);
|
| 135 |
+
return `Skill «${skill.name}» creada e instalada. Ya la sigo en cada conversación (mírala en la pestaña Skills).`;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Bloque para el systemPrompt (síncrono, desde la caché).
|
| 139 |
+
export function skillsPromptBlock() {
|
| 140 |
+
if (!cache.length) return '';
|
| 141 |
+
const parts = cache.map(s =>
|
| 142 |
+
`### Skill «${s.name}»${s.repo ? ` (de ${s.repo})` : ''}\n${s.description || ''}\n${(s.content || '').slice(0, MAX_SKILL)}`);
|
| 143 |
+
return `\n\nSKILLS ACTIVAS (instrucciones especializadas; síguelas cuando la tarea encaje):\n${parts.join('\n\n')}`;
|
| 144 |
+
}
|
js/splash-gl.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Galaxia de partículas WebGL para el splash — sin librerías, autocontenida.
|
| 2 |
+
// Técnica: GL_POINTS + blending aditivo + rotación diferencial calculada por
|
| 3 |
+
// completo en el vertex shader desde una semilla (la CPU no toca nada por
|
| 4 |
+
// frame). ~24k partículas en espiral rosa→violeta con motas doradas, parallax
|
| 5 |
+
// de ratón y explosión al pulsar Entrar.
|
| 6 |
+
export function startGalaxy(container) {
|
| 7 |
+
const canvas = document.createElement('canvas');
|
| 8 |
+
canvas.className = 'gl';
|
| 9 |
+
container.prepend(canvas);
|
| 10 |
+
const gl = canvas.getContext('webgl', { alpha: true, antialias: false, depth: false });
|
| 11 |
+
if (!gl) { canvas.remove(); return null; } // fallback: se queda el gradiente CSS
|
| 12 |
+
|
| 13 |
+
const N = 24000;
|
| 14 |
+
const seeds = new Float32Array(N * 2);
|
| 15 |
+
for (let i = 0; i < seeds.length; i++) seeds[i] = Math.random();
|
| 16 |
+
|
| 17 |
+
const VS = `
|
| 18 |
+
attribute vec2 seed;
|
| 19 |
+
uniform float t, aspect, burst, dpr;
|
| 20 |
+
uniform vec2 par;
|
| 21 |
+
varying vec3 vColor;
|
| 22 |
+
varying float vGlow;
|
| 23 |
+
float hash(float n){ return fract(sin(n) * 43758.5453); }
|
| 24 |
+
void main(){
|
| 25 |
+
float r = 0.08 + 1.35 * pow(seed.y, 0.65);
|
| 26 |
+
float arm = floor(hash(seed.x * 7.0 + seed.y) * 3.0);
|
| 27 |
+
float ang = seed.x * 0.9 + arm * 2.0944 + r * 2.6 + t * (0.12 + 0.25 / (r + 0.2));
|
| 28 |
+
float wob = (hash(seed.x * 13.1) - 0.5) * 0.22 * r;
|
| 29 |
+
vec2 p = vec2(cos(ang), sin(ang)) * (r + wob);
|
| 30 |
+
p.y *= 0.62;
|
| 31 |
+
p.y += (hash(seed.y * 17.7) - 0.5) * 0.14;
|
| 32 |
+
p += par * (0.03 + r * 0.06);
|
| 33 |
+
p *= 1.0 + burst * (0.4 + seed.y * 2.4);
|
| 34 |
+
gl_Position = vec4(p.x / aspect, p.y, 0.0, 1.0);
|
| 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(1.0, 0.30, 0.55);
|
| 39 |
+
vec3 violet = vec3(0.49, 0.36, 1.0);
|
| 40 |
+
vec3 goldc = vec3(1.0, 0.84, 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 |
+
}`;
|
| 44 |
+
const FS = `
|
| 45 |
+
precision mediump float;
|
| 46 |
+
varying vec3 vColor;
|
| 47 |
+
varying float vGlow;
|
| 48 |
+
void main(){
|
| 49 |
+
float m = smoothstep(0.5, 0.0, length(gl_PointCoord - 0.5));
|
| 50 |
+
gl_FragColor = vec4(vColor * vGlow, m * vGlow);
|
| 51 |
+
}`;
|
| 52 |
+
|
| 53 |
+
const sh = (type, src) => {
|
| 54 |
+
const s = gl.createShader(type);
|
| 55 |
+
gl.shaderSource(s, src);
|
| 56 |
+
gl.compileShader(s);
|
| 57 |
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
| 58 |
+
return s;
|
| 59 |
+
};
|
| 60 |
+
const prog = gl.createProgram();
|
| 61 |
+
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS));
|
| 62 |
+
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
|
| 63 |
+
gl.linkProgram(prog);
|
| 64 |
+
gl.useProgram(prog);
|
| 65 |
+
|
| 66 |
+
const buf = gl.createBuffer();
|
| 67 |
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
| 68 |
+
gl.bufferData(gl.ARRAY_BUFFER, seeds, gl.STATIC_DRAW); // una vez; la GPU manda
|
| 69 |
+
const loc = gl.getAttribLocation(prog, 'seed');
|
| 70 |
+
gl.enableVertexAttribArray(loc);
|
| 71 |
+
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
| 72 |
+
|
| 73 |
+
const U = n => gl.getUniformLocation(prog, n);
|
| 74 |
+
const uT = U('t'), uAspect = U('aspect'), uBurst = U('burst'), uPar = U('par'), uDpr = U('dpr');
|
| 75 |
+
|
| 76 |
+
gl.enable(gl.BLEND);
|
| 77 |
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // aditivo = brillo
|
| 78 |
+
gl.clearColor(0, 0, 0, 0);
|
| 79 |
+
|
| 80 |
+
let burstAt = 0, alive = true;
|
| 81 |
+
const mouse = { x: 0, y: 0, tx: 0, ty: 0 };
|
| 82 |
+
const onMove = e => {
|
| 83 |
+
mouse.tx = (e.clientX / innerWidth - 0.5) * 2;
|
| 84 |
+
mouse.ty = -(e.clientY / innerHeight - 0.5) * 2;
|
| 85 |
+
};
|
| 86 |
+
addEventListener('pointermove', onMove);
|
| 87 |
+
|
| 88 |
+
const dpr = Math.min(devicePixelRatio || 1, 2);
|
| 89 |
+
function frame(now) {
|
| 90 |
+
if (!alive) return;
|
| 91 |
+
const w = container.clientWidth, h = container.clientHeight;
|
| 92 |
+
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
|
| 93 |
+
canvas.width = w * dpr;
|
| 94 |
+
canvas.height = h * dpr;
|
| 95 |
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
| 96 |
+
}
|
| 97 |
+
mouse.x += (mouse.tx - mouse.x) * 0.05;
|
| 98 |
+
mouse.y += (mouse.ty - mouse.y) * 0.05;
|
| 99 |
+
const t = now / 1000;
|
| 100 |
+
const burst = burstAt ? Math.min((now - burstAt) / 900, 1) : 0;
|
| 101 |
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
| 102 |
+
gl.uniform1f(uT, t);
|
| 103 |
+
gl.uniform1f(uAspect, w / h);
|
| 104 |
+
gl.uniform1f(uBurst, burst);
|
| 105 |
+
gl.uniform1f(uDpr, dpr);
|
| 106 |
+
gl.uniform2f(uPar, mouse.x, mouse.y);
|
| 107 |
+
gl.drawArrays(gl.POINTS, 0, N);
|
| 108 |
+
requestAnimationFrame(frame);
|
| 109 |
+
}
|
| 110 |
+
requestAnimationFrame(frame);
|
| 111 |
+
|
| 112 |
+
return {
|
| 113 |
+
burst() { burstAt = performance.now(); },
|
| 114 |
+
stop() { alive = false; removeEventListener('pointermove', onMove); canvas.remove(); },
|
| 115 |
+
};
|
| 116 |
+
}
|
js/telemetry.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Buzón opt-in de errores/feedback. Apagado por defecto — la promesa de
|
| 2 |
+
// «nada sale de tu máquina» sigue siendo cierta salvo que el usuario decida
|
| 3 |
+
// lo contrario en Ajustes. Cuando está activo:
|
| 4 |
+
// - errores no capturados (window.onerror / unhandledrejection) y los
|
| 5 |
+
// puntos concretos del código que llaman a reportError() se mandan a
|
| 6 |
+
// /proxy/report (mismo origen, vía el proxy compartido de Elffuss)
|
| 7 |
+
// - sendFeedback() manda texto libre que el usuario escriba a propósito
|
| 8 |
+
// Nunca se manda código ni contenido del proyecto — solo el mensaje técnico,
|
| 9 |
+
// la pila, la URL y el user-agent. Sin cola ni reintentos: si el envío
|
| 10 |
+
// falla, se descarta sin más (evita que un fallo de red se convierta en
|
| 11 |
+
// almacenamiento sin límite).
|
| 12 |
+
let appName = 'elffuss';
|
| 13 |
+
let enabled = false;
|
| 14 |
+
let hooked = false;
|
| 15 |
+
|
| 16 |
+
const storageKey = () => 'elffuss.telemetry.' + appName;
|
| 17 |
+
|
| 18 |
+
export function isEnabled() { return enabled; }
|
| 19 |
+
|
| 20 |
+
export function setEnabled(v) {
|
| 21 |
+
enabled = !!v;
|
| 22 |
+
try { localStorage.setItem(storageKey(), enabled ? '1' : '0'); } catch { /* localStorage lleno/bloqueado */ }
|
| 23 |
+
if (enabled) hookGlobalErrors();
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
async function post(kind, message, opts = {}) {
|
| 27 |
+
if (!enabled) return;
|
| 28 |
+
try {
|
| 29 |
+
await fetch('/proxy/report', {
|
| 30 |
+
method: 'POST',
|
| 31 |
+
headers: { 'Content-Type': 'application/json' },
|
| 32 |
+
body: JSON.stringify({
|
| 33 |
+
app: appName, kind, message: String(message ?? '').slice(0, 2000),
|
| 34 |
+
stack: String(opts.stack || '').slice(0, 4000),
|
| 35 |
+
url: location.href, userAgent: navigator.userAgent,
|
| 36 |
+
extra: String(opts.extra || '').slice(0, 2000),
|
| 37 |
+
}),
|
| 38 |
+
});
|
| 39 |
+
} catch { /* sin conexión o proxy caído: se descarta, no hay reintento */ }
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function reportError(message, opts = {}) { return post('error', message, opts); }
|
| 43 |
+
export function sendFeedback(text) { return post('feedback', text); }
|
| 44 |
+
|
| 45 |
+
function hookGlobalErrors() {
|
| 46 |
+
if (hooked) return;
|
| 47 |
+
hooked = true;
|
| 48 |
+
window.addEventListener('error', e => {
|
| 49 |
+
reportError(e.message || String(e.error) || 'error', { stack: e.error?.stack || '' });
|
| 50 |
+
});
|
| 51 |
+
window.addEventListener('unhandledrejection', e => {
|
| 52 |
+
const r = e.reason;
|
| 53 |
+
reportError('unhandledrejection: ' + (r?.message || r), { stack: r?.stack || '' });
|
| 54 |
+
});
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Llamar UNA vez al arrancar, con el nombre de la app (para distinguir
|
| 58 |
+
// elffuss-code de elffuss-claw en los informes) — lee la preferencia guardada.
|
| 59 |
+
export function init(name) {
|
| 60 |
+
appName = name || appName;
|
| 61 |
+
try { enabled = localStorage.getItem(storageKey()) === '1'; } catch { enabled = false; }
|
| 62 |
+
if (enabled) hookGlobalErrors();
|
| 63 |
+
}
|
js/terminal.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Terminal de Elffuss Code: xterm.js (VT100 real) + un readline propio que
|
| 2 |
+
// ejecuta contra shell.js (los ficheros REALES del proyecto). xterm no trae
|
| 3 |
+
// shell: nosotros gestionamos la línea (edición, historial, cursor) y delegamos
|
| 4 |
+
// la ejecución en shell.exec. La misma orden la puede lanzar la elfa vía el
|
| 5 |
+
// tool `terminal.run`, y su salida aparece aquí.
|
| 6 |
+
import * as shell from './shell.js';
|
| 7 |
+
|
| 8 |
+
let term = null, fit = null, ready = false;
|
| 9 |
+
let Terminal = null, FitAddon = null;
|
| 10 |
+
let line = '', pos = 0, histIdx = -1, busy = false;
|
| 11 |
+
const XT = '@xterm/xterm@5.5.0', FIT = '@xterm/addon-fit@0.10.0';
|
| 12 |
+
|
| 13 |
+
// IMPORTANTE: cargamos xterm como ESM, no UMD. Monaco trae un loader AMD
|
| 14 |
+
// (define/require) y la build UMD de xterm se registra como «define anónimo»
|
| 15 |
+
// en vez de exponer window.Terminal → «Can only have one anonymous define».
|
| 16 |
+
// El import ESM esquiva por completo el define de Monaco.
|
| 17 |
+
async function loadXterm() {
|
| 18 |
+
if (Terminal) return;
|
| 19 |
+
if (!document.getElementById('xterm-css')) {
|
| 20 |
+
const l = document.createElement('link');
|
| 21 |
+
l.id = 'xterm-css'; l.rel = 'stylesheet'; l.href = `https://cdn.jsdelivr.net/npm/${XT}/css/xterm.min.css`;
|
| 22 |
+
document.head.appendChild(l);
|
| 23 |
+
}
|
| 24 |
+
const [xt, ft] = await Promise.all([
|
| 25 |
+
import(`https://cdn.jsdelivr.net/npm/${XT}/+esm`),
|
| 26 |
+
import(`https://cdn.jsdelivr.net/npm/${FIT}/+esm`),
|
| 27 |
+
]);
|
| 28 |
+
Terminal = xt.Terminal || (xt.default && xt.default.Terminal);
|
| 29 |
+
FitAddon = ft.FitAddon || (ft.default && ft.default.FitAddon) || ft.default;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const prompt = () => `\x1b[36m${shell.cwdString()}\x1b[0m \x1b[35m❯\x1b[0m `;
|
| 33 |
+
const writePrompt = () => term.write('\r\n' + prompt());
|
| 34 |
+
// Redibuja la línea de entrada (prompt + buffer) y coloca el cursor en `pos`.
|
| 35 |
+
function redraw() {
|
| 36 |
+
term.write('\r\x1b[K' + prompt() + line);
|
| 37 |
+
const back = line.length - pos;
|
| 38 |
+
if (back > 0) term.write(`\x1b[${back}D`);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
async function run(raw) {
|
| 42 |
+
const cmd = raw.trim();
|
| 43 |
+
if (!cmd) { writePrompt(); return; }
|
| 44 |
+
busy = true;
|
| 45 |
+
try {
|
| 46 |
+
const out = await shell.exec(cmd);
|
| 47 |
+
if (out === '\f') term.clear();
|
| 48 |
+
else if (out) term.write('\r\n' + out.replace(/\n/g, '\r\n'));
|
| 49 |
+
} catch (e) {
|
| 50 |
+
term.write('\r\n\x1b[31m' + String(e && e.message || e).replace(/\n/g, '\r\n') + '\x1b[0m');
|
| 51 |
+
}
|
| 52 |
+
busy = false;
|
| 53 |
+
writePrompt();
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function onKey(data) {
|
| 57 |
+
if (busy) return;
|
| 58 |
+
switch (data) {
|
| 59 |
+
case '\r': // Enter
|
| 60 |
+
{ const cmd = line; line = ''; pos = 0; histIdx = -1; run(cmd); return; }
|
| 61 |
+
case '\x7f': // Retroceso
|
| 62 |
+
if (pos > 0) { line = line.slice(0, pos - 1) + line.slice(pos); pos--; redraw(); } return;
|
| 63 |
+
case '\x1b[3~': // Supr
|
| 64 |
+
if (pos < line.length) { line = line.slice(0, pos) + line.slice(pos + 1); redraw(); } return;
|
| 65 |
+
case '\x03': // Ctrl+C
|
| 66 |
+
term.write('^C'); line = ''; pos = 0; histIdx = -1; writePrompt(); return;
|
| 67 |
+
case '\x0c': // Ctrl+L
|
| 68 |
+
term.clear(); redraw(); return;
|
| 69 |
+
case '\x1b[A': case '\x1b[B': { // arriba / abajo: historial
|
| 70 |
+
const h = shell.getHistory(); if (!h.length) return;
|
| 71 |
+
if (histIdx === -1) histIdx = h.length;
|
| 72 |
+
histIdx = Math.max(0, Math.min(h.length, histIdx + (data === '\x1b[A' ? -1 : 1)));
|
| 73 |
+
line = h[histIdx] || ''; pos = line.length; redraw(); return;
|
| 74 |
+
}
|
| 75 |
+
case '\x1b[C': if (pos < line.length) { pos++; term.write('\x1b[C'); } return; // derecha
|
| 76 |
+
case '\x1b[D': if (pos > 0) { pos--; term.write('\x1b[D'); } return; // izquierda
|
| 77 |
+
case '\x1b[H': case '\x01': pos = 0; redraw(); return; // Inicio / Ctrl+A
|
| 78 |
+
case '\x1b[F': case '\x05': pos = line.length; redraw(); return; // Fin / Ctrl+E
|
| 79 |
+
case '\t': complete(); return; // Tab: completar rutas
|
| 80 |
+
}
|
| 81 |
+
if (data.length !== 1 || data.charCodeAt(0) < 32) return; // control/escape: ignorar
|
| 82 |
+
line = line.slice(0, pos) + data + line.slice(pos); pos += data.length; redraw();
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Completado de rutas simple: mira el último token contra el directorio actual.
|
| 86 |
+
async function complete() {
|
| 87 |
+
const frag = (line.slice(0, pos).split(/\s+/).pop()) || '';
|
| 88 |
+
const slash = frag.lastIndexOf('/');
|
| 89 |
+
const dirPart = slash >= 0 ? frag.slice(0, slash + 1) : '';
|
| 90 |
+
const base = slash >= 0 ? frag.slice(slash + 1) : frag;
|
| 91 |
+
let names;
|
| 92 |
+
try { names = await shell.listDir(dirPart); } catch { return; }
|
| 93 |
+
const matches = names.filter(n => n.name.startsWith(base));
|
| 94 |
+
if (matches.length === 1) {
|
| 95 |
+
const add = matches[0].name.slice(base.length) + (matches[0].kind === 'directory' ? '/' : ' ');
|
| 96 |
+
line = line.slice(0, pos) + add + line.slice(pos); pos += add.length; redraw();
|
| 97 |
+
} else if (matches.length > 1) {
|
| 98 |
+
term.write('\r\n' + matches.map(m => m.kind === 'directory' ? '\x1b[36m' + m.name + '/\x1b[0m' : m.name).join(' '));
|
| 99 |
+
writePrompt(); term.write(line); if (line.length - pos > 0) term.write(`\x1b[${line.length - pos}D`);
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// Monta el terminal en `host` (una vez). Idempotente; refoca si ya existe.
|
| 104 |
+
export async function mount(host) {
|
| 105 |
+
if (ready) { setTimeout(() => { fit && fit.fit(); term && term.focus(); }, 0); return; }
|
| 106 |
+
await loadXterm();
|
| 107 |
+
term = new Terminal({
|
| 108 |
+
fontSize: 12.5, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
| 109 |
+
cursorBlink: true, convertEol: false, scrollback: 2000,
|
| 110 |
+
theme: { background: '#0b0d12', foreground: '#e8ebf4', cursor: '#ff4d8d', selectionBackground: '#7c5cff55',
|
| 111 |
+
brightBlack: '#6b7280', red: '#ff6b8b', green: '#7ee787', yellow: '#ffd479', blue: '#7c5cff', magenta: '#ff4d8d', cyan: '#56d4dd' },
|
| 112 |
+
});
|
| 113 |
+
fit = new FitAddon();
|
| 114 |
+
term.loadAddon(fit);
|
| 115 |
+
term.open(host);
|
| 116 |
+
fit.fit();
|
| 117 |
+
host.addEventListener('mousedown', () => setTimeout(() => term.focus(), 0));
|
| 118 |
+
term.onData(onKey);
|
| 119 |
+
const cap = shell.capabilities();
|
| 120 |
+
term.write('\x1b[1;35mElffuss shell\x1b[0m \x1b[90m— ficheros reales del proyecto, en tu navegador.\x1b[0m');
|
| 121 |
+
const rtHint = cap.bridge ? '🔌 Bridge local conectado: node/npm/python REALES en tu máquina.'
|
| 122 |
+
: 'Escribe \x1b[0m\x1b[32mhelp\x1b[0m\x1b[90m. node/npm/python reales → conecta el Bridge local (⚙ Ajustes).';
|
| 123 |
+
term.write('\r\n\x1b[90m' + rtHint + '\x1b[0m');
|
| 124 |
+
writePrompt();
|
| 125 |
+
ready = true;
|
| 126 |
+
setTimeout(() => term.focus(), 0);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
export function refit() { if (ready) setTimeout(() => fit && fit.fit(), 0); }
|
| 130 |
+
export function focus() { if (ready) term.focus(); }
|
| 131 |
+
export function isReady() { return ready; }
|
| 132 |
+
export function clearScreen() { if (ready) { term.clear(); line = ''; pos = 0; term.write('\r\x1b[K' + prompt()); term.focus(); } }
|
| 133 |
+
|
| 134 |
+
// Eco de una orden lanzada por la elfa (para que el usuario la vea en el terminal).
|
| 135 |
+
export function echoAgent(cmd, out) {
|
| 136 |
+
if (!ready) return;
|
| 137 |
+
term.write('\r\x1b[K' + prompt() + '\x1b[90m' + cmd + ' (elfa)\x1b[0m');
|
| 138 |
+
if (out) term.write('\r\n' + shell.stripAnsi(out).replace(/\n/g, '\r\n'));
|
| 139 |
+
writePrompt(); term.write(line);
|
| 140 |
+
}
|
js/tools/clone.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Descarga un repo PÚBLICO (GitHub/Bitbucket) en una carpeta real elegida por
|
| 2 |
+
// el usuario, para abrirlo como proyecto sin necesitar git instalado. Mismo
|
| 3 |
+
// patrón que skills.js: árbol de Git vía API + contenidos por raw CDN (sin
|
| 4 |
+
// tope de la API, solo la llamada al árbol cuenta contra el límite 60/h).
|
| 5 |
+
//
|
| 6 |
+
// RESUMIBLE por diseño: listRepo() (el árbol) y downloadFiles() (los ficheros)
|
| 7 |
+
// están separados a propósito — quien llama (main.js) persiste la lista y qué
|
| 8 |
+
// rutas ya se descargaron, así una descarga interrumpida (recarga, pestaña
|
| 9 |
+
// cerrada) continúa donde se quedó en vez de volver a empezar.
|
| 10 |
+
const MAX_FILES = 4000;
|
| 11 |
+
const MAX_FILE_BYTES = 4 * 1024 * 1024; // ficheros más grandes se saltan (binarios enormes)
|
| 12 |
+
const CONCURRENCY = 10;
|
| 13 |
+
|
| 14 |
+
export function parseRepoUrl(raw) {
|
| 15 |
+
const url = String(raw || '').trim().replace(/\.git$/, '').replace(/\/+$/, '');
|
| 16 |
+
let m = url.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)(?:\/tree\/([\w./-]+))?/i);
|
| 17 |
+
if (m) return { host: 'github', owner: m[1], repo: m[2], branch: m[3] || null };
|
| 18 |
+
m = url.match(/^(?:https?:\/\/)?(?:www\.)?bitbucket\.org\/([\w.-]+)\/([\w.-]+)(?:\/src\/([\w./-]+))?/i);
|
| 19 |
+
if (m) return { host: 'bitbucket', owner: m[1], repo: m[2], branch: m[3] || null };
|
| 20 |
+
throw new Error('URL no reconocida. Usa github.com/usuario/repo o bitbucket.org/usuario/repo');
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// escribe un fichero (texto o binario) en `root`, creando subcarpetas
|
| 24 |
+
async function writeInto(root, path, blob) {
|
| 25 |
+
const parts = path.split('/'); const name = parts.pop();
|
| 26 |
+
let dir = root;
|
| 27 |
+
for (const seg of parts) dir = await dir.getDirectoryHandle(seg, { create: true });
|
| 28 |
+
const fh = await dir.getFileHandle(name, { create: true });
|
| 29 |
+
const w = await fh.createWritable();
|
| 30 |
+
await w.write(blob);
|
| 31 |
+
await w.close();
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// pool de concurrencia simple: corre `items` a través de `worker`, máx N a la vez
|
| 35 |
+
async function pool(items, n, worker) {
|
| 36 |
+
let i = 0;
|
| 37 |
+
const runners = Array.from({ length: Math.min(n, items.length) }, async () => {
|
| 38 |
+
while (i < items.length) await worker(items[i++]);
|
| 39 |
+
});
|
| 40 |
+
await Promise.all(runners);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// ── listar (una sola vez, resultado persistible) ─────────────────────────
|
| 44 |
+
async function listGithubFiles({ owner, repo, branch }) {
|
| 45 |
+
if (!branch) {
|
| 46 |
+
const r = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
|
| 47 |
+
if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.');
|
| 48 |
+
if (!r.ok) throw new Error(`Repo no encontrado o privado (HTTP ${r.status})`);
|
| 49 |
+
branch = (await r.json()).default_branch || 'main';
|
| 50 |
+
}
|
| 51 |
+
let tree = null;
|
| 52 |
+
for (const b of [branch, 'main', 'master']) {
|
| 53 |
+
const r = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${b}?recursive=1`);
|
| 54 |
+
if (r.ok) { tree = (await r.json()).tree || []; branch = b; break; }
|
| 55 |
+
if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.');
|
| 56 |
+
}
|
| 57 |
+
if (!tree) throw new Error('No pude leer el árbol del repo');
|
| 58 |
+
const blobs = tree.filter(n => n.type === 'blob');
|
| 59 |
+
const files = blobs.filter(n => !n.size || n.size <= MAX_FILE_BYTES).slice(0, MAX_FILES).map(n => ({ path: n.path, size: n.size || 0 }));
|
| 60 |
+
return { branch, files, skipped: blobs.length - files.length };
|
| 61 |
+
}
|
| 62 |
+
// Bitbucket no tiene un único endpoint de árbol recursivo: se camina por directorios.
|
| 63 |
+
async function bitbucketWalk(owner, repo, branch, path, files) {
|
| 64 |
+
if (files.length >= MAX_FILES) return;
|
| 65 |
+
let url = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${branch}/${path}?pagelen=100`;
|
| 66 |
+
while (url && files.length < MAX_FILES) {
|
| 67 |
+
const r = await fetch(url);
|
| 68 |
+
if (!r.ok) throw new Error(`Bitbucket: HTTP ${r.status} (¿repo privado o rama incorrecta?)`);
|
| 69 |
+
const data = await r.json();
|
| 70 |
+
for (const e of data.values || []) {
|
| 71 |
+
if (files.length >= MAX_FILES) break;
|
| 72 |
+
if (e.type === 'commit_directory') await bitbucketWalk(owner, repo, branch, e.path, files);
|
| 73 |
+
else if (e.type === 'commit_file' && (!e.size || e.size <= MAX_FILE_BYTES)) files.push({ path: e.path, size: e.size || 0 });
|
| 74 |
+
}
|
| 75 |
+
url = data.next || null;
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
async function listBitbucketFiles({ owner, repo, branch }) {
|
| 79 |
+
if (!branch) {
|
| 80 |
+
const r = await fetch(`https://api.bitbucket.org/2.0/repositories/${owner}/${repo}`);
|
| 81 |
+
if (!r.ok) throw new Error(`Repo no encontrado o privado (HTTP ${r.status})`);
|
| 82 |
+
branch = (await r.json()).mainbranch?.name || 'main';
|
| 83 |
+
}
|
| 84 |
+
const files = [];
|
| 85 |
+
await bitbucketWalk(owner, repo, branch, '', files);
|
| 86 |
+
return { branch, files, skipped: 0 };
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Lista el repo completo UNA vez. Devuelve todo lo necesario para persistir
|
| 90 |
+
// el trabajo: { host, owner, repo, branch, files:[{path,size}], skipped }.
|
| 91 |
+
export async function listRepo(url) {
|
| 92 |
+
const info = parseRepoUrl(url);
|
| 93 |
+
const { branch, files, skipped } = info.host === 'github' ? await listGithubFiles(info) : await listBitbucketFiles(info);
|
| 94 |
+
if (!files.length) throw new Error('El repo está vacío (o no pude listarlo)');
|
| 95 |
+
return { host: info.host, owner: info.owner, repo: info.repo, branch, files, skipped };
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Descarga los ficheros de `job.files` que NO estén en `doneSet` (Set de
|
| 99 |
+
// rutas). Llama a onFileDone(path) tras cada escritura para que quien invoque
|
| 100 |
+
// persista el progreso — así una interrupción se retoma sin re-descargar nada.
|
| 101 |
+
export async function downloadFiles(job, targetHandle, doneSet, onProgress, onFileDone) {
|
| 102 |
+
const { host, owner, repo, branch, files } = job;
|
| 103 |
+
const pending = files.filter(f => !doneSet.has(f.path));
|
| 104 |
+
let done = files.length - pending.length;
|
| 105 |
+
onProgress?.({ text: `${done}/${files.length}`, done, total: files.length });
|
| 106 |
+
await pool(pending, CONCURRENCY, async f => {
|
| 107 |
+
onProgress?.({ text: `Descargando ${f.path}…`, done, total: files.length });
|
| 108 |
+
const url = host === 'github'
|
| 109 |
+
? `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${f.path}`
|
| 110 |
+
: `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${branch}/${f.path}`;
|
| 111 |
+
const r = await fetch(url);
|
| 112 |
+
if (r.ok) await writeInto(targetHandle, f.path, await r.blob());
|
| 113 |
+
done++;
|
| 114 |
+
onFileDone?.(f.path);
|
| 115 |
+
onProgress?.({ text: `${done}/${files.length}`, done, total: files.length });
|
| 116 |
+
});
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// Conveniencia: listar + descargar todo de una vez (sin resumibilidad; para
|
| 120 |
+
// quien no necesite persistir progreso entre sesiones).
|
| 121 |
+
export async function cloneToHandle(url, targetHandle, onProgress) {
|
| 122 |
+
const job = await listRepo(url);
|
| 123 |
+
await downloadFiles(job, targetHandle, new Set(), onProgress);
|
| 124 |
+
return { count: job.files.length, skipped: job.skipped };
|
| 125 |
+
}
|
js/tools/code.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Herramientas de proyecto: el único mundo que toca Elffuss Code es la
|
| 2 |
+
// carpeta de código que el usuario abre con el picker nativo.
|
| 3 |
+
import * as db from '../db.js';
|
| 4 |
+
|
| 5 |
+
let projectHandle = null;
|
| 6 |
+
let projectName = '';
|
| 7 |
+
let currentFile = null; // ruta abierta en el editor
|
| 8 |
+
let onFileWritten = () => {}; // el IDE refresca pestañas/árbol
|
| 9 |
+
|
| 10 |
+
export function setOnFileWritten(fn) { onFileWritten = fn; }
|
| 11 |
+
export function setCurrentFile(path) { currentFile = path; }
|
| 12 |
+
export function current() { return { projectName, currentFile }; }
|
| 13 |
+
export function handle() { return projectHandle; }
|
| 14 |
+
|
| 15 |
+
// Lee un archivo de texto dentro de un dir handle por ruta (soporta subdirs).
|
| 16 |
+
async function readIn(dir, path) {
|
| 17 |
+
const parts = path.split('/'); const name = parts.pop();
|
| 18 |
+
for (const p of parts) dir = await dir.getDirectoryHandle(p);
|
| 19 |
+
return (await (await dir.getFileHandle(name)).getFile()).text();
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// Integración git SIN dependencias: parsea el .git directamente (rama + último
|
| 23 |
+
// commit). Suficiente para orientar; commitear se le pide al agente/terminal.
|
| 24 |
+
export async function gitInfo() {
|
| 25 |
+
if (!projectHandle) return { isRepo: false };
|
| 26 |
+
let git;
|
| 27 |
+
try { git = await projectHandle.getDirectoryHandle('.git'); }
|
| 28 |
+
catch { return { isRepo: false }; }
|
| 29 |
+
const out = { isRepo: true, branch: '(detached)', lastCommit: null };
|
| 30 |
+
try {
|
| 31 |
+
const head = (await readIn(git, 'HEAD')).trim();
|
| 32 |
+
out.branch = head.match(/ref:\s*refs\/heads\/(.+)/)?.[1] || head.slice(0, 7);
|
| 33 |
+
} catch { /* sin HEAD */ }
|
| 34 |
+
try {
|
| 35 |
+
const last = (await readIn(git, 'logs/HEAD')).trim().split('\n').pop();
|
| 36 |
+
const m = last.match(/^\S+ \S+ (.+?) <[^>]*> (\d+)[^\t]*\t(.+)$/);
|
| 37 |
+
if (m) out.lastCommit = { author: m[1], when: new Date(+m[2] * 1000), msg: m[3] };
|
| 38 |
+
} catch { /* sin logs (repo recién creado) */ }
|
| 39 |
+
return out;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Lista plana de archivos (para el command palette). Cacheada por proyecto.
|
| 43 |
+
let fileListCache = null;
|
| 44 |
+
export function invalidateFileList() { fileListCache = null; }
|
| 45 |
+
export async function fileList() {
|
| 46 |
+
if (fileListCache) return fileListCache;
|
| 47 |
+
if (!projectHandle) return [];
|
| 48 |
+
const files = [];
|
| 49 |
+
async function walk(dir, prefix, depth) {
|
| 50 |
+
if (depth > 8 || files.length > 4000) return;
|
| 51 |
+
for await (const e of dir.values()) {
|
| 52 |
+
if (IGNORE.has(e.name)) continue;
|
| 53 |
+
const p = prefix ? prefix + '/' + e.name : e.name;
|
| 54 |
+
if (e.kind === 'directory') await walk(e, p, depth + 1);
|
| 55 |
+
else files.push(p);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
await walk(projectHandle, '', 0);
|
| 59 |
+
fileListCache = files;
|
| 60 |
+
return files;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export async function openProject(handle) {
|
| 64 |
+
projectHandle = handle;
|
| 65 |
+
projectName = handle.name || 'proyecto';
|
| 66 |
+
await db.set('kv', 'project', handle).catch(() => {});
|
| 67 |
+
return projectName;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// Reabrir el último proyecto (el navegador puede exigir un gesto para re-conceder).
|
| 71 |
+
export async function restoreProject() {
|
| 72 |
+
const h = await db.get('kv', 'project').catch(() => null);
|
| 73 |
+
if (!h) return null;
|
| 74 |
+
const q = h.queryPermission ? await h.queryPermission({ mode: 'readwrite' }) : 'granted';
|
| 75 |
+
if (q === 'granted') {
|
| 76 |
+
projectHandle = h;
|
| 77 |
+
projectName = h.name || 'proyecto';
|
| 78 |
+
return { name: projectName, ready: true };
|
| 79 |
+
}
|
| 80 |
+
return { name: h.name, ready: false, handle: h };
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export async function regrant(h) {
|
| 84 |
+
if (await h.requestPermission({ mode: 'readwrite' }) !== 'granted')
|
| 85 |
+
throw new Error('permiso denegado');
|
| 86 |
+
return openProject(h);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
| 90 |
+
const MAX_READ = 60_000;
|
| 91 |
+
|
| 92 |
+
// Normaliza rutas del modelo: quita ./ y / iniciales y segmentos '.'
|
| 93 |
+
const normalize = path => (path || '').split('/').filter(p => p && p !== '.');
|
| 94 |
+
|
| 95 |
+
async function dirOf(path, { create = false } = {}) {
|
| 96 |
+
if (!projectHandle) throw new Error('No hay proyecto abierto');
|
| 97 |
+
const parts = normalize(path);
|
| 98 |
+
const name = parts.pop();
|
| 99 |
+
let dir = projectHandle;
|
| 100 |
+
for (const p of parts) dir = await dir.getDirectoryHandle(p, { create });
|
| 101 |
+
return { dir, name };
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// Buscar archivos por nombre (para sugerir cuando el modelo inventa rutas, o
|
| 105 |
+
// para resolver un enlace del chat que solo mencionaba el nombre del fichero).
|
| 106 |
+
export async function findByName(basename, limit = 5) {
|
| 107 |
+
const hits = [];
|
| 108 |
+
let visited = 0;
|
| 109 |
+
async function walk(dir, prefix) {
|
| 110 |
+
if (hits.length >= limit || visited > 500) return;
|
| 111 |
+
for await (const e of dir.values()) {
|
| 112 |
+
if (IGNORE.has(e.name)) continue;
|
| 113 |
+
visited++;
|
| 114 |
+
const p = prefix ? prefix + '/' + e.name : e.name;
|
| 115 |
+
if (e.kind === 'directory') await walk(e, p);
|
| 116 |
+
else if (e.name.toLowerCase() === basename.toLowerCase()) hits.push(p);
|
| 117 |
+
if (hits.length >= limit || visited > 500) return;
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
await walk(projectHandle, '');
|
| 121 |
+
return hits;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Árbol de texto (para el modelo y el CONTEXTO). Ignora dependencias/binarios.
|
| 125 |
+
export async function tree({ path = '', depth = 3 } = {}) {
|
| 126 |
+
if (!projectHandle) throw new Error('No hay proyecto abierto');
|
| 127 |
+
let root = projectHandle;
|
| 128 |
+
for (const p of normalize(path)) root = await root.getDirectoryHandle(p);
|
| 129 |
+
const out = [];
|
| 130 |
+
let count = 0;
|
| 131 |
+
async function walk(dir, prefix, d) {
|
| 132 |
+
if (d > depth || count > 350) return;
|
| 133 |
+
const entries = [];
|
| 134 |
+
for await (const e of dir.values()) entries.push(e);
|
| 135 |
+
entries.sort((a, b) => (a.kind !== b.kind) ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name));
|
| 136 |
+
for (const e of entries) {
|
| 137 |
+
if (IGNORE.has(e.name)) continue;
|
| 138 |
+
if (++count > 350) { out.push(prefix + '…'); return; }
|
| 139 |
+
out.push(prefix + (e.kind === 'directory' ? '📁 ' : '') + e.name);
|
| 140 |
+
if (e.kind === 'directory') await walk(e, prefix + ' ', d + 1);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
await walk(root, '', 1);
|
| 144 |
+
return out.join('\n') || '(vacío)';
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
const PAGE_SIZE = 100, PAGE_MAX = 500;
|
| 148 |
+
|
| 149 |
+
// Lectura completa (comportamiento de siempre) O por páginas de líneas — para
|
| 150 |
+
// ficheros grandes, o para plantarse justo en el número de línea que dio
|
| 151 |
+
// code.search y traer contexto antes/después sin volcar el fichero entero.
|
| 152 |
+
// offset: línea 1-based donde empezar (con limit, PAGE_SIZE por defecto)
|
| 153 |
+
// around: centra la página en esa línea (offset = around - limit/2)
|
| 154 |
+
// Sin offset/limit/around → fichero completo tal cual (no rompe nada que ya
|
| 155 |
+
// dependa de leerlo entero, p.ej. code.edit).
|
| 156 |
+
export async function read({ path, offset, limit, around } = {}) {
|
| 157 |
+
if (!path) throw new Error('Falta path');
|
| 158 |
+
let file;
|
| 159 |
+
try {
|
| 160 |
+
const { dir, name } = await dirOf(path);
|
| 161 |
+
file = await (await dir.getFileHandle(name)).getFile();
|
| 162 |
+
} catch {
|
| 163 |
+
// error que ENSEÑA: el modelo puede auto-corregirse en el siguiente paso
|
| 164 |
+
const base = normalize(path).pop() || path;
|
| 165 |
+
const hits = await findByName(base).catch(() => []);
|
| 166 |
+
throw new Error(`no existe «${path}» en este proyecto.` +
|
| 167 |
+
(hits.length ? ` ¿Quizá: ${hits.join(' · ')}?` : '') +
|
| 168 |
+
' Consulta el árbol (code.tree) o busca (code.search) antes de leer.');
|
| 169 |
+
}
|
| 170 |
+
const full = await file.text();
|
| 171 |
+
|
| 172 |
+
if (offset == null && limit == null && around == null) {
|
| 173 |
+
return full.length > MAX_READ ? full.slice(0, MAX_READ) + `\n… (recortado, ${file.size} bytes)` : full;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const lines = full.split('\n');
|
| 177 |
+
const total = lines.length;
|
| 178 |
+
const size = Math.max(1, Math.min(Math.round(limit) || PAGE_SIZE, PAGE_MAX));
|
| 179 |
+
const start = around != null
|
| 180 |
+
? Math.max(1, Math.round(around) - Math.floor(size / 2))
|
| 181 |
+
: Math.max(1, Math.round(offset) || 1);
|
| 182 |
+
const startIdx = start - 1;
|
| 183 |
+
const slice = lines.slice(startIdx, startIdx + size);
|
| 184 |
+
const endLine = startIdx + slice.length;
|
| 185 |
+
const numbered = slice.map((l, i) => `${start + i}→${l}`).join('\n');
|
| 186 |
+
const more = endLine < total ? `\n… quedan líneas ${endLine + 1}-${total} — pide code.read con offset:${endLine + 1} para seguir` : '';
|
| 187 |
+
return `${path}: líneas ${start}-${endLine} de ${total}\n${numbered}${more}`;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// Aprobación de escritura: si «Auto» está apagado, el IDE pide confirmación
|
| 191 |
+
// antes de que la elfa toque archivos (como «Edit automatically» del plugin).
|
| 192 |
+
let approveWrite = async () => true;
|
| 193 |
+
export function setWriteApprover(fn) { approveWrite = fn; }
|
| 194 |
+
|
| 195 |
+
export async function write({ path, content = '' } = {}) {
|
| 196 |
+
if (!path) throw new Error('Falta path');
|
| 197 |
+
if (!await approveWrite(path, content)) return `Cambio en ${path} rechazado por el usuario`;
|
| 198 |
+
const { dir, name } = await dirOf(path, { create: true });
|
| 199 |
+
const fh = await dir.getFileHandle(name, { create: true });
|
| 200 |
+
const w = await fh.createWritable();
|
| 201 |
+
await w.write(content);
|
| 202 |
+
await w.close();
|
| 203 |
+
invalidateFileList();
|
| 204 |
+
onFileWritten(path, content);
|
| 205 |
+
return `Escrito ${path} (${content.split('\n').length} líneas)`;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Edición PARCIAL (no hace falta reescribir el fichero entero): sustituye
|
| 209 |
+
// `search` por `replace`. Primero intenta coincidencia EXACTA (rápida, sin
|
| 210 |
+
// ambigüedad); si no la encuentra tal cual —el modelo puede recordar mal un
|
| 211 |
+
// espacio o un salto de línea—, cae a un parcheado difuso (diff-match-patch,
|
| 212 |
+
// la misma librería que usan herramientas como aider) que localiza el punto
|
| 213 |
+
// más parecido dentro del fichero real. Si ni así hay confianza suficiente,
|
| 214 |
+
// falla con un mensaje claro para que el agente reintente con más contexto
|
| 215 |
+
// — nunca escribe una coincidencia dudosa.
|
| 216 |
+
export async function edit({ path, search, replace } = {}) {
|
| 217 |
+
if (!path) throw new Error('Falta path');
|
| 218 |
+
if (search == null || replace == null) throw new Error('Faltan search y replace');
|
| 219 |
+
const current = await read({ path });
|
| 220 |
+
|
| 221 |
+
const first = current.indexOf(search);
|
| 222 |
+
if (first !== -1) {
|
| 223 |
+
if (current.indexOf(search, first + 1) !== -1) {
|
| 224 |
+
throw new Error(`«search» aparece más de una vez en ${path} — añade más líneas de contexto alrededor para que sea inequívoco.`);
|
| 225 |
+
}
|
| 226 |
+
return write({ path, content: current.slice(0, first) + replace + current.slice(first + search.length) });
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// fallback difuso: el texto no aparece tal cual, prueba con la misma
|
| 230 |
+
// tolerancia con la que un desarrollador reconocería "es este trozo"
|
| 231 |
+
const { diff_match_patch } = await import('https://esm.sh/diff-match-patch@1.0.5');
|
| 232 |
+
const dmp = new diff_match_patch();
|
| 233 |
+
const patches = dmp.patch_make(search, replace);
|
| 234 |
+
const [next, results] = dmp.patch_apply(patches, current);
|
| 235 |
+
if (!results.length || !results.every(Boolean)) {
|
| 236 |
+
throw new Error(`No encontré con suficiente confianza el punto exacto a editar en ${path} (ni exacto ni aproximado). ` +
|
| 237 |
+
`Vuelve a leerlo (code.read) y copia «search» literal de esas líneas, más corto si hace falta.`);
|
| 238 |
+
}
|
| 239 |
+
return write({ path, content: next });
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// grep-lite por el proyecto (texto, con límites para no arrasar).
|
| 243 |
+
export async function search({ query, ext = '' } = {}) {
|
| 244 |
+
if (!query) throw new Error('Falta query');
|
| 245 |
+
if (!projectHandle) throw new Error('No hay proyecto abierto');
|
| 246 |
+
const results = [];
|
| 247 |
+
let checked = 0;
|
| 248 |
+
const q = query.toLowerCase();
|
| 249 |
+
async function walk(dir, prefix) {
|
| 250 |
+
if (results.length > 80 || checked > 400) return;
|
| 251 |
+
for await (const e of dir.values()) {
|
| 252 |
+
if (IGNORE.has(e.name)) continue;
|
| 253 |
+
const p = prefix ? prefix + '/' + e.name : e.name;
|
| 254 |
+
if (e.kind === 'directory') { await walk(e, p); continue; }
|
| 255 |
+
if (ext && !e.name.endsWith(ext)) continue;
|
| 256 |
+
const f = await e.getFile();
|
| 257 |
+
if (f.size > 200_000) continue;
|
| 258 |
+
checked++;
|
| 259 |
+
const lines = (await f.text()).split('\n');
|
| 260 |
+
lines.forEach((l, i) => {
|
| 261 |
+
if (results.length <= 80 && l.toLowerCase().includes(q))
|
| 262 |
+
results.push(`${p}:${i + 1}: ${l.trim().slice(0, 140)}`);
|
| 263 |
+
});
|
| 264 |
+
if (results.length > 80 || checked > 400) return;
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
await walk(projectHandle, '');
|
| 268 |
+
return results.length ? results.join('\n') : `Sin resultados para «${query}»`;
|
| 269 |
+
}
|
js/tools/index.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Registro de herramientas de Elffuss Code: proyecto abierto + shell + web.
|
| 2 |
+
import * as code from './code.js';
|
| 3 |
+
import * as shell from '../shell.js';
|
| 4 |
+
import * as web from './web.js';
|
| 5 |
+
|
| 6 |
+
export { code };
|
| 7 |
+
|
| 8 |
+
// El terminal (UI) se engancha aquí para reflejar lo que ejecuta la elfa.
|
| 9 |
+
let onTerminalEcho = () => {};
|
| 10 |
+
export function setTerminalEcho(fn) { onTerminalEcho = fn; }
|
| 11 |
+
|
| 12 |
+
export const TOOLS = {
|
| 13 |
+
'code.tree': { desc: 'Ver el árbol de archivos del proyecto', params: { path: 'subcarpeta (opcional)', depth: 'niveles (3)' }, run: a => code.tree(a) },
|
| 14 |
+
'code.read': { desc: 'Leer un archivo del proyecto. Sin más parámetros, lo lee ENTERO. Para ficheros grandes, pagina de 100 en 100 líneas con offset (offset:101 = siguiente página tras la primera). Para ir directo a donde dio un resultado de code.search (que ya incluye número de línea), usa around:N — trae ~100 líneas centradas en la línea N, con cada línea numerada (N→contenido) para poder citarla o editarla con precisión', params: { path: 'ruta relativa', offset: 'línea 1-based donde empezar (opcional)', limit: 'líneas a devolver, 100 por defecto (opcional)', around: 'centra la página en esta línea, p.ej. la que dio code.search (opcional)' }, run: a => code.read(a) },
|
| 15 |
+
'code.write': { desc: 'Escribir/crear un archivo (contenido COMPLETO; se refleja al instante en el editor). Para cambios PEQUEÑOS en un fichero YA existente, usa mejor code.edit', params: { path: 'ruta', content: 'contenido íntegro' }, run: a => code.write(a) },
|
| 16 |
+
'code.edit': { desc: 'Editar PARTE de un fichero existente sin reescribirlo entero: sustituye "search" (copia literal de unas pocas líneas, con contexto suficiente para ser única en el fichero) por "replace". Preferible a code.write para cambios pequeños/medianos — más rápido y sin riesgo de perder el resto del fichero. Si falla (no encuentra el punto con confianza), relee el fichero y reintenta con un search más ajustado', params: { path: 'ruta', search: 'texto exacto a sustituir', replace: 'texto nuevo' }, run: a => code.edit(a) },
|
| 17 |
+
'code.search': { desc: 'Buscar texto en el proyecto (grep)', params: { query: 'texto', ext: 'filtro extensión (opcional)' }, run: a => code.search(a) },
|
| 18 |
+
'terminal.run': { desc: 'Ejecutar un comando de shell sobre los ficheros del proyecto (ls, cat, grep, find, mkdir, echo>fichero, git status…). node/npm/python REALES si el usuario tiene un Bridge local conectado (⚙ Ajustes → 🔌 Bridge local); si no está conectado, el propio resultado lo indica', params: { command: 'la línea de comando' }, run: async a => { const out = await shell.runForAgent(a.command || ''); onTerminalEcho(a.command || '', out); return out; } },
|
| 19 |
+
'web.search': { desc: 'Buscar en internet (docs, errores, APIs) — devuelve títulos, URLs y fragmentos', params: { query: 'qué buscar' }, run: a => web.search(a) },
|
| 20 |
+
'web.fetch': { desc: 'Leer el contenido de texto de una URL (documentación, referencia)', params: { url: 'https://…' }, run: a => web.fetchUrl(a) },
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
export function toolHelp() {
|
| 24 |
+
return Object.entries(TOOLS).map(([n, t]) =>
|
| 25 |
+
`- ${n}(${Object.keys(t.params).join(', ')}): ${t.desc}`).join('\n');
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export async function runTool(name, args) {
|
| 29 |
+
const tool = TOOLS[name];
|
| 30 |
+
if (!tool) throw new Error(`Herramienta desconocida: ${name}`);
|
| 31 |
+
return tool.run(args || {});
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// CONTEXTO AHORA del IDE: proyecto, archivo abierto y árbol resumido.
|
| 35 |
+
export async function snapshot() {
|
| 36 |
+
const { projectName, currentFile } = code.current();
|
| 37 |
+
const parts = [
|
| 38 |
+
'Fecha y hora: ' + new Date().toLocaleString(),
|
| 39 |
+
'Proyecto abierto: ' + (projectName || 'ninguno'),
|
| 40 |
+
'Archivo abierto en el editor: ' + (currentFile || 'ninguno'),
|
| 41 |
+
];
|
| 42 |
+
try { parts.push('Árbol del proyecto (resumen):\n' + await code.tree({ depth: 2 })); }
|
| 43 |
+
catch { /* aún sin proyecto */ }
|
| 44 |
+
// GROUNDING: incluye el contenido REAL de los archivos clave (README, config)
|
| 45 |
+
// para que el modelo NO alucine sobre el proyecto — el heal no domina las
|
| 46 |
+
// herramientas code.* así que le damos el material masticado.
|
| 47 |
+
for (const key of ['README.md', 'readme.md', 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod']) {
|
| 48 |
+
try { const body = await code.read({ path: key }); parts.push(`Contenido REAL de ${key}:\n${body.slice(0, 1400)}`); break; } catch { /* siguiente */ }
|
| 49 |
+
}
|
| 50 |
+
if (currentFile) {
|
| 51 |
+
try {
|
| 52 |
+
const body = await code.read({ path: currentFile });
|
| 53 |
+
parts.push(`Contenido REAL del archivo abierto ${currentFile}:\n` + body.slice(0, 2000));
|
| 54 |
+
} catch { /* borrado */ }
|
| 55 |
+
}
|
| 56 |
+
return parts.join('\n');
|
| 57 |
+
}
|
js/tools/web.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Búsqueda y lectura web para la elfa de Code (docs, errores, APIs). fetch
|
| 2 |
+
// directo si el sitio permite CORS; si no, cae al proxy del servidor
|
| 3 |
+
// (/proxy?url=… → mismo backend que Elffuss Claw). Sin gate de permisos: un
|
| 4 |
+
// agente de código necesita consultar internet de serie.
|
| 5 |
+
const MAX = 8000;
|
| 6 |
+
|
| 7 |
+
function toText(html) {
|
| 8 |
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
| 9 |
+
doc.querySelectorAll('script,style,noscript,svg,iframe').forEach(n => n.remove());
|
| 10 |
+
return ((doc.body?.innerText || doc.body?.textContent || '').replace(/\n{3,}/g, '\n\n').trim()) || html.slice(0, MAX);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
async function proxyGet(url) {
|
| 14 |
+
let lastErr;
|
| 15 |
+
for (const target of [url, '/proxy?url=' + encodeURIComponent(url)]) {
|
| 16 |
+
try { const r = await fetch(target); if (!r.ok) throw new Error('HTTP ' + r.status); return await r.text(); }
|
| 17 |
+
catch (e) { lastErr = e; }
|
| 18 |
+
}
|
| 19 |
+
throw lastErr || new Error('sin respuesta');
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export async function fetchUrl({ url } = {}) {
|
| 23 |
+
if (!/^https?:\/\//.test(url || '')) throw new Error('URL inválida (usa https://…)');
|
| 24 |
+
const text = toText(await proxyGet(url));
|
| 25 |
+
return `[${url}]\n` + (text.length > MAX ? text.slice(0, MAX) + '\n… (recortado)' : text);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Búsqueda REAL en DuckDuckGo. Parseo tolerante: cada resultado es un enlace
|
| 29 |
+
// con ?uddg=<url real>. Independiente de clases (robusto a cambios de HTML).
|
| 30 |
+
export async function search({ query } = {}) {
|
| 31 |
+
if (!query) throw new Error('Falta query');
|
| 32 |
+
let html = '';
|
| 33 |
+
for (const ep of ['https://html.duckduckgo.com/html/?q=', 'https://lite.duckduckgo.com/lite/?q=']) {
|
| 34 |
+
try { html = await proxyGet(ep + encodeURIComponent(query)); if (html.includes('uddg=') || html.includes('result')) break; }
|
| 35 |
+
catch { /* siguiente endpoint */ }
|
| 36 |
+
}
|
| 37 |
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
| 38 |
+
const seen = new Set(), out = [];
|
| 39 |
+
for (const a of doc.querySelectorAll('a[href*="uddg="]')) {
|
| 40 |
+
const m = a.getAttribute('href').match(/uddg=([^&]+)/);
|
| 41 |
+
if (!m) continue;
|
| 42 |
+
const href = decodeURIComponent(m[1]);
|
| 43 |
+
const title = a.textContent.trim();
|
| 44 |
+
if (!title || title.length < 3 || seen.has(href)) continue;
|
| 45 |
+
seen.add(href);
|
| 46 |
+
const block = a.closest('tr, div, article') || a.parentElement;
|
| 47 |
+
const snip = (block?.textContent || '').replace(title, '').replace(/\s+/g, ' ').trim().slice(0, 160);
|
| 48 |
+
out.push(`• ${title}\n ${href}${snip ? '\n ' + snip : ''}`);
|
| 49 |
+
if (out.length >= 8) break;
|
| 50 |
+
}
|
| 51 |
+
return out.length ? `Resultados para «${query}»:\n\n` + out.join('\n\n') : `Sin resultados para «${query}».`;
|
| 52 |
+
}
|
js/vcc/NOTICE.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Vendorizado: VibeCodeViewer
|
| 2 |
+
|
| 3 |
+
`model.js` y `builder.js` son copia VERBATIM de
|
| 4 |
+
[Sendery/VibeCodeViewer](https://github.com/Sendery/VibeCodeViewer) (licencia
|
| 5 |
+
**Apache-2.0**), el motor de la ciudad 3D. No se altera su lógica; solo se les
|
| 6 |
+
alimenta el árbol del proyecto abierto (adaptador en `../city.js`) y `three` se
|
| 7 |
+
resuelve por el import map de `index.html`. `ownership.js` es un stub mínimo
|
| 8 |
+
(no usamos metadatos de propiedad) y `theme.js` es el tema «hacker» original.
|
js/vcc/builder.js
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Construcción de la escena: ciudad de cristal nítida.
|
| 2 |
+
// Receta anti-difuminado: aristas finas con colores HDR (el bloom solo se
|
| 3 |
+
// dispara en lo resaltado), cristal con fresnel de opacidad mínima y nada
|
| 4 |
+
// de volúmenes aditivos permanentes.
|
| 5 |
+
// Toda la paleta viene del tema activo (src/themes.js); los temas claros
|
| 6 |
+
// usan blending normal en lugar de aditivo.
|
| 7 |
+
import * as THREE from 'three';
|
| 8 |
+
|
| 9 |
+
function lerp3(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
|
| 10 |
+
function configureCanvasTexture(tx) {
|
| 11 |
+
tx.colorSpace = THREE.SRGBColorSpace;
|
| 12 |
+
tx.anisotropy = 8;
|
| 13 |
+
// Las texturas son texto fino sobre fondo transparente. Sin mipmaps, los
|
| 14 |
+
// píxeles transparentes no se mezclan con negro al orbitar y solaparse.
|
| 15 |
+
tx.generateMipmaps = false;
|
| 16 |
+
tx.minFilter = THREE.LinearFilter;
|
| 17 |
+
tx.magFilter = THREE.LinearFilter;
|
| 18 |
+
return tx;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// 12 aristas de una caja → 24 vértices
|
| 22 |
+
const EDGE_PAIRS = [
|
| 23 |
+
[0, 1], [1, 3], [3, 2], [2, 0],
|
| 24 |
+
[4, 5], [5, 7], [7, 6], [6, 4],
|
| 25 |
+
[0, 4], [1, 5], [2, 6], [3, 7],
|
| 26 |
+
];
|
| 27 |
+
function corners(b) {
|
| 28 |
+
const x0 = b.cx - b.sx / 2, x1 = b.cx + b.sx / 2;
|
| 29 |
+
const y0 = b.cy - b.sy / 2, y1 = b.cy + b.sy / 2;
|
| 30 |
+
const z0 = b.cz - b.sz / 2, z1 = b.cz + b.sz / 2;
|
| 31 |
+
return [
|
| 32 |
+
[x0, y0, z0], [x1, y0, z0], [x0, y0, z1], [x1, y0, z1],
|
| 33 |
+
[x0, y1, z0], [x1, y1, z0], [x0, y1, z1], [x1, y1, z1],
|
| 34 |
+
];
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// ── suelo: placa de circuito impreso ──────────────────────────────────────
|
| 38 |
+
function circuitTexture(theme) {
|
| 39 |
+
const T = theme.ground;
|
| 40 |
+
const S = 1024;
|
| 41 |
+
const cv = document.createElement('canvas');
|
| 42 |
+
cv.width = cv.height = S;
|
| 43 |
+
const g = cv.getContext('2d');
|
| 44 |
+
g.fillStyle = T.bg;
|
| 45 |
+
g.fillRect(0, 0, S, S);
|
| 46 |
+
g.strokeStyle = T.grid;
|
| 47 |
+
g.lineWidth = 1;
|
| 48 |
+
for (let i = 0; i <= S; i += 64) {
|
| 49 |
+
g.beginPath(); g.moveTo(i, 0); g.lineTo(i, S); g.stroke();
|
| 50 |
+
g.beginPath(); g.moveTo(0, i); g.lineTo(S, i); g.stroke();
|
| 51 |
+
}
|
| 52 |
+
const rnd = (() => { let s = 1234567; return () => (s = (s * 16807) % 2147483647) / 2147483647; })();
|
| 53 |
+
for (let i = 0; i < 260; i++) {
|
| 54 |
+
const isA = rnd() < 0.58;
|
| 55 |
+
const a = 0.18 + rnd() * 0.5;
|
| 56 |
+
const [tr, tg, tb] = isA ? T.traceA : T.traceB;
|
| 57 |
+
g.strokeStyle = `rgba(${tr}, ${tg}, ${tb}, ${isA ? a : a * 0.9})`;
|
| 58 |
+
g.lineWidth = rnd() < 0.12 ? 3 : 1.4;
|
| 59 |
+
let x = rnd() * S, y = rnd() * S;
|
| 60 |
+
g.beginPath(); g.moveTo(x, y);
|
| 61 |
+
const segs = 2 + Math.floor(rnd() * 4);
|
| 62 |
+
for (let sg = 0; sg < segs; sg++) {
|
| 63 |
+
const len = 24 + rnd() * 150;
|
| 64 |
+
if (rnd() < 0.5) x += rnd() < 0.5 ? len : -len; else y += rnd() < 0.5 ? len : -len;
|
| 65 |
+
g.lineTo(x, y);
|
| 66 |
+
}
|
| 67 |
+
g.stroke();
|
| 68 |
+
const [pr, pg, pb] = isA ? T.padA : T.padB;
|
| 69 |
+
g.fillStyle = `rgba(${pr}, ${pg}, ${pb}, ${a + 0.2})`;
|
| 70 |
+
const ps = 2 + rnd() * 3;
|
| 71 |
+
g.fillRect(x - ps / 2, y - ps / 2, ps, ps);
|
| 72 |
+
}
|
| 73 |
+
const tx = new THREE.CanvasTexture(cv);
|
| 74 |
+
tx.wrapS = tx.wrapT = THREE.RepeatWrapping;
|
| 75 |
+
tx.colorSpace = THREE.SRGBColorSpace;
|
| 76 |
+
tx.anisotropy = 8;
|
| 77 |
+
return tx;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// ── cristal con fresnel (bordes algo más vivos, centro casi invisible) ────
|
| 81 |
+
function glassMaterial(theme, dim = 1, solid = 0) {
|
| 82 |
+
return new THREE.ShaderMaterial({
|
| 83 |
+
uniforms: {
|
| 84 |
+
uColor: { value: new THREE.Color(...theme.glass) },
|
| 85 |
+
uHi: { value: new THREE.Color(...theme.glassHi) },
|
| 86 |
+
uGlow: { value: 0 },
|
| 87 |
+
uDim: { value: dim },
|
| 88 |
+
uSolid: { value: solid },
|
| 89 |
+
},
|
| 90 |
+
vertexShader: /* glsl */`
|
| 91 |
+
varying vec3 vN, vV;
|
| 92 |
+
void main() {
|
| 93 |
+
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
| 94 |
+
vN = normalMatrix * normal;
|
| 95 |
+
vV = -mv.xyz;
|
| 96 |
+
gl_Position = projectionMatrix * mv;
|
| 97 |
+
}`,
|
| 98 |
+
fragmentShader: /* glsl */`
|
| 99 |
+
uniform vec3 uColor;
|
| 100 |
+
uniform vec3 uHi;
|
| 101 |
+
uniform float uGlow;
|
| 102 |
+
uniform float uDim;
|
| 103 |
+
uniform float uSolid;
|
| 104 |
+
varying vec3 vN, vV;
|
| 105 |
+
void main() {
|
| 106 |
+
float fr = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.6);
|
| 107 |
+
vec3 col = mix(uColor, uHi, uGlow);
|
| 108 |
+
float alpha = (0.022 + fr * 0.12 + uSolid * 0.12) * uDim + uGlow * 0.05;
|
| 109 |
+
gl_FragColor = vec4(col * ((0.5 + fr * 1.9) * uDim + uGlow), alpha);
|
| 110 |
+
}`,
|
| 111 |
+
transparent: true,
|
| 112 |
+
depthWrite: false,
|
| 113 |
+
side: THREE.DoubleSide,
|
| 114 |
+
});
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// ── fachada: árbol de ficheros impreso sobre el cristal ───────────────────
|
| 118 |
+
const FACADE_MAX_LINES = 150;
|
| 119 |
+
|
| 120 |
+
function facadeLines(building) {
|
| 121 |
+
const lines = [];
|
| 122 |
+
let overflow = false;
|
| 123 |
+
(function walk(node, depth) {
|
| 124 |
+
if (lines.length > FACADE_MAX_LINES) { overflow = true; return; }
|
| 125 |
+
lines.push({ text: node.name + '/', depth, key: 'n' + node.id });
|
| 126 |
+
for (const c of node.children) walk(c, depth + 1);
|
| 127 |
+
for (const fid of node.fileIds) {
|
| 128 |
+
if (lines.length > FACADE_MAX_LINES) { overflow = true; return; }
|
| 129 |
+
const f = building.modelFiles[fid];
|
| 130 |
+
lines.push({ text: f.name, depth: depth + 1, key: 'f' + f.id });
|
| 131 |
+
}
|
| 132 |
+
})(building, 0);
|
| 133 |
+
if (overflow) lines.push({ text: '· · ·', depth: 1, key: '' });
|
| 134 |
+
return lines;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function drawFacade(building, highlight, theme) {
|
| 138 |
+
const cv = building.facadeCanvas;
|
| 139 |
+
if (!cv) return; // edificio sin fachada (recorte en ciudades enormes)
|
| 140 |
+
const g = cv.getContext('2d');
|
| 141 |
+
const W = cv.width, H = cv.height;
|
| 142 |
+
g.clearRect(0, 0, W, H);
|
| 143 |
+
const lines = building.facadeLineCache;
|
| 144 |
+
const fs = Math.max(9, Math.min(30, (H - 60) / (lines.length + 1)));
|
| 145 |
+
g.font = `${fs}px "JetBrains Mono", "Cascadia Code", monospace`;
|
| 146 |
+
g.textBaseline = 'top';
|
| 147 |
+
let y = 26;
|
| 148 |
+
for (const ln of lines) {
|
| 149 |
+
const x = 22 + ln.depth * fs * 1.1;
|
| 150 |
+
const hot = highlight && highlight.has(ln.key);
|
| 151 |
+
if (hot) {
|
| 152 |
+
g.shadowColor = theme.facade.hotGlow;
|
| 153 |
+
g.shadowBlur = 14;
|
| 154 |
+
g.fillStyle = theme.facade.hotBg;
|
| 155 |
+
g.fillRect(x - 6, y - 3, g.measureText(ln.text).width + 12, fs + 6);
|
| 156 |
+
g.fillStyle = theme.facade.hot;
|
| 157 |
+
} else {
|
| 158 |
+
g.shadowBlur = 0;
|
| 159 |
+
g.fillStyle = theme.facade.text;
|
| 160 |
+
}
|
| 161 |
+
g.fillText(ln.text, x, y);
|
| 162 |
+
y += fs * 1.18;
|
| 163 |
+
if (y > H - fs) break;
|
| 164 |
+
}
|
| 165 |
+
g.shadowBlur = 0;
|
| 166 |
+
building.facadeTexture.needsUpdate = true;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function buildFacade(building, model, group, theme) {
|
| 170 |
+
building.modelFiles = model.files;
|
| 171 |
+
building.facadeLineCache = facadeLines(building);
|
| 172 |
+
const b = building.box;
|
| 173 |
+
const H = 1024;
|
| 174 |
+
const W = Math.round(Math.min(2048, Math.max(256, H * (b.sx / b.sy))));
|
| 175 |
+
const cv = document.createElement('canvas');
|
| 176 |
+
cv.width = W; cv.height = H;
|
| 177 |
+
building.facadeCanvas = cv;
|
| 178 |
+
const tx = configureCanvasTexture(new THREE.CanvasTexture(cv));
|
| 179 |
+
building.facadeTexture = tx;
|
| 180 |
+
drawFacade(building, null, theme);
|
| 181 |
+
const mat = new THREE.MeshBasicMaterial({
|
| 182 |
+
map: tx, transparent: true, alphaTest: 0.025, depthWrite: false, side: THREE.FrontSide,
|
| 183 |
+
blending: theme.light ? THREE.NormalBlending : THREE.AdditiveBlending,
|
| 184 |
+
});
|
| 185 |
+
mat.userData.vccKind = 'facade';
|
| 186 |
+
const E = 0.14;
|
| 187 |
+
const faces = [
|
| 188 |
+
{ w: b.sx, pos: [b.cx, b.cy, b.cz + b.sz / 2 + E], rot: 0 },
|
| 189 |
+
{ w: b.sx, pos: [b.cx, b.cy, b.cz - b.sz / 2 - E], rot: Math.PI },
|
| 190 |
+
{ w: b.sz, pos: [b.cx + b.sx / 2 + E, b.cy, b.cz], rot: Math.PI / 2 },
|
| 191 |
+
{ w: b.sz, pos: [b.cx - b.sx / 2 - E, b.cy, b.cz], rot: -Math.PI / 2 },
|
| 192 |
+
];
|
| 193 |
+
for (const f of faces) {
|
| 194 |
+
const plane = new THREE.Mesh(new THREE.PlaneGeometry(f.w, b.sy), mat);
|
| 195 |
+
plane.position.set(...f.pos);
|
| 196 |
+
plane.rotation.y = f.rot;
|
| 197 |
+
plane.renderOrder = 3;
|
| 198 |
+
group.add(plane);
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// ── leyenda: nombres serigrafiados (suelo de distrito y tejado) ───────────
|
| 203 |
+
function labelTexture(text, color) {
|
| 204 |
+
const fs = 96, pad = 40;
|
| 205 |
+
const probe = document.createElement('canvas').getContext('2d');
|
| 206 |
+
probe.font = `bold ${fs}px "JetBrains Mono", monospace`;
|
| 207 |
+
const tw = probe.measureText(text).width;
|
| 208 |
+
const cv = document.createElement('canvas');
|
| 209 |
+
cv.width = Math.ceil(tw + pad * 2 + 220);
|
| 210 |
+
cv.height = 190;
|
| 211 |
+
const g = cv.getContext('2d');
|
| 212 |
+
g.font = `bold ${fs}px "JetBrains Mono", monospace`;
|
| 213 |
+
g.textBaseline = 'middle';
|
| 214 |
+
// pista de circuito: línea con pads a ambos lados del texto
|
| 215 |
+
g.strokeStyle = color; g.globalAlpha = 0.65; g.lineWidth = 5;
|
| 216 |
+
const y = cv.height / 2;
|
| 217 |
+
g.beginPath(); g.moveTo(14, y); g.lineTo(pad + 60, y); g.stroke();
|
| 218 |
+
g.beginPath(); g.moveTo(pad + 100 + tw, y); g.lineTo(cv.width - 14, y); g.stroke();
|
| 219 |
+
g.fillStyle = color;
|
| 220 |
+
for (const px of [14, pad + 60, pad + 100 + tw, cv.width - 26]) g.fillRect(px - 7, y - 7, 14, 14);
|
| 221 |
+
g.globalAlpha = 0.92;
|
| 222 |
+
g.shadowColor = color; g.shadowBlur = 16;
|
| 223 |
+
g.fillText(text, pad + 80, y);
|
| 224 |
+
const tx = configureCanvasTexture(new THREE.CanvasTexture(cv));
|
| 225 |
+
return { tx, aspect: cv.width / cv.height };
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
const LEGEND_MAX_BUILDINGS = 400;
|
| 229 |
+
|
| 230 |
+
function buildLegend(model, labeled, theme) {
|
| 231 |
+
const legend = new THREE.Group();
|
| 232 |
+
const add = (text, color, width, pos, flat) => {
|
| 233 |
+
const { tx, aspect } = labelTexture(text, color);
|
| 234 |
+
const w = Math.min(width, aspect * 8);
|
| 235 |
+
const mesh = new THREE.Mesh(
|
| 236 |
+
new THREE.PlaneGeometry(w, w / aspect),
|
| 237 |
+
new THREE.MeshBasicMaterial({
|
| 238 |
+
map: tx, transparent: true, alphaTest: 0.025, depthWrite: false,
|
| 239 |
+
blending: theme.light ? THREE.NormalBlending : THREE.AdditiveBlending,
|
| 240 |
+
}),
|
| 241 |
+
);
|
| 242 |
+
mesh.material.userData.vccKind = 'label';
|
| 243 |
+
if (flat) mesh.rotation.x = -Math.PI / 2;
|
| 244 |
+
mesh.position.set(...pos);
|
| 245 |
+
mesh.renderOrder = 4;
|
| 246 |
+
legend.add(mesh);
|
| 247 |
+
};
|
| 248 |
+
for (const di of model.districts) {
|
| 249 |
+
add(di.name, theme.labels.district, di.box.sx * 0.85,
|
| 250 |
+
[di.box.cx, 0.3, di.box.cz + di.box.sz / 2 - 2], true);
|
| 251 |
+
}
|
| 252 |
+
// urbanizaciones: mismo serigrafiado de suelo que los distritos, en tono
|
| 253 |
+
// intermedio; en monorepos se etiquetan las de mayor superficie
|
| 254 |
+
const urbs = [...model.urbs].sort((a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz);
|
| 255 |
+
for (const u of urbs.slice(0, LEGEND_MAX_BUILDINGS)) {
|
| 256 |
+
add(u.name, theme.labels.urb, u.box.sx * 0.8,
|
| 257 |
+
[u.box.cx, 0.32, u.box.cz + u.box.sz / 2 - 1.4], true);
|
| 258 |
+
}
|
| 259 |
+
for (const b of labeled.slice(0, LEGEND_MAX_BUILDINGS)) {
|
| 260 |
+
if (b.isBasement) continue; // su tejado es la calle: sin etiqueta
|
| 261 |
+
add(b.name, theme.labels.building, b.box.sx * 0.95,
|
| 262 |
+
[b.box.cx, b.box.cy + b.box.sy / 2 + 0.12, b.box.cz], true);
|
| 263 |
+
}
|
| 264 |
+
return legend;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// ── ciudad completa ───────────────────────────────────────────────────────
|
| 268 |
+
export function buildCity(scene, model, theme) {
|
| 269 |
+
const group = new THREE.Group();
|
| 270 |
+
scene.add(group);
|
| 271 |
+
// paleta del tema; en temas claros el blending aditivo no funciona
|
| 272 |
+
const ROLE_COLOR = theme.roleColor;
|
| 273 |
+
const HI = theme.hi;
|
| 274 |
+
const FILE_BASE = theme.fileBase;
|
| 275 |
+
const FILE_HI = theme.fileHi;
|
| 276 |
+
const BLEND = theme.light ? THREE.NormalBlending : THREE.AdditiveBlending;
|
| 277 |
+
|
| 278 |
+
// suelo
|
| 279 |
+
const extent = Math.max(model.city.box.sx, model.city.box.sz);
|
| 280 |
+
const ground = new THREE.Mesh(
|
| 281 |
+
new THREE.PlaneGeometry(extent * 7, extent * 7),
|
| 282 |
+
// semitransparente y a doble cara: los sótanos (ficheros ignorados)
|
| 283 |
+
// se intuyen a través del plano de circuitos. depthWrite: false — era el
|
| 284 |
+
// único material que escribía profundidad y, con el rango near/far de la
|
| 285 |
+
// ciudad, las plotas y serigrafías a ras de suelo hacían z-fighting
|
| 286 |
+
// (parpadeo en franjas) al mover la cámara
|
| 287 |
+
new THREE.MeshBasicMaterial({
|
| 288 |
+
map: circuitTexture(theme), transparent: true, opacity: 0.8, side: THREE.DoubleSide,
|
| 289 |
+
depthWrite: false,
|
| 290 |
+
// separa el suelo en el z-buffer de plotas y serigrafías a ras de
|
| 291 |
+
// suelo (evita el z-fighting cuando el modo opaco activa depthWrite)
|
| 292 |
+
polygonOffset: true, polygonOffsetFactor: 2, polygonOffsetUnits: 2,
|
| 293 |
+
}),
|
| 294 |
+
);
|
| 295 |
+
ground.material.map.repeat.set(7, 7);
|
| 296 |
+
ground.rotation.x = -Math.PI / 2;
|
| 297 |
+
ground.position.y = -0.05;
|
| 298 |
+
group.add(ground);
|
| 299 |
+
|
| 300 |
+
// aristas de las cajas en un único buffer con rangos por nodo.
|
| 301 |
+
// LOD por presupuesto para monorepos: decenas de miles de wireframes
|
| 302 |
+
// aditivos se acumulan en blanco. Distritos y edificios siempre dibujan;
|
| 303 |
+
// del interior se conservan las cajas más grandes y menos profundas.
|
| 304 |
+
const LINE_BUDGET = 12000;
|
| 305 |
+
const ROLE_DEPTH = { distrito: 0, urbanizacion: 0, edificio: 0, planta: 1, apartamento: 2, habitacion: 3, archivador: 4, cajon: 5 };
|
| 306 |
+
const candidates = model.nodes.filter(n => n.role !== 'ciudad' && n.box);
|
| 307 |
+
let boxNodes = candidates;
|
| 308 |
+
if (candidates.length > LINE_BUDGET) {
|
| 309 |
+
const score = (n) => (n.role === 'distrito' || n.role === 'urbanizacion' || n.role === 'edificio')
|
| 310 |
+
? Infinity
|
| 311 |
+
: Math.max(n.box.sx, n.box.sy, n.box.sz) - ROLE_DEPTH[n.role] * 3;
|
| 312 |
+
boxNodes = candidates.sort((a, b) => score(b) - score(a)).slice(0, LINE_BUDGET);
|
| 313 |
+
}
|
| 314 |
+
const positions = new Float32Array(boxNodes.length * 24 * 3);
|
| 315 |
+
const colors = new Float32Array(boxNodes.length * 24 * 3);
|
| 316 |
+
// brillo de arista normalizado por edificio: el interior de un edificio
|
| 317 |
+
// reparte un presupuesto de luz constante (mismo principio que las celdas)
|
| 318 |
+
const boxesPerBuilding = new Map();
|
| 319 |
+
for (const n of boxNodes) {
|
| 320 |
+
if (n.building && n.building !== n) {
|
| 321 |
+
boxesPerBuilding.set(n.building.id, (boxesPerBuilding.get(n.building.id) ?? 0) + 1);
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
// …y una atenuación global suave en ciudades con miles de edificios:
|
| 325 |
+
// uniforme (todos los edificios conservan el mismo aspecto entre sí),
|
| 326 |
+
// solo evita que la acumulación aditiva a vista de pájaro se queme
|
| 327 |
+
const cityDim = Math.min(1, Math.max(0.45, Math.sqrt(900 / Math.max(model.buildings.length, 1))));
|
| 328 |
+
for (const n of boxNodes) {
|
| 329 |
+
const local = (!n.building || n.building === n)
|
| 330 |
+
? 1
|
| 331 |
+
: Math.min(1, Math.max(0.12, 30 / (boxesPerBuilding.get(n.building.id) ?? 1)));
|
| 332 |
+
// las plotas (distrito y urbanización) son solo perímetro: siempre nítidas
|
| 333 |
+
n.lineDim = (n.role === 'distrito' || n.role === 'urbanizacion') ? 1 : local * cityDim;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
let v = 0;
|
| 337 |
+
for (const node of boxNodes) {
|
| 338 |
+
node.lineStart = v;
|
| 339 |
+
const cs = corners(node.box);
|
| 340 |
+
const base = (ROLE_COLOR[node.role] ?? ROLE_COLOR.cajon).map(c => c * node.lineDim);
|
| 341 |
+
node.baseColor = base;
|
| 342 |
+
for (const [a, b] of EDGE_PAIRS) {
|
| 343 |
+
for (const p of [cs[a], cs[b]]) {
|
| 344 |
+
positions[v * 3] = p[0]; positions[v * 3 + 1] = p[1]; positions[v * 3 + 2] = p[2];
|
| 345 |
+
colors[v * 3] = base[0]; colors[v * 3 + 1] = base[1]; colors[v * 3 + 2] = base[2];
|
| 346 |
+
v++;
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
const lineGeo = new THREE.BufferGeometry();
|
| 351 |
+
lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
| 352 |
+
lineGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
| 353 |
+
lineGeo.computeBoundingSphere();
|
| 354 |
+
// opacidad adaptativa: cuantas más cajas, más tenue cada arista
|
| 355 |
+
const lineOpacity = Math.min(0.95, Math.max(0.4, Math.sqrt(4000 / Math.max(boxNodes.length, 1))));
|
| 356 |
+
const lineBaseOpacity = lineOpacity;
|
| 357 |
+
const lines = new THREE.LineSegments(lineGeo, new THREE.LineBasicMaterial({
|
| 358 |
+
vertexColors: true, transparent: true, opacity: lineOpacity,
|
| 359 |
+
blending: BLEND, depthWrite: false,
|
| 360 |
+
}));
|
| 361 |
+
lines.renderOrder = 2;
|
| 362 |
+
group.add(lines);
|
| 363 |
+
|
| 364 |
+
// carcasas de cristal por edificio (también sirven para el raycast).
|
| 365 |
+
// En ciudades enormes las fachadas con texto se reservan a los edificios
|
| 366 |
+
// más grandes (una textura canvas por edificio no escala a miles).
|
| 367 |
+
const byArea = [...model.buildings].sort(
|
| 368 |
+
(a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz);
|
| 369 |
+
const facadeSet = new Set(byArea.slice(0, 250));
|
| 370 |
+
const shells = [];
|
| 371 |
+
for (const b of model.buildings) {
|
| 372 |
+
const mat = glassMaterial(theme, cityDim, b.onlyBasement ? 1 : 0);
|
| 373 |
+
mat.userData.baseDim = cityDim;
|
| 374 |
+
const mesh = new THREE.Mesh(new THREE.BoxGeometry(b.box.sx, b.box.sy, b.box.sz), mat);
|
| 375 |
+
mesh.position.set(b.box.cx, b.box.cy, b.box.cz);
|
| 376 |
+
mesh.renderOrder = 1;
|
| 377 |
+
mesh.userData.building = b;
|
| 378 |
+
b.shell = mesh;
|
| 379 |
+
shells.push(mesh);
|
| 380 |
+
group.add(mesh);
|
| 381 |
+
if (facadeSet.has(b)) buildFacade(b, model, group, theme);
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
// forjados de las plantas (losa fina, casi invisible); con tope en
|
| 385 |
+
// monorepos, priorizando las plantas de mayor superficie
|
| 386 |
+
let slabNodes = model.nodes.filter(n => n.role === 'planta');
|
| 387 |
+
if (slabNodes.length > 4000) {
|
| 388 |
+
slabNodes = slabNodes
|
| 389 |
+
.sort((a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz)
|
| 390 |
+
.slice(0, 4000);
|
| 391 |
+
}
|
| 392 |
+
if (slabNodes.length) {
|
| 393 |
+
const slabGeo = new THREE.BoxGeometry(1, 1, 1);
|
| 394 |
+
const slabMat = new THREE.MeshBasicMaterial({
|
| 395 |
+
color: new THREE.Color(...theme.slab), transparent: true,
|
| 396 |
+
opacity: Math.min(0.045, 0.045 * Math.sqrt(1500 / slabNodes.length)),
|
| 397 |
+
depthWrite: false, blending: BLEND,
|
| 398 |
+
});
|
| 399 |
+
const slabs = new THREE.InstancedMesh(slabGeo, slabMat, slabNodes.length);
|
| 400 |
+
const m = new THREE.Matrix4();
|
| 401 |
+
slabNodes.forEach((n, i) => {
|
| 402 |
+
m.makeScale(n.box.sx, 0.12, n.box.sz);
|
| 403 |
+
m.setPosition(n.box.cx, n.box.cy - n.box.sy / 2, n.box.cz);
|
| 404 |
+
slabs.setMatrixAt(i, m);
|
| 405 |
+
});
|
| 406 |
+
slabs.computeBoundingSphere();
|
| 407 |
+
slabs.renderOrder = 1;
|
| 408 |
+
group.add(slabs);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
// celdas de fichero (instanciadas)
|
| 412 |
+
const fileGeo = new THREE.BoxGeometry(1, 1, 1);
|
| 413 |
+
const fileMat = new THREE.MeshBasicMaterial({
|
| 414 |
+
transparent: true, opacity: 0.9, depthWrite: false, blending: BLEND,
|
| 415 |
+
});
|
| 416 |
+
const fileBaseOpacity = fileMat.opacity;
|
| 417 |
+
const filesMesh = new THREE.InstancedMesh(fileGeo, fileMat, model.files.length);
|
| 418 |
+
{
|
| 419 |
+
const m = new THREE.Matrix4();
|
| 420 |
+
const c = new THREE.Color();
|
| 421 |
+
for (const f of model.files) {
|
| 422 |
+
m.makeScale(f.box.sx, f.box.sy, f.box.sz);
|
| 423 |
+
m.setPosition(f.box.cx, f.box.cy, f.box.cz);
|
| 424 |
+
filesMesh.setMatrixAt(f.id, m);
|
| 425 |
+
filesMesh.setColorAt(f.id, c.setRGB(...FILE_BASE));
|
| 426 |
+
}
|
| 427 |
+
filesMesh.instanceColor.needsUpdate = true;
|
| 428 |
+
filesMesh.computeBoundingSphere();
|
| 429 |
+
}
|
| 430 |
+
filesMesh.renderOrder = 2;
|
| 431 |
+
group.add(filesMesh);
|
| 432 |
+
|
| 433 |
+
// cajas de relleno para los niveles encendidos (pool reutilizable)
|
| 434 |
+
const fillPool = [];
|
| 435 |
+
for (let i = 0; i < 10; i++) {
|
| 436 |
+
const mesh = new THREE.Mesh(
|
| 437 |
+
new THREE.BoxGeometry(1, 1, 1),
|
| 438 |
+
new THREE.MeshBasicMaterial({
|
| 439 |
+
color: new THREE.Color(...theme.fill), transparent: true, opacity: 0,
|
| 440 |
+
depthWrite: false, blending: BLEND,
|
| 441 |
+
}),
|
| 442 |
+
);
|
| 443 |
+
mesh.visible = false;
|
| 444 |
+
mesh.renderOrder = 2;
|
| 445 |
+
group.add(mesh);
|
| 446 |
+
fillPool.push(mesh);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
// ── API de iluminación ──────────────────────────────────────────────
|
| 450 |
+
const touchedNodes = new Set();
|
| 451 |
+
const touchedFiles = new Set();
|
| 452 |
+
const colorAttr = lineGeo.getAttribute('color');
|
| 453 |
+
const tmpColor = new THREE.Color();
|
| 454 |
+
// atenuación normalizada por edificio: cada edificio reparte un presupuesto
|
| 455 |
+
// de luz constante entre sus celdas — así un rascacielos con 50k ficheros
|
| 456 |
+
// no se quema y uno con 20 sigue viéndose igual que antes
|
| 457 |
+
const filesPerBuilding = new Map();
|
| 458 |
+
for (const f of model.files) {
|
| 459 |
+
const b = f.node.building;
|
| 460 |
+
if (b) filesPerBuilding.set(b.id, (filesPerBuilding.get(b.id) ?? 0) + 1);
|
| 461 |
+
}
|
| 462 |
+
const dimOf = (f) => {
|
| 463 |
+
const n = filesPerBuilding.get(f.node.building?.id) ?? 1;
|
| 464 |
+
return Math.min(1, Math.max(0.05, 350 / n)) * cityDim;
|
| 465 |
+
};
|
| 466 |
+
// color base por fichero, reescribible por los modos de coloreado
|
| 467 |
+
const fileBase = model.files.map(f => FILE_BASE.map(c => c * dimOf(f)));
|
| 468 |
+
|
| 469 |
+
function paintNode(node, glow, hi = HI) {
|
| 470 |
+
// los nodos diminutos no tienen aristas (LOD): solo carcasa/relleno
|
| 471 |
+
if (node.lineStart !== undefined) {
|
| 472 |
+
const col = lerp3(node.baseColor, hi, Math.pow(glow, 1.35));
|
| 473 |
+
for (let i = node.lineStart; i < node.lineStart + 24; i++) {
|
| 474 |
+
colorAttr.array[i * 3] = col[0];
|
| 475 |
+
colorAttr.array[i * 3 + 1] = col[1];
|
| 476 |
+
colorAttr.array[i * 3 + 2] = col[2];
|
| 477 |
+
}
|
| 478 |
+
// subida parcial: en monorepos el buffer completo pesa decenas de MB
|
| 479 |
+
colorAttr.addUpdateRange(node.lineStart * 3, 72);
|
| 480 |
+
colorAttr.needsUpdate = true;
|
| 481 |
+
}
|
| 482 |
+
if (node.shell) node.shell.material.uniforms.uGlow.value = glow * 0.3;
|
| 483 |
+
if (glow > 0.01) touchedNodes.add(node); else touchedNodes.delete(node);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
function paintFile(fileId, glow, hi = FILE_HI) {
|
| 487 |
+
const col = lerp3(fileBase[fileId], hi, glow);
|
| 488 |
+
filesMesh.setColorAt(fileId, tmpColor.setRGB(...col));
|
| 489 |
+
filesMesh.instanceColor.addUpdateRange(fileId * 3, 3);
|
| 490 |
+
filesMesh.instanceColor.needsUpdate = true;
|
| 491 |
+
if (glow > 0.01) touchedFiles.add(fileId); else touchedFiles.delete(fileId);
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
function clearGlows() {
|
| 495 |
+
for (const n of [...touchedNodes]) paintNode(n, 0);
|
| 496 |
+
for (const f of [...touchedFiles]) paintFile(f, 0);
|
| 497 |
+
for (const mesh of fillPool) mesh.visible = false;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
// haces del modo Blast: del fichero seleccionado a sus relacionados,
|
| 501 |
+
// con pulsos viajando por la línea como paquetes de datos
|
| 502 |
+
let blastBeams = null;
|
| 503 |
+
function setBlastBeams(pairs) {
|
| 504 |
+
if (blastBeams) {
|
| 505 |
+
group.remove(blastBeams);
|
| 506 |
+
blastBeams.geometry.dispose();
|
| 507 |
+
blastBeams.material.dispose();
|
| 508 |
+
blastBeams = null;
|
| 509 |
+
}
|
| 510 |
+
if (!pairs?.length) return;
|
| 511 |
+
// pairs: { a, b, impact } — impact=true: b depende del seleccionado
|
| 512 |
+
// (consecuencias de un cambio); el pulso siempre viaja en el sentido de
|
| 513 |
+
// la propagación: hacia fuera en impactos, hacia dentro en dependencias
|
| 514 |
+
const pos = new Float32Array(pairs.length * 6);
|
| 515 |
+
const dist = new Float32Array(pairs.length * 2);
|
| 516 |
+
const dir = new Float32Array(pairs.length * 2);
|
| 517 |
+
pairs.forEach(({ a, b, impact }, i) => {
|
| 518 |
+
pos.set(a, i * 6);
|
| 519 |
+
pos.set(b, i * 6 + 3);
|
| 520 |
+
const len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
| 521 |
+
dist[i * 2] = impact ? 0 : len;
|
| 522 |
+
dist[i * 2 + 1] = impact ? len : 0;
|
| 523 |
+
dir[i * 2] = dir[i * 2 + 1] = impact ? 1 : 0;
|
| 524 |
+
});
|
| 525 |
+
const geo = new THREE.BufferGeometry();
|
| 526 |
+
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
| 527 |
+
geo.setAttribute('aDist', new THREE.BufferAttribute(dist, 1));
|
| 528 |
+
geo.setAttribute('aDir', new THREE.BufferAttribute(dir, 1));
|
| 529 |
+
blastBeams = new THREE.LineSegments(geo, new THREE.ShaderMaterial({
|
| 530 |
+
uniforms: {
|
| 531 |
+
uTime: { value: 0 },
|
| 532 |
+
uColor: { value: new THREE.Color(...theme.beam) },
|
| 533 |
+
uColorIn: { value: new THREE.Color(...(theme.beamIn ?? theme.beam)) },
|
| 534 |
+
},
|
| 535 |
+
vertexShader: /* glsl */`
|
| 536 |
+
attribute float aDist;
|
| 537 |
+
attribute float aDir;
|
| 538 |
+
varying float vD;
|
| 539 |
+
varying float vDir;
|
| 540 |
+
void main() {
|
| 541 |
+
vD = aDist;
|
| 542 |
+
vDir = aDir;
|
| 543 |
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
| 544 |
+
}`,
|
| 545 |
+
fragmentShader: /* glsl */`
|
| 546 |
+
uniform float uTime;
|
| 547 |
+
uniform vec3 uColor;
|
| 548 |
+
uniform vec3 uColorIn;
|
| 549 |
+
varying float vD;
|
| 550 |
+
varying float vDir;
|
| 551 |
+
void main() {
|
| 552 |
+
// tren de pulsos avanzando por la línea (cada ~8 unidades); la
|
| 553 |
+
// línea base se mantiene bien visible entre pulsos
|
| 554 |
+
float w = fract(vD * 0.125 - uTime * 1.4);
|
| 555 |
+
float pulse = exp(-pow((w - 0.2) * 6.0, 2.0));
|
| 556 |
+
vec3 col = mix(uColor, uColorIn, vDir);
|
| 557 |
+
gl_FragColor = vec4(col * (0.6 + pulse * 2.4), 0.5 + pulse * 0.5);
|
| 558 |
+
}`,
|
| 559 |
+
transparent: true,
|
| 560 |
+
blending: BLEND,
|
| 561 |
+
depthWrite: false,
|
| 562 |
+
}));
|
| 563 |
+
blastBeams.renderOrder = 3;
|
| 564 |
+
group.add(blastBeams);
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
function tickBeams(timeMs) {
|
| 568 |
+
if (blastBeams) blastBeams.material.uniforms.uTime.value = timeMs / 1000;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
// modos de coloreado: reescribe los colores base de nodos y ficheros
|
| 572 |
+
// (fileColorFn/nodeColorFn → [r,g,b] o null para el color por defecto)
|
| 573 |
+
function setModeColors(fileColorFn, nodeColorFn) {
|
| 574 |
+
for (const node of boxNodes) {
|
| 575 |
+
const col = nodeColorFn?.(node) ?? (ROLE_COLOR[node.role] ?? ROLE_COLOR.cajon);
|
| 576 |
+
node.baseColor = col.map(c => c * (node.lineDim ?? 1));
|
| 577 |
+
paintNode(node, 0);
|
| 578 |
+
}
|
| 579 |
+
for (const f of model.files) {
|
| 580 |
+
const dim = dimOf(f);
|
| 581 |
+
const col = fileColorFn?.(f) ?? FILE_BASE;
|
| 582 |
+
fileBase[f.id] = col.map(c => c * dim);
|
| 583 |
+
paintFile(f.id, 0);
|
| 584 |
+
}
|
| 585 |
+
// se ha tocado todo: mejor una única subida completa que miles de rangos
|
| 586 |
+
colorAttr.clearUpdateRanges();
|
| 587 |
+
filesMesh.instanceColor.clearUpdateRanges();
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
function fillBoxes(boxesWithGlow) {
|
| 591 |
+
fillPool.forEach((mesh, i) => {
|
| 592 |
+
const entry = boxesWithGlow[i];
|
| 593 |
+
if (!entry) { mesh.visible = false; return; }
|
| 594 |
+
const { box, glow } = entry;
|
| 595 |
+
mesh.scale.set(box.sx, box.sy, box.sz);
|
| 596 |
+
mesh.position.set(box.cx, box.cy, box.cz);
|
| 597 |
+
mesh.material.opacity = 0.008 + glow * 0.045;
|
| 598 |
+
mesh.visible = true;
|
| 599 |
+
});
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
// leyenda serigrafiada (conmutable)
|
| 603 |
+
const legend = buildLegend(model, byArea, theme);
|
| 604 |
+
group.add(legend);
|
| 605 |
+
|
| 606 |
+
let groundOpaque = false;
|
| 607 |
+
const groundBaseOpacity = 0.8;
|
| 608 |
+
let visualSettings = {};
|
| 609 |
+
function setVisuals(v = {}) {
|
| 610 |
+
visualSettings = { ...visualSettings, ...v };
|
| 611 |
+
const linesMul = visualSettings.lines ?? 1;
|
| 612 |
+
const filesMul = visualSettings.files ?? 1;
|
| 613 |
+
const glassMul = visualSettings.glass ?? 1;
|
| 614 |
+
const facadeMul = visualSettings.facades ?? 1;
|
| 615 |
+
const labelMul = visualSettings.labels ?? 1;
|
| 616 |
+
const groundMul = visualSettings.ground ?? 1;
|
| 617 |
+
|
| 618 |
+
lines.material.opacity = Math.min(1, lineBaseOpacity * linesMul);
|
| 619 |
+
fileMat.opacity = Math.min(1, fileBaseOpacity * filesMul);
|
| 620 |
+
if (!groundOpaque) ground.material.opacity = Math.min(1, groundBaseOpacity * groundMul);
|
| 621 |
+
|
| 622 |
+
for (const shell of shells) {
|
| 623 |
+
const mat = shell.material;
|
| 624 |
+
mat.uniforms.uDim.value = (mat.userData.baseDim ?? cityDim) * glassMul;
|
| 625 |
+
}
|
| 626 |
+
group.traverse((o) => {
|
| 627 |
+
const mats = Array.isArray(o.material) ? o.material : [o.material];
|
| 628 |
+
for (const mat of mats) {
|
| 629 |
+
if (mat?.userData?.vccKind === 'facade') mat.opacity = Math.min(1, facadeMul);
|
| 630 |
+
if (mat?.userData?.vccKind === 'label') mat.opacity = Math.min(1, labelMul);
|
| 631 |
+
}
|
| 632 |
+
});
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
function dispose() {
|
| 636 |
+
scene.remove(group);
|
| 637 |
+
const geometries = new Set();
|
| 638 |
+
const materials = new Set();
|
| 639 |
+
group.traverse((o) => {
|
| 640 |
+
if (o.geometry) geometries.add(o.geometry);
|
| 641 |
+
for (const m of Array.isArray(o.material) ? o.material : [o.material]) {
|
| 642 |
+
if (m) materials.add(m);
|
| 643 |
+
}
|
| 644 |
+
});
|
| 645 |
+
for (const g of geometries) g.dispose();
|
| 646 |
+
for (const m of materials) {
|
| 647 |
+
m.map?.dispose?.();
|
| 648 |
+
m.dispose();
|
| 649 |
+
}
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
return {
|
| 653 |
+
group, lines, shells, filesMesh, ground, legend,
|
| 654 |
+
paintNode, paintFile, clearGlows, fillBoxes, dispose, setModeColors, setBlastBeams, tickBeams, setVisuals,
|
| 655 |
+
setLegend: (v) => { legend.visible = v; },
|
| 656 |
+
// suelo opaco/translúcido. Opaco = escribe profundidad y se dibuja en la
|
| 657 |
+
// pasada de opacos: oculta de verdad los sótanos (ficheros ignorados)
|
| 658 |
+
setGroundOpaque: (v) => {
|
| 659 |
+
groundOpaque = v;
|
| 660 |
+
const m = ground.material;
|
| 661 |
+
m.opacity = v ? 1 : Math.min(1, groundBaseOpacity * (visualSettings.ground ?? 1));
|
| 662 |
+
m.transparent = !v;
|
| 663 |
+
m.depthWrite = v;
|
| 664 |
+
m.needsUpdate = true;
|
| 665 |
+
},
|
| 666 |
+
redrawFacade: (building, highlightKeys) => drawFacade(building, highlightKeys, theme),
|
| 667 |
+
};
|
| 668 |
+
}
|
js/vcc/model.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Modelo: convierte el árbol del repo en una ciudad con cajas anidadas.
|
| 2 |
+
// Jerarquía: ciudad → distrito → edificio → planta → apartamento → habitación
|
| 3 |
+
// → archivador → cajón → fichero (los ficheros pueden vivir en
|
| 4 |
+
// cualquier nivel: se les reserva un hueco en su contenedor).
|
| 5 |
+
// Cuando un subárbol es demasiado profundo/poblado para un solo edificio,
|
| 6 |
+
// se interpone una «urbanización»: una plota dentro del distrito que agrupa
|
| 7 |
+
// procedualmente a sus subcarpetas como edificios (recursivo), de modo que
|
| 8 |
+
// la profundidad sobrante se despliega por la ciudad en vez de comprimirse.
|
| 9 |
+
// Los ficheros ignorados (.gitignore y equivalentes) se separan en un
|
| 10 |
+
// sótano espejado bajo el plano de circuitos.
|
| 11 |
+
import { makeOwnerResolver } from './ownership.js';
|
| 12 |
+
|
| 13 |
+
export const ROLES = ['ciudad', 'distrito', 'edificio', 'planta', 'apartamento', 'habitacion', 'archivador', 'cajon'];
|
| 14 |
+
const ROLE_AXIS = { planta: 'y', apartamento: 'x', habitacion: 'z', archivador: 'x', cajon: 'y' };
|
| 15 |
+
|
| 16 |
+
const STREET = 14; // separación entre distritos
|
| 17 |
+
const ALLEY = 4; // separación entre edificios
|
| 18 |
+
const URB_PAD = 3; // margen interior de una urbanización
|
| 19 |
+
// un edificio absorbe 5 niveles (planta→…→cajón); más allá, urbanización
|
| 20 |
+
const URB_DEPTH = 5;
|
| 21 |
+
const URB_MIN_FILES = 200;
|
| 22 |
+
const URB_MAX_FILES = 2000; // aun siendo poco profundo, esto no cabe en uno
|
| 23 |
+
const GROUND_Y = 0;
|
| 24 |
+
|
| 25 |
+
let nextNode = 0;
|
| 26 |
+
let nextFile = 0;
|
| 27 |
+
|
| 28 |
+
function hash01(str) {
|
| 29 |
+
let h = 2166136261;
|
| 30 |
+
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
|
| 31 |
+
return ((h >>> 0) % 1000) / 1000;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function unpack(packed) {
|
| 35 |
+
const [name, dirs, leaf] = packed;
|
| 36 |
+
const node = { name, dirs: dirs.map(unpack), leaf: leaf.map(([n, s, ig]) => ({ name: n, size: s, ignored: !!ig })) };
|
| 37 |
+
node.files = node.leaf.length + node.dirs.reduce((a, d) => a + d.files, 0);
|
| 38 |
+
node.size = node.leaf.reduce((a, f) => a + f.size, 0) + node.dirs.reduce((a, d) => a + d.size, 0);
|
| 39 |
+
return node;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function recount(node) {
|
| 43 |
+
node.files = node.leaf.length + node.dirs.reduce((a, d) => a + d.files, 0);
|
| 44 |
+
node.size = node.leaf.reduce((a, f) => a + f.size, 0) + node.dirs.reduce((a, d) => a + d.size, 0);
|
| 45 |
+
return node;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// separa un subárbol en parte visible y parte ignorada (sótano)
|
| 49 |
+
function splitSrc(src) {
|
| 50 |
+
const kept = { name: src.name, dirs: [], leaf: src.leaf.filter(f => !f.ignored) };
|
| 51 |
+
const ign = { name: src.name, dirs: [], leaf: src.leaf.filter(f => f.ignored) };
|
| 52 |
+
for (const d of src.dirs) {
|
| 53 |
+
const s = splitSrc(d);
|
| 54 |
+
if (s.kept.files) kept.dirs.push(s.kept);
|
| 55 |
+
if (s.ign.files) ign.dirs.push(s.ign);
|
| 56 |
+
}
|
| 57 |
+
return { kept: recount(kept), ign: recount(ign) };
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function makeNode(model, name, role, parent, { virtual = false } = {}) {
|
| 61 |
+
const node = {
|
| 62 |
+
id: nextNode++, name, role, parent, children: [], fileIds: [],
|
| 63 |
+
virtual, box: null, building: null,
|
| 64 |
+
path: parent ? (virtual ? parent.path : `${parent.path}/${name}`) : name,
|
| 65 |
+
};
|
| 66 |
+
if (parent) parent.children.push(node);
|
| 67 |
+
model.nodes.push(node);
|
| 68 |
+
return node;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function addFiles(model, node, leaf) {
|
| 72 |
+
for (const f of leaf) {
|
| 73 |
+
const file = {
|
| 74 |
+
id: nextFile++, name: f.name, size: f.size, node,
|
| 75 |
+
path: `${node.path}/${f.name}`, box: null,
|
| 76 |
+
};
|
| 77 |
+
node.fileIds.push(file.id);
|
| 78 |
+
model.files.push(file);
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// ── dimensiones de edificio ───────────────────────────────────────────────
|
| 83 |
+
function buildingDims(src) {
|
| 84 |
+
const kb = src.size / 1024;
|
| 85 |
+
const side = Math.min(22, Math.max(6, 4 + Math.log2(1 + kb) * 1.5));
|
| 86 |
+
const j = hash01(src.name);
|
| 87 |
+
const w = side * (0.85 + 0.3 * j);
|
| 88 |
+
const d = side * (0.85 + 0.3 * (1 - j));
|
| 89 |
+
const floors = Math.max(1, src.dirs.length + (src.leaf.length ? 1 : 0));
|
| 90 |
+
const h = Math.max(6, Math.max(floors * 3.0, 6 + src.files * 1.8));
|
| 91 |
+
return { w, d, h: Math.min(h, 80) };
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// ── reparto de un eje en franjas ponderadas ───────────────────────────────
|
| 95 |
+
function splitAxis(box, axis, weights, inset) {
|
| 96 |
+
const total = weights.reduce((a, b) => a + b, 0);
|
| 97 |
+
const span = box['s' + axis];
|
| 98 |
+
const start = box['c' + axis] - span / 2;
|
| 99 |
+
const gap = Math.min(inset, span * 0.04);
|
| 100 |
+
const usable = span - gap * (weights.length + 1);
|
| 101 |
+
const out = [];
|
| 102 |
+
let cursor = start + gap;
|
| 103 |
+
for (const w of weights) {
|
| 104 |
+
const s = usable * (w / total);
|
| 105 |
+
out.push({ ...box, ['c' + axis]: cursor + s / 2, ['s' + axis]: s });
|
| 106 |
+
cursor += s + gap;
|
| 107 |
+
}
|
| 108 |
+
return out;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function shrink(box, f) {
|
| 112 |
+
return { ...box, sx: box.sx * f, sy: box.sy * f, sz: box.sz * f };
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// ── celdas de fichero dentro de su contenedor ─────────────────────────────
|
| 116 |
+
function placeFiles(model, node, box, files) {
|
| 117 |
+
const n = files.length;
|
| 118 |
+
if (!n) return;
|
| 119 |
+
if (node.role === 'cajon') {
|
| 120 |
+
// carpetas colgantes: láminas verticales en fila
|
| 121 |
+
const slabs = splitAxis({ ...box, sy: box.sy * 0.72, cy: box.cy - box.sy * 0.1 }, 'x',
|
| 122 |
+
files.map(() => 1), 0.05);
|
| 123 |
+
files.forEach((fid, i) => {
|
| 124 |
+
const s = slabs[i];
|
| 125 |
+
model.files[fid].box = { ...s, sx: Math.min(s.sx * 0.55, 0.5), sz: s.sz * 0.78 };
|
| 126 |
+
});
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
// rejilla de cubos apoyada en el suelo del contenedor
|
| 130 |
+
const aspect = box.sx / box.sz || 1;
|
| 131 |
+
const cols = Math.max(1, Math.round(Math.sqrt(n * aspect)));
|
| 132 |
+
const rows = Math.ceil(n / cols);
|
| 133 |
+
const cw = box.sx / cols, cd = box.sz / rows;
|
| 134 |
+
const base = Math.min(cw, cd, box.sy) * 0.5;
|
| 135 |
+
files.forEach((fid, i) => {
|
| 136 |
+
const f = model.files[fid];
|
| 137 |
+
const col = i % cols, row = Math.floor(i / cols);
|
| 138 |
+
const k = 0.65 + 0.45 * Math.min(1, Math.log2(2 + f.size / 1024) / 8);
|
| 139 |
+
const s = Math.max(0.25, base * k);
|
| 140 |
+
f.box = {
|
| 141 |
+
cx: box.cx - box.sx / 2 + cw * (col + 0.5),
|
| 142 |
+
cz: box.cz - box.sz / 2 + cd * (row + 0.5),
|
| 143 |
+
cy: box.cy - box.sy / 2 + s / 2 + 0.05,
|
| 144 |
+
sx: s, sy: s, sz: s,
|
| 145 |
+
};
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// ── subdivisión recursiva del interior del edificio ───────────────────────
|
| 150 |
+
// flipY: en los sótanos las plantas crecen hacia abajo (dirección inversa)
|
| 151 |
+
function layoutInterior(model, node, src, box, role, flipY = false) {
|
| 152 |
+
node.box = box;
|
| 153 |
+
const dirs = src.dirs;
|
| 154 |
+
const hasFiles = src.leaf.length > 0;
|
| 155 |
+
addFiles(model, node, src.leaf);
|
| 156 |
+
|
| 157 |
+
if (!dirs.length) {
|
| 158 |
+
placeFiles(model, node, shrink(box, 0.9), node.fileIds);
|
| 159 |
+
return;
|
| 160 |
+
}
|
| 161 |
+
const childRole = ROLES[Math.min(ROLES.indexOf(role) + 1, ROLES.length - 1)];
|
| 162 |
+
const axis = ROLE_AXIS[childRole] ?? 'y';
|
| 163 |
+
const weights = dirs.map(d => 1 + Math.sqrt(d.files));
|
| 164 |
+
if (hasFiles) weights.push(0.8 + Math.sqrt(src.leaf.length) * 0.7);
|
| 165 |
+
const slices = splitAxis(box, axis, weights, 0.5);
|
| 166 |
+
if (flipY && axis === 'y') slices.reverse();
|
| 167 |
+
dirs.forEach((d, i) => {
|
| 168 |
+
const child = makeNode(model, d.name, childRole, node);
|
| 169 |
+
child.building = node.building;
|
| 170 |
+
layoutInterior(model, child, d, shrink(slices[i], 0.94), childRole, flipY);
|
| 171 |
+
});
|
| 172 |
+
if (hasFiles) placeFiles(model, node, shrink(slices[slices.length - 1], 0.9), node.fileIds);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// ── empaquetado en rejilla (edificios en distrito, distritos en ciudad) ───
|
| 176 |
+
function packGrid(items, gap) {
|
| 177 |
+
// items: { w, d } → coloca en filas; devuelve posiciones y tamaño del plot
|
| 178 |
+
const area = items.reduce((a, it) => a + (it.w + gap) * (it.d + gap), 0);
|
| 179 |
+
const targetW = Math.max(Math.sqrt(area) * 1.15, ...items.map(it => it.w + gap));
|
| 180 |
+
let x = 0, z = 0, rowD = 0, plotW = 0;
|
| 181 |
+
const pos = [];
|
| 182 |
+
for (const it of items) {
|
| 183 |
+
if (x > 0 && x + it.w > targetW) { x = 0; z += rowD + gap; rowD = 0; }
|
| 184 |
+
pos.push({ x: x + it.w / 2, z: z + it.d / 2 });
|
| 185 |
+
x += it.w + gap;
|
| 186 |
+
rowD = Math.max(rowD, it.d);
|
| 187 |
+
plotW = Math.max(plotW, x - gap);
|
| 188 |
+
}
|
| 189 |
+
return { pos, w: plotW, d: z + rowD };
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function subtreeDepth(src) {
|
| 193 |
+
let d = 0;
|
| 194 |
+
for (const s of src.dirs) d = Math.max(d, 1 + subtreeDepth(s));
|
| 195 |
+
return d;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// ¿demasiado profundo y poblado para un solo edificio? → urbanización
|
| 199 |
+
function needsUrb(src) {
|
| 200 |
+
if (src.virtual || !src.dirs.length) return false;
|
| 201 |
+
if (src.files > URB_MAX_FILES) return true;
|
| 202 |
+
return src.files > URB_MIN_FILES && subtreeDepth(src) > URB_DEPTH;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
function fileBlockSrc(leaf) {
|
| 206 |
+
return { name: '(ficheros)', dirs: [], leaf, files: leaf.length, size: leaf.reduce((a, f) => a + f.size, 0), virtual: true };
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// un «bloque» es lo que ocupa una parcela: un edificio o una urbanización.
|
| 210 |
+
// Devuelve { w, d, place(cx, cz) } para el empaquetado en rejilla.
|
| 211 |
+
function buildBlock(model, parent, src) {
|
| 212 |
+
if (needsUrb(src)) return buildUrb(model, parent, src);
|
| 213 |
+
const split = splitSrc(src);
|
| 214 |
+
const b = makeNode(model, src.name, 'edificio', parent, { virtual: !!src.virtual });
|
| 215 |
+
b.building = b;
|
| 216 |
+
b.dims = buildingDims(split.kept.files ? split.kept : src);
|
| 217 |
+
// sin nada en superficie y todo en el sótano: búnker bajo y más opaco
|
| 218 |
+
// que señala que la información está bajo el suelo
|
| 219 |
+
if (!split.kept.files && split.ign.files) {
|
| 220 |
+
b.onlyBasement = true;
|
| 221 |
+
b.dims.h = 3;
|
| 222 |
+
}
|
| 223 |
+
b.srcKept = split.kept;
|
| 224 |
+
b.srcIgn = split.ign;
|
| 225 |
+
model.buildings.push(b);
|
| 226 |
+
return { w: b.dims.w, d: b.dims.d, place: (cx, cz) => placeBuilding(model, b, cx, cz) };
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
function buildUrb(model, parent, src) {
|
| 230 |
+
const urb = makeNode(model, src.name, 'urbanizacion', parent);
|
| 231 |
+
const childSrcs = [...src.dirs];
|
| 232 |
+
if (src.leaf.length) childSrcs.push(fileBlockSrc(src.leaf));
|
| 233 |
+
const blocks = childSrcs.map(s => buildBlock(model, urb, s));
|
| 234 |
+
const grid = packGrid(blocks, ALLEY);
|
| 235 |
+
const w = grid.w + URB_PAD * 2, d = grid.d + URB_PAD * 2;
|
| 236 |
+
model.urbs.push(urb);
|
| 237 |
+
return {
|
| 238 |
+
w, d,
|
| 239 |
+
place: (cx, cz) => {
|
| 240 |
+
urb.box = { cx, cy: GROUND_Y + 0.26, cz, sx: w, sy: 0.14, sz: d };
|
| 241 |
+
const ox = cx - w / 2 + URB_PAD, oz = cz - d / 2 + URB_PAD;
|
| 242 |
+
blocks.forEach((blk, i) => blk.place(ox + grid.pos[i].x, oz + grid.pos[i].z));
|
| 243 |
+
},
|
| 244 |
+
};
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
function placeBuilding(model, b, cx, cz) {
|
| 248 |
+
const { w, d, h } = b.dims;
|
| 249 |
+
layoutInterior(model, b, b.srcKept, { cx, cz, cy: GROUND_Y + h / 2, sx: w, sy: h, sz: d }, 'edificio');
|
| 250 |
+
// sótano: los ficheros ignorados crecen hacia abajo, mismo patrón invertido
|
| 251 |
+
if (b.srcIgn.files) {
|
| 252 |
+
const hb = Math.min(60, Math.max(5, Math.max(
|
| 253 |
+
(b.srcIgn.dirs.length + (b.srcIgn.leaf.length ? 1 : 0)) * 3.0,
|
| 254 |
+
5 + b.srcIgn.files * 1.6,
|
| 255 |
+
)));
|
| 256 |
+
const bb = makeNode(model, b.name, 'edificio', b.parent, { virtual: b.virtual });
|
| 257 |
+
bb.building = bb;
|
| 258 |
+
bb.isBasement = true;
|
| 259 |
+
model.buildings.push(bb);
|
| 260 |
+
layoutInterior(model, bb, b.srcIgn,
|
| 261 |
+
{ cx, cz, cy: GROUND_Y - 0.7 - hb / 2, sx: w, sy: hb, sz: d }, 'edificio', true);
|
| 262 |
+
}
|
| 263 |
+
delete b.srcKept; delete b.srcIgn;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function buildDistrict(model, city, srcDir, files) {
|
| 267 |
+
const district = makeNode(model, srcDir ? srcDir.name : '(raíz)', 'distrito', city, { virtual: !srcDir });
|
| 268 |
+
const blockSrcs = [];
|
| 269 |
+
if (srcDir) {
|
| 270 |
+
for (const d of srcDir.dirs) blockSrcs.push(d);
|
| 271 |
+
if (srcDir.leaf.length) blockSrcs.push(fileBlockSrc(srcDir.leaf));
|
| 272 |
+
} else {
|
| 273 |
+
blockSrcs.push(fileBlockSrc(files));
|
| 274 |
+
}
|
| 275 |
+
const blocks = blockSrcs.map(s => buildBlock(model, district, s));
|
| 276 |
+
const grid = packGrid(blocks, ALLEY);
|
| 277 |
+
district.plot = { w: grid.w + ALLEY * 2, d: grid.d + ALLEY * 2 };
|
| 278 |
+
district.blocks = blocks;
|
| 279 |
+
district.grid = grid;
|
| 280 |
+
model.districts.push(district);
|
| 281 |
+
return district;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
export function buildModel(packedTree, meta = null) {
|
| 285 |
+
nextNode = 0; nextFile = 0;
|
| 286 |
+
const model = { nodes: [], files: [], buildings: [], districts: [], urbs: [], city: null };
|
| 287 |
+
const root = unpack(packedTree);
|
| 288 |
+
const city = makeNode(model, root.name, 'ciudad', null);
|
| 289 |
+
model.city = city;
|
| 290 |
+
|
| 291 |
+
for (const d of root.dirs) buildDistrict(model, city, d, []);
|
| 292 |
+
if (root.leaf.length) buildDistrict(model, city, null, root.leaf);
|
| 293 |
+
|
| 294 |
+
// colocar distritos en la ciudad
|
| 295 |
+
const plots = model.districts.map(di => ({ w: di.plot.w, d: di.plot.d }));
|
| 296 |
+
const cityGrid = packGrid(plots, STREET);
|
| 297 |
+
const ox = -cityGrid.w / 2, oz = -cityGrid.d / 2;
|
| 298 |
+
model.districts.forEach((di, i) => {
|
| 299 |
+
const p = cityGrid.pos[i];
|
| 300 |
+
const px = ox + p.x, pz = oz + p.z;
|
| 301 |
+
di.box = { cx: px, cy: GROUND_Y + 0.12, cz: pz, sx: di.plot.w, sy: 0.24, sz: di.plot.d };
|
| 302 |
+
const bx = px - di.plot.w / 2 + ALLEY, bz = pz - di.plot.d / 2 + ALLEY;
|
| 303 |
+
di.blocks.forEach((blk, j) => blk.place(bx + di.grid.pos[j].x, bz + di.grid.pos[j].z));
|
| 304 |
+
delete di.blocks; delete di.grid;
|
| 305 |
+
});
|
| 306 |
+
city.box = { cx: 0, cy: 0, cz: 0, sx: cityGrid.w + STREET * 2, sy: 1, sz: cityGrid.d + STREET * 2 };
|
| 307 |
+
|
| 308 |
+
// cadena de ancestros por fichero (para la iluminación jerárquica)
|
| 309 |
+
// + metadatos git y code ownership por fichero
|
| 310 |
+
const rootPrefix = city.name + '/';
|
| 311 |
+
const resolveOwner = makeOwnerResolver(meta?.owners);
|
| 312 |
+
const ownerIndex = new Map();
|
| 313 |
+
for (const f of model.files) {
|
| 314 |
+
const chain = [];
|
| 315 |
+
for (let n = f.node; n; n = n.parent) chain.push(n);
|
| 316 |
+
f.chain = chain; // del contenedor inmediato hacia la ciudad
|
| 317 |
+
f.rel = f.path.startsWith(rootPrefix) ? f.path.slice(rootPrefix.length) : f.path;
|
| 318 |
+
const g = meta?.git?.[f.rel];
|
| 319 |
+
f.git = g ? { st: g[0], a: g[1], d: g[2] } : null;
|
| 320 |
+
const owner = resolveOwner(f.rel);
|
| 321 |
+
if (owner != null) {
|
| 322 |
+
if (!ownerIndex.has(owner)) ownerIndex.set(owner, ownerIndex.size);
|
| 323 |
+
f.owner = ownerIndex.get(owner);
|
| 324 |
+
} else {
|
| 325 |
+
f.owner = null;
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
model.owners = [...ownerIndex.keys()];
|
| 329 |
+
model.hasGit = !!meta?.git;
|
| 330 |
+
|
| 331 |
+
applyCodeMeta(model, meta?.links, meta?.codeIndex);
|
| 332 |
+
model.fileByRel = new Map(model.files.map(f => [f.rel, f]));
|
| 333 |
+
model.dirByRel = new Map();
|
| 334 |
+
for (const n of model.nodes) {
|
| 335 |
+
if (n.role === 'ciudad' || n.virtual || n.isBasement || n.building?.isBasement) continue;
|
| 336 |
+
const rel = n.path.startsWith(rootPrefix) ? n.path.slice(rootPrefix.length) : n.path;
|
| 337 |
+
if (!model.dirByRel.has(rel)) model.dirByRel.set(rel, n);
|
| 338 |
+
}
|
| 339 |
+
return model;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
// enlaces de código (modo Blast): adyacencia saliente y entrante. Es una
|
| 343 |
+
// función aparte porque el análisis perezoso los refresca por lotes sobre
|
| 344 |
+
// el modelo ya construido, sin reconstruir la ciudad.
|
| 345 |
+
export function applyCodeMeta(model, links, codeIndex) {
|
| 346 |
+
model.linksOut = links ?? {};
|
| 347 |
+
model.linksIn = {};
|
| 348 |
+
for (const [src, toks] of Object.entries(model.linksOut)) {
|
| 349 |
+
for (const tok of toks) {
|
| 350 |
+
if (tok.startsWith('F:')) (model.linksIn[tok.slice(2)] ??= []).push(src);
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
// índice de símbolos (funciones/clases/tipos): aristas "src usa sym de target"
|
| 354 |
+
model.defs = codeIndex?.defs ?? {};
|
| 355 |
+
model.symOut = codeIndex?.symLinks ?? {};
|
| 356 |
+
model.symIn = {};
|
| 357 |
+
for (const [src, edges] of Object.entries(model.symOut)) {
|
| 358 |
+
for (const [target, sym] of edges) (model.symIn[target] ??= []).push([src, sym]);
|
| 359 |
+
}
|
| 360 |
+
}
|
js/vcc/ownership.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Stub de code-ownership: Elffuss Code no alimenta metadatos de propiedad, así
|
| 2 |
+
// que makeOwnerResolver devuelve siempre null (file.owner = null en model.js).
|
| 3 |
+
// El original (Sendery/VibeCodeViewer, Apache-2.0) parsea CODEOWNERS/package
|
| 4 |
+
// meta; aquí no hace falta para dibujar la ciudad.
|
| 5 |
+
export function makeOwnerResolver() { return () => null; }
|
js/vcc/theme.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Tema «hacker» de VibeCodeViewer (Sendery/VibeCodeViewer, Apache-2.0),
|
| 2 |
+
// copiado de src/themes.js. Es la paleta que consume builder.js.
|
| 3 |
+
export const THEME = {
|
| 4 |
+
label: 'Hacker',
|
| 5 |
+
light: false,
|
| 6 |
+
background: 0x020309, fog: 0x030510,
|
| 7 |
+
bloom: { strength: 0.65, threshold: 0.85 },
|
| 8 |
+
roleColor: {
|
| 9 |
+
distrito: [0.16, 0.07, 0.30], urbanizacion: [0.13, 0.09, 0.30],
|
| 10 |
+
edificio: [0.10, 0.30, 0.36], planta: [0.06, 0.20, 0.26],
|
| 11 |
+
apartamento: [0.05, 0.16, 0.21], habitacion: [0.045, 0.13, 0.18],
|
| 12 |
+
archivador: [0.04, 0.11, 0.15], cajon: [0.035, 0.10, 0.13],
|
| 13 |
+
},
|
| 14 |
+
hi: [0.28, 3.0, 3.6],
|
| 15 |
+
fileBase: [0.05, 0.22, 0.28], fileHi: [1.0, 5.0, 6.0],
|
| 16 |
+
glass: [0.12, 0.55, 0.65], glassHi: [0.3, 2.4, 2.8],
|
| 17 |
+
ground: {
|
| 18 |
+
bg: '#04050b', grid: 'rgba(50, 80, 140, 0.07)',
|
| 19 |
+
traceA: [112, 60, 235], traceB: [40, 200, 235],
|
| 20 |
+
padA: [170, 120, 255], padB: [120, 240, 255],
|
| 21 |
+
},
|
| 22 |
+
facade: {
|
| 23 |
+
text: 'rgba(140, 232, 250, 0.72)', hot: '#c8ffff',
|
| 24 |
+
hotGlow: 'rgba(0,255,255,0.9)', hotBg: 'rgba(0,255,255,0.16)',
|
| 25 |
+
},
|
| 26 |
+
labels: { district: '#a86cff', urb: '#8a7bff', building: '#49e8ff' },
|
| 27 |
+
beam: [2.6, 1.1, 0.25], beamIn: [3.4, 0.5, 0.8], fill: [0.08, 0.75, 0.95], slab: [0.1, 0.5, 0.6],
|
| 28 |
+
};
|
og.png
ADDED
|
Git LFS Details
|
package.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "elffuss-code-web",
|
| 3 |
+
"private": true,
|
| 4 |
+
"type": "module",
|
| 5 |
+
"description": "Elffuss Code: IDE web con agente élfico. Sin build: ES modules vanilla."
|
| 6 |
+
}
|
sw.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Service worker INERTE (a propósito).
|
| 2 |
+
//
|
| 3 |
+
// Antes cacheaba ficheros de modelo interceptando las peticiones a HuggingFace,
|
| 4 |
+
// pero eso ROMPÍA la carga del ONNX: el fichero de pesos externos
|
| 5 |
+
// `model_q4.onnx_data` fallaba con ERR_FAILED al re-fetchearlo → el modelo no
|
| 6 |
+
// cargaba. Resultó que el SW era INNECESARIO: el cacheo ya lo hacen
|
| 7 |
+
// - transformers.js con su propia Cache Storage (modelo ONNX «healed»), y
|
| 8 |
+
// - litert.js con blob cache-first en Cache Storage (Gemma .litertlm),
|
| 9 |
+
// y `navigator.storage.persist()` evita que el navegador los desaloje.
|
| 10 |
+
//
|
| 11 |
+
// Así que este SW ya NO intercepta nada (el navegador gestiona todas las
|
| 12 |
+
// peticiones de forma nativa). Se mantiene registrado solo para REEMPLAZAR
|
| 13 |
+
// limpiamente al SW anterior que sí interceptaba y rompía la carga.
|
| 14 |
+
self.addEventListener('install', () => self.skipWaiting());
|
| 15 |
+
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
|
| 16 |
+
|
| 17 |
+
// Permite vaciar la caché de modelos desde Ajustes (sin tocar el blob de Gemma
|
| 18 |
+
// salvo que se pida). NO hay handler de 'fetch' → cero interceptación.
|
| 19 |
+
self.addEventListener('message', e => {
|
| 20 |
+
if (e.data === 'clear-models') {
|
| 21 |
+
caches.delete('elffuss-models-v1').then(() => e.source && e.source.postMessage('models-cleared'));
|
| 22 |
+
}
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
// Botón «▶ Ejecutar» en la notificación de mejoras del cerebro CEO: las
|
| 26 |
+
// notificaciones normales (new Notification()) no soportan acciones, las que
|
| 27 |
+
// pasan por el SW (registration.showNotification) sí. Al pulsar, avisamos a
|
| 28 |
+
// la pestaña (postMessage) para que mande la propuesta a la cola real.
|
| 29 |
+
self.addEventListener('notificationclick', event => {
|
| 30 |
+
const action = event.action; // 'execute' | 'open' | '' (clic en el cuerpo)
|
| 31 |
+
const data = event.notification.data || {};
|
| 32 |
+
event.notification.close();
|
| 33 |
+
event.waitUntil((async () => {
|
| 34 |
+
const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
| 35 |
+
const client = clientsList[0];
|
| 36 |
+
if (client) {
|
| 37 |
+
client.postMessage({ type: 'notif-action', action, md: data.md });
|
| 38 |
+
client.focus?.();
|
| 39 |
+
} else if (self.clients.openWindow) {
|
| 40 |
+
await self.clients.openWindow('/');
|
| 41 |
+
}
|
| 42 |
+
})());
|
| 43 |
+
});
|