core(§7 Phase G G6/new): Web 端新建 task 入口
提前于 G5 落地 — 用户反馈 Web 没"开启新对话"的地方。
- GET /new 渲染 new_task.html 表单(description / mode / task_dir 三字段);
- POST /new:strip 校验 + description 与 task_dir 至少填一个否则 400 +
check_no_subtask 同 CLI / build_agent 一致拦前缀嵌套 → 409 +
ensure_local_task_row 写占位行 + 303 See Other 跳转 /tasks/{tid};
- task_dir 空 → 默认派生 workspace/tasks/<uuid>/(同 _default_task_dir),
显式 → Path.expanduser().resolve() 同 cli.py --task-dir;
- 模板 new_task.html:三字段表单 + error 渲染(400/409 重渲带 form_state
不丢用户填的值);home.html 加 + new task 主按钮;base.html 默认 nav
也带 tasks/new 链接;
- CSS:.btn-primary 商务红主按钮 / .new-task-form 表单 + focus / .navlinks
.active 当前页高亮 / .head-actions flex 容纳 filter + new 按钮;
- 懒创建保留语义:Web /new 入库占位,后续 build_agent 走 resume(已存在
不冲突);CLI REPL 仍走 build_agent 懒创建路径,两路互不干扰。
Smoke 21 路径全绿:GET 表单 200 + 三字段 / POST happy(description-only
和 custom task_dir)→ 303 + Location 正确 / DB 行字段对 + default-derived
task_dir 含 uuid / 空+空 → 400 重渲表单带 error / no-subtask 父子嵌套 →
409 + 错误文案 / home 页 + new task 按钮 + nav 链接 / /new nav active 标记。
版本 0.4 → 0.5。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7356d25652
commit
1035b12847
15
PROGRESS.md
15
PROGRESS.md
|
|
@ -37,6 +37,7 @@
|
||||||
- **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`。
|
- **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`。
|
||||||
|
- **05-15 / §7 Phase G G6 部分:/new 入口(提前于 G5 落)**:用户反馈 Web 没"新建对话"入口 — 加 `GET /new` 表单页(description / mode / task_dir 三字段)+ `POST /new` 处理(strip 校验 + `description` 与 `task_dir` 至少填一个否则 400 + `check_no_subtask` 同 CLI / build_agent 一致拦前缀嵌套 → 409 + `ensure_local_task_row` 写占位行 + 303 See Other 跳转 `/tasks/{tid}`)。task_dir 空 → 默认派生 `workspace/tasks/<uuid>/`(同 `_default_task_dir`),显式 → `Path.expanduser().resolve()` 同 cli.py `--task-dir`。模板 `new_task.html` 加表单 + error 渲染(400/409 重渲带 form_state 不丢用户填的值);home.html 加 `+ new task` 主按钮 + nav 加 `new` 链接;base.html 默认 nav 也带 tasks/new。CSS 加 `.btn-primary` / `.new-task-form` / `.navlinks .active` 配色。**懒创建保留语义**:Task 在 /new POST 时入库,后续 build_agent 走 resume 路径(已存在,不冲突);CLI REPL `/new` 仍走 build_agent 懒创建路径,不互相干扰。Smoke 21 路径全绿:GET 表单 200 + 三字段 / POST happy(description-only / custom task_dir)→ 303 + Location 正确 / DB 行字段对 + default-derived task_dir 含 uuid / 空描述空 task_dir → 400 重渲表单带 error / no-subtask 父子嵌套 → 409 + 错误文案 / home 页 `+ new task` 按钮 + nav 链接 / `/new` nav 链接 active 标记。版本 0.4 → 0.5。
|
||||||
- **05-15 / §7 Phase G G4 chat 发送 + SSE 流式**:新增 `web/broker.py::RunBroker`(in-process pub/sub,`subscribe/emit/close/unsubscribe`)+ `web/sinks.py::WebEventSink` 实现 §7 A 的 sink 协议,把 `AgentLoop._emit` 桥到 broker。**异步策略 = `asyncio.to_thread`**(不改 core):POST `/tasks/{tid}/messages` async handler → 校验 task + INSERT `runs` 行 + `asyncio.create_task(asyncio.to_thread(_run_agent_bg, ...))`,`_run_agent_bg` 在工作线程跑 `build_agent(resume=True) + agent.run`,sink 通过 `loop.call_soon_threadsafe(q.put_nowait, ev)` 跨线程桥事件回 asyncio queue。**多访问策略 = fan-out**:每订阅一个独立 `asyncio.Queue`,同 run 多 tab / 刷新 / 桌面+移动都看得到流;`_done` 集合让晚到订阅者立即收 `done`(不挂)。GET `/tasks/{tid}/runs/{rid}/events` 返 `StreamingResponse` async gen,响应头带 `text/event-stream / Cache-Control: no-cache / X-Accel-Buffering: no`(nginx 反代友好);第一帧发 `: connected\nretry: 3000\n\n` 让 EventSource 立即建立,30s 无 event 发 `: ping` 注释心跳。**SSE multi-line data**:HTML 片段含换行,每行加 `data: ` 前缀(SSE spec),EventSource API 还原成 `\n` 拼接的 HTML 字符串。**Event → HTML 片段**:`_render_event_fragment` 渲染 `text`/`tool_call`/`tool_result`/`error` 四种,`run_start/llm_start/llm_end/done` 发空 data(只让客户端识别 event type)。新 fragment 模板 `_frag_text.html` / `_frag_tool_call.html` / `_frag_tool_result.html` / `_frag_error.html` + `_send_response.html`(POST 响应:user msg 卡 + `msg-assistant streaming` 容器带 `sse-connect/sse-swap/sse-close`)。`chat.html` 加 send 表单(Enter 发送、Shift+Enter 换行,HTMX `hx-post / hx-target=#chat-stream / hx-swap=beforeend / hx-on::after-request reset`);`chat` section 改 `id="chat-stream"` 让 SSE 追加进同一容器;非 active task 隐藏表单。CSS 加 `.streaming .run-indicator` 红点脉冲 / `.send-form` 表单样式 / `.tool-result-inline` 追加式样式 / `.msg-error` 错误卡。**Run 状态写 PG `runs` 表**:POST 时 status=running,正常完结 status=ok + tokens_p/c,异常 status=error + error 文本;DB 写失败不放大噪声(已 emit error 给前端)。**lifespan** `bind_loop(asyncio.get_running_loop())` 让 broker 拿到 asyncio loop 引用。Smoke 双层全绿:broker 单元 8 case(subscribe/emit/get、fan-out 双订阅、跨 run_id 隔离、close 派 done、late subscribe 立刻收 done、unsubscribe 后失联、WebEventSink 桥、unbinded loop silent drop);端到端 24 case(POST 200 + HTML 含 sse-connect + run_id 抽出 + SSE stream content-type/x-accel-buffering/cache-control 头对、event types 序列 `run_start/llm_start/text/tool_call/tool_result/llm_end/done`、text fragment 含 `<strong>` markdown、tool_call 含 `<details>`、tool_result 含 preview、empty body 400、invalid/ghost UUID 404、late subscribe 立刻 done、PG runs 行 INSERT)。版本 0.3 → 0.4。**TODO**:并发同 task 多 run 互锁(messages idx UniqueConstraint 在并发 POST 下会冲突 — 用户连续点 send 暂时不会触发,但需要在 G6 或 D 阶段加 lock_for_update);event log 持久化(刷新继续看流式)留到未来。
|
- **05-15 / §7 Phase G G4 chat 发送 + SSE 流式**:新增 `web/broker.py::RunBroker`(in-process pub/sub,`subscribe/emit/close/unsubscribe`)+ `web/sinks.py::WebEventSink` 实现 §7 A 的 sink 协议,把 `AgentLoop._emit` 桥到 broker。**异步策略 = `asyncio.to_thread`**(不改 core):POST `/tasks/{tid}/messages` async handler → 校验 task + INSERT `runs` 行 + `asyncio.create_task(asyncio.to_thread(_run_agent_bg, ...))`,`_run_agent_bg` 在工作线程跑 `build_agent(resume=True) + agent.run`,sink 通过 `loop.call_soon_threadsafe(q.put_nowait, ev)` 跨线程桥事件回 asyncio queue。**多访问策略 = fan-out**:每订阅一个独立 `asyncio.Queue`,同 run 多 tab / 刷新 / 桌面+移动都看得到流;`_done` 集合让晚到订阅者立即收 `done`(不挂)。GET `/tasks/{tid}/runs/{rid}/events` 返 `StreamingResponse` async gen,响应头带 `text/event-stream / Cache-Control: no-cache / X-Accel-Buffering: no`(nginx 反代友好);第一帧发 `: connected\nretry: 3000\n\n` 让 EventSource 立即建立,30s 无 event 发 `: ping` 注释心跳。**SSE multi-line data**:HTML 片段含换行,每行加 `data: ` 前缀(SSE spec),EventSource API 还原成 `\n` 拼接的 HTML 字符串。**Event → HTML 片段**:`_render_event_fragment` 渲染 `text`/`tool_call`/`tool_result`/`error` 四种,`run_start/llm_start/llm_end/done` 发空 data(只让客户端识别 event type)。新 fragment 模板 `_frag_text.html` / `_frag_tool_call.html` / `_frag_tool_result.html` / `_frag_error.html` + `_send_response.html`(POST 响应:user msg 卡 + `msg-assistant streaming` 容器带 `sse-connect/sse-swap/sse-close`)。`chat.html` 加 send 表单(Enter 发送、Shift+Enter 换行,HTMX `hx-post / hx-target=#chat-stream / hx-swap=beforeend / hx-on::after-request reset`);`chat` section 改 `id="chat-stream"` 让 SSE 追加进同一容器;非 active task 隐藏表单。CSS 加 `.streaming .run-indicator` 红点脉冲 / `.send-form` 表单样式 / `.tool-result-inline` 追加式样式 / `.msg-error` 错误卡。**Run 状态写 PG `runs` 表**:POST 时 status=running,正常完结 status=ok + tokens_p/c,异常 status=error + error 文本;DB 写失败不放大噪声(已 emit error 给前端)。**lifespan** `bind_loop(asyncio.get_running_loop())` 让 broker 拿到 asyncio loop 引用。Smoke 双层全绿:broker 单元 8 case(subscribe/emit/get、fan-out 双订阅、跨 run_id 隔离、close 派 done、late subscribe 立刻收 done、unsubscribe 后失联、WebEventSink 桥、unbinded loop silent drop);端到端 24 case(POST 200 + HTML 含 sse-connect + run_id 抽出 + SSE stream content-type/x-accel-buffering/cache-control 头对、event types 序列 `run_start/llm_start/text/tool_call/tool_result/llm_end/done`、text fragment 含 `<strong>` markdown、tool_call 含 `<details>`、tool_result 含 preview、empty body 400、invalid/ghost UUID 404、late subscribe 立刻 done、PG runs 行 INSERT)。版本 0.3 → 0.4。**TODO**:并发同 task 多 run 互锁(messages idx UniqueConstraint 在并发 POST 下会冲突 — 用户连续点 send 暂时不会触发,但需要在 G6 或 D 阶段加 lock_for_update);event log 持久化(刷新继续看流式)留到未来。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -82,12 +83,12 @@ 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 468 ← Phase G G1-G4: 工厂 + list_tasks + chat 渲染 + POST/SSE
|
web/app.py 538 ← Phase G G1-G4 + G6/new: 工厂 + list/chat + SSE + /new
|
||||||
web/broker.py 88 ← Phase G G4: in-process pub/sub
|
web/broker.py 88 ← Phase G G4: in-process pub/sub
|
||||||
web/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议)
|
web/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议)
|
||||||
─────────────────────────────────
|
─────────────────────────────────
|
||||||
Python 合计 ~3662 行
|
Python 合计 ~3732 行
|
||||||
+ web/templates/* ~196 行(base/home/chat + 4 个 _frag/_send_response)+ web/static/style.css 169 行(不计 Python 主仓库)
|
+ web/templates/* ~249 行(base/home/chat/new_task + 5 个 _frag/_send_response)+ web/static/style.css 193 行(不计 Python 主仓库)
|
||||||
```
|
```
|
||||||
|
|
||||||
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。
|
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。
|
||||||
|
|
@ -96,11 +97,11 @@ Python 合计 ~3662 行
|
||||||
|
|
||||||
## 下一步候选(性价比排序)
|
## 下一步候选(性价比排序)
|
||||||
|
|
||||||
1. **§7 Phase G G5 文件浏览 + 上传下载**(~半天)—— `/tasks/{id}/files` 渲染 task_dir 树,upload (multipart)/ download / 删。
|
1. **真实 LLM 跑通 G4 + /new 全流程**(~10 分钟)—— smoke 走的是 mock,需在浏览器开:`cli.py web` → `/new` 建 task → 跳 chat → 发"你好" → 看真实流式 → 刷新看历史。
|
||||||
2. **§7 Phase G G6 打磨**(~半天)—— `/new` 入口、`/done /abandon` 按钮、`/export` docx 下载、错误 toast、并发 run 互锁。
|
2. **§7 Phase G G5 文件浏览 + 上传下载**(~半天)—— `/tasks/{id}/files` 渲染 task_dir 树,upload (multipart)/ download / 删。
|
||||||
3. **真实 LLM 跑通 G4**(~10 分钟)—— smoke 走的是 mock,需在浏览器开一个真 task 验证端到端体验(`cli.py web` → 打开 `/tasks/<id>` → send → 看流式)。
|
3. **§7 Phase G G6 剩余打磨**(~半天)—— `/done /abandon` 按钮、`/export` docx 下载、错误 toast、并发 run 互锁(messages idx 冲突)。/new 已提前完成。
|
||||||
4. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。
|
4. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。
|
||||||
5. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
|
5. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
|
||||||
6. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`。
|
6. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`。
|
||||||
|
|
||||||
> §7 B 已完工。Phase G 进行中(G1 ✅ G2 ✅ G3 ✅ G4 ✅)。剩余路线:G5-G6 → C(Executor)→ D(HTTP /v1 + OIDC)→ E(CLI 双模式)→ F(deploy / billing)。
|
> §7 B 已完工。Phase G 进行中(G1 ✅ G2 ✅ G3 ✅ G4 ✅ G6/new ✅)。剩余路线:G5 + G6 剩余 → C(Executor)→ D(HTTP /v1 + OIDC)→ E(CLI 双模式)→ F(deploy / billing)。
|
||||||
|
|
|
||||||
2
RUN.md
2
RUN.md
|
|
@ -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=` filter;G3 ✅ `/tasks/{uuid}` 消息流渲染(markdown-it-py + pygments syntax,tool_call 走 `<details>` 默认折叠);G4 ✅ chat 发送 + SSE 流式回复(POST `/tasks/{tid}/messages` 启 run、GET `/tasks/{tid}/runs/{rid}/events` SSE 流;HTMX `sse-swap` 追加 DOM,无 JS);G5-G6 待。task_dir 显示统一 forward-slash(Win 存 `\` 也归一)。Linux:`.venv/bin/python cli.py web` 一致。SSE 经 nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。
|
> G1 ✅ 脚手架 + /healthz;G2 ✅ `/` task 列表 + `?status=` filter;G3 ✅ `/tasks/{uuid}` 消息流渲染(markdown-it-py + pygments,tool_call 走 `<details>`);G4 ✅ chat 发送 + SSE 流式(POST `/tasks/{tid}/messages` 启 run、GET `/tasks/{tid}/runs/{rid}/events` SSE 流);**G6/new ✅ `/new` 表单新建 task**(desc / mode / task_dir 三字段,303 跳转 chat);G5 文件浏览 + G6 剩余打磨待。task_dir 显示统一 forward-slash(Win 存 `\` 也归一)。Linux:`.venv/bin/python cli.py web` 一致。SSE 经 nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
74
web/app.py
74
web/app.py
|
|
@ -18,13 +18,14 @@ from typing import Any, Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import FastAPI, Form, HTTPException, Request
|
from fastapi import FastAPI, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||||
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, update
|
from sqlalchemy import func, select, update
|
||||||
|
|
||||||
from core.storage import session_scope
|
from core.storage import NoSubtaskError, check_no_subtask, ensure_local_sentinel, session_scope
|
||||||
from core.storage.models import Message, Run, Task
|
from core.storage.models import Message, Run, Task
|
||||||
|
from core.storage.utils import ensure_local_task_row
|
||||||
|
|
||||||
from .broker import broker
|
from .broker import broker
|
||||||
from .sinks import WebEventSink
|
from .sinks import WebEventSink
|
||||||
|
|
@ -461,6 +462,75 @@ def create_app() -> FastAPI:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.get("/new", response_class=HTMLResponse)
|
||||||
|
def new_task_form(request: Request):
|
||||||
|
"""渲染新建 task 表单(description / mode / task_dir 可选)。"""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "new_task.html",
|
||||||
|
{"error": None, "form": {"description": "", "mode": "", "task_dir": ""}},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/new")
|
||||||
|
def new_task_submit(
|
||||||
|
request: Request,
|
||||||
|
description: str = Form(""),
|
||||||
|
mode: str = Form(""),
|
||||||
|
task_dir: str = Form(""),
|
||||||
|
):
|
||||||
|
"""新建 task:校验 + no-subtask + INSERT 占位行 + 303 redirect 到 /tasks/{tid}。
|
||||||
|
|
||||||
|
Task 在这里就入库(不走 build_agent 的懒创建),原因:用户在表单页填了
|
||||||
|
meta 但还没发消息 — task 必须先存在,不然 /tasks/{tid} 跳过去 404。
|
||||||
|
懒创建语义对 CLI 仍然适用(REPL `/exit` 没发消息会 _cleanup_if_empty 删
|
||||||
|
掉空 task);Web 这里多一行 task 行,用户可在 /tasks/{tid} 触发 G4
|
||||||
|
send 流程,首条消息 ensure_local_task_row 因 ON CONFLICT DO NOTHING
|
||||||
|
不冲突。
|
||||||
|
"""
|
||||||
|
description = (description or "").strip()
|
||||||
|
mode = (mode or "").strip()
|
||||||
|
task_dir = (task_dir or "").strip()
|
||||||
|
|
||||||
|
form_state = {"description": description, "mode": mode, "task_dir": task_dir}
|
||||||
|
|
||||||
|
if not description and not task_dir:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "new_task.html",
|
||||||
|
{"error": "description 或 task_dir 至少填一个,否则 task 不好识别。", "form": form_state},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# task_dir 显式 → 绝对化(同 cli.py `--task-dir`);空 → 默认派生 workspace/tasks/<uuid>/
|
||||||
|
tid = uuid4()
|
||||||
|
from main import _default_task_dir, resolve_workspace
|
||||||
|
ws = resolve_workspace(None)
|
||||||
|
if task_dir:
|
||||||
|
fs_dir = Path(task_dir).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
fs_dir = _default_task_dir(ws, tid)
|
||||||
|
fs_dir_str = str(fs_dir)
|
||||||
|
|
||||||
|
# §7.4 no-subtask 校验(同 cli.py chat / build_agent 入口)
|
||||||
|
try:
|
||||||
|
check_no_subtask(fs_dir_str)
|
||||||
|
except NoSubtaskError as e:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "new_task.html",
|
||||||
|
{"error": str(e), "form": form_state},
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 本地形态 — sentinel user 确保存在(build_agent 路径之外也要保险)
|
||||||
|
ensure_local_sentinel()
|
||||||
|
# INSERT 占位行 — idempotent;真值由 Web 这里给,build_agent 不再覆盖
|
||||||
|
ensure_local_task_row(
|
||||||
|
task_id=tid,
|
||||||
|
task_dir=fs_dir_str,
|
||||||
|
mode=mode,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
# 303 See Other:POST 转 GET,让浏览器刷新拿到 chat 页(避免重复提交)
|
||||||
|
return RedirectResponse(url=f"/tasks/{tid}", status_code=303)
|
||||||
|
|
||||||
@app.get("/healthz", response_class=HTMLResponse)
|
@app.get("/healthz", response_class=HTMLResponse)
|
||||||
def healthz():
|
def healthz():
|
||||||
return HTMLResponse("ok")
|
return HTMLResponse("ok")
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,32 @@ h2 { font-size: 1.1rem; margin: 1.5rem 0 .5rem; }
|
||||||
.page-head { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: .5rem; }
|
.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 { display: flex; gap: .5rem; align-items: center; font-size: .9rem; color: var(--muted); }
|
||||||
.filters select { padding: .25em .5em; font-size: .9rem; }
|
.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 { padding: .25em .75em; border: 1px solid var(--border); border-radius: 3px; color: var(--fg); background: var(--surface); display: inline-block; line-height: 1.4; }
|
||||||
.btn:hover { text-decoration: none; background: #f4f4f4; }
|
.btn:hover { text-decoration: none; background: #f4f4f4; }
|
||||||
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: white; font-weight: 600; }
|
||||||
|
.btn-primary:hover { background: var(--accent); filter: brightness(1.05); color: white; }
|
||||||
|
.head-actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* navlinks active state */
|
||||||
|
.navlinks a { color: var(--muted); padding: .15em .4em; border-radius: 3px; }
|
||||||
|
.navlinks a.active, .navlinks a:hover { color: var(--fg); background: #f4f4f4; text-decoration: none; }
|
||||||
|
|
||||||
|
/* new task form */
|
||||||
|
.new-task-form { display: flex; flex-direction: column; gap: 1rem; max-width: 640px; margin-top: 1rem; }
|
||||||
|
.new-task-form label { display: flex; flex-direction: column; gap: .3rem; }
|
||||||
|
.new-task-form .field-label { font-weight: 600; font-size: .9rem; color: #444; }
|
||||||
|
.new-task-form input[type="text"] {
|
||||||
|
padding: .55rem .7rem; font: inherit; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: var(--surface); color: var(--fg); font-size: .95rem;
|
||||||
|
}
|
||||||
|
.new-task-form input[type="text"]:focus { outline: 2px solid var(--accent-soft); outline-offset: 1px; border-color: var(--accent); }
|
||||||
|
.new-task-form .muted.small { font-weight: normal; }
|
||||||
|
.form-actions { display: flex; gap: .5rem; justify-content: flex-end; align-items: center; margin-top: .5rem; }
|
||||||
|
.form-actions button {
|
||||||
|
padding: .55rem 1.3rem; border: 1px solid var(--accent); border-radius: 4px;
|
||||||
|
background: var(--accent); color: white; cursor: pointer; font: inherit; font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-actions button:hover { filter: brightness(1.05); }
|
||||||
|
|
||||||
/* task list table */
|
/* task list table */
|
||||||
.empty { padding: 2rem 0; }
|
.empty { padding: 2rem 0; }
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/">zcbot</a>
|
<a class="brand" href="/">zcbot</a>
|
||||||
<nav class="navlinks">
|
<nav class="navlinks">
|
||||||
{% block nav %}{% endblock %}
|
{% block nav %}<a href="/">tasks</a> <a href="/new">new</a>{% endblock %}
|
||||||
</nav>
|
</nav>
|
||||||
<span class="user-tag" title="本地 sentinel user — Phase D 加 OIDC 之前固定">local</span>
|
<span class="user-tag" title="本地 sentinel user — Phase D 加 OIDC 之前固定">local</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,25 @@
|
||||||
{% block title %}zcbot · tasks{% endblock %}
|
{% block title %}zcbot · tasks{% endblock %}
|
||||||
{% block nav %}
|
{% block nav %}
|
||||||
<a href="/" class="active">tasks</a>
|
<a href="/" class="active">tasks</a>
|
||||||
|
<a href="/new">new</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>tasks <small class="muted">最近 {{ tasks|length }} 条{% if status %} · status={{ status }}{% endif %}</small></h1>
|
<h1>tasks <small class="muted">最近 {{ tasks|length }} 条{% if status %} · status={{ status }}{% endif %}</small></h1>
|
||||||
<form class="filters" method="get" action="/">
|
<div class="head-actions">
|
||||||
<label>status:
|
<form class="filters" method="get" action="/">
|
||||||
<select name="status" onchange="this.form.submit()">
|
<label>status:
|
||||||
<option value="">all</option>
|
<select name="status" onchange="this.form.submit()">
|
||||||
{% for f in filters %}
|
<option value="">all</option>
|
||||||
<option value="{{ f }}"{% if status == f %} selected{% endif %}>{{ f }}</option>
|
{% for f in filters %}
|
||||||
{% endfor %}
|
<option value="{{ f }}"{% if status == f %} selected{% endif %}>{{ f }}</option>
|
||||||
</select>
|
{% endfor %}
|
||||||
</label>
|
</select>
|
||||||
{% if status %}<a href="/" class="btn">reset</a>{% endif %}
|
</label>
|
||||||
</form>
|
{% if status %}<a href="/" class="btn">reset</a>{% endif %}
|
||||||
|
</form>
|
||||||
|
<a href="/new" class="btn btn-primary">+ new task</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not tasks %}
|
{% if not tasks %}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}zcbot · new task{% endblock %}
|
||||||
|
{% block nav %}<a href="/">tasks</a> <a href="/new" class="active">new</a>{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>新建 task</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="msg-error" style="margin-bottom:1rem;">
|
||||||
|
<span class="err-tag">error</span>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/new" class="new-task-form">
|
||||||
|
<label>
|
||||||
|
<span class="field-label">description</span>
|
||||||
|
<input type="text" name="description" value="{{ form.description }}"
|
||||||
|
placeholder="一句话任务描述,便于后续 list 识别(留空就靠 task_dir)"
|
||||||
|
autocomplete="off" autofocus>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span class="field-label">mode <span class="muted small">(可选)</span></span>
|
||||||
|
<input type="text" name="mode" value="{{ form.mode }}"
|
||||||
|
placeholder="coding / ppt / proposal / … 自由形式"
|
||||||
|
autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span class="field-label">task_dir <span class="muted small">(可选,留空 = 一次性对话;填路径 = 项目化)</span></span>
|
||||||
|
<input type="text" name="task_dir" value="{{ form.task_dir }}"
|
||||||
|
placeholder="/path/to/project 或 D:\projects\proj — 相对路径以服务器 cwd 为基"
|
||||||
|
autocomplete="off">
|
||||||
|
<span class="muted small">
|
||||||
|
留空 → 默认派生 <code>workspace/tasks/<uuid>/</code>(等价 ChatGPT 一次性对话)。
|
||||||
|
指定 → 同 task_dir 多 task 自动共享 source / sections / 终稿。
|
||||||
|
<strong>不允许前缀嵌套</strong>(no-subtask):同 user 下不能有父子关系的 task_dir。
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="/" class="btn">取消</a>
|
||||||
|
<button type="submit">创建并打开</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted small mt-1">
|
||||||
|
Tip:创建后会跳到 chat 页,在底部输入框发第一条消息开始对话。
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue