from __future__ import annotations import asyncio import logging import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from app.config import settings from app.db.store import SimulationStore from app.services.llm import LLMClient from app.services.world_generator import WorldGenerator from app.services.citizen_generator import CitizenGenerator from app.services.tension import TensionEngine from app.services.resolver import ActionResolver from app.services.narrator import Narrator from app.services.engine import SimulationEngine from app.services.research import ResearchService from app.services.gossip import GossipEngine from app.services.life_engine import LifeEngine from app.services.forecast import ForecastService from app.services.i18n import parse_accept_language from app.api.simulate import router as simulate_router from app.api.agents import router as agents_router from app.api.gallery import router as gallery_router from app.api.presets import router as presets_router from app.services.simulation_logging import ( ChineseSimulationFormatter, install_simulation_log_handler, ) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") install_simulation_log_handler() # Use Chinese labels for the terminal's timestamp/level/module metadata too; # the original message is kept intact so stack traces and search terms remain # useful when diagnosing third-party libraries. for _handler in logging.getLogger().handlers: if not isinstance(_handler, logging.NullHandler): _handler.setFormatter(ChineseSimulationFormatter()) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): # Keep user-authored scenario files available even before the first # presets request (and create the folder on a fresh checkout). custom_simulation_dir = Path(__file__).resolve().parents[2] / "custom_simulation" custom_simulation_dir.mkdir(parents=True, exist_ok=True) logs_dir = Path(__file__).resolve().parent.parent / "logs" logs_dir.mkdir(parents=True, exist_ok=True) # Project-level text logs are created per simulation, but ensure the # directory exists as soon as the application starts. project_logs_dir = Path(__file__).resolve().parents[2] / "logs" project_logs_dir.mkdir(parents=True, exist_ok=True) store = SimulationStore(settings.database_dir) await store.init() # Sweep up any simulations that were "running" when the previous process # died — without this they'd stay stuck in `running` forever and clients # polling /report would never see a terminal status. We mark them # `interrupted` so the API can surface a clear error. try: interrupted = await store.sweep_orphan_running_sims() if interrupted: logger.warning("已将 %d 个遗留模拟标记为已中断", interrupted) except Exception as e: # pragma: no cover - best effort logger.warning("清理遗留模拟失败:%s", e) llm = LLMClient( api_key=settings.llm_api_key, base_url=settings.llm_base_url, model=settings.llm_model, max_concurrent=settings.max_concurrent_llm_calls, ) world_gen = WorldGenerator(llm) citizen_gen = CitizenGenerator(llm) tension = TensionEngine(llm) resolver = ActionResolver() forecast = ForecastService() narrator = Narrator(llm, forecast=forecast) research = ResearchService( llm, enabled=settings.search_enabled, max_per_round=settings.max_searches_per_round, ) gossip = GossipEngine() life_engine = LifeEngine(llm=llm) engine = SimulationEngine(llm, store, tension, resolver, narrator, research=research, gossip=gossip, life_engine=life_engine, forecast=forecast) app.state.store = store app.state.llm = llm app.state.world_generator = world_gen app.state.citizen_generator = citizen_gen app.state.engine = engine app.state.forecast = forecast app.state.narrator = narrator app.state.event_queues = {} app.state.cancelled_sims = set() logger.info("MiroSociety 已启动:模型=%s,地址=%s", settings.llm_model, settings.llm_base_url) yield logger.info("MiroSociety 正在关闭") app = FastAPI(title="MiroSociety", version="0.1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def locale_middleware(request: Request, call_next): """Resolve Accept-Language once per request and stash it for handlers.""" request.state.locale = parse_accept_language(request.headers.get("accept-language")) response = await call_next(request) response.headers.setdefault("Content-Language", request.state.locale) return response app.include_router(simulate_router) app.include_router(agents_router) app.include_router(gallery_router) app.include_router(presets_router) @app.get("/health") async def health(): return {"status": "ok", "model": settings.llm_model} static_dir = Path(__file__).parent / "static" if static_dir.is_dir(): app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets") @app.get("/{path:path}") async def serve_spa(path: str): file_path = (static_dir / path).resolve() if file_path.is_relative_to(static_dir.resolve()) and file_path.is_file(): return FileResponse(file_path) return FileResponse(static_dir / "index.html")