@danieltmn/tamnora 0.9.1 → 0.9.3
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 +13 -0
- package/harness/packages/cli/src/tui/app.js +22 -3
- package/harness/packages/cli/src/tui/layout.js +59 -2
- package/harness/packages/cli/test/tui.test.js +30 -0
- package/harness/packages/core/src/config/app.js +2 -0
- package/harness/packages/core/src/index.js +2 -1
- package/harness/packages/core/src/providers/presets.js +4 -0
- package/harness/packages/core/src/stt/google.js +91 -0
- package/harness/packages/core/src/tools/diff.js +70 -1
- package/harness/packages/core/test/diff.test.js +24 -1
- package/harness/packages/server/public/app.js +824 -45
- package/harness/packages/server/public/index.html +121 -1
- package/harness/packages/server/public/styles.css +106 -12
- package/harness/packages/server/src/lib/channels/index.js +23 -3
- package/harness/packages/server/src/lib/channels/telegram.js +289 -101
- package/harness/packages/server/src/lib/presence.js +51 -0
- package/harness/packages/server/src/lib/profile.js +63 -0
- package/harness/packages/server/src/lib/prompt.js +7 -2
- package/harness/packages/server/src/lib/transcribe.js +51 -0
- package/harness/packages/server/src/routes/channels.js +2 -0
- package/harness/packages/server/src/routes/presence.js +17 -0
- package/harness/packages/server/src/routes/profile.js +22 -0
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/routes/sessions.js +55 -33
- package/harness/packages/server/src/routes/transcribe.js +115 -0
- package/harness/packages/server/src/routes/tts.js +2 -1
- package/harness/packages/server/src/server.js +11 -0
- package/harness/packages/server/test/profile.test.js +85 -0
- package/harness/packages/server/test/server.test.js +34 -0
- package/harness/packages/server/test/telegram-features.test.js +241 -0
- package/harness/packages/server/test/telegram-voz.test.js +12 -5
- package/harness/packages/server/test/telegram.test.js +37 -2
- package/harness/packages/server/test/transcribe.test.js +215 -0
- package/harness/packages/server/test/tts.test.js +24 -0
- package/package.json +1 -1
|
@@ -72,6 +72,10 @@ const ICON_PATHS = {
|
|
|
72
72
|
'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
|
|
73
73
|
trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
|
|
74
74
|
'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
|
|
75
|
+
'dots-horizontal': ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'],
|
|
76
|
+
play: ['m6 4 14 8-14 8z'],
|
|
77
|
+
pause: ['M8 5v14', 'M16 5v14'],
|
|
78
|
+
square: ['M6 6h12v12H6z'],
|
|
75
79
|
copy: ['M8 8V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3', 'M5 8h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2Z'],
|
|
76
80
|
pencil: ['M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z', 'm15 5 4 4'],
|
|
77
81
|
};
|
|
@@ -213,7 +217,7 @@ function activateTab(name) {
|
|
|
213
217
|
hideMoreMenu();
|
|
214
218
|
if (name === 'runs') refreshRuns();
|
|
215
219
|
if (name === 'providers') refreshProviders();
|
|
216
|
-
if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); }
|
|
220
|
+
if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshTranscribe(); refreshProfile(); }
|
|
217
221
|
}
|
|
218
222
|
|
|
219
223
|
document.querySelectorAll('.tab[data-tab]').forEach((t) =>
|
|
@@ -301,6 +305,8 @@ if (statusbarEl) {
|
|
|
301
305
|
const PALETTE_COMMANDS = [
|
|
302
306
|
{ id: 'new', label: 'Nueva sesión en otra carpeta', hint: '/new', run: () => openFolderPicker() },
|
|
303
307
|
{ id: 'init', label: 'Analizar el proyecto y crear AGENTS.md', hint: '/init', run: () => runSlash('init') },
|
|
308
|
+
{ id: 'resumen', label: 'Resumir la conversación', hint: '/resumen', run: () => runSlash('resumen') },
|
|
309
|
+
{ id: 'audioresumen', label: 'Resumen como audio', hint: '/audioresumen', run: () => runSlash('audioresumen') },
|
|
304
310
|
{ id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
|
|
305
311
|
{ id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
|
|
306
312
|
{ id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
|
|
@@ -330,7 +336,7 @@ function runSlash(name) {
|
|
|
330
336
|
function paletteItems(query) {
|
|
331
337
|
const q = query.trim().toLowerCase();
|
|
332
338
|
const items = PALETTE_COMMANDS.filter(
|
|
333
|
-
(c) => !q || c.label.toLowerCase().includes(q) || c.id.includes(q)
|
|
339
|
+
(c) => !q || c.label.toLowerCase().includes(q) || c.id.includes(q) || (c.hint ?? '').toLowerCase().includes(q)
|
|
334
340
|
).map((c) => ({ label: c.label, hint: c.hint || 'comando', run: c.run }));
|
|
335
341
|
|
|
336
342
|
for (const session of palette.results.sessions) {
|
|
@@ -461,6 +467,14 @@ async function loadModes() {
|
|
|
461
467
|
const settings = await api('/api/settings').catch(() => null);
|
|
462
468
|
const active = settings?.settings?.mode || 'agent';
|
|
463
469
|
if ([...modeSelect.options].some((o) => o.value === active)) modeSelect.value = active;
|
|
470
|
+
defaultCwdCache = settings?.settings?.defaultCwd || '';
|
|
471
|
+
if (!defaultCwdCache) {
|
|
472
|
+
// Migración: la carpeta predeterminada vivía en localStorage; la subimos al
|
|
473
|
+
// server una vez para que Telegram también la vea.
|
|
474
|
+
const legacy = storage.get('tamnora-default-cwd', '');
|
|
475
|
+
if (legacy) writeDefaultCwd(legacy);
|
|
476
|
+
}
|
|
477
|
+
syncDefaultCwd();
|
|
464
478
|
updateStatus();
|
|
465
479
|
}
|
|
466
480
|
|
|
@@ -894,7 +908,7 @@ function addMessageActions(message) {
|
|
|
894
908
|
const actions = el('div', 'msg-actions');
|
|
895
909
|
const trigger = el('button', 'msg-menu-btn');
|
|
896
910
|
trigger.type = 'button';
|
|
897
|
-
trigger.appendChild(icon('dots-vertical', 16));
|
|
911
|
+
trigger.appendChild(icon(window.matchMedia('(min-width: 821px)').matches ? 'dots-horizontal' : 'dots-vertical', 16));
|
|
898
912
|
trigger.title = 'opciones del mensaje';
|
|
899
913
|
trigger.setAttribute('aria-haspopup', 'menu');
|
|
900
914
|
trigger.setAttribute('aria-expanded', 'false');
|
|
@@ -1244,18 +1258,13 @@ async function loadFullToolResult(entry, message) {
|
|
|
1244
1258
|
}
|
|
1245
1259
|
}
|
|
1246
1260
|
|
|
1247
|
-
/**
|
|
1248
|
-
function
|
|
1261
|
+
/** Botón del tool `speak`: encola el audio pedido en el reproductor global. */
|
|
1262
|
+
function speakControl(args) {
|
|
1249
1263
|
const texto = String(args?.texto ?? '').trim();
|
|
1250
1264
|
if (!texto) return null;
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
const audio = el('audio', 'tool-audio');
|
|
1255
|
-
audio.controls = true;
|
|
1256
|
-
audio.preload = 'none';
|
|
1257
|
-
audio.src = `/api/tts?${params.toString()}`;
|
|
1258
|
-
return audio;
|
|
1265
|
+
const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
|
|
1266
|
+
btn.append(icon('play', 13), el('span', '', 'escuchar'));
|
|
1267
|
+
return btn;
|
|
1259
1268
|
}
|
|
1260
1269
|
|
|
1261
1270
|
/** Texto de la tarjeta: el server ya manda `summary` calculado; esto es el fallback. */
|
|
@@ -1269,17 +1278,17 @@ function addToolCard(message, name, args, { callId = null, open = null, round =
|
|
|
1269
1278
|
const argsEl = el('span', 'tool-args', summary ?? toolSummary(name, args));
|
|
1270
1279
|
head.append(glyph, el('span', 'tool-name', name), argsEl);
|
|
1271
1280
|
const out = el('div', 'tool-out');
|
|
1272
|
-
const
|
|
1281
|
+
const speak = name === 'speak' ? speakControl(args) : null;
|
|
1273
1282
|
const more = button('ver completo', () => loadFullToolResult(entry, message), 'tool-more');
|
|
1274
1283
|
more.hidden = true;
|
|
1275
1284
|
card.append(head, out);
|
|
1276
|
-
if (
|
|
1285
|
+
if (speak) card.appendChild(speak);
|
|
1277
1286
|
card.appendChild(more);
|
|
1278
1287
|
message.tools.appendChild(card);
|
|
1279
1288
|
ensureToolsToggle(message);
|
|
1280
1289
|
scrollMessages(paneOf(message));
|
|
1281
1290
|
|
|
1282
|
-
const entry = { card, out, glyph, argsEl, more,
|
|
1291
|
+
const entry = { card, out, glyph, argsEl, more, callId, full: false, truncated: false };
|
|
1283
1292
|
card.addEventListener('toggle', () => {
|
|
1284
1293
|
if (card.open && entry.truncated && !entry.full) loadFullToolResult(entry, message);
|
|
1285
1294
|
});
|
|
@@ -1476,8 +1485,20 @@ function persistCollapsedGroups() {
|
|
|
1476
1485
|
storage.setJson('tamnora-collapsed-groups', [...state.collapsedGroups]);
|
|
1477
1486
|
}
|
|
1478
1487
|
|
|
1488
|
+
// Pestañas cerradas desde el último reporte: para que el server las olvide ya,
|
|
1489
|
+
// sin esperar el TTL de presencia.
|
|
1490
|
+
const presenceClosed = new Set();
|
|
1491
|
+
|
|
1492
|
+
/** Reporta al server qué pestañas están abiertas (proyectos "habilitados"). */
|
|
1493
|
+
function sendPresence() {
|
|
1494
|
+
const closed = [...presenceClosed];
|
|
1495
|
+
presenceClosed.clear();
|
|
1496
|
+
return api('/api/presence', { method: 'POST', body: { sessions: state.tabOrder, closed } }).catch(() => {});
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1479
1499
|
function persistTabs() {
|
|
1480
1500
|
storage.setJson('tamnora-open-tabs', { ids: state.tabOrder, active: state.activeTab });
|
|
1501
|
+
sendPresence();
|
|
1481
1502
|
}
|
|
1482
1503
|
|
|
1483
1504
|
/**
|
|
@@ -1760,6 +1781,7 @@ function closeTab(id) {
|
|
|
1760
1781
|
state.panes.delete(id);
|
|
1761
1782
|
state.queues.delete(id);
|
|
1762
1783
|
state.tabOrder = state.tabOrder.filter((item) => item !== id);
|
|
1784
|
+
presenceClosed.add(id);
|
|
1763
1785
|
if (state.activeTab === id) {
|
|
1764
1786
|
const next = state.tabOrder[state.tabOrder.length - 1] ?? null;
|
|
1765
1787
|
if (next) {
|
|
@@ -2001,8 +2023,8 @@ function handleChatEvent(event, data, turn) {
|
|
|
2001
2023
|
setActivity(target, 'working');
|
|
2002
2024
|
turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
|
|
2003
2025
|
if (turn.live && data.name === 'speak') {
|
|
2004
|
-
const
|
|
2005
|
-
if (
|
|
2026
|
+
const texto = String(data.args?.texto ?? '').trim();
|
|
2027
|
+
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
|
|
2006
2028
|
}
|
|
2007
2029
|
} else if (event === 'tool_result') {
|
|
2008
2030
|
setActivity(turn.assistant(), 'thinking');
|
|
@@ -2419,6 +2441,365 @@ chatInput.addEventListener('keydown', (event) => {
|
|
|
2419
2441
|
});
|
|
2420
2442
|
chatInput.addEventListener('input', onChatInputChanged);
|
|
2421
2443
|
|
|
2444
|
+
/* --- voz de entrada (dictado) --------------------------------------------- */
|
|
2445
|
+
|
|
2446
|
+
const micBtn = $('#mic-btn');
|
|
2447
|
+
|
|
2448
|
+
// La cuenta 3-2-1 le da tiempo al micro a arrancar (si hablás apenas tocás se
|
|
2449
|
+
// pierden las primeras palabras); la cola al cortar evita perder el final.
|
|
2450
|
+
const REC_COUNT = [3, 2, 1, 0];
|
|
2451
|
+
const REC_COUNT_STEP_MS = 500;
|
|
2452
|
+
const REC_TAIL_MS = 500;
|
|
2453
|
+
|
|
2454
|
+
/** Estado visual del botón de dictado: cuenta regresiva, grabando (verde) o transcribiendo. */
|
|
2455
|
+
function micVisual(button, { live = false, count = null, busy = false } = {}) {
|
|
2456
|
+
if (!button) return;
|
|
2457
|
+
button.classList.toggle('recording', live);
|
|
2458
|
+
button.classList.toggle('busy', busy);
|
|
2459
|
+
button.classList.toggle('mic-counting', count !== null);
|
|
2460
|
+
const span = button.querySelector('.mic-count');
|
|
2461
|
+
if (span) {
|
|
2462
|
+
span.hidden = count === null;
|
|
2463
|
+
span.textContent = count === null ? '' : String(count);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
/** Cuenta regresiva antes de que empieces a hablar. Devuelve false si se canceló. */
|
|
2468
|
+
async function micCountdown(button, isCancelled) {
|
|
2469
|
+
for (const count of REC_COUNT) {
|
|
2470
|
+
if (isCancelled()) return false;
|
|
2471
|
+
micVisual(button, { live: true, count });
|
|
2472
|
+
await new Promise((resolve) => setTimeout(resolve, REC_COUNT_STEP_MS));
|
|
2473
|
+
}
|
|
2474
|
+
if (isCancelled()) return false;
|
|
2475
|
+
micVisual(button, { live: true });
|
|
2476
|
+
return true;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
function blobToDataUrl(blob) {
|
|
2480
|
+
return new Promise((resolve, reject) => {
|
|
2481
|
+
const reader = new FileReader();
|
|
2482
|
+
reader.onerror = () => reject(new Error('No se pudo leer el audio'));
|
|
2483
|
+
reader.onload = () => resolve(reader.result);
|
|
2484
|
+
reader.readAsDataURL(blob);
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
async function transcribirBlob(blob) {
|
|
2489
|
+
const audio = await blobToDataUrl(blob);
|
|
2490
|
+
const data = await api('/api/transcribe', { method: 'POST', body: { audio } });
|
|
2491
|
+
return String(data?.text ?? '').trim();
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
/** Une los bloques de muestras Float32 en uno solo. */
|
|
2495
|
+
function mergeFloat32(chunks) {
|
|
2496
|
+
const length = chunks.reduce((total, chunk) => total + chunk.length, 0);
|
|
2497
|
+
const out = new Float32Array(length);
|
|
2498
|
+
let offset = 0;
|
|
2499
|
+
for (const chunk of chunks) {
|
|
2500
|
+
out.set(chunk, offset);
|
|
2501
|
+
offset += chunk.length;
|
|
2502
|
+
}
|
|
2503
|
+
return out;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
/** Remuestrea a 16 kHz mono con OfflineAudioContext (sin dependencias). */
|
|
2507
|
+
async function resampleTo16k(samples, inputRate) {
|
|
2508
|
+
if (!samples.length || Math.round(inputRate) === 16000) return samples;
|
|
2509
|
+
const targetLength = Math.max(1, Math.round((samples.length * 16000) / inputRate));
|
|
2510
|
+
const Ctx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
|
2511
|
+
const off = new Ctx(1, targetLength, 16000);
|
|
2512
|
+
const buffer = off.createBuffer(1, samples.length, inputRate);
|
|
2513
|
+
buffer.copyToChannel(samples, 0);
|
|
2514
|
+
const source = off.createBufferSource();
|
|
2515
|
+
source.buffer = buffer;
|
|
2516
|
+
source.connect(off.destination);
|
|
2517
|
+
source.start();
|
|
2518
|
+
const rendered = await off.startRendering();
|
|
2519
|
+
return rendered.getChannelData(0);
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
/** Codifica muestras Float32 (-1..1) como WAV PCM 16-bit (lo acepta cualquier proveedor). */
|
|
2523
|
+
function encodeWav(samples, rate = 16000) {
|
|
2524
|
+
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
|
2525
|
+
const view = new DataView(buffer);
|
|
2526
|
+
const writeString = (offset, text) => {
|
|
2527
|
+
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
|
|
2528
|
+
};
|
|
2529
|
+
writeString(0, 'RIFF');
|
|
2530
|
+
view.setUint32(4, 36 + samples.length * 2, true);
|
|
2531
|
+
writeString(8, 'WAVE');
|
|
2532
|
+
writeString(12, 'fmt ');
|
|
2533
|
+
view.setUint32(16, 16, true);
|
|
2534
|
+
view.setUint16(20, 1, true);
|
|
2535
|
+
view.setUint16(22, 1, true);
|
|
2536
|
+
view.setUint32(24, rate, true);
|
|
2537
|
+
view.setUint32(28, rate * 2, true);
|
|
2538
|
+
view.setUint16(32, 2, true);
|
|
2539
|
+
view.setUint16(34, 16, true);
|
|
2540
|
+
writeString(36, 'data');
|
|
2541
|
+
view.setUint32(40, samples.length * 2, true);
|
|
2542
|
+
let offset = 44;
|
|
2543
|
+
for (let i = 0; i < samples.length; i++) {
|
|
2544
|
+
const sample = Math.max(-1, Math.min(1, samples[i]));
|
|
2545
|
+
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
|
2546
|
+
offset += 2;
|
|
2547
|
+
}
|
|
2548
|
+
return new Blob([view], { type: 'audio/wav' });
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
/**
|
|
2552
|
+
* Una grabación de micrófono (motor del servidor). Graba con Web Audio y devuelve
|
|
2553
|
+
* un **WAV** (no WebM): así el proveedor no depende del contenedor que produce
|
|
2554
|
+
* `MediaRecorder`. `start()` abre el micro (la cuenta 3-2-1 la maneja el que llama);
|
|
2555
|
+
* `stop()` espera la cola antes de cortar. La usan el micrófono del chat y el
|
|
2556
|
+
* "probar" de Ajustes.
|
|
2557
|
+
*/
|
|
2558
|
+
function createRecording() {
|
|
2559
|
+
let resolveBlob;
|
|
2560
|
+
const done = new Promise((resolve) => {
|
|
2561
|
+
resolveBlob = resolve;
|
|
2562
|
+
});
|
|
2563
|
+
let stream = null;
|
|
2564
|
+
let audioCtx = null;
|
|
2565
|
+
let processor = null;
|
|
2566
|
+
let source = null;
|
|
2567
|
+
let sink = null;
|
|
2568
|
+
let stopping = false;
|
|
2569
|
+
let started = false;
|
|
2570
|
+
const chunks = [];
|
|
2571
|
+
|
|
2572
|
+
async function cleanup() {
|
|
2573
|
+
try {
|
|
2574
|
+
processor?.disconnect();
|
|
2575
|
+
} catch {
|
|
2576
|
+
// ya desconectado
|
|
2577
|
+
}
|
|
2578
|
+
try {
|
|
2579
|
+
source?.disconnect();
|
|
2580
|
+
} catch {
|
|
2581
|
+
// ya desconectado
|
|
2582
|
+
}
|
|
2583
|
+
try {
|
|
2584
|
+
sink?.disconnect();
|
|
2585
|
+
} catch {
|
|
2586
|
+
// ya desconectado
|
|
2587
|
+
}
|
|
2588
|
+
try {
|
|
2589
|
+
await audioCtx?.close();
|
|
2590
|
+
} catch {
|
|
2591
|
+
// contexto ya cerrado
|
|
2592
|
+
}
|
|
2593
|
+
stream?.getTracks?.().forEach((track) => track.stop());
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
return {
|
|
2597
|
+
async start() {
|
|
2598
|
+
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
2599
|
+
if (!navigator.mediaDevices?.getUserMedia || !Ctx) {
|
|
2600
|
+
throw new Error('Este navegador no soporta dictado por voz.');
|
|
2601
|
+
}
|
|
2602
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
2603
|
+
audioCtx = new Ctx();
|
|
2604
|
+
if (audioCtx.state === 'suspended') await audioCtx.resume();
|
|
2605
|
+
source = audioCtx.createMediaStreamSource(stream);
|
|
2606
|
+
processor = audioCtx.createScriptProcessor(4096, 1, 1);
|
|
2607
|
+
processor.onaudioprocess = (event) => {
|
|
2608
|
+
chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
|
|
2609
|
+
};
|
|
2610
|
+
// El procesador solo corre si llega al destination: un gain en 0 evita el eco.
|
|
2611
|
+
sink = audioCtx.createGain();
|
|
2612
|
+
sink.gain.value = 0;
|
|
2613
|
+
source.connect(processor);
|
|
2614
|
+
processor.connect(sink);
|
|
2615
|
+
sink.connect(audioCtx.destination);
|
|
2616
|
+
started = true;
|
|
2617
|
+
},
|
|
2618
|
+
get stopping() {
|
|
2619
|
+
return stopping;
|
|
2620
|
+
},
|
|
2621
|
+
stop() {
|
|
2622
|
+
if (stopping || !started) return;
|
|
2623
|
+
stopping = true;
|
|
2624
|
+
setTimeout(async () => {
|
|
2625
|
+
const rate = audioCtx?.sampleRate || 48000;
|
|
2626
|
+
const merged = mergeFloat32(chunks);
|
|
2627
|
+
await cleanup();
|
|
2628
|
+
const resampled = await resampleTo16k(merged, rate);
|
|
2629
|
+
resolveBlob(encodeWav(resampled, 16000));
|
|
2630
|
+
}, REC_TAIL_MS);
|
|
2631
|
+
},
|
|
2632
|
+
done,
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
const ERRORES_DICTADO = {
|
|
2637
|
+
network:
|
|
2638
|
+
'el dictado del navegador necesita el servicio de voz de Google y no respondió (pasa en Brave o sin internet). Probá el motor «Servidor» en Ajustes → Transcripción.',
|
|
2639
|
+
'not-allowed': 'permiso de micrófono denegado.',
|
|
2640
|
+
'service-not-allowed': 'el navegador bloqueó el servicio de dictado.',
|
|
2641
|
+
aborted: 'dictado cancelado.',
|
|
2642
|
+
'audio-capture': 'no encontré micrófono.',
|
|
2643
|
+
'no-speech': 'no escuché nada.',
|
|
2644
|
+
};
|
|
2645
|
+
const DICTADO_FATAL = new Set(['network', 'not-allowed', 'service-not-allowed', 'audio-capture']);
|
|
2646
|
+
|
|
2647
|
+
/**
|
|
2648
|
+
* Dictado nativo del navegador (Web Speech API). `start()`/`stop()` controlan el
|
|
2649
|
+
* ciclo. Los cortes por silencio (`no-speech`) se reconectan solos para no perder
|
|
2650
|
+
* el hilo; los errores fatales (p. ej. `network`) se informan y se cortan.
|
|
2651
|
+
*/
|
|
2652
|
+
function createBrowserDictation({ language, onText, onEnd, onError } = {}) {
|
|
2653
|
+
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
2654
|
+
if (!Ctor) throw new Error('Este navegador no soporta dictado nativo (probá Chrome/Edge).');
|
|
2655
|
+
const rec = new Ctor();
|
|
2656
|
+
rec.lang = language || 'es-AR';
|
|
2657
|
+
rec.continuous = true;
|
|
2658
|
+
rec.interimResults = true;
|
|
2659
|
+
let finalText = '';
|
|
2660
|
+
let wanted = false;
|
|
2661
|
+
let fatal = false;
|
|
2662
|
+
let restarts = 0;
|
|
2663
|
+
|
|
2664
|
+
rec.onresult = (event) => {
|
|
2665
|
+
let interim = '';
|
|
2666
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
2667
|
+
const result = event.results[i];
|
|
2668
|
+
if (result.isFinal) finalText += result[0].transcript;
|
|
2669
|
+
else interim += result[0].transcript;
|
|
2670
|
+
}
|
|
2671
|
+
onText?.(finalText, finalText + interim);
|
|
2672
|
+
};
|
|
2673
|
+
rec.onerror = (event) => {
|
|
2674
|
+
const code = event.error || 'unknown';
|
|
2675
|
+
if (DICTADO_FATAL.has(code)) {
|
|
2676
|
+
fatal = true;
|
|
2677
|
+
wanted = false;
|
|
2678
|
+
onError?.(new Error(ERRORES_DICTADO[code] || `error de dictado (${code}).`));
|
|
2679
|
+
} else if (code !== 'no-speech' && code !== 'aborted') {
|
|
2680
|
+
onError?.(new Error(ERRORES_DICTADO[code] || `error de dictado (${code}).`));
|
|
2681
|
+
}
|
|
2682
|
+
};
|
|
2683
|
+
rec.onend = () => {
|
|
2684
|
+
// Chrome corta tras una pausa: si seguimos "escuchando", reconectamos.
|
|
2685
|
+
if (wanted && !fatal && restarts < 30) {
|
|
2686
|
+
restarts += 1;
|
|
2687
|
+
setTimeout(() => {
|
|
2688
|
+
if (!wanted || fatal) return;
|
|
2689
|
+
try {
|
|
2690
|
+
rec.start();
|
|
2691
|
+
} catch {
|
|
2692
|
+
// se reintenta en el proximo onend
|
|
2693
|
+
}
|
|
2694
|
+
}, 300);
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
wanted = false;
|
|
2698
|
+
onEnd?.();
|
|
2699
|
+
};
|
|
2700
|
+
|
|
2701
|
+
return {
|
|
2702
|
+
start() {
|
|
2703
|
+
wanted = true;
|
|
2704
|
+
fatal = false;
|
|
2705
|
+
restarts = 0;
|
|
2706
|
+
rec.start();
|
|
2707
|
+
},
|
|
2708
|
+
stop() {
|
|
2709
|
+
wanted = false;
|
|
2710
|
+
try {
|
|
2711
|
+
rec.stop();
|
|
2712
|
+
} catch {
|
|
2713
|
+
// ya estaba detenido
|
|
2714
|
+
}
|
|
2715
|
+
},
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
let composerRecording = null;
|
|
2720
|
+
let browserDictation = null;
|
|
2721
|
+
|
|
2722
|
+
function startBrowserMic() {
|
|
2723
|
+
const base = chatInput.value.trim();
|
|
2724
|
+
let dictation;
|
|
2725
|
+
try {
|
|
2726
|
+
dictation = createBrowserDictation({
|
|
2727
|
+
language: transcribeConfig.language,
|
|
2728
|
+
onText: (_final, combined) => {
|
|
2729
|
+
chatInput.value = [base, combined.trim()].filter(Boolean).join(' ');
|
|
2730
|
+
onChatInputChanged();
|
|
2731
|
+
},
|
|
2732
|
+
onEnd: () => {
|
|
2733
|
+
if (browserDictation === dictation) browserDictation = null;
|
|
2734
|
+
micVisual(micBtn);
|
|
2735
|
+
},
|
|
2736
|
+
onError: (error) => toast(`Dictado: ${error.message}`),
|
|
2737
|
+
});
|
|
2738
|
+
} catch (error) {
|
|
2739
|
+
return toast(error.message);
|
|
2740
|
+
}
|
|
2741
|
+
browserDictation = dictation;
|
|
2742
|
+
micVisual(micBtn, { live: true });
|
|
2743
|
+
try {
|
|
2744
|
+
dictation.start();
|
|
2745
|
+
} catch (error) {
|
|
2746
|
+
browserDictation = null;
|
|
2747
|
+
micVisual(micBtn);
|
|
2748
|
+
return toast(`Dictado: ${error.message}`);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
async function toggleMic() {
|
|
2753
|
+
if (!micBtn) return;
|
|
2754
|
+
if (browserDictation) {
|
|
2755
|
+
browserDictation.stop();
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
if (composerRecording) {
|
|
2759
|
+
composerRecording.stop();
|
|
2760
|
+
return;
|
|
2761
|
+
}
|
|
2762
|
+
if (micBtn.disabled) return;
|
|
2763
|
+
if (transcribeConfig.mode === 'browser') return startBrowserMic();
|
|
2764
|
+
|
|
2765
|
+
const rec = createRecording();
|
|
2766
|
+
micBtn.disabled = true;
|
|
2767
|
+
try {
|
|
2768
|
+
await rec.start();
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
micBtn.disabled = false;
|
|
2771
|
+
micVisual(micBtn);
|
|
2772
|
+
return toast(`No pude acceder al micrófono: ${error.message}`);
|
|
2773
|
+
}
|
|
2774
|
+
micBtn.disabled = false;
|
|
2775
|
+
composerRecording = rec;
|
|
2776
|
+
|
|
2777
|
+
// El manejo va antes de la cuenta: si cortás durante el 3-2-1, igual transcribe.
|
|
2778
|
+
rec.done.then(async (blob) => {
|
|
2779
|
+
if (composerRecording === rec) composerRecording = null;
|
|
2780
|
+
if (!blob.size) {
|
|
2781
|
+
micVisual(micBtn);
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
micVisual(micBtn, { busy: true });
|
|
2785
|
+
try {
|
|
2786
|
+
const text = await transcribirBlob(blob);
|
|
2787
|
+
if (!text) return toast('No se entendió el audio.');
|
|
2788
|
+
chatInput.value = chatInput.value.trim() ? `${chatInput.value.trim()} ${text}` : text;
|
|
2789
|
+
onChatInputChanged();
|
|
2790
|
+
chatInput.focus();
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
toast(`No pude transcribir: ${error.message}`);
|
|
2793
|
+
} finally {
|
|
2794
|
+
micVisual(micBtn);
|
|
2795
|
+
}
|
|
2796
|
+
});
|
|
2797
|
+
|
|
2798
|
+
await micCountdown(micBtn, () => rec.stopping);
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
micBtn?.addEventListener('click', toggleMic);
|
|
2802
|
+
|
|
2422
2803
|
/* --- image attachments ---------------------------------------------------- */
|
|
2423
2804
|
|
|
2424
2805
|
const attachInput = $('#image-input');
|
|
@@ -2511,6 +2892,8 @@ const COMMANDS = [
|
|
|
2511
2892
|
['/new', 'nueva sesión (elegís carpeta)'],
|
|
2512
2893
|
['/init', 'analizar el proyecto y crear/actualizar AGENTS.md'],
|
|
2513
2894
|
['/goal <texto>', 'fijar el objetivo de la sesión'],
|
|
2895
|
+
['/resumen', 'resumir la conversación (sin borrarla)'],
|
|
2896
|
+
['/audioresumen', 'el resumen como audio'],
|
|
2514
2897
|
['/compact', 'resumir la conversación y liberar contexto'],
|
|
2515
2898
|
['/clear', 'borrar los mensajes de esta sesión'],
|
|
2516
2899
|
['/cwd', 'mostrar la carpeta de trabajo'],
|
|
@@ -2520,7 +2903,9 @@ const COMMANDS = [
|
|
|
2520
2903
|
|
|
2521
2904
|
function notify(markdown, kind = 'notice') {
|
|
2522
2905
|
const message = addMessage('assistant', '');
|
|
2523
|
-
|
|
2906
|
+
// `kind` puede traer varias clases ("notice err"): classList.add no acepta
|
|
2907
|
+
// espacios, asi que se agregan una por una.
|
|
2908
|
+
for (const cls of String(kind).split(/\s+/).filter(Boolean)) message.textEl.classList.add(cls);
|
|
2524
2909
|
message.textEl.innerHTML = renderMarkdown(markdown);
|
|
2525
2910
|
scrollMessages();
|
|
2526
2911
|
return message;
|
|
@@ -2609,6 +2994,32 @@ async function runCommand(name, arg) {
|
|
|
2609
2994
|
notify(`Objetivo de la sesión: **${arg}**`);
|
|
2610
2995
|
return { handled: true };
|
|
2611
2996
|
}
|
|
2997
|
+
case 'resumen':
|
|
2998
|
+
case 'resume': {
|
|
2999
|
+
if (!state.sessionId) return needSession();
|
|
3000
|
+
notify('Resumiendo la conversación…');
|
|
3001
|
+
const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
|
|
3002
|
+
error: error.message,
|
|
3003
|
+
}));
|
|
3004
|
+
if (out.error) notify(`No se pudo resumir: ${out.error}`, 'notice err');
|
|
3005
|
+
else notify(`**Resumen**\n\n${out.summary}`);
|
|
3006
|
+
return { handled: true };
|
|
3007
|
+
}
|
|
3008
|
+
case 'audioresumen':
|
|
3009
|
+
case 'audio-resumen': {
|
|
3010
|
+
if (!state.sessionId) return needSession();
|
|
3011
|
+
notify('Preparando el resumen…');
|
|
3012
|
+
const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
|
|
3013
|
+
error: error.message,
|
|
3014
|
+
}));
|
|
3015
|
+
if (out.error) {
|
|
3016
|
+
notify(`No se pudo resumir: ${out.error}`, 'notice err');
|
|
3017
|
+
return { handled: true };
|
|
3018
|
+
}
|
|
3019
|
+
notify(`**Resumen**\n\n${out.summary}`);
|
|
3020
|
+
reproducirVoz(out.summary, { label: 'resumen' }).catch((error) => notify(`No se pudo reproducir el audio: ${error.message}`, 'notice err'));
|
|
3021
|
+
return { handled: true };
|
|
3022
|
+
}
|
|
2612
3023
|
case 'compact': {
|
|
2613
3024
|
if (!state.sessionId) return needSession();
|
|
2614
3025
|
notify('Compactando la conversación…');
|
|
@@ -2671,7 +3082,10 @@ function updateSlashMenu() {
|
|
|
2671
3082
|
const value = chatInput.value;
|
|
2672
3083
|
if (!value.startsWith('/') || /\s/.test(value)) return hideSlashMenu();
|
|
2673
3084
|
const query = value.slice(1).toLowerCase();
|
|
2674
|
-
slashMatches = slashCommandEntries().filter(
|
|
3085
|
+
slashMatches = slashCommandEntries().filter(
|
|
3086
|
+
(entry) =>
|
|
3087
|
+
entry.name.slice(1).toLowerCase().startsWith(query) || entry.desc.toLowerCase().includes(query)
|
|
3088
|
+
);
|
|
2675
3089
|
if (!slashMatches.length) return hideSlashMenu();
|
|
2676
3090
|
if (slashIndex >= slashMatches.length) slashIndex = 0;
|
|
2677
3091
|
renderSlashMenu();
|
|
@@ -2734,16 +3148,18 @@ let folderListPath = null;
|
|
|
2734
3148
|
let folderParent = null;
|
|
2735
3149
|
let folderDirs = [];
|
|
2736
3150
|
|
|
2737
|
-
const DEFAULT_CWD_KEY = 'tamnora-default-cwd';
|
|
2738
3151
|
let folderPickerMode = 'session';
|
|
3152
|
+
// La carpeta predeterminada vive en el server (la usan la consola y Telegram),
|
|
3153
|
+
// no en localStorage; acá se cachea para poder leerla sin await.
|
|
3154
|
+
let defaultCwdCache = '';
|
|
2739
3155
|
|
|
2740
3156
|
function readDefaultCwd() {
|
|
2741
|
-
return
|
|
3157
|
+
return defaultCwdCache || '';
|
|
2742
3158
|
}
|
|
2743
3159
|
|
|
2744
3160
|
function writeDefaultCwd(value) {
|
|
2745
|
-
|
|
2746
|
-
|
|
3161
|
+
defaultCwdCache = value || '';
|
|
3162
|
+
api('/api/settings', { method: 'PUT', body: { defaultCwd: defaultCwdCache } }).catch(() => {});
|
|
2747
3163
|
}
|
|
2748
3164
|
|
|
2749
3165
|
function syncDefaultCwd() {
|
|
@@ -3165,6 +3581,11 @@ const tgTokenEl = $('#tg-token');
|
|
|
3165
3581
|
const tgChatsEl = $('#tg-chats');
|
|
3166
3582
|
const tgTranscribeEl = $('#tg-transcribe');
|
|
3167
3583
|
const tgTranscribeProviderEl = $('#tg-transcribe-provider');
|
|
3584
|
+
const tgNotifyEl = $('#tg-notify');
|
|
3585
|
+
const tgNotifyDoneEl = $('#tg-notify-done');
|
|
3586
|
+
const tgNotifyErrorEl = $('#tg-notify-error');
|
|
3587
|
+
const tgNotifyPermissionEl = $('#tg-notify-permission');
|
|
3588
|
+
const tgNotifyAgentEl = $('#tg-notify-agent');
|
|
3168
3589
|
const tgStatusEl = $('#tg-status');
|
|
3169
3590
|
const vozEnabledEl = $('#voz-enabled');
|
|
3170
3591
|
const vozVoiceEl = $('#voz-voice');
|
|
@@ -3173,6 +3594,8 @@ const vozAnnStartEl = $('#voz-ann-start');
|
|
|
3173
3594
|
const vozAnnToolsEl = $('#voz-ann-tools');
|
|
3174
3595
|
const vozAnnEndEl = $('#voz-ann-end');
|
|
3175
3596
|
const vozFfmpegEl = $('#voz-ffmpeg');
|
|
3597
|
+
const trProviderEl = $('#tr-provider');
|
|
3598
|
+
const trModelEl = $('#tr-model');
|
|
3176
3599
|
|
|
3177
3600
|
/* --- ajustes: pestañas internas + límites del agente --------------------- */
|
|
3178
3601
|
|
|
@@ -3450,6 +3873,12 @@ async function refreshChannels() {
|
|
|
3450
3873
|
if (tgTranscribeProviderEl && document.activeElement !== tgTranscribeProviderEl) {
|
|
3451
3874
|
tgTranscribeProviderEl.value = tg.transcribeProvider ?? '';
|
|
3452
3875
|
}
|
|
3876
|
+
const notify = tg.notify ?? {};
|
|
3877
|
+
if (tgNotifyEl) tgNotifyEl.checked = notify.enabled === true;
|
|
3878
|
+
if (tgNotifyDoneEl) tgNotifyDoneEl.checked = notify.done !== false;
|
|
3879
|
+
if (tgNotifyErrorEl) tgNotifyErrorEl.checked = notify.error !== false;
|
|
3880
|
+
if (tgNotifyPermissionEl) tgNotifyPermissionEl.checked = notify.permission !== false;
|
|
3881
|
+
if (tgNotifyAgentEl) tgNotifyAgentEl.checked = notify.agent !== false;
|
|
3453
3882
|
if (tgTokenEl && document.activeElement !== tgTokenEl) {
|
|
3454
3883
|
tgTokenEl.value = '';
|
|
3455
3884
|
tgTokenEl.placeholder = tg.tokenHint ? `${tg.tokenHint} (dejar vacío para no cambiar)` : '123456:ABC…';
|
|
@@ -3463,6 +3892,13 @@ async function submitChannel() {
|
|
|
3463
3892
|
allowedChatIds: tgChatsEl?.value ?? '',
|
|
3464
3893
|
transcribeModel: tgTranscribeEl?.value.trim() || 'whisper-1',
|
|
3465
3894
|
transcribeProvider: tgTranscribeProviderEl?.value.trim() || '',
|
|
3895
|
+
notify: {
|
|
3896
|
+
enabled: tgNotifyEl?.checked === true,
|
|
3897
|
+
done: tgNotifyDoneEl?.checked !== false,
|
|
3898
|
+
error: tgNotifyErrorEl?.checked !== false,
|
|
3899
|
+
permission: tgNotifyPermissionEl?.checked !== false,
|
|
3900
|
+
agent: tgNotifyAgentEl?.checked !== false,
|
|
3901
|
+
},
|
|
3466
3902
|
};
|
|
3467
3903
|
const token = tgTokenEl.value.trim();
|
|
3468
3904
|
if (token) body.token = token;
|
|
@@ -3492,35 +3928,139 @@ const voz = {
|
|
|
3492
3928
|
rate: '+0%',
|
|
3493
3929
|
announce: { start: true, tools: true, end: true },
|
|
3494
3930
|
},
|
|
3495
|
-
cola: Promise.resolve(),
|
|
3496
3931
|
};
|
|
3497
3932
|
|
|
3498
|
-
/**
|
|
3499
|
-
|
|
3933
|
+
/**
|
|
3934
|
+
* Un único reproductor para TODO el audio (narración, tool `speak`,
|
|
3935
|
+
* `/audioresumen`, prueba de voz): encola y reproduce de a uno, en orden, con
|
|
3936
|
+
* controles visibles para pausar o detener. Antes cada audio creaba su propio
|
|
3937
|
+
* `<audio>` y, si el modelo pedía dos, sonaban encimados y sin forma de pararlos.
|
|
3938
|
+
*/
|
|
3939
|
+
const ap = { el: $('#audio-player'), audio: new Audio(), queue: [], current: null, ready: false };
|
|
3940
|
+
|
|
3941
|
+
function ensurePlayer() {
|
|
3942
|
+
if (!ap.el || ap.ready) return;
|
|
3943
|
+
ap.ready = true;
|
|
3944
|
+
ap.toggle = button('', () => toggleAudio(), 'ap-btn');
|
|
3945
|
+
ap.iconEl = el('span', 'ap-icon');
|
|
3946
|
+
ap.toggle.appendChild(ap.iconEl);
|
|
3947
|
+
ap.labelEl = el('span', 'ap-label');
|
|
3948
|
+
ap.barEl = el('div', 'ap-bar');
|
|
3949
|
+
const progress = el('div', 'ap-progress');
|
|
3950
|
+
progress.appendChild(ap.barEl);
|
|
3951
|
+
const info = el('div', 'ap-info');
|
|
3952
|
+
info.append(ap.labelEl, progress);
|
|
3953
|
+
ap.queueEl = el('span', 'ap-queue');
|
|
3954
|
+
ap.stopBtn = button('', () => stopAudio(), 'ap-btn ap-stop');
|
|
3955
|
+
ap.stopBtn.title = 'detener';
|
|
3956
|
+
ap.stopBtn.setAttribute('aria-label', 'detener audio');
|
|
3957
|
+
ap.stopBtn.appendChild(icon('square', 14));
|
|
3958
|
+
ap.el.append(ap.toggle, info, ap.queueEl, ap.stopBtn);
|
|
3959
|
+
|
|
3960
|
+
ap.audio.addEventListener('ended', () => advanceAudio(ap.current));
|
|
3961
|
+
ap.audio.addEventListener('error', () => {
|
|
3962
|
+
if (ap.current && ap.audio.getAttribute('src')) advanceAudio(ap.current, new Error('No se pudo reproducir el audio.'));
|
|
3963
|
+
});
|
|
3964
|
+
ap.audio.addEventListener('play', renderAudio);
|
|
3965
|
+
ap.audio.addEventListener('pause', renderAudio);
|
|
3966
|
+
ap.audio.addEventListener('timeupdate', () => {
|
|
3967
|
+
const { duration, currentTime } = ap.audio;
|
|
3968
|
+
if (!Number.isFinite(duration) || duration <= 0) return;
|
|
3969
|
+
ap.barEl.style.width = `${Math.min(100, (currentTime / duration) * 100)}%`;
|
|
3970
|
+
});
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
/** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
|
|
3974
|
+
function enqueueSpeech({ texto, voice, rate, label = 'audio', maxAgeMs = 0 } = {}) {
|
|
3975
|
+
const frase = String(texto ?? '').trim();
|
|
3976
|
+
if (!frase) return Promise.resolve();
|
|
3977
|
+
ensurePlayer();
|
|
3500
3978
|
return new Promise((resolve, reject) => {
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
rate: rate ?? voz.settings.rate,
|
|
3505
|
-
});
|
|
3506
|
-
const audio = new Audio(`/api/tts?${params}`);
|
|
3507
|
-
audio.onended = () => resolve();
|
|
3508
|
-
audio.onerror = () => reject(new Error('no se pudo reproducir (¿sin conexión?)'));
|
|
3509
|
-
audio.play().catch(() => reject(new Error('el navegador bloqueó el audio: tocá la página y probá de nuevo')));
|
|
3979
|
+
ap.queue.push({ texto: frase, voice, rate, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
|
|
3980
|
+
if (ap.current) renderAudio();
|
|
3981
|
+
else playNextAudio();
|
|
3510
3982
|
});
|
|
3511
3983
|
}
|
|
3512
3984
|
|
|
3985
|
+
function playNextAudio() {
|
|
3986
|
+
const item = ap.queue.shift();
|
|
3987
|
+
if (!item) {
|
|
3988
|
+
ap.current = null;
|
|
3989
|
+
renderAudio();
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
if (item.maxAgeMs && Date.now() - item.createdAt > item.maxAgeMs) {
|
|
3993
|
+
item.resolve?.();
|
|
3994
|
+
return playNextAudio();
|
|
3995
|
+
}
|
|
3996
|
+
ap.current = item;
|
|
3997
|
+
const params = new URLSearchParams({
|
|
3998
|
+
texto: item.texto,
|
|
3999
|
+
voice: item.voice ?? voz.settings.voice,
|
|
4000
|
+
rate: item.rate ?? voz.settings.rate,
|
|
4001
|
+
});
|
|
4002
|
+
ap.audio.src = `/api/tts?${params}`;
|
|
4003
|
+
ap.audio.play().catch(() => {
|
|
4004
|
+
advanceAudio(item, new Error('el navegador bloqueó el audio: tocá la página y probá de nuevo'));
|
|
4005
|
+
});
|
|
4006
|
+
renderAudio();
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
function advanceAudio(item, error) {
|
|
4010
|
+
if (!item || ap.current !== item) return;
|
|
4011
|
+
ap.current = null;
|
|
4012
|
+
if (error) item.reject?.(error);
|
|
4013
|
+
else item.resolve?.();
|
|
4014
|
+
playNextAudio();
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
function toggleAudio() {
|
|
4018
|
+
if (!ap.current) return;
|
|
4019
|
+
if (ap.audio.paused) ap.audio.play().catch(() => {});
|
|
4020
|
+
else ap.audio.pause();
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
/** Detiene lo que suena y vacía lo que espera. */
|
|
4024
|
+
function stopAudio() {
|
|
4025
|
+
for (const item of ap.queue) item.resolve?.();
|
|
4026
|
+
ap.queue = [];
|
|
4027
|
+
const current = ap.current;
|
|
4028
|
+
ap.current = null;
|
|
4029
|
+
current?.resolve?.();
|
|
4030
|
+
ap.audio.pause();
|
|
4031
|
+
ap.audio.removeAttribute('src');
|
|
4032
|
+
ap.audio.load();
|
|
4033
|
+
if (ap.barEl) ap.barEl.style.width = '0%';
|
|
4034
|
+
renderAudio();
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
function renderAudio() {
|
|
4038
|
+
if (!ap.el || !ap.ready) return;
|
|
4039
|
+
const active = Boolean(ap.current) || ap.queue.length > 0;
|
|
4040
|
+
ap.el.hidden = !active;
|
|
4041
|
+
if (!active) return;
|
|
4042
|
+
ap.labelEl.textContent = ap.current?.label ?? 'audio';
|
|
4043
|
+
ap.queueEl.hidden = ap.queue.length === 0;
|
|
4044
|
+
ap.queueEl.textContent = ap.queue.length ? `+${ap.queue.length}` : '';
|
|
4045
|
+
const playing = Boolean(ap.current) && !ap.audio.paused;
|
|
4046
|
+
ap.iconEl.replaceChildren(icon(playing ? 'pause' : 'play', 14));
|
|
4047
|
+
ap.toggle.title = playing ? 'pausar' : 'reproducir';
|
|
4048
|
+
ap.toggle.setAttribute('aria-label', ap.toggle.title);
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
/** Reproduce una frase contra el endpoint de sintesis del server. */
|
|
4052
|
+
function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
|
|
4053
|
+
return enqueueSpeech({ texto: frase, voice, rate, label, maxAgeMs });
|
|
4054
|
+
}
|
|
4055
|
+
|
|
3513
4056
|
/**
|
|
3514
|
-
*
|
|
3515
|
-
*
|
|
4057
|
+
* Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
|
|
4058
|
+
* cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
|
|
3516
4059
|
*/
|
|
3517
4060
|
function hablar(texto) {
|
|
3518
4061
|
const frase = String(texto ?? '').trim();
|
|
3519
4062
|
if (!frase || !voz.settings.enabled) return;
|
|
3520
|
-
|
|
3521
|
-
voz.cola = voz.cola
|
|
3522
|
-
.then(() => (Date.now() - creado > 45000 ? null : reproducirVoz(frase)))
|
|
3523
|
-
.catch(() => {});
|
|
4063
|
+
reproducirVoz(frase, { label: 'narrando', maxAgeMs: 45000 }).catch(() => {});
|
|
3524
4064
|
}
|
|
3525
4065
|
|
|
3526
4066
|
/** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
|
|
@@ -3535,9 +4075,11 @@ function narrar(event, data, turn) {
|
|
|
3535
4075
|
|
|
3536
4076
|
async function cargarVoces() {
|
|
3537
4077
|
if (!vozVoiceEl || vozVoiceEl.options.length) return;
|
|
3538
|
-
const data = await api('/api/tts/voices').catch(() => null);
|
|
4078
|
+
const data = await api('/api/tts/voices?filter=es-').catch(() => null);
|
|
3539
4079
|
if (!data?.voices?.length) return;
|
|
3540
|
-
|
|
4080
|
+
// Solo voces en español (el server ya filtra; guarda por si cambia).
|
|
4081
|
+
const espanol = data.voices.filter((v) => /^es[-_]/i.test(String(v.Locale || v.ShortName || '')));
|
|
4082
|
+
for (const v of espanol.sort((a, b) => String(a.ShortName).localeCompare(String(b.ShortName)))) {
|
|
3541
4083
|
const option = el('option', '', `${v.ShortName}${v.Gender === 'Female' ? ' · femenina' : v.Gender === 'Male' ? ' · masculina' : ''}`);
|
|
3542
4084
|
option.value = v.ShortName;
|
|
3543
4085
|
vozVoiceEl.appendChild(option);
|
|
@@ -3602,10 +4144,166 @@ async function submitVoz() {
|
|
|
3602
4144
|
setStatus('#voz-msg', 'guardado', 'ok');
|
|
3603
4145
|
}
|
|
3604
4146
|
|
|
4147
|
+
/* --- ajustes: transcripción ----------------------------------------------- */
|
|
4148
|
+
|
|
4149
|
+
const trModeEl = $('#tr-mode');
|
|
4150
|
+
const trLanguageEl = $('#tr-language');
|
|
4151
|
+
|
|
4152
|
+
// Config efectiva del dictado; se carga al boot para que el micrófono del chat
|
|
4153
|
+
// la use sin abrir Ajustes.
|
|
4154
|
+
const transcribeConfig = {
|
|
4155
|
+
mode: 'server',
|
|
4156
|
+
language: 'es-AR',
|
|
4157
|
+
provider: '',
|
|
4158
|
+
model: '',
|
|
4159
|
+
fallback: { provider: '', model: '' },
|
|
4160
|
+
};
|
|
4161
|
+
|
|
4162
|
+
function syncTranscribeMode() {
|
|
4163
|
+
const mode = trModeEl?.value ?? transcribeConfig.mode;
|
|
4164
|
+
const serverFields = $('#tr-server-fields');
|
|
4165
|
+
if (serverFields) serverFields.hidden = mode !== 'server';
|
|
4166
|
+
const browserNote = $('#tr-browser-note');
|
|
4167
|
+
if (browserNote) browserNote.hidden = mode !== 'browser';
|
|
4168
|
+
const googleNote = $('#tr-google-note');
|
|
4169
|
+
if (googleNote) googleNote.hidden = mode !== 'google';
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
function aplicarTranscribe(data) {
|
|
4173
|
+
if (!data) return;
|
|
4174
|
+
transcribeConfig.mode = ['browser', 'google'].includes(data.mode) ? data.mode : 'server';
|
|
4175
|
+
transcribeConfig.language = data.language || 'es-AR';
|
|
4176
|
+
transcribeConfig.provider = data.provider ?? '';
|
|
4177
|
+
transcribeConfig.model = data.model ?? '';
|
|
4178
|
+
transcribeConfig.fallback = data.fallback ?? { provider: '', model: '' };
|
|
4179
|
+
if (trModeEl && document.activeElement !== trModeEl) trModeEl.value = transcribeConfig.mode;
|
|
4180
|
+
if (trLanguageEl && document.activeElement !== trLanguageEl) trLanguageEl.value = transcribeConfig.language;
|
|
4181
|
+
if (trProviderEl && document.activeElement !== trProviderEl) trProviderEl.value = transcribeConfig.provider;
|
|
4182
|
+
if (trModelEl && document.activeElement !== trModelEl) trModelEl.value = transcribeConfig.model;
|
|
4183
|
+
if (trProviderEl) trProviderEl.placeholder = transcribeConfig.fallback.provider || 'groq · openai';
|
|
4184
|
+
if (trModelEl) trModelEl.placeholder = transcribeConfig.fallback.model || 'whisper-large-v3-turbo';
|
|
4185
|
+
syncTranscribeMode();
|
|
4186
|
+
if (transcribeConfig.mode === 'browser') {
|
|
4187
|
+
setStatus('#tr-status', `navegador · ${transcribeConfig.language}`, '');
|
|
4188
|
+
} else if (transcribeConfig.mode === 'google') {
|
|
4189
|
+
setStatus('#tr-status', `google (sin API key) · ${transcribeConfig.language}`, '');
|
|
4190
|
+
} else {
|
|
4191
|
+
const effProvider = transcribeConfig.provider || transcribeConfig.fallback.provider || 'proveedor activo';
|
|
4192
|
+
const effModel = transcribeConfig.model || transcribeConfig.fallback.model || 'whisper-1';
|
|
4193
|
+
setStatus('#tr-status', `usa: ${effProvider} · ${effModel}`, '');
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
async function refreshTranscribe() {
|
|
4198
|
+
if (!trModeEl) return;
|
|
4199
|
+
const data = await api('/api/transcribe/settings').catch(() => null);
|
|
4200
|
+
if (data) aplicarTranscribe(data);
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
async function submitTranscribe() {
|
|
4204
|
+
setStatus('#tr-msg', 'guardando…');
|
|
4205
|
+
const data = await api('/api/transcribe/settings', {
|
|
4206
|
+
method: 'PUT',
|
|
4207
|
+
body: {
|
|
4208
|
+
mode: trModeEl?.value || 'server',
|
|
4209
|
+
language: trLanguageEl?.value.trim() || 'es-AR',
|
|
4210
|
+
provider: trProviderEl?.value.trim() || '',
|
|
4211
|
+
model: trModelEl?.value.trim() || '',
|
|
4212
|
+
},
|
|
4213
|
+
}).catch((e) => ({ error: e.message }));
|
|
4214
|
+
if (data?.error) return setStatus('#tr-msg', data.error, 'err');
|
|
4215
|
+
setStatus('#tr-msg', 'guardado', 'ok');
|
|
4216
|
+
aplicarTranscribe(data);
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
$('#tr-save')?.addEventListener('click', submitTranscribe);
|
|
4220
|
+
trModeEl?.addEventListener('change', syncTranscribeMode);
|
|
4221
|
+
|
|
4222
|
+
const trTestBtn = $('#tr-test');
|
|
4223
|
+
let trRecording = null;
|
|
4224
|
+
let browserTest = null;
|
|
4225
|
+
|
|
4226
|
+
function startBrowserTest() {
|
|
4227
|
+
let dictation;
|
|
4228
|
+
try {
|
|
4229
|
+
dictation = createBrowserDictation({
|
|
4230
|
+
language: transcribeConfig.language,
|
|
4231
|
+
onText: (_final, combined) => {
|
|
4232
|
+
const text = combined.trim();
|
|
4233
|
+
setStatus('#tr-test-result', text ? `“${text}”` : 'escuchando…', text ? 'ok' : '');
|
|
4234
|
+
},
|
|
4235
|
+
onEnd: () => {
|
|
4236
|
+
if (browserTest === dictation) browserTest = null;
|
|
4237
|
+
micVisual(trTestBtn);
|
|
4238
|
+
},
|
|
4239
|
+
onError: (error) => setStatus('#tr-test-result', error.message, 'err'),
|
|
4240
|
+
});
|
|
4241
|
+
} catch (error) {
|
|
4242
|
+
return setStatus('#tr-test-result', error.message, 'err');
|
|
4243
|
+
}
|
|
4244
|
+
browserTest = dictation;
|
|
4245
|
+
setStatus('#tr-test-result', '');
|
|
4246
|
+
micVisual(trTestBtn, { live: true });
|
|
4247
|
+
try {
|
|
4248
|
+
dictation.start();
|
|
4249
|
+
} catch (error) {
|
|
4250
|
+
browserTest = null;
|
|
4251
|
+
micVisual(trTestBtn);
|
|
4252
|
+
setStatus('#tr-test-result', error.message, 'err');
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
/** Graba con el micrófono y muestra acá qué devuelve la transcripción. */
|
|
4257
|
+
async function toggleTrTest() {
|
|
4258
|
+
if (!trTestBtn) return;
|
|
4259
|
+
if (browserTest) {
|
|
4260
|
+
browserTest.stop();
|
|
4261
|
+
return;
|
|
4262
|
+
}
|
|
4263
|
+
if (trRecording) {
|
|
4264
|
+
trRecording.stop();
|
|
4265
|
+
return;
|
|
4266
|
+
}
|
|
4267
|
+
if (transcribeConfig.mode === 'browser') return startBrowserTest();
|
|
4268
|
+
|
|
4269
|
+
const rec = createRecording();
|
|
4270
|
+
trTestBtn.disabled = true;
|
|
4271
|
+
setStatus('#tr-test-result', '');
|
|
4272
|
+
try {
|
|
4273
|
+
await rec.start();
|
|
4274
|
+
} catch (error) {
|
|
4275
|
+
trTestBtn.disabled = false;
|
|
4276
|
+
micVisual(trTestBtn);
|
|
4277
|
+
return setStatus('#tr-test-result', `No pude acceder al micrófono: ${error.message}`, 'err');
|
|
4278
|
+
}
|
|
4279
|
+
trTestBtn.disabled = false;
|
|
4280
|
+
trRecording = rec;
|
|
4281
|
+
rec.done.then(async (blob) => {
|
|
4282
|
+
if (trRecording === rec) trRecording = null;
|
|
4283
|
+
if (!blob.size) {
|
|
4284
|
+
micVisual(trTestBtn);
|
|
4285
|
+
return setStatus('#tr-test-result', 'no se grabó audio', 'err');
|
|
4286
|
+
}
|
|
4287
|
+
micVisual(trTestBtn, { busy: true });
|
|
4288
|
+
setStatus('#tr-test-result', 'transcribiendo…');
|
|
4289
|
+
try {
|
|
4290
|
+
const text = await transcribirBlob(blob);
|
|
4291
|
+
setStatus('#tr-test-result', text ? `“${text}”` : 'no se entendió el audio', text ? 'ok' : 'err');
|
|
4292
|
+
} catch (error) {
|
|
4293
|
+
setStatus('#tr-test-result', error.message, 'err');
|
|
4294
|
+
} finally {
|
|
4295
|
+
micVisual(trTestBtn);
|
|
4296
|
+
}
|
|
4297
|
+
});
|
|
4298
|
+
await micCountdown(trTestBtn, () => rec.stopping);
|
|
4299
|
+
}
|
|
4300
|
+
|
|
4301
|
+
trTestBtn?.addEventListener('click', toggleTrTest);
|
|
4302
|
+
|
|
3605
4303
|
async function testVoz() {
|
|
3606
4304
|
setStatus('#voz-msg', 'sintetizando…');
|
|
3607
4305
|
try {
|
|
3608
|
-
await reproducirVoz('Hola, así cuento lo que estoy haciendo.', {
|
|
4306
|
+
await reproducirVoz('Hola, soy tamnora y así es la voz con la que cuento lo que estoy haciendo. ¿Qué te parece?', {
|
|
3609
4307
|
voice: vozVoiceEl?.value,
|
|
3610
4308
|
rate: vozRateEl?.value,
|
|
3611
4309
|
});
|
|
@@ -3615,6 +4313,83 @@ async function testVoz() {
|
|
|
3615
4313
|
}
|
|
3616
4314
|
}
|
|
3617
4315
|
|
|
4316
|
+
/* --- ajustes: perfil del usuario ------------------------------------------ */
|
|
4317
|
+
|
|
4318
|
+
const perfil = {
|
|
4319
|
+
address: $('#perfil-address'),
|
|
4320
|
+
name: $('#perfil-name'),
|
|
4321
|
+
language: $('#perfil-language'),
|
|
4322
|
+
notes: $('#perfil-notes'),
|
|
4323
|
+
fields: $('#perfil-fields'),
|
|
4324
|
+
add: $('#perfil-add-field'),
|
|
4325
|
+
save: $('#perfil-save'),
|
|
4326
|
+
};
|
|
4327
|
+
|
|
4328
|
+
function addPerfilFieldRow(key = '', value = '') {
|
|
4329
|
+
if (!perfil.fields) return;
|
|
4330
|
+
const row = el('div', 'perfil-field');
|
|
4331
|
+
const keyInput = el('input', 'pf-key');
|
|
4332
|
+
keyInput.placeholder = 'clave';
|
|
4333
|
+
keyInput.value = key;
|
|
4334
|
+
keyInput.spellcheck = false;
|
|
4335
|
+
const valueInput = el('input', 'pf-value');
|
|
4336
|
+
valueInput.placeholder = 'valor';
|
|
4337
|
+
valueInput.value = value;
|
|
4338
|
+
valueInput.spellcheck = false;
|
|
4339
|
+
const remove = button('', () => row.remove(), 'btn icon');
|
|
4340
|
+
remove.appendChild(icon('x', 12));
|
|
4341
|
+
row.append(keyInput, valueInput, remove);
|
|
4342
|
+
perfil.fields.appendChild(row);
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
function renderPerfilFields(fields = {}) {
|
|
4346
|
+
if (!perfil.fields) return;
|
|
4347
|
+
clear(perfil.fields);
|
|
4348
|
+
for (const [key, value] of Object.entries(fields)) addPerfilFieldRow(key, value);
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4351
|
+
function collectPerfilFields() {
|
|
4352
|
+
const out = {};
|
|
4353
|
+
for (const row of perfil.fields?.querySelectorAll('.perfil-field') ?? []) {
|
|
4354
|
+
const key = row.querySelector('.pf-key').value.trim();
|
|
4355
|
+
const value = row.querySelector('.pf-value').value.trim();
|
|
4356
|
+
if (key && value) out[key] = value;
|
|
4357
|
+
}
|
|
4358
|
+
return out;
|
|
4359
|
+
}
|
|
4360
|
+
|
|
4361
|
+
async function refreshProfile() {
|
|
4362
|
+
if (!perfil.address) return;
|
|
4363
|
+
const data = await api('/api/profile').catch(() => null);
|
|
4364
|
+
if (!data?.profile) return;
|
|
4365
|
+
const p = data.profile;
|
|
4366
|
+
perfil.address.value = p.address ?? '';
|
|
4367
|
+
perfil.name.value = p.name ?? '';
|
|
4368
|
+
perfil.language.value = p.language ?? '';
|
|
4369
|
+
perfil.notes.value = p.notes ?? '';
|
|
4370
|
+
renderPerfilFields(p.fields ?? {});
|
|
4371
|
+
setStatus('#perfil-status', p.address || p.name ? `trato: ${p.address || p.name}` : 'sin datos', '');
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
async function submitProfile() {
|
|
4375
|
+
if (!perfil.address) return;
|
|
4376
|
+
setStatus('#perfil-msg', 'guardando…');
|
|
4377
|
+
const body = {
|
|
4378
|
+
address: perfil.address.value.trim(),
|
|
4379
|
+
name: perfil.name.value.trim(),
|
|
4380
|
+
language: perfil.language.value.trim(),
|
|
4381
|
+
notes: perfil.notes.value.trim(),
|
|
4382
|
+
fields: collectPerfilFields(),
|
|
4383
|
+
};
|
|
4384
|
+
const data = await api('/api/profile', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
|
|
4385
|
+
if (data?.error) return setStatus('#perfil-msg', data.error, 'err');
|
|
4386
|
+
setStatus('#perfil-msg', 'guardado', 'ok');
|
|
4387
|
+
refreshProfile();
|
|
4388
|
+
}
|
|
4389
|
+
|
|
4390
|
+
perfil.add?.addEventListener('click', () => addPerfilFieldRow());
|
|
4391
|
+
perfil.save?.addEventListener('click', submitProfile);
|
|
4392
|
+
|
|
3618
4393
|
async function importMcp() {
|
|
3619
4394
|
const input = $('#mcp-import');
|
|
3620
4395
|
const value = input.value.trim();
|
|
@@ -4623,6 +5398,7 @@ function watchLifecycle() {
|
|
|
4623
5398
|
// background, asi que esperar al proximo tick puede ser Mucho tiempo.
|
|
4624
5399
|
const wake = () => {
|
|
4625
5400
|
reconcile({ immediate: true });
|
|
5401
|
+
sendPresence();
|
|
4626
5402
|
if (!eventsController || eventsController.signal.aborted) followEvents();
|
|
4627
5403
|
};
|
|
4628
5404
|
document.addEventListener('visibilitychange', () => {
|
|
@@ -4657,10 +5433,13 @@ async function boot() {
|
|
|
4657
5433
|
await refreshProviders();
|
|
4658
5434
|
await loadModes();
|
|
4659
5435
|
await loadModels();
|
|
5436
|
+
await refreshTranscribe();
|
|
4660
5437
|
await reconcile();
|
|
4661
5438
|
await restoreTabs();
|
|
4662
5439
|
renderAlertSignals();
|
|
4663
5440
|
followEvents();
|
|
4664
5441
|
watchLifecycle();
|
|
5442
|
+
// Latido de presencia: mantiene frescas las pestañas abiertas (TTL del server).
|
|
5443
|
+
setInterval(sendPresence, 30000);
|
|
4665
5444
|
}
|
|
4666
5445
|
boot();
|