Files
mirosociety/backend/app/main.py
T

160 lines
6.2 KiB
Python

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,
searxng_base_url=settings.searxng_base_url,
searxng_token=settings.searxng_token,
searxng_timeout=settings.searxng_timeout,
searxng_language=settings.searxng_language,
searxng_safesearch=settings.searxng_safesearch,
cache_ttl_seconds=settings.searxng_cache_ttl_seconds,
)
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 正在关闭")
try:
await research.aclose()
except Exception as e:
logger.warning("关闭搜索客户端失败:%s", e)
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")