feat: open source multi-user AI notebook with observability
This commit is contained in:
+363
@@ -0,0 +1,363 @@
|
||||
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_attempt_completed"}:
|
||||
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 "用户未允许调试共享,内容已脱敏"
|
||||
)
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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="前端尚未构建",
|
||||
)
|
||||
Reference in New Issue
Block a user