Files
ai-video-fullstack/backend/routes/voice_webrtc.py
Xin Wang 590cb33cbd Add authentication features for admin access
- Introduce a new `auth` module with login, logout, and user verification endpoints for a single admin user.
- Update backend routes to require admin authentication for sensitive operations, enhancing security.
- Modify frontend components to include an authentication provider and gate, ensuring only authorized users can access the application.
- Implement a login page for admin access, improving user experience and security management.
- Update API request handling to redirect unauthorized users to the login page, ensuring proper access control.
2026-07-09 23:27:50 +08:00

155 lines
5.9 KiB
Python

"""WebRTC 输出:SmallWebRTC 信令握手。
参考 dograh 的 webrtc_signaling.py,砍掉鉴权/配额/DB/org/ICE 过滤策略/TURN。
握手消息:
client → {type:"offer", payload:{pc_id, sdp, type, assistant_id}}
server → {type:"answer", payload:{pc_id, sdp, type}}
both → {type:"ice-candidate", payload:{pc_id, candidate:{...}}}
server → {type:"error", payload:{message}}
"""
import asyncio
from db.session import SessionLocal
from fastapi import APIRouter, Depends, WebSocket
from loguru import logger
from models import AssistantConfig, SignalingOffer
from services.auth import require_admin, require_admin_websocket
from services.config_resolver import resolve_runtime_config
from starlette.websockets import WebSocketDisconnect, WebSocketState
from services.webrtc_ice import aiortc_ice_servers, client_ice_servers
# 注意:pipecat 是重依赖(语音才用),在 _handle_offer 等处惰性导入。
router = APIRouter(tags=["voice"])
@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)."""
return {"iceServers": client_ice_servers()}
@router.websocket("/ws/voice")
async def voice_signaling(websocket: WebSocket):
if not await require_admin_websocket(websocket):
return
await websocket.accept()
peers: dict = {}
try:
while True:
message = await websocket.receive_json()
try:
if message.get("type") == "offer":
await _handle_offer(websocket, message.get("payload", {}), peers)
elif message.get("type") == "ice-candidate":
await _handle_ice(message.get("payload", {}), peers)
except Exception as e:
logger.exception(f"处理 WebRTC 信令消息失败: {e}")
if websocket.application_state == WebSocketState.CONNECTED:
await websocket.send_json(
{
"type": "error",
"payload": {
"message": f"语音会话启动失败: {type(e).__name__}"
},
}
)
except WebSocketDisconnect:
logger.info("WebRTC 信令断开")
except Exception as e:
logger.error(f"WebRTC 信令出错: {e}")
finally:
# disconnect() triggers the registered closed callback, which removes
# the peer from this dict. Iterate over a snapshot to avoid mutation.
for pc in list(peers.values()):
await pc.disconnect()
async def _resolve_config(offer: SignalingOffer) -> AssistantConfig:
"""优先用 assistant_id 从 DB 解析(含真 key);否则用调试内联配置。"""
if offer.assistant_id:
async with SessionLocal() as session:
return await resolve_runtime_config(session, offer.assistant_id)
if offer.inline_config:
return offer.inline_config
raise ValueError("offer 缺少 assistant_id 或 inline_config")
async def _handle_offer(websocket, payload, peers):
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from services.pipecat.pipeline import run_pipeline
from services.pipecat.transports import build_webrtc_transport
offer = SignalingOffer(**payload)
pc_id = offer.pc_id
if pc_id and pc_id in peers:
pc = peers[pc_id]
await pc.renegotiate(sdp=offer.sdp, type=offer.type, restart_pc=False)
else:
cfg = await _resolve_config(offer) # 解析放在建连前,配置错就别建连
# 服务端助手配置是视觉理解的唯一授权来源;客户端 offer 只负责携带媒体轨。
vision_enabled = cfg.vision_enabled
if vision_enabled:
has_native_vision = (
not cfg.vision_model_resource_id and cfg.llm_support_image_input
)
has_aux_vision_model = (
bool(cfg.vision_model_resource_id)
and cfg.vision_llm_support_image_input
)
if not (has_native_vision or has_aux_vision_model):
raise ValueError(
"当前模型不支持图片输入,请在模型资源中选择支持图片输入的视觉模型"
)
pc = SmallWebRTCConnection(ice_servers=aiortc_ice_servers())
if pc_id:
pc._pc_id = pc_id
await pc.initialize(sdp=offer.sdp, type=offer.type)
peers[pc.pc_id] = pc
@pc.event_handler("closed")
async def _on_closed(conn: SmallWebRTCConnection):
peers.pop(conn.pc_id, None)
# 后台跑管线:WebRTC transport + 解析出的运行时配置
transport = build_webrtc_transport(
pc,
video_in_enabled=vision_enabled,
)
asyncio.create_task(
run_pipeline(transport, cfg, vision_enabled=vision_enabled)
)
answer = pc.get_answer()
if websocket.application_state == WebSocketState.CONNECTED:
await websocket.send_json(
{
"type": "answer",
"payload": {
"pc_id": answer["pc_id"],
"sdp": answer["sdp"],
"type": answer["type"],
},
}
)
async def _handle_ice(payload, peers):
from aiortc.sdp import candidate_from_sdp
pc_id = payload.get("pc_id")
candidate_data = payload.get("candidate")
pc = peers.get(pc_id) if pc_id else None
if not pc or not candidate_data:
return
try:
candidate = candidate_from_sdp(candidate_data.get("candidate", ""))
candidate.sdpMid = candidate_data.get("sdpMid")
candidate.sdpMLineIndex = candidate_data.get("sdpMLineIndex")
await pc.add_ice_candidate(candidate)
except Exception as e:
logger.error(f"添加 ICE candidate 失败: {e}")