fix: support photo client tool in debug preview

This commit is contained in:
Xin Wang
2026-08-14 18:28:52 +08:00
parent 36253de864
commit abfcd4cd19
4 changed files with 147 additions and 11 deletions
+60 -2
View File
@@ -13,7 +13,7 @@ import json
from collections.abc import Coroutine
from typing import Any
from fastapi import APIRouter, Body, Depends, Request, WebSocket
from fastapi import APIRouter, Body, Depends, HTTPException, Request, WebSocket
from loguru import logger
from models import AssistantConfig, SignalingOffer
from services.auth import require_admin, require_admin_websocket
@@ -35,6 +35,7 @@ _active_connections = realtime_lifecycle.active_connections
_pipeline_tasks = realtime_lifecycle.pipeline_tasks
_connection_tasks = realtime_lifecycle.connection_tasks
PIPELINE_CLOSE_GRACE_SECONDS = 10.0
MAX_CLIENT_TOOL_NAME_LENGTH = 64
def _start_pipeline_task(
@@ -69,6 +70,53 @@ async def shutdown_active_sessions() -> None:
)
def _decode_client_tool_names(encoded: str | None) -> list[str] | None:
"""Decode client capabilities carried outside the WebRTC SDP body.
Pipecat's browser transport replaces the body of a ``Request`` endpoint
with its SDP offer and drops ``APIRequest.requestData``. Request headers are
preserved, so they are the authoritative metadata channel for this path.
``None`` means the header was absent and callers should use the legacy body.
"""
if encoded is None:
return None
try:
padding = "=" * (-len(encoded) % 4)
value = json.loads(
base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
)
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise HTTPException(
status_code=400,
detail="客户端工具能力请求头格式无效",
) from exc
if not isinstance(value, list):
raise HTTPException(
status_code=400,
detail="客户端工具能力必须是函数名列表",
)
names: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
raise HTTPException(
status_code=400,
detail="客户端工具函数名必须是字符串",
)
name = item.strip()
if not name or len(name) > MAX_CLIENT_TOOL_NAME_LENGTH:
raise HTTPException(
status_code=400,
detail="客户端工具函数名为空或过长",
)
if name not in seen:
names.append(name)
seen.add(name)
return names
@router.get("/api/webrtc/ice-servers", dependencies=[Depends(require_admin)])
async def ice_servers():
"""Browser fetches STUN/TURN config (with ephemeral TURN creds when configured)."""
@@ -86,12 +134,22 @@ async def http_offer(request: Request, payload: dict = Body(...)):
header_variables = json.loads(
base64.urlsafe_b64decode(encoded_variables + padding).decode("utf-8")
)
header_client_tools = _decode_client_tool_names(
request.headers.get("x-pipecat-client-tools")
)
client_tool_names = (
header_client_tools
if header_client_tools is not None
else request_data.get("client_tool_names") or []
)
logger.debug(f"WebRTC 客户端工具上报: {client_tool_names or '<none>'}")
offer_payload = {
**payload,
"assistant_id": request.headers.get("x-pipecat-assistant-id"),
"dynamic_variables": header_variables,
"client_tool_names": request_data.get("client_tool_names") or [],
**request_data,
# Keep header metadata authoritative when both paths are present.
"client_tool_names": client_tool_names,
}
answer = await _handle_offer_payload(offer_payload, _http_peers)
return answer
+72 -1
View File
@@ -1,16 +1,87 @@
from __future__ import annotations
import asyncio
import base64
import json
import unittest
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from routes import voice_webrtc
from starlette.requests import Request
class FakeConnection:
pc_id = "pc_test"
def encoded_header_json(value: object) -> str:
payload = json.dumps(value).encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
def offer_request(*, client_tools: object | None = None) -> Request:
headers = []
if client_tools is not None:
headers.append(
(
b"x-pipecat-client-tools",
encoded_header_json(client_tools).encode("ascii"),
)
)
return Request({"type": "http", "method": "POST", "headers": headers})
class WebRTCOfferMetadataTest(unittest.IsolatedAsyncioTestCase):
async def test_client_tool_header_survives_request_endpoint_body_replacement(self):
request = offer_request(
client_tools=["set_photo_button_visible", "show_message"]
)
payload = {"sdp": "offer", "type": "offer"}
with patch.object(
voice_webrtc,
"_handle_offer_payload",
new=AsyncMock(return_value={"type": "answer"}),
) as handle_offer:
await voice_webrtc.http_offer(request, payload)
forwarded = handle_offer.await_args.args[0]
self.assertEqual(
forwarded["client_tool_names"],
["set_photo_button_visible", "show_message"],
)
async def test_client_tool_request_data_remains_a_compatibility_fallback(self):
request = offer_request()
payload = {
"sdp": "offer",
"type": "offer",
"requestData": {"client_tool_names": ["show_message"]},
}
with patch.object(
voice_webrtc,
"_handle_offer_payload",
new=AsyncMock(return_value={"type": "answer"}),
) as handle_offer:
await voice_webrtc.http_offer(request, payload)
forwarded = handle_offer.await_args.args[0]
self.assertEqual(forwarded["client_tool_names"], ["show_message"])
async def test_invalid_client_tool_header_is_rejected(self):
request = offer_request(client_tools={"name": "show_message"})
with self.assertRaises(HTTPException) as raised:
await voice_webrtc.http_offer(
request,
{"sdp": "offer", "type": "offer"},
)
self.assertEqual(raised.exception.status_code, 400)
class WebRTCPipelineLifecycleTest(unittest.IsolatedAsyncioTestCase):
async def asyncTearDown(self):
tasks = list(voice_webrtc._pipeline_tasks)
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
import {
AudioLines,
Braces,
Camera,
Check,
Copy,
ImageIcon,
@@ -13,7 +14,6 @@ import {
Mic,
Orbit,
PhoneOff,
ScanLine,
Send,
Smartphone,
Sparkles,
@@ -1674,7 +1674,10 @@ function DebugPhotoCaptureButton({
onCapture: () => Promise<void>;
}) {
return (
<div className="absolute bottom-4 left-1/2 z-20 flex -translate-x-1/2 flex-col items-center gap-2">
<div className="absolute bottom-4 left-1/2 z-20 flex -translate-x-1/2 animate-in flex-col items-center gap-2 fade-in-0 slide-in-from-bottom-2 duration-200">
<span className="sr-only" role="status" aria-live="polite">
拍照按钮已开启
</span>
{error && (
<div className="max-w-xs rounded-full border border-destructive/20 bg-background/90 px-3 py-1 text-xs text-destructive shadow-sm backdrop-blur">
{error}
@@ -1682,18 +1685,19 @@ function DebugPhotoCaptureButton({
)}
<Button
type="button"
size="icon"
size="sm"
onClick={() => void onCapture()}
disabled={capturing || !enabled}
aria-label={capturing ? "正在提交照片" : "拍照并发送"}
title={capturing ? "正在提交照片" : "拍照并发送"}
className="size-12 rounded-full shadow-lg"
className="h-10 shrink-0 gap-2 rounded-full px-5 shadow-lg ring-1 ring-black/10"
>
{capturing ? (
<Loader2 className="size-5 animate-spin" />
) : (
<ScanLine className="size-5" />
<Camera className="size-5" />
)}
<span>{capturing ? "正在拍照" : "拍照"}</span>
</Button>
</div>
);
+6 -3
View File
@@ -826,6 +826,10 @@ export function useVoicePreview(
setVideoStream(localVideo ? new MediaStream([localVideo]) : null);
void refreshDevices();
// SmallWebRTCTransport discards APIRequest.requestData when endpoint is a
// Request object. Keep capabilities in a header as the reliable path;
// requestData remains below for compatibility with other transports.
const clientToolNames = Array.from(clientToolHandlersRef.current.keys());
const request = new Request(`${API_BASE}/api/webrtc/offer`, {
method: "POST",
headers: {
@@ -834,6 +838,7 @@ export function useVoicePreview(
"X-Pipecat-Dynamic-Variables": encodeHeaderJson(
options.dynamicVariables ?? {},
),
"X-Pipecat-Client-Tools": encodeHeaderJson(clientToolNames),
},
credentials: "include",
});
@@ -844,9 +849,7 @@ export function useVoicePreview(
assistant_id: assistantId,
vision_enabled: Boolean(options.visionEnabled),
dynamic_variables: options.dynamicVariables ?? {},
client_tool_names: Array.from(
clientToolHandlersRef.current.keys(),
),
client_tool_names: clientToolNames,
},
},
});