项目文件夹

文件
2026-07-13 13:39:38 +08:00

99 行
3.4 KiB
Python

"""UI rendering for the LiveKit Playground card attached to this example.
The Front Desk agent itself is UI-agnostic; this module is the only
place anything playground-specific lives. The agent receives an
optional ``UIView`` on its ``Userdata`` and calls two semantic
methods on it (``slots_listed`` / ``appointment_booked``). When
running outside the playground, ``Userdata.ui`` is ``None`` and
these helpers are simply never called.
Pushes are fire-and-forget so a slow or absent peer never blocks the
function-tool reply path.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from zoneinfo import ZoneInfo
from calendar_api import AvailableSlot
from livekit.agents import JobContext
logger = logging.getLogger("frontdesk.ui")
# The playground reads `views[].rpc` from playground.yaml and registers
# an RPC handler with this exact method name. Keep both in sync.
_VIEW_METHOD = "set_appointment_status"
def _relative(local: datetime.datetime, now: datetime.datetime) -> str:
"""Human "tomorrow" / "in 3 weeks" phrasing for a slot."""
delta = local - now
days = delta.days
if local.date() == now.date():
return "in less than an hour" if delta.seconds < 3600 else "later today"
if local.date() == (now.date() + datetime.timedelta(days=1)):
return "tomorrow"
if days < 7:
return f"in {days} days"
if days < 14:
return "in 1 week"
return f"in {days // 7} weeks"
class UIView:
"""Pushes markdown to the playground card associated with this example."""
def __init__(self, ctx: JobContext) -> None:
self._ctx = ctx
def slots_listed(
self,
slots: list[AvailableSlot],
now: datetime.datetime,
tz: ZoneInfo,
range_days: int,
) -> None:
"""Render the search range + slot count. Empty input hides the card."""
if not slots:
self._push("")
return
# The agent's tool reply already walks the LLM through specific
# options; the card just summarises the window it searched so
# the caller has a visual anchor without an overwhelming list.
if range_days <= 14:
window = "Next 2 weeks"
elif range_days <= 30:
window = "Next month"
else:
window = f"Next {range_days // 30} months"
last = max(s.start_time for s in slots).astimezone(tz)
span = f"{now.strftime('%b %d')}{last.strftime('%b %d')}"
plural = "slot" if len(slots) == 1 else "slots"
self._push(f"**{window}**\n\n[[{len(slots)}]] available {plural} · *{span}*")
def appointment_booked(self, slot: AvailableSlot, tz: ZoneInfo) -> None:
local = slot.start_time.astimezone(tz)
self._push(
f"**Booked: {local.strftime('%A, %B %d, %Y')}**\n\nat [[{local.strftime('%H:%M %Z')}]]"
)
# ---- internals ----
def _push(self, payload: str) -> None:
for p in list(self._ctx.room.remote_participants.values()):
asyncio.create_task(self._push_to(p.identity, payload))
async def _push_to(self, identity: str, payload: str) -> None:
try:
await self._ctx.room.local_participant.perform_rpc(
destination_identity=identity,
method=_VIEW_METHOD,
payload=payload,
)
except Exception:
logger.exception("UI push to %s failed", identity)