"""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 (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 (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, uuid4 as _uuid4 e = email.strip().lower() if not e or "@" not in e: click.echo(f"[err] email 不合法: {email!r}", err=True) sys.exit(2) if len(password) < 6: click.echo("[err] password 至少 6 字符", err=True) sys.exit(2) if user_id: try: uid = _UUID(user_id) except ValueError: click.echo(f"[err] user-id 不是合法 UUID: {user_id!r}", err=True) sys.exit(2) else: uid = _uuid4() from core.storage import session_scope from core.storage.models import User from web.auth import hash_password try: with session_scope() as s: s.add(User(user_id=uid, email=e, password_hash=hash_password(password))) except Exception as ex: # IntegrityError(email UNIQUE / user_id PK 撞)等都走这条 click.echo(f"[err] INSERT 失败: {type(ex).__name__}: {ex}", 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") if __name__ == "__main__": cli()