- 新增 NANO_OUTLOOK_MCP_URL 和 NANO_OUTLOOK_MCP_SERVER_ID 环境变量配置 - 实现 Outlook 邮件和日历的分页查询功能,添加安全参数验证 - 为 app-instance 创建脚本添加 Outlook MCP 服务器 ID 参数 - 更新前端 Outlook 页面实现邮件列表和日历事件的分页浏览 - 添加 Git 忽略文件配置和 Docker 挂载路径修复 BREAKING CHANGE: Outlook 集成现在需要配置 MCP URL 和服务器 ID 环境变量
2722 lines
104 KiB
Python
2722 lines
104 KiB
Python
"""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
|
||
|
||
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
|
||
|
||
|
||
# ============================================================================
|
||
# 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")
|
||
|
||
# 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,
|
||
)
|
||
# Single-user mode: cron jobs execute via the same in-process agent.
|
||
async def on_cron_job(job: CronJob) -> CronExecutionResult:
|
||
return await run_cron_job(
|
||
job,
|
||
agent=agent,
|
||
bus=bus,
|
||
default_channel="web",
|
||
default_chat_id="default",
|
||
)
|
||
|
||
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.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()
|
||
|
||
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)
|
||
|
||
# ------ Sessions ------
|
||
|
||
@app.get("/api/sessions")
|
||
async def list_sessions():
|
||
"""List all conversation sessions."""
|
||
sm: SessionManager = app.state.session_manager
|
||
return sm.list_sessions()
|
||
|
||
@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)
|
||
# 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.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()
|
||
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=req.session_key,
|
||
deliver=req.deliver,
|
||
channel=req.channel,
|
||
to=req.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,
|
||
}
|