190 lines
16 KiB
Markdown
190 lines
16 KiB
Markdown
# 运行手册
|
||
|
||
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
|
||
|
||
最后更新:2026-05-19(dev SPA 登录改邀请码;**邀请码后端从 env 升级到 `invites` 表**(0005 migration);新路由 `POST /v1/auth/login_invite`,原 `/v1/auth/login` 留给 platform 机器对机器入口;SENTINEL user 撤)
|
||
|
||
---
|
||
|
||
## 环境
|
||
|
||
- **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
|
||
# main.py web 必填(probe/db 用不到,只在起 web 时校验)
|
||
PLATFORM_KEY=<至少 16 字符的随机串,platform 服务端持有,机器对机器入口校验>
|
||
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护>
|
||
# 可选:覆盖默认 7d JWT TTL
|
||
# ZCBOT_JWT_TTL_SECONDS=604800
|
||
```
|
||
> 邀请码不再走 env(05-19 撤),改走 `invites` 表(见下方"邀请码"段)。
|
||
> litellm 在 import 时副作用加载 .env;入口走 `main.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**(`main.py web` 必填):`PLATFORM_KEY` + `JWT_SECRET`,任一缺失 web 启动会 fail-fast。生成随机串可用 `python -c "import secrets; print(secrets.token_urlsafe(48))"`。`main.py db / probe` 不验,不要这两个 env 也能跑。
|
||
- **邀请码**(`invites` 表,0005):dev SPA 登录后端。schema 极薄 — `token PK / name UNIQUE / created_at`。user_id 由 `uuid5(固定 namespace, name)` 推导,不入表(重启稳定)。空表 → `/v1/auth/login_invite` 全 403(发码:`INSERT INTO invites(token, name) VALUES(...)`;撤销:`DELETE FROM invites WHERE name=...`)。同事拿到 token 即可登录,**不接触** `PLATFORM_KEY`。token 生成可用 `python -c "import secrets;print(secrets.token_urlsafe(16))"`。后续可选加 `python main.py invite {add|list|revoke}` 薄包装。
|
||
|
||
---
|
||
|
||
## 一次性初始化
|
||
|
||
```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 main.py db upgrade head
|
||
.venv/Scripts/python.exe main.py db current # 应输出 0004 (head)
|
||
```
|
||
|
||
---
|
||
|
||
## 日常命令
|
||
|
||
> 入口统一在 `main.py`,只剩三个子命令:`web / db / probe`。所有 task / 消息 / 文件交互
|
||
> 走 `main.py web` 起服务后浏览器(dev SPA)或 platform 端 / curl 调 `/v1/*`(下方 Web API 段)。
|
||
|
||
### Web 服务(§7 D + D' 过渡 auth)
|
||
|
||
```bash
|
||
# 默认 127.0.0.1:8765 启;dev SPA 在 /,Swagger UI 在 /docs
|
||
.venv/Scripts/python.exe main.py web
|
||
|
||
# 自定义端口 / 监听 0.0.0.0(慎用,部署形态走反代不直暴)
|
||
.venv/Scripts/python.exe main.py web --port 9000
|
||
|
||
# dev:文件改动自动重启(uvicorn 工厂模式 reload)
|
||
.venv/Scripts/python.exe main.py web --reload
|
||
```
|
||
|
||
### 能力探测 / DB 管理
|
||
|
||
```bash
|
||
# 实测对账模型 yaml 声称的能力(费 token,有 API 开销)
|
||
.venv/Scripts/python.exe main.py probe --model deepseek_v4.flash
|
||
|
||
# DB migration
|
||
.venv/Scripts/python.exe main.py db upgrade head
|
||
.venv/Scripts/python.exe main.py db downgrade -1
|
||
.venv/Scripts/python.exe main.py db current
|
||
```
|
||
|
||
**Auth**:两条 login 路径,签**同款 JWT**。所有 `/v1/tasks*` 需 `Authorization: Bearer <jwt>`。
|
||
|
||
```bash
|
||
# 路径 1:邀请码(dev SPA 给同事 / 自己试用 — 推荐)
|
||
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_invite \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"token":"<同事的邀请码>"}'
|
||
# → {"token":"eyJ...","expires_at":"...","user_id":"...","name":"alice","ttl_seconds":604800}
|
||
|
||
# 路径 2:platform_key + 指定 user_id(platform 服务端机器对机器入口)
|
||
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"user_id":"<UUID>","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 表单填邀请码(需 `invites` 表里有对应行)进入 3 栏(task 列表 / chat / files)。给同事试用的最简发码流程:
|
||
1. `python -c "import secrets;print(secrets.token_urlsafe(16))"` 生成 token
|
||
2. PG 里 `INSERT INTO invites(token, name) VALUES('<tokenN>', '<nameN>');`(每人一条;name 是显示名,推导固定 user_id 用 — 建议英文 / 拼音 / 短)
|
||
3. **不用重启** web(每次 login 都查 DB),把 URL + 各自 token 分别发给同事
|
||
|
||
**路由表**(全 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` | platform 机器对机器入口;body `{user_id, platform_key}` → `{token,expires_at,user_id,ttl_seconds}` | 豁免 |
|
||
| `POST /v1/auth/login_invite` | dev SPA 邀请码入口;body `{token}` → `{token,expires_at,user_id,name,ttl_seconds}`;`invites` 表 token 未命中(含空表)→ 403 | 豁免 |
|
||
| `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` 1–100;`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/files?path=` | 列 user_root 下子目录条目 + 面包屑(user-rooted,不绑 task);dotfile 隐藏 | 必填 |
|
||
| `GET /v1/files/download?path=` | 下单文件(user_root 下) | 必填 |
|
||
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
|
||
| `POST /v1/files/delete` | body `{path}`;文件或空目录;**path 是顶层目录(user_root 直接子项,且为目录)且仍被 task 引用 working_dir → 409**,先 DELETE 关联 task | 必填 |
|
||
| `POST /v1/files/rename` | body `{path, new_name}`;`new_name` 是新 leaf 名(校验同 task_name);sibling 已存在 → 409;**path 是顶层目录** → 同步 `UPDATE tasks.working_dir`(同事务 + FOR UPDATE 锁;有 running/cancelling task → 409;`check_no_subtask` 防嵌套 → 409);非顶层(子目录 / 文件)纯 FS rename | 必填 |
|
||
| `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。`main.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 | dev SPA 左侧 task 列表看 task_id 是否在;或 `curl /v1/tasks` 拉 |
|
||
| `--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(平级) |
|
||
| `main.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)` | 上次 `main.py web` 进程未正常 finish 留下 N 个 `running` / `cancelling` Run 行,启动 lifespan 自动标 error。无需处理,info 级 |
|
||
| `POST /v1/files/delete` 返 409 `folder ... 仍被 N 个 task 引用` | 顶层目录(user_root 直接子项)被 task 引用 working_dir;先 `DELETE /v1/tasks/{id}` 删完所有关联 task 再删目录。子目录不受此限,可直接删空目录 |
|
||
| `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先点 stop / `POST /v1/tasks/{id}/cancel` 等流式 done 再 rename |
|
||
| `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套(§7.4 no-subtask)。换一个不冲突的 new_name |
|
||
| `main.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/auth/login_invite` 返 403 `invalid invite token` | `invites` 表无此 token(从未 INSERT 或已 DELETE)。`SELECT token, name, created_at FROM invites` 核对 |
|
||
| INSERT invites 报 `duplicate key value violates unique constraint` | token PK 或 name UNIQUE 撞,改值再试 — 重复 token = 两人同身份漏,重复 name = 两 token 共身份漏,都被 schema 拦 |
|
||
| 改了同事的 name 后他登不上 / 数据看不到 | user_id 由 `uuid5(namespace, name)` 推导,改 name 等于换身份(`UPDATE invites SET name=...` = 换 user)。要保留数据:别动 name,只 `UPDATE invites SET token=...`(换 token = 不换身份);真要换身份:告知"这是新账号,旧任务在另一个 user 下" |
|
||
| `/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 即可 |
|
||
|
||
---
|
||
|
||
## 关键路径与文件
|
||
|
||
- **入口**:`main.py`(`web / db / probe` 三子命令)→ `core/agent_builder.py::build_agent`(装配 lib)
|
||
- **核心**:`core/agent_builder.py`(build_agent / system prompt / validate_task_name 等装配 lib)/ `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 子树,user_id 来自 JWT `sub` — 邀请码登录走 uuid5、platform 登录直传):
|
||
- `workspace/users/<user_id>/.memory/{core.md, extended/}` —— 跨 task 记忆,FS 永久,dotfile 隔离
|
||
- `workspace/users/<user_id>/<working_dir>/` —— 工作目录,用户起的目录名(API `POST /v1/tasks {working_dir?}`,留空 fallback name),同 working_dir 多 task 共享
|
||
|
||
---
|
||
|
||
## 维护约定
|
||
|
||
- **每改一个对外行为(CLI 选项 / REPL 命令 / env 变量 / 文件布局)→ 同步更新本文档**。bug 修不动这个,只动 PROGRESS。
|
||
- 故障兜底表新增条目:用过一次的真实坑,写一行(现象 + 处理),不预测。
|
||
- 跟 DESIGN/PROGRESS 的边界:DESIGN 写"为什么",PROGRESS 写"做到哪",RUN 写"怎么跑"。
|