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:
caoqianming 2026-05-15 09:51:35 +08:00
parent 7356d25652
commit 1035b12847
7 changed files with 171 additions and 23 deletions

View File

@ -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 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 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 持久化(刷新继续看流式)留到未来。
---
@ -82,12 +83,12 @@ 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 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/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议)
─────────────────────────────────
Python 合计 ~3662 行
+ web/templates/* ~196 行(base/home/chat + 4 个 _frag/_send_response)+ web/static/style.css 169 行(不计 Python 主仓库)
Python 合计 ~3732 行
+ 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 行。
@ -96,11 +97,11 @@ Python 合计 ~3662 行
## 下一步候选(性价比排序)
1. **§7 Phase G G5 文件浏览 + 上传下载**(~半天)—— `/tasks/{id}/files` 渲染 task_dir 树,upload (multipart)/ download / 删
2. **§7 Phase G G6 打磨**(~半天)—— `/new` 入口、`/done /abandon` 按钮、`/export` docx 下载、错误 toast、并发 run 互锁
3. **真实 LLM 跑通 G4**(~10 分钟)—— smoke 走的是 mock,需在浏览器开一个真 task 验证端到端体验(`cli.py web` → 打开 `/tasks/<id>` → send → 看流式)
1. **真实 LLM 跑通 G4 + /new 全流程**(~10 分钟)—— smoke 走的是 mock,需在浏览器开:`cli.py web` → `/new` 建 task → 跳 chat → 发"你好" → 看真实流式 → 刷新看历史
2. **§7 Phase G G5 文件浏览 + 上传下载**(~半天)—— `/tasks/{id}/files` 渲染 task_dir 树,upload (multipart)/ download / 删
3. **§7 Phase G G6 剩余打磨**(~半天)—— `/done /abandon` 按钮、`/export` docx 下载、错误 toast、并发 run 互锁(messages idx 冲突)。/new 已提前完成
4. **§7 C Executor + sandbox**(~2-3 天)—— Phase G 完后再做,或穿插。
5. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
6. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`
> §7 B 已完工。Phase G 进行中(G1 ✅ G2 ✅ 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
View File

@ -103,7 +103,7 @@ REPL 内命令:`/exit /reset /new /resume [last|<id>] /id /status /done /abandon
.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` 默认起效)。
---

View File

@ -18,13 +18,14 @@ from typing import Any, Optional
from uuid import UUID, uuid4
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.templating import Jinja2Templates
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.utils import ensure_local_task_row
from .broker import broker
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)
def healthz():
return HTMLResponse("ok")

View File

@ -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; }
.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 { 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-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 */
.empty { padding: 2rem 0; }

View File

@ -12,7 +12,7 @@
<header class="topbar">
<a class="brand" href="/">zcbot</a>
<nav class="navlinks">
{% block nav %}{% endblock %}
{% block nav %}<a href="/">tasks</a> <a href="/new">new</a>{% endblock %}
</nav>
<span class="user-tag" title="本地 sentinel user — Phase D 加 OIDC 之前固定">local</span>
</header>

View File

@ -2,10 +2,12 @@
{% block title %}zcbot · tasks{% endblock %}
{% block nav %}
<a href="/" class="active">tasks</a>
<a href="/new">new</a>
{% endblock %}
{% block content %}
<div class="page-head">
<h1>tasks <small class="muted">最近 {{ tasks|length }} 条{% if status %} · status={{ status }}{% endif %}</small></h1>
<div class="head-actions">
<form class="filters" method="get" action="/">
<label>status:
<select name="status" onchange="this.form.submit()">
@ -17,6 +19,8 @@
</label>
{% if status %}<a href="/" class="btn">reset</a>{% endif %}
</form>
<a href="/new" class="btn btn-primary">+ new task</a>
</div>
</div>
{% if not tasks %}

View File

@ -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/&lt;uuid&gt;/</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 %}