181 lines
7.5 KiB
Python
181 lines
7.5 KiB
Python
"""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 BigInteger, cast, func, select
|
|
|
|
from core.branding import brand_name
|
|
from core.storage.models import Task, UsageEvent
|
|
|
|
# 蓝绿双实例部署(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 = "-created_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`(`-created_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.created_at.desc()]
|
|
return cols
|
|
|
|
|
|
def usage_aggregates(s: Any, tids: list) -> dict:
|
|
"""按 task_id 批量聚合 usage_events:真实成本 + chat token + 缓存命中。
|
|
|
|
单查询 GROUP BY(复用列表接口 msg_counts 同款批量范式,无 N+1)。on-the-fly 现算,
|
|
不落 tasks 列 —— 对所有历史 task 即时准确,免回填。
|
|
- cost_cny:全 kind(chat+image+video)合计 = task 真实花费
|
|
- tokens_in/out + cache_hit:仅 chat。**三者同源 usage_events**,故缓存命中率
|
|
`cache_hit / tokens_in` 恒 ≤ 100%;不能拿 `tasks.tokens_prompt` 当分母 ——
|
|
那列会被「清空对话」重置而 usage_events 不重置,跨源相除会算出 >100% 的怪值。
|
|
返回 {task_id: {"cost_cny": float, "tokens_in": int, "tokens_out": int,
|
|
"tokens_cache_hit": int}}。
|
|
"""
|
|
if not tids:
|
|
return {}
|
|
chat = UsageEvent.kind == "chat"
|
|
tin_col = cast(UsageEvent.units["tokens_in"].astext, BigInteger)
|
|
tout_col = cast(UsageEvent.units["tokens_out"].astext, BigInteger)
|
|
hit_col = cast(UsageEvent.units["cache_hit_tokens"].astext, BigInteger)
|
|
rows = s.execute(
|
|
select(
|
|
UsageEvent.task_id,
|
|
func.coalesce(func.sum(UsageEvent.cost_cny), 0),
|
|
func.coalesce(func.sum(tin_col).filter(chat), 0),
|
|
func.coalesce(func.sum(tout_col).filter(chat), 0),
|
|
func.coalesce(func.sum(hit_col).filter(chat), 0),
|
|
)
|
|
.where(UsageEvent.task_id.in_(tids))
|
|
.group_by(UsageEvent.task_id)
|
|
).all()
|
|
return {
|
|
tid: {
|
|
"cost_cny": float(cost or 0),
|
|
"tokens_in": int(tin or 0),
|
|
"tokens_out": int(tout or 0),
|
|
"tokens_cache_hit": int(hit or 0),
|
|
}
|
|
for tid, cost, tin, tout, hit in rows
|
|
}
|
|
|
|
|
|
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 "",
|
|
"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}")
|