@danieltmn/tamnora 0.9.1 → 0.9.2

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 (27) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/harness/packages/core/src/config/app.js +2 -0
  3. package/harness/packages/core/src/index.js +1 -0
  4. package/harness/packages/core/src/stt/google.js +91 -0
  5. package/harness/packages/server/public/app.js +652 -9
  6. package/harness/packages/server/public/index.html +120 -1
  7. package/harness/packages/server/public/styles.css +19 -0
  8. package/harness/packages/server/src/lib/channels/index.js +23 -3
  9. package/harness/packages/server/src/lib/channels/telegram.js +301 -93
  10. package/harness/packages/server/src/lib/presence.js +51 -0
  11. package/harness/packages/server/src/lib/profile.js +63 -0
  12. package/harness/packages/server/src/lib/prompt.js +7 -2
  13. package/harness/packages/server/src/lib/transcribe.js +51 -0
  14. package/harness/packages/server/src/routes/channels.js +2 -0
  15. package/harness/packages/server/src/routes/presence.js +17 -0
  16. package/harness/packages/server/src/routes/profile.js +22 -0
  17. package/harness/packages/server/src/routes/providers.js +2 -0
  18. package/harness/packages/server/src/routes/transcribe.js +115 -0
  19. package/harness/packages/server/src/routes/tts.js +2 -1
  20. package/harness/packages/server/src/server.js +11 -0
  21. package/harness/packages/server/test/profile.test.js +85 -0
  22. package/harness/packages/server/test/telegram-features.test.js +241 -0
  23. package/harness/packages/server/test/telegram-voz.test.js +9 -0
  24. package/harness/packages/server/test/telegram.test.js +37 -2
  25. package/harness/packages/server/test/transcribe.test.js +215 -0
  26. package/harness/packages/server/test/tts.test.js +24 -0
  27. package/package.json +1 -1
@@ -213,7 +213,7 @@ function activateTab(name) {
213
213
  hideMoreMenu();
214
214
  if (name === 'runs') refreshRuns();
215
215
  if (name === 'providers') refreshProviders();
216
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); }
216
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshTranscribe(); refreshProfile(); }
217
217
  }
218
218
 
219
219
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -461,6 +461,14 @@ async function loadModes() {
461
461
  const settings = await api('/api/settings').catch(() => null);
462
462
  const active = settings?.settings?.mode || 'agent';
463
463
  if ([...modeSelect.options].some((o) => o.value === active)) modeSelect.value = active;
464
+ defaultCwdCache = settings?.settings?.defaultCwd || '';
465
+ if (!defaultCwdCache) {
466
+ // Migración: la carpeta predeterminada vivía en localStorage; la subimos al
467
+ // server una vez para que Telegram también la vea.
468
+ const legacy = storage.get('tamnora-default-cwd', '');
469
+ if (legacy) writeDefaultCwd(legacy);
470
+ }
471
+ syncDefaultCwd();
464
472
  updateStatus();
465
473
  }
466
474
 
@@ -1476,8 +1484,20 @@ function persistCollapsedGroups() {
1476
1484
  storage.setJson('tamnora-collapsed-groups', [...state.collapsedGroups]);
1477
1485
  }
1478
1486
 
1487
+ // Pestañas cerradas desde el último reporte: para que el server las olvide ya,
1488
+ // sin esperar el TTL de presencia.
1489
+ const presenceClosed = new Set();
1490
+
1491
+ /** Reporta al server qué pestañas están abiertas (proyectos "habilitados"). */
1492
+ function sendPresence() {
1493
+ const closed = [...presenceClosed];
1494
+ presenceClosed.clear();
1495
+ return api('/api/presence', { method: 'POST', body: { sessions: state.tabOrder, closed } }).catch(() => {});
1496
+ }
1497
+
1479
1498
  function persistTabs() {
1480
1499
  storage.setJson('tamnora-open-tabs', { ids: state.tabOrder, active: state.activeTab });
1500
+ sendPresence();
1481
1501
  }
1482
1502
 
