zcbot/main.py

223 lines
7.7 KiB
Python

"""zcbot 命令入口: web 服务 + db migration + 模型能力探测。
REPL 形态(原 `chat / tasks / export`)已撤(2026-05-18)—— 浏览器 dev SPA
(`/static/dev.html`)+ web `/v1/*` 路由全覆盖,本地命令行 REPL 维护双套
task 创建 / resume / 切换语义已经是冗余。所有交互走 `python main.py web`
起服务后浏览器操作。
装配 lib 在 `core/agent_builder.py`(原 main.py 内容,改名归位);
本文件仅做入口 + click 命令组。
"""
from __future__ import annotations
import sys
import click
from core.agent_builder import load_config
from core.paths import ROOT
from core.ui import make_console
@click.group()
def cli() -> None:
"""zcbot - 个人任务 agent"""
# ─────────────── DB migration(alembic 包装) ───────────────
@cli.group()
def db() -> None:
"""数据库管理 (alembic upgrade/downgrade/current)。需先 export ZCBOT_DB_URL。"""
def _alembic_cfg():
from alembic.config import Config
return Config(str(ROOT / "alembic.ini"))
def _run_alembic(fn, *args) -> None:
"""统一包一层友好出错(ZCBOT_DB_URL 未设置 / 连不上 → 简洁报错,不打 traceback)。"""
try:
fn(_alembic_cfg(), *args)
except RuntimeError as e:
click.echo(f"[err] {e}", err=True)
sys.exit(2)
except Exception as e:
click.echo(f"[err] {type(e).__name__}: {e}", err=True)
sys.exit(3)
@db.command("upgrade")
@click.argument("revision", default="head")
def db_upgrade(revision: str) -> None:
"""alembic upgrade <revision> (default head)."""
from alembic import command
_run_alembic(command.upgrade, revision)
@db.command("downgrade")
@click.argument("revision")
def db_downgrade(revision: str) -> None:
"""alembic downgrade <revision> (use -1 for one step, base for all)."""
from alembic import command
_run_alembic(command.downgrade, revision)
@db.command("current")
def db_current() -> None:
"""alembic current -- show currently applied revision."""
from alembic import command
_run_alembic(command.current)
# ─────────────── Capability probing ───────────────
@cli.command()
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
@click.option("--long-context", is_flag=True, help="加跑 needle-in-haystack(费 token,默认关)")
def probe(model: str, long_context: bool) -> None:
"""实测对账模型 yaml 声称的能力。会调用 LLM,有 API 开销。"""
from rich.table import Table
from core.capabilities import ModelCapabilities
from core.llm import LLM
from core.probe import probe_capabilities
cfg = load_config()
name = model or cfg["default_model"]
console = make_console()
try:
caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"])
except Exception as e:
console.print(f"[err]档案加载失败:[/err] {type(e).__name__}: {e}")
sys.exit(1)
console.print(
f"[bold]probing[/bold] [accent]{caps.model_id}[/accent] (profile: {name}) "
f"[muted]long-context={long_context}[/muted]\n"
)
try:
llm = LLM(caps)
except Exception as e:
console.print(f"[err]LLM 构造失败:[/err] {type(e).__name__}: {e}")
sys.exit(1)
with console.status("[muted]running probes...[/muted]", spinner="dots"):
report = probe_capabilities(caps, llm, include_long_context=long_context)
tbl = Table(show_lines=False)
tbl.add_column("capability", style="bold")
tbl.add_column("declared")
tbl.add_column("observed")
tbl.add_column("status")
tbl.add_column("detail")
color = {"ok": "ok", "mismatch": "warn", "error": "err", "skip": "muted"}
for r in report.results:
c = color.get(r.status, "info")
tbl.add_row(
r.name,
str(r.declared),
str(r.observed),
f"[{c}]{r.status}[/{c}]",
r.detail,
)
console.print(tbl)
if report.has_mismatch:
console.print(
"\n[warn]存在能力对账差异 —— 看 detail,必要时改 "
f"config/models/{caps.family}.yaml[/warn]"
)
sys.exit(2)
if any(r.status == "error" for r in report.results):
console.print("\n[err]部分探测出错(见 detail)[/err]")
sys.exit(3)
console.print("\n[ok]全部能力声明与实测一致。[/ok]")
# ─────────────── User 管理(dev SPA 邮箱密码登录后端 bootstrap) ───────────────
@cli.group()
def user() -> None:
"""用户管理:dev SPA 邮箱密码登录 bootstrap。需先 export ZCBOT_DB_URL。"""
@user.command("add")
@click.option("--email", required=True, help="登录邮箱(UNIQUE),登录页填这个")
@click.option("--password", required=True, help="明文密码,后台 bcrypt 哈希落盘")
@click.option("--user-id", default=None,
help="可选指定 UUID(默认随机);用于把已有 user_id 接到邮箱密码登录路径")
def user_add(email: str, password: str, user_id: str) -> None:
"""新建用户:bcrypt(password) → INSERT users(email,password_hash[,user_id])。
email 撞 UNIQUE → 报错退出 2;user_id 撞 PK 也是。撤销直接
`DELETE FROM users WHERE email='...'`(先清该 user 的 tasks,否则 FK 拦)。
"""
from uuid import UUID as _UUID
from web.auth import UserCreateError, create_user
uid_arg = None
if user_id:
try:
uid_arg = _UUID(user_id)
except ValueError:
click.echo(f"[err] user-id 不是合法 UUID: {user_id!r}", err=True)
sys.exit(2)
try:
uid, e = create_user(email=email, password=password, user_id=uid_arg)
except UserCreateError as ex:
click.echo(f"[err] {ex.message}", err=True)
sys.exit(2)
click.echo(f"[ok] user added email={e} user_id={uid}")
# ─────────────── Web 服务 ───────────────
@cli.command()
@click.option("--host", default="127.0.0.1", show_default=True,
help="监听地址。本地形态默认 127.0.0.1,不对外暴露")
@click.option("--port", default=8765, show_default=True, type=int,
help="监听端口")
@click.option("--reload/--no-reload", default=False,
help="dev:文件改动自动重启(uvicorn 工厂模式)")
def web(host: str, port: int, reload: bool) -> None:
"""启动 Web 服务(JSON API + dev SPA)。Auth 需 PLATFORM_KEY / JWT_SECRET env。"""
import uvicorn
if reload:
# reload 模式需要 import string + factory,uvicorn 才能监听文件
uvicorn.run("web.app:create_app", host=host, port=port,
reload=True, factory=True, log_level="info")
else:
from web.app import create_app
uvicorn.run(create_app(), host=host, port=port, log_level="info")
# ─────────────── Sandbox(Stage C 部署前置对账) ───────────────
@cli.group()
def sandbox() -> None:
"""Sandbox 容器部署对账(`ZCBOT_SANDBOX_BACKEND=docker` 启用前跑一遍)。"""
@sandbox.command("check")
def sandbox_check() -> None:
"""对账 docker backend 启动前置(daemon / 镜像 / network / HOST_UID / fs quota)。
非阻塞 ─ 每项独立打印 `[ok]` / `[warn]` / `[err]`,最后汇总。`err` 一项 → 退出 1,
全 ok / 仅 warn → 退出 0。warn 项不阻塞 web 启动,但**外部用户开放前必须清零**
(详 DESIGN §7.5 落地清单)。
"""
from core.sandbox.check import run_sandbox_check
rc = run_sandbox_check()
sys.exit(rc)
if __name__ == "__main__":
cli()