fix: support photo client tool in debug preview
This commit is contained in:
@@ -13,7 +13,7 @@ import json
|
|||||||
from collections.abc import Coroutine
|
from collections.abc import Coroutine
|
||||||
from typing import Any
|
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 loguru import logger
|
||||||
from models import AssistantConfig, SignalingOffer
|
from models import AssistantConfig, SignalingOffer
|
||||||
from services.auth import require_admin, require_admin_websocket
|
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
|
_pipeline_tasks = realtime_lifecycle.pipeline_tasks
|
||||||
_connection_tasks = realtime_lifecycle.connection_tasks
|
_connection_tasks = realtime_lifecycle.connection_tasks
|
||||||
PIPELINE_CLOSE_GRACE_SECONDS = 10.0
|
PIPELINE_CLOSE_GRACE_SECONDS = 10.0
|
||||||
|
MAX_CLIENT_TOOL_NAME_LENGTH = 64
|
||||||
|
|
||||||
|
|
||||||
def _start_pipeline_task(
|
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)])
|
@router.get("/api/webrtc/ice-servers", dependencies=[Depends(require_admin)])
|
||||||
async def ice_servers():
|
async def ice_servers():
|
||||||
"""Browser fetches STUN/TURN config (with ephemeral TURN creds when configured)."""
|
"""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(
|
header_variables = json.loads(
|
||||||
base64.urlsafe_b64decode(encoded_variables + padding).decode("utf-8")
|
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 = {
|
offer_payload = {
|
||||||
**payload,
|
**payload,
|
||||||
"assistant_id": request.headers.get("x-pipecat-assistant-id"),
|
"assistant_id": request.headers.get("x-pipecat-assistant-id"),
|
||||||
"dynamic_variables": header_variables,
|
"dynamic_variables": header_variables,
|
||||||
"client_tool_names": request_data.get("client_tool_names") or [],
|
|
||||||
**request_data,
|
**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)
|
answer = await _handle_offer_payload(offer_payload, _http_peers)
|
||||||
return answer
|
return answer
|
||||||
|
|||||||
@@ -1,16 +1,87 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
from routes import voice_webrtc
|
from routes import voice_webrtc
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
||||||
class FakeConnection:
|
class FakeConnection:
|
||||||
pc_id = "pc_test"
|
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):
|
class WebRTCPipelineLifecycleTest(unittest.IsolatedAsyncioTestCase):
|
||||||
async def asyncTearDown(self):
|
async def asyncTearDown(self):
|
||||||
tasks = list(voice_webrtc._pipeline_tasks)
|
tasks = list(voice_webrtc._pipeline_tasks)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
|
|||||||
import {
|
import {
|
||||||
AudioLines,
|
AudioLines,
|
||||||
Braces,
|
Braces,
|
||||||
|
Camera,
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
Mic,
|
Mic,
|
||||||
Orbit,
|
Orbit,
|
||||||
PhoneOff,
|
PhoneOff,
|
||||||
ScanLine,
|
|
||||||
Send,
|
Send,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -1674,7 +1674,10 @@ function DebugPhotoCaptureButton({
|
|||||||
onCapture: () => Promise<void>;
|
onCapture: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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 && (
|
{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">
|
<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}
|
{error}
|
||||||
@@ -1682,18 +1685,19 @@ function DebugPhotoCaptureButton({
|
|||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="sm"
|
||||||
onClick={() => void onCapture()}
|
onClick={() => void onCapture()}
|
||||||
disabled={capturing || !enabled}
|
disabled={capturing || !enabled}
|
||||||
aria-label={capturing ? "正在提交照片" : "拍照并发送"}
|
aria-label={capturing ? "正在提交照片" : "拍照并发送"}
|
||||||
title={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 ? (
|
{capturing ? (
|
||||||
<Loader2 className="size-5 animate-spin" />
|
<Loader2 className="size-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<ScanLine className="size-5" />
|
<Camera className="size-5" />
|
||||||
)}
|
)}
|
||||||
|
<span>{capturing ? "正在拍照" : "拍照"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -826,6 +826,10 @@ export function useVoicePreview(
|
|||||||
setVideoStream(localVideo ? new MediaStream([localVideo]) : null);
|
setVideoStream(localVideo ? new MediaStream([localVideo]) : null);
|
||||||
void refreshDevices();
|
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`, {
|
const request = new Request(`${API_BASE}/api/webrtc/offer`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -834,6 +838,7 @@ export function useVoicePreview(
|
|||||||
"X-Pipecat-Dynamic-Variables": encodeHeaderJson(
|
"X-Pipecat-Dynamic-Variables": encodeHeaderJson(
|
||||||
options.dynamicVariables ?? {},
|
options.dynamicVariables ?? {},
|
||||||
),
|
),
|
||||||
|
"X-Pipecat-Client-Tools": encodeHeaderJson(clientToolNames),
|
||||||
},
|
},
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
@@ -844,9 +849,7 @@ export function useVoicePreview(
|
|||||||
assistant_id: assistantId,
|
assistant_id: assistantId,
|
||||||
vision_enabled: Boolean(options.visionEnabled),
|
vision_enabled: Boolean(options.visionEnabled),
|
||||||
dynamic_variables: options.dynamicVariables ?? {},
|
dynamic_variables: options.dynamicVariables ?? {},
|
||||||
client_tool_names: Array.from(
|
client_tool_names: clientToolNames,
|
||||||
clientToolHandlersRef.current.keys(),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user