zcbot/RUN.md

211 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 运行手册
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
最后更新:2026-05-18(0004 schema 简化:删 runs / usage_events 表;cancel 改 task-level `POST /v1/tasks/{id}/cancel`;SSE 改 `GET /v1/tasks/{id}/events`;run_status / run_error 合并入 tasks)
---
## 环境
- **Python**:虚拟环境 `.venv/`,所有依赖装在里面。一律用 `.venv/Scripts/python.exe ...`(Windows)/`.venv/bin/python ...`(Unix),不要全局 `python`(litellm/python-pptx 等会 ModuleNotFoundError)。
- **配置文件 `.env`**(项目根,git 忽略,litellm 自动加载):
```
DEEPSEEK_API_KEY=sk-...
ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot
# cli.py web 必填(纯 CLI 用不到,只在起 web 时校验)
PLATFORM_KEY=<至少 16 字符的随机串,platform 服务端 / dev 浏览器持有,登录时校验>
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护>
# 可选:覆盖默认 7d
# ZCBOT_JWT_TTL_SECONDS=604800
```
> litellm 在 import 时副作用加载 .env;CLI 入口直接走 `cli.py`,`.env` 会自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`。
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里)。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。
- **Auth env**(`cli.py web` 必填):`PLATFORM_KEY` + `JWT_SECRET`,任一缺失 web 启动会 fail-fast。生成随机串可用 `python -c "import secrets; print(secrets.token_urlsafe(48))"`。CLI(`chat / tasks / probe / db`)不验,不要这两个 env 也能跑。
---
## 一次性初始化
```bash
# 1) 装依赖(若 .venv 不在)
python -m venv .venv
.venv/Scripts/python.exe -m pip install -r requirements.txt
# 2) 准备 .env(见上)
# 3) DB schema 上车
.venv/Scripts/python.exe cli.py db upgrade head
.venv/Scripts/python.exe cli.py db current # 应输出 0004 (head)
```
---
## 日常命令
### 聊天 / 任务
```bash
# 新建 task —— `--name` 必填(任务显示名),`--working-dir` 可选(目录名,留空 → 用 --name)
.venv/Scripts/python.exe cli.py chat --name "初稿大纲" --working-dir proposal_v3
# 只给 name → working_dir fallback 用 name
.venv/Scripts/python.exe cli.py chat --name proposal_v3
# 带 skill + 描述(便于后续 list 识别)
.venv/Scripts/python.exe cli.py chat --name "修登录 401" --working-dir fix_login_bug --skill coding --desc "登录返回 401 排查"
# 同 working_dir 多 task(共享 workspace/users/<sentinel>/proposal_v3/ 目录,name 各不同)
.venv/Scripts/python.exe cli.py chat --name "补充资料" --working-dir proposal_v3
# 恢复最近一个 task(resume 时 --name / --working-dir 都忽略)
.venv/Scripts/python.exe cli.py chat --resume last
# 恢复指定 task(UUID 完整或 ≥8 字符前缀)
.venv/Scripts/python.exe cli.py chat --resume 76c6bd25
# 切模型
.venv/Scripts/python.exe cli.py chat --name x --model deepseek_v4.pro
```
REPL 内命令:`/exit /reset /new [<name>] /resume [last|<id>] /id /status /done /abandon /desc <文本> /export [<id>]`(`/new <name>` 用新任务名 + 沿用当前 working_dir;`/new` 无参 → 自动 gen `新任务_HH-MM-SS`)
### 列表 / 导出
```bash
# 看最近 20 个 task
.venv/Scripts/python.exe cli.py tasks
# 只看 active
.venv/Scripts/python.exe cli.py tasks --status active --limit 50
# 导出某 task 的对话为 .docx(自动从 PG 找 task_dir 作为输出目录)
.venv/Scripts/python.exe cli.py export 76c6bd25
# 导出最近的
.venv/Scripts/python.exe cli.py export last -o /tmp/chat.docx
```
### 能力探测 / DB 管理
```bash
# 实测对账模型 yaml 声称的能力(费 token,有 API 开销)
.venv/Scripts/python.exe cli.py probe --model deepseek_v4.flash
# DB migration
.venv/Scripts/python.exe cli.py db upgrade head
.venv/Scripts/python.exe cli.py db downgrade -1
.venv/Scripts/python.exe cli.py db current
```
### Web API(§7 D + D' 过渡 auth)
```bash
# 默认 127.0.0.1:8765 启;dev SPA 在 /,Swagger UI 在 /docs
.venv/Scripts/python.exe cli.py web
# 自定义端口 / 监听 0.0.0.0(慎用,部署形态走反代不直暴)
.venv/Scripts/python.exe cli.py web --port 9000
# dev:文件改动自动重启(uvicorn 工厂模式 reload)
.venv/Scripts/python.exe cli.py web --reload
```
**Auth**:所有 `/v1/tasks*``Authorization: Bearer <jwt>`;先走 `/v1/auth/login` 拿 token:
```bash
# 登录 → 拿 token(本地默 user_id = sentinel 全 0)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"user_id":"00000000-0000-0000-0000-000000000000","platform_key":"<value of $PLATFORM_KEY>"}'
# → {"token":"eyJ...","expires_at":"...","user_id":"...","ttl_seconds":604800}
# 用 token 调 /v1/*
TOKEN="eyJ..."
curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/tasks
```
**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),login 表单填 user_id(默 sentinel)+ PLATFORM_KEY 进入 3 栏(task 列表 / chat / files)。仅给开发自验,不发布给真用户。
**路由表**(全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `http://127.0.0.1:8765/docs`):
| 方法 + 路径 | 用途 | Auth |
|---|---|---|
| `GET /healthz` | `{"status":"ok"}` 健康检查 | 豁免 |
| `GET /` | 302 → `/static/dev.html` dev SPA | 豁免 |
| `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 |
| `GET /static/*` | dev.html 等静态文件 | 豁免 |
| `POST /v1/auth/login` | body `{user_id, platform_key}``{token,expires_at,user_id,ttl_seconds}` | 豁免 |
| `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 |
| `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`page` 1-based,`page_size` 1100;`working_dir` 末段名;`q` ILIKE name+desc;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 |
| `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
| `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}` 部分更新;active 走 CLI 切回 | 必填 |
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE),FS working_dir 保留 | 必填 |
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used(供创建 task 自动补全用) | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{events_url}`;**`tasks.run_status` 是 running / cancelling → 409**(单活 run 保护;error 状态视为可重启,起新 run 时清);UI 应 disable send 按钮直到 SSE `done` | 必填 |
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 — 单活 run 形态下无歧义 | 必填 |
| `POST /v1/tasks/{id}/cancel` | 协作式 cancel 当前活跃 run;返 `{ok, task_id, run_status:"cancelling"}`;`run_status != "running"` → 409;LLM 同步 call 本身不可中断,最坏等当前一轮跑完 | 必填 |
| `GET /v1/tasks/{id}/files?path=` | 列子目录条目 + 面包屑 | 必填 |
| `GET /v1/tasks/{id}/files/download?path=` | 下单文件 | 必填 |
| `POST /v1/tasks/{id}/files/upload` | multipart 上传,`path` 走 form | 必填 |
| `POST /v1/tasks/{id}/files/delete` | body `{path}`;文件或空目录 | 必填 |
| `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 |
**SSE 事件 schema**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}``text{content}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}``llm_end{prompt_tokens,completion_tokens}``done{}`;cancel 命中走 `cancelled{}` 后随 `done{}` 收流;异常路径走 `error{msg}`。30s 无 event 服务端发 `: ping` 注释心跳。SSE 经 nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。
**SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么走 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query 路径(目前不支持,避免 token 进 access log)。
> 原 Phase G Jinja2 + HTMX Web UI 路线撤(DESIGN §7.9 取舍说明)—— UI 改 platform 端实现,本仓库只维护 API + 一个 dev SPA。`cli.py web` 跑的是 API + Swagger + dev.html。
---
## 故障兜底
| 现象 | 原因 / 处理 |
|---|---|
| `ZCBOT_DB_URL is not set` | `.env` 没写 / litellm 链路没触发。直跑脚本时 `import litellm`,或 `export ZCBOT_DB_URL=...` |
| `ModuleNotFoundError: litellm` | 用了全局 `python`,改 `.venv/Scripts/python.exe ...` |
| Windows 控制台 emoji 崩 | Python stdout 是 GBK,emoji 不能直 print。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) |
| `db upgrade``column already exists` | DB 已被改过,先 `db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 |
| Resume 找不到 task | `cli.py tasks` 看 task_id 是否在;前缀冲突报 ambiguous 时给完整 UUID |
| `--working-dir` 指定后 `/exit` 没清目录 | 设计如此 —— 工作目录绝不 rmtree(同 working_dir 多 task 共享);DB 行该删还是删。要彻底删手动 `rm -rf <dir>` |
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export |
| `NoSubtaskError: working_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--working-dir`;否则改路径成 sibling(平级) |
| `cli.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或加 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` → `{"status":"ok"}` |
| SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`,nginx ≥ 1.5.6 默认认。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` |
| platform 端 CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)|
| `POST /v1/tasks/{id}/messages` 返 409 `task already has an active run` | 上一条消息的 BG run 还没跑完(SSE 没 `done`)。等流式跑完;或点 dev SPA 的 stop / `POST /v1/tasks/{id}/cancel`;服务异常下 `tasks.run_status``running`/`cancelling`,启动 reaper 会清 |
| `POST /v1/tasks/{id}/cancel` 返 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel,error 只能起新 run 顶掉);dev SPA 自动忽略不报错 |
| 点 stop 后流式没立刻停 | LLM 同步调用本身不可中断,最坏等当前一轮跑完(通常几十秒)。loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop 按钮 |
| `[startup] reaped N stale active run(s)` | 上次 `cli.py web` 进程未正常 finish 留下 N 个 `running` / `cancelling` Run 行,启动 lifespan 自动标 error。无需处理,info 级 |
| `cli.py web` 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写进 `.env` 重起 |
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/login` 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
| `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env |
| dev.html SSE 收不到流(消息发出去但 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`。看浏览器 devtools Network,POST /messages 是否 202 + Network 看 events_url GET 是否 200 + Content-Type 是 text/event-stream;若 401,token 过期了 — logout 重 login |
| dev.html 显示 "load failed" 且立刻回登录页 | token 过期或 JWT_SECRET 服务端变了,localStorage 旧 token 失效。已自动跳登录页,重新填 platform_key 即可 |
---
## 关键路径与文件
- **入口**:`cli.py`(REPL + `chat / tasks / probe / db / web` 子命令)→ `main.py::build_agent`(装配)
- **核心**:`core/loop.py`(ReAct)/ `core/session.py`(PG messages)/ `core/task.py`(PG tasks)/ `core/llm.py`(LiteLLM 封装)
- **工具**:`tools/{fs,shell,run_python,skill_tool}.py`
- **存储**:`core/storage/{engine,models,utils}.py`(SQLAlchemy 2.x ORM)+ `db/migrations/`(alembic)
- **Web**:`web/{app.py, auth.py, broker.py, sinks.py}`(FastAPI + /v1 JSON API + SSE + PLATFORM_KEY→JWT)+ `web/static/dev.html`(dev SPA,单文件 vanilla JS)
- **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile)
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**(per-user 子树,本地 CLI sentinel = `00000000-0000-0000-0000-000000000000`,web/JWT 用 sub):
- `workspace/users/<user_id>/.memory/{core.md, extended/}` —— 跨 task 记忆,FS 永久,dotfile 隔离
- `workspace/users/<user_id>/<working_dir>/` —— 工作目录,用户起的目录名(`cli chat --working-dir` 或留空 fallback `--name` / API `POST /v1/tasks {working_dir?}`),同 working_dir 多 task 共享
---
## 维护约定
- **每改一个对外行为(CLI 选项 / REPL 命令 / env 变量 / 文件布局)→ 同步更新本文档**。bug 修不动这个,只动 PROGRESS。
- 故障兜底表新增条目:用过一次的真实坑,写一行(现象 + 处理),不预测。
- 跟 DESIGN/PROGRESS 的边界:DESIGN 写"为什么",PROGRESS 写"做到哪",RUN 写"怎么跑"。