@danieltmn/tamnora 0.9.0 → 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 +12 -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 +659 -66
  6. package/harness/packages/server/public/index.html +120 -2
  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
 
@@ -998,7 +1006,6 @@ async function deleteFrom(message) {
998
1006
  const ACTIVITY = {
999
1007
  thinking: { cls: 'loader-thinking', label: 'pensando…' },
1000
1008
  working: { cls: 'loader-working', label: 'trabajando…' },
1001
- waiting: { cls: 'loader-thinking', label: 'esperando aprobación…' },
1002
1009
  reconnecting: { cls: 'loader-working', label: 'reconectando…' },
1003
1010
  };
1004
1011
 
@@ -1477,8 +1484,20 @@ function persistCollapsedGroups() {
1477
1484
  storage.setJson('tamnora-collapsed-groups', [...state.collapsedGroups]);
1478
1485
  }
1479
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
+
1480
1498
  function persistTabs() {
1481
1499
  storage.setJson('tamnora-open-tabs', { ids: state.tabOrder, active: state.activeTab });
1500
+ sendPresence();
1482
1501
  }
1483
1502
 
1484
1503
  /**
@@ -1670,7 +1689,6 @@ function showStandalonePermission(record, ask) {
1670
1689
  record.el.querySelector('.empty')?.remove();
1671
1690
  const holder = addMessage('assistant', '', record.el);
1672
1691
  holder.textEl.remove();
1673
- setActivity(holder, 'waiting');
1674
1692
  addPermissionCard(holder, { askId: ask.askId, name: ask.name, args: ask.args });
1675
1693
  state.pendingAsks.set(ask.askId, { sessionId: record.id, name: ask.name ?? '', args: ask.args ?? {} });
1676
1694
  renderAlertSignals();
@@ -1762,6 +1780,7 @@ function closeTab(id) {
1762
1780
  state.panes.delete(id);
1763
1781
  state.queues.delete(id);
1764
1782
  state.tabOrder = state.tabOrder.filter((item) => item !== id);
1783
+ presenceClosed.add(id);
1765
1784
  if (state.activeTab === id) {
1766
1785
  const next = state.tabOrder[state.tabOrder.length - 1] ?? null;
1767
1786
  if (next) {
@@ -1790,7 +1809,11 @@ function refreshTokensDefaults() {
1790
1809
 
1791
1810
  function setWorking(id, working) {
1792
1811
  const record = state.panes.get(id);
1793
- if (record) record.working = working;
1812
+ // `attached` tiene que acompanar a `working`: mientras el pane sigue un run
1813
+ // (tiene un controller vivo) el reconciliador NO debe recrearlo, o el
1814
+ // `replaceChildren` de `renderPane` borra lo que el agente viene haciendo y
1815
+ // solo se recupera recargando.
1816
+ if (record) record.working = record.attached = working;
1794
1817
  renderTabs();
1795
1818
  if (id === state.sessionId) {
1796
1819
  submitBtn.disabled = false;
@@ -2012,15 +2035,14 @@ function handleChatEvent(event, data, turn) {
2012
2035
  const existing = livePermission(data.askId);
2013
2036
  const target = existing ? existing.message : turn.assistant();
2014
2037
  if (!existing) addPermissionCard(target, data);
2015
- setActivity(target, 'waiting');
2016
2038
  state.pendingAsks.set(data.askId, {
2017
2039
  sessionId: data.sessionId ?? turn.sessionKey,
2018
2040
  name: data.name ?? '',
2019
2041
  args: data.args ?? {},
2020
2042
  });
2021
2043
  renderAlertSignals();
2022
- // `setActivity` solo pinta el footer de la sesion activa. Si el permiso cae
2023
- // en otra pestaña, sin esto la tarjeta queda enterrada y el run bloqueado
2044
+ // La tarjeta vive solo en el chat de esta sesion. Si el permiso cae en otra
2045
+ // pestaña, sin este aviso la tarjeta queda enterrada y el run bloqueado
2024
2046
  // esperando una decision que nadie ve.
2025
2047
  if (paneOf(turn.active) !== activePane) {
2026
2048
  toast(`Permiso pendiente: ${data.name} en otra pestaña.`);
@@ -2418,6 +2440,365 @@ chatInput.addEventListener('keydown', (event) => {
2418
2440
  });
2419
2441
  chatInput.addEventListener('input', onChatInputChanged);
2420
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
+
2421
2802
  /* --- image attachments ---------------------------------------------------- */
2422
2803
 
2423
2804
  const attachInput = $('#image-input');
@@ -2519,7 +2900,9 @@ const COMMANDS = [
2519
2900
 
2520
2901
  function notify(markdown, kind = 'notice') {
2521
2902
  const message = addMessage('assistant', '');
2522
- 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);
2523
2906
  message.textEl.innerHTML = renderMarkdown(markdown);
2524
2907
  scrollMessages();
2525
2908
  return message;
@@ -2733,16 +3116,18 @@ let folderListPath = null;
2733
3116
  let folderParent = null;
2734
3117
  let folderDirs = [];
2735
3118
 
2736
- const DEFAULT_CWD_KEY = 'tamnora-default-cwd';
2737
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 = '';
2738
3123
 
2739
3124
  function readDefaultCwd() {
2740
- return storage.get(DEFAULT_CWD_KEY, '') || '';
3125
+ return defaultCwdCache || '';
2741
3126
  }
2742
3127
 
2743
3128
  function writeDefaultCwd(value) {
2744
- if (value) storage.set(DEFAULT_CWD_KEY, value);
2745
- else storage.remove(DEFAULT_CWD_KEY);
3129
+ defaultCwdCache = value || '';
3130
+ api('/api/settings', { method: 'PUT', body: { defaultCwd: defaultCwdCache } }).catch(() => {});
2746
3131
  }
2747
3132
 
2748
3133
  function syncDefaultCwd() {
@@ -3164,6 +3549,11 @@ const tgTokenEl = $('#tg-token');
3164
3549
  const tgChatsEl = $('#tg-chats');
3165
3550
  const tgTranscribeEl = $('#tg-transcribe');
3166
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');
3167
3557
  const tgStatusEl = $('#tg-status');
3168
3558
  const vozEnabledEl = $('#voz-enabled');
3169
3559
  const vozVoiceEl = $('#voz-voice');
@@ -3172,6 +3562,8 @@ const vozAnnStartEl = $('#voz-ann-start');
3172
3562
  const vozAnnToolsEl = $('#voz-ann-tools');
3173
3563
  const vozAnnEndEl = $('#voz-ann-end');
3174
3564
  const vozFfmpegEl = $('#voz-ffmpeg');
3565
+ const trProviderEl = $('#tr-provider');
3566
+ const trModelEl = $('#tr-model');
3175
3567
 
3176
3568
  /* --- ajustes: pestañas internas + límites del agente --------------------- */
3177
3569
 
@@ -3449,6 +3841,12 @@ async function refreshChannels() {
3449
3841
  if (tgTranscribeProviderEl && document.activeElement !== tgTranscribeProviderEl) {
3450
3842
  tgTranscribeProviderEl.value = tg.transcribeProvider ?? '';
3451
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;
3452
3850
  if (tgTokenEl && document.activeElement !== tgTokenEl) {
3453
3851
  tgTokenEl.value = '';
3454
3852
  tgTokenEl.placeholder = tg.tokenHint ? `${tg.tokenHint} (dejar vacío para no cambiar)` : '123456:ABC…';
@@ -3462,6 +3860,13 @@ async function submitChannel() {
3462
3860
  allowedChatIds: tgChatsEl?.value ?? '',
3463
3861
  transcribeModel: tgTranscribeEl?.value.trim() || 'whisper-1',
3464
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
+ },
3465
3870
  };
3466
3871
  const token = tgTokenEl.value.trim();
3467
3872
  if (token) body.token = token;
@@ -3534,9 +3939,11 @@ function narrar(event, data, turn) {
3534
3939
 
3535
3940
  async function cargarVoces() {
3536
3941
  if (!vozVoiceEl || vozVoiceEl.options.length) return;
3537
- const data = await api('/api/tts/voices').catch(() => null);
3942
+ const data = await api('/api/tts/voices?filter=es-').catch(() => null);
3538
3943
  if (!data?.voices?.length) return;
3539
- 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)))) {
3540
3947
  const option = el('option', '', `${v.ShortName}${v.Gender === 'Female' ? ' · femenina' : v.Gender === 'Male' ? ' · masculina' : ''}`);
3541
3948
  option.value = v.ShortName;
3542
3949
  vozVoiceEl.appendChild(option);
@@ -3601,10 +4008,166 @@ async function submitVoz() {
3601
4008
  setStatus('#voz-msg', 'guardado', 'ok');
3602
4009
  }
3603
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
+
3604
4167
  async function testVoz() {
3605
4168
  setStatus('#voz-msg', 'sintetizando…');
3606
4169
  try {
3607
- 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?', {
3608
4171
  voice: vozVoiceEl?.value,
3609
4172
  rate: vozRateEl?.value,
3610
4173
  });
@@ -3614,6 +4177,83 @@ async function testVoz() {
3614
4177
  }
3615
4178
  }
3616
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
+
3617
4257
  async function importMcp() {
3618
4258
  const input = $('#mcp-import');
3619
4259
  const value = input.value.trim();
@@ -4526,56 +5166,6 @@ function renderAlertSignals() {
4526
5166
  document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
4527
5167
  const link = document.querySelector('link[rel="icon"]');
4528
5168
  if (link) link.href = pending > 0 ? FAVICON_ALERT : FAVICON_IDLE;
4529
- renderPermissionBanner();
4530
- }
4531
-
4532
- function sessionLabel(id) {
4533
- const record = state.panes.get(id);
4534
- if (record?.title) return record.title;
4535
- return lastState?.sessions?.find((session) => session.id === id)?.title ?? '';
4536
- }
4537
-
4538
- /**
4539
- * Aviso de un permiso que caio en **otra** sesion.
4540
- *
4541
- * Deliberadamente **sin botones**: la decision se toma en la tarjeta que vive
4542
- * dentro del chat de esa sesion. Poner los botones aca duplicaba la misma
4543
- * pregunta en pantalla, y ademas los dos caminos competian por el mismo
4544
- * `askId`, lo que es una forma de Invite a resolverlo dos veces.
4545
- */
4546
- function renderPermissionBanner() {
4547
- const host = $('#perm-banner');
4548
- if (!host) return;
4549
- clear(host);
4550
- // Los permisos de la sesion que estas mirando ya tienen su tarjeta en el
4551
- // chat: el aviso es solo para los que no ves.
4552
- const others = [...state.pendingAsks.entries()].filter(([, ask]) => {
4553
- const record = state.panes.get(ask.sessionId);
4554
- return !(record && livePermission(ask.askId));
4555
- });
4556
- host.hidden = others.length === 0;
4557
- for (const [askId, ask] of others) {
4558
- const row = el('div', 'perm-banner-row');
4559
- const label = el('div', 'perm-banner-label');
4560
- label.appendChild(el('span', 'perm-banner-icon', '⚠'));
4561
- label.appendChild(el('span', 'perm-banner-title', `permiso para ${ask.name || 'una tool'}`));
4562
- label.appendChild(el('span', 'perm-banner-session', sessionLabel(ask.sessionId) || 'otra sesión'));
4563
- row.appendChild(label);
4564
- const summary = summarizeArgs(ask.name, ask.args, 160);
4565
- if (summary) row.appendChild(el('div', 'perm-banner-args', summary));
4566
- row.appendChild(
4567
- button('abrir', () => {
4568
- host.hidden = true;
4569
- openTab(ask.sessionId).then(() => {
4570
- // La tarjeta se pinta al abrir; si el tab ya existia, `openTab` solo
4571
- // la activa y hay que repintarla.
4572
- const record = state.panes.get(ask.sessionId);
4573
- if (record && !livePermission(askId)) showStandalonePermission(record, ask);
4574
- });
4575
- }, 'btn')
4576
- );
4577
- host.appendChild(row);
4578
- }
4579
5169
  }
4580
5170
 
4581
5171
  /* --- canal global: solo latencia ------------------------------------------ */
@@ -4672,6 +5262,7 @@ function watchLifecycle() {
4672
5262
  // background, asi que esperar al proximo tick puede ser Mucho tiempo.
4673
5263
  const wake = () => {
4674
5264
  reconcile({ immediate: true });
5265
+ sendPresence();
4675
5266
  if (!eventsController || eventsController.signal.aborted) followEvents();
4676
5267
  };
4677
5268
  document.addEventListener('visibilitychange', () => {
@@ -4706,11 +5297,13 @@ async function boot() {
4706
5297
  await refreshProviders();
4707
5298
  await loadModes();
4708
5299
  await loadModels();
5300
+ await refreshTranscribe();
4709
5301
  await reconcile();
4710
5302
  await restoreTabs();
4711
- renderPermissionBanner();
4712
5303
  renderAlertSignals();
4713
5304
  followEvents();
4714
5305
  watchLifecycle();
5306
+ // Latido de presencia: mantiene frescas las pestañas abiertas (TTL del server).
5307
+ setInterval(sendPresence, 30000);
4715
5308
  }
4716
5309
  boot();