391 lines
12 KiB
Python
391 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import time
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status
|
||
from fastapi.responses import FileResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
from .auth import (
|
||
AuthUser,
|
||
create_session,
|
||
delete_session,
|
||
find_user_for_key,
|
||
limiter,
|
||
require_admin,
|
||
require_auth,
|
||
require_same_origin_intent,
|
||
)
|
||
from .config import Settings, UserSeed
|
||
from .curator import Curator, PROMPT_VERSION
|
||
from .db import Database
|
||
from .schemas import (
|
||
DebugSharingUpdate,
|
||
FragmentCreate,
|
||
IdeaOverride,
|
||
LoginRequest,
|
||
)
|
||
|
||
settings = Settings.load()
|
||
db = Database(settings.database_path)
|
||
curator = Curator(db, settings)
|
||
started_at = time.time()
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(_: FastAPI):
|
||
seeds = list(settings.users)
|
||
if settings.auth_disabled and not seeds:
|
||
seeds.append(
|
||
UserSeed(
|
||
id="development",
|
||
label="本地开发",
|
||
role="admin",
|
||
access_key_hash="authentication-disabled",
|
||
debug_sharing=True,
|
||
)
|
||
)
|
||
db.initialize(seeds)
|
||
db.add_audit_event(
|
||
"application_started",
|
||
payload={
|
||
"configured_users": len(seeds),
|
||
"curator_configured": curator.configured,
|
||
"prompt_version": PROMPT_VERSION,
|
||
},
|
||
)
|
||
await curator.start()
|
||
yield
|
||
await curator.stop()
|
||
|
||
|
||
app = FastAPI(
|
||
title="续想",
|
||
docs_url=None,
|
||
redoc_url=None,
|
||
openapi_url=None,
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def security_headers(request: Request, call_next):
|
||
response = await call_next(request)
|
||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||
response.headers["X-Frame-Options"] = "DENY"
|
||
response.headers["Referrer-Policy"] = "no-referrer"
|
||
response.headers["Strict-Transport-Security"] = (
|
||
"max-age=31536000; includeSubDomains"
|
||
)
|
||
response.headers["Permissions-Policy"] = (
|
||
"camera=(), microphone=(), geolocation=(), payment=()"
|
||
)
|
||
response.headers["Content-Security-Policy"] = (
|
||
"default-src 'self'; script-src 'self'; style-src 'self'; "
|
||
"img-src 'self' data:; font-src 'self'; connect-src 'self'; "
|
||
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||
)
|
||
return response
|
||
|
||
|
||
def authenticated(request: Request) -> AuthUser:
|
||
user = require_auth(request, db, settings)
|
||
require_same_origin_intent(request)
|
||
return user
|
||
|
||
|
||
def administrator(user: AuthUser = Depends(authenticated)) -> AuthUser:
|
||
require_admin(user)
|
||
return user
|
||
|
||
|
||
@app.get("/health")
|
||
async def health() -> dict[str, str]:
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.post("/api/login")
|
||
async def login(payload: LoginRequest, request: Request, response: Response):
|
||
if settings.auth_disabled:
|
||
user = require_auth(request, db, settings)
|
||
return {"authenticated": True, "user": user.public()}
|
||
client_key = request.client.host if request.client else "unknown"
|
||
limiter.check(client_key)
|
||
user = find_user_for_key(payload.access_key, db)
|
||
if not user:
|
||
limiter.fail(client_key)
|
||
db.add_audit_event("login_failed")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED, detail="密钥不对"
|
||
)
|
||
limiter.clear(client_key)
|
||
create_session(response, db, settings, user.id)
|
||
db.add_audit_event("login_succeeded", user.id)
|
||
return {"authenticated": True, "user": user.public()}
|
||
|
||
|
||
@app.post("/api/logout")
|
||
async def logout(
|
||
request: Request,
|
||
response: Response,
|
||
user: AuthUser = Depends(authenticated),
|
||
):
|
||
delete_session(request, response, db, settings)
|
||
db.add_audit_event("logout", user.id)
|
||
return {"authenticated": False}
|
||
|
||
|
||
@app.get("/api/session")
|
||
async def session(user: AuthUser = Depends(authenticated)):
|
||
return {"authenticated": True, "user": user.public()}
|
||
|
||
|
||
@app.patch("/api/account/debug-sharing")
|
||
async def update_debug_sharing(
|
||
payload: DebugSharingUpdate,
|
||
user: AuthUser = Depends(authenticated),
|
||
):
|
||
updated = db.update_debug_sharing(user.id, payload.enabled)
|
||
if not updated:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||
db.add_audit_event(
|
||
"debug_sharing_changed",
|
||
user.id,
|
||
{"enabled": payload.enabled},
|
||
)
|
||
return updated
|
||
|
||
|
||
@app.get("/api/fragments")
|
||
async def list_fragments(user: AuthUser = Depends(authenticated)):
|
||
return {"items": db.list_fragments(user.id)}
|
||
|
||
|
||
@app.post("/api/fragments", status_code=status.HTTP_201_CREATED)
|
||
async def create_fragment(
|
||
payload: FragmentCreate, user: AuthUser = Depends(authenticated)
|
||
):
|
||
fragment = db.create_fragment(user.id, payload.content)
|
||
db.add_audit_event(
|
||
"fragment_created",
|
||
user.id,
|
||
{"fragment_id": fragment["id"], "characters": len(payload.content)},
|
||
)
|
||
curator.enqueue(fragment["id"])
|
||
return {
|
||
"id": fragment["id"],
|
||
"content": fragment["content"],
|
||
"created_at": fragment["created_at"],
|
||
"analysis_status": fragment["analysis_status"],
|
||
}
|
||
|
||
|
||
@app.get("/api/ideas")
|
||
async def list_ideas(user: AuthUser = Depends(authenticated)):
|
||
return {"items": db.list_ideas(user.id)}
|
||
|
||
|
||
@app.get("/api/ideas/{idea_id}")
|
||
async def get_idea(idea_id: str, user: AuthUser = Depends(authenticated)):
|
||
idea = db.get_idea(user.id, idea_id)
|
||
if not idea:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||
return idea
|
||
|
||
|
||
@app.patch("/api/ideas/{idea_id}/position")
|
||
async def update_idea_position(
|
||
idea_id: str,
|
||
payload: IdeaOverride,
|
||
user: AuthUser = Depends(authenticated),
|
||
):
|
||
idea = db.update_idea_override(user.id, idea_id, payload.maturity)
|
||
if not idea:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||
db.add_audit_event(
|
||
"idea_position_calibrated",
|
||
user.id,
|
||
{"idea_id": idea_id, "maturity": payload.maturity},
|
||
)
|
||
return idea
|
||
|
||
|
||
@app.get("/api/system/status")
|
||
async def system_status(user: AuthUser = Depends(authenticated)):
|
||
return {
|
||
"pending": db.processing_count(user.id),
|
||
"curator_configured": curator.configured,
|
||
}
|
||
|
||
|
||
@app.get("/api/admin/overview")
|
||
async def admin_overview(_: AuthUser = Depends(administrator)):
|
||
overview = db.admin_overview()
|
||
recent = overview["last_24h"]
|
||
if recent["runs"] == 0:
|
||
narrative = "过去 24 小时没有 Agent 运行。"
|
||
else:
|
||
narrative = (
|
||
f"过去 24 小时运行 {recent['runs']} 次,"
|
||
f"成功率 {recent['success_rate']}%,"
|
||
f"平均耗时 {round(recent['avg_duration_ms'] / 1000, 1)} 秒;"
|
||
f"其中推理 token {recent['reasoning_tokens']}。"
|
||
)
|
||
return {
|
||
**overview,
|
||
"runtime": {
|
||
"uptime_seconds": round(time.time() - started_at),
|
||
"curator_configured": curator.configured,
|
||
"queue_depth": curator.queue.qsize(),
|
||
"model": settings.deepseek_model,
|
||
"prompt_version": PROMPT_VERSION,
|
||
},
|
||
"narrative": narrative,
|
||
}
|
||
|
||
|
||
@app.get("/api/admin/users")
|
||
async def admin_users(_: AuthUser = Depends(administrator)):
|
||
return {"items": db.list_users()}
|
||
|
||
|
||
@app.get("/api/admin/runs")
|
||
async def admin_runs(
|
||
limit: int = Query(default=80, ge=1, le=200),
|
||
_: AuthUser = Depends(administrator),
|
||
):
|
||
return {"items": db.list_agent_runs(limit)}
|
||
|
||
|
||
@app.get("/api/admin/audit")
|
||
async def admin_audit(
|
||
limit: int = Query(default=80, ge=1, le=200),
|
||
_: AuthUser = Depends(administrator),
|
||
):
|
||
return {"items": db.list_audit_events(limit)}
|
||
|
||
|
||
def _redacted_event(event: dict[str, Any]) -> dict[str, Any]:
|
||
payload = event["payload"]
|
||
event_type = event["event_type"]
|
||
safe: dict[str, Any] = {}
|
||
if event_type == "run_started":
|
||
safe = payload
|
||
elif event_type == "context_loaded":
|
||
safe = payload
|
||
elif event_type in {
|
||
"attempt_started",
|
||
"model_round_completed",
|
||
"model_attempt_completed",
|
||
"convergence_repair_started",
|
||
}:
|
||
safe = payload
|
||
elif event_type == "tool_search_ideas":
|
||
safe = {"result_count": payload.get("result_count", 0)}
|
||
elif event_type == "tool_inspect_idea":
|
||
safe = {
|
||
key: payload.get(key)
|
||
for key in ("found", "fragment_count", "snapshot_count")
|
||
if key in payload
|
||
}
|
||
elif event_type == "validation_failed":
|
||
safe = {
|
||
"attempt": payload.get("attempt"),
|
||
"error_type": payload.get("error_type"),
|
||
}
|
||
elif event_type == "decision_committed":
|
||
safe = {
|
||
"standalone": payload.get("standalone"),
|
||
"assessment_count": len(payload.get("assessments", [])),
|
||
}
|
||
elif event_type == "run_failed":
|
||
safe = {"error_type": payload.get("error_type")}
|
||
return {**event, "payload": safe}
|
||
|
||
|
||
@app.get("/api/admin/runs/{run_id}")
|
||
async def admin_run_detail(
|
||
run_id: str, admin: AuthUser = Depends(administrator)
|
||
):
|
||
run = db.get_agent_run(run_id)
|
||
if not run:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||
content_visible = run["user_id"] == admin.id or run["debug_sharing"]
|
||
events = db.list_agent_events(run_id)
|
||
if not content_visible:
|
||
events = [_redacted_event(event) for event in events]
|
||
content = run.pop("content")
|
||
return {
|
||
"run": run,
|
||
"fragment": (
|
||
{"content": content, "content_visible": True}
|
||
if content_visible
|
||
else {
|
||
"content_visible": False,
|
||
"content_length": run["content_length"],
|
||
"content_sha256": run["content_sha256"],
|
||
}
|
||
),
|
||
"events": events,
|
||
"privacy": {
|
||
"content_visible": content_visible,
|
||
"reasoning_content_stored": False,
|
||
"reason": (
|
||
"管理员自己的运行"
|
||
if run["user_id"] == admin.id
|
||
else (
|
||
"用户已允许调试共享"
|
||
if run["debug_sharing"]
|
||
else "用户未允许调试共享,内容已脱敏"
|
||
)
|
||
),
|
||
},
|
||
}
|
||
|
||
|
||
@app.post(
|
||
"/api/admin/runs/{run_id}/retry",
|
||
status_code=status.HTTP_202_ACCEPTED,
|
||
)
|
||
async def retry_admin_run(
|
||
run_id: str, admin: AuthUser = Depends(administrator)
|
||
):
|
||
retry = db.prepare_agent_retry(run_id)
|
||
if not retry:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail="这次分析现在不能重新运行",
|
||
)
|
||
curator.enqueue(retry["fragment_id"])
|
||
db.add_audit_event(
|
||
"agent_run_requeued",
|
||
admin.id,
|
||
{"run_id": run_id, "owner_user_id": retry["user_id"]},
|
||
)
|
||
return {"queued": True}
|
||
|
||
|
||
static_dir = Path(__file__).parent / "static"
|
||
assets_dir = static_dir / "assets"
|
||
if assets_dir.is_dir():
|
||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||
|
||
|
||
@app.get("/{full_path:path}", include_in_schema=False)
|
||
async def frontend(full_path: str):
|
||
if full_path.startswith("api/"):
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||
target = static_dir / full_path
|
||
if full_path and target.is_file() and static_dir in target.resolve().parents:
|
||
return FileResponse(target)
|
||
index = static_dir / "index.html"
|
||
if index.is_file():
|
||
return FileResponse(index)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail="前端尚未构建",
|
||
)
|