from contextlib import asynccontextmanager import asyncio from time import perf_counter from uuid import uuid4 from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from starlette.exceptions import HTTPException as StarletteHttpException from app.config import get_settings from app.container import container from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler from app.export import service as export_service from app.routes import router as api_router from app.media_routes import router as media_router from app.local_model_routes import router as local_model_router from app.usage_routes import router as usage_router from app.provider_preview_routes import router as provider_preview_router from app.schemas import HealthResponse, ServiceStatusResponse from app.log_routes import router as log_router from app.operation_logs import install_logging, log_event, request_id, shutdown_logging settings = get_settings() @asynccontextmanager async def lifespan(_: FastAPI): install_logging() log_event('system', 'service.started') # 重启后内存注册表为空,清理上一次运行遗留的导出产物,避免磁盘垃圾堆积。 export_service.cleanup_orphan_files() from app.services import transcription_service transcription_service.recover_interrupted() try: yield finally: from app.benchmarks import service as benchmark_service await benchmark_service.shutdown() await container.agent.shutdown() from app.services import index_service await index_service.shutdown() await transcription_service.shutdown() from app.local_models import components await components.shutdown() from app.local_models import manager for _, key in list(manager._downloads): await manager.cancel_download(key) # 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。 container.plugins.shutdown() container.mcp_servers.shutdown() log_event('system', 'service.stopped') await asyncio.to_thread(shutdown_logging) app = FastAPI( title=settings.name, version=settings.version, description="AI 笔记软件的本地 AI Core 与 Agent Core 服务。", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=[ "http://127.0.0.1:5173", "http://localhost:5173", "http://tauri.localhost", "tauri://localhost", ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.add_exception_handler(ApiError, api_error_handler) app.add_exception_handler(RequestValidationError, validation_error_handler) app.add_exception_handler(StarletteHttpException, http_error_handler) app.include_router(api_router) app.include_router(media_router) app.include_router(local_model_router) app.include_router(usage_router) app.include_router(provider_preview_router) app.include_router(log_router) from app.plot_routes import router as plot_router app.include_router(plot_router) @app.middleware('http') async def operation_log(request, call_next): token = request_id.set(uuid4().hex) started = perf_counter() status = 500 failure = None try: response = await call_next(request) status = response.status_code response.headers['X-Request-ID'] = request_id.get() return response except Exception as exc: failure = exc raise finally: # Do not record query strings, request/response bodies or arbitrary URLs. route = getattr(request.scope.get('route'), 'path', 'unmatched') if not route.startswith('/api/logs') and (request.method not in {'GET', 'HEAD', 'OPTIONS'} or status >= 400 or perf_counter() - started > 1): log_event('http', 'request.finished', level='ERROR' if status >= 500 else 'WARNING' if status >= 400 else 'INFO', error=failure, method=request.method, route=route, status=status, duration_ms=round((perf_counter() - started) * 1000, 2), **{k: v for k, v in request.path_params.items() if k in {'run_id', 'task_id', 'note_id', 'job_id', 'provider_id'}}) request_id.reset(token) @app.get("/health", response_model=HealthResponse, tags=["System"]) async def health() -> HealthResponse: return HealthResponse() @app.get("/api/status", response_model=ServiceStatusResponse, tags=["System"]) async def service_status() -> ServiceStatusResponse: return ServiceStatusResponse( name=settings.name, version=settings.version, environment=settings.environment, )