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:
Shane
2026-08-22 15:21:59 -04:00
parent d1eeb01f3d
commit d3f9f2c4ed
17 changed files with 4441 additions and 242 deletions
+110 -43
View File
@@ -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)
-1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff