@danieltmn/tamnora 0.24.1 → 0.24.2
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/CHANGELOG.md +7 -0
- package/harness/packages/server/public/app.js +2 -0
- package/harness/packages/server/public/chat.js +17 -0
- package/harness/packages/server/public/chrome.js +3 -1
- package/harness/packages/server/public/commands.js +2 -0
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/public/styles.css +21 -0
- package/harness/packages/server/public/system.js +495 -495
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.24.2] - 2026-10-30
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- feat(consola): el input del chat crece hacia arriba al escribir
|
|
13
|
+
- feat: botón para volver al chat desde las demás páginas
|
|
14
|
+
|
|
8
15
|
## [0.24.1] - 2026-10-28
|
|
9
16
|
|
|
10
17
|
### Cambios
|
|
@@ -220,6 +220,8 @@ document.addEventListener('keydown', (event) => {
|
|
|
220
220
|
if (event.key === 'Escape' && !moreMenu.hidden) hideMoreMenu();
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
$('#back-chat')?.addEventListener('click', () => activateTab('chat'));
|
|
224
|
+
|
|
223
225
|
if (settingsModal) {
|
|
224
226
|
$('#settings-toggle')?.addEventListener('click', openSettingsModal);
|
|
225
227
|
$('#settings-close')?.addEventListener('click', closeSettingsModal);
|
|
@@ -103,6 +103,16 @@ export const panesEl = $('#panes');
|
|
|
103
103
|
export const tabbarEl = $('#tabbar');
|
|
104
104
|
export const chatForm = $('#chat-form');
|
|
105
105
|
export const chatInput = $('#chat-input');
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ajusta el alto del textarea al contenido: crece hacia arriba a medida que se
|
|
109
|
+
* escribe, hasta el `max-height` del CSS (donde pasa a scrollear por dentro).
|
|
110
|
+
* Hay que llamarlo tambien al vaciarlo por codigo, o quedaria alto y vacio.
|
|
111
|
+
*/
|
|
112
|
+
export function autoGrowChatInput() {
|
|
113
|
+
chatInput.style.height = 'auto';
|
|
114
|
+
chatInput.style.height = `${chatInput.scrollHeight}px`;
|
|
115
|
+
}
|
|
106
116
|
export const submitBtn = chatForm.querySelector('button[type="submit"]');
|
|
107
117
|
export const stopBtn = $('#stop-btn');
|
|
108
118
|
export const interruptBtn = $('#interrupt-btn');
|
|
@@ -305,6 +315,7 @@ export async function regenerate(message) {
|
|
|
305
315
|
await api(`/api/sessions/${id}/truncate`, { method: 'POST', body: { fromId: lastUser.id } }).catch(() => {});
|
|
306
316
|
await reloadPane(id);
|
|
307
317
|
chatInput.value = userText(lastUser.content);
|
|
318
|
+
autoGrowChatInput();
|
|
308
319
|
await sendMessage();
|
|
309
320
|
}
|
|
310
321
|
|
|
@@ -338,6 +349,7 @@ export async function undoTurn(message) {
|
|
|
338
349
|
|
|
339
350
|
export function editMessage(message) { const id = messageId(message);
|
|
340
351
|
chatInput.value = message.textEl.textContent;
|
|
352
|
+
autoGrowChatInput();
|
|
341
353
|
chatInput.focus();
|
|
342
354
|
state.pendingEdit = id ? { fromId: id } : null;
|
|
343
355
|
}
|
|
@@ -753,6 +765,7 @@ export function addContinueButton(turn, data) {
|
|
|
753
765
|
btn.addEventListener('click', () => {
|
|
754
766
|
if (state.panes.get(turn.sessionKey)?.working) return;
|
|
755
767
|
chatInput.value = 'Continuá donde quedaste.';
|
|
768
|
+
autoGrowChatInput();
|
|
756
769
|
sendMessage();
|
|
757
770
|
});
|
|
758
771
|
turn.assistant().tools.appendChild(btn);
|
|
@@ -1195,6 +1208,7 @@ export async function sendMessage() {
|
|
|
1195
1208
|
chatInput.value = '';
|
|
1196
1209
|
state.attachments = [];
|
|
1197
1210
|
renderAttachments();
|
|
1211
|
+
autoGrowChatInput();
|
|
1198
1212
|
if (command) runShell(command);
|
|
1199
1213
|
return;
|
|
1200
1214
|
}
|
|
@@ -1224,6 +1238,7 @@ export async function sendMessage() {
|
|
|
1224
1238
|
chatInput.value = '';
|
|
1225
1239
|
state.attachments = [];
|
|
1226
1240
|
renderAttachments();
|
|
1241
|
+
autoGrowChatInput();
|
|
1227
1242
|
syncInterruptBtn();
|
|
1228
1243
|
return;
|
|
1229
1244
|
}
|
|
@@ -1240,6 +1255,7 @@ export async function sendMessage() {
|
|
|
1240
1255
|
chatInput.value = '';
|
|
1241
1256
|
state.attachments = [];
|
|
1242
1257
|
renderAttachments();
|
|
1258
|
+
autoGrowChatInput();
|
|
1243
1259
|
await startTurn(state.sessionId, activePane, input, images);
|
|
1244
1260
|
}
|
|
1245
1261
|
|
|
@@ -1275,6 +1291,7 @@ export function sendInterrupt() {
|
|
|
1275
1291
|
chatInput.value = '';
|
|
1276
1292
|
state.attachments = [];
|
|
1277
1293
|
renderAttachments();
|
|
1294
|
+
autoGrowChatInput();
|
|
1278
1295
|
syncInterruptBtn();
|
|
1279
1296
|
interruptAndSend(state.sessionId, input, images);
|
|
1280
1297
|
}
|
|
@@ -44,7 +44,8 @@ export function setCwd(value) {
|
|
|
44
44
|
|
|
45
45
|
export const moreTab = $('#more-tab');
|
|
46
46
|
export const moreMenu = $('#more-menu');
|
|
47
|
-
export const topbarSection = $('#topbar-section');
|
|
47
|
+
export const topbarSection = $('#topbar-section');
|
|
48
|
+
export const backChat = $('#back-chat');
|
|
48
49
|
|
|
49
50
|
export const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
|
|
50
51
|
|
|
@@ -61,6 +62,7 @@ export function activateTab(name) {
|
|
|
61
62
|
.querySelectorAll('#more-menu button[data-tab]')
|
|
62
63
|
.forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
|
|
63
64
|
if (topbarSection) topbarSection.textContent = TAB_LABELS[name] || name;
|
|
65
|
+
if (backChat) backChat.hidden = name === 'chat';
|
|
64
66
|
moreTab.classList.toggle('active', name !== 'chat');
|
|
65
67
|
document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
|
|
66
68
|
hideMoreMenu();
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
activePane,
|
|
4
4
|
addCompactCard,
|
|
5
5
|
addMessage,
|
|
6
|
+
autoGrowChatInput,
|
|
6
7
|
basename,
|
|
7
8
|
chatInput,
|
|
8
9
|
finishCompactCard,
|
|
@@ -398,6 +399,7 @@ export function handleSlashKey(event) {
|
|
|
398
399
|
export function onChatInputChanged() {
|
|
399
400
|
slashIndex = 0;
|
|
400
401
|
updateSlashMenu();
|
|
402
|
+
autoGrowChatInput();
|
|
401
403
|
syncInterruptBtn();
|
|
402
404
|
}
|
|
403
405
|
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
</head>
|
|
76
76
|
<body>
|
|
77
77
|
<header class="topbar">
|
|
78
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.24.
|
|
78
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <button id="back-chat" type="button" class="back-chat" title="volver al chat" aria-label="volver al chat" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg><span>chat</span></button> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.24.2</span></div>
|
|
79
79
|
<div class="settings">
|
|
80
80
|
<button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
|
|
81
81
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
|
@@ -571,6 +571,27 @@ html[data-scheme='light'] .brand .logo img { content: url('/icons/logo_light.png
|
|
|
571
571
|
letter-spacing: 0.08em;
|
|
572
572
|
white-space: nowrap;
|
|
573
573
|
}
|
|
574
|
+
/* Botón para volver al chat desde las demás páginas (proveedores, ajustes, eval, runs). */
|
|
575
|
+
.back-chat {
|
|
576
|
+
display: inline-flex;
|
|
577
|
+
align-items: center;
|
|
578
|
+
gap: 3px;
|
|
579
|
+
height: 24px;
|
|
580
|
+
padding: 0 9px 0 5px;
|
|
581
|
+
border: 1px solid var(--border);
|
|
582
|
+
border-radius: 999px;
|
|
583
|
+
background: var(--theirs);
|
|
584
|
+
color: var(--muted);
|
|
585
|
+
cursor: pointer;
|
|
586
|
+
font: 700 10px var(--sans);
|
|
587
|
+
text-transform: uppercase;
|
|
588
|
+
letter-spacing: 0.08em;
|
|
589
|
+
white-space: nowrap;
|
|
590
|
+
transition: 0.15s ease;
|
|
591
|
+
}
|
|
592
|
+
.back-chat[hidden] { display: none; }
|
|
593
|
+
.back-chat:hover { border-color: var(--accent); color: var(--accent); }
|
|
594
|
+
.back-chat svg { width: 15px; height: 15px; display: block; flex: 0 0 auto; }
|
|
574
595
|
.menu { position: relative; display: block; }
|
|
575
596
|
|
|
576
597
|
/* El icono muestra el tema al que se va a cambiar: claro -> luna, oscuro -> sol. */
|
|
@@ -1,495 +1,495 @@
|
|
|
1
|
-
import { livePermission, reloadPane, renderSessions, resolvePermissionCard, sendPresence } from './chat.js';
|
|
2
|
-
import { providerSelect, setSummaryText, yesInput } from './chrome.js';
|
|
3
|
-
import { toast } from './commands.js';
|
|
4
|
-
import { $, api, clear, el, openStream, showConfirm, state, storage, streamPost } from './core.js';
|
|
5
|
-
import { diffState, localState, pollDelayMs } from './reconcile.js';
|
|
6
|
-
import { followRun } from './runStream.js';
|
|
7
|
-
import { closeTab, renderTabs, showStandalonePermission } from './sessions.js';
|
|
8
|
-
|
|
9
|
-
/* --- eval ---------------------------------------------------------------- */
|
|
10
|
-
|
|
11
|
-
export const evalTasks = $('#eval-tasks');
|
|
12
|
-
export const evalResults = $('#eval-results');
|
|
13
|
-
export const evalSummary = $('#eval-summary');
|
|
14
|
-
export const evalRunBtn = $('#eval-run');
|
|
15
|
-
|
|
16
|
-
export function parseTasks(text) {
|
|
17
|
-
const trimmed = text.trim();
|
|
18
|
-
if (!trimmed) return [];
|
|
19
|
-
try {
|
|
20
|
-
const parsed = JSON.parse(trimmed);
|
|
21
|
-
return Array.isArray(parsed) ? parsed : [parsed];
|
|
22
|
-
} catch {
|
|
23
|
-
// fall through to JSONL
|
|
24
|
-
}
|
|
25
|
-
return trimmed
|
|
26
|
-
.split('\n')
|
|
27
|
-
.map((l) => l.trim())
|
|
28
|
-
.filter(Boolean)
|
|
29
|
-
.map((l) => JSON.parse(l));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function setEvalSummary(text, cls = '') {
|
|
33
|
-
setSummaryText(evalSummary, text, cls);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function detailOf(entry) {
|
|
37
|
-
if (entry.error) return entry.error;
|
|
38
|
-
return (entry.trace?.scorers ?? []).map((s) => `${s.scorer}:${s.passed ? 'ok' : 'no'}`).join(' ');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function addResultRow(tbody, entry) {
|
|
42
|
-
const tr = el('tr');
|
|
43
|
-
const pass = entry.passed;
|
|
44
|
-
tr.append(
|
|
45
|
-
el('td', pass ? 'pass' : 'fail', pass ? '✓' : '·'),
|
|
46
|
-
el('td', '', entry.taskId ?? '—'),
|
|
47
|
-
el('td', '', entry.model ?? '—'),
|
|
48
|
-
el('td', '', entry.score === undefined || entry.score === null ? '' : String(entry.score)),
|
|
49
|
-
el('td', '', entry.durationMs === undefined ? '' : String(entry.durationMs)),
|
|
50
|
-
el('td', 'detail', detailOf(entry))
|
|
51
|
-
);
|
|
52
|
-
tbody.appendChild(tr);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export async function runEval() {
|
|
56
|
-
let tasks;
|
|
57
|
-
try {
|
|
58
|
-
tasks = parseTasks(evalTasks.value);
|
|
59
|
-
} catch (error) {
|
|
60
|
-
return setEvalSummary(`Dataset inválido: ${error.message}`, 'err');
|
|
61
|
-
}
|
|
62
|
-
const models = $('#eval-models')
|
|
63
|
-
.value.split(',')
|
|
64
|
-
.map((s) => s.trim())
|
|
65
|
-
.filter(Boolean);
|
|
66
|
-
if (!tasks.length) return setEvalSummary('Ingresá al menos una tarea.', 'err');
|
|
67
|
-
if (!models.length) return setEvalSummary('Ingresá al menos un modelo.', 'err');
|
|
68
|
-
|
|
69
|
-
clear(evalResults);
|
|
70
|
-
evalRunBtn.disabled = true;
|
|
71
|
-
setEvalSummary('corriendo…');
|
|
72
|
-
try {
|
|
73
|
-
await streamPost(
|
|
74
|
-
'/api/eval',
|
|
75
|
-
{
|
|
76
|
-
tasks,
|
|
77
|
-
models,
|
|
78
|
-
provider: providerSelect.value || undefined,
|
|
79
|
-
trials: Number($('#eval-trials').value) || 1,
|
|
80
|
-
concurrency: Number($('#eval-concurrency').value) || 1,
|
|
81
|
-
judgeModel: $('#eval-judge').value.trim() || undefined,
|
|
82
|
-
yes: yesInput.checked ? true : undefined,
|
|
83
|
-
},
|
|
84
|
-
(event, data) => {
|
|
85
|
-
if (event === 'run') setEvalSummary(`Run ${data.runId} · ${data.total} ejecuciones`);
|
|
86
|
-
else if (event === 'result') addResultRow(evalResults, data);
|
|
87
|
-
else if (event === 'summary') {
|
|
88
|
-
const s = data.summary;
|
|
89
|
-
setEvalSummary(`total ${s.total} · ${s.passed} ok · ${s.errors} errores · ${(s.durationMs / 1000).toFixed(1)}s`);
|
|
90
|
-
} else if (event === 'error') setEvalSummary(`Error: ${data.message}`, 'err');
|
|
91
|
-
}
|
|
92
|
-
);
|
|
93
|
-
} catch (error) {
|
|
94
|
-
setEvalSummary(`Error: ${error.message}`, 'err');
|
|
95
|
-
} finally {
|
|
96
|
-
evalRunBtn.disabled = false;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/* --- runs ---------------------------------------------------------------- */
|
|
101
|
-
|
|
102
|
-
export const runsList = $('#runs-list');
|
|
103
|
-
export const runDetail = $('#run-detail');
|
|
104
|
-
|
|
105
|
-
export async function refreshRuns() {
|
|
106
|
-
const data = await api('/api/runs').catch(() => ({ runs: [] }));
|
|
107
|
-
clear(runsList);
|
|
108
|
-
clear(runDetail);
|
|
109
|
-
for (const run of data.runs) {
|
|
110
|
-
const li = el('li');
|
|
111
|
-
li.append(el('span', '', run.dataset || '(inline)'), el('span', 'muted', `${run.status} · ${new Date(run.created_at).toLocaleString()}`));
|
|
112
|
-
li.addEventListener('click', () => {
|
|
113
|
-
runsList.querySelectorAll('li').forEach((n) => n.classList.remove('active'));
|
|
114
|
-
li.classList.add('active');
|
|
115
|
-
openRun(run.id);
|
|
116
|
-
});
|
|
117
|
-
runsList.appendChild(li);
|
|
118
|
-
}
|
|
119
|
-
if (!data.runs.length) runsList.appendChild(el('li', 'muted', 'Sin runs.'));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export async function openRun(id) {
|
|
123
|
-
const data = await api(`/api/runs/${id}`);
|
|
124
|
-
clear(runDetail);
|
|
125
|
-
const summary = data.run.summary ?? {};
|
|
126
|
-
runDetail.appendChild(el('p', 'muted', `total ${summary.total ?? 0} · ${summary.passed ?? 0} ok · ${summary.errors ?? 0} errores`));
|
|
127
|
-
const table = el('table', 'results');
|
|
128
|
-
const thead = el('thead');
|
|
129
|
-
const head = el('tr');
|
|
130
|
-
for (const label of ['ok', 'tarea', 'modelo', 'score', 'ms', 'detalle']) head.appendChild(el('th', '', label));
|
|
131
|
-
thead.appendChild(head);
|
|
132
|
-
const tbody = el('tbody');
|
|
133
|
-
table.append(thead, tbody);
|
|
134
|
-
runDetail.appendChild(table);
|
|
135
|
-
for (const row of data.results) {
|
|
136
|
-
addResultRow(tbody, {
|
|
137
|
-
taskId: row.task_id,
|
|
138
|
-
model: row.model,
|
|
139
|
-
passed: Boolean(row.passed),
|
|
140
|
-
score: row.score,
|
|
141
|
-
durationMs: row.duration_ms,
|
|
142
|
-
trace: row.trace,
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/* --- reconciliacion: la verdad no depende del canal ------------------------ */
|
|
148
|
-
|
|
149
|
-
// El canal global lleva el heartbeat de 15 s del server, asi que 30 s son tres
|
|
150
|
-
// pings: menos espera cuando lo que se pierde tiempo es que aparezca un permiso
|
|
151
|
-
// que te esta esperando.
|
|
152
|
-
export const CHANNEL_SILENCE_MS = 30000;
|
|
153
|
-
|
|
154
|
-
export let lastState = null;
|
|
155
|
-
export let reconciling = false;
|
|
156
|
-
export let reconcileTimer = null;
|
|
157
|
-
export let syncStale = false;
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Un solo lugar decide que hacer con el estado remoto.
|
|
161
|
-
*
|
|
162
|
-
* Antes esto estaba repartido entre el canal global y un sweep: dos caminos que
|
|
163
|
-
* se contradijian, lo que hacia que un SSE caido se tradujera en "no me entero
|
|
164
|
-
* de nada". La logica de diff vive en `reconcile.js`, que es puro y testeado.
|
|
165
|
-
*
|
|
166
|
-
* La verdad es `/api/state`: un request corto. Los SSE son atajos de latencia.
|
|
167
|
-
*/
|
|
168
|
-
export async function reconcile({ immediate = false } = {}) {
|
|
169
|
-
if (reconciling) {
|
|
170
|
-
// Si hay una en curso, encadenamos otra: si no, una mutacion que cae justo
|
|
171
|
-
// en medio se pierde.
|
|
172
|
-
if (immediate) state.reconcileAgain = true;
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
reconciling = true;
|
|
176
|
-
try {
|
|
177
|
-
const remote = await api('/api/state');
|
|
178
|
-
syncStale = false;
|
|
179
|
-
renderSyncState();
|
|
180
|
-
applyState(diffState(localState({ panes: state.panes, asks: state.pendingAsks.keys() }), remote));
|
|
181
|
-
lastState = remote;
|
|
182
|
-
scheduleReconcile();
|
|
183
|
-
} catch (error) {
|
|
184
|
-
// Esto antes era un `.catch(() => null)` silencioso: un fallo de red se via
|
|
185
|
-
// exactamente igual que "no hay nada pendiente", y por eso no se podia
|
|
186
|
-
// diagnosticar nada.
|
|
187
|
-
syncStale = true;
|
|
188
|
-
renderSyncState(error);
|
|
189
|
-
scheduleReconcile();
|
|
190
|
-
} finally {
|
|
191
|
-
reconciling = false;
|
|
192
|
-
if (state.reconcileAgain) {
|
|
193
|
-
state.reconcileAgain = false;
|
|
194
|
-
reconcile();
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export function scheduleReconcile() {
|
|
200
|
-
if (reconcileTimer) clearTimeout(reconcileTimer);
|
|
201
|
-
// Con un permiso pendiente hay algo esperandonos: la latencia se nota.
|
|
202
|
-
reconcileTimer = setTimeout(() => reconcile(), pollDelayMs(lastState));
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export function applyState(diff) {
|
|
206
|
-
for (const id of diff.gone) closeTab(id);
|
|
207
|
-
for (const id of diff.toDetach) reloadPane(id);
|
|
208
|
-
for (const id of diff.toAttach) reloadPane(id);
|
|
209
|
-
for (const askId of diff.resolvedAsks) {
|
|
210
|
-
state.pendingAsks.delete(askId);
|
|
211
|
-
// `null` = alguien lo resolvio en otro lado: la tarjeta ya se cerro alla y
|
|
212
|
-
// solo hay que apagarla.
|
|
213
|
-
resolvePermissionCard(state.permissionCards.get(askId), null);
|
|
214
|
-
}
|
|
215
|
-
for (const ask of diff.newAsks) {
|
|
216
|
-
if (state.pendingAsks.has(ask.askId)) continue;
|
|
217
|
-
state.pendingAsks.set(ask.askId, ask);
|
|
218
|
-
toast(`Permiso pendiente: ${ask.name || 'una tool'}${state.panes.has(ask.sessionId) ? '' : ' en otra sesión'}.`);
|
|
219
|
-
}
|
|
220
|
-
// Los permisos pendientes se pintan siempre, sin importar por donde llegaron:
|
|
221
|
-
// asi tambien se ven cuando el canal estaba caido y llego por el snapshot.
|
|
222
|
-
for (const [askId, ask] of state.pendingAsks) {
|
|
223
|
-
const record = state.panes.get(ask.sessionId);
|
|
224
|
-
if (record && !livePermission(askId)) showStandalonePermission(record, ask);
|
|
225
|
-
}
|
|
226
|
-
state.remoteActive = new Set(diff.remoteActive);
|
|
227
|
-
renderSessions(diff.sessions);
|
|
228
|
-
renderTabs();
|
|
229
|
-
renderAlertSignals();
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/* --- señales de alerta: que se note aun mirando otra cosa ----------------- */
|
|
233
|
-
|
|
234
|
-
export const FAVICON_IDLE = '/favicon.ico';
|
|
235
|
-
export const FAVICON_SOURCE = '/icons/icon-192x192.png';
|
|
236
|
-
export let faviconAlert = '';
|
|
237
|
-
export let faviconAlertPending = null;
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* El favicon de alerta se dibuja sobre una copia del logo (mismo origen), para
|
|
241
|
-
* que el punto rojo combine con el icono real y no con un SVG desincronizado.
|
|
242
|
-
*/
|
|
243
|
-
export function alertFavicon() {
|
|
244
|
-
if (faviconAlert) return Promise.resolve(faviconAlert);
|
|
245
|
-
if (faviconAlertPending) return faviconAlertPending;
|
|
246
|
-
faviconAlertPending = new Promise((resolve) => {
|
|
247
|
-
const img = new Image();
|
|
248
|
-
img.onload = () => {
|
|
249
|
-
try {
|
|
250
|
-
const canvas = document.createElement('canvas');
|
|
251
|
-
canvas.width = 64;
|
|
252
|
-
canvas.height = 64;
|
|
253
|
-
const ctx = canvas.getContext('2d');
|
|
254
|
-
ctx.drawImage(img, 0, 0, 64, 64);
|
|
255
|
-
ctx.beginPath();
|
|
256
|
-
ctx.arc(64 - 16, 16, 13, 0, Math.PI * 2);
|
|
257
|
-
ctx.fillStyle = '#d9704a';
|
|
258
|
-
ctx.fill();
|
|
259
|
-
ctx.lineWidth = 3;
|
|
260
|
-
ctx.strokeStyle = 'rgba(35,26,23,0.9)';
|
|
261
|
-
ctx.stroke();
|
|
262
|
-
faviconAlert = canvas.toDataURL('image/png');
|
|
263
|
-
resolve(faviconAlert);
|
|
264
|
-
} catch {
|
|
265
|
-
resolve(FAVICON_IDLE);
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
img.onerror = () => resolve(FAVICON_IDLE);
|
|
269
|
-
img.src = FAVICON_SOURCE;
|
|
270
|
-
});
|
|
271
|
-
return faviconAlertPending;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
export let baseTitle = '';
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Cuando iOS manda la pestana a background mata los timers y los SSE. El
|
|
278
|
-
* titulo y el favicon son las **unicas** señales que quedan, asi que los dos
|
|
279
|
-
* tienen que marcar que hay un permiso esperando.
|
|
280
|
-
*/
|
|
281
|
-
export function renderAlertSignals() {
|
|
282
|
-
const pending = state.pendingAsks.size + state.pendingQuestions.size;
|
|
283
|
-
if (!baseTitle) baseTitle = document.title;
|
|
284
|
-
const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
|
|
285
|
-
document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
|
|
286
|
-
const link = document.querySelector('link[rel="icon"]');
|
|
287
|
-
if (link) {
|
|
288
|
-
if (pending > 0) alertFavicon().then((href) => { link.href = href; });
|
|
289
|
-
else link.href = FAVICON_IDLE;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/* --- canal global: solo latencia ------------------------------------------ */
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Un SSE por pestana para enterarse al instante. **No decide nada**: dispara
|
|
297
|
-
* `reconcile()`. Si se cae -que es lo normal en un tunel, y lo que hace iOS al
|
|
298
|
-
* mandar la pestana a background- solo perdemos inmediatez, no correctitud.
|
|
299
|
-
*/
|
|
300
|
-
export function handleGlobalEvent(event) {
|
|
301
|
-
// No decide nada: solo pide el estado fresco. El diff es el unico que sabe
|
|
302
|
-
// que hay que hacer.
|
|
303
|
-
if (event === 'run_start' || event === 'run_end' || event === 'permission' || event === 'permission_resolved' || event === 'snapshot') {
|
|
304
|
-
reconcile({ immediate: true });
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export let eventsController = null;
|
|
309
|
-
export let channelState = 'off';
|
|
310
|
-
export let channelReason = '';
|
|
311
|
-
|
|
312
|
-
export function renderChannelState() {
|
|
313
|
-
const node = $('#sb-channel');
|
|
314
|
-
if (!node) return;
|
|
315
|
-
if (channelState === 'live') {
|
|
316
|
-
node.hidden = true;
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
node.hidden = false;
|
|
320
|
-
node.className = `sb-channel ${channelState}`;
|
|
321
|
-
node.textContent = channelState === 'off' ? 'sin canal' : 'reconectando…';
|
|
322
|
-
node.title = channelReason
|
|
323
|
-
? `Canal de eventos caido (${channelReason}). Los cambios llegan igual por sondeo cada ${Math.round(pollDelayMs(lastState) / 1000)} s, pero con retardo.`
|
|
324
|
-
: 'Canal de eventos: sin novedades instantaneas.';
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/** El sondeo de /api/state tambien puede caerse; eso si hay que verlo. */
|
|
328
|
-
export function renderSyncState(error) {
|
|
329
|
-
const node = $('#sb-sync');
|
|
330
|
-
if (!node) return;
|
|
331
|
-
node.hidden = !syncStale;
|
|
332
|
-
node.className = 'sb-channel retrying';
|
|
333
|
-
node.textContent = 'sin sincronía';
|
|
334
|
-
node.title = error
|
|
335
|
-
? `No pude leer el estado del server: ${error.message}. Reintentando cada ${Math.round(pollDelayMs(lastState) / 1000)} s.`
|
|
336
|
-
: 'No pude leer el estado del server. Reintentando.';
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
export async function followEvents() {
|
|
340
|
-
if (eventsController && !eventsController.signal.aborted) return;
|
|
341
|
-
const controller = new AbortController();
|
|
342
|
-
eventsController = controller;
|
|
343
|
-
channelState = 'waiting';
|
|
344
|
-
channelReason = '';
|
|
345
|
-
renderChannelState();
|
|
346
|
-
await followRun({
|
|
347
|
-
signal: controller.signal,
|
|
348
|
-
attempts: Infinity,
|
|
349
|
-
backoff: [1000, 2000, 5000, 10000, 15000],
|
|
350
|
-
onRetry: ({ attempt, error }) => {
|
|
351
|
-
channelState = 'retrying';
|
|
352
|
-
channelReason = error?.message ? `${error.message} (intento ${attempt})` : `sin datos (intento ${attempt})`;
|
|
353
|
-
renderChannelState();
|
|
354
|
-
},
|
|
355
|
-
open: (cursor, signal) =>
|
|
356
|
-
openStream('/api/events', { signal, silenceMs: CHANNEL_SILENCE_MS }, (event, data) => {
|
|
357
|
-
channelState = 'live';
|
|
358
|
-
channelReason = '';
|
|
359
|
-
renderChannelState();
|
|
360
|
-
handleGlobalEvent(event, data);
|
|
361
|
-
}),
|
|
362
|
-
})
|
|
363
|
-
.catch((error) => {
|
|
364
|
-
// Un AbortError es que estamos cerrando. Cualquier otra cosa es un bug de
|
|
365
|
-
// la consola: se avisa en vez de dejar un "reconectando" que no dice nada.
|
|
366
|
-
if (error?.name === 'AbortError') return;
|
|
367
|
-
channelState = 'off';
|
|
368
|
-
channelReason = error?.message ?? String(error);
|
|
369
|
-
renderChannelState();
|
|
370
|
-
toast(`Canal de eventos roto: ${error?.message ?? error}`);
|
|
371
|
-
})
|
|
372
|
-
.finally(() => {
|
|
373
|
-
if (eventsController === controller) {
|
|
374
|
-
channelState = 'off';
|
|
375
|
-
renderChannelState();
|
|
376
|
-
}
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/* --- ciclo de vida --------------------------------------------------------- */
|
|
381
|
-
|
|
382
|
-
export function watchLifecycle() {
|
|
383
|
-
// Volver a `visible` es el momento de reconciliar: iOS congela los timers en
|
|
384
|
-
// background, asi que esperar al proximo tick puede ser Mucho tiempo.
|
|
385
|
-
const wake = () => {
|
|
386
|
-
reconcile({ immediate: true });
|
|
387
|
-
sendPresence();
|
|
388
|
-
if (!eventsController || eventsController.signal.aborted) followEvents();
|
|
389
|
-
};
|
|
390
|
-
document.addEventListener('visibilitychange', () => {
|
|
391
|
-
if (document.visibilityState === 'visible') wake();
|
|
392
|
-
});
|
|
393
|
-
window.addEventListener('pageshow', wake);
|
|
394
|
-
window.addEventListener('online', wake);
|
|
395
|
-
window.addEventListener('focus', wake);
|
|
396
|
-
// La cache del server dura 10 min: revisar al volver a la pestaña es barato.
|
|
397
|
-
refreshUpdate();
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/* --- actualizacion de version --------------------------------------------- */
|
|
401
|
-
|
|
402
|
-
export const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
|
|
403
|
-
export const updateBanner = $('#update-banner');
|
|
404
|
-
export const updateBannerText = $('#update-banner-text');
|
|
405
|
-
export let updateInfo = null;
|
|
406
|
-
|
|
407
|
-
export function showUpdateBanner(info, { force = false } = {}) {
|
|
408
|
-
if (!updateBanner || !info?.updateAvailable) return;
|
|
409
|
-
if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
|
|
410
|
-
updateInfo = info;
|
|
411
|
-
updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
|
|
412
|
-
updateBanner.hidden = false;
|
|
413
|
-
const ver = document.querySelector('.brand .ver');
|
|
414
|
-
if (ver) {
|
|
415
|
-
ver.classList.add('has-update');
|
|
416
|
-
ver.title = `Actualización disponible: v${info.latest}`;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
export async function refreshUpdate() {
|
|
421
|
-
if (!updateBanner) return;
|
|
422
|
-
const info = await api('/api/update').catch(() => null);
|
|
423
|
-
if (info?.updateAvailable) showUpdateBanner(info);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
export function dismissUpdate() {
|
|
427
|
-
if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
|
|
428
|
-
if (updateBanner) updateBanner.hidden = true;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
|
|
432
|
-
export async function forceUpdateCheck() {
|
|
433
|
-
const ver = document.querySelector('.brand .ver');
|
|
434
|
-
const original = ver?.textContent;
|
|
435
|
-
if (ver) ver.textContent = '…';
|
|
436
|
-
const info = await api('/api/update?force=1').catch(() => null);
|
|
437
|
-
if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
|
|
438
|
-
if (!info) return toast('No pude consultar la versión.');
|
|
439
|
-
if (info.updateAvailable) {
|
|
440
|
-
showUpdateBanner(info, { force: true });
|
|
441
|
-
toast(`Hay una versión nueva: v${info.latest}`);
|
|
442
|
-
} else if (info.error) {
|
|
443
|
-
toast(`No pude consultar npm: ${info.error}`);
|
|
444
|
-
} else {
|
|
445
|
-
toast(`Estás al día (v${info.current}).`);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/** Espera a que el server reiniciado vuelva a responder `/api/health`. */
|
|
450
|
-
export async function waitServerVersion(tries = 45) {
|
|
451
|
-
for (let i = 0; i < tries; i++) {
|
|
452
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
453
|
-
const health = await fetch('/api/health', { cache: 'no-store' })
|
|
454
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
455
|
-
.catch(() => null);
|
|
456
|
-
if (health?.version) return health.version;
|
|
457
|
-
}
|
|
458
|
-
return '';
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
export async function runUpdate() {
|
|
462
|
-
if (!updateInfo) return;
|
|
463
|
-
if (
|
|
464
|
-
!(await showConfirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`, {
|
|
465
|
-
title: 'Actualizar Tamnora',
|
|
466
|
-
confirmLabel: 'actualizar',
|
|
467
|
-
}))
|
|
468
|
-
) {
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
const runBtn = $('#update-run');
|
|
472
|
-
const laterBtn = $('#update-later');
|
|
473
|
-
if (runBtn) runBtn.disabled = true;
|
|
474
|
-
if (laterBtn) laterBtn.disabled = true;
|
|
475
|
-
updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
|
|
476
|
-
const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
|
|
477
|
-
if (res?.error) {
|
|
478
|
-
updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
|
|
479
|
-
if (runBtn) runBtn.disabled = false;
|
|
480
|
-
if (laterBtn) laterBtn.disabled = false;
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
|
|
484
|
-
const version = await waitServerVersion();
|
|
485
|
-
if (version) {
|
|
486
|
-
updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
|
|
487
|
-
setTimeout(() => location.reload(), 900);
|
|
488
|
-
} else {
|
|
489
|
-
updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
|
|
490
|
-
if (runBtn) runBtn.disabled = false;
|
|
491
|
-
if (laterBtn) laterBtn.disabled = false;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
export const brandVerEl = document.querySelector('.brand .ver');
|
|
1
|
+
import { livePermission, reloadPane, renderSessions, resolvePermissionCard, sendPresence } from './chat.js';
|
|
2
|
+
import { providerSelect, setSummaryText, yesInput } from './chrome.js';
|
|
3
|
+
import { toast } from './commands.js';
|
|
4
|
+
import { $, api, clear, el, openStream, showConfirm, state, storage, streamPost } from './core.js';
|
|
5
|
+
import { diffState, localState, pollDelayMs } from './reconcile.js';
|
|
6
|
+
import { followRun } from './runStream.js';
|
|
7
|
+
import { closeTab, renderTabs, showStandalonePermission } from './sessions.js';
|
|
8
|
+
|
|
9
|
+
/* --- eval ---------------------------------------------------------------- */
|
|
10
|
+
|
|
11
|
+
export const evalTasks = $('#eval-tasks');
|
|
12
|
+
export const evalResults = $('#eval-results');
|
|
13
|
+
export const evalSummary = $('#eval-summary');
|
|
14
|
+
export const evalRunBtn = $('#eval-run');
|
|
15
|
+
|
|
16
|
+
export function parseTasks(text) {
|
|
17
|
+
const trimmed = text.trim();
|
|
18
|
+
if (!trimmed) return [];
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(trimmed);
|
|
21
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
22
|
+
} catch {
|
|
23
|
+
// fall through to JSONL
|
|
24
|
+
}
|
|
25
|
+
return trimmed
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map((l) => l.trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.map((l) => JSON.parse(l));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setEvalSummary(text, cls = '') {
|
|
33
|
+
setSummaryText(evalSummary, text, cls);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function detailOf(entry) {
|
|
37
|
+
if (entry.error) return entry.error;
|
|
38
|
+
return (entry.trace?.scorers ?? []).map((s) => `${s.scorer}:${s.passed ? 'ok' : 'no'}`).join(' ');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function addResultRow(tbody, entry) {
|
|
42
|
+
const tr = el('tr');
|
|
43
|
+
const pass = entry.passed;
|
|
44
|
+
tr.append(
|
|
45
|
+
el('td', pass ? 'pass' : 'fail', pass ? '✓' : '·'),
|
|
46
|
+
el('td', '', entry.taskId ?? '—'),
|
|
47
|
+
el('td', '', entry.model ?? '—'),
|
|
48
|
+
el('td', '', entry.score === undefined || entry.score === null ? '' : String(entry.score)),
|
|
49
|
+
el('td', '', entry.durationMs === undefined ? '' : String(entry.durationMs)),
|
|
50
|
+
el('td', 'detail', detailOf(entry))
|
|
51
|
+
);
|
|
52
|
+
tbody.appendChild(tr);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runEval() {
|
|
56
|
+
let tasks;
|
|
57
|
+
try {
|
|
58
|
+
tasks = parseTasks(evalTasks.value);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return setEvalSummary(`Dataset inválido: ${error.message}`, 'err');
|
|
61
|
+
}
|
|
62
|
+
const models = $('#eval-models')
|
|
63
|
+
.value.split(',')
|
|
64
|
+
.map((s) => s.trim())
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
if (!tasks.length) return setEvalSummary('Ingresá al menos una tarea.', 'err');
|
|
67
|
+
if (!models.length) return setEvalSummary('Ingresá al menos un modelo.', 'err');
|
|
68
|
+
|
|
69
|
+
clear(evalResults);
|
|
70
|
+
evalRunBtn.disabled = true;
|
|
71
|
+
setEvalSummary('corriendo…');
|
|
72
|
+
try {
|
|
73
|
+
await streamPost(
|
|
74
|
+
'/api/eval',
|
|
75
|
+
{
|
|
76
|
+
tasks,
|
|
77
|
+
models,
|
|
78
|
+
provider: providerSelect.value || undefined,
|
|
79
|
+
trials: Number($('#eval-trials').value) || 1,
|
|
80
|
+
concurrency: Number($('#eval-concurrency').value) || 1,
|
|
81
|
+
judgeModel: $('#eval-judge').value.trim() || undefined,
|
|
82
|
+
yes: yesInput.checked ? true : undefined,
|
|
83
|
+
},
|
|
84
|
+
(event, data) => {
|
|
85
|
+
if (event === 'run') setEvalSummary(`Run ${data.runId} · ${data.total} ejecuciones`);
|
|
86
|
+
else if (event === 'result') addResultRow(evalResults, data);
|
|
87
|
+
else if (event === 'summary') {
|
|
88
|
+
const s = data.summary;
|
|
89
|
+
setEvalSummary(`total ${s.total} · ${s.passed} ok · ${s.errors} errores · ${(s.durationMs / 1000).toFixed(1)}s`);
|
|
90
|
+
} else if (event === 'error') setEvalSummary(`Error: ${data.message}`, 'err');
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
setEvalSummary(`Error: ${error.message}`, 'err');
|
|
95
|
+
} finally {
|
|
96
|
+
evalRunBtn.disabled = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* --- runs ---------------------------------------------------------------- */
|
|
101
|
+
|
|
102
|
+
export const runsList = $('#runs-list');
|
|
103
|
+
export const runDetail = $('#run-detail');
|
|
104
|
+
|
|
105
|
+
export async function refreshRuns() {
|
|
106
|
+
const data = await api('/api/runs').catch(() => ({ runs: [] }));
|
|
107
|
+
clear(runsList);
|
|
108
|
+
clear(runDetail);
|
|
109
|
+
for (const run of data.runs) {
|
|
110
|
+
const li = el('li');
|
|
111
|
+
li.append(el('span', '', run.dataset || '(inline)'), el('span', 'muted', `${run.status} · ${new Date(run.created_at).toLocaleString()}`));
|
|
112
|
+
li.addEventListener('click', () => {
|
|
113
|
+
runsList.querySelectorAll('li').forEach((n) => n.classList.remove('active'));
|
|
114
|
+
li.classList.add('active');
|
|
115
|
+
openRun(run.id);
|
|
116
|
+
});
|
|
117
|
+
runsList.appendChild(li);
|
|
118
|
+
}
|
|
119
|
+
if (!data.runs.length) runsList.appendChild(el('li', 'muted', 'Sin runs.'));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function openRun(id) {
|
|
123
|
+
const data = await api(`/api/runs/${id}`);
|
|
124
|
+
clear(runDetail);
|
|
125
|
+
const summary = data.run.summary ?? {};
|
|
126
|
+
runDetail.appendChild(el('p', 'muted', `total ${summary.total ?? 0} · ${summary.passed ?? 0} ok · ${summary.errors ?? 0} errores`));
|
|
127
|
+
const table = el('table', 'results');
|
|
128
|
+
const thead = el('thead');
|
|
129
|
+
const head = el('tr');
|
|
130
|
+
for (const label of ['ok', 'tarea', 'modelo', 'score', 'ms', 'detalle']) head.appendChild(el('th', '', label));
|
|
131
|
+
thead.appendChild(head);
|
|
132
|
+
const tbody = el('tbody');
|
|
133
|
+
table.append(thead, tbody);
|
|
134
|
+
runDetail.appendChild(table);
|
|
135
|
+
for (const row of data.results) {
|
|
136
|
+
addResultRow(tbody, {
|
|
137
|
+
taskId: row.task_id,
|
|
138
|
+
model: row.model,
|
|
139
|
+
passed: Boolean(row.passed),
|
|
140
|
+
score: row.score,
|
|
141
|
+
durationMs: row.duration_ms,
|
|
142
|
+
trace: row.trace,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* --- reconciliacion: la verdad no depende del canal ------------------------ */
|
|
148
|
+
|
|
149
|
+
// El canal global lleva el heartbeat de 15 s del server, asi que 30 s son tres
|
|
150
|
+
// pings: menos espera cuando lo que se pierde tiempo es que aparezca un permiso
|
|
151
|
+
// que te esta esperando.
|
|
152
|
+
export const CHANNEL_SILENCE_MS = 30000;
|
|
153
|
+
|
|
154
|
+
export let lastState = null;
|
|
155
|
+
export let reconciling = false;
|
|
156
|
+
export let reconcileTimer = null;
|
|
157
|
+
export let syncStale = false;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Un solo lugar decide que hacer con el estado remoto.
|
|
161
|
+
*
|
|
162
|
+
* Antes esto estaba repartido entre el canal global y un sweep: dos caminos que
|
|
163
|
+
* se contradijian, lo que hacia que un SSE caido se tradujera en "no me entero
|
|
164
|
+
* de nada". La logica de diff vive en `reconcile.js`, que es puro y testeado.
|
|
165
|
+
*
|
|
166
|
+
* La verdad es `/api/state`: un request corto. Los SSE son atajos de latencia.
|
|
167
|
+
*/
|
|
168
|
+
export async function reconcile({ immediate = false } = {}) {
|
|
169
|
+
if (reconciling) {
|
|
170
|
+
// Si hay una en curso, encadenamos otra: si no, una mutacion que cae justo
|
|
171
|
+
// en medio se pierde.
|
|
172
|
+
if (immediate) state.reconcileAgain = true;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
reconciling = true;
|
|
176
|
+
try {
|
|
177
|
+
const remote = await api('/api/state');
|
|
178
|
+
syncStale = false;
|
|
179
|
+
renderSyncState();
|
|
180
|
+
applyState(diffState(localState({ panes: state.panes, asks: state.pendingAsks.keys() }), remote));
|
|
181
|
+
lastState = remote;
|
|
182
|
+
scheduleReconcile();
|
|
183
|
+
} catch (error) {
|
|
184
|
+
// Esto antes era un `.catch(() => null)` silencioso: un fallo de red se via
|
|
185
|
+
// exactamente igual que "no hay nada pendiente", y por eso no se podia
|
|
186
|
+
// diagnosticar nada.
|
|
187
|
+
syncStale = true;
|
|
188
|
+
renderSyncState(error);
|
|
189
|
+
scheduleReconcile();
|
|
190
|
+
} finally {
|
|
191
|
+
reconciling = false;
|
|
192
|
+
if (state.reconcileAgain) {
|
|
193
|
+
state.reconcileAgain = false;
|
|
194
|
+
reconcile();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function scheduleReconcile() {
|
|
200
|
+
if (reconcileTimer) clearTimeout(reconcileTimer);
|
|
201
|
+
// Con un permiso pendiente hay algo esperandonos: la latencia se nota.
|
|
202
|
+
reconcileTimer = setTimeout(() => reconcile(), pollDelayMs(lastState));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function applyState(diff) {
|
|
206
|
+
for (const id of diff.gone) closeTab(id);
|
|
207
|
+
for (const id of diff.toDetach) reloadPane(id);
|
|
208
|
+
for (const id of diff.toAttach) reloadPane(id);
|
|
209
|
+
for (const askId of diff.resolvedAsks) {
|
|
210
|
+
state.pendingAsks.delete(askId);
|
|
211
|
+
// `null` = alguien lo resolvio en otro lado: la tarjeta ya se cerro alla y
|
|
212
|
+
// solo hay que apagarla.
|
|
213
|
+
resolvePermissionCard(state.permissionCards.get(askId), null);
|
|
214
|
+
}
|
|
215
|
+
for (const ask of diff.newAsks) {
|
|
216
|
+
if (state.pendingAsks.has(ask.askId)) continue;
|
|
217
|
+
state.pendingAsks.set(ask.askId, ask);
|
|
218
|
+
toast(`Permiso pendiente: ${ask.name || 'una tool'}${state.panes.has(ask.sessionId) ? '' : ' en otra sesión'}.`);
|
|
219
|
+
}
|
|
220
|
+
// Los permisos pendientes se pintan siempre, sin importar por donde llegaron:
|
|
221
|
+
// asi tambien se ven cuando el canal estaba caido y llego por el snapshot.
|
|
222
|
+
for (const [askId, ask] of state.pendingAsks) {
|
|
223
|
+
const record = state.panes.get(ask.sessionId);
|
|
224
|
+
if (record && !livePermission(askId)) showStandalonePermission(record, ask);
|
|
225
|
+
}
|
|
226
|
+
state.remoteActive = new Set(diff.remoteActive);
|
|
227
|
+
renderSessions(diff.sessions);
|
|
228
|
+
renderTabs();
|
|
229
|
+
renderAlertSignals();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* --- señales de alerta: que se note aun mirando otra cosa ----------------- */
|
|
233
|
+
|
|
234
|
+
export const FAVICON_IDLE = '/favicon.ico';
|
|
235
|
+
export const FAVICON_SOURCE = '/icons/icon-192x192.png';
|
|
236
|
+
export let faviconAlert = '';
|
|
237
|
+
export let faviconAlertPending = null;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* El favicon de alerta se dibuja sobre una copia del logo (mismo origen), para
|
|
241
|
+
* que el punto rojo combine con el icono real y no con un SVG desincronizado.
|
|
242
|
+
*/
|
|
243
|
+
export function alertFavicon() {
|
|
244
|
+
if (faviconAlert) return Promise.resolve(faviconAlert);
|
|
245
|
+
if (faviconAlertPending) return faviconAlertPending;
|
|
246
|
+
faviconAlertPending = new Promise((resolve) => {
|
|
247
|
+
const img = new Image();
|
|
248
|
+
img.onload = () => {
|
|
249
|
+
try {
|
|
250
|
+
const canvas = document.createElement('canvas');
|
|
251
|
+
canvas.width = 64;
|
|
252
|
+
canvas.height = 64;
|
|
253
|
+
const ctx = canvas.getContext('2d');
|
|
254
|
+
ctx.drawImage(img, 0, 0, 64, 64);
|
|
255
|
+
ctx.beginPath();
|
|
256
|
+
ctx.arc(64 - 16, 16, 13, 0, Math.PI * 2);
|
|
257
|
+
ctx.fillStyle = '#d9704a';
|
|
258
|
+
ctx.fill();
|
|
259
|
+
ctx.lineWidth = 3;
|
|
260
|
+
ctx.strokeStyle = 'rgba(35,26,23,0.9)';
|
|
261
|
+
ctx.stroke();
|
|
262
|
+
faviconAlert = canvas.toDataURL('image/png');
|
|
263
|
+
resolve(faviconAlert);
|
|
264
|
+
} catch {
|
|
265
|
+
resolve(FAVICON_IDLE);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
img.onerror = () => resolve(FAVICON_IDLE);
|
|
269
|
+
img.src = FAVICON_SOURCE;
|
|
270
|
+
});
|
|
271
|
+
return faviconAlertPending;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export let baseTitle = '';
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Cuando iOS manda la pestana a background mata los timers y los SSE. El
|
|
278
|
+
* titulo y el favicon son las **unicas** señales que quedan, asi que los dos
|
|
279
|
+
* tienen que marcar que hay un permiso esperando.
|
|
280
|
+
*/
|
|
281
|
+
export function renderAlertSignals() {
|
|
282
|
+
const pending = state.pendingAsks.size + state.pendingQuestions.size;
|
|
283
|
+
if (!baseTitle) baseTitle = document.title;
|
|
284
|
+
const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
|
|
285
|
+
document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
|
|
286
|
+
const link = document.querySelector('link[rel="icon"]');
|
|
287
|
+
if (link) {
|
|
288
|
+
if (pending > 0) alertFavicon().then((href) => { link.href = href; });
|
|
289
|
+
else link.href = FAVICON_IDLE;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* --- canal global: solo latencia ------------------------------------------ */
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Un SSE por pestana para enterarse al instante. **No decide nada**: dispara
|
|
297
|
+
* `reconcile()`. Si se cae -que es lo normal en un tunel, y lo que hace iOS al
|
|
298
|
+
* mandar la pestana a background- solo perdemos inmediatez, no correctitud.
|
|
299
|
+
*/
|
|
300
|
+
export function handleGlobalEvent(event) {
|
|
301
|
+
// No decide nada: solo pide el estado fresco. El diff es el unico que sabe
|
|
302
|
+
// que hay que hacer.
|
|
303
|
+
if (event === 'run_start' || event === 'run_end' || event === 'permission' || event === 'permission_resolved' || event === 'snapshot') {
|
|
304
|
+
reconcile({ immediate: true });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export let eventsController = null;
|
|
309
|
+
export let channelState = 'off';
|
|
310
|
+
export let channelReason = '';
|
|
311
|
+
|
|
312
|
+
export function renderChannelState() {
|
|
313
|
+
const node = $('#sb-channel');
|
|
314
|
+
if (!node) return;
|
|
315
|
+
if (channelState === 'live') {
|
|
316
|
+
node.hidden = true;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
node.hidden = false;
|
|
320
|
+
node.className = `sb-channel ${channelState}`;
|
|
321
|
+
node.textContent = channelState === 'off' ? 'sin canal' : 'reconectando…';
|
|
322
|
+
node.title = channelReason
|
|
323
|
+
? `Canal de eventos caido (${channelReason}). Los cambios llegan igual por sondeo cada ${Math.round(pollDelayMs(lastState) / 1000)} s, pero con retardo.`
|
|
324
|
+
: 'Canal de eventos: sin novedades instantaneas.';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** El sondeo de /api/state tambien puede caerse; eso si hay que verlo. */
|
|
328
|
+
export function renderSyncState(error) {
|
|
329
|
+
const node = $('#sb-sync');
|
|
330
|
+
if (!node) return;
|
|
331
|
+
node.hidden = !syncStale;
|
|
332
|
+
node.className = 'sb-channel retrying';
|
|
333
|
+
node.textContent = 'sin sincronía';
|
|
334
|
+
node.title = error
|
|
335
|
+
? `No pude leer el estado del server: ${error.message}. Reintentando cada ${Math.round(pollDelayMs(lastState) / 1000)} s.`
|
|
336
|
+
: 'No pude leer el estado del server. Reintentando.';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function followEvents() {
|
|
340
|
+
if (eventsController && !eventsController.signal.aborted) return;
|
|
341
|
+
const controller = new AbortController();
|
|
342
|
+
eventsController = controller;
|
|
343
|
+
channelState = 'waiting';
|
|
344
|
+
channelReason = '';
|
|
345
|
+
renderChannelState();
|
|
346
|
+
await followRun({
|
|
347
|
+
signal: controller.signal,
|
|
348
|
+
attempts: Infinity,
|
|
349
|
+
backoff: [1000, 2000, 5000, 10000, 15000],
|
|
350
|
+
onRetry: ({ attempt, error }) => {
|
|
351
|
+
channelState = 'retrying';
|
|
352
|
+
channelReason = error?.message ? `${error.message} (intento ${attempt})` : `sin datos (intento ${attempt})`;
|
|
353
|
+
renderChannelState();
|
|
354
|
+
},
|
|
355
|
+
open: (cursor, signal) =>
|
|
356
|
+
openStream('/api/events', { signal, silenceMs: CHANNEL_SILENCE_MS }, (event, data) => {
|
|
357
|
+
channelState = 'live';
|
|
358
|
+
channelReason = '';
|
|
359
|
+
renderChannelState();
|
|
360
|
+
handleGlobalEvent(event, data);
|
|
361
|
+
}),
|
|
362
|
+
})
|
|
363
|
+
.catch((error) => {
|
|
364
|
+
// Un AbortError es que estamos cerrando. Cualquier otra cosa es un bug de
|
|
365
|
+
// la consola: se avisa en vez de dejar un "reconectando" que no dice nada.
|
|
366
|
+
if (error?.name === 'AbortError') return;
|
|
367
|
+
channelState = 'off';
|
|
368
|
+
channelReason = error?.message ?? String(error);
|
|
369
|
+
renderChannelState();
|
|
370
|
+
toast(`Canal de eventos roto: ${error?.message ?? error}`);
|
|
371
|
+
})
|
|
372
|
+
.finally(() => {
|
|
373
|
+
if (eventsController === controller) {
|
|
374
|
+
channelState = 'off';
|
|
375
|
+
renderChannelState();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* --- ciclo de vida --------------------------------------------------------- */
|
|
381
|
+
|
|
382
|
+
export function watchLifecycle() {
|
|
383
|
+
// Volver a `visible` es el momento de reconciliar: iOS congela los timers en
|
|
384
|
+
// background, asi que esperar al proximo tick puede ser Mucho tiempo.
|
|
385
|
+
const wake = () => {
|
|
386
|
+
reconcile({ immediate: true });
|
|
387
|
+
sendPresence();
|
|
388
|
+
if (!eventsController || eventsController.signal.aborted) followEvents();
|
|
389
|
+
};
|
|
390
|
+
document.addEventListener('visibilitychange', () => {
|
|
391
|
+
if (document.visibilityState === 'visible') wake();
|
|
392
|
+
});
|
|
393
|
+
window.addEventListener('pageshow', wake);
|
|
394
|
+
window.addEventListener('online', wake);
|
|
395
|
+
window.addEventListener('focus', wake);
|
|
396
|
+
// La cache del server dura 10 min: revisar al volver a la pestaña es barato.
|
|
397
|
+
refreshUpdate();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* --- actualizacion de version --------------------------------------------- */
|
|
401
|
+
|
|
402
|
+
export const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
|
|
403
|
+
export const updateBanner = $('#update-banner');
|
|
404
|
+
export const updateBannerText = $('#update-banner-text');
|
|
405
|
+
export let updateInfo = null;
|
|
406
|
+
|
|
407
|
+
export function showUpdateBanner(info, { force = false } = {}) {
|
|
408
|
+
if (!updateBanner || !info?.updateAvailable) return;
|
|
409
|
+
if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
|
|
410
|
+
updateInfo = info;
|
|
411
|
+
updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
|
|
412
|
+
updateBanner.hidden = false;
|
|
413
|
+
const ver = document.querySelector('.brand .ver');
|
|
414
|
+
if (ver) {
|
|
415
|
+
ver.classList.add('has-update');
|
|
416
|
+
ver.title = `Actualización disponible: v${info.latest}`;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function refreshUpdate() {
|
|
421
|
+
if (!updateBanner) return;
|
|
422
|
+
const info = await api('/api/update').catch(() => null);
|
|
423
|
+
if (info?.updateAvailable) showUpdateBanner(info);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function dismissUpdate() {
|
|
427
|
+
if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
|
|
428
|
+
if (updateBanner) updateBanner.hidden = true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
|
|
432
|
+
export async function forceUpdateCheck() {
|
|
433
|
+
const ver = document.querySelector('.brand .ver');
|
|
434
|
+
const original = ver?.textContent;
|
|
435
|
+
if (ver) ver.textContent = '…';
|
|
436
|
+
const info = await api('/api/update?force=1').catch(() => null);
|
|
437
|
+
if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
|
|
438
|
+
if (!info) return toast('No pude consultar la versión.');
|
|
439
|
+
if (info.updateAvailable) {
|
|
440
|
+
showUpdateBanner(info, { force: true });
|
|
441
|
+
toast(`Hay una versión nueva: v${info.latest}`);
|
|
442
|
+
} else if (info.error) {
|
|
443
|
+
toast(`No pude consultar npm: ${info.error}`);
|
|
444
|
+
} else {
|
|
445
|
+
toast(`Estás al día (v${info.current}).`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Espera a que el server reiniciado vuelva a responder `/api/health`. */
|
|
450
|
+
export async function waitServerVersion(tries = 45) {
|
|
451
|
+
for (let i = 0; i < tries; i++) {
|
|
452
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
453
|
+
const health = await fetch('/api/health', { cache: 'no-store' })
|
|
454
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
455
|
+
.catch(() => null);
|
|
456
|
+
if (health?.version) return health.version;
|
|
457
|
+
}
|
|
458
|
+
return '';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export async function runUpdate() {
|
|
462
|
+
if (!updateInfo) return;
|
|
463
|
+
if (
|
|
464
|
+
!(await showConfirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`, {
|
|
465
|
+
title: 'Actualizar Tamnora',
|
|
466
|
+
confirmLabel: 'actualizar',
|
|
467
|
+
}))
|
|
468
|
+
) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const runBtn = $('#update-run');
|
|
472
|
+
const laterBtn = $('#update-later');
|
|
473
|
+
if (runBtn) runBtn.disabled = true;
|
|
474
|
+
if (laterBtn) laterBtn.disabled = true;
|
|
475
|
+
updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
|
|
476
|
+
const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
|
|
477
|
+
if (res?.error) {
|
|
478
|
+
updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
|
|
479
|
+
if (runBtn) runBtn.disabled = false;
|
|
480
|
+
if (laterBtn) laterBtn.disabled = false;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
|
|
484
|
+
const version = await waitServerVersion();
|
|
485
|
+
if (version) {
|
|
486
|
+
updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
|
|
487
|
+
setTimeout(() => location.reload(), 900);
|
|
488
|
+
} else {
|
|
489
|
+
updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
|
|
490
|
+
if (runBtn) runBtn.disabled = false;
|
|
491
|
+
if (laterBtn) laterBtn.disabled = false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export const brandVerEl = document.querySelector('.brand .ver');
|
package/package.json
CHANGED