1483
1503
  /**
@@ -1760,6 +1780,7 @@ function closeTab(id) {
1760
1780
  state.panes.delete(id);
1761
1781
  state.queues.delete(id);
1762
1782
  state.tabOrder = state.tabOrder.filter((item) => item !== id);
1783
+ presenceClosed.add(id);
1763
1784
  if (state.activeTab === id) {
1764
1785
  const next = state.tabOrder[state.tabOrder.length - 1] ?? null;
1765
1786
  if (next) {
@@ -2419,6 +2440,365 @@ chatInput.addEventListener('keydown', (event) => {
2419
2440
  });
2420
2441
  chatInput.addEventListener('input', onChatInputChanged);
2421
2442
 
2443
+ /* --- voz de entrada (dictado) --------------------------------------------- */
2444
+
2445
+ const micBtn = $('#mic-btn');
2446
+
2447
+ // La cuenta 3-2-1 le da tiempo al micro a arrancar (si hablás apenas tocás se
2448
+ // pierden las primeras palabras); la cola al cortar evita perder el final.
2449
+ const REC_COUNT = [3, 2, 1];
2450
+ const REC_COUNT_STEP_MS = 900;
2451
+ const REC_TAIL_MS = 500;
2452
+
2453
+ /** Estado visual del botón de dictado: cuenta regresiva, grabando (verde) o transcribiendo. */
2454
+ function micVisual(button, { live = false, count = null, busy = false } = {}) {
2455
+ if (!button) return;
2456
+ button.classList.toggle('recording', live);
2457
+ button.classList.toggle('busy', busy);
2458
+ button.classList.toggle('mic-counting', count !== null);
2459
+ const span = button.querySelector('.mic-count');
2460
+ if (span) {
2461
+ span.hidden = count === null;
2462
+ span.textContent = count === null ? '' : String(count);
2463
+ }
2464
+ }
2465
+
2466
+ /** Cuenta regresiva antes de que empieces a hablar. Devuelve false si se canceló. */
2467
+ async function micCountdown(button, isCancelled) {
2468
+ for (const count of REC_COUNT) {
2469
+ if (isCancelled()) return false;
2470
+ micVisual(button, { live: true, count });
2471
+ await new Promise((resolve) => setTimeout(resolve, REC_COUNT_STEP_MS));
2472
+ }
2473
+ if (isCancelled()) return false;
2474
+ micVisual(button, { live: true });
2475
+ return true;
2476
+ }
2477
+
2478
+ function blobToDataUrl(blob) {
2479
+ return new Promise((resolve, reject) => {
2480
+ const reader = new FileReader();
2481
+ reader.onerror = () => reject(new Error('No se pudo leer el audio'));
2482
+ reader.onload = () => resolve(reader.result);
2483
+ reader.readAsDataURL(blob);
2484
+ });
2485
+ }
2486
+
2487
+ async function transcribirBlob(blob) {
2488
+ const audio = await blobToDataUrl(blob);
2489
+ const data = await api('/api/transcribe', { method: 'POST', body: { audio } });
2490
+ return String(data?.text ?? '').trim();
2491
+ }
2492
+
2493
+ /** Une los bloques de muestras Float32 en uno solo. */
2494
+ function mergeFloat32(chunks) {
2495
+ const length = chunks.reduce((total, chunk) => total + chunk.length, 0);
2496
+ const out = new Float32Array(length);
2497
+ let offset = 0;
2498
+ for (const chunk of chunks) {
2499
+ out.set(chunk, offset);
2500
+ offset += chunk.length;
2501
+ }
2502
+ return out;
2503
+ }
2504
+
2505
+ /** Remuestrea a 16 kHz mono con OfflineAudioContext (sin dependencias). */
2506
+ async function resampleTo16k(samples, inputRate) {
2507
+ if (!samples.length || Math.round(inputRate) === 16000) return samples;
2508
+ const targetLength = Math.max(1, Math.round((samples.length * 16000) / inputRate));
2509
+ const Ctx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
2510
+ const off = new Ctx(1, targetLength, 16000);
2511
+ const buffer = off.createBuffer(1, samples.length, inputRate);
2512
+ buffer.copyToChannel(samples, 0);
2513
+ const source = off.createBufferSource();
2514
+ source.buffer = buffer;
2515
+ source.connect(off.destination);
2516
+ source.start();
2517
+ const rendered = await off.startRendering();
2518
+ return rendered.getChannelData(0);
2519
+ }
2520
+
2521
+ /** Codifica muestras Float32 (-1..1) como WAV PCM 16-bit (lo acepta cualquier proveedor). */
2522
+ function encodeWav(samples, rate = 16000) {
2523
+ const buffer = new ArrayBuffer(44 + samples.length * 2);
2524
+ const view = new DataView(buffer);
2525
+ const writeString = (offset, text) => {
2526
+ for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
2527
+ };
2528
+ writeString(0, 'RIFF');
2529
+ view.setUint32(4, 36 + samples.length * 2, true);
2530
+ writeString(8, 'WAVE');
2531
+ writeString(12, 'fmt ');
2532
+ view.setUint32(16, 16, true);
2533
+ view.setUint16(20, 1, true);
2534
+ view.setUint16(22, 1, true);
2535
+ view.setUint32(24, rate, true);
2536
+ view.setUint32(28, rate * 2, true);
2537
+ view.setUint16(32, 2, true);
2538
+ view.setUint16(34, 16, true);
2539
+ writeString(36, 'data');
2540
+ view.setUint32(40, samples.length * 2, true);
2541
+ let offset = 44;
2542
+ for (let i = 0; i < samples.length; i++) {
2543
+ const sample = Math.max(-1, Math.min(1, samples[i]));
2544
+ view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
2545
+ offset += 2;
2546
+ }
2547
+ return new Blob([view], { type: 'audio/wav' });
2548
+ }
2549
+
2550
+ /**
2551
+ * Una grabación de micrófono (motor del servidor). Graba con Web Audio y devuelve
2552
+ * un **WAV** (no WebM): así el proveedor no depende del contenedor que produce
2553
+ * `MediaRecorder`. `start()` abre el micro (la cuenta 3-2-1 la maneja el que llama);
2554
+ * `stop()` espera la cola antes de cortar. La usan el micrófono del chat y el
2555
+ * "probar" de Ajustes.
2556
+ */
2557
+ function createRecording() {
2558
+ let resolveBlob;
2559
+ const done = new Promise((resolve) => {
2560
+ resolveBlob = resolve;
2561
+ });
2562
+ let stream = null;
2563
+ let audioCtx = null;
2564
+ let processor = null;
2565
+ let source = null;
2566
+ let sink = null;
2567
+ let stopping = false;
2568
+ let started = false;
2569
+ const chunks = [];
2570
+
2571
+ async function cleanup() {
2572
+ try {
2573
+ processor?.disconnect();
2574
+ } catch {
2575
+ // ya desconectado
2576
+ }
2577
+ try {
2578
+ source?.disconnect();
2579
+ } catch {
2580
+ // ya desconectado
2581
+ }
2582
+ try {
2583
+ sink?.disconnect();
2584
+ } catch {
2585
+ // ya desconectado
2586
+ }
2587
+ try {
2588
+ await audioCtx?.close();
2589
+ } catch {
2590
+ // contexto ya cerrado
2591
+ }
2592
+ stream?.getTracks?.().forEach((track) => track.stop());
2593
+ }
2594
+
2595
+ return {
2596
+ async start() {
2597
+ const Ctx = window.AudioContext || window.webkitAudioContext;
2598
+ if (!navigator.mediaDevices?.getUserMedia || !Ctx) {
2599
+ throw new Error('Este navegador no soporta dictado por voz.');
2600
+ }
2601
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
2602
+ audioCtx = new Ctx();
2603
+ if (audioCtx.state === 'suspended') await audioCtx.resume();
2604
+ source = audioCtx.createMediaStreamSource(stream);
2605
+ processor = audioCtx.createScriptProcessor(4096, 1, 1);
2606
+ processor.onaudioprocess = (event) => {
2607
+ chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
2608
+ };
2609
+ // El procesador solo corre si llega al destination: un gain en 0 evita el eco.
2610
+ sink = audioCtx.createGain();
2611
+ sink.gain.value = 0;
2612
+ source.connect(processor);
2613
+ processor.connect(sink);
2614
+ sink.connect(audioCtx.destination);
2615
+ started = true;
2616
+ },
2617
+ get stopping() {
2618
+ return stopping;
2619
+ },
2620
+ stop() {
2621
+ if (stopping || !started) return;
2622
+ stopping = true;
2623
+ setTimeout(async () => {
2624
+ const rate = audioCtx?.sampleRate || 48000;
2625
+ const merged = mergeFloat32(chunks);
2626
+ await cleanup();
2627
+ const resampled = await resampleTo16k(merged, rate);
2628
+ resolveBlob(encodeWav(resampled, 16000));
2629
+ }, REC_TAIL_MS);
2630
+ },
2631
+ done,
2632
+ };
2633
+ }
2634
+
2635
+ const ERRORES_DICTADO = {
2636
+ network:
2637
+ '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.',
2638
+ 'not-allowed': 'permiso de micrófono denegado.',
2639
+ 'service-not-allowed': 'el navegador bloqueó el servicio de dictado.',
2640
+ aborted: 'dictado cancelado.',
2641
+ 'audio-capture': 'no encontré micrófono.',
2642
+ 'no-speech': 'no escuché nada.',
2643
+ };
2644
+ const DICTADO_FATAL = new Set(['network', 'not-allowed', 'service-not-allowed', 'audio-capture']);
2645
+
2646
+ /**
2647
+ * Dictado nativo del navegador (Web Speech API). `start()`/`stop()` controlan el
2648
+ * ciclo. Los cortes por silencio (`no-speech`) se reconectan solos para no perder
2649
+ * el hilo; los errores fatales (p. ej. `network`) se informan y se cortan.
2650
+ */
2651
+ function createBrowserDictation({ language, onText, onEnd, onError } = {}) {
2652
+ const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
2653
+ if (!Ctor) throw new Error('Este navegador no soporta dictado nativo (probá Chrome/Edge).');
2654
+ const rec = new Ctor();
2655
+ rec.lang = language || 'es-AR';
2656
+ rec.continuous = true;
2657
+ rec.interimResults = true;
2658
+ let finalText = '';
2659
+ let wanted = false;
2660
+ let fatal = false;
2661
+ let restarts = 0;
2662
+
2663
+ rec.onresult = (event) => {
2664
+ let interim = '';
2665
+ for (let i = event.resultIndex; i < event.results.length; i++) {
2666
+ const result = event.results[i];
2667
+ if (result.isFinal) finalText += result[0].transcript;
2668
+ else interim += result[0].transcript;
2669
+ }
2670
+ onText?.(finalText, finalText + interim);
2671
+ };
2672
+ rec.onerror = (event) => {
2673
+ const code = event.error || 'unknown';
2674
+ if (DICTADO_FATAL.has(code)) {
2675
+ fatal = true;
2676
+ wanted = false;
2677
+ onError?.(new Error(ERRORES_DICTADO[code] || `error de dictado (${code}).`));
2678
+ } else if (code !== 'no-speech' && code !== 'aborted') {
2679
+ onError?.(new Error(ERRORES_DICTADO[code] || `error de dictado (${code}).`));
2680
+ }
2681
+ };
2682
+ rec.onend = () => {
2683
+ // Chrome corta tras una pausa: si seguimos "escuchando", reconectamos.
2684
+ if (wanted && !fatal && restarts < 30) {
2685
+ restarts += 1;
2686
+ setTimeout(() => {
2687
+ if (!wanted || fatal) return;
2688
+ try {
2689
+ rec.start();
2690
+ } catch {
2691
+ // se reintenta en el proximo onend
2692
+ }
2693
+ }, 300);
2694
+ return;
2695
+ }
2696
+ wanted = false;
2697
+ onEnd?.();
2698
+ };
2699
+
2700
+ return {
2701
+ start() {
2702
+ wanted = true;
2703
+ fatal = false;
2704
+ restarts = 0;
2705
+ rec.start();
2706
+ },
2707
+ stop() {
2708
+ wanted = false;
2709
+ try {
2710
+ rec.stop();
2711
+ } catch {
2712
+ // ya estaba detenido
2713
+ }
2714
+ },
2715
+ };
2716
+ }
2717
+
2718
+ let composerRecording = null;
2719
+ let browserDictation = null;
2720
+
2721
+ function startBrowserMic() {
2722
+ const base = chatInput.value.trim();
2723
+ let dictation;
2724
+ try {
2725
+ dictation = createBrowserDictation({
2726
+ language: transcribeConfig.language,
2727
+ onText: (_final, combined) => {
2728
+ chatInput.value = [base, combined.trim()].filter(Boolean).join(' ');
2729
+ onChatInputChanged();
2730
+ },
2731
+ onEnd: () => {
2732
+ if (browserDictation === dictation) browserDictation = null;
2733
+ micVisual(micBtn);
2734
+ },
2735
+ onError: (error) => toast(`Dictado: ${error.message}`),
2736
+ });
2737
+ } catch (error) {
2738
+ return toast(error.message);
2739
+ }
2740
+ browserDictation = dictation;
2741
+ micVisual(micBtn, { live: true });
2742
+ try {
2743
+ dictation.start();
2744
+ } catch (error) {
2745
+ browserDictation = null;
2746
+ micVisual(micBtn);
2747
+ return toast(`Dictado: ${error.message}`);
2748
+ }
2749
+ }
2750
+
2751
+ async function toggleMic() {
2752
+ if (!micBtn) return;
2753
+ if (browserDictation) {
2754
+ browserDictation.stop();
2755
+ return;
2756
+ }
2757
+ if (composerRecording) {
2758
+ composerRecording.stop();
2759
+ return;
2760
+ }
2761
+ if (micBtn.disabled) return;
2762
+ if (transcribeConfig.mode === 'browser') return startBrowserMic();
2763
+
2764
+ const rec = createRecording();
2765
+ micBtn.disabled = true;
2766
+ try {
2767
+ await rec.start();
2768
+ } catch (error) {
2769
+ micBtn.disabled = false;
2770
+ micVisual(micBtn);
2771
+ return toast(`No pude acceder al micrófono: ${error.message}`);
2772
+ }
2773
+ micBtn.disabled = false;
2774
+ composerRecording = rec;
2775
+
2776
+ // El manejo va antes de la cuenta: si cortás durante el 3-2-1, igual transcribe.
2777
+ rec.done.then(async (blob) => {
2778
+ if (composerRecording === rec) composerRecording = null;
2779
+ if (!blob.size) {
2780
+ micVisual(micBtn);
2781
+ return;
2782
+ }
2783
+ micVisual(micBtn, { busy: true });
2784
+ try {
2785
+ const text = await transcribirBlob(blob);
2786
+ if (!text) return toast('No se entendió el audio.');
2787
+ chatInput.value = chatInput.value.trim() ? `${chatInput.value.trim()} ${text}` : text;
2788
+ onChatInputChanged();
2789
+ chatInput.focus();
2790
+ } catch (error) {
2791
+ toast(`No pude transcribir: ${error.message}`);
2792
+ } finally {
2793
+ micVisual(micBtn);
2794
+ }
2795
+ });
2796
+
2797
+ await micCountdown(micBtn, () => rec.stopping);
2798
+ }
2799
+
2800
+ micBtn?.addEventListener('click', toggleMic);
2801
+
2422
2802
  /* --- image attachments ---------------------------------------------------- */
