Files
beaver_project/app-instance/backend/nanobot/web/server.py
steven_li 0c180f48f2 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 页面的表单处理以传递当前会话键
2026-03-18 14:34:25 +08:00

2833 lines
109 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""FastAPI web server for nanobot frontend."""
from __future__ import annotations
import asyncio
import ipaddress
import json
import os
import re
import secrets
import shutil
import time
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit, urlunsplit
import httpx
from fastapi import (
FastAPI,
File,
Form,
Header,
HTTPException,
Request,
UploadFile,
WebSocket,
WebSocketDisconnect,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from loguru import logger
from pydantic import BaseModel, Field
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import Config
from nanobot.cron.runtime import run_cron_job
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, parse_session_key
if TYPE_CHECKING:
from nanobot.channels.web import WebChannel
def _has_backend_identity(config: Config) -> bool:
return bool(
config.backend_identity.backend_id
and config.backend_identity.client_id
and config.backend_identity.client_secret
)
def _frontend_port() -> int:
raw = os.getenv("NANOBOT_FRONTEND_PORT", "3080").strip()
try:
return int(raw)
except ValueError:
return 3080
def _frontend_public_base_url() -> str:
return os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL", "").strip().rstrip("/")
def _uses_managed_outlook_mcp(config: Config) -> bool:
return bool(
getattr(config, "authz", None)
and config.authz.enabled
and config.authz.base_url.strip()
and config.authz.outlook_mcp_url.strip()
)
def _mcp_server_snapshot(server_cfg: Any | None) -> dict[str, Any] | None:
if server_cfg is None:
return None
if hasattr(server_cfg, "model_dump"):
return server_cfg.model_dump(mode="json")
return {
"command": getattr(server_cfg, "command", ""),
"args": list(getattr(server_cfg, "args", []) or []),
"env": dict(getattr(server_cfg, "env", {}) or {}),
"url": getattr(server_cfg, "url", ""),
"headers": dict(getattr(server_cfg, "headers", {}) or {}),
"auth_mode": getattr(server_cfg, "auth_mode", ""),
"auth_audience": getattr(server_cfg, "auth_audience", ""),
"auth_scopes": list(getattr(server_cfg, "auth_scopes", []) or []),
"tool_timeout": int(getattr(server_cfg, "tool_timeout", 30)),
"sensitive": bool(getattr(server_cfg, "sensitive", False)),
}
async def _reconcile_managed_outlook_mcp(config: Config) -> bool:
if not (_uses_managed_outlook_mcp(config) and _has_backend_identity(config)):
return False
from nanobot.web.outlook import (
OUTLOOK_SERVER_ID,
ensure_outlook_authz_permissions,
ensure_outlook_mcp_registration,
)
before = _mcp_server_snapshot(config.tools.mcp_servers.get(OUTLOOK_SERVER_ID))
ensure_outlook_mcp_registration(config)
await ensure_outlook_authz_permissions(config)
after = _mcp_server_snapshot(config.tools.mcp_servers.get(OUTLOOK_SERVER_ID))
return before != after
# ============================================================================
# Request/Response models
# ============================================================================
class ChatRequest(BaseModel):
message: str
session_id: str = "web:default"
attachments: list[dict[str, str]] | None = None
class ChatResponse(BaseModel):
response: str
session_id: str
class AddCronJobRequest(BaseModel):
# 任务展示名。
name: str
# 提醒文案或 task prompt。
message: str
# `reminder` 直接发消息,`task` 重新进入 agent 执行。
mode: str | None = None
# task 模式可选复用的原会话 key。
session_key: str | None = None
every_seconds: int | None = None
cron_expr: str | None = None
at_iso: str | None = None
deliver: bool = False
channel: str | None = None
to: str | None = None
class ToggleCronJobRequest(BaseModel):
enabled: bool
class AddMarketplaceRequest(BaseModel):
source: str
class ApproveSkillReviewRequest(BaseModel):
overwrite: bool = False
class AddAgentRequest(BaseModel):
# 可选稳定 ID若未提供后端会尝试从 A2A card 推导。
id: str | None = None
name: str | None = None
description: str | None = None
protocol: str = "a2a"
base_url: str | None = None
endpoint: str | None = None
card_url: str | None = None
auth_env: str | None = None
auth_mode: str = "none"
auth_audience: str | None = None
auth_scopes: list[str] = Field(default_factory=list)
enabled: bool = True
tags: list[str] = Field(default_factory=list)
aliases: list[str] = Field(default_factory=list)
metadata: dict[str, Any] | None = None
_AGENT_CARD_PATHS = (
"/.well-known/agent-card",
"/.well-known/agent-card.json",
"/.well-known/agent.json",
)
_AGENT_ID_SANITIZE_RE = re.compile(r"[^a-z0-9]+")
def _first_text(*values: Any) -> str | None:
for value in values:
text = str(value or "").strip()
if text:
return text
return None
def _dedupe_texts(*groups: Any) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for group in groups:
if not isinstance(group, list):
continue
for item in group:
text = str(item or "").strip()
if not text:
continue
key = text.lower()
if key in seen:
continue
seen.add(key)
result.append(text)
return result
def _is_localish_host(host: str) -> bool:
probe = host.strip().strip("[]").lower()
if not probe:
return False
if probe in {"localhost", "127.0.0.1", "0.0.0.0", "::1", "::"} or probe.endswith(".local"):
return True
try:
ip = ipaddress.ip_address(probe)
except ValueError:
return False
return bool(ip.is_private or ip.is_loopback or ip.is_unspecified or ip.is_link_local)
def _normalize_probe_urls(raw_value: str) -> list[str]:
value = raw_value.strip()
if not value:
return []
raw_candidates: list[str] = []
if "://" in value:
raw_candidates.append(value)
else:
host = urlsplit(f"//{value}").hostname or ""
schemes = ["http", "https"] if _is_localish_host(host) else ["https", "http"]
raw_candidates.extend(f"{scheme}://{value}" for scheme in schemes)
result: list[str] = []
seen: set[str] = set()
for candidate in raw_candidates:
parsed = urlsplit(candidate)
normalized = urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")).rstrip("/")
if not normalized:
continue
variants = [normalized]
origin = urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/")
if origin and origin.lower() != normalized.lower():
variants.append(origin)
for variant in variants:
key = variant.lower()
if key in seen:
continue
seen.add(key)
result.append(variant)
return result
def _looks_like_agent_card_url(url: str) -> bool:
path = urlsplit(url).path.rstrip("/").lower()
return any(path.endswith(candidate.rstrip("/")) for candidate in _AGENT_CARD_PATHS)
def _slugify_agent_id(*values: Any) -> str:
for value in values:
text = str(value or "").strip().lower()
if not text:
continue
slug = _AGENT_ID_SANITIZE_RE.sub("-", text).strip("-")
if slug:
return slug
return "a2a-agent"
def _card_supports_group(card: dict[str, Any]) -> bool:
if "support_group" in card:
return bool(card.get("support_group"))
capabilities = card.get("capabilities")
if not isinstance(capabilities, dict):
return True
group = capabilities.get("group")
if isinstance(group, dict):
for key in ("enabled", "supported"):
if key in group:
return bool(group.get(key))
return True
if group is None:
return True
return bool(group)
async def _discover_agent_payload(
req: AddAgentRequest,
config: Config,
) -> dict[str, Any]:
from nanobot.a2a.client import A2AClient
from nanobot.agent.agent_registry import AgentDescriptor
probe_inputs = [req.card_url, req.endpoint, req.base_url]
if not any(str(item or "").strip() for item in probe_inputs):
raise ValueError("missing probe input")
client = A2AClient(
timeout_seconds=config.tools.a2a.timeout_seconds,
card_cache_ttl_seconds=0,
allowed_hosts=config.tools.a2a.allowed_hosts,
)
last_error: Exception | None = None
for probe_input in probe_inputs:
text = str(probe_input or "").strip()
if not text:
continue
for normalized in _normalize_probe_urls(text):
descriptor = AgentDescriptor(
id=_slugify_agent_id(req.id, req.name, normalized, "a2a-agent"),
name=_first_text(req.name, req.id, "A2A Agent") or "A2A Agent",
description=_first_text(req.description, req.name, req.id, "A2A Agent") or "A2A Agent",
source="workspace",
kind="a2a_remote",
protocol="a2a",
base_url=None if _looks_like_agent_card_url(normalized) else normalized,
endpoint=None if _looks_like_agent_card_url(normalized) else normalized,
card_url=normalized if _looks_like_agent_card_url(normalized) else None,
auth_env=req.auth_env,
auth_mode=(req.auth_mode or "none").strip().lower() or "none",
auth_audience=req.auth_audience,
auth_scopes=list(req.auth_scopes),
)
try:
discovered_card_url, card = await client.fetch_agent_card_with_url(descriptor)
except Exception as exc:
last_error = exc
continue
primary_url = _first_text(
client._resolve_primary_url(card, descriptor),
descriptor.endpoint,
descriptor.base_url,
)
agent_id = _slugify_agent_id(
req.id,
card.get("id"),
card.get("name"),
primary_url,
discovered_card_url,
)
name = _first_text(req.name, card.get("name"), req.id, agent_id) or agent_id
description = _first_text(req.description, card.get("description"), name) or name
auth_mode = _first_text(
req.auth_mode if req.auth_mode != "none" else None,
card.get("auth_mode"),
"none",
) or "none"
return {
"id": agent_id,
"name": name,
"description": description,
"protocol": "a2a",
"base_url": _first_text(descriptor.base_url, primary_url),
"endpoint": _first_text(primary_url, descriptor.endpoint, descriptor.base_url),
"card_url": _first_text(discovered_card_url, req.card_url),
"auth_env": _first_text(req.auth_env, card.get("auth_env")),
"auth_mode": auth_mode.strip().lower() or "none",
"auth_audience": _first_text(req.auth_audience, card.get("auth_audience")),
"auth_scopes": _dedupe_texts(req.auth_scopes, card.get("auth_scopes")),
"enabled": req.enabled,
"tags": _dedupe_texts(req.tags, card.get("tags")),
"aliases": _dedupe_texts(req.aliases, card.get("aliases")),
"capabilities": card.get("capabilities") if isinstance(card.get("capabilities"), dict) else {},
"support_group": _card_supports_group(card),
"support_streaming": client._supports_streaming(card),
"metadata": dict(req.metadata or {}),
}
if last_error:
raise last_error
raise ValueError("agent card discovery failed")
def _manual_agent_payload(req: AddAgentRequest) -> dict[str, Any]:
agent_id = _first_text(req.id)
if not agent_id:
raise HTTPException(status_code=400, detail="缺少智能体 ID且无法从 A2A card 自动发现")
name = _first_text(req.name, agent_id) or agent_id
return {
"id": agent_id,
"name": name,
"description": _first_text(req.description, req.name, agent_id) or name,
"protocol": req.protocol,
"base_url": req.base_url,
"endpoint": req.endpoint,
"card_url": req.card_url,
"auth_env": req.auth_env,
"auth_mode": (req.auth_mode or "none").strip().lower() or "none",
"auth_audience": req.auth_audience,
"auth_scopes": _dedupe_texts(req.auth_scopes),
"enabled": req.enabled,
"tags": _dedupe_texts(req.tags),
"aliases": _dedupe_texts(req.aliases),
"metadata": dict(req.metadata or {}),
}
def _should_auto_discover_agent(req: AddAgentRequest) -> bool:
has_probe = any(str(value or "").strip() for value in (req.base_url, req.endpoint, req.card_url))
is_complete_manual_entry = bool(
_first_text(req.id)
and _first_text(req.name)
and _first_text(req.description)
and (_first_text(req.endpoint) or _first_text(req.card_url))
)
return has_probe and not is_complete_manual_entry
class MCPServerRequest(BaseModel):
# MCP server 的稳定配置 ID。
id: str
command: str = ""
args: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(default_factory=dict)
url: str = ""
headers: dict[str, str] = Field(default_factory=dict)
auth_mode: str = "none"
auth_audience: str = ""
auth_scopes: list[str] = Field(default_factory=list)
tool_timeout: int = 30
sensitive: bool = False
class OutlookConnectionRequest(BaseModel):
email: str
password: str
username: str | None = None
domain: str | None = None
service_endpoint: str | None = None
server: str | None = None
autodiscover: bool = False
default_timezone: str = "Asia/Shanghai"
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
email: str | None = None
password: str
authz_base_url: str | None = None
backend_name: str | None = None
backend_id: str | None = None
base_url: str | None = None
frontend_base_url: str | None = None
class AuthzRegisterBackendRequest(BaseModel):
name: str | None = None
backend_id: str | None = None
base_url: str | None = None
frontend_base_url: str | None = None
save_to_backend: bool = True
authz_base_url: str | None = None
class LocalBackendIdentityRequest(BaseModel):
backend_id: str
client_id: str
client_secret: str
name: str | None = None
public_base_url: str | None = None
authz_base_url: str | None = None
authz_enabled: bool = True
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
# ============================================================================
def create_app(
*,
bus: MessageBus | None = None,
web_channel: "WebChannel | None" = None,
session_manager: SessionManager | None = None,
config: Config | None = None,
cron_service: CronService | None = None,
) -> FastAPI:
"""Create and configure the FastAPI application.
Two modes:
- **Gateway mode** (bus + web_channel provided): messages go through the
MessageBus; the WebChannel's ``_handle_message`` publishes inbound
messages and the AgentLoop processes them asynchronously.
- **Standalone mode** (no bus): creates its own AgentLoop and uses
``process_direct()`` for synchronous request-response (legacy).
"""
if config is None:
config = load_config()
app = FastAPI(title="nanobot", version="0.1.0")
websocket_broadcaster = WebSocketBroadcaster()
# CORS for frontend dev server
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Standalone fallback: create an isolated AgentLoop when no bus provided
if bus is None:
from nanobot.agent.loop import AgentLoop
bus = MessageBus()
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
cron_store_path = get_cron_store_path(config.workspace_path)
cron_service = CronService(cron_store_path)
agent = AgentLoop(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
max_iterations=config.agents.defaults.max_tool_iterations,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
a2a_config=config.tools.a2a,
cron_service=cron_service,
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager,
mcp_servers=config.tools.mcp_servers,
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:
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
@app.on_event("startup")
async def _startup() -> None:
should_reload_mcp = False
try:
if _uses_managed_outlook_mcp(app.state.config) and _has_backend_identity(app.state.config):
config_changed = await _reconcile_managed_outlook_mcp(app.state.config)
if config_changed:
save_config(app.state.config, app.state.config_path)
should_reload_mcp = True
except Exception as exc:
logger.warning("Managed Outlook MCP startup reconciliation failed: {}", exc)
if should_reload_mcp:
try:
await agent.reload_mcp_servers(app.state.config.tools.mcp_servers)
except Exception as exc:
logger.warning("Managed Outlook MCP reload failed during startup: {}", exc)
await cron_service.start()
@app.on_event("shutdown")
async def _shutdown() -> None:
cron_service.stop()
agent.stop()
await agent.close_mcp()
app.state.agent = agent
else:
app.state.agent = None # gateway mode no standalone agent
if session_manager is None:
session_manager = SessionManager(config.workspace_path)
if cron_service is None:
cron_store_path = get_cron_store_path(config.workspace_path)
cron_service = CronService(cron_store_path)
app.state.config = config
app.state.config_path = get_config_path()
app.state.session_manager = session_manager
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()
_register_routes(app)
return app
def _make_provider(config: Config):
"""Create LLM provider from config."""
from nanobot.providers.custom_provider import CustomProvider
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
p = config.get_provider(model)
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model)
if provider_name == "custom":
return CustomProvider(
api_key=p.api_key if p else "no-key",
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
default_model=model,
)
if not (p and p.api_key) and not model.startswith("bedrock/"):
raise RuntimeError("No API key configured. Set one in ~/.nanobot/config.json")
return LiteLLMProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
provider_name=provider_name,
)
# ============================================================================
# Routes
# ============================================================================
def _with_attachment_hints(content: str, media_paths: list[str]) -> str:
"""Append local attachment paths so the agent can open them via file tools."""
if not media_paths:
return content
hints = "\n".join(f"- {p}" for p in media_paths)
return f"{content}\n\n[Attached files]\n{hints}"
def _resolve_attachment_paths(
workspace: Path,
attachments: list[dict[str, str]] | None,
) -> list[str]:
"""Resolve uploaded attachment ids to local file paths."""
if not attachments:
return []
from nanobot.web.files import get_file_path
media_paths: list[str] = []
for attachment in attachments:
# 前端上传接口约定附件通过 `file_id` 引用本地已缓存文件。
file_id = attachment.get("file_id", "")
if not file_id:
continue
file_path = get_file_path(workspace, file_id)
if file_path:
media_paths.append(str(file_path))
return media_paths
def _get_auth_file_path() -> Path:
"""Resolve local auth file path for web login."""
env = os.getenv("NANOBOT_AUTH_FILE", "").strip()
if env:
return Path(env).expanduser()
# Default to project root: <repo>/web_auth_users.json
return Path(__file__).resolve().parents[2] / "web_auth_users.json"
def _load_auth_users(path: Path) -> dict[str, str]:
"""Load users from local JSON file.
Supported formats:
1) {"users":[{"username":"admin","password":"123456"}]}
2) {"accounts":[{"username":"admin","password":"123456"}]}
3) {"admin":"123456","alice":"pwd"}
4) [{"username":"admin","password":"123456"}]
"""
if not path.exists():
raise ValueError(f"Auth file not found: {path}")
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
raise ValueError(f"Failed to parse auth file: {e}") from e
users: dict[str, str] = {}
def _add_from_list(items: list[Any]) -> None:
for item in items:
if not isinstance(item, dict):
continue
username = (
item.get("username")
or item.get("user")
or item.get("account")
)
password = item.get("password") or item.get("pass") or item.get("pwd")
if isinstance(username, str) and isinstance(password, str) and username.strip():
users[username.strip()] = password
if isinstance(raw, list):
_add_from_list(raw)
elif isinstance(raw, dict):
user_list = raw.get("users")
if isinstance(user_list, list):
_add_from_list(user_list)
account_list = raw.get("accounts")
if isinstance(account_list, list):
_add_from_list(account_list)
for k, v in raw.items():
if k in {"users", "accounts"}:
continue
if isinstance(k, str) and isinstance(v, str):
users[k.strip()] = v
if not users:
raise ValueError(
"No valid users found in auth file. "
"Use {'users':[{'username':'admin','password':'123456'}]} or {'admin':'123456'}"
)
return users
def _save_auth_users(path: Path, users: dict[str, str]) -> None:
"""Persist web login users in a stable JSON shape."""
path.parent.mkdir(parents=True, exist_ok=True)
data = {
"users": [
{"username": username, "password": password}
for username, password in sorted(users.items())
]
}
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
tmp_path.write_text(
json.dumps(data, indent=2, ensure_ascii=False),
encoding="utf-8",
)
tmp_path.replace(path)
def _issue_web_token(app: FastAPI, username: str) -> str:
token = secrets.token_urlsafe(32)
app.state.auth_tokens[token] = username
return token
def _handoff_ttl_seconds() -> int:
raw = os.getenv("NANOBOT_HANDOFF_CODE_TTL_SECONDS", "90").strip()
try:
return max(15, int(raw))
except ValueError:
return 90
def _handoff_replay_window_seconds() -> int:
raw = os.getenv("NANOBOT_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip()
try:
return max(1, int(raw))
except ValueError:
return 15
def _prune_handoff_codes(app: FastAPI) -> None:
now = time.time()
replay_window = _handoff_replay_window_seconds()
expired: list[str] = []
for code, payload in list(app.state.handoff_codes.items()):
expires_at = float(payload.get("expires_at") or 0)
consumed_at = payload.get("consumed_at")
if expires_at <= now:
expired.append(code)
continue
if consumed_at is not None and (now - float(consumed_at)) > replay_window:
expired.append(code)
for code in expired:
app.state.handoff_codes.pop(code, None)
def _issue_handoff_code(app: FastAPI, username: str, access_token: str, refresh_token: str = "") -> tuple[str, int]:
_prune_handoff_codes(app)
code = secrets.token_urlsafe(24)
expires_at = int(time.time()) + _handoff_ttl_seconds()
app.state.handoff_codes[code] = {
"username": username,
"access_token": access_token,
"refresh_token": refresh_token,
"expires_at": expires_at,
"consumed_at": None,
}
return code, expires_at
def _consume_handoff_code(app: FastAPI, code: str) -> dict[str, Any]:
if not code.strip():
raise HTTPException(status_code=400, detail="Handoff code is required")
_prune_handoff_codes(app)
payload = app.state.handoff_codes.get(code)
if payload is None:
raise HTTPException(status_code=401, detail="Invalid or expired handoff code")
now = time.time()
expires_at = float(payload.get("expires_at") or 0)
if expires_at <= now:
app.state.handoff_codes.pop(code, None)
raise HTTPException(status_code=410, detail="Handoff code expired")
consumed_at = payload.get("consumed_at")
if consumed_at is None:
payload["consumed_at"] = now
elif now - float(consumed_at) > _handoff_replay_window_seconds():
app.state.handoff_codes.pop(code, None)
raise HTTPException(status_code=410, detail="Handoff code already used")
username = str(payload.get("username") or "").strip()
access_token = str(payload.get("access_token") or "").strip()
refresh_token = str(payload.get("refresh_token") or "")
if not username or not access_token:
app.state.handoff_codes.pop(code, None)
raise HTTPException(status_code=401, detail="Invalid handoff payload")
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"user_id": username,
"username": username,
"role": "owner",
}
def _require_web_user(app: FastAPI, authorization: str | None) -> str:
"""Validate bearer token and return username."""
if not authorization:
raise HTTPException(status_code=401, detail="Missing Authorization header")
prefix = "bearer "
if not authorization.lower().startswith(prefix):
raise HTTPException(status_code=401, detail="Invalid Authorization header")
token = authorization[len(prefix):].strip()
if not token:
raise HTTPException(status_code=401, detail="Invalid token")
username = app.state.auth_tokens.get(token)
if not username:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return username
def _register_routes(app: FastAPI) -> None:
"""Register all API routes."""
def _get_agent_loop():
return app.state.agent
def _get_agent_registry():
# 单机 standalone 模式优先复用运行中的 registry保证与当前 agent 配置一致。
from nanobot.agent.agent_registry import AgentRegistry
agent = _get_agent_loop()
if agent is not None and hasattr(agent, "agent_registry"):
return agent.agent_registry
config: Config = app.state.config
return AgentRegistry(
config.workspace_path,
allow_skill_cards=config.tools.a2a.allow_skill_cards,
allow_workspace_agents=config.tools.a2a.allow_workspace_agents,
)
def _save_app_config(config: Config) -> None:
# 同时更新 app.state 和配置文件,保证后续请求读到的是新配置。
app.state.config = config
save_config(config, app.state.config_path)
agent = _get_agent_loop()
if agent is not None and hasattr(agent, "apply_runtime_config"):
agent.apply_runtime_config(
authz_config=config.authz,
backend_identity=config.backend_identity,
)
def _require_authenticated_user(authorization: str | None = Header(default=None)) -> str:
return _require_web_user(app, authorization)
def _normalize_client_base_url(base_url: str, request: Request | None = None) -> str:
value = base_url.strip().rstrip("/")
if not value:
return value
parts = urlsplit(value)
if parts.hostname not in {"0.0.0.0", "::"} or request is None:
return value
request_parts = urlsplit(str(request.base_url).rstrip("/"))
host = request_parts.hostname or "127.0.0.1"
port = parts.port
if ":" in host and not host.startswith("["):
host = f"[{host}]"
netloc = f"{host}:{port}" if port is not None else host
scheme = parts.scheme or request_parts.scheme or "http"
return urlunsplit((scheme, netloc, parts.path, parts.query, parts.fragment)).rstrip("/")
def _resolve_local_backend_base_url(config: Config, request: Request | None = None) -> str:
explicit = (config.backend_identity.public_base_url or "").strip()
if explicit:
return _normalize_client_base_url(explicit, request)
if request is not None:
return str(request.base_url).rstrip("/")
return "http://127.0.0.1:18080"
def _resolve_local_frontend_base_url(config: Config, request: Request | None = None) -> str:
explicit = _frontend_public_base_url()
if explicit:
return _normalize_client_base_url(explicit, request)
api_base_url = _resolve_local_backend_base_url(config, request)
api_parts = urlsplit(api_base_url)
frontend_host = api_parts.hostname or "127.0.0.1"
frontend_port = _frontend_port()
if ":" in frontend_host and not frontend_host.startswith("["):
frontend_host = f"[{frontend_host}]"
frontend_netloc = f"{frontend_host}:{frontend_port}" if frontend_port else frontend_host
return urlunsplit((api_parts.scheme or "http", frontend_netloc, "", "", "")).rstrip("/")
def _local_backend_view(config: Config) -> dict[str, Any]:
return {
"backend_id": config.backend_identity.backend_id,
"client_id": config.backend_identity.client_id,
"name": config.backend_identity.name,
"public_base_url": config.backend_identity.public_base_url,
"authz": {
"enabled": config.authz.enabled,
"base_url": config.authz.base_url,
},
}
def _backend_connection_view(config: Config, request: Request | None = None) -> dict[str, Any]:
api_base_url = _resolve_local_backend_base_url(config, request)
ws_parts = urlsplit(api_base_url)
ws_scheme = "wss" if ws_parts.scheme == "https" else "ws"
ws_base_url = urlunsplit((ws_scheme, ws_parts.netloc, ws_parts.path, ws_parts.query, ws_parts.fragment)).rstrip("/")
frontend_base_url = _resolve_local_frontend_base_url(config, request)
return {
"backend_id": config.backend_identity.backend_id or None,
"client_id": config.backend_identity.client_id or None,
"name": config.backend_identity.name or None,
"public_base_url": api_base_url or None,
"api_base_url": api_base_url or None,
"ws_base_url": ws_base_url or None,
"frontend_base_url": frontend_base_url or None,
"registered": _has_backend_identity(config),
}
async def _build_backend_connection_view(config: Config, request: Request | None = None) -> dict[str, Any]:
local_view = _backend_connection_view(config, request)
if not (
config.authz.enabled
and config.authz.base_url.strip()
and config.backend_identity.backend_id.strip()
):
return local_view
backend_id = config.backend_identity.backend_id.strip()
desired_name = (config.backend_identity.name or backend_id).strip() or backend_id
desired_api_base_url = local_view.get("api_base_url") or None
desired_frontend_base_url = local_view.get("frontend_base_url") or None
try:
client = _authz_client(config)
try:
await client.update_backend(
backend_id,
name=desired_name,
base_url=str(desired_api_base_url or "").strip() or None,
frontend_base_url=str(desired_frontend_base_url or "").strip() or None,
)
except httpx.HTTPStatusError as exc:
if exc.response.status_code != 404:
raise
authz_backend = await client.get_backend(backend_id)
except httpx.HTTPError as exc:
logger.warning("Failed to resolve backend routing from AuthZ: {}", exc)
return local_view
authz_api_base_url = _normalize_client_base_url(
str(authz_backend.get("base_url") or desired_api_base_url or ""),
request,
)
if not authz_api_base_url:
return local_view
authz_frontend_base_url = _normalize_client_base_url(
str(authz_backend.get("frontend_base_url") or desired_frontend_base_url or ""),
request,
) or str(desired_frontend_base_url or "")
ws_parts = urlsplit(authz_api_base_url)
ws_scheme = "wss" if ws_parts.scheme == "https" else "ws"
ws_base_url = urlunsplit((ws_scheme, ws_parts.netloc, ws_parts.path, ws_parts.query, ws_parts.fragment)).rstrip("/")
return {
**local_view,
"name": str(authz_backend.get("name") or desired_name or "") or None,
"public_base_url": authz_api_base_url or None,
"api_base_url": authz_api_base_url or None,
"ws_base_url": ws_base_url or None,
"frontend_base_url": authz_frontend_base_url or None,
}
def _save_local_backend_identity(
config: Config,
*,
backend_id: str,
client_id: str,
client_secret: str,
name: str | None = None,
public_base_url: str | None = None,
authz_base_url: str | None = None,
authz_enabled: bool = True,
) -> dict[str, Any]:
config.backend_identity.backend_id = backend_id.strip()
config.backend_identity.client_id = client_id.strip()
config.backend_identity.client_secret = client_secret
config.backend_identity.name = (name or backend_id).strip() or backend_id.strip()
if public_base_url is not None:
config.backend_identity.public_base_url = public_base_url.strip()
if authz_base_url is not None and authz_base_url.strip():
config.authz.base_url = authz_base_url.strip()
if authz_enabled:
config.authz.enabled = True
_save_app_config(config)
return _local_backend_view(config)
def _authz_client(config: Config):
from nanobot.authz.client import AuthzClient
if not config.authz.base_url.strip():
raise HTTPException(status_code=400, detail="AuthZ base URL is not configured")
return AuthzClient(
config.authz.base_url,
timeout_seconds=int(config.authz.request_timeout_seconds),
)
def _coerce_authz_error(exc: httpx.HTTPError) -> HTTPException:
if isinstance(exc, httpx.HTTPStatusError):
detail = exc.response.text.strip() or str(exc)
return HTTPException(status_code=exc.response.status_code, detail=detail)
return HTTPException(status_code=502, detail=f"AuthZ request failed: {exc}")
def _require_local_authz_backend(config: Config) -> tuple[Any, str]:
if not (config.authz.enabled and config.authz.base_url.strip()):
raise HTTPException(status_code=400, detail="AuthZ is not enabled")
backend_id = (config.backend_identity.backend_id or "").strip()
if not backend_id:
raise HTTPException(status_code=400, detail="Local backend is not registered with AuthZ")
return _authz_client(config), backend_id
def _extract_authz_backend_identity(payload: dict[str, Any]) -> dict[str, str] | None:
def _pick_str(candidate: dict[str, Any], *keys: str) -> str:
for key in keys:
value = candidate.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
candidates: list[dict[str, Any]] = [payload]
for key in ("backend", "local_backend", "localBackend", "agent_sandbox", "agentSandbox", "sandbox"):
candidate = payload.get(key)
if isinstance(candidate, dict):
candidates.append(candidate)
for candidate in candidates:
backend_id = _pick_str(candidate, "backend_id", "backendId")
client_secret = _pick_str(candidate, "client_secret", "clientSecret", "secret")
if not backend_id or not client_secret:
continue
client_id = _pick_str(candidate, "client_id", "clientId") or backend_id
created_at = _pick_str(candidate, "created_at", "createdAt") or _pick_str(
payload,
"created_at",
"createdAt",
)
return {
"backend_id": backend_id,
"client_id": client_id,
"client_secret": client_secret,
"created_at": created_at,
}
return None
def _reject_backend_collection_ui() -> None:
raise HTTPException(
status_code=410,
detail=(
"Backend registration moved to /api/auth/register. "
"Sensitive MCP settings should be managed from the MCP detail page."
),
)
@app.middleware("http")
async def _require_api_login(request: Request, call_next):
path = request.url.path
if (
request.method == "OPTIONS"
or not path.startswith("/api/")
or path in {"/api/auth/login", "/api/auth/register", "/api/auth/logout", "/api/auth/handoff/consume", "/api/ping"}
):
return await call_next(request)
try:
_require_web_user(app, request.headers.get("Authorization"))
except HTTPException as exc:
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
return await call_next(request)
async def _apply_mcp_runtime_config() -> None:
# 只有 standalone 模式才有可热重载的本地 AgentLoop。
agent = _get_agent_loop()
if agent is None:
return
config: Config = app.state.config
await agent.reload_mcp_servers(config.tools.mcp_servers)
def _mcp_servers_view() -> list[dict[str, Any]]:
# 有运行中 agent 时,优先取其运行态视图;否则回退到纯配置视图。
agent = _get_agent_loop()
if agent is not None and hasattr(agent, "get_mcp_servers_view"):
return agent.get_mcp_servers_view()
config: Config = app.state.config
result: list[dict[str, Any]] = []
for name in sorted(config.tools.mcp_servers):
cfg = config.tools.mcp_servers[name]
sensitive = bool(getattr(cfg, "sensitive", False))
result.append({
"id": name,
"name": name,
"transport": "stdio" if getattr(cfg, "command", "") else "http",
"url": getattr(cfg, "url", "") or None,
"command": getattr(cfg, "command", "") or None,
"args": list(getattr(cfg, "args", []) or []),
"auth_mode": getattr(cfg, "auth_mode", "none") or "none",
"auth_audience": getattr(cfg, "auth_audience", "") or None,
"auth_scopes": [str(item) for item in list(getattr(cfg, "auth_scopes", []) or [])],
"headers": (
{key: "***" for key in dict(getattr(cfg, "headers", {}) or {})}
if sensitive
else dict(getattr(cfg, "headers", {}) or {})
),
"env": (
{key: "***" for key in dict(getattr(cfg, "env", {}) or {})}
if sensitive
else dict(getattr(cfg, "env", {}) or {})
),
"tool_timeout": int(getattr(cfg, "tool_timeout", 30)),
"sensitive": sensitive,
"enabled": True,
"status": "disconnected",
"tool_count": 0,
"tool_names": [],
"last_error": None,
})
return result
async def _safe_ws_send_json(
websocket: WebSocket,
payload: dict[str, Any],
send_lock: asyncio.Lock | None = None,
) -> None:
# WebSocket 下进度事件和最终消息可能并发发送,因此允许传入 send_lock 做串行化。
try:
if send_lock is None:
await websocket.send_text(json.dumps(payload))
else:
async with send_lock:
await websocket.send_text(json.dumps(payload))
except Exception:
logger.debug("Skipping websocket payload after disconnect: {}", payload.get("type"))
# ------ Auth ------
@app.post("/api/auth/login")
async def auth_login(req: LoginRequest, request: Request):
username = req.username.strip()
if not username:
raise HTTPException(status_code=400, detail="Username is required")
auth_file: Path = app.state.auth_file
try:
users = _load_auth_users(auth_file)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
expected = users.get(username)
if expected is None or not secrets.compare_digest(expected, req.password):
raise HTTPException(status_code=401, detail="Invalid username or password")
token = _issue_web_token(app, username)
handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token)
config: Config = app.state.config
return {
"access_token": token,
"refresh_token": "",
"token_type": "bearer",
"user_id": username,
"username": username,
"role": "owner",
"handoff_code": handoff_code,
"handoff_expires_at": handoff_expires_at,
"backend_connection": await _build_backend_connection_view(config, request),
"local_backend": _local_backend_view(config),
}
@app.get("/api/auth/me")
async def auth_me(authorization: str | None = Header(default=None)):
username = _require_web_user(app, authorization)
return {
"id": username,
"username": username,
"email": "",
"role": "owner",
"quota_tier": "single-user",
}
@app.post("/api/auth/handoff/consume")
async def auth_handoff_consume(req: HandoffConsumeRequest):
return _consume_handoff_code(app, req.code)
@app.post("/api/auth/register")
async def auth_register(req: RegisterRequest, request: Request):
from nanobot.authz.client import AuthzClient
username = req.username.strip()
if not username:
raise HTTPException(status_code=400, detail="Username is required")
if not req.password:
raise HTTPException(status_code=400, detail="Password is required")
auth_file: Path = app.state.auth_file
try:
users = _load_auth_users(auth_file) if auth_file.exists() else {}
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
user_exists = username in users
if user_exists and not secrets.compare_digest(users[username], req.password):
raise HTTPException(
status_code=409,
detail="Username already exists. Use the existing password to finish setup or log in.",
)
config: Config = app.state.config
authz_base_url = (
req.authz_base_url
or (config.authz.base_url if config.authz.enabled else "")
).strip()
authz_user_registered = False
authz_backend_registered = False
local_backend: dict[str, Any] | None = None
existing_backend_registered = _has_backend_identity(config)
requested_backend_id = (req.backend_id or config.backend_identity.backend_id).strip() or None
backend_name = (req.backend_name or config.backend_identity.name or username).strip() or username
public_base_url = (req.base_url or _resolve_local_backend_base_url(config, request)).strip()
frontend_base_url = (req.frontend_base_url or _resolve_local_frontend_base_url(config, request)).strip()
if authz_base_url:
client = AuthzClient(
authz_base_url,
timeout_seconds=int(config.authz.request_timeout_seconds),
)
authz_payload: dict[str, Any] = {}
try:
authz_payload = await client.register_user(
username=username,
password=req.password,
email=req.email,
backend_name=backend_name,
backend_id=requested_backend_id,
base_url=public_base_url,
frontend_base_url=frontend_base_url,
)
authz_user_registered = bool(authz_payload)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 409:
# Allow retrying registration to complete backend/AuthZ setup
# when the user record already exists upstream.
authz_user_registered = True
authz_payload = {}
elif exc.response.status_code not in {404, 405}:
raise _coerce_authz_error(exc) from exc
except httpx.HTTPError as exc:
raise _coerce_authz_error(exc) from exc
if existing_backend_registered:
local_backend = _local_backend_view(config)
authz_backend_registered = True
else:
backend_identity = _extract_authz_backend_identity(authz_payload)
if backend_identity is None:
try:
registered_backend = await client.register_backend(
name=backend_name,
base_url=public_base_url,
frontend_base_url=frontend_base_url,
backend_id=requested_backend_id,
)
except httpx.HTTPError as exc:
raise _coerce_authz_error(exc) from exc
backend_identity = {
"backend_id": registered_backend.backend_id,
"client_id": registered_backend.client_id,
"client_secret": registered_backend.client_secret,
"created_at": registered_backend.created_at,
}
local_backend = _save_local_backend_identity(
config,
backend_id=backend_identity["backend_id"],
client_id=backend_identity["client_id"],
client_secret=backend_identity["client_secret"],
name=backend_name,
public_base_url=public_base_url,
authz_base_url=authz_base_url,
authz_enabled=True,
)
authz_backend_registered = True
if _uses_managed_outlook_mcp(config) and _has_backend_identity(config):
try:
config_changed = await _reconcile_managed_outlook_mcp(config)
except httpx.HTTPError as exc:
raise _coerce_authz_error(exc) from exc
if config_changed:
_save_app_config(config)
await _apply_mcp_runtime_config()
if not user_exists:
users[username] = req.password
_save_auth_users(auth_file, users)
token = _issue_web_token(app, username)
handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token)
response: dict[str, Any] = {
"access_token": token,
"refresh_token": "",
"token_type": "bearer",
"user_id": username,
"username": username,
"email": req.email or "",
"role": "owner",
"handoff_code": handoff_code,
"handoff_expires_at": handoff_expires_at,
"existing_user": user_exists,
"authz": {
"enabled": bool(authz_base_url),
"base_url": authz_base_url or None,
"user_registered": authz_user_registered,
"backend_registered": authz_backend_registered,
},
"backend_connection": await _build_backend_connection_view(config, request),
}
if local_backend is not None:
response["local_backend"] = local_backend
return response
@app.post("/api/auth/logout")
async def auth_logout(authorization: str | None = Header(default=None)):
if authorization and authorization.lower().startswith("bearer "):
token = authorization[7:].strip()
if token:
app.state.auth_tokens.pop(token, None)
return {"ok": True}
# ------ Chat ------
@app.post("/api/chat")
async def chat(req: ChatRequest):
"""Send a message.
Gateway mode: publishes to the bus and returns immediately.
Standalone mode: processes synchronously and returns the response.
"""
session_key = req.session_id
config_ref: Config = app.state.config
media_paths = _resolve_attachment_paths(config_ref.workspace_path, req.attachments)
chat_id = session_key.split(":", 1)[-1] if ":" in session_key else session_key
web_channel: "WebChannel | None" = app.state.web_channel
if web_channel is not None:
# Gateway mode async via bus
await web_channel._handle_message(
sender_id="web_user",
chat_id=chat_id,
content=req.message,
media=media_paths or None,
metadata={"attachments": req.attachments} if req.attachments else None,
)
# Notify connected clients that processing started
await web_channel.notify_thinking(chat_id)
return {"status": "accepted", "session_id": session_key}
else:
# Standalone fallback
from nanobot.agent.loop import AgentLoop
agent: AgentLoop = app.state.agent
response = await agent.process_direct(
content=_with_attachment_hints(req.message, media_paths),
session_key=session_key,
channel="web",
chat_id=chat_id,
)
return ChatResponse(response=response, session_id=session_key)
@app.post("/api/chat/stream")
async def chat_stream(req: ChatRequest):
"""Send a message and stream the response via SSE (standalone mode only)."""
from nanobot.agent.loop import AgentLoop
agent: AgentLoop | None = app.state.agent
if agent is None:
raise HTTPException(
status_code=400,
detail="Streaming not available in gateway mode. Use WebSocket.",
)
session_key = req.session_id
config_ref: Config = app.state.config
media_paths = _resolve_attachment_paths(config_ref.workspace_path, req.attachments)
async def event_generator():
yield f"data: {json.dumps({'type': 'start'})}\n\n"
try:
response = await agent.process_direct(
content=_with_attachment_hints(req.message, media_paths),
session_key=session_key,
channel="web",
chat_id=session_key.split(":", 1)[-1] if ":" in session_key else session_key,
)
chunk_size = 20
for i in range(0, len(response), chunk_size):
chunk = response[i : i + chunk_size]
yield f"data: {json.dumps({'type': 'content', 'content': chunk})}\n\n"
await asyncio.sleep(0.02)
yield f"data: {json.dumps({'type': 'done'})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
# ------ WebSocket ------
@app.websocket("/ws/{session_id}")
async def websocket_endpoint(websocket: WebSocket, session_id: str):
"""WebSocket endpoint for real-time chat.
Clients send: {"type":"message","content":"..."}
Server sends: {"type":"message","role":"assistant","content":"..."}
{"type":"status","status":"thinking"}
"""
web_channel: "WebChannel | None" = app.state.web_channel
ws_token = (websocket.query_params.get("token") or "").strip()
if not ws_token or ws_token not in app.state.auth_tokens:
await websocket.close(code=4401)
return
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)
try:
while True:
raw = await websocket.receive_text()
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
if data.get("type") == "ping":
await _safe_ws_send_json(websocket, {"type": "pong"}, send_lock)
continue
if data.get("type") == "cancel_process":
# 取消请求走委派层 run_id 取消;非委派流程会返回 ok=false。
run_id = str(data.get("run_id") or "").strip()
agent = _get_agent_loop()
cancelled = bool(agent and run_id and await agent.delegation.cancel(run_id))
await _safe_ws_send_json(
websocket,
{"type": "process_cancel_ack", "run_id": run_id, "ok": cancelled},
send_lock,
)
continue
if data.get("type") == "message":
content = data.get("content", "").strip()
if not content:
continue
# Extract file attachments if present
attachments = data.get("attachments") or []
config_ref: Config = app.state.config
media_paths = _resolve_attachment_paths(config_ref.workspace_path, attachments)
if web_channel is not None:
# Gateway mode publish via bus
await web_channel._handle_message(
sender_id="web_user",
chat_id=session_id,
content=content,
media=media_paths or None,
metadata={"attachments": attachments} if attachments else None,
)
await web_channel.notify_thinking(session_id)
else:
# Standalone fallback process directly
from nanobot.agent.loop import AgentLoop
agent: AgentLoop = app.state.agent
session_key = f"web:{session_id}"
await _safe_ws_send_json(
websocket,
{"type": "status", "status": "thinking"},
send_lock,
)
async def _process_sink(event: dict[str, Any]) -> None:
# 给直连 WebSocket 模式补上 session_id前端可按会话归档过程事件。
payload = {"session_id": session_key, **event}
await _safe_ws_send_json(websocket, payload, send_lock)
response = await agent.process_direct(
content=_with_attachment_hints(content, media_paths),
session_key=session_key,
channel="web",
chat_id=session_id,
process_event_callback=_process_sink,
)
await _safe_ws_send_json(
websocket,
{
"type": "message",
"role": "assistant",
"content": response,
},
send_lock,
)
except WebSocketDisconnect:
logger.debug(f"WebSocket disconnected for session {session_id}")
except Exception as e:
logger.error(f"WebSocket error for session {session_id}: {e}")
finally:
if web_channel is not None:
web_channel.unregister_connection(session_id, websocket)
await broadcaster.unregister(websocket)
# ------ Sessions ------
@app.get("/api/sessions")
async def list_sessions():
"""List all conversation sessions."""
sm: SessionManager = app.state.session_manager
return sm.list_sessions()
def _serialize_session_detail(session: Session) -> dict[str, Any]:
"""Build the filtered session payload returned to the web UI."""
# Filter out tool messages and assistant messages with tool_calls
# (intermediate steps), only keep user messages and final assistant replies
visible_messages = []
for m in session.messages:
role = m.get("role", "")
# Skip tool result messages (e.g. SKILL.md content, file reads, etc.)
if role == "tool":
continue
# Skip assistant messages that are just tool call requests (not final replies)
if role == "assistant" and m.get("tool_calls"):
continue
msg_data: dict[str, Any] = {
"role": role,
"content": m.get("content", ""),
"timestamp": m.get("timestamp"),
}
# Include attachments if stored in metadata
meta = m.get("metadata")
if isinstance(meta, dict):
attachments = meta.get("attachments")
if attachments:
msg_data["attachments"] = attachments
visible_messages.append(msg_data)
return {
"key": session.key,
"messages": visible_messages,
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
}
@app.post("/api/sessions/{key:path}")
async def create_session(key: str):
"""Create or persist a session immediately."""
sm: SessionManager = app.state.session_manager
session = sm.get_or_create(key)
sm.save(session)
return _serialize_session_detail(session)
@app.get("/api/sessions/{key:path}")
async def get_session(key: str):
"""Get a session's message history."""
sm: SessionManager = app.state.session_manager
session = sm.get_or_create(key)
return _serialize_session_detail(session)
@app.delete("/api/sessions/{key:path}")
async def delete_session(key: str):
"""Delete a session."""
sm: SessionManager = app.state.session_manager
if sm.delete(key):
return {"ok": True}
raise HTTPException(status_code=404, detail="Session not found")
# ------ Status ------
@app.get("/api/status")
async def get_status():
"""Get system status."""
config: Config = app.state.config
config_path = get_config_path()
providers_status = []
for spec in PROVIDERS:
p = getattr(config.providers, spec.name, None)
if p is None:
continue
if spec.is_local:
providers_status.append({
"name": spec.label,
"has_key": bool(p.api_base),
"detail": p.api_base or "",
})
else:
providers_status.append({
"name": spec.label,
"has_key": bool(p.api_key),
})
channels_status = []
for ch_name in ["whatsapp", "telegram", "discord", "feishu", "dingtalk", "email", "slack", "qq", "matrix"]:
ch_cfg = getattr(config.channels, ch_name, None)
if ch_cfg:
channels_status.append({
"name": ch_name,
"enabled": getattr(ch_cfg, "enabled", False),
})
channels_status.append({"name": "web", "enabled": True})
cron: CronService = app.state.cron_service
cron_status = cron.status()
return {
"config_path": str(config_path),
"config_exists": config_path.exists(),
"workspace": str(config.workspace_path),
"workspace_exists": config.workspace_path.exists(),
"model": config.agents.defaults.model,
"max_tokens": config.agents.defaults.max_tokens,
"temperature": config.agents.defaults.temperature,
"max_tool_iterations": config.agents.defaults.max_tool_iterations,
"providers": providers_status,
"channels": channels_status,
"cron": cron_status,
"authz": {
"enabled": config.authz.enabled,
"base_url": config.authz.base_url,
"outlook_mcp_url": config.authz.outlook_mcp_url,
"backend_id": config.backend_identity.backend_id,
"client_id": config.backend_identity.client_id,
"registered": bool(
config.backend_identity.backend_id
and config.backend_identity.client_id
and config.backend_identity.client_secret
),
},
}
# ------ Cron Jobs ------
@app.get("/api/authz/status")
async def get_authz_status():
config: Config = app.state.config
registered = bool(
config.backend_identity.backend_id
and config.backend_identity.client_id
and config.backend_identity.client_secret
)
response: dict[str, Any] = {
"enabled": config.authz.enabled,
"base_url": config.authz.base_url,
"outlook_mcp_url": config.authz.outlook_mcp_url,
"local_backend": {
"backend_id": config.backend_identity.backend_id or None,
"client_id": config.backend_identity.client_id or None,
"name": config.backend_identity.name or None,
"public_base_url": config.backend_identity.public_base_url or None,
"registered": registered,
},
}
if not (config.authz.enabled and config.authz.base_url.strip() and config.backend_identity.backend_id.strip()):
return response
try:
client, backend_id = _require_local_authz_backend(config)
response["backend"] = await client.get_backend(backend_id)
response["permissions"] = await client.get_permissions(backend_id)
response["outlook"] = await client.get_outlook_settings(backend_id)
response["channel_settings"] = await client.list_channel_settings(backend_id)
except Exception as exc: # noqa: BLE001
response["error"] = str(exc)
return response
@app.post("/api/authz/local-backend/bind")
async def bind_local_backend_identity(payload: LocalBackendIdentityRequest):
config: Config = app.state.config
return _save_local_backend_identity(
config,
backend_id=payload.backend_id,
client_id=payload.client_id,
client_secret=payload.client_secret,
name=payload.name,
public_base_url=payload.public_base_url,
authz_base_url=payload.authz_base_url,
authz_enabled=payload.authz_enabled,
)
@app.get("/api/authz/backends")
async def list_authz_backends():
_reject_backend_collection_ui()
@app.post("/api/authz/backends/register")
async def register_authz_backend(payload: AuthzRegisterBackendRequest, request: Request):
_reject_backend_collection_ui()
@app.get("/api/authz/backends/{backend_id}")
async def get_authz_backend(backend_id: str):
_reject_backend_collection_ui()
@app.post("/api/authz/backends/{backend_id}/enable")
async def enable_authz_backend(backend_id: str):
_reject_backend_collection_ui()
@app.post("/api/authz/backends/{backend_id}/disable")
async def disable_authz_backend(backend_id: str):
_reject_backend_collection_ui()
@app.post("/api/authz/backends/{backend_id}/rotate-secret")
async def rotate_authz_backend_secret(backend_id: str):
_reject_backend_collection_ui()
@app.get("/api/authz/backends/{backend_id}/permissions")
async def get_authz_backend_permissions(backend_id: str):
_reject_backend_collection_ui()
@app.post("/api/authz/backends/{backend_id}/permissions")
async def save_authz_backend_permissions(backend_id: str, payload: dict[str, Any]):
_reject_backend_collection_ui()
@app.get("/api/authz/backends/{backend_id}/settings/outlook")
async def get_authz_backend_outlook_settings(backend_id: str):
_reject_backend_collection_ui()
@app.post("/api/authz/backends/{backend_id}/settings/outlook")
async def save_authz_backend_outlook_settings(backend_id: str, payload: dict[str, Any]):
_reject_backend_collection_ui()
@app.delete("/api/authz/backends/{backend_id}/settings/outlook")
async def delete_authz_backend_outlook_settings(backend_id: str):
_reject_backend_collection_ui()
@app.get("/api/authz/channel-settings")
async def list_authz_channel_settings():
config: Config = app.state.config
try:
client, backend_id = _require_local_authz_backend(config)
return await client.list_channel_settings(backend_id)
except httpx.HTTPError as exc:
raise _coerce_authz_error(exc) from exc
@app.get("/api/authz/channel-settings/{channel_id}")
async def get_authz_channel_settings(channel_id: str):
config: Config = app.state.config
try:
client, backend_id = _require_local_authz_backend(config)
return await client.get_channel_settings(backend_id, channel_id)
except httpx.HTTPError as exc:
raise _coerce_authz_error(exc) from exc
@app.post("/api/authz/channel-settings/{channel_id}")
async def save_authz_channel_settings(channel_id: str, payload: dict[str, Any]):
config: Config = app.state.config
try:
client, backend_id = _require_local_authz_backend(config)
return await client.set_channel_settings(backend_id, channel_id, payload)
except httpx.HTTPError as exc:
raise _coerce_authz_error(exc) from exc
@app.delete("/api/authz/channel-settings/{channel_id}")
async def delete_authz_channel_settings(channel_id: str):
config: Config = app.state.config
try:
client, backend_id = _require_local_authz_backend(config)
return await client.delete_channel_settings(backend_id, channel_id)
except httpx.HTTPError as exc:
raise _coerce_authz_error(exc) from exc
@app.get("/api/cron/jobs")
async def list_cron_jobs(include_disabled: bool = False):
"""List cron jobs."""
cron: CronService = app.state.cron_service
jobs = cron.list_jobs(include_disabled=include_disabled)
return [_serialize_job(j) for j in jobs]
@app.post("/api/cron/jobs")
async def add_cron_job(req: AddCronJobRequest):
"""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 自动执行。
payload_kind = "system_event" if normalized_mode == "reminder" else "agent_turn"
if req.every_seconds:
schedule = CronSchedule(kind="every", every_ms=req.every_seconds * 1000)
elif req.cron_expr:
schedule = CronSchedule(kind="cron", expr=req.cron_expr)
elif req.at_iso:
import datetime
dt = datetime.datetime.fromisoformat(req.at_iso)
schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000))
else:
raise HTTPException(status_code=400, detail="Must specify every_seconds, cron_expr, or at_iso")
job = cron.add_job(
name=req.name,
schedule=schedule,
message=req.message,
payload_kind=payload_kind,
session_key=normalized_session_key,
deliver=req.deliver,
channel=normalized_channel,
to=normalized_to,
)
return _serialize_job(job)
@app.delete("/api/cron/jobs/{job_id}")
async def remove_cron_job(job_id: str):
"""Remove a cron job."""
cron: CronService = app.state.cron_service
if cron.remove_job(job_id):
return {"ok": True}
raise HTTPException(status_code=404, detail="Job not found")
@app.put("/api/cron/jobs/{job_id}/toggle")
async def toggle_cron_job(job_id: str, req: ToggleCronJobRequest):
"""Enable or disable a cron job."""
cron: CronService = app.state.cron_service
job = cron.enable_job(job_id, enabled=req.enabled)
if job:
return _serialize_job(job)
raise HTTPException(status_code=404, detail="Job not found")
@app.post("/api/cron/jobs/{job_id}/run")
async def run_cron_job(job_id: str):
"""Manually run a cron job."""
cron: CronService = app.state.cron_service
if await cron.run_job(job_id, force=True):
return {"ok": True}
raise HTTPException(status_code=404, detail="Job not found")
# ------ Skills ------
@app.get("/api/skills")
async def list_skills():
"""List all skills (builtin + workspace)."""
from nanobot.agent.skills import SkillsLoader
config: Config = app.state.config
loader = SkillsLoader(config.workspace_path)
raw = loader.list_skills(filter_unavailable=False)
result = []
for s in raw:
meta = loader.get_skill_metadata(s["name"]) or {}
available = loader._check_requirements(loader._get_skill_meta(s["name"]))
result.append({
"name": s["name"],
"description": meta.get("description", s["name"]),
"source": s["source"],
"available": available,
"path": s["path"],
"agent_cards": loader.get_skill_agent_cards(s["name"]),
})
return result
@app.delete("/api/skills/{name}")
async def delete_skill(name: str):
"""Delete a workspace skill."""
from nanobot.agent.skills import SkillsLoader
config: Config = app.state.config
loader = SkillsLoader(config.workspace_path)
# Check the skill exists and is a workspace skill
all_skills = loader.list_skills(filter_unavailable=False)
skill = next((s for s in all_skills if s["name"] == name), None)
if not skill:
raise HTTPException(status_code=404, detail="Skill not found")
if skill["source"] != "workspace":
raise HTTPException(status_code=400, detail="Cannot delete builtin skills")
skill_dir = loader.workspace_skills / name
if skill_dir.exists():
shutil.rmtree(skill_dir)
return {"ok": True}
@app.get("/api/skills/reviews")
async def list_skill_reviews():
"""List staged skill installs awaiting review."""
from nanobot.agent.skill_reviews import SkillReviewManager
config: Config = app.state.config
return SkillReviewManager(config.workspace_path).list_reviews()
@app.get("/api/skills/reviews/{review_id}")
async def get_skill_review(review_id: str):
"""Get a staged skill install preview."""
from nanobot.agent.skill_reviews import SkillReviewManager
config: Config = app.state.config
manager = SkillReviewManager(config.workspace_path)
try:
return manager.get_review(review_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@app.post("/api/skills/reviews/{review_id}/approve")
async def approve_skill_review(
review_id: str,
req: ApproveSkillReviewRequest | None = None,
):
"""Approve a staged skill install and copy it into workspace skills."""
from nanobot.agent.skill_reviews import SkillReviewManager
from nanobot.agent.skills import SkillsLoader
config: Config = app.state.config
manager = SkillReviewManager(config.workspace_path)
overwrite = bool(req.overwrite) if req else False
try:
review = manager.approve_review(review_id, overwrite=overwrite)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except FileExistsError as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
loader = SkillsLoader(config.workspace_path)
meta = loader.get_skill_metadata(review["skill_name"]) or {}
available = loader._check_requirements(loader._get_skill_meta(review["skill_name"]))
return {
"status": review["status"],
"review_id": review["id"],
"name": review["skill_name"],
"description": meta.get("description", review["skill_name"]),
"source": "workspace",
"available": available,
"path": review["installed_path"],
"approved_at": review.get("approved_at"),
"overwrite": review.get("overwrite", False),
}
@app.delete("/api/skills/reviews/{review_id}")
async def discard_skill_review(review_id: str):
"""Discard a staged skill install without activating it."""
from nanobot.agent.skill_reviews import SkillReviewManager
config: Config = app.state.config
manager = SkillReviewManager(config.workspace_path)
try:
manager.discard_review(review_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return {"ok": True}
@app.get("/api/skills/{name}/download")
async def download_skill(name: str):
"""Download a skill as a zip file."""
import io
from nanobot.agent.skills import SkillsLoader
config: Config = app.state.config
loader = SkillsLoader(config.workspace_path)
all_skills = loader.list_skills(filter_unavailable=False)
skill = next((s for s in all_skills if s["name"] == name), None)
if not skill:
raise HTTPException(status_code=404, detail="Skill not found")
# Resolve the skill directory from the SKILL.md path
skill_dir = Path(skill["path"]).parent
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for file_path in skill_dir.rglob("*"):
if file_path.is_file():
arcname = f"{name}/{file_path.relative_to(skill_dir)}"
zf.write(file_path, arcname)
from fastapi.responses import Response
from nanobot.web.files import content_disposition
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": content_disposition("attachment", f"{name}.zip")},
)
@app.post("/api/skills/upload")
async def upload_skill(file: UploadFile = File(...)):
"""Upload a skill archive into the review queue without activating it."""
from nanobot.agent.skill_reviews import SkillReviewManager
config: Config = app.state.config
manager = SkillReviewManager(config.workspace_path)
if not file.filename or not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="File must be a .zip archive")
try:
content = await file.read()
return manager.create_review_from_zip(file.filename, content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
# ------ Files ------
max_file_size = 50 * 1024 * 1024 # 50MB
@app.post("/api/files/upload")
async def upload_file(
file: UploadFile = File(...),
session_id: str = Form("web:default"),
):
"""Upload a file for chat attachment or analysis."""
from nanobot.web.files import generate_file_id, save_file
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
content = await file.read()
if len(content) > max_file_size:
raise HTTPException(status_code=413, detail="File too large (max 50MB)")
file_id = generate_file_id()
ct = file.content_type or "application/octet-stream"
config: Config = app.state.config
metadata = save_file(
workspace=config.workspace_path,
file_id=file_id,
filename=file.filename,
content=content,
content_type=ct,
session_id=session_id,
)
metadata["url"] = f"/api/files/{file_id}"
return metadata
@app.get("/api/files")
async def list_uploaded_files(session_id: str | None = None):
"""List uploaded files, optionally filtered by session."""
from nanobot.web.files import list_files
config: Config = app.state.config
return list_files(config.workspace_path, session_id=session_id)
@app.get("/api/files/{file_id}")
async def download_file(file_id: str):
"""Download a file by ID."""
from nanobot.web.files import get_file_metadata, get_file_path
config: Config = app.state.config
meta = get_file_metadata(config.workspace_path, file_id)
if meta is None:
raise HTTPException(status_code=404, detail="File not found")
file_path = get_file_path(config.workspace_path, file_id)
if file_path is None:
raise HTTPException(status_code=404, detail="File data missing")
ct = meta.get("content_type", "application/octet-stream")
disposition = "inline" if ct.startswith("image/") else "attachment"
filename = meta["name"]
from fastapi.responses import Response
from nanobot.web.files import content_disposition
return Response(
content=file_path.read_bytes(),
media_type=ct,
headers={"Content-Disposition": content_disposition(disposition, filename)},
)
@app.delete("/api/files/{file_id}")
async def remove_file(file_id: str):
"""Delete a file."""
from nanobot.web.files import delete_file
config: Config = app.state.config
if delete_file(config.workspace_path, file_id):
return {"ok": True}
raise HTTPException(status_code=404, detail="File not found")
# ------ Workspace Browser ------
@app.get("/api/workspace/browse")
async def browse_workspace_dir(path: str = ""):
"""Browse workspace directory contents."""
from nanobot.web.files import browse_workspace
config: Config = app.state.config
try:
return browse_workspace(config.workspace_path, path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/workspace/download")
async def download_workspace_file(path: str):
"""Download a file from workspace by relative path."""
from nanobot.web.files import workspace_file_path
config: Config = app.state.config
file_path = workspace_file_path(config.workspace_path, path)
if file_path is None:
raise HTTPException(status_code=404, detail="File not found")
import mimetypes
from fastapi.responses import Response
from nanobot.web.files import content_disposition
ct, _ = mimetypes.guess_type(file_path.name)
ct = ct or "application/octet-stream"
disposition = "inline" if ct.startswith("image/") else "attachment"
return Response(
content=file_path.read_bytes(),
media_type=ct,
headers={"Content-Disposition": content_disposition(disposition, file_path.name)},
)
@app.post("/api/workspace/upload")
async def upload_to_workspace(
file: UploadFile = File(...),
path: str = Form(""),
):
"""Upload a file to a specific workspace directory."""
from nanobot.web.files import save_to_workspace
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
content = await file.read()
if len(content) > max_file_size:
raise HTTPException(status_code=413, detail="File too large (max 50MB)")
config: Config = app.state.config
try:
return save_to_workspace(config.workspace_path, path, file.filename, content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/workspace/delete")
async def delete_workspace_item(path: str):
"""Delete a file or directory from workspace."""
from nanobot.web.files import delete_workspace_path
config: Config = app.state.config
if delete_workspace_path(config.workspace_path, path):
return {"ok": True}
raise HTTPException(status_code=404, detail="Path not found")
@app.post("/api/workspace/mkdir")
async def create_workspace_directory(path: str):
"""Create a directory in workspace."""
from nanobot.web.files import create_workspace_dir
config: Config = app.state.config
try:
return create_workspace_dir(config.workspace_path, path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# ------ Plugins ------
@app.get("/api/plugins")
async def list_plugins():
"""List all loaded plugins with their agents, commands, and skills."""
from nanobot.agent.plugins import PluginLoader
config: Config = app.state.config
loader = PluginLoader(config.workspace_path)
result = []
for plugin in loader.plugins.values():
result.append({
"name": plugin.name,
"description": plugin.description,
"source": plugin.source,
"agents": [
{
"name": a.name,
"description": a.description,
"model": a.model,
}
for a in plugin.agents.values()
],
"commands": [
{
"name": c.name,
"description": c.description,
"argument_hint": c.argument_hint,
}
for c in plugin.commands.values()
],
"skills": [
skill_dir.name
for skill_dir_root in plugin.skill_dirs
for skill_dir in sorted(skill_dir_root.iterdir())
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists()
],
})
return result
@app.get("/api/agents")
async def list_agents():
"""List unified agents from workspace, plugins, skills, and local fallback."""
registry = _get_agent_registry()
return registry.list_public_agents()
@app.post("/api/agents")
async def add_agent(req: AddAgentRequest):
"""Add or update a workspace agent entry."""
from nanobot.agent.agent_registry import WorkspaceAgentStore
config: Config = app.state.config
store = WorkspaceAgentStore(config.workspace_path)
if _should_auto_discover_agent(req):
try:
payload = await _discover_agent_payload(req, config)
except Exception as exc:
if not _first_text(req.id):
raise HTTPException(status_code=400, detail=f"自动读取 A2A card 失败: {exc}") from exc
logger.warning("Failed to auto-discover agent '{}': {}", req.id, exc)
payload = _manual_agent_payload(req)
else:
payload = _manual_agent_payload(req)
return store.upsert_agent(payload)
@app.delete("/api/agents/{agent_id}")
async def delete_agent(agent_id: str):
"""Delete a workspace agent entry."""
from nanobot.agent.agent_registry import WorkspaceAgentStore
config: Config = app.state.config
store = WorkspaceAgentStore(config.workspace_path)
if store.delete_agent(agent_id):
return {"ok": True}
raise HTTPException(status_code=404, detail="Agent not found")
@app.post("/api/agents/refresh")
async def refresh_agents():
"""Refresh unified agent view."""
# 当前 registry 不做强缓存,这里本质上是重新拉一遍视图给前端刷新。
registry = _get_agent_registry()
return {"agents": registry.list_public_agents()}
@app.post("/api/delegations/{run_id}/cancel")
async def cancel_delegation(run_id: str):
"""Cancel a running delegation, if present."""
agent = _get_agent_loop()
if agent is None:
raise HTTPException(status_code=400, detail="Delegation control requires standalone mode")
cancelled = await agent.delegation.cancel(run_id)
if not cancelled:
raise HTTPException(status_code=404, detail="Delegation not found")
return {"ok": True, "run_id": run_id}
@app.get("/api/mcp/servers")
async def list_mcp_servers():
"""List MCP server configuration merged with runtime state."""
return _mcp_servers_view()
@app.post("/api/mcp/servers")
async def add_mcp_server(req: MCPServerRequest):
"""Create or replace an MCP server config entry."""
from nanobot.config.schema import MCPServerConfig
config: Config = app.state.config
server_id = req.id.strip()
if not server_id:
raise HTTPException(status_code=400, detail="Server id is required")
auth_mode = (req.auth_mode or "none").strip().lower() or "none"
auth_audience = (req.auth_audience or "").strip()
auth_scopes = [str(item).strip() for item in list(req.auth_scopes or []) if str(item).strip()]
if auth_mode == "oauth_backend_token" and not auth_audience:
auth_audience = f"mcp:{server_id}"
config.tools.mcp_servers[server_id] = MCPServerConfig(
command=req.command,
args=req.args,
env=req.env,
url=req.url,
headers=req.headers,
auth_mode=auth_mode,
auth_audience=auth_audience,
auth_scopes=auth_scopes,
tool_timeout=req.tool_timeout,
sensitive=req.sensitive,
)
_save_app_config(config)
# 配置落盘后立刻把运行中的 MCP 连接重载一遍,保证 UI 与运行态一致。
await _apply_mcp_runtime_config()
return next((item for item in _mcp_servers_view() if item["id"] == server_id), {"id": server_id})
@app.put("/api/mcp/servers/{server_id}")
async def update_mcp_server(server_id: str, req: MCPServerRequest):
"""Update an MCP server config entry."""
if server_id != req.id:
raise HTTPException(status_code=400, detail="Path id must match body id")
return await add_mcp_server(req)
@app.delete("/api/mcp/servers/{server_id}")
async def delete_mcp_server(server_id: str):
"""Delete an MCP server config entry."""
config: Config = app.state.config
if server_id not in config.tools.mcp_servers:
raise HTTPException(status_code=404, detail="MCP server not found")
config.tools.mcp_servers.pop(server_id, None)
_save_app_config(config)
await _apply_mcp_runtime_config()
return {"ok": True, "id": server_id}
@app.post("/api/mcp/servers/{server_id}/test")
async def test_mcp_server(server_id: str):
"""Attempt a fresh connection to one MCP server config."""
from contextlib import AsyncExitStack
from nanobot.agent.tools.mcp import connect_mcp_servers
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.web.outlook import OUTLOOK_SERVER_ID
config: Config = app.state.config
if server_id == OUTLOOK_SERVER_ID and _uses_managed_outlook_mcp(config) and _has_backend_identity(config):
try:
config_changed = await _reconcile_managed_outlook_mcp(config)
except httpx.HTTPError as exc:
raise _coerce_authz_error(exc) from exc
if config_changed:
_save_app_config(config)
await _apply_mcp_runtime_config()
config = app.state.config
cfg = config.tools.mcp_servers.get(server_id)
if cfg is None:
raise HTTPException(status_code=404, detail="MCP server not found")
registry = ToolRegistry()
async with AsyncExitStack() as stack:
# 用临时 registry + 临时连接做探测,不污染当前正式运行中的工具集合。
report = await connect_mcp_servers(
{server_id: cfg},
registry,
stack,
authz_config=config.authz,
backend_identity=config.backend_identity,
)
item = report.get(server_id, {})
return {
"ok": item.get("status") == "connected",
"server": server_id,
**item,
}
@app.get("/api/integrations/outlook/status")
async def get_outlook_status():
from nanobot.web.outlook import OutlookIntegrationError, outlook_status
config: Config = app.state.config
try:
return await outlook_status(config)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/api/integrations/outlook/test-connection")
async def test_outlook_connection(req: OutlookConnectionRequest):
from nanobot.web.outlook import (
OutlookConnectionInput,
OutlookIntegrationError,
test_connection,
)
config: Config = app.state.config
try:
return await test_connection(OutlookConnectionInput(**req.model_dump()), config)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/integrations/outlook/connect")
async def connect_outlook(req: OutlookConnectionRequest):
from nanobot.web.outlook import (
OutlookConnectionInput,
OutlookIntegrationError,
connect_workspace,
)
config: Config = app.state.config
try:
result = await connect_workspace(config, OutlookConnectionInput(**req.model_dump()))
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
_save_app_config(config)
await _apply_mcp_runtime_config()
return result
@app.post("/api/integrations/outlook/disconnect")
async def disconnect_outlook():
from nanobot.web.outlook import OutlookIntegrationError, disconnect_workspace
config: Config = app.state.config
try:
result = await disconnect_workspace(config)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_save_app_config(config)
await _apply_mcp_runtime_config()
return result
@app.get("/api/integrations/outlook/overview")
async def get_outlook_overview():
from nanobot.web.outlook import OutlookIntegrationError, get_overview
config: Config = app.state.config
try:
return await get_overview(config)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/integrations/outlook/messages")
async def get_outlook_messages(
folder: str = "inbox",
top: int = 20,
skip: int = 0,
unread_only: bool = False,
):
from nanobot.web.outlook import OutlookIntegrationError, list_messages
config: Config = app.state.config
if not folder.strip():
raise HTTPException(status_code=400, detail="folder is required")
try:
return await list_messages(
config,
folder=folder.strip(),
top=top,
skip=skip,
unread_only=unread_only,
)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/integrations/outlook/events")
async def get_outlook_events(
start_time: str,
end_time: str,
top: int = 20,
skip: int = 0,
):
from nanobot.web.outlook import OutlookIntegrationError, list_events
config: Config = app.state.config
if not start_time.strip() or not end_time.strip():
raise HTTPException(status_code=400, detail="start_time and end_time are required")
try:
return await list_events(
config,
start_time=start_time.strip(),
end_time=end_time.strip(),
top=top,
skip=skip,
)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/integrations/outlook/message-detail")
async def get_outlook_message_detail(message_id: str, changekey: str | None = None):
from nanobot.web.outlook import OutlookIntegrationError, get_message_detail
config: Config = app.state.config
if not message_id.strip():
raise HTTPException(status_code=400, detail="message_id is required")
try:
return await get_message_detail(
config,
message_id.strip(),
changekey=changekey.strip() if changekey else None,
)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/mcp/tools")
async def list_mcp_tools():
"""List discovered MCP tools grouped by server."""
grouped: dict[str, list[dict[str, Any]]] = {}
agent = _get_agent_loop()
if agent is not None:
# 先按 server_id 长度倒序,避免前缀相近时被短 id 误匹配。
server_ids = sorted(agent._mcp_servers.keys(), key=len, reverse=True) if hasattr(agent, "_mcp_servers") else []
for tool_name in agent.tools.tool_names:
if not tool_name.startswith("mcp_"):
continue
server_name = None
public_name = tool_name
for candidate in server_ids:
prefix = f"mcp_{candidate}_"
if tool_name.startswith(prefix):
server_name = candidate
public_name = tool_name[len(prefix):]
break
if server_name is None:
_, remainder = tool_name.split("mcp_", 1)
server_name, _, public_name = remainder.partition("_")
tool_obj = agent.tools.get(tool_name)
grouped.setdefault(server_name, []).append({
"server_id": server_name,
"tool_name": public_name,
"name": tool_name,
"description": getattr(tool_obj, "description", ""),
"parameters": getattr(tool_obj, "parameters", {}),
})
result = []
for server_id in sorted(grouped):
result.append({
"server_id": server_id,
"tools": sorted(grouped[server_id], key=lambda item: item["tool_name"]),
})
return result
# ------ Commands (plugin slash commands) ------
@app.get("/api/commands")
async def list_commands():
"""List slash commands supported by the current single-user loop."""
return [
{"name": "new", "description": "Start a new conversation", "argument_hint": None, "plugin_name": "builtin"},
{"name": "help", "description": "Show available commands", "argument_hint": None, "plugin_name": "builtin"},
]
# ------ Marketplace ------
@app.get("/api/marketplaces")
async def list_marketplaces():
"""List all registered marketplaces."""
from nanobot.agent.marketplace import MarketplaceManager
mgr = MarketplaceManager()
return [
{"name": m.name, "source": m.source, "type": m.type}
for m in mgr.list_marketplaces()
]
@app.post("/api/marketplaces")
async def add_marketplace(req: AddMarketplaceRequest):
"""Register a new marketplace from local path or Git URL."""
from nanobot.agent.marketplace import MarketplaceManager
mgr = MarketplaceManager()
try:
entry = mgr.add_marketplace(req.source)
return {"name": entry.name, "source": entry.source, "type": entry.type}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/marketplaces/{name}")
async def remove_marketplace(name: str):
"""Remove a registered marketplace."""
from nanobot.agent.marketplace import MarketplaceManager
mgr = MarketplaceManager()
try:
mgr.remove_marketplace(name)
return {"ok": True}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@app.post("/api/marketplaces/{name}/update")
async def update_marketplace(name: str):
"""Update (clone or pull) a marketplace's cached data."""
from nanobot.agent.marketplace import MarketplaceManager
mgr = MarketplaceManager()
try:
entry = mgr.update_marketplace(name)
return {"name": entry.name, "source": entry.source, "type": entry.type}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/marketplaces/{name}/plugins")
async def list_marketplace_plugins(name: str):
"""List available plugins in a marketplace."""
from nanobot.agent.marketplace import MarketplaceManager
mgr = MarketplaceManager()
try:
plugins = mgr.list_available_plugins(name)
return [
{
"name": p.name,
"description": p.description,
"marketplace_name": p.marketplace_name,
"installed": p.installed,
}
for p in plugins
]
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@app.post("/api/marketplaces/{name}/plugins/{plugin_name}/install")
async def install_marketplace_plugin(name: str, plugin_name: str):
"""Install a plugin from a marketplace."""
from nanobot.agent.marketplace import MarketplaceManager
mgr = MarketplaceManager()
try:
dest = mgr.install_plugin(name, plugin_name)
return {"ok": True, "path": str(dest)}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/plugins/{plugin_name}")
async def uninstall_plugin(plugin_name: str):
"""Uninstall a plugin."""
from nanobot.agent.marketplace import MarketplaceManager
mgr = MarketplaceManager()
try:
mgr.uninstall_plugin(plugin_name)
return {"ok": True}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ------ Health ------
@app.get("/api/ping")
async def ping():
return {"message": "pong"}
def _serialize_job(job: CronJob) -> dict[str, Any]:
"""Serialize a CronJob to a JSON-friendly dict."""
sched_str = ""
if job.schedule.kind == "every":
secs = (job.schedule.every_ms or 0) // 1000
if secs >= 3600:
sched_str = f"every {secs // 3600}h"
elif secs >= 60:
sched_str = f"every {secs // 60}m"
else:
sched_str = f"every {secs}s"
elif job.schedule.kind == "cron":
sched_str = job.schedule.expr or ""
else:
sched_str = "one-time"
next_run = None
if job.state.next_run_at_ms:
next_run = job.state.next_run_at_ms
last_run = None
if job.state.last_run_at_ms:
last_run = job.state.last_run_at_ms
return {
"id": job.id,
"name": job.name,
"enabled": job.enabled,
"payload_kind": job.payload.kind,
"mode": "reminder" if job.payload.kind == "system_event" else "task",
"session_key": job.payload.session_key,
"schedule_kind": job.schedule.kind,
"schedule_display": sched_str,
"schedule_expr": job.schedule.expr,
"schedule_every_ms": job.schedule.every_ms,
"message": job.payload.message,
"deliver": job.payload.deliver,
"channel": job.payload.channel,
"to": job.payload.to,
"next_run_at_ms": next_run,
"last_run_at_ms": last_run,
"last_status": job.state.last_status,
"last_error": job.state.last_error,
"created_at_ms": job.created_at_ms,
}