@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.
- package/CHANGELOG.md +6 -0
- package/harness/packages/core/src/config/app.js +2 -0
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/stt/google.js +91 -0
- package/harness/packages/server/public/app.js +652 -9
- package/harness/packages/server/public/index.html +120 -1
- package/harness/packages/server/public/styles.css +19 -0
- package/harness/packages/server/src/lib/channels/index.js +23 -3
- package/harness/packages/server/src/lib/channels/telegram.js +301 -93
- package/harness/packages/server/src/lib/presence.js +51 -0
- package/harness/packages/server/src/lib/profile.js +63 -0
- package/harness/packages/server/src/lib/prompt.js +7 -2
- package/harness/packages/server/src/lib/transcribe.js +51 -0
- package/harness/packages/server/src/routes/channels.js +2 -0
- package/harness/packages/server/src/routes/presence.js +17 -0
- package/harness/packages/server/src/routes/profile.js +22 -0
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/routes/transcribe.js +115 -0
- package/harness/packages/server/src/routes/tts.js +2 -1
- package/harness/packages/server/src/server.js +11 -0
- package/harness/packages/server/test/profile.test.js +85 -0
- package/harness/packages/server/test/telegram-features.test.js +241 -0
- package/harness/packages/server/test/telegram-voz.test.js +9 -0
- package/harness/packages/server/test/telegram.test.js +37 -2
- package/harness/packages/server/test/transcribe.test.js +215 -0
- package/harness/packages/server/test/tts.test.js +24 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
3125
|
+
return defaultCwdCache || '';
|
|
2742
3126
|
}
|
|
2743
3127
|
|
|
2744
3128
|
function writeDefaultCwd(value) {
|
|
2745
|
-
|
|
2746
|
-
|
|
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
|
-
|
|
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();
|