core(§7 D + D'): /v1 JSON API + PLATFORM_KEY→JWT auth + dev SPA

整合今日累积的 §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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-15 16:14:25 +08:00
parent 1035b12847
commit 02a69058df
24 changed files with 1790 additions and 927 deletions

View File

@ -6,6 +6,16 @@
- 跑脚本 / 测试一律用 `.venv/Scripts/python.exe ...`,**不要用全局 `python`**(没装 litellm/python-pptx 等会报 ModuleNotFoundError) - 跑脚本 / 测试一律用 `.venv/Scripts/python.exe ...`,**不要用全局 `python`**(没装 litellm/python-pptx 等会报 ModuleNotFoundError)
- requirements 见 `requirements.txt` - requirements 见 `requirements.txt`
## 开发阶段心智
当前处于开发阶段(尚未发布给真实用户)。改需求 / 重构时,**以最优实现为准,不为旧数据 / 旧字段 / 旧 API 留兼容层**:
- DB schema 变 → 直接改 model + 写一条干净的 migration(必要时清空旧 row,不写双向兼容代码)
- 字段语义变 → 全量替换,不留 `legacy_xxx` / `*_v2` 并存
- CLI / REPL 选项变 → 直接改,不留 deprecated 别名
- 只有当用户明确说"这条要保留兼容"时才写兼容代码
理由:兼容层就是技术债,开发期写了之后忘记删反而拖累;真上线后再视情况补迁移路径。
## 文档维护 ## 文档维护
每完成一步实现(commit 前),**必须更新 `PROGRESS.md`**: 每完成一步实现(commit 前),**必须更新 `PROGRESS.md`**:

View File

@ -191,7 +191,7 @@ SaaS 化不是"重写"也不是"取代 CLI",而是**给同一份 core 加一个
| task_dir 默认值 | `workspace/tasks/<task_id>/`(留空时派生) | `<storage_root>/users/<user_id>/tasks/<task_id>/`(留空时派生);用户指定时走 `<storage_root>/users/<user_id>/<user-path>/` | | task_dir 默认值 | `workspace/tasks/<task_id>/`(留空时派生) | `<storage_root>/users/<user_id>/tasks/<task_id>/`(留空时派生);用户指定时走 `<storage_root>/users/<user_id>/<user-path>/` |
| Memory | `workspace/memory/`(FS) | `<storage_root>/users/<user_id>/memory/`(仍是 FS) | | Memory | `workspace/memory/`(FS) | `<storage_root>/users/<user_id>/memory/`(仍是 FS) |
| Sandbox | subprocess + env 过滤 | per-task docker exec | | 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 层。 **CLI 长期双模式**:本地直跑(默认,in-process,直连 PG,适合调内部状态)/ `--remote https://...`(HTTP 走 `/v1`,等价真实用户路径)。两模式共用 `cli.py`,差别只在 transport 层。
@ -223,29 +223,60 @@ state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享
### 7.2 资源模型(/v1) ### 7.2 资源模型(/v1)
Task 是主视图,排在前面;folder 是文件副视图 Task 一等公民,files 是其副视图(经 `task_dir` 暴露,无独立 folder 实体)。所有路由统一 `/v1` 前缀,**返 JSON**;前端 / UI 由 platform 端实现,本仓库不维护(§7.9 取舍)。本地开发用 FastAPI 自带 `/docs` Swagger UI 自查;`GET /` 302 跳 `/docs`
``` ```
# Tasks(主) Tasks
CRUD /v1/tasks[/{id}] {task_dir?, mode, desc, model};task_dir 留空默认派生 POST /v1/tasks 创建 {description?, mode?, task_dir?};
POST/GET /v1/tasks/{id}/messages 发消息 / 历史(?search= 走 jsonb GIN / tsvector) task_dir 留空 → 默认派生 workspace/tasks/<uuid>/
GET/POST /v1/tasks/{id}/runs/{run_id}/{events,cancel} SSE 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(文件副视图) Files(per-task,task_dir 副视图)
POST/GET/PATCH/DELETE /v1/folders[/{path}] 列树 / 创建 / 改名(cascade)/ hard cascade GET /v1/tasks/{id}/files?path= 列子目录 {entries, crumbs, exists, root}
GET/POST/DELETE /v1/folders/{path}/files[/{name}] 列 / 上传 / 下载 / 删 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
# 元数据 Export
GET /v1/{skills,models,usage} GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp
POST /v1/probe (admin)
Misc
GET /healthz {"status":"ok"}
GET / 302 → /docs (Swagger UI 自查,本地形态便利)
``` ```
**SSE 事件**:`tool_call` / `tool_result` / `text` (delta) / `usage` / `done`,带 `run_id` **SSE 事件**(`Content-Type: text/event-stream`,响应头带 `X-Accel-Buffering: no` 给 nginx 反代友好;每事件 `event: <type>` + `data: <JSON>`):
**版本化**:`/v1` minor 半年向后兼容,major 6 个月 deprecation。
```
run_start {}
llm_start {}
text {"content":"<delta 或全量,取决于 model streaming 配置>"}
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":"<type>: <detail>"}
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 <jwt>` 走所有 `/v1/tasks*`;`/healthz`、`/docs`、`/openapi.json`、`/`、`/v1/auth/login`、`/static/*` 豁免。
### 7.3 认证 ### 7.3 认证
OIDC / Clerk / 自建邮箱登录,JWT 只带 `user_id` claim:`Authorization: Bearer <user_jwt>` + `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 <jwt>` 走所有 /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 + 本地文件系统 ### 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, status, model_profile, tokens_prompt, tokens_completion, cost_usd,
created_at, updated_at); created_at, updated_at);
create index on tasks (user_id, task_dir); create index on tasks (user_id, task_dir);
-- task_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串(`workspace/tasks/<uuid>`);
-- ROOT 外 → 绝对 str(用户自指定项目目录);空串 → 未绑项目。SaaS 阶段同理(基础是
-- <storage_root>/users/<uid>/)。读写边界统一过 core/paths.py::{to_db_path,from_db_path}。
messages(message_id uuid pk, task_id fk, idx int not null, messages(message_id uuid pk, task_id fk, idx int not null,
payload jsonb not null, tokens_in, tokens_out, created_at, 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 硬删后审计仍存活 -- 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。 **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 天 | | 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 天 | | 7 | **HTTP /v1**:FastAPI + SSE + OIDC | 4 天 |
| 8 | **CLI 双模式**:transport 层抽象,默认 in-process;`--remote` 走 HTTP;**本地直跑不删** | 1.5 天 | | 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 分阶段落地 ### 7.7 分阶段落地
| 阶段 | 范围 | 工作量 | 验收 | | 阶段 | 范围 | 工作量 | 验收 |
|---|---|---|---| |---|---|---|---|
| A | #1 事件流化 | done | ✅ | | A | #1 事件流化 | done ✅ | sink 协议铺 SSE 路 |
| B | #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + Folder API + no-subtask) | ~1 周 | 本地走 PG,messages 进 DB 全文搜可用;多 task + folder rename 单测;`migrate-from-fs` 跑通 | | B | #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + no-subtask)| done ✅ | 本地走 PG,messages 进 DB,任务/消息/状态全在 PG;task_dir 改相对存储(§7.4 注释)|
| C | #6(Executor + sandbox) | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 | | D | #7 HTTP /v1 surface(无 auth)| done ✅ | `/v1/tasks/*` + SSE JSON + files 4 路由 + export + Swagger;本地形态 sentinel user 跑通 |
| D | #7(HTTP /v1 + auth) | 4 天 | curl / Postman 跑通主流程 | | 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 |
| E | #8(CLI transport 双模式) | 1.5 天 | 默认本地直跑保留,`--remote` 走 HTTP 跑通 | | D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天 | 真发布给真实用户前补;路由层 Depends 不动,只换 login 内部 |
| G | #9(Web UI 简洁版) | 2-3 天 | 浏览器跑通:列 task → 进 chat → 流式回复 → 文件上传下载;与 D / E 无强序,但需 D 的 SSE 端点 | | 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% | | F | 上线打磨(限流 / 监控 / 告警 / HA)| 持续 | SLO 99.5% |
**B 阶段一次性切换** —— 切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨。**dogfood 即生效**(messages 进 DB → 全文搜、jsonb 查询立刻可用)。 **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 已知风险 ### 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 也能连远端测试服。 **本地也用 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 兜底,不靠"全栈零依赖"幻觉。 **CLI 不被 API 取代,而是双模式共存**:本地直跑调 core 内部状态比 HTTP roundtrip 顺手;前端用户路径靠 `--remote` 打通。离线靠本地 docker compose PG 兜底,不靠"全栈零依赖"幻觉。
**Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。 **Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `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 覆盖健康检查 | | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | | 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-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 区(`<pre>` / `<code>` / `<table>` / `<blockquote>` / `<s>` 全 GFM 样式),tool_call 用 `<details>` 默认折叠(无 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 含 `<details>`/`tool-badge`/`codehilite`/`<s>` + 空 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 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 区(`<pre>` / `<code>` / `<table>` / `<blockquote>` / `<s>` 全 GFM 样式),tool_call 用 `<details>` 默认折叠(无 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 含 `<details>`/`tool-badge`/`codehilite`/`<s>` + 空 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/<uuid>/`(同 `_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 / §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/<uuid>/`(同 `_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\<uuid>`),改为 **ROOT 内→相对 posix(`workspace/tasks/<uuid>`)、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: <type>` + `data: <JSON dict>`,前端自渲染;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=<rel>`(列目录树,面包屑 + 目录在前文件在后 + size humanize + mtime 格式化)/ `GET /tasks/{id}/files/download?path=<rel>`(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` + `<table class="file-list">` 行渲染目录用蓝色 + `/` 后缀,文件用 `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 渲两个 `<form method="post">` 按钮(原生 `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` 时渲 `<a class="btn">export .docx</a>`(浏览器原生下载,无 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` 末尾加 `<div id="toast-region">` + 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 <jwt>`)。**实现**:`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 含 `<strong>` markdown、tool_call 含 `<details>`、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 持久化(刷新继续看流式)留到未来。 - **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 含 `<strong>` markdown、tool_call 含 `<details>`、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/capabilities.py 71
core/llm.py 89 core/llm.py 93 ← +litellm 离线 cost map env
core/loop.py 152 ← §7 A: sink.emit core/loop.py 152 ← §7 A: sink.emit
core/sinks.py 101 ← §7 A core/sinks.py 101 ← §7 A
core/ui.py 38 core/ui.py 38
core/paths.py 50 ← task_dir db form 归一(to_db_path / from_db_path)
core/probe.py 243 core/probe.py 243
core/session.py 153 ← §7 B Step 2-3: ORM + ensure 补 meta core/session.py 153 ← §7 B Step 2-3: ORM + ensure 补 meta
core/skills.py 81 core/skills.py 81
core/task.py 82 ← §7 B Step 3: PG-backed TaskState,去 cwd core/task.py 82 ← §7 B Step 3: PG-backed TaskState,去 cwd
core/memory.py 76 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/__init__.py 27 ← §7 B Step 1-3
core/storage/engine.py 80 ← §7 B Step 1 core/storage/engine.py 80 ← §7 B Step 1
core/storage/models.py 124 ← §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/base.py 34
tools/fs.py 182 tools/fs.py 182
tools/shell.py 94 tools/shell.py 94
tools/run_python.py 84 tools/run_python.py 84
tools/skill_tool.py 45 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 子命令 cli.py 558 ← §7 B Step 4 / Phase G G1: --task-dir / web 子命令
db/migrations/env.py 61 ← §7 B Step 1 db/migrations/env.py 61 ← §7 B Step 1
db/migrations/versions/ db/migrations/versions/
0001_initial_schema.py 125 ← §7 B Step 1 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/__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/broker.py 88 ← Phase G G4: in-process pub/sub
web/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议) 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 行 Python 合计 ~3700 行(+ dev.html ~600 静态)
+ web/templates/* ~249 行(base/home/chat/new_task + 5 个 _frag/_send_response)+ web/static/style.css 193 行(不计 Python 主仓库)
``` ```
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 加 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 → 发"你好" → 看真实流式 → 刷新看历史。 1. **platform 端起 API 联调**(~?)—— platform 服务端持 `PLATFORM_KEY``POST /v1/auth/login {user_id, platform_key}` 拿 token,后续走 `Authorization: Bearer <jwt>`。Swagger UI(`http://127.0.0.1:8765/docs`)生成 client stub。
2. **§7 Phase G G5 文件浏览 + 上传下载**(~半天)—— `/tasks/{id}/files` 渲染 task_dir 树,upload (multipart)/ download / 删。 2. **dev.html 浏览器手验**(~10 分钟)—— `cli.py web` 起后访问 `http://127.0.0.1:8765/`,login(填 sentinel UUID + PLATFORM_KEY)→ 看 3 栏布局 + 新建 task + 发消息 SSE 流式 + 文件浏览。
3. **§7 Phase G G6 剩余打磨**(~半天)—— `/done /abandon` 按钮、`/export` docx 下载、错误 toast、并发 run 互锁(messages idx 冲突)。/new 已提前完成。 3. **真 OIDC 接入 + CORS 收紧**(~1 天)—— 把 `/v1/auth/login` 内部从 platform_key 校验换成 OIDC ID token 校验(路由层 Depends 不动);CORS 改成 platform 域名 allowlist。
4. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。 4. **§7 C Executor + sandbox**(~2-3 天)—— D 完工,继续 C。
5. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 5. **并发 run 互锁**(~2 小时)—— 用户连发两条消息 messages idx UniqueConstraint 在 race 下会冲;PG `SELECT ... FOR UPDATE` 锁 tasks 行,或 advisory lock。
6. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc` 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 并存不冲突

74
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 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-... DEEPSEEK_API_KEY=sk-...
ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot 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=...` > 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` 里)。 - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里)。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。
- **Auth env**(`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 上车 # 3) DB schema 上车
.venv/Scripts/python.exe cli.py db upgrade head .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>] /id /status /done /abandon
.venv/Scripts/python.exe cli.py db current .venv/Scripts/python.exe cli.py db current
``` ```
### Web UI(§7 Phase G,本地 sentinel user 无 auth) ### Web API(§7 D + D' 过渡 auth)
```bash ```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 .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 .venv/Scripts/python.exe cli.py web --port 9000
# dev:文件改动自动重启(uvicorn 工厂模式 reload) # dev:文件改动自动重启(uvicorn 工厂模式 reload)
.venv/Scripts/python.exe cli.py web --reload .venv/Scripts/python.exe cli.py web --reload
``` ```
> G1 ✅ 脚手架 + /healthz;G2 ✅ `/` task 列表 + `?status=` filter;G3 ✅ `/tasks/{uuid}` 消息流渲染(markdown-it-py + pygments,tool_call 走 `<details>`);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 <jwt>`;先走 `/v1/auth/login` 拿 token:
```bash
# 登录 → 拿 token(本地默 user_id = sentinel 全 0)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"user_id":"00000000-0000-0000-0000-000000000000","platform_key":"<value of $PLATFORM_KEY>"}'
# → {"token":"eyJ...","expires_at":"...","user_id":"...","ttl_seconds":604800}
# 用 token 调 /v1/*
TOKEN="eyJ..."
curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/tasks
```
**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),login 表单填 user_id(默 sentinel)+ PLATFORM_KEY 进入 3 栏(task 列表 / chat / files)。仅给开发自验,不发布给真用户。
**路由表**(全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `http://127.0.0.1:8765/docs`):
| 方法 + 路径 | 用途 | Auth |
|---|---|---|
| `GET /healthz` | `{"status":"ok"}` 健康检查 | 豁免 |
| `GET /` | 302 → `/static/dev.html` dev SPA | 豁免 |
| `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 |
| `GET /static/*` | dev.html 等静态文件 | 豁免 |
| `POST /v1/auth/login` | body `{user_id, platform_key}``{token,expires_at,user_id,ttl_seconds}` | 豁免 |
| `POST /v1/tasks` | 创建 task,body `{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: <type>` + `data: <json>`) | 必填 |
| `GET /v1/tasks/{id}/files?path=` | 列子目录条目 + 面包屑 | 必填 |
| `GET /v1/tasks/{id}/files/download?path=` | 下单文件 | 必填 |
| `POST /v1/tasks/{id}/files/upload` | multipart 上传,`path` 走 form | 必填 |
| `POST /v1/tasks/{id}/files/delete` | body `{path}`;文件或空目录 | 必填 |
| `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 |
**SSE 事件 schema**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}``text{content}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}``llm_end{prompt_tokens,completion_tokens}``done{}`;异常路径走 `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>] /id /status /done /abandon
| `--task-dir` 指定后 `/exit` 没清 task_dir | 设计如此 —— 用户路径绝不 rmtree;DB 行该删还是删。要彻底删手动 `rm -rf <dir>` | | `--task-dir` 指定后 `/exit` 没清 task_dir | 设计如此 —— 用户路径绝不 rmtree;DB 行该删还是删。要彻底删手动 `rm -rf <dir>` |
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export | | Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export |
| `NoSubtaskError: task_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 task_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--task-dir`;否则改路径成 sibling(平级) | | `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`) | | `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"}` |
| `TypeError: unhashable type: 'dict'` from Jinja templating | Starlette 新版签名:`TemplateResponse(request, name, context)`,旧式 `(name, {"request":..., "...":...})` 在 newer Starlette 会把 context dict 当 cache key 炸 |
| SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`,nginx ≥ 1.5.6 默认认。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` | | 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) | | platform 端 CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)|
| `UniqueViolation idx already exists` from messages | 同 task 连续两次快速 POST,messages idx 冲突。**已知 TODO**:G6/D 阶段加 task 级 lock_for_update 或 advisory lock | | `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>] /id /status /done /abandon
- **核心**:`core/loop.py`(ReAct)/ `core/session.py`(PG messages)/ `core/task.py`(PG tasks)/ `core/llm.py`(LiteLLM 封装) - **核心**:`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` - **工具**:`tools/{fs,shell,run_python,skill_tool}.py`
- **存储**:`core/storage/{engine,models,utils}.py`(SQLAlchemy 2.x ORM)+ `db/migrations/`(alembic) - **存储**:`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) - **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile)
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**:`workspace/memory/{core.md, extended/}`(跨 task 记忆,FS 永久)/ `workspace/tasks/<uuid>/`(默认派生 task_dir,只放 skill 产物) - **Workspace**:`workspace/memory/{core.md, extended/}`(跨 task 记忆,FS 永久)/ `workspace/tasks/<uuid>/`(默认派生 task_dir,只放 skill 产物)

View File

@ -168,7 +168,7 @@ def _format_args(args_str: str) -> str:
# ───────────────────────── Meta 区块 ───────────────────────── # ───────────────────────── Meta 区块 ─────────────────────────
def _add_meta_block( 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: ) -> None:
p = doc.add_paragraph() p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT p.alignment = WD_ALIGN_PARAGRAPH.LEFT
@ -202,7 +202,7 @@ def _add_meta_block(
("更新时间", updated), ("更新时间", updated),
("消息数", str(n_msgs)), ("消息数", str(n_msgs)),
("Tokens", f"{tp} prompt / {tc} completion / {tp + tc} total"), ("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")), ("导出时间", datetime.now().isoformat(timespec="seconds")),
] ]
@ -346,11 +346,15 @@ def export_chat_to_docx(
if task_dir is None: if task_dir is None:
td_str = task_state.get("task_dir", "") td_str = task_state.get("task_dir", "")
if not td_str: if td_str:
raise ValueError(f"task {task_id} 无 task_dir(PG 未存且未传参) —— 无处放 .docx") # td_str 是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原 absolute Path
task_dir = Path(td_str) 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 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" out_path = task_dir / f"chat_{task_id}.docx"
meta = { meta = {

View File

@ -5,7 +5,11 @@ import os
import time import time
from typing import Any, List, Optional 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 ( from litellm.exceptions import (
APIConnectionError, APIConnectionError,
APIError, APIError,

50
core/paths.py Normal file
View File

@ -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()

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import func, select, text, update from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from .engine import session_scope from .engine import session_scope
@ -109,30 +109,27 @@ def check_no_subtask(
拒绝:new existing 的子目录existing new 的子目录 拒绝:new existing 的子目录existing new 的子目录
task_dir / whitespace 跳过(legacy / 未绑项目) 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(): if not task_dir or not task_dir.strip():
return return
td_norm = task_dir.replace("\\", "/") from core.paths import from_db_path
# 用 bind 参数传 backslash,绕开 SQL 字符串转义陷阱
sql = text( new_abs = from_db_path(task_dir).as_posix()
"SELECT task_id, task_dir FROM tasks " if not new_abs:
"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:
return return
existing_id, existing_dir = row 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( raise NoSubtaskError(
f"task_dir {task_dir!r} 与已有 task {str(existing_id)[:8]}" f"task_dir {task_dir!r} 与已有 task {str(existing_id)[:8]}"
f"task_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 task_dir" f"task_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 task_dir"

View File

@ -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;`<root>/<rel>` 长度 = 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)或盘符
# `<letter>:` 起头(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 + "/")
)

22
main.py
View File

@ -19,6 +19,7 @@ from core.capabilities import ModelCapabilities
from core.llm import LLM from core.llm import LLM
from core.loop import AgentLoop from core.loop import AgentLoop
from core.memory import memory_block from core.memory import memory_block
from core.paths import ROOT, from_db_path, to_db_path
from core.session import Session from core.session import Session
from core.sinks import ConsoleEventSink from core.sinks import ConsoleEventSink
from core.skills import SkillRegistry from core.skills import SkillRegistry
@ -29,8 +30,6 @@ from tools.run_python import RunPythonTool
from tools.shell import ShellTool from tools.shell import ShellTool
from tools.skill_tool import LoadSkillTool from tools.skill_tool import LoadSkillTool
ROOT = Path(__file__).resolve().parent
def load_config() -> dict: def load_config() -> dict:
return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {} 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) select(Task.task_dir).where(Task.task_id == tid)
).scalar_one_or_none() or "" ).scalar_one_or_none() or ""
chosen = task_dir_arg.strip() if task_dir_arg else db_dir if task_dir_arg and task_dir_arg.strip():
fs_dir = Path(chosen).expanduser().resolve() if chosen else _default_task_dir(workspace_dir, tid) # 用户显式覆盖(允许 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 return tid, fs_dir
tid = uuid4() tid = uuid4()
@ -207,11 +212,14 @@ def build_agent(
system_prompt = _build_system_prompt(cfg, skills, workspace_dir, tool_base, task_dir) system_prompt = _build_system_prompt(cfg, skills, workspace_dir, tool_base, task_dir)
now_iso = datetime.now().isoformat(timespec="seconds") 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 = { meta = {
"id": sid, "id": sid,
"created_at": now_iso, "created_at": now_iso,
"cwd": str(tool_base), "cwd": str(tool_base),
"task_dir": str(task_dir), "task_dir": task_dir_db,
"model": caps.model_id, "model": caps.model_id,
"model_profile": model, "model_profile": model,
"mode": mode, "mode": mode,
@ -226,7 +234,7 @@ def build_agent(
# tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里 # tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里
# 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令) # 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令)
task_state = TaskState( task_state = TaskState(
task_id=sid, task_dir=str(task_dir), task_id=sid, task_dir=task_dir_db,
mode=mode, description=description, status="active", mode=mode, description=description, status="active",
model=caps.model_id, model_profile=model, model=caps.model_id, model_profile=model,
) )
@ -235,7 +243,7 @@ def build_agent(
# 懒创建:TaskState 仅内存。tasks 行在首条 user 消息 append 时由 # 懒创建:TaskState 仅内存。tasks 行在首条 user 消息 append 时由
# ensure_local_task_row 占位 INSERT;首次 sync_task_tokens 或 /done /desc 走 upsert 覆盖。 # ensure_local_task_row 占位 INSERT;首次 sync_task_tokens 或 /done /desc 走 upsert 覆盖。
task_state = TaskState( 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", mode=mode, description=description, status="active",
model=caps.model_id, model_profile=model, model=caps.model_id, model_profile=model,
reasoning_effort=caps.default_reasoning_effort or "", reasoning_effort=caps.default_reasoning_effort or "",

View File

@ -16,12 +16,8 @@ sqlalchemy>=2.0.0
psycopg[binary]>=3.1.0 psycopg[binary]>=3.1.0
alembic>=1.13.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 fastapi>=0.111.0
uvicorn[standard]>=0.30.0 uvicorn[standard]>=0.30.0
jinja2>=3.1.0 python-multipart>=0.0.9 # files upload multipart 解析
python-multipart>=0.0.9 pyjwt>=2.8.0 # /v1/auth/login HS256 token mint/verify(§7 D' 过渡形态)
# G3: server-side markdown 渲染 + 代码 syntax highlight
markdown-it-py[linkify]>=3.0.0
mdit-py-plugins>=0.4.0
pygments>=2.17.0

File diff suppressed because it is too large Load Diff

138
web/auth.py Normal file
View File

@ -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 <jwt>`
- 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=<shared secret between platform and zcbot>\n"
" JWT_SECRET=<HMAC secret used to sign session tokens>"
)
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 <token>")
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",
]

788
web/static/dev.html Normal file
View File

@ -0,0 +1,788 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>zcbot dev</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root {
--bg: #f7f7f7;
--panel: #ffffff;
--border: #e3e3e3;
--text: #222;
--muted: #888;
--accent: #c0392b;
--accent-soft: #fde9e7;
--hover: #f0f0f0;
--code-bg: #f4f4f4;
--user-bg: #eef4fb;
--asst-bg: #ffffff;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text); background: var(--bg);
}
button, input, textarea, select {
font: inherit; color: inherit;
}
button {
background: #fff; border: 1px solid var(--border);
padding: 4px 10px; border-radius: 4px; cursor: pointer;
}
button:hover { background: var(--hover); }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.primary:hover { filter: brightness(1.08); }
button.danger:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
input, textarea, select {
background: #fff; border: 1px solid var(--border);
padding: 5px 8px; border-radius: 4px; width: 100%;
}
textarea { resize: vertical; min-height: 60px; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ───── login overlay ───── */
#login {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
#login .card {
background: var(--panel); padding: 24px; border-radius: 6px;
width: 360px; box-shadow: 0 8px 24px rgba(0,0,0,.15);
}
#login h2 { margin: 0 0 16px; font-size: 18px; }
#login label { display: block; margin-top: 10px; font-size: 12px; color: var(--muted); }
#login .err { color: var(--accent); font-size: 12px; margin-top: 10px; min-height: 1em; }
#login .actions { margin-top: 14px; display: flex; gap: 8px; }
/* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; }
#app.ready { display: grid; grid-template-columns: 280px 1fr 320px; grid-template-rows: auto 1fr; grid-template-areas: "head head head" "left mid right"; }
header {
grid-area: head; background: #fff; border-bottom: 1px solid var(--border);
padding: 8px 14px; display: flex; align-items: center; gap: 12px;
}
header .title { font-weight: 600; }
header .who { color: var(--muted); font-size: 12px; font-family: monospace; }
header .spacer { flex: 1; }
.pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; }
#pane-left { grid-area: left; }
#pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); }
#pane-right { grid-area: right; border-right: none; overflow: auto; background: var(--panel); }
.pane-head {
padding: 8px 12px; border-bottom: 1px solid var(--border);
display: flex; gap: 8px; align-items: center; background: #fafafa;
position: sticky; top: 0;
}
.pane-head .label { font-weight: 600; font-size: 13px; }
.pane-head .spacer { flex: 1; }
/* ───── task list ───── */
.task-row {
padding: 8px 12px; border-bottom: 1px solid var(--border); cursor: pointer;
}
.task-row:hover { background: var(--hover); }
.task-row.active { background: var(--accent-soft); border-left: 3px solid var(--accent); padding-left: 9px; }
.task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-row .meta { font-size: 11px; color: var(--muted); display: flex; gap: 8px; }
.task-row .badge {
display: inline-block; padding: 0 6px; border-radius: 8px; font-size: 11px;
background: #eef; color: #336;
}
.badge.completed { background: #e8f5e9; color: #2e7d32; }
.badge.abandoned { background: #fde9e7; color: var(--accent); }
.badge.active { background: #eef; color: #336; }
.empty { padding: 24px; color: var(--muted); text-align: center; font-size: 13px; }
/* ───── chat ───── */
#chat-meta { padding: 8px 12px; border-bottom: 1px solid var(--border); background: #fafafa;
font-size: 12px; color: var(--muted); display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
#chat-meta .tid { font-family: monospace; color: var(--text); }
#chat-meta .spacer { flex: 1; }
#chat-stream {
flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px;
}
.msg {
border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px;
max-width: 92%;
}
.msg.user { background: var(--user-bg); align-self: flex-end; }
.msg.assistant, .msg.system, .msg.tool, .msg.error { background: var(--asst-bg); align-self: flex-start; }
.msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: monospace; }
.msg .body { white-space: pre-wrap; word-wrap: break-word; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; }
.msg .body.streaming::after { content: "▌"; color: var(--accent); animation: blink 1s infinite; }
@keyframes blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
.tool-call {
margin-top: 6px; font-family: ui-monospace, Consolas, monospace; font-size: 12px;
}
.tool-call summary {
cursor: pointer; padding: 4px 6px; background: var(--code-bg); border-radius: 3px;
color: #555;
}
.tool-call summary:hover { background: #ebebeb; }
.tool-call pre {
margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: 3px;
overflow-x: auto; max-height: 300px; white-space: pre-wrap;
}
#chat-form {
border-top: 1px solid var(--border); padding: 10px; background: #fafafa;
display: flex; flex-direction: column; gap: 6px;
}
#chat-form .row { display: flex; gap: 8px; }
#chat-form textarea { flex: 1; }
#chat-form .hint { font-size: 11px; color: var(--muted); }
/* ───── files ───── */
.crumbs { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; background: #fafafa; }
.crumbs a { margin-right: 4px; }
.file-row {
display: flex; padding: 6px 12px; border-bottom: 1px solid var(--border);
align-items: center; gap: 8px;
}
.file-row:hover { background: var(--hover); }
.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row .size { font-size: 11px; color: var(--muted); font-family: monospace; }
.ico-dir::before { content: "▸ "; color: var(--accent); }
.ico-file::before { content: "· "; color: var(--muted); }
/* ───── new task modal ───── */
#new-task-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: none; align-items: center; justify-content: center; z-index: 80;
}
#new-task-modal.show { display: flex; }
#new-task-modal .card {
background: var(--panel); padding: 20px; border-radius: 6px;
width: 420px; box-shadow: 0 8px 24px rgba(0,0,0,.15);
}
#new-task-modal h3 { margin: 0 0 12px; font-size: 16px; }
#new-task-modal label { display: block; margin-top: 8px; font-size: 12px; color: var(--muted); }
#new-task-modal .err { color: var(--accent); font-size: 12px; margin-top: 8px; min-height: 1em; }
#new-task-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
.small { font-size: 12px; }
.muted { color: var(--muted); }
</style>
</head>
<body>
<!-- ───── login overlay ───── -->
<div id="login">
<div class="card">
<h2>zcbot dev login</h2>
<label for="li-uid">user_id (UUID)</label>
<input id="li-uid" autocomplete="off" />
<label for="li-key">platform_key</label>
<input id="li-key" type="password" autocomplete="off" />
<div class="err" id="li-err"></div>
<div class="actions">
<button class="primary" id="li-go">登录</button>
</div>
<div class="small muted" style="margin-top: 12px;">
本地默认 user_id 是 sentinel(全 0)。platform_key 见服务端 env <code>PLATFORM_KEY</code>
</div>
</div>
</div>
<!-- ───── main 3-pane ───── -->
<div id="app">
<header>
<div class="title">zcbot</div>
<div class="who" id="hd-who"></div>
<div class="spacer"></div>
<button id="hd-new" class="primary">+ new task</button>
<button id="hd-logout">logout</button>
</header>
<!-- left -->
<div class="pane" id="pane-left">
<div class="pane-head">
<span class="label">tasks</span>
<span class="spacer"></span>
<select id="filter-status" class="small" style="width: auto;">
<option value="">(all)</option>
<option value="active">active</option>
<option value="completed">completed</option>
<option value="abandoned">abandoned</option>
</select>
<button id="btn-refresh-tasks" class="small"></button>
</div>
<div id="task-list"><div class="empty">loading…</div></div>
</div>
<!-- middle -->
<div id="pane-mid">
<div class="pane-head">
<span class="label">chat</span>
<span class="spacer"></span>
<button id="btn-export" class="small" disabled>export .docx</button>
<button id="btn-done" class="small" disabled>done</button>
<button id="btn-abandon" class="small danger" disabled>abandon</button>
</div>
<div id="chat-meta"><span class="muted">(no task selected)</span></div>
<div id="chat-stream"><div class="empty">select a task on the left</div></div>
<form id="chat-form" style="display:none;">
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行)"></textarea>
<div class="row">
<span class="hint" id="chat-hint">ready</span>
<span style="flex:1;"></span>
<button type="submit" class="primary" id="chat-send">send</button>
</div>
</form>
</div>
<!-- right -->
<div id="pane-right">
<div class="pane-head">
<span class="label">files</span>
<span class="spacer"></span>
<button id="btn-refresh-files" class="small" disabled></button>
</div>
<div id="file-crumbs" class="crumbs muted">(no task selected)</div>
<div id="file-list"></div>
</div>
</div>
<!-- ───── new task modal ───── -->
<div id="new-task-modal">
<div class="card">
<h3>新建 task</h3>
<label for="nt-desc">description</label>
<input id="nt-desc" />
<label for="nt-mode">mode (可选,如 coding / writing)</label>
<input id="nt-mode" />
<label for="nt-dir">task_dir (可选,绑项目目录;留空 → 默认派生)</label>
<input id="nt-dir" placeholder="例如 D:/projects/foo 或 留空" />
<div class="err" id="nt-err"></div>
<div class="actions">
<button id="nt-cancel">取消</button>
<button class="primary" id="nt-go">创建</button>
</div>
</div>
</div>
<script>
const SENTINEL = "00000000-0000-0000-0000-000000000000";
const LS_TOKEN = "zcbot.token";
const LS_UID = "zcbot.user_id";
const state = {
token: localStorage.getItem(LS_TOKEN) || "",
userId: localStorage.getItem(LS_UID) || "",
taskId: null,
taskMeta: null,
filesPath: "",
evtSrc: null,
};
// ───── helpers ─────
const $ = (id) => document.getElementById(id);
async function api(method, path, body) {
const opts = { method, headers: {} };
if (state.token) opts.headers["Authorization"] = "Bearer " + state.token;
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
let data = null;
const ct = r.headers.get("content-type") || "";
if (ct.includes("application/json")) {
try { data = await r.json(); } catch (e) {}
} else {
data = await r.text();
}
if (!r.ok) {
const msg = (data && data.detail) || data || (r.status + " " + r.statusText);
const err = new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
err.status = r.status;
throw err;
}
return data;
}
function humanSize(n) {
if (n == null) return "";
if (n < 1024) return n + " B";
if (n < 1024*1024) return (n/1024).toFixed(1) + " K";
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + " M";
return (n/1024/1024/1024).toFixed(1) + " G";
}
function fmtTime(iso) {
if (!iso) return "";
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
}
function escapeHtml(s) {
return (s || "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
}
// ───── login ─────
$("li-uid").value = state.userId || SENTINEL;
$("li-go").onclick = doLogin;
$("li-key").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
$("li-uid").addEventListener("keydown", (e) => { if (e.key === "Enter") $("li-key").focus(); });
async function doLogin() {
const uid = $("li-uid").value.trim();
const key = $("li-key").value;
$("li-err").textContent = "";
if (!uid || !key) {
$("li-err").textContent = "user_id 与 platform_key 都要填";
return;
}
try {
const r = await fetch("/v1/auth/login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: uid, platform_key: key }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || (r.status + " login failed"));
}
const data = await r.json();
state.token = data.token;
state.userId = data.user_id;
localStorage.setItem(LS_TOKEN, state.token);
localStorage.setItem(LS_UID, state.userId);
enterApp();
} catch (e) {
$("li-err").textContent = e.message;
}
}
function logout() {
state.token = ""; state.userId = "";
localStorage.removeItem(LS_TOKEN);
localStorage.removeItem(LS_UID);
if (state.evtSrc) state.evtSrc.close();
location.reload();
}
$("hd-logout").onclick = logout;
// ───── enter app ─────
function enterApp() {
$("login").style.display = "none";
$("app").classList.add("ready");
$("hd-who").textContent = state.userId;
loadTaskList();
}
async function loadTaskList() {
const filter = $("filter-status").value;
const qs = filter ? "?status=" + filter : "";
try {
const data = await api("GET", "/v1/tasks" + qs);
renderTaskList(data.tasks);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("task-list").innerHTML = `<div class="empty">load failed: ${escapeHtml(e.message)}</div>`;
}
}
function renderTaskList(tasks) {
if (!tasks.length) {
$("task-list").innerHTML = `<div class="empty">(no tasks)</div>`;
return;
}
const html = tasks.map((t) => {
const active = state.taskId === t.task_id ? " active" : "";
const desc = t.description || "(no desc)";
const dir = t.task_dir ? (" · " + t.task_dir.split("/").slice(-2).join("/")) : "";
return `
<div class="task-row${active}" data-tid="${t.task_id}">
<div class="desc" title="${escapeHtml(desc)}">${escapeHtml(desc)}</div>
<div class="meta">
<span class="badge ${t.status}">${t.status}</span>
<span>${t.n_messages || 0} msg</span>
<span>${t.tokens || 0} tok</span>
</div>
<div class="meta muted" title="${escapeHtml(t.task_dir || "")}">
${t.task_id.slice(0, 8)}${escapeHtml(dir)}
</div>
</div>
`;
}).join("");
$("task-list").innerHTML = html;
$("task-list").querySelectorAll(".task-row").forEach((el) => {
el.onclick = () => selectTask(el.dataset.tid);
});
}
$("filter-status").onchange = loadTaskList;
$("btn-refresh-tasks").onclick = loadTaskList;
// ───── select task ─────
async function selectTask(tid) {
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
state.taskId = tid;
document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid);
});
try {
const meta = await api("GET", "/v1/tasks/" + tid);
state.taskMeta = meta;
renderChatMeta();
await loadMessages();
state.filesPath = "";
await loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }
$("chat-stream").innerHTML = `<div class="empty">load failed: ${escapeHtml(e.message)}</div>`;
}
}
function renderChatMeta() {
const t = state.taskMeta;
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`; return; }
$("chat-meta").innerHTML = `
<span class="tid">${t.task_id.slice(0, 8)}</span>
<span class="badge ${t.status}">${t.status}</span>
<span class="muted">${escapeHtml(t.description || "(no desc)")}</span>
<span class="spacer"></span>
<span class="muted small">${t.n_messages || 0} msg · ${t.tokens || 0} tok</span>
`;
const active = t.status === "active";
$("chat-form").style.display = active ? "flex" : "none";
$("btn-done").disabled = !active;
$("btn-abandon").disabled = !active;
$("btn-export").disabled = (t.n_messages || 0) === 0;
$("btn-refresh-files").disabled = false;
}
async function loadMessages() {
const data = await api("GET", `/v1/tasks/${state.taskId}/messages`);
renderMessages(data.messages);
}
function renderMessages(msgs) {
const wrap = $("chat-stream");
wrap.innerHTML = "";
if (!msgs.length) {
wrap.innerHTML = `<div class="empty">(no messages yet · send something below)</div>`;
return;
}
for (const m of msgs) {
const p = m.payload || {};
const role = p.role || "?";
if (role === "system") continue; // 不显示 system
if (role === "tool") {
// 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
const card = document.createElement("div");
card.className = "msg tool";
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
card.innerHTML = `
<div class="role">tool · ${escapeHtml(p.name || "")}</div>
<details class="tool-call"><summary>result (${(txt || "").length} chars)</summary><pre>${escapeHtml(txt || "")}</pre></details>
`;
wrap.appendChild(card);
continue;
}
const card = document.createElement("div");
card.className = "msg " + role;
let html = `<div class="role">${role}</div>`;
if (typeof p.content === "string" && p.content) {
html += `<div class="body">${escapeHtml(p.content)}</div>`;
}
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
for (const tc of p.tool_calls) {
const fn = (tc.function && tc.function.name) || "?";
let args = "";
try {
args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2);
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
html += `
<details class="tool-call"><summary>tool_call: ${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
`;
}
}
card.innerHTML = html;
wrap.appendChild(card);
}
wrap.scrollTop = wrap.scrollHeight;
}
// ───── send + SSE ─────
$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); sendMessage(); });
$("chat-input").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
async function sendMessage() {
if (!state.taskId) return;
const content = $("chat-input").value.trim();
if (!content) return;
$("chat-send").disabled = true;
$("chat-hint").textContent = "sending…";
try {
// 立刻渲染 user 消息卡(乐观)
const wrap = $("chat-stream");
const userCard = document.createElement("div");
userCard.className = "msg user";
userCard.innerHTML = `<div class="role">user</div><div class="body">${escapeHtml(content)}</div>`;
wrap.appendChild(userCard);
// assistant 流式占位卡
const asstCard = document.createElement("div");
asstCard.className = "msg assistant";
asstCard.innerHTML = `<div class="role">assistant</div><div class="body streaming"></div>`;
wrap.appendChild(asstCard);
wrap.scrollTop = wrap.scrollHeight;
const r = await api("POST", `/v1/tasks/${state.taskId}/messages`, { content });
$("chat-input").value = "";
streamSse(r.events_url, asstCard);
} catch (e) {
if (e.status === 401) { logout(); return; }
appendErrorCard(e.message);
$("chat-send").disabled = false;
$("chat-hint").textContent = "ready";
}
}
function streamSse(url, asstCard) {
// EventSource 不支持自定义 header,token 走 query string(?token=...)
// 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
// 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource
fetchSse(url, asstCard).catch((e) => appendErrorCard("sse: " + e.message));
}
async function fetchSse(url, asstCard) {
const body = asstCard.querySelector(".body");
const r = await fetch(url, {
headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" },
});
if (!r.ok) throw new Error(r.status + " " + r.statusText);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
let acc = "";
$("chat-hint").textContent = "streaming…";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
while (true) {
const idx = buf.indexOf("\n\n");
if (idx < 0) break;
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
const ev = parseSseFrame(frame);
if (!ev) continue;
handleSseEvent(ev, body, asstCard);
if (ev.event === "text" && ev.data && ev.data.delta) acc += ev.data.delta;
if (ev.event === "done" || ev.event === "error") break;
}
}
body.classList.remove("streaming");
$("chat-send").disabled = false;
$("chat-hint").textContent = "ready";
// 最后刷新 task meta + messages(拿真实持久化的)
loadTaskList();
await loadMessages();
}
function parseSseFrame(frame) {
const lines = frame.split("\n");
let event = "msg"; let dataLines = [];
for (const ln of lines) {
if (ln.startsWith(":")) continue; // comment
if (ln.startsWith("event:")) event = ln.slice(6).trim();
else if (ln.startsWith("data:")) dataLines.push(ln.slice(5).replace(/^ /, ""));
}
if (!dataLines.length) return { event, data: null };
const raw = dataLines.join("\n");
let data = null;
try { data = JSON.parse(raw); } catch (e) { data = raw; }
return { event, data };
}
function handleSseEvent(ev, body, asstCard) {
const t = ev.event;
if (t === "text" && ev.data && ev.data.delta) {
body.textContent += ev.data.delta;
$("chat-stream").scrollTop = $("chat-stream").scrollHeight;
} else if (t === "tool_call") {
const fn = (ev.data && ev.data.name) || "?";
const args = (ev.data && ev.data.arguments) || "";
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>tool_call: ${escapeHtml(fn)}</summary><pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>`;
asstCard.appendChild(det);
} else if (t === "tool_result") {
const txt = (ev.data && ev.data.result) || "";
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>tool_result</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`;
asstCard.appendChild(det);
} else if (t === "error") {
const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
appendErrorCard(msg);
}
}
function appendErrorCard(msg) {
const card = document.createElement("div");
card.className = "msg error";
card.innerHTML = `<div class="role">error</div><div class="body">${escapeHtml(msg)}</div>`;
$("chat-stream").appendChild(card);
$("chat-stream").scrollTop = $("chat-stream").scrollHeight;
}
// ───── done / abandon / export ─────
$("btn-done").onclick = () => patchStatus("completed");
$("btn-abandon").onclick = () => patchStatus("abandoned");
async function patchStatus(status) {
if (!state.taskId) return;
if (!confirm("确认置为 " + status + "?")) return;
try {
await api("PATCH", "/v1/tasks/" + state.taskId, { status });
await selectTask(state.taskId);
loadTaskList();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("failed: " + e.message);
}
}
$("btn-export").onclick = () => {
if (!state.taskId) return;
// 同源下载:把 token 注入临时 fetch,blob 落地再触发下载
fetch("/v1/tasks/" + state.taskId + "/export", {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) { alert("export failed: " + r.status); return; }
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "chat_" + state.taskId.slice(0, 8) + ".docx";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
};
// ───── files ─────
$("btn-refresh-files").onclick = () => loadFiles();
async function loadFiles() {
if (!state.taskId) return;
try {
const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : "";
const data = await api("GET", `/v1/tasks/${state.taskId}/files` + qs);
renderFiles(data);
} catch (e) {
if (e.status === 401) { logout(); return; }
if (e.status === 400) {
$("file-crumbs").innerHTML = `<span class="muted">(no task_dir bound)</span>`;
$("file-list").innerHTML = "";
} else {
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
$("file-list").innerHTML = "";
}
}
}
function renderFiles(data) {
const cr = data.crumbs.map((c, i) => {
const isLast = i === data.crumbs.length - 1;
if (isLast) return `<span>${escapeHtml(c.label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(c.label)}</a> /`;
}).join(" ");
$("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("file-crumbs").querySelectorAll("a").forEach((a) => {
a.onclick = (e) => { e.preventDefault(); state.filesPath = a.dataset.rel; loadFiles(); };
});
if (!data.exists) {
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
return;
}
if (!data.entries.length) {
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
return;
}
$("file-list").innerHTML = data.entries.map((e) => {
const cls = e.is_dir ? "ico-dir" : "ico-file";
return `
<div class="file-row">
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">
${escapeHtml(e.name)}
</span>
<span class="size">${humanSize(e.size)}</span>
</div>
`;
}).join("");
$("file-list").querySelectorAll(".name").forEach((el) => {
el.style.cursor = "pointer";
el.onclick = () => {
const rel = el.dataset.rel;
if (el.dataset.isdir === "true") { state.filesPath = rel; loadFiles(); }
else { downloadFile(rel); }
};
});
}
function downloadFile(rel) {
fetch(`/v1/tasks/${state.taskId}/files/download?path=` + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) { alert("download failed: " + r.status); return; }
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = rel.split("/").pop() || "file";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
}
// ───── new task ─────
$("hd-new").onclick = () => {
$("nt-desc").value = ""; $("nt-mode").value = ""; $("nt-dir").value = "";
$("nt-err").textContent = "";
$("new-task-modal").classList.add("show");
$("nt-desc").focus();
};
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-go").onclick = async () => {
const desc = $("nt-desc").value.trim();
const mode = $("nt-mode").value.trim();
const dir = $("nt-dir").value.trim();
$("nt-err").textContent = "";
if (!desc && !dir) { $("nt-err").textContent = "description 与 task_dir 至少填一个"; return; }
try {
const t = await api("POST", "/v1/tasks", { description: desc, mode, task_dir: dir });
$("new-task-modal").classList.remove("show");
await loadTaskList();
selectTask(t.task_id);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("nt-err").textContent = e.message;
}
};
// ───── boot ─────
if (state.token) {
// 已有 token:试探一下,失败回登录页
enterApp();
} else {
$("li-uid").value = SENTINEL;
$("li-uid").focus();
}
</script>
</body>
</html>

View File

@ -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 <details> 折叠 */
.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 */

View File

@ -1,4 +0,0 @@
<div class="msg-error">
<span class="err-tag">error</span>
<span>{{ msg }}</span>
</div>

View File

@ -1 +0,0 @@
<div class="body">{{ html | safe }}</div>

View File

@ -1,13 +0,0 @@
<details class="tool">
<summary>
<span class="tool-badge">tool</span>
<span class="tool-name">{{ name }}</span>
<span class="tool-args-preview">{{ args_preview }}</span>
</summary>
<div class="tool-body">
<div class="tool-section">
<div class="tool-label">args</div>
<pre class="tool-pre">{{ args_pretty }}</pre>
</div>
</div>
</details>

View File

@ -1,4 +0,0 @@
<div class="tool-result-inline">
<span class="tool-result-tag">↳ {{ name }}</span>
<pre class="tool-pre">{{ preview }}{% if truncated %}<span class="muted"> (truncated)</span>{% endif %}</pre>
</div>

View File

@ -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 决定追加而非替换)。
#}
<article class="msg msg-user">
<div class="role">user</div>
<div class="body">{{ user_html | safe }}</div>
</article>
<article class="msg msg-assistant streaming"
hx-ext="sse"
sse-connect="/tasks/{{ task_id }}/runs/{{ run_id }}/events"
sse-swap="text,tool_call,tool_result,error"
sse-close="done,error"
hx-swap="beforeend">
<div class="role">
assistant
<span class="run-indicator" title="run {{ run_id[:8] }}"></span>
</div>
{# SSE event=text/tool_call/tool_result/error 的 data → swap 到这个 article 内尾部 #}
</article>

View File

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}zcbot{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}">
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2" defer></script>
</head>
<body>
<header class="topbar">
<a class="brand" href="/">zcbot</a>
<nav class="navlinks">
{% block nav %}<a href="/">tasks</a> <a href="/new">new</a>{% endblock %}
</nav>
<span class="user-tag" title="本地 sentinel user — Phase D 加 OIDC 之前固定">local</span>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@ -1,69 +0,0 @@
{% extends "base.html" %}
{% block title %}zcbot · {{ task_id_short }}{% endblock %}
{% block nav %}<a href="/">tasks</a>{% endblock %}
{% block content %}
<div class="page-head">
<h1>task <code class="mono">{{ task_id_short }}</code>
<span class="status status-{{ status }}">{{ status }}</span>
{% if mode %}<span class="badge">{{ mode }}</span>{% endif %}
</h1>
<div class="task-meta muted">
{{ n_messages }} msgs · {{ tokens }} tokens · {{ model_label }}
</div>
</div>
{% if description %}<p class="lead">{{ description }}</p>{% endif %}
{% if task_dir %}<p class="muted mono small">{{ task_dir }}</p>{% endif %}
<section class="chat" id="chat-stream">
{% for b in blocks %}
{% if b.type == "user" %}
<article class="msg msg-user">
<div class="role">user</div>
<div class="body">{{ b.html | safe }}</div>
</article>
{% elif b.type == "assistant" %}
<article class="msg msg-assistant">
<div class="role">assistant</div>
{% if b.html %}<div class="body">{{ b.html | safe }}</div>{% endif %}
{% for tc in b.tool_calls %}
<details class="tool">
<summary>
<span class="tool-badge">tool</span>
<span class="tool-name">{{ tc.name }}</span>
<span class="tool-args-preview">{{ tc.args_preview }}</span>
</summary>
<div class="tool-body">
<div class="tool-section">
<div class="tool-label">args</div>
<pre class="tool-pre">{{ tc.args_pretty }}</pre>
</div>
<div class="tool-section">
<div class="tool-label">result</div>
<pre class="tool-pre">{{ tc.result }}</pre>
</div>
</div>
</details>
{% endfor %}
</article>
{% endif %}
{% endfor %}
</section>
{% if status == "active" %}
<form class="send-form"
hx-post="/tasks/{{ task_id }}/messages"
hx-target="#chat-stream"
hx-swap="beforeend"
hx-on::after-request="if(event.detail.successful) this.reset()">
<textarea name="content" placeholder="发条消息… (Enter 发送,Shift+Enter 换行)"
required rows="2"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.form.requestSubmit();}"></textarea>
<button type="submit">send</button>
</form>
{% else %}
<p class="muted small mt-1">task 已 {{ status }},不接收新消息。CLI <code>/done</code> 改 status 来恢复。</p>
{% endif %}
<p class="muted small mt-1">G4 流式 ✓ · 文件浏览 = G5 · 打磨 = G6</p>
{% endblock %}

View File

@ -1,64 +0,0 @@
{% extends "base.html" %}
{% block title %}zcbot · tasks{% endblock %}
{% block nav %}
<a href="/" class="active">tasks</a>
<a href="/new">new</a>
{% endblock %}
{% block content %}
<div class="page-head">
<h1>tasks <small class="muted">最近 {{ tasks|length }} 条{% if status %} · status={{ status }}{% endif %}</small></h1>
<div class="head-actions">
<form class="filters" method="get" action="/">
<label>status:
<select name="status" onchange="this.form.submit()">
<option value="">all</option>
{% for f in filters %}
<option value="{{ f }}"{% if status == f %} selected{% endif %}>{{ f }}</option>
{% endfor %}
</select>
</label>
{% if status %}<a href="/" class="btn">reset</a>{% endif %}
</form>
<a href="/new" class="btn btn-primary">+ new task</a>
</div>
</div>
{% if not tasks %}
<p class="empty muted">
没有 task{% if status %}(status={{ status }}){% endif %}。
CLI 起一个:<code>cli.py chat --desc "..."</code>;Web 起 task 留到 G6。
</p>
{% else %}
<table class="task-list">
<thead>
<tr>
<th>id</th>
<th>updated</th>
<th>status</th>
<th>mode</th>
<th>model</th>
<th class="num">msgs</th>
<th class="num">tokens</th>
<th>desc / dir</th>
</tr>
</thead>
<tbody>
{% for t in tasks %}
<tr>
<td><a href="/tasks/{{ t.task_id }}" class="task-id" title="{{ t.task_id }}">{{ t.task_id_short }}</a></td>
<td class="muted">{{ t.updated_at.strftime("%m-%d %H:%M") }}</td>
<td><span class="status status-{{ t.status }}">{{ t.status }}</span></td>
<td>{{ t.mode }}</td>
<td class="muted">{{ t.model_label }}</td>
<td class="num">{{ t.n_messages }}</td>
<td class="num">{{ t.tokens }}</td>
<td class="dir">
{% if t.description %}<div class="desc">{{ t.description }}</div>{% endif %}
{% if t.task_dir %}<div class="muted small mono">{{ t.task_dir }}</div>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View File

@ -1,49 +0,0 @@
{% extends "base.html" %}
{% block title %}zcbot · new task{% endblock %}
{% block nav %}<a href="/">tasks</a> <a href="/new" class="active">new</a>{% endblock %}
{% block content %}
<div class="page-head">
<h1>新建 task</h1>
</div>
{% if error %}
<div class="msg-error" style="margin-bottom:1rem;">
<span class="err-tag">error</span>
<span>{{ error }}</span>
</div>
{% endif %}
<form method="post" action="/new" class="new-task-form">
<label>
<span class="field-label">description</span>
<input type="text" name="description" value="{{ form.description }}"
placeholder="一句话任务描述,便于后续 list 识别(留空就靠 task_dir)"
autocomplete="off" autofocus>
</label>
<label>
<span class="field-label">mode <span class="muted small">(可选)</span></span>
<input type="text" name="mode" value="{{ form.mode }}"
placeholder="coding / ppt / proposal / … 自由形式"
autocomplete="off">
</label>
<label>
<span class="field-label">task_dir <span class="muted small">(可选,留空 = 一次性对话;填路径 = 项目化)</span></span>
<input type="text" name="task_dir" value="{{ form.task_dir }}"
placeholder="/path/to/project 或 D:\projects\proj — 相对路径以服务器 cwd 为基"
autocomplete="off">
<span class="muted small">
留空 → 默认派生 <code>workspace/tasks/&lt;uuid&gt;/</code>(等价 ChatGPT 一次性对话)。
指定 → 同 task_dir 多 task 自动共享 source / sections / 终稿。
<strong>不允许前缀嵌套</strong>(no-subtask):同 user 下不能有父子关系的 task_dir。
</span>
</label>
<div class="form-actions">
<a href="/" class="btn">取消</a>
<button type="submit">创建并打开</button>
</div>
</form>
<p class="muted small mt-1">
Tip:创建后会跳到 chat 页,在底部输入框发第一条消息开始对话。
</p>
{% endblock %}