auth(dev SPA): 邀请码登录(invites 表 0005) + SENTINEL user 彻底撤

- 新增 POST /v1/auth/login_invite {token}: dev SPA 给同事试用,token → name → uuid5(NS, name) 推导 user_id;原 /v1/auth/login 保留为 platform 机器对机器入口
- 0005 migration 新表 invites(token PK / name UNIQUE / created_at);先用 ZCBOT_INVITES env 试了一版,讨论后升级到 DB 表 — schema 极薄,不入 user_id (uuid5 推导),不入 revoked_at (DELETE 即撤销);管理直接 SQL,后期可加 main.py invite CLI
- web/auth.py: 删 _parse_invites / AuthConfig.invites / env 读取;新模块函数 resolve_invite(token) 每次 SELECT,无缓存避免 DELETE 后还能登
- SENTINEL_USER_ID 常量 + ensure_local_sentinel 函数 + agent_builder fallback 全删 (CLI 撤后无 caller);storage/utils.py 三函数 user_id 改必填;TaskState 加 user_id 字段;build_agent user_id 改 KEYWORD_ONLY 必填;session.py 删多余 ensure_local_task_row (task 行 web 入口已 INSERT)
- DB 清: SENTINEL 行 + 5 个 dev task + 307 messages + workspace/users/00000000.../ 全删
- dev.html: 登录页 2 格 (uuid+key) → 1 格邀请码,header 显示 name·uuid 前 8 位
- 文档全套同步: RUN/DESIGN/PROGRESS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-19 13:14:31 +08:00
parent f61503fbdb
commit 53f59eb78a
16 changed files with 228 additions and 125 deletions

View File

