@danieltmn/tamnora 0.18.0 → 0.20.0
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 +31 -0
- package/README.md +6 -3
- package/harness/packages/cli/src/commands/tui.js +2 -2
- package/harness/packages/cli/src/tui/app.js +2 -1
- package/harness/packages/core/src/agent/compact.js +6 -1
- package/harness/packages/core/src/agent/modes.js +29 -0
- package/harness/packages/core/src/providers/openaiCompatible.js +30 -17
- package/harness/packages/core/src/providers/stream.js +81 -7
- package/harness/packages/core/src/tools/executors.js +2 -2
- package/harness/packages/core/src/tools/registry.js +21 -1
- package/harness/packages/core/src/tools/schema.js +1 -0
- package/harness/packages/core/test/compact.test.js +4 -2
- package/harness/packages/core/test/providers.test.js +29 -0
- package/harness/packages/core/test/stream.test.js +46 -0
- package/harness/packages/core/test/tools.test.js +30 -0
- package/harness/packages/server/public/app.js +433 -53
- package/harness/packages/server/public/index.html +27 -8
- package/harness/packages/server/public/styles.css +51 -1
- package/harness/packages/server/src/lib/advisor.js +14 -13
- package/harness/packages/server/src/lib/channels/telegram.js +8 -3
- package/harness/packages/server/src/lib/compact.js +26 -1
- package/harness/packages/server/src/lib/scheduler.js +1 -0
- package/harness/packages/server/src/lib/sessionView.js +1 -0
- package/harness/packages/server/src/lib/tasks.js +11 -1
- package/harness/packages/server/src/lib/ttsSettings.js +11 -0
- package/harness/packages/server/src/routes/chat.js +20 -26
- package/harness/packages/server/src/routes/sessions.js +20 -11
- package/harness/packages/server/src/routes/tts.js +4 -2
- package/harness/packages/server/test/advisor.test.js +17 -13
- package/harness/packages/server/test/server.test.js +154 -51
- package/harness/packages/server/test/tts.test.js +5 -1
- package/package.json +1 -1
|
@@ -465,7 +465,9 @@ async function loadModes() {
|
|
|
465
465
|
async function setMode(id, { persist = true } = {}) {
|
|
466
466
|
if (![...modeSelect.options].some((o) => o.value === id)) return;
|
|
467
467
|
modeSelect.value = id;
|
|
468
|
-
if (persist) await
|
|
468
|
+
if (persist) await persistAgent({ mode: id });
|
|
469
|
+
// Modo Voz = manos libres (conversación); al salir, se apaga.
|
|
470
|
+
setHandsFree(id === 'voice');
|
|
469
471
|
updateStatus();
|
|
470
472
|
}
|
|
471
473
|
|
|
@@ -499,16 +501,26 @@ function renderCwdPill() {
|
|
|
499
501
|
if (cwdPill) cwdPill.title = state.cwd || 'sin carpeta de trabajo';
|
|
500
502
|
}
|
|
501
503
|
|
|
502
|
-
// El pill de permisos resume modo + auto-aprobación en
|
|
503
|
-
//
|
|
504
|
+
// El pill de permisos resume modo + auto-aprobación en un control estilo Codex:
|
|
505
|
+
// Read Only, Workspace Write, Full access y Voz (notas .md/.json).
|
|
504
506
|
function renderModePill() {
|
|
505
507
|
const plan = modeSelect.value === 'plan';
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
+
const voice = modeSelect.value === 'voice';
|
|
509
|
+
const full = !plan && !voice && Boolean(yesInput?.checked);
|
|
510
|
+
if (modePillLabel) {
|
|
511
|
+
modePillLabel.textContent = voice ? 'Voz' : plan ? 'Read Only' : full ? 'Full access' : 'Workspace Write';
|
|
512
|
+
}
|
|
508
513
|
if (!modeMenu) return;
|
|
509
514
|
for (const item of modeMenu.querySelectorAll('.ctrl-menu-item')) {
|
|
510
515
|
const kind = item.dataset.kind;
|
|
511
|
-
const active =
|
|
516
|
+
const active =
|
|
517
|
+
kind === 'read'
|
|
518
|
+
? plan
|
|
519
|
+
: kind === 'voice'
|
|
520
|
+
? voice
|
|
521
|
+
: kind === 'full'
|
|
522
|
+
? full
|
|
523
|
+
: !plan && !voice && !full;
|
|
512
524
|
item.classList.toggle('active', active);
|
|
513
525
|
item.setAttribute('aria-checked', active ? 'true' : 'false');
|
|
514
526
|
}
|
|
@@ -516,8 +528,11 @@ function renderModePill() {
|
|
|
516
528
|
|
|
517
529
|
function renderModelPill() {
|
|
518
530
|
if (!modelPillLabel) return;
|
|
519
|
-
|
|
520
|
-
|
|
531
|
+
const record = state.panes.get(state.sessionId);
|
|
532
|
+
const provider = record?.provider || state.active.provider;
|
|
533
|
+
const model = record?.model || state.active.model;
|
|
534
|
+
modelPillLabel.textContent = model || 'elegí un modelo';
|
|
535
|
+
if (modelPill) modelPill.title = `${provider || '—'} / ${model || '—'}`;
|
|
521
536
|
}
|
|
522
537
|
|
|
523
538
|
function closeModeMenu() {
|
|
@@ -543,7 +558,7 @@ modeMenu?.addEventListener('click', async (event) => {
|
|
|
543
558
|
if (yesInput) yesInput.checked = true;
|
|
544
559
|
} else {
|
|
545
560
|
if (yesInput) yesInput.checked = false;
|
|
546
|
-
await setMode(kind === 'read' ? 'plan' : 'agent');
|
|
561
|
+
await setMode(kind === 'read' ? 'plan' : kind === 'voice' ? 'voice' : 'agent');
|
|
547
562
|
}
|
|
548
563
|
renderModePill();
|
|
549
564
|
});
|
|
@@ -773,7 +788,45 @@ async function saveSettings(patch) {
|
|
|
773
788
|
updateStatus();
|
|
774
789
|
}
|
|
775
790
|
|
|
776
|
-
|
|
791
|
+
/**
|
|
792
|
+
* Persiste el tipo de agente elegido. Con una sesion abierta, ese tipo es de la
|
|
793
|
+
* sesion (asi dos pestañas pueden correr con proveedor/modelo/modo distintos);
|
|
794
|
+
* ademas se actualiza el default global para que las sesiones nuevas hereden.
|
|
795
|
+
*/
|
|
796
|
+
async function persistAgent(patch) {
|
|
797
|
+
const record = state.panes.get(state.sessionId);
|
|
798
|
+
if (record) {
|
|
799
|
+
if (patch.provider) record.provider = patch.provider;
|
|
800
|
+
if (patch.model) record.model = patch.model;
|
|
801
|
+
if (patch.mode) record.mode = patch.mode;
|
|
802
|
+
api(`/api/sessions/${record.id}`, { method: 'PUT', body: patch }).catch(() => null);
|
|
803
|
+
}
|
|
804
|
+
await saveSettings(patch);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Aplica a los controles compartidos el tipo de agente guardado en una sesion. */
|
|
808
|
+
async function applySessionAgent(record) {
|
|
809
|
+
if (!record) return;
|
|
810
|
+
const id = record.id;
|
|
811
|
+
const provider = record.provider || state.active.provider;
|
|
812
|
+
if (provider && [...providerSelect.options].some((o) => o.value === provider)) {
|
|
813
|
+
providerSelect.value = provider;
|
|
814
|
+
}
|
|
815
|
+
await loadModels({ prefer: record.model || null });
|
|
816
|
+
if (state.sessionId !== id) return;
|
|
817
|
+
const model = record.model || state.active.model;
|
|
818
|
+
if (model && [...modelSelect.options].some((o) => o.value === model)) modelSelect.value = model;
|
|
819
|
+
const mode = record.mode || modeSelect.value || 'agent';
|
|
820
|
+
if ([...modeSelect.options].some((o) => o.value === mode)) modeSelect.value = mode;
|
|
821
|
+
// No auto-encendemos manos libres al abrir (evita pedir el micro al recargar);
|
|
822
|
+
// si la sesión no es de voz, sí apagamos.
|
|
823
|
+
if (mode !== 'voice') setHandsFree(false);
|
|
824
|
+
updateStatus();
|
|
825
|
+
renderModePill();
|
|
826
|
+
renderModelPill();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function loadModels({ save = false, prefer = null } = {}) {
|
|
777
830
|
const provider = providerSelect.value;
|
|
778
831
|
modelSelect.disabled = true;
|
|
779
832
|
const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch((error) => ({
|
|
@@ -801,8 +854,9 @@ async function loadModels({ save = false } = {}) {
|
|
|
801
854
|
if (model.context) option.dataset.context = String(model.context);
|
|
802
855
|
modelSelect.appendChild(option);
|
|
803
856
|
}
|
|
804
|
-
|
|
805
|
-
|
|
857
|
+
const wanted = prefer || state.active.model;
|
|
858
|
+
if (wanted && models.some((m) => m.id === wanted)) {
|
|
859
|
+
modelSelect.value = wanted;
|
|
806
860
|
} else {
|
|
807
861
|
modelSelect.value = models[0].id;
|
|
808
862
|
if (save) await saveSettings({ model: modelSelect.value });
|
|
@@ -813,10 +867,10 @@ async function loadModels({ save = false } = {}) {
|
|
|
813
867
|
}
|
|
814
868
|
|
|
815
869
|
providerSelect.addEventListener('change', async () => {
|
|
816
|
-
await
|
|
817
|
-
await
|
|
870
|
+
await loadModels({ save: false });
|
|
871
|
+
await persistAgent({ provider: providerSelect.value, model: modelSelect.value || undefined });
|
|
818
872
|
});
|
|
819
|
-
modelSelect.addEventListener('change', () =>
|
|
873
|
+
modelSelect.addEventListener('change', () => persistAgent({ model: modelSelect.value }));
|
|
820
874
|
|
|
821
875
|
/* --- chat ---------------------------------------------------------------- */
|
|
822
876
|
|
|
@@ -1091,6 +1145,7 @@ async function deleteFrom(message) {
|
|
|
1091
1145
|
const ACTIVITY = {
|
|
1092
1146
|
thinking: { cls: 'loader-thinking', label: 'pensando…' },
|
|
1093
1147
|
working: { cls: 'loader-working', label: 'trabajando…' },
|
|
1148
|
+
listening: { cls: 'loader-working', label: 'escuchando…' },
|
|
1094
1149
|
reconnecting: { cls: 'loader-working', label: 'reconectando…' },
|
|
1095
1150
|
stopping: { cls: 'loader-working', label: 'deteniendo…' },
|
|
1096
1151
|
};
|
|
@@ -1367,7 +1422,7 @@ async function loadFullToolResult(entry, message) {
|
|
|
1367
1422
|
function speakControl(args) {
|
|
1368
1423
|
const texto = String(args?.texto ?? '').trim();
|
|
1369
1424
|
if (!texto) return null;
|
|
1370
|
-
const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
|
|
1425
|
+
const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, pitch: args?.pitch, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
|
|
1371
1426
|
btn.append(icon('play', 13), el('span', '', 'escuchar'));
|
|
1372
1427
|
return btn;
|
|
1373
1428
|
}
|
|
@@ -1721,8 +1776,27 @@ function addAdvisorCard(container, { text, kind } = {}) {
|
|
|
1721
1776
|
return card;
|
|
1722
1777
|
}
|
|
1723
1778
|
|
|
1779
|
+
/**
|
|
1780
|
+
* Marca del punto de compactación: una línea que corta el historial y, debajo,
|
|
1781
|
+
* el resumen que pasa a ser el contexto del modelo. Lo de arriba sigue visible
|
|
1782
|
+
* como histórico; al modelo solo le llega lo de abajo (más los mensajes nuevos).
|
|
1783
|
+
*/
|
|
1784
|
+
function addCompactCard(container, { text, label = 'contexto compactado' } = {}) {
|
|
1785
|
+
if (!container) return null;
|
|
1786
|
+
const card = el('div', 'compact-card');
|
|
1787
|
+
card.append(el('div', 'compact-rule', label));
|
|
1788
|
+
const clean = String(text ?? '').trim();
|
|
1789
|
+
if (clean) card.append(el('div', 'compact-text', clean));
|
|
1790
|
+
container.appendChild(card);
|
|
1791
|
+
return card;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1724
1794
|
function renderStored(message, pane) {
|
|
1725
1795
|
if (message.agent_id) return;
|
|
1796
|
+
if (message.name === 'compact') {
|
|
1797
|
+
addCompactCard(pane, { text: typeof message.content === 'string' ? message.content : '' });
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1726
1800
|
if (message.role === 'assistant' && message.name === 'advisor') {
|
|
1727
1801
|
addAdvisorCard(pane, {
|
|
1728
1802
|
text: typeof message.content === 'string' ? message.content : '',
|
|
@@ -1955,6 +2029,11 @@ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, run
|
|
|
1955
2029
|
el: pane,
|
|
1956
2030
|
title: session.title || '(sin título)',
|
|
1957
2031
|
cwd: session.cwd || '',
|
|
2032
|
+
// Tipo de agente de la sesion: se aplica a los controles al abrirla y se
|
|
2033
|
+
// persiste al cambiarlos. Cada pestaña puede correr con uno distinto.
|
|
2034
|
+
provider: session.provider || '',
|
|
2035
|
+
model: session.model || '',
|
|
2036
|
+
mode: session.mode || '',
|
|
1958
2037
|
working: false,
|
|
1959
2038
|
tokens,
|
|
1960
2039
|
lastTurn: { prompt: 0, completion: 0 },
|
|
@@ -2048,6 +2127,7 @@ function activateSession(id) {
|
|
|
2048
2127
|
auditStatus(record);
|
|
2049
2128
|
updateStatus();
|
|
2050
2129
|
renderTokens();
|
|
2130
|
+
applySessionAgent(record);
|
|
2051
2131
|
renderQueue(id);
|
|
2052
2132
|
renderTabs();
|
|
2053
2133
|
refreshSessions();
|
|
@@ -2366,7 +2446,7 @@ function handleChatEvent(event, data, turn) {
|
|
|
2366
2446
|
turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
|
|
2367
2447
|
if (turn.live && data.name === 'speak') {
|
|
2368
2448
|
const texto = String(data.args?.texto ?? '').trim();
|
|
2369
|
-
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
|
|
2449
|
+
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, pitch: data.args?.pitch, label: 'voz' }).catch(() => {});
|
|
2370
2450
|
}
|
|
2371
2451
|
if (turn.live && data.name === 'preview_url' && data.args?.url) {
|
|
2372
2452
|
setExplorerMode('preview');
|
|
@@ -2610,6 +2690,9 @@ async function startTurn(sessionKey, pane, input, images, opts = {}) {
|
|
|
2610
2690
|
controllers.set(sessionKey, controller);
|
|
2611
2691
|
setWorking(sessionKey, true);
|
|
2612
2692
|
|
|
2693
|
+
// El tipo de agente lo manda la sesion (no los controles globales): asi dos
|
|
2694
|
+
// pestañas con modelos distintos no se pisan.
|
|
2695
|
+
const owner = state.panes.get(sessionKey);
|
|
2613
2696
|
try {
|
|
2614
2697
|
await streamPost(
|
|
2615
2698
|
'/api/chat',
|
|
@@ -2617,9 +2700,9 @@ async function startTurn(sessionKey, pane, input, images, opts = {}) {
|
|
|
2617
2700
|
input: input || '(imagen adjunta)',
|
|
2618
2701
|
images: images.length ? images : undefined,
|
|
2619
2702
|
sessionId: sessionKey,
|
|
2620
|
-
provider: providerSelect.value || undefined,
|
|
2621
|
-
model: modelSelect.value || undefined,
|
|
2622
|
-
mode: modeSelect.value || undefined,
|
|
2703
|
+
provider: owner?.provider || providerSelect.value || undefined,
|
|
2704
|
+
model: owner?.model || modelSelect.value || undefined,
|
|
2705
|
+
mode: owner?.mode || modeSelect.value || undefined,
|
|
2623
2706
|
yes: yesInput.checked ? true : undefined,
|
|
2624
2707
|
contextTokens: contextTokensForRequest(),
|
|
2625
2708
|
interrupt: opts.interrupt ? true : undefined,
|
|
@@ -2984,7 +3067,7 @@ function encodeWav(samples, rate = 16000) {
|
|
|
2984
3067
|
* `stop()` espera la cola antes de cortar. La usan el micrófono del chat y el
|
|
2985
3068
|
* "probar" de Ajustes.
|
|
2986
3069
|
*/
|
|
2987
|
-
function createRecording() {
|
|
3070
|
+
function createRecording({ silenceMs = 0, noSpeechMs = 0, maxMs = 0, speechThreshold = 0.02 } = {}) {
|
|
2988
3071
|
let resolveBlob;
|
|
2989
3072
|
const done = new Promise((resolve) => {
|
|
2990
3073
|
resolveBlob = resolve;
|
|
@@ -2996,9 +3079,15 @@ function createRecording() {
|
|
|
2996
3079
|
let sink = null;
|
|
2997
3080
|
let stopping = false;
|
|
2998
3081
|
let started = false;
|
|
3082
|
+
let startedAt = 0;
|
|
3083
|
+
let hasSpeech = false;
|
|
3084
|
+
let lastLoudAt = 0;
|
|
3085
|
+
let watchdog = null;
|
|
2999
3086
|
const chunks = [];
|
|
3000
3087
|
|
|
3001
3088
|
async function cleanup() {
|
|
3089
|
+
if (watchdog) clearInterval(watchdog);
|
|
3090
|
+
watchdog = null;
|
|
3002
3091
|
try {
|
|
3003
3092
|
processor?.disconnect();
|
|
3004
3093
|
} catch {
|
|
@@ -3022,6 +3111,37 @@ function createRecording() {
|
|
|
3022
3111
|
stream?.getTracks?.().forEach((track) => track.stop());
|
|
3023
3112
|
}
|
|
3024
3113
|
|
|
3114
|
+
function stopRecording() {
|
|
3115
|
+
if (stopping || !started) return;
|
|
3116
|
+
stopping = true;
|
|
3117
|
+
if (watchdog) clearInterval(watchdog);
|
|
3118
|
+
watchdog = null;
|
|
3119
|
+
setTimeout(async () => {
|
|
3120
|
+
const rate = audioCtx?.sampleRate || 48000;
|
|
3121
|
+
const merged = mergeFloat32(chunks);
|
|
3122
|
+
await cleanup();
|
|
3123
|
+
const resampled = await resampleTo16k(merged, rate);
|
|
3124
|
+
resolveBlob(encodeWav(resampled, 16000));
|
|
3125
|
+
}, REC_TAIL_MS);
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// Manos libres: corta solo por silencio (tras haber hablado), si no hubo voz en
|
|
3129
|
+
// `noSpeechMs` o al llegar a `maxMs`. Sin estas opciones no interviene.
|
|
3130
|
+
function startWatchdog() {
|
|
3131
|
+
if (!silenceMs && !noSpeechMs && !maxMs) return;
|
|
3132
|
+
watchdog = setInterval(() => {
|
|
3133
|
+
if (stopping || !started) return;
|
|
3134
|
+
const now = Date.now();
|
|
3135
|
+
if (maxMs && now - startedAt >= maxMs) return stopRecording();
|
|
3136
|
+
if (!silenceMs) return;
|
|
3137
|
+
if (!hasSpeech) {
|
|
3138
|
+
if (noSpeechMs && now - startedAt >= noSpeechMs) stopRecording();
|
|
3139
|
+
return;
|
|
3140
|
+
}
|
|
3141
|
+
if (now - lastLoudAt >= silenceMs) stopRecording();
|
|
3142
|
+
}, 150);
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3025
3145
|
return {
|
|
3026
3146
|
async start() {
|
|
3027
3147
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
@@ -3034,7 +3154,16 @@ function createRecording() {
|
|
|
3034
3154
|
source = audioCtx.createMediaStreamSource(stream);
|
|
3035
3155
|
processor = audioCtx.createScriptProcessor(4096, 1, 1);
|
|
3036
3156
|
processor.onaudioprocess = (event) => {
|
|
3037
|
-
|
|
3157
|
+
const frame = event.inputBuffer.getChannelData(0);
|
|
3158
|
+
chunks.push(new Float32Array(frame));
|
|
3159
|
+
if (!silenceMs) return;
|
|
3160
|
+
let sum = 0;
|
|
3161
|
+
for (let i = 0; i < frame.length; i += 2) sum += frame[i] * frame[i];
|
|
3162
|
+
const rms = Math.sqrt(sum / (frame.length / 2));
|
|
3163
|
+
if (rms >= speechThreshold) {
|
|
3164
|
+
hasSpeech = true;
|
|
3165
|
+
lastLoudAt = Date.now();
|
|
3166
|
+
}
|
|
3038
3167
|
};
|
|
3039
3168
|
// El procesador solo corre si llega al destination: un gain en 0 evita el eco.
|
|
3040
3169
|
sink = audioCtx.createGain();
|
|
@@ -3043,21 +3172,13 @@ function createRecording() {
|
|
|
3043
3172
|
processor.connect(sink);
|
|
3044
3173
|
sink.connect(audioCtx.destination);
|
|
3045
3174
|
started = true;
|
|
3175
|
+
startedAt = Date.now();
|
|
3176
|
+
startWatchdog();
|
|
3046
3177
|
},
|
|
3047
3178
|
get stopping() {
|
|
3048
3179
|
return stopping;
|
|
3049
3180
|
},
|
|
3050
|
-
stop
|
|
3051
|
-
if (stopping || !started) return;
|
|
3052
|
-
stopping = true;
|
|
3053
|
-
setTimeout(async () => {
|
|
3054
|
-
const rate = audioCtx?.sampleRate || 48000;
|
|
3055
|
-
const merged = mergeFloat32(chunks);
|
|
3056
|
-
await cleanup();
|
|
3057
|
-
const resampled = await resampleTo16k(merged, rate);
|
|
3058
|
-
resolveBlob(encodeWav(resampled, 16000));
|
|
3059
|
-
}, REC_TAIL_MS);
|
|
3060
|
-
},
|
|
3181
|
+
stop: stopRecording,
|
|
3061
3182
|
done,
|
|
3062
3183
|
};
|
|
3063
3184
|
}
|
|
@@ -3180,6 +3301,8 @@ function startBrowserMic() {
|
|
|
3180
3301
|
|
|
3181
3302
|
async function toggleMic() {
|
|
3182
3303
|
if (!micBtn) return;
|
|
3304
|
+
// Con manos libres el micro lo maneja el loop: no dejamos que se pisen.
|
|
3305
|
+
if (handsFree.active) return;
|
|
3183
3306
|
if (browserDictation) {
|
|
3184
3307
|
browserDictation.stop();
|
|
3185
3308
|
return;
|
|
@@ -3229,6 +3352,155 @@ async function toggleMic() {
|
|
|
3229
3352
|
|
|
3230
3353
|
micBtn?.addEventListener('click', toggleMic);
|
|
3231
3354
|
|
|
3355
|
+
/* --- manos libres: conversación por voz ----------------------------------- */
|
|
3356
|
+
|
|
3357
|
+
const HANDS_FREE_SILENCE_MS = 1400; // silencio que cierra tu turno
|
|
3358
|
+
const HANDS_FREE_NOSPEECH_MS = 8000; // si no hablás, vuelve a intentar
|
|
3359
|
+
const HANDS_FREE_MAX_MS = 45000; // tope duro de una intervención
|
|
3360
|
+
|
|
3361
|
+
const handsFreeBtn = $('#hands-free-btn');
|
|
3362
|
+
const handsFree = { active: false, phase: 'idle', token: 0 };
|
|
3363
|
+
|
|
3364
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3365
|
+
|
|
3366
|
+
function renderHandsFree() {
|
|
3367
|
+
if (handsFreeBtn) {
|
|
3368
|
+
handsFreeBtn.classList.toggle('hands-free-active', handsFree.active);
|
|
3369
|
+
handsFreeBtn.classList.toggle('listening', handsFree.active && handsFree.phase === 'listening');
|
|
3370
|
+
handsFreeBtn.setAttribute('aria-pressed', handsFree.active ? 'true' : 'false');
|
|
3371
|
+
handsFreeBtn.title = handsFree.active
|
|
3372
|
+
? 'manos libres: activado (tocá para desactivar)'
|
|
3373
|
+
: 'manos libres: conversación por voz';
|
|
3374
|
+
}
|
|
3375
|
+
if (handsFree.active && handsFree.phase === 'listening') renderFooterActivity('listening');
|
|
3376
|
+
else if (handsFree.active && handsFree.phase === 'thinking') renderFooterActivity('thinking');
|
|
3377
|
+
else if (handsFree.active) renderFooterActivity(null);
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
function setHandsFreePhase(phase) {
|
|
3381
|
+
handsFree.phase = phase;
|
|
3382
|
+
renderHandsFree();
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
function setHandsFree(on) {
|
|
3386
|
+
const next = Boolean(on);
|
|
3387
|
+
if (handsFree.active === next) return;
|
|
3388
|
+
handsFree.active = next;
|
|
3389
|
+
handsFree.token += 1;
|
|
3390
|
+
if (next) {
|
|
3391
|
+
// Cortamos un dictado manual en curso para no pisarlo.
|
|
3392
|
+
composerRecording?.stop();
|
|
3393
|
+
setHandsFreePhase('idle');
|
|
3394
|
+
renderHandsFree();
|
|
3395
|
+
runHandsFree(handsFree.token).catch(() => {});
|
|
3396
|
+
} else {
|
|
3397
|
+
// Cortamos lo que esté grabando para no dejar el micro abierto.
|
|
3398
|
+
handsFree.recording?.stop();
|
|
3399
|
+
handsFree.recording = null;
|
|
3400
|
+
browserDictation?.stop();
|
|
3401
|
+
setHandsFreePhase('idle');
|
|
3402
|
+
renderHandsFree();
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
function toggleHandsFree() {
|
|
3407
|
+
setHandsFree(!handsFree.active);
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
handsFreeBtn?.addEventListener('click', toggleHandsFree);
|
|
3411
|
+
|
|
3412
|
+
/** Una escucha de manos libres: graba hasta el silencio y devuelve el texto. */
|
|
3413
|
+
async function handsFreeListen(token) {
|
|
3414
|
+
if (transcribeConfig.mode === 'browser') {
|
|
3415
|
+
const text = await new Promise((resolve, reject) => {
|
|
3416
|
+
let combined = '';
|
|
3417
|
+
let dictation;
|
|
3418
|
+
const finish = () => {
|
|
3419
|
+
if (browserDictation === dictation) browserDictation = null;
|
|
3420
|
+
resolve(combined.trim());
|
|
3421
|
+
};
|
|
3422
|
+
try {
|
|
3423
|
+
dictation = createBrowserDictation({
|
|
3424
|
+
language: transcribeConfig.language,
|
|
3425
|
+
onText: (_final, all) => {
|
|
3426
|
+
combined = all;
|
|
3427
|
+
},
|
|
3428
|
+
onEnd: finish,
|
|
3429
|
+
onError: reject,
|
|
3430
|
+
});
|
|
3431
|
+
} catch (error) {
|
|
3432
|
+
reject(error);
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
browserDictation = dictation;
|
|
3436
|
+
dictation.start();
|
|
3437
|
+
setTimeout(() => {
|
|
3438
|
+
if (browserDictation === dictation) dictation.stop();
|
|
3439
|
+
}, HANDS_FREE_MAX_MS);
|
|
3440
|
+
});
|
|
3441
|
+
return token === handsFree.token ? text : '';
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
const rec = createRecording({
|
|
3445
|
+
silenceMs: HANDS_FREE_SILENCE_MS,
|
|
3446
|
+
noSpeechMs: HANDS_FREE_NOSPEECH_MS,
|
|
3447
|
+
maxMs: HANDS_FREE_MAX_MS,
|
|
3448
|
+
});
|
|
3449
|
+
await rec.start();
|
|
3450
|
+
if (token !== handsFree.token) {
|
|
3451
|
+
rec.stop();
|
|
3452
|
+
return '';
|
|
3453
|
+
}
|
|
3454
|
+
handsFree.recording = rec;
|
|
3455
|
+
setHandsFreePhase('listening');
|
|
3456
|
+
const blob = await rec.done;
|
|
3457
|
+
handsFree.recording = null;
|
|
3458
|
+
if (token !== handsFree.token || !blob.size) return '';
|
|
3459
|
+
setHandsFreePhase('thinking');
|
|
3460
|
+
try {
|
|
3461
|
+
return await transcribirBlob(blob);
|
|
3462
|
+
} catch (error) {
|
|
3463
|
+
toast(`No pude transcribir: ${error.message}`);
|
|
3464
|
+
return '';
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
/** Loop conversacional: escuchar → enviar → esperar la voz → repetir. */
|
|
3469
|
+
async function runHandsFree(token) {
|
|
3470
|
+
while (handsFree.active && token === handsFree.token) {
|
|
3471
|
+
if (!state.sessionId) {
|
|
3472
|
+
toast('Manos libres necesita una sesión abierta.');
|
|
3473
|
+
setHandsFree(false);
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
if (state.panes.get(state.sessionId)?.working) {
|
|
3477
|
+
await sleep(400);
|
|
3478
|
+
continue;
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
let text = '';
|
|
3482
|
+
try {
|
|
3483
|
+
text = await handsFreeListen(token);
|
|
3484
|
+
} catch (error) {
|
|
3485
|
+
if (token !== handsFree.token) return;
|
|
3486
|
+
toast(`Manos libres: ${error.message}`);
|
|
3487
|
+
setHandsFree(false);
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3490
|
+
if (!handsFree.active || token !== handsFree.token) return;
|
|
3491
|
+
if (!text) continue; // silencio o no se entendió: otra vuelta
|
|
3492
|
+
|
|
3493
|
+
chatInput.value = text;
|
|
3494
|
+
onChatInputChanged();
|
|
3495
|
+
setHandsFreePhase('thinking');
|
|
3496
|
+
await sendMessage();
|
|
3497
|
+
if (!handsFree.active || token !== handsFree.token) return;
|
|
3498
|
+
// Esperamos a que termine de hablar antes de volver a escuchar (evita el eco).
|
|
3499
|
+
await whenAudioIdle();
|
|
3500
|
+
await sleep(250);
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3232
3504
|
/* --- image attachments ---------------------------------------------------- */
|
|
3233
3505
|
|
|
3234
3506
|
const attachInput = $('#image-input');
|
|
@@ -3465,13 +3737,18 @@ async function runCommand(name, arg) {
|
|
|
3465
3737
|
}
|
|
3466
3738
|
case 'compact': {
|
|
3467
3739
|
if (!state.sessionId) return needSession();
|
|
3468
|
-
|
|
3740
|
+
toast('Compactando la conversación…');
|
|
3469
3741
|
const out = await api(`/api/sessions/${state.sessionId}/compact`, { method: 'POST' }).catch((error) => ({
|
|
3470
3742
|
error: error.message,
|
|
3471
3743
|
}));
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3744
|
+
if (out.error) {
|
|
3745
|
+
notify(`No se pudo compactar: ${out.error}`, 'notice err');
|
|
3746
|
+
return { handled: true };
|
|
3747
|
+
}
|
|
3748
|
+
// No borramos el pane: el historial queda a la vista y marcamos el corte
|
|
3749
|
+
// con la línea + el resumen que ahora ve el modelo.
|
|
3750
|
+
addCompactCard(activePane, { text: out.summary });
|
|
3751
|
+
scrollMessages();
|
|
3475
3752
|
refreshSessions();
|
|
3476
3753
|
return { handled: true };
|
|
3477
3754
|
}
|
|
@@ -3736,7 +4013,17 @@ async function loadFolders(target = '') {
|
|
|
3736
4013
|
}
|
|
3737
4014
|
|
|
3738
4015
|
async function createSessionIn(cwd, { title = 'Nueva sesión' } = {}) {
|
|
3739
|
-
const
|
|
4016
|
+
const current = state.panes.get(state.sessionId);
|
|
4017
|
+
const created = await api('/api/sessions', {
|
|
4018
|
+
method: 'POST',
|
|
4019
|
+
body: {
|
|
4020
|
+
title,
|
|
4021
|
+
cwd,
|
|
4022
|
+
provider: current?.provider || providerSelect.value || undefined,
|
|
4023
|
+
model: current?.model || modelSelect.value || undefined,
|
|
4024
|
+
mode: current?.mode || modeSelect.value || undefined,
|
|
4025
|
+
},
|
|
4026
|
+
}).catch((error) => ({
|
|
3740
4027
|
error: error.message,
|
|
3741
4028
|
}));
|
|
3742
4029
|
if (created?.error) return created;
|
|
@@ -3866,6 +4153,8 @@ async function refreshProviders() {
|
|
|
3866
4153
|
state.active = data.active;
|
|
3867
4154
|
syncTopbar();
|
|
3868
4155
|
renderProviderList();
|
|
4156
|
+
const record = state.panes.get(state.sessionId);
|
|
4157
|
+
if (record) await applySessionAgent(record);
|
|
3869
4158
|
}
|
|
3870
4159
|
|
|
3871
4160
|
function isConfigured(p) {
|
|
@@ -3881,7 +4170,8 @@ function syncTopbar() {
|
|
|
3881
4170
|
option.textContent = p.label && p.label !== p.id ? `${p.label} (${p.id})` : p.id;
|
|
3882
4171
|
providerSelect.appendChild(option);
|
|
3883
4172
|
}
|
|
3884
|
-
const
|
|
4173
|
+
const record = state.panes.get(state.sessionId);
|
|
4174
|
+
const active = record?.provider || state.active.provider;
|
|
3885
4175
|
if (active && !configured.some((p) => p.id === active)) {
|
|
3886
4176
|
const current = state.providers.find((p) => p.id === active);
|
|
3887
4177
|
const option = document.createElement('option');
|
|
@@ -3930,8 +4220,9 @@ function renderProviderList() {
|
|
|
3930
4220
|
}
|
|
3931
4221
|
|
|
3932
4222
|
async function useProvider(id) {
|
|
3933
|
-
|
|
3934
|
-
await loadModels({ save:
|
|
4223
|
+
providerSelect.value = id;
|
|
4224
|
+
await loadModels({ save: false });
|
|
4225
|
+
await persistAgent({ provider: id, model: modelSelect.value || undefined });
|
|
3935
4226
|
await refreshProviders();
|
|
3936
4227
|
}
|
|
3937
4228
|
|
|
@@ -4050,6 +4341,9 @@ const tgStatusEl = $('#tg-status');
|
|
|
4050
4341
|
const vozEnabledEl = $('#voz-enabled');
|
|
4051
4342
|
const vozVoiceEl = $('#voz-voice');
|
|
4052
4343
|
const vozRateEl = $('#voz-rate');
|
|
4344
|
+
const vozRateValEl = $('#voz-rate-val');
|
|
4345
|
+
const vozPitchEl = $('#voz-pitch');
|
|
4346
|
+
const vozPitchValEl = $('#voz-pitch-val');
|
|
4053
4347
|
const vozAnnStartEl = $('#voz-ann-start');
|
|
4054
4348
|
const vozAnnToolsEl = $('#voz-ann-tools');
|
|
4055
4349
|
const vozAnnEndEl = $('#voz-ann-end');
|
|
@@ -4645,6 +4939,7 @@ const voz = {
|
|
|
4645
4939
|
enabled: false,
|
|
4646
4940
|
voice: 'es-AR-ElenaNeural',
|
|
4647
4941
|
rate: '+0%',
|
|
4942
|
+
pitch: '+0Hz',
|
|
4648
4943
|
announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
|
|
4649
4944
|
},
|
|
4650
4945
|
};
|
|
@@ -4657,6 +4952,18 @@ const voz = {
|
|
|
4657
4952
|
*/
|
|
4658
4953
|
const ap = { el: $('#audio-player'), audio: new Audio(), queue: [], current: null, ready: false };
|
|
4659
4954
|
|
|
4955
|
+
// Avisa cuando el reproductor queda en silencio (cola vacia). Lo usa manos libres
|
|
4956
|
+
// para saber cuando el asistente termino de hablar y volver a escuchar.
|
|
4957
|
+
const audioIdleWaiters = [];
|
|
4958
|
+
function whenAudioIdle() {
|
|
4959
|
+
if (!ap.current && !ap.queue.length) return Promise.resolve();
|
|
4960
|
+
return new Promise((resolve) => audioIdleWaiters.push(resolve));
|
|
4961
|
+
}
|
|
4962
|
+
function notifyAudioIdle() {
|
|
4963
|
+
if (ap.current || ap.queue.length) return;
|
|
4964
|
+
for (const resolve of audioIdleWaiters.splice(0)) resolve();
|
|
4965
|
+
}
|
|
4966
|
+
|
|
4660
4967
|
function ensurePlayer() {
|
|
4661
4968
|
if (!ap.el || ap.ready) return;
|
|
4662
4969
|
ap.ready = true;
|
|
@@ -4689,13 +4996,53 @@ function ensurePlayer() {
|
|
|
4689
4996
|
});
|
|
4690
4997
|
}
|
|
4691
4998
|
|
|
4999
|
+
/** Convierte la velocidad guardada ("+20%") al valor del slider (20). */
|
|
5000
|
+
function rateToSlider(rate) {
|
|
5001
|
+
const m = /^([+-]?)(\d+)%$/.exec(String(rate ?? '').trim());
|
|
5002
|
+
return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
|
|
5003
|
+
}
|
|
5004
|
+
|
|
5005
|
+
/** Convierte el valor del slider (-20) a la velocidad guardada. */
|
|
5006
|
+
function sliderToRate(n) {
|
|
5007
|
+
const v = Math.max(-100, Math.min(100, Math.round(Number(n) || 0)));
|
|
5008
|
+
return v === 0 ? '+0%' : `${v > 0 ? '+' : '-'}${Math.abs(v)}%`;
|
|
5009
|
+
}
|
|
5010
|
+
|
|
5011
|
+
/** Convierte el tono guardado ("-10Hz") al valor del slider (-10). */
|
|
5012
|
+
function pitchToSlider(pitch) {
|
|
5013
|
+
const m = /^([+-]?)(\d+)Hz$/.exec(String(pitch ?? '').trim());
|
|
5014
|
+
return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
|
|
5015
|
+
}
|
|
5016
|
+
|
|
5017
|
+
/** Convierte el valor del slider (-10) al tono guardado. */
|
|
5018
|
+
function sliderToPitch(n) {
|
|
5019
|
+
const v = Math.max(-50, Math.min(50, Math.round(Number(n) || 0)));
|
|
5020
|
+
return v === 0 ? '+0Hz' : `${v > 0 ? '+' : '-'}${Math.abs(v)}Hz`;
|
|
5021
|
+
}
|
|
5022
|
+
|
|
5023
|
+
function etiquetaRate(n) {
|
|
5024
|
+
const v = Math.round(Number(n) || 0);
|
|
5025
|
+
return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)}%`;
|
|
5026
|
+
}
|
|
5027
|
+
|
|
5028
|
+
function etiquetaPitch(n) {
|
|
5029
|
+
const v = Math.round(Number(n) || 0);
|
|
5030
|
+
return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)} Hz`;
|
|
5031
|
+
}
|
|
5032
|
+
|
|
5033
|
+
/** Refresca la etiqueta numérica de los sliders de voz. */
|
|
5034
|
+
function renderVozSliders() {
|
|
5035
|
+
if (vozRateValEl && vozRateEl) vozRateValEl.textContent = etiquetaRate(vozRateEl.value);
|
|
5036
|
+
if (vozPitchValEl && vozPitchEl) vozPitchValEl.textContent = etiquetaPitch(vozPitchEl.value);
|
|
5037
|
+
}
|
|
5038
|
+
|
|
4692
5039
|
/** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
|
|
4693
|
-
function enqueueSpeech({ texto, voice, rate, label = 'audio', maxAgeMs = 0 } = {}) {
|
|
5040
|
+
function enqueueSpeech({ texto, voice, rate, pitch, label = 'audio', maxAgeMs = 0 } = {}) {
|
|
4694
5041
|
const frase = String(texto ?? '').trim();
|
|
4695
5042
|
if (!frase) return Promise.resolve();
|
|
4696
5043
|
ensurePlayer();
|
|
4697
5044
|
return new Promise((resolve, reject) => {
|
|
4698
|
-
ap.queue.push({ texto: frase, voice, rate, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
|
|
5045
|
+
ap.queue.push({ texto: frase, voice, rate, pitch, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
|
|
4699
5046
|
if (ap.current) renderAudio();
|
|
4700
5047
|
else playNextAudio();
|
|
4701
5048
|
});
|
|
@@ -4706,6 +5053,7 @@ function playNextAudio() {
|
|
|
4706
5053
|
if (!item) {
|
|
4707
5054
|
ap.current = null;
|
|
4708
5055
|
renderAudio();
|
|
5056
|
+
notifyAudioIdle();
|
|
4709
5057
|
return;
|
|
4710
5058
|
}
|
|
4711
5059
|
if (item.maxAgeMs && Date.now() - item.createdAt > item.maxAgeMs) {
|
|
@@ -4717,6 +5065,7 @@ function playNextAudio() {
|
|
|
4717
5065
|
texto: item.texto,
|
|
4718
5066
|
voice: item.voice ?? voz.settings.voice,
|
|
4719
5067
|
rate: item.rate ?? voz.settings.rate,
|
|
5068
|
+
pitch: item.pitch ?? voz.settings.pitch,
|
|
4720
5069
|
});
|
|
4721
5070
|
ap.audio.src = `/api/tts?${params}`;
|
|
4722
5071
|
ap.audio.play().catch(() => {
|
|
@@ -4751,6 +5100,7 @@ function stopAudio() {
|
|
|
4751
5100
|
ap.audio.load();
|
|
4752
5101
|
if (ap.barEl) ap.barEl.style.width = '0%';
|
|
4753
5102
|
renderAudio();
|
|
5103
|
+
notifyAudioIdle();
|
|
4754
5104
|
}
|
|
4755
5105
|
|
|
4756
5106
|
function renderAudio() {
|
|
@@ -4768,24 +5118,40 @@ function renderAudio() {
|
|
|
4768
5118
|
}
|
|
4769
5119
|
|
|
4770
5120
|
/** Reproduce una frase contra el endpoint de sintesis del server. */
|
|
4771
|
-
function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
|
|
4772
|
-
return enqueueSpeech({ texto: frase, voice, rate, label, maxAgeMs });
|
|
5121
|
+
function reproducirVoz(frase, { voice, rate, pitch, label, maxAgeMs } = {}) {
|
|
5122
|
+
return enqueueSpeech({ texto: frase, voice, rate, pitch, label, maxAgeMs });
|
|
4773
5123
|
}
|
|
4774
5124
|
|
|
4775
5125
|
/**
|
|
4776
5126
|
* Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
|
|
4777
5127
|
* cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
|
|
4778
5128
|
*/
|
|
4779
|
-
function hablar(texto, { label = 'narrando', maxAgeMs = 45000 } = {}) {
|
|
5129
|
+
function hablar(texto, { label = 'narrando', maxAgeMs = 45000, force = false } = {}) {
|
|
4780
5130
|
const frase = String(texto ?? '').trim();
|
|
4781
|
-
if (!frase || !voz.settings.enabled) return;
|
|
5131
|
+
if (!frase || (!voz.settings.enabled && !force)) return;
|
|
4782
5132
|
reproducirVoz(frase, { label, maxAgeMs }).catch(() => {});
|
|
4783
5133
|
}
|
|
4784
5134
|
|
|
4785
5135
|
/** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
|
|
4786
5136
|
function narrar(event, data, turn) {
|
|
4787
5137
|
if (!turn?.live) return;
|
|
5138
|
+
const handsFreeOn = handsFree.active;
|
|
4788
5139
|
const { enabled, announce } = voz.settings;
|
|
5140
|
+
if (!enabled && !handsFreeOn) return;
|
|
5141
|
+
// Manos libres: conversacion, sin narracion de arranque/herramientas; lee la
|
|
5142
|
+
// respuesta final (y el asesor) aunque la narracion este apagada en Ajustes.
|
|
5143
|
+
if (handsFreeOn) {
|
|
5144
|
+
if (event === 'advice') {
|
|
5145
|
+
if (data?.say) hablar(data.say, { label: 'asesor', maxAgeMs: 0, force: true });
|
|
5146
|
+
} else if (event === 'done') {
|
|
5147
|
+
const spoken = speechText(data?.content);
|
|
5148
|
+
if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0, force: true });
|
|
5149
|
+
else if (data?.say) hablar(data.say, { maxAgeMs: 0, force: true });
|
|
5150
|
+
} else if ((event === 'aborted' || event === 'error') && data?.say) {
|
|
5151
|
+
hablar(data.say, { maxAgeMs: 0, force: true });
|
|
5152
|
+
}
|
|
5153
|
+
return;
|
|
5154
|
+
}
|
|
4789
5155
|
if (!enabled) return;
|
|
4790
5156
|
if (event === 'start' && announce.start) hablar(data?.say);
|
|
4791
5157
|
else if (event === 'tool_call' && announce.tools) hablar(data?.say);
|
|
@@ -4824,6 +5190,7 @@ async function aplicarVoz(data) {
|
|
|
4824
5190
|
enabled: data.enabled === true,
|
|
4825
5191
|
voice: data.voice || 'es-AR-ElenaNeural',
|
|
4826
5192
|
rate: data.rate || '+0%',
|
|
5193
|
+
pitch: data.pitch || '+0Hz',
|
|
4827
5194
|
announce: {
|
|
4828
5195
|
start: data.announce?.start !== false,
|
|
4829
5196
|
tools: data.announce?.tools !== false,
|
|
@@ -4844,7 +5211,9 @@ async function aplicarVoz(data) {
|
|
|
4844
5211
|
}
|
|
4845
5212
|
vozVoiceEl.value = voz.settings.voice;
|
|
4846
5213
|
}
|
|
4847
|
-
if (vozRateEl) vozRateEl.value = voz.settings.rate;
|
|
5214
|
+
if (vozRateEl) vozRateEl.value = rateToSlider(voz.settings.rate);
|
|
5215
|
+
if (vozPitchEl) vozPitchEl.value = pitchToSlider(voz.settings.pitch);
|
|
5216
|
+
renderVozSliders();
|
|
4848
5217
|
if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
|
|
4849
5218
|
if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
|
|
4850
5219
|
if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
|
|
@@ -4869,7 +5238,8 @@ async function submitVoz() {
|
|
|
4869
5238
|
const body = {
|
|
4870
5239
|
enabled: vozEnabledEl?.checked === true,
|
|
4871
5240
|
voice: vozVoiceEl?.value,
|
|
4872
|
-
rate: vozRateEl?.value,
|
|
5241
|
+
rate: sliderToRate(vozRateEl?.value),
|
|
5242
|
+
pitch: sliderToPitch(vozPitchEl?.value),
|
|
4873
5243
|
announce: {
|
|
4874
5244
|
start: vozAnnStartEl?.checked !== false,
|
|
4875
5245
|
tools: vozAnnToolsEl?.checked !== false,
|
|
@@ -5043,10 +5413,15 @@ trTestBtn?.addEventListener('click', toggleTrTest);
|
|
|
5043
5413
|
|
|
5044
5414
|
async function testVoz() {
|
|
5045
5415
|
setStatus('#voz-msg', 'sintetizando…');
|
|
5416
|
+
const trato = ($('#perfil-address')?.value || '').trim();
|
|
5417
|
+
const frase = trato
|
|
5418
|
+
? `${trato}, esta sería la voz predeterminada para mí`
|
|
5419
|
+
: 'Esta sería la voz predeterminada para mí';
|
|
5046
5420
|
try {
|
|
5047
|
-
await reproducirVoz(
|
|
5421
|
+
await reproducirVoz(frase, {
|
|
5048
5422
|
voice: vozVoiceEl?.value,
|
|
5049
|
-
rate: vozRateEl?.value,
|
|
5423
|
+
rate: sliderToRate(vozRateEl?.value),
|
|
5424
|
+
pitch: sliderToPitch(vozPitchEl?.value),
|
|
5050
5425
|
});
|
|
5051
5426
|
setStatus('#voz-msg', 'sonó', 'ok');
|
|
5052
5427
|
} catch (error) {
|
|
@@ -5108,8 +5483,7 @@ async function testAsesor() {
|
|
|
5108
5483
|
setStatus('#asesor-msg', 'consultando…');
|
|
5109
5484
|
const data = await api('/api/advisor/test', { method: 'POST' }).catch((e) => ({ error: e.message }));
|
|
5110
5485
|
if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
|
|
5111
|
-
|
|
5112
|
-
setStatus('#asesor-msg', partes.length ? partes.join(' · ') : '(no diría nada en este caso)', 'ok');
|
|
5486
|
+
setStatus('#asesor-msg', data?.speak && data.text ? data.text : '(no diría nada en este caso)', 'ok');
|
|
5113
5487
|
}
|
|
5114
5488
|
|
|
5115
5489
|
/* --- ajustes: perfil del usuario ------------------------------------------ */
|
|
@@ -5416,6 +5790,8 @@ if (mcpListEl) {
|
|
|
5416
5790
|
$('#tg-test')?.addEventListener('click', testChannel);
|
|
5417
5791
|
$('#voz-save')?.addEventListener('click', submitVoz);
|
|
5418
5792
|
$('#voz-test')?.addEventListener('click', testVoz);
|
|
5793
|
+
vozRateEl?.addEventListener('input', renderVozSliders);
|
|
5794
|
+
vozPitchEl?.addEventListener('input', renderVozSliders);
|
|
5419
5795
|
$('#asesor-save')?.addEventListener('click', submitAsesor);
|
|
5420
5796
|
$('#asesor-test')?.addEventListener('click', testAsesor);
|
|
5421
5797
|
$('#mcp-close').addEventListener('click', closeMcpForm);
|
|
@@ -6712,6 +7088,10 @@ async function boot() {
|
|
|
6712
7088
|
await loadModes();
|
|
6713
7089
|
await loadModels();
|
|
6714
7090
|
await refreshTranscribe();
|
|
7091
|
+
// La config de voz se carga al boot (sin `await`, para no bloquear con el
|
|
7092
|
+
// fetch del catálogo de voces): si no, `voz.settings.enabled` queda en el
|
|
7093
|
+
// default `false` hasta abrir Ajustes y la narración no arranca al entrar.
|
|
7094
|
+
refreshVoz();
|
|
6715
7095
|
await reconcile();
|
|
6716
7096
|
await restoreTabs();
|
|
6717
7097
|
renderAlertSignals();
|