"""FastAPI 应用入口""" import asyncio import re from contextlib import asynccontextmanager from fastapi import Depends, FastAPI from loguru import logger from app.api.agent import router from app.api.conversations import router as conversation_router from app.api.datasource import router as datasource_router from app.api.files import UploadSizeLimit from app.api.files import limits_router as file_limits_router from app.api.files import router as file_router from app.api.materials import router as material_router from app.api.public import router as public_router from app.api.screens import router as screen_router from app.config import settings from app.core.body_limit import BodyLimit, BodyLimitRule from app.core.auth import get_current_user from app.core.exceptions import register_exception_handlers from app.core.logging import setup_logging @asynccontextmanager async def lifespan(application: FastAPI): setup_logging() from app.agent.screen_agent import get_screen_agent from app.clients.decision import close_decision_engine from app.clients.mcp import start_mcp_servers, stop_mcp_servers from app.clients.user_center import close_user_center from app.datafiles.insights import generate_insights, insights_enabled from app.datafiles.service import DataFileService from app.datafiles.storage import open_data_file_store from app.observability.tracing import flush_tracing from app.processor.graph import build_graph from app.services.agent import AgentService from app.services.material_mongo import warm_material_cache from app.services.persistence import open_persistence from app.services.screen_mongo import MongoScreenStore from app.services.screens import ScreenService from app.services.thumbnails import ThumbnailStorage # 按配置选择 MongoDB 或进程内存,会话记录与图检查点使用同一种存储 persistence = await open_persistence() application.state.conversation_store = persistence.store mcp_processes = [] sweeper = None trash_sweeper = None data_files = None try: # 数据文件的元数据和会话记录用同一种存储;启动时先清理一遍过期目录,之后每小时一次 data_files = DataFileService( await open_data_file_store(persistence.db), persistence.store, insights=generate_insights if insights_enabled() else None, ) application.state.data_file_service = data_files sweeper = asyncio.create_task(data_files.run_sweeper()) # 大屏存取必须落 MongoDB:没配时不创建服务,screens 相关接口返回 503,而不是存在内存里重启就丢 screen_service = None if persistence.db is not None: screen_store = MongoScreenStore(persistence.db) await screen_store.ensure_indexes() screen_service = ScreenService( screen_store, ThumbnailStorage(settings.thumbnail_dir), data_files, settings.trash_retention_days, ) trash_sweeper = asyncio.create_task(screen_service.run_trash_sweeper()) else: logger.warning("未配置 MONGODB_URI,大屏存取接口不可用") application.state.screen_service = screen_service # 物料目录启动时载入内存,动作校验与前端接口共用,避免重复查询数据库 await warm_material_cache(persistence.db) # 拉起 mcp_servers.json 中由应用管理的服务;单个服务失败只记录,用到它的请求执行时报错 mcp_processes = await start_mcp_servers() # 提前拉取 MCP 工具并组装 agent,问题在启动时暴露;失败只记录,首个请求会重试 try: await get_screen_agent() except Exception as error: logger.opt(exception=error).error("screen agent 预先组装失败") application.state.agent_service = AgentService( build_graph(persistence.checkpointer), persistence.store, data_files ) yield finally: if sweeper is not None: sweeper.cancel() if trash_sweeper is not None: trash_sweeper.cancel() if data_files is not None: await data_files.aclose() # 共享客户端只在应用退出时关闭,追踪刷新放到线程避免阻塞事件循环 try: await stop_mcp_servers(mcp_processes) finally: try: await persistence.close() finally: try: await close_decision_engine() finally: try: await close_user_center() finally: try: await asyncio.to_thread(flush_tracing) finally: await logger.complete() def create_app() -> FastAPI: application = FastAPI(title="Screen Agent API", lifespan=lifespan) # 统一处理请求校验、HTTP 错误和未捕获异常 register_exception_handlers(application) # 统一 /api 前缀,方便前端代理和反向代理只配置一条规则 # 会话和 agent 接口需登录,物料目录是全局公开数据 authenticated = [Depends(get_current_user)] application.include_router(router, prefix="/api", dependencies=authenticated) application.include_router( conversation_router, prefix="/api", dependencies=authenticated ) # 上传限制无需登录;要先于 /files/{file_id} 注册,否则会被当成文件 ID application.include_router(file_limits_router, prefix="/api") application.include_router(file_router, prefix="/api", dependencies=authenticated) # 上传在读请求体之前先按 Content-Length 检查大小 application.add_middleware(UploadSizeLimit, path=f"/api{file_router.prefix}") application.include_router(material_router, prefix="/api") application.include_router(screen_router, prefix="/api", dependencies=authenticated) application.include_router( datasource_router, prefix="/api", dependencies=authenticated ) # 匿名接口统一放 /api/public 下,不挂鉴权 application.include_router(public_router, prefix="/api") # 保存 / 发布的请求体比普通接口大得多,单独放宽;封面图 1 MB application.add_middleware( BodyLimit, rules=[ BodyLimitRule( frozenset({"POST", "PUT"}), re.compile(r"/api/screens(/[^/]+)?"), settings.screen_max_mb * 1024 * 1024, f"大屏数据不能超过 {settings.screen_max_mb} MB", ), BodyLimitRule( frozenset({"PUT"}), re.compile(r"/api/screens/[^/]+/thumbnail"), 1024 * 1024 + 64 * 1024, "封面图不能超过 1 MB", ), # 其余带请求体的接口(发布、复制、改名、数据源取数、会话)都只有几个字段 BodyLimitRule( frozenset({"POST", "PUT", "PATCH"}), re.compile(r"/api/(screens|datasource|conversations)(/.*)?"), 64 * 1024, "请求体过大", ), ], ) return application app = create_app()