core(§7 Phase G G3): chat 只读页 + markdown + tool 折叠

- 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 读 messages。
  build_chat_blocks(messages):system / tool 不入 block(tool 内嵌进
  assistant.tool_call.result),user / assistant text 走 md 渲染,
  orphan tool_call → [no result]。_args_preview 60 字截断,
  _pretty_json 解析失败 fallback 原串。
- /tasks/{id} 渲染 chat.html;删 task_placeholder.html。
- chat.html:.msg 卡片(user 浅蓝 / assistant 白底),tool_call 用
  <details> 默认折叠(无 JS,浏览器原生);summary 显示 tool 名 +
  args 前 60 字预览,展开看 args_pretty + result。
- CSS 加 .body 内 markdown 元素样式(table / blockquote / code / pre
  / strikethrough)+ .codehilite 浅色 token 配色(keyword/string/
  comment/function/number/operator,余下黑色)。
- requirements: markdown-it-py[linkify] / mdit-py-plugins / pygments。

Smoke 28 路径全绿(in-process Starlette TestClient):4 display
blocks aggregation + GFM 特性(table/fence/autolink/strikethrough/
bold)+ tool 配对(命中 + orphan [no result])+ HTML 含 <details>/
tool-badge/codehilite/<s> + 空 task 文案 + invalid UUID 404 + util
单测(args_preview/pretty_json/render_md 边界)。版本 0.2 → 0.3。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-15 08:38:31 +08:00
parent 80a658eba4
commit 514d36c481
7 changed files with 268 additions and 52 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-14(Phase G G2) 最后更新:2026-05-15(Phase G G3)
--- ---
@ -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) 待。 |
--- ---
@ -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 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。 - **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 区(`<pre>` / `<code>` / `<table>` / `<blockquote>` / `<s>` 全 GFM 样式),tool_call 用 `<details>` 默认折叠(无 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 含 `<details>`/`tool-badge`/`codehilite`/`<s>` + 空 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/ 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 133 ← Phase G G1-G2: 工厂 + list_tasks + / + /tasks/{id} 占位 web/app.py 264 ← Phase G G1-G3: 工厂 + list_tasks + chat 渲染 + md/pygments
───────────────────────────────── ─────────────────────────────────
Python 合计 ~3239 Python 合计 ~3370
+ web/templates/{base,home,task_placeholder}.html ~103 行 + web/static/style.css 88 行(不计 Python 主仓库) + web/templates/{base,home,chat}.html ~141 行 + web/static/style.css 131 行(不计 Python 主仓库)
``` ```
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 加 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 折叠展示。 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 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 Phase G G5 文件浏览 + G6 打磨**(~半天 + 半天)—— task_dir 树 / upload / download / 错误 toast / `/new` `/done /abandon` 按钮 / `/export` 链接。 3. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。
4. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。 4. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
5. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 5. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`
6. **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)。

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 G2) 最后更新:2026-05-15(Phase G G3)
--- ---
@ -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;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 走 `<details>` 默认折叠);G4-G6 待。task_dir 显示统一 forward-slash(Win 存 `\` 也归一)。Linux:`.venv/bin/python cli.py web` 一致。
--- ---

View File

@ -21,3 +21,7 @@ fastapi>=0.111.0
uvicorn[standard]>=0.30.0 uvicorn[standard]>=0.30.0
jinja2>=3.1.0 jinja2>=3.1.0
python-multipart>=0.0.9 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

View File

@ -1,14 +1,16 @@
"""FastAPI app 工厂。G1 脚手架 → G2 task list 接 PG → G3+ 渐进上。 """FastAPI app 工厂。G1 脚手架 → G2 task list → G3 chat 只读 → G4+ 渐进上。
设计: 设计:
- FastAPI 进程,模板走 Jinja2,静态走 StaticFiles - FastAPI 进程,模板走 Jinja2,静态走 StaticFiles
- 模板里 path 显示一律 `replace('\\', '/')`,Win / Linux 看到统一形态 - 模板里 path 显示一律 `replace('\\', '/')`,Win / Linux 看到统一形态
(`Path.as_posix()` Linux Windows backslash 串时不归一,所以直接 replace) (`Path.as_posix()` Linux Windows backslash 串时不归一,所以直接 replace)
- Markdown 渲染走 markdown-it-py(gfm-like)+ pygments syntax highlight
- 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
import json
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID from uuid import UUID
@ -34,14 +36,129 @@ def _norm_path(p: str) -> str:
return (p or "").replace("\\", "/") return (p or "").replace("\\", "/")
def list_tasks(limit: int = 50, status: Optional[str] = None) -> list[dict[str, Any]]: # --------------------------- Markdown 渲染 ---------------------------
"""Tasks 列表(updated_at 降序),含 messages 计数。
返回 dict 列表,模板友好;cli.py `_list_task_rows` 自留 tuple 形态不变 _md_instance = None
(CLI / Web 数据形状不一定一致 等真有 schema 变更同步成本时再抽)
def _pygments_highlight(code: str, lang: str, attrs: str) -> str:
"""markdown-it highlight 回调。lang 未识别 / pygments 异常时返回 '' 让 md 走默认 <pre><code>。"""
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: if status and status not in STATUS_FILTERS:
status = None # 无效 status 静默忽略,等价 all status = None
with session_scope() as s: with session_scope() as s:
q = ( q = (
select( select(
@ -78,9 +195,11 @@ def list_tasks(limit: int = 50, status: Optional[str] = None) -> list[dict[str,
return result return result
# --------------------------- App 工厂 ---------------------------
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.2") app = FastAPI(title="zcbot web", version="0.3")
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")
@ -100,29 +219,41 @@ def create_app() -> FastAPI:
@app.get("/tasks/{task_id}", response_class=HTMLResponse) @app.get("/tasks/{task_id}", response_class=HTMLResponse)
def task_detail(request: Request, task_id: str): def task_detail(request: Request, task_id: str):
"""G2 占位:UUID 校验 + "G3 进行中" 提示。G3 落地后替换为消息渲染。""" """G3:UUID 校验 + 读 task 元数据 + 读 messages + 聚合成显示块 + 渲染。"""
try: try:
tid = UUID(task_id) tid = UUID(task_id)
except ValueError: except ValueError:
return HTMLResponse( return HTMLResponse(f"invalid task id: {task_id!r}", status_code=404)
f"invalid task id: {task_id!r}", status_code=404
)
# 顺便 DB 里查一下 task 是否存在 — 不存在直接 404,避免 G3 上之前给假象
with session_scope() as s: with session_scope() as s:
row = s.execute( row = s.execute(
select(Task.task_id, Task.description, Task.task_dir, Task.status) select(
.where(Task.task_id == tid) 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() ).first()
if row is None: if row is None:
return HTMLResponse(f"task not found: {tid}", status_code=404) return HTMLResponse(f"task not found: {tid}", status_code=404)
messages = load_chat_messages(tid)
blocks = build_chat_blocks(messages)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "task_placeholder.html", request, "chat.html",
{ {
"task_id": str(tid), "task_id": str(tid),
"task_id_short": str(tid)[:8], "task_id_short": str(tid)[:8],
"description": row.description or "", "description": row.description or "",
"task_dir": _norm_path(row.task_dir or ""), "task_dir": _norm_path(row.task_dir or ""),
"status": row.status, "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,
}, },
) )

View File

@ -83,6 +83,49 @@ table.task-list tr:hover { background: #fdf6f6; }
.status-completed { background: #e0eaf7; color: #1a3d6b; } .status-completed { background: #e0eaf7; color: #1a3d6b; }
.status-abandoned { background: #f0f0f0; color: #777; } .status-abandoned { background: #f0f0f0; color: #777; }
/* placeholder pages */ /* task meta line + badges */
.placeholder-note { padding: 1rem 1.25rem; background: var(--accent-soft); border-left: 3px solid var(--accent); margin-top: 1rem; border-radius: 3px; } .task-meta { font-size: .85rem; }
.placeholder-note p { margin: .25rem 0; } .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 <details> 折叠 */
.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 */

58
web/templates/chat.html Normal file
View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}zcbot · {{ 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>
{% if mode %}<span class="badge">{{ mode }}</span>{% endif %}
</h1>
<div class="task-meta muted">
{{ n_messages }} msgs · {{ tokens }} tokens · {{ model_label }}
</div>
</div>
{% if description %}<p class="lead">{{ description }}</p>{% endif %}
{% if task_dir %}<p class="muted mono small">{{ task_dir }}</p>{% endif %}
<section class="chat">
{% for b in blocks %}
{% if b.type == "user" %}
<article class="msg msg-user">
<div class="role">user</div>
<div class="body">{{ b.html | safe }}</div>
</article>
{% elif b.type == "assistant" %}
<article class="msg msg-assistant">
<div class="role">assistant</div>
{% if b.html %}<div class="body">{{ b.html | safe }}</div>{% endif %}
{% for tc in b.tool_calls %}
<details class="tool">
<summary>
<span class="tool-badge">tool</span>
<span class="tool-name">{{ tc.name }}</span>
<span class="tool-args-preview">{{ tc.args_preview }}</span>
</summary>
<div class="tool-body">
<div class="tool-section">
<div class="tool-label">args</div>
<pre class="tool-pre">{{ tc.args_pretty }}</pre>
</div>
<div class="tool-section">
<div class="tool-label">result</div>
<pre class="tool-pre">{{ tc.result }}</pre>
</div>
</div>
</details>
{% endfor %}
</article>
{% endif %}
{% else %}
<p class="empty muted">
该 task 还没消息(只读视图)。G4 上线后从浏览器发送 / 流式回复。
</p>
{% endfor %}
</section>
<p class="muted small mt-1">G3 只读 · 发送 + SSE = G4 · 文件浏览 = G5</p>
{% endblock %}

View File

@ -1,20 +0,0 @@
{% 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 %}