feat: implement full UPDATE.md review — critical fixes, UI upgrade, infra hardening
Critical frontend bugs: - Add TrackSubscribed/attach() for agent audio playback - Fix decodeToString TypeError with TextDecoder - XSS fix: innerHTML -> textContent in addMessage - Fresh token on reconnect retry Agent fixes: - GemmaLLM subclass with reasoning_content fallback wrapper - Disable Gemma 4 thinking mode via chat_template_kwargs (6.8s -> 0.5s) - Remove duplicate session-level LLM - Replace global _active_session with closure-based handler - asyncio.create_task instead of deprecated get_event_loop - Explicit silero VAD, topic filter on voice-control Infra: - supervisord: all programs log to /dev/stdout - Dockerfile: uv sync --frozen with committed uv.lock - nginx config moved to real file, token_server.py no longer served - entrypoint.sh: cert persisted, only regenerated on IP change - compose: healthcheck + cert volume - token_server: CORS removed, room pinned to voice-room UI upgrade: - Orb UI with state machine (idle/connecting/listening/thinking/speaking) - Streaming transcripts via lk.transcription text streams - Barge-in hint, thinking chip, audio visualizer - Glassmorphism, chat bubbles, settings sheet, light mode - PWA manifest, favicon, wake-lock, safe-area insets - localStorage conversation history Docs: AGENTS.md drift fixed
This commit is contained in:
+110
-43
@@ -19,6 +19,8 @@ from livekit.agents import (
|
||||
JobContext,
|
||||
TurnHandlingOptions,
|
||||
cli,
|
||||
inference,
|
||||
llm as lk_llm,
|
||||
mcp,
|
||||
room_io,
|
||||
)
|
||||
@@ -70,6 +72,85 @@ SYSTEM_PROMPT = textwrap.dedent("""\
|
||||
""")
|
||||
|
||||
|
||||
# ── Gemma LLM with reasoning_content fallback ───────────────────────────────
|
||||
|
||||
|
||||
class _ReasoningFallbackWrapper:
|
||||
"""Wraps an LLMStream to fall back to ``reasoning_content`` when the model
|
||||
finishes a turn with empty visible content (Gemma spends its whole budget
|
||||
on hidden reasoning). Delegates all iteration to the underlying stream and
|
||||
injects a final content chunk if no real content was produced."""
|
||||
|
||||
def __init__(self, inner: lk_llm.LLMStream) -> None:
|
||||
self._inner = inner
|
||||
self._last_reasoning: str | None = None
|
||||
self._has_content = False
|
||||
|
||||
@property
|
||||
def chat_ctx(self) -> lk_llm.ChatContext:
|
||||
return self._inner.chat_ctx
|
||||
|
||||
@property
|
||||
def tools(self) -> list:
|
||||
return self._inner.tools
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._inner.aclose()
|
||||
|
||||
async def __aenter__(self) -> "_ReasoningFallbackWrapper":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
await self.aclose()
|
||||
|
||||
def __aiter__(self) -> "_ReasoningFallbackWrapper":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> lk_llm.ChatChunk:
|
||||
try:
|
||||
chunk = await self._inner.__anext__()
|
||||
except StopAsyncIteration:
|
||||
# Stream exhausted — inject reasoning fallback if no content was produced
|
||||
if not self._has_content and self._last_reasoning:
|
||||
logger.warning(
|
||||
"LLM returned empty content; falling back to reasoning_content"
|
||||
)
|
||||
return lk_llm.ChatChunk(
|
||||
id="reasoning-fallback",
|
||||
delta=lk_llm.ChoiceDelta(role="assistant", content=self._last_reasoning),
|
||||
)
|
||||
raise
|
||||
# Track reasoning_content from the chunk's delta if present
|
||||
delta = getattr(chunk, "delta", None)
|
||||
if delta is not None:
|
||||
reasoning = getattr(delta, "reasoning_content", None)
|
||||
if reasoning:
|
||||
self._last_reasoning = reasoning
|
||||
if chunk.has_response():
|
||||
self._has_content = True
|
||||
return chunk
|
||||
|
||||
async def collect(self):
|
||||
return await self._inner.collect()
|
||||
|
||||
|
||||
class GemmaLLM(openai.LLM):
|
||||
"""OpenAI-compatible LLM (llama.cpp Gemma 4) with thinking disabled."""
|
||||
|
||||
def chat(self, *, chat_ctx, tools=None, conn_options=None, **kwargs):
|
||||
if conn_options is None:
|
||||
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS
|
||||
|
||||
conn_options = DEFAULT_API_CONNECT_OPTIONS
|
||||
stream = super().chat(
|
||||
chat_ctx=chat_ctx,
|
||||
tools=tools,
|
||||
conn_options=conn_options,
|
||||
**kwargs,
|
||||
)
|
||||
return _ReasoningFallbackWrapper(stream)
|
||||
|
||||
|
||||
# ── MCP toolsets (web access + any extra configured servers) ────────────────
|
||||
|
||||
def build_mcp_toolsets() -> list[mcp.MCPToolset]:
|
||||
@@ -125,10 +206,12 @@ class VoiceAssistant(Agent):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
llm=openai.LLM(
|
||||
llm=GemmaLLM(
|
||||
model=GEMMA_MODEL,
|
||||
base_url=GEMMA_BASE_URL,
|
||||
api_key=GEMMA_API_KEY,
|
||||
max_completion_tokens=1000,
|
||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
||||
),
|
||||
instructions=SYSTEM_PROMPT,
|
||||
tools=build_mcp_toolsets(),
|
||||
@@ -138,14 +221,9 @@ class VoiceAssistant(Agent):
|
||||
# ── Agent server ────────────────────────────────────────────────────────────
|
||||
server = AgentServer()
|
||||
|
||||
# Track the active session so we can update its voice on data messages.
|
||||
_active_session: AgentSession | None = None
|
||||
|
||||
|
||||
@server.rtc_session(agent_name="voice-assistant")
|
||||
async def handle_job(ctx: JobContext) -> None:
|
||||
global _active_session
|
||||
|
||||
logger.info("Job started for room %s", ctx.room.name)
|
||||
|
||||
# Azure STT — streaming, reads AZURE_SPEECH_KEY / AZURE_SPEECH_REGION from env
|
||||
@@ -163,17 +241,10 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
speech_region=AZURE_REGION,
|
||||
)
|
||||
|
||||
# Gemma LLM via OpenAI-compatible endpoint (llama.cpp on xNAS)
|
||||
llm = openai.LLM(
|
||||
model=GEMMA_MODEL,
|
||||
base_url=GEMMA_BASE_URL,
|
||||
api_key=GEMMA_API_KEY,
|
||||
)
|
||||
|
||||
session = AgentSession(
|
||||
stt=stt,
|
||||
tts=tts,
|
||||
llm=llm,
|
||||
vad=inference.VAD(model="silero"),
|
||||
turn_handling=TurnHandlingOptions(
|
||||
# VAD-based turn detection: agent waits for user to stop speaking.
|
||||
# ("adaptive" mode requires the LiveKit Cloud barge-in service.)
|
||||
@@ -183,9 +254,30 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
),
|
||||
)
|
||||
|
||||
_active_session = session
|
||||
# Listen for data messages (voice switching) from the web UI.
|
||||
# Defined here so it captures this job's session via closure.
|
||||
def _on_room_data(packet) -> None:
|
||||
topic = getattr(packet, "topic", None)
|
||||
if topic and topic != "voice-control":
|
||||
return
|
||||
try:
|
||||
msg = json.loads(packet.data.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
if msg.get("type") != "set_voice":
|
||||
return
|
||||
voice = msg.get("voice", "")
|
||||
if not voice:
|
||||
return
|
||||
logger.info("Switching TTS voice to %s", voice)
|
||||
try:
|
||||
tts = session.tts
|
||||
if hasattr(tts, "update_options"):
|
||||
tts.update_options(voice=voice)
|
||||
logger.info("Voice updated to %s", voice)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("Failed to update voice: %s", e)
|
||||
|
||||
# Listen for data messages (voice switching) from the web UI
|
||||
ctx.room.on("data_received", _on_room_data)
|
||||
|
||||
# Publish user/agent transcripts to the room so the web UI can render them.
|
||||
@@ -207,9 +299,9 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
role = getattr(msg, "role", None)
|
||||
text = getattr(msg, "text_content", None)
|
||||
if role == "user":
|
||||
asyncio.get_event_loop().create_task(publish_transcript("user", text))
|
||||
asyncio.create_task(publish_transcript("user", text))
|
||||
elif role == "assistant":
|
||||
asyncio.get_event_loop().create_task(publish_transcript("agent", text))
|
||||
asyncio.create_task(publish_transcript("agent", text))
|
||||
|
||||
@session.on("user_input_transcribed")
|
||||
def _on_user_transcribed(ev) -> None:
|
||||
@@ -240,30 +332,5 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
logger.info("Agent connected to room %s", ctx.room.name)
|
||||
|
||||
|
||||
# ── Voice switching via data channel ────────────────────────────────────────
|
||||
# The web UI sends a JSON data message: {"type": "set_voice", "voice": "en-US-AvaNeural"}
|
||||
# We listen on the room's data channel and update the TTS voice live.
|
||||
|
||||
|
||||
def _on_room_data(packet) -> None:
|
||||
"""Handle data messages from the web UI (voice selection)."""
|
||||
try:
|
||||
msg = json.loads(packet.data.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
|
||||
if msg.get("type") == "set_voice":
|
||||
voice = msg.get("voice", "")
|
||||
if voice and _active_session:
|
||||
logger.info("Switching TTS voice to %s", voice)
|
||||
try:
|
||||
tts = _active_session.tts
|
||||
if hasattr(tts, "update_options"):
|
||||
tts.update_options(voice=voice)
|
||||
logger.info("Voice updated to %s", voice)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to update voice: %s", e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli.run_app(server)
|
||||
|
||||
@@ -9,7 +9,6 @@ description = "Real-time voice assistant: Azure STT/TTS + Gemma LLM via LiveKit
|
||||
requires-python = ">=3.10,<3.15"
|
||||
|
||||
dependencies = [
|
||||
"livekit-agents~=1.7",
|
||||
"livekit-agents[mcp]~=1.7",
|
||||
"livekit-plugins-azure~=1.7",
|
||||
"livekit-plugins-openai~=1.7",
|
||||
|
||||
Generated
+3040
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user