@ -14,7 +14,7 @@
- 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4) - 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4)
- 任务持久化:任意时刻关机,下次能恢复 - 任务持久化:任意时刻关机,下次能恢复
- 演化性:模型升级不需要大改架构 - 演化性:模型升级不需要大改架构
- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG,无 SQLite / JSON 分支)/ web `/v1` API。本地形态 = `python main.py web` 起 FastAPI + dev SPA + sentinel JWT,跟 SaaS 走完全一致的路径(只是 user_id 一直填 sentinel),无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9) - **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG,无 SQLite / JSON 分支)/ web `/v1` API。本地形态 = `python main.py web` 起 FastAPI + dev SPA + 邀请码登录(`invites` 表,name → uuid5 推导 user_id),跟 SaaS 走完全一致的路径,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9)
--- ---
@ -47,9 +47,9 @@ zcbot/
└── main.py # 入口: web / db / probe 三子命令 └── main.py # 入口: web / db / probe 三子命令
``` ```
**工作目录(working_dir) = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 `task_dir` 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`;dev SPA 默认填 SENTINEL(`00000000-...`)走同一条路径,无本地 / 远程分叉。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/``<storage_root>/`,布局不变。 **工作目录(working_dir) = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 `task_dir` 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`:邀请码登录(`invites` 表)由 name 经 `uuid5(固定 namespace, name)` 推导,platform 登录则由 platform 直传;**无 SENTINEL fallback,所有路径必须显式有 user_id**。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/``<storage_root>/`,布局不变。
**启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login` 换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent`(`core/agent_builder.py`)读 `agent.yaml` → 加载 `ModelCapabilities``LLM(caps)` → 解析 working_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。 **启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login_invite`(dev SPA / 同事试用)或 `/v1/auth/login`(platform 机器对机器)换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent`(`core/agent_builder.py`)读 `agent.yaml` → 加载 `ModelCapabilities``LLM(caps)` → 解析 working_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。
--- ---
@ -111,7 +111,7 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 关键差异:**事实由用户判断,不由 LLM 自动总结**。 memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 关键差异:**事实由用户判断,不由 LLM 自动总结**。
**memory 永远在 FS,不入 DB**:统一 `<workspace_or_storage_root>/users/<user_id>/.memory/`(本地直接是 `workspace/`,SaaS 是 `<storage_root>/`,bind mount 进容器)。本地 CLI 走 SENTINEL user;web/JWT 走 `sub`。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 **memory 永远在 FS,不入 DB**:统一 `<workspace_or_storage_root>/users/<user_id>/.memory/`(本地直接是 `workspace/`,SaaS 是 `<storage_root>/`,bind mount 进容器)。`user_id` 全程从 JWT `sub` 透传(邀请码登录走 uuid5、platform 登录直传)。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。
--- ---
@ -195,10 +195,10 @@ SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。本
|---|---|---| |---|---|---|
| 入口 | `python main.py web` 起 FastAPI + dev SPA | uvicorn 部署形态,反代到 platform UI | | 入口 | `python main.py web` 起 FastAPI + dev SPA | uvicorn 部署形态,反代到 platform UI |
| Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) | | Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) |
| working_dir | `workspace/users/<sentinel>/<name>/`(dev SPA 默认填 SENTINEL) | `<storage_root>/users/<user_id>/<name>/`(JWT `sub`) | | working_dir | `workspace/users/<user_id>/<name>/`(user_id 从 JWT 透传) | `<storage_root>/users/<user_id>/<name>/`(JWT `sub`) |
| Memory | `workspace/users/<sentinel>/.memory/`(FS,dotfile) | `<storage_root>/users/<user_id>/.memory/`(仍是 FS,dotfile) | | Memory | `workspace/users/<user_id>/.memory/`(FS,dotfile) | `<storage_root>/users/<user_id>/.memory/`(仍是 FS,dotfile) |
| Sandbox | subprocess + env 过滤 | per-task docker exec | | Sandbox | subprocess + env 过滤 | per-task docker exec |
| Auth | PLATFORM_KEY → JWT(过渡)— dev SPA 填 sentinel + 本地 key | 同 — platform 端服务端持 key 签 JWT | | Auth | 邀请码(`invites` 表,name→uuid5)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邀请码同步下线) |
`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录(`workspace/` vs `<storage_root>/`),不在 storage 形态。 `workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录(`workspace/` vs `<storage_root>/`),不在 storage 形态。
@ -311,7 +311,7 @@ done {}
```sql ```sql
users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at) users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at)
-- 本地形态固定 INSERT sentinel: user_id = '00000000-...',email/auth/plan 全 NULL -- 行由 web auth 入口按需 INSERT(邀请码登录走 uuid5、platform 登录直传);email/auth/plan 全 NULL 直到接 OIDC
tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description, tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description,
status, model_profile, tokens_prompt, tokens_completion, cost_usd, status, model_profile, tokens_prompt, tokens_completion, cost_usd,
@ -331,6 +331,11 @@ messages(message_id uuid pk, task_id fk, idx int not null,
unique (task_id, idx)); unique (task_id, idx));
create index on messages using gin (payload jsonb_path_ops); create index on messages using gin (payload jsonb_path_ops);
-- 全文搜按需加 tsvector + GIN(中文 simple + pg_trgm 起步) -- 全文搜按需加 tsvector + GIN(中文 simple + pg_trgm 起步)
invites(token text pk, name text not null unique, created_at)
-- 0005 加;dev SPA 邀请码登录后端。user_id 由 uuid5(固定 NS, name) 推导,不入表。
-- 管理:直接 INSERT/DELETE/UPDATE(后续若需要可加 `python main.py invite ...` 薄包装);
-- 撤销 = DELETE row;换 token 不换身份 = UPDATE token;换 name = 换身份(旧 task 留在旧 user 下)。
``` ```
**0004 简化:删 `runs` / `usage_events` 表**(从未真用过 — 详 §7.9 取舍)。原 `runs` **0004 简化:删 `runs` / `usage_events` 表**(从未真用过 — 详 §7.9 取舍)。原 `runs`
@ -374,7 +379,7 @@ create index on messages using gin (payload jsonb_path_ops);
|---|---|---| |---|---|---|
| 1 | ~~事件流化 `loop.py`~~(commit `375bb29`) | done | | 1 | ~~事件流化 `loop.py`~~(commit `375bb29`) | done |
| 2 | **Storage 落 PG**:`Session` / `TaskState` 改 SQLAlchemy 写 PG;alembic;`cli migrate-from-fs`;`docker-compose.yml` 起本地 PG | 3 天 | | 2 | **Storage 落 PG**:`Session` / `TaskState` 改 SQLAlchemy 写 PG;alembic;`cli migrate-from-fs`;`docker-compose.yml` 起本地 PG | 3 天 |
| 3 | **task_dir 字段语义**:新建必给 `name`(简单名),task_dir 派生为 `<storage_root>/users/<user_id>/<name>/`(本地 `<storage_root>` = `workspace/`,sentinel user);同 name 多 task 共享同目录;`tools/fs.py::_resolve` 接 task_dir 注入;system prompt 注入 | 1 天 | | 3 | **task_dir 字段语义**:新建必给 `name`(简单名),task_dir 派生为 `<storage_root>/users/<user_id>/<name>/`(本地 `<storage_root>` = `workspace/`,user_id 走 JWT);同 name 多 task 共享同目录;`tools/fs.py::_resolve` 接 task_dir 注入;system prompt 注入 | 1 天 |
| 4 | **Folder API**:list / create / rename(cascade + 锁 running) / delete(hard cascade) / upload / download | 2 天 | | 4 | **Folder API**:list / create / rename(cascade + 锁 running) / delete(hard cascade) / upload / download | 2 天 |
| 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 SQL | 0.5 天 | | 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 SQL | 0.5 天 |
| 6 | **Executor + sandbox**:`run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入 | 2-3 天 | | 6 | **Executor + sandbox**:`run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入 | 2-3 天 |
@ -390,7 +395,7 @@ create index on messages using gin (payload jsonb_path_ops);
|---|---|---|---| |---|---|---|---|
| A | #1 事件流化 | done ✅ | sink 协议铺 SSE 路 | | A | #1 事件流化 | done ✅ | sink 协议铺 SSE 路 |
| B | #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + no-subtask)| done ✅ | 本地走 PG,messages 进 DB,任务/消息/状态全在 PG;task_dir 改相对存储(§7.4 注释)| | B | #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + no-subtask)| done ✅ | 本地走 PG,messages 进 DB,任务/消息/状态全在 PG;task_dir 改相对存储(§7.4 注释)|
| D | #7 HTTP /v1 surface(无 auth)| done ✅ | `/v1/tasks/*` + SSE JSON + files 4 路由 + export + Swagger;本地形态 sentinel user 跑通 | | D | #7 HTTP /v1 surface(无 auth)| done ✅ | `/v1/tasks/*` + SSE JSON + files 4 路由 + export + Swagger;本地形态走 JWT 跑通 |
| D' 过渡 | PLATFORM_KEY → JWT 兑换 + user_id 数据隔离 + dev SPA | done ✅ | `POST /v1/auth/login` 拿 token,`Authorization: Bearer` 走全部 /v1/tasks*;`web/static/dev.html` 单文件 3 栏 SPA 给开发自验。详 §7.3 | | D' 过渡 | PLATFORM_KEY → JWT 兑换 + user_id 数据隔离 + dev SPA | done ✅ | `POST /v1/auth/login` 拿 token,`Authorization: Bearer` 走全部 /v1/tasks*;`web/static/dev.html` 单文件 3 栏 SPA 给开发自验。详 §7.3 |
| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天 | 真发布给真实用户前补;路由层 Depends 不动,只换 login 内部 | | D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天 | 真发布给真实用户前补;路由层 Depends 不动,只换 login 内部 |
| C | #6 Executor + sandbox | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 | | C | #6 Executor + sandbox | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 |
@ -443,11 +448,11 @@ create index on messages using gin (payload jsonb_path_ops);
- 删 `web/templates/*` `web/static/*` + jinja2/markdown-it-py/pygments/mdit-py-plugins 依赖 - 删 `web/templates/*` `web/static/*` + jinja2/markdown-it-py/pygments/mdit-py-plugins 依赖
- SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠 - SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠
- 路由统一 `/v1` 前缀,响应全 JSON,FastAPI 自带 `/docs` Swagger UI 接替"对内调试"角色(本地形态 `GET /` 302→ `/docs`) - 路由统一 `/v1` 前缀,响应全 JSON,FastAPI 自带 `/docs` Swagger UI 接替"对内调试"角色(本地形态 `GET /` 302→ `/docs`)
- 本地 sentinel user 形态保留;auth 走 D' 过渡形态(PLATFORM_KEY → JWT,见 §7.3),真 OIDC 留到联调约定 token 形态后接 - auth 走 D' 过渡形态:邀请码(`invites` 表,dev SPA / 同事试用)+ PLATFORM_KEY(platform 机器对机器),两条都签同款 JWT(见 §7.3);真 OIDC 留到联调约定 token 形态后接
- CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧 - CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧
- **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连 - **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连
**dev SPA 留一份 + 升级为本地 dogfood 主路径**(2026-05-15 决策,2026-05-18 强化):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),~1100 行无构建链。**与"UI 由 platform 实现"不冲突**:platform UI 是给真用户的生产形态;dev.html 是给本仓库开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着;④ CLI REPL 撤(见下条)后 dev SPA 成为唯一本地交互通道,功能要齐(新建 / resume / done/abandon / 硬删 / 改 status/desc/name/skill / 文件浏览 + 上传 + 删 / chat 流式 + stop / 导出 docx)。形态:登录页填 user_id(默 sentinel)+ platform_key → localStorage 存 JWT → fetch+Bearer。 **dev SPA 留一份 + 升级为本地 dogfood 主路径**(2026-05-15 决策,2026-05-18 强化):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),~1100 行无构建链。**与"UI 由 platform 实现"不冲突**:platform UI 是给真用户的生产形态;dev.html 是给本仓库开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着;④ CLI REPL 撤(见下条)后 dev SPA 成为唯一本地交互通道,功能要齐(新建 / resume / done/abandon / 硬删 / 改 status/desc/name/skill / 文件浏览 + 上传 + 删 / chat 流式 + stop / 导出 docx)。形态:登录页填邀请码 → `/v1/auth/login_invite` 拿 JWT → localStorage 存 → fetch+Bearer。
**CLI REPL 撤,入口统一 `main.py {web,db,probe}`**(2026-05-18 决策,推翻原"CLI 双模式共存"): **CLI REPL 撤,入口统一 `main.py {web,db,probe}`**(2026-05-18 决策,推翻原"CLI 双模式共存"):
- **原计划**:`cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。 - **原计划**:`cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。
@ -456,7 +461,7 @@ create index on messages using gin (payload jsonb_path_ops);
- `cli.py` 改名 `main.py`(入口);原 `main.py`(装配 lib)挪到 `core/agent_builder.py`(单一职责,SoC)。 - `cli.py` 改名 `main.py`(入口);原 `main.py`(装配 lib)挪到 `core/agent_builder.py`(单一职责,SoC)。
- 删 `chat / tasks / export` 三命令(浏览器 dev SPA + web `/v1` 全覆盖);保留 `web / db / probe`(uvicorn / alembic / 模型探测,各有不可替代逻辑)。 - 删 `chat / tasks / export` 三命令(浏览器 dev SPA + web `/v1` 全覆盖);保留 `web / db / probe`(uvicorn / alembic / 模型探测,各有不可替代逻辑)。
- 净减 ~400 行 REPL 逻辑(`_cleanup_if_empty / _list_task_rows / _resolve_uuid_or_prefix / build_agent.console` 等 CLI-only 代码),`main.py` ~180 行,`core/agent_builder.py` ~320 行。 - 净减 ~400 行 REPL 逻辑(`_cleanup_if_empty / _list_task_rows / _resolve_uuid_or_prefix / build_agent.console` 等 CLI-only 代码),`main.py` ~180 行,`core/agent_builder.py` ~320 行。
- **失**:CLI "无 auth 直跑调 core 内部状态"通道。但 dev SPA 默认填 SENTINEL 走同一条 web 路径,看内部状态可以临时写几行 ad-hoc script(`from core.agent_builder import build_agent; ...`),不需要常驻 CLI 命令。 - **失**:CLI "无 auth 直跑调 core 内部状态"通道。但 dev SPA 邀请码登录走同一条 web 路径,看内部状态可以临时写几行 ad-hoc script(`from core.agent_builder import build_agent; ...`),不需要常驻 CLI 命令。
- **离线**:本地 `python main.py web` 起服务 + 浏览器 dev SPA;`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG,跟之前一样,不靠"全栈零依赖"幻觉。 - **离线**:本地 `python main.py web` 起服务 + 浏览器 dev SPA;`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG,跟之前一样,不靠"全栈零依赖"幻觉。
**Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。 **Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。