2423
2803
 
2424
2804
  const attachInput = $('#image-input');
@@ -2520,7 +2900,9 @@ const COMMANDS = [
2520
2900
 
2521
2901
  function notify(markdown, kind = 'notice') {
2522
2902
  const message = addMessage('assistant', '');
2523
- message.textEl.classList.add(kind);
2903
+ // `kind` puede traer varias clases ("notice err"): classList.add no acepta
2904
+ // espacios, asi que se agregan una por una.
2905
+ for (const cls of String(kind).split(/\s+/).filter(Boolean)) message.textEl.classList.add(cls);
2524
2906
  message.textEl.innerHTML = renderMarkdown(markdown);
2525
2907
  scrollMessages();
2526
2908
  return message;
@@ -2734,16 +3116,18 @@ let folderListPath = null;
2734
3116
  let folderParent = null;
2735
3117
  let folderDirs = [];
2736
3118
 
2737
- const DEFAULT_CWD_KEY = 'tamnora-default-cwd';
2738
3119
  let folderPickerMode = 'session';
3120
+ // La carpeta predeterminada vive en el server (la usan la consola y Telegram),
3121
+ // no en localStorage; acá se cachea para poder leerla sin await.
3122
+ let defaultCwdCache = '';
2739
3123
 
2740
3124
  function readDefaultCwd() {
2741
- return storage.get(DEFAULT_CWD_KEY, '') || '';
3125
+ return defaultCwdCache || '';
2742
3126
  }
2743
3127
 
2744
3128
  function writeDefaultCwd(value) {
2745
- if (value) storage.set(DEFAULT_CWD_KEY, value);
2746
- else storage.remove(DEFAULT_CWD_KEY);
3129
+ defaultCwdCache = value || '';
3130
+ api('/api/settings', { method: 'PUT', body: { defaultCwd: defaultCwdCache } }).catch(() => {});
2747
3131
  }
2748
3132
 
2749
3133
  function syncDefaultCwd() {
@@ -3165,6 +3549,11 @@ const tgTokenEl = $('#tg-token');
3165
3549
  const tgChatsEl = $('#tg-chats');
3166
3550
  const tgTranscribeEl = $('#tg-transcribe');
3167
3551
  const tgTranscribeProviderEl = $('#tg-transcribe-provider');
3552
+ const tgNotifyEl = $('#tg-notify');
3553
+ const tgNotifyDoneEl = $('#tg-notify-done');
3554
+ const tgNotifyErrorEl = $('#tg-notify-error');
3555
+ const tgNotifyPermissionEl = $('#tg-notify-permission');
3556
+ const tgNotifyAgentEl = $('#tg-notify-agent');
3168
3557
  const tgStatusEl = $('#tg-status');
3169
3558
  const vozEnabledEl = $('#voz-enabled');
3170
3559
  const vozVoiceEl = $('#voz-voice');
@@ -3173,6 +3562,8 @@ const vozAnnStartEl = $('#voz-ann-start');
3173
3562
  const vozAnnToolsEl = $('#voz-ann-tools');
3174
3563
  const vozAnnEndEl = $('#voz-ann-end');
3175
3564
  const vozFfmpegEl = $('#voz-ffmpeg');
3565
+ const trProviderEl = $('#tr-provider');
3566
+ const trModelEl = $('#tr-model');
3176
3567
 
3177
3568
  /* --- ajustes: pestañas internas + límites del agente --------------------- */
3178
3569
 
@@ -3450,6 +3841,12 @@ async function refreshChannels() {
3450
3841
  if (tgTranscribeProviderEl && document.activeElement !== tgTranscribeProviderEl) {
3451
3842
  tgTranscribeProviderEl.value = tg.transcribeProvider ?? '';
3452
3843
  }
3844
+ const notify = tg.notify ?? {};
3845
+ if (tgNotifyEl) tgNotifyEl.checked = notify.enabled === true;
3846
+ if (tgNotifyDoneEl) tgNotifyDoneEl.checked = notify.done !== false;
3847
+ if (tgNotifyErrorEl) tgNotifyErrorEl.checked = notify.error !== false;
3848
+ if (tgNotifyPermissionEl) tgNotifyPermissionEl.checked = notify.permission !== false;
3849
+ if (tgNotifyAgentEl) tgNotifyAgentEl.checked = notify.agent !== false;
3453
3850
  if (tgTokenEl && document.activeElement !== tgTokenEl) {
3454
3851
  tgTokenEl.value = '';
3455
3852
  tgTokenEl.placeholder = tg.tokenHint ? `${tg.tokenHint} (dejar vacío para no cambiar)` : '123456:ABC…';
@@ -3463,6 +3860,13 @@ async function submitChannel() {
3463
3860
  allowedChatIds: tgChatsEl?.value ?? '',
3464
3861
  transcribeModel: tgTranscribeEl?.value.trim() || 'whisper-1',
3465
3862
  transcribeProvider: tgTranscribeProviderEl?.value.trim() || '',
3863
+ notify: {
3864
+ enabled: tgNotifyEl?.checked === true,
3865
+ done: tgNotifyDoneEl?.checked !== false,
3866
+ error: tgNotifyErrorEl?.checked !== false,
3867
+ permission: tgNotifyPermissionEl?.checked !== false,
3868
+ agent: tgNotifyAgentEl?.checked !== false,
3869
+ },
3466
3870
  };
3467
3871
  const token = tgTokenEl.value.trim();
3468
3872
  if (token) body.token = token;
@@ -3535,9 +3939,11 @@ function narrar(event, data, turn) {
3535
3939
 
3536
3940
  async function cargarVoces() {
3537
3941
  if (!vozVoiceEl || vozVoiceEl.options.length) return;
3538
- const data = await api('/api/tts/voices').catch(() => null);
3942
+ const data = await api('/api/tts/voices?filter=es-').catch(() => null);
3539
3943
  if (!data?.voices?.length) return;
3540
- for (const v of [...data.voices].sort((a, b) => String(a.ShortName).localeCompare(String(b.ShortName)))) {
3944
+ // Solo voces en español (el server ya filtra; guarda por si cambia).
3945
+ const espanol = data.voices.filter((v) => /^es[-_]/i.test(String(v.Locale || v.ShortName || '')));
3946
+ for (const v of espanol.sort((a, b) => String(a.ShortName).localeCompare(String(b.ShortName)))) {
3541
3947
  const option = el('option', '', `${v.ShortName}${v.Gender === 'Female' ? ' · femenina' : v.Gender === 'Male' ? ' · masculina' : ''}`);
3542
3948
  option.value = v.ShortName;
3543
3949
  vozVoiceEl.appendChild(option);
@@ -3602,10 +4008,166 @@ async function submitVoz() {
3602
4008
  setStatus('#voz-msg', 'guardado', 'ok');
3603
4009
  }
3604
4010
 
4011
+ /* --- ajustes: transcripción ----------------------------------------------- */
4012
+
4013
+ const trModeEl = $('#tr-mode');
4014
+ const trLanguageEl = $('#tr-language');
4015
+
4016
+ // Config efectiva del dictado; se carga al boot para que el micrófono del chat
4017
+ // la use sin abrir Ajustes.
4018
+ const transcribeConfig = {
4019
+ mode: 'server',
4020
+ language: 'es-AR',
4021
+ provider: '',
4022
+ model: '',
4023
+ fallback: { provider: '', model: '' },
4024
+ };
4025
+
4026
+ function syncTranscribeMode() {
4027
+ const mode = trModeEl?.value ?? transcribeConfig.mode;
4028
+ const serverFields = $('#tr-server-fields');
4029
+ if (serverFields) serverFields.hidden = mode !== 'server';
4030
+ const browserNote = $('#tr-browser-note');
4031
+ if (browserNote) browserNote.hidden = mode !== 'browser';
4032
+ const googleNote = $('#tr-google-note');
4033
+ if (googleNote) googleNote.hidden = mode !== 'google';
4034
+ }
4035
+
4036
+ function aplicarTranscribe(data) {
4037
+ if (!data) return;
4038
+ transcribeConfig.mode = ['browser', 'google'].includes(data.mode) ? data.mode : 'server';
4039
+ transcribeConfig.language = data.language || 'es-AR';
4040
+ transcribeConfig.provider = data.provider ?? '';
4041
+ transcribeConfig.model = data.model ?? '';
4042
+ transcribeConfig.fallback = data.fallback ?? { provider: '', model: '' };
4043
+ if (trModeEl && document.activeElement !== trModeEl) trModeEl.value = transcribeConfig.mode;
4044
+ if (trLanguageEl && document.activeElement !== trLanguageEl) trLanguageEl.value = transcribeConfig.language;
4045
+ if (trProviderEl && document.activeElement !== trProviderEl) trProviderEl.value = transcribeConfig.provider;
4046
+ if (trModelEl && document.activeElement !== trModelEl) trModelEl.value = transcribeConfig.model;
4047
+ if (trProviderEl) trProviderEl.placeholder = transcribeConfig.fallback.provider || 'groq · openai';
4048
+ if (trModelEl) trModelEl.placeholder = transcribeConfig.fallback.model || 'whisper-large-v3-turbo';
4049
+ syncTranscribeMode();
4050
+ if (transcribeConfig.mode === 'browser') {
4051
+ setStatus('#tr-status', `navegador · ${transcribeConfig.language}`, '');
4052
+ } else if (transcribeConfig.mode === 'google') {
4053
+ setStatus('#tr-status', `google (sin API key) · ${transcribeConfig.language}`, '');
4054
+ } else {
4055
+ const effProvider = transcribeConfig.provider || transcribeConfig.fallback.provider || 'proveedor activo';
4056
+ const effModel = transcribeConfig.model || transcribeConfig.fallback.model || 'whisper-1';
4057
+ setStatus('#tr-status', `usa: ${effProvider} · ${effModel}`, '');
4058
+ }
4059
+ }
4060
+
4061
+ async function refreshTranscribe() {
4062
+ if (!trModeEl) return;
4063
+ const data = await api('/api/transcribe/settings').catch(() => null);
4064
+ if (data) aplicarTranscribe(data);
4065
+ }
4066
+
4067
+ async function submitTranscribe() {
4068
+ setStatus('#tr-msg', 'guardando…');
4069
+ const data = await api('/api/transcribe/settings', {
4070
+ method: 'PUT',
4071
+ body: {
4072
+ mode: trModeEl?.value || 'server',
4073
+ language: trLanguageEl?.value.trim() || 'es-AR',
4074
+ provider: trProviderEl?.value.trim() || '',
4075
+ model: trModelEl?.value.trim() || '',
4076
+ },
4077
+ }).catch((e) => ({ error: e.message }));
4078
+ if (data?.error) return setStatus('#tr-msg', data.error, 'err');
4079
+ setStatus('#tr-msg', 'guardado', 'ok');
4080
+ aplicarTranscribe(data);
4081
+ }
4082
+
4083
+ $('#tr-save')?.addEventListener('click', submitTranscribe);
4084
+ trModeEl?.addEventListener('change', syncTranscribeMode);
4085
+
4086
+ const trTestBtn = $('#tr-test');
4087
+ let trRecording = null;
4088
+ let browserTest = null;
4089
+
4090
+ function startBrowserTest() {
4091
+ let dictation;
4092
+ try {
4093
+ dictation = createBrowserDictation({
4094
+ language: transcribeConfig.language,
4095
+ onText: (_final, combined) => {
4096
+ const text = combined.trim();
4097
+ setStatus('#tr-test-result', text ? `“${text}”` : 'escuchando…', text ? 'ok' : '');
4098
+ },
4099
+ onEnd: () => {
4100
+ if (browserTest === dictation) browserTest = null;
4101
+ micVisual(trTestBtn);
4102
+ },
4103
+ onError: (error) => setStatus('#tr-test-result', error.message, 'err'),
4104
+ });
4105
+ } catch (error) {
4106
+ return setStatus('#tr-test-result', error.message, 'err');
4107
+ }
4108
+ browserTest = dictation;
4109
+ setStatus('#tr-test-result', '');
4110
+ micVisual(trTestBtn, { live: true });
4111
+ try {
4112
+ dictation.start();
4113
+ } catch (error) {
4114
+ browserTest = null;
4115
+ micVisual(trTestBtn);
4116
+ setStatus('#tr-test-result', error.message, 'err');
4117
+ }
4118
+ }
4119
+
4120
+ /** Graba con el micrófono y muestra acá qué devuelve la transcripción. */
4121
+ async function toggleTrTest() {
4122
+ if (!trTestBtn) return;
4123
+ if (browserTest) {
4124
+ browserTest.stop();
4125
+ return;
4126
+ }
4127
+ if (trRecording) {
4128
+ trRecording.stop();
4129
+ return;
4130
+ }
4131
+ if (transcribeConfig.mode === 'browser') return startBrowserTest();
4132
+
4133
+ const rec = createRecording();
4134
+ trTestBtn.disabled = true;
4135
+ setStatus('#tr-test-result', '');
4136
+ try {
4137
+ await rec.start();
4138
+ } catch (error) {
4139
+ trTestBtn.disabled = false;
4140
+ micVisual(trTestBtn);
4141
+ return setStatus('#tr-test-result', `No pude acceder al micrófono: ${error.message}`, 'err');
4142
+ }
4143
+ trTestBtn.disabled = false;
4144
+ trRecording = rec;
4145
+ rec.done.then(async (blob) => {
4146
+ if (trRecording === rec) trRecording = null;
4147
+ if (!blob.size) {
4148
+ micVisual(trTestBtn);
4149
+ return setStatus('#tr-test-result', 'no se grabó audio', 'err');
4150
+ }
4151
+ micVisual(trTestBtn, { busy: true });
4152
+ setStatus('#tr-test-result', 'transcribiendo…');
4153
+ try {
4154
+ const text = await transcribirBlob(blob);
4155
+ setStatus('#tr-test-result', text ? `“${text}”` : 'no se entendió el audio', text ? 'ok' : 'err');
4156
+ } catch (error) {
4157
+ setStatus('#tr-test-result', error.message, 'err');
4158
+ } finally {
4159
+ micVisual(trTestBtn);
4160
+ }
4161
+ });
4162
+ await micCountdown(trTestBtn, () => rec.stopping);
4163
+ }
4164
+
4165
+ trTestBtn?.addEventListener('click', toggleTrTest);
4166
+
3605
4167
  async function testVoz() {
3606
4168
  setStatus('#voz-msg', 'sintetizando…');
3607
4169
  try {
3608
- await reproducirVoz('Hola, así cuento lo que estoy haciendo.', {
4170
+ await reproducirVoz('Hola, soy tamnora y así es la voz con la que cuento lo que estoy haciendo. ¿Qué te parece?', {
3609
4171
  voice: vozVoiceEl?.value,
3610
4172
  rate: vozRateEl?.value,
3611
4173
  });
@@ -3615,6 +4177,83 @@ async function testVoz() {
3615
4177
  }
3616
4178
  }
3617
4179
 
4180
+ /* --- ajustes: perfil del usuario ------------------------------------------ */
4181
+
4182
+ const perfil = {
4183
+ address: $('#perfil-address'),
4184
+ name: $('#perfil-name'),
4185
+ language: $('#perfil-language'),
4186
+ notes: $('#perfil-notes'),
4187
+ fields: $('#perfil-fields'),
4188
+ add: $('#perfil-add-field'),
4189
+ save: $('#perfil-save'),
4190
+ };
4191
+
4192
+ function addPerfilFieldRow(key = '', value = '') {
4193
+ if (!perfil.fields) return;
4194
+ const row = el('div', 'perfil-field');
4195
+ const keyInput = el('input', 'pf-key');
4196
+ keyInput.placeholder = 'clave';
4197
+ keyInput.value = key;
4198
+ keyInput.spellcheck = false;
4199
+ const valueInput = el('input', 'pf-value');
4200
+ valueInput.placeholder = 'valor';
4201
+ valueInput.value = value;
4202
+ valueInput.spellcheck = false;
4203
+ const remove = button('', () => row.remove(), 'btn icon');
4204
+ remove.appendChild(icon('x', 12));
4205
+ row.append(keyInput, valueInput, remove);
4206
+ perfil.fields.appendChild(row);
4207
+ }
4208
+
4209
+ function renderPerfilFields(fields = {}) {
4210
+ if (!perfil.fields) return;
4211
+ clear(perfil.fields);
4212
+ for (const [key, value] of Object.entries(fields)) addPerfilFieldRow(key, value);
4213
+ }
4214
+
4215
+ function collectPerfilFields() {
4216
+ const out = {};
4217
+ for (const row of perfil.fields?.querySelectorAll('.perfil-field') ?? []) {
4218
+ const key = row.querySelector('.pf-key').value.trim();
4219
+ const value = row.querySelector('.pf-value').value.trim();
4220
+ if (key && value) out[key] = value;
4221
+ }
4222
+ return out;
4223
+ }
4224
+
4225
+ async function refreshProfile() {
4226
+ if (!perfil.address) return;
4227
+ const data = await api('/api/profile').catch(() => null);
4228
+ if (!data?.profile) return;
4229
+ const p = data.profile;
4230
+ perfil.address.value = p.address ?? '';
4231
+ perfil.name.value = p.name ?? '';
4232
+ perfil.language.value = p.language ?? '';
4233
+ perfil.notes.value = p.notes ?? '';
4234
+ renderPerfilFields(p.fields ?? {});
4235
+ setStatus('#perfil-status', p.address || p.name ? `trato: ${p.address || p.name}` : 'sin datos', '');
4236
+ }
4237
+
4238
+ async function submitProfile() {
4239
+ if (!perfil.address) return;
4240
+ setStatus('#perfil-msg', 'guardando…');
4241
+ const body = {
4242
+ address: perfil.address.value.trim(),
4243
+ name: perfil.name.value.trim(),
4244
+ language: perfil.language.value.trim(),
4245
+ notes: perfil.notes.value.trim(),
4246
+ fields: collectPerfilFields(),
4247
+ };
4248
+ const data = await api('/api/profile', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
4249
+ if (data?.error) return setStatus('#perfil-msg', data.error, 'err');
4250
+ setStatus('#perfil-msg', 'guardado', 'ok');
4251
+ refreshProfile();
4252
+ }
4253
+
4254
+ perfil.add?.addEventListener('click', () => addPerfilFieldRow());
4255
+ perfil.save?.addEventListener('click', submitProfile);
4256
+
3618
4257
  async function importMcp() {
3619
4258
  const input = $('#mcp-import');
3620
4259
  const value = input.value.trim();
@@ -4623,6 +5262,7 @@ function watchLifecycle() {
4623
5262
  // background, asi que esperar al proximo tick puede ser Mucho tiempo.
4624
5263
  const wake = () => {
4625
5264
  reconcile({ immediate: true });
5265
+ sendPresence();
4626
5266
  if (!eventsController || eventsController.signal.aborted) followEvents();
4627
5267
  };
4628
5268
  document.addEventListener('visibilitychange', () => {
@@ -4657,10 +5297,13 @@ async function boot() {
4657
5297
  await refreshProviders();
4658
5298
  await loadModes();
4659
5299
  await loadModels();
5300
+ await refreshTranscribe();
4660
5301
  await reconcile();
4661
5302
  await restoreTabs();
4662
5303
  renderAlertSignals();
4663
5304
  followEvents();
4664
5305
  watchLifecycle();
5306
+ // Latido de presencia: mantiene frescas las pestañas abiertas (TTL del server).
5307
+ setInterval(sendPresence, 30000);
4665
5308
  }
4666
5309
  boot();