"""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: /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, }