feat(delegation): 添加直连模式下的委托公告回调机制
- 引入 DirectAnnouncementCallback 类型用于处理直连模式下的公告 - 在 DelegationManager 中添加 _direct_announcement_callback 属性和设置方法 - 实现 _notify_direct_announcement 方法用于在非总线模式下将公告回写到本地会话 - 在委托取消、完成和分组完成时添加对直连公告的通知逻辑 feat(web): 增加 WebSocket 广播器支持实时会话更新通知 - 创建 WebSocketBroadcaster 类用于跟踪认证的 WebSocket 连接并广播 JSON 事件 - 在应用启动时初始化 websocket_broadcaster 实例 - 实现连接注册、注销和消息广播功能 - 添加过期连接清理机制 feat(agent): 新增系统公告处理方法支持本地处理 - 在 AgentLoop 中添加 process_system_announcement 方法用于在无常驻 run() 场景下处理系统公告 - 创建 InboundMessage 并通过 _process_message 进行处理 feat(cron): 改进定时任务的会话路由解析和实时更新 - 添加 _resolve_cron_session_key 和 _infer_cron_route_from_session_key 辅助函数 - 在 cron 任务执行完成后通过 WebSocket 广播会话更新事件 - 在添加定时任务时自动推断目标会话的渠道和聊天 ID refactor: 项目名称从 Boardware Genius 统一改为 Boardware Agent Sandbox - 更新前端页面标题和描述文本中的产品名称 - 添加新的品牌 Logo 图片资源 - 在前端布局中使用新的 Logo 显示 - 更新授权门户中的品牌信息和 Logo 显示 feat(frontend): 添加会话更新事件监听实现消息自动刷新 - 定义 SessionUpdatedEvent 类型接口 - 在 ChatPage 中添加会话更新事件的处理逻辑 - 当收到会话更新事件时自动重新加载会话列表和当前会话消息 feat(api): 扩展定时任务 API 支持会话键参数 - 在 addCronJob API 参数中添加 session_key 字段 - 更新前端 Cron 页面的表单处理以传递当前会话键
This commit is contained in:
@ -40,7 +40,7 @@ from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronExecutionResult, CronJob, CronSchedule
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.utils.helpers import get_cron_store_path
|
||||
from nanobot.utils.helpers import get_cron_store_path, parse_session_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.channels.web import WebChannel
|
||||
@ -477,6 +477,60 @@ class HandoffConsumeRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class WebSocketBroadcaster:
|
||||
"""Track authenticated websocket connections and broadcast JSON events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._connections: dict[int, tuple[WebSocket, asyncio.Lock]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def register(self, websocket: WebSocket, send_lock: asyncio.Lock) -> None:
|
||||
async with self._lock:
|
||||
self._connections[id(websocket)] = (websocket, send_lock)
|
||||
|
||||
async def unregister(self, websocket: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
self._connections.pop(id(websocket), None)
|
||||
|
||||
async def broadcast(self, payload: dict[str, Any]) -> None:
|
||||
async with self._lock:
|
||||
targets = list(self._connections.items())
|
||||
|
||||
stale: list[int] = []
|
||||
for key, (websocket, send_lock) in targets:
|
||||
try:
|
||||
async with send_lock:
|
||||
await websocket.send_text(json.dumps(payload))
|
||||
except Exception:
|
||||
stale.append(key)
|
||||
|
||||
if stale:
|
||||
async with self._lock:
|
||||
for key in stale:
|
||||
self._connections.pop(key, None)
|
||||
|
||||
|
||||
def _resolve_cron_session_key(job: CronJob) -> str:
|
||||
"""Mirror cron runtime session resolution for web-side notifications."""
|
||||
if job.payload.session_key:
|
||||
return job.payload.session_key
|
||||
if job.payload.channel and job.payload.to:
|
||||
return f"{job.payload.channel}:{job.payload.to}"
|
||||
return f"cron:{job.id}"
|
||||
|
||||
|
||||
def _infer_cron_route_from_session_key(session_key: str | None) -> tuple[str | None, str | None]:
|
||||
"""Best-effort route inference so cron jobs can target the correct web chat."""
|
||||
normalized = (session_key or "").strip()
|
||||
if not normalized:
|
||||
return None, None
|
||||
try:
|
||||
channel, chat_id = parse_session_key(normalized)
|
||||
except ValueError:
|
||||
return None, None
|
||||
return channel, chat_id
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# App factory
|
||||
# ============================================================================
|
||||
@ -503,6 +557,7 @@ def create_app(
|
||||
config = load_config()
|
||||
|
||||
app = FastAPI(title="nanobot", version="0.1.0")
|
||||
websocket_broadcaster = WebSocketBroadcaster()
|
||||
|
||||
# CORS for frontend dev server
|
||||
app.add_middleware(
|
||||
@ -539,15 +594,48 @@ def create_app(
|
||||
authz_config=config.authz,
|
||||
backend_identity=config.backend_identity,
|
||||
)
|
||||
|
||||
async def _handle_direct_delegation_announcement(
|
||||
content: str,
|
||||
origin: dict[str, str],
|
||||
sender_id: str,
|
||||
notify_session_update: bool,
|
||||
) -> None:
|
||||
origin_channel = str(origin.get("channel") or "cli").strip() or "cli"
|
||||
origin_chat_id = str(origin.get("chat_id") or "direct").strip() or "direct"
|
||||
await agent.process_system_announcement(
|
||||
content,
|
||||
origin_channel=origin_channel,
|
||||
origin_chat_id=origin_chat_id,
|
||||
sender_id=sender_id,
|
||||
)
|
||||
if notify_session_update and origin_channel == "web":
|
||||
await websocket_broadcaster.broadcast({
|
||||
"type": "session_updated",
|
||||
"session_id": f"{origin_channel}:{origin_chat_id}",
|
||||
"source": "delegation",
|
||||
})
|
||||
|
||||
agent.delegation.set_direct_announcement_callback(_handle_direct_delegation_announcement)
|
||||
# Single-user mode: cron jobs execute via the same in-process agent.
|
||||
async def on_cron_job(job: CronJob) -> CronExecutionResult:
|
||||
return await run_cron_job(
|
||||
result = await run_cron_job(
|
||||
job,
|
||||
agent=agent,
|
||||
bus=bus,
|
||||
default_channel="web",
|
||||
default_chat_id="default",
|
||||
)
|
||||
target_session_key = _resolve_cron_session_key(job)
|
||||
if job.payload.kind == "agent_turn" and target_session_key.startswith("web:"):
|
||||
await websocket_broadcaster.broadcast({
|
||||
"type": "session_updated",
|
||||
"session_id": target_session_key,
|
||||
"source": "cron",
|
||||
"job_id": job.id,
|
||||
"job_name": job.name,
|
||||
})
|
||||
return result
|
||||
|
||||
cron_service.on_job = on_cron_job
|
||||
|
||||
@ -592,6 +680,7 @@ def create_app(
|
||||
app.state.cron_service = cron_service
|
||||
app.state.bus = bus
|
||||
app.state.web_channel = web_channel # may be None in standalone
|
||||
app.state.websocket_broadcaster = websocket_broadcaster
|
||||
app.state.auth_tokens: dict[str, str] = {}
|
||||
app.state.handoff_codes: dict[str, dict[str, Any]] = {}
|
||||
app.state.auth_file = _get_auth_file_path()
|
||||
@ -1482,6 +1571,8 @@ def _register_routes(app: FastAPI) -> None:
|
||||
|
||||
await websocket.accept()
|
||||
send_lock = asyncio.Lock()
|
||||
broadcaster: WebSocketBroadcaster = app.state.websocket_broadcaster
|
||||
await broadcaster.register(websocket, send_lock)
|
||||
|
||||
if web_channel is not None:
|
||||
web_channel.register_connection(session_id, websocket)
|
||||
@ -1571,6 +1662,7 @@ def _register_routes(app: FastAPI) -> None:
|
||||
finally:
|
||||
if web_channel is not None:
|
||||
web_channel.unregister_connection(session_id, websocket)
|
||||
await broadcaster.unregister(websocket)
|
||||
|
||||
# ------ Sessions ------
|
||||
|
||||
@ -1841,6 +1933,13 @@ def _register_routes(app: FastAPI) -> None:
|
||||
"""Add a new cron job."""
|
||||
cron: CronService = app.state.cron_service
|
||||
normalized_mode = (req.mode or "").strip().lower()
|
||||
normalized_session_key = (req.session_key or "").strip() or None
|
||||
normalized_channel = (req.channel or "").strip() or None
|
||||
normalized_to = (req.to or "").strip() or None
|
||||
if normalized_session_key and (not normalized_channel or not normalized_to):
|
||||
inferred_channel, inferred_to = _infer_cron_route_from_session_key(normalized_session_key)
|
||||
normalized_channel = normalized_channel or inferred_channel
|
||||
normalized_to = normalized_to or inferred_to
|
||||
if normalized_mode and normalized_mode not in {"reminder", "task"}:
|
||||
raise HTTPException(status_code=400, detail="mode must be 'reminder' or 'task'")
|
||||
# reminder 直接发消息,task 则进入 agent 自动执行。
|
||||
@ -1862,10 +1961,10 @@ def _register_routes(app: FastAPI) -> None:
|
||||
schedule=schedule,
|
||||
message=req.message,
|
||||
payload_kind=payload_kind,
|
||||
session_key=req.session_key,
|
||||
session_key=normalized_session_key,
|
||||
deliver=req.deliver,
|
||||
channel=req.channel,
|
||||
to=req.to,
|
||||
channel=normalized_channel,
|
||||
to=normalized_to,
|
||||
)
|
||||
return _serialize_job(job)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user