core(§7 Phase G G2): task list 页 + /tasks/{id} 占位

- web/app.py 加 list_tasks(limit, status):PG tasks + messages count,
  updated_at 降序,返回模板友好 dict。Web 与 cli.py 数据形状不一致
  (CLI 用 tuple,Web 用 dict),不预付抽象,等真有 schema 同步成本
  再抽。
- / 路由换成 task 列表,支持 ?status=active|completed|abandoned
  filter(无效值静默降级 all)。/tasks/{task_id} 占位路由:UUID 解析
  失败 → 404,DB 不存在 → 404,有效则渲 task_placeholder.html(G3 来填
  消息流)。
- Linux portability:_norm_path() 显示前 replace('\','/') 把 Win
  存的 backslash 归一,Win Path.resolve()-str → "D:/..." 显示;Linux
  forward-slash 原路通过。Path.as_posix() 在 Linux 读 Win backslash
  串时不归一,所以选 replace 而非 as_posix。
- 模板 home.html 表格(id/updated/status/mode/model/msgs/tokens/desc-dir)
  + status badge 配色(active 绿 / completed 蓝 / abandoned 灰) +
  filter 表单 + 空态文案。task_placeholder.html 渲染 G3 提示。CSS
  tabular-nums 数字对齐 / hover 高亮 / accent-soft note。

Smoke 18 路径全绿(in-process Starlette TestClient):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。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-14 15:52:11 +08:00
parent 91202b6172
commit 80a658eba4
6 changed files with 224 additions and 41 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-14(Phase G G1) 最后更新:2026-05-14(Phase G G2)
--- ---
@ -15,7 +15,7 @@
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill |
| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**Phase G Web UI 进行中(G1 脚手架 ✅;G2 task list 待;G3 chat 只读;G4 SSE 流式;G5 文件浏览;G6 打磨)**。下一阶 C(Executor) / D(HTTP /v1) 待。 | | §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**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 <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 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 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 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/ db/migrations/versions/
0001_initial_schema.py 125 ← §7 B Step 1 0001_initial_schema.py 125 ← §7 B Step 1
web/__init__.py 5 ← Phase G G1 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 Python 合计 ~3239
+ web/templates/{base,home}.html ~40 行 + web/static/style.css 64 行(不计 Python 主仓库) + 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 行。 加 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` 1. **§7 Phase G G3 chat 只读页**(~小半天)—— `/tasks/{id}` 渲染 PG messages,Markdown server-side 渲染(`markdown` 或 `mistune`);tool_call / tool_result 折叠展示。
2. **§7 Phase G G3 chat 只读页**(~小半天)—— `/tasks/{id}` 渲染 PG messages,Markdown server-side 渲染。 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 G4 chat 发送 + SSE**(~1 天)—— `WebEventSink` 把 §7 A 的 `sink.emit` 推 text/event-stream,HTMX `sse-swap` 追加 DOM。**核心一步**。 3. **§7 Phase G G5 文件浏览 + G6 打磨**(~半天 + 半天)—— task_dir 树 / upload / download / 错误 toast / `/new` `/done /abandon` 按钮 / `/export` 链接。
4. **§7 Phase G G5 文件浏览 + G6 打磨**(~半天 + 半天)—— task_dir 树 / upload / download / 错误 toast / `/done /abandon` 按钮 / `/export` 链接。 4. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。
5. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。 5. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
6. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 6. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`
7. **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)。

4
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 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>] /id /status /done /abandon
.venv/Scripts/python.exe cli.py web --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` 一致。 > 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` 一致。
--- ---

View File

@ -1,36 +1,129 @@
"""FastAPI app 工厂。G1 = 脚手架 + 占位 /;G2 起接 PG """FastAPI app 工厂。G1 脚手架 → G2 task list 接 PG → G3+ 渐进上
设计: 设计:
- FastAPI 进程,模板走 Jinja2,静态走 StaticFiles - 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 反代友好) - SSE G4 ,响应头会带 `X-Accel-Buffering: no`(nginx 反代友好)
- 本地形态 sentinel user 固定;Phase D OIDC 之后才有真正 user - 本地形态 sentinel user 固定;Phase D OIDC 之后才有真正 user
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Optional
from uuid import UUID
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates 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 WEB_ROOT = Path(__file__).resolve().parent
TEMPLATES_DIR = WEB_ROOT / "templates" TEMPLATES_DIR = WEB_ROOT / "templates"
STATIC_DIR = WEB_ROOT / "static" 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: def create_app() -> FastAPI:
"""FastAPI 工厂。uvicorn --reload 模式需要工厂签名(factory=True)。""" """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)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def home(request: Request): def home(request: Request, status: Optional[str] = None, limit: int = 50):
# Starlette 新签名:request 升一等位置参数,context 不再带 request tasks = list_tasks(limit=limit, status=status)
return templates.TemplateResponse( 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) @app.get("/healthz", response_class=HTMLResponse)

View File

@ -52,8 +52,37 @@ small.muted, .muted { color: var(--muted); font-weight: normal; }
padding: 1.5rem 1.25rem; 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; } h2 { font-size: 1.1rem; margin: 1.5rem 0 .5rem; }
.lead { font-size: 1rem; color: #444; } .lead { font-size: 1rem; color: #444; }
.status ul { padding-left: 1.25rem; } .mono { font-family: var(--mono); }
.status li { margin: .25rem 0; } .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; }

View File

@ -1,19 +1,60 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}zcbot · home{% endblock %} {% block title %}zcbot · tasks{% endblock %}
{% block content %} {% block nav %}
<h1>zcbot web <small class="muted">v{{ version }}</small></h1> <a href="/" class="active">tasks</a>
<p class="lead"> {% endblock %}
§7 Phase G 脚手架已就位。task list / chat / SSE 流式回复 / 文件上传下载将在 G2-G6 上线。 {% block content %}
</p> <div class="page-head">
<section class="status"> <h1>tasks <small class="muted">最近 {{ tasks|length }} 条{% if status %} · status={{ status }}{% endif %}</small></h1>
<h2>状态</h2> <form class="filters" method="get" action="/">
<ul> <label>status:
<li>G1 ✅ 脚手架 + <code>cli.py web</code></li> <select name="status" onchange="this.form.submit()">
<li>G2 🚧 task list 页</li> <option value="">all</option>
<li>G3 🚧 chat 只读页</li> {% for f in filters %}
<li>G4 🚧 chat 发送 + SSE 流式</li> <option value="{{ f }}"{% if status == f %} selected{% endif %}>{{ f }}</option>
<li>G5 🚧 文件浏览 / 上传 / 下载</li> {% endfor %}
<li>G6 🚧 打磨 + export</li> </select>
</ul> </label>
</section> {% if status %}<a href="/" class="btn">reset</a>{% endif %}
</form>
</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 %} {% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}zcbot · task {{ 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>
</h1>
</div>
{% if description %}<p class="lead">{{ description }}</p>{% endif %}
{% if task_dir %}<p class="muted mono small">{{ task_dir }}</p>{% endif %}
<div class="placeholder-note">
<p><strong>G3 进行中</strong> — 这页将渲染该 task 的消息流(从 PG <code>messages</code> 表读,markdown server-side 渲染)。</p>
<p class="muted">完整 task_id: <code class="mono">{{ task_id }}</code></p>
</div>
{% endblock %}