pretheme
This commit is contained in:
+5
-5
@@ -129,12 +129,12 @@ s2s-reasoning-engine/lib/flows_cred.json
|
||||
s2s-reasoning-engine/.config.*
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Demo static artifacts generated at runtime by browser / proxy logs
|
||||
# s2s-client static artifacts generated at runtime by browser / proxy logs
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
demo/.cache/
|
||||
demo/uploads/
|
||||
demo/sessions/
|
||||
demo/*.log
|
||||
s2s-client/.cache/
|
||||
s2s-client/uploads/
|
||||
s2s-client/sessions/
|
||||
s2s-client/*.log
|
||||
speech_to_speech/*.log
|
||||
speech_to_speech/sessions/
|
||||
speech_to_speech/.cache/
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate, max-age=0" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>Minimal Conversation · S2S backend (WebSocket)</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css?v=20260902001" />
|
||||
</head>
|
||||
<!--
|
||||
`booting` disables transitions/animations until the first paint commits.
|
||||
Without this the orb visibly fades/scales in on the very first frame
|
||||
because `state-idle` and the generic `.ind` defaults differ. The
|
||||
bootstrap script at the end of main.js strips the class after one rAF.
|
||||
-->
|
||||
<body class="booting">
|
||||
<div id="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="ident">
|
||||
<div class="ident-head">
|
||||
<a class="ident-title">Azurian Vocero realtime ver.Beta 2.0</a>
|
||||
</div>
|
||||
<div id="ident-user-name" class="ident-user-name" hidden></div>
|
||||
<div id="reports-hint" class="reports-hint" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<!-- HF login chip / sign-in pill. Populated by ui/account.js; only
|
||||
shown when the deploy runs behind a load balancer with OAuth. -->
|
||||
<div id="account" class="account" hidden></div>
|
||||
<button id="about-btn-m" class="icon-btn about-btn-mobile" title="About this space" aria-label="About this space">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||
</button>
|
||||
<button id="tools-btn" class="icon-btn" title="Tools" aria-label="Tools">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
</button>
|
||||
<button id="rag-btn" class="icon-btn" title="Custom RAG context" aria-label="Custom RAG context">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
</button>
|
||||
<button id="chat-btn" class="icon-btn" title="Conversation history" aria-label="Conversation history">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span id="chat-badge" class="chat-badge" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button id="settings-btn" class="icon-btn" title="Settings" aria-label="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .66.39 1.25 1 1.51H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<div class="orb-wrap">
|
||||
<div id="mic-gate" class="mic-gate">
|
||||
<svg id="mic-gate-arc" class="mic-gate-arc" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<path id="mga-track" class="mga-track" fill="none" />
|
||||
<path id="mga-fill" class="mga-fill" fill="none" />
|
||||
<path id="mga-hit" class="mga-hit" fill="none" />
|
||||
<circle id="mga-handle" class="mga-handle" r="3" />
|
||||
</svg>
|
||||
<button id="mic-btn" class="side-btn" type="button" aria-label="Mute" title="Mute" aria-hidden="true">
|
||||
<svg class="mic-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
|
||||
<svg class="mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M9 5a3 3 0 0 1 6 0v4"/><path d="M9 10v1a3 3 0 0 0 5.1 2.1"/><path d="M19 10a7 7 0 0 1-1.24 3.97"/><path d="M5 10a7 7 0 0 0 11 5.67"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="main-circle"
|
||||
class="circle state-idle"
|
||||
type="button"
|
||||
aria-label="Start voice conversation"
|
||||
>
|
||||
<span class="circle-glow" aria-hidden="true"></span>
|
||||
<span class="circle-ring" aria-hidden="true"></span>
|
||||
<span class="circle-ring-outer" aria-hidden="true"></span>
|
||||
<span class="circle-core">
|
||||
<span class="circle-indicator" aria-hidden="true">
|
||||
<svg class="ind ind-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2" width="6" height="12" rx="3" fill="currentColor" stroke="none"/>
|
||||
<path d="M5 10a7 7 0 0 0 14 0"/>
|
||||
<line x1="12" y1="19" x2="12" y2="22"/>
|
||||
<line x1="8" y1="22" x2="16" y2="22"/>
|
||||
</svg>
|
||||
<svg class="ind ind-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="16" x2="12" y2="16"/></svg>
|
||||
<span class="ind ind-spinner"></span>
|
||||
<span class="ind ind-thinking">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</span>
|
||||
<span class="ind ind-bars">
|
||||
<span class="bar"></span>
|
||||
<span class="bar"></span>
|
||||
<span class="bar"></span>
|
||||
<span class="bar"></span>
|
||||
<span class="bar"></span>
|
||||
</span>
|
||||
<svg class="ind ind-voice" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 10v4a1 1 0 0 0 1 1h3l5 4V5L7 9H4a1 1 0 0 0-1 1z" fill="currentColor" stroke="none"/>
|
||||
<path class="wave wave-1" d="M16 8a5 5 0 0 1 0 8"/>
|
||||
<path class="wave wave-2" d="M19 5a9 9 0 0 1 0 14"/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button id="stop-btn" class="side-btn" type="button" aria-label="End" title="End" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="circle-caption" class="circle-caption" role="status">Tap to start</p>
|
||||
<p id="circle-subcaption" class="circle-subcaption" hidden></p>
|
||||
|
||||
<div id="queue-actions" class="queue-actions" hidden>
|
||||
<button id="join-queue-btn" class="join-queue-btn" type="button" hidden>
|
||||
Join now
|
||||
</button>
|
||||
<button id="leave-queue-btn" class="leave-queue-btn" type="button" hidden>
|
||||
Leave queue
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Ephemeral chat bubbles anchored to the top-right -->
|
||||
<div id="bubble-stack" class="bubble-stack" aria-live="polite" aria-atomic="false"></div>
|
||||
|
||||
<!-- Webcam self-view, shown bottom-left while the camera tool is enabled.
|
||||
HIDDEN BY DEFAULT TODAY: riattivare togliendo hidden + scommentando listener in main.js -->
|
||||
<div id="cam-pip" class="cam-pip" aria-hidden="true" hidden>
|
||||
<video id="cam-video" class="cam-video" autoplay playsinline muted></video>
|
||||
<span class="cam-flash" aria-hidden="true"></span>
|
||||
<span class="cam-label">camera</span>
|
||||
</div>
|
||||
|
||||
<!-- Conversation history panel (overlay) -->
|
||||
<div id="chat-panel" class="chat-panel">
|
||||
<div id="chat-panel-backdrop" class="chat-panel-backdrop"></div>
|
||||
<div class="chat-panel-inner">
|
||||
<header class="chat-panel-header">
|
||||
<h3>Conversation</h3>
|
||||
<button id="chat-panel-close" class="icon-btn" aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div id="chat-history" class="chat-history"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="about-modal" class="modal about-modal">
|
||||
<div class="modal-content">
|
||||
<header class="modal-header">
|
||||
<h2>About</h2>
|
||||
<button id="about-close" class="icon-btn" aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- General intro to the speech-to-speech project -->
|
||||
<div class="about-intro">
|
||||
</div>
|
||||
|
||||
<!-- The backend, shown as the path a turn actually travels -->
|
||||
<div class="about-pipeline">
|
||||
<p class="pipeline-title">The pipeline</p>
|
||||
<ol class="pipeline">
|
||||
<li class="pipe-endpoint">You speak</li>
|
||||
<li class="pipe-stage">
|
||||
<span class="pipe-tag">VAD</span>
|
||||
<span class="pipe-job">detects speech</span>
|
||||
</li>
|
||||
<li class="pipe-stage">
|
||||
<span class="pipe-tag">STT</span>
|
||||
<span class="pipe-job">transcribes it</span>
|
||||
<a class="pipe-model" href="https://huggingface.co/nvidia/parakeet-tdt-1.1b" target="_blank" rel="noopener">nvidia/parakeet-tdt-1.1b<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg></a>
|
||||
</li>
|
||||
<li class="pipe-stage">
|
||||
<span class="pipe-tag">VLM</span>
|
||||
<span class="pipe-job">analyses and composes the reply <span class="pipe-note">· via <a class="cerebras-credit" href="https://cerebras.ai" target="_blank" rel="noopener"><svg class="cerebras-mark" viewBox="0 0 50 50" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M29.4186 5.62713C24.2806 5.62713 19.353 7.6682 15.7199 11.3013C12.0868 14.9345 10.0457 19.862 10.0457 25C10.0457 30.1381 12.0868 35.0656 15.7199 38.6988C19.353 42.3319 24.2806 44.373 29.4186 44.373V47.2917C17.1061 47.2917 7.12695 37.3105 7.12695 24.998C7.12695 12.6855 17.104 2.7063 29.4165 2.7063V5.62505L29.4186 5.62713ZM39.3186 13.2875C37.7794 11.9809 35.9971 10.9915 34.0741 10.3761C32.1512 9.76067 30.1256 9.53147 28.1137 9.70166C26.1019 9.87185 24.1435 10.4381 22.3513 11.3677C20.559 12.2974 18.9683 13.5722 17.6704 15.1189C16.3726 16.6655 15.3933 18.4534 14.7888 20.3798C14.1844 22.3062 13.9667 24.3332 14.1483 26.344C14.33 28.3548 14.9073 30.3099 15.8472 32.0968C16.787 33.8838 18.0709 35.4673 19.6249 36.7563L17.7478 38.9938C15.9126 37.4545 14.3987 35.5687 13.2924 33.4442C12.1862 31.3197 11.5092 28.9981 11.3003 26.6119C11.0914 24.2258 11.3545 21.8219 12.0747 19.5374C12.7949 17.253 13.9581 15.1328 15.4978 13.298C17.0373 11.4628 18.9233 9.94877 21.0479 8.84247C23.1726 7.73618 25.4944 7.05923 27.8807 6.8503C30.267 6.64136 32.6711 6.90453 34.9557 7.62477C37.2403 8.34501 39.3607 9.50822 41.1957 11.048L39.3186 13.2875ZM34.6207 15.0459C31.9818 13.6829 28.9113 13.4172 26.0776 14.3068C23.2438 15.1964 20.876 17.1691 19.4895 19.7958C18.1029 22.4224 17.8099 25.4903 18.6741 28.332C19.5384 31.1736 21.4899 33.5589 24.104 34.9688L22.7374 37.5521C19.4721 35.7617 17.0418 32.7592 15.9707 29.1927C14.8996 25.6261 15.2737 21.7815 17.0123 18.4883C18.7509 15.1952 21.7146 12.7176 25.2637 11.5903C28.8129 10.463 32.663 10.7763 35.9832 12.4625L34.6207 15.0459ZM29.4165 17.7896C27.5042 17.7896 25.6702 18.5493 24.318 19.9015C22.9658 21.2537 22.2061 23.0877 22.2061 25C22.2061 26.9124 22.9658 28.7464 24.318 30.0986C25.6702 31.4508 27.5042 32.2105 29.4165 32.2105V35.1313C26.7296 35.1313 24.1526 34.0639 22.2527 32.1639C20.3527 30.2639 19.2853 27.687 19.2853 25C19.2853 22.3131 20.3527 19.7362 22.2527 17.8362C24.1526 15.9362 26.7296 14.8688 29.4165 14.8688V17.7896Z" fill="#F15A29"/><path d="M32.0981 22.5749C31.7875 22.2404 31.4295 21.9534 31.0356 21.7228C30.6925 21.519 30.3014 21.4097 29.9023 21.4062C29.371 21.4062 28.896 21.5041 28.4773 21.6978C28.0725 21.8802 27.7089 22.1425 27.4081 22.469C27.1074 22.7955 26.8758 23.1795 26.7273 23.5978C26.5731 24.0207 26.4981 24.4645 26.4981 24.9124C26.4981 25.3666 26.5731 25.8082 26.7273 26.227C26.877 26.6449 27.1091 27.0286 27.4096 27.3553C27.7102 27.682 28.0733 27.9451 28.4773 28.1291C28.8939 28.3228 29.3731 28.4207 29.9023 28.4207C30.3523 28.4207 30.771 28.3249 31.1564 28.1395C31.5481 27.952 31.8856 27.6707 32.146 27.3228L34.0794 29.4187C33.7877 29.7103 33.4544 29.9624 33.0752 30.1749C32.374 30.566 31.6108 30.8338 30.8189 30.9666C30.4648 31.0207 30.1585 31.0499 29.9023 31.0499C29.0617 31.0552 28.2271 30.9069 27.4398 30.6124C26.6954 30.3371 26.0144 29.914 25.4377 29.3687C24.8643 28.8208 24.4078 28.1624 24.096 27.4332C23.7572 26.6366 23.5883 25.778 23.6002 24.9124C23.6002 23.9874 23.7669 23.1478 24.096 22.3916C24.4085 21.6624 24.8627 21.0041 25.4356 20.4562C26.0148 19.9124 26.696 19.4895 27.4398 19.2145C28.2271 18.92 29.0617 18.7717 29.9023 18.777C30.6419 18.777 31.3856 18.9187 32.1356 19.202C32.8877 19.4895 33.5627 19.952 34.1023 20.5541L32.0981 22.5749Z" fill="#fff"/></svg>Cerebras</a></span></span>
|
||||
<a class="pipe-model" href="https://huggingface.co/google/gemma-4-31B-it" target="_blank" rel="noopener">google/gemma-4-31B-it<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg></a>
|
||||
</li>
|
||||
<li class="pipe-stage">
|
||||
<span class="pipe-tag">TTS</span>
|
||||
<span class="pipe-job">speaks it back</span>
|
||||
<a class="pipe-model" href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" target="_blank" rel="noopener">Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg></a>
|
||||
</li>
|
||||
<li class="pipe-endpoint">The orb replies</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settings-modal" class="modal">
|
||||
<form method="dialog" class="modal-content">
|
||||
<header class="modal-header">
|
||||
<h2>Settings</h2>
|
||||
<button class="icon-btn" value="close" aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="tab-panels">
|
||||
<section class="tab-panel active" role="tabpanel">
|
||||
<label class="field" id="conn-field">
|
||||
<span id="conn-label">Speech-to-speech server URL</span>
|
||||
<input id="lb-url" type="text" autocomplete="off" spellcheck="false" placeholder="http://localhost:port" />
|
||||
<small id="conn-hint">
|
||||
URL of your speech-to-speech server, e.g.
|
||||
<code>http://localhost:8080</code> (the app adds <code>/v1/realtime</code>).
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Voice</span>
|
||||
<select id="voice">
|
||||
<option value="Aiden" selected>Aiden</option>
|
||||
<option value="Ryan">Ryan</option>
|
||||
<option value="Dylan">Dylan</option>
|
||||
<option value="Eric">Eric</option>
|
||||
<option value="Ono_Anna">Ono_Anna</option>
|
||||
<option value="Serena">Serena</option>
|
||||
<option value="Sohee">Sohee</option>
|
||||
<option value="Uncle_Fu">Uncle_Fu</option>
|
||||
<option value="Vivian">Vivian</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-head">
|
||||
Noise gate
|
||||
<span id="gate-value" class="field-value">Off</span>
|
||||
</span>
|
||||
<div class="gate">
|
||||
<div class="gate-track" aria-hidden="true">
|
||||
<div id="gate-meter-fill" class="gate-meter-fill"></div>
|
||||
<input
|
||||
id="noise-gate"
|
||||
type="range"
|
||||
min="-66"
|
||||
max="-3"
|
||||
step="1"
|
||||
value="-50"
|
||||
aria-label="Noise gate threshold"
|
||||
/>
|
||||
</div>
|
||||
<div class="gate-ends">
|
||||
<span>Off</span>
|
||||
<span>−3 dB</span>
|
||||
</div>
|
||||
</div>
|
||||
<small>
|
||||
Mutes the mic below the handle so room noise isn't sent. Slide fully
|
||||
left to turn it off; the bar shows your live input while in a call.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Instructions</span>
|
||||
<textarea id="instructions" rows="5" placeholder="You are Vocero, a friendly voice assistant for the company Aquachile..."></textarea>
|
||||
<small id="primary-prompt-meta" hidden></small>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<button id="restart-conversation" type="button" class="btn primary wide" disabled>
|
||||
Restart conversation with these settings
|
||||
</button>
|
||||
<small id="restart-hint">Connect first, then come back here to apply live changes.</small>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button id="settings-save" type="submit" class="btn primary" value="save">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="tools-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<header class="modal-header">
|
||||
<h2>Tools</h2>
|
||||
<button id="tools-close" class="icon-btn" aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="tools-intro">Let the assistant act during the conversation. Changes apply live.</p>
|
||||
|
||||
<div class="tool-list">
|
||||
<div class="tool-row" id="tool-web-row">
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">Web search</span>
|
||||
<span class="tool-desc">Look things up on Google, via Serper.</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input id="tool-web" type="checkbox" />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field tools-key">
|
||||
<span>Search API key</span>
|
||||
<input id="search-key" type="password" autocomplete="off" spellcheck="false" />
|
||||
<small id="tool-web-hint"></small>
|
||||
</label>
|
||||
|
||||
<!-- Camera tool row — HIDDEN BY DEFAULT TODAY.
|
||||
Per riattivarlo in futuro: togli hidden da div + small,
|
||||
scommenta listener toolCamSwitch + funzioni enableCamera in main.js. -->
|
||||
<div class="tool-row tool-row-sep" id="tool-cam-row" hidden>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">Camera</span>
|
||||
<span class="tool-desc">Let the assistant see through your webcam.</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input id="tool-cam" type="checkbox" />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
<small id="tool-cam-hint" class="tool-hint" hidden>A live preview shows bottom-left while the camera is on.</small>
|
||||
|
||||
<div class="tool-row tool-row-sep">
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">Loaded MCP tools</span>
|
||||
<span class="tool-desc">Tools published by the remote agent profile.</span>
|
||||
</div>
|
||||
</div>
|
||||
<small id="agent-tools-hint" class="tool-hint"></small>
|
||||
<div id="agent-tools-list" class="agent-tools-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="rag-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<header class="modal-header">
|
||||
<h2>Custom RAG context</h2>
|
||||
<button id="rag-close" class="icon-btn" aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="tools-intro">
|
||||
Incolla qui testo, documenti, o estratti RAG che vuoi siano disponibili
|
||||
per la sessione vocale corrente. Il testo viene aggiunto sia alle
|
||||
istruzioni del LLM sia al campo <code>user_context</code> del body
|
||||
<code>/api/reasoning</code>.
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<span>RAG context (testo libero, puro)</span>
|
||||
<textarea
|
||||
id="rag-textarea"
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
placeholder="Incolla o scrivi qui il contesto RAG che il modello deve usare durante la risposta. Es: dati cliente, policy, estratti documentali, ecc."
|
||||
></textarea>
|
||||
<small id="rag-hint" class="tool-hint">
|
||||
Nessun RAG context caricato. Premi "Invia nella sessione" per applicare le modifiche.
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button id="rag-clear" class="btn secondary" type="button" value="clear">Svuota</button>
|
||||
<div style="flex: 1"></div>
|
||||
<button id="rag-submit" class="btn primary" type="button" value="submit">Invia nella sessione</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Shown when the per-day conversation budget is spent (mid-call or at
|
||||
start). Title / message / CTA / note are filled per tier by
|
||||
ui/account.js. The hero is the HF mark — a friendly smiling face. -->
|
||||
<dialog id="limit-modal" class="modal limit-modal">
|
||||
<div class="modal-content limit-card">
|
||||
<button id="limit-close" class="icon-btn limit-close" aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="limit-badge" aria-hidden="true">
|
||||
<svg class="hf-logo" viewBox="0 0 95 88" fill="none" aria-hidden="true"><path fill="#FFD21E" d="M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5Z"/><path fill="#FF9D0B" d="M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0Zm-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0Z"/><path fill="#3A3B45" d="M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32ZM34.95 32.3c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32Z"/><path fill="#FF323D" d="M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"/><path fill="#3A3B45" fill-rule="evenodd" d="M39.43 54a8.7 8.7 0 0 1 5.3-4.49c.4-.12.81.57 1.24 1.28.4.68.82 1.37 1.24 1.37.45 0 .9-.68 1.33-1.35.45-.7.89-1.38 1.32-1.25a8.61 8.61 0 0 1 5 4.17c3.73-2.94 5.1-7.74 5.1-10.7 0-2.34-1.57-1.6-4.09-.36l-.14.07c-2.31 1.15-5.39 2.67-8.77 2.67s-6.45-1.52-8.77-2.67c-2.6-1.29-4.23-2.1-4.23.29 0 3.05 1.46 8.06 5.47 10.97Z" clip-rule="evenodd"/><path fill="#FF9D0B" d="M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM24.21 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM17.52 48c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7.1 7.1 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.33 44.33 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48Zm0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85Z"/><path fill="#FFD21E" d="M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9c-1.87.3-3.24 5.08.68 8.01 3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12Z"/><path fill="#FF9D0B" d="M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7.1 7.1 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.33 44.33 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48Zm0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.54 1.54 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85Z"/><path fill="#FFD21E" d="M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44c2.08 2.27-1.58 4.17-6.8 7.33-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12Z"/></svg>
|
||||
</div>
|
||||
<h2 id="limit-title" class="limit-title">That's a wrap for now</h2>
|
||||
<p id="limit-msg" class="limit-msg"></p>
|
||||
<a id="limit-cta" class="btn primary wide limit-cta" href="#"></a>
|
||||
<p id="limit-note" class="limit-note"></p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="main.js?v=20260902001"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
# Changelog — s2s-client (Azurian Vocero)
|
||||
|
||||
Todos los cambios relevantes del proyecto están documentados en este archivo.
|
||||
Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/).
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-07]
|
||||
|
||||
### Añadido — Soporte `episode_id` (inject de episodio podcast)
|
||||
|
||||
Nuevo parámetro query string `?episode_id=` junto a `?phoneNumber=` que
|
||||
habilita la carga y la inyección automática de un episodio podcast como
|
||||
contexto PRIMARIO en el prompt LLM.
|
||||
|
||||
**URL de ejemplo:**
|
||||
```
|
||||
/?phoneNumber=%2B447432477433&episode_id=episode-19569f77-a06e-5ce5-a52f-6299dbd67d03
|
||||
```
|
||||
|
||||
**Archivos modificados:**
|
||||
|
||||
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)):
|
||||
1. Añadida la constante `PODCAST_EPISODE_HINT` ([L426-L429](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)) para el hint semántico en el prompt.
|
||||
2. Añadida la función `_resolve_episode_context(raw_value)` ([L475-L504](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)): normaliza la respuesta API (dict/list/str, posible wrapper `{"result": …}`), la serializa en JSON (tope 24.000 chars) y la envuelve con los marcadores `<<<PODCAST_EPISODE_BEGIN/END>>>`.
|
||||
3. Añadida la función asíncrona `_fetch_podcast_episode(episode_id)` ([L1010-L1078](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)): GET directo a
|
||||
`https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}`
|
||||
(timeout 15s, follow redirects, manejo de errores tolerante — nunca rompe el flujo, devuelve `None` en caso de fallo).
|
||||
4. Extendida `_augment_instructions_with_profile(...)` ([L1137-L1181](file:///home/duxiter/s2s/s2s-client/server.py#L1137-L1181)) con el nuevo parámetro `episode_context_text`; el orden de inject ahora es (más específico → más genérico): **episode → profile → reports → summary**.
|
||||
5. Extendida `_patch_client_session_update_with_profile(...)` ([L1184-L1306](file:///home/duxiter/s2s/s2s-client/server.py#L1184-L1306)) con soporte a `episode_context_text` y logging dedicado `[INJECT-EPISODE]` / `[DEBUG-EPISODE]`.
|
||||
6. WebSocket connect handler ([L1791-L1959](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)):
|
||||
- lee `episode_id` desde query params junto a `phoneNumber`;
|
||||
- ejecuta fetch episodio + resolve ANTES de profile/reports/summary;
|
||||
- guarda `injected_episode_context` e `inject_episode_id` en el `session_ctx` compartido.
|
||||
7. `_client_to_upstream` ([L2649-L2661](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)): pasa `ctx_episode_context` al patch de `session.update`.
|
||||
8. Endpoint `POST /api/reasoning`:
|
||||
- extendido el modelo `ReasoningRequest` con el campo `episode_id: str | None = None` ([L1309-L1323](file:///home/duxiter/s2s/s2s-client/server.py#L1309-L1323));
|
||||
- en el handler ([L1411-L1577](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577)) fetch episodio independiente de phone;
|
||||
- `inject_episode_text` se hace **prepend** sobre `user_context_parts` (primero de todos);
|
||||
- reenvío a NODE_RED / OPENAI con logging extendido (`episodeIdLen`).
|
||||
|
||||
- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)):
|
||||
1. Lectura desde query string: `const voceroEpisodeId = pageParams.get("episode_id")` ([L676-L680](file:///home/duxiter/s2s/s2s-client/main.js#L676-L680)).
|
||||
2. `fetchOpenAiReasoningAnswer` ([L1337-L1364](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364)): campo `episode_id` añadido al body POST `/api/reasoning`, logKey y `console.info` extendidos.
|
||||
3. Construcción URL WebSocket ([L2323-L2342](file:///home/duxiter/s2s/s2s-client/main.js#L2323-L2342)): `episode_id` añadido a query params tanto con la API `URL.searchParams` como en el fallback string (concat con `&`).
|
||||
|
||||
---
|
||||
|
||||
### Añadido — `S2S_START_SESSION_MODE`: 3 modos de inicio de sesión
|
||||
|
||||
Variable de entorno leída por el servidor y propagada al cliente vía `GET /api/config.startSessionMode`.
|
||||
Objetivo: controlar si y cómo se enciende automáticamente el micrófono y si el modelo saluda
|
||||
primero (flujo similar al wake-word). Valores soportados:
|
||||
|
||||
| Valor | Descripción |
|
||||
|---|---|
|
||||
| *(vacío) / default* | Inicio manual: solo tap sobre el Orb / botón Start |
|
||||
| **`TALK`** | Después de 2s overlay a pantalla completa "Toca en cualquier lugar para empezar" (policy safe para Safari/iOS `getUserMedia`). Click/touch/tecla dentro de la user gesture → `doStart()`. El cliente WS inyecta bootstrap `"hola vocero"` + `requestResponse()`. |
|
||||
| **`TALKFIRST`** | Después de 2s **intenta `doStart()` DIRECTAMENTE SIN click**, precargando `pendingWakeWordConfirmationText = "hola vocero"` (misma variable de la wake-word real). Si los permisos de mic ya están "allow always" funciona sin click. Si falla (policy Safari/iOS) → fallback sobre el mismo unlocker de TALK con mensaje "permiso micrófono". |
|
||||
|
||||
**Archivos modificados:**
|
||||
|
||||
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)):
|
||||
1. Lectura env: `S2S_START_SESSION_MODE = os.environ.get(...)`, `.strip().upper()` ([L150](file:///home/duxiter/s2s/s2s-client/server.py#L150)).
|
||||
2. Banner `[STARTUP]` extendido con el campo `startSessionMode=` ([L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212)).
|
||||
3. Endpoint `GET /api/config` → nuevo campo `startSessionMode` + log `[INTERNAL] /api/config` ([L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400)).
|
||||
|
||||
- **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39)):
|
||||
- Añadido nuevo overlay `#talk-unlocker` a pantalla completa (z-index 9999), `hidden` por defecto, con tarjeta "Preparando Vocero… / Toca en cualquier lugar para empezar a hablar" + spinner degradado.
|
||||
|
||||
- **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215)):
|
||||
- Clases `.talk-unlocker`, `.talk-unlocker-hint`, `.talk-unlocker-spinner`, `.talk-unlocker-title`, `.talk-unlocker-sub` + animaciones fade-in y pop-up de tarjeta.
|
||||
|
||||
- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)):
|
||||
1. Variable global `startSessionMode = ""` + comentario ([L628-L632](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632)).
|
||||
2. Parse dentro de `fetchConfig()` → uppercase, log en `reportDebug("B",…)` ([L2309-L2320](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320)).
|
||||
3. Paso a cliente WS → `startWithResponse: startSessionMode === "TALK"` ([L2683-L2692](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692)).
|
||||
4. Bloque scheduling auto-start ([L2976-L3083](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083)):
|
||||
- `bootstrapWithAutoStart()`: `await fetchConfig()` → `maybeScheduleAutoStart()`;
|
||||
- `setTimeout(2000 ms)` warmup para finalizar fetch de episode/profile/reports;
|
||||
- `showTalkUnlocker(fallbackHint)`: gestiona labels personalizadas en caso de fallback TALKFIRST;
|
||||
- **`tryTalkFirstDirect()` (TALKFIRST)**: set `pendingWakeWordConfirmationText="hola vocero"` + `doStart()` + try/catch con teardown y fallback unlocker;
|
||||
- listener unlocker click/touchstart/keydown → `startTalkFromGesture()` con `createResumedAudioContext()` dentro de la gesture (para iOS silent-free).
|
||||
5. `onClientStatus()` ([L2878-L2932](file:///home/duxiter/s2s/s2s-client/main.js#L2878-L2932)) añadido `hideTalkUnlocker()` en `connecting/queued/your-turn/connected`.
|
||||
|
||||
- **`ws/s2s-ws-client.js`** ([s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js)):
|
||||
1. Constructor: nuevas propiedades `_startWithResponse`, `_startWithResponseText` (default `"hola vocero"`), `_initialResponseSent` guardia one-shot ([L163-L178](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L163-L178)).
|
||||
2. Handler `session.updated` ([L688-L713](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713)): **Solo para `TALK`** (no TALKFIRST, que en cambio usa `pendingWakeWordConfirmationText`): `this.sendUserText("hola vocero") + this.requestResponse()` — mismo code path identico de la wake-word.
|
||||
3. `requestResponse` / `_createResponseNow` restaurados limpios (solo payload opcional `image`; ningún shaped `response.create`).
|
||||
|
||||
---
|
||||
|
||||
### Modificado — `#reports-sidebar-toggle` visible solo con `?debug=true`
|
||||
|
||||
El botón "reports" en la topbar (toggle del panel lateral reports) ahora es
|
||||
`data-debug-only="true"` — por lo tanto solo se muestra si la URL contiene
|
||||
`?debug=true`, de manera análoga al cluster `.topbar-right`.
|
||||
|
||||
**Archivos modificados:**
|
||||
|
||||
- **`index.html`** → `#reports-sidebar-toggle` ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L47-L50)): añadido `data-debug-only="true"`.
|
||||
- **`main.js`** → `applyDebugOnlyVisibilityGate()` ([L48-L109](file:///home/duxiter/s2s/s2s-client/main.js#L48-L109)): extendido para iterar *todos* los elementos `[data-debug-only="true"]` (no solo `.topbar-right`); línea 1227: `reportsSidebarToggle.hidden = false` condicionado a `qs.get("debug") === "true"` ([L1227-L1246](file:///home/duxiter/s2s/s2s-client/main.js#L1227-L1246)) + log rojo `🚫 FORZADO HIDDEN` cuando falta debug=true.
|
||||
- **`style.css`** → regla de seguridad ya presente: `[data-debug-only]:not([data-debug-enabled]) { display:none !important; visibility:hidden; opacity:0 }` ([L532-L537](file:///home/duxiter/s2s/s2s-client/style.css#L532-L537)).
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-04]
|
||||
|
||||
### Modificado — Chat siempre visible + integración seamless
|
||||
|
||||
Reorganizado el layout para mostrar el chat textual de forma permanente e
|
||||
integrado visualmente con el Orb en la parte superior del stage.
|
||||
|
||||
- Eliminado botón toggle chat en la topbar (`#chat-btn`) y relativo overlay.
|
||||
- Stage con el Orb en la parte superior; nuevo `#chat-panel` a continuación (historial
|
||||
+ form input fijo sobre el footer sticky).
|
||||
- Actualizada lógica en `main.js` y `ui/chat.js` para el envío de texto mediante
|
||||
`client.sendUserText` y auto-scroll sin controles de visibilidad del panel.
|
||||
- Versión banner deploy: **2026-09-03 14:40**.
|
||||
|
||||
### Resuelto — Estabilidad integración chat mobile
|
||||
|
||||
- Error `Missing element: #chat-btn` resuelto con selectores non-strict
|
||||
(`document.querySelector`) para elementos opcionales eliminados.
|
||||
- Refactorización estética "seamless": chat sin marcos, sombras ni fondos
|
||||
(`background:transparent`, `box-shadow:none`).
|
||||
- **Layout mobile:**
|
||||
- padding incrementado en `#app` (56px) y `.chat-history` (96px) para evitar
|
||||
superposiciones con el footer sticky;
|
||||
- márgenes fijos en las burbujas: `margin-right:22px` (Assistant), `margin-left:6px` (User).
|
||||
- **Auto-scroll streaming:** implementado `_scrollToBottom` con cadena de retry
|
||||
múltiples (`requestAnimationFrame` + `setTimeout`) para compensar el line-wrap
|
||||
asíncrono durante la llegada de los tokens; `overflow-anchor:none` + `scroll-behavior:auto !important`.
|
||||
|
||||
### Añadido — Avatar usuario con iniciales + modal de perfil
|
||||
|
||||
- Sustituido el texto del nombre de usuario en el header por avatar circular 40×40px
|
||||
(gradient de marca) conteniendo las iniciales calculadas (ej. "AV").
|
||||
- Nueva `<dialog id="user-profile-dialog">` en `index.html` que se abre al click
|
||||
sobre el avatar: muestra email, teléfono, rol, empresa, con soporte a shapes
|
||||
anidadas del payload de perfil.
|
||||
- Cierre del modal: tecla ESC, click fuera de la tarjeta, botón `×` dedicado.
|
||||
|
||||
---
|
||||
|
||||
## [Notas de formato]
|
||||
|
||||
- **Tope campo prompt episodio:** 24.000 caracteres (transcript largos).
|
||||
- **Tope profile / summary:** 1.600 caracteres cada uno.
|
||||
- **Tope reports list:** 12.000 caracteres.
|
||||
- **Orden inject prompt LLM (WS session.update + reasoning):**
|
||||
1. Episodio podcast actual (PRIMARIO)
|
||||
2. Perfil llamador (identidad)
|
||||
3. Lista reports / scripts podcast programados
|
||||
4. Summary última conversación
|
||||
- Todos los bloques están marcados con `<<<NOMBRE_BLOQUE_BEGIN/END>>>` para inspección de logs.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Changelog — s2s-client (Azurian Vocero)
|
||||
|
||||
Tutte le modifiche rilevanti al progetto sono documentate in questo file.
|
||||
Formato basato su [Keep a Changelog](https://keepachangelog.com/it/1.1.0/).
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-07]
|
||||
|
||||
### Aggiunto — Supporto `episode_id` (podcast episode inject)
|
||||
|
||||
Nuovo parametro query string `?episode_id=` affiancato a `?phoneNumber=` che
|
||||
abilita il caricamento e l'iniezione automatica di un episodio podcast come
|
||||
contesto PRIMARIO nel prompt LLM.
|
||||
|
||||
**URL di esempio:**
|
||||
```
|
||||
/?phoneNumber=%2B447432477433&episode_id=episode-19569f77-a06e-5ce5-a52f-6299dbd67d03
|
||||
```
|
||||
|
||||
**File modificati:**
|
||||
|
||||
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)):
|
||||
1. Aggiunta costante `PODCAST_EPISODE_HINT` ([L426-L429](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)) per l'hint semantico nel prompt.
|
||||
2. Aggiunta funzione `_resolve_episode_context(raw_value)` ([L475-L504](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)): normalizza la risposta API (dict/list/str, eventuale wrapper `{"result": …}`), la serializza in JSON (cap 24.000 chars) e la avvolge con i marker `<<<PODCAST_EPISODE_BEGIN/END>>>`.
|
||||
3. Aggiunta funzione asincrona `_fetch_podcast_episode(episode_id)` ([L1010-L1078](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)): GET plain verso
|
||||
`https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}`
|
||||
(timeout 15s, follow redirects, error handling soft — non rompe mai il flusso, restituisce `None` su fail).
|
||||
4. Estesa `_augment_instructions_with_profile(...)` ([L1137-L1181](file:///home/duxiter/s2s/s2s-client/server.py#L1137-L1181)) con nuovo parametro `episode_context_text`; l'ordine di inject ora è (più specifico → più generico): **episode → profile → reports → summary**.
|
||||
5. Estesa `_patch_client_session_update_with_profile(...)` ([L1184-L1306](file:///home/duxiter/s2s/s2s-client/server.py#L1184-L1306)) con supporto a `episode_context_text` e logging dedicato `[INJECT-EPISODE]` / `[DEBUG-EPISODE]`.
|
||||
6. WebSocket connect handler ([L1791-L1959](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)):
|
||||
- legge `episode_id` da query params assieme a `phoneNumber`;
|
||||
- esegue fetch episodio + resolve PRIMA di profile/reports/summary;
|
||||
- salva `injected_episode_context` e `inject_episode_id` nel `session_ctx` condiviso.
|
||||
7. `_client_to_upstream` ([L2649-L2661](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)): passa `ctx_episode_context` al patch di `session.update`.
|
||||
8. Endpoint `POST /api/reasoning`:
|
||||
- esteso modello `ReasoningRequest` con campo `episode_id: str | None = None` ([L1309-L1323](file:///home/duxiter/s2s/s2s-client/server.py#L1309-L1323));
|
||||
- nel handler ([L1411-L1577](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577)) fetch episodio indipendente da phone;
|
||||
- `inject_episode_text` viene **prepend** a `user_context_parts` (prima di tutti);
|
||||
- inoltro a NODE_RED / OPENAI con logging esteso (`episodeIdLen`).
|
||||
|
||||
- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)):
|
||||
1. Lettura da query string: `const voceroEpisodeId = pageParams.get("episode_id")` ([L676-L680](file:///home/duxiter/s2s/s2s-client/main.js#L676-L680)).
|
||||
2. `fetchOpenAiReasoningAnswer` ([L1337-L1364](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364)): campo `episode_id` aggiunto al body POST `/api/reasoning`, logKey e `console.info` estesi.
|
||||
3. Costruzione URL WebSocket ([L2323-L2342](file:///home/duxiter/s2s/s2s-client/main.js#L2323-L2342)): `episode_id` appeso a query params sia con API `URL.searchParams` sia nel fallback stringa (concat con `&`).
|
||||
|
||||
---
|
||||
|
||||
### Aggiunto — `S2S_START_SESSION_MODE`: 3 modalità di start sessione
|
||||
|
||||
Env var letta dal server e propagata al client via `GET /api/config.startSessionMode`.
|
||||
Scopo: governare se e come il microfono si auto-accende e se il modello saluta
|
||||
per primo (flow simile a wake-word). Valori supportati:
|
||||
|
||||
| Valore | Descrizione |
|
||||
|---|---|
|
||||
| *(vuoto) / default* | Start manuale: solo tap sull'Orb / pulsante Start |
|
||||
| **`TALK`** | Dopo 2s overlay fullscreen "Tocca ovunque per iniziare" (policy safe per Safari/iOS `getUserMedia`). Click/touch/tasto dentro la user gesture → `doStart()`. WS client inietta bootstrap `"hola vocero"` + `requestResponse()`. |
|
||||
| **`TALKFIRST`** | Dopo 2s **tenta `doStart()` DIRETTAMENTE SENZA click**, pre-caricando `pendingWakeWordConfirmationText = "hola vocero"` (stessa variabile della wake-word vera). Se i permessi mic sono già "allow always" funziona senza click. Se fallisce (policy Safari/iOS) → fallback sullo stesso unlocker di TALK con messaggio "permesso microfono". |
|
||||
|
||||
**File modificati:**
|
||||
|
||||
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)):
|
||||
1. Lettura env: `S2S_START_SESSION_MODE = os.environ.get(...)`, `.strip().upper()` ([L150](file:///home/duxiter/s2s/s2s-client/server.py#L150)).
|
||||
2. Banner `[STARTUP]` esteso con campo `startSessionMode=` ([L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212)).
|
||||
3. Endpoint `GET /api/config` → nuovo campo `startSessionMode` + log `[INTERNAL] /api/config` ([L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400)).
|
||||
|
||||
- **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39)):
|
||||
- Aggiunto nuovo `#talk-unlocker` overlay fullscreen (z-index 9999), `hidden` di default, con card "Preparando Vocero… / Toca en cualquier lugar para empezar a hablar" + spinner gradient.
|
||||
|
||||
- **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215)):
|
||||
- Classi `.talk-unlocker`, `.talk-unlocker-hint`, `.talk-unlocker-spinner`, `.talk-unlocker-title`, `.talk-unlocker-sub` + animazioni fade-in e pop-up card.
|
||||
|
||||
- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)):
|
||||
1. Variabile globale `startSessionMode = ""` + commento ([L628-L632](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632)).
|
||||
2. Parse dentro `fetchConfig()` → uppercase, log in `reportDebug("B",…)` ([L2309-L2320](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320)).
|
||||
3. Passaggio a WS client → `startWithResponse: startSessionMode === "TALK"` ([L2683-L2692](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692)).
|
||||
4. Blocco scheduling auto-start ([L2976-L3083](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083)):
|
||||
- `bootstrapWithAutoStart()`: `await fetchConfig()` → `maybeScheduleAutoStart()`;
|
||||
- `setTimeout(2000 ms)` warmup per finire fetch di episode/profile/reports;
|
||||
- `showTalkUnlocker(fallbackHint)`: gestisce label custom nel caso fallback TALKFIRST;
|
||||
- **`tryTalkFirstDirect()` (TALKFIRST)**: set `pendingWakeWordConfirmationText="hola vocero"` + `doStart()` + try/catch con teardown e fallback unlocker;
|
||||
- listener unlocker click/touchstart/keydown → `startTalkFromGesture()` con `createResumedAudioContext()` dentro la gesture (per iOS silent-free).
|
||||
5. `onClientStatus()` ([L2878-L2932](file:///home/duxiter/s2s/s2s-client/main.js#L2878-L2932)) aggiunto `hideTalkUnlocker()` in `connecting/queued/your-turn/connected`.
|
||||
|
||||
- **`ws/s2s-ws-client.js`** ([s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js)):
|
||||
1. Costruttore: nuove proprietà `_startWithResponse`, `_startWithResponseText` (default `"hola vocero"`), `_initialResponseSent` guardia one-shot ([L163-L178](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L163-L178)).
|
||||
2. Handler `session.updated` ([L688-L713](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713)): **Solo per `TALK`** (non TALKFIRST, che usa invece `pendingWakeWordConfirmationText`): `this.sendUserText("hola vocero") + this.requestResponse()` — stesso identico code path della wake-word.
|
||||
3. `requestResponse` / `_createResponseNow` ripristinati puliti (solo payload opzionale `image`; nessun shaped `response.create`).
|
||||
|
||||
---
|
||||
|
||||
### Modificato — `#reports-sidebar-toggle` visibile solo con `?debug=true`
|
||||
|
||||
Il bottone "reports" nella topbar (toggle del pannello laterale reports) è ora
|
||||
`data-debug-only="true"` — viene quindi mostrato solo se l'URL contiene
|
||||
`?debug=true`, analogamente al cluster `.topbar-right`.
|
||||
|
||||
**File modificati:**
|
||||
|
||||
- **`index.html`** → `#reports-sidebar-toggle` ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L47-L50)): aggiunto `data-debug-only="true"`.
|
||||
- **`main.js`** → `applyDebugOnlyVisibilityGate()` ([L48-L109](file:///home/duxiter/s2s/s2s-client/main.js#L48-L109)): esteso per iterare *tutti* gli elementi `[data-debug-only="true"]` (non solo `.topbar-right`); riga 1227: `reportsSidebarToggle.hidden = false` gated a `qs.get("debug") === "true"` ([L1227-L1246](file:///home/duxiter/s2s/s2s-client/main.js#L1227-L1246)) + log rosso `🚫 FORZATO HIDDEN` quando manca debug=true.
|
||||
- **`style.css`** → regola di sicurezza già presente: `[data-debug-only]:not([data-debug-enabled]) { display:none !important; visibility:hidden; opacity:0 }` ([L532-L537](file:///home/duxiter/s2s/s2s-client/style.css#L532-L537)).
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-04]
|
||||
|
||||
### Modificato — Chat sempre visibile + integrazione seamless
|
||||
|
||||
Riorganizzato il layout per mostrare la chat testuale in modo permanente e
|
||||
integrato visivamente con l'Orb nella parte superiore dello stage.
|
||||
|
||||
- Rimosso pulsante toggle chat nella topbar (`#chat-btn`) e relativo overlay.
|
||||
- Stage con l'Orb nella parte superiore; nuovo `#chat-panel` a seguire (cronologia
|
||||
+ form input fisso sopra il footer sticky).
|
||||
- Aggiornata logica in `main.js` e `ui/chat.js` per l'invio testo tramite
|
||||
`client.sendUserText` e auto-scroll senza controlli di visibilità pannello.
|
||||
- Versione banner deploy: **2026-09-03 14:40**.
|
||||
|
||||
### Risolto — Stabilità integrazione chat mobile
|
||||
|
||||
- Errore `Missing element: #chat-btn` risolto con selettori non-strict
|
||||
(`document.querySelector`) per elementi opzionali rimossi.
|
||||
- Refactoring estetico "seamless": chat senza cornici, ombre o sfondi
|
||||
(`background:transparent`, `box-shadow:none`).
|
||||
- **Layout mobile:**
|
||||
- padding incrementato in `#app` (56px) e `.chat-history` (96px) per evitare
|
||||
sovrapposizioni col footer sticky;
|
||||
- margini fissi bolle: `margin-right:22px` (Assistant), `margin-left:6px` (User).
|
||||
- **Auto-scroll streaming:** implementato `_scrollToBottom` con catena di retry
|
||||
multipli (`requestAnimationFrame` + `setTimeout`) per compensare il line-wrap
|
||||
asincrono durante l'arrivo dei token; `overflow-anchor:none` + `scroll-behavior:auto !important`.
|
||||
|
||||
### Aggiunto — Avatar utente iniziali + modale profilo
|
||||
|
||||
- Sostituito testo nome utente nell'header con avatar circolare 40×40px
|
||||
(gradient brand) contenente le iniziali calcolate (es. "AV").
|
||||
- Nuova `<dialog id="user-profile-dialog">` in `index.html` che si apre al click
|
||||
sull'avatar: mostra email, telefono, ruolo, azienda, con supporto a shape
|
||||
annidate del payload profilo.
|
||||
- Chiusura modale: tasto ESC, click fuori card, pulsante `×` dedicato.
|
||||
|
||||
---
|
||||
|
||||
## [Note di formato]
|
||||
|
||||
- **Cap campo prompt episodio:** 24.000 caratteri (transcript lunghi).
|
||||
- **Cap profilo / summary:** 1.600 caratteri ciascuno.
|
||||
- **Cap reports list:** 12.000 caratteri.
|
||||
- **Ordine inject prompt LLM (WS session.update + reasoning):**
|
||||
1. Episodio podcast corrente (PRIMARIO)
|
||||
2. Profilo chiamante (identity)
|
||||
3. Lista reports / script podcast schedulati
|
||||
4. Summary ultima conversazione
|
||||
- Tutti i blocchi sono marcati con `<<<NOME_BLOCCO_BEGIN/END>>>` per ispezione log.
|
||||
@@ -0,0 +1,484 @@
|
||||
# Inject pipeline — caller context & reference material
|
||||
|
||||
Documento ufficiale di tutti i blocchi di contesto che il server inietta
|
||||
automaticamente nel prompt LLM **all'inizio di ogni sessione**, sia per il
|
||||
canale **WebSocket realtime** (`/v1/realtime`, patch di `session.update`) sia
|
||||
per l'endpoint **HTTP reasoning** (`POST /api/reasoning`, augmentation di
|
||||
`instructions` e `user_context`).
|
||||
|
||||
Tutto il sistema è **generico / deploy-agnostic** per scelta architetturale:
|
||||
nessun campo viene hardcodato. Ogni blocco accetta payload arbitrari e li
|
||||
serializza in JSON direttamente. Qualsiasi errore di rete / JSON non
|
||||
interrompe mai il flusso: il blocco problematico viene solo saltato con
|
||||
una riga `[INJECT-*] ⏭️ skipped` nel log server.
|
||||
|
||||
---
|
||||
|
||||
## 0. Master flag: `S2S_INJECT_PROFILE`
|
||||
|
||||
Tutta la pipeline è sotto **un unico master flag** lato server.
|
||||
|
||||
| Valore env `S2S_INJECT_PROFILE` | Comportamento |
|
||||
|---|---|
|
||||
| `false` / non impostato | **Pure passthrough.** Nessuna fetch upstream, nessuna modifica a `session.update` o al body `/api/reasoning`. Il proxy passa tutto invariato. |
|
||||
| `true` / `1` / `yes` / `on` | Pipeline inject attiva. I singoli blocchi poi dipendono dagli env specifici e dai query params della URL del client. |
|
||||
|
||||
Perché esiste: su deploy generici (nessuna integrazione con AzurianLab /
|
||||
Aquachile / sistemi interni) il client rimane un puro speech-to-speech
|
||||
passthrough, senza dipendere da endpoint esterni.
|
||||
|
||||
---
|
||||
|
||||
## 1. Blocco episodio podcast — `episode_id`
|
||||
|
||||
**Priorità massima.** Quando c'è, viene considerato il riferimento PRINCIPALE
|
||||
e più fidato per tutta la sessione.
|
||||
|
||||
### Trigger (quando si attiva)
|
||||
|
||||
Una qualunque delle due:
|
||||
1. URL pagina browser → query string `?episode_id=episode-xxx`
|
||||
2. Body `POST /api/reasoning` → campo `episode_id: "episode-xxx"`
|
||||
|
||||
### Sorgente dati
|
||||
|
||||
GET plain verso l'endpoint Aquachile AzurianLab:
|
||||
|
||||
```
|
||||
https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}
|
||||
```
|
||||
|
||||
Parametri fetch:
|
||||
- timeout 15 s
|
||||
- `follow_redirects: true`
|
||||
- **Nessuna auth** (endpoint pubblico)
|
||||
|
||||
Formato risposta accettato:
|
||||
- `Content-Type: application/json` → oggetto arbitrario o array
|
||||
- `{"result": <payload>}` → wrapper supportato, il contenuto viene "spacchettato"
|
||||
- `text/plain` → usato come stringa raw
|
||||
|
||||
### Cap
|
||||
|
||||
24.000 caratteri dopo serializzazione JSON (transcript lunghi).
|
||||
Se supera → troncato con `...` append.
|
||||
|
||||
### Wrap applicato
|
||||
|
||||
```
|
||||
< PODCAST_EPISODE_HINT >
|
||||
<<<PODCAST_EPISODE_BEGIN>>>
|
||||
{ <TUTTI I CAMPI DELL'EPISODIO, JSON> }
|
||||
<<<PODCAST_EPISODE_END>>>. This is the episode the caller is currently interacting with — answer with reference to it first.
|
||||
```
|
||||
|
||||
**Hint testuale** (in testa, letto dal modello come primissima frase del blocco):
|
||||
|
||||
> Podcast episode context loaded from the page `episode_id` query param (current episode the user is referencing). Treat the episode fields (title, description, transcript, script, show notes, guests, audio metadata, etc.) as the **PRIMARY trusted reference material** for this session.
|
||||
|
||||
### Campi supportati (esempi — lista NON esaustiva, qualsiasi campo extra viene incluso)
|
||||
|
||||
- metadati base: `id`, `title`, `slug`, `description`, `summary`, `publishedAt`, `durationSeconds`
|
||||
- contenuti: `transcript`, `podcastScript`, `script`, `chapters[]`, `showNotes`
|
||||
- persone: `host`, `guests[]`, `author`, `guestsInfo[]`
|
||||
- audio: `audioUrl`, `audioFile`, `waveform`, `sampleRate`
|
||||
- classificazione: `tags[]`, `topics[]`, `category`, `season`, `episodeNumber`
|
||||
- riferimenti incrociati: `reportId`, `scheduleId`, `company`, `product`
|
||||
|
||||
### Riferimenti codice
|
||||
|
||||
- fetch upstream: [_fetch_podcast_episode](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)
|
||||
- wrap + serialize + cap 24k: [_resolve_episode_context](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)
|
||||
- hint costante: [PODCAST_EPISODE_HINT](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)
|
||||
|
||||
---
|
||||
|
||||
## 2. Blocco profilo chiamante — `phoneNumber`
|
||||
|
||||
Contesto identità del chiamante. Viene usato per il modello quando risponde
|
||||
(riferirsi all'utente per nome, conoscere la sua azienda, ruolo, ecc.).
|
||||
|
||||
### Trigger
|
||||
|
||||
1. URL browser → `?phoneNumber=%2B<numero-internazionale>`
|
||||
2. Body `/api/reasoning` → `phoneNumber: "+…"`
|
||||
|
||||
### Sorgente dati
|
||||
|
||||
```
|
||||
GET VOCERO_USER_PHONE_AUTH_URL?phoneNumber=<phone>
|
||||
```
|
||||
|
||||
Se è configurato anche `S2S_LOGIN_BY_PHONE`, PRIMA della profile fetch:
|
||||
```
|
||||
POST S2S_LOGIN_BY_PHONE body { "phoneNumber": "..." }
|
||||
```
|
||||
Da questa risposta il proxy estrae un bearer token e lo inoltra come
|
||||
`Authorization: Bearer <token>` sulle chiamate successive (profile, reports,
|
||||
summary).
|
||||
|
||||
Formato risposta: JSON arbitrario, wrapper `{"result": ...}` supportato.
|
||||
|
||||
### Cap
|
||||
|
||||
1.600 caratteri dopo JSON serialize.
|
||||
|
||||
### Wrap
|
||||
|
||||
```
|
||||
Caller profile resolved from the page phoneNumber:
|
||||
{ <profilo JSON troncato> }.
|
||||
Use this as trusted caller context when relevant.
|
||||
```
|
||||
|
||||
### Riferimenti codice
|
||||
|
||||
- login by phone: [_login_by_phone](file:///home/duxiter/s2s/s2s-client/server.py#L812-L890)
|
||||
- fetch profile: [_fetch_user_profile_for_phone](file:///home/duxiter/s2s/s2s-client/server.py#L1081-L1134)
|
||||
- wrap serialize: [_resolve_user_context_text](file:///home/duxiter/s2s/s2s-client/server.py#L432-L444)
|
||||
|
||||
---
|
||||
|
||||
## 3. Blocco lista reports / script schedulati
|
||||
|
||||
Riferimento "storico" dei contenuti preparati prima della sessione. Non sono
|
||||
contesto live, ma materiale che il modello deve sapere esiste (e a cui
|
||||
riferirsi se l'utente chiede di report pregressi, script, podcast schedulati).
|
||||
|
||||
### Trigger
|
||||
|
||||
Solo env: `S2S_REPORT_LIST` deve essere valorizzato a un URL valido.
|
||||
**Funziona anche SENZA `phoneNumber`** (lista reports generica per tutti).
|
||||
|
||||
Se `phoneNumber` è presente lo passa come query param all'endpoint.
|
||||
|
||||
### Sorgente dati
|
||||
|
||||
```
|
||||
GET S2S_REPORT_LIST[?phoneNumber=<phone>]
|
||||
```
|
||||
|
||||
Wrapper `{"result": [...]}` supportato.
|
||||
|
||||
### Cap
|
||||
|
||||
12.000 caratteri sul blocco COMPLETO (ogni podcast script può essere lungo).
|
||||
|
||||
### Wrap + filtri + sort
|
||||
|
||||
```
|
||||
Scheduled reports / podcast scripts available for this caller before the current session.
|
||||
Each entry is a scheduled report; use the podcastScript contents as trusted reference material
|
||||
when the user asks about scheduled topics, reports, or prior content.
|
||||
<<<REPORTS_LIST_BEGIN>>>
|
||||
[
|
||||
{
|
||||
"sourceScheduleLabel": "...",
|
||||
"date": "2026-09-06T…",
|
||||
"podcastScript": "…"
|
||||
},
|
||||
… ordinati per data DESC (nuovi prima) …
|
||||
]
|
||||
<<<REPORTS_LIST_END>>>
|
||||
```
|
||||
|
||||
Regole per ogni entry:
|
||||
1. **Solo dict** vengono tenuti (stringhe/int scartati).
|
||||
2. Per ogni entry estrae 3 campi con fallback a nested shape:
|
||||
- Label: `sourceScheduleLabel` oppure `sourceSchedule.label`
|
||||
- Date: `date` oppure `sourceSchedule.date`
|
||||
- Script: `podcastScript`
|
||||
3. Ordinamento **data DESC** (nuovi prima) con parser robusto:
|
||||
- ISO datetime / date strings
|
||||
- `Date` / `DateTime` Python (se deserializzati)
|
||||
- Unix timestamp int/float
|
||||
- Lexicografico DESC come fallback per stringhe ISO-ish
|
||||
- Valori nulli / non-parse-abili finiscono in fondo.
|
||||
|
||||
### Riferimenti codice
|
||||
|
||||
- fetch: [_fetch_reports_list](file:///home/duxiter/s2s/s2s-client/server.py#L668-L745)
|
||||
- parse data sort key: [_parse_date_key](file:///home/duxiter/s2s/s2s-client/server.py#L507-L515)
|
||||
- wrap + filtro + sort: [_resolve_reports_context](file:///home/duxiter/s2s/s2s-client/server.py#L518-L666)
|
||||
|
||||
---
|
||||
|
||||
## 4. Blocco summary ultima conversazione
|
||||
|
||||
Sintesi della chiamata/chat IMMEDIATAMENTE precedente a questa. Usato dal
|
||||
modello per "ricordarsi" cosa si è detto l'ultima volta senza conservare
|
||||
storico lunghissimo nel prompt.
|
||||
|
||||
### Trigger
|
||||
|
||||
Entrambe:
|
||||
- env `S2S_LATEST_SUMMARY` impostato
|
||||
- `phoneNumber` presente in URL/body
|
||||
|
||||
### Sorgente dati
|
||||
|
||||
```
|
||||
GET S2S_LATEST_SUMMARY?phoneNumber=<phone>
|
||||
```
|
||||
|
||||
Wrapper `{"result": <valore>}` supportato.
|
||||
Accetta SIA JSON che plain text (diversi backend summary ritornano testo semplice).
|
||||
|
||||
### Cap
|
||||
|
||||
1.600 caratteri.
|
||||
|
||||
### Wrap
|
||||
|
||||
```
|
||||
Latest conversation summary for this caller (most recent before the current session):
|
||||
<<<LATEST_SUMMARY_BEGIN>>>
|
||||
<summary, stringa o JSON serializzato>
|
||||
<<<LATEST_SUMMARY_END>>>.
|
||||
Use this as trusted history when the user references prior calls.
|
||||
```
|
||||
|
||||
### Riferimenti codice
|
||||
|
||||
- fetch: [_fetch_latest_summary_for_phone](file:///home/duxiter/s2s/s2s-client/server.py#L893-L971)
|
||||
- wrap: [_resolve_latest_summary_context](file:///home/duxiter/s2s/s2s-client/server.py#L446-L472)
|
||||
|
||||
---
|
||||
|
||||
## 5. Blocco `user_context` custom dal browser (SOLO `/api/reasoning`)
|
||||
|
||||
**Canale solo HTTP reasoning.**
|
||||
Non è nel WS: il WS non ha campo user_context nel protocollo realtime
|
||||
OpenAI (tutto passa per `session.instructions`).
|
||||
|
||||
Composizione lato client in [main.js fetchOpenAiReasoningAnswer](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364):
|
||||
|
||||
```js
|
||||
const profileCtx = voceroUserContextText();
|
||||
const ragCtx = customRagContextText();
|
||||
const user_context = [profileCtx, ragCtx]
|
||||
.map(s => s.trim()).filter(Boolean).join("\n\n");
|
||||
```
|
||||
|
||||
### Dove va a finire
|
||||
|
||||
Nel body a monte sia NODE_RED sia OPENAI, questo blocco viene **APPESO DOPO**
|
||||
tutti i blocchi iniettati server-side:
|
||||
|
||||
```
|
||||
user_context finale =
|
||||
[1. episode] ← solo se episode_id
|
||||
[2. profile] ← solo se phoneNumber + env profilatura
|
||||
[3. reports] ← solo se env S2S_REPORT_LIST
|
||||
[4. summary] ← solo se phoneNumber + S2S_LATEST_SUMMARY
|
||||
+ browser user_context (profileCtx + ragCtx)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ordine finale nel prompt LLM
|
||||
|
||||
Questa è la sequenza **letterale** con cui i blocchi vengono concatenati
|
||||
dentro `session.instructions` (WS) e in `instructions` / `user_context`
|
||||
(reasoning HTTP).
|
||||
|
||||
```
|
||||
1. EPISODIO PODCAST ← PRIORITÀ PIÙ ALTA (primary trusted reference)
|
||||
2. PROFILO CHIAMANTE ← identity / caller profile
|
||||
3. REPORTS SCHEDULATI ← scheduled / prior content
|
||||
4. SUMMARY ULTIMA CALL ← history più recente
|
||||
5. Base instructions (prompt predefinito o primary prompt)
|
||||
6. (solo reasoning) user_context custom inviato dal browser
|
||||
```
|
||||
|
||||
Perché questo ordine:
|
||||
- più in alto nel prompt = più peso / attenzione dal modello.
|
||||
- l'episodio attuale è il materiale che l'utente ha esplicitamente richiesto
|
||||
aprendo la pagina con `?episode_id=`, quindi merita massimo peso.
|
||||
- il profilo chiamante va sempre prima di "contenuti generici" (reports e
|
||||
summary) perché serve per coerenza nel riferirsi all'utente.
|
||||
- reports = contenuti schedulati PRIMA della sessione corrente → più vecchi
|
||||
dell'ultima chiamata, quindi messi PRIMA del summary ultima chiamata che
|
||||
è il contenuto più "vivo".
|
||||
|
||||
Implementazione ordine: [_augment_instructions_with_profile](file:///home/duxiter/s2s/s2s-client/server.py#L1149-L1159)
|
||||
```python
|
||||
contexts = [c for c in [episode_ctx, profile_ctx, reports_ctx, summary_ctx] if c]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Punti di innesco (quando viene calcolato / applicato)
|
||||
|
||||
### Canale WebSocket — `/v1/realtime`
|
||||
|
||||
**MOMENTO 1 (solo una volta):** apertura WS.
|
||||
|
||||
Nel handler [@app.websocket("/v1/realtime")](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)
|
||||
appena la WS viene accettata:
|
||||
1. parsa `ws.url.query` per `phoneNumber` e `episode_id`
|
||||
2. esegue login (se attivo) → profile → episode → reports → summary
|
||||
3. salva TUTTI i blocchi già wrappati e serializzati nel dict `session_ctx`:
|
||||
```python
|
||||
session_ctx = {
|
||||
"injected_episode_context": "...",
|
||||
"injected_profile_context": "...",
|
||||
"injected_reports_context": "...",
|
||||
"injected_summary_context": "...",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**MOMENTO 2 (ogni `session.update` client→upstream):** nel task
|
||||
[_client_to_upstream](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)
|
||||
ogni frame in uscita viene passato a:
|
||||
|
||||
```python
|
||||
_patch_client_session_update_with_profile(
|
||||
frame_text,
|
||||
user_context_text = profile_ctx,
|
||||
reports_context_text = reports_ctx,
|
||||
summary_context_text = summary_ctx,
|
||||
episode_context_text = episode_ctx, # ← nuovo
|
||||
)
|
||||
```
|
||||
|
||||
Se il frame NON è `session.update` → ritorna **identico** e non viene loggato
|
||||
(per evitare spam).
|
||||
Se è `session.update` → patch del campo `payload["session"]["instructions"]`.
|
||||
|
||||
### Canale HTTP — `POST /api/reasoning`
|
||||
|
||||
**Ogni chiamata** ricostruisce i blocchi da zero nel
|
||||
[handler `/api/reasoning`](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577):
|
||||
1. legge `req.phoneNumber` + `req.episode_id`
|
||||
2. fetch episodio (se episode_id) + login + profile + reports + summary
|
||||
3. costruisce `user_context_parts` e `effective_instructions`
|
||||
4. inoltra al backend (NODE_RED o OPENAI)
|
||||
|
||||
---
|
||||
|
||||
## Quando un blocco viene SKIPPATO (niente inject)
|
||||
|
||||
Tutti i blocchi hanno 5 possibili ragioni di skip. Nessuna rompe mai il
|
||||
flusso: ogni caso produce una riga `[INJECT-*] ⏭️ skipped` nei log e basta.
|
||||
|
||||
| Causa | Log |
|
||||
|---|---|
|
||||
| Master flag `S2S_INJECT_PROFILE=false` | `[INJECT-PROFILE] ⏭️ skipped (S2S_INJECT_PROFILE=false)` |
|
||||
| Parametro mancante (es. `phoneNumber` non in URL) | `[INJECT-*] ⏭️ skipped (<parametro> missing)` |
|
||||
| Endpoint env non configurato | `[INJECT-*] ⏭️ skipped (env for ... empty)` |
|
||||
| Upstream HTTP ≠ 200 | `[INJECT-*] ⛔ upstream HTTP <status_code>` |
|
||||
| Upstream ha ritornato vuoto / null / `{}` / `[]` | `[INJECT-*] ⏭️ upstream returned empty` |
|
||||
| Eccezione di rete (timeout, DNS, SSL) | `[INJECT-*] ⛔ fetch unreachable error=<exc>` |
|
||||
|
||||
---
|
||||
|
||||
## Marcatori log / marker nel prompt
|
||||
|
||||
Ogni blocco è **auto-documentante** per ispezione dei log con coppie di
|
||||
marker:
|
||||
|
||||
```
|
||||
<<<PODCAST_EPISODE_BEGIN>>> / <<<PODCAST_EPISODE_END>>>
|
||||
<<<REPORTS_LIST_BEGIN>>> / <<<REPORTS_LIST_END>>>
|
||||
<<<LATEST_SUMMARY_BEGIN>>> / <<<LATEST_SUMMARY_END>>>
|
||||
```
|
||||
|
||||
Quando `S2S_DEBUG_PROMPT=true` viene loggato il contenuto COMPLETO di ogni
|
||||
blocco con i tag `[DEBUG-EPISODE]`, `[DEBUG-REPORTS]`, `[DEBUG-SUMMARY]` sia
|
||||
all'atto del load (WS connect) sia dopo l'augment delle instructions (prima
|
||||
di inoltrare a monte). **WARNING:** questo env può loggare PII.
|
||||
|
||||
---
|
||||
|
||||
## Tabella riepilogo: trigger per ogni blocco
|
||||
|
||||
| # | Blocco | Trigger | env richiesto | parametro | cap |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Episodio podcast | `episode_id` in URL o body | (nessuno — URL hardcodato Aquachile) | `episode_id=` | 24 k |
|
||||
| 2 | Profilo chiamante | `phoneNumber` | `VOCERO_USER_PHONE_AUTH_URL` | `phoneNumber=` | 1,6 k |
|
||||
| 3 | Reports schedulati | sempre se env | `S2S_REPORT_LIST` | opzionale `phoneNumber=` | 12 k |
|
||||
| 4 | Summary ultima call | `phoneNumber` | `S2S_LATEST_SUMMARY` | `phoneNumber=` | 1,6 k |
|
||||
| 5 | Browser user_context | solo reasoning | (nessuno) | body `user_context` | illimitato |
|
||||
|
||||
---
|
||||
|
||||
## Error handling strategico
|
||||
|
||||
Perché tutti gli errori sono silenziosi (log + skip) e non abortiscono:
|
||||
|
||||
> Se il backend dei profili è giù per manutenzione 5 minuti non vogliamo
|
||||
> che il Voice Agent smetta di funzionare. Semplicemente l'assistente non
|
||||
> conosce l'identità del chiamante per 5 minuti, ma parla normalmente.
|
||||
|
||||
L'utente non si accorge di niente. L'operatore legge i log e vede le righe
|
||||
`⛔`; può risolvere offline.
|
||||
|
||||
Se volessi forzare il fail (raro, su deploy dedicato), si trasformerebbero
|
||||
le `return None` finali in `raise HTTPException(...)` — ma per default
|
||||
non è il comportamento voluto.
|
||||
|
||||
---
|
||||
|
||||
## Appendice A — Start-session modes (`S2S_START_SESSION_MODE`)
|
||||
|
||||
Oltre alla inject pipeline lato server, è presente una feature lato client che
|
||||
governa **come e se la sessione vocale inizia in automatico** e se il modello
|
||||
saluta per primo (in modo identico alla wake-word "Hola Vocero").
|
||||
|
||||
### Valori supportati
|
||||
|
||||
| `S2S_START_SESSION_MODE` | Chi inizia? | Richiede click utente? | Trigger greet iniziale |
|
||||
|---|---|---|---|
|
||||
| *(vuoto) / default* | Utente, tap manuale Orb | ✅ | No |
|
||||
| `TALK` | Auto dopo 2s con overlay unlocker | ✅ (1 click policy-safe su Safari/iOS) | WS client: `sendUserText("hola vocero") + requestResponse()` dopo `session.updated` |
|
||||
| `TALKFIRST` | Auto dopo 2s **SENZA click** (se possibile) | ❌ se mic già "allow always"; fallback unlocker se `getUserMedia` fallisce | **Stesso identico codice della wake-word vera:** `pendingWakeWordConfirmationText = "hola vocero"` (main.js `case "connected"` → `sendUserText + requestResponse`) |
|
||||
|
||||
### Warmup 2s
|
||||
|
||||
Il timer non parte prima di 2000 ms per lasciare terminare:
|
||||
- `fetchVoceroUserProfile()` (identity chiamante)
|
||||
- `fetchVoceroReportsList()` (storico script podcast)
|
||||
- fetch episode (`episode_id`) se presente
|
||||
- `GET /api/config` (con la quale il client apprende `startSessionMode`)
|
||||
|
||||
Senza warmup le injection lato server potrebbero ancora non essere completate e
|
||||
il greet iniziale arriverebbe "cieco" (senza contesto episodio/profilo).
|
||||
|
||||
### Fallback di `TALKFIRST`
|
||||
|
||||
Nel 99% dei casi `getUserMedia()` fuori da una user gesture fallisce
|
||||
(NotAllowedError / NotReadableError su Safari, Chrome con policy strict, iOS).
|
||||
In tal caso:
|
||||
1. si chiude il client WS (`teardown()`)
|
||||
2. stato torna `idle`
|
||||
3. si mostra `#talk-unlocker` con la dicitura:
|
||||
- **"Se requiere permiso de micrófono"**
|
||||
- **"Toca en cualquier lugar para permitir el audio y empezar"**
|
||||
4. `pendingWakeWordConfirmationText` NON viene svuotato → quando l'utente
|
||||
toccherà, `case "connected"` triggerà comunque la greet come wake-word.
|
||||
|
||||
### Riferimenti codice
|
||||
|
||||
| Componente | File | Range righe |
|
||||
|---|---|---|
|
||||
| Lettura env + banner startup + `/api/config` exposure | [server.py](file:///home/duxiter/s2s/s2s-client/server.py) | [L150](file:///home/duxiter/s2s/s2s-client/server.py#L150), [L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212), [L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400) |
|
||||
| Overlay HTML unlocker | [index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39) | L32-L39 |
|
||||
| Stili overlay | [style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215) | L145-L215 |
|
||||
| Variabile globale + parse config client | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632) | L628-L632 |
|
||||
| `fetchConfig()` parsing + `reportDebug("B")` | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320) | L2309-L2320 |
|
||||
| `new S2sWsRealtimeClient({startWithResponse})` | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692) | L2683-L2692 |
|
||||
| Scheduling + `tryTalkFirstDirect()` + unlocker gesture | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083) | L2976-L3083 |
|
||||
| `onClientStatus("connected")` → wake-word inject flow | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2897-L2914) | L2897-L2914 |
|
||||
| WS client → `TALK` mode sendUserText("hola vocero") + `requestResponse()` | [s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713) | L688-L713 |
|
||||
|
||||
---
|
||||
|
||||
## Riferimenti file
|
||||
|
||||
| File | Ruolo |
|
||||
|---|---|
|
||||
| [server.py](file:///home/duxiter/s2s/s2s-client/server.py) | Tutta la pipeline inject: fetch, wrap, patch, handler WS + reasoning |
|
||||
| [main.js](file:///home/duxiter/s2s/s2s-client/main.js) | Passaggio client `?phoneNumber` + `?episode_id` → WS query params e POST `/api/reasoning` body |
|
||||
| [CHANGELOG.md](file:///home/duxiter/s2s/s2s-client/CHANGELOG.md) | History rilasci / modifiche alla inject pipeline |
|
||||
+77
-5
@@ -16,6 +16,76 @@ Browser voice-chat UI for the
|
||||
backend. The browser streams mic audio over a WebSocket using the OpenAI
|
||||
Realtime **GA** protocol and plays back the assistant's audio as it arrives.
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Novedades / Actualizaciones (Azurian Vocero realtime ver.Beta 2.1)
|
||||
|
||||
> Versión del navegador que debes ver cargada en consola: **`2026-09-04 11:20`**.
|
||||
> Si no aparece, haz **borrado de datos del sitio + recarga dura (Ctrl+Shift+R)** o abre una pestaña en modo incógnito.
|
||||
|
||||
### 🎨 Rediseño visual completo (tema aurora + glassmorphism)
|
||||
- **Tema de fondo aurora**: gradiente radial violeta/rosa/cian sobre lienzo oscuro con malla sutil + animación `auroraShift` lenta.
|
||||
- **Cromado uniforme glass**: todos los elementos flotantes (tarjeta brand, panel user profile, botones icono, botón enviar chat, panel informes sidebar) usan *glass blur + saturate + borde gradiente brand 1px* (técnica mask `padding-box / border-box` doble linear-gradient).
|
||||
- **Tokens de marca 3 colores brand** (violeta `#8b5cf6`, rosa `#ec4899`, cian `#22d3ee`) mezclados con `color-mix(in srgb, brand X%, neutral)` → ningún color hardcodeado deriva; todo el UI sigue estos tres tonos.
|
||||
|
||||
### 🧭 Estructura (orden vertical de arriba → abajo)
|
||||
1. **`header.topbar`** brand "Vocero" → avatar circular iniciales nombre usuario.
|
||||
- 🟦 **Avatar user**: círculo 40×40px con gradiente brand, initials mayúsculas bold 800, hover lift/glow + foco teclado. Al hacer clic → diálogo modal perfil usuario (rejilla 2 columnas email/teléfono/username/rol/empresa/userid/país/fecha creación/último login; links `mailto:`/`tel:`; chips mono para códigos; mobile ≤480px apilado 1 col).
|
||||
- 🟦 **Botón toggle chat (icono speech bubble) → ELIMINADO PERMANENTEMENTE**. La chat ahora siempre está visible inline; el botón toggle ya no existe en el DOM.
|
||||
2. **`main.stage`** (arriba del todo, flexible): orbe/globode voz animado (3 anillos: core, ring, conic spin 3 velocidades + highlight specular + glows breathe `breatheOrb`) + caption `TAP OR SAY "HOLA VOCERO"` micro-bob uppercase gradient.
|
||||
- ✅ Antes había una zona vacía sobre el orbe → eliminada, ahora el stage empieza en la parte superior (`justify-content: flex-start`).
|
||||
3. **`<section id="chat-panel" class="chat-panel chat-panel-inline">` SIEMPRE VISIBLE, 100% ancho, SOTTO stage, SOPRA footer sticky** (rellena la zona vacía inferior).
|
||||
- ✅ Antes: overlay lateral derecho. **Ahora: bloque inline dentro de `#app` como hijo flex-col**.
|
||||
- 🟦 Sin marco / sin riquadro (fondo aurora seamless, 0 borde, 0 shadow, 0 backdrop blur).
|
||||
- Cabecera `CONVERSACIÓN` h3 uppercase 13px gradiente.
|
||||
- `#chat-history` scroll interno. Burbujas de mensaje glass con **código de colores por rol**: usuario (tint 10% voice-user cyan + borde 44% cyan + glow) / assistant (tint 10% voice-assistant violet/pink + borde 42% + glow).
|
||||
- Distancias seguras anti-borde: `.hist-msg.user margin-left:6px auto dx` / `.hist-msg.assistant margin-right:22px auto sx` → **nunca las burbujas tocan el borde del viewport** (fix confirmado).
|
||||
- Formulario input texto `#chat-form` (arriba del botón enviar ✈️) **actualmente oculto (`hidden`)** para modo voz-puro; elimina ese atributo para reactivarlo. El handler submit que envía `client.sendUserText(text)` al websocket ya está conectado.
|
||||
4. **Footer sticky `position: sticky; bottom:0; z:3; backdrop-blur`** "Azurian Vocero realtime ver.Beta 2.1" siempre dentro del viewport, incluso cuando el documento crece y haces scroll (no requiere scroll hacia abajo para verlo). Separador superior línea gradiente brand.
|
||||
|
||||
### 📜 Sidebar de informes a la IZQUIERDA (antes topbar inline)
|
||||
- Desktop **≥ 1024px**: sidebar fija left 320px siempre visible; `#app padding-left 320px` reserva sitio.
|
||||
- Tablet/Mobile **< 1024**: offcanvas `translateX(-105%)` slide-in con backdrop blur; toggle mediante botón icono reportes `#reports-sidebar-toggle` en topbar con badge contador.
|
||||
- `#reports-hint` siempre visible; `.reports-suggestions` y `.topbar-right` con puerta triple de visibilidad (atributo HTML hidden + CSS rule `[data-debug-only]:not([data-debug-enabled]) !important display:none` + JS `applyDebugOnlyVisibilityGate()` ejecutado 6 veces por lifecycle) → solo aparecen cuando el query param `?debug=true` está presente en la URL.
|
||||
|
||||
### ⬇️ Auto-scroll chat SIEMPRE hacia la última respuesta del asistente
|
||||
Arquitectura anti-clipping definitiva (versión 11:20 fix):
|
||||
|
||||
1. **CSS sobrante intencional** (`96 → 160px chat-history padding-bottom`, `40→160px chat-panel clamp`, `56→120px #app padding-bottom + safe-area-inset-bottom`) → el último párrafo siempre está por ENCIMA de la línea del footer glass flotante.
|
||||
2. **Unidades móviles seguras `svh / svw`** (smallest viewport): evita oscilaciones layout cuando aparece/desaparece la barra URL del móvil.
|
||||
3. **JS cancel-and-reschedule** cada vez que `_scrollToBottom()` se llama: si llegan 100 tokens streaming en 1 frame, el último cancela todas las cadenas pendientes y se ejecuta desde cero → **no hay coalescing flag SKIP** (esta era la causa original del scroll insuficiente).
|
||||
4. **5 reintentos escalonados `0 / +30 / +70 / +150 / +250ms`**:
|
||||
- 0 ms (inmediato): 90% de los casos.
|
||||
- 30 ms: tras 1 layout-pass.
|
||||
- 70 ms: **corrige el wrapping de la última palabra en una línea nueva** (bug más común).
|
||||
- 150 ms: después de reflow Chrome scroll-anchor.
|
||||
- 250 ms: después de expandir filas tool / imágenes grandes.
|
||||
5. **Snap con `Number.MAX_SAFE_INTEGER`**: el navegador clampa automáticamente al máximo real (`scrollHeight - clientHeight`); no hay race de leer scrollHeight demasiado pronto.
|
||||
6. `overflow-anchor: none; scroll-behavior: auto !important;` → sin smooth, sin Chrome scroll-anchor-luchando contra nosotros.
|
||||
7. Llamadas scroll explicitas extra en: `onResponseFinished` (fin de respuesta assistant, crítico), `onToolCall`, `onToolResult`.
|
||||
|
||||
### 🛡️ Protecciones anti-error de refs eliminadas
|
||||
- En el refactor de chat eliminamos 4 nodos DOM (`#chat-btn`, `#chat-badge`, `#chat-panel-backdrop`, `#chat-panel-close`).
|
||||
- **Antes**: el constructor de `ChatView` usaba `$("#...")` strict → lanzaba `Error("Missing element: #chat-btn")` al arrancar.
|
||||
- **Ahora**: esas 4 refs opcionales usan `document.querySelector("#...")` → devuelve `null` cuando no existen, sin throw; todos los listeners guarded `if (el)`. Los 3 IDs obligatorios (`#chat-panel`, `#chat-history`, `#bubble-stack`) siguen con strict para fallar fuerte si alguien los borra por error.
|
||||
|
||||
### 🔄 Actualizaciones de versión / cache bust
|
||||
Para evitar caché HTTP agresiva en navegadores móviles (ESM sin bundler + service worker = nunca refrescan URLs iguales), todas las referencias a recursos estáticos usan query param `?v=YYYYMMDDHHMM` sincronizado en **6 puntos**:
|
||||
1. `index.html <link rel="stylesheet" href="style.css?v=...">`
|
||||
2. `index.html <script type="module" src="main.js?v=...">`
|
||||
3-6. Los **4 imports ESM dentro de main.js** (`ws/s2s-ws-client`, `ui/dom`, `ui/chat`, `ui/account`).
|
||||
|
||||
Además hay un **script inline en `<head>`** tras el style.css que al `DOMContentLoaded` fuerza a reescribir el href del stylesheet con la última versión si por algún motivo cargó la antigua (anti-cache móvil).
|
||||
|
||||
### Deploy
|
||||
```bash
|
||||
cd /home/duxiter/s2s
|
||||
docker compose restart s2s_ui
|
||||
```
|
||||
Limpia siempre datos del sitio en el navegador (Application → Storage → Clear site data) y recarga dura `Ctrl+Shift+R` al finalizar.
|
||||
|
||||
---
|
||||
|
||||
## Quick start (local)
|
||||
|
||||
1. **Start the speech-to-speech backend** in realtime mode (from the repo root;
|
||||
@@ -149,21 +219,23 @@ NOT collide with the WebRTC variant.
|
||||
|
||||
## Files
|
||||
|
||||
> 🇪🇸 **Notas adicionales (Azurian Vocero)**: `index.html` ahora incluye (1) botón toggle lateral izquierdo reportes `#reports-sidebar-toggle` con badge contador `#reports-sidebar-badge` visible solo <1024px, (2) `<dialog id="user-profile-dialog">` modal perfil usuario que se abre al hacer clic en el avatar circular del encabezado, (3) panel chat `<section id="chat-panel">` permanente dentro de `#app` entre `</main>` y footer sticky (el panel overlay derecho antiguo y el botón toggle chat superior fueron ELIMINADOS completamente).
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `index.html` | Single page, orb + settings modal (identical UI to the WebRTC app) |
|
||||
| `main.js` | State machine, settings, tools, camera, noise-gate UI wiring |
|
||||
| `ui/chat.js` | `ChatView`: history panel, ephemeral bubbles, transcript/tool streaming |
|
||||
| `main.js` | State machine, settings, tools, camera, noise-gate UI wiring + chat-form submit handler (`client.sendUserText`) + user profile dialog open/close + reports sidebar open/close + 3-layer debug gate |
|
||||
| `ui/chat.js` | `ChatView`: history panel, ephemeral bubbles, transcript/tool streaming; always-inline 100% width chat panel w/ 5-step retry auto-scroll to last assistant line |
|
||||
| `ui/account.js` | `Account`: HF login chip + popover, daily-limit modal |
|
||||
| `ui/dom.js` | Shared helpers: `$`, `escHtml`, `truncateError`, `DEBUG` |
|
||||
| `ui/dom.js` | Shared helpers: strict `$(selector)` that throws on missing, `document.querySelector` fallback, `escHtml`, `truncateError`, `DEBUG` from URL |
|
||||
| `auth.py` | HF OAuth + per-request identity (tier, hashed keys) |
|
||||
| `limiter.py` | SQLite per-day talk-time budget (chunked server-clock reservation) |
|
||||
| `ws/s2s-ws-client.js` | WebSocket handshake + OpenAI Realtime GA protocol |
|
||||
| `ws/codec.js` | base64 <-> PCM helpers + transcript extraction (pure) |
|
||||
| `ws/orb-visualizer.js` | `OrbVisualiser`: FFT bands -> orb CSS custom properties |
|
||||
| `ws/orb-visualizer.js` | `OrbVisualiser`: FFT bands -> orb CSS custom properties (violet/pink/cyan brand gradient) |
|
||||
| `worklets/mic-capture.js` | AudioWorklet: 48 kHz Float32 -> 16 kHz Int16 PCM, posts ~40 ms chunks |
|
||||
| `worklets/audio-playback.js` | AudioWorklet: 24 kHz Float32 ring buffer -> 48 kHz, linear interp, fade in/out |
|
||||
| `style.css` | Orb animations, layout, dark theme (verbatim from the WebRTC app) |
|
||||
| `style.css` | Orb animations, layout, dark aurora gradient theme, glass chat bubbles, sticky footer, reports sidebar desktop left 320px / mobile offcanvas, user profile 2-col grid modal |
|
||||
|
||||
## Audio pipeline notes
|
||||
|
||||
|
||||
+95
-17
@@ -13,7 +13,15 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css?v=20260902001" />
|
||||
<link rel="stylesheet" href="style.css?v=202609041120" />
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const l = document.querySelector('link[rel="stylesheet"]');
|
||||
if (l && !l.href.includes("v=202609041120")) {
|
||||
l.href = l.href.replace(/v=\d+/, "v=202609041120");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<!--
|
||||
`booting` disables transitions/animations until the first paint commits.
|
||||
@@ -22,17 +30,32 @@
|
||||
bootstrap script at the end of main.js strips the class after one rAF.
|
||||
-->
|
||||
<body class="booting">
|
||||
<div id="talk-unlocker" class="talk-unlocker" hidden data-debug-only-never>
|
||||
<div class="talk-unlocker-hint">
|
||||
<div class="talk-unlocker-spinner" aria-hidden="true"></div>
|
||||
<p class="talk-unlocker-title">Preparando Vocero…</p>
|
||||
<p class="talk-unlocker-sub">Toca en cualquier lugar para empezar a hablar</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="ident">
|
||||
<div class="ident-head">
|
||||
<a class="ident-title">Azurian Vocero realtime ver.Beta 2.0</a>
|
||||
<a class="ident-title">Vocero</a>
|
||||
</div>
|
||||
<div id="ident-user-name" class="ident-user-name" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<!-- Left sidebar (reports) toggle: always visible, pinned left side of the
|
||||
topbar control cluster. Clicking slides in the offcanvas sidebar on
|
||||
mobile; it's a no-op hover hint on desktop where sidebar is always
|
||||
shown. -->
|
||||
<button id="reports-sidebar-toggle" class="icon-btn" title="Reports programados" aria-label="Abrir panel de reportes" aria-expanded="false" aria-controls="reports-sidebar" hidden data-reports-sidebar-toggle data-debug-only="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z" fill="none"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||
<span id="reports-sidebar-badge" class="chat-badge" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="topbar-right" hidden data-debug-only="true">
|
||||
<!-- HF login chip / sign-in pill. Populated by ui/account.js; only
|
||||
shown when the deploy runs behind a load balancer with OAuth. -->
|
||||
<div id="account" class="account" hidden></div>
|
||||
@@ -45,10 +68,6 @@
|
||||
<button id="rag-btn" class="icon-btn" title="Custom RAG context" aria-label="Custom RAG context">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
</button>
|
||||
<button id="chat-btn" class="icon-btn" title="Conversation history" aria-label="Conversation history">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span id="chat-badge" class="chat-badge" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button id="settings-btn" class="icon-btn" title="Settings" aria-label="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .66.39 1.25 1 1.51H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
@@ -128,6 +147,31 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Chat di testo SEMPRE VISIBILE (fissa in basso, tra stage e footer).
|
||||
Nota: ora è un blocco diretto del flex-col #app, non più un overlay
|
||||
destro → non c'è più bisogno del bottone toggle in topbar. -->
|
||||
<section id="chat-panel" class="chat-panel chat-panel-inline" aria-label="Chat de texto">
|
||||
<div class="chat-panel-inner">
|
||||
<header class="chat-panel-header">
|
||||
<h3>Conversación</h3>
|
||||
</header>
|
||||
<div id="chat-history" class="chat-history" role="log" aria-live="polite" aria-atomic="false"></div>
|
||||
<!-- Text input row HIDDEN per user request 2026-09-04 (restore removing hidden tag below) -->
|
||||
<form id="chat-form" class="chat-form" autocomplete="off" spellcheck="false" hidden style="display:none!important">
|
||||
<input id="chat-input"
|
||||
class="chat-input"
|
||||
type="text"
|
||||
name="message"
|
||||
placeholder="Escribe aquí tu mensaje a Vocero…"
|
||||
aria-label="Enviar mensaje de texto a Vocero"
|
||||
maxlength="500"
|
||||
/>
|
||||
<button id="chat-send" type="submit" class="chat-send-btn icon-btn" aria-label="Enviar mensaje">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Ephemeral chat bubbles anchored to the top-right -->
|
||||
@@ -141,19 +185,26 @@
|
||||
<span class="cam-label">camera</span>
|
||||
</div>
|
||||
|
||||
<!-- Conversation history panel (overlay) -->
|
||||
<div id="chat-panel" class="chat-panel">
|
||||
<div id="chat-panel-backdrop" class="chat-panel-backdrop"></div>
|
||||
<div class="chat-panel-inner">
|
||||
<header class="chat-panel-header">
|
||||
<h3>Conversation</h3>
|
||||
<button id="chat-panel-close" class="icon-btn" aria-label="Close">
|
||||
<!-- Left sidebar: reports programados.
|
||||
Desktop: fixed left, 320px, always visible (below topbar + above sticky footer).
|
||||
Mobile : off-canvas left overlay, toggled by #reports-sidebar-toggle button in topbar. -->
|
||||
<aside id="reports-sidebar" class="reports-sidebar" aria-label="Reportes programados" hidden>
|
||||
<div id="reports-sidebar-backdrop" class="reports-sidebar-backdrop" data-close-reports-sidebar></div>
|
||||
<div class="reports-sidebar-inner">
|
||||
<header class="reports-sidebar-header">
|
||||
<h3>Reportes</h3>
|
||||
<button id="reports-sidebar-close" class="icon-btn" aria-label="Cerrar panel de reportes" data-close-reports-sidebar>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div id="chat-history" class="chat-history"></div>
|
||||
</div>
|
||||
<!-- MOVED from inside .ident in the topbar — same #id so main.js syncVoceroHeaderUser()
|
||||
continues to work with zero JS changes to reports rendering logic. -->
|
||||
<div id="reports-hint" class="reports-hint" hidden></div>
|
||||
<!-- Debug-only: Spanish suggestions panel (gated behind ?debug=true).
|
||||
Shown as the bottom block inside the left sidebar. -->
|
||||
<div id="reports-suggestions" class="reports-suggestions" hidden data-debug-only="true"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<dialog id="about-modal" class="modal about-modal">
|
||||
<div class="modal-content">
|
||||
@@ -395,6 +446,33 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="main.js?v=20260902001"></script>
|
||||
<!-- User profile dialog — opened by clicking the circular avatar badge in
|
||||
the brand header. Styling: large glass hero with initials + rows of
|
||||
user metadata populated via main.js populateUserProfileDialog(). -->
|
||||
<dialog id="user-profile-dialog" class="modal user-profile-modal" aria-labelledby="user-profile-title">
|
||||
<div class="modal-content user-profile-card">
|
||||
<button id="user-profile-close" class="icon-btn user-profile-close" aria-label="Close user profile">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Hero: big glass avatar circle + title + subtitle (email fallback). -->
|
||||
<section class="user-profile-hero">
|
||||
<div id="user-profile-avatar" class="user-profile-avatar" aria-hidden="true">AV</div>
|
||||
<h2 id="user-profile-title" class="user-profile-title"></h2>
|
||||
<p id="user-profile-subtitle" class="user-profile-subtitle"></p>
|
||||
</section>
|
||||
|
||||
<!-- Metadata: a 1-col definition-list-like grid. Empty rows are hidden
|
||||
automatically via JS when the field has no value, so we never show
|
||||
blank labels. -->
|
||||
<section id="user-profile-metadata" class="user-profile-metadata" aria-label="Informazioni utente"></section>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<footer class="app-footer" aria-label="App version footer">
|
||||
<span class="app-footer-text">Azurian Vocero realtime ver.Beta 2.1</span>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="main.js?v=202609041120"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1000
-11
File diff suppressed because it is too large
Load Diff
+528
-70
@@ -147,6 +147,7 @@ S2S_DEBUG_PROMPT = (
|
||||
(os.environ.get("S2S_DEBUG_PROMPT", "") or "").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
)
|
||||
S2S_START_SESSION_MODE = (os.environ.get("S2S_START_SESSION_MODE", "") or "").strip().upper()
|
||||
S2S_REASONING_TYPE = _normalize_reasoning_type(os.environ.get("S2S_REASONING_TYPE", "PROXY"))
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip().rstrip("/")
|
||||
@@ -193,7 +194,7 @@ app = FastAPI(title="s2s-demo")
|
||||
AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app)
|
||||
|
||||
logger.info(
|
||||
"[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s summaryApi=%s loginApi=%s injectProfile=%s debugPrompt=%s auth=%s",
|
||||
"[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s summaryApi=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s auth=%s",
|
||||
S2S_REASONING_TYPE,
|
||||
NODE_RED_REASONING_URL or "-",
|
||||
bool(SERPER_KEY),
|
||||
@@ -206,6 +207,7 @@ logger.info(
|
||||
bool(S2S_LOGIN_BY_PHONE),
|
||||
S2S_INJECT_PROFILE,
|
||||
S2S_DEBUG_PROMPT,
|
||||
S2S_START_SESSION_MODE or "-",
|
||||
AUTH_ENABLED,
|
||||
)
|
||||
logger.debug(
|
||||
@@ -423,6 +425,10 @@ REPORTS_LIST_HINT = (
|
||||
LATEST_SUMMARY_HINT = (
|
||||
" Latest conversation summary for this caller (most recent before the current session): "
|
||||
)
|
||||
PODCAST_EPISODE_HINT = (
|
||||
" Podcast episode context loaded from the page episode_id query param (current episode the user is referencing). "
|
||||
"Treat the episode fields (title, description, transcript, script, show notes, guests, audio metadata, etc.) as the PRIMARY trusted reference material for this session. "
|
||||
)
|
||||
|
||||
|
||||
def _resolve_user_context_text(phone_number: str, profile_obj: dict | None, loaded: bool = True) -> str:
|
||||
@@ -468,6 +474,38 @@ def _resolve_latest_summary_context(summary_value: str | dict | None) -> str:
|
||||
return f"{LATEST_SUMMARY_HINT}<<<LATEST_SUMMARY_BEGIN>>>\n{trimmed}\n<<<LATEST_SUMMARY_END>>>. Use this as trusted history when the user references prior calls."
|
||||
|
||||
|
||||
def _resolve_episode_context(raw_value: object) -> str:
|
||||
"""Normalize the podcast episode API response into a prompt block injected
|
||||
at the TOP of the contexts (most specific / current reference).
|
||||
|
||||
Accepts a dict (or {"result": {...}} wrapped) with any arbitrary episode
|
||||
fields (title, description, transcript, script, showNotes, guests,
|
||||
publishedAt, audioUrl, duration, author, etc.). Capped at 24000 chars
|
||||
(episodes can have long transcripts). Wrapped with
|
||||
<<<PODCAST_EPISODE_BEGIN/END>>> markers.
|
||||
Returns "" if empty / fetch failed / None.
|
||||
"""
|
||||
if raw_value is None:
|
||||
return ""
|
||||
episode_obj: object = raw_value
|
||||
if isinstance(raw_value, dict) and isinstance(raw_value.get("result"), dict):
|
||||
episode_obj = raw_value["result"]
|
||||
if isinstance(episode_obj, dict) and len(episode_obj) == 0:
|
||||
return ""
|
||||
if isinstance(episode_obj, list) and len(episode_obj) == 0:
|
||||
return ""
|
||||
try:
|
||||
serialized = json.dumps(episode_obj, ensure_ascii=False)
|
||||
except Exception:
|
||||
serialized = str(episode_obj)
|
||||
trimmed = serialized.strip()
|
||||
if not trimmed:
|
||||
return ""
|
||||
if len(trimmed) > 24000:
|
||||
trimmed = trimmed[:24000] + "..."
|
||||
return f"{PODCAST_EPISODE_HINT}<<<PODCAST_EPISODE_BEGIN>>>\n{trimmed}\n<<<PODCAST_EPISODE_END>>>. This is the episode the caller is currently interacting with — answer with reference to it first."
|
||||
|
||||
|
||||
def _parse_date_key(value: object) -> tuple:
|
||||
"""Return a sortable tuple for an arbitrary date-like value.
|
||||
|
||||
@@ -520,6 +558,7 @@ def _resolve_reports_context(raw_value: object) -> str:
|
||||
|
||||
Expects an array/list (or dict {"result": [...]} unwrapped by the fetcher).
|
||||
For each dict item keeps only fields: sourceScheduleLabel, date, podcastScript.
|
||||
Also supports nested shape: sourceSchedule.label / sourceSchedule.date.
|
||||
Sorts by date DESC (newest first) via _parse_date_key. Capped at 12000 chars
|
||||
(podcastScript can be long). Wrapped with <<<REPORTS_LIST_BEGIN/END>>> markers.
|
||||
Returns "" if no valid items / empty list.
|
||||
@@ -539,18 +578,88 @@ def _resolve_reports_context(raw_value: object) -> str:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
cleaned: dict = {}
|
||||
if "sourceScheduleLabel" in item and item["sourceScheduleLabel"] is not None:
|
||||
cleaned["sourceScheduleLabel"] = item["sourceScheduleLabel"]
|
||||
if "date" in item and item["date"] is not None:
|
||||
cleaned["date"] = item["date"]
|
||||
if "podcastScript" in item and item["podcastScript"] is not None:
|
||||
cleaned["podcastScript"] = item["podcastScript"]
|
||||
# Field extraction with fallback to nested sourceSchedule.* shape.
|
||||
# 1. Label: prefer flat sourceScheduleLabel, else nested sourceSchedule.label
|
||||
label_val: object = None
|
||||
if item.get("sourceScheduleLabel") is not None:
|
||||
label_val = item["sourceScheduleLabel"]
|
||||
elif isinstance(item.get("sourceSchedule"), dict):
|
||||
ss = item["sourceSchedule"]
|
||||
if ss.get("label") is not None:
|
||||
label_val = ss["label"]
|
||||
if label_val is None and ss.get("name") is not None:
|
||||
label_val = ss["name"]
|
||||
if label_val is None and ss.get("title") is not None:
|
||||
label_val = ss["title"]
|
||||
if label_val is not None:
|
||||
cleaned["sourceScheduleLabel"] = label_val
|
||||
# 2. Date: prefer flat "date", else nested sourceSchedule.date
|
||||
date_val: object = None
|
||||
if item.get("date") is not None:
|
||||
date_val = item["date"]
|
||||
elif isinstance(item.get("sourceSchedule"), dict):
|
||||
ss = item["sourceSchedule"]
|
||||
if ss.get("date") is not None:
|
||||
date_val = ss["date"]
|
||||
if date_val is None and ss.get("scheduledAt") is not None:
|
||||
date_val = ss["scheduledAt"]
|
||||
if date_val is None and ss.get("publishedAt") is not None:
|
||||
date_val = ss["publishedAt"]
|
||||
if date_val is not None:
|
||||
cleaned["date"] = date_val
|
||||
# 3. Script: prefer flat podcastScript, else nested podcastScript / script / text
|
||||
script_val: object = None
|
||||
if item.get("podcastScript") is not None:
|
||||
script_val = item["podcastScript"]
|
||||
elif item.get("script") is not None:
|
||||
script_val = item["script"]
|
||||
elif item.get("text") is not None:
|
||||
script_val = item["text"]
|
||||
elif item.get("content") is not None:
|
||||
script_val = item["content"]
|
||||
if script_val is not None:
|
||||
cleaned["podcastScript"] = script_val
|
||||
if cleaned:
|
||||
filtered.append(cleaned)
|
||||
if not filtered:
|
||||
return ""
|
||||
# Sort by date DESC (newest first)
|
||||
filtered.sort(key=lambda r: _parse_date_key(r.get("date")))
|
||||
# Build a quick HINT LINE per report (label + date) so the LLM can see at a
|
||||
# glance which scheduled topics are available before diving into scripts.
|
||||
hint_lines = []
|
||||
import datetime as _dt
|
||||
for idx, r in enumerate(filtered, start=1):
|
||||
label_piece = ""
|
||||
if r.get("sourceScheduleLabel"):
|
||||
label_piece = str(r["sourceScheduleLabel"]).strip()
|
||||
date_piece = ""
|
||||
if r.get("date") is not None:
|
||||
raw_d = r["date"]
|
||||
try:
|
||||
# Integer / float timestamp → render as YYYY-MM-DD HH:MM (local)
|
||||
if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool):
|
||||
ts_sec = float(raw_d)
|
||||
if ts_sec > 1e12: # ms → seconds
|
||||
ts_sec = ts_sec / 1000.0
|
||||
dt = _dt.datetime.fromtimestamp(ts_sec, tz=_dt.timezone.utc)
|
||||
date_piece = dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
else:
|
||||
s = str(raw_d).strip()
|
||||
if "T" in s and len(s) >= 16:
|
||||
date_piece = s.replace("T", " ")[:16]
|
||||
else:
|
||||
date_piece = s
|
||||
except Exception:
|
||||
date_piece = str(raw_d)[:32]
|
||||
if label_piece and date_piece:
|
||||
hint_lines.append(
|
||||
f" • Report #{idx}: '{label_piece}' — scheduled on {date_piece}"
|
||||
)
|
||||
elif label_piece:
|
||||
hint_lines.append(f" • Report #{idx}: '{label_piece}'")
|
||||
elif date_piece:
|
||||
hint_lines.append(f" • Report #{idx}: (no label) — scheduled on {date_piece}")
|
||||
# Serialize: compact JSON (pretty-ish but one line per report saves space)
|
||||
entries_lines = []
|
||||
for idx, r in enumerate(filtered, start=1):
|
||||
@@ -558,14 +667,39 @@ def _resolve_reports_context(raw_value: object) -> str:
|
||||
entries_lines.append(f" [{idx}] {json.dumps(r, ensure_ascii=False)}")
|
||||
except Exception:
|
||||
entries_lines.append(f" [{idx}] {str(r)[:500]}")
|
||||
body = "\n".join(entries_lines)
|
||||
wrapped = f"{REPORTS_LIST_HINT}\n<<<REPORTS_LIST_BEGIN>>>\n{body}\n<<<REPORTS_LIST_END>>>. Use these scheduled reports as trusted prior content relevant to the caller's company topics."
|
||||
hints_block = ""
|
||||
if hint_lines:
|
||||
hints_block = "\n".join(hint_lines) + "\n"
|
||||
body = hints_block + "\n--- Full JSON entries ---\n" + "\n".join(entries_lines)
|
||||
wrapped = (
|
||||
f"{REPORTS_LIST_HINT}\n"
|
||||
"Quick hints (available scheduled reports / podcasts by label):\n"
|
||||
f"{body}\n"
|
||||
f"<<<REPORTS_LIST_END>>>. Use these scheduled reports as trusted prior "
|
||||
"content relevant to the caller's company topics. Reference individual "
|
||||
"reports by their label when answering."
|
||||
)
|
||||
# Build wrapped with HINT BLOCK before the JSON. We prepend a summary line per
|
||||
# report so the LLM has a quick table-of-contents of what's available.
|
||||
if len(wrapped) > 12000:
|
||||
# Truncate body preserving markers + hint
|
||||
budget = 12000 - len(REPORTS_LIST_HINT) - len("\n<<<REPORTS_LIST_BEGIN>>>\n") - len("\n<<<REPORTS_LIST_END>>>...truncated.")
|
||||
budget = (
|
||||
12000
|
||||
- len(REPORTS_LIST_HINT)
|
||||
- len("\nQuick hints (available scheduled reports / podcasts by label):\n")
|
||||
- len("\n--- Full JSON entries ---\n")
|
||||
- len("\n<<<REPORTS_LIST_END>>>...truncated.")
|
||||
- 200 # reserve for wrap sentence
|
||||
)
|
||||
if budget > 0:
|
||||
body = body[:budget] + "..."
|
||||
wrapped = f"{REPORTS_LIST_HINT}\n<<<REPORTS_LIST_BEGIN>>>\n{body}\n<<<REPORTS_LIST_END>>>...truncated (cap 12000 chars). Use these scheduled reports as trusted prior content relevant to the caller's company topics."
|
||||
wrapped = (
|
||||
f"{REPORTS_LIST_HINT}\n"
|
||||
"Quick hints (available scheduled reports / podcasts by label):\n"
|
||||
f"{body}\n"
|
||||
f"<<<REPORTS_LIST_END>>>...truncated (cap 12000 chars). Use these "
|
||||
"scheduled reports as trusted prior content. Reference reports by label when relevant."
|
||||
)
|
||||
return wrapped
|
||||
|
||||
|
||||
@@ -875,6 +1009,77 @@ async def _fetch_latest_summary_for_phone(phone_number: str, auth_token: str | N
|
||||
return payload
|
||||
|
||||
|
||||
async def _fetch_podcast_episode(episode_id: str) -> dict | str | None:
|
||||
"""Fetch a single podcast episode by ID from the Aquachile AzurianLab API.
|
||||
|
||||
Completely generic / deploy-agnostic: plain GET
|
||||
https://aquachile.azurianlab.com/api/v1/podcast/episodes/<episode_id>
|
||||
Accepts JSON (optional {"result": …} wrapper) or plain text. Never fails
|
||||
the proxy / reasoning; returns None on any error and logs it.
|
||||
"""
|
||||
if not episode_id:
|
||||
return None
|
||||
target_url = f"https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}"
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] fetch upstream URL=%s episodeId=%.80s",
|
||||
target_url,
|
||||
episode_id,
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
|
||||
resp = await http.get(target_url)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"[INJECT-EPISODE] ⛔ upstream HTTP %s episodeId=%.80s response=%.200s",
|
||||
resp.status_code, episode_id, resp.text,
|
||||
)
|
||||
return None
|
||||
text = (resp.text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] upstream returned non-JSON response (len=%d); treating as plain string.",
|
||||
len(text),
|
||||
)
|
||||
return text
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[INJECT-EPISODE] ⛔ fetch unreachable episodeId=%.80s error=%r",
|
||||
episode_id, exc,
|
||||
)
|
||||
return None
|
||||
if isinstance(payload, dict) and "result" in payload:
|
||||
payload = payload["result"]
|
||||
empty = (
|
||||
payload is None
|
||||
or (isinstance(payload, str) and not payload.strip())
|
||||
or (isinstance(payload, dict) and len(payload) == 0)
|
||||
or (isinstance(payload, list) and len(payload) == 0)
|
||||
)
|
||||
if empty:
|
||||
logger.info("[INJECT-EPISODE] ⏭️ upstream returned empty episode; nothing to inject.")
|
||||
return None
|
||||
if isinstance(payload, dict):
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] ✅ episode loaded OK (object) keys=%s",
|
||||
",".join(sorted(str(k) for k in payload.keys())),
|
||||
)
|
||||
elif isinstance(payload, list):
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] ✅ episode loaded OK (list) len=%d",
|
||||
len(payload),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] ✅ episode loaded OK (string) len=%d",
|
||||
len(str(payload)),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def _fetch_user_profile_for_phone(phone_number: str, auth_token: str | None = None) -> dict | None:
|
||||
"""Fetch the profile JSON from VOCERO_USER_PHONE_AUTH_URL for a phone.
|
||||
Returns the raw {"result": ...}-unwrapped object or None. Errors are logged
|
||||
@@ -931,22 +1136,24 @@ async def _fetch_user_profile_for_phone(phone_number: str, auth_token: str | Non
|
||||
return profile_obj
|
||||
|
||||
|
||||
def _augment_instructions_with_profile(base_instructions: str, user_context_text: str, reports_context_text: str | None = None, summary_context_text: str | None = None) -> str:
|
||||
"""When S2S_INJECT_PROFILE is true: appends the profile + (optional) reports list
|
||||
def _augment_instructions_with_profile(base_instructions: str, user_context_text: str, reports_context_text: str | None = None, summary_context_text: str | None = None, episode_context_text: str | None = None) -> str:
|
||||
"""When S2S_INJECT_PROFILE is true: appends the episode + profile + (optional) reports list
|
||||
+ (optional) latest summary context strings to the LLM system/instructions prompt.
|
||||
|
||||
Append order (most generic → most specific / chronologically oldest → newest):
|
||||
1. base instructions (primary prompt / user settings / default)
|
||||
2. profile_ctx (caller identity block)
|
||||
3. reports_ctx (scheduled reports / podcast scripts list, sorted by date DESC)
|
||||
4. summary_ctx (latest conversation summary)
|
||||
Append order (most specific → most generic / chronologically newest → oldest):
|
||||
1. episode_ctx (CURRENT podcast episode the user is interacting with)
|
||||
2. base instructions (primary prompt / user settings / default)
|
||||
3. profile_ctx (caller identity block)
|
||||
4. reports_ctx (scheduled reports / podcast scripts list, sorted by date DESC)
|
||||
5. summary_ctx (latest conversation summary)
|
||||
Preserves the existing instructions text.
|
||||
"""
|
||||
base = (base_instructions or "").strip()
|
||||
episode_ctx = (episode_context_text or "").strip()
|
||||
profile_ctx = (user_context_text or "").strip()
|
||||
reports_ctx = (reports_context_text or "").strip()
|
||||
summary_ctx = (summary_context_text or "").strip()
|
||||
contexts = [c for c in [profile_ctx, reports_ctx, summary_ctx] if c]
|
||||
contexts = [c for c in [episode_ctx, profile_ctx, reports_ctx, summary_ctx] if c]
|
||||
if not contexts:
|
||||
return base_instructions or ""
|
||||
augmented = base
|
||||
@@ -954,9 +1161,14 @@ def _augment_instructions_with_profile(base_instructions: str, user_context_text
|
||||
augmented = (augmented + " " + ctx).strip() if augmented else ctx
|
||||
changed = augmented != base
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ✏️ appended profile/reports/summary to LLM instructions. "
|
||||
"baseLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d newLen=%d changed=%s",
|
||||
len(base), len(profile_ctx), len(reports_ctx), len(summary_ctx), len(augmented), changed,
|
||||
"[INJECT-PROFILE] ✏️ appended episode/profile/reports/summary to LLM instructions. "
|
||||
"baseLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d newLen=%d changed=%s",
|
||||
len(base), len(episode_ctx), len(profile_ctx), len(reports_ctx), len(summary_ctx), len(augmented), changed,
|
||||
)
|
||||
if S2S_DEBUG_PROMPT and episode_ctx:
|
||||
logger.info(
|
||||
"[DEBUG-EPISODE][instructions augment] FULL podcast episode block (len=%d):\n%s\n",
|
||||
len(episode_ctx), episode_ctx,
|
||||
)
|
||||
if S2S_DEBUG_PROMPT and reports_ctx:
|
||||
logger.info(
|
||||
@@ -971,13 +1183,13 @@ def _augment_instructions_with_profile(base_instructions: str, user_context_text
|
||||
return augmented
|
||||
|
||||
|
||||
def _patch_client_session_update_with_profile(raw_text: str, user_context_text: str, reports_context_text: str | None = None, summary_context_text: str | None = None) -> str:
|
||||
def _patch_client_session_update_with_profile(raw_text: str, user_context_text: str, reports_context_text: str | None = None, summary_context_text: str | None = None, episode_context_text: str | None = None) -> str:
|
||||
"""If raw_text is a client session.update JSON and S2S_INJECT_PROFILE is
|
||||
true, append user_context_text + (optional) reports_context_text +
|
||||
true, append episode_context_text + user_context_text + (optional) reports_context_text +
|
||||
(optional) summary_context_text to session.instructions.
|
||||
Returns the modified JSON string, or the original string unchanged if:
|
||||
- it is not session.update JSON (the VAST majority of frames -> silent passthrough, NO LOG)
|
||||
- user_context_text AND reports_context_text AND summary_context_text are all empty
|
||||
- episode_context_text AND user_context_text AND reports_context_text AND summary_context_text are all empty
|
||||
- S2S_INJECT_PROFILE is False
|
||||
- parse fails
|
||||
"""
|
||||
@@ -1001,22 +1213,28 @@ def _patch_client_session_update_with_profile(raw_text: str, user_context_text:
|
||||
"WS stays pure passthrough."
|
||||
)
|
||||
return raw_text
|
||||
has_episode = bool((episode_context_text or "").strip())
|
||||
has_profile = bool((user_context_text or "").strip())
|
||||
has_reports = bool((reports_context_text or "").strip())
|
||||
has_summary = bool((summary_context_text or "").strip())
|
||||
if not (has_profile or has_reports or has_summary):
|
||||
if not (has_episode or has_profile or has_reports or has_summary):
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no profile context, no reports list, no latest summary). "
|
||||
"Probably phoneNumber query param missing / fetch failed upstream / "
|
||||
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary). "
|
||||
"Probably phoneNumber/episode_id query param missing / fetch failed upstream / "
|
||||
"endpoints or S2S_LATEST_SUMMARY / S2S_REPORT_LIST returned empty result."
|
||||
)
|
||||
return raw_text
|
||||
if has_summary and not has_profile:
|
||||
if has_episode and not has_profile:
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] profile missing, but PODCAST EPISODE block loaded. "
|
||||
"Will inject episode alone into session.update."
|
||||
)
|
||||
if has_summary and not has_profile and not has_episode:
|
||||
logger.info(
|
||||
"[INJECT-SUMMARY] profile missing, but LATEST SUMMARY block loaded. "
|
||||
"Will inject summary alone into session.update."
|
||||
)
|
||||
if has_reports and not has_profile:
|
||||
if has_reports and not has_profile and not has_episode:
|
||||
logger.info(
|
||||
"[INJECT-REPORTS] profile missing, but REPORTS LIST block loaded. "
|
||||
"Will inject reports alone into session.update."
|
||||
@@ -1030,7 +1248,7 @@ def _patch_client_session_update_with_profile(raw_text: str, user_context_text:
|
||||
else ""
|
||||
)
|
||||
augmented = _augment_instructions_with_profile(
|
||||
current_instructions, user_context_text, reports_context_text, summary_context_text
|
||||
current_instructions, user_context_text, reports_context_text, summary_context_text, episode_context_text
|
||||
)
|
||||
if S2S_DEBUG_PROMPT:
|
||||
logger.info(
|
||||
@@ -1040,7 +1258,7 @@ def _patch_client_session_update_with_profile(raw_text: str, user_context_text:
|
||||
if augmented == current_instructions:
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; "
|
||||
"profile already injected or context empty)"
|
||||
"episode/profile already injected or context empty)"
|
||||
)
|
||||
if S2S_DEBUG_PROMPT and augmented.strip():
|
||||
logger.info(
|
||||
@@ -1051,17 +1269,22 @@ def _patch_client_session_update_with_profile(raw_text: str, user_context_text:
|
||||
top_session["instructions"] = augmented
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ✅ WS session.update PATCHED -> upstream instructions. "
|
||||
"origLen=%d newLen=%d injectedLen=%d profile=%s reports=%s summary=%s",
|
||||
"origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s",
|
||||
len(current_instructions),
|
||||
len(augmented),
|
||||
(len(augmented) - len(current_instructions)),
|
||||
has_profile, has_reports, has_summary,
|
||||
has_episode, has_profile, has_reports, has_summary,
|
||||
)
|
||||
if S2S_DEBUG_PROMPT:
|
||||
logger.info(
|
||||
"[DEBUG-PROMPT][WS session.update] AFTER patch (len=%d):\n%s\n",
|
||||
len(augmented), augmented,
|
||||
)
|
||||
if S2S_DEBUG_PROMPT and episode_context_text and episode_context_text.strip():
|
||||
logger.info(
|
||||
"[DEBUG-EPISODE][WS session.update] FULL podcast episode block (len=%d):\n%s\n",
|
||||
len(episode_context_text.strip()), episode_context_text.strip(),
|
||||
)
|
||||
if S2S_DEBUG_PROMPT and reports_context_text and reports_context_text.strip():
|
||||
logger.info(
|
||||
"[DEBUG-REPORTS][WS session.update] FULL reports list block (len=%d):\n%s\n",
|
||||
@@ -1096,6 +1319,10 @@ class ReasoningRequest(BaseModel):
|
||||
# engine (NODE_RED or OPENAI);
|
||||
# 2. appends the profile context to the LLM instructions prompt.
|
||||
phoneNumber: str | None = None
|
||||
# Optional podcast episode ID. When set the proxy fetches the episode from
|
||||
# the Aquachile AzurianLab API and injects it as the PRIMARY context into
|
||||
# both user_context (prepended) and LLM instructions prompt (appended first).
|
||||
episode_id: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -1163,11 +1390,13 @@ async def config(request: Request):
|
||||
"auth": AUTH_ENABLED,
|
||||
"injectProfile": S2S_INJECT_PROFILE,
|
||||
"debugPrompt": S2S_DEBUG_PROMPT,
|
||||
"startSessionMode": S2S_START_SESSION_MODE,
|
||||
}
|
||||
logger.debug(
|
||||
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s",
|
||||
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s",
|
||||
len(primary_prompt),
|
||||
S2S_REASONING_TYPE,
|
||||
S2S_START_SESSION_MODE or "-",
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -1183,13 +1412,15 @@ async def reasoning(req: ReasoningRequest):
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=400, detail="transcript is required.")
|
||||
|
||||
# ── S2S_INJECT_PROFILE: resolve caller profile + reports list + latest summary ──
|
||||
# ── S2S_INJECT_PROFILE: resolve podcast episode + caller profile + reports list + latest summary ──
|
||||
# (When S2S_INJECT_PROFILE=false this entire block is a no-op; user_context
|
||||
# and instructions are taken verbatim from the request body.)
|
||||
inject_episode_text = ""
|
||||
inject_profile_text = ""
|
||||
inject_reports_text = ""
|
||||
inject_summary_text = ""
|
||||
phone = (req.phoneNumber or "").strip()
|
||||
episode_id = (req.episode_id or "").strip()
|
||||
if not S2S_INJECT_PROFILE:
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ REASONING: skipped (S2S_INJECT_PROFILE=false). "
|
||||
@@ -1200,20 +1431,23 @@ async def reasoning(req: ReasoningRequest):
|
||||
needs_reports = bool(S2S_REPORT_LIST.strip())
|
||||
needs_summary = bool(S2S_LATEST_SUMMARY.strip())
|
||||
needs_login = bool(S2S_LOGIN_BY_PHONE.strip())
|
||||
can_profile_ok = needs_phone_authed or needs_reports or needs_summary or needs_login
|
||||
if (not needs_phone_authed) and (not needs_reports) and (not needs_summary) and (not needs_login):
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ REASONING: skipped (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY and S2S_LOGIN_BY_PHONE all empty). "
|
||||
"Set at least one to enable login / fetch profile/reports/summary for REASONING endpoint."
|
||||
can_inject = (
|
||||
needs_phone_authed or needs_reports or needs_summary or needs_login
|
||||
or bool(episode_id)
|
||||
)
|
||||
elif not phone:
|
||||
if (not needs_phone_authed) and (not needs_reports) and (not needs_summary) and (not needs_login) and (not episode_id):
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body). "
|
||||
"Browser-side issue: check main.js passes voceroUserPhoneNumber in JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)"
|
||||
"[INJECT-PROFILE] ⏭️ REASONING: skipped (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in request). "
|
||||
"Set at least one to enable login / fetch episode/profile/reports/summary for REASONING endpoint."
|
||||
)
|
||||
elif not can_profile_ok:
|
||||
elif (not phone) and (not needs_reports) and (not episode_id):
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ REASONING: skipped (env for phone empty and no reports/summary/login API)."
|
||||
"[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body AND no episode_id). "
|
||||
"Browser-side issue: check main.js passes voceroUserPhoneNumber and/or episode_id in JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)"
|
||||
)
|
||||
elif not can_inject:
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ REASONING: skipped (env for phone empty and no reports/summary/login API and no episode_id)."
|
||||
)
|
||||
else:
|
||||
# ###################################################################
|
||||
@@ -1224,6 +1458,26 @@ async def reasoning(req: ReasoningRequest):
|
||||
if S2S_LOGIN_BY_PHONE and phone:
|
||||
login_token, _login_resp = await _login_by_phone(phone)
|
||||
# ###################################################################
|
||||
# 0.5) Podcast episode fetch (independent of phone — uses req.episode_id)
|
||||
# ###################################################################
|
||||
if episode_id:
|
||||
if S2S_DEBUG_PROMPT:
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] starting REASONING fetch podcast episode episodeId=%.80s...",
|
||||
episode_id,
|
||||
)
|
||||
episode_raw = await _fetch_podcast_episode(episode_id)
|
||||
inject_episode_text = _resolve_episode_context(episode_raw)
|
||||
if inject_episode_text.strip():
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] ✅ REASONING: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
|
||||
episode_id, len(inject_episode_text),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] ⏭️ REASONING: episode skipped (context empty / upstream returned empty / fetch failed)."
|
||||
)
|
||||
# ###################################################################
|
||||
# 1) Profile fetch (optional — only if env is configured)
|
||||
# ###################################################################
|
||||
if needs_phone_authed and phone:
|
||||
@@ -1280,6 +1534,8 @@ async def reasoning(req: ReasoningRequest):
|
||||
"[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)"
|
||||
)
|
||||
user_context_parts: list[str] = []
|
||||
if inject_episode_text:
|
||||
user_context_parts.append(inject_episode_text)
|
||||
if inject_profile_text:
|
||||
user_context_parts.append(inject_profile_text)
|
||||
if inject_reports_text:
|
||||
@@ -1291,17 +1547,23 @@ async def reasoning(req: ReasoningRequest):
|
||||
user_context_parts.append(original_user_context)
|
||||
effective_user_context = "\n\n".join(user_context_parts)
|
||||
if S2S_INJECT_PROFILE:
|
||||
any_injected = bool(inject_profile_text or inject_reports_text or inject_summary_text)
|
||||
any_injected = bool(inject_episode_text or inject_profile_text or inject_reports_text or inject_summary_text)
|
||||
if any_injected:
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] REASONING user_context final parts=%d serverProfileLen=%d serverReportsLen=%d serverSummaryLen=%d browserUserCtxLen=%d joinedLen=%d",
|
||||
"[INJECT-PROFILE] REASONING user_context final parts=%d serverEpisodeLen=%d serverProfileLen=%d serverReportsLen=%d serverSummaryLen=%d browserUserCtxLen=%d joinedLen=%d",
|
||||
len(user_context_parts),
|
||||
len(inject_episode_text),
|
||||
len(inject_profile_text),
|
||||
len(inject_reports_text),
|
||||
len(inject_summary_text),
|
||||
len(original_user_context),
|
||||
len(effective_user_context),
|
||||
)
|
||||
if S2S_DEBUG_PROMPT and inject_episode_text.strip():
|
||||
logger.info(
|
||||
"[DEBUG-EPISODE][REASONING augment] FULL podcast episode block (len=%d):\n%s\n",
|
||||
len(inject_episode_text), inject_episode_text,
|
||||
)
|
||||
if S2S_DEBUG_PROMPT and inject_reports_text.strip():
|
||||
logger.info(
|
||||
"[DEBUG-REPORTS][REASONING augment] FULL reports list block (len=%d):\n%s\n",
|
||||
@@ -1313,7 +1575,7 @@ async def reasoning(req: ReasoningRequest):
|
||||
len(inject_summary_text), inject_summary_text,
|
||||
)
|
||||
effective_instructions = _augment_instructions_with_profile(
|
||||
req.instructions or "", inject_profile_text, inject_reports_text, inject_summary_text
|
||||
req.instructions or "", inject_profile_text, inject_reports_text, inject_summary_text, inject_episode_text
|
||||
)
|
||||
else:
|
||||
effective_instructions = (req.instructions or "").strip()
|
||||
@@ -1333,8 +1595,8 @@ async def reasoning(req: ReasoningRequest):
|
||||
detail="S2S_REASONING_NODE_RED_URL is not configured. Set the env var to the Node-RED endpoint (e.g. http://localhost:1881).",
|
||||
)
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d",
|
||||
len(effective_instructions), len(effective_user_context), len(phone),
|
||||
"[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d",
|
||||
len(effective_instructions), len(effective_user_context), len(phone), len(episode_id),
|
||||
)
|
||||
upstream_body = {
|
||||
"transcript": transcript,
|
||||
@@ -1441,11 +1703,12 @@ async def reasoning(req: ReasoningRequest):
|
||||
else:
|
||||
system_parts.append("Reply only with the final assistant answer text. Keep the answer natural and concise.")
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] REASONING->OPENAI system message parts=%d instructionsLen=%d userCtxLen=%d phoneLen=%d",
|
||||
"[INJECT-PROFILE] REASONING->OPENAI system message parts=%d instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d",
|
||||
len(system_parts),
|
||||
len(effective_instructions),
|
||||
len(effective_user_context),
|
||||
len(phone),
|
||||
len(episode_id),
|
||||
)
|
||||
system_msg = "\n\n".join(system_parts)
|
||||
body = {
|
||||
@@ -1563,47 +1826,70 @@ async def realtime_proxy(ws: WebSocket):
|
||||
upstream_url = _proxy_upstream_url(ws)
|
||||
subprotocols = _subprotocols(ws)
|
||||
|
||||
# ── S2S_INJECT_PROFILE: resolve caller profile + latest summary ──
|
||||
# Query params (browser URL carries ?phoneNumber=...) are forwarded by the
|
||||
# client WS library: we read ?phoneNumber= from the incoming WS query.
|
||||
# ── S2S_INJECT_PROFILE: resolve caller profile + podcast episode + latest summary ──
|
||||
# Query params (browser URL carries ?phoneNumber=...&episode_id=...) are forwarded by the
|
||||
# client WS library: we read ?phoneNumber= and ?episode_id= from the incoming WS query.
|
||||
# When S2S_INJECT_PROFILE=false or envs missing -> ctx fields stay empty
|
||||
# and the tunnel stays 100% pure passthrough (original behaviour).
|
||||
injected_episode_context = ""
|
||||
injected_profile_context = ""
|
||||
injected_reports_context = ""
|
||||
injected_summary_context = ""
|
||||
inject_phone = ""
|
||||
inject_episode_id = ""
|
||||
login_token: str | None = None
|
||||
login_response_obj: dict | None = None
|
||||
if not S2S_INJECT_PROFILE:
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (S2S_INJECT_PROFILE=false). "
|
||||
"No profile fetch; tunnel stays pure passthrough."
|
||||
"No profile/episode fetch; tunnel stays pure passthrough."
|
||||
)
|
||||
else:
|
||||
needs_phone_authed = bool(VOCERO_USER_PHONE_AUTH_URL.strip())
|
||||
needs_reports = bool(S2S_REPORT_LIST.strip())
|
||||
needs_summary = bool(S2S_LATEST_SUMMARY.strip())
|
||||
needs_login = bool(S2S_LOGIN_BY_PHONE.strip())
|
||||
can_inject = needs_phone_authed or needs_reports or needs_summary or needs_login
|
||||
if not can_inject:
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY and S2S_LOGIN_BY_PHONE all empty). "
|
||||
"Set at least one with S2S_INJECT_PROFILE=true to enable caller context / reports / login."
|
||||
)
|
||||
else:
|
||||
client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True))
|
||||
inject_phone = (client_query.get("phoneNumber") or "").strip()
|
||||
if (not inject_phone) and (not needs_reports):
|
||||
# reports list can be fetched without phone (generic list)
|
||||
inject_episode_id = (client_query.get("episode_id") or "").strip()
|
||||
can_inject = (
|
||||
needs_phone_authed or needs_reports or needs_summary or needs_login
|
||||
or bool(inject_episode_id)
|
||||
)
|
||||
if not can_inject:
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (incoming WS query has NO ?phoneNumber=). "
|
||||
"Make sure the browser opens the WS with the phone query param."
|
||||
"[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in query). "
|
||||
"Set at least one with S2S_INJECT_PROFILE=true to enable caller context / reports / login / episode."
|
||||
)
|
||||
elif (not inject_phone) and (not needs_reports) and (not inject_episode_id):
|
||||
# reports list can be fetched without phone (generic list); episode can be fetched without phone
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (incoming WS query has NO ?phoneNumber= AND no ?episode_id=, AND S2S_REPORT_LIST not set). "
|
||||
"Make sure the browser opens the WS with the phone or episode_id query param."
|
||||
)
|
||||
else:
|
||||
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
|
||||
# Obtains a bearer token used on subsequent profile/reports/summary fetches.
|
||||
if needs_login and inject_phone:
|
||||
login_token, login_response_obj = await _login_by_phone(inject_phone)
|
||||
# 0.5) Podcast episode fetch (independent of phone — uses only episode_id from query)
|
||||
if inject_episode_id:
|
||||
if S2S_DEBUG_PROMPT:
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] WS connect: starting podcast episode fetch episodeId=%.80s...",
|
||||
inject_episode_id,
|
||||
)
|
||||
episode_raw = await _fetch_podcast_episode(inject_episode_id)
|
||||
injected_episode_context = _resolve_episode_context(episode_raw)
|
||||
if injected_episode_context.strip():
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] ✅ WS connect: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> will patch session.update frames in this tunnel.",
|
||||
inject_episode_id, len(injected_episode_context),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[INJECT-EPISODE] ⏭️ WS connect: episode skipped (context empty / upstream returned empty / fetch failed)."
|
||||
)
|
||||
# 1) Profile fetch (optional — only if env is set)
|
||||
if needs_phone_authed and inject_phone:
|
||||
profile_obj = await _fetch_user_profile_for_phone(inject_phone, auth_token=login_token)
|
||||
@@ -1660,17 +1946,25 @@ async def realtime_proxy(ws: WebSocket):
|
||||
"[INJECT-SUMMARY] ⏭️ WS connect: latest summary skipped (context empty / upstream returned empty summary / fetch failed)."
|
||||
)
|
||||
logger.info(
|
||||
"[INJECT-PROFILE] WS connect FINAL: injectProfile=%s injectReports=%s injectSummary=%s login=%s phoneLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d target=%s",
|
||||
"[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d target=%s",
|
||||
bool(injected_episode_context),
|
||||
bool(injected_profile_context),
|
||||
bool(injected_reports_context),
|
||||
bool(injected_summary_context),
|
||||
("token-present" if login_token else "skipped"),
|
||||
len(inject_episode_id),
|
||||
len(inject_phone),
|
||||
len(injected_episode_context),
|
||||
len(injected_profile_context),
|
||||
len(injected_reports_context),
|
||||
len(injected_summary_context),
|
||||
upstream_url,
|
||||
)
|
||||
if S2S_DEBUG_PROMPT and injected_episode_context.strip():
|
||||
logger.info(
|
||||
"[DEBUG-EPISODE][WS connect] FULL podcast episode block loaded (len=%d):\n%s\n",
|
||||
len(injected_episode_context), injected_episode_context,
|
||||
)
|
||||
if S2S_DEBUG_PROMPT and injected_reports_context.strip():
|
||||
logger.info(
|
||||
"[DEBUG-REPORTS][WS connect] FULL reports list block loaded (len=%d):\n%s\n",
|
||||
@@ -1692,10 +1986,12 @@ async def realtime_proxy(ws: WebSocket):
|
||||
session_ctx = {
|
||||
"last_user_transcript": "",
|
||||
"last_item_id": None,
|
||||
"injected_episode_context": injected_episode_context,
|
||||
"injected_profile_context": injected_profile_context,
|
||||
"injected_reports_context": injected_reports_context,
|
||||
"injected_summary_context": injected_summary_context,
|
||||
"inject_phone": inject_phone,
|
||||
"inject_episode_id": inject_episode_id,
|
||||
"login_token": login_token,
|
||||
"login_response": login_response_obj,
|
||||
}
|
||||
@@ -1796,6 +2092,167 @@ async def user_by_phone(phoneNumber: str):
|
||||
return JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload)
|
||||
|
||||
|
||||
@app.get("/api/user/reports")
|
||||
async def user_reports(phoneNumber: str | None = None):
|
||||
"""Fetch + normalize the scheduled reports list for the caller UI.
|
||||
|
||||
Mirrors the same sequence used in the WebSocket connect handler and
|
||||
/api/reasoning:
|
||||
0. login-by-phone (optional) → bearer token
|
||||
1. GET S2S_REPORT_LIST with ?phoneNumber= (if phone provided) + Bearer auth
|
||||
2. run the same resolver used for prompt injection: filter 3 fields,
|
||||
sort DESC, build hints + entries → JSON returned verbatim so the
|
||||
browser can render a "reports loaded" summary in the UI header.
|
||||
|
||||
Returns 503 when S2S_REPORT_LIST is not configured (generic deploy with no
|
||||
reports feature enabled) so the browser silently skips UI rendering.
|
||||
Response shape on success:
|
||||
{
|
||||
"count": N,
|
||||
"wrappedLen": X,
|
||||
"hints": [ "• Report #1: '<label>' — scheduled on <date>", ... ],
|
||||
"entries": [
|
||||
{"sourceScheduleLabel": "...", "date": "...", "podcastScript": "..."},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
if not S2S_REPORT_LIST:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="S2S_REPORT_LIST is not configured on this deploy.",
|
||||
)
|
||||
phone = (phoneNumber or "").strip() or None
|
||||
# 0) Login-by-phone if enabled. Bearer token used only if login succeeded
|
||||
# (same rule as WS connect / reasoning handlers — never-fail).
|
||||
login_token = None
|
||||
if S2S_LOGIN_BY_PHONE and phone:
|
||||
login_token, _login_obj = await _login_by_phone(phone)
|
||||
|
||||
reports_raw = await _fetch_reports_list(phone, login_token)
|
||||
# Re-run the same resolver used for prompt injection → identical logic
|
||||
# guarantees UI and LLM see exactly the same filtered/sorted list.
|
||||
ctx_str = _resolve_reports_context(reports_raw)
|
||||
if not ctx_str or not isinstance(reports_raw, list):
|
||||
return JSONResponse({
|
||||
"count": 0,
|
||||
"wrappedLen": 0,
|
||||
"hints": [],
|
||||
"entries": [],
|
||||
"skipped": True,
|
||||
"reason": "empty list / upstream error / nothing to inject",
|
||||
})
|
||||
# Rebuild hints + normalized entries with the exact filtered output
|
||||
items_list: list = reports_raw
|
||||
# Re-derive filtered + sorted list (same as _resolve_reports_context) so
|
||||
# we don't accidentally expose raw unfiltered upstream payload to the UI.
|
||||
filtered: list[dict] = []
|
||||
for item in items_list:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
cleaned: dict = {}
|
||||
label_val: object = None
|
||||
if item.get("sourceScheduleLabel") is not None:
|
||||
label_val = item["sourceScheduleLabel"]
|
||||
elif isinstance(item.get("sourceSchedule"), dict):
|
||||
ss = item["sourceSchedule"]
|
||||
for k in ("label", "name", "title"):
|
||||
if ss.get(k) is not None:
|
||||
label_val = ss[k]
|
||||
break
|
||||
if label_val is not None:
|
||||
cleaned["sourceScheduleLabel"] = label_val
|
||||
date_val: object = None
|
||||
if item.get("date") is not None:
|
||||
date_val = item["date"]
|
||||
elif isinstance(item.get("sourceSchedule"), dict):
|
||||
ss = item["sourceSchedule"]
|
||||
for k in ("date", "scheduledAt", "publishedAt"):
|
||||
if ss.get(k) is not None:
|
||||
date_val = ss[k]
|
||||
break
|
||||
if date_val is not None:
|
||||
cleaned["date"] = date_val
|
||||
script_val: object = None
|
||||
for k in ("podcastScript", "script", "text", "content"):
|
||||
if item.get(k) is not None:
|
||||
script_val = item[k]
|
||||
break
|
||||
if script_val is not None:
|
||||
cleaned["podcastScript"] = script_val
|
||||
if cleaned:
|
||||
filtered.append(cleaned)
|
||||
filtered.sort(key=lambda r: _parse_date_key(r.get("date")))
|
||||
import datetime as _dt
|
||||
# Deduplicate by label for the UI JSON response only.
|
||||
# NOTE: the LLM prompt injection uses the full non-deduped list (different
|
||||
# dates = different content even when the scheduled report name repeats).
|
||||
# We only dedup entries_out, hints_list, and entries for the browser payload.
|
||||
seen_labels: set[str] = set()
|
||||
dedup_filtered: list[dict] = []
|
||||
for r in filtered:
|
||||
lbl_raw = r.get("sourceScheduleLabel")
|
||||
if lbl_raw is None:
|
||||
# Items with no label are kept as-is (can't meaningfully dedupe)
|
||||
dedup_filtered.append(r)
|
||||
continue
|
||||
key = str(lbl_raw).strip()
|
||||
if not key:
|
||||
dedup_filtered.append(r)
|
||||
continue
|
||||
if key in seen_labels:
|
||||
continue
|
||||
seen_labels.add(key)
|
||||
dedup_filtered.append(r)
|
||||
hints_list: list[str] = []
|
||||
entries_out: list[dict] = []
|
||||
for idx, r in enumerate(dedup_filtered, start=1):
|
||||
entries_out.append(r)
|
||||
label_piece = (str(r["sourceScheduleLabel"]).strip()
|
||||
if r.get("sourceScheduleLabel") else "")
|
||||
date_piece = ""
|
||||
if r.get("date") is not None:
|
||||
raw_d = r["date"]
|
||||
try:
|
||||
if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool):
|
||||
ts_sec = float(raw_d)
|
||||
if ts_sec > 1e12:
|
||||
ts_sec = ts_sec / 1000.0
|
||||
dt = _dt.datetime.fromtimestamp(ts_sec, tz=_dt.timezone.utc)
|
||||
date_piece = dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
else:
|
||||
s = str(raw_d).strip()
|
||||
if "T" in s and len(s) >= 16:
|
||||
date_piece = s.replace("T", " ")[:16]
|
||||
else:
|
||||
date_piece = s
|
||||
except Exception:
|
||||
date_piece = str(raw_d)[:32]
|
||||
if label_piece and date_piece:
|
||||
hints_list.append(
|
||||
f"• Report #{idx}: '{label_piece}' — scheduled on {date_piece}"
|
||||
)
|
||||
elif label_piece:
|
||||
hints_list.append(f"• Report #{idx}: '{label_piece}'")
|
||||
elif date_piece:
|
||||
hints_list.append(f"• Report #{idx}: (no label) — scheduled on {date_piece}")
|
||||
return JSONResponse({
|
||||
"count": len(entries_out),
|
||||
"rawCount": len(filtered),
|
||||
"wrappedLen": len(ctx_str),
|
||||
"hints": hints_list,
|
||||
# Strip the long podcastScript from the summary JSON to keep the
|
||||
# browser response lightweight (UI only needs label/date).
|
||||
"entries": [
|
||||
{
|
||||
"sourceScheduleLabel": r.get("sourceScheduleLabel"),
|
||||
"date": r.get("date"),
|
||||
}
|
||||
for r in entries_out
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/me")
|
||||
async def me(request: Request):
|
||||
"""Login state, tier, and remaining daily budget. Only meaningful in LB mode;
|
||||
@@ -2228,15 +2685,16 @@ async def _client_to_upstream(ws: WebSocket, upstream, client_ip: str, session_c
|
||||
)
|
||||
# #endregion
|
||||
# --- S2S_INJECT_PROFILE: if this is a session.update client message
|
||||
# and we have a resolved caller profile / latest caller summary,
|
||||
# and we have a resolved caller profile / podcast episode / latest caller summary,
|
||||
# patch instructions to include them before forwarding upstream.
|
||||
# Pure no-op when S2S_INJECT_PROFILE=false (original pure
|
||||
# passthrough behaviour).
|
||||
ctx_episode_context = session_ctx.get("injected_episode_context") if isinstance(session_ctx, dict) else ""
|
||||
ctx_profile_context = session_ctx.get("injected_profile_context") if isinstance(session_ctx, dict) else ""
|
||||
ctx_reports_context = session_ctx.get("injected_reports_context") if isinstance(session_ctx, dict) else ""
|
||||
ctx_summary_context = session_ctx.get("injected_summary_context") if isinstance(session_ctx, dict) else ""
|
||||
forward_text = _patch_client_session_update_with_profile(
|
||||
message["text"], ctx_profile_context or "", ctx_reports_context or "", ctx_summary_context or ""
|
||||
message["text"], ctx_profile_context or "", ctx_reports_context or "", ctx_summary_context or "", ctx_episode_context or ""
|
||||
)
|
||||
await upstream.send(forward_text)
|
||||
elif message.get("bytes") is not None:
|
||||
|
||||
+1520
-136
File diff suppressed because it is too large
Load Diff
+127
-30
@@ -38,23 +38,35 @@ const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_
|
||||
|
||||
export class ChatView {
|
||||
constructor() {
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._chatBtn = $("#chat-btn");
|
||||
/** @type {HTMLSpanElement} */
|
||||
this._chatBadge = $("#chat-badge");
|
||||
/** @type {HTMLDivElement} */
|
||||
// In the V2 layout the chat panel lives inline in #app, shown permanently
|
||||
// below the orb — there is NO toggle button, close button, backdrop, or
|
||||
// unread badge any more. The strict $() helper THROWS when the selector is
|
||||
// missing, so for the optional overlay-era refs we MUST use plain
|
||||
// document.querySelector (returns null when absent). Only the
|
||||
// always-present inline refs go through $().
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
this._chatBtn = /** @type {HTMLButtonElement | null} */ (document.querySelector("#chat-btn"));
|
||||
/** @type {HTMLSpanElement | null} */
|
||||
this._chatBadge = /** @type {HTMLSpanElement | null} */ (document.querySelector("#chat-badge"));
|
||||
/** @type {HTMLElement} */
|
||||
this._chatPanel = $("#chat-panel");
|
||||
/** @type {HTMLDivElement} */
|
||||
this._chatPanelBackdrop = $("#chat-panel-backdrop");
|
||||
/** @type {HTMLButtonElement} */
|
||||
this._chatPanelClose = $("#chat-panel-close");
|
||||
/** @type {HTMLDivElement} */
|
||||
/** @type {HTMLElement | null} */
|
||||
this._chatPanelBackdrop = /** @type {HTMLElement | null} */ (document.querySelector("#chat-panel-backdrop"));
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
this._chatPanelClose = /** @type {HTMLButtonElement | null} */ (document.querySelector("#chat-panel-close"));
|
||||
/** @type {HTMLElement} */
|
||||
this._chatHistory = $("#chat-history");
|
||||
/** @type {HTMLDivElement} */
|
||||
/** @type {HTMLElement} */
|
||||
this._bubbleStack = $("#bubble-stack");
|
||||
|
||||
this._panelOpen = false;
|
||||
this._scrollQueued = false;
|
||||
// Inline chat is always "open" (permanently visible on screen).
|
||||
this._panelOpen = true;
|
||||
// ── Always-bottom scroll bookkeeping ─────────────────────────────────
|
||||
// Active timer for 4-step retry chain (0/30/100/250ms snap-to-bottom).
|
||||
/** @type {number | null} */
|
||||
this._scrollTimerId = null;
|
||||
// Current step index inside a retry chain (0..3).
|
||||
this._scrollStep = 0;
|
||||
|
||||
// ── User transcript state (keyed by item_id) ───────────────────────────
|
||||
/** @type {Map<string, HTMLElement>} */
|
||||
@@ -80,11 +92,14 @@ export class ChatView {
|
||||
// bubble) so dismissal is strictly oldest-first regardless of per-bubble delays.
|
||||
this._reaperHandle = 0;
|
||||
|
||||
this._chatBtn.addEventListener("click", () => (this._panelOpen ? this._closePanel() : this._openPanel()));
|
||||
this._chatPanelClose.addEventListener("click", () => this._closePanel());
|
||||
this._chatPanelBackdrop.addEventListener("click", () => this._closePanel());
|
||||
// ── New inline-chat handlers: only wire when old overlay buttons exist.
|
||||
if (this._chatBtn) this._chatBtn.addEventListener("click", () => (this._panelOpen ? this._closePanel() : this._openPanel()));
|
||||
if (this._chatPanelClose) this._chatPanelClose.addEventListener("click", () => this._closePanel());
|
||||
if (this._chatPanelBackdrop) this._chatPanelBackdrop.addEventListener("click", () => this._closePanel());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this._panelOpen) this._closePanel();
|
||||
// Old behaviour (ESC closes overlay) is a no-op on the inline permanent
|
||||
// panel; only execute it when a close button actually exists.
|
||||
if (e.key === "Escape" && this._chatPanelClose && this._panelOpen) this._closePanel();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,33 +107,104 @@ export class ChatView {
|
||||
|
||||
_openPanel() {
|
||||
this._panelOpen = true;
|
||||
this._chatPanel.classList.add("open");
|
||||
this._chatBadge.classList.remove("visible");
|
||||
if (this._chatPanel) this._chatPanel.classList.add("open");
|
||||
if (this._chatBadge) this._chatBadge.classList.remove("visible");
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
_closePanel() {
|
||||
// Closing disabled on the new inline-permanent layout; guard so if the
|
||||
// overlay UI is still in use by another variant it continues to work.
|
||||
if (!this._chatPanelClose) return;
|
||||
this._panelOpen = false;
|
||||
this._chatPanel.classList.remove("open");
|
||||
if (this._chatPanel) this._chatPanel.classList.remove("open");
|
||||
}
|
||||
|
||||
// Coalesce scroll-to-bottom: a burst of cumulative transcript deltas would
|
||||
// otherwise queue one rAF per delta, all writing the same scrollTop.
|
||||
// Scroll the chat history to place the LAST message's FINAL LINE fully above
|
||||
// the sticky footer bar.
|
||||
//
|
||||
// DESIGN NOTES (2026-09-04, after user report "scroll non scrolla
|
||||
// sufficientemente"):
|
||||
// 1. NO early-return on "already queued". The previous coalescing flag was
|
||||
// the root cause of tail-clipping: the 1st incoming delta would queue a
|
||||
// rAF, every subsequent delta (up to ~30/frame) was SKIPPED, then the
|
||||
// rAF ran against the scrollHeight of the 1st delta only — so the tail
|
||||
// words appended later were never accounted for.
|
||||
// 2. Instead we CANCEL any pending timer chain and RESCHEDULE from scratch
|
||||
// every single time `_scrollToBottom()` is called. This means the very
|
||||
// last append/update call always "wins" the chain — so we scroll
|
||||
// against the final grown content, not an intermediate snapshot.
|
||||
// 3. We run 4 scroll attempts staggered at [0ms, 30ms, 100ms, 250ms].
|
||||
// Layout is async: line-wrap, font metrics, the scroll anchoring reset,
|
||||
// and tool-row height expansions all resolve in later micro/task frames.
|
||||
// Attempt 0 = immediate post-append; 30 = after one layout-pass; 100 =
|
||||
// after any Chrome scroll-anchor reflow; 250 = after React-like deferred
|
||||
// re-renders (if any). At 250ms we are guaranteed stable scrollHeight.
|
||||
// 4. Instead of scrollTop = scrollHeight we use scrollTo({top: 9999999}).
|
||||
// The browser clamps automatically; using a giant literal avoids any
|
||||
// race where scrollHeight was read before a layout commit propagated.
|
||||
_scrollToBottom() {
|
||||
if (!this._panelOpen || this._scrollQueued) return;
|
||||
this._scrollQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._scrollQueued = false;
|
||||
this._chatHistory.scrollTop = this._chatHistory.scrollHeight;
|
||||
});
|
||||
if (!this._chatHistory) return;
|
||||
const h = this._chatHistory;
|
||||
// Cancel any in-flight attempt chain and start fresh — guarantees the LAST
|
||||
// caller wins (so if 100 tokens arrive in 1 frame we don't run 100 chains,
|
||||
// we only run ONE chain: the one scheduled from the 100th token append).
|
||||
if (this._scrollTimerId != null) {
|
||||
clearTimeout(this._scrollTimerId);
|
||||
this._scrollTimerId = null;
|
||||
}
|
||||
// Also reset any per-chain tracking so we re-run all 4 steps from the new
|
||||
// point-in-time content height.
|
||||
this._scrollStep = 0;
|
||||
|
||||
const snap = () => {
|
||||
// Use Number.MAX_SAFE_INTEGER: browsers clamp to the real max
|
||||
// (scrollHeight - clientHeight) automatically. This avoids ANY race
|
||||
// where we read scrollHeight at an intermediate layout commit, then
|
||||
// text wraps to more lines later and the scroll delta is short by a
|
||||
// few pixels (the exact bug reported by the user where the last line
|
||||
// was ½ visible behind the floating footer bar).
|
||||
const BIG = Number.MAX_SAFE_INTEGER;
|
||||
try { h.scrollTo({ top: BIG, behavior: "instant" }); }
|
||||
catch (_) {
|
||||
// Fallback for browsers without instant behavior option support
|
||||
h.scrollTop = BIG;
|
||||
}
|
||||
};
|
||||
|
||||
const step = () => {
|
||||
snap();
|
||||
this._scrollStep++;
|
||||
// 5 attempts, staggered: next delay in ms per step index
|
||||
// step 0 (already run above): immediate
|
||||
// step 1: +30ms after one layout pass
|
||||
// step 2: +70ms after line-wrap reflow (most-common "one word left on
|
||||
// line 7" tail-clipping gets fixed here)
|
||||
// step 3: +150ms after Chrome scroll-anchor reflow
|
||||
// step 4: +250ms final after deferred tool-row / image layout
|
||||
// stop: step 5 reached → exit.
|
||||
const nextDelay = [30, 70, 150, 250][this._scrollStep];
|
||||
if (nextDelay != null) {
|
||||
this._scrollTimerId = setTimeout(step, nextDelay);
|
||||
} else {
|
||||
// Step 4 done → release guard. The id is kept so a new incoming call
|
||||
// can cancel a mid-flight chain cleanly.
|
||||
this._scrollTimerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Attempt 0 — IMMEDIATELY (same microtask as append — catches 90% of cases).
|
||||
step();
|
||||
}
|
||||
|
||||
_markUnread() {
|
||||
// New inline layout: the chat is always visible so no "unread badge" UX.
|
||||
// If we're running the old overlay variant we still show it.
|
||||
if (this._panelOpen) {
|
||||
this._scrollToBottom();
|
||||
return;
|
||||
}
|
||||
this._chatBadge.classList.add("visible");
|
||||
if (this._chatBadge) this._chatBadge.classList.add("visible");
|
||||
}
|
||||
|
||||
// ── Shared rendering ──────────────────────────────────────────────────────
|
||||
@@ -230,7 +316,7 @@ export class ChatView {
|
||||
/** Reset the panel to the empty state and clear the unread badge. */
|
||||
clear() {
|
||||
this.renderEmptyState();
|
||||
this._chatBadge.classList.remove("visible");
|
||||
if (this._chatBadge) this._chatBadge.classList.remove("visible");
|
||||
}
|
||||
|
||||
/** @param {"user"|"assistant"} role @param {string} text @param {boolean} partial @returns {HTMLElement} */
|
||||
@@ -419,6 +505,10 @@ export class ChatView {
|
||||
// the history row persists as the conversation log. Crucially we do NOT
|
||||
// touch user state here — that lifecycle is fully independent.
|
||||
this._asstByResp.delete(responseId);
|
||||
// ALWAYS re-anchor to the last line when a response ends, since the final
|
||||
// punctuation line + any trailing tool-result row pushes the tail further
|
||||
// down than the last streamed delta did.
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
/** The model called a tool — show an ephemeral "running" bubble.
|
||||
@@ -426,6 +516,7 @@ export class ChatView {
|
||||
onToolCall(name) {
|
||||
this._bumpDismiss(this._spawnBubble("tool", name));
|
||||
this._markUnread();
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
/** The tool finished — append its call+result row (and any captured image).
|
||||
@@ -433,6 +524,12 @@ export class ChatView {
|
||||
onToolResult(name, argsJson, output, image) {
|
||||
const startedAt = performance.now();
|
||||
this._appendHistTool(name, argsJson, output);
|
||||
if (image) this._appendHistImage(image);
|
||||
// Tool rows can be tall (big JSON payloads, tables). Force a scroll after
|
||||
// insert so the start of the result isn't hidden off-screen above the
|
||||
// sticky footer; we want the LAST line of the answer always in view.
|
||||
this._scrollToBottom();
|
||||
this._markUnread();
|
||||
// #region debug-point C:chat-tool-render
|
||||
reportChatDebug("C", "ui/chat.js:onToolResult", "rendered tool result row", {
|
||||
name,
|
||||
|
||||
@@ -162,6 +162,20 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
this._joinTimer = 0;
|
||||
/** @type {NoiseGate} Mic noise gate; off by default. */
|
||||
this._noiseGate = options.noiseGate ?? { enabled: false, thresholdDb: -45 };
|
||||
/** @type {boolean} If true, right after the initial session.updated ack we inject
|
||||
* the wake-word-style bootstrap phrase (default "hola vocero") as if the user
|
||||
* had spoken it, then trigger `requestResponse()` — so the assistant
|
||||
* behaves EXACTLY as it does when the "Hola Vocero" hot-word is detected
|
||||
* (same code path, same greeting style). Used when S2S_START_SESSION_MODE=TALK. */
|
||||
this._startWithResponse = options.startWithResponse === true;
|
||||
/** @type {string} Phrase injected into the first turn (mirrors the wake-word
|
||||
* confirmation flow. Defaults to "hola vocero". Used ONLY on first session start.
|
||||
* Overridable via options.startWithResponseText for test/CI flows. */
|
||||
this._startWithResponseText = (options.startWithResponseText || "").trim() || "hola vocero";
|
||||
/** @type {boolean} Guards the initial bootstrap turn from firing more than once
|
||||
* (e.g. after a live `updateSession()` that re-emits session.updated later
|
||||
* while the session is already running). */
|
||||
this._initialResponseSent = false;
|
||||
/** @type {WebSocket | null} */
|
||||
this._ws = null;
|
||||
/** @type {AudioContext | null} */
|
||||
@@ -671,6 +685,32 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
`tools=${toolNames || "none"}`
|
||||
);
|
||||
}
|
||||
// Primo ack del server: se startup mode=TALK vogliamo il comportamento
|
||||
// IDENTICO a quando arriva la frase di wake word "Hola Vocero".
|
||||
// Quindi NON mandiamo shaped response.create con istruzioni di greet
|
||||
// hardcodate, ma invece:
|
||||
// 1. injectiamo "hola vocero" nella conversazione come se l'utente
|
||||
// l'avesse detto (sendUserText → conversation.item.create user)
|
||||
// 2. requestResponse() VUOTO → il modello risponde esattamente come
|
||||
// nel codice wake-word in main.js.
|
||||
// Stesso identico risultato, nessuna istruzione custom.
|
||||
if (this._startWithResponse && !this._initialResponseSent) {
|
||||
this._initialResponseSent = true;
|
||||
const phrase = this._startWithResponseText.trim() || "hola vocero";
|
||||
// #region debug-point B:start-session-initial-response
|
||||
reportWsDebug("B", "s2s-ws-client.js:session.updated",
|
||||
"emitting initial turn via wake-word style sendUserText + requestResponse() (S2S_START_SESSION_MODE=TALK)",
|
||||
{
|
||||
toolCount: Array.isArray(event.session?.tools) ? event.session.tools.length : 0,
|
||||
phrase,
|
||||
});
|
||||
// #endregion
|
||||
if (this._debug) {
|
||||
console.debug(`[Vocero WS] bootstrap turn: sendUserText(${JSON.stringify(phrase)}) → requestResponse()`);
|
||||
}
|
||||
this.sendUserText(phrase);
|
||||
this.requestResponse();
|
||||
}
|
||||
// Acknowledged by server, nothing to do.
|
||||
break;
|
||||
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$ROOT_DIR/.venv"
|
||||
REQ_FILE="$ROOT_DIR/demo/requirements.txt"
|
||||
REQ_FILE="$ROOT_DIR/s2s-client/requirements.txt"
|
||||
UI_PORT="${UI_PORT:-8420}"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
@@ -30,16 +30,16 @@ if [ ! -f "$REQ_FILE" ]; then
|
||||
fi
|
||||
|
||||
if ! python -c "import fastapi, uvicorn, httpx, huggingface_hub, dotenv" >/dev/null 2>&1; then
|
||||
echo "Installazione dipendenze da demo/requirements.txt..."
|
||||
echo "Installazione dipendenze da s2s-client/requirements.txt..."
|
||||
python -m pip install -r "$REQ_FILE"
|
||||
fi
|
||||
|
||||
export S2S_PROXY_TARGET
|
||||
|
||||
echo "💻 Avvio dell'interfaccia Demo UI (Porta $UI_PORT)..."
|
||||
echo "💻 Avvio dell'interfaccia S2S Client UI (Porta $UI_PORT)..."
|
||||
echo "🔌 Proxy realtime verso $S2S_PROXY_TARGET"
|
||||
echo "💡 Premi Ctrl+C in questo terminale per fermarla."
|
||||
echo "----------------------------------------------------"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
PYTHONPATH=demo python -m uvicorn demo.server:app --host 0.0.0.0 --port "$UI_PORT" --reload
|
||||
PYTHONPATH=s2s-client python -m uvicorn server:app --host 0.0.0.0 --port "$UI_PORT" --reload
|
||||
|
||||
Reference in New Issue
Block a user