zcbot/web/common.py

146 lines
6.1 KiB
Python
Raw 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.

"""web 层共享常量与小 helper(从 app.py 析出,2026-07-23 拆分)。
只放「多个 router 都要用」的东西:实例/品牌常量、序列化 helper、task→JSON、
usage 聚合、SSE 帧格式。单一 router 专用的 helper 跟着各自 router 走,不进这里。
"""
from __future__ import annotations
import json
import os
from typing import Any, Optional
from sqlalchemy import select
from core.branding import brand_name
from core.storage.models import Task
# 蓝绿双实例部署(RUN.md B 档):实例名(blue/green),由 systemd 模板 unit 的
# per-instance env 注入;单实例部署不设 = ""。用途:① 起 run 时写 tasks.run_owner,
# 启动 reaper 只收自己实例的孤儿 ② sandbox 容器名/label 带实例色互不清扫(pool.py 同款
# 读法)③ /healthz 返回,蓝绿切换时验证 nginx 已指到新实例。
INSTANCE = os.getenv("ZCBOT_INSTANCE", "").strip()
# 对外品牌名:zcbot 只是内部项目代号,所有用户可见文案(页面标题 / 推送消息 / OAuth
# 提示页)统一用这个。/healthz 也返回,前端 boot 时拉取覆盖静态页里的默认值。
# 单一事实源在 core/branding.py(企业微信欢迎语等 web 层之外的文案也要用)。
BRAND = brand_name()
STATUS_FILTERS = ("active", "completed", "abandoned")
STATUS_WRITABLE = ("completed", "abandoned") # web 不让从 web 端切回 active(走 CLI)
# 渠道镜像 task 的 channel 取值(每用户每渠道一条常驻只读对话):从普通任务列表排除,
# 改由 /v1/channel_tasks 单独取、前端做成固定卡片。新增渠道在此追加即可。
CHANNEL_MIRROR_KINDS = ("wechat", "wecom")
ORDER_FIELDS = ("created_at", "updated_at", "name", "status")
ORDER_DEFAULT = "-updated_at"
def norm_path(p: str) -> str:
"""跨 OS 显示归一:backslash → forward slash。"""
return (p or "").replace("\\", "/")
def iso(dt: Optional[Any]) -> Optional[str]:
return dt.isoformat() if dt else None
def outline_snippet(text: Optional[str], maxlen: int = 48) -> str:
"""user 消息正文 → 目录条目片段:取首个非空行,压平首尾空白,截断到 maxlen。"""
if not text:
return ""
for line in text.splitlines():
line = line.strip()
if line:
return line[:maxlen]
return text.strip()[:maxlen]
def parse_ordering(s: Optional[str]) -> list:
"""DRF 风格 `ordering` 解析:逗号分隔多字段,`-` 前缀代表 desc。
allowlist 见 `ORDER_FIELDS`;非法字段静默丢弃。全部非法或空串 → `ORDER_DEFAULT`(`-updated_at`)。
返回 sqlalchemy `order_by` 列表(可直接 `*expand`)。
"""
spec = (s or "").strip() or ORDER_DEFAULT
cols = []
for part in spec.split(","):
p = part.strip()
if not p:
continue
asc = True
if p.startswith("-"):
asc = False
p = p[1:]
if p in ORDER_FIELDS:
col = getattr(Task, p)
cols.append(col.asc() if asc else col.desc())
if not cols:
# 用户传了全无效字段 → fallback 默认
cols = [Task.updated_at.desc()]
# 所有允许字段都可能重复UUID 兜底保证 OFFSET 分页在同一快照内稳定。
last_desc = str(cols[-1]).upper().endswith(" DESC")
cols.append(Task.task_id.desc() if last_desc else Task.task_id.asc())
return cols
def task_dict(
row: Any,
*,
n_messages: Optional[int] = None,
usage: Optional[dict] = None,
) -> dict:
"""Task ORM row → API JSON dict。
`usage`(可选)= `usage_aggregates` 算出的本 task 概要,带真实成本与缓存命中;
缺省回退到 tasks.cost_cny 列(多为 0)与 0 命中,前端据此显 ¥ / 缓存命中率。
"""
u = usage or {}
# token 总量优先取 usage_events 聚合(用量 source-of-truth,且与 cache_hit 同源 →
# 命中率分母一致、恒 ≤100%);无 usage 时回退 tasks 概览列。tasks.tokens_prompt 会被
# 「清空对话」重置,不能与 usage_events 的 cache_hit 跨源相除。
tokens_prompt = int(u["tokens_in"]) if "tokens_in" in u else (row.tokens_prompt or 0)
tokens_completion = int(u["tokens_out"]) if "tokens_out" in u else (row.tokens_completion or 0)
d = {
"task_id": str(row.task_id),
"name": row.name or "",
"auto_title_pending": bool(getattr(row, "auto_title_pending", False)),
"title_source": getattr(row, "title_source", None) or "manual",
"description": row.description or "",
"working_dir": norm_path(row.working_dir or ""),
"status": row.status,
"skill": row.skill or "",
"channel": getattr(row, "channel", None) or "web",
"model": row.model or "",
"model_profile": row.model_profile or "",
"tokens_prompt": tokens_prompt,
"tokens_completion": tokens_completion,
"tokens": tokens_prompt + tokens_completion,
# 缓存命中 token(chat 前缀缓存)+ 真实成本(已按缓存折价,见 usage.py)。
# on-the-fly 聚合;未传 usage 时回退列/0。
"tokens_cache_hit": int(u.get("tokens_cache_hit", 0)),
"cost_cny": float(u["cost_cny"]) if "cost_cny" in u else float(row.cost_cny or 0),
# 当前 run 状态(0004 schema 简化:原 runs 表合并入 task)
"run_status": row.run_status or "idle",
"run_error": row.run_error or None,
"created_at": iso(getattr(row, "created_at", None)),
"updated_at": iso(getattr(row, "updated_at", None)),
}
if n_messages is not None:
d["n_messages"] = n_messages
return d
def sse_event(event_type: str, payload: dict) -> bytes:
"""格式化 SSE 一帧:`event: <type>` + `data: <json single-line>`。"""
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return f"event: {event_type}\ndata: {body}\n\n".encode("utf-8")
def assert_owns_task(s, tid, user_id) -> None:
"""task 归属校验(404 掩盖存在性,不泄露他人 task id 是否有效)。"""
from fastapi import HTTPException
ok = s.execute(
select(Task.task_id).where(Task.task_id == tid, Task.user_id == user_id)
).first()
if ok is None:
raise HTTPException(404, f"task not found: {tid}")