@danieltmn/tamnora 0.9.4 → 0.9.6
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 +32 -0
- package/README.md +25 -10
- package/harness/packages/cli/src/commands/autostart.js +5 -1
- package/harness/packages/cli/src/commands/doctor.js +14 -0
- package/harness/packages/cli/src/commands/update.js +91 -21
- package/harness/packages/core/src/index.js +7 -1
- package/harness/packages/core/src/version.js +81 -7
- package/harness/packages/core/test/version.test.js +34 -0
- package/harness/packages/server/public/app.js +337 -24
- package/harness/packages/server/public/favicon.ico +0 -0
- package/harness/packages/server/public/icons/icon-128x128.png +0 -0
- package/harness/packages/server/public/icons/icon-144x144.png +0 -0
- package/harness/packages/server/public/icons/icon-152x152.png +0 -0
- package/harness/packages/server/public/icons/icon-192x192.png +0 -0
- package/harness/packages/server/public/icons/icon-384x384.png +0 -0
- package/harness/packages/server/public/icons/icon-512x512.png +0 -0
- package/harness/packages/server/public/icons/icon-72x72.png +0 -0
- package/harness/packages/server/public/icons/icon-96x96.png +0 -0
- package/harness/packages/server/public/icons/logo.png +0 -0
- package/harness/packages/server/public/icons/tamnora.ico +0 -0
- package/harness/packages/server/public/index.html +119 -30
- package/harness/packages/server/public/landing/index.html +8 -4
- package/harness/packages/server/public/logo.png +0 -0
- package/harness/packages/server/public/manifest.webmanifest +20 -0
- package/harness/packages/server/public/markdown.js +54 -0
- package/harness/packages/server/public/styles.css +1775 -1633
- package/harness/packages/server/src/lib/advisor.js +177 -0
- package/harness/packages/server/src/lib/agentRun.js +2 -1
- package/harness/packages/server/src/lib/ttsSettings.js +2 -2
- package/harness/packages/server/src/lib/update.js +82 -0
- package/harness/packages/server/src/routes/advisor.js +76 -0
- package/harness/packages/server/src/routes/chat.js +30 -0
- package/harness/packages/server/src/routes/tts.js +1 -1
- package/harness/packages/server/src/routes/update.js +43 -0
- package/harness/packages/server/src/server.js +26 -2
- package/harness/packages/server/src/static.js +1 -0
- package/harness/packages/server/test/advisor.test.js +205 -0
- package/harness/packages/server/test/markdown.test.js +43 -1
- package/harness/packages/server/test/update.test.js +94 -0
- package/harness/scripts/self-update.mjs +123 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tamnora console — dependency-free client for the harness server (terminal theme).
|
|
2
2
|
|
|
3
|
-
import { renderMarkdown } from './markdown.js';
|
|
3
|
+
import { renderMarkdown, speechText } from './markdown.js';
|
|
4
4
|
import { streamSse, followRun } from './runStream.js';
|
|
5
5
|
import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
|
|
6
6
|
import { installRelayTransport, showRelayTokenGate } from './relayClient.js';
|
|
@@ -39,6 +39,7 @@ function setCwd(value) {
|
|
|
39
39
|
if (!value) return;
|
|
40
40
|
state.cwd = value;
|
|
41
41
|
$('#rail-cwd').textContent = value;
|
|
42
|
+
renderCwdPill();
|
|
42
43
|
if (state.explorerOpen) refreshExplorerMode();
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -217,7 +218,7 @@ function activateTab(name) {
|
|
|
217
218
|
hideMoreMenu();
|
|
218
219
|
if (name === 'runs') refreshRuns();
|
|
219
220
|
if (name === 'providers') refreshProviders();
|
|
220
|
-
if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshTranscribe(); refreshProfile(); }
|
|
221
|
+
if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
document.querySelectorAll('.tab[data-tab]').forEach((t) =>
|
|
@@ -478,11 +479,6 @@ async function loadModes() {
|
|
|
478
479
|
updateStatus();
|
|
479
480
|
}
|
|
480
481
|
|
|
481
|
-
function modeLabel(id) {
|
|
482
|
-
const option = [...modeSelect.options].find((o) => o.value === id);
|
|
483
|
-
return option ? option.textContent : id;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
482
|
async function setMode(id, { persist = true } = {}) {
|
|
487
483
|
if (![...modeSelect.options].some((o) => o.value === id)) return;
|
|
488
484
|
modeSelect.value = id;
|
|
@@ -504,11 +500,82 @@ document.addEventListener('keydown', (event) => {
|
|
|
504
500
|
}
|
|
505
501
|
});
|
|
506
502
|
|
|
503
|
+
/* --- pills del composer (carpeta, permisos, modelo) ----------------------- */
|
|
504
|
+
|
|
505
|
+
const cwdPill = $('#cwd-pill');
|
|
506
|
+
const cwdPillLabel = $('#cwd-pill-label');
|
|
507
|
+
const modePill = $('#mode-pill');
|
|
508
|
+
const modePillLabel = $('#mode-pill-label');
|
|
509
|
+
const modeMenu = $('#mode-menu');
|
|
510
|
+
const modelPill = $('#model-pill');
|
|
511
|
+
const modelPillLabel = $('#model-pill-label');
|
|
512
|
+
|
|
513
|
+
function renderCwdPill() {
|
|
514
|
+
if (!cwdPillLabel) return;
|
|
515
|
+
cwdPillLabel.textContent = state.cwd ? basename(state.cwd) : 'sin carpeta';
|
|
516
|
+
if (cwdPill) cwdPill.title = state.cwd || 'sin carpeta de trabajo';
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// El pill de permisos resume modo + auto-aprobación en uno de tres niveles
|
|
520
|
+
// estilo Codex: Read Only, Workspace Write y Full access.
|
|
521
|
+
function renderModePill() {
|
|
522
|
+
const plan = modeSelect.value === 'plan';
|
|
523
|
+
const full = !plan && Boolean(yesInput?.checked);
|
|
524
|
+
if (modePillLabel) modePillLabel.textContent = plan ? 'Read Only' : full ? 'Full access' : 'Workspace Write';
|
|
525
|
+
if (!modeMenu) return;
|
|
526
|
+
for (const item of modeMenu.querySelectorAll('.ctrl-menu-item')) {
|
|
527
|
+
const kind = item.dataset.kind;
|
|
528
|
+
const active = kind === 'read' ? plan : kind === 'full' ? full : !plan && !full;
|
|
529
|
+
item.classList.toggle('active', active);
|
|
530
|
+
item.setAttribute('aria-checked', active ? 'true' : 'false');
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function renderModelPill() {
|
|
535
|
+
if (!modelPillLabel) return;
|
|
536
|
+
modelPillLabel.textContent = state.active.model || 'elegí un modelo';
|
|
537
|
+
if (modelPill) modelPill.title = `${state.active.provider || '—'} / ${state.active.model || '—'}`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function closeModeMenu() {
|
|
541
|
+
if (!modeMenu || modeMenu.hidden) return;
|
|
542
|
+
modeMenu.hidden = true;
|
|
543
|
+
modePill?.setAttribute('aria-expanded', 'false');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
cwdPill?.addEventListener('click', () => openFolderPicker('session'));
|
|
547
|
+
modePill?.addEventListener('click', (event) => {
|
|
548
|
+
event.stopPropagation();
|
|
549
|
+
if (!modeMenu) return;
|
|
550
|
+
modeMenu.hidden = !modeMenu.hidden;
|
|
551
|
+
modePill.setAttribute('aria-expanded', modeMenu.hidden ? 'false' : 'true');
|
|
552
|
+
});
|
|
553
|
+
modeMenu?.addEventListener('click', async (event) => {
|
|
554
|
+
const item = event.target.closest('.ctrl-menu-item');
|
|
555
|
+
if (!item) return;
|
|
556
|
+
const kind = item.dataset.kind;
|
|
557
|
+
closeModeMenu();
|
|
558
|
+
if (kind === 'full') {
|
|
559
|
+
await setMode('agent');
|
|
560
|
+
if (yesInput) yesInput.checked = true;
|
|
561
|
+
} else {
|
|
562
|
+
if (yesInput) yesInput.checked = false;
|
|
563
|
+
await setMode(kind === 'read' ? 'plan' : 'agent');
|
|
564
|
+
}
|
|
565
|
+
renderModePill();
|
|
566
|
+
});
|
|
567
|
+
document.addEventListener('click', (event) => {
|
|
568
|
+
if (modeMenu && !modeMenu.hidden && !event.target.closest('.ctrl-wrap')) closeModeMenu();
|
|
569
|
+
});
|
|
570
|
+
document.addEventListener('keydown', (event) => {
|
|
571
|
+
if (event.key === 'Escape') closeModeMenu();
|
|
572
|
+
});
|
|
573
|
+
modelPill?.addEventListener('click', () => openSettingsModal());
|
|
574
|
+
yesInput?.addEventListener('change', renderModePill);
|
|
575
|
+
|
|
507
576
|
function updateStatus(extra = {}) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
$('#sb-provider').textContent = p ? `${p.id}` : state.active.provider || '—';
|
|
511
|
-
$('#sb-model').textContent = state.active.model || '—';
|
|
577
|
+
renderModePill();
|
|
578
|
+
renderModelPill();
|
|
512
579
|
if (extra.rounds !== undefined) $('#sb-rounds').textContent = `${extra.rounds} rondas`;
|
|
513
580
|
if (extra.stopReason) $('#sb-rounds').textContent = extra.stopReason;
|
|
514
581
|
if (extra.running) $('#sb-rounds').textContent = 'pensando…';
|
|
@@ -1464,8 +1531,35 @@ function userImages(content) {
|
|
|
1464
1531
|
.filter(Boolean);
|
|
1465
1532
|
}
|
|
1466
1533
|
|
|
1534
|
+
function advisorGlyph(kind) {
|
|
1535
|
+
if (kind === 'code') return '⚠';
|
|
1536
|
+
if (kind === 'explain') return 'ℹ';
|
|
1537
|
+
return '✦';
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** Tarjeta del asesor: una capa de comentario, no un mensaje del agente. */
|
|
1541
|
+
function addAdvisorCard(container, { text, kind } = {}) {
|
|
1542
|
+
const clean = String(text ?? '').trim();
|
|
1543
|
+
if (!clean || !container) return null;
|
|
1544
|
+
const exists = [...container.querySelectorAll('.advisor-text')].some((node) => node.textContent === clean);
|
|
1545
|
+
if (exists) return null;
|
|
1546
|
+
const card = el('div', `advisor-card${kind ? ` advisor-${kind}` : ''}`);
|
|
1547
|
+
const head = el('div', 'advisor-head');
|
|
1548
|
+
head.append(el('span', 'advisor-glyph', advisorGlyph(kind)), el('span', 'advisor-title', 'asesor'));
|
|
1549
|
+
card.append(head, el('div', 'advisor-text', clean));
|
|
1550
|
+
container.appendChild(card);
|
|
1551
|
+
return card;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1467
1554
|
function renderStored(message, pane) {
|
|
1468
1555
|
if (message.agent_id) return;
|
|
1556
|
+
if (message.role === 'assistant' && message.name === 'advisor') {
|
|
1557
|
+
addAdvisorCard(pane, {
|
|
1558
|
+
text: typeof message.content === 'string' ? message.content : '',
|
|
1559
|
+
kind: message.mode,
|
|
1560
|
+
});
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1469
1563
|
if (message.role === 'user') {
|
|
1470
1564
|
const bubble = addMessage('user', userText(message.content), pane);
|
|
1471
1565
|
if (message.id) {
|
|
@@ -1787,8 +1881,9 @@ function activateSession(id) {
|
|
|
1787
1881
|
|
|
1788
1882
|
function updateSubmitLabel(working) {
|
|
1789
1883
|
if (!submitBtn) return;
|
|
1790
|
-
submitBtn.
|
|
1791
|
-
submitBtn.
|
|
1884
|
+
submitBtn.dataset.working = working ? '1' : '0';
|
|
1885
|
+
submitBtn.setAttribute('aria-label', working ? 'encolar' : 'enviar');
|
|
1886
|
+
submitBtn.title = working ? 'El agente está trabajando: se envía al terminar' : 'enviar';
|
|
1792
1887
|
}
|
|
1793
1888
|
|
|
1794
1889
|
function auditStatus(record) {
|
|
@@ -2006,6 +2101,11 @@ function addContinueButton(turn, data) {
|
|
|
2006
2101
|
function handleChatEvent(event, data, turn) {
|
|
2007
2102
|
if (handleAgentEvent(event, data, turn)) return;
|
|
2008
2103
|
narrar(event, data, turn);
|
|
2104
|
+
if (event === 'advice') {
|
|
2105
|
+
addAdvisorCard(turn.pane, { text: data?.text, kind: data?.kind });
|
|
2106
|
+
scrollMessages(turn.pane);
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2009
2109
|
if (event === 'agent_start') {
|
|
2010
2110
|
turn.agents.set(data.agentId, addAgentCard(turn.assistant(), data));
|
|
2011
2111
|
} else if (event === 'agent_end') {
|
|
@@ -3622,7 +3722,15 @@ const vozRateEl = $('#voz-rate');
|
|
|
3622
3722
|
const vozAnnStartEl = $('#voz-ann-start');
|
|
3623
3723
|
const vozAnnToolsEl = $('#voz-ann-tools');
|
|
3624
3724
|
const vozAnnEndEl = $('#voz-ann-end');
|
|
3725
|
+
const vozAnnAdviceEl = $('#voz-ann-advice');
|
|
3726
|
+
const vozAnnAnswerEl = $('#voz-ann-answer');
|
|
3625
3727
|
const vozFfmpegEl = $('#voz-ffmpeg');
|
|
3728
|
+
const asesorEnabledEl = $('#asesor-enabled');
|
|
3729
|
+
const asesorCodeEl = $('#asesor-code');
|
|
3730
|
+
const asesorExplainEl = $('#asesor-explain');
|
|
3731
|
+
const asesorAdviceEl = $('#asesor-advice');
|
|
3732
|
+
const asesorWordsEl = $('#asesor-words');
|
|
3733
|
+
const asesorNotesEl = $('#asesor-notes');
|
|
3626
3734
|
const trProviderEl = $('#tr-provider');
|
|
3627
3735
|
const trModelEl = $('#tr-model');
|
|
3628
3736
|
|
|
@@ -3955,7 +4063,7 @@ const voz = {
|
|
|
3955
4063
|
enabled: false,
|
|
3956
4064
|
voice: 'es-AR-ElenaNeural',
|
|
3957
4065
|
rate: '+0%',
|
|
3958
|
-
announce: { start: true, tools: true, end: true },
|
|
4066
|
+
announce: { start: true, tools: true, end: true, advice: true, answer: false },
|
|
3959
4067
|
},
|
|
3960
4068
|
};
|
|
3961
4069
|
|
|
@@ -4086,10 +4194,10 @@ function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
|
|
|
4086
4194
|
* Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
|
|
4087
4195
|
* cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
|
|
4088
4196
|
*/
|
|
4089
|
-
function hablar(texto) {
|
|
4197
|
+
function hablar(texto, { label = 'narrando', maxAgeMs = 45000 } = {}) {
|
|
4090
4198
|
const frase = String(texto ?? '').trim();
|
|
4091
4199
|
if (!frase || !voz.settings.enabled) return;
|
|
4092
|
-
reproducirVoz(frase, { label
|
|
4200
|
+
reproducirVoz(frase, { label, maxAgeMs }).catch(() => {});
|
|
4093
4201
|
}
|
|
4094
4202
|
|
|
4095
4203
|
/** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
|
|
@@ -4099,7 +4207,15 @@ function narrar(event, data, turn) {
|
|
|
4099
4207
|
if (!enabled) return;
|
|
4100
4208
|
if (event === 'start' && announce.start) hablar(data?.say);
|
|
4101
4209
|
else if (event === 'tool_call' && announce.tools) hablar(data?.say);
|
|
4102
|
-
|
|
4210
|
+
// El asesor y la respuesta final son el cierre del turno: si quedan en cola
|
|
4211
|
+
// detras de un audio largo, igual hay que leerlos (sin vencimiento).
|
|
4212
|
+
else if (event === 'advice' && announce.advice) hablar(data?.say, { label: 'asesor', maxAgeMs: 0 });
|
|
4213
|
+
else if (event === 'done' && announce.answer) {
|
|
4214
|
+
// Lee la respuesta final limpia; si era solo codigo, cae al cierre.
|
|
4215
|
+
const spoken = speechText(data?.content);
|
|
4216
|
+
if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0 });
|
|
4217
|
+
else if (announce.end) hablar(data?.say);
|
|
4218
|
+
} else if ((event === 'done' || event === 'aborted' || event === 'error') && announce.end) hablar(data?.say);
|
|
4103
4219
|
}
|
|
4104
4220
|
|
|
4105
4221
|
async function cargarVoces() {
|
|
@@ -4125,6 +4241,8 @@ async function aplicarVoz(data) {
|
|
|
4125
4241
|
start: data.announce?.start !== false,
|
|
4126
4242
|
tools: data.announce?.tools !== false,
|
|
4127
4243
|
end: data.announce?.end !== false,
|
|
4244
|
+
advice: data.announce?.advice !== false,
|
|
4245
|
+
answer: data.announce?.answer === true,
|
|
4128
4246
|
},
|
|
4129
4247
|
};
|
|
4130
4248
|
if (vozEnabledEl) vozEnabledEl.checked = voz.settings.enabled;
|
|
@@ -4142,6 +4260,8 @@ async function aplicarVoz(data) {
|
|
|
4142
4260
|
if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
|
|
4143
4261
|
if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
|
|
4144
4262
|
if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
|
|
4263
|
+
if (vozAnnAdviceEl) vozAnnAdviceEl.checked = voz.settings.announce.advice;
|
|
4264
|
+
if (vozAnnAnswerEl) vozAnnAnswerEl.checked = voz.settings.announce.answer;
|
|
4145
4265
|
const estado = $('#voz-status');
|
|
4146
4266
|
if (estado) {
|
|
4147
4267
|
estado.className = `summary${voz.settings.enabled ? ' ok' : ''}`;
|
|
@@ -4165,6 +4285,8 @@ async function submitVoz() {
|
|
|
4165
4285
|
start: vozAnnStartEl?.checked !== false,
|
|
4166
4286
|
tools: vozAnnToolsEl?.checked !== false,
|
|
4167
4287
|
end: vozAnnEndEl?.checked !== false,
|
|
4288
|
+
advice: vozAnnAdviceEl?.checked !== false,
|
|
4289
|
+
answer: vozAnnAnswerEl?.checked === true,
|
|
4168
4290
|
},
|
|
4169
4291
|
};
|
|
4170
4292
|
const data = await api('/api/tts/settings', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
|
|
@@ -4342,6 +4464,57 @@ async function testVoz() {
|
|
|
4342
4464
|
}
|
|
4343
4465
|
}
|
|
4344
4466
|
|
|
4467
|
+
/* --- ajustes: asesor ------------------------------------------------------- */
|
|
4468
|
+
|
|
4469
|
+
function aplicarAsesor(data) {
|
|
4470
|
+
if (!data) return;
|
|
4471
|
+
if (asesorEnabledEl) asesorEnabledEl.checked = data.enabled === true;
|
|
4472
|
+
if (asesorCodeEl) asesorCodeEl.checked = data.code !== false;
|
|
4473
|
+
if (asesorExplainEl) asesorExplainEl.checked = data.explain !== false;
|
|
4474
|
+
if (asesorAdviceEl) asesorAdviceEl.checked = data.advice !== false;
|
|
4475
|
+
if (asesorWordsEl) asesorWordsEl.value = data.maxWords ?? 40;
|
|
4476
|
+
if (asesorNotesEl) asesorNotesEl.value = data.notes ?? '';
|
|
4477
|
+
const estado = $('#asesor-status');
|
|
4478
|
+
if (estado) {
|
|
4479
|
+
estado.className = `summary${data.enabled ? ' ok' : ''}`;
|
|
4480
|
+
estado.textContent = data.enabled ? 'activo' : 'apagado';
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
async function refreshAsesor() {
|
|
4485
|
+
if (!asesorEnabledEl) return;
|
|
4486
|
+
const data = await api('/api/advisor/settings').catch(() => null);
|
|
4487
|
+
if (data) aplicarAsesor(data);
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4490
|
+
function asesorBody() {
|
|
4491
|
+
return {
|
|
4492
|
+
enabled: asesorEnabledEl?.checked === true,
|
|
4493
|
+
code: asesorCodeEl?.checked !== false,
|
|
4494
|
+
explain: asesorExplainEl?.checked !== false,
|
|
4495
|
+
advice: asesorAdviceEl?.checked !== false,
|
|
4496
|
+
maxWords: Number(asesorWordsEl?.value) || 40,
|
|
4497
|
+
notes: asesorNotesEl?.value ?? '',
|
|
4498
|
+
};
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
async function submitAsesor() {
|
|
4502
|
+
setStatus('#asesor-msg', 'guardando…');
|
|
4503
|
+
const data = await api('/api/advisor/settings', { method: 'PUT', body: asesorBody() }).catch((e) => ({
|
|
4504
|
+
error: e.message,
|
|
4505
|
+
}));
|
|
4506
|
+
if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
|
|
4507
|
+
aplicarAsesor(data);
|
|
4508
|
+
setStatus('#asesor-msg', 'guardado', 'ok');
|
|
4509
|
+
}
|
|
4510
|
+
|
|
4511
|
+
async function testAsesor() {
|
|
4512
|
+
setStatus('#asesor-msg', 'consultando…');
|
|
4513
|
+
const data = await api('/api/advisor/test', { method: 'POST' }).catch((e) => ({ error: e.message }));
|
|
4514
|
+
if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
|
|
4515
|
+
setStatus('#asesor-msg', data.speak ? data.text : '(no diría nada en este caso)', 'ok');
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4345
4518
|
/* --- ajustes: perfil del usuario ------------------------------------------ */
|
|
4346
4519
|
|
|
4347
4520
|
const perfil = {
|
|
@@ -4474,8 +4647,10 @@ if (mcpListEl) {
|
|
|
4474
4647
|
$('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
|
|
4475
4648
|
$('#tg-save')?.addEventListener('click', submitChannel);
|
|
4476
4649
|
$('#tg-test')?.addEventListener('click', testChannel);
|
|
4477
|
-
|
|
4478
|
-
|
|
4650
|
+
$('#voz-save')?.addEventListener('click', submitVoz);
|
|
4651
|
+
$('#voz-test')?.addEventListener('click', testVoz);
|
|
4652
|
+
$('#asesor-save')?.addEventListener('click', submitAsesor);
|
|
4653
|
+
$('#asesor-test')?.addEventListener('click', testAsesor);
|
|
4479
4654
|
$('#mcp-close').addEventListener('click', closeMcpForm);
|
|
4480
4655
|
$('#mf-cancel').addEventListener('click', closeMcpForm);
|
|
4481
4656
|
$('#mf-transport').addEventListener('change', syncMcpFormFields);
|
|
@@ -5312,10 +5487,45 @@ function applyState(diff) {
|
|
|
5312
5487
|
|
|
5313
5488
|
/* --- señales de alerta: que se note aun mirando otra cosa ----------------- */
|
|
5314
5489
|
|
|
5315
|
-
const FAVICON_IDLE =
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5490
|
+
const FAVICON_IDLE = '/favicon.ico';
|
|
5491
|
+
const FAVICON_SOURCE = '/icons/icon-192x192.png';
|
|
5492
|
+
let faviconAlert = '';
|
|
5493
|
+
let faviconAlertPending = null;
|
|
5494
|
+
|
|
5495
|
+
/**
|
|
5496
|
+
* El favicon de alerta se dibuja sobre una copia del logo (mismo origen), para
|
|
5497
|
+
* que el punto rojo combine con el icono real y no con un SVG desincronizado.
|
|
5498
|
+
*/
|
|
5499
|
+
function alertFavicon() {
|
|
5500
|
+
if (faviconAlert) return Promise.resolve(faviconAlert);
|
|
5501
|
+
if (faviconAlertPending) return faviconAlertPending;
|
|
5502
|
+
faviconAlertPending = new Promise((resolve) => {
|
|
5503
|
+
const img = new Image();
|
|
5504
|
+
img.onload = () => {
|
|
5505
|
+
try {
|
|
5506
|
+
const canvas = document.createElement('canvas');
|
|
5507
|
+
canvas.width = 64;
|
|
5508
|
+
canvas.height = 64;
|
|
5509
|
+
const ctx = canvas.getContext('2d');
|
|
5510
|
+
ctx.drawImage(img, 0, 0, 64, 64);
|
|
5511
|
+
ctx.beginPath();
|
|
5512
|
+
ctx.arc(64 - 16, 16, 13, 0, Math.PI * 2);
|
|
5513
|
+
ctx.fillStyle = '#d9704a';
|
|
5514
|
+
ctx.fill();
|
|
5515
|
+
ctx.lineWidth = 3;
|
|
5516
|
+
ctx.strokeStyle = 'rgba(35,26,23,0.9)';
|
|
5517
|
+
ctx.stroke();
|
|
5518
|
+
faviconAlert = canvas.toDataURL('image/png');
|
|
5519
|
+
resolve(faviconAlert);
|
|
5520
|
+
} catch {
|
|
5521
|
+
resolve(FAVICON_IDLE);
|
|
5522
|
+
}
|
|
5523
|
+
};
|
|
5524
|
+
img.onerror = () => resolve(FAVICON_IDLE);
|
|
5525
|
+
img.src = FAVICON_SOURCE;
|
|
5526
|
+
});
|
|
5527
|
+
return faviconAlertPending;
|
|
5528
|
+
}
|
|
5319
5529
|
|
|
5320
5530
|
let baseTitle = '';
|
|
5321
5531
|
|
|
@@ -5330,7 +5540,10 @@ function renderAlertSignals() {
|
|
|
5330
5540
|
const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
|
|
5331
5541
|
document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
|
|
5332
5542
|
const link = document.querySelector('link[rel="icon"]');
|
|
5333
|
-
if (link)
|
|
5543
|
+
if (link) {
|
|
5544
|
+
if (pending > 0) alertFavicon().then((href) => { link.href = href; });
|
|
5545
|
+
else link.href = FAVICON_IDLE;
|
|
5546
|
+
}
|
|
5334
5547
|
}
|
|
5335
5548
|
|
|
5336
5549
|
/* --- canal global: solo latencia ------------------------------------------ */
|
|
@@ -5436,6 +5649,105 @@ function watchLifecycle() {
|
|
|
5436
5649
|
window.addEventListener('pageshow', wake);
|
|
5437
5650
|
window.addEventListener('online', wake);
|
|
5438
5651
|
window.addEventListener('focus', wake);
|
|
5652
|
+
// La cache del server dura 10 min: revisar al volver a la pestaña es barato.
|
|
5653
|
+
refreshUpdate();
|
|
5654
|
+
}
|
|
5655
|
+
|
|
5656
|
+
/* --- actualizacion de version --------------------------------------------- */
|
|
5657
|
+
|
|
5658
|
+
const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
|
|
5659
|
+
const updateBanner = $('#update-banner');
|
|
5660
|
+
const updateBannerText = $('#update-banner-text');
|
|
5661
|
+
let updateInfo = null;
|
|
5662
|
+
|
|
5663
|
+
function showUpdateBanner(info, { force = false } = {}) {
|
|
5664
|
+
if (!updateBanner || !info?.updateAvailable) return;
|
|
5665
|
+
if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
|
|
5666
|
+
updateInfo = info;
|
|
5667
|
+
updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
|
|
5668
|
+
updateBanner.hidden = false;
|
|
5669
|
+
const ver = document.querySelector('.brand .ver');
|
|
5670
|
+
if (ver) {
|
|
5671
|
+
ver.classList.add('has-update');
|
|
5672
|
+
ver.title = `Actualización disponible: v${info.latest}`;
|
|
5673
|
+
}
|
|
5674
|
+
}
|
|
5675
|
+
|
|
5676
|
+
async function refreshUpdate() {
|
|
5677
|
+
if (!updateBanner) return;
|
|
5678
|
+
const info = await api('/api/update').catch(() => null);
|
|
5679
|
+
if (info?.updateAvailable) showUpdateBanner(info);
|
|
5680
|
+
}
|
|
5681
|
+
|
|
5682
|
+
function dismissUpdate() {
|
|
5683
|
+
if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
|
|
5684
|
+
if (updateBanner) updateBanner.hidden = true;
|
|
5685
|
+
}
|
|
5686
|
+
|
|
5687
|
+
/** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
|
|
5688
|
+
async function forceUpdateCheck() {
|
|
5689
|
+
const ver = document.querySelector('.brand .ver');
|
|
5690
|
+
const original = ver?.textContent;
|
|
5691
|
+
if (ver) ver.textContent = '…';
|
|
5692
|
+
const info = await api('/api/update?force=1').catch(() => null);
|
|
5693
|
+
if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
|
|
5694
|
+
if (!info) return toast('No pude consultar la versión.');
|
|
5695
|
+
if (info.updateAvailable) {
|
|
5696
|
+
showUpdateBanner(info, { force: true });
|
|
5697
|
+
toast(`Hay una versión nueva: v${info.latest}`);
|
|
5698
|
+
} else if (info.error) {
|
|
5699
|
+
toast(`No pude consultar npm: ${info.error}`);
|
|
5700
|
+
} else {
|
|
5701
|
+
toast(`Estás al día (v${info.current}).`);
|
|
5702
|
+
}
|
|
5703
|
+
}
|
|
5704
|
+
|
|
5705
|
+
/** Espera a que el server reiniciado vuelva a responder `/api/health`. */
|
|
5706
|
+
async function waitServerVersion(tries = 45) {
|
|
5707
|
+
for (let i = 0; i < tries; i++) {
|
|
5708
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
5709
|
+
const health = await fetch('/api/health', { cache: 'no-store' })
|
|
5710
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
5711
|
+
.catch(() => null);
|
|
5712
|
+
if (health?.version) return health.version;
|
|
5713
|
+
}
|
|
5714
|
+
return '';
|
|
5715
|
+
}
|
|
5716
|
+
|
|
5717
|
+
async function runUpdate() {
|
|
5718
|
+
if (!updateInfo) return;
|
|
5719
|
+
if (!confirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`)) return;
|
|
5720
|
+
const runBtn = $('#update-run');
|
|
5721
|
+
const laterBtn = $('#update-later');
|
|
5722
|
+
if (runBtn) runBtn.disabled = true;
|
|
5723
|
+
if (laterBtn) laterBtn.disabled = true;
|
|
5724
|
+
updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
|
|
5725
|
+
const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
|
|
5726
|
+
if (res?.error) {
|
|
5727
|
+
updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
|
|
5728
|
+
if (runBtn) runBtn.disabled = false;
|
|
5729
|
+
if (laterBtn) laterBtn.disabled = false;
|
|
5730
|
+
return;
|
|
5731
|
+
}
|
|
5732
|
+
updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
|
|
5733
|
+
const version = await waitServerVersion();
|
|
5734
|
+
if (version) {
|
|
5735
|
+
updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
|
|
5736
|
+
setTimeout(() => location.reload(), 900);
|
|
5737
|
+
} else {
|
|
5738
|
+
updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
|
|
5739
|
+
if (runBtn) runBtn.disabled = false;
|
|
5740
|
+
if (laterBtn) laterBtn.disabled = false;
|
|
5741
|
+
}
|
|
5742
|
+
}
|
|
5743
|
+
|
|
5744
|
+
$('#update-run')?.addEventListener('click', runUpdate);
|
|
5745
|
+
$('#update-later')?.addEventListener('click', dismissUpdate);
|
|
5746
|
+
const brandVerEl = document.querySelector('.brand .ver');
|
|
5747
|
+
if (brandVerEl) {
|
|
5748
|
+
brandVerEl.title = 'buscar actualización';
|
|
5749
|
+
brandVerEl.classList.add('ver-action');
|
|
5750
|
+
brandVerEl.addEventListener('click', forceUpdateCheck);
|
|
5439
5751
|
}
|
|
5440
5752
|
|
|
5441
5753
|
/* --- boot ---------------------------------------------------------------- */
|
|
@@ -5459,6 +5771,7 @@ async function boot() {
|
|
|
5459
5771
|
const ver = document.querySelector('.brand .ver');
|
|
5460
5772
|
if (ver) ver.textContent = `v${health.version}`;
|
|
5461
5773
|
}
|
|
5774
|
+
refreshUpdate();
|
|
5462
5775
|
await refreshProviders();
|
|
5463
5776
|
await loadModes();
|
|
5464
5777
|
await loadModels();
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|