@danieltmn/tamnora 0.9.4 → 0.9.5
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 +24 -0
- package/README.md +25 -10
- 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 +215 -8
- package/harness/packages/server/public/index.html +57 -3
- package/harness/packages/server/public/markdown.js +54 -0
- package/harness/packages/server/public/styles.css +37 -0
- 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/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';
|
|
@@ -217,7 +217,7 @@ function activateTab(name) {
|
|
|
217
217
|
hideMoreMenu();
|
|
218
218
|
if (name === 'runs') refreshRuns();
|
|
219
219
|
if (name === 'providers') refreshProviders();
|
|
220
|
-
if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshTranscribe(); refreshProfile(); }
|
|
220
|
+
if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
document.querySelectorAll('.tab[data-tab]').forEach((t) =>
|
|
@@ -1464,8 +1464,35 @@ function userImages(content) {
|
|
|
1464
1464
|
.filter(Boolean);
|
|
1465
1465
|
}
|
|
1466
1466
|
|
|
1467
|
+
function advisorGlyph(kind) {
|
|
1468
|
+
if (kind === 'code') return '⚠';
|
|
1469
|
+
if (kind === 'explain') return 'ℹ';
|
|
1470
|
+
return '✦';
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Tarjeta del asesor: una capa de comentario, no un mensaje del agente. */
|
|
1474
|
+
function addAdvisorCard(container, { text, kind } = {}) {
|
|
1475
|
+
const clean = String(text ?? '').trim();
|
|
1476
|
+
if (!clean || !container) return null;
|
|
1477
|
+
const exists = [...container.querySelectorAll('.advisor-text')].some((node) => node.textContent === clean);
|
|
1478
|
+
if (exists) return null;
|
|
1479
|
+
const card = el('div', `advisor-card${kind ? ` advisor-${kind}` : ''}`);
|
|
1480
|
+
const head = el('div', 'advisor-head');
|
|
1481
|
+
head.append(el('span', 'advisor-glyph', advisorGlyph(kind)), el('span', 'advisor-title', 'asesor'));
|
|
1482
|
+
card.append(head, el('div', 'advisor-text', clean));
|
|
1483
|
+
container.appendChild(card);
|
|
1484
|
+
return card;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1467
1487
|
function renderStored(message, pane) {
|
|
1468
1488
|
if (message.agent_id) return;
|
|
1489
|
+
if (message.role === 'assistant' && message.name === 'advisor') {
|
|
1490
|
+
addAdvisorCard(pane, {
|
|
1491
|
+
text: typeof message.content === 'string' ? message.content : '',
|
|
1492
|
+
kind: message.mode,
|
|
1493
|
+
});
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1469
1496
|
if (message.role === 'user') {
|
|
1470
1497
|
const bubble = addMessage('user', userText(message.content), pane);
|
|
1471
1498
|
if (message.id) {
|
|
@@ -2006,6 +2033,11 @@ function addContinueButton(turn, data) {
|
|
|
2006
2033
|
function handleChatEvent(event, data, turn) {
|
|
2007
2034
|
if (handleAgentEvent(event, data, turn)) return;
|
|
2008
2035
|
narrar(event, data, turn);
|
|
2036
|
+
if (event === 'advice') {
|
|
2037
|
+
addAdvisorCard(turn.pane, { text: data?.text, kind: data?.kind });
|
|
2038
|
+
scrollMessages(turn.pane);
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2009
2041
|
if (event === 'agent_start') {
|
|
2010
2042
|
turn.agents.set(data.agentId, addAgentCard(turn.assistant(), data));
|
|
2011
2043
|
} else if (event === 'agent_end') {
|
|
@@ -3622,7 +3654,15 @@ const vozRateEl = $('#voz-rate');
|
|
|
3622
3654
|
const vozAnnStartEl = $('#voz-ann-start');
|
|
3623
3655
|
const vozAnnToolsEl = $('#voz-ann-tools');
|
|
3624
3656
|
const vozAnnEndEl = $('#voz-ann-end');
|
|
3657
|
+
const vozAnnAdviceEl = $('#voz-ann-advice');
|
|
3658
|
+
const vozAnnAnswerEl = $('#voz-ann-answer');
|
|
3625
3659
|
const vozFfmpegEl = $('#voz-ffmpeg');
|
|
3660
|
+
const asesorEnabledEl = $('#asesor-enabled');
|
|
3661
|
+
const asesorCodeEl = $('#asesor-code');
|
|
3662
|
+
const asesorExplainEl = $('#asesor-explain');
|
|
3663
|
+
const asesorAdviceEl = $('#asesor-advice');
|
|
3664
|
+
const asesorWordsEl = $('#asesor-words');
|
|
3665
|
+
const asesorNotesEl = $('#asesor-notes');
|
|
3626
3666
|
const trProviderEl = $('#tr-provider');
|
|
3627
3667
|
const trModelEl = $('#tr-model');
|
|
3628
3668
|
|
|
@@ -3955,7 +3995,7 @@ const voz = {
|
|
|
3955
3995
|
enabled: false,
|
|
3956
3996
|
voice: 'es-AR-ElenaNeural',
|
|
3957
3997
|
rate: '+0%',
|
|
3958
|
-
announce: { start: true, tools: true, end: true },
|
|
3998
|
+
announce: { start: true, tools: true, end: true, advice: true, answer: false },
|
|
3959
3999
|
},
|
|
3960
4000
|
};
|
|
3961
4001
|
|
|
@@ -4086,10 +4126,10 @@ function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
|
|
|
4086
4126
|
* Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
|
|
4087
4127
|
* cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
|
|
4088
4128
|
*/
|
|
4089
|
-
function hablar(texto) {
|
|
4129
|
+
function hablar(texto, { label = 'narrando', maxAgeMs = 45000 } = {}) {
|
|
4090
4130
|
const frase = String(texto ?? '').trim();
|
|
4091
4131
|
if (!frase || !voz.settings.enabled) return;
|
|
4092
|
-
reproducirVoz(frase, { label
|
|
4132
|
+
reproducirVoz(frase, { label, maxAgeMs }).catch(() => {});
|
|
4093
4133
|
}
|
|
4094
4134
|
|
|
4095
4135
|
/** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
|
|
@@ -4099,7 +4139,15 @@ function narrar(event, data, turn) {
|
|
|
4099
4139
|
if (!enabled) return;
|
|
4100
4140
|
if (event === 'start' && announce.start) hablar(data?.say);
|
|
4101
4141
|
else if (event === 'tool_call' && announce.tools) hablar(data?.say);
|
|
4102
|
-
|
|
4142
|
+
// El asesor y la respuesta final son el cierre del turno: si quedan en cola
|
|
4143
|
+
// detras de un audio largo, igual hay que leerlos (sin vencimiento).
|
|
4144
|
+
else if (event === 'advice' && announce.advice) hablar(data?.say, { label: 'asesor', maxAgeMs: 0 });
|
|
4145
|
+
else if (event === 'done' && announce.answer) {
|
|
4146
|
+
// Lee la respuesta final limpia; si era solo codigo, cae al cierre.
|
|
4147
|
+
const spoken = speechText(data?.content);
|
|
4148
|
+
if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0 });
|
|
4149
|
+
else if (announce.end) hablar(data?.say);
|
|
4150
|
+
} else if ((event === 'done' || event === 'aborted' || event === 'error') && announce.end) hablar(data?.say);
|
|
4103
4151
|
}
|
|
4104
4152
|
|
|
4105
4153
|
async function cargarVoces() {
|
|
@@ -4125,6 +4173,8 @@ async function aplicarVoz(data) {
|
|
|
4125
4173
|
start: data.announce?.start !== false,
|
|
4126
4174
|
tools: data.announce?.tools !== false,
|
|
4127
4175
|
end: data.announce?.end !== false,
|
|
4176
|
+
advice: data.announce?.advice !== false,
|
|
4177
|
+
answer: data.announce?.answer === true,
|
|
4128
4178
|
},
|
|
4129
4179
|
};
|
|
4130
4180
|
if (vozEnabledEl) vozEnabledEl.checked = voz.settings.enabled;
|
|
@@ -4142,6 +4192,8 @@ async function aplicarVoz(data) {
|
|
|
4142
4192
|
if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
|
|
4143
4193
|
if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
|
|
4144
4194
|
if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
|
|
4195
|
+
if (vozAnnAdviceEl) vozAnnAdviceEl.checked = voz.settings.announce.advice;
|
|
4196
|
+
if (vozAnnAnswerEl) vozAnnAnswerEl.checked = voz.settings.announce.answer;
|
|
4145
4197
|
const estado = $('#voz-status');
|
|
4146
4198
|
if (estado) {
|
|
4147
4199
|
estado.className = `summary${voz.settings.enabled ? ' ok' : ''}`;
|
|
@@ -4165,6 +4217,8 @@ async function submitVoz() {
|
|
|
4165
4217
|
start: vozAnnStartEl?.checked !== false,
|
|
4166
4218
|
tools: vozAnnToolsEl?.checked !== false,
|
|
4167
4219
|
end: vozAnnEndEl?.checked !== false,
|
|
4220
|
+
advice: vozAnnAdviceEl?.checked !== false,
|
|
4221
|
+
answer: vozAnnAnswerEl?.checked === true,
|
|
4168
4222
|
},
|
|
4169
4223
|
};
|
|
4170
4224
|
const data = await api('/api/tts/settings', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
|
|
@@ -4342,6 +4396,57 @@ async function testVoz() {
|
|
|
4342
4396
|
}
|
|
4343
4397
|
}
|
|
4344
4398
|
|
|
4399
|
+
/* --- ajustes: asesor ------------------------------------------------------- */
|
|
4400
|
+
|
|
4401
|
+
function aplicarAsesor(data) {
|
|
4402
|
+
if (!data) return;
|
|
4403
|
+
if (asesorEnabledEl) asesorEnabledEl.checked = data.enabled === true;
|
|
4404
|
+
if (asesorCodeEl) asesorCodeEl.checked = data.code !== false;
|
|
4405
|
+
if (asesorExplainEl) asesorExplainEl.checked = data.explain !== false;
|
|
4406
|
+
if (asesorAdviceEl) asesorAdviceEl.checked = data.advice !== false;
|
|
4407
|
+
if (asesorWordsEl) asesorWordsEl.value = data.maxWords ?? 40;
|
|
4408
|
+
if (asesorNotesEl) asesorNotesEl.value = data.notes ?? '';
|
|
4409
|
+
const estado = $('#asesor-status');
|
|
4410
|
+
if (estado) {
|
|
4411
|
+
estado.className = `summary${data.enabled ? ' ok' : ''}`;
|
|
4412
|
+
estado.textContent = data.enabled ? 'activo' : 'apagado';
|
|
4413
|
+
}
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
async function refreshAsesor() {
|
|
4417
|
+
if (!asesorEnabledEl) return;
|
|
4418
|
+
const data = await api('/api/advisor/settings').catch(() => null);
|
|
4419
|
+
if (data) aplicarAsesor(data);
|
|
4420
|
+
}
|
|
4421
|
+
|
|
4422
|
+
function asesorBody() {
|
|
4423
|
+
return {
|
|
4424
|
+
enabled: asesorEnabledEl?.checked === true,
|
|
4425
|
+
code: asesorCodeEl?.checked !== false,
|
|
4426
|
+
explain: asesorExplainEl?.checked !== false,
|
|
4427
|
+
advice: asesorAdviceEl?.checked !== false,
|
|
4428
|
+
maxWords: Number(asesorWordsEl?.value) || 40,
|
|
4429
|
+
notes: asesorNotesEl?.value ?? '',
|
|
4430
|
+
};
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
async function submitAsesor() {
|
|
4434
|
+
setStatus('#asesor-msg', 'guardando…');
|
|
4435
|
+
const data = await api('/api/advisor/settings', { method: 'PUT', body: asesorBody() }).catch((e) => ({
|
|
4436
|
+
error: e.message,
|
|
4437
|
+
}));
|
|
4438
|
+
if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
|
|
4439
|
+
aplicarAsesor(data);
|
|
4440
|
+
setStatus('#asesor-msg', 'guardado', 'ok');
|
|
4441
|
+
}
|
|
4442
|
+
|
|
4443
|
+
async function testAsesor() {
|
|
4444
|
+
setStatus('#asesor-msg', 'consultando…');
|
|
4445
|
+
const data = await api('/api/advisor/test', { method: 'POST' }).catch((e) => ({ error: e.message }));
|
|
4446
|
+
if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
|
|
4447
|
+
setStatus('#asesor-msg', data.speak ? data.text : '(no diría nada en este caso)', 'ok');
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4345
4450
|
/* --- ajustes: perfil del usuario ------------------------------------------ */
|
|
4346
4451
|
|
|
4347
4452
|
const perfil = {
|
|
@@ -4474,8 +4579,10 @@ if (mcpListEl) {
|
|
|
4474
4579
|
$('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
|
|
4475
4580
|
$('#tg-save')?.addEventListener('click', submitChannel);
|
|
4476
4581
|
$('#tg-test')?.addEventListener('click', testChannel);
|
|
4477
|
-
|
|
4478
|
-
|
|
4582
|
+
$('#voz-save')?.addEventListener('click', submitVoz);
|
|
4583
|
+
$('#voz-test')?.addEventListener('click', testVoz);
|
|
4584
|
+
$('#asesor-save')?.addEventListener('click', submitAsesor);
|
|
4585
|
+
$('#asesor-test')?.addEventListener('click', testAsesor);
|
|
4479
4586
|
$('#mcp-close').addEventListener('click', closeMcpForm);
|
|
4480
4587
|
$('#mf-cancel').addEventListener('click', closeMcpForm);
|
|
4481
4588
|
$('#mf-transport').addEventListener('change', syncMcpFormFields);
|
|
@@ -5436,6 +5543,105 @@ function watchLifecycle() {
|
|
|
5436
5543
|
window.addEventListener('pageshow', wake);
|
|
5437
5544
|
window.addEventListener('online', wake);
|
|
5438
5545
|
window.addEventListener('focus', wake);
|
|
5546
|
+
// La cache del server dura 10 min: revisar al volver a la pestaña es barato.
|
|
5547
|
+
refreshUpdate();
|
|
5548
|
+
}
|
|
5549
|
+
|
|
5550
|
+
/* --- actualizacion de version --------------------------------------------- */
|
|
5551
|
+
|
|
5552
|
+
const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
|
|
5553
|
+
const updateBanner = $('#update-banner');
|
|
5554
|
+
const updateBannerText = $('#update-banner-text');
|
|
5555
|
+
let updateInfo = null;
|
|
5556
|
+
|
|
5557
|
+
function showUpdateBanner(info, { force = false } = {}) {
|
|
5558
|
+
if (!updateBanner || !info?.updateAvailable) return;
|
|
5559
|
+
if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
|
|
5560
|
+
updateInfo = info;
|
|
5561
|
+
updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
|
|
5562
|
+
updateBanner.hidden = false;
|
|
5563
|
+
const ver = document.querySelector('.brand .ver');
|
|
5564
|
+
if (ver) {
|
|
5565
|
+
ver.classList.add('has-update');
|
|
5566
|
+
ver.title = `Actualización disponible: v${info.latest}`;
|
|
5567
|
+
}
|
|
5568
|
+
}
|
|
5569
|
+
|
|
5570
|
+
async function refreshUpdate() {
|
|
5571
|
+
if (!updateBanner) return;
|
|
5572
|
+
const info = await api('/api/update').catch(() => null);
|
|
5573
|
+
if (info?.updateAvailable) showUpdateBanner(info);
|
|
5574
|
+
}
|
|
5575
|
+
|
|
5576
|
+
function dismissUpdate() {
|
|
5577
|
+
if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
|
|
5578
|
+
if (updateBanner) updateBanner.hidden = true;
|
|
5579
|
+
}
|
|
5580
|
+
|
|
5581
|
+
/** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
|
|
5582
|
+
async function forceUpdateCheck() {
|
|
5583
|
+
const ver = document.querySelector('.brand .ver');
|
|
5584
|
+
const original = ver?.textContent;
|
|
5585
|
+
if (ver) ver.textContent = '…';
|
|
5586
|
+
const info = await api('/api/update?force=1').catch(() => null);
|
|
5587
|
+
if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
|
|
5588
|
+
if (!info) return toast('No pude consultar la versión.');
|
|
5589
|
+
if (info.updateAvailable) {
|
|
5590
|
+
showUpdateBanner(info, { force: true });
|
|
5591
|
+
toast(`Hay una versión nueva: v${info.latest}`);
|
|
5592
|
+
} else if (info.error) {
|
|
5593
|
+
toast(`No pude consultar npm: ${info.error}`);
|
|
5594
|
+
} else {
|
|
5595
|
+
toast(`Estás al día (v${info.current}).`);
|
|
5596
|
+
}
|
|
5597
|
+
}
|
|
5598
|
+
|
|
5599
|
+
/** Espera a que el server reiniciado vuelva a responder `/api/health`. */
|
|
5600
|
+
async function waitServerVersion(tries = 45) {
|
|
5601
|
+
for (let i = 0; i < tries; i++) {
|
|
5602
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
5603
|
+
const health = await fetch('/api/health', { cache: 'no-store' })
|
|
5604
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
5605
|
+
.catch(() => null);
|
|
5606
|
+
if (health?.version) return health.version;
|
|
5607
|
+
}
|
|
5608
|
+
return '';
|
|
5609
|
+
}
|
|
5610
|
+
|
|
5611
|
+
async function runUpdate() {
|
|
5612
|
+
if (!updateInfo) return;
|
|
5613
|
+
if (!confirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`)) return;
|
|
5614
|
+
const runBtn = $('#update-run');
|
|
5615
|
+
const laterBtn = $('#update-later');
|
|
5616
|
+
if (runBtn) runBtn.disabled = true;
|
|
5617
|
+
if (laterBtn) laterBtn.disabled = true;
|
|
5618
|
+
updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
|
|
5619
|
+
const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
|
|
5620
|
+
if (res?.error) {
|
|
5621
|
+
updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
|
|
5622
|
+
if (runBtn) runBtn.disabled = false;
|
|
5623
|
+
if (laterBtn) laterBtn.disabled = false;
|
|
5624
|
+
return;
|
|
5625
|
+
}
|
|
5626
|
+
updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
|
|
5627
|
+
const version = await waitServerVersion();
|
|
5628
|
+
if (version) {
|
|
5629
|
+
updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
|
|
5630
|
+
setTimeout(() => location.reload(), 900);
|
|
5631
|
+
} else {
|
|
5632
|
+
updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
|
|
5633
|
+
if (runBtn) runBtn.disabled = false;
|
|
5634
|
+
if (laterBtn) laterBtn.disabled = false;
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
|
|
5638
|
+
$('#update-run')?.addEventListener('click', runUpdate);
|
|
5639
|
+
$('#update-later')?.addEventListener('click', dismissUpdate);
|
|
5640
|
+
const brandVerEl = document.querySelector('.brand .ver');
|
|
5641
|
+
if (brandVerEl) {
|
|
5642
|
+
brandVerEl.title = 'buscar actualización';
|
|
5643
|
+
brandVerEl.classList.add('ver-action');
|
|
5644
|
+
brandVerEl.addEventListener('click', forceUpdateCheck);
|
|
5439
5645
|
}
|
|
5440
5646
|
|
|
5441
5647
|
/* --- boot ---------------------------------------------------------------- */
|
|
@@ -5459,6 +5665,7 @@ async function boot() {
|
|
|
5459
5665
|
const ver = document.querySelector('.brand .ver');
|
|
5460
5666
|
if (ver) ver.textContent = `v${health.version}`;
|
|
5461
5667
|
}
|
|
5668
|
+
refreshUpdate();
|
|
5462
5669
|
await refreshProviders();
|
|
5463
5670
|
await loadModes();
|
|
5464
5671
|
await loadModels();
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
</head>
|
|
35
35
|
<body>
|
|
36
36
|
<header class="topbar">
|
|
37
|
-
<div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.
|
|
37
|
+
<div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.5</span></div>
|
|
38
38
|
<nav class="tabs">
|
|
39
39
|
<button class="tab active" data-tab="chat">chat</button>
|
|
40
40
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -88,6 +88,15 @@
|
|
|
88
88
|
</div>
|
|
89
89
|
</header>
|
|
90
90
|
|
|
91
|
+
<div id="update-banner" class="update-banner" hidden>
|
|
92
|
+
<span class="update-banner-icon" aria-hidden="true">⬆</span>
|
|
93
|
+
<span id="update-banner-text" class="update-banner-text"></span>
|
|
94
|
+
<div class="update-banner-actions">
|
|
95
|
+
<button id="update-run" type="button" class="btn primary">actualizar</button>
|
|
96
|
+
<button id="update-later" type="button" class="btn">más tarde</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
91
100
|
<main>
|
|
92
101
|
<section id="view-chat" class="view active">
|
|
93
102
|
<aside id="sidebar" class="sidebar">
|
|
@@ -415,8 +424,9 @@
|
|
|
415
424
|
<button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
|
|
416
425
|
<button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
|
|
417
426
|
<button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
|
|
418
|
-
|
|
419
|
-
|
|
427
|
+
<button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
|
|
428
|
+
<button type="button" class="ajustes-tab" data-panel="asesor" role="tab">Asesor</button>
|
|
429
|
+
<button type="button" class="ajustes-tab" data-panel="transcripcion" role="tab">Transcripción</button>
|
|
420
430
|
<button type="button" class="ajustes-tab" data-panel="limites" role="tab">Límites</button>
|
|
421
431
|
</nav>
|
|
422
432
|
|
|
@@ -626,6 +636,12 @@
|
|
|
626
636
|
<label class="check" title="avisar al terminar">
|
|
627
637
|
<input id="voz-ann-end" type="checkbox" /> el cierre
|
|
628
638
|
</label>
|
|
639
|
+
<label class="check" title="leer los consejos del asesor">
|
|
640
|
+
<input id="voz-ann-advice" type="checkbox" /> los consejos del asesor
|
|
641
|
+
</label>
|
|
642
|
+
<label class="check" title="leer la respuesta final del agente, sin bloques de código ni tablas">
|
|
643
|
+
<input id="voz-ann-answer" type="checkbox" /> la respuesta final (sin código)
|
|
644
|
+
</label>
|
|
629
645
|
</div>
|
|
630
646
|
<p id="voz-ffmpeg" class="muted" hidden>
|
|
631
647
|
Sin <code>ffmpeg</code> en el PATH, en Telegram llega como audio MP3 en vez de nota
|
|
@@ -638,6 +654,44 @@
|
|
|
638
654
|
</div>
|
|
639
655
|
</div>
|
|
640
656
|
|
|
657
|
+
<div class="panel ajustes-panel" data-panel="asesor">
|
|
658
|
+
<div class="row between">
|
|
659
|
+
<h2>Asesor</h2>
|
|
660
|
+
<span id="asesor-status" class="summary">cargando…</span>
|
|
661
|
+
</div>
|
|
662
|
+
<p class="muted verify-line">
|
|
663
|
+
Un asesor que observa lo que el agente hace, lo interpreta y solo habla cuando tiene
|
|
664
|
+
algo que aportar: te alerta de un problema en el código, te explica un proceso o te da
|
|
665
|
+
un consejo. No lee la respuesta del agente. Usa el proveedor y modelo activos.
|
|
666
|
+
</p>
|
|
667
|
+
<label class="check" title="tener un asesor que interprete y comente el turno">
|
|
668
|
+
<input id="asesor-enabled" type="checkbox" /> Habilitar el asesor
|
|
669
|
+
</label>
|
|
670
|
+
<div class="voz-announce">
|
|
671
|
+
<span class="muted">qué aporta:</span>
|
|
672
|
+
<label class="check" title="alertar de problemas reales en el código">
|
|
673
|
+
<input id="asesor-code" type="checkbox" /> problemas en el código
|
|
674
|
+
</label>
|
|
675
|
+
<label class="check" title="explicar procesos o decisiones">
|
|
676
|
+
<input id="asesor-explain" type="checkbox" /> explicar procesos
|
|
677
|
+
</label>
|
|
678
|
+
<label class="check" title="dar consejos concretos">
|
|
679
|
+
<input id="asesor-advice" type="checkbox" /> consejos
|
|
680
|
+
</label>
|
|
681
|
+
</div>
|
|
682
|
+
<label class="field"><span>largo de cada consejo (palabras)</span>
|
|
683
|
+
<input id="asesor-words" type="number" min="10" max="120" step="5" value="40" />
|
|
684
|
+
</label>
|
|
685
|
+
<label class="field"><span>instrucciones para el asesor (opcional)</span>
|
|
686
|
+
<textarea id="asesor-notes" rows="3" placeholder="Ej: avisame siempre si toco secretos o config de producción."></textarea>
|
|
687
|
+
</label>
|
|
688
|
+
<div class="row">
|
|
689
|
+
<button id="asesor-save" class="btn primary" type="button">guardar</button>
|
|
690
|
+
<button id="asesor-test" class="btn" type="button">probar asesor</button>
|
|
691
|
+
<span id="asesor-msg" class="summary"></span>
|
|
692
|
+
</div>
|
|
693
|
+
</div>
|
|
694
|
+
|
|
641
695
|
<div class="panel ajustes-panel" data-panel="transcripcion">
|
|
642
696
|
<div class="row between">
|
|
643
697
|
<h2>Transcripción</h2>
|
|
@@ -215,3 +215,57 @@ export function renderMarkdown(text) {
|
|
|
215
215
|
flush();
|
|
216
216
|
return out.join('\n');
|
|
217
217
|
}
|
|
218
|
+
|
|
219
|
+
function cleanSpeechLine(line) {
|
|
220
|
+
return line
|
|
221
|
+
.replace(/^\s*#{1,6}\s+/, '')
|
|
222
|
+
.replace(/^\s*>\s?/, '')
|
|
223
|
+
.replace(/^\s*([-*+]|\d+[.)])\s+/, '')
|
|
224
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
225
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
226
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
227
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
228
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
229
|
+
.replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1$2')
|
|
230
|
+
.replace(/(^|[^_\w])_([^_\n]+)_/g, '$1$2');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Version hablada de una respuesta: deja el texto corrido y saca lo que no se
|
|
235
|
+
* lee bien (bloques de codigo, tablas, marcas de markdown). Recorta a `maxChars`
|
|
236
|
+
* cortando en un limite de oracion, para no mandar una sintesis gigante al TTS.
|
|
237
|
+
*/
|
|
238
|
+
export function speechText(text, { maxChars = 1500 } = {}) {
|
|
239
|
+
const stripped = String(text ?? '')
|
|
240
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
241
|
+
.replace(/~~~[\s\S]*?~~~/g, ' ');
|
|
242
|
+
const lines = stripped.replace(/\r\n?/g, '\n').split('\n');
|
|
243
|
+
const kept = [];
|
|
244
|
+
let i = 0;
|
|
245
|
+
while (i < lines.length) {
|
|
246
|
+
const line = lines[i];
|
|
247
|
+
if (line.includes('|') && i + 1 < lines.length && isDelimiterRow(lines[i + 1])) {
|
|
248
|
+
i += 2;
|
|
249
|
+
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) i += 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
|
253
|
+
i += 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
kept.push(cleanSpeechLine(line));
|
|
257
|
+
i += 1;
|
|
258
|
+
}
|
|
259
|
+
let out = kept.join('\n').replace(/\s+/g, ' ').trim();
|
|
260
|
+
if (out.length > maxChars) {
|
|
261
|
+
const slice = out.slice(0, maxChars);
|
|
262
|
+
const cut = Math.max(
|
|
263
|
+
slice.lastIndexOf('. '),
|
|
264
|
+
slice.lastIndexOf('! '),
|
|
265
|
+
slice.lastIndexOf('? '),
|
|
266
|
+
slice.lastIndexOf('; ')
|
|
267
|
+
);
|
|
268
|
+
out = `${(cut > maxChars * 0.5 ? slice.slice(0, cut + 1) : slice).trim()}…`;
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
@@ -1631,3 +1631,40 @@ dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: bl
|
|
|
1631
1631
|
.modal-card { display: flex; flex-direction: column; gap: 12px; padding: 20px; }
|
|
1632
1632
|
.modal-card h3 { margin: 0; font: 700 17px var(--serif); letter-spacing: -0.02em; }
|
|
1633
1633
|
.row .spacer { flex: 1; }
|
|
1634
|
+
|
|
1635
|
+
/* update banner ----------------------------------------------------------- */
|
|
1636
|
+
.update-banner {
|
|
1637
|
+
display: flex;
|
|
1638
|
+
align-items: center;
|
|
1639
|
+
gap: 12px;
|
|
1640
|
+
padding: 8px 16px;
|
|
1641
|
+
background: var(--accent-soft);
|
|
1642
|
+
border-bottom: 1px solid var(--border);
|
|
1643
|
+
color: var(--text);
|
|
1644
|
+
font: 500 13px var(--sans, inherit);
|
|
1645
|
+
}
|
|
1646
|
+
.update-banner[hidden] { display: none; }
|
|
1647
|
+
.update-banner-icon { color: var(--accent); flex: 0 0 auto; }
|
|
1648
|
+
.update-banner-text { flex: 1; min-width: 0; }
|
|
1649
|
+
.update-banner-actions { display: flex; gap: 8px; flex: 0 0 auto; }
|
|
1650
|
+
.update-banner .btn { padding: 4px 12px; font-size: 12px; }
|
|
1651
|
+
.brand .ver.has-update { color: var(--accent); cursor: help; }
|
|
1652
|
+
.brand .ver.has-update::after { content: " ⬆"; }
|
|
1653
|
+
|
|
1654
|
+
/* advisor card ------------------------------------------------------------ */
|
|
1655
|
+
.advisor-card {
|
|
1656
|
+
margin: 10px 0;
|
|
1657
|
+
padding: 10px 14px;
|
|
1658
|
+
border-left: 3px solid var(--accent);
|
|
1659
|
+
border-radius: 8px;
|
|
1660
|
+
background: var(--accent-soft);
|
|
1661
|
+
}
|
|
1662
|
+
.advisor-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
|
1663
|
+
.advisor-glyph { color: var(--accent); font-size: 13px; line-height: 1; }
|
|
1664
|
+
.advisor-title { font: 600 10px var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
|
|
1665
|
+
.advisor-text { color: var(--text); line-height: 1.5; }
|
|
1666
|
+
.advisor-code { border-left-color: var(--warn); }
|
|
1667
|
+
.advisor-code .advisor-glyph { color: var(--warn); }
|
|
1668
|
+
|
|
1669
|
+
.brand .ver.ver-action { cursor: pointer; }
|
|
1670
|
+
.brand .ver.ver-action:hover { color: var(--accent); }
|