"""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 = "-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 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: ` + `data: `。""" 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}")