diff --git a/PROGRESS.md b/PROGRESS.md index 79cd804..582b687 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 -最后更新:2026-05-14(Phase G G1) +最后更新:2026-05-14(Phase G G2) --- @@ -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 完工;**Phase G Web UI 进行中(G1 脚手架 ✅;G2 task list ✅;G3 chat 只读 待;G4 SSE 流式;G5 文件浏览;G6 打磨)**。下一阶 C(Executor) / D(HTTP /v1) 待。 | --- @@ -35,6 +35,7 @@ - **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 反代友好)。 +- **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。 --- @@ -79,10 +80,10 @@ 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 工厂 +web/app.py 133 ← Phase G G1-G2: 工厂 + list_tasks + / + /tasks/{id} 占位 ───────────────────────────────── -Python 合计 ~3146 行 -+ web/templates/{base,home}.html ~40 行 + web/static/style.css 64 行(不计 Python 主仓库) +Python 合计 ~3239 行 ++ web/templates/{base,home,task_placeholder}.html ~103 行 + web/static/style.css 88 行(不计 Python 主仓库) ``` 加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 @@ -91,12 +92,11 @@ Python 合计 ~3146 行 ## 下一步候选(性价比排序) -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`。 +1. **§7 Phase G G3 chat 只读页**(~小半天)—— `/tasks/{id}` 渲染 PG messages,Markdown server-side 渲染(`markdown` 或 `mistune`);tool_call / tool_result 折叠展示。 +2. **§7 Phase G G4 chat 发送 + SSE**(~1 天)—— `WebEventSink` 把 §7 A 的 `sink.emit` 推 text/event-stream(响应头带 `X-Accel-Buffering: no`),HTMX `sse-swap` 追加 DOM。**核心一步**,需异步跑 `AgentLoop` —— 走 `asyncio.to_thread` 或在 BG task 启 sync runner + 队列。 +3. **§7 Phase G G5 文件浏览 + G6 打磨**(~半天 + 半天)—— task_dir 树 / upload / download / 错误 toast / `/new` `/done /abandon` 按钮 / `/export` 链接。 +4. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。 +5. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 +6. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`。 -> §7 B 已完工。Phase G 进行中(G1 ✅)。剩余路线:G2-G6 → C(Executor)→ D(HTTP /v1 + OIDC)→ E(CLI 双模式)→ F(deploy / billing)。 +> §7 B 已完工。Phase G 进行中(G1 ✅ G2 ✅)。剩余路线:G3-G6 → C(Executor)→ D(HTTP /v1 + OIDC)→ E(CLI 双模式)→ F(deploy / billing)。 diff --git a/RUN.md b/RUN.md index 96c9faf..d4d1a09 100644 --- a/RUN.md +++ b/RUN.md @@ -2,7 +2,7 @@ > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。 -最后更新:2026-05-14(Phase G G1) +最后更新:2026-05-14(Phase G G2) --- @@ -103,7 +103,7 @@ REPL 内命令:`/exit /reset /new /resume [last|] /id /status /done /abandon .venv/Scripts/python.exe cli.py web --reload ``` -> G1 阶段只有 / + /healthz + /static;G2-G6 渐进上 task list / chat / SSE / 文件浏览。Linux:`.venv/bin/python cli.py web` 一致。 +> G1 ✅ 脚手架 + /healthz;G2 ✅ `/` task 列表 + `?status=active|completed|abandoned` filter + `/tasks/{uuid}` 占位(G3 来填消息流);G3-G6 待。task_dir 显示统一 forward-slash(Win 存 `\` 也归一)。Linux:`.venv/bin/python cli.py web` 一致。 --- diff --git a/web/app.py b/web/app.py index 85e83f8..ee43fa3 100644 --- a/web/app.py +++ b/web/app.py @@ -1,36 +1,129 @@ -"""FastAPI app 工厂。G1 = 脚手架 + 占位 /;G2 起接 PG。 +"""FastAPI app 工厂。G1 脚手架 → G2 task list 接 PG → G3+ 渐进上。 设计: - 单 FastAPI 进程,模板走 Jinja2,静态走 StaticFiles -- 模板里 path 显示一律 `Path.as_posix()`,Win / Linux 看到统一形态 +- 模板里 path 显示一律 `replace('\\', '/')`,Win / Linux 看到统一形态 + (`Path.as_posix()` 在 Linux 读 Windows backslash 串时不归一,所以直接 replace) - SSE 在 G4 加,响应头会带 `X-Accel-Buffering: no`(nginx 反代友好) - 本地形态 sentinel user 固定;Phase D 加 OIDC 之后才有真正 user 态 """ from __future__ import annotations from pathlib import Path +from typing import Any, Optional +from uuid import UUID from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from sqlalchemy import func, select + +from core.storage import session_scope +from core.storage.models import Message, Task WEB_ROOT = Path(__file__).resolve().parent TEMPLATES_DIR = WEB_ROOT / "templates" STATIC_DIR = WEB_ROOT / "static" +STATUS_FILTERS = ("active", "completed", "abandoned") + + +def _norm_path(p: str) -> str: + """跨 OS 显示归一:backslash → forward slash。Win 存 `\\`、Linux 存 `/`,显示统一 `/`。""" + return (p or "").replace("\\", "/") + + +def list_tasks(limit: int = 50, status: Optional[str] = None) -> list[dict[str, Any]]: + """Tasks 列表(updated_at 降序),含 messages 计数。 + + 返回 dict 列表,模板友好;cli.py 的 `_list_task_rows` 自留 tuple 形态不变 + (CLI / Web 数据形状不一定一致 — 等真有 schema 变更同步成本时再抽。) + """ + if status and status not in STATUS_FILTERS: + status = None # 无效 status 静默忽略,等价 all + with session_scope() as s: + q = ( + select( + Task.task_id, Task.updated_at, Task.created_at, Task.status, + Task.mode, Task.model, Task.model_profile, + Task.tokens_prompt, Task.tokens_completion, Task.description, + Task.task_dir, + ) + .order_by(Task.updated_at.desc()) + ) + if status: + q = q.where(Task.status == status) + rows_db = s.execute(q.limit(limit)).all() + msg_counts = dict(s.execute( + select(Message.task_id, func.count()).group_by(Message.task_id) + ).all()) + + result = [] + for r in rows_db: + tid = r.task_id + result.append({ + "task_id": str(tid), + "task_id_short": str(tid)[:8], + "updated_at": r.updated_at, + "created_at": r.created_at, + "status": r.status, + "mode": r.mode or "", + "model_label": r.model_profile or r.model or "", + "tokens": (r.tokens_prompt or 0) + (r.tokens_completion or 0), + "n_messages": msg_counts.get(tid, 0), + "description": r.description or "", + "task_dir": _norm_path(r.task_dir or ""), + }) + return result + def create_app() -> FastAPI: """FastAPI 工厂。uvicorn --reload 模式需要工厂签名(factory=True)。""" - app = FastAPI(title="zcbot web", version="0.1") + app = FastAPI(title="zcbot web", version="0.2") 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 + def home(request: Request, status: Optional[str] = None, limit: int = 50): + tasks = list_tasks(limit=limit, status=status) return templates.TemplateResponse( - request, "home.html", {"version": app.version} + request, "home.html", + { + "version": app.version, + "tasks": tasks, + "status": status or "", + "limit": limit, + "filters": STATUS_FILTERS, + }, + ) + + @app.get("/tasks/{task_id}", response_class=HTMLResponse) + def task_detail(request: Request, task_id: str): + """G2 占位:UUID 校验 + "G3 进行中" 提示。G3 落地后替换为消息渲染。""" + try: + tid = UUID(task_id) + except ValueError: + return HTMLResponse( + f"invalid task id: {task_id!r}", status_code=404 + ) + # 顺便 DB 里查一下 task 是否存在 — 不存在直接 404,避免 G3 上之前给假象 + with session_scope() as s: + row = s.execute( + select(Task.task_id, Task.description, Task.task_dir, Task.status) + .where(Task.task_id == tid) + ).first() + if row is None: + return HTMLResponse(f"task not found: {tid}", status_code=404) + return templates.TemplateResponse( + request, "task_placeholder.html", + { + "task_id": str(tid), + "task_id_short": str(tid)[:8], + "description": row.description or "", + "task_dir": _norm_path(row.task_dir or ""), + "status": row.status, + }, ) @app.get("/healthz", response_class=HTMLResponse) diff --git a/web/static/style.css b/web/static/style.css index e534701..cddf14e 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -52,8 +52,37 @@ small.muted, .muted { color: var(--muted); font-weight: normal; } padding: 1.5rem 1.25rem; } -h1 { font-size: 1.5rem; margin: 0 0 .5rem; } +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; } -.status ul { padding-left: 1.25rem; } -.status li { margin: .25rem 0; } +.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); } +.btn:hover { text-decoration: none; background: #f4f4f4; } + +/* 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; } + +/* placeholder pages */ +.placeholder-note { padding: 1rem 1.25rem; background: var(--accent-soft); border-left: 3px solid var(--accent); margin-top: 1rem; border-radius: 3px; } +.placeholder-note p { margin: .25rem 0; } diff --git a/web/templates/home.html b/web/templates/home.html index 9dc8682..f17cb96 100644 --- a/web/templates/home.html +++ b/web/templates/home.html @@ -1,19 +1,60 @@ {% 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
  • -
-
+{% block title %}zcbot · tasks{% endblock %} +{% block nav %} +tasks +{% endblock %} +{% block content %} +
+

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

+
+ + {% if status %}reset{% endif %} +
+
+ +{% if not tasks %} +

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

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

task {{ task_id_short }} + {{ status }} +

+
+ +{% if description %}

{{ description }}

{% endif %} +{% if task_dir %}

{{ task_dir }}

{% endif %} + +
+

G3 进行中 — 这页将渲染该 task 的消息流(从 PG messages 表读,markdown server-side 渲染)。

+

完整 task_id: {{ task_id }}

+
+{% endblock %}