146 lines
6.1 KiB
Python
146 lines
6.1 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 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}")
|