Files
2026-07-13 13:32:32 +08:00

151 lines
4.9 KiB
Python

"""WebSocket 端点 — 实时推送事件到前端。
取代旧架构的 5 秒 HTTP 轮询,改为:
- 客户端 WebSocket 连接
- 服务端订阅 Redis Pub/Sub 频道
- 实时推送事件(状态变更、Agent 思考流、心跳等)
"""
import asyncio
import json
import logging
import redis.asyncio as aioredis
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from ..config import get_settings
from ..services.event_bus import get_event_bus
log = logging.getLogger("edict.ws")
router = APIRouter()
# 活跃连接管理
_connections: set[WebSocket] = set()
@router.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
"""主 WebSocket 端点 — 推送所有事件。"""
await ws.accept()
_connections.add(ws)
log.info(f"WebSocket connected. Total: {len(_connections)}")
# 创建独立的 Redis Pub/Sub 连接
settings = get_settings()
pubsub_redis = aioredis.from_url(settings.redis_url, decode_responses=True)
pubsub = pubsub_redis.pubsub()
# 订阅所有 edict 频道
await pubsub.psubscribe("edict:pubsub:*")
try:
# 并发:监听 Redis Pub/Sub + 客户端消息
await asyncio.gather(
_relay_events(pubsub, ws),
_handle_client_messages(ws),
)
except WebSocketDisconnect:
log.info("WebSocket disconnected")
except Exception as e:
log.error(f"WebSocket error: {e}")
finally:
_connections.discard(ws)
await pubsub.punsubscribe("edict:pubsub:*")
await pubsub_redis.aclose()
log.info(f"WebSocket cleaned up. Remaining: {len(_connections)}")
async def _relay_events(pubsub, ws: WebSocket):
"""从 Redis Pub/Sub 接收事件,推送到 WebSocket。"""
async for message in pubsub.listen():
if message["type"] == "pmessage":
channel = message["channel"]
data = message["data"]
# 提取 topic 名
topic = channel.replace("edict:pubsub:", "") if channel.startswith("edict:pubsub:") else channel
try:
event_data = json.loads(data) if isinstance(data, str) else data
await ws.send_json({
"type": "event",
"topic": topic,
"data": event_data,
})
except Exception as e:
log.warning(f"Failed to relay event: {e}")
break
async def _handle_client_messages(ws: WebSocket):
"""处理客户端发送的消息(心跳、订阅过滤等)。"""
while True:
try:
data = await ws.receive_json()
msg_type = data.get("type", "")
if msg_type == "ping":
await ws.send_json({"type": "pong"})
elif msg_type == "subscribe":
# 前端可请求只订阅特定 topic(未来扩展)
topics = data.get("topics", [])
log.debug(f"Client subscribe request: {topics}")
await ws.send_json({"type": "subscribed", "topics": topics})
else:
log.debug(f"Unknown client message: {msg_type}")
except WebSocketDisconnect:
raise
except Exception:
break
@router.websocket("/ws/task/{task_id}")
async def task_websocket(ws: WebSocket, task_id: str):
"""单任务 WebSocket — 只推送与特定任务相关的事件。"""
await ws.accept()
_connections.add(ws)
settings = get_settings()
pubsub_redis = aioredis.from_url(settings.redis_url, decode_responses=True)
pubsub = pubsub_redis.pubsub()
await pubsub.psubscribe("edict:pubsub:*")
try:
async for message in pubsub.listen():
if message["type"] == "pmessage":
data = message["data"]
try:
event_data = json.loads(data) if isinstance(data, str) else data
payload = event_data.get("payload", {})
if isinstance(payload, str):
payload = json.loads(payload)
# 只转发与此任务相关的事件
if payload.get("task_id") == task_id:
topic = message["channel"].replace("edict:pubsub:", "")
await ws.send_json({
"type": "event",
"topic": topic,
"data": event_data,
})
except Exception:
continue
except WebSocketDisconnect:
pass
finally:
_connections.discard(ws)
await pubsub.punsubscribe("edict:pubsub:*")
await pubsub_redis.aclose()
async def broadcast(event: dict):
"""向所有连接的 WebSocket 客户端广播事件(服务端内部调用用)。"""
dead = set()
for ws in _connections:
try:
await ws.send_json(event)
except Exception:
dead.add(ws)
_connections -= dead