@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.
- package/CHANGELOG.md +12 -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 +659 -66
- package/harness/packages/server/public/index.html +120 -2
- 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
|
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
2023
|
-
//
|
|
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
|
-
|
|
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
|
|
3125
|
+
return defaultCwdCache || '';
|
|
2741
3126
|
}
|
|
2742
3127
|
|
|
2743
3128
|
function writeDefaultCwd(value) {
|
|
2744
|
-
|
|
2745
|
-
|
|
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
|
-
|
|
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();
|