From 02a69058df0f4d25187526ae647a80f660e26503 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 15 May 2026 16:14:25 +0800 Subject: [PATCH] =?UTF-8?q?core(=C2=A77=20D=20+=20D'):=20/v1=20JSON=20API?= =?UTF-8?q?=20+=20PLATFORM=5FKEY=E2=86=92JWT=20auth=20+=20dev=20SPA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整合今日累积的 §7 D 阶段主体工作: - §7 D /v1 JSON API:删 web/templates/* + web/static/style.css, web/app.py 重写为纯 /v1 JSON 路由(tasks CRUD + messages + SSE 事件 JSON 化 + files 4 路由 + export),CORS allow_origins 起步 *,GET / 改 302 → dev SPA(详 DESIGN §7.9)。 - §7 D' 过渡 auth:web/auth.py 新增 — PLATFORM_KEY env(共享密钥) + JWT_SECRET env(HS256 签),POST /v1/auth/login 校验 key → 签 JWT(默 7d TTL),所有 /v1/tasks* 走 Depends(require_user) 验签 并按 user_id 隔离数据;豁免 /healthz、/docs、/openapi.json、 /static/*、/v1/auth/login。env 双必填,缺则 fail-fast。 - dev SPA:web/static/dev.html ~600 行 vanilla JS 单文件,login overlay(user_id 默 sentinel + platform_key)+ 3 栏布局(task list + chat 流 + files 浏览)+ new-task modal + done/abandon/ export。SSE 走 fetch+ReadableStream(EventSource 不支持 Bearer)。 - task_dir 改相对存储:新增 core/paths.py(to_db_path/from_db_path) + alembic 0002 migration 把 ROOT-内绝对路径转 posix 相对,跨 OS 和混合分隔符历史数据天然兼容。check_no_subtask 改 Python 端归一 比对,逻辑更清晰。 - litellm 启动 cost map 网络警告兜底:core/llm.py 在 import 前 setdefault LITELLM_LOCAL_MODEL_COST_MAP=True,墙内冷启动 ~5s → <1s。 - docs:DESIGN §7.3 改写(过渡 auth + 真 OIDC 路线)+ §7.7 状态表 + §7.9 dev SPA 取舍;PROGRESS 加多条今日条目 + 文件清单 + 下一 步;RUN env 双 auth env + curl 示例 + 路由表 Auth 列 + 5 条故 障兜底新条目。CLAUDE.md 加"开发期不写兼容层"心智。 Smoke 全绿:env fail-fast / 8 路径无 token 全 401 / login 3 分 支 / 带 token CRUD / 跨 user 4 case 隔离 / token 异常 4 case / 真实 HTTP uvicorn 端到端 login + bearer call + dev.html 服务。 requirements: 加 pyjwt>=2.8.0;删 jinja2 / markdown-it-py / mdit-py-plugins / pygments(模板路线撤一并清);保留 python- multipart(files upload 还用)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 10 + DESIGN.md | 101 +- PROGRESS.md | 42 +- RUN.md | 74 +- core/export_docx.py | 14 +- core/llm.py | 6 +- core/paths.py | 50 + core/storage/utils.py | 47 +- .../20260515_1011_0002_task_dir_relative.py | 61 ++ main.py | 22 +- requirements.txt | 10 +- web/app.py | 912 ++++++++++-------- web/auth.py | 138 +++ web/static/dev.html | 788 +++++++++++++++ web/static/style.css | 193 ---- web/templates/_frag_error.html | 4 - web/templates/_frag_text.html | 1 - web/templates/_frag_tool_call.html | 13 - web/templates/_frag_tool_result.html | 4 - web/templates/_send_response.html | 22 - web/templates/base.html | 23 - web/templates/chat.html | 69 -- web/templates/home.html | 64 -- web/templates/new_task.html | 49 - 24 files changed, 1790 insertions(+), 927 deletions(-) create mode 100644 core/paths.py create mode 100644 db/migrations/versions/20260515_1011_0002_task_dir_relative.py create mode 100644 web/auth.py create mode 100644 web/static/dev.html delete mode 100644 web/static/style.css delete mode 100644 web/templates/_frag_error.html delete mode 100644 web/templates/_frag_text.html delete mode 100644 web/templates/_frag_tool_call.html delete mode 100644 web/templates/_frag_tool_result.html delete mode 100644 web/templates/_send_response.html delete mode 100644 web/templates/base.html delete mode 100644 web/templates/chat.html delete mode 100644 web/templates/home.html delete mode 100644 web/templates/new_task.html diff --git a/CLAUDE.md b/CLAUDE.md index 8aaa8f2..241d083 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,16 @@ - 跑脚本 / 测试一律用 `.venv/Scripts/python.exe ...`,**不要用全局 `python`**(没装 litellm/python-pptx 等会报 ModuleNotFoundError) - requirements 见 `requirements.txt` +## 开发阶段心智 + +当前处于开发阶段(尚未发布给真实用户)。改需求 / 重构时,**以最优实现为准,不为旧数据 / 旧字段 / 旧 API 留兼容层**: +- DB schema 变 → 直接改 model + 写一条干净的 migration(必要时清空旧 row,不写双向兼容代码) +- 字段语义变 → 全量替换,不留 `legacy_xxx` / `*_v2` 并存 +- CLI / REPL 选项变 → 直接改,不留 deprecated 别名 +- 只有当用户明确说"这条要保留兼容"时才写兼容代码 + +理由:兼容层就是技术债,开发期写了之后忘记删反而拖累;真上线后再视情况补迁移路径。 + ## 文档维护 每完成一步实现(commit 前),**必须更新 `PROGRESS.md`**: diff --git a/DESIGN.md b/DESIGN.md index 04effcd..26ad8ab 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -191,7 +191,7 @@ SaaS 化不是"重写"也不是"取代 CLI",而是**给同一份 core 加一个 | task_dir 默认值 | `workspace/tasks//`(留空时派生) | `/users//tasks//`(留空时派生);用户指定时走 `/users///` | | Memory | `workspace/memory/`(FS) | `/users//memory/`(仍是 FS) | | Sandbox | subprocess + env 过滤 | per-task docker exec | -| Auth | 无(`user_id='local'`) | OIDC + JWT | +| Auth | 无(`user_id='local'`) | PLATFORM_KEY → JWT(过渡)→ OIDC | **CLI 长期双模式**:本地直跑(默认,in-process,直连 PG,适合调内部状态)/ `--remote https://...`(HTTP 走 `/v1`,等价真实用户路径)。两模式共用 `cli.py`,差别只在 transport 层。 @@ -223,29 +223,60 @@ state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享 ### 7.2 资源模型(/v1) -Task 是主视图,排在前面;folder 是文件副视图。 +Task 一等公民,files 是其副视图(经 `task_dir` 暴露,无独立 folder 实体)。所有路由统一 `/v1` 前缀,**返 JSON**;前端 / UI 由 platform 端实现,本仓库不维护(§7.9 取舍)。本地开发用 FastAPI 自带 `/docs` Swagger UI 自查;`GET /` 302 跳 `/docs`。 ``` -# Tasks(主) -CRUD /v1/tasks[/{id}] {task_dir?, mode, desc, model};task_dir 留空默认派生 -POST/GET /v1/tasks/{id}/messages 发消息 / 历史(?search= 走 jsonb GIN / tsvector) -GET/POST /v1/tasks/{id}/runs/{run_id}/{events,cancel} SSE +Tasks + POST /v1/tasks 创建 {description?, mode?, task_dir?}; + task_dir 留空 → 默认派生 workspace/tasks// + GET /v1/tasks?status=&limit= 列表(updated_at 降序,?status=active|completed|abandoned) + GET /v1/tasks/{id} 单 task meta + 完整 messages + PATCH /v1/tasks/{id} {status?,description?,mode?};status 从 web 不让切回 active(走 CLI) + GET /v1/tasks/{id}/messages 历史(后续 ?search= 走 jsonb GIN / tsvector) + POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {run_id} + GET /v1/tasks/{id}/runs/{rid}/events SSE 流(见下) + POST /v1/tasks/{id}/runs/{rid}/cancel (待) -# Folders(文件副视图) -POST/GET/PATCH/DELETE /v1/folders[/{path}] 列树 / 创建 / 改名(cascade)/ hard cascade -GET/POST/DELETE /v1/folders/{path}/files[/{name}] 列 / 上传 / 下载 / 删 +Files(per-task,task_dir 副视图) + GET /v1/tasks/{id}/files?path= 列子目录 {entries, crumbs, exists, root} + POST /v1/tasks/{id}/files/upload multipart;path 通过 query 或 form;严格拒含 / \\ .. 的 filename + GET /v1/tasks/{id}/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400 + POST /v1/tasks/{id}/files/delete {path} 文件或空目录;非空目录 400 -# 元数据 -GET /v1/{skills,models,usage} -POST /v1/probe (admin) +Export + GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp + +Misc + GET /healthz {"status":"ok"} + GET / 302 → /docs (Swagger UI 自查,本地形态便利) ``` -**SSE 事件**:`tool_call` / `tool_result` / `text` (delta) / `usage` / `done`,带 `run_id`。 -**版本化**:`/v1` minor 半年向后兼容,major 6 个月 deprecation。 +**SSE 事件**(`Content-Type: text/event-stream`,响应头带 `X-Accel-Buffering: no` 给 nginx 反代友好;每事件 `event: ` + `data: `): + +``` +run_start {} +llm_start {} +text {"content":""} +tool_call {"name":"...","args":{...},"args_preview":"..."} +tool_result {"name":"...","preview":"...","truncated":bool} # 完整 result 走 DB,SSE 只送预览给 UI +llm_end {"prompt_tokens":N,"completion_tokens":N} +error {"msg":": "} +done {} +``` + +订阅 fan-out:同 run 多订阅者(刷新 / 多 tab / 多设备)每订阅 1 独立 queue。订阅迟到(run 已 done)立刻收 done 不挂。事件不持久化 —— messages 走 PG,未来要"刷新继续看流式"再加 event log。 + +**版本化**:`/v1` minor 半年向后兼容,major 6 个月 deprecation。`/v1internal` 实验位(未启)。 + +**CORS**:本地 dev `allow_origins=["*"]`;部署 platform 时收紧到 platform 域名 allowlist。 + +**Auth**:PLATFORM_KEY → JWT 兑换(过渡形态,见 §7.3);`Authorization: Bearer ` 走所有 `/v1/tasks*`;`/healthz`、`/docs`、`/openapi.json`、`/`、`/v1/auth/login`、`/static/*` 豁免。 ### 7.3 认证 -OIDC / Clerk / 自建邮箱登录,JWT 只带 `user_id` claim:`Authorization: Bearer ` + `X-Request-Id`。所有 storage/executor scoped by `user_id`。**无 tenant 层** —— 个人 SaaS 用不上,做企业版再加 `org_id` 等价隔离。 +**当前形态(D' 过渡,2026-05-15 落地)**:platform 服务端(或 dev 浏览器)持有 `PLATFORM_KEY` 共享密钥,调 `POST /v1/auth/login {user_id, platform_key}` → 后端校验 key 匹配 → 签 HS256 JWT(`sub=user_id`,默 7d TTL,`JWT_SECRET` env 签)→ 返 `{token, expires_at, user_id, ttl_seconds}`。后续 `Authorization: Bearer ` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。**信任模型**:platform 是单点可信中间层(持 KEY = 可为任意 user_id 签 token,等同 user 身份由 platform 注入);风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。 + +**未来形态(真 OIDC,D 阶段后期)**:OIDC / Clerk / 自建邮箱登录,Provider 签 ID token,zcbot `/v1/auth/login` 内部从"校验 PLATFORM_KEY"换成"校验 ID token 签名 + 提取 sub" —— **路由层 Depends 不动**,Bearer JWT 契约不变。所有 storage/executor scoped by `user_id`。**无 tenant 层** —— 个人 SaaS 用不上,做企业版再加 `org_id` 等价隔离。 ### 7.4 存储:Postgres + 本地文件系统 @@ -257,6 +288,9 @@ tasks(task_id uuid pk, user_id fk, task_dir text not null, mode, description, status, model_profile, tokens_prompt, tokens_completion, cost_usd, created_at, updated_at); create index on tasks (user_id, task_dir); +-- task_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串(`workspace/tasks/`); +-- ROOT 外 → 绝对 str(用户自指定项目目录);空串 → 未绑项目。SaaS 阶段同理(基础是 +-- /users//)。读写边界统一过 core/paths.py::{to_db_path,from_db_path}。 messages(message_id uuid pk, task_id fk, idx int not null, payload jsonb not null, tokens_in, tokens_out, created_at, @@ -269,7 +303,7 @@ usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts) -- append-only。task_id/run_id 不 FK,task 硬删后审计仍存活 ``` -**No-subtask 校验**(`create_task`):查同 user 下是否存在 `new LIKE existing/%` 或 `existing LIKE new/%`,中一则拒;同 task_dir 允许。 +**No-subtask 校验**(`create_task`):查同 user 下是否存在 `new LIKE existing/%` 或 `existing LIKE new/%`,中一则拒;同 task_dir 允许。**两侧先用 `from_db_path` 归一到 absolute posix 再比前缀**(混合存储形态 [相对+绝对] 不会漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。 **Folder rename**(`old → new`,FS rename 成功后):`UPDATE tasks SET task_dir = new || substring(task_dir from len(old)+1) WHERE user_id=? AND (task_dir = old OR task_dir LIKE old||'/%')`。**用 `old/%` 而非 `old%`**,避免 `project_a` 误中 `project_a_other`。running task 引用时禁 rename / delete。 @@ -310,24 +344,28 @@ usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts) | 6 | **Executor + sandbox**:`run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入 | 2-3 天 | | 7 | **HTTP /v1**:FastAPI + SSE + OIDC | 4 天 | | 8 | **CLI 双模式**:transport 层抽象,默认 in-process;`--remote` 走 HTTP;**本地直跑不删** | 1.5 天 | -| 9 | **Web UI(简洁版)**:FastAPI + Jinja2 + HTMX + 原生 SSE,task list / chat / folder tree / 文件上传下载;无 React/Vue 构建链 | 2-3 天 | +| 9 | ~~Web UI 简洁版(Jinja2+HTMX)~~ → 改为 **API surface 完工**:Phase G 落地的模板 / HTMX / 服务端 markdown 渲染删除,所有路由切纯 JSON;UI 由 platform 端实现(§7.9 取舍) | 已落 | -代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;Web UI 加 600-1000 行 HTML/CSS/JS 不计入 Python 主仓库)。 +代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;UI 不计入,本仓库只维护 API)。 ### 7.7 分阶段落地 | 阶段 | 范围 | 工作量 | 验收 | |---|---|---|---| -| A | #1 事件流化 | done | ✅ | -| B | #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + Folder API + no-subtask) | ~1 周 | 本地走 PG,messages 进 DB 全文搜可用;多 task + folder rename 单测;`migrate-from-fs` 跑通 | -| C | #6(Executor + sandbox) | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 | -| D | #7(HTTP /v1 + auth) | 4 天 | curl / Postman 跑通主流程 | -| E | #8(CLI transport 双模式) | 1.5 天 | 默认本地直跑保留,`--remote` 走 HTTP 跑通 | -| G | #9(Web UI 简洁版) | 2-3 天 | 浏览器跑通:列 task → 进 chat → 流式回复 → 文件上传下载;与 D / E 无强序,但需 D 的 SSE 端点 | -| F | 上线打磨(限流 / 监控 / 告警 / HA) | 持续 | SLO 99.5% | +| 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 注释)| +| D | #7 HTTP /v1 surface(无 auth)| done ✅ | `/v1/tasks/*` + SSE JSON + files 4 路由 + export + Swagger;本地形态 sentinel user 跑通 | +| 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 内部 | +| C | #6 Executor + sandbox | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 | +| E | #8 CLI transport 双模式 | 1.5 天 | 默认本地直跑保留,`--remote` 走 HTTP 跑通 | +| ~~G~~ | ~~Web UI 简洁版~~ —— **删除**,前端由 platform 端实现 | — | 本仓库不维护 UI | +| F | 上线打磨(限流 / 监控 / 告警 / HA)| 持续 | SLO 99.5% | **B 阶段一次性切换** —— 切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨。**dogfood 即生效**(messages 进 DB → 全文搜、jsonb 查询立刻可用)。 +**D 落在 G 前面** —— 原排期 D 在 G 后(以为 dogfood 用 UI 跑),实际转向"platform 端联调"后,API surface 反而成阻塞;G 的 Jinja2+HTMX 投入(G1-G6 ~3 天)沉淀 = 删除前的 dogfood 价值,留下的 sink 协议 / broker / no-subtask / files 路径安全归一 / task_dir 相对存储仍被 D 复用。 + ### 7.8 已知风险 | 风险 | 缓解 | @@ -355,6 +393,19 @@ usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts) **本地也用 PG,不用 SQLite / JSON**:① dogfood ≡ 真实用户路径,bug 在 dogfood 就能复现;② Docker 已是必然依赖(§7.5),`docker compose up postgres` 零增量门槛;③ 双 adapter 维护税远高于 PG 一次性配置成本;④ 本地 dev 也能连远端测试服。 +**API-only,UI 由 platform 实现**(2026-05-15 决策): +- **原计划**:Phase G 用 Jinja2 + HTMX 在本仓库做"简洁 Web UI",dogfood 用,真上线再做正经前端。已落地 G1-G6:task list / chat 流式 / files 浏览 / new / done/abandon/export/toast,共 ~600 行 HTML+CSS+SSE-HTML-片段。 +- **触发**:用户决定与已有 platform 联调,前端用 platform 的框架,本仓库再维护 HTML / CSS / HTMX 就是双套 UI 浪费。 +- **取舍**: + - 删 `web/templates/*` `web/static/*` + jinja2/markdown-it-py/pygments/mdit-py-plugins 依赖 + - SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠 + - 路由统一 `/v1` 前缀,响应全 JSON,FastAPI 自带 `/docs` Swagger UI 接替"对内调试"角色(本地形态 `GET /` 302→ `/docs`) + - 本地 sentinel user 形态保留;auth 走 D' 过渡形态(PLATFORM_KEY → JWT,见 §7.3),真 OIDC 留到联调约定 token 形态后接 + - CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧 +- **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连 + +**dev SPA 留一份**(2026-05-15 决策):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),~600 行无构建链。**与"UI 由 platform 实现"不冲突**:platform UI 是给真用户的、生产形态;dev.html 是给本仓库开发者自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着。形态:登录页填 user_id(默 sentinel)+ platform_key → localStorage 存 JWT → fetch+Bearer。 + **CLI 不被 API 取代,而是双模式共存**:本地直跑调 core 内部状态比 HTTP roundtrip 顺手;前端用户路径靠 `--remote` 打通。离线靠本地 docker compose PG 兜底,不靠"全栈零依赖"幻觉。 **Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。 diff --git a/PROGRESS.md b/PROGRESS.md index 16ada29..51424b7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 -最后更新:2026-05-15(Phase G G4) +最后更新:2026-05-15(D 阶段:/v1 JSON API 落地 + PLATFORM_KEY → JWT auth + dev SPA;Phase G Jinja2 路线撤掉) --- @@ -15,7 +15,7 @@ | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | -| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**Phase G Web UI 进行中(G1 脚手架 ✅;G2 task list ✅;G3 chat 只读 ✅;G4 SSE 流式 ✅;G5 文件浏览 待;G6 打磨)**。下一阶 C(Executor) / D(HTTP /v1) 待。 | +| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**D `/v1` JSON API 完工 ✅**(原 Phase G Jinja2/HTMX UI 撤,改 platform 端实现);**D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA ✅**;真 OIDC 待;C(Executor)待;E(CLI 双模式)待。 | --- @@ -38,6 +38,12 @@ - **05-14 / §7 Phase G G2 task list 页**:`web/app.py::list_tasks(limit, status)` 读 PG `tasks` + `messages` count(updated_at 降序),返回模板友好的 dict 列表;**不复用 `cli.py::_list_task_rows`** —— CLI 拿 tuple, Web 拿 dict,数据形状有别,等真有 schema 变更同步成本时再抽(避免预付抽象)。`/` 路由换成 task 表渲染,filter via `?status=active|completed|abandoned`(无效值静默降级为 all);`/tasks/{task_id}` 占位路由 UUID 校验 + DB 存在性校验,缺一则 404,有效则渲染 `task_placeholder.html`(G3 来填消息流)。**Linux portability 落地**:`_norm_path()` 把存的 backslash 在显示时全替成 forward slash(`Path.as_posix()` 在 Linux 读 Win backslash 串时不归一,所以直接 `replace('\\','/')`);Win Path.resolve() 存 `D:\projects\...`、Linux 存 `/home/user/...`,都能正确显示。template:`home.html` 表格(id/updated/status/mode/model/msgs/tokens/desc-dir),status 用 badge(`status-active/completed/abandoned` 配色),hover 高亮;空态文案。CSS:table 紧凑(.9rem)+ `tabular-nums` 对齐 + accent-soft placeholder note。Smoke 18 路径全绿(in-process):3 task seed(active/completed/abandoned)+ Win\Linux 双路径形态 → / 渲染对、status filter 正/反向、garbage status 静默 all、UUID 占位、notauuid 404、ghost UUID 404、limit 生效、/healthz 不退化。版本 0.1 → 0.2。 - **05-15 / §7 Phase G G3 chat 只读页**:`web/app.py` 加 `_get_md()` 单例 MarkdownIt(`gfm-like` 预设 + linkify + breaks,`html=False` 禁内联 HTML 防 XSS),fenced code 走 pygments `_pygments_highlight()` 回调(`codehilite` cssclass)。`load_chat_messages(tid)` 读 PG idx asc;`build_chat_blocks(messages)` 聚合显示块 —— system / tool 不入 block(tool 内嵌进 assistant 的 tool_call.result),user / assistant text 走 markdown 渲染,assistant.tool_calls 配对 tool result(orphan tool_call → `[no result]`)。`_args_preview` 60 字符截断,`_pretty_json` 解析失败 fallback 原串。`/tasks/{id}` 替换占位为 `chat.html` 渲染,删 `task_placeholder.html`。template:`.msg` 卡片(user 浅蓝 / assistant 白底),`.body` markdown 区(`
` / `` / `` / `
` / `` 全 GFM 样式),tool_call 用 `
` 默认折叠(无 JS,浏览器原生开闭;`summary` 显示 tool 名 + args 前 60 字预览,展开看 args_pretty + result)。CSS 加 `.codehilite` 浅色 token 配色(keyword / string / comment / function / number / operator 6 类,余下黑色)。Smoke 28 路径全绿:4 display blocks(user/assistant×3,system/tool 跳过)+ markdown 特性(table / fence / autolink / strikethrough / bold)+ tool 配对(call_1 命中、orphan 走 `[no result]`)+ HTML 含 `
`/`tool-badge`/`codehilite`/`` + 空 task 文案 + invalid UUID 404 + util 单测(args_preview / pretty_json / render_md 边界)。版本 0.2 → 0.3。requirements 加 `markdown-it-py[linkify]` / `mdit-py-plugins` / `pygments`。 - **05-15 / §7 Phase G G6 部分:/new 入口(提前于 G5 落)**:用户反馈 Web 没"新建对话"入口 — 加 `GET /new` 表单页(description / mode / task_dir 三字段)+ `POST /new` 处理(strip 校验 + `description` 与 `task_dir` 至少填一个否则 400 + `check_no_subtask` 同 CLI / build_agent 一致拦前缀嵌套 → 409 + `ensure_local_task_row` 写占位行 + 303 See Other 跳转 `/tasks/{tid}`)。task_dir 空 → 默认派生 `workspace/tasks//`(同 `_default_task_dir`),显式 → `Path.expanduser().resolve()` 同 cli.py `--task-dir`。模板 `new_task.html` 加表单 + error 渲染(400/409 重渲带 form_state 不丢用户填的值);home.html 加 `+ new task` 主按钮 + nav 加 `new` 链接;base.html 默认 nav 也带 tasks/new。CSS 加 `.btn-primary` / `.new-task-form` / `.navlinks .active` 配色。**懒创建保留语义**:Task 在 /new POST 时入库,后续 build_agent 走 resume 路径(已存在,不冲突);CLI REPL `/new` 仍走 build_agent 懒创建路径,不互相干扰。Smoke 21 路径全绿:GET 表单 200 + 三字段 / POST happy(description-only / custom task_dir)→ 303 + Location 正确 / DB 行字段对 + default-derived task_dir 含 uuid / 空描述空 task_dir → 400 重渲表单带 error / no-subtask 父子嵌套 → 409 + 错误文案 / home 页 `+ new task` 按钮 + nav 链接 / `/new` nav 链接 active 标记。版本 0.4 → 0.5。 +- **05-15 / litellm 启动 cost map 网络警告兜底**:litellm 启动会去 GitHub 拉 `model_prices_and_context_window.json`,墙内 SSL 握手常超时,虽然有本地 backup 不影响功能,但 stdout 一行 WARNING 噪声大。`core/llm.py` 在 `import litellm` 之前 `os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")`(setdefault 不覆盖用户已显式设的值),走 litellm 的 `LITELLM_LOCAL_MODEL_COST_MAP=True` 路径直接用打包的本地 cost map,跳过 httpx.get。CLI / Web 都经 `core.llm` 走这条单点,不需要在多个入口分别设。冷启动从原来 ~5s SSL 超时降到 <1s。 +- **05-15 / task_dir 改相对存储**:DB `tasks.task_dir` 原存绝对(`D:\projects\zcbot\workspace\tasks\`),改为 **ROOT 内→相对 posix(`workspace/tasks/`)、ROOT 外→保留绝对**(用户 `--task-dir` 指外部项目的场景)。新增 `core/paths.py` 提供 `ROOT` / `to_db_path` / `from_db_path` 三个出口,所有读写边界统一过这里。读端:`resolve_task_id` resume 分支 `from_db_path(db_dir)`(相对走 `ROOT/.`,绝对原样 resolve);`export_chat_to_docx` 自动从 PG 读时同样过 `from_db_path`。写端:`build_agent` 构造 `meta` 和 `TaskState` 时 `to_db_path(task_dir)`,`web/app.py::/new` 同步。`check_no_subtask` 抛掉原来 SQL 里 `replace(task_dir, :bs, '/')` 的拼接,改 Python 端 fetch + 双侧 `from_db_path` 归一到 absolute posix 后比前缀,逻辑更清晰且天然支持混合形态(老绝对 + 新相对 DB row 并存也对)。alembic `0002_task_dir_relative` 一次 UPDATE 把现有 ROOT-prefix 行转相对(本机两条 active row 已 migrate 完);downgrade 反向用 `_:%` / `/%` LIKE 区分相对 vs 绝对。Smoke 四段全绿:round-trip(ROOT-内 / 外 / 空 / Windows backslash)/ check_no_subtask 混合形态 7 case(same / child / parent / sibling / outside-child / 绝对串新值 vs 相对串老 row 仍能拦 / 空跳过)/ resolve_task_id resume 还原一致 / build_agent 端到端写 DB 验证默认派生→相对、`--task-dir` 外部→绝对。`CLAUDE.md` 加"开发阶段不写兼容层"心智(用户指示)。 +- **05-15 / §7 D 阶段:`/v1` JSON API 落地;Phase G Jinja2/HTMX 路线撤掉**:用户决定与已有 platform 联调,前端用 platform 框架,本仓库再维护 HTML 就是双套 UI 浪费(DESIGN §7.9 新增取舍说明)。**删除**:`web/templates/*` 9 个模板 + `web/static/*` CSS 全去;`requirements.txt` 拿掉 jinja2 / markdown-it-py / mdit-py-plugins / pygments(`python-multipart` upload 还要用,保留)。**重写 `web/app.py` 全 `/v1/` 前缀,JSON 响应**:`POST /v1/tasks`(创建,Pydantic body)/ `GET /v1/tasks?status=&limit=`(列表)/ `GET /v1/tasks/{id}`(单 meta,不含 messages 走 /messages 拿)/ `PATCH /v1/tasks/{id}`(`{status?,description?,mode?}` 部分更新,active 不让从 web 切回)/ `GET /v1/tasks/{id}/messages`(LiteLLM 原 payload 透传)/ `POST /v1/tasks/{id}/messages`(JSON `{content}`,返 `{run_id, events_url}` + 起 BG run)/ `GET /v1/tasks/{id}/runs/{rid}/events`(SSE)/ files 4 路由全 `/v1/` + JSON 返回 / `GET /v1/tasks/{id}/export`(.docx 下载不变)/ `GET /healthz`(`{"status":"ok"}`)/ `GET /` 302→`/docs`(Swagger UI)。**SSE 事件 payload 由 HTML 片段切 JSON**:每帧 `event: ` + `data: `,前端自渲染;event types `run_start / llm_start / text / tool_call / tool_result / llm_end / error / done`(去掉 `type` 键的剩余字段进 data)。**Pydantic 请求体** 给 FastAPI auto-docs 自动出 schema。**CORS** `allow_origins=["*"]` 起步(部署 platform 时收紧)。**没动**:`core/loop.py` event shape(已是 dict)/ `web/broker.py` fan-out / `web/sinks.py` WebEventSink / 文件路径安全归一 / no-subtask 校验。**Smoke 50+ case 全绿**(in-process TestClient + 真实 HTTP):root 302、healthz JSON、docs/openapi 暴露、tasks CRUD 全分支(create happy + custom dir + 双空 400 + 嵌套 409 + 列表 + 单 get + ghost/非 UUID 404 + PATCH 多分支 + 空 PATCH 400)、messages list/post(payload 透传 + run_id 返 + events_url 拼对 + 空 content 400)、files list/upload/download/delete(攻击名 400、路径越界 400、root 拒、size raw int、mtime ISO)、export PK\x03\x04 magic、CORS preflight `Access-Control-Allow-Origin: *`。真实 HTTP `cli.py web` 起服务 → curl `/healthz` `/v1/tasks` `/openapi.json` 全 200 + 干净 JSON。版本 0.7 → 0.7(API surface 完工)。`_smoke_api.py` ad-hoc 跑完即删。**沉淀的 Phase G 工作**:sink 协议 / RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留 —— 删的只是 UI 层。 +- **05-15 / §7 Phase G G5 文件浏览 + 上传 / 下载 / 删**:`web/app.py` 加四件套路由 — `GET /tasks/{id}/files?path=`(列目录树,面包屑 + 目录在前文件在后 + size humanize + mtime 格式化)/ `GET /tasks/{id}/files/download?path=`(FileResponse + Content-Disposition)/ `POST /tasks/{id}/files/upload`(multipart `list[UploadFile]`,`?path=` 指目标子目录,自动 `mkdir(parents=True)`,303 回浏览页)/ `POST /tasks/{id}/files/delete`(form `path=...`,文件 / 空目录可删,非空目录 → 400,root → 400)。**核心:`_safe_join(root, rel)` 路径安全归一**——空 / "."→ root;`/` `\\` 起头 → 400(absolute-style 拒);Path.is_absolute → 400;`(root / rel).resolve().relative_to(root.resolve())` 校验仍在 root 内(防 `../` / symlink 逃逸)。上传文件名 strict 拒带 path 痕迹(`/` `\\` `..` parts)—— 现代浏览器只给 basename,异常 client 直接 400 不悄悄 sanitize。task_dir 不存在(skill 还没产物)→ 200 + 空文案,不报错。task_dir 空(legacy / 未绑)→ 400。`_load_task_dir(task_id)` 共用入口:404 if 非 UUID / task 不存在,400 if task_dir 空,否则返 `(tid, abs_path)`。**模板**:新增 `files.html`(面包屑 nav + upload-form `multipart/form-data` + `
` 行渲染目录用蓝色 + `/` 后缀,文件用 `download` 链 + size + mtime + 删除按钮);`chat.html` 在 page-head 加 `files` 按钮(task_dir 非空时显示)。**CSS**:`.crumbs` / `.upload-form`(虚线红框 accent-soft 区)/ `.file-list` 表 / `.btn-mini` mini 按钮 + `.btn-mini-danger` 红 hover / `.ico-dir` `.ico-file` 文件类型标识。**Smoke 50+ case 全绿**:task_dir 不存在 200(2) / 列文件 + 子目录(12) / download 文件 + 子目录 + 404 + 目录-是非文件 400(7) / path 安全 6 case(`../` 越界 + POSIX 绝对 + Win 绝对 + `\\` 越界 + `/tmp`) / upload 单文件 + multi-file + nested mkdir + 攻击名 `../escape.txt` / `../../boom` / empty 全拒 + 目标 path 是文件 400 + 文件落 FS 内容一致(13) / delete 文件 + 空目录 + 非空 400 + ghost 404 + root 拒 + 越界拒(9) / chat.html files 链接 + ghost task_id 全 404(5) / task_dir 空 400(2)。版本 0.6 → 0.7。`_smoke_g5.py` ad-hoc 跑完即删。 +- **05-15 / §7 Phase G G6 三件套:/done /abandon 按钮 + /export 下载 + 全局 toast**:① `POST /tasks/{id}/status`(`status=completed|abandoned`,active 不让从 web 切回 → 400)走 `UPDATE tasks SET status`,303 redirect 回 `/tasks/{id}` —— 浏览器全页刷新,聊天流不重发。chat.html active task 渲两个 `
` 按钮(原生 `confirm()` 防误操,无 HTMX 依赖),completed/abandoned 自动隐藏按钮只显 status badge。② `GET /tasks/{id}/export` 走 `tempfile.mkstemp(suffix=.docx)` → `export_chat_to_docx(tid, out_path=tmp)` → `FileResponse(..., background=BackgroundTask(tmp.unlink, missing_ok=True))` 响应完成自动删 tmpfile;无 messages → 400 / ghost UUID → 404 / 失败 → 500 带错文。chat.html 在 `n_messages > 0` 时渲 `export .docx`(浏览器原生下载,无 HTMX 干预 Content-Disposition)。**`export_chat_to_docx` 顺手修了一个 bug**:`task_dir is None` 且 PG 也空时旧逻辑硬抛 `ValueError`,即便 `out_path` 已经显式传入 —— 现在 `task_dir` 改为可选(None 时 meta 段显示 `(未绑)`),只在 `out_path` 也 None 时才报错。③ `base.html` 末尾加 `
` + inline JS 监听 `htmx:responseError`(4xx/5xx 抓 responseText 截 200 字)和 `htmx:sendError`(网络层挂),自动 5-6s dismiss + 手动 × 关。CSS `.toast` / `.toast-error` 右上角 fixed 区 + `@keyframes toast-in/out` 滑入滑出;`#toast-region` z-index 9999 + `pointer-events: none`(容器穿透,toast 自身可点)。Smoke 32 case 全绿:status 6 case(completed/abandoned 303 + DB UPDATE + GET 不再渲按钮、invalid status 400、active 400 拒切回、非 UUID 404、ghost 404)+ export 7 case(200 + Content-Disposition attachment + filename `chat_<8>.docx` + media-type docx + size > 8KB + magic `PK\x03\x04` + no messages 400 + 404 双路径)+ toast 6 case(div / 两 listener / CSS)+ chat.html 7 case(active 渲 done/abandon/export + confirm 文案 + completed 不渲)。版本 0.5 → 0.6。`_smoke_g6.py` ad-hoc 跑完即删(不入 git)。**TODO**:并发同 task 多 run lock 还没做(留到 D 阶段或下次)。 +- **05-15 / §7 D' 过渡 auth + dev SPA**:platform 联调前需要 auth,但完整 OIDC 还要等;落地 **PLATFORM_KEY → JWT 兑换** 过渡形态(`web/auth.py`),前后端走完全同一条流(platform 服务端 / dev 浏览器都持有 PLATFORM_KEY、调 `/v1/auth/login` 换 token、后续 `Authorization: Bearer `)。**实现**:`pyjwt` HS256,`AuthConfig.from_env()` 启动校验 `PLATFORM_KEY` / `JWT_SECRET` 必填(任一缺失 fail-fast)、`ZCBOT_JWT_TTL_SECONDS` 默认 7d、`mint_token` / `verify_token` / `ensure_user_row`(任意 user_id 幂等 INSERT users 行避免 FK 失败)。`HTTPBearer(auto_error=False)` Depends 拿凭证 → `verify_token` → UUID;`make_require_user(cfg)` 工厂闭包持 cfg,FastAPI Depends 抽签到每个 /v1/tasks* 路由。**数据隔离**:所有 `SELECT Task` / `UPDATE Task` 增 `Task.user_id == user_id` 条件;`_load_task_dir(task_id, user_id)` 跨 user 视为 404(不暴露存在性);`check_no_subtask(... user_id=user_id)`、`ensure_local_task_row(... user_id=user_id)` 同 user 隔离 no-subtask 校验。新增 `_assert_owns_task(s, tid, user_id)` helper 复用 messages / SSE / export 三处所有权校验。**豁免**:`/`、`/healthz`、`/docs`、`/openapi.json`、`/v1/auth/login`、`/static/*` 不验 token。**dev SPA**(`web/static/dev.html` ~600 行单文件 vanilla JS):login overlay(user_id 默 SENTINEL 全 0 + platform_key) → localStorage 存 token → 3 栏布局(左 task 列表 + 状态 filter + 新建按钮;中 chat meta + 流式消息卡 + send 表单;右 file 浏览 + 面包屑 + 下载)+ 顶 bar(user 显示 + logout)+ new task modal。**SSE 走 fetch + ReadableStream**(不用 EventSource,因为 EventSource API 不支持自定义 header,token 没法塞;改用 fetch + 手解 SSE frame `\n\n` 切帧、`event:` `data:` 行解析、JSON.parse data 字段)。/ 302 → /static/dev.html(Swagger 仍在 /docs)。**Smoke 32 case 全绿**(TestClient + 真实 HTTP via uvicorn @8767):基本路由(/healthz / / 302 / dev.html 28KB / /docs 仍 200)+ 未带 token 8 路径全 401 + login 路径(bad key 403 / bad user_id 400 / happy 200 + token/expires_at/user_id 回显)+ 带 token CRUD 200/201 + 跨 user 隔离 4 case(other 看 sentinel 404 / 列表不串 / 各自创建独立 / sentinel 看 other 404)+ token 异常(garbled / Basic scheme / wrong-secret / expired 全 401)+ 真实 HTTP login + bearer call + dev.html 静态服务 29KB + root 302 Location 正确 + /docs 仍开放。版本 0.7 → 0.8。requirements 加 `pyjwt>=2.8.0`。**没动**:`core/*`、`build_agent`、`Session.append`、CLI 全链(本地 SENTINEL 单 user 默认走通,不进 web auth)。**TODO**:真 OIDC 接入(替换 /v1/auth/login 内部为 ID token 校验,路由层不动)。 - **05-15 / §7 Phase G G4 chat 发送 + SSE 流式**:新增 `web/broker.py::RunBroker`(in-process pub/sub,`subscribe/emit/close/unsubscribe`)+ `web/sinks.py::WebEventSink` 实现 §7 A 的 sink 协议,把 `AgentLoop._emit` 桥到 broker。**异步策略 = `asyncio.to_thread`**(不改 core):POST `/tasks/{tid}/messages` async handler → 校验 task + INSERT `runs` 行 + `asyncio.create_task(asyncio.to_thread(_run_agent_bg, ...))`,`_run_agent_bg` 在工作线程跑 `build_agent(resume=True) + agent.run`,sink 通过 `loop.call_soon_threadsafe(q.put_nowait, ev)` 跨线程桥事件回 asyncio queue。**多访问策略 = fan-out**:每订阅一个独立 `asyncio.Queue`,同 run 多 tab / 刷新 / 桌面+移动都看得到流;`_done` 集合让晚到订阅者立即收 `done`(不挂)。GET `/tasks/{tid}/runs/{rid}/events` 返 `StreamingResponse` async gen,响应头带 `text/event-stream / Cache-Control: no-cache / X-Accel-Buffering: no`(nginx 反代友好);第一帧发 `: connected\nretry: 3000\n\n` 让 EventSource 立即建立,30s 无 event 发 `: ping` 注释心跳。**SSE multi-line data**:HTML 片段含换行,每行加 `data: ` 前缀(SSE spec),EventSource API 还原成 `\n` 拼接的 HTML 字符串。**Event → HTML 片段**:`_render_event_fragment` 渲染 `text`/`tool_call`/`tool_result`/`error` 四种,`run_start/llm_start/llm_end/done` 发空 data(只让客户端识别 event type)。新 fragment 模板 `_frag_text.html` / `_frag_tool_call.html` / `_frag_tool_result.html` / `_frag_error.html` + `_send_response.html`(POST 响应:user msg 卡 + `msg-assistant streaming` 容器带 `sse-connect/sse-swap/sse-close`)。`chat.html` 加 send 表单(Enter 发送、Shift+Enter 换行,HTMX `hx-post / hx-target=#chat-stream / hx-swap=beforeend / hx-on::after-request reset`);`chat` section 改 `id="chat-stream"` 让 SSE 追加进同一容器;非 active task 隐藏表单。CSS 加 `.streaming .run-indicator` 红点脉冲 / `.send-form` 表单样式 / `.tool-result-inline` 追加式样式 / `.msg-error` 错误卡。**Run 状态写 PG `runs` 表**:POST 时 status=running,正常完结 status=ok + tokens_p/c,异常 status=error + error 文本;DB 写失败不放大噪声(已 emit error 给前端)。**lifespan** `bind_loop(asyncio.get_running_loop())` 让 broker 拿到 asyncio loop 引用。Smoke 双层全绿:broker 单元 8 case(subscribe/emit/get、fan-out 双订阅、跨 run_id 隔离、close 派 done、late subscribe 立刻收 done、unsubscribe 后失联、WebEventSink 桥、unbinded loop silent drop);端到端 24 case(POST 200 + HTML 含 sse-connect + run_id 抽出 + SSE stream content-type/x-accel-buffering/cache-control 头对、event types 序列 `run_start/llm_start/text/tool_call/tool_result/llm_end/done`、text fragment 含 `` markdown、tool_call 含 `
`、tool_result 含 preview、empty body 400、invalid/ghost UUID 404、late subscribe 立刻 done、PG runs 行 INSERT)。版本 0.3 → 0.4。**TODO**:并发同 task 多 run 互锁(messages idx UniqueConstraint 在并发 POST 下会冲突 — 用户连续点 send 暂时不会触发,但需要在 G6 或 D 阶段加 lock_for_update);event log 持久化(刷新继续看流式)留到未来。 --- @@ -58,37 +64,40 @@ ``` core/capabilities.py 71 -core/llm.py 89 +core/llm.py 93 ← +litellm 离线 cost map env core/loop.py 152 ← §7 A: sink.emit core/sinks.py 101 ← §7 A core/ui.py 38 +core/paths.py 50 ← task_dir db form 归一(to_db_path / from_db_path) core/probe.py 243 core/session.py 153 ← §7 B Step 2-3: ORM + ensure 补 meta core/skills.py 81 core/task.py 82 ← §7 B Step 3: PG-backed TaskState,去 cwd core/memory.py 76 -core/export_docx.py 379 ← §7 B Step 2-4: task_id 升一等 +core/export_docx.py 383 ← §7 B Step 2-4 + from_db_path 还原 + task_dir Optional core/storage/__init__.py 27 ← §7 B Step 1-3 core/storage/engine.py 80 ← §7 B Step 1 core/storage/models.py 124 ← §7 B Step 1 -core/storage/utils.py 139 ← §7 B Step 3-6: +upsert_task/update_task/check_no_subtask +core/storage/utils.py 136 ← check_no_subtask 改 Python 端归一 tools/base.py 34 tools/fs.py 182 tools/shell.py 94 tools/run_python.py 84 tools/skill_tool.py 45 -main.py 277 ← §7 B Step 4-6: +is_managed_task_dir / task_dir_arg / no-subtask check +main.py 285 ← +to_db_path 写 / from_db_path 读 cli.py 558 ← §7 B Step 4 / Phase G G1: --task-dir / web 子命令 db/migrations/env.py 61 ← §7 B Step 1 db/migrations/versions/ 0001_initial_schema.py 125 ← §7 B Step 1 + 0002_task_dir_relative.py 61 ← 现有 ROOT-prefix 绝对 → 相对 web/__init__.py 5 ← Phase G G1 -web/app.py 538 ← Phase G G1-G4 + G6/new: 工厂 + list/chat + SSE + /new +web/app.py 660 ← /v1/ JSON API + user_id 隔离(D' 过渡 auth) +web/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换 web/broker.py 88 ← Phase G G4: in-process pub/sub web/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议) +web/static/dev.html ~600 ← D' dev SPA(login + 3-pane,vanilla JS) ───────────────────────────────── -Python 合计 ~3732 行 -+ web/templates/* ~249 行(base/home/chat/new_task + 5 个 _frag/_send_response)+ web/static/style.css 193 行(不计 Python 主仓库) +Python 合计 ~3700 行(+ dev.html ~600 静态) ``` 加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 @@ -97,11 +106,12 @@ Python 合计 ~3732 行 ## 下一步候选(性价比排序) -1. **真实 LLM 跑通 G4 + /new 全流程**(~10 分钟)—— smoke 走的是 mock,需在浏览器开:`cli.py web` → `/new` 建 task → 跳 chat → 发"你好" → 看真实流式 → 刷新看历史。 -2. **§7 Phase G G5 文件浏览 + 上传下载**(~半天)—— `/tasks/{id}/files` 渲染 task_dir 树,upload (multipart)/ download / 删。 -3. **§7 Phase G G6 剩余打磨**(~半天)—— `/done /abandon` 按钮、`/export` docx 下载、错误 toast、并发 run 互锁(messages idx 冲突)。/new 已提前完成。 -4. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。 -5. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 -6. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`。 +1. **platform 端起 API 联调**(~?)—— platform 服务端持 `PLATFORM_KEY` 调 `POST /v1/auth/login {user_id, platform_key}` 拿 token,后续走 `Authorization: Bearer `。Swagger UI(`http://127.0.0.1:8765/docs`)生成 client stub。 +2. **dev.html 浏览器手验**(~10 分钟)—— `cli.py web` 起后访问 `http://127.0.0.1:8765/`,login(填 sentinel UUID + PLATFORM_KEY)→ 看 3 栏布局 + 新建 task + 发消息 SSE 流式 + 文件浏览。 +3. **真 OIDC 接入 + CORS 收紧**(~1 天)—— 把 `/v1/auth/login` 内部从 platform_key 校验换成 OIDC ID token 校验(路由层 Depends 不动);CORS 改成 platform 域名 allowlist。 +4. **§7 C Executor + sandbox**(~2-3 天)—— D 完工,继续 C。 +5. **并发 run 互锁**(~2 小时)—— 用户连发两条消息 messages idx UniqueConstraint 在 race 下会冲;PG `SELECT ... FOR UPDATE` 锁 tasks 行,或 advisory lock。 +6. **§7 E CLI transport 双模式**(~1.5 天)—— `cli.py chat --remote https://...` 走 HTTP 替代 in-process。 +7. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 -> §7 B 已完工。Phase G 进行中(G1 ✅ G2 ✅ G3 ✅ G4 ✅ G6/new ✅)。剩余路线:G5 + G6 剩余 → C(Executor)→ D(HTTP /v1 + OIDC)→ E(CLI 双模式)→ F(deploy / billing)。 +> §7 B + D + D'(过渡 auth)主体已完工。剩余路线:真 OIDC → C(Executor)→ E(CLI 双模式)→ F(deploy / billing)。原 Phase G Web UI 路线撤(DESIGN §7.9),UI 改 platform 端实现;`web/static/dev.html` 是开发期单文件 SPA,跟 platform UI 并存不冲突。 diff --git a/RUN.md b/RUN.md index 39d8b6f..abab4fc 100644 --- a/RUN.md +++ b/RUN.md @@ -2,7 +2,7 @@ > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。 -最后更新:2026-05-15(Phase G G4) +最后更新:2026-05-15(D' 过渡 auth + dev SPA 落地) --- @@ -13,10 +13,16 @@ ``` 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 也能跑。 --- @@ -31,7 +37,7 @@ python -m venv .venv # 3) DB schema 上车 .venv/Scripts/python.exe cli.py db upgrade head -.venv/Scripts/python.exe cli.py db current # 应输出 0001 (head) +.venv/Scripts/python.exe cli.py db current # 应输出 0002 (head) ``` --- @@ -90,20 +96,62 @@ REPL 内命令:`/exit /reset /new /resume [last|] /id /status /done /abandon .venv/Scripts/python.exe cli.py db current ``` -### Web UI(§7 Phase G,本地 sentinel user 无 auth) +### Web API(§7 D + D' 过渡 auth) ```bash -# 默认 127.0.0.1:8765 启,浏览器开 http://127.0.0.1:8765 +# 默认 127.0.0.1:8765 启;dev SPA 在 /,Swagger UI 在 /docs .venv/Scripts/python.exe cli.py web -# 自定义端口 / 监听 0.0.0.0(配合 firewall 慎用,本地形态无 auth) +# 自定义端口 / 监听 0.0.0.0(慎用,部署形态走反代不直暴) .venv/Scripts/python.exe cli.py web --port 9000 # dev:文件改动自动重启(uvicorn 工厂模式 reload) .venv/Scripts/python.exe cli.py web --reload ``` -> G1 ✅ 脚手架 + /healthz;G2 ✅ `/` task 列表 + `?status=` filter;G3 ✅ `/tasks/{uuid}` 消息流渲染(markdown-it-py + pygments,tool_call 走 `
`);G4 ✅ chat 发送 + SSE 流式(POST `/tasks/{tid}/messages` 启 run、GET `/tasks/{tid}/runs/{rid}/events` SSE 流);**G6/new ✅ `/new` 表单新建 task**(desc / mode / task_dir 三字段,303 跳转 chat);G5 文件浏览 + G6 剩余打磨待。task_dir 显示统一 forward-slash(Win 存 `\` 也归一)。Linux:`.venv/bin/python cli.py web` 一致。SSE 经 nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。 +**Auth**:所有 `/v1/tasks*` 需 `Authorization: Bearer `;先走 `/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":""}' +# → {"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 `{description?, mode?, task_dir?}`;同 cli `chat --task-dir` | 必填 | +| `GET /v1/tasks?status=&limit=` | 列当前 user 的任务,`updated_at` 降序 | 必填 | +| `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 | +| `PATCH /v1/tasks/{id}` | `{status?,description?,mode?}` 部分更新;active 走 CLI 切回 | 必填 | +| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 | +| `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{run_id, events_url}` | 必填 | +| `GET /v1/tasks/{id}/runs/{rid}/events` | SSE 流(`event: ` + `data: `) | 必填 | +| `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: ` + `data: `):`run_start{}` → `llm_start{}` → `text{content}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}` → `llm_end{prompt_tokens,completion_tokens}` → `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。 --- @@ -119,11 +167,15 @@ REPL 内命令:`/exit /reset /new /resume [last|] /id /status /done /abandon | `--task-dir` 指定后 `/exit` 没清 task_dir | 设计如此 —— 用户路径绝不 rmtree;DB 行该删还是删。要彻底删手动 `rm -rf ` | | Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export | | `NoSubtaskError: task_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 task_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--task-dir`;否则改路径成 sibling(平级) | -| `cli.py web` 启动后浏览器开不了 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地形态服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或浏览器配 bypass。`curl` 验通走 `curl --noproxy '*' http://127.0.0.1:8765/healthz`(应返 `ok`) | -| `TypeError: unhashable type: 'dict'` from Jinja templating | Starlette 新版签名:`TemplateResponse(request, name, context)`,旧式 `(name, {"request":..., "...":...})` 在 newer Starlette 会把 context dict 当 cache key 炸 | +| `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;` | -| 浏览器 send 后没反应 | 看 console:HTMX 报 connect failed → 看 `/tasks/{tid}/messages` 响应;200 但流不到 → 看 EventSource 状态(devtools Network → EventStream tab) | -| `UniqueViolation idx already exists` from messages | 同 task 连续两次快速 POST,messages idx 冲突。**已知 TODO**:G6/D 阶段加 task 级 lock_for_update 或 advisory lock | +| platform 端 CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)| +| `UniqueViolation idx already exists` from messages | 同 task 并发 POST messages,idx 冲突。**已知 TODO**:加 task 级 `SELECT ... FOR UPDATE` 或 advisory lock(留到 D' / 真发布前) | +| `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 即可 | --- @@ -133,7 +185,7 @@ REPL 内命令:`/exit /reset /new /resume [last|] /id /status /done /abandon - **核心**:`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, templates/, static/}`(§7 Phase G,FastAPI + Jinja2 + HTMX) +- **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**:`workspace/memory/{core.md, extended/}`(跨 task 记忆,FS 永久)/ `workspace/tasks//`(默认派生 task_dir,只放 skill 产物) diff --git a/core/export_docx.py b/core/export_docx.py index b1846e9..7677b36 100644 --- a/core/export_docx.py +++ b/core/export_docx.py @@ -168,7 +168,7 @@ def _format_args(args_str: str) -> str: # ───────────────────────── Meta 区块 ───────────────────────── def _add_meta_block( - doc: Document, meta: dict, task_state: dict, n_msgs: int, task_dir: Path + doc: Document, meta: dict, task_state: dict, n_msgs: int, task_dir: Optional[Path] ) -> None: p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.LEFT @@ -202,7 +202,7 @@ def _add_meta_block( ("更新时间", updated), ("消息数", str(n_msgs)), ("Tokens", f"{tp} prompt / {tc} completion / {tp + tc} total"), - ("Task dir", str(task_dir)), + ("Task dir", str(task_dir) if task_dir else "(未绑)"), ("导出时间", datetime.now().isoformat(timespec="seconds")), ] @@ -346,11 +346,15 @@ def export_chat_to_docx( if task_dir is None: td_str = task_state.get("task_dir", "") - if not td_str: - raise ValueError(f"task {task_id} 无 task_dir(PG 未存且未传参) —— 无处放 .docx") - task_dir = Path(td_str) + if td_str: + # td_str 是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原 absolute Path + from core.paths import from_db_path + task_dir = from_db_path(td_str) + # else: task_dir 留 None,只在 out_path 也 None 时报错(不能没地方落 .docx) if out_path is None: + if task_dir is None: + raise ValueError(f"task {task_id} 无 task_dir 且未指定 out_path —— 无处放 .docx") out_path = task_dir / f"chat_{task_id}.docx" meta = { diff --git a/core/llm.py b/core/llm.py index 726ab38..575bc40 100644 --- a/core/llm.py +++ b/core/llm.py @@ -5,7 +5,11 @@ import os import time from typing import Any, List, Optional -import litellm +# 跳过启动时从 GitHub 拉 model_prices 的网络请求,直接用 litellm 打包的本地副本。 +# 必须在 `import litellm` 之前设置,否则 get_model_cost_map() 已经跑过了。 +os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True") + +import litellm # noqa: E402 from litellm.exceptions import ( APIConnectionError, APIError, diff --git a/core/paths.py b/core/paths.py new file mode 100644 index 0000000..fd9958b --- /dev/null +++ b/core/paths.py @@ -0,0 +1,50 @@ +"""task_dir 在 DB 与文件系统两种形态之间的归一。 + +存储约定(DESIGN §7.4): +- task_dir 在 ROOT 内 → 相对 ROOT 的 posix 串(如 `workspace/tasks/abc-...`) +- task_dir 在 ROOT 外 → 绝对 str(如 `D:\\projects\\other\\proj` 或 `/home/u/proj`) +- 空串 → 空串(legacy / 未绑项目) + +跨机器迁移 / 切 OS / 移 repo 后,ROOT-内路径仍能 resolve;ROOT-外仍存绝对是务实选择 +—— 用户自指定的项目目录没有更好的归一基。 + +Read 端两种来源走两个入口: +- DB tasks.task_dir → `from_db_path(s)` → absolute Path +- 用户 CLI `--task-dir` / Web `/new` 表单 → `Path(arg).expanduser().resolve()`(原行为不变) + +Write 端只通过 `to_db_path(absolute Path)` → DB 串。 +""" +from __future__ import annotations + +from pathlib import Path +from typing import Union + +ROOT: Path = Path(__file__).resolve().parent.parent + + +def to_db_path(p: Union[Path, str, None]) -> str: + """absolute Path / str → DB 串。 + + 输入应已是绝对路径(build_agent / web 路由那一层都 .resolve() 过)。 + ROOT 内 → 相对 posix(`workspace/tasks/abc`) + ROOT 外 → str(Path)(保留 OS 原生分隔符) + 空 → "" + """ + if not p: + return "" + pp = Path(p).resolve() + try: + return pp.relative_to(ROOT).as_posix() + except ValueError: + return str(pp) + + +def from_db_path(s: str) -> Path: + """DB 串 → absolute Path。 + + 相对串 → ROOT / s(再 resolve);绝对串 → resolve();空 → Path("")(调用方判)。 + """ + if not s or not s.strip(): + return Path("") + p = Path(s) + return p.resolve() if p.is_absolute() else (ROOT / p).resolve() diff --git a/core/storage/utils.py b/core/storage/utils.py index 60853e5..085e37d 100644 --- a/core/storage/utils.py +++ b/core/storage/utils.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, Optional from uuid import UUID -from sqlalchemy import func, select, text, update +from sqlalchemy import func, select, update from sqlalchemy.dialects.postgresql import insert from .engine import session_scope @@ -109,31 +109,28 @@ def check_no_subtask( 拒绝:new 是 existing 的子目录、existing 是 new 的子目录。 空 task_dir / 仅 whitespace 跳过(legacy / 未绑项目)。 - 分隔符容差:Windows `\\` 和 Linux `/` 在 LIKE 前统一替换成 `/`,跨 OS 不漏判。 + `task_dir` 入参既可以是 db 形态(相对 ROOT)也可以是 absolute str,内部统一用 + `from_db_path` 归一到 absolute posix 后再比前缀;DB 里行的两种形态同样归一。 + 数量小(per user 几十量级),全量拉到 Python 端比对,不在 SQL 里拼分隔符 / 前缀。 """ if not task_dir or not task_dir.strip(): return - td_norm = task_dir.replace("\\", "/") - # 用 bind 参数传 backslash,绕开 SQL 字符串转义陷阱 - sql = text( - "SELECT task_id, task_dir FROM tasks " - "WHERE user_id = :uid " - " AND task_dir <> '' " - " AND replace(task_dir, :bs, '/') <> :td " - " AND (" - " :td LIKE replace(task_dir, :bs, '/') || '/%' " - " OR replace(task_dir, :bs, '/') LIKE :td || '/%'" - " ) " - "LIMIT 1" - ) - with session_scope() as s: - row = s.execute( - sql, {"uid": str(user_id), "td": td_norm, "bs": "\\"} - ).first() - if row is None: + from core.paths import from_db_path + + new_abs = from_db_path(task_dir).as_posix() + if not new_abs: return - existing_id, existing_dir = row - raise NoSubtaskError( - f"task_dir {task_dir!r} 与已有 task {str(existing_id)[:8]} 的 " - f"task_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 task_dir" - ) + with session_scope() as s: + rows = s.execute( + select(Task.task_id, Task.task_dir) + .where(Task.user_id == user_id, Task.task_dir != "") + ).all() + for existing_id, existing_dir in rows: + existing_abs = from_db_path(existing_dir).as_posix() + if not existing_abs or existing_abs == new_abs: + continue + if new_abs.startswith(existing_abs + "/") or existing_abs.startswith(new_abs + "/"): + raise NoSubtaskError( + f"task_dir {task_dir!r} 与已有 task {str(existing_id)[:8]} 的 " + f"task_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 task_dir" + ) diff --git a/db/migrations/versions/20260515_1011_0002_task_dir_relative.py b/db/migrations/versions/20260515_1011_0002_task_dir_relative.py new file mode 100644 index 0000000..ee9d88b --- /dev/null +++ b/db/migrations/versions/20260515_1011_0002_task_dir_relative.py @@ -0,0 +1,61 @@ +"""task_dir: ROOT-prefix absolute → relative posix. + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-05-15 + +把 `tasks.task_dir` 在 ROOT(本机仓库根)内的绝对路径统一改成相对 ROOT 的 posix 串; +ROOT 外的绝对路径(用户自指定的项目目录)保持原样。 + +ROOT 从 `core.paths` 读 —— alembic env.py 把项目根注入 sys.path,可正常 import。 + +存储约定见 DESIGN.md §7.4 / core/paths.py 头部注释。 + +UPDATE 逻辑: +- 把 task_dir 的 backslash 归一成 `/`(`replace`),与 ROOT 的 posix 串比前缀 +- 命中 → 截掉前缀 + 一个分隔符,得到相对 posix +- 没命中 → 不动 + +downgrade 反向 —— 相对(无盘符 / 不以 `/` 起头)拼回 ROOT。 +""" +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +revision: str = "0002" +down_revision: Union[str, None] = "0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _root_posix() -> str: + from core.paths import ROOT + return str(ROOT).replace("\\", "/") + + +def upgrade() -> None: + root = _root_posix() + # SUBSTRING from N 是 1-indexed;`/` 长度 = len(root)+1+len(rel),想取 rel 从 len+2 起 + op.execute( + text( + "UPDATE tasks " + "SET task_dir = substring(replace(task_dir, '\\', '/') from :off) " + "WHERE replace(task_dir, '\\', '/') LIKE :prefix" + ).bindparams(off=len(root) + 2, prefix=root + "/%") + ) + + +def downgrade() -> None: + root = _root_posix() + # 把"看起来是相对"的行拼回 ROOT 绝对。绝对 = 以 `/` 起头(Linux/posix)或盘符 + # `:` 起头(Windows);LIKE 里 `_` 通配单字符,正好可匹配盘符。 + op.execute( + text( + "UPDATE tasks " + "SET task_dir = :prefix || task_dir " + "WHERE task_dir <> '' " + " AND task_dir NOT LIKE '/%' " + " AND task_dir NOT LIKE '_:%'" + ).bindparams(prefix=root + "/") + ) diff --git a/main.py b/main.py index 2e07f18..c0c9aa5 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ from core.capabilities import ModelCapabilities from core.llm import LLM from core.loop import AgentLoop from core.memory import memory_block +from core.paths import ROOT, from_db_path, to_db_path from core.session import Session from core.sinks import ConsoleEventSink from core.skills import SkillRegistry @@ -29,8 +30,6 @@ from tools.run_python import RunPythonTool from tools.shell import ShellTool from tools.skill_tool import LoadSkillTool -ROOT = Path(__file__).resolve().parent - def load_config() -> dict: return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {} @@ -108,8 +107,14 @@ def resolve_task_id( select(Task.task_dir).where(Task.task_id == tid) ).scalar_one_or_none() or "" - chosen = task_dir_arg.strip() if task_dir_arg else db_dir - fs_dir = Path(chosen).expanduser().resolve() if chosen else _default_task_dir(workspace_dir, tid) + if task_dir_arg and task_dir_arg.strip(): + # 用户显式覆盖(允许 resume 时改绑路径,调用方需自行 UPSERT 持久化) + fs_dir = Path(task_dir_arg).expanduser().resolve() + elif db_dir: + # DB 存的是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原绝对 + fs_dir = from_db_path(db_dir) + else: + fs_dir = _default_task_dir(workspace_dir, tid) return tid, fs_dir tid = uuid4() @@ -207,11 +212,14 @@ def build_agent( system_prompt = _build_system_prompt(cfg, skills, workspace_dir, tool_base, task_dir) now_iso = datetime.now().isoformat(timespec="seconds") + # meta["task_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row + # 把它直接落 PG tasks.task_dir,所以这里就转好。文件系统操作仍用上面的 task_dir(absolute)。 + task_dir_db = to_db_path(task_dir) meta = { "id": sid, "created_at": now_iso, "cwd": str(tool_base), - "task_dir": str(task_dir), + "task_dir": task_dir_db, "model": caps.model_id, "model_profile": model, "mode": mode, @@ -226,7 +234,7 @@ def build_agent( # tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里 # 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令) task_state = TaskState( - task_id=sid, task_dir=str(task_dir), + task_id=sid, task_dir=task_dir_db, mode=mode, description=description, status="active", model=caps.model_id, model_profile=model, ) @@ -235,7 +243,7 @@ def build_agent( # 懒创建:TaskState 仅内存。tasks 行在首条 user 消息 append 时由 # ensure_local_task_row 占位 INSERT;首次 sync_task_tokens 或 /done /desc 走 upsert 覆盖。 task_state = TaskState( - task_id=sid, task_dir=str(task_dir), + task_id=sid, task_dir=task_dir_db, mode=mode, description=description, status="active", model=caps.model_id, model_profile=model, reasoning_effort=caps.default_reasoning_effort or "", diff --git a/requirements.txt b/requirements.txt index d312691..716e8e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,12 +16,8 @@ sqlalchemy>=2.0.0 psycopg[binary]>=3.1.0 alembic>=1.13.0 -# §7 Phase G: Web UI (FastAPI + Jinja2 + HTMX + 原生 SSE) +# §7 Phase G / D: 纯 JSON API(FastAPI + 原生 SSE),前端由 platform 提供 fastapi>=0.111.0 uvicorn[standard]>=0.30.0 -jinja2>=3.1.0 -python-multipart>=0.0.9 -# G3: server-side markdown 渲染 + 代码 syntax highlight -markdown-it-py[linkify]>=3.0.0 -mdit-py-plugins>=0.4.0 -pygments>=2.17.0 +python-multipart>=0.0.9 # files upload multipart 解析 +pyjwt>=2.8.0 # /v1/auth/login HS256 token mint/verify(§7 D' 过渡形态) diff --git a/web/app.py b/web/app.py index 09b84b3..12d0141 100644 --- a/web/app.py +++ b/web/app.py @@ -1,217 +1,172 @@ -"""FastAPI app 工厂。G1 脚手架 → G2 task list → G3 chat 只读 → G4+ 渐进上。 +"""FastAPI app: 纯 /v1 JSON API(2026-05-15 切换 — 详见 DESIGN §7.9)。 -设计: -- 单 FastAPI 进程,模板走 Jinja2,静态走 StaticFiles -- 模板里 path 显示一律 `replace('\\', '/')`,Win / Linux 看到统一形态 - (`Path.as_posix()` 在 Linux 读 Windows backslash 串时不归一,所以直接 replace) -- Markdown 渲染走 markdown-it-py(gfm-like)+ pygments syntax highlight -- SSE 在 G4 加,响应头会带 `X-Accel-Buffering: no`(nginx 反代友好) -- 本地形态 sentinel user 固定;Phase D 加 OIDC 之后才有真正 user 态 +设计要点: +- 所有路由 `/v1/*` 前缀,响应 JSON;模板 / HTMX / 服务端 markdown 渲染全删 +- SSE 事件 payload 是 JSON dict 而非 HTML 片段(`event: ` + `data: `) +- Auth: PLATFORM_KEY → JWT 兑换(§7 D' 过渡形态,见 web/auth.py);OIDC 替换时只动 /v1/auth/login 内部 +- 所有 /v1/tasks* 路由 Depends(require_user),按 user_id 隔离数据 +- 豁免:/healthz、/docs、/openapi.json、/、/v1/auth/login、/static/* +- CORS allow_origins=["*"] 本地宽松;真发布按 platform 域名收紧 +- `GET /` 302 → /static/dev.html(本地 dev SPA) """ from __future__ import annotations import asyncio import json +import os +import tempfile from contextlib import asynccontextmanager +from datetime import datetime as _dt from pathlib import Path from typing import Any, Optional from uuid import UUID, uuid4 -from fastapi import FastAPI, Form, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse +from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates +from pydantic import BaseModel from sqlalchemy import func, select, update +from starlette.background import BackgroundTask -from core.storage import NoSubtaskError, check_no_subtask, ensure_local_sentinel, session_scope +from core.paths import from_db_path, to_db_path +from core.storage import ( + NoSubtaskError, + check_no_subtask, + session_scope, +) from core.storage.models import Message, Run, Task from core.storage.utils import ensure_local_task_row +from .auth import AuthConfig, ensure_user_row, make_require_user, mint_token from .broker import broker from .sinks import WebEventSink -WEB_ROOT = Path(__file__).resolve().parent -TEMPLATES_DIR = WEB_ROOT / "templates" -STATIC_DIR = WEB_ROOT / "static" STATUS_FILTERS = ("active", "completed", "abandoned") +STATUS_WRITABLE = ("completed", "abandoned") # web 不让从 web 端切回 active(走 CLI) +# ─────────────────────────── helpers ─────────────────────────── + def _norm_path(p: str) -> str: - """跨 OS 显示归一:backslash → forward slash。Win 存 `\\`、Linux 存 `/`,显示统一 `/`。""" + """跨 OS 显示归一:backslash → forward slash。""" return (p or "").replace("\\", "/") -# --------------------------- Markdown 渲染 --------------------------- - -_md_instance = None +def _iso(dt: Optional[Any]) -> Optional[str]: + return dt.isoformat() if dt else None -def _pygments_highlight(code: str, lang: str, attrs: str) -> str: - """markdown-it highlight 回调。lang 未识别 / pygments 异常时返回 '' 让 md 走默认
。"""
-    if not lang:
-        return ""
-    try:
-        from pygments import highlight
-        from pygments.formatters import HtmlFormatter
-        from pygments.lexers import get_lexer_by_name
-        from pygments.util import ClassNotFound
-    except ImportError:
-        return ""
-    try:
-        lexer = get_lexer_by_name(lang, stripall=False)
-    except ClassNotFound:
-        return ""
-    formatter = HtmlFormatter(nowrap=False, cssclass="codehilite")
-    return highlight(code, lexer, formatter)
+def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict:
+    """Task ORM row → API JSON dict。"""
+    d = {
+        "task_id": str(row.task_id),
+        "description": row.description or "",
+        "task_dir": _norm_path(row.task_dir or ""),
+        "status": row.status,
+        "mode": row.mode or "",
+        "model": row.model or "",
+        "model_profile": row.model_profile or "",
+        "tokens_prompt": row.tokens_prompt or 0,
+        "tokens_completion": row.tokens_completion or 0,
+        "tokens": (row.tokens_prompt or 0) + (row.tokens_completion or 0),
+        "created_at": _iso(getattr(row, "created_at", None)),
+        "updated_at": _iso(getattr(row, "updated_at", None)),
+    }
+    if n_messages is not None:
+        d["n_messages"] = n_messages
+    return d
 
 
-def _get_md():
-    """单例 MarkdownIt:gfm-like(表/strikethrough/linkify),禁 html(防 XSS),break=True。"""
-    global _md_instance
-    if _md_instance is None:
-        from markdown_it import MarkdownIt
-        _md_instance = MarkdownIt(
-            "gfm-like",
-            {
-                "linkify": True,
-                "html": False,
-                "breaks": True,
-                "highlight": _pygments_highlight,
-            },
-        )
-    return _md_instance
+# ─────────────────────── files helpers ───────────────────────
 
-
-def _render_md(text: str) -> str:
-    """渲染 markdown → HTML。空串返空。"""
-    if not text:
-        return ""
-    return _get_md().render(text)
-
-
-# --------------------------- 消息块聚合 ---------------------------
-
-def _args_preview(args: str, max_len: int = 60) -> str:
-    s = (args or "").replace("\n", " ").strip()
-    return s if len(s) <= max_len else s[: max_len - 3] + "..."
-
-
-def _pretty_json(s: str) -> str:
-    """JSON 串美化输出。解析失败返回原串。"""
-    try:
-        return json.dumps(json.loads(s), indent=2, ensure_ascii=False)
-    except Exception:
-        return s or ""
-
-
-def load_chat_messages(task_id: UUID) -> list[dict]:
-    """读 task 全部 messages(idx asc)。空 task 返空列表。"""
-    with session_scope() as s:
-        rows = s.execute(
-            select(Message.payload).where(Message.task_id == task_id).order_by(Message.idx)
-        ).scalars().all()
-    return [dict(p) for p in rows]
-
-
-def build_chat_blocks(messages: list[dict]) -> list[dict]:
-    """把 LiteLLM 消息序列聚合成显示块。
-
-    - system / tool 不进 blocks(system 不入 DB;tool result 跟随 assistant 的 tool_call 内嵌)
-    - user → {type=user, html}
-    - assistant → {type=assistant, html, tool_calls=[{name,args_preview,args_pretty,result}]}
+def _load_task_dir(task_id: str, user_id: UUID) -> tuple[UUID, Path]:
+    """task_id 解析 + 查 PG 拿 task_dir db form + 还原 absolute Path。
+    404 / 400 if 非 UUID / task 不存在 / 不属于 user / task_dir 空。
+    跨 user 视为 not found(不暴露 task 存在性)。
     """
-    tool_results: dict[str, str] = {}
-    for m in messages:
-        if m.get("role") == "tool":
-            tcid = m.get("tool_call_id")
-            if tcid:
-                tool_results[tcid] = m.get("content") or ""
-
-    blocks: list[dict] = []
-    for m in messages:
-        role = m.get("role")
-        if role in ("system", "tool"):
-            continue
-        if role == "user":
-            blocks.append({
-                "type": "user",
-                "html": _render_md(m.get("content") or ""),
-            })
-        elif role == "assistant":
-            content = m.get("content") or ""
-            tool_calls = m.get("tool_calls") or []
-            tc_blocks = []
-            for tc in tool_calls:
-                fn = tc.get("function", {}) or {}
-                args_raw = fn.get("arguments", "") or ""
-                tc_blocks.append({
-                    "name": fn.get("name", "?"),
-                    "args_preview": _args_preview(args_raw),
-                    "args_pretty": _pretty_json(args_raw),
-                    "result": tool_results.get(tc.get("id", ""), "[no result]"),
-                })
-            blocks.append({
-                "type": "assistant",
-                "html": _render_md(content),
-                "tool_calls": tc_blocks,
-            })
-    return blocks
-
-
-# --------------------------- Task list 查询 ---------------------------
-
-def list_tasks(limit: int = 50, status: Optional[str] = None) -> list[dict[str, Any]]:
-    """Tasks 列表(updated_at 降序),含 messages 计数。"""
-    if status and status not in STATUS_FILTERS:
-        status = None
+    try:
+        tid = UUID(task_id)
+    except ValueError:
+        raise HTTPException(404, f"invalid task id: {task_id!r}")
     with session_scope() as s:
-        q = (
-            select(
-                Task.task_id, Task.updated_at, Task.created_at, Task.status,
-                Task.mode, Task.model, Task.model_profile,
-                Task.tokens_prompt, Task.tokens_completion, Task.description,
-                Task.task_dir,
+        row = s.execute(
+            select(Task.task_dir).where(
+                Task.task_id == tid, Task.user_id == user_id
             )
-            .order_by(Task.updated_at.desc())
-        )
-        if status:
-            q = q.where(Task.status == status)
-        rows_db = s.execute(q.limit(limit)).all()
-        msg_counts = dict(s.execute(
-            select(Message.task_id, func.count()).group_by(Message.task_id)
-        ).all())
-
-    result = []
-    for r in rows_db:
-        tid = r.task_id
-        result.append({
-            "task_id": str(tid),
-            "task_id_short": str(tid)[:8],
-            "updated_at": r.updated_at,
-            "created_at": r.created_at,
-            "status": r.status,
-            "mode": r.mode or "",
-            "model_label": r.model_profile or r.model or "",
-            "tokens": (r.tokens_prompt or 0) + (r.tokens_completion or 0),
-            "n_messages": msg_counts.get(tid, 0),
-            "description": r.description or "",
-            "task_dir": _norm_path(r.task_dir or ""),
-        })
-    return result
+        ).first()
+    if row is None:
+        raise HTTPException(404, f"task not found: {tid}")
+    td = row[0] or ""
+    if not td:
+        raise HTTPException(400, f"task {tid} has no task_dir, files browsing unavailable")
+    return tid, from_db_path(td)
 
 
-# --------------------------- Run 启动 / SSE event 渲染 ---------------------------
+def _safe_join(root: Path, rel: str) -> Path:
+    """归一用户路径到 absolute,并校验仍在 root 内。防 `../` / 绝对 path / symlink 越界。"""
+    rel = (rel or "").strip()
+    if not rel:
+        return root.resolve()
+    if rel[0] in ("/", "\\"):
+        raise HTTPException(400, f"absolute-style path not allowed: {rel!r}")
+    if Path(rel).is_absolute():
+        raise HTTPException(400, f"absolute path not allowed: {rel!r}")
+    target = (root / rel).resolve()
+    try:
+        target.relative_to(root.resolve())
+    except ValueError:
+        raise HTTPException(400, f"path escapes task_dir: {rel!r}")
+    return target
+
+
+def _rel_to(root: Path, target: Path) -> str:
+    try:
+        return target.resolve().relative_to(root.resolve()).as_posix()
+    except ValueError:
+        return ""
+
+
+def _enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict], bool]:
+    """枚举 current 下条目 + 拼面包屑。size raw bytes,mtime ISO 串(前端 humanize)。"""
+    entries: list[dict] = []
+    exists = current.exists()
+    if exists and current.is_dir():
+        try:
+            raw = sorted(current.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
+        except OSError:
+            raw = []
+        for p in raw:
+            try:
+                st = p.stat()
+            except OSError:
+                continue
+            entries.append({
+                "name": p.name,
+                "is_dir": p.is_dir(),
+                "size": st.st_size if p.is_file() else None,
+                "mtime": _dt.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
+                "rel": _rel_to(root, p),
+            })
+    cur_rel = _rel_to(root, current)
+    crumbs = [{"label": "/", "rel": ""}]
+    if cur_rel:
+        acc = ""
+        for part in cur_rel.split("/"):
+            acc = f"{acc}/{part}" if acc else part
+            crumbs.append({"label": part, "rel": acc})
+    return entries, crumbs, exists
+
+
+# ─────────────────── Run 启动 + SSE 帧格式 ───────────────────
 
 def _run_agent_bg(task_id: UUID, run_id: UUID, user_message: str) -> None:
-    """工作线程入口。这里**不能** await asyncio —— 在 to_thread 跑。
+    """工作线程:`build_agent(resume=True)` → 装 WebEventSink → `agent.run` → 写 runs 状态。
 
-    流程:build_agent(resume=True) → 装 WebEventSink → agent.run → 写 runs 状态。
+    sink 通过 broker.emit 桥事件回 asyncio loop;agent.run 是 sync,所以在 to_thread 跑。
     """
     from main import build_agent, sync_task_tokens
-
-    # build_agent 会调 ensure_local_sentinel / LLM init / Session.load 等。
-    # 单次 POST 每次都走一遍 — 不便宜但简单;未来按需缓存 agent。
     try:
         broker.emit(run_id, {"type": "run_start"})
         agent, session, sid, task_state, task_dir = build_agent(
@@ -222,9 +177,7 @@ def _run_agent_bg(task_id: UUID, run_id: UUID, user_message: str) -> None:
         sync_task_tokens(task_state, agent.llm)
         with session_scope() as s:
             s.execute(
-                update(Run)
-                .where(Run.run_id == run_id)
-                .values(
+                update(Run).where(Run.run_id == run_id).values(
                     status="ok",
                     finished_at=func.now(),
                     tokens_p=agent.llm.token_counter.prompt_tokens,
@@ -237,203 +190,329 @@ def _run_agent_bg(task_id: UUID, run_id: UUID, user_message: str) -> None:
         try:
             with session_scope() as s:
                 s.execute(
-                    update(Run)
-                    .where(Run.run_id == run_id)
-                    .values(status="error", error=err, finished_at=func.now())
+                    update(Run).where(Run.run_id == run_id).values(
+                        status="error", error=err, finished_at=func.now()
+                    )
                 )
         except Exception:
-            pass  # 已 emit 给前端,DB 写失败不再放大噪声
+            pass  # 已 emit error 给前端,DB 写失败不放大噪声
     finally:
         broker.close(run_id)
 
 
-def _render_event_fragment(templates: Jinja2Templates, ev: dict, request: Request) -> str:
-    """把一条 event 渲染成 HTML 片段(供 SSE data 推送)。
-
-    片段类型与 chat.html 静态 block 视觉一致,append 模式追加到 #chat-stream 容器尾。
-    text / tool_call / tool_result / error 各有专用块;run_start / llm_start / llm_end /
-    done 不出 HTML(用空串当 keep-alive,客户端依然能识别 event type 控制状态)。
-    """
-    t = ev.get("type")
-    if t == "text":
-        content = ev.get("content") or ""
-        if not content:
-            return ""
-        # assistant text 片段:跟 chat.html 静态 assistant body 同形态
-        return templates.get_template("_frag_text.html").render(
-            request=request, html=_render_md(content)
-        )
-    if t == "tool_call":
-        return templates.get_template("_frag_tool_call.html").render(
-            request=request,
-            name=ev.get("name", "?"),
-            args_preview=_args_preview(ev.get("args_preview", "")),
-            args_pretty=_pretty_json(json.dumps(ev.get("args", {}), ensure_ascii=False))
-            if ev.get("args") is not None else _pretty_json(ev.get("args_preview", "")),
-        )
-    if t == "tool_result":
-        return templates.get_template("_frag_tool_result.html").render(
-            request=request,
-            name=ev.get("name", "?"),
-            preview=ev.get("preview", ""),
-            truncated=ev.get("truncated", False),
-        )
-    if t == "error":
-        return templates.get_template("_frag_error.html").render(
-            request=request, msg=ev.get("msg", "")
-        )
-    # llm_start / llm_end / run_start / done:发空 data,htmx-ext-sse 也会触发 event,
-    # 客户端只读 type 控制状态(spinner / close);data 内容不需要 swap。
-    return ""
+def _sse_event(event_type: str, payload: dict) -> bytes:
+    """格式化 SSE 一帧:`event: ` + `data: `。"""
+    body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
+    return f"event: {event_type}\ndata: {body}\n\n".encode("utf-8")
 
 
-def _sse_format(event_type: str, payload: str) -> bytes:
-    """格式化一帧 SSE。data 多行要每行 `data: ` 前缀(SSE spec)。
+# ────────────────────── Pydantic 请求体 ──────────────────────
 
-    EventSource API 会自动把 multi-line data 用 \n 拼接还原 — htmx-ext-sse 直接拿来当 HTML swap。
-    """
-    parts = [f"event: {event_type}"]
-    if payload:
-        for line in payload.splitlines() or [""]:
-            parts.append(f"data: {line}")
-    else:
-        parts.append("data: ")  # 空 data 也要有,EventSource 才认这帧
-    parts.append("")  # 终结空行
-    parts.append("")
-    return ("\n".join(parts)).encode("utf-8")
+class TaskCreateRequest(BaseModel):
+    description: str = ""
+    mode: str = ""
+    task_dir: str = ""
 
 
-# --------------------------- App 工厂 ---------------------------
+class TaskPatchRequest(BaseModel):
+    status: Optional[str] = None
+    description: Optional[str] = None
+    mode: Optional[str] = None
+
+
+class MessageRequest(BaseModel):
+    content: str
+
+
+class FileDeleteRequest(BaseModel):
+    path: str
+
+
+class LoginRequest(BaseModel):
+    user_id: str
+    platform_key: str
+
+
+# ────────────────────── App 工厂 ──────────────────────
+
+# web/static 目录路径 — /static 静态挂载用,dev.html 也放这
+_STATIC_DIR = Path(__file__).parent / "static"
+
 
 def create_app() -> FastAPI:
-    """FastAPI 工厂。uvicorn --reload 模式需要工厂签名(factory=True)。"""
+    # fail-fast:env 缺失直接抛,不裸跑无密
+    auth_cfg = AuthConfig.from_env()
+    require_user = make_require_user(auth_cfg)
 
     @asynccontextmanager
     async def lifespan(app: FastAPI):
-        # 把当前 asyncio loop 绑给 broker — emit() 从工作线程会 call_soon_threadsafe 桥回
         broker.bind_loop(asyncio.get_running_loop())
         yield
 
-    app = FastAPI(title="zcbot web", version="0.4", lifespan=lifespan)
-    templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
-    app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+    app = FastAPI(
+        title="zcbot api",
+        version="0.8",
+        description=(
+            "zcbot 后端 — /v1 JSON API + SSE。Auth: PLATFORM_KEY → JWT(§7 D' 过渡)。"
+            "本地 dev SPA: /static/dev.html。"
+        ),
+        lifespan=lifespan,
+    )
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=["*"],         # 本地宽松,部署 platform 时按域名收紧
+        allow_credentials=False,
+        allow_methods=["*"],
+        allow_headers=["*"],
+    )
 
-    @app.get("/", response_class=HTMLResponse)
-    def home(request: Request, status: Optional[str] = None, limit: int = 50):
-        tasks = list_tasks(limit=limit, status=status)
-        return templates.TemplateResponse(
-            request, "home.html",
-            {
-                "version": app.version,
-                "tasks": tasks,
-                "status": status or "",
-                "limit": limit,
-                "filters": STATUS_FILTERS,
-            },
-        )
+    if _STATIC_DIR.is_dir():
+        app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
 
-    @app.get("/tasks/{task_id}", response_class=HTMLResponse)
-    def task_detail(request: Request, task_id: str):
-        """G3:UUID 校验 + 读 task 元数据 + 读 messages + 聚合成显示块 + 渲染。"""
-        try:
-            tid = UUID(task_id)
-        except ValueError:
-            return HTMLResponse(f"invalid task id: {task_id!r}", status_code=404)
-        with session_scope() as s:
-            row = s.execute(
-                select(
-                    Task.task_id, Task.description, Task.task_dir, Task.status,
-                    Task.mode, Task.model, Task.model_profile,
-                    Task.tokens_prompt, Task.tokens_completion,
-                    Task.created_at, Task.updated_at,
-                ).where(Task.task_id == tid)
-            ).first()
-        if row is None:
-            return HTMLResponse(f"task not found: {tid}", status_code=404)
+    # ───────────── Misc ─────────────
 
-        messages = load_chat_messages(tid)
-        blocks = build_chat_blocks(messages)
+    @app.get("/", include_in_schema=False)
+    def root():
+        # 本地 dev SPA;Swagger UI 仍在 /docs
+        return RedirectResponse(url="/static/dev.html", status_code=302)
 
-        return templates.TemplateResponse(
-            request, "chat.html",
-            {
-                "task_id": str(tid),
-                "task_id_short": str(tid)[:8],
-                "description": row.description or "",
-                "task_dir": _norm_path(row.task_dir or ""),
-                "status": row.status,
-                "mode": row.mode or "",
-                "model_label": row.model_profile or row.model or "",
-                "tokens": (row.tokens_prompt or 0) + (row.tokens_completion or 0),
-                "n_messages": len(messages),
-                "created_at": row.created_at,
-                "updated_at": row.updated_at,
-                "blocks": blocks,
-            },
-        )
+    @app.get("/healthz", tags=["misc"])
+    def healthz():
+        return {"status": "ok"}
 
-    @app.post("/tasks/{task_id}/messages", response_class=HTMLResponse)
-    async def post_message(request: Request, task_id: str, content: str = Form(...)):
-        """G4:用户提交消息 → 启 BG run → 返回 user msg 卡 + assistant 占位 + SSE 容器。
+    # ───────────── Auth ─────────────
 
-        客户端 HTMX hx-post 这条,响应 swap 到 #chat-stream beforeend;响应 HTML 内含
-        sse-connect=/tasks/{id}/runs/{rid}/events,htmx-ext-sse 自动开 EventSource。
+    @app.post("/v1/auth/login", tags=["auth"])
+    def login(body: LoginRequest):
+        """platform_key 校验通过 → 签 JWT(user_id 作为 sub)。
+
+        platform_key 错 → 403;user_id 非 UUID → 400。
+        user_id 未存在则幂等创建 users 行(避免下游 FK 失败)。
         """
+        if body.platform_key != auth_cfg.platform_key:
+            raise HTTPException(403, "invalid platform_key")
+        try:
+            uid = UUID(body.user_id)
+        except (ValueError, TypeError):
+            raise HTTPException(400, f"invalid user_id (must be UUID): {body.user_id!r}")
+        ensure_user_row(uid)
+        token, exp = mint_token(auth_cfg, uid)
+        return {
+            "token": token,
+            "expires_at": _dt.fromtimestamp(exp).isoformat(),
+            "user_id": str(uid),
+            "ttl_seconds": auth_cfg.ttl_seconds,
+        }
+
+    # ───────────── Tasks CRUD ─────────────
+
+    @app.post("/v1/tasks", status_code=201, tags=["tasks"])
+    def create_task(body: TaskCreateRequest, user_id: UUID = Depends(require_user)):
+        """新建 task。`task_dir` 留空 → 默认派生 `workspace/tasks//`。
+        `description` 与 `task_dir` 至少给一个否则 400。
+        前缀嵌套(no-subtask,同 user 内)→ 409。
+        """
+        description = body.description.strip()
+        mode = body.mode.strip()
+        task_dir_raw = body.task_dir.strip()
+        if not description and not task_dir_raw:
+            raise HTTPException(400, "either description or task_dir must be provided")
+
+        tid = uuid4()
+        from main import _default_task_dir, resolve_workspace
+        ws = resolve_workspace(None)
+        if task_dir_raw:
+            fs_dir = Path(task_dir_raw).expanduser().resolve()
+        else:
+            fs_dir = _default_task_dir(ws, tid)
+        fs_dir_db = to_db_path(fs_dir)
+
+        try:
+            check_no_subtask(fs_dir_db, user_id=user_id)
+        except NoSubtaskError as e:
+            raise HTTPException(409, str(e))
+
+        ensure_local_task_row(
+            task_id=tid, task_dir=fs_dir_db, mode=mode,
+            description=description, user_id=user_id,
+        )
+        with session_scope() as s:
+            row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one()
+        return _task_dict(row, n_messages=0)
+
+    @app.get("/v1/tasks", tags=["tasks"])
+    def list_tasks_route(
+        status: Optional[str] = None,
+        limit: int = 50,
+        user_id: UUID = Depends(require_user),
+    ):
+        """列出当前 user 的 task,`updated_at` 降序。"""
+        if status and status not in STATUS_FILTERS:
+            status = None
+        with session_scope() as s:
+            q = select(Task).where(Task.user_id == user_id).order_by(Task.updated_at.desc())
+            if status:
+                q = q.where(Task.status == status)
+            rows = s.execute(q.limit(limit)).scalars().all()
+            tids = [r.task_id for r in rows]
+            counts = (
+                dict(s.execute(
+                    select(Message.task_id, func.count())
+                    .where(Message.task_id.in_(tids))
+                    .group_by(Message.task_id)
+                ).all())
+                if tids else {}
+            )
+        return {
+            "tasks": [
+                _task_dict(r, n_messages=counts.get(r.task_id, 0))
+                for r in rows
+            ]
+        }
+
+    @app.get("/v1/tasks/{task_id}", tags=["tasks"])
+    def get_task(task_id: str, user_id: UUID = Depends(require_user)):
+        """单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。"""
         try:
             tid = UUID(task_id)
         except ValueError:
             raise HTTPException(404, f"invalid task id: {task_id!r}")
-        content = (content or "").strip()
-        if not content:
-            raise HTTPException(400, "empty message")
-        # 校验 task 存在
         with session_scope() as s:
             row = s.execute(
-                select(Task.task_id).where(Task.task_id == tid)
-            ).first()
-        if row is None:
+                select(Task).where(Task.task_id == tid, Task.user_id == user_id)
+            ).scalar_one_or_none()
+            if row is None:
+                raise HTTPException(404, f"task not found: {tid}")
+            n = s.execute(
+                select(func.count()).select_from(Message).where(Message.task_id == tid)
+            ).scalar_one()
+        return _task_dict(row, n_messages=n)
+
+    @app.patch("/v1/tasks/{task_id}", tags=["tasks"])
+    def patch_task(
+        task_id: str,
+        body: TaskPatchRequest,
+        user_id: UUID = Depends(require_user),
+    ):
+        """更新 task 字段。`status` 仅允许 completed/abandoned(active 走 CLI 切回)。"""
+        try:
+            tid = UUID(task_id)
+        except ValueError:
+            raise HTTPException(404, f"invalid task id: {task_id!r}")
+        updates: dict[str, Any] = {}
+        if body.status is not None:
+            if body.status not in STATUS_WRITABLE:
+                raise HTTPException(
+                    400, f"invalid status {body.status!r}; allowed: {STATUS_WRITABLE}"
+                )
+            updates["status"] = body.status
+        if body.description is not None:
+            updates["description"] = body.description
+        if body.mode is not None:
+            updates["mode"] = body.mode
+        if not updates:
+            raise HTTPException(400, "no fields to update")
+        with session_scope() as s:
+            result = s.execute(
+                update(Task)
+                .where(Task.task_id == tid, Task.user_id == user_id)
+                .values(**updates)
+            )
+            if result.rowcount == 0:
+                raise HTTPException(404, f"task not found: {tid}")
+            row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one()
+            n = s.execute(
+                select(func.count()).select_from(Message).where(Message.task_id == tid)
+            ).scalar_one()
+        return _task_dict(row, n_messages=n)
+
+    # ───────────── Messages ─────────────
+
+    def _assert_owns_task(s, tid: UUID, user_id: UUID) -> None:
+        ok = s.execute(
+            select(Task.task_id).where(Task.task_id == tid, Task.user_id == user_id)
+        ).first()
+        if ok is None:
             raise HTTPException(404, f"task not found: {tid}")
 
+    @app.get("/v1/tasks/{task_id}/messages", tags=["messages"])
+    def list_messages(task_id: str, user_id: UUID = Depends(require_user)):
+        """task 历史消息(idx 升序);LiteLLM 原 payload 透传给前端,自行渲染。"""
+        try:
+            tid = UUID(task_id)
+        except ValueError:
+            raise HTTPException(404, f"invalid task id: {task_id!r}")
+        with session_scope() as s:
+            _assert_owns_task(s, tid, user_id)
+            rows = s.execute(
+                select(
+                    Message.idx, Message.payload, Message.tokens_in,
+                    Message.tokens_out, Message.created_at,
+                ).where(Message.task_id == tid).order_by(Message.idx)
+            ).all()
+        return {
+            "messages": [
+                {
+                    "idx": r.idx,
+                    "payload": dict(r.payload),
+                    "tokens_in": r.tokens_in,
+                    "tokens_out": r.tokens_out,
+                    "created_at": _iso(r.created_at),
+                }
+                for r in rows
+            ]
+        }
+
+    @app.post("/v1/tasks/{task_id}/messages", status_code=202, tags=["messages"])
+    async def post_message(
+        task_id: str,
+        body: MessageRequest,
+        user_id: UUID = Depends(require_user),
+    ):
+        """发消息 + 起 BG run。返 `{run_id, events_url}`,客户端立刻订阅 SSE 拿流式。"""
+        try:
+            tid = UUID(task_id)
+        except ValueError:
+            raise HTTPException(404, f"invalid task id: {task_id!r}")
+        content = (body.content or "").strip()
+        if not content:
+            raise HTTPException(400, "empty content")
+        with session_scope() as s:
+            _assert_owns_task(s, tid, user_id)
+
         run_id = uuid4()
         with session_scope() as s:
             s.add(Run(run_id=run_id, task_id=tid, status="running", started_at=func.now()))
-
-        # 启 BG agent — to_thread 跑 sync agent.run,sink 通过 broker 把 event 桥回 asyncio
+        # to_thread 跑 sync agent.run;sink 通过 broker 把 event 桥回 asyncio
         asyncio.create_task(asyncio.to_thread(_run_agent_bg, tid, run_id, content))
+        return {
+            "run_id": str(run_id),
+            "events_url": f"/v1/tasks/{tid}/runs/{run_id}/events",
+        }
 
-        return templates.TemplateResponse(
-            request, "_send_response.html",
-            {
-                "task_id": str(tid),
-                "run_id": str(run_id),
-                "user_html": _render_md(content),
-            },
-        )
+    # ───────────── SSE events ─────────────
 
-    @app.get("/tasks/{task_id}/runs/{run_id}/events")
-    async def stream_events(request: Request, task_id: str, run_id: str):
-        """G4:SSE 流。订阅 broker[run_id] → 渲染 HTML 片段 → 推。
-
-        客户端断开(close tab / navigate)→ asyncio 在下次 yield 抛 CancelledError →
-        finally 清理。同 run 多订阅者(刷新页面 / 多 tab)各自独立 queue。
+    @app.get("/v1/tasks/{task_id}/runs/{run_id}/events", tags=["runs"])
+    async def stream_events(
+        task_id: str,
+        run_id: str,
+        user_id: UUID = Depends(require_user),
+    ):
+        """SSE 流。事件类型:run_start / llm_start / text / tool_call / tool_result /
+        llm_end / error / done。data 是 JSON dict(已剔除 `type` 字段,移到 event 名)。
         """
         try:
             tid = UUID(task_id)
             rid = UUID(run_id)
         except ValueError:
             raise HTTPException(404, "invalid id")
-        # task 存在性校验(防探测 / 错链)
         with session_scope() as s:
-            ok = s.execute(
-                select(Task.task_id).where(Task.task_id == tid)
-            ).first()
-        if ok is None:
-            raise HTTPException(404, f"task not found: {tid}")
+            _assert_owns_task(s, tid, user_id)
 
         async def gen():
             q = broker.subscribe(rid)
             try:
-                # 第一帧 retry 注释 + 心跳:让 EventSource 立即建立(不被 buffer 卡住)
+                # 第一帧 retry 注释 + 心跳:让 EventSource 立即建立(不被 buffer 卡)
                 yield b": connected\nretry: 3000\n\n"
                 while True:
                     try:
@@ -442,13 +521,12 @@ def create_app() -> FastAPI:
                         yield b": ping\n\n"
                         continue
                     ev_type = ev.get("type", "msg")
-                    frag = _render_event_fragment(templates, ev, request)
-                    yield _sse_format(ev_type, frag)
+                    payload = {k: v for k, v in ev.items() if k != "type"}
+                    yield _sse_event(ev_type, payload)
                     if ev_type in ("done", "error"):
                         break
             except asyncio.CancelledError:
-                # 客户端断开 — 静默退,不向上抛
-                pass
+                pass  # 客户端断开,静默退
             finally:
                 broker.unsubscribe(rid, q)
 
@@ -458,81 +536,139 @@ def create_app() -> FastAPI:
             headers={
                 "Cache-Control": "no-cache",
                 "Connection": "keep-alive",
-                "X-Accel-Buffering": "no",  # nginx 反代:别 buffer 这条流
+                "X-Accel-Buffering": "no",
             },
         )
 
-    @app.get("/new", response_class=HTMLResponse)
-    def new_task_form(request: Request):
-        """渲染新建 task 表单(description / mode / task_dir 可选)。"""
-        return templates.TemplateResponse(
-            request, "new_task.html",
-            {"error": None, "form": {"description": "", "mode": "", "task_dir": ""}},
-        )
+    # ───────────── Files ─────────────
 
-    @app.post("/new")
-    def new_task_submit(
-        request: Request,
-        description: str = Form(""),
-        mode: str = Form(""),
-        task_dir: str = Form(""),
+    @app.get("/v1/tasks/{task_id}/files", tags=["files"])
+    def list_files(
+        task_id: str,
+        path: str = "",
+        user_id: UUID = Depends(require_user),
     ):
-        """新建 task:校验 + no-subtask + INSERT 占位行 + 303 redirect 到 /tasks/{tid}。
+        """列子目录条目 + 面包屑。`path` 留空 → root;`../` / 绝对 → 400。"""
+        tid, root = _load_task_dir(task_id, user_id)
+        current = _safe_join(root, path)
+        entries, crumbs, exists = _enumerate_files(root, current)
+        return {
+            "task_id": str(tid),
+            "root": _norm_path(str(root)),
+            "current": _rel_to(root, current),
+            "exists": exists,
+            "crumbs": crumbs,
+            "entries": entries,
+        }
 
-        Task 在这里就入库(不走 build_agent 的懒创建),原因:用户在表单页填了
-        meta 但还没发消息 — task 必须先存在,不然 /tasks/{tid} 跳过去 404。
-        懒创建语义对 CLI 仍然适用(REPL `/exit` 没发消息会 _cleanup_if_empty 删
-        掉空 task);Web 这里多一行 task 行,用户可在 /tasks/{tid} 触发 G4
-        send 流程,首条消息 ensure_local_task_row 因 ON CONFLICT DO NOTHING
-        不冲突。
+    @app.get("/v1/tasks/{task_id}/files/download", tags=["files"])
+    def download_file(
+        task_id: str,
+        path: str,
+        user_id: UUID = Depends(require_user),
+    ):
+        """下载单个 regular file(目录 → 400 / 不存在 → 404)。"""
+        tid, root = _load_task_dir(task_id, user_id)
+        target = _safe_join(root, path)
+        if not target.exists():
+            raise HTTPException(404, f"file not found: {path}")
+        if not target.is_file():
+            raise HTTPException(400, f"not a file: {path}")
+        return FileResponse(path=str(target), filename=target.name)
+
+    @app.post("/v1/tasks/{task_id}/files/upload", tags=["files"])
+    async def upload_files(
+        task_id: str,
+        path: str = Form(""),
+        files: list[UploadFile] = File(...),
+        user_id: UUID = Depends(require_user),
+    ):
+        """multipart 多文件上传到 `//`。
+        路径不存在自动 mkdir(parents=True);重名直接覆盖。
+        文件名严格校验(含 `/ \\ ..` 或为空 → 400)。
         """
-        description = (description or "").strip()
-        mode = (mode or "").strip()
-        task_dir = (task_dir or "").strip()
+        tid, root = _load_task_dir(task_id, user_id)
+        dest_dir = _safe_join(root, path)
+        if dest_dir.exists() and not dest_dir.is_dir():
+            raise HTTPException(400, f"upload target is a file, not a directory: {path}")
+        dest_dir.mkdir(parents=True, exist_ok=True)
 
-        form_state = {"description": description, "mode": mode, "task_dir": task_dir}
+        saved: list[dict] = []
+        for up in files or []:
+            raw_name = up.filename or ""
+            if (
+                not raw_name
+                or raw_name in (".", "..")
+                or "/" in raw_name or "\\" in raw_name
+                or any(part in (".", "..") for part in Path(raw_name).parts)
+            ):
+                raise HTTPException(400, f"invalid filename: {raw_name!r}")
+            dest = dest_dir / raw_name
+            try:
+                dest.resolve().relative_to(root.resolve())
+            except ValueError:
+                raise HTTPException(400, f"path escapes task_dir: {raw_name!r}")
+            data = await up.read()
+            dest.write_bytes(data)
+            saved.append({"name": raw_name, "size": len(data), "rel": _rel_to(root, dest)})
+        if not saved:
+            raise HTTPException(400, "no files uploaded")
+        return {"count": len(saved), "saved": saved}
 
-        if not description and not task_dir:
-            return templates.TemplateResponse(
-                request, "new_task.html",
-                {"error": "description 或 task_dir 至少填一个,否则 task 不好识别。", "form": form_state},
-                status_code=400,
-            )
-
-        # task_dir 显式 → 绝对化(同 cli.py `--task-dir`);空 → 默认派生 workspace/tasks//
-        tid = uuid4()
-        from main import _default_task_dir, resolve_workspace
-        ws = resolve_workspace(None)
-        if task_dir:
-            fs_dir = Path(task_dir).expanduser().resolve()
-        else:
-            fs_dir = _default_task_dir(ws, tid)
-        fs_dir_str = str(fs_dir)
-
-        # §7.4 no-subtask 校验(同 cli.py chat / build_agent 入口)
+    @app.post("/v1/tasks/{task_id}/files/delete", tags=["files"])
+    def delete_file(
+        task_id: str,
+        body: FileDeleteRequest,
+        user_id: UUID = Depends(require_user),
+    ):
+        """删 task_dir 下文件或**空**目录。非空目录 → 400(避免误操);root → 400。"""
+        tid, root = _load_task_dir(task_id, user_id)
+        target = _safe_join(root, body.path)
+        if target.resolve() == root.resolve():
+            raise HTTPException(400, "cannot delete task_dir root")
+        if not target.exists():
+            raise HTTPException(404, f"path not found: {body.path}")
         try:
-            check_no_subtask(fs_dir_str)
-        except NoSubtaskError as e:
-            return templates.TemplateResponse(
-                request, "new_task.html",
-                {"error": str(e), "form": form_state},
-                status_code=409,
-            )
+            if target.is_dir():
+                target.rmdir()  # 非空目录会触发 OSError
+            else:
+                target.unlink()
+        except OSError as e:
+            raise HTTPException(400, f"delete failed: {e}")
+        return {"ok": True, "path": body.path}
 
-        # 本地形态 — sentinel user 确保存在(build_agent 路径之外也要保险)
-        ensure_local_sentinel()
-        # INSERT 占位行 — idempotent;真值由 Web 这里给,build_agent 不再覆盖
-        ensure_local_task_row(
-            task_id=tid,
-            task_dir=fs_dir_str,
-            mode=mode,
-            description=description,
+    # ───────────── Export ─────────────
+
+    @app.get("/v1/tasks/{task_id}/export", tags=["export"])
+    def export_task(task_id: str, user_id: UUID = Depends(require_user)):
+        """导出对话为 .docx,临时文件下载完后 BackgroundTask 删 tmp。"""
+        try:
+            tid = UUID(task_id)
+        except ValueError:
+            raise HTTPException(404, f"invalid task id: {task_id!r}")
+        with session_scope() as s:
+            _assert_owns_task(s, tid, user_id)
+            has_msg = s.execute(
+                select(Message.message_id).where(Message.task_id == tid).limit(1)
+            ).first()
+        if not has_msg:
+            raise HTTPException(400, "no messages to export")
+
+        fd, tmp_str = tempfile.mkstemp(suffix=".docx", prefix="zcbot-export-")
+        os.close(fd)
+        tmp_path = Path(tmp_str)
+        try:
+            from core.export_docx import export_chat_to_docx
+            export_chat_to_docx(tid, out_path=tmp_path)
+        except Exception as e:
+            tmp_path.unlink(missing_ok=True)
+            raise HTTPException(500, f"export failed: {type(e).__name__}: {e}")
+
+        return FileResponse(
+            path=str(tmp_path),
+            media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+            filename=f"chat_{str(tid)[:8]}.docx",
+            background=BackgroundTask(tmp_path.unlink, missing_ok=True),
         )
-        # 303 See Other:POST 转 GET,让浏览器刷新拿到 chat 页(避免重复提交)
-        return RedirectResponse(url=f"/tasks/{tid}", status_code=303)
-
-    @app.get("/healthz", response_class=HTMLResponse)
-    def healthz():
-        return HTMLResponse("ok")
 
     return app
diff --git a/web/auth.py b/web/auth.py
new file mode 100644
index 0000000..2d164cf
--- /dev/null
+++ b/web/auth.py
@@ -0,0 +1,138 @@
+"""Auth: PLATFORM_KEY → JWT token 兑换(§7 D' 过渡形态)。
+
+模型:
+- `PLATFORM_KEY` env(必填)是 platform/本仓库间的共享密钥;platform 服务端 / dev 页持有它
+- `JWT_SECRET` env(必填)用于 HS256 签 token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护
+- `POST /v1/auth/login {user_id, platform_key}` → `{token, expires_at}`(后端校验 key 对 → 签 JWT)
+- 后续 `/v1/*`(除 /healthz、/docs、/openapi.json、/、/v1/auth/login)走 `Authorization: Bearer `
+- Token TTL: `ZCBOT_JWT_TTL_SECONDS` env 覆盖,默 7 天
+
+OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key),路由层 Depends 不变。
+"""
+from __future__ import annotations
+
+import os
+import time
+from typing import Optional
+from uuid import UUID
+
+import jwt
+from fastapi import Depends, HTTPException, Request
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from core.storage import session_scope
+from core.storage.models import SENTINEL_USER_ID, User
+
+
+_DEFAULT_TTL_SECONDS = 7 * 24 * 3600  # 7d
+
+
+class AuthConfig:
+    """App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。"""
+
+    def __init__(self, platform_key: str, jwt_secret: str, ttl_seconds: int):
+        self.platform_key = platform_key
+        self.jwt_secret = jwt_secret
+        self.ttl_seconds = ttl_seconds
+
+    @classmethod
+    def from_env(cls) -> "AuthConfig":
+        key = os.environ.get("PLATFORM_KEY", "").strip()
+        secret = os.environ.get("JWT_SECRET", "").strip()
+        missing = []
+        if not key:
+            missing.append("PLATFORM_KEY")
+        if not secret:
+            missing.append("JWT_SECRET")
+        if missing:
+            raise RuntimeError(
+                f"{', '.join(missing)} env not set. zcbot web requires both:\n"
+                "  PLATFORM_KEY=\n"
+                "  JWT_SECRET="
+            )
+        ttl_raw = os.environ.get("ZCBOT_JWT_TTL_SECONDS", "").strip()
+        try:
+            ttl = int(ttl_raw) if ttl_raw else _DEFAULT_TTL_SECONDS
+        except ValueError:
+            raise RuntimeError(
+                f"ZCBOT_JWT_TTL_SECONDS must be int seconds, got {ttl_raw!r}"
+            )
+        if ttl <= 0:
+            raise RuntimeError(f"ZCBOT_JWT_TTL_SECONDS must be > 0, got {ttl}")
+        return cls(platform_key=key, jwt_secret=secret, ttl_seconds=ttl)
+
+
+def mint_token(cfg: AuthConfig, user_id: UUID) -> tuple[str, int]:
+    """签 JWT。返回 `(token, exp_unix_seconds)`。"""
+    now = int(time.time())
+    exp = now + cfg.ttl_seconds
+    payload = {"sub": str(user_id), "iat": now, "exp": exp}
+    token = jwt.encode(payload, cfg.jwt_secret, algorithm="HS256")
+    return token, exp
+
+
+def verify_token(cfg: AuthConfig, token: str) -> UUID:
+    """验签 + 取 sub。失败抛 HTTPException 401。"""
+    try:
+        payload = jwt.decode(token, cfg.jwt_secret, algorithms=["HS256"])
+    except jwt.ExpiredSignatureError:
+        raise HTTPException(401, "token expired")
+    except jwt.InvalidTokenError as e:
+        raise HTTPException(401, f"invalid token: {e}")
+    sub = payload.get("sub", "")
+    try:
+        return UUID(sub)
+    except (ValueError, TypeError):
+        raise HTTPException(401, f"invalid sub in token: {sub!r}")
+
+
+def ensure_user_row(user_id: UUID) -> None:
+    """幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
+
+    dev 用 SENTINEL,platform 注入的 user_id 也走这条 — 无论是新用户首次登录还是
+    既有用户复登,都安全。真用户 profile(email/oidc_subject 等)在 D' OIDC 阶段
+    再走专门的 register/sync 路径。
+    """
+    from sqlalchemy.dialects.postgresql import insert
+    stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing(
+        index_elements=["user_id"]
+    )
+    with session_scope() as s:
+        s.execute(stmt)
+
+
+# ──────────────── FastAPI Depends ────────────────
+# auto_error=False 让我们自己出 401 文案,而不是 FastAPI 默认 "Not authenticated"
+_bearer = HTTPBearer(auto_error=False)
+
+
+def make_require_user(cfg: AuthConfig):
+    """工厂:返回一个 Depends 函数,闭包持有 cfg(避免 app 启动后改 env)。
+
+    用法:
+        require_user = make_require_user(cfg)
+        @app.get("/v1/...", dependencies=[Depends(require_user)])
+        def route(user_id: UUID = Depends(require_user)):
+            ...
+    实际使用建议直接 `user_id: UUID = Depends(require_user)`,既验签又拿到 user_id。
+    """
+    async def require_user(
+        creds: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
+    ) -> UUID:
+        if creds is None or not creds.credentials:
+            raise HTTPException(401, "missing Authorization: Bearer ")
+        if creds.scheme.lower() != "bearer":
+            raise HTTPException(401, f"unsupported auth scheme: {creds.scheme!r}")
+        return verify_token(cfg, creds.credentials)
+
+    return require_user
+
+
+__all__ = [
+    "AuthConfig",
+    "SENTINEL_USER_ID",
+    "ensure_user_row",
+    "make_require_user",
+    "mint_token",
+    "verify_token",
+]
diff --git a/web/static/dev.html b/web/static/dev.html
new file mode 100644
index 0000000..3492d62
--- /dev/null
+++ b/web/static/dev.html
@@ -0,0 +1,788 @@
+
+
+
+
+zcbot dev
+
+
+
+
+
+
+
+
+

zcbot dev login

+ + + + +
+
+ +
+
+ 本地默认 user_id 是 sentinel(全 0)。platform_key 见服务端 env PLATFORM_KEY。 +
+
+
+ + +
+
+
zcbot
+
+
+ + +
+ + +
+
+ tasks + + + +
+
loading…
+
+ + +
+
+ chat + + + + +
+
(no task selected)
+
select a task on the left
+ + +
+ ready + + +
+ +
+ + +
+
+ files + + +
+
(no task selected)
+
+
+
+ + +
+
+

新建 task

+ + + + + + +
+
+ + +
+
+
+ + + + diff --git a/web/static/style.css b/web/static/style.css deleted file mode 100644 index ab05ca8..0000000 --- a/web/static/style.css +++ /dev/null @@ -1,193 +0,0 @@ -/* zcbot web — minimal sane defaults. Phase G 渐进扩。 */ -:root { - --bg: #fafafa; - --surface: #ffffff; - --fg: #1a1a1a; - --muted: #888; - --border: #e5e5e5; - --accent: #c00; /* 商务红,延续 ppt skill 配色 */ - --accent-soft: #fceaea; - --link: #0a58ca; - --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace; -} - -* { box-sizing: border-box; } -html, body { margin: 0; padding: 0; } -body { - font: 14px/1.5 -apple-system, "Segoe UI", Roboto, "Helvetica Neue", system-ui, "PingFang SC", "Microsoft YaHei", sans-serif; - background: var(--bg); - color: var(--fg); -} -a { color: var(--link); text-decoration: none; } -a:hover { text-decoration: underline; } -code { font-family: var(--mono); background: var(--accent-soft); padding: 0 .25em; border-radius: 3px; color: var(--accent); } -small.muted, .muted { color: var(--muted); font-weight: normal; } - -.topbar { - display: flex; - align-items: center; - gap: 1.25rem; - padding: .65rem 1.25rem; - background: var(--surface); - border-bottom: 1px solid var(--border); -} -.brand { - font-weight: 700; - color: var(--accent); - font-size: 1.1rem; -} -.brand:hover { text-decoration: none; } -.navlinks { display: flex; gap: 1rem; flex: 1; } -.user-tag { - font-size: .75rem; - color: var(--muted); - border: 1px solid var(--border); - padding: .1em .5em; - border-radius: 3px; -} - -.container { - max-width: 960px; - margin: 0 auto; - padding: 1.5rem 1.25rem; -} - -h1 { font-size: 1.5rem; margin: 0 0 .5rem; display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } -h2 { font-size: 1.1rem; margin: 1.5rem 0 .5rem; } -.lead { font-size: 1rem; color: #444; } -.mono { font-family: var(--mono); } -.small { font-size: .8rem; } - -/* page head + filters */ -.page-head { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: .5rem; } -.filters { display: flex; gap: .5rem; align-items: center; font-size: .9rem; color: var(--muted); } -.filters select { padding: .25em .5em; font-size: .9rem; } -.btn { padding: .25em .75em; border: 1px solid var(--border); border-radius: 3px; color: var(--fg); background: var(--surface); display: inline-block; line-height: 1.4; } -.btn:hover { text-decoration: none; background: #f4f4f4; } -.btn-primary { background: var(--accent); border-color: var(--accent); color: white; font-weight: 600; } -.btn-primary:hover { background: var(--accent); filter: brightness(1.05); color: white; } -.head-actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; } - -/* navlinks active state */ -.navlinks a { color: var(--muted); padding: .15em .4em; border-radius: 3px; } -.navlinks a.active, .navlinks a:hover { color: var(--fg); background: #f4f4f4; text-decoration: none; } - -/* new task form */ -.new-task-form { display: flex; flex-direction: column; gap: 1rem; max-width: 640px; margin-top: 1rem; } -.new-task-form label { display: flex; flex-direction: column; gap: .3rem; } -.new-task-form .field-label { font-weight: 600; font-size: .9rem; color: #444; } -.new-task-form input[type="text"] { - padding: .55rem .7rem; font: inherit; border: 1px solid var(--border); border-radius: 4px; - background: var(--surface); color: var(--fg); font-size: .95rem; -} -.new-task-form input[type="text"]:focus { outline: 2px solid var(--accent-soft); outline-offset: 1px; border-color: var(--accent); } -.new-task-form .muted.small { font-weight: normal; } -.form-actions { display: flex; gap: .5rem; justify-content: flex-end; align-items: center; margin-top: .5rem; } -.form-actions button { - padding: .55rem 1.3rem; border: 1px solid var(--accent); border-radius: 4px; - background: var(--accent); color: white; cursor: pointer; font: inherit; font-weight: 600; -} -.form-actions button:hover { filter: brightness(1.05); } - -/* task list table */ -.empty { padding: 2rem 0; } -table.task-list { width: 100%; border-collapse: collapse; margin-top: .5rem; } -table.task-list th, table.task-list td { text-align: left; padding: .5rem .65rem; border-bottom: 1px solid var(--border); vertical-align: top; } -table.task-list th { background: #f4f4f4; font-weight: 600; color: #555; font-size: .85rem; white-space: nowrap; } -table.task-list td { font-size: .9rem; } -table.task-list .num { text-align: right; font-variant-numeric: tabular-nums; } -table.task-list tr:hover { background: #fdf6f6; } -.task-id { font-family: var(--mono); font-weight: 600; } -.dir .desc { margin-bottom: .15em; } -.dir .small { word-break: break-all; } - -/* status badge */ -.status { font-size: .75rem; padding: .1em .55em; border-radius: 3px; font-weight: 500; } -.status-active { background: #dff7e0; color: #196b3a; } -.status-completed { background: #e0eaf7; color: #1a3d6b; } -.status-abandoned { background: #f0f0f0; color: #777; } - -/* task meta line + badges */ -.task-meta { font-size: .85rem; } -.badge { font-size: .75rem; padding: .1em .55em; background: #eee; color: #555; border-radius: 3px; } -.mt-1 { margin-top: 1rem; } - -/* chat 视图 */ -.chat { margin-top: 1rem; display: flex; flex-direction: column; gap: 1rem; } -.msg { padding: .75rem 1rem; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); } -.msg-user { background: #f4f7fb; border-color: #d8e3f0; } -.msg-assistant { background: var(--surface); } -.msg .role { font-size: .7rem; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; margin-bottom: .35rem; } -.msg .body { font-size: .95rem; line-height: 1.55; } -.msg .body p { margin: .5em 0; } -.msg .body p:first-child { margin-top: 0; } -.msg .body p:last-child { margin-bottom: 0; } -.msg .body pre, .msg .body .codehilite { background: #f7f7f7; padding: .75rem; border-radius: 4px; overflow-x: auto; font-family: var(--mono); font-size: .85rem; margin: .5em 0; } -.msg .body code { font-family: var(--mono); font-size: .9em; background: #f4f4f4; padding: 0 .25em; border-radius: 3px; color: #333; } -.msg .body pre code, .msg .body .codehilite code { background: transparent; padding: 0; color: inherit; font-size: 1em; } -.msg .body table { border-collapse: collapse; margin: .5em 0; font-size: .9rem; } -.msg .body table th, .msg .body table td { border: 1px solid var(--border); padding: .3em .6em; text-align: left; } -.msg .body table th { background: #f4f4f4; } -.msg .body a { color: var(--link); } -.msg .body blockquote { border-left: 3px solid var(--border); padding-left: 1em; margin: .5em 0; color: #555; } - -/* tool_call
折叠 */ -.tool { margin-top: .75rem; border: 1px solid var(--border); border-radius: 4px; background: #fafafa; } -.tool summary { padding: .5rem .75rem; cursor: pointer; user-select: none; list-style: none; display: flex; gap: .5rem; align-items: center; font-size: .85rem; } -.tool summary::-webkit-details-marker { display: none; } -.tool summary::before { content: ">"; color: var(--muted); transition: transform .15s; display: inline-block; font-family: var(--mono); font-weight: bold; } -.tool[open] summary::before { transform: rotate(90deg); } -.tool-badge { background: var(--accent-soft); color: var(--accent); padding: .1em .4em; border-radius: 3px; font-size: .7rem; font-weight: 600; letter-spacing: .03em; } -.tool-name { font-family: var(--mono); font-weight: 600; color: #333; } -.tool-args-preview { color: var(--muted); font-family: var(--mono); font-size: .8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } -.tool-body { padding: 0 .75rem .75rem; border-top: 1px solid var(--border); background: #fff; } -.tool-section { margin-top: .5rem; } -.tool-label { font-size: .7rem; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; margin-bottom: .15rem; } -.tool-pre { background: #fafafa; padding: .5rem .65rem; border: 1px solid var(--border); border-radius: 3px; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: .8rem; line-height: 1.4; margin: 0; color: #333; } - -/* 流式状态指示 + send form (G4) */ -.streaming .run-indicator { - display: inline-block; width: 6px; height: 6px; border-radius: 50%; - background: var(--accent); margin-left: .35rem; vertical-align: middle; - animation: pulse 1.2s ease-in-out infinite; -} -@keyframes pulse { - 0%, 100% { opacity: .35; transform: scale(.9); } - 50% { opacity: 1; transform: scale(1.15); } -} -.send-form { display: flex; gap: .5rem; margin-top: 1rem; align-items: flex-end; } -.send-form textarea { - flex: 1; font: inherit; padding: .5rem .65rem; border: 1px solid var(--border); - border-radius: 4px; resize: vertical; min-height: 2.4rem; max-height: 14rem; - background: var(--surface); color: var(--fg); font-size: .95rem; line-height: 1.4; -} -.send-form textarea:focus { outline: 2px solid var(--accent-soft); outline-offset: 1px; border-color: var(--accent); } -.send-form button { - padding: .55rem 1.1rem; border: 1px solid var(--accent); border-radius: 4px; - background: var(--accent); color: white; cursor: pointer; font: inherit; font-weight: 600; -} -.send-form button:hover { filter: brightness(1.05); } -.send-form button:disabled { background: var(--muted); border-color: var(--muted); cursor: wait; } - -/* tool_result append-only 片段(G4 流式来:跟在上一个 tool_call 后) */ -.tool-result-inline { margin: .5rem 0 .25rem 1rem; padding-left: .65rem; border-left: 2px solid var(--border); } -.tool-result-tag { font-family: var(--mono); font-size: .75rem; color: var(--muted); font-weight: 600; } -.tool-pending { color: var(--muted); font-style: italic; } - -/* error 片段 */ -.msg-error { - display: flex; gap: .5rem; align-items: baseline; - margin-top: .5rem; padding: .5rem .75rem; - background: #fceaea; border-left: 3px solid var(--accent); border-radius: 3px; - font-size: .85rem; color: #5c0a0a; -} -.err-tag { font-weight: 600; text-transform: uppercase; font-size: .7rem; letter-spacing: .05em; } - -/* pygments codehilite (轻量配色,选少数高频 token,余下走默认黑色) */ -.codehilite .k, .codehilite .kn, .codehilite .kr { color: #c00; } /* keyword */ -.codehilite .s, .codehilite .s1, .codehilite .s2, .codehilite .sb, .codehilite .sd { color: #1a3d6b; } /* string */ -.codehilite .c, .codehilite .c1, .codehilite .cm { color: #888; font-style: italic; } /* comment */ -.codehilite .nb { color: #5e3092; } /* builtin */ -.codehilite .nf, .codehilite .nc { color: #1a3d6b; font-weight: 600; } /* function/class name */ -.codehilite .mi, .codehilite .mf { color: #008080; } /* number */ -.codehilite .o, .codehilite .ow { color: #555; } /* operator */ diff --git a/web/templates/_frag_error.html b/web/templates/_frag_error.html deleted file mode 100644 index 4f34e30..0000000 --- a/web/templates/_frag_error.html +++ /dev/null @@ -1,4 +0,0 @@ -
- error - {{ msg }} -
diff --git a/web/templates/_frag_text.html b/web/templates/_frag_text.html deleted file mode 100644 index e8aa55e..0000000 --- a/web/templates/_frag_text.html +++ /dev/null @@ -1 +0,0 @@ -
{{ html | safe }}
diff --git a/web/templates/_frag_tool_call.html b/web/templates/_frag_tool_call.html deleted file mode 100644 index 224d7b5..0000000 --- a/web/templates/_frag_tool_call.html +++ /dev/null @@ -1,13 +0,0 @@ -
- - tool - {{ name }} - {{ args_preview }} - -
-
-
args
-
{{ args_pretty }}
-
-
-
diff --git a/web/templates/_frag_tool_result.html b/web/templates/_frag_tool_result.html deleted file mode 100644 index 9a71ab1..0000000 --- a/web/templates/_frag_tool_result.html +++ /dev/null @@ -1,4 +0,0 @@ -
- ↳ {{ name }} -
{{ preview }}{% if truncated %} (truncated){% endif %}
-
diff --git a/web/templates/_send_response.html b/web/templates/_send_response.html deleted file mode 100644 index f7de5f2..0000000 --- a/web/templates/_send_response.html +++ /dev/null @@ -1,22 +0,0 @@ -{# POST /tasks/{id}/messages 响应 — append 进 #chat-stream beforeend。 - 含 user msg 卡 + assistant 容器(SSE 监听器在它身上)。 - htmx-ext-sse:sse-connect 开 EventSource;sse-swap 列的 event 把 data - 作为 HTML swap 到自己(hx-swap=beforeend 决定追加而非替换)。 -#} -
-
user
-
{{ user_html | safe }}
-
- -
-
- assistant - -
- {# SSE event=text/tool_call/tool_result/error 的 data → swap 到这个 article 内尾部 #} -
diff --git a/web/templates/base.html b/web/templates/base.html deleted file mode 100644 index 633e0f2..0000000 --- a/web/templates/base.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - {% block title %}zcbot{% endblock %} - - - - - -
- zcbot - - local -
-
- {% block content %}{% endblock %} -
- - diff --git a/web/templates/chat.html b/web/templates/chat.html deleted file mode 100644 index ea50b48..0000000 --- a/web/templates/chat.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "base.html" %} -{% block title %}zcbot · {{ task_id_short }}{% endblock %} -{% block nav %}tasks{% endblock %} -{% block content %} -
-

task {{ task_id_short }} - {{ status }} - {% if mode %}{{ mode }}{% endif %} -

-
- {{ n_messages }} msgs · {{ tokens }} tokens · {{ model_label }} -
-
- -{% if description %}

{{ description }}

{% endif %} -{% if task_dir %}

{{ task_dir }}

{% endif %} - -
- {% for b in blocks %} - {% if b.type == "user" %} -
-
user
-
{{ b.html | safe }}
-
- {% elif b.type == "assistant" %} -
-
assistant
- {% if b.html %}
{{ b.html | safe }}
{% endif %} - {% for tc in b.tool_calls %} -
- - tool - {{ tc.name }} - {{ tc.args_preview }} - -
-
-
args
-
{{ tc.args_pretty }}
-
-
-
result
-
{{ tc.result }}
-
-
-
- {% endfor %} -
- {% endif %} - {% endfor %} -
- -{% if status == "active" %} -
- - - -{% else %} -

task 已 {{ status }},不接收新消息。CLI /done 改 status 来恢复。

-{% endif %} - -

G4 流式 ✓ · 文件浏览 = G5 · 打磨 = G6

-{% endblock %} diff --git a/web/templates/home.html b/web/templates/home.html deleted file mode 100644 index 9efa4cb..0000000 --- a/web/templates/home.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends "base.html" %} -{% block title %}zcbot · tasks{% endblock %} -{% block nav %} -tasks -new -{% endblock %} -{% block content %} -
-

tasks 最近 {{ tasks|length }} 条{% if status %} · status={{ status }}{% endif %}

-
-
- - {% if status %}reset{% endif %} - - + new task -
-
- -{% if not tasks %} -

- 没有 task{% if status %}(status={{ status }}){% endif %}。 - CLI 起一个:cli.py chat --desc "...";Web 起 task 留到 G6。 -

-{% else %} -
- - - - - - - - - - - - - - {% for t in tasks %} - - - - - - - - - - - {% endfor %} - -
idupdatedstatusmodemodelmsgstokensdesc / dir
{{ t.task_id_short }}{{ t.updated_at.strftime("%m-%d %H:%M") }}{{ t.status }}{{ t.mode }}{{ t.model_label }}{{ t.n_messages }}{{ t.tokens }} - {% if t.description %}
{{ t.description }}
{% endif %} - {% if t.task_dir %}
{{ t.task_dir }}
{% endif %} -
-{% endif %} -{% endblock %} diff --git a/web/templates/new_task.html b/web/templates/new_task.html deleted file mode 100644 index f8167e3..0000000 --- a/web/templates/new_task.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends "base.html" %} -{% block title %}zcbot · new task{% endblock %} -{% block nav %}tasks new{% endblock %} -{% block content %} -
-

新建 task

-
- -{% if error %} -
- error - {{ error }} -
-{% endif %} - -
- - - -
- 取消 - -
-
- -

- Tip:创建后会跳到 chat 页,在底部输入框发第一条消息开始对话。 -

-{% endblock %}