lampson 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -1
- package/README.md +9 -3
- package/bin/lampson.js +1 -1
- package/chat.syn +112 -8
- package/cli.syn +143 -7
- package/edge.syn +26 -0
- package/hub.tpl.syn +127 -6
- package/lampson.ps1 +43 -2
- package/lampson.sh +40 -2
- package/lib/agents.syn +30 -9
- package/lib/approvals.syn +2 -2
- package/lib/auth.syn +274 -0
- package/lib/computer.syn +2000 -0
- package/lib/computer_judge.syn +410 -0
- package/lib/fs.syn +242 -0
- package/lib/git.syn +2 -2
- package/lib/judge.syn +78 -0
- package/lib/line.syn +2 -2
- package/lib/loop.syn +41 -8
- package/lib/lsp.syn +4 -4
- package/lib/mcp.syn +20 -4
- package/lib/md.syn +11 -11
- package/lib/origin.syn +67 -0
- package/lib/permission.syn +47 -2
- package/lib/prompt.syn +10 -1
- package/lib/provider.syn +70 -20
- package/lib/schedule.syn +12 -12
- package/lib/session.syn +5 -5
- package/lib/settings.syn +65 -1
- package/lib/skills.syn +22 -6
- package/lib/tools/bash.syn +1 -1
- package/lib/tools/common.syn +1 -1
- package/lib/tools/fetch.syn +600 -0
- package/lib/tools/grep.syn +1 -1
- package/lib/tools/memo.syn +3 -3
- package/lib/tools/proc.syn +38 -20
- package/lib/tools/qr.js +26 -0
- package/lib/tools/url.syn +220 -0
- package/lib/tools.syn +25 -2
- package/lib/trace.syn +2 -2
- package/lib/tree.syn +29 -13
- package/lib/update.syn +1 -1
- package/lib/workspaces.syn +202 -36
- package/package.json +12 -3
- package/public/apple-touch-icon.png +0 -0
- package/public/badge-96.png +0 -0
- package/public/css/code.css +48 -0
- package/public/css/hub.css +9 -0
- package/public/css/login.css +16 -0
- package/public/css/panel.css +30 -0
- package/public/css/sidebar.css +21 -0
- package/public/css/tokens.css +1 -1
- package/public/hub.html +11 -1
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/icons-src/badge.svg +12 -0
- package/public/icons-src/icon-maskable.svg +15 -0
- package/public/icons-src/icon.svg +13 -0
- package/public/index.html +14 -3
- package/public/js/agents.js +1 -1
- package/public/js/chat.js +1 -1
- package/public/js/config.js +145 -2
- package/public/js/core.js +5 -3
- package/public/js/devices.js +80 -0
- package/public/js/editor.js +232 -0
- package/public/js/login.js +78 -0
- package/public/js/procs.js +1 -1
- package/public/js/pwa.js +5 -0
- package/public/js/tree.js +224 -14
- package/public/login.html +43 -0
- package/public/manifest.webmanifest +16 -0
- package/public/offline.html +24 -0
- package/public/sw.js +63 -0
- package/public/vendor/highlight.min.js +1343 -0
- package/public/vendor/qrcode.js +2297 -0
- package/skills/computer-use/SKILL.md +178 -0
- package/skills/lampson/SKILL.md +13 -0
- package/web.syn +82 -7
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect width="64" height="64" fill="#131412"/>
|
|
3
|
+
<g transform="translate(9.6 9.6) scale(0.7)">
|
|
4
|
+
<circle cx="32" cy="27" r="11" fill="#d9c26a"/>
|
|
5
|
+
<g stroke="#d9c26a" stroke-width="3" stroke-linecap="round">
|
|
6
|
+
<line x1="32" y1="6" x2="32" y2="10"/>
|
|
7
|
+
<line x1="14" y1="13" x2="17" y2="16"/>
|
|
8
|
+
<line x1="50" y1="13" x2="47" y2="16"/>
|
|
9
|
+
<line x1="9" y1="27" x2="13" y2="27"/>
|
|
10
|
+
<line x1="55" y1="27" x2="51" y2="27"/>
|
|
11
|
+
</g>
|
|
12
|
+
<rect x="26" y="40" width="12" height="7" rx="1.5" fill="#e9e8df"/>
|
|
13
|
+
<rect x="27.5" y="49" width="9" height="3" rx="1.5" fill="#9b9c90"/>
|
|
14
|
+
</g>
|
|
15
|
+
</svg>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect width="64" height="64" rx="10" fill="#131412"/>
|
|
3
|
+
<circle cx="32" cy="27" r="11" fill="#d9c26a"/>
|
|
4
|
+
<g stroke="#d9c26a" stroke-width="3" stroke-linecap="round">
|
|
5
|
+
<line x1="32" y1="6" x2="32" y2="10"/>
|
|
6
|
+
<line x1="14" y1="13" x2="17" y2="16"/>
|
|
7
|
+
<line x1="50" y1="13" x2="47" y2="16"/>
|
|
8
|
+
<line x1="9" y1="27" x2="13" y2="27"/>
|
|
9
|
+
<line x1="55" y1="27" x2="51" y2="27"/>
|
|
10
|
+
</g>
|
|
11
|
+
<rect x="26" y="40" width="12" height="7" rx="1.5" fill="#e9e8df"/>
|
|
12
|
+
<rect x="27.5" y="49" width="9" height="3" rx="1.5" fill="#9b9c90"/>
|
|
13
|
+
</svg>
|
package/public/index.html
CHANGED
|
@@ -4,16 +4,24 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
6
6
|
<title>Lampson</title>
|
|
7
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8
|
-
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
8
|
+
<meta name="theme-color" content="#131412">
|
|
9
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
10
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
11
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
12
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
13
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
14
|
+
<!-- estilos: tokens (fuentes, paleta, base) → layout (grid, cabecera, paneles, chat, composer) → sidebar → chat → panel (el modal) → code (editor de archivos + tema de highlight.js) -->
|
|
9
15
|
<link rel="stylesheet" href="/css/tokens.css">
|
|
10
16
|
<link rel="stylesheet" href="/css/layout.css">
|
|
11
17
|
<link rel="stylesheet" href="/css/sidebar.css">
|
|
12
18
|
<link rel="stylesheet" href="/css/chat.css">
|
|
13
19
|
<link rel="stylesheet" href="/css/panel.css">
|
|
20
|
+
<link rel="stylesheet" href="/css/code.css">
|
|
14
21
|
<link rel="stylesheet" href="/vendor/xterm.css">
|
|
15
22
|
<script src="/vendor/xterm.js"></script>
|
|
16
23
|
<script src="/vendor/xterm-addon-fit.js"></script>
|
|
24
|
+
<script src="/vendor/highlight.min.js"></script>
|
|
17
25
|
<script>(function(){try{var t=localStorage.getItem('lampson.theme');if(t!=='light'&&t!=='dark'){t='dark';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
|
|
18
26
|
</head>
|
|
19
27
|
<body>
|
|
@@ -91,7 +99,8 @@
|
|
|
91
99
|
</aside>
|
|
92
100
|
<main>
|
|
93
101
|
<div id="stage">
|
|
94
|
-
|
|
102
|
+
<!-- visor: #vbody para logs y texto; #editor (js/editor.js) para archivos: textarea transparente sobre el <pre> resaltado -->
|
|
103
|
+
<div id="viewer"><div class="vh"><b id="vpath"></b><span id="vmeta"></span><span class="spacer"></span><button class="hbtn sm" id="vsave" hidden title="guardar (Ctrl+S)">guardar</button><span class="x" id="vclose">✕ cerrar</span></div><pre id="vbody"></pre><div id="editor" hidden><div id="egut"></div><pre id="ehl"><code class="hljs"></code></pre><textarea id="eta" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" wrap="off" aria-label="editor"></textarea></div></div>
|
|
95
104
|
<div id="log"></div>
|
|
96
105
|
<div id="termpane"><div class="vh"><span class="st"></span><b>terminal</b><span class="ttabs" id="ttabs"></span><button class="hbtn sm new" id="tnew" title="abrir otra terminal: una shell nueva en el workspace">+ nueva terminal</button><span id="tmeta"></span><span class="spacer"></span><span class="wbtn" id="tmin" title="minimizar: esconde el panel y la shell sigue viva">—</span><span class="wbtn" id="tmax" title="pantalla completa">□</span><span class="wbtn cl" id="tclose" title="cerrar esta terminal y matar su shell">✕</span></div><div id="xterms"></div></div>
|
|
97
106
|
</div>
|
|
@@ -112,6 +121,7 @@
|
|
|
112
121
|
<script src="/js/theme.js"></script>
|
|
113
122
|
<script src="/js/sidebar.js"></script>
|
|
114
123
|
<script src="/js/tree.js"></script>
|
|
124
|
+
<script src="/js/editor.js"></script>
|
|
115
125
|
<script src="/js/terminal.js"></script>
|
|
116
126
|
<script src="/js/procs.js"></script>
|
|
117
127
|
<script src="/js/agents.js"></script>
|
|
@@ -129,5 +139,6 @@
|
|
|
129
139
|
<script src="/js/chat.js"></script>
|
|
130
140
|
<script src="/js/events.js"></script>
|
|
131
141
|
<script src="/js/app.js"></script>
|
|
142
|
+
<script src="/js/pwa.js"></script>
|
|
132
143
|
</body>
|
|
133
144
|
</html>
|
package/public/js/agents.js
CHANGED
|
@@ -21,7 +21,7 @@ async function openAgent(id) {
|
|
|
21
21
|
let r; try { r = await (await fetch(BASE + '/api/agents/log?id=' + encodeURIComponent(id))).json(); } catch (e) { return false; }
|
|
22
22
|
const meta = (r.agent || {});
|
|
23
23
|
$('#vpath').textContent = 'subagente ' + id; $('#vmeta').textContent = meta.status === 'running' ? `● corriendo · ${meta.steps} pasos · log en vivo` : `○ ${meta.status || 'terminado'} · ${meta.steps ?? '?'} pasos · ${meta.tokens ?? '?'} tokens`;
|
|
24
|
-
const pre = $('#vbody'); pre.className = 'log'; pre.textContent = (r.log || '(sin salida todavía)') + (meta.status && meta.status !== 'running' && meta.text ? '\n\n── informe ──\n' + meta.text : '');
|
|
24
|
+
viewerText(); const pre = $('#vbody'); pre.className = 'log'; pre.textContent = (r.log || '(sin salida todavía)') + (meta.status && meta.status !== 'running' && meta.text ? '\n\n── informe ──\n' + meta.text : '');
|
|
25
25
|
return meta.status === 'running';
|
|
26
26
|
};
|
|
27
27
|
agentPaint = async () => { if (agentOpen !== id || $('#viewer').style.display === 'none') return; const still = await paint(); if (!still) loadAgents(); };
|
package/public/js/chat.js
CHANGED
|
@@ -72,7 +72,7 @@ function handle(chunk, thinking) {
|
|
|
72
72
|
else if (k === 'tool_call') { thinking.querySelector('span:last-child').textContent = 'ejecutando ' + esc(d.name) + '…'; pending = add('step', `<span class="ic">⚙</span>${cmdHtml(describe(d))}`); wireMore(pending); if (d.name === 'process') setTimeout(loadProcs, 2500); if (d.name === 'delegate') { setTimeout(loadAgents, 800); setTimeout(loadAgents, 4000); } if (d.name === 'todo') setTimeout(loadTodo, 300); if (d.name === 'plugin') setTimeout(loadPlugins, 300); if (d.name === 'lsp') setTimeout(loadLsp, 1500); }
|
|
73
73
|
else if (k === 'inbox') { add('meta', '✉ ' + esc(String(d).split('\n')[0].slice(0, 160))); loadAgents(); }
|
|
74
74
|
else if (k === 'tool_result') { const out = String(d.output); const bad = /^(ERROR|DENIED)/.test(out);
|
|
75
|
-
if (!bad && d.call && /^(write|edit|bash|process|delegate|plugin|skill)$/.test(d.call.name)) treeChanged();
|
|
75
|
+
if (!bad && d.call && /^(write|edit|bash|process|delegate|plugin|skill)$/.test(d.call.name)) { treeChanged(); edTouched(/^(write|edit)$/.test(d.call.name) ? (d.call.args || {}).path : ''); }
|
|
76
76
|
if (d.call && d.call.name === 'schedule') { setTimeout(loadSched, 300); if (/^scheduled '/.test(out)) setSec('sched', true); }
|
|
77
77
|
// plugin recién creado: avisar en el chat con un acceso directo al switch (el modelo no sabe cómo se enciende en esta UI)
|
|
78
78
|
if (d.call && d.call.name === 'plugin' && /^plugin '([^']+)' created/.test(out)) { const nm = out.match(/^plugin '([^']+)' created/)[1]; const m = add('meta', `☼ plugin <b>${esc(nm)}</b> creado — está apagado: <a href="#" class="plugingo">encenderlo en «plugins»</a>`); m.querySelector('.plugingo').onclick = (ev) => { ev.preventDefault(); openPlugins(nm); }; $('#plugins').classList.add('new'); setTimeout(() => $('#plugins').classList.remove('new'), 4000); }
|
package/public/js/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// config.js — configuración (⚙) y proveedor/modelo/key: UNA vista browse del Panel. Secciones a la izquierda
|
|
2
|
-
// (General · Aprobaciones a distancia · Proveedor), su formulario a la derecha. Cada cosa configurable nueva
|
|
2
|
+
// (General · Aprobaciones a distancia · … · Proveedor · Apagar o reiniciar), su formulario a la derecha. Cada cosa configurable nueva
|
|
3
3
|
// entra como una sección más. Los valores fijados en .env se muestran bloqueados; la API key nunca vuelve.
|
|
4
4
|
let setupSel = '';
|
|
5
5
|
function paintModel() {
|
|
@@ -11,11 +11,36 @@ function paintModel() {
|
|
|
11
11
|
const CFG_SECTIONS = [
|
|
12
12
|
{ id: 'general', name: 'General', meta: 'zona horaria' },
|
|
13
13
|
{ id: 'approvals', name: 'Aprobaciones a distancia', meta: 'URL pública · webhook' },
|
|
14
|
-
{ id: '
|
|
14
|
+
{ id: 'computer', name: 'Computer use', meta: 'escritorio y navegador · driver aparte' },
|
|
15
|
+
{ id: 'judge', name: 'Juez de decisión', meta: 'Jev · Laya · decisiones rápidas con probabilidades' },
|
|
16
|
+
{ id: 'provider', name: 'Proveedor', meta: 'modelo · API key' },
|
|
17
|
+
{ id: 'power', name: 'Apagar o reiniciar', meta: 'Lampson entero · hub y workspaces' }
|
|
15
18
|
];
|
|
16
19
|
function cfgField(v, key, label, placeholder, ds, type) {
|
|
17
20
|
return `<label class="col">${label}<input name="${key}" type="${type || 'text'}" spellcheck="false" autocomplete="off" placeholder="${esc(placeholder)}" value="${esc(v[key] || '')}" ${v[key + '_from_env'] ? 'disabled title="fijado en .env (LAMPSON_' + key.toUpperCase() + '): editalo ahí"' : ''}><span class="ds">${ds}</span></label>`;
|
|
18
21
|
}
|
|
22
|
+
// juez de decisión (lib/judge.syn): un modelo System One que contesta con probabilidades — una credencial de
|
|
23
|
+
// Lampson, no de computer use (hoy lo usa la acción `act`). Jev (TypeSafe, nube, key) o Laya (local, carpeta).
|
|
24
|
+
function judgeMode(v) { const m = (v.judge || v.computer_judge || '').toLowerCase(); return m === 'jev' || m === 'laya' ? m : 'off'; }
|
|
25
|
+
function judgeHtml(v) {
|
|
26
|
+
const mode = judgeMode(v);
|
|
27
|
+
const opt = (val, txt) => `<option value="${val}" ${mode === val ? 'selected' : ''}>${txt}</option>`;
|
|
28
|
+
return `<label class="col">juez<select name="judge" ${v.judge_from_env ? 'disabled title="fijado en .env (LAMPSON_JUDGE): editalo ahí"' : ''}>${opt('off', 'apagado')}${opt('jev', 'Jev — TypeSafe, en la nube (~0,3 s por decisión, con API key)')}${opt('laya', 'Laya — local, sin red ni costo (~1-2 s por decisión en CPU)')}</select><span class="ds">Jev es rápido, entiende español e inglés y necesita una API key de <a href="https://console.typesafe.ai/" target="_blank" rel="noopener">TypeSafe</a> · Laya corre en tu máquina sin mandar nada afuera, pero es chico: con interfaces en español se equivoca más</span></label>
|
|
29
|
+
<div class="jd-jev" ${mode === 'jev' ? '' : 'hidden'}><label class="col">API key de TypeSafe<input name="typesafe_key" type="password" spellcheck="false" autocomplete="off" placeholder="${v.has_typesafe_key ? '● guardada — pegá una nueva para cambiarla' : 'pegala acá (console.typesafe.ai)'}"><span class="ds">queda en <code>lampson/.lampson/config.json</code> con las demás keys; nunca vuelve al navegador</span></label></div>
|
|
30
|
+
<div class="jd-laya" ${mode === 'laya' ? '' : 'hidden'}><label class="col">carpeta del checkpoint de Laya<input name="judge_model" type="text" spellcheck="false" autocomplete="off" placeholder="C:\\Users\\vos\\models\\laya" value="${esc(v.judge_model || v.computer_judge_model || '')}"><span class="ds">la carpeta con <code>model.safetensors</code>, <code>encoder/</code> y <code>tokenizer/</code> (~840 MB, <a href="https://huggingface.co/convaiinnovations/laya" target="_blank" rel="noopener">convaiinnovations/laya</a>) · Lampson no la descarga</span></label></div>
|
|
31
|
+
<div class="ds" id="jdStatus"></div>`;
|
|
32
|
+
}
|
|
33
|
+
function judgeStatusHtml(j) {
|
|
34
|
+
if (!j) return '';
|
|
35
|
+
if (j.mode === 'off') return '○ sin juez';
|
|
36
|
+
if (j.live && !j.restart) return `● juez ${esc(j.mode)} activo${j.provider_model ? ' · ' + esc(j.provider_model) : ''}`;
|
|
37
|
+
return `▲ configurado (${esc(j.mode)}) pero este proceso todavía no lo tiene — tocá Guardar (el workspace se reinicia solo)${j.mode === 'jev' && !j.has_key ? ' · falta la API key' : ''}${j.mode === 'laya' && !j.model_setting ? ' · falta la carpeta del checkpoint' : ''}`;
|
|
38
|
+
}
|
|
39
|
+
// interruptor visible (pill con perilla): el texto cambia con el estado; fijado en .env queda deshabilitado
|
|
40
|
+
function toggleHtml(name, on, fromEnv, envName, onText, offText) {
|
|
41
|
+
return `<label class="toggle ${on ? 'on' : ''}" title="${fromEnv ? 'fijado en .env (' + envName + '): editalo ahí' : ''}"><input type="checkbox" name="${name}" ${on ? 'checked' : ''} ${fromEnv ? 'disabled' : ''}><span class="knob"></span><span class="tx" data-on="${esc(onText)}" data-off="${esc(offText)}">${on ? esc(onText) : esc(offText)}</span></label>`;
|
|
42
|
+
}
|
|
43
|
+
function paintToggle(lab) { const on = lab.querySelector('input').checked; const tx = lab.querySelector('.tx'); lab.classList.toggle('on', on); if (tx) tx.textContent = on ? tx.dataset.on : tx.dataset.off; }
|
|
19
44
|
async function openCfg(section, onboarding) {
|
|
20
45
|
let r; try { r = await (await fetch(BASE + '/api/settings/values')).json(); } catch (e) { r = { values: {} }; }
|
|
21
46
|
const v = r.values || {};
|
|
@@ -37,6 +62,21 @@ async function openCfg(section, onboarding) {
|
|
|
37
62
|
+ cfgField(v, 'webhook_url', 'webhook', 'https://hooks.example.com/lampson', 'POST JSON por cada aprobación pendiente (id, mensaje, links); reenvialo a Telegram, Slack o mail con n8n, un bot o un .syn de 6 líneas')
|
|
38
63
|
+ `<label class="col">secreto del webhook<input name="webhook_secret" type="password" spellcheck="false" autocomplete="off" placeholder="${v.has_webhook_secret ? '● guardado — escribí uno nuevo para cambiarlo' : 'opcional'}"><span class="ds">firma HMAC-SHA256 del body en <code>X-Lampson-Signature</code>; dejá vacío para no cambiarlo</span></label>`
|
|
39
64
|
+ `<div class="pfoot"><button class="primary" data-save>Guardar</button><span class="derr"></span></div></div>`;
|
|
65
|
+
// computer use (lib/computer.syn): un switch, el estado del driver (o cómo instalarlo) y sus dos knobs
|
|
66
|
+
if (s.id === 'computer') return `<div class="dhead"><span class="nm serif">Computer use</span>${toggleHtml('computer_use', v.computer_use, v.computer_use_from_env, 'LAMPSON_COMPUTER_USE', 'encendido', 'apagado')}</div><div class="dform"><p class="lead">El agente maneja tu escritorio y tu navegador <b>en segundo plano</b>: ve las ventanas, hace clic, escribe y navega sin moverte el mouse ni robarte el foco. Usa un <b>driver aparte</b> que Lampson no instala: encendé el interruptor y, si falta, acá abajo aparece el comando para instalarlo. Apagado, el agente ni ve la tool.</p>
|
|
67
|
+
<div class="cu-status" id="cuStatus"><span class="ds">comprobando el driver…</span></div>`
|
|
68
|
+
+ cfgField(v, 'computer_max_tabs', 'pestañas como máximo', '5', 'cuántas pestañas distintas puede manejar el agente por corrida · los atajos que abren pestañas (ctrl+t…) se rechazan siempre: navega en la misma pestaña, y busca en la web con fetch (sin abrir nada)')
|
|
69
|
+
+ `<div class="trow">${toggleHtml('computer_grant_profile', v.computer_grant_profile, v.computer_grant_profile_from_env, 'LAMPSON_COMPUTER_GRANT_PROFILE', 'usa mi navegador abierto (sesión iniciada)', 'navegador aparte, sin mis cookies')}<span class="ds">encendido (recomendado): el agente se ata a tu Chrome/Edge abierto y maneja páginas con precisión (formularios, desplegables) con tus sesiones; abre su propia ventana y no toca tus pestañas — puede ver páginas, cookies y sesiones de ese perfil · apagado: navegador aparte con perfil propio, sin tus cookies · aplica al reiniciar Lampson</span></div>`
|
|
70
|
+
+ `<div class="trow"><span class="ds">juez de decisión (acción «act»: elige los clics con probabilidades, sin gastar turnos del modelo): <span id="cuJudge">…</span> · se configura en <a href="#" data-goto-judge>⚙ Juez de decisión</a></span></div>`
|
|
71
|
+
+ `<div class="pfoot"><button class="primary" data-save-computer>Guardar</button><span class="derr"></span></div></div>`;
|
|
72
|
+
if (s.id === 'judge') return `<div class="dhead"><span class="nm serif">Juez de decisión</span></div><div class="dform"><p class="lead">Un modelo chico que contesta <b>con probabilidades, no con texto</b>: elige una opción de una lista, dice si algo aplica o puntúa en una escala, en menos de un segundo. Es una credencial de Lampson, como el proveedor: hoy la usa <b>computer use</b> (elige en qué control hacer clic sin gastar un turno del modelo principal) y la van a poder usar otras partes que necesiten decidir rápido.</p>`
|
|
73
|
+
+ judgeHtml(v)
|
|
74
|
+
+ `<div class="pfoot"><button class="primary" data-save-judge>Guardar</button><span class="derr"></span></div></div>`;
|
|
75
|
+
// Lampson entero (hub + workspaces): reiniciar o apagar. Apagar un workspace solo es en la lista de workspaces
|
|
76
|
+
if (s.id === 'power') return `<div class="dhead"><span class="nm serif">Apagar o reiniciar</span></div><div class="dform"><p class="lead">Lampson entero: el hub (<code>localhost:8080</code>) y todos los workspaces. Apagar <b>un</b> workspace se hace en su tarjeta, en la lista de workspaces; el hub queda prendido para encender los demás.</p>
|
|
77
|
+
<div class="trow"><button class="hbtn pwr" data-reboot>⟳ reiniciar Lampson</button><span class="ds">para todo y lo vuelve a arrancar con el código que hay en disco (por ejemplo, después de actualizar). Se cortan las terminales, los procesos que lanzó el agente y los turnos en curso; las sesiones quedan guardadas. La página vuelve sola en unos segundos.</span></div>
|
|
78
|
+
<div class="trow"><button class="hbtn pwr danger" data-shutdown>■ apagar Lampson</button><span class="ds">para el hub y todos los workspaces: esta página deja de responder. Para volver: <code>lampson --hub start</code> en una terminal (o <code>lampson</code> dentro de un proyecto). Si lo instalaste con <code>lampson --install</code>, arranca solo al iniciar sesión; para que no lo haga más, <code>lampson --uninstall</code>. <span data-remote-note hidden>Solo desde la PC donde corre: desde acá no podrías volver a encenderlo.</span></span></div>
|
|
79
|
+
<span class="derr"></span></div>`;
|
|
40
80
|
// proveedor / modelo / key
|
|
41
81
|
setupSel = cfg.provider || (cfg.providers || [])[0]?.name || '';
|
|
42
82
|
const rows = (cfg.providers || []).map(p => `<div class="pr ${p.name === setupSel ? 'on' : ''}" data-name="${esc(p.name)}"><b>${esc(p.name)}</b><span class="dm">${esc(p.model)}</span><span class="k ${p.has_key ? '' : 'no'}">${p.name === 'ollama' ? 'sin key' : (p.has_key ? '● key guardada' : '○ sin key')}</span></div>`).join('');
|
|
@@ -61,6 +101,109 @@ async function openCfg(section, onboarding) {
|
|
|
61
101
|
if (!r2.ok) { err(r2.data.error || ('error ' + r2.status)); return; }
|
|
62
102
|
Object.assign(v, r2.data.values || {}); err('✓ guardado'); add('meta', '⚙ configuración guardada'); loadSched(); loadApprovals();
|
|
63
103
|
};
|
|
104
|
+
const saveC = box.querySelector('[data-save-computer]');
|
|
105
|
+
if (saveC) {
|
|
106
|
+
const paintStatus = (st, code) => {
|
|
107
|
+
const el = box.querySelector('#cuStatus'); if (!el) return;
|
|
108
|
+
if (!st) { el.innerHTML = `<span class="ds">no pude consultar el estado del driver${code ? ' (HTTP ' + code + ')' : ''} — si acabás de actualizar Lampson, reinicialo (<code>lampson --hub restart</code>) y volvé a abrir esta ventana</span>`; return; }
|
|
109
|
+
if (!st.installed) {
|
|
110
|
+
el.innerHTML = `<div class="cu-line no">○ driver no instalado</div><p class="ds">Es un paquete aparte (<a href="${esc(st.install.docs)}" target="_blank" rel="noopener">cua.ai</a>): instalalo vos en una terminal y después tocá «comprobar de nuevo».</p><pre class="cu-cmd">${esc(st.install.command)}\n${esc(st.install.then)}</pre><button class="hbtn sm" data-recheck>comprobar de nuevo</button>`;
|
|
111
|
+
} else {
|
|
112
|
+
const bad = (st.checks || []).filter(c => c.status !== 'ok' && c.status !== 'skip');
|
|
113
|
+
el.innerHTML = `<div class="cu-line ${st.ok ? 'ok' : 'warn'}">${st.ok ? '●' : '▲'} driver ${esc(st.version)} <span class="ds">${esc(st.path)}</span></div>`
|
|
114
|
+
+ (bad.length ? `<ul class="ds">${bad.map(c => `<li>${esc(c.label)}: ${esc(c.message)}</li>`).join('')}</ul>` : '')
|
|
115
|
+
+ `<p class="ds">${st.running ? 'conectado' : 'arranca con la primera acción del agente'} · ${st.enabled ? 'la tool está en el catálogo del agente' : 'apagado: el agente no ve la tool'}${st.driver_error ? ' · ' + esc(st.driver_error) : ''}</p><button class="hbtn sm" data-recheck>comprobar de nuevo</button>`;
|
|
116
|
+
}
|
|
117
|
+
const rc = el.querySelector('[data-recheck]'); if (rc) rc.onclick = () => load();
|
|
118
|
+
const jd = box.querySelector('#cuJudge'); if (jd && st.judge) jd.innerHTML = judgeStatusHtml(st.judge);
|
|
119
|
+
};
|
|
120
|
+
const load = async () => { try { const r = await fetch(BASE + '/api/computer'); if (!r.ok) { paintStatus(null, r.status); return; } paintStatus(await r.json()); } catch (e) { paintStatus(null, 0); } };
|
|
121
|
+
load();
|
|
122
|
+
// el interruptor principal guarda al instante (como el de los plugins); el del navegador y las pestañas, con Guardar
|
|
123
|
+
const sw = box.querySelector('[name="computer_use"]');
|
|
124
|
+
if (sw) sw.onchange = async () => {
|
|
125
|
+
const lab = sw.closest('label'); paintToggle(lab); sw.disabled = true; err(sw.checked ? 'encendiendo…' : 'apagando…');
|
|
126
|
+
try {
|
|
127
|
+
const r2 = await api(BASE + '/api/computer', { enabled: sw.checked });
|
|
128
|
+
if (!r2.ok) { sw.checked = !sw.checked; paintToggle(lab); err(r2.data.error || ('error ' + r2.status)); return; }
|
|
129
|
+
v.computer_use = sw.checked ? '1' : ''; err(sw.checked ? '✓ encendido' : '✓ apagado'); add('meta', '⚙ computer use ' + (sw.checked ? 'encendido' : 'apagado')); paintStatus(r2.data.status);
|
|
130
|
+
} catch (e) { sw.checked = !sw.checked; paintToggle(lab); err(e.message); } finally { sw.disabled = false; }
|
|
131
|
+
};
|
|
132
|
+
const gp = box.querySelector('[name="computer_grant_profile"]');
|
|
133
|
+
if (gp) gp.onchange = () => paintToggle(gp.closest('label'));
|
|
134
|
+
const gj = box.querySelector('[data-goto-judge]'); if (gj) gj.onclick = (e) => { e.preventDefault(); openCfg('judge'); };
|
|
135
|
+
saveC.onclick = async () => {
|
|
136
|
+
const body = {};
|
|
137
|
+
const mt = box.querySelector('[name="computer_max_tabs"]'); if (mt && !mt.disabled) { if (mt.value.trim() && !/^[0-9]+$/.test(mt.value.trim())) { err('pestañas: un número entero'); return; } body.max_tabs = mt.value.trim(); }
|
|
138
|
+
if (gp && !gp.disabled) body.grant_profile = gp.checked;
|
|
139
|
+
err('guardando…'); saveC.disabled = true;
|
|
140
|
+
try {
|
|
141
|
+
const r2 = await api(BASE + '/api/computer', body);
|
|
142
|
+
if (!r2.ok) { err(r2.data.error || ('error ' + r2.status)); return; }
|
|
143
|
+
if (body.grant_profile !== undefined) v.computer_grant_profile = body.grant_profile ? '1' : '';
|
|
144
|
+
if (body.max_tabs !== undefined) v.computer_max_tabs = body.max_tabs;
|
|
145
|
+
err('✓ guardado'); paintStatus(r2.data.status);
|
|
146
|
+
} catch (e) { err(e.message); } finally { saveC.disabled = false; }
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const saveJ = box.querySelector('[data-save-judge]');
|
|
150
|
+
if (saveJ) {
|
|
151
|
+
const js = box.querySelector('[name="judge"]');
|
|
152
|
+
const paintJ = (j) => { const el = box.querySelector('#jdStatus'); if (el) el.innerHTML = judgeStatusHtml(j); if (j) { v.has_typesafe_key = j.has_key; const tk = box.querySelector('[name="typesafe_key"]'); if (tk) tk.placeholder = j.has_key ? '● guardada — pegá una nueva para cambiarla' : 'pegala acá (console.typesafe.ai)'; } };
|
|
153
|
+
fetch(BASE + '/api/judge').then(r => r.ok ? r.json() : null).then(paintJ).catch(() => {});
|
|
154
|
+
if (js) js.onchange = () => { box.querySelector('.jd-jev').hidden = js.value !== 'jev'; box.querySelector('.jd-laya').hidden = js.value !== 'laya'; };
|
|
155
|
+
saveJ.onclick = async () => {
|
|
156
|
+
const body = {};
|
|
157
|
+
if (js && !js.disabled) body.mode = js.value;
|
|
158
|
+
const jmod = box.querySelector('[name="judge_model"]'); if (jmod && js && js.value === 'laya') body.model = jmod.value.trim();
|
|
159
|
+
const tkey = box.querySelector('[name="typesafe_key"]'); if (tkey && tkey.value.trim()) body.typesafe_key = tkey.value.trim();
|
|
160
|
+
if (body.mode === 'laya' && !body.model) { err('Laya: poné la carpeta del checkpoint'); return; }
|
|
161
|
+
if (body.mode === 'jev' && !v.has_typesafe_key && !body.typesafe_key) { err('Jev: pegá la API key de TypeSafe'); return; }
|
|
162
|
+
const changed = body.mode !== judgeMode(v) || !!body.typesafe_key || (body.model !== undefined && body.model !== (v.judge_model || v.computer_judge_model || ''));
|
|
163
|
+
if (!changed) { err('sin cambios'); return; }
|
|
164
|
+
err('guardando…'); saveJ.disabled = true;
|
|
165
|
+
try {
|
|
166
|
+
const r2 = await api(BASE + '/api/judge', body);
|
|
167
|
+
if (!r2.ok) { err(r2.data.error || ('error ' + r2.status)); return; }
|
|
168
|
+
v.judge = body.mode === 'off' ? '' : body.mode; if (body.model !== undefined) v.judge_model = body.model;
|
|
169
|
+
if (tkey) tkey.value = '';
|
|
170
|
+
if (r2.data.restarting) {
|
|
171
|
+
// el juez se lee al arrancar: el workspace se reinicia solo; esperar a que vuelva y recargar
|
|
172
|
+
err('✓ guardado · reiniciando el workspace para aplicarlo…'); add('meta', '⚙ juez de decisión: ' + body.mode + ' · reiniciando el workspace');
|
|
173
|
+
const t0 = Date.now(); await new Promise(r => setTimeout(r, 3500));
|
|
174
|
+
while (Date.now() - t0 < 30000) { try { const r = await fetch(BASE + '/api/judge'); if (r.ok) { location.reload(); return; } } catch (e) {} await new Promise(r => setTimeout(r, 1000)); }
|
|
175
|
+
err('el workspace tarda en volver: recargá la página'); return;
|
|
176
|
+
}
|
|
177
|
+
err('✓ guardado'); paintJ(r2.data.status);
|
|
178
|
+
} catch (e) { err(e.message); } finally { saveJ.disabled = false; }
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const reboot = box.querySelector('[data-reboot]');
|
|
182
|
+
if (reboot) {
|
|
183
|
+
const off = box.querySelector('[data-shutdown]');
|
|
184
|
+
fetch('/api/hub').then(r => r.ok ? r.json() : null).then(h => { if (h && h.local === false) { off.disabled = true; box.querySelector('[data-remote-note]').hidden = false; } }).catch(() => {});
|
|
185
|
+
reboot.onclick = () => inlineConfirm(reboot, '¿reiniciar todo?', async () => {
|
|
186
|
+
reboot.disabled = off.disabled = true; err('reiniciando Lampson…');
|
|
187
|
+
const r2 = await api('/api/hub/restart', {});
|
|
188
|
+
if (!r2.ok) { reboot.disabled = false; err(r2.data.error || ('error ' + r2.status)); return; }
|
|
189
|
+
add('meta', '⚙ reiniciando Lampson');
|
|
190
|
+
// el hub tarda en morir (1,5 s) y en volver: esperar a que caiga y a que conteste otra vez
|
|
191
|
+
const t0 = Date.now(); let down = false;
|
|
192
|
+
while (Date.now() - t0 < 60000) {
|
|
193
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
194
|
+
let up = false; try { up = (await fetch('/api/hub', { cache: 'no-store' })).ok; } catch (e) {}
|
|
195
|
+
if (!up) down = true; else if (down) { location.reload(); return; }
|
|
196
|
+
}
|
|
197
|
+
err('Lampson tarda en volver: recargá la página, o en una terminal lampson --hub status');
|
|
198
|
+
});
|
|
199
|
+
off.onclick = () => inlineConfirm(off, '¿apagar Lampson?', async () => {
|
|
200
|
+
reboot.disabled = off.disabled = true; err('apagando…');
|
|
201
|
+
const r2 = await api('/api/hub/shutdown', {});
|
|
202
|
+
if (!r2.ok) { reboot.disabled = false; off.disabled = false; err(r2.data.error || ('error ' + r2.status)); return; }
|
|
203
|
+
add('meta', '⚙ Lampson apagado — para volver: lampson --hub start');
|
|
204
|
+
err('✓ apagado en unos segundos · para volver: lampson --hub start');
|
|
205
|
+
});
|
|
206
|
+
}
|
|
64
207
|
const saveP = box.querySelector('[data-save-provider]');
|
|
65
208
|
if (saveP) {
|
|
66
209
|
const f = n => box.querySelector(`[name="${n}"]`);
|
package/public/js/core.js
CHANGED
|
@@ -9,7 +9,7 @@ let busy = false; let cfg = {};
|
|
|
9
9
|
|
|
10
10
|
function esc(s) { return String(s ?? '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
|
11
11
|
function md(src) { // Markdown mínimo: code fences, inline code, bold, headers, listas, párrafos
|
|
12
|
-
const blocks = []; src = String(src ?? '').replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => { blocks.push('<pre><code>' + esc(c) + '</code></pre>'); return '\uE000' + (blocks.length - 1) + '\uE001'; }); // marcador = índice entre U+E000/U+E001, NUNCA dígitos a secas (se perdieron dos veces al reescribir el archivo y todo número salía undefined)
|
|
12
|
+
const blocks = []; src = String(src ?? '').replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => { blocks.push('<pre><code class="hljs">' + (typeof hlCode === 'function' ? hlCode(c, l) : esc(c)) + '</code></pre>'); return '\uE000' + (blocks.length - 1) + '\uE001'; }); // marcador = índice entre U+E000/U+E001, NUNCA dígitos a secas (se perdieron dos veces al reescribir el archivo y todo número salía undefined)
|
|
13
13
|
let h = esc(src).replace(/`([^`\n]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
|
|
14
14
|
h = h.split(/\n{2,}/).map(p => {
|
|
15
15
|
if (/^\uE000\d+\uE001$/.test(p.trim())) return p;
|
|
@@ -22,7 +22,7 @@ function md(src) { // Markdown mínimo: code fences, inline code, bold, headers,
|
|
|
22
22
|
}
|
|
23
23
|
// un bloque en el chat: 'user' | 'assistant' | 'step' | 'meta' | 'denied' | 'approval' | 'working'
|
|
24
24
|
function add(cls, html) { showPane('log'); log.classList.remove('hero'); const d = document.createElement('div'); d.className = 'msg ' + cls; d.innerHTML = html; log.appendChild(d); $('#stage').scrollTop = $('#stage').scrollHeight; return d; }
|
|
25
|
-
function describe(c) { const a = c.args || {}; if (c.name === 'bash') return '$ ' + (a.command || ''); if (c.name === 'delegate') return 'delegate → ' + a.agent + ': ' + String(a.brief || '').slice(0, 80); if (a.path) return c.name + ' ' + a.path; if (a.pattern) return c.name + ' ' + a.pattern; if (a.name) return c.name + ' ' + a.name; return c.name + ' ' + JSON.stringify(a); }
|
|
25
|
+
function describe(c) { const a = c.args || {}; if (c.name === 'bash') return '$ ' + (a.command || ''); if (c.name === 'fetch') return 'fetch ' + (a.url || '') + (a.format && a.format !== 'markdown' ? ' (' + a.format + ')' : ''); if (c.name === 'delegate') return 'delegate → ' + a.agent + ': ' + String(a.brief || '').slice(0, 80); if (a.path) return c.name + ' ' + a.path; if (a.pattern) return c.name + ' ' + a.pattern; if (a.name) return c.name + ' ' + a.name; return c.name + ' ' + JSON.stringify(a); }
|
|
26
26
|
function cmdHtml(text) { const t = String(text); const long = t.length > 220 || t.split('\n').length > 4; return `<span class="cmd">${esc(t)}</span>` + (long ? ' <span class="more">ver todo</span>' : ''); }
|
|
27
27
|
function wireMore(el) { const m = el.querySelector('.more'); if (m) m.onclick = () => { el.querySelector('.cmd').classList.toggle('open'); m.textContent = el.querySelector('.cmd').classList.contains('open') ? 'menos' : 'ver todo'; }; }
|
|
28
28
|
// POST JSON → {ok, status, data}
|
|
@@ -34,7 +34,8 @@ async function api(path, body) {
|
|
|
34
34
|
// zona central: 'log' | 'viewer' | 'term'
|
|
35
35
|
function showPane(which) {
|
|
36
36
|
log.style.display = which === 'log' ? '' : 'none';
|
|
37
|
-
|
|
37
|
+
// el editor de archivos (#viewer.edit, code.css) es una columna flex que llena el stage; el texto/log sigue en bloque
|
|
38
|
+
$('#viewer').style.display = which !== 'viewer' ? 'none' : ($('#viewer').classList.contains('edit') ? 'flex' : 'block');
|
|
38
39
|
$('#termpane').style.display = which === 'term' ? 'flex' : 'none';
|
|
39
40
|
// volver al chat = al final de la conversación (el viewer de archivos deja el scroll de #stage arriba)
|
|
40
41
|
if (which === 'log') $('#stage').scrollTop = $('#stage').scrollHeight;
|
|
@@ -42,6 +43,7 @@ function showPane(which) {
|
|
|
42
43
|
// visor de texto (archivo, log, traza, memoria): título, meta y contenido plano
|
|
43
44
|
function showText(title, meta, text, toEnd) {
|
|
44
45
|
$('#vpath').textContent = title; $('#vmeta').textContent = meta || '';
|
|
46
|
+
if (typeof viewerText === 'function') viewerText();
|
|
45
47
|
const pre = $('#vbody'); pre.className = 'log'; pre.textContent = text;
|
|
46
48
|
showPane('viewer'); $('#stage').scrollTop = toEnd ? $('#stage').scrollHeight : 0;
|
|
47
49
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// devices.js — «dispositivos» del hub: emparejar un celular por QR (solo desde esta PC), ver y quitar los
|
|
2
|
+
// emparejados, cerrar la sesión remota. El API es del hub (lib/auth.syn vía hub.tpl.syn): /api/auth/…
|
|
3
|
+
let authMe = { local: true, authenticated: true };
|
|
4
|
+
const DEV_PAIR = { id: '__pair' };
|
|
5
|
+
async function fetchMe() { try { authMe = await (await fetch('/api/auth/me')).json(); } catch (e) {} return authMe; }
|
|
6
|
+
async function fetchDevices() { try { const r = await fetch('/api/auth/devices'); if (!r.ok) return { devices: [] }; return await r.json(); } catch (e) { return { devices: [] }; } }
|
|
7
|
+
let pairTimer = null;
|
|
8
|
+
function stopPairTimer() { if (pairTimer) { clearInterval(pairTimer); pairTimer = null; } }
|
|
9
|
+
|
|
10
|
+
function pairDetail() {
|
|
11
|
+
return `<div class="dhead"><span class="nm serif">Emparejar un dispositivo</span></div><div class="dform">
|
|
12
|
+
<p class="lead">Abrí la cámara del celular y escaneá el QR. Se crea una passkey y después entrás con la huella o la cara.</p>
|
|
13
|
+
<label>URL pública <input name="purl" spellcheck="false" autocomplete="off" placeholder="https://….trycloudflare.com"></label>
|
|
14
|
+
<span class="ds">la dirección https por la que el celular llega a esta PC (túnel o dominio). Levantá uno con <code>cloudflared tunnel --url http://localhost:8080</code> y pegá la URL que imprime.</span>
|
|
15
|
+
<div class="pfoot" style="justify-content:flex-start;gap:10px;margin-top:12px"><button class="primary" data-gen>Generar QR</button><span class="derr"></span></div>
|
|
16
|
+
<div class="pairqr" data-qr style="display:none"><div class="qr" data-qrimg></div><div class="qrmeta"><div class="ds" data-left></div><a class="ds" data-link target="_blank" rel="noopener">abrir el link</a><div class="ds">Una passkey queda atada a esta URL: si el túnel cambia de dirección, hay que emparejar de nuevo.</div></div></div>
|
|
17
|
+
</div>`;
|
|
18
|
+
}
|
|
19
|
+
function pairWire(box) {
|
|
20
|
+
const inp = box.querySelector('[name="purl"]'), err = (m) => { box.querySelector('.derr').textContent = m || ''; };
|
|
21
|
+
fetch('/api/auth/public-url').then(r => r.json()).then(d => { if (!inp.value) inp.value = d.public_url || ''; }).catch(() => {});
|
|
22
|
+
box.querySelector('[data-gen]').onclick = async () => {
|
|
23
|
+
err(''); stopPairTimer();
|
|
24
|
+
const url = inp.value.trim();
|
|
25
|
+
const s = await api('/api/auth/public-url', { url });
|
|
26
|
+
if (!s.ok) { err(s.data.error || 'URL inválida'); return; }
|
|
27
|
+
if (!s.data.public_url) { err('falta la URL pública'); return; }
|
|
28
|
+
const p = await api('/api/auth/pair', {});
|
|
29
|
+
if (!p.ok) { err(p.data.error || ('error ' + p.status)); return; }
|
|
30
|
+
const link = s.data.public_url + '/pair#' + encodeURIComponent(p.data.code);
|
|
31
|
+
const qr = qrcode(0, 'M'); qr.addData(link); qr.make();
|
|
32
|
+
box.querySelector('[data-qrimg]').innerHTML = qr.createSvgTag({ cellSize: 5, margin: 3, scalable: true });
|
|
33
|
+
const a = box.querySelector('[data-link]'); a.href = link;
|
|
34
|
+
box.querySelector('[data-qr]').style.display = '';
|
|
35
|
+
const before = (await fetchDevices()).devices.map(d => d.id);
|
|
36
|
+
const until = Date.now() + p.data.expires_in * 1000, left = box.querySelector('[data-left]');
|
|
37
|
+
let ticks = 0;
|
|
38
|
+
pairTimer = setInterval(async () => {
|
|
39
|
+
if (!document.body.contains(left)) { stopPairTimer(); return; }
|
|
40
|
+
const s2 = Math.max(0, Math.round((until - Date.now()) / 1000));
|
|
41
|
+
left.textContent = s2 > 0 ? `vence en ${Math.floor(s2 / 60)}:${String(s2 % 60).padStart(2, '0')}` : 'venció · generá otro';
|
|
42
|
+
if (s2 <= 0) { stopPairTimer(); box.querySelector('[data-qrimg]').style.opacity = .25; return; }
|
|
43
|
+
if (++ticks % 2) return; // cada 2 s: ¿apareció un dispositivo nuevo?
|
|
44
|
+
const now = (await fetchDevices()).devices, nuevo = now.find(d => !before.includes(d.id));
|
|
45
|
+
if (nuevo) { stopPairTimer(); Panel.refresh(); setTimeout(() => Panel.selectKey(nuevo.id), 300); }
|
|
46
|
+
}, 1000);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function openDevices() {
|
|
51
|
+
Panel.open({
|
|
52
|
+
id: 'devices', eyebrow: 'acceso remoto', title: 'Dispositivos', sub: authMe.local ? 'celulares y tablets que pueden entrar a esta PC' : 'entraste desde un dispositivo emparejado', layout: 'browse',
|
|
53
|
+
select: authMe.local ? '__pair' : null,
|
|
54
|
+
onClose: stopPairTimer,
|
|
55
|
+
browse: {
|
|
56
|
+
placeholder: 'buscar dispositivo…', listWidth: '280px', key: d => d.id,
|
|
57
|
+
load: async (q) => { const r = await fetchDevices(); const list = (r.devices || []).filter(d => !q || d.name.toLowerCase().includes(q.toLowerCase())); return authMe.local && !q ? [DEV_PAIR, ...list] : list; },
|
|
58
|
+
render: (d) => d === DEV_PAIR ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">emparejar dispositivo…</div><div class="meta">QR para el celular</div></div>` : `<span class="dot on">●</span><div><div class="nm">${esc(d.name)}${d.current ? ' <span class="meta">(este)</span>' : ''}</div><div class="meta">${esc(d.rp_id)} · ${esc(fmtWhen(d.last_used))}</div></div>`,
|
|
59
|
+
count: (rows) => { const n = rows.filter(r => r !== DEV_PAIR).length; return `${n} dispositivo${n === 1 ? '' : 's'}`; },
|
|
60
|
+
emptyHtml: 'ninguno', emptyDetail: authMe.local ? 'Todavía no emparejaste ninguno.' : 'Sin dispositivos.',
|
|
61
|
+
detail: (d) => d === DEV_PAIR ? pairDetail() : `<div class="dhead"><span class="nm">${esc(d.name)}</span><span class="meta">${d.current ? 'este dispositivo' : ''}</span></div>
|
|
62
|
+
<div class="dcap">${esc(d.rp_id)}</div>
|
|
63
|
+
<div class="ddesc">Emparejado ${esc(fmtWhen(d.created))} · último ingreso ${esc(fmtWhen(d.last_used))}. Entra con su passkey (huella, cara o bloqueo de pantalla).</div>
|
|
64
|
+
<div class="dform" style="margin-top:14px"><label>nombre <input name="dname" value="${esc(d.name)}" maxlength="60" spellcheck="false"></label></div>
|
|
65
|
+
<div class="dacts">${d.current ? '<button data-logout>cerrar sesión</button>' : ''}</div>
|
|
66
|
+
<div class="dfoot"><span>quitarlo corta sus sesiones al instante</span><span class="del">quitar dispositivo</span></div><div class="derr"></div>`,
|
|
67
|
+
wire: (d, box) => {
|
|
68
|
+
if (d === DEV_PAIR) { pairWire(box); return; }
|
|
69
|
+
stopPairTimer();
|
|
70
|
+
const err = (m) => { const e = box.querySelector('.derr'); if (e) e.textContent = m || ''; };
|
|
71
|
+
const nm = box.querySelector('[name="dname"]');
|
|
72
|
+
nm.onchange = async () => { const r = await api('/api/auth/devices/rename', { id: d.id, name: nm.value }); if (!r.ok) err(r.data.error || 'error'); else Panel.refresh(); };
|
|
73
|
+
const lo = box.querySelector('[data-logout]'); if (lo) lo.onclick = async () => { await api('/api/auth/logout', {}); location.href = '/login'; };
|
|
74
|
+
const del = box.querySelector('.dfoot .del');
|
|
75
|
+
del.onclick = () => inlineConfirm(del, `¿quitar ${d.name}?`, async () => { const r = await api('/api/auth/devices/remove', { id: d.id }); if (!r.ok) { err(r.data.error || 'error'); return; } if (d.current) location.href = '/login'; else Panel.refresh(); });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
(async () => { const b = $('#devicesBtn'); if (!b) return; await fetchMe(); b.onclick = () => openDevices(); b.title = authMe.local ? 'emparejar el celular y ver los dispositivos con acceso' : 'dispositivos · cerrar sesión'; })();
|