"""FastAPI app 工厂。G1 脚手架 → G2 task list 接 PG → G3+ 渐进上。 设计: - 单 FastAPI 进程,模板走 Jinja2,静态走 StaticFiles - 模板里 path 显示一律 `replace('\\', '/')`,Win / Linux 看到统一形态 (`Path.as_posix()` 在 Linux 读 Windows backslash 串时不归一,所以直接 replace) - SSE 在 G4 加,响应头会带 `X-Accel-Buffering: no`(nginx 反代友好) - 本地形态 sentinel user 固定;Phase D 加 OIDC 之后才有真正 user 态 """ from __future__ import annotations from pathlib import Path from typing import Any, Optional from uuid import UUID from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import func, select from core.storage import session_scope from core.storage.models import Message, Task WEB_ROOT = Path(__file__).resolve().parent TEMPLATES_DIR = WEB_ROOT / "templates" STATIC_DIR = WEB_ROOT / "static" STATUS_FILTERS = ("active", "completed", "abandoned") def _norm_path(p: str) -> str: """跨 OS 显示归一:backslash → forward slash。Win 存 `\\`、Linux 存 `/`,显示统一 `/`。""" return (p or "").replace("\\", "/") def list_tasks(limit: int = 50, status: Optional[str] = None) -> list[dict[str, Any]]: """Tasks 列表(updated_at 降序),含 messages 计数。 返回 dict 列表,模板友好;cli.py 的 `_list_task_rows` 自留 tuple 形态不变 (CLI / Web 数据形状不一定一致 — 等真有 schema 变更同步成本时再抽。) """ if status and status not in STATUS_FILTERS: status = None # 无效 status 静默忽略,等价 all with session_scope() as s: q = ( select( Task.task_id, Task.updated_at, Task.created_at, Task.status, Task.mode, Task.model, Task.model_profile, Task.tokens_prompt, Task.tokens_completion, Task.description, Task.task_dir, ) .order_by(Task.updated_at.desc()) ) if status: q = q.where(Task.status == status) rows_db = s.execute(q.limit(limit)).all() msg_counts = dict(s.execute( select(Message.task_id, func.count()).group_by(Message.task_id) ).all()) result = [] for r in rows_db: tid = r.task_id result.append({ "task_id": str(tid), "task_id_short": str(tid)[:8], "updated_at": r.updated_at, "created_at": r.created_at, "status": r.status, "mode": r.mode or "", "model_label": r.model_profile or r.model or "", "tokens": (r.tokens_prompt or 0) + (r.tokens_completion or 0), "n_messages": msg_counts.get(tid, 0), "description": r.description or "", "task_dir": _norm_path(r.task_dir or ""), }) return result def create_app() -> FastAPI: """FastAPI 工厂。uvicorn --reload 模式需要工厂签名(factory=True)。""" app = FastAPI(title="zcbot web", version="0.2") templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") @app.get("/", response_class=HTMLResponse) def home(request: Request, status: Optional[str] = None, limit: int = 50): tasks = list_tasks(limit=limit, status=status) return templates.TemplateResponse( request, "home.html", { "version": app.version, "tasks": tasks, "status": status or "", "limit": limit, "filters": STATUS_FILTERS, }, ) @app.get("/tasks/{task_id}", response_class=HTMLResponse) def task_detail(request: Request, task_id: str): """G2 占位:UUID 校验 + "G3 进行中" 提示。G3 落地后替换为消息渲染。""" try: tid = UUID(task_id) except ValueError: return HTMLResponse( f"invalid task id: {task_id!r}", status_code=404 ) # 顺便 DB 里查一下 task 是否存在 — 不存在直接 404,避免 G3 上之前给假象 with session_scope() as s: row = s.execute( select(Task.task_id, Task.description, Task.task_dir, Task.status) .where(Task.task_id == tid) ).first() if row is None: return HTMLResponse(f"task not found: {tid}", status_code=404) return templates.TemplateResponse( request, "task_placeholder.html", { "task_id": str(tid), "task_id_short": str(tid)[:8], "description": row.description or "", "task_dir": _norm_path(row.task_dir or ""), "status": row.status, }, ) @app.get("/healthz", response_class=HTMLResponse) def healthz(): return HTMLResponse("ok") return app