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:
parent
1035b12847
commit
02a69058df
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -6,6 +6,16 @@
|
|||
- 跑脚本 / 测试一律用 `.venv/Scripts/python.exe ...`,**不要用全局 `python`**(没装 litellm/python-pptx 等会报 ModuleNotFoundError)
|
||||
- requirements 见 `requirements.txt`
|
||||
|
||||
## 开发阶段心智
|
||||
|
||||
当前处于开发阶段(尚未发布给真实用户)。改需求 / 重构时,**以最优实现为准,不为旧数据 / 旧字段 / 旧 API 留兼容层**:
|
||||
- DB schema 变 → 直接改 model + 写一条干净的 migration(必要时清空旧 row,不写双向兼容代码)
|
||||
- 字段语义变 → 全量替换,不留 `legacy_xxx` / `*_v2` 并存
|
||||
- CLI / REPL 选项变 → 直接改,不留 deprecated 别名
|
||||
- 只有当用户明确说"这条要保留兼容"时才写兼容代码
|
||||
|
||||
理由:兼容层就是技术债,开发期写了之后忘记删反而拖累;真上线后再视情况补迁移路径。
|
||||
|
||||
## 文档维护
|
||||
|
||||
每完成一步实现(commit 前),**必须更新 `PROGRESS.md`**:
|
||||
|
|
|
|||
101
DESIGN.md
101
DESIGN.md
|
|
@ -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>/` |
|
||||
| Memory | `workspace/memory/`(FS) | `<storage_root>/users/<user_id>/memory/`(仍是 FS) |
|
||||
| Sandbox | subprocess + env 过滤 | per-task docker exec |
|
||||
| Auth | 无(`user_id='local'`) | OIDC + JWT |
|
||||
| Auth | 无(`user_id='local'`) | PLATFORM_KEY → JWT(过渡)→ OIDC |
|
||||
|
||||
**CLI 长期双模式**:本地直跑(默认,in-process,直连 PG,适合调内部状态)/ `--remote https://...`(HTTP 走 `/v1`,等价真实用户路径)。两模式共用 `cli.py`,差别只在 transport 层。
|
||||
|
||||
|
|
@ -223,29 +223,60 @@ state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享
|
|||
|
||||
### 7.2 资源模型(/v1)
|
||||
|
||||
Task 是主视图,排在前面;folder 是文件副视图。
|
||||
Task 一等公民,files 是其副视图(经 `task_dir` 暴露,无独立 folder 实体)。所有路由统一 `/v1` 前缀,**返 JSON**;前端 / UI 由 platform 端实现,本仓库不维护(§7.9 取舍)。本地开发用 FastAPI 自带 `/docs` Swagger UI 自查;`GET /` 302 跳 `/docs`。
|
||||
|
||||
```
|
||||
# Tasks(主)
|
||||
CRUD /v1/tasks[/{id}] {task_dir?, mode, desc, model};task_dir 留空默认派生
|
||||
POST/GET /v1/tasks/{id}/messages 发消息 / 历史(?search= 走 jsonb GIN / tsvector)
|
||||
GET/POST /v1/tasks/{id}/runs/{run_id}/{events,cancel} SSE
|
||||
Tasks
|
||||
POST /v1/tasks 创建 {description?, mode?, task_dir?};
|
||||
task_dir 留空 → 默认派生 workspace/tasks/<uuid>/
|
||||
GET /v1/tasks?status=&limit= 列表(updated_at 降序,?status=active|completed|abandoned)
|
||||
GET /v1/tasks/{id} 单 task meta + 完整 messages
|
||||
PATCH /v1/tasks/{id} {status?,description?,mode?};status 从 web 不让切回 active(走 CLI)
|
||||
GET /v1/tasks/{id}/messages 历史(后续 ?search= 走 jsonb GIN / tsvector)
|
||||
POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {run_id}
|
||||
GET /v1/tasks/{id}/runs/{rid}/events SSE 流(见下)
|
||||
POST /v1/tasks/{id}/runs/{rid}/cancel (待)
|
||||
|
||||
# Folders(文件副视图)
|
||||
POST/GET/PATCH/DELETE /v1/folders[/{path}] 列树 / 创建 / 改名(cascade)/ hard cascade
|
||||
GET/POST/DELETE /v1/folders/{path}/files[/{name}] 列 / 上传 / 下载 / 删
|
||||
Files(per-task,task_dir 副视图)
|
||||
GET /v1/tasks/{id}/files?path= 列子目录 {entries, crumbs, exists, root}
|
||||
POST /v1/tasks/{id}/files/upload multipart;path 通过 query 或 form;严格拒含 / \\ .. 的 filename
|
||||
GET /v1/tasks/{id}/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400
|
||||
POST /v1/tasks/{id}/files/delete {path} 文件或空目录;非空目录 400
|
||||
|
||||
# 元数据
|
||||
GET /v1/{skills,models,usage}
|
||||
POST /v1/probe (admin)
|
||||
Export
|
||||
GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp
|
||||
|
||||
Misc
|
||||
GET /healthz {"status":"ok"}
|
||||
GET / 302 → /docs (Swagger UI 自查,本地形态便利)
|
||||
```
|
||||
|
||||
**SSE 事件**:`tool_call` / `tool_result` / `text` (delta) / `usage` / `done`,带 `run_id`。
|
||||
**版本化**:`/v1` minor 半年向后兼容,major 6 个月 deprecation。
|
||||
**SSE 事件**(`Content-Type: text/event-stream`,响应头带 `X-Accel-Buffering: no` 给 nginx 反代友好;每事件 `event: <type>` + `data: <JSON>`):
|
||||
|
||||
```
|
||||
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 认证
|
||||
|
||||
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 + 本地文件系统
|
||||
|
||||
|
|
@ -257,6 +288,9 @@ tasks(task_id uuid pk, user_id fk, task_dir text not null, mode, description,
|
|||
status, model_profile, tokens_prompt, tokens_completion, cost_usd,
|
||||
created_at, updated_at);
|
||||
create index on tasks (user_id, task_dir);
|
||||
-- task_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串(`workspace/tasks/<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,
|
||||
payload jsonb not null, tokens_in, tokens_out, created_at,
|
||||
|
|
@ -269,7 +303,7 @@ usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts)
|
|||
-- append-only。task_id/run_id 不 FK,task 硬删后审计仍存活
|
||||
```
|
||||
|
||||
**No-subtask 校验**(`create_task`):查同 user 下是否存在 `new LIKE existing/%` 或 `existing LIKE new/%`,中一则拒;同 task_dir 允许。
|
||||
**No-subtask 校验**(`create_task`):查同 user 下是否存在 `new LIKE existing/%` 或 `existing LIKE new/%`,中一则拒;同 task_dir 允许。**两侧先用 `from_db_path` 归一到 absolute posix 再比前缀**(混合存储形态 [相对+绝对] 不会漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。
|
||||
|
||||
**Folder rename**(`old → new`,FS rename 成功后):`UPDATE tasks SET task_dir = new || substring(task_dir from len(old)+1) WHERE user_id=? AND (task_dir = old OR task_dir LIKE old||'/%')`。**用 `old/%` 而非 `old%`**,避免 `project_a` 误中 `project_a_other`。running task 引用时禁 rename / delete。
|
||||
|
||||
|
|
@ -310,24 +344,28 @@ usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts)
|
|||
| 6 | **Executor + sandbox**:`run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入 | 2-3 天 |
|
||||
| 7 | **HTTP /v1**:FastAPI + SSE + OIDC | 4 天 |
|
||||
| 8 | **CLI 双模式**:transport 层抽象,默认 in-process;`--remote` 走 HTTP;**本地直跑不删** | 1.5 天 |
|
||||
| 9 | **Web UI(简洁版)**:FastAPI + Jinja2 + HTMX + 原生 SSE,task list / chat / folder tree / 文件上传下载;无 React/Vue 构建链 | 2-3 天 |
|
||||
| 9 | ~~Web UI 简洁版(Jinja2+HTMX)~~ → 改为 **API surface 完工**:Phase G 落地的模板 / HTMX / 服务端 markdown 渲染删除,所有路由切纯 JSON;UI 由 platform 端实现(§7.9 取舍) | 已落 |
|
||||
|
||||
代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;Web UI 加 600-1000 行 HTML/CSS/JS 不计入 Python 主仓库)。
|
||||
代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;UI 不计入,本仓库只维护 API)。
|
||||
|
||||
### 7.7 分阶段落地
|
||||
|
||||
| 阶段 | 范围 | 工作量 | 验收 |
|
||||
|---|---|---|---|
|
||||
| A | #1 事件流化 | done | ✅ |
|
||||
| B | #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + Folder API + no-subtask) | ~1 周 | 本地走 PG,messages 进 DB 全文搜可用;多 task + folder rename 单测;`migrate-from-fs` 跑通 |
|
||||
| C | #6(Executor + sandbox) | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 |
|
||||
| D | #7(HTTP /v1 + auth) | 4 天 | curl / Postman 跑通主流程 |
|
||||
| E | #8(CLI transport 双模式) | 1.5 天 | 默认本地直跑保留,`--remote` 走 HTTP 跑通 |
|
||||
| G | #9(Web UI 简洁版) | 2-3 天 | 浏览器跑通:列 task → 进 chat → 流式回复 → 文件上传下载;与 D / E 无强序,但需 D 的 SSE 端点 |
|
||||
| F | 上线打磨(限流 / 监控 / 告警 / HA) | 持续 | SLO 99.5% |
|
||||
| A | #1 事件流化 | done ✅ | sink 协议铺 SSE 路 |
|
||||
| B | #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + no-subtask)| done ✅ | 本地走 PG,messages 进 DB,任务/消息/状态全在 PG;task_dir 改相对存储(§7.4 注释)|
|
||||
| D | #7 HTTP /v1 surface(无 auth)| done ✅ | `/v1/tasks/*` + SSE JSON + files 4 路由 + export + Swagger;本地形态 sentinel user 跑通 |
|
||||
| D' 过渡 | PLATFORM_KEY → JWT 兑换 + user_id 数据隔离 + dev SPA | done ✅ | `POST /v1/auth/login` 拿 token,`Authorization: Bearer` 走全部 /v1/tasks*;`web/static/dev.html` 单文件 3 栏 SPA 给开发自验。详 §7.3 |
|
||||
| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天 | 真发布给真实用户前补;路由层 Depends 不动,只换 login 内部 |
|
||||
| C | #6 Executor + sandbox | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 |
|
||||
| E | #8 CLI transport 双模式 | 1.5 天 | 默认本地直跑保留,`--remote` 走 HTTP 跑通 |
|
||||
| ~~G~~ | ~~Web UI 简洁版~~ —— **删除**,前端由 platform 端实现 | — | 本仓库不维护 UI |
|
||||
| F | 上线打磨(限流 / 监控 / 告警 / HA)| 持续 | SLO 99.5% |
|
||||
|
||||
**B 阶段一次性切换** —— 切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨。**dogfood 即生效**(messages 进 DB → 全文搜、jsonb 查询立刻可用)。
|
||||
|
||||
**D 落在 G 前面** —— 原排期 D 在 G 后(以为 dogfood 用 UI 跑),实际转向"platform 端联调"后,API surface 反而成阻塞;G 的 Jinja2+HTMX 投入(G1-G6 ~3 天)沉淀 = 删除前的 dogfood 价值,留下的 sink 协议 / broker / no-subtask / files 路径安全归一 / task_dir 相对存储仍被 D 复用。
|
||||
|
||||
### 7.8 已知风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|
|
@ -355,6 +393,19 @@ usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts)
|
|||
|
||||
**本地也用 PG,不用 SQLite / JSON**:① dogfood ≡ 真实用户路径,bug 在 dogfood 就能复现;② Docker 已是必然依赖(§7.5),`docker compose up postgres` 零增量门槛;③ 双 adapter 维护税远高于 PG 一次性配置成本;④ 本地 dev 也能连远端测试服。
|
||||
|
||||
**API-only,UI 由 platform 实现**(2026-05-15 决策):
|
||||
- **原计划**:Phase G 用 Jinja2 + HTMX 在本仓库做"简洁 Web UI",dogfood 用,真上线再做正经前端。已落地 G1-G6:task list / chat 流式 / files 浏览 / new / done/abandon/export/toast,共 ~600 行 HTML+CSS+SSE-HTML-片段。
|
||||
- **触发**:用户决定与已有 platform 联调,前端用 platform 的框架,本仓库再维护 HTML / CSS / HTMX 就是双套 UI 浪费。
|
||||
- **取舍**:
|
||||
- 删 `web/templates/*` `web/static/*` + jinja2/markdown-it-py/pygments/mdit-py-plugins 依赖
|
||||
- SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠
|
||||
- 路由统一 `/v1` 前缀,响应全 JSON,FastAPI 自带 `/docs` Swagger UI 接替"对内调试"角色(本地形态 `GET /` 302→ `/docs`)
|
||||
- 本地 sentinel user 形态保留;auth 走 D' 过渡形态(PLATFORM_KEY → JWT,见 §7.3),真 OIDC 留到联调约定 token 形态后接
|
||||
- CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧
|
||||
- **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连
|
||||
|
||||
**dev SPA 留一份**(2026-05-15 决策):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),~600 行无构建链。**与"UI 由 platform 实现"不冲突**:platform UI 是给真用户的、生产形态;dev.html 是给本仓库开发者自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着。形态:登录页填 user_id(默 sentinel)+ platform_key → localStorage 存 JWT → fetch+Bearer。
|
||||
|
||||
**CLI 不被 API 取代,而是双模式共存**:本地直跑调 core 内部状态比 HTTP roundtrip 顺手;前端用户路径靠 `--remote` 打通。离线靠本地 docker compose PG 兜底,不靠"全栈零依赖"幻觉。
|
||||
|
||||
**Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。
|
||||
|
|
|
|||
42
PROGRESS.md
42
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
|
||||
|
||||
最后更新:2026-05-15(Phase G G4)
|
||||
最后更新:2026-05-15(D 阶段:/v1 JSON API 落地 + PLATFORM_KEY → JWT auth + dev SPA;Phase G Jinja2 路线撤掉)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
|
||||
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
|
||||
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill |
|
||||
| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**Phase G Web UI 进行中(G1 脚手架 ✅;G2 task list ✅;G3 chat 只读 ✅;G4 SSE 流式 ✅;G5 文件浏览 待;G6 打磨)**。下一阶 C(Executor) / D(HTTP /v1) 待。 |
|
||||
| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**D `/v1` JSON API 完工 ✅**(原 Phase G Jinja2/HTMX UI 撤,改 platform 端实现);**D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA ✅**;真 OIDC 待;C(Executor)待;E(CLI 双模式)待。 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -38,6 +38,12 @@
|
|||
- **05-14 / §7 Phase G G2 task list 页**:`web/app.py::list_tasks(limit, status)` 读 PG `tasks` + `messages` count(updated_at 降序),返回模板友好的 dict 列表;**不复用 `cli.py::_list_task_rows`** —— CLI 拿 tuple, Web 拿 dict,数据形状有别,等真有 schema 变更同步成本时再抽(避免预付抽象)。`/` 路由换成 task 表渲染,filter via `?status=active|completed|abandoned`(无效值静默降级为 all);`/tasks/{task_id}` 占位路由 UUID 校验 + DB 存在性校验,缺一则 404,有效则渲染 `task_placeholder.html`(G3 来填消息流)。**Linux portability 落地**:`_norm_path()` 把存的 backslash 在显示时全替成 forward slash(`Path.as_posix()` 在 Linux 读 Win backslash 串时不归一,所以直接 `replace('\\','/')`);Win Path.resolve() 存 `D:\projects\...`、Linux 存 `/home/user/...`,都能正确显示。template:`home.html` 表格(id/updated/status/mode/model/msgs/tokens/desc-dir),status 用 badge(`status-active/completed/abandoned` 配色),hover 高亮;空态文案。CSS:table 紧凑(.9rem)+ `tabular-nums` 对齐 + accent-soft placeholder note。Smoke 18 路径全绿(in-process):3 task seed(active/completed/abandoned)+ Win\Linux 双路径形态 → / 渲染对、status filter 正/反向、garbage status 静默 all、UUID 占位、notauuid 404、ghost UUID 404、limit 生效、/healthz 不退化。版本 0.1 → 0.2。
|
||||
- **05-15 / §7 Phase G G3 chat 只读页**:`web/app.py` 加 `_get_md()` 单例 MarkdownIt(`gfm-like` 预设 + linkify + breaks,`html=False` 禁内联 HTML 防 XSS),fenced code 走 pygments `_pygments_highlight()` 回调(`codehilite` cssclass)。`load_chat_messages(tid)` 读 PG idx asc;`build_chat_blocks(messages)` 聚合显示块 —— system / tool 不入 block(tool 内嵌进 assistant 的 tool_call.result),user / assistant text 走 markdown 渲染,assistant.tool_calls 配对 tool result(orphan tool_call → `[no result]`)。`_args_preview` 60 字符截断,`_pretty_json` 解析失败 fallback 原串。`/tasks/{id}` 替换占位为 `chat.html` 渲染,删 `task_placeholder.html`。template:`.msg` 卡片(user 浅蓝 / assistant 白底),`.body` markdown 区(`<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 / 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 持久化(刷新继续看流式)留到未来。
|
||||
|
||||
---
|
||||
|
|
@ -58,37 +64,40 @@
|
|||
|
||||
```
|
||||
core/capabilities.py 71
|
||||
core/llm.py 89
|
||||
core/llm.py 93 ← +litellm 离线 cost map env
|
||||
core/loop.py 152 ← §7 A: sink.emit
|
||||
core/sinks.py 101 ← §7 A
|
||||
core/ui.py 38
|
||||
core/paths.py 50 ← task_dir db form 归一(to_db_path / from_db_path)
|
||||
core/probe.py 243
|
||||
core/session.py 153 ← §7 B Step 2-3: ORM + ensure 补 meta
|
||||
core/skills.py 81
|
||||
core/task.py 82 ← §7 B Step 3: PG-backed TaskState,去 cwd
|
||||
core/memory.py 76
|
||||
core/export_docx.py 379 ← §7 B Step 2-4: task_id 升一等
|
||||
core/export_docx.py 383 ← §7 B Step 2-4 + from_db_path 还原 + task_dir Optional
|
||||
core/storage/__init__.py 27 ← §7 B Step 1-3
|
||||
core/storage/engine.py 80 ← §7 B Step 1
|
||||
core/storage/models.py 124 ← §7 B Step 1
|
||||
core/storage/utils.py 139 ← §7 B Step 3-6: +upsert_task/update_task/check_no_subtask
|
||||
core/storage/utils.py 136 ← check_no_subtask 改 Python 端归一
|
||||
tools/base.py 34
|
||||
tools/fs.py 182
|
||||
tools/shell.py 94
|
||||
tools/run_python.py 84
|
||||
tools/skill_tool.py 45
|
||||
main.py 277 ← §7 B Step 4-6: +is_managed_task_dir / task_dir_arg / no-subtask check
|
||||
main.py 285 ← +to_db_path 写 / from_db_path 读
|
||||
cli.py 558 ← §7 B Step 4 / Phase G G1: --task-dir / web 子命令
|
||||
db/migrations/env.py 61 ← §7 B Step 1
|
||||
db/migrations/versions/
|
||||
0001_initial_schema.py 125 ← §7 B Step 1
|
||||
0002_task_dir_relative.py 61 ← 现有 ROOT-prefix 绝对 → 相对
|
||||
web/__init__.py 5 ← Phase G G1
|
||||
web/app.py 538 ← Phase G G1-G4 + G6/new: 工厂 + list/chat + SSE + /new
|
||||
web/app.py 660 ← /v1/ JSON API + user_id 隔离(D' 过渡 auth)
|
||||
web/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换
|
||||
web/broker.py 88 ← Phase G G4: in-process pub/sub
|
||||
web/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议)
|
||||
web/static/dev.html ~600 ← D' dev SPA(login + 3-pane,vanilla JS)
|
||||
─────────────────────────────────
|
||||
Python 合计 ~3732 行
|
||||
+ web/templates/* ~249 行(base/home/chat/new_task + 5 个 _frag/_send_response)+ web/static/style.css 193 行(不计 Python 主仓库)
|
||||
Python 合计 ~3700 行(+ dev.html ~600 静态)
|
||||
```
|
||||
|
||||
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。
|
||||
|
|
@ -97,11 +106,12 @@ Python 合计 ~3732 行
|
|||
|
||||
## 下一步候选(性价比排序)
|
||||
|
||||
1. **真实 LLM 跑通 G4 + /new 全流程**(~10 分钟)—— smoke 走的是 mock,需在浏览器开:`cli.py web` → `/new` 建 task → 跳 chat → 发"你好" → 看真实流式 → 刷新看历史。
|
||||
2. **§7 Phase G G5 文件浏览 + 上传下载**(~半天)—— `/tasks/{id}/files` 渲染 task_dir 树,upload (multipart)/ download / 删。
|
||||
3. **§7 Phase G G6 剩余打磨**(~半天)—— `/done /abandon` 按钮、`/export` docx 下载、错误 toast、并发 run 互锁(messages idx 冲突)。/new 已提前完成。
|
||||
4. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。
|
||||
5. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
|
||||
6. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`。
|
||||
1. **platform 端起 API 联调**(~?)—— platform 服务端持 `PLATFORM_KEY` 调 `POST /v1/auth/login {user_id, platform_key}` 拿 token,后续走 `Authorization: Bearer <jwt>`。Swagger UI(`http://127.0.0.1:8765/docs`)生成 client stub。
|
||||
2. **dev.html 浏览器手验**(~10 分钟)—— `cli.py web` 起后访问 `http://127.0.0.1:8765/`,login(填 sentinel UUID + PLATFORM_KEY)→ 看 3 栏布局 + 新建 task + 发消息 SSE 流式 + 文件浏览。
|
||||
3. **真 OIDC 接入 + CORS 收紧**(~1 天)—— 把 `/v1/auth/login` 内部从 platform_key 校验换成 OIDC ID token 校验(路由层 Depends 不动);CORS 改成 platform 域名 allowlist。
|
||||
4. **§7 C Executor + sandbox**(~2-3 天)—— D 完工,继续 C。
|
||||
5. **并发 run 互锁**(~2 小时)—— 用户连发两条消息 messages idx UniqueConstraint 在 race 下会冲;PG `SELECT ... FOR UPDATE` 锁 tasks 行,或 advisory lock。
|
||||
6. **§7 E CLI transport 双模式**(~1.5 天)—— `cli.py chat --remote https://...` 走 HTTP 替代 in-process。
|
||||
7. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
|
||||
|
||||
> §7 B 已完工。Phase G 进行中(G1 ✅ G2 ✅ G3 ✅ G4 ✅ G6/new ✅)。剩余路线:G5 + G6 剩余 → C(Executor)→ D(HTTP /v1 + OIDC)→ E(CLI 双模式)→ F(deploy / billing)。
|
||||
> §7 B + D + D'(过渡 auth)主体已完工。剩余路线:真 OIDC → C(Executor)→ E(CLI 双模式)→ F(deploy / billing)。原 Phase G Web UI 路线撤(DESIGN §7.9),UI 改 platform 端实现;`web/static/dev.html` 是开发期单文件 SPA,跟 platform UI 并存不冲突。
|
||||
|
|
|
|||
74
RUN.md
74
RUN.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
|
||||
|
||||
最后更新:2026-05-15(Phase G G4)
|
||||
最后更新:2026-05-15(D' 过渡 auth + dev SPA 落地)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -13,10 +13,16 @@
|
|||
```
|
||||
DEEPSEEK_API_KEY=sk-...
|
||||
ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot
|
||||
# cli.py web 必填(纯 CLI 用不到,只在起 web 时校验)
|
||||
PLATFORM_KEY=<至少 16 字符的随机串,platform 服务端 / dev 浏览器持有,登录时校验>
|
||||
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护>
|
||||
# 可选:覆盖默认 7d
|
||||
# ZCBOT_JWT_TTL_SECONDS=604800
|
||||
```
|
||||
> litellm 在 import 时副作用加载 .env;CLI 入口直接走 `cli.py`,`.env` 会自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`。
|
||||
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里)。
|
||||
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。
|
||||
- **Auth env**(`cli.py web` 必填):`PLATFORM_KEY` + `JWT_SECRET`,任一缺失 web 启动会 fail-fast。生成随机串可用 `python -c "import secrets; print(secrets.token_urlsafe(48))"`。CLI(`chat / tasks / probe / db`)不验,不要这两个 env 也能跑。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -31,7 +37,7 @@ python -m venv .venv
|
|||
|
||||
# 3) DB schema 上车
|
||||
.venv/Scripts/python.exe cli.py db upgrade head
|
||||
.venv/Scripts/python.exe cli.py db current # 应输出 0001 (head)
|
||||
.venv/Scripts/python.exe cli.py db current # 应输出 0002 (head)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -90,20 +96,62 @@ REPL 内命令:`/exit /reset /new /resume [last|<id>] /id /status /done /abandon
|
|||
.venv/Scripts/python.exe cli.py db current
|
||||
```
|
||||
|
||||
### Web UI(§7 Phase G,本地 sentinel user 无 auth)
|
||||
### Web API(§7 D + D' 过渡 auth)
|
||||
|
||||
```bash
|
||||
# 默认 127.0.0.1:8765 启,浏览器开 http://127.0.0.1:8765
|
||||
# 默认 127.0.0.1:8765 启;dev SPA 在 /,Swagger UI 在 /docs
|
||||
.venv/Scripts/python.exe cli.py web
|
||||
|
||||
# 自定义端口 / 监听 0.0.0.0(配合 firewall 慎用,本地形态无 auth)
|
||||
# 自定义端口 / 监听 0.0.0.0(慎用,部署形态走反代不直暴)
|
||||
.venv/Scripts/python.exe cli.py web --port 9000
|
||||
|
||||
# dev:文件改动自动重启(uvicorn 工厂模式 reload)
|
||||
.venv/Scripts/python.exe cli.py web --reload
|
||||
```
|
||||
|
||||
> G1 ✅ 脚手架 + /healthz;G2 ✅ `/` task 列表 + `?status=` filter;G3 ✅ `/tasks/{uuid}` 消息流渲染(markdown-it-py + pygments,tool_call 走 `<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>` |
|
||||
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export |
|
||||
| `NoSubtaskError: task_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 task_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--task-dir`;否则改路径成 sibling(平级) |
|
||||
| `cli.py web` 启动后浏览器开不了 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地形态服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或浏览器配 bypass。`curl` 验通走 `curl --noproxy '*' http://127.0.0.1:8765/healthz`(应返 `ok`) |
|
||||
| `TypeError: unhashable type: 'dict'` from Jinja templating | Starlette 新版签名:`TemplateResponse(request, name, context)`,旧式 `(name, {"request":..., "...":...})` 在 newer Starlette 会把 context dict 当 cache key 炸 |
|
||||
| `cli.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或加 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` → `{"status":"ok"}` |
|
||||
| SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`,nginx ≥ 1.5.6 默认认。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` |
|
||||
| 浏览器 send 后没反应 | 看 console:HTMX 报 connect failed → 看 `/tasks/{tid}/messages` 响应;200 但流不到 → 看 EventSource 状态(devtools Network → EventStream tab) |
|
||||
| `UniqueViolation idx already exists` from messages | 同 task 连续两次快速 POST,messages idx 冲突。**已知 TODO**:G6/D 阶段加 task 级 lock_for_update 或 advisory lock |
|
||||
| platform 端 CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)|
|
||||
| `UniqueViolation idx already exists` from messages | 同 task 并发 POST messages,idx 冲突。**已知 TODO**:加 task 级 `SELECT ... FOR UPDATE` 或 advisory lock(留到 D' / 真发布前) |
|
||||
| `cli.py web` 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写进 `.env` 重起 |
|
||||
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/login` 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
|
||||
| `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env |
|
||||
| dev.html SSE 收不到流(消息发出去但 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`。看浏览器 devtools Network,POST /messages 是否 202 + Network 看 events_url GET 是否 200 + Content-Type 是 text/event-stream;若 401,token 过期了 — logout 重 login |
|
||||
| dev.html 显示 "load failed" 且立刻回登录页 | token 过期或 JWT_SECRET 服务端变了,localStorage 旧 token 失效。已自动跳登录页,重新填 platform_key 即可 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -133,7 +185,7 @@ REPL 内命令:`/exit /reset /new /resume [last|<id>] /id /status /done /abandon
|
|||
- **核心**:`core/loop.py`(ReAct)/ `core/session.py`(PG messages)/ `core/task.py`(PG tasks)/ `core/llm.py`(LiteLLM 封装)
|
||||
- **工具**:`tools/{fs,shell,run_python,skill_tool}.py`
|
||||
- **存储**:`core/storage/{engine,models,utils}.py`(SQLAlchemy 2.x ORM)+ `db/migrations/`(alembic)
|
||||
- **Web**:`web/{app.py, templates/, static/}`(§7 Phase G,FastAPI + Jinja2 + HTMX)
|
||||
- **Web**:`web/{app.py, auth.py, broker.py, sinks.py}`(FastAPI + /v1 JSON API + SSE + PLATFORM_KEY→JWT)+ `web/static/dev.html`(dev SPA,单文件 vanilla JS)
|
||||
- **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile)
|
||||
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
|
||||
- **Workspace**:`workspace/memory/{core.md, extended/}`(跨 task 记忆,FS 永久)/ `workspace/tasks/<uuid>/`(默认派生 task_dir,只放 skill 产物)
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ def _format_args(args_str: str) -> str:
|
|||
# ───────────────────────── Meta 区块 ─────────────────────────
|
||||
|
||||
def _add_meta_block(
|
||||
doc: Document, meta: dict, task_state: dict, n_msgs: int, task_dir: Path
|
||||
doc: Document, meta: dict, task_state: dict, n_msgs: int, task_dir: Optional[Path]
|
||||
) -> None:
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
|
|
@ -202,7 +202,7 @@ def _add_meta_block(
|
|||
("更新时间", updated),
|
||||
("消息数", str(n_msgs)),
|
||||
("Tokens", f"{tp} prompt / {tc} completion / {tp + tc} total"),
|
||||
("Task dir", str(task_dir)),
|
||||
("Task dir", str(task_dir) if task_dir else "(未绑)"),
|
||||
("导出时间", datetime.now().isoformat(timespec="seconds")),
|
||||
]
|
||||
|
||||
|
|
@ -346,11 +346,15 @@ def export_chat_to_docx(
|
|||
|
||||
if task_dir is None:
|
||||
td_str = task_state.get("task_dir", "")
|
||||
if not td_str:
|
||||
raise ValueError(f"task {task_id} 无 task_dir(PG 未存且未传参) —— 无处放 .docx")
|
||||
task_dir = Path(td_str)
|
||||
if td_str:
|
||||
# td_str 是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原 absolute Path
|
||||
from core.paths import from_db_path
|
||||
task_dir = from_db_path(td_str)
|
||||
# else: task_dir 留 None,只在 out_path 也 None 时报错(不能没地方落 .docx)
|
||||
|
||||
if out_path is None:
|
||||
if task_dir is None:
|
||||
raise ValueError(f"task {task_id} 无 task_dir 且未指定 out_path —— 无处放 .docx")
|
||||
out_path = task_dir / f"chat_{task_id}.docx"
|
||||
|
||||
meta = {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import os
|
|||
import time
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import litellm
|
||||
# 跳过启动时从 GitHub 拉 model_prices 的网络请求,直接用 litellm 打包的本地副本。
|
||||
# 必须在 `import litellm` 之前设置,否则 get_model_cost_map() 已经跑过了。
|
||||
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
|
||||
import litellm # noqa: E402
|
||||
from litellm.exceptions import (
|
||||
APIConnectionError,
|
||||
APIError,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select, text, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from .engine import session_scope
|
||||
|
|
@ -109,31 +109,28 @@ def check_no_subtask(
|
|||
拒绝:new 是 existing 的子目录、existing 是 new 的子目录。
|
||||
空 task_dir / 仅 whitespace 跳过(legacy / 未绑项目)。
|
||||
|
||||
分隔符容差:Windows `\\` 和 Linux `/` 在 LIKE 前统一替换成 `/`,跨 OS 不漏判。
|
||||
`task_dir` 入参既可以是 db 形态(相对 ROOT)也可以是 absolute str,内部统一用
|
||||
`from_db_path` 归一到 absolute posix 后再比前缀;DB 里行的两种形态同样归一。
|
||||
数量小(per user 几十量级),全量拉到 Python 端比对,不在 SQL 里拼分隔符 / 前缀。
|
||||
"""
|
||||
if not task_dir or not task_dir.strip():
|
||||
return
|
||||
td_norm = task_dir.replace("\\", "/")
|
||||
# 用 bind 参数传 backslash,绕开 SQL 字符串转义陷阱
|
||||
sql = text(
|
||||
"SELECT task_id, task_dir FROM tasks "
|
||||
"WHERE user_id = :uid "
|
||||
" AND task_dir <> '' "
|
||||
" AND replace(task_dir, :bs, '/') <> :td "
|
||||
" AND ("
|
||||
" :td LIKE replace(task_dir, :bs, '/') || '/%' "
|
||||
" OR replace(task_dir, :bs, '/') LIKE :td || '/%'"
|
||||
" ) "
|
||||
"LIMIT 1"
|
||||
)
|
||||
with session_scope() as s:
|
||||
row = s.execute(
|
||||
sql, {"uid": str(user_id), "td": td_norm, "bs": "\\"}
|
||||
).first()
|
||||
if row is None:
|
||||
from core.paths import from_db_path
|
||||
|
||||
new_abs = from_db_path(task_dir).as_posix()
|
||||
if not new_abs:
|
||||
return
|
||||
existing_id, existing_dir = row
|
||||
raise NoSubtaskError(
|
||||
f"task_dir {task_dir!r} 与已有 task {str(existing_id)[:8]} 的 "
|
||||
f"task_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 task_dir"
|
||||
)
|
||||
with session_scope() as s:
|
||||
rows = s.execute(
|
||||
select(Task.task_id, Task.task_dir)
|
||||
.where(Task.user_id == user_id, Task.task_dir != "")
|
||||
).all()
|
||||
for existing_id, existing_dir in rows:
|
||||
existing_abs = from_db_path(existing_dir).as_posix()
|
||||
if not existing_abs or existing_abs == new_abs:
|
||||
continue
|
||||
if new_abs.startswith(existing_abs + "/") or existing_abs.startswith(new_abs + "/"):
|
||||
raise NoSubtaskError(
|
||||
f"task_dir {task_dir!r} 与已有 task {str(existing_id)[:8]} 的 "
|
||||
f"task_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 task_dir"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
22
main.py
|
|
@ -19,6 +19,7 @@ from core.capabilities import ModelCapabilities
|
|||
from core.llm import LLM
|
||||
from core.loop import AgentLoop
|
||||
from core.memory import memory_block
|
||||
from core.paths import ROOT, from_db_path, to_db_path
|
||||
from core.session import Session
|
||||
from core.sinks import ConsoleEventSink
|
||||
from core.skills import SkillRegistry
|
||||
|
|
@ -29,8 +30,6 @@ from tools.run_python import RunPythonTool
|
|||
from tools.shell import ShellTool
|
||||
from tools.skill_tool import LoadSkillTool
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {}
|
||||
|
|
@ -108,8 +107,14 @@ def resolve_task_id(
|
|||
select(Task.task_dir).where(Task.task_id == tid)
|
||||
).scalar_one_or_none() or ""
|
||||
|
||||
chosen = task_dir_arg.strip() if task_dir_arg else db_dir
|
||||
fs_dir = Path(chosen).expanduser().resolve() if chosen else _default_task_dir(workspace_dir, tid)
|
||||
if task_dir_arg and task_dir_arg.strip():
|
||||
# 用户显式覆盖(允许 resume 时改绑路径,调用方需自行 UPSERT 持久化)
|
||||
fs_dir = Path(task_dir_arg).expanduser().resolve()
|
||||
elif db_dir:
|
||||
# DB 存的是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原绝对
|
||||
fs_dir = from_db_path(db_dir)
|
||||
else:
|
||||
fs_dir = _default_task_dir(workspace_dir, tid)
|
||||
return tid, fs_dir
|
||||
|
||||
tid = uuid4()
|
||||
|
|
@ -207,11 +212,14 @@ def build_agent(
|
|||
system_prompt = _build_system_prompt(cfg, skills, workspace_dir, tool_base, task_dir)
|
||||
|
||||
now_iso = datetime.now().isoformat(timespec="seconds")
|
||||
# meta["task_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row
|
||||
# 把它直接落 PG tasks.task_dir,所以这里就转好。文件系统操作仍用上面的 task_dir(absolute)。
|
||||
task_dir_db = to_db_path(task_dir)
|
||||
meta = {
|
||||
"id": sid,
|
||||
"created_at": now_iso,
|
||||
"cwd": str(tool_base),
|
||||
"task_dir": str(task_dir),
|
||||
"task_dir": task_dir_db,
|
||||
"model": caps.model_id,
|
||||
"model_profile": model,
|
||||
"mode": mode,
|
||||
|
|
@ -226,7 +234,7 @@ def build_agent(
|
|||
# tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里
|
||||
# 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令)
|
||||
task_state = TaskState(
|
||||
task_id=sid, task_dir=str(task_dir),
|
||||
task_id=sid, task_dir=task_dir_db,
|
||||
mode=mode, description=description, status="active",
|
||||
model=caps.model_id, model_profile=model,
|
||||
)
|
||||
|
|
@ -235,7 +243,7 @@ def build_agent(
|
|||
# 懒创建:TaskState 仅内存。tasks 行在首条 user 消息 append 时由
|
||||
# ensure_local_task_row 占位 INSERT;首次 sync_task_tokens 或 /done /desc 走 upsert 覆盖。
|
||||
task_state = TaskState(
|
||||
task_id=sid, task_dir=str(task_dir),
|
||||
task_id=sid, task_dir=task_dir_db,
|
||||
mode=mode, description=description, status="active",
|
||||
model=caps.model_id, model_profile=model,
|
||||
reasoning_effort=caps.default_reasoning_effort or "",
|
||||
|
|
|
|||
|
|
@ -16,12 +16,8 @@ sqlalchemy>=2.0.0
|
|||
psycopg[binary]>=3.1.0
|
||||
alembic>=1.13.0
|
||||
|
||||
# §7 Phase G: Web UI (FastAPI + Jinja2 + HTMX + 原生 SSE)
|
||||
# §7 Phase G / D: 纯 JSON API(FastAPI + 原生 SSE),前端由 platform 提供
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
jinja2>=3.1.0
|
||||
python-multipart>=0.0.9
|
||||
# G3: server-side markdown 渲染 + 代码 syntax highlight
|
||||
markdown-it-py[linkify]>=3.0.0
|
||||
mdit-py-plugins>=0.4.0
|
||||
pygments>=2.17.0
|
||||
python-multipart>=0.0.9 # files upload multipart 解析
|
||||
pyjwt>=2.8.0 # /v1/auth/login HS256 token mint/verify(§7 D' 过渡形态)
|
||||
|
|
|
|||
912
web/app.py
912
web/app.py
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|
||||
|
|
@ -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 */
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<div class="msg-error">
|
||||
<span class="err-tag">error</span>
|
||||
<span>{{ msg }}</span>
|
||||
</div>
|
||||
|
|
@ -1 +0,0 @@
|
|||
<div class="body">{{ html | safe }}</div>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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/<uuid>/</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 %}
|
||||
Loading…
Reference in New Issue