@danieltmn/tamnora 0.19.0 → 0.20.1
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 +38 -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 +12 -2
- 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/registry.js +21 -1
- 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 +376 -47
- package/harness/packages/server/public/index.html +17 -3
- package/harness/packages/server/public/styles.css +25 -0
- package/harness/packages/server/src/lib/advisor.js +14 -13
- 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/routes/chat.js +20 -26
- package/harness/packages/server/src/routes/sessions.js +20 -11
- package/harness/packages/server/test/advisor.test.js +17 -13
- package/harness/packages/server/test/server.test.js +154 -51
- 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
|
};
|
|
@@ -1721,8 +1776,31 @@ 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) {
|
|
1790
|
+
const body = el('div', 'compact-text text');
|
|
1791
|
+
body.innerHTML = renderMarkdown(clean);
|
|
1792
|
+
card.append(body);
|
|
1793
|
+
}
|
|
1794
|
+
container.appendChild(card);
|
|
1795
|
+
return card;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1724
1798
|
function renderStored(message, pane) {
|
|
1725
1799
|
if (message.agent_id) return;
|
|
1800
|
+
if (message.name === 'compact') {
|
|
1801
|
+
addCompactCard(pane, { text: typeof message.content === 'string' ? message.content : '' });
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1726
1804
|
if (message.role === 'assistant' && message.name === 'advisor') {
|
|
1727
1805
|
addAdvisorCard(pane, {
|
|
1728
1806
|
text: typeof message.content === 'string' ? message.content : '',
|
|
@@ -1955,6 +2033,11 @@ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, run
|
|
|
1955
2033
|
el: pane,
|
|
1956
2034
|
title: session.title || '(sin título)',
|
|
1957
2035
|
cwd: session.cwd || '',
|
|
2036
|
+
// Tipo de agente de la sesion: se aplica a los controles al abrirla y se
|
|
2037
|
+
// persiste al cambiarlos. Cada pestaña puede correr con uno distinto.
|
|
2038
|
+
provider: session.provider || '',
|
|
2039
|
+
model: session.model || '',
|
|
2040
|
+
mode: session.mode || '',
|
|
1958
2041
|
working: false,
|
|
1959
2042
|
tokens,
|
|
1960
2043
|
lastTurn: { prompt: 0, completion: 0 },
|
|
@@ -2048,6 +2131,7 @@ function activateSession(id) {
|
|
|
2048
2131
|
auditStatus(record);
|
|
2049
2132
|
updateStatus();
|
|
2050
2133
|
renderTokens();
|
|
2134
|
+
applySessionAgent(record);
|
|
2051
2135
|
renderQueue(id);
|
|
2052
2136
|
renderTabs();
|
|
2053
2137
|
refreshSessions();
|
|
@@ -2610,6 +2694,9 @@ async function startTurn(sessionKey, pane, input, images, opts = {}) {
|
|
|
2610
2694
|
controllers.set(sessionKey, controller);
|
|
2611
2695
|
setWorking(sessionKey, true);
|
|
2612
2696
|
|
|
2697
|
+
// El tipo de agente lo manda la sesion (no los controles globales): asi dos
|
|
2698
|
+
// pestañas con modelos distintos no se pisan.
|
|
2699
|
+
const owner = state.panes.get(sessionKey);
|
|
2613
2700
|
try {
|
|
2614
2701
|
await streamPost(
|
|
2615
2702
|
'/api/chat',
|
|
@@ -2617,9 +2704,9 @@ async function startTurn(sessionKey, pane, input, images, opts = {}) {
|
|
|
2617
2704
|
input: input || '(imagen adjunta)',
|
|
2618
2705
|
images: images.length ? images : undefined,
|
|
2619
2706
|
sessionId: sessionKey,
|
|
2620
|
-
provider: providerSelect.value || undefined,
|
|
2621
|
-
model: modelSelect.value || undefined,
|
|
2622
|
-
mode: modeSelect.value || undefined,
|
|
2707
|
+
provider: owner?.provider || providerSelect.value || undefined,
|
|
2708
|
+
model: owner?.model || modelSelect.value || undefined,
|
|
2709
|
+
mode: owner?.mode || modeSelect.value || undefined,
|
|
2623
2710
|
yes: yesInput.checked ? true : undefined,
|
|
2624
2711
|
contextTokens: contextTokensForRequest(),
|
|
2625
2712
|
interrupt: opts.interrupt ? true : undefined,
|
|
@@ -2755,11 +2842,12 @@ async function sendMessage() {
|
|
|
2755
2842
|
if (input.startsWith('/') && !images.length) {
|
|
2756
2843
|
const name = input.slice(1).split(/\s+/)[0].toLowerCase();
|
|
2757
2844
|
const arg = input.slice(1 + name.length).trim();
|
|
2845
|
+
// Limpiamos el input de una: hay comandos lentos (compactar, resumir) y no
|
|
2846
|
+
// queremos dejar el comando escrito mientras corren.
|
|
2847
|
+
chatInput.value = '';
|
|
2848
|
+
onChatInputChanged();
|
|
2758
2849
|
const outcome = await runCommand(name, arg);
|
|
2759
|
-
if (!outcome || outcome.handled)
|
|
2760
|
-
chatInput.value = '';
|
|
2761
|
-
return;
|
|
2762
|
-
}
|
|
2850
|
+
if (!outcome || outcome.handled) return;
|
|
2763
2851
|
input = outcome.input ?? input;
|
|
2764
2852
|
}
|
|
2765
2853
|
if (!input && !images.length) return;
|
|
@@ -2984,7 +3072,7 @@ function encodeWav(samples, rate = 16000) {
|
|
|
2984
3072
|
* `stop()` espera la cola antes de cortar. La usan el micrófono del chat y el
|
|
2985
3073
|
* "probar" de Ajustes.
|
|
2986
3074
|
*/
|
|
2987
|
-
function createRecording() {
|
|
3075
|
+
function createRecording({ silenceMs = 0, noSpeechMs = 0, maxMs = 0, speechThreshold = 0.02 } = {}) {
|
|
2988
3076
|
let resolveBlob;
|
|
2989
3077
|
const done = new Promise((resolve) => {
|
|
2990
3078
|
resolveBlob = resolve;
|
|
@@ -2996,9 +3084,15 @@ function createRecording() {
|
|
|
2996
3084
|
let sink = null;
|
|
2997
3085
|
let stopping = false;
|
|
2998
3086
|
let started = false;
|
|
3087
|
+
let startedAt = 0;
|
|
3088
|
+
let hasSpeech = false;
|
|
3089
|
+
let lastLoudAt = 0;
|
|
3090
|
+
let watchdog = null;
|
|
2999
3091
|
const chunks = [];
|
|
3000
3092
|
|
|
3001
3093
|
async function cleanup() {
|
|
3094
|
+
if (watchdog) clearInterval(watchdog);
|
|
3095
|
+
watchdog = null;
|
|
3002
3096
|
try {
|
|
3003
3097
|
processor?.disconnect();
|
|
3004
3098
|
} catch {
|
|
@@ -3022,6 +3116,37 @@ function createRecording() {
|
|
|
3022
3116
|
stream?.getTracks?.().forEach((track) => track.stop());
|
|
3023
3117
|
}
|
|
3024
3118
|
|
|
3119
|
+
function stopRecording() {
|
|
3120
|
+
if (stopping || !started) return;
|
|
3121
|
+
stopping = true;
|
|
3122
|
+
if (watchdog) clearInterval(watchdog);
|
|
3123
|
+
watchdog = null;
|
|
3124
|
+
setTimeout(async () => {
|
|
3125
|
+
const rate = audioCtx?.sampleRate || 48000;
|
|
3126
|
+
const merged = mergeFloat32(chunks);
|
|
3127
|
+
await cleanup();
|
|
3128
|
+
const resampled = await resampleTo16k(merged, rate);
|
|
3129
|
+
resolveBlob(encodeWav(resampled, 16000));
|
|
3130
|
+
}, REC_TAIL_MS);
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
// Manos libres: corta solo por silencio (tras haber hablado), si no hubo voz en
|
|
3134
|
+
// `noSpeechMs` o al llegar a `maxMs`. Sin estas opciones no interviene.
|
|
3135
|
+
function startWatchdog() {
|
|
3136
|
+
if (!silenceMs && !noSpeechMs && !maxMs) return;
|
|
3137
|
+
watchdog = setInterval(() => {
|
|
3138
|
+
if (stopping || !started) return;
|
|
3139
|
+
const now = Date.now();
|
|
3140
|
+
if (maxMs && now - startedAt >= maxMs) return stopRecording();
|
|
3141
|
+
if (!silenceMs) return;
|
|
3142
|
+
if (!hasSpeech) {
|
|
3143
|
+
if (noSpeechMs && now - startedAt >= noSpeechMs) stopRecording();
|
|
3144
|
+
return;
|
|
3145
|
+
}
|
|
3146
|
+
if (now - lastLoudAt >= silenceMs) stopRecording();
|
|
3147
|
+
}, 150);
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3025
3150
|
return {
|
|
3026
3151
|
async start() {
|
|
3027
3152
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
@@ -3034,7 +3159,16 @@ function createRecording() {
|
|
|
3034
3159
|
source = audioCtx.createMediaStreamSource(stream);
|
|
3035
3160
|
processor = audioCtx.createScriptProcessor(4096, 1, 1);
|
|
3036
3161
|
processor.onaudioprocess = (event) => {
|
|
3037
|
-
|
|
3162
|
+
const frame = event.inputBuffer.getChannelData(0);
|
|
3163
|
+
chunks.push(new Float32Array(frame));
|
|
3164
|
+
if (!silenceMs) return;
|
|
3165
|
+
let sum = 0;
|
|
3166
|
+
for (let i = 0; i < frame.length; i += 2) sum += frame[i] * frame[i];
|
|
3167
|
+
const rms = Math.sqrt(sum / (frame.length / 2));
|
|
3168
|
+
if (rms >= speechThreshold) {
|
|
3169
|
+
hasSpeech = true;
|
|
3170
|
+
lastLoudAt = Date.now();
|
|
3171
|
+
}
|
|
3038
3172
|
};
|
|
3039
3173
|
// El procesador solo corre si llega al destination: un gain en 0 evita el eco.
|
|
3040
3174
|
sink = audioCtx.createGain();
|
|
@@ -3043,21 +3177,13 @@ function createRecording() {
|
|
|
3043
3177
|
processor.connect(sink);
|
|
3044
3178
|
sink.connect(audioCtx.destination);
|
|
3045
3179
|
started = true;
|
|
3180
|
+
startedAt = Date.now();
|
|
3181
|
+
startWatchdog();
|
|
3046
3182
|
},
|
|
3047
3183
|
get stopping() {
|
|
3048
3184
|
return stopping;
|
|
3049
3185
|
},
|
|
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
|
-
},
|
|
3186
|
+
stop: stopRecording,
|
|
3061
3187
|
done,
|
|
3062
3188
|
};
|
|
3063
3189
|
}
|
|
@@ -3180,6 +3306,8 @@ function startBrowserMic() {
|
|
|
3180
3306
|
|
|
3181
3307
|
async function toggleMic() {
|
|
3182
3308
|
if (!micBtn) return;
|
|
3309
|
+
// Con manos libres el micro lo maneja el loop: no dejamos que se pisen.
|
|
3310
|
+
if (handsFree.active) return;
|
|
3183
3311
|
if (browserDictation) {
|
|
3184
3312
|
browserDictation.stop();
|
|
3185
3313
|
return;
|
|
@@ -3229,6 +3357,155 @@ async function toggleMic() {
|
|
|
3229
3357
|
|
|
3230
3358
|
micBtn?.addEventListener('click', toggleMic);
|
|
3231
3359
|
|
|
3360
|
+
/* --- manos libres: conversación por voz ----------------------------------- */
|
|
3361
|
+
|
|
3362
|
+
const HANDS_FREE_SILENCE_MS = 1400; // silencio que cierra tu turno
|
|
3363
|
+
const HANDS_FREE_NOSPEECH_MS = 8000; // si no hablás, vuelve a intentar
|
|
3364
|
+
const HANDS_FREE_MAX_MS = 45000; // tope duro de una intervención
|
|
3365
|
+
|
|
3366
|
+
const handsFreeBtn = $('#hands-free-btn');
|
|
3367
|
+
const handsFree = { active: false, phase: 'idle', token: 0 };
|
|
3368
|
+
|
|
3369
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3370
|
+
|
|
3371
|
+
function renderHandsFree() {
|
|
3372
|
+
if (handsFreeBtn) {
|
|
3373
|
+
handsFreeBtn.classList.toggle('hands-free-active', handsFree.active);
|
|
3374
|
+
handsFreeBtn.classList.toggle('listening', handsFree.active && handsFree.phase === 'listening');
|
|
3375
|
+
handsFreeBtn.setAttribute('aria-pressed', handsFree.active ? 'true' : 'false');
|
|
3376
|
+
handsFreeBtn.title = handsFree.active
|
|
3377
|
+
? 'manos libres: activado (tocá para desactivar)'
|
|
3378
|
+
: 'manos libres: conversación por voz';
|
|
3379
|
+
}
|
|
3380
|
+
if (handsFree.active && handsFree.phase === 'listening') renderFooterActivity('listening');
|
|
3381
|
+
else if (handsFree.active && handsFree.phase === 'thinking') renderFooterActivity('thinking');
|
|
3382
|
+
else if (handsFree.active) renderFooterActivity(null);
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
function setHandsFreePhase(phase) {
|
|
3386
|
+
handsFree.phase = phase;
|
|
3387
|
+
renderHandsFree();
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
function setHandsFree(on) {
|
|
3391
|
+
const next = Boolean(on);
|
|
3392
|
+
if (handsFree.active === next) return;
|
|
3393
|
+
handsFree.active = next;
|
|
3394
|
+
handsFree.token += 1;
|
|
3395
|
+
if (next) {
|
|
3396
|
+
// Cortamos un dictado manual en curso para no pisarlo.
|
|
3397
|
+
composerRecording?.stop();
|
|
3398
|
+
setHandsFreePhase('idle');
|
|
3399
|
+
renderHandsFree();
|
|
3400
|
+
runHandsFree(handsFree.token).catch(() => {});
|
|
3401
|
+
} else {
|
|
3402
|
+
// Cortamos lo que esté grabando para no dejar el micro abierto.
|
|
3403
|
+
handsFree.recording?.stop();
|
|
3404
|
+
handsFree.recording = null;
|
|
3405
|
+
browserDictation?.stop();
|
|
3406
|
+
setHandsFreePhase('idle');
|
|
3407
|
+
renderHandsFree();
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
function toggleHandsFree() {
|
|
3412
|
+
setHandsFree(!handsFree.active);
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
handsFreeBtn?.addEventListener('click', toggleHandsFree);
|
|
3416
|
+
|
|
3417
|
+
/** Una escucha de manos libres: graba hasta el silencio y devuelve el texto. */
|
|
3418
|
+
async function handsFreeListen(token) {
|
|
3419
|
+
if (transcribeConfig.mode === 'browser') {
|
|
3420
|
+
const text = await new Promise((resolve, reject) => {
|
|
3421
|
+
let combined = '';
|
|
3422
|
+
let dictation;
|
|
3423
|
+
const finish = () => {
|
|
3424
|
+
if (browserDictation === dictation) browserDictation = null;
|
|
3425
|
+
resolve(combined.trim());
|
|
3426
|
+
};
|
|
3427
|
+
try {
|
|
3428
|
+
dictation = createBrowserDictation({
|
|
3429
|
+
language: transcribeConfig.language,
|
|
3430
|
+
onText: (_final, all) => {
|
|
3431
|
+
combined = all;
|
|
3432
|
+
},
|
|
3433
|
+
onEnd: finish,
|
|
3434
|
+
onError: reject,
|
|
3435
|
+
});
|
|
3436
|
+
} catch (error) {
|
|
3437
|
+
reject(error);
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
browserDictation = dictation;
|
|
3441
|
+
dictation.start();
|
|
3442
|
+
setTimeout(() => {
|
|
3443
|
+
if (browserDictation === dictation) dictation.stop();
|
|
3444
|
+
}, HANDS_FREE_MAX_MS);
|
|
3445
|
+
});
|
|
3446
|
+
return token === handsFree.token ? text : '';
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
const rec = createRecording({
|
|
3450
|
+
silenceMs: HANDS_FREE_SILENCE_MS,
|
|
3451
|
+
noSpeechMs: HANDS_FREE_NOSPEECH_MS,
|
|
3452
|
+
maxMs: HANDS_FREE_MAX_MS,
|
|
3453
|
+
});
|
|
3454
|
+
await rec.start();
|
|
3455
|
+
if (token !== handsFree.token) {
|
|
3456
|
+
rec.stop();
|
|
3457
|
+
return '';
|
|
3458
|
+
}
|
|
3459
|
+
handsFree.recording = rec;
|
|
3460
|
+
setHandsFreePhase('listening');
|
|
3461
|
+
const blob = await rec.done;
|
|
3462
|
+
handsFree.recording = null;
|
|
3463
|
+
if (token !== handsFree.token || !blob.size) return '';
|
|
3464
|
+
setHandsFreePhase('thinking');
|
|
3465
|
+
try {
|
|
3466
|
+
return await transcribirBlob(blob);
|
|
3467
|
+
} catch (error) {
|
|
3468
|
+
toast(`No pude transcribir: ${error.message}`);
|
|
3469
|
+
return '';
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
/** Loop conversacional: escuchar → enviar → esperar la voz → repetir. */
|
|
3474
|
+
async function runHandsFree(token) {
|
|
3475
|
+
while (handsFree.active && token === handsFree.token) {
|
|
3476
|
+
if (!state.sessionId) {
|
|
3477
|
+
toast('Manos libres necesita una sesión abierta.');
|
|
3478
|
+
setHandsFree(false);
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
3481
|
+
if (state.panes.get(state.sessionId)?.working) {
|
|
3482
|
+
await sleep(400);
|
|
3483
|
+
continue;
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
let text = '';
|
|
3487
|
+
try {
|
|
3488
|
+
text = await handsFreeListen(token);
|
|
3489
|
+
} catch (error) {
|
|
3490
|
+
if (token !== handsFree.token) return;
|
|
3491
|
+
toast(`Manos libres: ${error.message}`);
|
|
3492
|
+
setHandsFree(false);
|
|
3493
|
+
return;
|
|
3494
|
+
}
|
|
3495
|
+
if (!handsFree.active || token !== handsFree.token) return;
|
|
3496
|
+
if (!text) continue; // silencio o no se entendió: otra vuelta
|
|
3497
|
+
|
|
3498
|
+
chatInput.value = text;
|
|
3499
|
+
onChatInputChanged();
|
|
3500
|
+
setHandsFreePhase('thinking');
|
|
3501
|
+
await sendMessage();
|
|
3502
|
+
if (!handsFree.active || token !== handsFree.token) return;
|
|
3503
|
+
// Esperamos a que termine de hablar antes de volver a escuchar (evita el eco).
|
|
3504
|
+
await whenAudioIdle();
|
|
3505
|
+
await sleep(250);
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3232
3509
|
/* --- image attachments ---------------------------------------------------- */
|
|
3233
3510
|
|
|
3234
3511
|
const attachInput = $('#image-input');
|
|
@@ -3465,13 +3742,18 @@ async function runCommand(name, arg) {
|
|
|
3465
3742
|
}
|
|
3466
3743
|
case 'compact': {
|
|
3467
3744
|
if (!state.sessionId) return needSession();
|
|
3468
|
-
|
|
3745
|
+
toast('Compactando la conversación…');
|
|
3469
3746
|
const out = await api(`/api/sessions/${state.sessionId}/compact`, { method: 'POST' }).catch((error) => ({
|
|
3470
3747
|
error: error.message,
|
|
3471
3748
|
}));
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3749
|
+
if (out.error) {
|
|
3750
|
+
notify(`No se pudo compactar: ${out.error}`, 'notice err');
|
|
3751
|
+
return { handled: true };
|
|
3752
|
+
}
|
|
3753
|
+
// No borramos el pane: el historial queda a la vista y marcamos el corte
|
|
3754
|
+
// con la línea + el resumen que ahora ve el modelo.
|
|
3755
|
+
addCompactCard(activePane, { text: out.summary });
|
|
3756
|
+
scrollMessages();
|
|
3475
3757
|
refreshSessions();
|
|
3476
3758
|
return { handled: true };
|
|
3477
3759
|
}
|
|
@@ -3736,7 +4018,17 @@ async function loadFolders(target = '') {
|
|
|
3736
4018
|
}
|
|
3737
4019
|
|
|
3738
4020
|
async function createSessionIn(cwd, { title = 'Nueva sesión' } = {}) {
|
|
3739
|
-
const
|
|
4021
|
+
const current = state.panes.get(state.sessionId);
|
|
4022
|
+
const created = await api('/api/sessions', {
|
|
4023
|
+
method: 'POST',
|
|
4024
|
+
body: {
|
|
4025
|
+
title,
|
|
4026
|
+
cwd,
|
|
4027
|
+
provider: current?.provider || providerSelect.value || undefined,
|
|
4028
|
+
model: current?.model || modelSelect.value || undefined,
|
|
4029
|
+
mode: current?.mode || modeSelect.value || undefined,
|
|
4030
|
+
},
|
|
4031
|
+
}).catch((error) => ({
|
|
3740
4032
|
error: error.message,
|
|
3741
4033
|
}));
|
|
3742
4034
|
if (created?.error) return created;
|
|
@@ -3866,6 +4158,8 @@ async function refreshProviders() {
|
|
|
3866
4158
|
state.active = data.active;
|
|
3867
4159
|
syncTopbar();
|
|
3868
4160
|
renderProviderList();
|
|
4161
|
+
const record = state.panes.get(state.sessionId);
|
|
4162
|
+
if (record) await applySessionAgent(record);
|
|
3869
4163
|
}
|
|
3870
4164
|
|
|
3871
4165
|
function isConfigured(p) {
|
|
@@ -3881,7 +4175,8 @@ function syncTopbar() {
|
|
|
3881
4175
|
option.textContent = p.label && p.label !== p.id ? `${p.label} (${p.id})` : p.id;
|
|
3882
4176
|
providerSelect.appendChild(option);
|
|
3883
4177
|
}
|
|
3884
|
-
const
|
|
4178
|
+
const record = state.panes.get(state.sessionId);
|
|
4179
|
+
const active = record?.provider || state.active.provider;
|
|
3885
4180
|
if (active && !configured.some((p) => p.id === active)) {
|
|
3886
4181
|
const current = state.providers.find((p) => p.id === active);
|
|
3887
4182
|
const option = document.createElement('option');
|
|
@@ -3930,8 +4225,9 @@ function renderProviderList() {
|
|
|
3930
4225
|
}
|
|
3931
4226
|
|
|
3932
4227
|
async function useProvider(id) {
|
|
3933
|
-
|
|
3934
|
-
await loadModels({ save:
|
|
4228
|
+
providerSelect.value = id;
|
|
4229
|
+
await loadModels({ save: false });
|
|
4230
|
+
await persistAgent({ provider: id, model: modelSelect.value || undefined });
|
|
3935
4231
|
await refreshProviders();
|
|
3936
4232
|
}
|
|
3937
4233
|
|
|
@@ -4661,6 +4957,18 @@ const voz = {
|
|
|
4661
4957
|
*/
|
|
4662
4958
|
const ap = { el: $('#audio-player'), audio: new Audio(), queue: [], current: null, ready: false };
|
|
4663
4959
|
|
|
4960
|
+
// Avisa cuando el reproductor queda en silencio (cola vacia). Lo usa manos libres
|
|
4961
|
+
// para saber cuando el asistente termino de hablar y volver a escuchar.
|
|
4962
|
+
const audioIdleWaiters = [];
|
|
4963
|
+
function whenAudioIdle() {
|
|
4964
|
+
if (!ap.current && !ap.queue.length) return Promise.resolve();
|
|
4965
|
+
return new Promise((resolve) => audioIdleWaiters.push(resolve));
|
|
4966
|
+
}
|
|
4967
|
+
function notifyAudioIdle() {
|
|
4968
|
+
if (ap.current || ap.queue.length) return;
|
|
4969
|
+
for (const resolve of audioIdleWaiters.splice(0)) resolve();
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4664
4972
|
function ensurePlayer() {
|
|
4665
4973
|
if (!ap.el || ap.ready) return;
|
|
4666
4974
|
ap.ready = true;
|
|
@@ -4750,6 +5058,7 @@ function playNextAudio() {
|
|
|
4750
5058
|
if (!item) {
|
|
4751
5059
|
ap.current = null;
|
|
4752
5060
|
renderAudio();
|
|
5061
|
+
notifyAudioIdle();
|
|
4753
5062
|
return;
|
|
4754
5063
|
}
|
|
4755
5064
|
if (item.maxAgeMs && Date.now() - item.createdAt > item.maxAgeMs) {
|
|
@@ -4796,6 +5105,7 @@ function stopAudio() {
|
|
|
4796
5105
|
ap.audio.load();
|
|
4797
5106
|
if (ap.barEl) ap.barEl.style.width = '0%';
|
|
4798
5107
|
renderAudio();
|
|
5108
|
+
notifyAudioIdle();
|
|
4799
5109
|
}
|
|
4800
5110
|
|
|
4801
5111
|
function renderAudio() {
|
|
@@ -4821,16 +5131,32 @@ function reproducirVoz(frase, { voice, rate, pitch, label, maxAgeMs } = {}) {
|
|
|
4821
5131
|
* Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
|
|
4822
5132
|
* cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
|
|
4823
5133
|
*/
|
|
4824
|
-
function hablar(texto, { label = 'narrando', maxAgeMs = 45000 } = {}) {
|
|
5134
|
+
function hablar(texto, { label = 'narrando', maxAgeMs = 45000, force = false } = {}) {
|
|
4825
5135
|
const frase = String(texto ?? '').trim();
|
|
4826
|
-
if (!frase || !voz.settings.enabled) return;
|
|
5136
|
+
if (!frase || (!voz.settings.enabled && !force)) return;
|
|
4827
5137
|
reproducirVoz(frase, { label, maxAgeMs }).catch(() => {});
|
|
4828
5138
|
}
|
|
4829
5139
|
|
|
4830
5140
|
/** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
|
|
4831
5141
|
function narrar(event, data, turn) {
|
|
4832
5142
|
if (!turn?.live) return;
|
|
5143
|
+
const handsFreeOn = handsFree.active;
|
|
4833
5144
|
const { enabled, announce } = voz.settings;
|
|
5145
|
+
if (!enabled && !handsFreeOn) return;
|
|
5146
|
+
// Manos libres: conversacion, sin narracion de arranque/herramientas; lee la
|
|
5147
|
+
// respuesta final (y el asesor) aunque la narracion este apagada en Ajustes.
|
|
5148
|
+
if (handsFreeOn) {
|
|
5149
|
+
if (event === 'advice') {
|
|
5150
|
+
if (data?.say) hablar(data.say, { label: 'asesor', maxAgeMs: 0, force: true });
|
|
5151
|
+
} else if (event === 'done') {
|
|
5152
|
+
const spoken = speechText(data?.content);
|
|
5153
|
+
if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0, force: true });
|
|
5154
|
+
else if (data?.say) hablar(data.say, { maxAgeMs: 0, force: true });
|
|
5155
|
+
} else if ((event === 'aborted' || event === 'error') && data?.say) {
|
|
5156
|
+
hablar(data.say, { maxAgeMs: 0, force: true });
|
|
5157
|
+
}
|
|
5158
|
+
return;
|
|
5159
|
+
}
|
|
4834
5160
|
if (!enabled) return;
|
|
4835
5161
|
if (event === 'start' && announce.start) hablar(data?.say);
|
|
4836
5162
|
else if (event === 'tool_call' && announce.tools) hablar(data?.say);
|
|
@@ -5162,8 +5488,7 @@ async function testAsesor() {
|
|
|
5162
5488
|
setStatus('#asesor-msg', 'consultando…');
|
|
5163
5489
|
const data = await api('/api/advisor/test', { method: 'POST' }).catch((e) => ({ error: e.message }));
|
|
5164
5490
|
if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
|
|
5165
|
-
|
|
5166
|
-
setStatus('#asesor-msg', partes.length ? partes.join(' · ') : '(no diría nada en este caso)', 'ok');
|
|
5491
|
+
setStatus('#asesor-msg', data?.speak && data.text ? data.text : '(no diría nada en este caso)', 'ok');
|
|
5167
5492
|
}
|
|
5168
5493
|
|
|
5169
5494
|
/* --- ajustes: perfil del usuario ------------------------------------------ */
|
|
@@ -6768,6 +7093,10 @@ async function boot() {
|
|
|
6768
7093
|
await loadModes();
|
|
6769
7094
|
await loadModels();
|
|
6770
7095
|
await refreshTranscribe();
|
|
7096
|
+
// La config de voz se carga al boot (sin `await`, para no bloquear con el
|
|
7097
|
+
// fetch del catálogo de voces): si no, `voz.settings.enabled` queda en el
|
|
7098
|
+
// default `false` hasta abrir Ajustes y la narración no arranca al entrar.
|
|
7099
|
+
refreshVoz();
|
|
6771
7100
|
await reconcile();
|
|
6772
7101
|
await restoreTabs();
|
|
6773
7102
|
renderAlertSignals();
|