From 91202b61721bfd595dd55dc605eecf234c48308e Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 14 May 2026 13:37:54 +0800 Subject: [PATCH] =?UTF-8?q?core(=C2=A77=20Phase=20G=20G1):=20Web=20UI=20?= =?UTF-8?q?=E8=84=9A=E6=89=8B=E6=9E=B6=20+=20cli.py=20web=20=E5=AD=90?= =?UTF-8?q?=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/ 新包:app.py FastAPI 工厂(/ + /healthz + /static),Jinja2 base.html / home.html,minimal style.css。HTMX + HTMX-SSE 走 CDN (无 node 链路,与 §5 Less Scaffolding 一致)。 - cli.py 加 web --host --port --reload 子命令,默认 127.0.0.1:8765, 本地形态 sentinel user 无 auth(Phase D 才上 OIDC)。 - requirements: fastapi / uvicorn[standard] / jinja2 / python-multipart (multipart 为 G5 文件上传留)。 - Starlette 新签名踩坑:TemplateResponse(request, name, context), 旧式塞 context 里会让 jinja 用 dict 当 cache key 炸 unhashable,记 RUN.md 故障兜底。 - Linux portability:模板 path 显示约定 .as_posix();SSE 头 G4 上时 带 X-Accel-Buffering: no(nginx 反代友好)。`cli.py web` 在 .venv/Scripts/python.exe(Win)/ .venv/bin/python(Linux)走同一路径。 Smoke 四路径(in-process via Starlette TestClient)全绿:/healthz → "ok" / / → 1063B(title + static + version)/ /static/style.css → 1624B / /nonexistent → 404。`cli.py web --help` 子命令注册 OK。 Co-Authored-By: Claude Opus 4.7 (1M context) --- PROGRESS.md | 26 +++++++++++------- RUN.md | 22 +++++++++++++-- cli.py | 20 ++++++++++++++ requirements.txt | 6 +++++ web/__init__.py | 5 ++++ web/app.py | 40 ++++++++++++++++++++++++++++ web/static/style.css | 59 +++++++++++++++++++++++++++++++++++++++++ web/templates/base.html | 23 ++++++++++++++++ web/templates/home.html | 19 +++++++++++++ 9 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 web/__init__.py create mode 100644 web/app.py create mode 100644 web/static/style.css create mode 100644 web/templates/base.html create mode 100644 web/templates/home.html diff --git a/PROGRESS.md b/PROGRESS.md index ef8fef9..79cd804 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 -最后更新:2026-05-14(Step 6) +最后更新:2026-05-14(Phase G G1) --- @@ -15,7 +15,7 @@ | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | -| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工(Step 1 基建 ✅;Step 2 Session ORM ✅;Step 3 TaskState ORM ✅;Step 4 task_dir 双形态 ✅;Step 6 no-subtask 校验 ✅;Step 5 migrate-from-fs 取消)。下一阶 C(Executor + sandbox)或 Phase G(Web UI)。 | +| §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) 待。 | --- @@ -34,6 +34,7 @@ - **05-14 / §7 B Step 3 TaskState ORM**:`core/task.py` 重写,TaskState dataclass 保留为内存 DTO 但落地走 PG —— `save()` 调 `upsert_task`(INSERT ON CONFLICT DO UPDATE,显式 set `updated_at=func.now()`),`load(task_id)` 走 SELECT;**字段去掉 `cwd`**(改读 task_dir,§7 SaaS task_dir-as-identity)。`state.json` 文件**全面废除**,task_dir 只承担 skill 产物。`core/storage/utils.py` 加 `upsert_task` / `update_task` 工具。`main.py::sync_task_tokens` 改 `update_task(tokens_p,tokens_c)` 单字段 UPDATE(ORM-level update 自带 onupdate=func.now())。`core/session.py::Session.append` 的 ensure 调用补传 `mode/description/reasoning_effort`,避免首次 INSERT 后 _list_task_rows 看到空 meta。`cli.py` 全字段从 ORM Task 列读;`_cleanup_if_empty` 去 state.json 特例(任何 FS 文件 / 子目录都算实质痕迹);`/done /abandon /desc` 走 PG。`core/export_docx.py` meta 改从 `TaskState.load(tid)` 读(asdict 拿到 dict),去 CWD 字段。端到端 smoke:storage UPSERT/UPDATE round-trip + build_agent 懒创建 + Session.append 自动 INSERT 完整 meta + sync_task_tokens 局部 UPDATE + task_state.save UPSERT 保留 task_dir/tokens + export → .docx 37KB 全绿。 - **05-14 / §7 B Step 4 task_dir 双形态**:CLI `chat --task-dir ` 支持用户显式指定项目目录(§7.1 task-primary + dir 副视图心智模型落地)—— 留空走默认派生 `workspace/tasks//`,显式走用户路径(绝对或相对 cwd,Path.resolve())。`main.py::resolve_task_id` 增 `task_dir_arg`;resume 时从 PG `tasks.task_dir` 读(`SELECT task_dir WHERE task_id=?`),空则降级默认派生。新增 `is_managed_task_dir(td, ws)` 判断是否在 `workspace/tasks//` 模板下,作 `_cleanup_if_empty` 保护开关 —— 用户自指定的项目目录**绝不 rmtree**(可能含用户已有文件);DB 行该删还是删。`core/export_docx.py::export_chat_to_docx` 重构:task_id 升一等参数(从 `task_dir.name` 提取改入参传入),task_dir 留空时自动从 PG 读,支持用户目录(非 UUID 命名)正常导出。cli `/export` 与 `cli.py export` 子命令均改走 `_resolve_uuid_or_prefix` + task_id 直传。Smoke 4 路径全绿:default-derived(managed=True, cleanup rmtree)/ --task-dir(managed=False, FS preserved)/ resume reads DB / export 自动 PG 查路径。 - **05-14 / §7 B Step 6 no-subtask 校验**:`core/storage/utils.py::check_no_subtask(task_dir, user_id=SENTINEL)` —— 同 user 下查 `new LIKE existing||'/%' OR existing LIKE new||'/%'`(`task_dir != new` 过滤掉同 task_dir 同项目多对话场景)。冲突抛 `NoSubtaskError`(`ValueError` 子类),消息带冲突 task 的 UUID 前 8 位 + 它的 task_dir。**分隔符容差**:SQL 里 `replace(task_dir, :bs, '/')` 把存的 Windows `\` 在比较前归一,新值也 `replace('\\', '/')`,跨 OS / 历史数据混合分隔符不漏判;`bs` 通过 bind 参数传(绕开 SQL 字符串转义陷阱)。空 / whitespace `task_dir` 直接 return(legacy / 未绑项目)。`main.py::build_agent` 在 `resolve_task_id` 后、TaskState 构造前调,`if not resume` 单层闸 —— resume 跳过(目录改名走未来 Folder API cascade,这里只拦新建)。`cli.py` 三处 build_agent 调用现有 try/except 直接接住 NoSubtaskError 并友好打印。Smoke 全绿:同 dir 允许 / child 拒 / parent 拒 / sibling 允许 / `proj_a_other` 不误中 `proj_a`(因为用 `/%` 而非 `%`)/ 空跳过 / Win `\` 子目录拒 / 混合分隔符(`\` 存 + `/` 查)仍拒 / build_agent 端到端三分支(child raise / same pass / resume bypass)。 +- **05-14 / §7 Phase G G1 Web UI 脚手架**:新增 `web/` 包(`app.py` FastAPI 工厂 + `templates/{base,home}.html` + `static/style.css`),`cli.py web --host --port --reload` 子命令(默认 127.0.0.1:8765,本地形态 sentinel user 无 auth,Phase D 才上 OIDC)。模板用 Jinja2 + HTMX/HTMX-SSE 走 CDN(无 node 链路),`base.html` 留 `{% block nav %}` 让 G2+ 扩。**Starlette 新版 `TemplateResponse` 签名**:`(request, name, context)`,旧式塞 context 里会让 jinja 用 dict 当 cache key 报 `unhashable type`,踩过修了。requirements 加 `fastapi>=0.111 uvicorn[standard] jinja2>=3.1 python-multipart`(后者为 G5 文件上传留)。Smoke 四路径全绿(in-process via Starlette `TestClient`):`/healthz` → "ok" / `/` → 1063B(title + static link + version) / `/static/style.css` → 1624B / `/nonexistent` → 404。**Linux portability 顺手**:模板里 path 显示约定用 `Path.as_posix()`(G3+ 模板落地);SSE 响应头 G4 上时带 `X-Accel-Buffering: no`(nginx 反代友好)。 --- @@ -73,12 +74,15 @@ 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 -cli.py 538 ← §7 B Step 4: --task-dir / cleanup 保护用户目录 +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 +web/__init__.py 5 ← Phase G G1 +web/app.py 40 ← Phase G G1: FastAPI 工厂 ───────────────────────────────── -Python 合计 ~3101 行 +Python 合计 ~3146 行 ++ web/templates/{base,home}.html ~40 行 + web/static/style.css 64 行(不计 Python 主仓库) ``` 加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 @@ -87,10 +91,12 @@ Python 合计 ~3101 行 ## 下一步候选(性价比排序) -1. **§7 Phase G Web UI 简洁版**(~2-3 天)—— FastAPI + Jinja2 + HTMX + SSE,task list / chat / folder tree / 文件上传下载;依赖 D(HTTP /v1)的 SSE 端点,与 E 无强序。Phase G 也可先上 task list + chat(读 PG)再补 folder tree。 -2. **§7 C Executor + sandbox**(~2-3 天)—— `run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入。 -3. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 -4. **Phase 7 更多 skill / 模型档案**(持续)。 -5. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`。 +1. **§7 Phase G G2 task list 页**(~小半天)—— `/` 渲染最近 task,filter by status + 分页,链 chat 页。复用 `_list_task_rows`。 +2. **§7 Phase G G3 chat 只读页**(~小半天)—— `/tasks/{id}` 渲染 PG messages,Markdown server-side 渲染。 +3. **§7 Phase G G4 chat 发送 + SSE**(~1 天)—— `WebEventSink` 把 §7 A 的 `sink.emit` 推 text/event-stream,HTMX `sse-swap` 追加 DOM。**核心一步**。 +4. **§7 Phase G G5 文件浏览 + G6 打磨**(~半天 + 半天)—— task_dir 树 / upload / download / 错误 toast / `/done /abandon` 按钮 / `/export` 链接。 +5. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。 +6. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 +7. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`。 -> §7 B 已完工(Step 1-4 + Step 6,Step 5 取消)。剩余路线:C(Executor)/ D(HTTP API)/ E(auth + UI)/ G(Web UI)/ F(deploy / billing)。 +> §7 B 已完工。Phase G 进行中(G1 ✅)。剩余路线:G2-G6 → C(Executor)→ D(HTTP /v1 + OIDC)→ E(CLI 双模式)→ F(deploy / billing)。 diff --git a/RUN.md b/RUN.md index f303285..96c9faf 100644 --- a/RUN.md +++ b/RUN.md @@ -2,7 +2,7 @@ > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。 -最后更新:2026-05-14(Step 6) +最后更新:2026-05-14(Phase G G1) --- @@ -90,6 +90,21 @@ REPL 内命令:`/exit /reset /new /resume [last|] /id /status /done /abandon .venv/Scripts/python.exe cli.py db current ``` +### Web UI(§7 Phase G,本地 sentinel user 无 auth) + +```bash +# 默认 127.0.0.1:8765 启,浏览器开 http://127.0.0.1:8765 +.venv/Scripts/python.exe cli.py web + +# 自定义端口 / 监听 0.0.0.0(配合 firewall 慎用,本地形态无 auth) +.venv/Scripts/python.exe cli.py web --port 9000 + +# dev:文件改动自动重启(uvicorn 工厂模式 reload) +.venv/Scripts/python.exe cli.py web --reload +``` + +> G1 阶段只有 / + /healthz + /static;G2-G6 渐进上 task list / chat / SSE / 文件浏览。Linux:`.venv/bin/python cli.py web` 一致。 + --- ## 故障兜底 @@ -104,15 +119,18 @@ REPL 内命令:`/exit /reset /new /resume [last|] /id /status /done /abandon | `--task-dir` 指定后 `/exit` 没清 task_dir | 设计如此 —— 用户路径绝不 rmtree;DB 行该删还是删。要彻底删手动 `rm -rf ` | | Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export | | `NoSubtaskError: task_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 task_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--task-dir`;否则改路径成 sibling(平级) | +| `cli.py web` 启动后浏览器开不了 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地形态服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或浏览器配 bypass。`curl` 验通走 `curl --noproxy '*' http://127.0.0.1:8765/healthz`(应返 `ok`) | +| `TypeError: unhashable type: 'dict'` from Jinja templating | Starlette 新版签名:`TemplateResponse(request, name, context)`,旧式 `(name, {"request":..., "...":...})` 在 newer Starlette 会把 context dict 当 cache key 炸 | --- ## 关键路径与文件 -- **入口**:`cli.py`(REPL + 子命令)→ `main.py::build_agent`(装配) +- **入口**:`cli.py`(REPL + `chat / tasks / probe / db / web` 子命令)→ `main.py::build_agent`(装配) - **核心**:`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) - **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile) - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) - **Workspace**:`workspace/memory/{core.md, extended/}`(跨 task 记忆,FS 永久)/ `workspace/tasks//`(默认派生 task_dir,只放 skill 产物) diff --git a/cli.py b/cli.py index 4f7480a..9d5c4d4 100644 --- a/cli.py +++ b/cli.py @@ -534,5 +534,25 @@ def probe(model: str, long_context: bool) -> None: console.print("\n[ok]全部能力声明与实测一致。[/ok]") +@cli.command() +@click.option("--host", default="127.0.0.1", show_default=True, + help="监听地址。本地形态默认 127.0.0.1,不对外暴露") +@click.option("--port", default=8765, show_default=True, type=int, + help="监听端口") +@click.option("--reload/--no-reload", default=False, + help="dev:文件改动自动重启(uvicorn 工厂模式)") +def web(host: str, port: int, reload: bool) -> None: + """启动 Web UI(§7 Phase G,本地形态 sentinel user 无 auth)。""" + import uvicorn + + if reload: + # reload 模式需要 import string + factory,uvicorn 才能监听文件 + uvicorn.run("web.app:create_app", host=host, port=port, + reload=True, factory=True, log_level="info") + else: + from web.app import create_app + uvicorn.run(create_app(), host=host, port=port, log_level="info") + + if __name__ == "__main__": cli() diff --git a/requirements.txt b/requirements.txt index 8817068..fcec746 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,9 @@ markitdown[pdf,docx,pptx,xlsx]>=0.0.1 sqlalchemy>=2.0.0 psycopg[binary]>=3.1.0 alembic>=1.13.0 + +# §7 Phase G: Web UI (FastAPI + Jinja2 + HTMX + 原生 SSE) +fastapi>=0.111.0 +uvicorn[standard]>=0.30.0 +jinja2>=3.1.0 +python-multipart>=0.0.9 diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..6971dcb --- /dev/null +++ b/web/__init__.py @@ -0,0 +1,5 @@ +"""§7 Phase G: Web UI 简洁版(FastAPI + Jinja2 + HTMX + 原生 SSE)。 + +入口:`cli.py web` → `web.app.create_app()` → uvicorn 起。 +本地形态固定 sentinel user(无 auth);Phase D 加 OIDC 后才有真正用户态。 +""" diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..85e83f8 --- /dev/null +++ b/web/app.py @@ -0,0 +1,40 @@ +"""FastAPI app 工厂。G1 = 脚手架 + 占位 /;G2 起接 PG。 + +设计: +- 单 FastAPI 进程,模板走 Jinja2,静态走 StaticFiles +- 模板里 path 显示一律 `Path.as_posix()`,Win / Linux 看到统一形态 +- SSE 在 G4 加,响应头会带 `X-Accel-Buffering: no`(nginx 反代友好) +- 本地形态 sentinel user 固定;Phase D 加 OIDC 之后才有真正 user 态 +""" +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +WEB_ROOT = Path(__file__).resolve().parent +TEMPLATES_DIR = WEB_ROOT / "templates" +STATIC_DIR = WEB_ROOT / "static" + + +def create_app() -> FastAPI: + """FastAPI 工厂。uvicorn --reload 模式需要工厂签名(factory=True)。""" + app = FastAPI(title="zcbot web", version="0.1") + templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + @app.get("/", response_class=HTMLResponse) + def home(request: Request): + # Starlette 新签名:request 升一等位置参数,context 不再带 request + return templates.TemplateResponse( + request, "home.html", {"version": app.version} + ) + + @app.get("/healthz", response_class=HTMLResponse) + def healthz(): + return HTMLResponse("ok") + + return app diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..e534701 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,59 @@ +/* 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; } +h2 { font-size: 1.1rem; margin: 1.5rem 0 .5rem; } +.lead { font-size: 1rem; color: #444; } +.status ul { padding-left: 1.25rem; } +.status li { margin: .25rem 0; } diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..8c6b77c --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,23 @@ + + + + + + {% block title %}zcbot{% endblock %} + + + + + +
+ zcbot + + local +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/web/templates/home.html b/web/templates/home.html new file mode 100644 index 0000000..9dc8682 --- /dev/null +++ b/web/templates/home.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}zcbot · home{% endblock %} +{% block content %} +

zcbot web v{{ version }}

+

+ §7 Phase G 脚手架已就位。task list / chat / SSE 流式回复 / 文件上传下载将在 G2-G6 上线。 +

+
+

状态

+
    +
  • G1 ✅ 脚手架 + cli.py web
  • +
  • G2 🚧 task list 页
  • +
  • G3 🚧 chat 只读页
  • +
  • G4 🚧 chat 发送 + SSE 流式
  • +
  • G5 🚧 文件浏览 / 上传 / 下载
  • +
  • G6 🚧 打磨 + export
  • +
+
+{% endblock %}