core(§7 Phase G G1): Web UI 脚手架 + cli.py web 子命令

- 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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-14 13:37:54 +08:00
parent e8dbfa57a5
commit 91202b6172
9 changed files with 208 additions and 12 deletions

View File

@ -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 <path>` 支持用户显式指定项目目录(§7.1 task-primary + dir 副视图心智模型落地)—— 留空走默认派生 `workspace/tasks/<uuid>/`,显式走用户路径(绝对或相对 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/<uuid>/` 模板下,作 `_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)。

22
RUN.md
View File

@ -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>] /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>] /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`(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/<uuid>/`(默认派生 task_dir,只放 skill 产物)

20
cli.py
View File

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

View File

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

5
web/__init__.py Normal file
View File

@ -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 后才有真正用户态
"""

40
web/app.py Normal file
View File

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

59
web/static/style.css Normal file
View File

@ -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; }

23
web/templates/base.html Normal file
View File

@ -0,0 +1,23 @@
<!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 %}{% endblock %}
</nav>
<span class="user-tag" title="本地 sentinel user — Phase D 加 OIDC 之前固定">local</span>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

19
web/templates/home.html Normal file
View File

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}zcbot · home{% endblock %}
{% block content %}
<h1>zcbot web <small class="muted">v{{ version }}</small></h1>
<p class="lead">
§7 Phase G 脚手架已就位。task list / chat / SSE 流式回复 / 文件上传下载将在 G2-G6 上线。
</p>
<section class="status">
<h2>状态</h2>
<ul>
<li>G1 ✅ 脚手架 + <code>cli.py web</code></li>
<li>G2 🚧 task list 页</li>
<li>G3 🚧 chat 只读页</li>
<li>G4 🚧 chat 发送 + SSE 流式</li>
<li>G5 🚧 文件浏览 / 上传 / 下载</li>
<li>G6 🚧 打磨 + export</li>
</ul>
</section>
{% endblock %}