@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/harness/packages/cli/src/tui/app.js +22 -3
  3. package/harness/packages/cli/src/tui/layout.js +59 -2
  4. package/harness/packages/cli/test/tui.test.js +30 -0
  5. package/harness/packages/core/src/config/app.js +2 -0
  6. package/harness/packages/core/src/index.js +2 -1
  7. package/harness/packages/core/src/providers/presets.js +4 -0
  8. package/harness/packages/core/src/stt/google.js +91 -0
  9. package/harness/packages/core/src/tools/diff.js +70 -1
  10. package/harness/packages/core/test/diff.test.js +24 -1
  11. package/harness/packages/server/public/app.js +824 -45
  12. package/harness/packages/server/public/index.html +121 -1
  13. package/harness/packages/server/public/styles.css +106 -12
  14. package/harness/packages/server/src/lib/channels/index.js +23 -3
  15. package/harness/packages/server/src/lib/channels/telegram.js +289 -101
  16. package/harness/packages/server/src/lib/presence.js +51 -0
  17. package/harness/packages/server/src/lib/profile.js +63 -0
  18. package/harness/packages/server/src/lib/prompt.js +7 -2
  19. package/harness/packages/server/src/lib/transcribe.js +51 -0
  20. package/harness/packages/server/src/routes/channels.js +2 -0
  21. package/harness/packages/server/src/routes/presence.js +17 -0
  22. package/harness/packages/server/src/routes/profile.js +22 -0
  23. package/harness/packages/server/src/routes/providers.js +2 -0
  24. package/harness/packages/server/src/routes/sessions.js +55 -33
  25. package/harness/packages/server/src/routes/transcribe.js +115 -0
  26. package/harness/packages/server/src/routes/tts.js +2 -1
  27. package/harness/packages/server/src/server.js +11 -0
  28. package/harness/packages/server/test/profile.test.js +85 -0
  29. package/harness/packages/server/test/server.test.js +34 -0
  30. package/harness/packages/server/test/telegram-features.test.js +241 -0
  31. package/harness/packages/server/test/telegram-voz.test.js +12 -5
  32. package/harness/packages/server/test/telegram.test.js +37 -2
  33. package/harness/packages/server/test/transcribe.test.js +215 -0
  34. package/harness/packages/server/test/tts.test.js +24 -0
  35. 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
- /** Reproductor del tool `speak`: sintetiza el texto pedido contra /api/tts. */
1248
- function speakAudio(args) {
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 params = new URLSearchParams({ texto });
1252
- if (args?.voice) params.set('voice', args.voice);
1253
- if (args?.rate) params.set('rate', args.rate);
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 audio = name === 'speak' ? speakAudio(args) : null;
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 (audio) card.appendChild(audio);
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, audio, callId, full: false, truncated: false };
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 player = turn.lastTool?.audio;
2005
- if (player) player.play?.().catch?.(() => {});
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
- message.textEl.classList.add(kind);
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((entry) => entry.name.slice(1).toLowerCase().startsWith(query));
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 storage.get(DEFAULT_CWD_KEY, '') || '';
3157
+ return defaultCwdCache || '';
2742
3158
  }
2743
3159
 
2744
3160
  function writeDefaultCwd(value) {
2745
- if (value) storage.set(DEFAULT_CWD_KEY, value);
2746
- else storage.remove(DEFAULT_CWD_KEY);
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
- /** Reproduce una frase contra el endpoint de sintesis del server. */
3499
- function reproducirVoz(frase, { voice, rate } = {}) {
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
- const params = new URLSearchParams({
3502
- texto: frase,
3503
- voice: voice ?? voz.settings.voice,
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
- * Encola una frase para hablar (secuencial: una a la vez). Si el turno tarda y
3515
- * la frase quedó vieja en la cola, se descarta: narrar el pasado no informa.
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
- const creado = Date.now();
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
- for (const v of [...data.voices].sort((a, b) => String(a.ShortName).localeCompare(String(b.ShortName)))) {
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();