@nac3/forge-cli 0.2.0-alpha.3 → 0.2.0-alpha.30
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/dist/bin/yf.d.ts.map +1 -1
- package/dist/bin/yf.js +27 -0
- package/dist/bin/yf.js.map +1 -1
- package/dist/chat/claude.d.ts +22 -15
- package/dist/chat/claude.d.ts.map +1 -1
- package/dist/chat/claude.js +75 -22
- package/dist/chat/claude.js.map +1 -1
- package/dist/chat/panel.d.ts.map +1 -1
- package/dist/chat/panel.js +692 -17
- package/dist/chat/panel.js.map +1 -1
- package/dist/chat/server.js +630 -32
- package/dist/chat/server.js.map +1 -1
- package/dist/chat/spec_extract.d.ts.map +1 -1
- package/dist/chat/spec_extract.js +39 -0
- package/dist/chat/spec_extract.js.map +1 -1
- package/dist/chat/tools/audit_consumers.d.ts +66 -0
- package/dist/chat/tools/audit_consumers.d.ts.map +1 -0
- package/dist/chat/tools/audit_consumers.js +231 -0
- package/dist/chat/tools/audit_consumers.js.map +1 -0
- package/dist/chat/tools/git.js +4 -4
- package/dist/chat/tools/github.js +3 -3
- package/dist/chat/tools/lifecycle.js +3 -3
- package/dist/chat/tools/manual.js +1 -1
- package/dist/chat/tools/reader.js +8 -8
- package/dist/chat/tools/workflow.d.ts +45 -0
- package/dist/chat/tools/workflow.d.ts.map +1 -0
- package/dist/chat/tools/workflow.js +404 -0
- package/dist/chat/tools/workflow.js.map +1 -0
- package/dist/chat/tools.d.ts.map +1 -1
- package/dist/chat/tools.js +23 -4
- package/dist/chat/tools.js.map +1 -1
- package/dist/commands/approve.d.ts +32 -0
- package/dist/commands/approve.d.ts.map +1 -0
- package/dist/commands/approve.js +198 -0
- package/dist/commands/approve.js.map +1 -0
- package/dist/commands/block.d.ts +28 -0
- package/dist/commands/block.d.ts.map +1 -0
- package/dist/commands/block.js +189 -0
- package/dist/commands/block.js.map +1 -0
- package/dist/commands/bootstrap.d.ts +35 -0
- package/dist/commands/bootstrap.d.ts.map +1 -0
- package/dist/commands/bootstrap.js +205 -0
- package/dist/commands/bootstrap.js.map +1 -0
- package/dist/commands/chat.d.ts +3 -0
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +46 -1
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/clarify.d.ts +30 -0
- package/dist/commands/clarify.d.ts.map +1 -0
- package/dist/commands/clarify.js +671 -0
- package/dist/commands/clarify.js.map +1 -0
- package/dist/commands/discover.d.ts +30 -0
- package/dist/commands/discover.d.ts.map +1 -0
- package/dist/commands/discover.js +178 -0
- package/dist/commands/discover.js.map +1 -0
- package/dist/commands/doctor.js +94 -42
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/keys_setup.d.ts +53 -0
- package/dist/commands/keys_setup.d.ts.map +1 -0
- package/dist/commands/keys_setup.js +487 -0
- package/dist/commands/keys_setup.js.map +1 -0
- package/dist/commands/legacy-audit.d.ts +34 -0
- package/dist/commands/legacy-audit.d.ts.map +1 -0
- package/dist/commands/legacy-audit.js +270 -0
- package/dist/commands/legacy-audit.js.map +1 -0
- package/dist/commands/license.d.ts.map +1 -1
- package/dist/commands/license.js +41 -0
- package/dist/commands/license.js.map +1 -1
- package/dist/commands/operate.d.ts +22 -0
- package/dist/commands/operate.d.ts.map +1 -0
- package/dist/commands/operate.js +523 -0
- package/dist/commands/operate.js.map +1 -0
- package/dist/commands/spec.d.ts +38 -0
- package/dist/commands/spec.d.ts.map +1 -0
- package/dist/commands/spec.js +256 -0
- package/dist/commands/spec.js.map +1 -0
- package/dist/commands/support.d.ts +22 -0
- package/dist/commands/support.d.ts.map +1 -0
- package/dist/commands/support.js +143 -0
- package/dist/commands/support.js.map +1 -0
- package/dist/commands/triage.d.ts +34 -0
- package/dist/commands/triage.d.ts.map +1 -0
- package/dist/commands/triage.js +228 -0
- package/dist/commands/triage.js.map +1 -0
- package/dist/commands/vault-inventory.d.ts +30 -0
- package/dist/commands/vault-inventory.d.ts.map +1 -0
- package/dist/commands/vault-inventory.js +214 -0
- package/dist/commands/vault-inventory.js.map +1 -0
- package/dist/commands/vault.d.ts.map +1 -1
- package/dist/commands/vault.js +5 -0
- package/dist/commands/vault.js.map +1 -1
- package/dist/commands/voice.js +1 -1
- package/dist/commands/voice.js.map +1 -1
- package/dist/commands/workflow-coverage.d.ts +30 -0
- package/dist/commands/workflow-coverage.d.ts.map +1 -0
- package/dist/commands/workflow-coverage.js +138 -0
- package/dist/commands/workflow-coverage.js.map +1 -0
- package/dist/core/keys_envelope.d.ts +13 -0
- package/dist/core/keys_envelope.d.ts.map +1 -1
- package/dist/core/keys_envelope.js.map +1 -1
- package/dist/deploy/adapter.d.ts +93 -0
- package/dist/deploy/adapter.d.ts.map +1 -0
- package/dist/deploy/adapter.js +42 -0
- package/dist/deploy/adapter.js.map +1 -0
- package/dist/deploy/aws_adapter.d.ts +28 -0
- package/dist/deploy/aws_adapter.d.ts.map +1 -0
- package/dist/deploy/aws_adapter.js +98 -0
- package/dist/deploy/aws_adapter.js.map +1 -0
- package/dist/deploy/cloudflare.d.ts +24 -0
- package/dist/deploy/cloudflare.d.ts.map +1 -0
- package/dist/deploy/cloudflare.js +169 -0
- package/dist/deploy/cloudflare.js.map +1 -0
- package/dist/license/hito4_client.d.ts +17 -1
- package/dist/license/hito4_client.d.ts.map +1 -1
- package/dist/license/hito4_client.js +71 -10
- package/dist/license/hito4_client.js.map +1 -1
- package/dist/license/index.d.ts.map +1 -1
- package/dist/license/index.js +7 -0
- package/dist/license/index.js.map +1 -1
- package/dist/license/sync.d.ts +54 -0
- package/dist/license/sync.d.ts.map +1 -0
- package/dist/license/sync.js +131 -0
- package/dist/license/sync.js.map +1 -0
- package/dist/support/reports.d.ts +31 -0
- package/dist/support/reports.d.ts.map +1 -0
- package/dist/support/reports.js +162 -0
- package/dist/support/reports.js.map +1 -0
- package/dist/telemetry/usage.d.ts +67 -0
- package/dist/telemetry/usage.d.ts.map +1 -0
- package/dist/telemetry/usage.js +208 -0
- package/dist/telemetry/usage.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/voice/intents.d.ts +1 -1
- package/dist/voice/intents.js +0 -0
- package/dist/voice/providers/google.d.ts +9 -0
- package/dist/voice/providers/google.d.ts.map +1 -1
- package/dist/voice/providers/google.js +204 -28
- package/dist/voice/providers/google.js.map +1 -1
- package/dist/voice/router.d.ts +10 -0
- package/dist/voice/router.d.ts.map +1 -1
- package/dist/voice/router.js +39 -20
- package/dist/voice/router.js.map +1 -1
- package/dist/voice/types.d.ts +5 -2
- package/dist/voice/types.d.ts.map +1 -1
- package/dist/voice/types.js.map +1 -1
- package/dist/workflow/state.d.ts +190 -0
- package/dist/workflow/state.d.ts.map +1 -0
- package/dist/workflow/state.js +119 -0
- package/dist/workflow/state.js.map +1 -0
- package/package.json +13 -15
- package/templates/nextjs-app/README.md +48 -0
- package/templates/nextjs-app/next.config.js +8 -0
- package/templates/nextjs-app/package.json +33 -0
- package/templates/nextjs-app/src/app/globals.css +43 -0
- package/templates/nextjs-app/src/app/layout.tsx +29 -0
- package/templates/nextjs-app/src/app/page.tsx +63 -0
- package/templates/nextjs-app/src/nac/manifest.ts +36 -0
- package/templates/nextjs-app/tsconfig.json +21 -0
- package/templates/nextjs-app/yujin.forge.json +11 -0
package/dist/chat/panel.js
CHANGED
|
@@ -237,6 +237,27 @@ body {
|
|
|
237
237
|
}
|
|
238
238
|
.header .actions button:hover { background: rgba(255,255,255,0.2); }
|
|
239
239
|
|
|
240
|
+
/* alpha.29 -- settings dropdown */
|
|
241
|
+
.settings-panel {
|
|
242
|
+
position: absolute; top: 56px; right: 16px; z-index: 50;
|
|
243
|
+
background: var(--bg-1); border: 1px solid rgba(255,255,255,0.15);
|
|
244
|
+
border-radius: 6px; padding: 10px; min-width: 280px;
|
|
245
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
246
|
+
display: flex; flex-direction: column; gap: 8px;
|
|
247
|
+
}
|
|
248
|
+
.settings-panel.hidden { display: none; }
|
|
249
|
+
.settings-panel .set-row {
|
|
250
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
251
|
+
gap: 12px; font-size: 12px;
|
|
252
|
+
}
|
|
253
|
+
.settings-panel .set-lbl { color: var(--ink-2); font-weight: 500; }
|
|
254
|
+
.settings-panel .set-btn {
|
|
255
|
+
background: var(--bg-2); color: var(--ink); border: 1px solid rgba(255,255,255,0.15);
|
|
256
|
+
padding: 4px 8px; border-radius: 4px; font-size: 11px; cursor: pointer;
|
|
257
|
+
}
|
|
258
|
+
.settings-panel .set-btn:hover { background: rgba(255,255,255,0.1); }
|
|
259
|
+
.settings-panel .set-status { font-size: 11px; color: var(--ink-2); }
|
|
260
|
+
|
|
240
261
|
/* ---- chat stream ---- */
|
|
241
262
|
.stream { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
|
242
263
|
.empty { color: var(--ink-muted); text-align: center; padding: 32px 16px; }
|
|
@@ -453,12 +474,29 @@ body {
|
|
|
453
474
|
<div class="t"><span class="name">Yujin Forge</span><span class="proj" id="proj-full" data-nac-id="yujin.panel.project-full"></span></div>
|
|
454
475
|
</div>
|
|
455
476
|
<div class="actions">
|
|
477
|
+
<button class="settings-btn" data-settings-toggle aria-label="ajustes" title="Ajustes (modo voz, STT, TTS, claves)">⚙</button>
|
|
456
478
|
<button class="lang-btn" data-lang-open aria-label="${escapeHtml(tr.languageButton)}" title="${escapeHtml(tr.languageButton)}">🌐</button>
|
|
457
479
|
<button class="keys-btn" data-vault-open aria-label="${escapeHtml(tr.keysButton)}" title="${escapeHtml(tr.keysButton)}">${escapeHtml(tr.keysButton)}</button>
|
|
458
480
|
<button id="full-mini" data-nac-id="yujin.panel.full-to-mini" aria-label="${escapeHtml(tr.minimiseButton)}" title="${escapeHtml(tr.minimiseButton)}">▭</button>
|
|
459
481
|
<button id="full-close" data-nac-id="yujin.panel.full-close" aria-label="${escapeHtml(tr.closeButton)}" title="${escapeHtml(tr.closeButton)}">✕</button>
|
|
460
482
|
</div>
|
|
461
483
|
</div>
|
|
484
|
+
<!-- alpha.29: settings dropdown in topbar (voice mode, STT
|
|
485
|
+
provider, TTS voice, key status). Toggle via the cog. -->
|
|
486
|
+
<div id="yf-settings" class="hidden settings-panel">
|
|
487
|
+
<div class="set-row"><span class="set-lbl">Modo voz</span>
|
|
488
|
+
<button type="button" data-voice-mode-btn class="set-btn">modo mic</button>
|
|
489
|
+
</div>
|
|
490
|
+
<div class="set-row"><span class="set-lbl">STT</span>
|
|
491
|
+
<button type="button" data-stt-provider-btn class="set-btn">STT navegador</button>
|
|
492
|
+
</div>
|
|
493
|
+
<div class="set-row"><span class="set-lbl">Voz TTS</span>
|
|
494
|
+
<button type="button" data-tts-voice-btn class="set-btn">es-ES-Neural2-A (Lucia)</button>
|
|
495
|
+
</div>
|
|
496
|
+
<div class="set-row"><span class="set-lbl">Claves</span>
|
|
497
|
+
<span class="set-status" id="set-keys-status">cargando...</span>
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
462
500
|
<div class="body">
|
|
463
501
|
<div class="chat-col">
|
|
464
502
|
<div id="stream-full" data-nac-id="yujin.chat.stream-full" class="stream"></div>
|
|
@@ -557,6 +595,19 @@ function renderMd(src) {
|
|
|
557
595
|
out = out.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
|
|
558
596
|
out = out.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
559
597
|
out = out.replace(/\\n/g, '<br>');
|
|
598
|
+
/* Layer A.2 -- linkify file paths so the minority who wants to
|
|
599
|
+
* read code can click straight into their editor. Matches:
|
|
600
|
+
* src/foo/bar.ts
|
|
601
|
+
* tests/foo/bar.test.ts
|
|
602
|
+
* docs/SPEC.md
|
|
603
|
+
* packages/*\/src/*.ts
|
|
604
|
+
* Optionally with :line:col suffix. The result is a
|
|
605
|
+
* data-fpath link that the bound click handler resolves via
|
|
606
|
+
* /api/forge/open-in-editor. */
|
|
607
|
+
out = out.replace(
|
|
608
|
+
/(?<![\\w/])((?:src|tests|docs|packages|apps|scripts)\\/[\\w./-]+(?:\\.[\\w]+)(?::\\d+(?::\\d+)?)?)/g,
|
|
609
|
+
'<a class="fpath" data-fpath="$1" href="#" title="Abrir en mi editor">$1</a>',
|
|
610
|
+
);
|
|
560
611
|
return out;
|
|
561
612
|
}
|
|
562
613
|
|
|
@@ -577,6 +628,37 @@ function renderStream() {
|
|
|
577
628
|
else div.textContent = m.content;
|
|
578
629
|
target.appendChild(div);
|
|
579
630
|
}
|
|
631
|
+
/* Layer A.2 -- bind click handlers on .fpath links to call
|
|
632
|
+
the open-in-editor endpoint + post a trust event. Idempotent
|
|
633
|
+
re-binding is safe because we replace innerHTML each turn. */
|
|
634
|
+
target.querySelectorAll('a.fpath').forEach(function (a) {
|
|
635
|
+
a.addEventListener('click', async function (ev) {
|
|
636
|
+
ev.preventDefault();
|
|
637
|
+
const p = a.getAttribute('data-fpath');
|
|
638
|
+
if (!p) return;
|
|
639
|
+
/* Strip trailing :line:col -- the editor opens at the file. */
|
|
640
|
+
const cleanPath = p.replace(/:\\d+(?::\\d+)?$/, '');
|
|
641
|
+
try {
|
|
642
|
+
const r = await fetch('/api/forge/open-in-editor', {
|
|
643
|
+
method: 'POST',
|
|
644
|
+
headers: { 'content-type': 'application/json' },
|
|
645
|
+
body: JSON.stringify({ path: cleanPath }),
|
|
646
|
+
});
|
|
647
|
+
const body = await r.json();
|
|
648
|
+
if (!body.ok && body.path) {
|
|
649
|
+
navigator.clipboard.writeText(body.path).catch(function () {});
|
|
650
|
+
alert('No editor en PATH. Copie el path: ' + body.path);
|
|
651
|
+
}
|
|
652
|
+
fetch('/api/forge/trust-event', {
|
|
653
|
+
method: 'POST',
|
|
654
|
+
headers: { 'content-type': 'application/json' },
|
|
655
|
+
body: JSON.stringify({ kind: 'editor_open' }),
|
|
656
|
+
}).catch(function () {});
|
|
657
|
+
} catch (e) {
|
|
658
|
+
/* network down -- ignore, the link is best-effort */
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
});
|
|
580
662
|
}
|
|
581
663
|
if (state.busy) {
|
|
582
664
|
const d = document.createElement('div');
|
|
@@ -920,10 +1002,142 @@ async function vaultTest(slot, button) {
|
|
|
920
1002
|
}
|
|
921
1003
|
|
|
922
1004
|
/* ---- Voice integration (V1.6) ---- */
|
|
923
|
-
const TTS_TOGGLE_KEY
|
|
1005
|
+
const TTS_TOGGLE_KEY = 'yf-tts-replies-on';
|
|
1006
|
+
const VOICE_MODE_KEY = 'yf-voice-mode'; /* 2026-05-31 -- 'mic' | 'auricular' */
|
|
924
1007
|
let mediaRecorder = null;
|
|
925
1008
|
let recordChunks = [];
|
|
926
1009
|
let recordingPanelButton = null;
|
|
1010
|
+
/* 2026-05-31 half-duplex: when the user's last turn came through
|
|
1011
|
+
the mic (not the keyboard), auto-reopen the mic after TTS
|
|
1012
|
+
finishes -- so a conversation flows without push-to-talk every
|
|
1013
|
+
turn. If they typed, do NOT auto-open. Only applies in
|
|
1014
|
+
'auricular' mode -- in 'mic' (push-to-talk) the user stays
|
|
1015
|
+
in control. */
|
|
1016
|
+
let _lastInputWasVoice = false;
|
|
1017
|
+
let _lastVoiceButton = null;
|
|
1018
|
+
|
|
1019
|
+
/* STT provider selector (alpha.28+). Default 'browser' = Web
|
|
1020
|
+
* Speech API (instant, free, native es-AR). 'google' = our
|
|
1021
|
+
* existing MediaRecorder + Google Cloud STT REST path. 'whisper'
|
|
1022
|
+
* = server routes to Whisper API.
|
|
1023
|
+
* Per Pablo (2026-05-31): "el default queda el del navegador,
|
|
1024
|
+
* pero quiero poder elegir Google u otro". */
|
|
1025
|
+
const STT_PROVIDER_KEY = 'yf-stt-provider';
|
|
1026
|
+
function sttProvider() {
|
|
1027
|
+
try {
|
|
1028
|
+
const v = localStorage.getItem(STT_PROVIDER_KEY);
|
|
1029
|
+
if (v === 'google' || v === 'whisper' || v === 'browser') return v;
|
|
1030
|
+
} catch (_) {}
|
|
1031
|
+
return 'browser';
|
|
1032
|
+
}
|
|
1033
|
+
function setSttProvider(p) {
|
|
1034
|
+
try { localStorage.setItem(STT_PROVIDER_KEY, p); } catch (_) {}
|
|
1035
|
+
updateSttProviderButtons();
|
|
1036
|
+
}
|
|
1037
|
+
function updateSttProviderButtons() {
|
|
1038
|
+
const p = sttProvider();
|
|
1039
|
+
const label = p === 'browser' ? 'STT navegador'
|
|
1040
|
+
: p === 'google' ? 'STT Google'
|
|
1041
|
+
: 'STT Whisper';
|
|
1042
|
+
document.querySelectorAll('[data-stt-provider-btn]').forEach((b) => {
|
|
1043
|
+
b.textContent = label;
|
|
1044
|
+
b.title = 'Click para alternar entre navegador (rapido + es-AR nativo + gratis), '
|
|
1045
|
+
+ 'Google Cloud STT (server-side, BYOK) o Whisper (server-side, BYOK).';
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
function cycleSttProvider() {
|
|
1049
|
+
const order = ['browser', 'google', 'whisper'];
|
|
1050
|
+
const i = order.indexOf(sttProvider());
|
|
1051
|
+
setSttProvider(order[(i + 1) % order.length]);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/* alpha.29 -- TTS voice selection. Pablo: voz default debe ser
|
|
1055
|
+
* la que articule mejor las "s". es-ES (Spain) Neural2 voices
|
|
1056
|
+
* pronunciate finales claros vs es-US (Latin) que se las traga.
|
|
1057
|
+
* Also shows the active persona name in the settings panel. */
|
|
1058
|
+
const TTS_VOICE_KEY = 'yf-tts-voice';
|
|
1059
|
+
const TTS_VOICES = [
|
|
1060
|
+
{ id: 'es-ES-Neural2-A', persona: 'Lucia (ES)', gender: 'F', lang: 'es-ES' },
|
|
1061
|
+
{ id: 'es-ES-Neural2-B', persona: 'Javier (ES)', gender: 'M', lang: 'es-ES' },
|
|
1062
|
+
{ id: 'es-ES-Neural2-C', persona: 'Marta (ES)', gender: 'F', lang: 'es-ES' },
|
|
1063
|
+
{ id: 'es-ES-Neural2-D', persona: 'Carlos (ES)', gender: 'M', lang: 'es-ES' },
|
|
1064
|
+
{ id: 'es-US-Neural2-A', persona: 'Sofia (LATAM)', gender: 'F', lang: 'es-US' },
|
|
1065
|
+
{ id: 'es-US-Neural2-B', persona: 'Mateo (LATAM)', gender: 'M', lang: 'es-US' },
|
|
1066
|
+
{ id: 'en-US-Neural2-C', persona: 'Emma (EN)', gender: 'F', lang: 'en-US' },
|
|
1067
|
+
];
|
|
1068
|
+
function ttsVoice() {
|
|
1069
|
+
try {
|
|
1070
|
+
const v = localStorage.getItem(TTS_VOICE_KEY);
|
|
1071
|
+
if (v && TTS_VOICES.find((x) => x.id === v)) return v;
|
|
1072
|
+
} catch (_) {}
|
|
1073
|
+
return 'es-ES-Neural2-A'; /* default: Lucia ES -- articula las "s" */
|
|
1074
|
+
}
|
|
1075
|
+
function setTtsVoice(id) {
|
|
1076
|
+
try { localStorage.setItem(TTS_VOICE_KEY, id); } catch (_) {}
|
|
1077
|
+
updateTtsVoiceButtons();
|
|
1078
|
+
}
|
|
1079
|
+
function cycleTtsVoice() {
|
|
1080
|
+
const cur = ttsVoice();
|
|
1081
|
+
const i = TTS_VOICES.findIndex((v) => v.id === cur);
|
|
1082
|
+
const nxt = TTS_VOICES[(i + 1) % TTS_VOICES.length];
|
|
1083
|
+
setTtsVoice(nxt.id);
|
|
1084
|
+
}
|
|
1085
|
+
function updateTtsVoiceButtons() {
|
|
1086
|
+
const cur = ttsVoice();
|
|
1087
|
+
const meta = TTS_VOICES.find((v) => v.id === cur) || TTS_VOICES[0];
|
|
1088
|
+
document.querySelectorAll('[data-tts-voice-btn]').forEach((b) => {
|
|
1089
|
+
b.textContent = meta.id + ' (' + meta.persona + ')';
|
|
1090
|
+
b.title = 'Click para cambiar de voz. Persona actual: ' + meta.persona
|
|
1091
|
+
+ ' (' + meta.gender + ') -- lengua ' + meta.lang;
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/* Keys status fetcher: GETs /api/forge/keys-status (new endpoint)
|
|
1096
|
+
* and renders a short summary in the settings dropdown. */
|
|
1097
|
+
async function refreshKeysStatus() {
|
|
1098
|
+
const el = document.getElementById('set-keys-status');
|
|
1099
|
+
if (!el) return;
|
|
1100
|
+
el.textContent = 'cargando...';
|
|
1101
|
+
try {
|
|
1102
|
+
const r = await fetch('/api/forge/keys-status');
|
|
1103
|
+
const data = await r.json();
|
|
1104
|
+
if (!r.ok || !data.ok) {
|
|
1105
|
+
el.textContent = 'error: ' + (data.error || r.status);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const k = data.keys || {};
|
|
1109
|
+
const parts = [];
|
|
1110
|
+
parts.push((k.anthropic ? '✓' : '✗') + ' brain');
|
|
1111
|
+
parts.push((k.google_stt ? '✓' : '✗') + ' stt');
|
|
1112
|
+
parts.push((k.google_tts || k.elevenlabs ? '✓' : '✗') + ' tts');
|
|
1113
|
+
parts.push((k.license_paid ? '✓' : '✗') + ' license');
|
|
1114
|
+
el.textContent = parts.join(' · ');
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
el.textContent = 'error fetching: ' + (err && err.message ? err.message : 'unknown');
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function voiceMode() {
|
|
1121
|
+
/* Default 'mic' = push-to-talk (preserves pre-2026-05-31 UX). */
|
|
1122
|
+
try {
|
|
1123
|
+
const v = localStorage.getItem(VOICE_MODE_KEY);
|
|
1124
|
+
return v === 'auricular' ? 'auricular' : 'mic';
|
|
1125
|
+
} catch (_) { return 'mic'; }
|
|
1126
|
+
}
|
|
1127
|
+
function setVoiceMode(mode) {
|
|
1128
|
+
try { localStorage.setItem(VOICE_MODE_KEY, mode === 'auricular' ? 'auricular' : 'mic'); } catch (_) {}
|
|
1129
|
+
updateVoiceModeButtons();
|
|
1130
|
+
}
|
|
1131
|
+
function updateVoiceModeButtons() {
|
|
1132
|
+
const mode = voiceMode();
|
|
1133
|
+
document.querySelectorAll('[data-voice-mode-btn]').forEach((b) => {
|
|
1134
|
+
b.setAttribute('aria-pressed', String(mode === 'auricular'));
|
|
1135
|
+
b.textContent = mode === 'auricular' ? 'modo auricular' : 'modo mic';
|
|
1136
|
+
b.title = mode === 'auricular'
|
|
1137
|
+
? 'Manos libres -- el mic se reabre solo despues de que Yujin habla. Click para cambiar a push-to-talk.'
|
|
1138
|
+
: 'Push-to-talk -- apretas para hablar, soltas. Click para activar manos libres (auricular).';
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
927
1141
|
|
|
928
1142
|
function ttsRepliesEnabled() {
|
|
929
1143
|
try {
|
|
@@ -955,12 +1169,57 @@ function setMicButtonsState(state) {
|
|
|
955
1169
|
});
|
|
956
1170
|
}
|
|
957
1171
|
|
|
1172
|
+
/* 2026-05-31 -- Web Speech API path (browser-native STT).
|
|
1173
|
+
Mirrors the Pilot CRM implementation. When the browser
|
|
1174
|
+
exposes SpeechRecognition / webkitSpeechRecognition we use
|
|
1175
|
+
IT instead of MediaRecorder + Google Cloud STT REST. Why:
|
|
1176
|
+
- Instant (no server round-trip).
|
|
1177
|
+
- es-AR + es-CL + es-PE + every regional Spanish locale
|
|
1178
|
+
supported natively (no es-US mapping needed).
|
|
1179
|
+
- Free (the browser routes to Google STT for free under
|
|
1180
|
+
Chrome/Edge; Firefox uses its own backend).
|
|
1181
|
+
- Interim results (text appears as you speak).
|
|
1182
|
+
The Google Cloud STT path stays as a fallback for callers
|
|
1183
|
+
that explicitly want server-side recognition (e.g. audio
|
|
1184
|
+
pre-recorded files via the yf CLI). */
|
|
1185
|
+
let _webSpeechRecognition = null;
|
|
1186
|
+
|
|
1187
|
+
function _webSpeechAvailable() {
|
|
1188
|
+
return typeof window !== 'undefined'
|
|
1189
|
+
&& (typeof window.SpeechRecognition !== 'undefined'
|
|
1190
|
+
|| typeof window.webkitSpeechRecognition !== 'undefined');
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function _webSpeechLangFromDoc() {
|
|
1194
|
+
const raw = (document.documentElement.lang
|
|
1195
|
+
|| navigator.language || 'es-AR').toString();
|
|
1196
|
+
/* Web Speech API takes the locale as-is, including es-AR.
|
|
1197
|
+
No normalisation needed. */
|
|
1198
|
+
return raw;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
958
1201
|
async function micToggle(button) {
|
|
1202
|
+
if (_webSpeechRecognition) {
|
|
1203
|
+
/* Already listening -> stop. */
|
|
1204
|
+
try { _webSpeechRecognition.stop(); } catch (_) {}
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
959
1207
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
960
1208
|
/* Stop -> onstop handler dispatches transcription. */
|
|
961
1209
|
mediaRecorder.stop();
|
|
962
1210
|
return;
|
|
963
1211
|
}
|
|
1212
|
+
/* Provider-aware dispatch. Default 'browser' uses Web Speech
|
|
1213
|
+
API when available. 'google' / 'whisper' route through the
|
|
1214
|
+
server-side MediaRecorder + REST path. */
|
|
1215
|
+
const provider = sttProvider();
|
|
1216
|
+
if (provider === 'browser' && _webSpeechAvailable()) {
|
|
1217
|
+
return _micToggleWebSpeech(button);
|
|
1218
|
+
}
|
|
1219
|
+
if (provider === 'browser' && !_webSpeechAvailable()) {
|
|
1220
|
+
setStatus('Browser STT unavailable; using server-side fallback.');
|
|
1221
|
+
/* Fall through to MediaRecorder path. */
|
|
1222
|
+
}
|
|
964
1223
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
965
1224
|
setStatus('Your browser does not support microphone access.', true);
|
|
966
1225
|
return;
|
|
@@ -989,7 +1248,85 @@ async function micToggle(button) {
|
|
|
989
1248
|
mediaRecorder.ondataavailable = (e) => {
|
|
990
1249
|
if (e.data && e.data.size > 0) recordChunks.push(e.data);
|
|
991
1250
|
};
|
|
1251
|
+
|
|
1252
|
+
/* alpha.29 -- Real VAD for the MediaRecorder path (Google /
|
|
1253
|
+
Whisper STT providers). Tap the same audio stream that
|
|
1254
|
+
MediaRecorder is consuming via AudioContext + AnalyserNode,
|
|
1255
|
+
compute time-domain RMS every 50ms. State machine:
|
|
1256
|
+
- 'pre-voice': waiting for the first sustained speech.
|
|
1257
|
+
- 'voiced': speech detected; resets silence countdown
|
|
1258
|
+
on every chunk above threshold.
|
|
1259
|
+
- 'closing': N ms of sustained silence -> stop().
|
|
1260
|
+
Per Pablo: needs to close as soon as silence falls, just
|
|
1261
|
+
like Web Speech does natively. */
|
|
1262
|
+
const VAD_RMS_THRESHOLD = 0.02; /* normalised 0..1 */
|
|
1263
|
+
const VAD_SILENCE_MS = 800;
|
|
1264
|
+
const VAD_MIN_VOICED_MS = 200;
|
|
1265
|
+
let _vadCtx = null;
|
|
1266
|
+
let _vadAnalyser = null;
|
|
1267
|
+
let _vadSrc = null;
|
|
1268
|
+
let _vadTimer = null;
|
|
1269
|
+
let _vadVoicedStart = 0;
|
|
1270
|
+
let _vadLastVoicedAt = 0;
|
|
1271
|
+
let _vadHasVoiced = false;
|
|
1272
|
+
try {
|
|
1273
|
+
const AC = (window.AudioContext || window.webkitAudioContext);
|
|
1274
|
+
if (AC) {
|
|
1275
|
+
_vadCtx = new AC();
|
|
1276
|
+
_vadSrc = _vadCtx.createMediaStreamSource(stream);
|
|
1277
|
+
_vadAnalyser = _vadCtx.createAnalyser();
|
|
1278
|
+
_vadAnalyser.fftSize = 512;
|
|
1279
|
+
_vadSrc.connect(_vadAnalyser);
|
|
1280
|
+
const buf = new Float32Array(_vadAnalyser.fftSize);
|
|
1281
|
+
_vadTimer = setInterval(() => {
|
|
1282
|
+
if (!mediaRecorder || mediaRecorder.state !== 'recording') return;
|
|
1283
|
+
_vadAnalyser.getFloatTimeDomainData(buf);
|
|
1284
|
+
let sumsq = 0;
|
|
1285
|
+
for (let i = 0; i < buf.length; i++) sumsq += buf[i] * buf[i];
|
|
1286
|
+
const rms = Math.sqrt(sumsq / buf.length);
|
|
1287
|
+
const now = Date.now();
|
|
1288
|
+
if (rms > VAD_RMS_THRESHOLD) {
|
|
1289
|
+
if (!_vadHasVoiced) _vadVoicedStart = now;
|
|
1290
|
+
_vadHasVoiced = (now - _vadVoicedStart) >= VAD_MIN_VOICED_MS || _vadHasVoiced;
|
|
1291
|
+
_vadLastVoicedAt = now;
|
|
1292
|
+
} else if (_vadHasVoiced && (now - _vadLastVoicedAt) > VAD_SILENCE_MS) {
|
|
1293
|
+
/* Voice detected earlier, then sustained silence: close. */
|
|
1294
|
+
try { mediaRecorder.stop(); } catch (_) {}
|
|
1295
|
+
}
|
|
1296
|
+
}, 50);
|
|
1297
|
+
}
|
|
1298
|
+
} catch (_) { /* VAD unavailable; mic still works manually */ }
|
|
1299
|
+
/* Cleanup hook -- onstop also clears the VAD interval. */
|
|
1300
|
+
const _vadCleanup = () => {
|
|
1301
|
+
if (_vadTimer) { clearInterval(_vadTimer); _vadTimer = null; }
|
|
1302
|
+
try { if (_vadSrc) _vadSrc.disconnect(); } catch (_) {}
|
|
1303
|
+
try { if (_vadCtx && _vadCtx.state !== 'closed') _vadCtx.close(); } catch (_) {}
|
|
1304
|
+
};
|
|
1305
|
+
/* 2026-05-31 -- Google STT sync API rejects audio > 1 min
|
|
1306
|
+
with HTTP 400 "Sync input too long". Cap the recording
|
|
1307
|
+
at 55s (5s safety margin) + visual countdown so the user
|
|
1308
|
+
knows + auto-stop when reached. To go past this we would
|
|
1309
|
+
need to upload to GCS + use LongRunningRecognize; out of
|
|
1310
|
+
scope today. */
|
|
1311
|
+
const MAX_RECORD_MS = 55_000;
|
|
1312
|
+
const stopAt = Date.now() + MAX_RECORD_MS;
|
|
1313
|
+
const countdown = setInterval(() => {
|
|
1314
|
+
if (!mediaRecorder || mediaRecorder.state !== 'recording') {
|
|
1315
|
+
clearInterval(countdown);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const secLeft = Math.max(0, Math.ceil((stopAt - Date.now()) / 1000));
|
|
1319
|
+
if (secLeft <= 10) {
|
|
1320
|
+
setStatus('Recording... auto-stop in ' + secLeft + 's');
|
|
1321
|
+
}
|
|
1322
|
+
if (secLeft <= 0) {
|
|
1323
|
+
clearInterval(countdown);
|
|
1324
|
+
try { mediaRecorder.stop(); } catch { /* ignore */ }
|
|
1325
|
+
setStatus('Recording auto-stopped at 55s (Google STT 1-min limit). Try shorter.', true);
|
|
1326
|
+
}
|
|
1327
|
+
}, 1000);
|
|
992
1328
|
mediaRecorder.onstop = async () => {
|
|
1329
|
+
_vadCleanup();
|
|
993
1330
|
stream.getTracks().forEach((t) => t.stop());
|
|
994
1331
|
const blob = new Blob(recordChunks, { type: mimeType || 'audio/webm' });
|
|
995
1332
|
mediaRecorder = null;
|
|
@@ -998,6 +1335,14 @@ async function micToggle(button) {
|
|
|
998
1335
|
setStatus('No audio captured. Try again.', true);
|
|
999
1336
|
return;
|
|
1000
1337
|
}
|
|
1338
|
+
/* Client-side size guard. 4 MB ~ 60s of WebM/Opus at
|
|
1339
|
+
32 kbps which already trips the Google sync limit.
|
|
1340
|
+
Refuse before even hitting the network. */
|
|
1341
|
+
if (blob.size > 4_000_000) {
|
|
1342
|
+
setMicButtonsState('idle');
|
|
1343
|
+
setStatus('Audio too long (>1 min). Google STT sync limit. Try shorter.', true);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1001
1346
|
setMicButtonsState('processing');
|
|
1002
1347
|
try {
|
|
1003
1348
|
const buf = await blob.arrayBuffer();
|
|
@@ -1005,6 +1350,18 @@ async function micToggle(button) {
|
|
|
1005
1350
|
'content-type': 'application/octet-stream',
|
|
1006
1351
|
'x-audio-format': serverFormat,
|
|
1007
1352
|
};
|
|
1353
|
+
/* 2026-05-31 FIX: tell the server which language the
|
|
1354
|
+
user is speaking. Without this header the Google STT
|
|
1355
|
+
provider falls back to en-US, which silently mangles
|
|
1356
|
+
Spanish input ("No me escucha" symptom in alpha.19).
|
|
1357
|
+
Source-of-truth priority:
|
|
1358
|
+
1. <html lang="..."> (set by the panel via i18n).
|
|
1359
|
+
2. navigator.language (browser locale).
|
|
1360
|
+
3. 'es-AR' as a last-resort default for our market. */
|
|
1361
|
+
const langGuess = (document.documentElement.lang
|
|
1362
|
+
|| navigator.language
|
|
1363
|
+
|| 'es-AR').toString();
|
|
1364
|
+
if (langGuess) headers['x-audio-language'] = langGuess;
|
|
1008
1365
|
/* V1.32 -- carry the active reader doc_id so the
|
|
1009
1366
|
matcher can resolve commands like "siguiente" /
|
|
1010
1367
|
"buscar X" against the currently-open document. */
|
|
@@ -1026,6 +1383,11 @@ async function micToggle(button) {
|
|
|
1026
1383
|
setStatus('Did not catch that -- try again.', true);
|
|
1027
1384
|
return;
|
|
1028
1385
|
}
|
|
1386
|
+
/* Mark this turn as voice-originated so playTtsForText
|
|
1387
|
+
knows to reopen the mic afterwards (half-duplex
|
|
1388
|
+
conversational mode). */
|
|
1389
|
+
_lastInputWasVoice = true;
|
|
1390
|
+
_lastVoiceButton = recordingPanelButton;
|
|
1029
1391
|
/* V1.32 -- if the matcher recognised a reader command,
|
|
1030
1392
|
dispatch it directly (no Claude round-trip). Otherwise
|
|
1031
1393
|
the transcript flows into the chat input as before. */
|
|
@@ -1093,7 +1455,7 @@ async function dispatchReaderIntent(utterance, intent) {
|
|
|
1093
1455
|
}
|
|
1094
1456
|
/* Update the active reader doc on a successful open so
|
|
1095
1457
|
subsequent voice commands ("siguiente", etc) target it. */
|
|
1096
|
-
if (intent.tool === '
|
|
1458
|
+
if (intent.tool === 'forge_reader_open' && !data.is_error) {
|
|
1097
1459
|
const docId = data.result && data.result.doc_id;
|
|
1098
1460
|
if (docId) state.activeReaderDocId = String(docId);
|
|
1099
1461
|
}
|
|
@@ -1212,10 +1574,25 @@ function splitIntoTtsChunks(text, opts) {
|
|
|
1212
1574
|
* status bar). */
|
|
1213
1575
|
async function _fetchTtsChunk(text) {
|
|
1214
1576
|
try {
|
|
1577
|
+
/* Pass current panel language so the server can pick a voice
|
|
1578
|
+
* persona that matches (es-ES, en-US, etc). The TTS endpoint
|
|
1579
|
+
* resolves voice from { language } if no explicit voice given. */
|
|
1580
|
+
const lang = (document && document.documentElement && document.documentElement.lang)
|
|
1581
|
+
? document.documentElement.lang : undefined;
|
|
1582
|
+
/* 2026-05-31 -- speakingRate 1.05 for Spanish (mirror Pilot
|
|
1583
|
+
* CRM). Neural2 voices at rate 1.0 tend to swallow final
|
|
1584
|
+
* "s" in Latin-American Spanish; +5% rate makes the voice
|
|
1585
|
+
* articulate each syllable + drops the dropped-s symptom. */
|
|
1586
|
+
let speed;
|
|
1587
|
+
if (lang && /^es(-|$)/i.test(lang)) speed = 1.05;
|
|
1588
|
+
const voiceId = ttsVoice();
|
|
1589
|
+
const body = lang ? { text, language: lang } : { text };
|
|
1590
|
+
if (speed) body.speed = speed;
|
|
1591
|
+
if (voiceId) body.voice = voiceId;
|
|
1215
1592
|
const r = await fetch('/api/voice/tts', {
|
|
1216
1593
|
method: 'POST',
|
|
1217
1594
|
headers: { 'content-type': 'application/json' },
|
|
1218
|
-
body: JSON.stringify(
|
|
1595
|
+
body: JSON.stringify(body),
|
|
1219
1596
|
});
|
|
1220
1597
|
if (!r.ok) {
|
|
1221
1598
|
const data = await r.json().catch(() => ({}));
|
|
@@ -1311,7 +1688,7 @@ async function ingestSpecFile(file) {
|
|
|
1311
1688
|
state.toolHistory.push({
|
|
1312
1689
|
turnIndex: state.messages.length - 1,
|
|
1313
1690
|
rounds: [{
|
|
1314
|
-
tool: '
|
|
1691
|
+
tool: 'forge_spec_ingest',
|
|
1315
1692
|
input: { filename: ing.filename, format: ing.format },
|
|
1316
1693
|
display: 'parsed ' + ing.format + ': ' + (ing.title || ing.filename),
|
|
1317
1694
|
is_error: false,
|
|
@@ -1326,24 +1703,182 @@ async function ingestSpecFile(file) {
|
|
|
1326
1703
|
}
|
|
1327
1704
|
}
|
|
1328
1705
|
|
|
1706
|
+
/** Strip markdown emphasis + decorative characters so the TTS
|
|
1707
|
+
* reads fluidly. Without this Forge's chat output (asterisks,
|
|
1708
|
+
* code ticks, dashes, emojis) was being read literally as
|
|
1709
|
+
* "asterisco asterisco". Output keeps acentos + n-tilde + open
|
|
1710
|
+
* punctuation chars -- those ARE pronounced. */
|
|
1711
|
+
function cleanForTts(raw) {
|
|
1712
|
+
let s = String(raw || '');
|
|
1713
|
+
/* Code fences first (greedy, capture content but strip the
|
|
1714
|
+
fence markers + any language hint). */
|
|
1715
|
+
s = s.replace(/\`\`\`[a-zA-Z0-9_-]*\\n?([\\s\\S]*?)\`\`\`/g, ' $1 ');
|
|
1716
|
+
/* Inline code: backticks -> nothing (just keep the content). */
|
|
1717
|
+
s = s.replace(/\`([^\`]+)\`/g, '$1');
|
|
1718
|
+
/* Markdown links: [label](url) -> "label". */
|
|
1719
|
+
s = s.replace(/\\[([^\\]]+)\\]\\([^)]+\\)/g, '$1');
|
|
1720
|
+
/* Bold + italic + strikethrough markers -> nothing. */
|
|
1721
|
+
s = s.replace(/\\*+([^*]+?)\\*+/g, '$1');
|
|
1722
|
+
s = s.replace(/_+([^_]+?)_+/g, '$1');
|
|
1723
|
+
s = s.replace(/~+([^~]+?)~+/g, '$1');
|
|
1724
|
+
/* Standalone emphasis or decorative chars at any position. */
|
|
1725
|
+
s = s.replace(/[*_~\`]+/g, '');
|
|
1726
|
+
/* Heading hashes at line start. */
|
|
1727
|
+
s = s.replace(/^#+\\s*/gm, '');
|
|
1728
|
+
/* Bullet list markers at line start (- + *). */
|
|
1729
|
+
s = s.replace(/^\\s*[-+]\\s+/gm, '');
|
|
1730
|
+
/* Horizontal rules -> period. */
|
|
1731
|
+
s = s.replace(/^[-*_=]{3,}$/gm, '.');
|
|
1732
|
+
/* Emojis + symbols ranges (broad pass; leave latin-1 supplement
|
|
1733
|
+
intact so acentos survive). */
|
|
1734
|
+
s = s.replace(/[\\u{1F300}-\\u{1FAFF}]/gu, '');
|
|
1735
|
+
s = s.replace(/[\\u{2600}-\\u{27BF}]/gu, '');
|
|
1736
|
+
/* Multiple punctuation -> single period. */
|
|
1737
|
+
s = s.replace(/[.!?]{2,}/g, '.');
|
|
1738
|
+
/* Multiple spaces / newlines -> single space. */
|
|
1739
|
+
s = s.replace(/\\s+/g, ' ');
|
|
1740
|
+
return s.trim();
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/* 2026-05-31 half-duplex fix (per Pablo: "el audio no debe quedar
|
|
1744
|
+
full duplex"). When TTS is playing, the mic MUST be closed so
|
|
1745
|
+
we do not capture Yujin's own voice as if it were user input.
|
|
1746
|
+
That feedback loop is what made STT receive >1 min audio and
|
|
1747
|
+
trip the Google 1-min sync limit.
|
|
1748
|
+
|
|
1749
|
+
Implementation: closeMicForTts() stops any in-progress
|
|
1750
|
+
recording before TTS starts. If the user had push-to-talked,
|
|
1751
|
+
the audio they captured BEFORE TTS started is already in
|
|
1752
|
+
flight; we just prevent NEW captures. */
|
|
1753
|
+
function closeMicForTts() {
|
|
1754
|
+
if (_webSpeechRecognition) {
|
|
1755
|
+
try { _webSpeechRecognition.abort(); } catch (_) {}
|
|
1756
|
+
_webSpeechRecognition = null;
|
|
1757
|
+
setStatus('Mic closed while Yujin speaks (half-duplex).');
|
|
1758
|
+
}
|
|
1759
|
+
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
1760
|
+
try { mediaRecorder.stop(); } catch { /* ignore */ }
|
|
1761
|
+
setStatus('Mic closed while Yujin speaks (half-duplex).');
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
async function _micToggleWebSpeech(button) {
|
|
1766
|
+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
1767
|
+
recordingPanelButton = button;
|
|
1768
|
+
const rec = new SR();
|
|
1769
|
+
rec.continuous = false;
|
|
1770
|
+
rec.interimResults = true;
|
|
1771
|
+
rec.lang = _webSpeechLangFromDoc();
|
|
1772
|
+
let finalText = '';
|
|
1773
|
+
let interim = '';
|
|
1774
|
+
/* 2026-05-31 -- Aggressive silence cutoff. Chrome's default
|
|
1775
|
+
silence timeout for SpeechRecognition is ~3s; per Pablo
|
|
1776
|
+
"cerrar el mic mas rapido". We piggyback on interimResults:
|
|
1777
|
+
every time interim text changes we reset a timer; if the
|
|
1778
|
+
timer fires (no interim activity for SILENCE_MS) we stop
|
|
1779
|
+
the recogniser. */
|
|
1780
|
+
const SILENCE_MS = 900;
|
|
1781
|
+
let silenceTimer = null;
|
|
1782
|
+
const resetSilenceTimer = () => {
|
|
1783
|
+
if (silenceTimer) clearTimeout(silenceTimer);
|
|
1784
|
+
silenceTimer = setTimeout(() => {
|
|
1785
|
+
try { rec.stop(); } catch (_) {}
|
|
1786
|
+
}, SILENCE_MS);
|
|
1787
|
+
};
|
|
1788
|
+
rec.onresult = (e) => {
|
|
1789
|
+
interim = '';
|
|
1790
|
+
for (let i = e.resultIndex; i < e.results.length; i += 1) {
|
|
1791
|
+
const r = e.results[i];
|
|
1792
|
+
if (r.isFinal) finalText += r[0].transcript;
|
|
1793
|
+
else interim += r[0].transcript;
|
|
1794
|
+
}
|
|
1795
|
+
if (interim) setStatus('Listening... ' + interim);
|
|
1796
|
+
/* Activity heard -> reset the silence countdown. */
|
|
1797
|
+
resetSilenceTimer();
|
|
1798
|
+
};
|
|
1799
|
+
rec.onerror = (e) => {
|
|
1800
|
+
const errKind = (e && e.error) ? String(e.error) : 'unknown';
|
|
1801
|
+
if (errKind === 'no-speech' || errKind === 'aborted') {
|
|
1802
|
+
setStatus('');
|
|
1803
|
+
} else {
|
|
1804
|
+
setStatus('Mic error: ' + errKind, true);
|
|
1805
|
+
}
|
|
1806
|
+
};
|
|
1807
|
+
rec.onend = () => {
|
|
1808
|
+
if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null; }
|
|
1809
|
+
_webSpeechRecognition = null;
|
|
1810
|
+
setMicButtonsState('idle');
|
|
1811
|
+
const text = (finalText || '').trim();
|
|
1812
|
+
if (!text) {
|
|
1813
|
+
if (!interim) setStatus('Did not catch that -- try again.', true);
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
_lastInputWasVoice = true;
|
|
1817
|
+
_lastVoiceButton = recordingPanelButton;
|
|
1818
|
+
/* Inject + submit, same as the Google-STT path. */
|
|
1819
|
+
const inputSel = state.mode === 'full' ? '#bar-full input' : '#bar-mini input';
|
|
1820
|
+
const input = $(inputSel);
|
|
1821
|
+
if (input) {
|
|
1822
|
+
input.value = text;
|
|
1823
|
+
send(text);
|
|
1824
|
+
input.value = '';
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
try {
|
|
1828
|
+
_webSpeechRecognition = rec;
|
|
1829
|
+
rec.start();
|
|
1830
|
+
setMicButtonsState('recording');
|
|
1831
|
+
setStatus('Listening (web speech)...');
|
|
1832
|
+
/* IMPORTANT (alpha.29 bugfix): DO NOT arm the silence timer
|
|
1833
|
+
here. If we did, and the user takes >900ms to start
|
|
1834
|
+
talking (typical when they click + think), the timer would
|
|
1835
|
+
fire and rec.stop() before any audio arrived ->
|
|
1836
|
+
'Did not catch that'. The timer is armed by onresult
|
|
1837
|
+
once the FIRST interim/final result lands. */
|
|
1838
|
+
} catch (err) {
|
|
1839
|
+
_webSpeechRecognition = null;
|
|
1840
|
+
setStatus('Could not start mic: ' + (err && err.message ? err.message : String(err)), true);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1329
1844
|
async function playTtsForText(text) {
|
|
1845
|
+
text = cleanForTts(text);
|
|
1330
1846
|
if (!ttsRepliesEnabled()) return;
|
|
1331
1847
|
if (!text || text.trim() === '') return;
|
|
1848
|
+
/* HALF-DUPLEX GATE: close mic before TTS starts. */
|
|
1849
|
+
closeMicForTts();
|
|
1850
|
+
const wasVoiceTurn = _lastInputWasVoice;
|
|
1851
|
+
/* Reset so the next turn re-decides; e.g. user can switch
|
|
1852
|
+
to keyboard mid-conversation and the auto-reopen stops. */
|
|
1853
|
+
_lastInputWasVoice = false;
|
|
1854
|
+
const reopenBtn = _lastVoiceButton;
|
|
1332
1855
|
const chunks = splitIntoTtsChunks(text);
|
|
1333
1856
|
if (chunks.length === 0) return;
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1857
|
+
try {
|
|
1858
|
+
if (chunks.length === 1) {
|
|
1859
|
+
const url = await _fetchTtsChunk(chunks[0]);
|
|
1860
|
+
if (url) await _playChunkUrl(url);
|
|
1861
|
+
} else {
|
|
1862
|
+
/* Pipeline: prefetch chunk N+1 while chunk N plays. */
|
|
1863
|
+
let nextFetch = _fetchTtsChunk(chunks[0]);
|
|
1864
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
1865
|
+
const url = await nextFetch;
|
|
1866
|
+
nextFetch = (i + 1 < chunks.length) ? _fetchTtsChunk(chunks[i + 1]) : Promise.resolve(null);
|
|
1867
|
+
if (url) await _playChunkUrl(url);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
} finally {
|
|
1871
|
+
/* 2026-05-31 half-duplex auto-reopen. Only in 'auricular'
|
|
1872
|
+
mode (manos libres). In 'mic' mode (push-to-talk) we
|
|
1873
|
+
respect the user's explicit gesture and do NOT reopen.
|
|
1874
|
+
Tiny pause (200ms) so the TTS audio tail does not feed
|
|
1875
|
+
back into the next capture. */
|
|
1876
|
+
if (wasVoiceTurn && reopenBtn && voiceMode() === 'auricular') {
|
|
1877
|
+
setTimeout(() => {
|
|
1878
|
+
if (mediaRecorder && mediaRecorder.state === 'recording') return;
|
|
1879
|
+
try { micToggle(reopenBtn); } catch { /* ignore */ }
|
|
1880
|
+
}, 200);
|
|
1881
|
+
}
|
|
1347
1882
|
}
|
|
1348
1883
|
}
|
|
1349
1884
|
|
|
@@ -1360,7 +1895,124 @@ send = async function (text) {
|
|
|
1360
1895
|
}
|
|
1361
1896
|
};
|
|
1362
1897
|
|
|
1898
|
+
/* =========================================================================
|
|
1899
|
+
* Support report collector (2026-05-31 -- F30 lite).
|
|
1900
|
+
* Captures errors that Pablo / users would otherwise have to copy
|
|
1901
|
+
* from DevTools. Flushes batches to /api/forge/support/report.
|
|
1902
|
+
* ========================================================================= */
|
|
1903
|
+
const supportBuffer = [];
|
|
1904
|
+
const supportRequestTrail = [];
|
|
1905
|
+
let supportFlushTimer = null;
|
|
1906
|
+
const SUPPORT_BUFFER_MAX = 50;
|
|
1907
|
+
const SUPPORT_FLUSH_MS = 30000;
|
|
1908
|
+
|
|
1909
|
+
function supportPush(report) {
|
|
1910
|
+
if (supportBuffer.length >= SUPPORT_BUFFER_MAX) return;
|
|
1911
|
+
supportBuffer.push({
|
|
1912
|
+
...report,
|
|
1913
|
+
client_ts: new Date().toISOString(),
|
|
1914
|
+
view: typeof state !== 'undefined' && state.mode ? state.mode : 'unknown',
|
|
1915
|
+
request_trail: supportRequestTrail.slice(-10),
|
|
1916
|
+
});
|
|
1917
|
+
scheduleSupportFlush();
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function scheduleSupportFlush() {
|
|
1921
|
+
if (supportFlushTimer) return;
|
|
1922
|
+
supportFlushTimer = setTimeout(supportFlush, SUPPORT_FLUSH_MS);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
async function supportFlush() {
|
|
1926
|
+
supportFlushTimer = null;
|
|
1927
|
+
if (supportBuffer.length === 0) return;
|
|
1928
|
+
const batch = supportBuffer.splice(0, supportBuffer.length);
|
|
1929
|
+
try {
|
|
1930
|
+
await fetch('/api/forge/support/report', {
|
|
1931
|
+
method: 'POST',
|
|
1932
|
+
headers: { 'content-type': 'application/json' },
|
|
1933
|
+
body: JSON.stringify(batch),
|
|
1934
|
+
keepalive: true, /* survives page unload */
|
|
1935
|
+
});
|
|
1936
|
+
} catch {
|
|
1937
|
+
/* Network error -- drop the batch silently. Trying to
|
|
1938
|
+
report errors via the same broken pipe just loops. */
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
function installSupportCollector() {
|
|
1943
|
+
window.addEventListener('error', (ev) => {
|
|
1944
|
+
supportPush({
|
|
1945
|
+
level: 'error',
|
|
1946
|
+
source: 'window.onerror',
|
|
1947
|
+
message: ev.message || 'unknown error',
|
|
1948
|
+
stack: ev.error && ev.error.stack ? ev.error.stack : undefined,
|
|
1949
|
+
url: ev.filename || undefined,
|
|
1950
|
+
line: typeof ev.lineno === 'number' ? ev.lineno : undefined,
|
|
1951
|
+
col: typeof ev.colno === 'number' ? ev.colno : undefined,
|
|
1952
|
+
});
|
|
1953
|
+
});
|
|
1954
|
+
window.addEventListener('unhandledrejection', (ev) => {
|
|
1955
|
+
let msg = 'unhandled promise rejection';
|
|
1956
|
+
let stack;
|
|
1957
|
+
if (ev.reason && typeof ev.reason === 'object') {
|
|
1958
|
+
msg = ev.reason.message || String(ev.reason);
|
|
1959
|
+
stack = ev.reason.stack;
|
|
1960
|
+
} else if (ev.reason) {
|
|
1961
|
+
msg = String(ev.reason);
|
|
1962
|
+
}
|
|
1963
|
+
supportPush({
|
|
1964
|
+
level: 'error',
|
|
1965
|
+
source: 'unhandled',
|
|
1966
|
+
message: msg,
|
|
1967
|
+
stack,
|
|
1968
|
+
});
|
|
1969
|
+
});
|
|
1970
|
+
/* fetch interceptor -- record non-2xx + network failures. */
|
|
1971
|
+
const originalFetch = window.fetch.bind(window);
|
|
1972
|
+
window.fetch = async function patchedFetch(input, init) {
|
|
1973
|
+
const url = typeof input === 'string' ? input
|
|
1974
|
+
: (input && input.url) ? input.url : String(input);
|
|
1975
|
+
const method = (init && init.method) || (input && input.method) || 'GET';
|
|
1976
|
+
try {
|
|
1977
|
+
const r = await originalFetch(input, init);
|
|
1978
|
+
/* Track every call (last 10) so reports carry network context. */
|
|
1979
|
+
supportRequestTrail.push({ method, url, status: r.status });
|
|
1980
|
+
while (supportRequestTrail.length > 10) supportRequestTrail.shift();
|
|
1981
|
+
if (r.status >= 500) {
|
|
1982
|
+
supportPush({
|
|
1983
|
+
level: 'error',
|
|
1984
|
+
source: 'fetch',
|
|
1985
|
+
message: 'fetch ' + method + ' ' + url + ' HTTP ' + r.status,
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
return r;
|
|
1989
|
+
} catch (err) {
|
|
1990
|
+
supportRequestTrail.push({ method, url });
|
|
1991
|
+
while (supportRequestTrail.length > 10) supportRequestTrail.shift();
|
|
1992
|
+
supportPush({
|
|
1993
|
+
level: 'error',
|
|
1994
|
+
source: 'fetch',
|
|
1995
|
+
message: 'fetch ' + method + ' ' + url + ' threw: '
|
|
1996
|
+
+ (err && err.message ? err.message : String(err)),
|
|
1997
|
+
stack: err && err.stack ? err.stack : undefined,
|
|
1998
|
+
});
|
|
1999
|
+
throw err;
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
2002
|
+
/* Flush on unload using sendBeacon (more reliable than fetch
|
|
2003
|
+
during navigation tear-down). */
|
|
2004
|
+
window.addEventListener('pagehide', () => {
|
|
2005
|
+
if (supportBuffer.length === 0) return;
|
|
2006
|
+
const batch = supportBuffer.splice(0, supportBuffer.length);
|
|
2007
|
+
try {
|
|
2008
|
+
const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
|
|
2009
|
+
navigator.sendBeacon('/api/forge/support/report', blob);
|
|
2010
|
+
} catch { /* ignore */ }
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1363
2014
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2015
|
+
installSupportCollector();
|
|
1364
2016
|
setProj();
|
|
1365
2017
|
showMode(loadMode());
|
|
1366
2018
|
$('#yf-globito').addEventListener('click', () => showMode('mini'));
|
|
@@ -1376,6 +2028,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1376
2028
|
/* Voice controls. */
|
|
1377
2029
|
$$('[data-mic-btn]').forEach((b) => b.addEventListener('click', () => micToggle(b)));
|
|
1378
2030
|
$$('[data-tts-toggle]').forEach((b) => b.addEventListener('click', () => setTtsReplies(!ttsRepliesEnabled())));
|
|
2031
|
+
$$('[data-voice-mode-btn]').forEach((b) => b.addEventListener('click', () => {
|
|
2032
|
+
setVoiceMode(voiceMode() === 'auricular' ? 'mic' : 'auricular');
|
|
2033
|
+
}));
|
|
2034
|
+
updateVoiceModeButtons();
|
|
2035
|
+
$$('[data-stt-provider-btn]').forEach((b) => b.addEventListener('click', cycleSttProvider));
|
|
2036
|
+
updateSttProviderButtons();
|
|
2037
|
+
/* alpha.29 -- settings dropdown toggle + keys status. */
|
|
2038
|
+
const settingsPanel = $('#yf-settings');
|
|
2039
|
+
$$('[data-settings-toggle]').forEach((b) => b.addEventListener('click', () => {
|
|
2040
|
+
if (!settingsPanel) return;
|
|
2041
|
+
settingsPanel.classList.toggle('hidden');
|
|
2042
|
+
if (!settingsPanel.classList.contains('hidden')) refreshKeysStatus();
|
|
2043
|
+
}));
|
|
2044
|
+
/* Click outside closes. */
|
|
2045
|
+
document.addEventListener('click', (e) => {
|
|
2046
|
+
if (!settingsPanel || settingsPanel.classList.contains('hidden')) return;
|
|
2047
|
+
if (settingsPanel.contains(e.target)) return;
|
|
2048
|
+
if (e.target.closest('[data-settings-toggle]')) return;
|
|
2049
|
+
settingsPanel.classList.add('hidden');
|
|
2050
|
+
});
|
|
2051
|
+
/* TTS voice cycle. */
|
|
2052
|
+
$$('[data-tts-voice-btn]').forEach((b) => b.addEventListener('click', cycleTtsVoice));
|
|
2053
|
+
updateTtsVoiceButtons();
|
|
1379
2054
|
setMicButtonsState('idle');
|
|
1380
2055
|
updateTtsToggleButtons();
|
|
1381
2056
|
|