Files

391 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="前端尚未构建",
)