From afdcf46577fce2736e353fb829ddcbb5f9657b01 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 31 Jul 2026 14:31:50 +0800 Subject: [PATCH] feat(tasks): streamline task creation and embedded entry --- CHANGELOG.md | 5 + DESIGN.md | 4 +- PROGRESS.md | 3 +- RUN.md | 2 +- core/__init__.py | 2 +- tests/test_static_vendor.py | 18 +++ tests/test_web_routes_db.py | 19 ++- tests/test_web_routes_nodb.py | 7 + web/routers/tasks.py | 28 ++-- web/schemas.py | 8 +- web/static/dev.html | 24 ++- web/static/js/chat.js | 5 - web/static/js/embed.js | 12 +- web/static/js/newtask.js | 266 +++++++++++++++++++++++----------- 14 files changed, 275 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae81fd..5ab392b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.60.16 — 2026-07-31 + +- 从 platform 打开历史对话时会等待本次登录凭证签发完成后再加载,不再因浏览器缓存的旧登录状态一直停在对话和文件“加载中”。 +- 新建任务统一要求明确选择工作目录,任务名可以留空并在首条消息后自动生成;自定义创建也改用可搜索、可直接新建目录的选择框。 + ## 0.60.15 — 2026-07-31 - 从 platform、企业微信或 App 携带指定对话进入时,现在会直接打开该对话对应的工作目录,右侧文件栏不再停留在用户根目录。 diff --git a/DESIGN.md b/DESIGN.md index aa0b867..3b25f63 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -52,7 +52,7 @@ zcbot/ └── main.py # 入口:web / db / probe / user ``` -**工作目录** = `workspace/users///`,所有 skill 产物写这里,绝对路径注入 system prompt。`user_id` 走 JWT `sub`,**无 SENTINEL fallback**。`name`(显示名)必填、`working_dir` 可选(留空用 name);都是简单名(拒 `/\..`、`.` 起头);同 working_dir 多 task 共享同目录(§7.1)。SaaS 化只换外层根目录,布局不变。 +**工作目录** = `workspace/users///`,所有 skill 产物写这里,绝对路径注入 system prompt。`user_id` 走 JWT `sub`,**无 SENTINEL fallback**。DB 内 `name`(显示名)与 `working_dir` 都有值;创建 API 显式给 name 时 working_dir 可留空并沿用 name,省略/留空 name 时必须显式给 working_dir、name 先以「新对话」占位并自动生成。二者落库前都是简单名(拒 `/\..`、`.` 起头);同 working_dir 多 task 共享同目录(§7.1)。SaaS 化只换外层根目录,布局不变。 **启动**:`main.py web` → FastAPI + lifespan(reaper / scheduler / 渠道入站)→ 登录换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程 → `build_agent`(capabilities → LLM → system prompt → 工具)→ `AgentLoop.run`。 @@ -150,7 +150,7 @@ Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM 两个并列入口,正交不嵌套:**Task list**(主,"我的对话历史")+ **Dir tree**(辅,"我的文件资产")。类比 Finder + 最近使用。dir **不是 task 的父容器**、无 DB 实体、path 即标识。同 working_dir 多 task 共享 = "同一项目多对话",无需"项目"实体;前缀嵌套拒(no-subtask)。skill 产物全落 working_dir,不引 artifacts 表。空 dir 正常展示(上传本身是有效行为)。多 task 并发写由软警告兜底(§7.9)。 -**新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB,首发时才 `POST /v1/tasks`,避免空 task 堆积。task 先以「新对话」占位并置一次性 `auto_title_pending`,首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir;人工 PATCH name 同时清 pending,条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口(预设 name/description/skill/model)。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`,失败只保留占位名、不阻塞主 run。 +**新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB,首发时才 `POST /v1/tasks`,避免空 task 堆积。创建请求省略/留空 name 时必须显式给 working_dir,后端据此判定自动命名,以「新对话」占位并置一次性 `auto_title_pending`;显式 name 的旧调用继续视为人工标题,working_dir 仍可省略并 fallback 到 name,旧 `auto_title` 字段只作兼容保留。首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir;人工 PATCH name 同时清 pending,条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口,UI 同样要求明确选择 working_dir,name 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`,失败只保留占位名、不阻塞主 run。 ### 7.2 资源模型(/v1) diff --git a/PROGRESS.md b/PROGRESS.md index 1319af6..6f852ad 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-31(嵌入初始 task 直接进入工作目录,bump 0.60.15) +最后更新:2026-07-31(platform 历史 task 稳定进入 + 新建任务名称可选,bump 0.60.16) --- @@ -23,6 +23,7 @@ ### 2026-07-31 +- **07-31 / 0.60.16 / platform 历史 task 稳定进入 + 新建任务名称可选**:普通 platform iframe 不再复用 localStorage 缓存 JWT 抢跑,始终等待父页面本次 `zcbot-token` 后再进入初始 task,消除旧 token 401 后界面永久停在“加载中”的时序;企微/App fragment 登录路径不变。`POST /v1/tasks` 省略/留空 name 且提供 working_dir 时自动以“新对话”占位并启用首条消息标题生成,旧 `auto_title` 语义继续兼容;快速新对话不再发送冗余字段,自定义创建统一为工作目录必填、任务名选填,并换用可搜索/可创建的目录组合框。27 项无 DB 测试、Python 编译、3 个 JavaScript 语法及 diff 格式检查通过;DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时安全跳过。 - **07-31 / 0.60.15 / 嵌入初始 task 直接进入工作目录**:platform iframe、企业微信和 App 携带 `task_id` 首次进入时,由 `enterApp` 直接选择目标 task,跳过新对话草稿及 user_root 文件面板初始化,随后沿既有 `selectTask` 链路同步 `working_dir`;401 重签仍不重复定位,普通登录空态不变。JavaScript 语法、7 项静态前端测试及 diff 格式检查通过;无 API、schema、migration 或依赖变化。 - **07-31 / 0.60.14 / 清空对话后自动标题重生成**:tasks 新增 `title_source`(auto/manual/fixed)与 `auto_title_version`(0024 migration);快速新对话的自动标题在清空后重置为“新对话”,下一条消息重新生成,人工重命名及微信/企微/调度固定标题保持不变。版本闸阻止清空前在途标题跨轮次写回;历史任务无法可靠判定“自动命名后又人工改名”,除当前 pending 外保守回填 manual。相关 18 项无 DB 测试、Python 编译、Alembic 单 head 与 diff 格式检查通过;DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时按安全门控跳过。 - **07-31 / 0.60.13 / 渠道常驻会话操作收口**:微信与企业微信的 web 只读镜像只保留导出和清空对话,隐藏普通任务才适用的完成、废弃、重命名和删除操作,顶部独立完成按钮同步隐藏;普通任务菜单不变。纯前端行为修正,无 API、schema、migration 或依赖变化;JavaScript 语法及 diff 格式检查通过。 diff --git a/RUN.md b/RUN.md index 0929401..91eaf2a 100644 --- a/RUN.md +++ b/RUN.md @@ -264,7 +264,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta | `POST /v1/auth/login` | platform 机器对机器;body `{user_id, platform_key}` → `{token,expires_at,user_id,ttl_seconds}` | 豁免 | | `POST /v1/auth/login_password` | dev SPA 邮箱密码;body `{email, password}` → `{token,...,email,...}`;邮箱不存在 / 密码错 / 未设密码统一 403 | 豁免 | | `POST /v1/auth/change_password` | dev SPA 顶栏「改密码」;body `{old_password, new_password}`(user_id 取自 JWT)→ `{ok:true}`;新密码 <6→400、旧密码错 / 无密码(platform_key 建的行)→403 | 必填 | -| `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 | +| `POST /v1/tasks` | 创建 task,body `{name?, working_dir?, description?, skill?}`;省略/留空 `name` 时必须传 `working_dir`,后端以“新对话”占位并在首条消息后自动命名;显式 `name` 时仍可省略 `working_dir` 并沿用同名目录。旧字段 `auto_title` 仅作兼容保留,新调用方无需传 | 必填 | | `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 | | `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 | | `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 | diff --git a/core/__init__.py b/core/__init__.py index 9186075..7c6edab 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.60.15" +__version__ = "0.60.16" diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index d4ed095..fedd031 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -83,6 +83,19 @@ class StaticVendorTests(unittest.TestCase): self.assertIn("context_original_chars", src) self.assertIn("cache_hit_tokens", src) + def test_custom_task_name_is_optional_with_explicit_directory(self) -> None: + html = DEV_HTML.read_text(encoding="utf-8") + newtask_js = (JS_DIR / "newtask.js").read_text(encoding="utf-8") + + self.assertIn("任务名(可选)", html) + self.assertIn("工作目录(必填)", html) + self.assertIn('id="nt-wd-combobox" class="new-chat-dir-combobox"', html) + self.assertIn('id="nt-wd-picker" role="combobox"', html) + self.assertNotIn('id="nt-wd-sel"', html) + self.assertNotIn('id="nt-wd-new"', html) + self.assertIn("if (name) body.name = name;", newtask_js) + self.assertNotIn("任务名为必填项", newtask_js) + def test_embed_initial_task_skips_root_directory_bootstrap(self) -> None: main_js = (JS_DIR / "main.js").read_text(encoding="utf-8") embed_js = (JS_DIR / "embed.js").read_text(encoding="utf-8") @@ -95,6 +108,11 @@ class StaticVendorTests(unittest.TestCase): ) self.assertIn("enterApp(takeInitialTaskId());", embed_js) self.assertNotIn("gotoInitialTask()", embed_js) + self.assertIn('state.token = "";', embed_js) + self.assertNotIn( + 'if (state.token) {\n enterApp(takeInitialTaskId());', + embed_js, + ) def test_static_js_is_served_with_revalidation_header(self) -> None: with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_web_routes_db.py b/tests/test_web_routes_db.py index 8a15fc2..45854d4 100644 --- a/tests/test_web_routes_db.py +++ b/tests/test_web_routes_db.py @@ -146,18 +146,27 @@ class TasksCrudTests(unittest.TestCase): def test_quick_task_auto_title_marker_and_manual_rename_gate(self): r = _client.post( "/v1/tasks", - json={ - "name": "新对话", - "working_dir": "快速对话目录", - "auto_title": True, - }, + json={"working_dir": "快速对话目录"}, headers=_AUTH, ) self.assertEqual(r.status_code, 201, r.text) t = r.json() + self.assertEqual(t["name"], "新对话") self.assertTrue(t["auto_title_pending"]) self.assertEqual(t["title_source"], "auto") + # 空 name 与省略 name 同义;但二者都没有 working_dir 时不能确定文件归属。 + blank = _client.post( + "/v1/tasks", + json={"name": " ", "working_dir": "快速对话空名称目录"}, + headers=_AUTH, + ) + self.assertEqual(blank.status_code, 201, blank.text) + self.assertEqual(blank.json()["name"], "新对话") + self.assertTrue(blank.json()["auto_title_pending"]) + missing_both = _client.post("/v1/tasks", json={}, headers=_AUTH) + self.assertEqual(missing_both.status_code, 400, missing_both.text) + # 人工改名拥有最高优先级:立刻清 pending,异步标题条件 UPDATE 将失配。 d = _client.patch( f"/v1/tasks/{t['task_id']}", diff --git a/tests/test_web_routes_nodb.py b/tests/test_web_routes_nodb.py index 7532387..0746070 100644 --- a/tests/test_web_routes_nodb.py +++ b/tests/test_web_routes_nodb.py @@ -34,6 +34,7 @@ from starlette.testclient import TestClient # noqa: E402 from web.app import create_app # noqa: E402 from web.auth import AuthConfig, mint_token # noqa: E402 +from web.schemas import TaskCreateRequest # noqa: E402 _app = create_app() _client = TestClient(_app) # 刻意不进 with:不跑 lifespan(reaper/调度都不启动) @@ -56,6 +57,12 @@ def tearDownModule() -> None: class PublicEndpointTests(unittest.TestCase): + def test_task_create_schema_accepts_working_dir_without_name(self): + body = TaskCreateRequest(working_dir="低碳水泥项目") + self.assertEqual(body.name, "") + self.assertEqual(body.working_dir, "低碳水泥项目") + self.assertFalse(body.auto_title) # 自动命名由路由据空 name 推断 + def test_healthz(self): r = _client.get("/healthz") self.assertEqual(r.status_code, 200) diff --git a/web/routers/tasks.py b/web/routers/tasks.py index 494026c..fcebec1 100644 --- a/web/routers/tasks.py +++ b/web/routers/tasks.py @@ -37,19 +37,27 @@ def register_task_routes(app, *, require_user) -> None: def create_task(body: TaskCreateRequest, user_id: UUID = Depends(require_user)): """新建 task。 - - `name` 必填(任务显示名,DB 列 NOT NULL,UI 列表 / 标题用) - - `working_dir` 可选(留空 → 用 name 作目录名);同 working_dir 多 task 共享同目录(§7.1) - - `auto_title=true` 仅标记“首条消息后自动命名”;旧调用方缺省 false + - `name` 有值 → 作为人工标题;`working_dir` 可留空并 fallback 到 name + - `name` 缺失/空值 → `working_dir` 必填,以“新对话”占位并在首条消息后自动命名 + - `auto_title` 仅作旧客户端兼容:显式 name + true 仍启用自动命名 - name / working_dir 都过 validate_task_name(简单名,无 `/\\..`,非 `.` 起头,≤255) - 前缀嵌套(no-subtask,同 user 内)→ 409 """ from core.agent_builder import InvalidTaskName, resolve_workspace, validate_task_name, working_dir_from_name - try: - name = validate_task_name(body.name) - except InvalidTaskName as e: - raise HTTPException(400, f"name 不合法: {e}") - # working_dir 留空 → fallback 用 name + name_raw = (body.name or "").strip() wd_raw = (body.working_dir or "").strip() + auto_title = bool(body.auto_title) or not name_raw + if name_raw: + try: + name = validate_task_name(name_raw) + except InvalidTaskName as e: + raise HTTPException(400, f"name 不合法: {e}") + else: + if not wd_raw: + raise HTTPException(400, "name 为空时 working_dir 必填") + name = "新对话" + + # 兼容旧契约:显式 name 时 working_dir 留空仍 fallback 用 name。 wd_name = wd_raw if wd_raw else name try: wd_name = validate_task_name(wd_name) @@ -76,8 +84,8 @@ def register_task_routes(app, *, require_user) -> None: task_id=tid, name=name, working_dir=fs_dir_db, skill=skill, description=description, user_id=user_id, model=model_id, model_profile=profile, - auto_title_pending=body.auto_title, - title_source="auto" if body.auto_title else "manual", + auto_title_pending=auto_title, + title_source="auto" if auto_title else "manual", ) with session_scope() as s: row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one() diff --git a/web/schemas.py b/web/schemas.py index ed08d28..e21e3a4 100644 --- a/web/schemas.py +++ b/web/schemas.py @@ -7,9 +7,11 @@ from pydantic import BaseModel class TaskCreateRequest(BaseModel): - name: str # 任务显示名(必填,DB 列 NOT NULL) - working_dir: str = "" # 工作目录名(可选,留空 → 用 name 作目录名) - # 快速新对话入口专用:首条消息后异步生成一次标题。缺省 False 保持旧契约。 + # name 缺失/空值 + working_dir 有值 → 后端以“新对话”占位并自动生成标题。 + # DB 列仍 NOT NULL;显式 name 保持旧契约,可继续省略 working_dir 并用 name 作目录名。 + name: str = "" + working_dir: str = "" + # 兼容旧客户端:新调用方无需再传;旧的 name + auto_title=true 语义继续保留。 auto_title: bool = False description: str = "" skill: str = "" diff --git a/web/static/dev.html b/web/static/dev.html index 8adde94..a721722 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1930,13 +1930,23 @@