File diff suppressed because one or more lines are too long

35
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-18(`cli.py` 改名 `main.py`(入口),原 `main.py` 挪到 `core/agent_builder.py`(装配 lib);CLI 子命令 `chat / tasks / export` 删 — 全走 web `/v1` + dev SPA;`main.py` 只剩 `web / db / probe` 三命令) 最后更新:2026-05-19(dev SPA 登录改邀请码;**邀请码后端从 env 升级到 `invites` 表**(0005 migration);新路由 `POST /v1/auth/login_invite`,原 `/v1/auth/login` 留给 platform 机器对机器入口;SENTINEL user 撤)
--- ---
@ -14,15 +14,17 @@
DEEPSEEK_API_KEY=sk-... DEEPSEEK_API_KEY=sk-...
ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot
# main.py web 必填(probe/db 用不到,只在起 web 时校验) # main.py web 必填(probe/db 用不到,只在起 web 时校验)
PLATFORM_KEY=<至少 16 字符的随机串,platform 服务端 / dev 浏览器持有,登录时校验> PLATFORM_KEY=<至少 16 字符的随机串,platform 服务端持有,机器对机器入口校验>
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护> JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护>
# 可选:覆盖默认 7d # 可选:覆盖默认 7d JWT TTL
# ZCBOT_JWT_TTL_SECONDS=604800 # 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=...` > 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` 里)。 - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里)。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。 - **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 也能跑。 - **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}` 薄包装。
--- ---
@ -72,13 +74,19 @@ python -m venv .venv
.venv/Scripts/python.exe main.py db current .venv/Scripts/python.exe main.py db current
``` ```
**Auth**:所有 `/v1/tasks*``Authorization: Bearer <jwt>`;先走 `/v1/auth/login` 拿 token: **Auth**:两条 login 路径,签**同款 JWT**。所有 `/v1/tasks*``Authorization: Bearer <jwt>`
```bash ```bash
# 登录 → 拿 token(本地默 user_id = sentinel 全 0) # 路径 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 \ curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"user_id":"00000000-0000-0000-0000-000000000000","platform_key":"<value of $PLATFORM_KEY>"}' -d '{"user_id":"<UUID>","platform_key":"<value of $PLATFORM_KEY>"}'
# → {"token":"eyJ...","expires_at":"...","user_id":"...","ttl_seconds":604800} # → {"token":"eyJ...","expires_at":"...","user_id":"...","ttl_seconds":604800}
# 用 token 调 /v1/* # 用 token 调 /v1/*
@ -86,7 +94,10 @@ TOKEN="eyJ..."
curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/tasks 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)。仅给开发自验,不发布给真用户。 **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`): **路由表**(全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `http://127.0.0.1:8765/docs`):
@ -96,7 +107,8 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `GET /` | 302 → `/static/dev.html` dev SPA | 豁免 | | `GET /` | 302 → `/static/dev.html` dev SPA | 豁免 |
| `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 | | `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 |
| `GET /static/*` | dev.html 等静态文件 | 豁免 | | `GET /static/*` | dev.html 等静态文件 | 豁免 |
| `POST /v1/auth/login` | body `{user_id, platform_key}``{token,expires_at,user_id,ttl_seconds}` | 豁免 | | `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?}` | 必填 | | `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?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 | 必填 | | `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
@ -145,6 +157,9 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `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 `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 | | `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` 重起 | | `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 `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 | | `/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 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 |
@ -161,9 +176,9 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
- **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) - **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) - **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile)
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**(per-user 子树,本地 CLI sentinel = `00000000-0000-0000-0000-000000000000`,web/JWT 用 sub): - **Workspace**(per-user 子树,user_id 来自 JWT `sub` — 邀请码登录走 uuid5、platform 登录直传):
- `workspace/users/<user_id>/.memory/{core.md, extended/}` —— 跨 task 记忆,FS 永久,dotfile 隔离 - `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 共享 - `workspace/users/<user_id>/<working_dir>/` —— 工作目录,用户起的目录名(API `POST /v1/tasks {working_dir?}`,留空 fallback name),同 working_dir 多 task 共享
--- ---

