diff --git a/PROGRESS.md b/PROGRESS.md index 582b687..0c1dfdb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 -最后更新:2026-05-14(Phase G G2) +最后更新:2026-05-15(Phase G G3) --- @@ -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) 待。 | --- @@ -36,6 +36,7 @@ - **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。 +- **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 区(`
` / `` / `` / `` / `` 全 GFM 样式),tool_call 用 `` 默认折叠(无 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 含 ``/`tool-badge`/`codehilite`/`` + 空 task 文案 + invalid UUID 404 + util 单测(args_preview / pretty_json / render_md 边界)。版本 0.2 → 0.3。requirements 加 `markdown-it-py[linkify]` / `mdit-py-plugins` / `pygments`。
---
@@ -80,10 +81,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 133 ← Phase G G1-G2: 工厂 + list_tasks + / + /tasks/{id} 占位
+web/app.py 264 ← Phase G G1-G3: 工厂 + list_tasks + chat 渲染 + md/pygments
─────────────────────────────────
-Python 合计 ~3239 行
-+ web/templates/{base,home,task_placeholder}.html ~103 行 + web/static/style.css 88 行(不计 Python 主仓库)
+Python 合计 ~3370 行
++ web/templates/{base,home,chat}.html ~141 行 + web/static/style.css 131 行(不计 Python 主仓库)
```
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。
@@ -92,11 +93,10 @@ Python 合计 ~3239 行
## 下一步候选(性价比排序)
-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`。
+1. **§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 + 队列。
+2. **§7 Phase G G5 文件浏览 + G6 打磨**(~半天 + 半天)—— task_dir 树 / upload / download / 错误 toast / `/new` `/done /abandon` 按钮 / `/export` 链接。
+3. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。
+4. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
+5. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`。
-> §7 B 已完工。Phase G 进行中(G1 ✅ G2 ✅)。剩余路线:G3-G6 → C(Executor)→ D(HTTP /v1 + OIDC)→ E(CLI 双模式)→ F(deploy / billing)。
+> §7 B 已完工。Phase G 进行中(G1 ✅ G2 ✅ G3 ✅)。剩余路线:G4-G6 → C(Executor)→ D(HTTP /v1 + OIDC)→ E(CLI 双模式)→ F(deploy / billing)。
diff --git a/RUN.md b/RUN.md
index d4d1a09..74bbf0f 100644
--- a/RUN.md
+++ b/RUN.md
@@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
-最后更新:2026-05-14(Phase G G2)
+最后更新:2026-05-15(Phase G G3)
---
@@ -103,7 +103,7 @@ REPL 内命令:`/exit /reset /new /resume [last|] /id /status /done /abandon
.venv/Scripts/python.exe cli.py web --reload
```
-> 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` 一致。
+> G1 ✅ 脚手架 + /healthz;G2 ✅ `/` task 列表 + `?status=` filter;G3 ✅ `/tasks/{uuid}` 消息流渲染(markdown-it-py + pygments syntax,tool_call 走 `` 默认折叠);G4-G6 待。task_dir 显示统一 forward-slash(Win 存 `\` 也归一)。Linux:`.venv/bin/python cli.py web` 一致。
---
diff --git a/requirements.txt b/requirements.txt
index fcec746..d312691 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,3 +21,7 @@ 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
diff --git a/web/app.py b/web/app.py
index ee43fa3..a5a9669 100644
--- a/web/app.py
+++ b/web/app.py
@@ -1,14 +1,16 @@
-"""FastAPI app 工厂。G1 脚手架 → G2 task list 接 PG → G3+ 渐进上。
+"""FastAPI app 工厂。G1 脚手架 → G2 task list → G3 chat 只读 → G4+ 渐进上。
设计:
- 单 FastAPI 进程,模板走 Jinja2,静态走 StaticFiles
- 模板里 path 显示一律 `replace('\\', '/')`,Win / Linux 看到统一形态
(`Path.as_posix()` 在 Linux 读 Windows backslash 串时不归一,所以直接 replace)
+- Markdown 渲染走 markdown-it-py(gfm-like)+ pygments syntax highlight
- SSE 在 G4 加,响应头会带 `X-Accel-Buffering: no`(nginx 反代友好)
- 本地形态 sentinel user 固定;Phase D 加 OIDC 之后才有真正 user 态
"""
from __future__ import annotations
+import json
from pathlib import Path
from typing import Any, Optional
from uuid import UUID
@@ -34,14 +36,129 @@ def _norm_path(p: str) -> str:
return (p or "").replace("\\", "/")
-def list_tasks(limit: int = 50, status: Optional[str] = None) -> list[dict[str, Any]]:
- """Tasks 列表(updated_at 降序),含 messages 计数。
+# --------------------------- Markdown 渲染 ---------------------------
- 返回 dict 列表,模板友好;cli.py 的 `_list_task_rows` 自留 tuple 形态不变
- (CLI / Web 数据形状不一定一致 — 等真有 schema 变更同步成本时再抽。)
+_md_instance = None
+
+
+def _pygments_highlight(code: str, lang: str, attrs: str) -> str:
+ """markdown-it highlight 回调。lang 未识别 / pygments 异常时返回 '' 让 md 走默认 。"""
+ if not lang:
+ return ""
+ try:
+ from pygments import highlight
+ from pygments.formatters import HtmlFormatter
+ from pygments.lexers import get_lexer_by_name
+ from pygments.util import ClassNotFound
+ except ImportError:
+ return ""
+ try:
+ lexer = get_lexer_by_name(lang, stripall=False)
+ except ClassNotFound:
+ return ""
+ formatter = HtmlFormatter(nowrap=False, cssclass="codehilite")
+ return highlight(code, lexer, formatter)
+
+
+def _get_md():
+ """单例 MarkdownIt:gfm-like(表/strikethrough/linkify),禁 html(防 XSS),break=True。"""
+ global _md_instance
+ if _md_instance is None:
+ from markdown_it import MarkdownIt
+ _md_instance = MarkdownIt(
+ "gfm-like",
+ {
+ "linkify": True,
+ "html": False,
+ "breaks": True,
+ "highlight": _pygments_highlight,
+ },
+ )
+ return _md_instance
+
+
+def _render_md(text: str) -> str:
+ """渲染 markdown → HTML。空串返空。"""
+ if not text:
+ return ""
+ return _get_md().render(text)
+
+
+# --------------------------- 消息块聚合 ---------------------------
+
+def _args_preview(args: str, max_len: int = 60) -> str:
+ s = (args or "").replace("\n", " ").strip()
+ return s if len(s) <= max_len else s[: max_len - 3] + "..."
+
+
+def _pretty_json(s: str) -> str:
+ """JSON 串美化输出。解析失败返回原串。"""
+ try:
+ return json.dumps(json.loads(s), indent=2, ensure_ascii=False)
+ except Exception:
+ return s or ""
+
+
+def load_chat_messages(task_id: UUID) -> list[dict]:
+ """读 task 全部 messages(idx asc)。空 task 返空列表。"""
+ with session_scope() as s:
+ rows = s.execute(
+ select(Message.payload).where(Message.task_id == task_id).order_by(Message.idx)
+ ).scalars().all()
+ return [dict(p) for p in rows]
+
+
+def build_chat_blocks(messages: list[dict]) -> list[dict]:
+ """把 LiteLLM 消息序列聚合成显示块。
+
+ - system / tool 不进 blocks(system 不入 DB;tool result 跟随 assistant 的 tool_call 内嵌)
+ - user → {type=user, html}
+ - assistant → {type=assistant, html, tool_calls=[{name,args_preview,args_pretty,result}]}
"""
+ tool_results: dict[str, str] = {}
+ for m in messages:
+ if m.get("role") == "tool":
+ tcid = m.get("tool_call_id")
+ if tcid:
+ tool_results[tcid] = m.get("content") or ""
+
+ blocks: list[dict] = []
+ for m in messages:
+ role = m.get("role")
+ if role in ("system", "tool"):
+ continue
+ if role == "user":
+ blocks.append({
+ "type": "user",
+ "html": _render_md(m.get("content") or ""),
+ })
+ elif role == "assistant":
+ content = m.get("content") or ""
+ tool_calls = m.get("tool_calls") or []
+ tc_blocks = []
+ for tc in tool_calls:
+ fn = tc.get("function", {}) or {}
+ args_raw = fn.get("arguments", "") or ""
+ tc_blocks.append({
+ "name": fn.get("name", "?"),
+ "args_preview": _args_preview(args_raw),
+ "args_pretty": _pretty_json(args_raw),
+ "result": tool_results.get(tc.get("id", ""), "[no result]"),
+ })
+ blocks.append({
+ "type": "assistant",
+ "html": _render_md(content),
+ "tool_calls": tc_blocks,
+ })
+ return blocks
+
+
+# --------------------------- Task list 查询 ---------------------------
+
+def list_tasks(limit: int = 50, status: Optional[str] = None) -> list[dict[str, Any]]:
+ """Tasks 列表(updated_at 降序),含 messages 计数。"""
if status and status not in STATUS_FILTERS:
- status = None # 无效 status 静默忽略,等价 all
+ status = None
with session_scope() as s:
q = (
select(
@@ -78,9 +195,11 @@ def list_tasks(limit: int = 50, status: Optional[str] = None) -> list[dict[str,
return result
+# --------------------------- App 工厂 ---------------------------
+
def create_app() -> FastAPI:
"""FastAPI 工厂。uvicorn --reload 模式需要工厂签名(factory=True)。"""
- app = FastAPI(title="zcbot web", version="0.2")
+ app = FastAPI(title="zcbot web", version="0.3")
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@@ -100,29 +219,41 @@ def create_app() -> FastAPI:
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
def task_detail(request: Request, task_id: str):
- """G2 占位:UUID 校验 + "G3 进行中" 提示。G3 落地后替换为消息渲染。"""
+ """G3:UUID 校验 + 读 task 元数据 + 读 messages + 聚合成显示块 + 渲染。"""
try:
tid = UUID(task_id)
except ValueError:
- return HTMLResponse(
- f"invalid task id: {task_id!r}", status_code=404
- )
- # 顺便 DB 里查一下 task 是否存在 — 不存在直接 404,避免 G3 上之前给假象
+ return HTMLResponse(f"invalid task id: {task_id!r}", status_code=404)
with session_scope() as s:
row = s.execute(
- select(Task.task_id, Task.description, Task.task_dir, Task.status)
- .where(Task.task_id == tid)
+ select(
+ Task.task_id, Task.description, Task.task_dir, Task.status,
+ Task.mode, Task.model, Task.model_profile,
+ Task.tokens_prompt, Task.tokens_completion,
+ Task.created_at, Task.updated_at,
+ ).where(Task.task_id == tid)
).first()
if row is None:
return HTMLResponse(f"task not found: {tid}", status_code=404)
+
+ messages = load_chat_messages(tid)
+ blocks = build_chat_blocks(messages)
+
return templates.TemplateResponse(
- request, "task_placeholder.html",
+ request, "chat.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,
+ "mode": row.mode or "",
+ "model_label": row.model_profile or row.model or "",
+ "tokens": (row.tokens_prompt or 0) + (row.tokens_completion or 0),
+ "n_messages": len(messages),
+ "created_at": row.created_at,
+ "updated_at": row.updated_at,
+ "blocks": blocks,
},
)
diff --git a/web/static/style.css b/web/static/style.css
index cddf14e..ec4c2f8 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -83,6 +83,49 @@ table.task-list tr:hover { background: #fdf6f6; }
.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; }
+/* 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 折叠 */
+.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; }
+
+/* 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 */
diff --git a/web/templates/chat.html b/web/templates/chat.html
new file mode 100644
index 0000000..ea50319
--- /dev/null
+++ b/web/templates/chat.html
@@ -0,0 +1,58 @@
+{% extends "base.html" %}
+{% block title %}zcbot · {{ task_id_short }}{% endblock %}
+{% block nav %}tasks{% endblock %}
+{% block content %}
+
+ task {{ task_id_short }}
+ {{ status }}
+ {% if mode %}{{ mode }}{% endif %}
+
+
+
+
+{% if description %}{{ description }}
{% endif %}
+{% if task_dir %}{{ task_dir }}
{% endif %}
+
+
+ {% for b in blocks %}
+ {% if b.type == "user" %}
+
+ user
+ {{ b.html | safe }}
+
+ {% elif b.type == "assistant" %}
+
+ assistant
+ {% if b.html %}{{ b.html | safe }}{% endif %}
+ {% for tc in b.tool_calls %}
+
+
+ tool
+ {{ tc.name }}
+ {{ tc.args_preview }}
+
+
+
+ args
+ {{ tc.args_pretty }}
+
+
+ result
+ {{ tc.result }}
+
+
+
+ {% endfor %}
+
+ {% endif %}
+ {% else %}
+
+ 该 task 还没消息(只读视图)。G4 上线后从浏览器发送 / 流式回复。
+
+ {% endfor %}
+
+
+G3 只读 · 发送 + SSE = G4 · 文件浏览 = G5
+{% endblock %}
diff --git a/web/templates/task_placeholder.html b/web/templates/task_placeholder.html
deleted file mode 100644
index 9bbfd2a..0000000
--- a/web/templates/task_placeholder.html
+++ /dev/null
@@ -1,20 +0,0 @@
-{% 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 %}