@nac3/forge-cli 0.2.0-alpha.3 → 0.2.0-alpha.31
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 +791 -17
- package/dist/chat/panel.js.map +1 -1
- package/dist/chat/server.js +734 -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/keys.d.ts +9 -0
- package/dist/chat/tools/keys.d.ts.map +1 -0
- package/dist/chat/tools/keys.js +194 -0
- package/dist/chat/tools/keys.js.map +1 -0
- 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 +31 -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,59 @@ 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: 12px; min-width: 360px; max-width: 420px;
|
|
245
|
+
max-height: 80vh; overflow-y: auto;
|
|
246
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
247
|
+
display: flex; flex-direction: column; gap: 12px;
|
|
248
|
+
}
|
|
249
|
+
.settings-panel.hidden { display: none; }
|
|
250
|
+
.settings-panel .set-section {
|
|
251
|
+
display: flex; flex-direction: column; gap: 6px;
|
|
252
|
+
padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.08);
|
|
253
|
+
}
|
|
254
|
+
.settings-panel .set-section:last-of-type { border-bottom: 0; padding-bottom: 0; }
|
|
255
|
+
.settings-panel .set-section-title {
|
|
256
|
+
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
|
|
257
|
+
color: var(--ink-2); font-weight: 600;
|
|
258
|
+
}
|
|
259
|
+
.settings-panel .set-row,
|
|
260
|
+
.settings-panel .set-key-row {
|
|
261
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
262
|
+
gap: 12px; font-size: 12px;
|
|
263
|
+
}
|
|
264
|
+
.settings-panel .set-key-row .set-lbl { flex: 1; }
|
|
265
|
+
.settings-panel .set-key-row .set-status { min-width: 24px; text-align: center; }
|
|
266
|
+
.settings-panel .set-lbl { color: var(--ink); font-weight: 500; }
|
|
267
|
+
.settings-panel .set-btn {
|
|
268
|
+
background: var(--bg-2); color: var(--ink); border: 1px solid rgba(255,255,255,0.15);
|
|
269
|
+
padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer;
|
|
270
|
+
}
|
|
271
|
+
.settings-panel .set-btn:hover { background: rgba(255,255,255,0.1); }
|
|
272
|
+
.settings-panel .set-btn-danger { color: #ff8b7a; border-color: rgba(255,139,122,0.3); }
|
|
273
|
+
.settings-panel .set-btn-danger:hover { background: rgba(255,139,122,0.1); }
|
|
274
|
+
.settings-panel .set-status { font-size: 11px; color: var(--ink-2); }
|
|
275
|
+
.settings-panel .set-link {
|
|
276
|
+
font-size: 11px; color: #6cb6ff; text-decoration: underline;
|
|
277
|
+
}
|
|
278
|
+
.settings-panel .key-editor {
|
|
279
|
+
margin-top: 8px; padding: 10px; background: var(--bg-2);
|
|
280
|
+
border-radius: 4px; display: flex; flex-direction: column; gap: 8px;
|
|
281
|
+
}
|
|
282
|
+
.settings-panel .key-editor.hidden { display: none; }
|
|
283
|
+
.settings-panel .key-editor .ke-title { font-size: 11px; color: var(--ink-2); }
|
|
284
|
+
.settings-panel .key-editor input {
|
|
285
|
+
background: var(--bg-1); color: var(--ink); border: 1px solid rgba(255,255,255,0.2);
|
|
286
|
+
padding: 6px 8px; border-radius: 4px; font-size: 12px; font-family: monospace;
|
|
287
|
+
}
|
|
288
|
+
.settings-panel .key-editor .ke-actions { display: flex; gap: 6px; }
|
|
289
|
+
.settings-panel .key-editor .ke-msg { font-size: 11px; min-height: 14px; }
|
|
290
|
+
.settings-panel .key-editor .ke-msg.error { color: #ff8b7a; }
|
|
291
|
+
.settings-panel .key-editor .ke-msg.success { color: #6dff8b; }
|
|
292
|
+
|
|
240
293
|
/* ---- chat stream ---- */
|
|
241
294
|
.stream { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
|
242
295
|
.empty { color: var(--ink-muted); text-align: center; padding: 32px 16px; }
|
|
@@ -453,12 +506,77 @@ body {
|
|
|
453
506
|
<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
507
|
</div>
|
|
455
508
|
<div class="actions">
|
|
509
|
+
<button class="settings-btn" data-settings-toggle aria-label="ajustes" title="Ajustes (modo voz, STT, TTS, claves)">⚙</button>
|
|
456
510
|
<button class="lang-btn" data-lang-open aria-label="${escapeHtml(tr.languageButton)}" title="${escapeHtml(tr.languageButton)}">🌐</button>
|
|
457
511
|
<button class="keys-btn" data-vault-open aria-label="${escapeHtml(tr.keysButton)}" title="${escapeHtml(tr.keysButton)}">${escapeHtml(tr.keysButton)}</button>
|
|
458
512
|
<button id="full-mini" data-nac-id="yujin.panel.full-to-mini" aria-label="${escapeHtml(tr.minimiseButton)}" title="${escapeHtml(tr.minimiseButton)}">▭</button>
|
|
459
513
|
<button id="full-close" data-nac-id="yujin.panel.full-close" aria-label="${escapeHtml(tr.closeButton)}" title="${escapeHtml(tr.closeButton)}">✕</button>
|
|
460
514
|
</div>
|
|
461
515
|
</div>
|
|
516
|
+
<!-- alpha.30: settings dropdown in topbar with editable
|
|
517
|
+
API keys (BYOK) + voice settings + license. -->
|
|
518
|
+
<div id="yf-settings" class="hidden settings-panel">
|
|
519
|
+
<div class="set-section">
|
|
520
|
+
<div class="set-section-title">Voz</div>
|
|
521
|
+
<div class="set-row"><span class="set-lbl">Modo</span>
|
|
522
|
+
<button type="button" data-voice-mode-btn class="set-btn">modo mic</button>
|
|
523
|
+
</div>
|
|
524
|
+
<div class="set-row"><span class="set-lbl">STT</span>
|
|
525
|
+
<button type="button" data-stt-provider-btn class="set-btn">STT navegador</button>
|
|
526
|
+
</div>
|
|
527
|
+
<div class="set-row"><span class="set-lbl">Voz TTS</span>
|
|
528
|
+
<button type="button" data-tts-voice-btn class="set-btn">es-ES-Neural2-A (Lucia)</button>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
<div class="set-section">
|
|
532
|
+
<div class="set-section-title">API keys (BYOK)</div>
|
|
533
|
+
<div class="set-key-row" data-key-slot="anthropic_api_key"><span class="set-lbl">Brain (Anthropic)</span>
|
|
534
|
+
<span class="set-status" data-key-status>...</span>
|
|
535
|
+
<button type="button" class="set-btn" data-key-edit>Configurar</button>
|
|
536
|
+
</div>
|
|
537
|
+
<div class="set-key-row" data-key-slot="openai_api_key"><span class="set-lbl">Brain (OpenAI)</span>
|
|
538
|
+
<span class="set-status" data-key-status>...</span>
|
|
539
|
+
<button type="button" class="set-btn" data-key-edit>Configurar</button>
|
|
540
|
+
</div>
|
|
541
|
+
<div class="set-key-row" data-key-slot="google_stt_key"><span class="set-lbl">STT Google</span>
|
|
542
|
+
<span class="set-status" data-key-status>...</span>
|
|
543
|
+
<button type="button" class="set-btn" data-key-edit>Configurar</button>
|
|
544
|
+
</div>
|
|
545
|
+
<div class="set-key-row" data-key-slot="google_tts_key"><span class="set-lbl">TTS Google</span>
|
|
546
|
+
<span class="set-status" data-key-status>...</span>
|
|
547
|
+
<button type="button" class="set-btn" data-key-edit>Configurar</button>
|
|
548
|
+
</div>
|
|
549
|
+
<div class="set-key-row" data-key-slot="elevenlabs_api_key"><span class="set-lbl">TTS ElevenLabs</span>
|
|
550
|
+
<span class="set-status" data-key-status>...</span>
|
|
551
|
+
<button type="button" class="set-btn" data-key-edit>Configurar</button>
|
|
552
|
+
</div>
|
|
553
|
+
<div class="set-key-row" data-key-slot="whisper_api_key"><span class="set-lbl">STT Whisper</span>
|
|
554
|
+
<span class="set-status" data-key-status>...</span>
|
|
555
|
+
<button type="button" class="set-btn" data-key-edit>Configurar</button>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
<div class="set-section">
|
|
559
|
+
<div class="set-section-title">License</div>
|
|
560
|
+
<div class="set-row"><span class="set-lbl">Plan</span>
|
|
561
|
+
<span class="set-status" id="set-license-status">cargando...</span>
|
|
562
|
+
</div>
|
|
563
|
+
<div class="set-row">
|
|
564
|
+
<a href="https://polar.sh/checkout/polar_c_FXDtc94fvh5H8rXORu24IQcSpnOhp9ijG3Cxn4CUWxr"
|
|
565
|
+
target="_blank" rel="noopener" class="set-link">Comprar / activar plan en polar.sh</a>
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
<!-- inline edit form (single, repositioned over the active key row) -->
|
|
569
|
+
<div id="yf-key-editor" class="hidden key-editor">
|
|
570
|
+
<div class="ke-title">Configurar <span id="ke-slot-name"></span></div>
|
|
571
|
+
<input type="password" id="ke-input" placeholder="Pega aqui tu API key" autocomplete="off"/>
|
|
572
|
+
<div class="ke-actions">
|
|
573
|
+
<button type="button" id="ke-save" class="set-btn">Guardar</button>
|
|
574
|
+
<button type="button" id="ke-clear" class="set-btn set-btn-danger">Quitar</button>
|
|
575
|
+
<button type="button" id="ke-cancel" class="set-btn">Cancelar</button>
|
|
576
|
+
</div>
|
|
577
|
+
<div id="ke-msg" class="ke-msg"></div>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
462
580
|
<div class="body">
|
|
463
581
|
<div class="chat-col">
|
|
464
582
|
<div id="stream-full" data-nac-id="yujin.chat.stream-full" class="stream"></div>
|
|
@@ -557,6 +675,19 @@ function renderMd(src) {
|
|
|
557
675
|
out = out.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
|
|
558
676
|
out = out.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
559
677
|
out = out.replace(/\\n/g, '<br>');
|
|
678
|
+
/* Layer A.2 -- linkify file paths so the minority who wants to
|
|
679
|
+
* read code can click straight into their editor. Matches:
|
|
680
|
+
* src/foo/bar.ts
|
|
681
|
+
* tests/foo/bar.test.ts
|
|
682
|
+
* docs/SPEC.md
|
|
683
|
+
* packages/*\/src/*.ts
|
|
684
|
+
* Optionally with :line:col suffix. The result is a
|
|
685
|
+
* data-fpath link that the bound click handler resolves via
|
|
686
|
+
* /api/forge/open-in-editor. */
|
|
687
|
+
out = out.replace(
|
|
688
|
+
/(?<![\\w/])((?:src|tests|docs|packages|apps|scripts)\\/[\\w./-]+(?:\\.[\\w]+)(?::\\d+(?::\\d+)?)?)/g,
|
|
689
|
+
'<a class="fpath" data-fpath="$1" href="#" title="Abrir en mi editor">$1</a>',
|
|
690
|
+
);
|
|
560
691
|
return out;
|
|
561
692
|
}
|
|
562
693
|
|
|
@@ -577,6 +708,37 @@ function renderStream() {
|
|
|
577
708
|
else div.textContent = m.content;
|
|
578
709
|
target.appendChild(div);
|
|
579
710
|
}
|
|
711
|
+
/* Layer A.2 -- bind click handlers on .fpath links to call
|
|
712
|
+
the open-in-editor endpoint + post a trust event. Idempotent
|
|
713
|
+
re-binding is safe because we replace innerHTML each turn. */
|
|
714
|
+
target.querySelectorAll('a.fpath').forEach(function (a) {
|
|
715
|
+
a.addEventListener('click', async function (ev) {
|
|
716
|
+
ev.preventDefault();
|
|
717
|
+
const p = a.getAttribute('data-fpath');
|
|
718
|
+
if (!p) return;
|
|
719
|
+
/* Strip trailing :line:col -- the editor opens at the file. */
|
|
720
|
+
const cleanPath = p.replace(/:\\d+(?::\\d+)?$/, '');
|
|
721
|
+
try {
|
|
722
|
+
const r = await fetch('/api/forge/open-in-editor', {
|
|
723
|
+
method: 'POST',
|
|
724
|
+
headers: { 'content-type': 'application/json' },
|
|
725
|
+
body: JSON.stringify({ path: cleanPath }),
|
|
726
|
+
});
|
|
727
|
+
const body = await r.json();
|
|
728
|
+
if (!body.ok && body.path) {
|
|
729
|
+
navigator.clipboard.writeText(body.path).catch(function () {});
|
|
730
|
+
alert('No editor en PATH. Copie el path: ' + body.path);
|
|
731
|
+
}
|
|
732
|
+
fetch('/api/forge/trust-event', {
|
|
733
|
+
method: 'POST',
|
|
734
|
+
headers: { 'content-type': 'application/json' },
|
|
735
|
+
body: JSON.stringify({ kind: 'editor_open' }),
|
|
736
|
+
}).catch(function () {});
|
|
737
|
+
} catch (e) {
|
|
738
|
+
/* network down -- ignore, the link is best-effort */
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
});
|
|
580
742
|
}
|
|
581
743
|
if (state.busy) {
|
|
582
744
|
const d = document.createElement('div');
|
|
@@ -920,10 +1082,157 @@ async function vaultTest(slot, button) {
|
|
|
920
1082
|
}
|
|
921
1083
|
|
|
922
1084
|
/* ---- Voice integration (V1.6) ---- */
|
|
923
|
-
const TTS_TOGGLE_KEY
|
|
1085
|
+
const TTS_TOGGLE_KEY = 'yf-tts-replies-on';
|
|
1086
|
+
const VOICE_MODE_KEY = 'yf-voice-mode'; /* 2026-05-31 -- 'mic' | 'auricular' */
|
|
924
1087
|
let mediaRecorder = null;
|
|
925
1088
|
let recordChunks = [];
|
|
926
1089
|
let recordingPanelButton = null;
|
|
1090
|
+
/* 2026-05-31 half-duplex: when the user's last turn came through
|
|
1091
|
+
the mic (not the keyboard), auto-reopen the mic after TTS
|
|
1092
|
+
finishes -- so a conversation flows without push-to-talk every
|
|
1093
|
+
turn. If they typed, do NOT auto-open. Only applies in
|
|
1094
|
+
'auricular' mode -- in 'mic' (push-to-talk) the user stays
|
|
1095
|
+
in control. */
|
|
1096
|
+
let _lastInputWasVoice = false;
|
|
1097
|
+
let _lastVoiceButton = null;
|
|
1098
|
+
|
|
1099
|
+
/* STT provider selector (alpha.28+). Default 'browser' = Web
|
|
1100
|
+
* Speech API (instant, free, native es-AR). 'google' = our
|
|
1101
|
+
* existing MediaRecorder + Google Cloud STT REST path. 'whisper'
|
|
1102
|
+
* = server routes to Whisper API.
|
|
1103
|
+
* Per Pablo (2026-05-31): "el default queda el del navegador,
|
|
1104
|
+
* pero quiero poder elegir Google u otro". */
|
|
1105
|
+
const STT_PROVIDER_KEY = 'yf-stt-provider';
|
|
1106
|
+
function sttProvider() {
|
|
1107
|
+
try {
|
|
1108
|
+
const v = localStorage.getItem(STT_PROVIDER_KEY);
|
|
1109
|
+
if (v === 'google' || v === 'whisper' || v === 'browser') return v;
|
|
1110
|
+
} catch (_) {}
|
|
1111
|
+
return 'browser';
|
|
1112
|
+
}
|
|
1113
|
+
function setSttProvider(p) {
|
|
1114
|
+
try { localStorage.setItem(STT_PROVIDER_KEY, p); } catch (_) {}
|
|
1115
|
+
updateSttProviderButtons();
|
|
1116
|
+
}
|
|
1117
|
+
function updateSttProviderButtons() {
|
|
1118
|
+
const p = sttProvider();
|
|
1119
|
+
const label = p === 'browser' ? 'STT navegador'
|
|
1120
|
+
: p === 'google' ? 'STT Google'
|
|
1121
|
+
: 'STT Whisper';
|
|
1122
|
+
document.querySelectorAll('[data-stt-provider-btn]').forEach((b) => {
|
|
1123
|
+
b.textContent = label;
|
|
1124
|
+
b.title = 'Click para alternar entre navegador (rapido + es-AR nativo + gratis), '
|
|
1125
|
+
+ 'Google Cloud STT (server-side, BYOK) o Whisper (server-side, BYOK).';
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
function cycleSttProvider() {
|
|
1129
|
+
const order = ['browser', 'google', 'whisper'];
|
|
1130
|
+
const i = order.indexOf(sttProvider());
|
|
1131
|
+
setSttProvider(order[(i + 1) % order.length]);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/* alpha.29 -- TTS voice selection. Pablo: voz default debe ser
|
|
1135
|
+
* la que articule mejor las "s". es-ES (Spain) Neural2 voices
|
|
1136
|
+
* pronunciate finales claros vs es-US (Latin) que se las traga.
|
|
1137
|
+
* Also shows the active persona name in the settings panel. */
|
|
1138
|
+
const TTS_VOICE_KEY = 'yf-tts-voice';
|
|
1139
|
+
/* alpha.31 -- voice catalogue expanded with Studio + Wavenet
|
|
1140
|
+
* variants. Studio voices hyperarticulate (newscaster style)
|
|
1141
|
+
* and pronounce final-'s' clearly; Neural2 voices use natural
|
|
1142
|
+
* Spanish prosody that aspirates 's'. User toggles between
|
|
1143
|
+
* "claridad" (Studio) and "naturalidad" (Neural2). */
|
|
1144
|
+
const TTS_VOICES = [
|
|
1145
|
+
/* Studio voices FIRST -- they articulate every 's' clearly. */
|
|
1146
|
+
{ id: 'es-ES-Studio-C', persona: 'Lucia Studio (ES)', gender: 'F', lang: 'es-ES', style: 'Studio (clara)' },
|
|
1147
|
+
{ id: 'es-ES-Studio-F', persona: 'Javier Studio (ES)', gender: 'M', lang: 'es-ES', style: 'Studio (clara)' },
|
|
1148
|
+
{ id: 'es-US-Studio-B', persona: 'Mateo Studio (LATAM)', gender: 'M', lang: 'es-US', style: 'Studio (clara)' },
|
|
1149
|
+
/* Neural2 (natural Spanish prosody, aspirates final 's'). */
|
|
1150
|
+
{ id: 'es-ES-Neural2-A', persona: 'Lucia Neural (ES)', gender: 'F', lang: 'es-ES', style: 'Neural2 (natural)' },
|
|
1151
|
+
{ id: 'es-ES-Neural2-B', persona: 'Javier Neural (ES)', gender: 'M', lang: 'es-ES', style: 'Neural2 (natural)' },
|
|
1152
|
+
{ id: 'es-ES-Neural2-C', persona: 'Marta Neural (ES)', gender: 'F', lang: 'es-ES', style: 'Neural2 (natural)' },
|
|
1153
|
+
{ id: 'es-ES-Neural2-D', persona: 'Carlos Neural (ES)', gender: 'M', lang: 'es-ES', style: 'Neural2 (natural)' },
|
|
1154
|
+
{ id: 'es-US-Neural2-A', persona: 'Sofia Neural (LATAM)', gender: 'F', lang: 'es-US', style: 'Neural2 (natural)' },
|
|
1155
|
+
{ id: 'es-US-Neural2-B', persona: 'Mateo Neural (LATAM)', gender: 'M', lang: 'es-US', style: 'Neural2 (natural)' },
|
|
1156
|
+
/* Wavenet (older, balanced articulation). */
|
|
1157
|
+
{ id: 'es-ES-Wavenet-B', persona: 'Lucia Wavenet (ES)', gender: 'F', lang: 'es-ES', style: 'Wavenet (clasica)' },
|
|
1158
|
+
{ id: 'en-US-Neural2-C', persona: 'Emma (EN)', gender: 'F', lang: 'en-US', style: 'Neural2' },
|
|
1159
|
+
];
|
|
1160
|
+
function ttsVoice() {
|
|
1161
|
+
try {
|
|
1162
|
+
const v = localStorage.getItem(TTS_VOICE_KEY);
|
|
1163
|
+
if (v && TTS_VOICES.find((x) => x.id === v)) return v;
|
|
1164
|
+
} catch (_) {}
|
|
1165
|
+
/* alpha.31 default: Studio -- hyperarticulates 's' finals. */
|
|
1166
|
+
return 'es-ES-Studio-C';
|
|
1167
|
+
}
|
|
1168
|
+
function setTtsVoice(id) {
|
|
1169
|
+
try { localStorage.setItem(TTS_VOICE_KEY, id); } catch (_) {}
|
|
1170
|
+
updateTtsVoiceButtons();
|
|
1171
|
+
}
|
|
1172
|
+
function cycleTtsVoice() {
|
|
1173
|
+
const cur = ttsVoice();
|
|
1174
|
+
const i = TTS_VOICES.findIndex((v) => v.id === cur);
|
|
1175
|
+
const nxt = TTS_VOICES[(i + 1) % TTS_VOICES.length];
|
|
1176
|
+
setTtsVoice(nxt.id);
|
|
1177
|
+
}
|
|
1178
|
+
function updateTtsVoiceButtons() {
|
|
1179
|
+
const cur = ttsVoice();
|
|
1180
|
+
const meta = TTS_VOICES.find((v) => v.id === cur) || TTS_VOICES[0];
|
|
1181
|
+
document.querySelectorAll('[data-tts-voice-btn]').forEach((b) => {
|
|
1182
|
+
b.textContent = meta.persona + ' [' + meta.style + ']';
|
|
1183
|
+
b.title = 'Voz actual: ' + meta.id + ' / ' + meta.persona
|
|
1184
|
+
+ ' (' + meta.gender + ') -- ' + meta.style + '. '
|
|
1185
|
+
+ 'Click para ciclar. Studio = articula s claro; Neural2 = '
|
|
1186
|
+
+ 'natural espaniol (aspira s finales).';
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/* Keys status fetcher: GETs /api/forge/keys-status (new endpoint)
|
|
1191
|
+
* and renders a short summary in the settings dropdown. */
|
|
1192
|
+
async function refreshKeysStatus() {
|
|
1193
|
+
const el = document.getElementById('set-keys-status');
|
|
1194
|
+
if (!el) return;
|
|
1195
|
+
el.textContent = 'cargando...';
|
|
1196
|
+
try {
|
|
1197
|
+
const r = await fetch('/api/forge/keys-status');
|
|
1198
|
+
const data = await r.json();
|
|
1199
|
+
if (!r.ok || !data.ok) {
|
|
1200
|
+
el.textContent = 'error: ' + (data.error || r.status);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
const k = data.keys || {};
|
|
1204
|
+
const parts = [];
|
|
1205
|
+
parts.push((k.anthropic ? '✓' : '✗') + ' brain');
|
|
1206
|
+
parts.push((k.google_stt ? '✓' : '✗') + ' stt');
|
|
1207
|
+
parts.push((k.google_tts || k.elevenlabs ? '✓' : '✗') + ' tts');
|
|
1208
|
+
parts.push((k.license_paid ? '✓' : '✗') + ' license');
|
|
1209
|
+
el.textContent = parts.join(' · ');
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
el.textContent = 'error fetching: ' + (err && err.message ? err.message : 'unknown');
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function voiceMode() {
|
|
1216
|
+
/* Default 'mic' = push-to-talk (preserves pre-2026-05-31 UX). */
|
|
1217
|
+
try {
|
|
1218
|
+
const v = localStorage.getItem(VOICE_MODE_KEY);
|
|
1219
|
+
return v === 'auricular' ? 'auricular' : 'mic';
|
|
1220
|
+
} catch (_) { return 'mic'; }
|
|
1221
|
+
}
|
|
1222
|
+
function setVoiceMode(mode) {
|
|
1223
|
+
try { localStorage.setItem(VOICE_MODE_KEY, mode === 'auricular' ? 'auricular' : 'mic'); } catch (_) {}
|
|
1224
|
+
updateVoiceModeButtons();
|
|
1225
|
+
}
|
|
1226
|
+
function updateVoiceModeButtons() {
|
|
1227
|
+
const mode = voiceMode();
|
|
1228
|
+
document.querySelectorAll('[data-voice-mode-btn]').forEach((b) => {
|
|
1229
|
+
b.setAttribute('aria-pressed', String(mode === 'auricular'));
|
|
1230
|
+
b.textContent = mode === 'auricular' ? 'modo auricular' : 'modo mic';
|
|
1231
|
+
b.title = mode === 'auricular'
|
|
1232
|
+
? 'Manos libres -- el mic se reabre solo despues de que Yujin habla. Click para cambiar a push-to-talk.'
|
|
1233
|
+
: 'Push-to-talk -- apretas para hablar, soltas. Click para activar manos libres (auricular).';
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
927
1236
|
|
|
928
1237
|
function ttsRepliesEnabled() {
|
|
929
1238
|
try {
|
|
@@ -955,12 +1264,57 @@ function setMicButtonsState(state) {
|
|
|
955
1264
|
});
|
|
956
1265
|
}
|
|
957
1266
|
|
|
1267
|
+
/* 2026-05-31 -- Web Speech API path (browser-native STT).
|
|
1268
|
+
Mirrors the Pilot CRM implementation. When the browser
|
|
1269
|
+
exposes SpeechRecognition / webkitSpeechRecognition we use
|
|
1270
|
+
IT instead of MediaRecorder + Google Cloud STT REST. Why:
|
|
1271
|
+
- Instant (no server round-trip).
|
|
1272
|
+
- es-AR + es-CL + es-PE + every regional Spanish locale
|
|
1273
|
+
supported natively (no es-US mapping needed).
|
|
1274
|
+
- Free (the browser routes to Google STT for free under
|
|
1275
|
+
Chrome/Edge; Firefox uses its own backend).
|
|
1276
|
+
- Interim results (text appears as you speak).
|
|
1277
|
+
The Google Cloud STT path stays as a fallback for callers
|
|
1278
|
+
that explicitly want server-side recognition (e.g. audio
|
|
1279
|
+
pre-recorded files via the yf CLI). */
|
|
1280
|
+
let _webSpeechRecognition = null;
|
|
1281
|
+
|
|
1282
|
+
function _webSpeechAvailable() {
|
|
1283
|
+
return typeof window !== 'undefined'
|
|
1284
|
+
&& (typeof window.SpeechRecognition !== 'undefined'
|
|
1285
|
+
|| typeof window.webkitSpeechRecognition !== 'undefined');
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function _webSpeechLangFromDoc() {
|
|
1289
|
+
const raw = (document.documentElement.lang
|
|
1290
|
+
|| navigator.language || 'es-AR').toString();
|
|
1291
|
+
/* Web Speech API takes the locale as-is, including es-AR.
|
|
1292
|
+
No normalisation needed. */
|
|
1293
|
+
return raw;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
958
1296
|
async function micToggle(button) {
|
|
1297
|
+
if (_webSpeechRecognition) {
|
|
1298
|
+
/* Already listening -> stop. */
|
|
1299
|
+
try { _webSpeechRecognition.stop(); } catch (_) {}
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
959
1302
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
960
1303
|
/* Stop -> onstop handler dispatches transcription. */
|
|
961
1304
|
mediaRecorder.stop();
|
|
962
1305
|
return;
|
|
963
1306
|
}
|
|
1307
|
+
/* Provider-aware dispatch. Default 'browser' uses Web Speech
|
|
1308
|
+
API when available. 'google' / 'whisper' route through the
|
|
1309
|
+
server-side MediaRecorder + REST path. */
|
|
1310
|
+
const provider = sttProvider();
|
|
1311
|
+
if (provider === 'browser' && _webSpeechAvailable()) {
|
|
1312
|
+
return _micToggleWebSpeech(button);
|
|
1313
|
+
}
|
|
1314
|
+
if (provider === 'browser' && !_webSpeechAvailable()) {
|
|
1315
|
+
setStatus('Browser STT unavailable; using server-side fallback.');
|
|
1316
|
+
/* Fall through to MediaRecorder path. */
|
|
1317
|
+
}
|
|
964
1318
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
965
1319
|
setStatus('Your browser does not support microphone access.', true);
|
|
966
1320
|
return;
|
|
@@ -989,7 +1343,85 @@ async function micToggle(button) {
|
|
|
989
1343
|
mediaRecorder.ondataavailable = (e) => {
|
|
990
1344
|
if (e.data && e.data.size > 0) recordChunks.push(e.data);
|
|
991
1345
|
};
|
|
1346
|
+
|
|
1347
|
+
/* alpha.29 -- Real VAD for the MediaRecorder path (Google /
|
|
1348
|
+
Whisper STT providers). Tap the same audio stream that
|
|
1349
|
+
MediaRecorder is consuming via AudioContext + AnalyserNode,
|
|
1350
|
+
compute time-domain RMS every 50ms. State machine:
|
|
1351
|
+
- 'pre-voice': waiting for the first sustained speech.
|
|
1352
|
+
- 'voiced': speech detected; resets silence countdown
|
|
1353
|
+
on every chunk above threshold.
|
|
1354
|
+
- 'closing': N ms of sustained silence -> stop().
|
|
1355
|
+
Per Pablo: needs to close as soon as silence falls, just
|
|
1356
|
+
like Web Speech does natively. */
|
|
1357
|
+
const VAD_RMS_THRESHOLD = 0.02; /* normalised 0..1 */
|
|
1358
|
+
const VAD_SILENCE_MS = 800;
|
|
1359
|
+
const VAD_MIN_VOICED_MS = 200;
|
|
1360
|
+
let _vadCtx = null;
|
|
1361
|
+
let _vadAnalyser = null;
|
|
1362
|
+
let _vadSrc = null;
|
|
1363
|
+
let _vadTimer = null;
|
|
1364
|
+
let _vadVoicedStart = 0;
|
|
1365
|
+
let _vadLastVoicedAt = 0;
|
|
1366
|
+
let _vadHasVoiced = false;
|
|
1367
|
+
try {
|
|
1368
|
+
const AC = (window.AudioContext || window.webkitAudioContext);
|
|
1369
|
+
if (AC) {
|
|
1370
|
+
_vadCtx = new AC();
|
|
1371
|
+
_vadSrc = _vadCtx.createMediaStreamSource(stream);
|
|
1372
|
+
_vadAnalyser = _vadCtx.createAnalyser();
|
|
1373
|
+
_vadAnalyser.fftSize = 512;
|
|
1374
|
+
_vadSrc.connect(_vadAnalyser);
|
|
1375
|
+
const buf = new Float32Array(_vadAnalyser.fftSize);
|
|
1376
|
+
_vadTimer = setInterval(() => {
|
|
1377
|
+
if (!mediaRecorder || mediaRecorder.state !== 'recording') return;
|
|
1378
|
+
_vadAnalyser.getFloatTimeDomainData(buf);
|
|
1379
|
+
let sumsq = 0;
|
|
1380
|
+
for (let i = 0; i < buf.length; i++) sumsq += buf[i] * buf[i];
|
|
1381
|
+
const rms = Math.sqrt(sumsq / buf.length);
|
|
1382
|
+
const now = Date.now();
|
|
1383
|
+
if (rms > VAD_RMS_THRESHOLD) {
|
|
1384
|
+
if (!_vadHasVoiced) _vadVoicedStart = now;
|
|
1385
|
+
_vadHasVoiced = (now - _vadVoicedStart) >= VAD_MIN_VOICED_MS || _vadHasVoiced;
|
|
1386
|
+
_vadLastVoicedAt = now;
|
|
1387
|
+
} else if (_vadHasVoiced && (now - _vadLastVoicedAt) > VAD_SILENCE_MS) {
|
|
1388
|
+
/* Voice detected earlier, then sustained silence: close. */
|
|
1389
|
+
try { mediaRecorder.stop(); } catch (_) {}
|
|
1390
|
+
}
|
|
1391
|
+
}, 50);
|
|
1392
|
+
}
|
|
1393
|
+
} catch (_) { /* VAD unavailable; mic still works manually */ }
|
|
1394
|
+
/* Cleanup hook -- onstop also clears the VAD interval. */
|
|
1395
|
+
const _vadCleanup = () => {
|
|
1396
|
+
if (_vadTimer) { clearInterval(_vadTimer); _vadTimer = null; }
|
|
1397
|
+
try { if (_vadSrc) _vadSrc.disconnect(); } catch (_) {}
|
|
1398
|
+
try { if (_vadCtx && _vadCtx.state !== 'closed') _vadCtx.close(); } catch (_) {}
|
|
1399
|
+
};
|
|
1400
|
+
/* 2026-05-31 -- Google STT sync API rejects audio > 1 min
|
|
1401
|
+
with HTTP 400 "Sync input too long". Cap the recording
|
|
1402
|
+
at 55s (5s safety margin) + visual countdown so the user
|
|
1403
|
+
knows + auto-stop when reached. To go past this we would
|
|
1404
|
+
need to upload to GCS + use LongRunningRecognize; out of
|
|
1405
|
+
scope today. */
|
|
1406
|
+
const MAX_RECORD_MS = 55_000;
|
|
1407
|
+
const stopAt = Date.now() + MAX_RECORD_MS;
|
|
1408
|
+
const countdown = setInterval(() => {
|
|
1409
|
+
if (!mediaRecorder || mediaRecorder.state !== 'recording') {
|
|
1410
|
+
clearInterval(countdown);
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const secLeft = Math.max(0, Math.ceil((stopAt - Date.now()) / 1000));
|
|
1414
|
+
if (secLeft <= 10) {
|
|
1415
|
+
setStatus('Recording... auto-stop in ' + secLeft + 's');
|
|
1416
|
+
}
|
|
1417
|
+
if (secLeft <= 0) {
|
|
1418
|
+
clearInterval(countdown);
|
|
1419
|
+
try { mediaRecorder.stop(); } catch { /* ignore */ }
|
|
1420
|
+
setStatus('Recording auto-stopped at 55s (Google STT 1-min limit). Try shorter.', true);
|
|
1421
|
+
}
|
|
1422
|
+
}, 1000);
|
|
992
1423
|
mediaRecorder.onstop = async () => {
|
|
1424
|
+
_vadCleanup();
|
|
993
1425
|
stream.getTracks().forEach((t) => t.stop());
|
|
994
1426
|
const blob = new Blob(recordChunks, { type: mimeType || 'audio/webm' });
|
|
995
1427
|
mediaRecorder = null;
|
|
@@ -998,6 +1430,14 @@ async function micToggle(button) {
|
|
|
998
1430
|
setStatus('No audio captured. Try again.', true);
|
|
999
1431
|
return;
|
|
1000
1432
|
}
|
|
1433
|
+
/* Client-side size guard. 4 MB ~ 60s of WebM/Opus at
|
|
1434
|
+
32 kbps which already trips the Google sync limit.
|
|
1435
|
+
Refuse before even hitting the network. */
|
|
1436
|
+
if (blob.size > 4_000_000) {
|
|
1437
|
+
setMicButtonsState('idle');
|
|
1438
|
+
setStatus('Audio too long (>1 min). Google STT sync limit. Try shorter.', true);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1001
1441
|
setMicButtonsState('processing');
|
|
1002
1442
|
try {
|
|
1003
1443
|
const buf = await blob.arrayBuffer();
|
|
@@ -1005,6 +1445,18 @@ async function micToggle(button) {
|
|
|
1005
1445
|
'content-type': 'application/octet-stream',
|
|
1006
1446
|
'x-audio-format': serverFormat,
|
|
1007
1447
|
};
|
|
1448
|
+
/* 2026-05-31 FIX: tell the server which language the
|
|
1449
|
+
user is speaking. Without this header the Google STT
|
|
1450
|
+
provider falls back to en-US, which silently mangles
|
|
1451
|
+
Spanish input ("No me escucha" symptom in alpha.19).
|
|
1452
|
+
Source-of-truth priority:
|
|
1453
|
+
1. <html lang="..."> (set by the panel via i18n).
|
|
1454
|
+
2. navigator.language (browser locale).
|
|
1455
|
+
3. 'es-AR' as a last-resort default for our market. */
|
|
1456
|
+
const langGuess = (document.documentElement.lang
|
|
1457
|
+
|| navigator.language
|
|
1458
|
+
|| 'es-AR').toString();
|
|
1459
|
+
if (langGuess) headers['x-audio-language'] = langGuess;
|
|
1008
1460
|
/* V1.32 -- carry the active reader doc_id so the
|
|
1009
1461
|
matcher can resolve commands like "siguiente" /
|
|
1010
1462
|
"buscar X" against the currently-open document. */
|
|
@@ -1026,6 +1478,11 @@ async function micToggle(button) {
|
|
|
1026
1478
|
setStatus('Did not catch that -- try again.', true);
|
|
1027
1479
|
return;
|
|
1028
1480
|
}
|
|
1481
|
+
/* Mark this turn as voice-originated so playTtsForText
|
|
1482
|
+
knows to reopen the mic afterwards (half-duplex
|
|
1483
|
+
conversational mode). */
|
|
1484
|
+
_lastInputWasVoice = true;
|
|
1485
|
+
_lastVoiceButton = recordingPanelButton;
|
|
1029
1486
|
/* V1.32 -- if the matcher recognised a reader command,
|
|
1030
1487
|
dispatch it directly (no Claude round-trip). Otherwise
|
|
1031
1488
|
the transcript flows into the chat input as before. */
|
|
@@ -1093,7 +1550,7 @@ async function dispatchReaderIntent(utterance, intent) {
|
|
|
1093
1550
|
}
|
|
1094
1551
|
/* Update the active reader doc on a successful open so
|
|
1095
1552
|
subsequent voice commands ("siguiente", etc) target it. */
|
|
1096
|
-
if (intent.tool === '
|
|
1553
|
+
if (intent.tool === 'forge_reader_open' && !data.is_error) {
|
|
1097
1554
|
const docId = data.result && data.result.doc_id;
|
|
1098
1555
|
if (docId) state.activeReaderDocId = String(docId);
|
|
1099
1556
|
}
|
|
@@ -1212,10 +1669,29 @@ function splitIntoTtsChunks(text, opts) {
|
|
|
1212
1669
|
* status bar). */
|
|
1213
1670
|
async function _fetchTtsChunk(text) {
|
|
1214
1671
|
try {
|
|
1672
|
+
/* Pass current panel language so the server can pick a voice
|
|
1673
|
+
* persona that matches (es-ES, en-US, etc). The TTS endpoint
|
|
1674
|
+
* resolves voice from { language } if no explicit voice given. */
|
|
1675
|
+
const lang = (document && document.documentElement && document.documentElement.lang)
|
|
1676
|
+
? document.documentElement.lang : undefined;
|
|
1677
|
+
/* alpha.31 -- Empirically verified (tools/tts_trace_smoke.mjs)
|
|
1678
|
+
* that the panel-side filter preserves every 's'. What the
|
|
1679
|
+
* user heard as 'dropped s' is Google Neural2 voices
|
|
1680
|
+
* naturally aspirating Spanish final-'s' (it is prosodically
|
|
1681
|
+
* correct castellano + LATAM behaviour). The 1.05 rate from
|
|
1682
|
+
* alpha.28 actually made it WORSE (less time per phoneme).
|
|
1683
|
+
* Back to speed=1.0; for hyperarticulated reads switch the
|
|
1684
|
+
* voice in settings to a Studio variant. */
|
|
1685
|
+
let speed;
|
|
1686
|
+
if (lang && /^es(-|$)/i.test(lang)) speed = 1.0;
|
|
1687
|
+
const voiceId = ttsVoice();
|
|
1688
|
+
const body = lang ? { text, language: lang } : { text };
|
|
1689
|
+
if (speed) body.speed = speed;
|
|
1690
|
+
if (voiceId) body.voice = voiceId;
|
|
1215
1691
|
const r = await fetch('/api/voice/tts', {
|
|
1216
1692
|
method: 'POST',
|
|
1217
1693
|
headers: { 'content-type': 'application/json' },
|
|
1218
|
-
body: JSON.stringify(
|
|
1694
|
+
body: JSON.stringify(body),
|
|
1219
1695
|
});
|
|
1220
1696
|
if (!r.ok) {
|
|
1221
1697
|
const data = await r.json().catch(() => ({}));
|
|
@@ -1311,7 +1787,7 @@ async function ingestSpecFile(file) {
|
|
|
1311
1787
|
state.toolHistory.push({
|
|
1312
1788
|
turnIndex: state.messages.length - 1,
|
|
1313
1789
|
rounds: [{
|
|
1314
|
-
tool: '
|
|
1790
|
+
tool: 'forge_spec_ingest',
|
|
1315
1791
|
input: { filename: ing.filename, format: ing.format },
|
|
1316
1792
|
display: 'parsed ' + ing.format + ': ' + (ing.title || ing.filename),
|
|
1317
1793
|
is_error: false,
|
|
@@ -1326,24 +1802,182 @@ async function ingestSpecFile(file) {
|
|
|
1326
1802
|
}
|
|
1327
1803
|
}
|
|
1328
1804
|
|
|
1805
|
+
/** Strip markdown emphasis + decorative characters so the TTS
|
|
1806
|
+
* reads fluidly. Without this Forge's chat output (asterisks,
|
|
1807
|
+
* code ticks, dashes, emojis) was being read literally as
|
|
1808
|
+
* "asterisco asterisco". Output keeps acentos + n-tilde + open
|
|
1809
|
+
* punctuation chars -- those ARE pronounced. */
|
|
1810
|
+
function cleanForTts(raw) {
|
|
1811
|
+
let s = String(raw || '');
|
|
1812
|
+
/* Code fences first (greedy, capture content but strip the
|
|
1813
|
+
fence markers + any language hint). */
|
|
1814
|
+
s = s.replace(/\`\`\`[a-zA-Z0-9_-]*\\n?([\\s\\S]*?)\`\`\`/g, ' $1 ');
|
|
1815
|
+
/* Inline code: backticks -> nothing (just keep the content). */
|
|
1816
|
+
s = s.replace(/\`([^\`]+)\`/g, '$1');
|
|
1817
|
+
/* Markdown links: [label](url) -> "label". */
|
|
1818
|
+
s = s.replace(/\\[([^\\]]+)\\]\\([^)]+\\)/g, '$1');
|
|
1819
|
+
/* Bold + italic + strikethrough markers -> nothing. */
|
|
1820
|
+
s = s.replace(/\\*+([^*]+?)\\*+/g, '$1');
|
|
1821
|
+
s = s.replace(/_+([^_]+?)_+/g, '$1');
|
|
1822
|
+
s = s.replace(/~+([^~]+?)~+/g, '$1');
|
|
1823
|
+
/* Standalone emphasis or decorative chars at any position. */
|
|
1824
|
+
s = s.replace(/[*_~\`]+/g, '');
|
|
1825
|
+
/* Heading hashes at line start. */
|
|
1826
|
+
s = s.replace(/^#+\\s*/gm, '');
|
|
1827
|
+
/* Bullet list markers at line start (- + *). */
|
|
1828
|
+
s = s.replace(/^\\s*[-+]\\s+/gm, '');
|
|
1829
|
+
/* Horizontal rules -> period. */
|
|
1830
|
+
s = s.replace(/^[-*_=]{3,}$/gm, '.');
|
|
1831
|
+
/* Emojis + symbols ranges (broad pass; leave latin-1 supplement
|
|
1832
|
+
intact so acentos survive). */
|
|
1833
|
+
s = s.replace(/[\\u{1F300}-\\u{1FAFF}]/gu, '');
|
|
1834
|
+
s = s.replace(/[\\u{2600}-\\u{27BF}]/gu, '');
|
|
1835
|
+
/* Multiple punctuation -> single period. */
|
|
1836
|
+
s = s.replace(/[.!?]{2,}/g, '.');
|
|
1837
|
+
/* Multiple spaces / newlines -> single space. */
|
|
1838
|
+
s = s.replace(/\\s+/g, ' ');
|
|
1839
|
+
return s.trim();
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/* 2026-05-31 half-duplex fix (per Pablo: "el audio no debe quedar
|
|
1843
|
+
full duplex"). When TTS is playing, the mic MUST be closed so
|
|
1844
|
+
we do not capture Yujin's own voice as if it were user input.
|
|
1845
|
+
That feedback loop is what made STT receive >1 min audio and
|
|
1846
|
+
trip the Google 1-min sync limit.
|
|
1847
|
+
|
|
1848
|
+
Implementation: closeMicForTts() stops any in-progress
|
|
1849
|
+
recording before TTS starts. If the user had push-to-talked,
|
|
1850
|
+
the audio they captured BEFORE TTS started is already in
|
|
1851
|
+
flight; we just prevent NEW captures. */
|
|
1852
|
+
function closeMicForTts() {
|
|
1853
|
+
if (_webSpeechRecognition) {
|
|
1854
|
+
try { _webSpeechRecognition.abort(); } catch (_) {}
|
|
1855
|
+
_webSpeechRecognition = null;
|
|
1856
|
+
setStatus('Mic closed while Yujin speaks (half-duplex).');
|
|
1857
|
+
}
|
|
1858
|
+
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
1859
|
+
try { mediaRecorder.stop(); } catch { /* ignore */ }
|
|
1860
|
+
setStatus('Mic closed while Yujin speaks (half-duplex).');
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
async function _micToggleWebSpeech(button) {
|
|
1865
|
+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
1866
|
+
recordingPanelButton = button;
|
|
1867
|
+
const rec = new SR();
|
|
1868
|
+
rec.continuous = false;
|
|
1869
|
+
rec.interimResults = true;
|
|
1870
|
+
rec.lang = _webSpeechLangFromDoc();
|
|
1871
|
+
let finalText = '';
|
|
1872
|
+
let interim = '';
|
|
1873
|
+
/* 2026-05-31 -- Aggressive silence cutoff. Chrome's default
|
|
1874
|
+
silence timeout for SpeechRecognition is ~3s; per Pablo
|
|
1875
|
+
"cerrar el mic mas rapido". We piggyback on interimResults:
|
|
1876
|
+
every time interim text changes we reset a timer; if the
|
|
1877
|
+
timer fires (no interim activity for SILENCE_MS) we stop
|
|
1878
|
+
the recogniser. */
|
|
1879
|
+
const SILENCE_MS = 900;
|
|
1880
|
+
let silenceTimer = null;
|
|
1881
|
+
const resetSilenceTimer = () => {
|
|
1882
|
+
if (silenceTimer) clearTimeout(silenceTimer);
|
|
1883
|
+
silenceTimer = setTimeout(() => {
|
|
1884
|
+
try { rec.stop(); } catch (_) {}
|
|
1885
|
+
}, SILENCE_MS);
|
|
1886
|
+
};
|
|
1887
|
+
rec.onresult = (e) => {
|
|
1888
|
+
interim = '';
|
|
1889
|
+
for (let i = e.resultIndex; i < e.results.length; i += 1) {
|
|
1890
|
+
const r = e.results[i];
|
|
1891
|
+
if (r.isFinal) finalText += r[0].transcript;
|
|
1892
|
+
else interim += r[0].transcript;
|
|
1893
|
+
}
|
|
1894
|
+
if (interim) setStatus('Listening... ' + interim);
|
|
1895
|
+
/* Activity heard -> reset the silence countdown. */
|
|
1896
|
+
resetSilenceTimer();
|
|
1897
|
+
};
|
|
1898
|
+
rec.onerror = (e) => {
|
|
1899
|
+
const errKind = (e && e.error) ? String(e.error) : 'unknown';
|
|
1900
|
+
if (errKind === 'no-speech' || errKind === 'aborted') {
|
|
1901
|
+
setStatus('');
|
|
1902
|
+
} else {
|
|
1903
|
+
setStatus('Mic error: ' + errKind, true);
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
rec.onend = () => {
|
|
1907
|
+
if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null; }
|
|
1908
|
+
_webSpeechRecognition = null;
|
|
1909
|
+
setMicButtonsState('idle');
|
|
1910
|
+
const text = (finalText || '').trim();
|
|
1911
|
+
if (!text) {
|
|
1912
|
+
if (!interim) setStatus('Did not catch that -- try again.', true);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
_lastInputWasVoice = true;
|
|
1916
|
+
_lastVoiceButton = recordingPanelButton;
|
|
1917
|
+
/* Inject + submit, same as the Google-STT path. */
|
|
1918
|
+
const inputSel = state.mode === 'full' ? '#bar-full input' : '#bar-mini input';
|
|
1919
|
+
const input = $(inputSel);
|
|
1920
|
+
if (input) {
|
|
1921
|
+
input.value = text;
|
|
1922
|
+
send(text);
|
|
1923
|
+
input.value = '';
|
|
1924
|
+
}
|
|
1925
|
+
};
|
|
1926
|
+
try {
|
|
1927
|
+
_webSpeechRecognition = rec;
|
|
1928
|
+
rec.start();
|
|
1929
|
+
setMicButtonsState('recording');
|
|
1930
|
+
setStatus('Listening (web speech)...');
|
|
1931
|
+
/* IMPORTANT (alpha.29 bugfix): DO NOT arm the silence timer
|
|
1932
|
+
here. If we did, and the user takes >900ms to start
|
|
1933
|
+
talking (typical when they click + think), the timer would
|
|
1934
|
+
fire and rec.stop() before any audio arrived ->
|
|
1935
|
+
'Did not catch that'. The timer is armed by onresult
|
|
1936
|
+
once the FIRST interim/final result lands. */
|
|
1937
|
+
} catch (err) {
|
|
1938
|
+
_webSpeechRecognition = null;
|
|
1939
|
+
setStatus('Could not start mic: ' + (err && err.message ? err.message : String(err)), true);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1329
1943
|
async function playTtsForText(text) {
|
|
1944
|
+
text = cleanForTts(text);
|
|
1330
1945
|
if (!ttsRepliesEnabled()) return;
|
|
1331
1946
|
if (!text || text.trim() === '') return;
|
|
1947
|
+
/* HALF-DUPLEX GATE: close mic before TTS starts. */
|
|
1948
|
+
closeMicForTts();
|
|
1949
|
+
const wasVoiceTurn = _lastInputWasVoice;
|
|
1950
|
+
/* Reset so the next turn re-decides; e.g. user can switch
|
|
1951
|
+
to keyboard mid-conversation and the auto-reopen stops. */
|
|
1952
|
+
_lastInputWasVoice = false;
|
|
1953
|
+
const reopenBtn = _lastVoiceButton;
|
|
1332
1954
|
const chunks = splitIntoTtsChunks(text);
|
|
1333
1955
|
if (chunks.length === 0) return;
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1956
|
+
try {
|
|
1957
|
+
if (chunks.length === 1) {
|
|
1958
|
+
const url = await _fetchTtsChunk(chunks[0]);
|
|
1959
|
+
if (url) await _playChunkUrl(url);
|
|
1960
|
+
} else {
|
|
1961
|
+
/* Pipeline: prefetch chunk N+1 while chunk N plays. */
|
|
1962
|
+
let nextFetch = _fetchTtsChunk(chunks[0]);
|
|
1963
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
1964
|
+
const url = await nextFetch;
|
|
1965
|
+
nextFetch = (i + 1 < chunks.length) ? _fetchTtsChunk(chunks[i + 1]) : Promise.resolve(null);
|
|
1966
|
+
if (url) await _playChunkUrl(url);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
} finally {
|
|
1970
|
+
/* 2026-05-31 half-duplex auto-reopen. Only in 'auricular'
|
|
1971
|
+
mode (manos libres). In 'mic' mode (push-to-talk) we
|
|
1972
|
+
respect the user's explicit gesture and do NOT reopen.
|
|
1973
|
+
Tiny pause (200ms) so the TTS audio tail does not feed
|
|
1974
|
+
back into the next capture. */
|
|
1975
|
+
if (wasVoiceTurn && reopenBtn && voiceMode() === 'auricular') {
|
|
1976
|
+
setTimeout(() => {
|
|
1977
|
+
if (mediaRecorder && mediaRecorder.state === 'recording') return;
|
|
1978
|
+
try { micToggle(reopenBtn); } catch { /* ignore */ }
|
|
1979
|
+
}, 200);
|
|
1980
|
+
}
|
|
1347
1981
|
}
|
|
1348
1982
|
}
|
|
1349
1983
|
|
|
@@ -1360,7 +1994,124 @@ send = async function (text) {
|
|
|
1360
1994
|
}
|
|
1361
1995
|
};
|
|
1362
1996
|
|
|
1997
|
+
/* =========================================================================
|
|
1998
|
+
* Support report collector (2026-05-31 -- F30 lite).
|
|
1999
|
+
* Captures errors that Pablo / users would otherwise have to copy
|
|
2000
|
+
* from DevTools. Flushes batches to /api/forge/support/report.
|
|
2001
|
+
* ========================================================================= */
|
|
2002
|
+
const supportBuffer = [];
|
|
2003
|
+
const supportRequestTrail = [];
|
|
2004
|
+
let supportFlushTimer = null;
|
|
2005
|
+
const SUPPORT_BUFFER_MAX = 50;
|
|
2006
|
+
const SUPPORT_FLUSH_MS = 30000;
|
|
2007
|
+
|
|
2008
|
+
function supportPush(report) {
|
|
2009
|
+
if (supportBuffer.length >= SUPPORT_BUFFER_MAX) return;
|
|
2010
|
+
supportBuffer.push({
|
|
2011
|
+
...report,
|
|
2012
|
+
client_ts: new Date().toISOString(),
|
|
2013
|
+
view: typeof state !== 'undefined' && state.mode ? state.mode : 'unknown',
|
|
2014
|
+
request_trail: supportRequestTrail.slice(-10),
|
|
2015
|
+
});
|
|
2016
|
+
scheduleSupportFlush();
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function scheduleSupportFlush() {
|
|
2020
|
+
if (supportFlushTimer) return;
|
|
2021
|
+
supportFlushTimer = setTimeout(supportFlush, SUPPORT_FLUSH_MS);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
async function supportFlush() {
|
|
2025
|
+
supportFlushTimer = null;
|
|
2026
|
+
if (supportBuffer.length === 0) return;
|
|
2027
|
+
const batch = supportBuffer.splice(0, supportBuffer.length);
|
|
2028
|
+
try {
|
|
2029
|
+
await fetch('/api/forge/support/report', {
|
|
2030
|
+
method: 'POST',
|
|
2031
|
+
headers: { 'content-type': 'application/json' },
|
|
2032
|
+
body: JSON.stringify(batch),
|
|
2033
|
+
keepalive: true, /* survives page unload */
|
|
2034
|
+
});
|
|
2035
|
+
} catch {
|
|
2036
|
+
/* Network error -- drop the batch silently. Trying to
|
|
2037
|
+
report errors via the same broken pipe just loops. */
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function installSupportCollector() {
|
|
2042
|
+
window.addEventListener('error', (ev) => {
|
|
2043
|
+
supportPush({
|
|
2044
|
+
level: 'error',
|
|
2045
|
+
source: 'window.onerror',
|
|
2046
|
+
message: ev.message || 'unknown error',
|
|
2047
|
+
stack: ev.error && ev.error.stack ? ev.error.stack : undefined,
|
|
2048
|
+
url: ev.filename || undefined,
|
|
2049
|
+
line: typeof ev.lineno === 'number' ? ev.lineno : undefined,
|
|
2050
|
+
col: typeof ev.colno === 'number' ? ev.colno : undefined,
|
|
2051
|
+
});
|
|
2052
|
+
});
|
|
2053
|
+
window.addEventListener('unhandledrejection', (ev) => {
|
|
2054
|
+
let msg = 'unhandled promise rejection';
|
|
2055
|
+
let stack;
|
|
2056
|
+
if (ev.reason && typeof ev.reason === 'object') {
|
|
2057
|
+
msg = ev.reason.message || String(ev.reason);
|
|
2058
|
+
stack = ev.reason.stack;
|
|
2059
|
+
} else if (ev.reason) {
|
|
2060
|
+
msg = String(ev.reason);
|
|
2061
|
+
}
|
|
2062
|
+
supportPush({
|
|
2063
|
+
level: 'error',
|
|
2064
|
+
source: 'unhandled',
|
|
2065
|
+
message: msg,
|
|
2066
|
+
stack,
|
|
2067
|
+
});
|
|
2068
|
+
});
|
|
2069
|
+
/* fetch interceptor -- record non-2xx + network failures. */
|
|
2070
|
+
const originalFetch = window.fetch.bind(window);
|
|
2071
|
+
window.fetch = async function patchedFetch(input, init) {
|
|
2072
|
+
const url = typeof input === 'string' ? input
|
|
2073
|
+
: (input && input.url) ? input.url : String(input);
|
|
2074
|
+
const method = (init && init.method) || (input && input.method) || 'GET';
|
|
2075
|
+
try {
|
|
2076
|
+
const r = await originalFetch(input, init);
|
|
2077
|
+
/* Track every call (last 10) so reports carry network context. */
|
|
2078
|
+
supportRequestTrail.push({ method, url, status: r.status });
|
|
2079
|
+
while (supportRequestTrail.length > 10) supportRequestTrail.shift();
|
|
2080
|
+
if (r.status >= 500) {
|
|
2081
|
+
supportPush({
|
|
2082
|
+
level: 'error',
|
|
2083
|
+
source: 'fetch',
|
|
2084
|
+
message: 'fetch ' + method + ' ' + url + ' HTTP ' + r.status,
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
return r;
|
|
2088
|
+
} catch (err) {
|
|
2089
|
+
supportRequestTrail.push({ method, url });
|
|
2090
|
+
while (supportRequestTrail.length > 10) supportRequestTrail.shift();
|
|
2091
|
+
supportPush({
|
|
2092
|
+
level: 'error',
|
|
2093
|
+
source: 'fetch',
|
|
2094
|
+
message: 'fetch ' + method + ' ' + url + ' threw: '
|
|
2095
|
+
+ (err && err.message ? err.message : String(err)),
|
|
2096
|
+
stack: err && err.stack ? err.stack : undefined,
|
|
2097
|
+
});
|
|
2098
|
+
throw err;
|
|
2099
|
+
}
|
|
2100
|
+
};
|
|
2101
|
+
/* Flush on unload using sendBeacon (more reliable than fetch
|
|
2102
|
+
during navigation tear-down). */
|
|
2103
|
+
window.addEventListener('pagehide', () => {
|
|
2104
|
+
if (supportBuffer.length === 0) return;
|
|
2105
|
+
const batch = supportBuffer.splice(0, supportBuffer.length);
|
|
2106
|
+
try {
|
|
2107
|
+
const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
|
|
2108
|
+
navigator.sendBeacon('/api/forge/support/report', blob);
|
|
2109
|
+
} catch { /* ignore */ }
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
|
|
1363
2113
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2114
|
+
installSupportCollector();
|
|
1364
2115
|
setProj();
|
|
1365
2116
|
showMode(loadMode());
|
|
1366
2117
|
$('#yf-globito').addEventListener('click', () => showMode('mini'));
|
|
@@ -1376,6 +2127,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1376
2127
|
/* Voice controls. */
|
|
1377
2128
|
$$('[data-mic-btn]').forEach((b) => b.addEventListener('click', () => micToggle(b)));
|
|
1378
2129
|
$$('[data-tts-toggle]').forEach((b) => b.addEventListener('click', () => setTtsReplies(!ttsRepliesEnabled())));
|
|
2130
|
+
$$('[data-voice-mode-btn]').forEach((b) => b.addEventListener('click', () => {
|
|
2131
|
+
setVoiceMode(voiceMode() === 'auricular' ? 'mic' : 'auricular');
|
|
2132
|
+
}));
|
|
2133
|
+
updateVoiceModeButtons();
|
|
2134
|
+
$$('[data-stt-provider-btn]').forEach((b) => b.addEventListener('click', cycleSttProvider));
|
|
2135
|
+
updateSttProviderButtons();
|
|
2136
|
+
/* alpha.29 -- settings dropdown toggle + keys status. */
|
|
2137
|
+
const settingsPanel = $('#yf-settings');
|
|
2138
|
+
$$('[data-settings-toggle]').forEach((b) => b.addEventListener('click', () => {
|
|
2139
|
+
if (!settingsPanel) return;
|
|
2140
|
+
settingsPanel.classList.toggle('hidden');
|
|
2141
|
+
if (!settingsPanel.classList.contains('hidden')) refreshKeysStatus();
|
|
2142
|
+
}));
|
|
2143
|
+
/* Click outside closes. */
|
|
2144
|
+
document.addEventListener('click', (e) => {
|
|
2145
|
+
if (!settingsPanel || settingsPanel.classList.contains('hidden')) return;
|
|
2146
|
+
if (settingsPanel.contains(e.target)) return;
|
|
2147
|
+
if (e.target.closest('[data-settings-toggle]')) return;
|
|
2148
|
+
settingsPanel.classList.add('hidden');
|
|
2149
|
+
});
|
|
2150
|
+
/* TTS voice cycle. */
|
|
2151
|
+
$$('[data-tts-voice-btn]').forEach((b) => b.addEventListener('click', cycleTtsVoice));
|
|
2152
|
+
updateTtsVoiceButtons();
|
|
1379
2153
|
setMicButtonsState('idle');
|
|
1380
2154
|
updateTtsToggleButtons();
|
|
1381
2155
|
|