View File

@ -6,9 +6,9 @@
workspace/users/<user_id>/<working_dir>/ 工作目录(用户起名,可多 task 共享) workspace/users/<user_id>/<working_dir>/ 工作目录(用户起名,可多 task 共享)
workspace/users/<user_id>/.memory/{core.md, extended/} per-user 记忆(dotfile 隔离) workspace/users/<user_id>/.memory/{core.md, extended/} per-user 记忆(dotfile 隔离)
所有入口都走 web `/v1` + JWT(user_id = sub);本地 dev SPA 默认填 SENTINEL 所有入口都走 web `/v1` + JWT(user_id = sub);dev SPA 走邀请码登录(`invites` ,
(`00000000-...`)走同一条路径task_id / user_id UUID;state.json 已删除 name uuid5)platform 服务端走 platform_key 登录task_id / user_id UUID;
(元数据全在 PG) state.json 已删除(元数据全在 PG)
**新建 task 必须给 `name`**(任务显示名,DB NOT NULL);**`working_dir` 可选** **新建 task 必须给 `name`**(任务显示名,DB NOT NULL);**`working_dir` 可选**
(留空 name 作目录名; working_dir task 自动共享 §7.1)name working_dir (留空 name 作目录名; working_dir task 自动共享 §7.1)name working_dir
@ -33,7 +33,7 @@ from core.paths import ROOT, from_db_path, to_db_path
from core.session import Session from core.session import Session
from core.sinks import ConsoleEventSink from core.sinks import ConsoleEventSink
from core.skills import SkillRegistry from core.skills import SkillRegistry
from core.storage import SENTINEL_USER_ID, check_no_subtask, ensure_local_sentinel from core.storage import check_no_subtask
from core.task import TaskState from core.task import TaskState
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
from tools.run_python import RunPythonTool from tools.run_python import RunPythonTool
@ -163,6 +163,8 @@ def _build_system_prompt(
def build_agent( def build_agent(
*,
user_id: UUID,
model_name: Optional[str] = None, model_name: Optional[str] = None,
workspace: Optional[str] = None, workspace: Optional[str] = None,
console: Optional[Console] = None, console: Optional[Console] = None,
@ -173,7 +175,6 @@ def build_agent(
description: str = "", description: str = "",
name: Optional[str] = None, name: Optional[str] = None,
working_dir: Optional[str] = None, working_dir: Optional[str] = None,
user_id: Optional[UUID] = None,
) -> Tuple[AgentLoop, Session, str, TaskState, Path]: ) -> Tuple[AgentLoop, Session, str, TaskState, Path]:
"""返回 (agent, session, task_id_str, task_state, working_dir_path)。 """返回 (agent, session, task_id_str, task_state, working_dir_path)。
@ -182,15 +183,12 @@ def build_agent(
- `working_dir` 可选(留空 fallback name 作目录名;非空也走 validate_task_name) - `working_dir` 可选(留空 fallback name 作目录名;非空也走 validate_task_name)
Resume:name / working_dir 都忽略( DB ) Resume:name / working_dir 都忽略( DB )
`user_id` 决定 working_dir memory 子树no-subtask 校验作用域 `user_id` 必填,决定 working_dir memory 子树no-subtask 校验作用域
None SENTINEL(本地 CLI)web 入口必须显式传入 JWT user_id web 入口从 JWT 拿到后透传;不允许无 user 的调用路径
""" """
cfg = load_config() cfg = load_config()
model = model_name or cfg["default_model"] model = model_name or cfg["default_model"]
uid = user_id or SENTINEL_USER_ID uid = user_id
# 本地 sentinel user 入库(idempotent);build_agent 是所有 task 操作的入口
ensure_local_sentinel()
caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"]) caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"])
llm = LLM(caps) llm = LLM(caps)
@ -253,7 +251,7 @@ def build_agent(
# tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里 # tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里
# 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令) # 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令)
task_state = TaskState( task_state = TaskState(
task_id=sid, name="", working_dir=wd_db, task_id=sid, user_id=uid, name="", working_dir=wd_db,
skill=skill, description=description, status="active", skill=skill, description=description, status="active",
model=caps.model_id, model_profile=model, model=caps.model_id, model_profile=model,
) )
@ -265,7 +263,7 @@ def build_agent(
# ensure_local_task_row 占位 INSERT(name 已就位);首次 sync_task_tokens # ensure_local_task_row 占位 INSERT(name 已就位);首次 sync_task_tokens
# 或 /done /desc 走 upsert 覆盖完整字段。 # 或 /done /desc 走 upsert 覆盖完整字段。
task_state = TaskState( task_state = TaskState(
task_id=sid, name=task_name_safe, working_dir=wd_db, task_id=sid, user_id=uid, name=task_name_safe, working_dir=wd_db,
skill=skill, description=description, status="active", skill=skill, description=description, status="active",
model=caps.model_id, model_profile=model, model=caps.model_id, model_profile=model,
reasoning_effort=caps.default_reasoning_effort or "", reasoning_effort=caps.default_reasoning_effort or "",

View File

@ -12,8 +12,8 @@
memory per-user(同一 workspace 内按 user_id 隔离), user 的所有 task 共享 memory per-user(同一 workspace 内按 user_id 隔离), user 的所有 task 共享
**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` )区分,避免 **dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` )区分,避免
项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` ,双向防呆 项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` ,双向防呆
本地 CLI = SENTINEL user;web/JWT subSaaS 化时 `<storage_root>` 替换 user_id web auth 入口(JWT `sub`)透传到 build_agentSaaS 化时 `<storage_root>`
`workspace`,布局不变(§7.0) 替换 `workspace`,布局不变(§7.0)
""" """
from __future__ import annotations from __future__ import annotations

View File

@ -70,28 +70,16 @@ class Session:
self.messages.append({"role": "system", "content": system_prompt}) self.messages.append({"role": "system", "content": system_prompt})
def append(self, msg: Any) -> None: def append(self, msg: Any) -> None:
"""追加消息;非 system 落 DB,system 仅内存。""" """追加消息;非 system 落 DB,system 仅内存。
前置条件:tasks 行已由 web 入口(`POST /v1/tasks` `ensure_local_task_row`)写入;
Session 不再做 idempotent ensure( user 上下文, task 必先在,多余)
"""
msg_dict = _to_dict(msg) msg_dict = _to_dict(msg)
self.messages.append(msg_dict) self.messages.append(msg_dict)
if msg_dict.get("role") == "system": if msg_dict.get("role") == "system":
return return
# 首次写入前,让 tasks 行就位。`ensure_local_task_row` 在 storage 层 idempotent。
# meta 字段(name/working_dir/skill/description/reasoning_effort)走 INSERT 一次性带入,
# 避免首次 append 后 _list_task_rows 看到空 meta;后续 task_state.save() 走 UPSERT 覆盖。
# name 是 NOT NULL,build_agent 必须放进 meta(新建 / resume 都已就位)。
from .storage.utils import ensure_local_task_row
ensure_local_task_row(
task_id=self.task_id,
name=self.meta.get("name", ""),
working_dir=self.meta.get("working_dir", ""),
skill=self.meta.get("skill", ""),
description=self.meta.get("description", ""),
model=self.meta.get("model", ""),
model_profile=self.meta.get("model_profile", ""),
reasoning_effort=self.meta.get("reasoning_effort", ""),
)
with session_scope() as s: with session_scope() as s:
s.add(Message( s.add(Message(
task_id=self.task_id, task_id=self.task_id,

View File

@ -1,18 +1,17 @@
"""§7 B 阶段:Storage 落 PG。 """§7 B 阶段:Storage 落 PG。
入口: 入口:
from core.storage import get_engine, session_scope, ensure_local_sentinel from core.storage import get_engine, session_scope
from core.storage.models import User, Task, Message from core.storage.models import User, Task, Message
ZCBOT_DB_URL 环境变量必填(本地连测试 / staging PG;SaaS 连生产 PG) ZCBOT_DB_URL 环境变量必填(本地连测试 / staging PG;SaaS 连生产 PG)
未设置时 get_engine() RuntimeError 并指引设置 未设置时 get_engine() RuntimeError 并指引设置
users 行由 web auth 入口按需 INSERT,storage 层不再 bootstrap 任何固定 user
""" """
from .engine import ( from .engine import (
ensure_local_sentinel,
get_engine, get_engine,
session_scope, session_scope,
) )
from .models import SENTINEL_USER_ID
from .utils import ( from .utils import (
NoSubtaskError, NoSubtaskError,
check_no_subtask, check_no_subtask,
@ -24,9 +23,7 @@ from .utils import (
__all__ = [ __all__ = [
"NoSubtaskError", "NoSubtaskError",
"SENTINEL_USER_ID",
"check_no_subtask", "check_no_subtask",
"ensure_local_sentinel",
"ensure_local_task_row", "ensure_local_task_row",
"get_engine", "get_engine",
"get_task", "get_task",

View File

@ -1,9 +1,10 @@
"""PG 连接 + Session factory + 本地 sentinel 初始化 """PG 连接 + Session factory
`ZCBOT_DB_URL` 必填,标准 SQLAlchemy URL,: `ZCBOT_DB_URL` 必填,标准 SQLAlchemy URL,:
postgresql+psycopg://user:pass@host:5432/zcbot postgresql+psycopg://user:pass@host:5432/zcbot
未设置时 get_engine() RuntimeError 并打印指引(不引导 docker) 未设置时 get_engine() RuntimeError 并打印指引(不引导 docker)
users 行由 web auth 入口按需 INSERT (`web.auth.ensure_user_row`),引擎层不再 bootstrap
""" """
from __future__ import annotations from __future__ import annotations
@ -11,11 +12,9 @@ import os
from contextlib import contextmanager from contextlib import contextmanager
from typing import Iterator, Optional from typing import Iterator, Optional
from sqlalchemy import Engine, create_engine, select from sqlalchemy import Engine, create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from .models import SENTINEL_USER_ID, User
_engine: Optional[Engine] = None _engine: Optional[Engine] = None
_SessionLocal: Optional[sessionmaker[Session]] = None _SessionLocal: Optional[sessionmaker[Session]] = None
@ -64,17 +63,3 @@ def session_scope() -> Iterator[Session]:
raise raise
finally: finally:
s.close() s.close()
def ensure_local_sentinel() -> None:
"""本地形态:若 users 表无 sentinel 行则 INSERT。
本地 CLI 启动时调用一次,SaaS 形态不调用(用户由 auth 流程创建)
幂等
"""
with session_scope() as s:
existing = s.execute(
select(User).where(User.user_id == SENTINEL_USER_ID)
).scalar_one_or_none()
if existing is None:
s.add(User(user_id=SENTINEL_USER_ID))

View File

@ -1,10 +1,11 @@
"""SQLAlchemy 2.x ORM models,对应 DESIGN.md §7.4 schema。 """SQLAlchemy 2.x ORM models,对应 DESIGN.md §7.4 schema。
3 张表:users / tasks / messages 4 张表:users / tasks / messages / invites
- users 本地形态固定 INSERT sentinel(`00000000-...`) - users 行在 web 入口(`/v1/auth/login*`)按需 INSERT;user_id invite name uuid5 推导 platform 直传
- messages.payload jsonb,GIN 索引在 migration 里建 - messages.payload jsonb,GIN 索引在 migration 里建
- run 状态承载在 tasks.run_status / run_error 两列(0004 合并 runs ); - run 状态承载在 tasks.run_status / run_error 两列(0004 合并 runs );
runs / usage_events 0004 DESIGN §7.4 取舍 / PROGRESS 05-18 runs / usage_events 0004 DESIGN §7.4 取舍 / PROGRESS 05-18
- invites 0005 (token PK / name UNIQUE / created_at):dev SPA 邀请码登录后端
""" """
from __future__ import annotations from __future__ import annotations
@ -30,10 +31,6 @@ class Base(DeclarativeBase):
pass pass
# 本地单用户 sentinel —— 所有本地 task 都 FK 到这一行
SENTINEL_USER_ID: UUID = UUID("00000000-0000-0000-0000-000000000000")
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@ -97,3 +94,19 @@ class Message(Base):
) )
class Invite(Base):
"""dev SPA 邀请码登录后端表(0005)。
token lookup 入口(PK);name 是显示名 + uuid5(NS, name) user_id 推导源
(UNIQUE 防同名 = token 共身份);created_at 给审计
管理:目前直接在 DB INSERT/DELETE(`python main.py invite ...` 后续若需要再加)
"""
__tablename__ = "invites"
token: Mapped[str] = mapped_column(Text, primary_key=True)
name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View File

@ -8,7 +8,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from .engine import session_scope from .engine import session_scope
from .models import SENTINEL_USER_ID, Task from .models import Task
class NoSubtaskError(ValueError): class NoSubtaskError(ValueError):
@ -18,13 +18,13 @@ class NoSubtaskError(ValueError):
def ensure_local_task_row( def ensure_local_task_row(
task_id: UUID, task_id: UUID,
name: str, name: str,
user_id: UUID,
working_dir: str = "", working_dir: str = "",
skill: str = "", skill: str = "",
description: str = "", description: str = "",
model: str = "", model: str = "",
model_profile: str = "", model_profile: str = "",
reasoning_effort: str = "", reasoning_effort: str = "",
user_id: UUID = SENTINEL_USER_ID,
) -> None: ) -> None:
"""占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。 """占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。
@ -55,7 +55,7 @@ def ensure_local_task_row(
def upsert_task( def upsert_task(
task_id: UUID, task_id: UUID,
*, *,
user_id: UUID = SENTINEL_USER_ID, user_id: UUID,
**fields: Any, **fields: Any,
) -> None: ) -> None:
"""INSERT ... ON CONFLICT DO UPDATE —— TaskState.save 的落地点。 """INSERT ... ON CONFLICT DO UPDATE —— TaskState.save 的落地点。
@ -105,7 +105,7 @@ def get_task(task_id: UUID) -> Optional[Task]:
def check_no_subtask( def check_no_subtask(
working_dir: str, working_dir: str,
user_id: UUID = SENTINEL_USER_ID, user_id: UUID,
exclude_task_ids: Optional[Iterable[UUID]] = None, exclude_task_ids: Optional[Iterable[UUID]] = None,
) -> None: ) -> None:
"""§7.4 no-subtask:同 user 下校验 working_dir 不能与已有 working_dir 形成前缀嵌套。 """§7.4 no-subtask:同 user 下校验 working_dir 不能与已有 working_dir 形成前缀嵌套。

View File

@ -25,6 +25,7 @@ def _iso(dt: Optional[datetime]) -> str:
@dataclass @dataclass
class TaskState: class TaskState:
task_id: str # UUID 字符串形式(对外展示用,DB 仍是 UUID) task_id: str # UUID 字符串形式(对外展示用,DB 仍是 UUID)
user_id: UUID # 归属 user,UPSERT INSERT 路径必填(列 NOT NULL)
name: str = "" # 任务显示名(列 NOT NULL,新建必填;resume 时从 DB 读) name: str = "" # 任务显示名(列 NOT NULL,新建必填;resume 时从 DB 读)
working_dir: str = "" # 工作目录(db 形态:ROOT 内相对 / ROOT 外绝对;空=未绑) working_dir: str = "" # 工作目录(db 形态:ROOT 内相对 / ROOT 外绝对;空=未绑)
skill: str = "" # 智能体类型(coding / ppt / proposal / 自由形式,后续可对齐 skills/ 注册表) skill: str = "" # 智能体类型(coding / ppt / proposal / 自由形式,后续可对齐 skills/ 注册表)
@ -47,6 +48,7 @@ class TaskState:
"""UPSERT 到 PG。created_at / updated_at 不参与写入(PG 自动管)。""" """UPSERT 到 PG。created_at / updated_at 不参与写入(PG 自动管)。"""
upsert_task( upsert_task(
UUID(self.task_id), UUID(self.task_id),
user_id=self.user_id,
name=self.name, name=self.name,
working_dir=self.working_dir, working_dir=self.working_dir,
skill=self.skill, skill=self.skill,
@ -63,6 +65,7 @@ class TaskState:
def from_row(cls, row: TaskRow) -> "TaskState": def from_row(cls, row: TaskRow) -> "TaskState":
return cls( return cls(
task_id=str(row.task_id), task_id=str(row.task_id),
user_id=row.user_id,
name=row.name, name=row.name,
working_dir=row.working_dir, working_dir=row.working_dir,
skill=row.skill, skill=row.skill,

View File

@ -9,9 +9,8 @@ DESIGN.md section 7.4 schema. First migration.
older versions need the extension). older versions need the extension).
- messages.payload GIN index (jsonb_path_ops). - messages.payload GIN index (jsonb_path_ops).
- tasks (user_id, task_dir) and (user_id, status) composite indexes. - tasks (user_id, task_dir) and (user_id, status) composite indexes.
- Local sentinel user is INSERTed by core.storage.ensure_local_sentinel - users rows are INSERTed on demand by web auth entry points
at CLI startup, NOT in this migration (avoids stray sentinel rows on (`web.auth.ensure_user_row`), NOT in this migration.
the SaaS instance).
""" """
from typing import Sequence, Union from typing import Sequence, Union

View File

@ -0,0 +1,43 @@
"""add invites table — dev SPA 邀请码登录改 DB 后端。
Revision ID: 0005
Revises: 0004
Create Date: 2026-05-19
05-19 邀请码登录(`/v1/auth/login_invite`): `ZCBOT_INVITES` env 字符串解析撤,
`invites` 最薄 schema:
- token PK login 入口直接查这列
- name UNIQUE 推导 uuid5 , name = user_id,UNIQUE "两 token 同身份"
- created_at 审计
不存 user_id( uuid5(NS, name) 推导,namespace 固定不动);不存 revoked_at
(撤销直接 DELETE,5 人级别用户不要软删的额外分支)
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0005"
down_revision: Union[str, None] = "0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"invites",
sa.Column("token", sa.Text(), primary_key=True),
sa.Column("name", sa.Text(), nullable=False, unique=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("invites")

View File

@ -38,7 +38,7 @@ from core.storage import (
from core.storage.models import Message, Task from core.storage.models import Message, Task
from core.storage.utils import ensure_local_task_row from core.storage.utils import ensure_local_task_row
from .auth import AuthConfig, ensure_user_row, make_require_user, mint_token from .auth import AuthConfig, ensure_user_row, make_require_user, mint_token, resolve_invite
from .broker import broker from .broker import broker
from .sinks import WebEventSink from .sinks import WebEventSink
@ -267,6 +267,10 @@ class LoginRequest(BaseModel):
platform_key: str platform_key: str
class InviteLoginRequest(BaseModel):
token: str
# ────────────────────── App 工厂 ────────────────────── # ────────────────────── App 工厂 ──────────────────────
# web/static 目录路径 — /static 静态挂载用,dev.html 也放这 # web/static 目录路径 — /static 静态挂载用,dev.html 也放这
@ -342,6 +346,7 @@ def create_app() -> FastAPI:
platform_key 403;user_id UUID 400 platform_key 403;user_id UUID 400
user_id 未存在则幂等创建 users (避免下游 FK 失败) user_id 未存在则幂等创建 users (避免下游 FK 失败)
platform 服务端用此入口注入指定 user_id;dev SPA /login_invite
""" """
if body.platform_key != auth_cfg.platform_key: if body.platform_key != auth_cfg.platform_key:
raise HTTPException(403, "invalid platform_key") raise HTTPException(403, "invalid platform_key")
@ -358,6 +363,29 @@ def create_app() -> FastAPI:
"ttl_seconds": auth_cfg.ttl_seconds, "ttl_seconds": auth_cfg.ttl_seconds,
} }
@app.post("/v1/auth/login_invite", tags=["auth"])
def login_invite(body: InviteLoginRequest):
"""邀请码登录(dev SPA 给同事试用)。
- token `invites` (0005)未命中 403(包括表为空 / DELETE 的情况)
- 命中 name 推导 user_id (uuid5),幂等建 users , JWT
- 发码:DB `INSERT INTO invites(token, name) VALUES('xxx','alice')`;
撤销:`DELETE FROM invites WHERE name='alice'`
"""
hit = resolve_invite(body.token)
if hit is None:
raise HTTPException(403, "invalid invite token")
name, uid = hit
ensure_user_row(uid)
token, exp = mint_token(auth_cfg, uid)
return {
"token": token,
"expires_at": _dt.fromtimestamp(exp).isoformat(),
"user_id": str(uid),
"name": name,
"ttl_seconds": auth_cfg.ttl_seconds,
}
# ───────────── Tasks CRUD ───────────── # ───────────── Tasks CRUD ─────────────
@app.post("/v1/tasks", status_code=201, tags=["tasks"]) @app.post("/v1/tasks", status_code=201, tags=["tasks"])

View File

@ -1,31 +1,39 @@
"""Auth: PLATFORM_KEY → JWT token 兑换(§7 D' 过渡形态)。 """Auth: 两条 login 路径,签同款 JWT(§7 D' 过渡形态)。
模型: 模型:
- `PLATFORM_KEY` env(必填) platform/本仓库间的共享密钥;platform 服务端 / dev 页持有它 - `PLATFORM_KEY` env(必填):platform 服务端 / zcbot 间机器对机器共享密钥
- `JWT_SECRET` env(必填)用于 HS256 token;泄漏 = 任意伪造, PLATFORM_KEY 同级保护 - `JWT_SECRET` env(必填):HS256 token;泄漏 = 任意伪造, PLATFORM_KEY 同级保护
- `POST /v1/auth/login {user_id, platform_key}` `{token, expires_at}`(后端校验 key JWT) - **`invites` **(0005)dev SPA 邀请码登录后端:token PK / name UNIQUE / created_at;
- 后续 `/v1/*`( /healthz/docs/openapi.json//v1/auth/login) `Authorization: Bearer <jwt>` user_id `uuid5(_INVITE_NAMESPACE, name)` 推导,重启稳定; name 会换身份(数据看不到)
表空 `/v1/auth/login_invite` 403(发码:`INSERT INTO invites(token, name) VALUES(...)`)
- `POST /v1/auth/login {user_id, platform_key}` JWT(platform 服务端用,自带 user_id 注入)
- `POST /v1/auth/login_invite {token}` JWT(dev SPA ,name user_id 服务端推导)
- 后续 `/v1/*`( /healthz/docs/openapi.json//v1/auth/login*) `Authorization: Bearer <jwt>`
- Token TTL: `ZCBOT_JWT_TTL_SECONDS` env 覆盖, 7 - Token TTL: `ZCBOT_JWT_TTL_SECONDS` env 覆盖, 7
OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key),路由层 Depends 不变。 OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key);invite 路径同期可下线
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import time import time
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID, uuid5
import jwt import jwt
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from core.storage import session_scope from core.storage import session_scope
from core.storage.models import SENTINEL_USER_ID, User from core.storage.models import Invite, User
_DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d _DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d
# uuid5 命名空间 — 别改,改了所有邀请码用户身份漂移、历史数据全丢
_INVITE_NAMESPACE = UUID("9b5e7a2a-3c8e-5f4d-8c1a-f0e6b9d7c3a1")
class AuthConfig: class AuthConfig:
"""App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。""" """App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。"""
@ -62,6 +70,24 @@ class AuthConfig:
return cls(platform_key=key, jwt_secret=secret, ttl_seconds=ttl) return cls(platform_key=key, jwt_secret=secret, ttl_seconds=ttl)
def resolve_invite(token: str) -> Optional[tuple[str, UUID]]:
"""查 invites 表;命中返 `(name, user_id)`,user_id 由 uuid5(NS, name) 推导。
None 表示 token 未命中(空串 / 不存在 / DELETE 都走这条)每次 login 一次
SELECT,5 人级别用户开销可忽略;不缓存避免 DELETE 后还能登的不一致窗口
"""
t = (token or "").strip()
if not t:
return None
with session_scope() as s:
row = s.execute(
select(Invite.name).where(Invite.token == t)
).scalar_one_or_none()
if row is None:
return None
return row, uuid5(_INVITE_NAMESPACE, row)
def mint_token(cfg: AuthConfig, user_id: UUID) -> tuple[str, int]: def mint_token(cfg: AuthConfig, user_id: UUID) -> tuple[str, int]:
"""签 JWT。返回 `(token, exp_unix_seconds)`。""" """签 JWT。返回 `(token, exp_unix_seconds)`。"""
now = int(time.time()) now = int(time.time())
@ -89,9 +115,9 @@ def verify_token(cfg: AuthConfig, token: str) -> UUID:
def ensure_user_row(user_id: UUID) -> None: def ensure_user_row(user_id: UUID) -> None:
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。 """幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
dev SENTINEL,platform 注入的 user_id 也走这条 无论是新用户首次登录还是 邀请码登录(uuid5 推导)platform_key 登录(显式传入)未来 OIDC 都走这条
既有用户复登,都安全真用户 profile(email/oidc_subject ) D' OIDC 阶段 新用户首次登录建行,既有用户复登 no-op真用户 profile(email/oidc_subject )
再走专门的 register/sync 路径 D' OIDC 阶段再走专门的 register/sync 路径。
""" """
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing( stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing(
@ -130,9 +156,9 @@ def make_require_user(cfg: AuthConfig):
__all__ = [ __all__ = [
"AuthConfig", "AuthConfig",
"SENTINEL_USER_ID",
"ensure_user_row", "ensure_user_row",
"make_require_user", "make_require_user",
"mint_token", "mint_token",
"resolve_invite",
"verify_token", "verify_token",
] ]

View File

@ -332,16 +332,14 @@
<div id="login"> <div id="login">
<div class="card"> <div class="card">
<h2>zcbot 登录</h2> <h2>zcbot 登录</h2>
<label for="li-uid">user_id (UUID)</label> <label for="li-token">邀请码</label>
<input id="li-uid" autocomplete="off" /> <input id="li-token" type="password" autocomplete="off" placeholder="请输入管理员分配的邀请码" />
<label for="li-key">platform_key</label>
<input id="li-key" type="password" autocomplete="off" />
<div class="err" id="li-err"></div> <div class="err" id="li-err"></div>
<div class="actions"> <div class="actions">
<button class="primary" id="li-go">登录</button> <button class="primary" id="li-go">登录</button>
</div> </div>
<div class="small muted" style="margin-top: 12px;"> <div class="small muted" style="margin-top: 12px;">
本地默认 user_id 是 sentinel(全 0)。platform_key 见服务端 env <code>PLATFORM_KEY</code> 邀请码由管理员在服务端 <code>invites</code> 表里分配,每人一码,丢失找管理员重发
</div> </div>
</div> </div>
</div> </div>
@ -475,13 +473,14 @@
</div> </div>
<script> <script>
const SENTINEL = "00000000-0000-0000-0000-000000000000";
const LS_TOKEN = "zcbot.token"; const LS_TOKEN = "zcbot.token";
const LS_UID = "zcbot.user_id"; const LS_UID = "zcbot.user_id";
const LS_NAME = "zcbot.name";
const state = { const state = {
token: localStorage.getItem(LS_TOKEN) || "", token: localStorage.getItem(LS_TOKEN) || "",
userId: localStorage.getItem(LS_UID) || "", userId: localStorage.getItem(LS_UID) || "",
userName: localStorage.getItem(LS_NAME) || "",
taskId: null, taskId: null,
taskMeta: null, taskMeta: null,
filesPath: "", filesPath: "",
@ -611,24 +610,20 @@ function highlightIn(container) {
} }
// ───── login ───── // ───── login ─────
$("li-uid").value = state.userId || SENTINEL;
$("li-go").onclick = doLogin; $("li-go").onclick = doLogin;
$("li-key").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); }); $("li-token").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
$("li-uid").addEventListener("keydown", (e) => { if (e.key === "Enter") $("li-key").focus(); });
async function doLogin() { async function doLogin() {
const uid = $("li-uid").value.trim(); const token = $("li-token").value.trim();
const key = $("li-key").value;
$("li-err").textContent = ""; $("li-err").textContent = "";
if (!uid || !key) { if (!token) {
$("li-err").textContent = "user_id 与 platform_key 都要填"; $("li-err").textContent = "请填邀请码";
return; return;
} }
try { try {
const r = await fetch("/v1/auth/login", { const r = await fetch("/v1/auth/login_invite", {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: uid, platform_key: key }), body: JSON.stringify({ token }),
}); });
if (!r.ok) { if (!r.ok) {
const d = await r.json().catch(() => ({})); const d = await r.json().catch(() => ({}));
@ -637,8 +632,10 @@ async function doLogin() {
const data = await r.json(); const data = await r.json();
state.token = data.token; state.token = data.token;
state.userId = data.user_id; state.userId = data.user_id;
state.userName = data.name || "";
localStorage.setItem(LS_TOKEN, state.token); localStorage.setItem(LS_TOKEN, state.token);
localStorage.setItem(LS_UID, state.userId); localStorage.setItem(LS_UID, state.userId);
if (state.userName) localStorage.setItem(LS_NAME, state.userName);
enterApp(); enterApp();
} catch (e) { } catch (e) {
$("li-err").textContent = e.message; $("li-err").textContent = e.message;
@ -646,9 +643,10 @@ async function doLogin() {
} }
function logout() { function logout() {
state.token = ""; state.userId = ""; state.token = ""; state.userId = ""; state.userName = "";
localStorage.removeItem(LS_TOKEN); localStorage.removeItem(LS_TOKEN);
localStorage.removeItem(LS_UID); localStorage.removeItem(LS_UID);
localStorage.removeItem(LS_NAME);
if (state.evtSrc) state.evtSrc.close(); if (state.evtSrc) state.evtSrc.close();
location.reload(); location.reload();
} }
@ -658,7 +656,10 @@ $("hd-logout").onclick = logout;
function enterApp() { function enterApp() {
$("login").style.display = "none"; $("login").style.display = "none";
$("app").classList.add("ready"); $("app").classList.add("ready");
$("hd-who").textContent = state.userId; // 显示「name · uuid 前 8 位」;name 缺失(老 token 升级前)只显 uuid
const uid8 = (state.userId || "").slice(0, 8);
$("hd-who").textContent = state.userName ? `${state.userName} · ${uid8}` : state.userId;
$("hd-who").title = state.userId;
loadTaskList(); loadTaskList();
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
} }
@ -1649,8 +1650,7 @@ if (state.token) {
// 已有 token:试探一下,失败回登录页 // 已有 token:试探一下,失败回登录页
enterApp(); enterApp();
} else { } else {
$("li-uid").value = SENTINEL; $("li-token").focus();
$("li-uid").focus();
} }
</script> </script>
</body> </body>