@danieltmn/tamnora 0.9.3 → 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 +30 -0
- package/README.md +51 -16
- 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/agent/modes.js +14 -1
- package/harness/packages/core/src/agent/runAgent.js +23 -2
- package/harness/packages/core/src/browser/index.js +289 -0
- package/harness/packages/core/src/config/home.js +5 -0
- package/harness/packages/core/src/index.js +9 -2
- package/harness/packages/core/src/tools/executors.js +96 -0
- package/harness/packages/core/src/tools/labels.js +4 -0
- package/harness/packages/core/src/tools/permissions.js +4 -0
- package/harness/packages/core/src/tools/schema.js +47 -0
- package/harness/packages/core/src/version.js +81 -7
- package/harness/packages/core/test/agent.test.js +30 -1
- package/harness/packages/core/test/browser.test.js +18 -0
- package/harness/packages/core/test/tools.test.js +16 -1
- package/harness/packages/core/test/version.test.js +34 -0
- package/harness/packages/server/public/app.js +244 -8
- package/harness/packages/server/public/index.html +57 -3
- package/harness/packages/server/public/landing/index.html +14 -1
- package/harness/packages/server/public/markdown.js +54 -0
- package/harness/packages/server/public/reconcile.js +3 -1
- package/harness/packages/server/public/styles.css +48 -0
- package/harness/packages/server/src/lib/advisor.js +177 -0
- package/harness/packages/server/src/lib/agentRun.js +4 -3
- package/harness/packages/server/src/lib/channels/telegram.js +130 -25
- 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/files.js +25 -1
- 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/server.test.js +50 -14
- package/harness/packages/server/test/telegram-features.test.js +25 -4
- package/harness/packages/server/test/update.test.js +94 -0
- package/harness/scripts/self-update.mjs +123 -0
- package/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { findBrowserPath } from '../src/browser/index.js';
|
|
6
|
+
|
|
7
|
+
test('findBrowserPath respeta TAMNORA_BROWSER cuando el archivo existe', () => {
|
|
8
|
+
const self = fileURLToPath(import.meta.url);
|
|
9
|
+
assert.ok(existsSync(self));
|
|
10
|
+
const previous = process.env.TAMNORA_BROWSER;
|
|
11
|
+
process.env.TAMNORA_BROWSER = self;
|
|
12
|
+
try {
|
|
13
|
+
assert.equal(findBrowserPath(), self);
|
|
14
|
+
} finally {
|
|
15
|
+
if (previous === undefined) delete process.env.TAMNORA_BROWSER;
|
|
16
|
+
else process.env.TAMNORA_BROWSER = previous;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { resolveInRoot, toRel } from '../src/tools/paths.js';
|
|
7
7
|
import { evaluatePermission } from '../src/tools/permissions.js';
|
|
8
8
|
import { createTools, createToolRegistry } from '../src/tools/index.js';
|
|
9
|
-
import { fetchUrlTool } from '../src/tools/executors.js';
|
|
9
|
+
import { fetchUrlTool, previewUrlTool, screenshotTool, consoleTool } from '../src/tools/executors.js';
|
|
10
10
|
|
|
11
11
|
async function makeWorkspace() {
|
|
12
12
|
const root = await mkdtemp(path.join(tmpdir(), 'tamnora-ws-'));
|
|
@@ -169,3 +169,18 @@ test('la tool speak valida el texto y la recorta', async () => {
|
|
|
169
169
|
const { definitions } = createTools({ root: await makeWorkspace() });
|
|
170
170
|
assert.ok(definitions.some((d) => d.function.name === 'speak'));
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
test('screenshot, console_errors y preview_url validan la URL sin lanzar el navegador', async () => {
|
|
174
|
+
assert.match(previewUrlTool(null, { url: 'https://example.com/x' }), /example\.com/);
|
|
175
|
+
assert.match(previewUrlTool(null, {}), /falta "url"/);
|
|
176
|
+
assert.match(await screenshotTool(null, { url: 'ftp://x' }), /Solo se admite http/);
|
|
177
|
+
assert.match(await screenshotTool(null, {}), /falta "url"/);
|
|
178
|
+
assert.match(await consoleTool(null, { url: 'no-url' }), /URL inválida/);
|
|
179
|
+
assert.equal(evaluatePermission('screenshot', {}, {}), 'allow');
|
|
180
|
+
assert.equal(evaluatePermission('console_errors', {}, {}), 'allow');
|
|
181
|
+
assert.equal(evaluatePermission('preview_url', {}, {}), 'allow');
|
|
182
|
+
const { definitions } = createTools({ root: await makeWorkspace() });
|
|
183
|
+
for (const name of ['screenshot', 'console_errors', 'preview_url']) {
|
|
184
|
+
assert.ok(definitions.some((d) => d.function.name === name), `falta ${name}`);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { compareVersions, fetchLatestVersion } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
test('compareVersions ordena semver (incluye prerelease)', () => {
|
|
6
|
+
assert.equal(compareVersions('1.2.3', '1.2.3'), 0);
|
|
7
|
+
assert.equal(compareVersions('1.2.4', '1.2.3'), 1);
|
|
8
|
+
assert.equal(compareVersions('1.3.0', '1.2.9'), 1);
|
|
9
|
+
assert.equal(compareVersions('2.0.0', '1.9.9'), 1);
|
|
10
|
+
assert.equal(compareVersions('1.0.0', '1.0.0-beta'), 1);
|
|
11
|
+
assert.equal(compareVersions('1.0.0-beta', '1.0.0'), -1);
|
|
12
|
+
assert.equal(compareVersions('v0.9.4', '0.9.4'), 0);
|
|
13
|
+
// Formato desconocido: no afirmamos que hay algo nuevo.
|
|
14
|
+
assert.equal(compareVersions('base', '1.0.0'), 0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('fetchLatestVersion resuelve el tag desde el registro', async () => {
|
|
18
|
+
const fetchImpl = async (url) => {
|
|
19
|
+
assert.match(String(url), /registry\.npmjs\.org\/@danieltmn\/tamnora$/);
|
|
20
|
+
return { ok: true, json: async () => ({ 'dist-tags': { latest: '9.9.9' } }) };
|
|
21
|
+
};
|
|
22
|
+
assert.equal(await fetchLatestVersion({ fetchImpl }), '9.9.9');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('fetchLatestVersion falla claro con HTTP malo o sin version', async () => {
|
|
26
|
+
await assert.rejects(
|
|
27
|
+
fetchLatestVersion({ fetchImpl: async () => ({ ok: false, status: 500 }) }),
|
|
28
|
+
/HTTP 500/
|
|
29
|
+
);
|
|
30
|
+
await assert.rejects(
|
|
31
|
+
fetchLatestVersion({ fetchImpl: async () => ({ ok: true, json: async () => ({}) }) }),
|
|
32
|
+
/version/
|
|
33
|
+
);
|
|
34
|
+
});
|
|
@@ -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) =>
|
|
@@ -1158,6 +1158,10 @@ function toolSummary(name, args) {
|
|
|
1158
1158
|
return shortText(a.url, 90);
|
|
1159
1159
|
case 'speak':
|
|
1160
1160
|
return shortText(a.texto, 90);
|
|
1161
|
+
case 'screenshot':
|
|
1162
|
+
case 'console_errors':
|
|
1163
|
+
case 'preview_url':
|
|
1164
|
+
return shortText(a.url, 90);
|
|
1161
1165
|
case 'git_diff':
|
|
1162
1166
|
return [a.ref ? `vs ${shortText(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? shortText(a.path, 40) : '']
|
|
1163
1167
|
.filter(Boolean)
|
|
@@ -1189,6 +1193,26 @@ function isFailedToolText(text) {
|
|
|
1189
1193
|
return /^Error|^Permiso denegado/i.test(String(text ?? ''));
|
|
1190
1194
|
}
|
|
1191
1195
|
|
|
1196
|
+
/** Nombre de archivo de una captura, del meta o del texto del resultado. */
|
|
1197
|
+
function screenshotName(data, text) {
|
|
1198
|
+
const fromMeta = data?.meta?.screenshot?.name;
|
|
1199
|
+
if (fromMeta) return String(fromMeta);
|
|
1200
|
+
const match = /capturas\/([a-z0-9][a-z0-9-]*\.png)/i.exec(String(text ?? ''));
|
|
1201
|
+
return match ? match[1] : null;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** Muestra la captura del tool `screenshot` en su tarjeta (clic para ampliar). */
|
|
1205
|
+
function showScreenshot(entry, name) {
|
|
1206
|
+
if (!entry || !name || entry.shot) return;
|
|
1207
|
+
entry.shot = true;
|
|
1208
|
+
const img = el('img', 'tool-shot');
|
|
1209
|
+
img.loading = 'lazy';
|
|
1210
|
+
img.alt = 'captura de pantalla';
|
|
1211
|
+
img.src = `/api/screenshots/${encodeURIComponent(name)}`;
|
|
1212
|
+
img.addEventListener('click', () => window.open(img.src, '_blank'));
|
|
1213
|
+
entry.card.appendChild(img);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1192
1216
|
/** Cierra la tarjeta de un tool-call (evento tool_result o mensaje persistido). */
|
|
1193
1217
|
function finishToolCard(entry, data) {
|
|
1194
1218
|
if (!entry) return;
|
|
@@ -1203,6 +1227,7 @@ function finishToolCard(entry, data) {
|
|
|
1203
1227
|
entry.truncated = true;
|
|
1204
1228
|
entry.more.hidden = false;
|
|
1205
1229
|
}
|
|
1230
|
+
if (!failed) showScreenshot(entry, screenshotName(data, preview));
|
|
1206
1231
|
}
|
|
1207
1232
|
|
|
1208
1233
|
function previewText(value, maxLines = 24, maxChars = 4000) {
|
|
@@ -1439,8 +1464,35 @@ function userImages(content) {
|
|
|
1439
1464
|
.filter(Boolean);
|
|
1440
1465
|
}
|
|
1441
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
|
+
|
|
1442
1487
|
function renderStored(message, pane) {
|
|
1443
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
|
+
}
|
|
1444
1496
|
if (message.role === 'user') {
|
|
1445
1497
|
const bubble = addMessage('user', userText(message.content), pane);
|
|
1446
1498
|
if (message.id) {
|
|
@@ -1981,6 +2033,11 @@ function addContinueButton(turn, data) {
|
|
|
1981
2033
|
function handleChatEvent(event, data, turn) {
|
|
1982
2034
|
if (handleAgentEvent(event, data, turn)) return;
|
|
1983
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
|
+
}
|
|
1984
2041
|
if (event === 'agent_start') {
|
|
1985
2042
|
turn.agents.set(data.agentId, addAgentCard(turn.assistant(), data));
|
|
1986
2043
|
} else if (event === 'agent_end') {
|
|
@@ -2026,6 +2083,10 @@ function handleChatEvent(event, data, turn) {
|
|
|
2026
2083
|
const texto = String(data.args?.texto ?? '').trim();
|
|
2027
2084
|
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
|
|
2028
2085
|
}
|
|
2086
|
+
if (turn.live && data.name === 'preview_url' && data.args?.url) {
|
|
2087
|
+
setExplorerMode('preview');
|
|
2088
|
+
loadPreview(data.args.url);
|
|
2089
|
+
}
|
|
2029
2090
|
} else if (event === 'tool_result') {
|
|
2030
2091
|
setActivity(turn.assistant(), 'thinking');
|
|
2031
2092
|
finishToolCard(turn.lastTool, data);
|
|
@@ -3593,7 +3654,15 @@ const vozRateEl = $('#voz-rate');
|
|
|
3593
3654
|
const vozAnnStartEl = $('#voz-ann-start');
|
|
3594
3655
|
const vozAnnToolsEl = $('#voz-ann-tools');
|
|
3595
3656
|
const vozAnnEndEl = $('#voz-ann-end');
|
|
3657
|
+
const vozAnnAdviceEl = $('#voz-ann-advice');
|
|
3658
|
+
const vozAnnAnswerEl = $('#voz-ann-answer');
|
|
3596
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');
|
|
3597
3666
|
const trProviderEl = $('#tr-provider');
|
|
3598
3667
|
const trModelEl = $('#tr-model');
|
|
3599
3668
|
|
|
@@ -3926,7 +3995,7 @@ const voz = {
|
|
|
3926
3995
|
enabled: false,
|
|
3927
3996
|
voice: 'es-AR-ElenaNeural',
|
|
3928
3997
|
rate: '+0%',
|
|
3929
|
-
announce: { start: true, tools: true, end: true },
|
|
3998
|
+
announce: { start: true, tools: true, end: true, advice: true, answer: false },
|
|
3930
3999
|
},
|
|
3931
4000
|
};
|
|
3932
4001
|
|
|
@@ -4057,10 +4126,10 @@ function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
|
|
|
4057
4126
|
* Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
|
|
4058
4127
|
* cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
|
|
4059
4128
|
*/
|
|
4060
|
-
function hablar(texto) {
|
|
4129
|
+
function hablar(texto, { label = 'narrando', maxAgeMs = 45000 } = {}) {
|
|
4061
4130
|
const frase = String(texto ?? '').trim();
|
|
4062
4131
|
if (!frase || !voz.settings.enabled) return;
|
|
4063
|
-
reproducirVoz(frase, { label
|
|
4132
|
+
reproducirVoz(frase, { label, maxAgeMs }).catch(() => {});
|
|
4064
4133
|
}
|
|
4065
4134
|
|
|
4066
4135
|
/** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
|
|
@@ -4070,7 +4139,15 @@ function narrar(event, data, turn) {
|
|
|
4070
4139
|
if (!enabled) return;
|
|
4071
4140
|
if (event === 'start' && announce.start) hablar(data?.say);
|
|
4072
4141
|
else if (event === 'tool_call' && announce.tools) hablar(data?.say);
|
|
4073
|
-
|
|
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);
|
|
4074
4151
|
}
|
|
4075
4152
|
|
|
4076
4153
|
async function cargarVoces() {
|
|
@@ -4096,6 +4173,8 @@ async function aplicarVoz(data) {
|
|
|
4096
4173
|
start: data.announce?.start !== false,
|
|
4097
4174
|
tools: data.announce?.tools !== false,
|
|
4098
4175
|
end: data.announce?.end !== false,
|
|
4176
|
+
advice: data.announce?.advice !== false,
|
|
4177
|
+
answer: data.announce?.answer === true,
|
|
4099
4178
|
},
|
|
4100
4179
|
};
|
|
4101
4180
|
if (vozEnabledEl) vozEnabledEl.checked = voz.settings.enabled;
|
|
@@ -4113,6 +4192,8 @@ async function aplicarVoz(data) {
|
|
|
4113
4192
|
if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
|
|
4114
4193
|
if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
|
|
4115
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;
|
|
4116
4197
|
const estado = $('#voz-status');
|
|
4117
4198
|
if (estado) {
|
|
4118
4199
|
estado.className = `summary${voz.settings.enabled ? ' ok' : ''}`;
|
|
@@ -4136,6 +4217,8 @@ async function submitVoz() {
|
|
|
4136
4217
|
start: vozAnnStartEl?.checked !== false,
|
|
4137
4218
|
tools: vozAnnToolsEl?.checked !== false,
|
|
4138
4219
|
end: vozAnnEndEl?.checked !== false,
|
|
4220
|
+
advice: vozAnnAdviceEl?.checked !== false,
|
|
4221
|
+
answer: vozAnnAnswerEl?.checked === true,
|
|
4139
4222
|
},
|
|
4140
4223
|
};
|
|
4141
4224
|
const data = await api('/api/tts/settings', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
|
|
@@ -4313,6 +4396,57 @@ async function testVoz() {
|
|
|
4313
4396
|
}
|
|
4314
4397
|
}
|
|
4315
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
|
+
|
|
4316
4450
|
/* --- ajustes: perfil del usuario ------------------------------------------ */
|
|
4317
4451
|
|
|
4318
4452
|
const perfil = {
|
|
@@ -4445,8 +4579,10 @@ if (mcpListEl) {
|
|
|
4445
4579
|
$('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
|
|
4446
4580
|
$('#tg-save')?.addEventListener('click', submitChannel);
|
|
4447
4581
|
$('#tg-test')?.addEventListener('click', testChannel);
|
|
4448
|
-
|
|
4449
|
-
|
|
4582
|
+
$('#voz-save')?.addEventListener('click', submitVoz);
|
|
4583
|
+
$('#voz-test')?.addEventListener('click', testVoz);
|
|
4584
|
+
$('#asesor-save')?.addEventListener('click', submitAsesor);
|
|
4585
|
+
$('#asesor-test')?.addEventListener('click', testAsesor);
|
|
4450
4586
|
$('#mcp-close').addEventListener('click', closeMcpForm);
|
|
4451
4587
|
$('#mf-cancel').addEventListener('click', closeMcpForm);
|
|
4452
4588
|
$('#mf-transport').addEventListener('change', syncMcpFormFields);
|
|
@@ -5407,6 +5543,105 @@ function watchLifecycle() {
|
|
|
5407
5543
|
window.addEventListener('pageshow', wake);
|
|
5408
5544
|
window.addEventListener('online', wake);
|
|
5409
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);
|
|
5410
5645
|
}
|
|
5411
5646
|
|
|
5412
5647
|
/* --- boot ---------------------------------------------------------------- */
|
|
@@ -5430,6 +5665,7 @@ async function boot() {
|
|
|
5430
5665
|
const ver = document.querySelector('.brand .ver');
|
|
5431
5666
|
if (ver) ver.textContent = `v${health.version}`;
|
|
5432
5667
|
}
|
|
5668
|
+
refreshUpdate();
|
|
5433
5669
|
await refreshProviders();
|
|
5434
5670
|
await loadModes();
|
|
5435
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>
|
|
@@ -451,7 +451,8 @@
|
|
|
451
451
|
|
|
452
452
|
<p class="hero-copy">
|
|
453
453
|
Un harness local para orquestar agentes de IA, ejecutar tools en tu workspace y mantener el
|
|
454
|
-
control desde tu máquina o el celular (túnel + QR o Telegram).
|
|
454
|
+
control desde tu máquina o el celular (túnel + QR o Telegram). Renderiza páginas con un
|
|
455
|
+
navegador headless, narra su progreso y hace trazable cada corrida.
|
|
455
456
|
</p>
|
|
456
457
|
|
|
457
458
|
<div class="actions">
|
|
@@ -472,6 +473,18 @@
|
|
|
472
473
|
<div class="feature-icon">✓</div>
|
|
473
474
|
<div><b>Control</b><span>Permisos allow/ask/deny y runs trazables.</span></div>
|
|
474
475
|
</div>
|
|
476
|
+
<div class="feature">
|
|
477
|
+
<div class="feature-icon">◎</div>
|
|
478
|
+
<div><b>Navegador</b><span>Screenshot y errores de consola de una página.</span></div>
|
|
479
|
+
</div>
|
|
480
|
+
<div class="feature">
|
|
481
|
+
<div class="feature-icon">♪</div>
|
|
482
|
+
<div><b>Voz</b><span>Narra el progreso y manda audio bajo pedido.</span></div>
|
|
483
|
+
</div>
|
|
484
|
+
<div class="feature">
|
|
485
|
+
<div class="feature-icon">⌘</div>
|
|
486
|
+
<div><b>Multi-modelo</b><span>OpenRouter, OpenAI, Claude, Gemini y Ollama.</span></div>
|
|
487
|
+
</div>
|
|
475
488
|
</div>
|
|
476
489
|
</section>
|
|
477
490
|
|
|
@@ -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
|
+
}
|
|
@@ -110,7 +110,9 @@ export function summarizeArgs(name, args, max = 240) {
|
|
|
110
110
|
if ((name === 'write' || name === 'edit' || name === 'read') && a.path) {
|
|
111
111
|
return a.content ? `${a.path} · ${humanSize(a.content)}` : String(a.path);
|
|
112
112
|
}
|
|
113
|
-
if ((name === 'fetch_url' || name === 'preview_url') && a.url)
|
|
113
|
+
if ((name === 'fetch_url' || name === 'preview_url' || name === 'screenshot' || name === 'console_errors') && a.url) {
|
|
114
|
+
return cut(a.url);
|
|
115
|
+
}
|
|
114
116
|
if (name === 'task' && (a.description || a.prompt)) return cut(a.description || a.prompt);
|
|
115
117
|
const keys = Object.keys(a);
|
|
116
118
|
if (keys.length === 1 && typeof a[keys[0]] === 'string') return cut(a[keys[0]]);
|