feat(tasks): streamline task creation and embedded entry

This commit is contained in:
caoqianming 2026-07-31 14:31:50 +08:00
parent da265b88d5
commit afdcf46577
14 changed files with 275 additions and 128 deletions

View File

@ -5,6 +5,11 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.16 — 2026-07-31
- 从 platform 打开历史对话时会等待本次登录凭证签发完成后再加载,不再因浏览器缓存的旧登录状态一直停在对话和文件“加载中”。
- 新建任务统一要求明确选择工作目录,任务名可以留空并在首条消息后自动生成;自定义创建也改用可搜索、可直接新建目录的选择框。
## 0.60.15 — 2026-07-31 ## 0.60.15 — 2026-07-31
- 从 platform、企业微信或 App 携带指定对话进入时,现在会直接打开该对话对应的工作目录,右侧文件栏不再停留在用户根目录。 - 从 platform、企业微信或 App 携带指定对话进入时,现在会直接打开该对话对应的工作目录,右侧文件栏不再停留在用户根目录。

View File

@ -52,7 +52,7 @@ zcbot/
└── main.py # 入口:web / db / probe / user └── main.py # 入口:web / db / probe / user
``` ```
**工作目录** = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写这里,绝对路径注入 system prompt。`user_id` 走 JWT `sub`,**无 SENTINEL fallback**。`name`(显示名)必填、`working_dir` 可选(留空用 name);都是简单名(拒 `/\..`、`.` 起头);同 working_dir 多 task 共享同目录(§7.1)。SaaS 化只换外层根目录,布局不变。 **工作目录** = `workspace/users/<user_id>/<working_dir>/`,所有 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` **启动**:`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)。 两个并列入口,正交不嵌套:**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_dirname 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`,失败只保留占位名、不阻塞主 run。
### 7.2 资源模型(/v1) ### 7.2 资源模型(/v1)

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `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 ### 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.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.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 格式检查通过。 - **07-31 / 0.60.13 / 渠道常驻会话操作收口**:微信与企业微信的 web 只读镜像只保留导出和清空对话,隐藏普通任务才适用的完成、废弃、重命名和删除操作,顶部独立完成按钮同步隐藏;普通任务菜单不变。纯前端行为修正,无 API、schema、migration 或依赖变化JavaScript 语法及 diff 格式检查通过。

2
RUN.md
View File

@ -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` | 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/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}`;新密码 <6400旧密码错 / 无密码(platform_key 建的行)→403 | 必填 | | `POST /v1/auth/change_password` | dev SPA 顶栏「改密码」;body `{old_password, new_password}`(user_id 取自 JWT)→ `{ok:true}`;新密码 <6400旧密码错 / 无密码(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?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 | 必填 | | `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
| `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 | | `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 |

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.60.15" __version__ = "0.60.16"

View File

@ -83,6 +83,19 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("context_original_chars", src) self.assertIn("context_original_chars", src)
self.assertIn("cache_hit_tokens", 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: def test_embed_initial_task_skips_root_directory_bootstrap(self) -> None:
main_js = (JS_DIR / "main.js").read_text(encoding="utf-8") main_js = (JS_DIR / "main.js").read_text(encoding="utf-8")
embed_js = (JS_DIR / "embed.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.assertIn("enterApp(takeInitialTaskId());", embed_js)
self.assertNotIn("gotoInitialTask()", 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: def test_static_js_is_served_with_revalidation_header(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:

View File

@ -146,18 +146,27 @@ class TasksCrudTests(unittest.TestCase):
def test_quick_task_auto_title_marker_and_manual_rename_gate(self): def test_quick_task_auto_title_marker_and_manual_rename_gate(self):
r = _client.post( r = _client.post(
"/v1/tasks", "/v1/tasks",
json={ json={"working_dir": "快速对话目录"},
"name": "新对话",
"working_dir": "快速对话目录",
"auto_title": True,
},
headers=_AUTH, headers=_AUTH,
) )
self.assertEqual(r.status_code, 201, r.text) self.assertEqual(r.status_code, 201, r.text)
t = r.json() t = r.json()
self.assertEqual(t["name"], "新对话")
self.assertTrue(t["auto_title_pending"]) self.assertTrue(t["auto_title_pending"])
self.assertEqual(t["title_source"], "auto") 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 将失配。 # 人工改名拥有最高优先级:立刻清 pending异步标题条件 UPDATE 将失配。
d = _client.patch( d = _client.patch(
f"/v1/tasks/{t['task_id']}", f"/v1/tasks/{t['task_id']}",

View File

@ -34,6 +34,7 @@ from starlette.testclient import TestClient # noqa: E402
from web.app import create_app # noqa: E402 from web.app import create_app # noqa: E402
from web.auth import AuthConfig, mint_token # noqa: E402 from web.auth import AuthConfig, mint_token # noqa: E402
from web.schemas import TaskCreateRequest # noqa: E402
_app = create_app() _app = create_app()
_client = TestClient(_app) # 刻意不进 with:不跑 lifespan(reaper/调度都不启动) _client = TestClient(_app) # 刻意不进 with:不跑 lifespan(reaper/调度都不启动)
@ -56,6 +57,12 @@ def tearDownModule() -> None:
class PublicEndpointTests(unittest.TestCase): 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): def test_healthz(self):
r = _client.get("/healthz") r = _client.get("/healthz")
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)

View File

@ -37,19 +37,27 @@ def register_task_routes(app, *, require_user) -> None:
def create_task(body: TaskCreateRequest, user_id: UUID = Depends(require_user)): def create_task(body: TaskCreateRequest, user_id: UUID = Depends(require_user)):
"""新建 task。 """新建 task。
- `name` 必填(任务显示名,DB NOT NULL,UI 列表 / 标题用) - `name` 有值 作为人工标题`working_dir` 可留空并 fallback name
- `working_dir` 可选(留空 name 作目录名); working_dir task 共享同目录(§7.1) - `name` 缺失/空值 `working_dir` 必填新对话占位并在首条消息后自动命名
- `auto_title=true` 仅标记首条消息后自动命名旧调用方缺省 false - `auto_title` 仅作旧客户端兼容显式 name + true 仍启用自动命名
- name / working_dir 都过 validate_task_name(简单名, `/\\..`, `.` 起头,255) - name / working_dir 都过 validate_task_name(简单名, `/\\..`, `.` 起头,255)
- 前缀嵌套(no-subtask, user ) 409 - 前缀嵌套(no-subtask, user ) 409
""" """
from core.agent_builder import InvalidTaskName, resolve_workspace, validate_task_name, working_dir_from_name from core.agent_builder import InvalidTaskName, resolve_workspace, validate_task_name, working_dir_from_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: try:
name = validate_task_name(body.name) name = validate_task_name(name_raw)
except InvalidTaskName as e: except InvalidTaskName as e:
raise HTTPException(400, f"name 不合法: {e}") raise HTTPException(400, f"name 不合法: {e}")
# working_dir 留空 → fallback 用 name else:
wd_raw = (body.working_dir or "").strip() 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 wd_name = wd_raw if wd_raw else name
try: try:
wd_name = validate_task_name(wd_name) 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, task_id=tid, name=name, working_dir=fs_dir_db, skill=skill,
description=description, user_id=user_id, description=description, user_id=user_id,
model=model_id, model_profile=profile, model=model_id, model_profile=profile,
auto_title_pending=body.auto_title, auto_title_pending=auto_title,
title_source="auto" if body.auto_title else "manual", title_source="auto" if auto_title else "manual",
) )
with session_scope() as s: with session_scope() as s:
row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one() row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one()

View File

@ -7,9 +7,11 @@ from pydantic import BaseModel
class TaskCreateRequest(BaseModel): class TaskCreateRequest(BaseModel):
name: str # 任务显示名(必填,DB 列 NOT NULL) # name 缺失/空值 + working_dir 有值 → 后端以“新对话”占位并自动生成标题。
working_dir: str = "" # 工作目录名(可选,留空 → 用 name 作目录名) # DB 列仍 NOT NULL显式 name 保持旧契约,可继续省略 working_dir 并用 name 作目录名。
# 快速新对话入口专用:首条消息后异步生成一次标题。缺省 False 保持旧契约。 name: str = ""
working_dir: str = ""
# 兼容旧客户端:新调用方无需再传;旧的 name + auto_title=true 语义继续保留。
auto_title: bool = False auto_title: bool = False
description: str = "" description: str = ""
skill: str = "" skill: str = ""

View File

@ -1930,13 +1930,23 @@
<div id="new-task-modal" class="modal"> <div id="new-task-modal" class="modal">
<div class="card"> <div class="card">
<h3>新建任务</h3> <h3>新建任务</h3>
<label for="nt-name">任务名(必填)</label> <label for="nt-name">任务名(可选)</label>
<input id="nt-name" placeholder="例如 初稿大纲" /> <input id="nt-name" placeholder="留空则根据首条消息自动生成" />
<label for="nt-wd-sel">工作目录</label> <label for="nt-wd-picker">工作目录(必填)</label>
<select id="nt-wd-sel"> <div class="new-chat-dir-row">
<option value="__new__">+ 新建(跟随任务名)</option> <div id="nt-wd-combobox" class="new-chat-dir-combobox">
</select> <div class="new-chat-dir-picker-wrap">
<input id="nt-wd-new" placeholder="新目录名" style="margin-top:6px;" /> <input type="text" id="nt-wd-picker" role="combobox"
aria-autocomplete="list" aria-expanded="false"
aria-controls="nt-wd-options"
placeholder="选择已有目录或输入新名称" autocomplete="off"
maxlength="255" />
<button type="button" id="nt-wd-toggle" tabindex="-1"
aria-label="展开工作目录"></button>
</div>
<div id="nt-wd-options" class="new-chat-dir-options" role="listbox"></div>
</div>
</div>
<div class="small muted" id="nt-wd-hint" style="margin-top:4px;min-height:1em;"></div> <div class="small muted" id="nt-wd-hint" style="margin-top:4px;min-height:1em;"></div>
<label for="nt-desc">描述(可选,任务长描述)</label> <label for="nt-desc">描述(可选,任务长描述)</label>
<input id="nt-desc" /> <input id="nt-desc" />

View File

@ -2408,12 +2408,7 @@ async function createTaskFromDraft() {
const workingDir = draftWorkingDir(); const workingDir = draftWorkingDir();
if (!workingDir) throw new Error("请先选择或新建工作目录"); if (!workingDir) throw new Error("请先选择或新建工作目录");
const t = await api("POST", "/v1/tasks", { const t = await api("POST", "/v1/tasks", {
name: "新对话",
working_dir: workingDir, working_dir: workingDir,
auto_title: true,
description: "",
skill: "",
model_profile: "",
}); });
state.taskId = t.task_id; state.taskId = t.task_id;
state.taskMeta = t; state.taskMeta = t;

View File

@ -105,12 +105,12 @@ export function embedInit() {
} }
document.body.classList.add("embed-mode"); document.body.classList.add("embed-mode");
window.addEventListener("message", embedHandleMessage); window.addEventListener("message", embedHandleMessage);
if (state.token) { // 普通 platform iframe 始终以父页面本次签发的 token 为准。若复用本地缓存,
enterApp(takeInitialTaskId()); // 过期/上一用户 token 会抢先触发初始 task 请求;其 401 返回后新 token 虽能
} else { // 刷新任务列表,却不会重跑初始 task页面便会一直停在“加载中”。
state.token = "";
document.body.classList.add("embed-waiting"); document.body.classList.add("embed-waiting");
embedShowWaiting("等待登录…", false); embedShowWaiting("等待登录…", false);
}
embedPostToParent({ type: "zcbot-ready" }); embedPostToParent({ type: "zcbot-ready" });
} }

View File

@ -1,4 +1,4 @@
// 新建任务弹框:任务名 / 工作目录(新建 sentinel 或复用已有,二级 input 联动)/ // 新建任务弹框:可选任务名 / 可搜索工作目录组合框(复用“开始一段新对话”交互)/
// 描述 / 智能体(skill)/ 模型 select,提交 POST /v1/tasks。 // 描述 / 智能体(skill)/ 模型 select,提交 POST /v1/tasks。
// 顶层自绑 hd-new-custom 打开、nt-go 提交、各 input 联动;唯一对外导出 loadFolderSuggestions // 顶层自绑 hd-new-custom 打开、nt-go 提交、各 input 联动;唯一对外导出 loadFolderSuggestions
//(供 main enterApp 初始化顶部 filter-wd、files 复制/移动后刷新目录列表)。 //(供 main enterApp 初始化顶部 filter-wd、files 复制/移动后刷新目录列表)。
@ -10,20 +10,24 @@ import { logout } from "./auth.js";
import { loadModels, loadTaskList, selectTask } from "./chat.js"; import { loadModels, loadTaskList, selectTask } from "./chat.js";
// ───── new task ───── // ───── new task ─────
// wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag const LS_RECENT_WORKING_DIR = "zcbot.recent-working-dir";
let wdManuallyEdited = false; let customWorkingDir = "";
let customDirNewMode = false;
let customDirActiveIndex = -1;
$("hd-new-custom").onclick = async () => { $("hd-new-custom").onclick = async () => {
$("nt-name").value = ""; $("nt-name").value = "";
$("nt-wd-sel").value = "__new__"; // 默认选 sentinel customWorkingDir = "";
$("nt-wd-new").value = ""; customDirNewMode = false;
$("nt-wd-new").style.display = ""; // sentinel 选中态 → 二级 input 可见 $("nt-wd-picker").value = "";
$("nt-wd-picker").dataset.searching = "";
setCustomFolderOpen(false);
$("nt-desc").value = ""; $("nt-skill").value = ""; $("nt-desc").value = ""; $("nt-skill").value = "";
$("nt-err").textContent = ""; $("nt-err").textContent = "";
$("nt-wd-hint").textContent = "";
wdManuallyEdited = false;
$("new-task-modal").classList.add("show"); $("new-task-modal").classList.add("show");
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]); await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]);
$("nt-wd-sel").value = "__new__"; // populateFolderSelects 重渲后再保险一次 renderCustomFolderOptions();
syncCustomDirHint();
populateModelSelect(); populateModelSelect();
$("nt-name").focus(); $("nt-name").focus();
}; };
@ -41,19 +45,20 @@ function populateModelSelect() {
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show"); $("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-go").onclick = async () => { $("nt-go").onclick = async () => {
const name = $("nt-name").value.trim(); const name = $("nt-name").value.trim();
const sel = $("nt-wd-sel").value; const working_dir = currentCustomWorkingDir();
// sentinel:用二级 input 值,空则 fallback name;选已有目录:直接用 value
const working_dir = sel === "__new__"
? ($("nt-wd-new").value.trim() || name)
: sel;
const desc = $("nt-desc").value.trim(); const desc = $("nt-desc").value.trim();
const skill = $("nt-skill").value; const skill = $("nt-skill").value;
const model_profile = $("nt-model").value; const model_profile = $("nt-model").value;
$("nt-err").textContent = ""; $("nt-err").textContent = "";
if (!name) { $("nt-err").textContent = "任务名为必填项"; return; } if (!working_dir) {
$("nt-err").textContent = "请选择已有工作目录或创建新目录";
return;
}
try { try {
const t = await api("POST", "/v1/tasks", const body = { working_dir, description: desc, skill, model_profile };
{ name, working_dir, description: desc, skill, model_profile }); if (name) body.name = name;
const t = await api("POST", "/v1/tasks", body);
localStorage.setItem(LS_RECENT_WORKING_DIR, working_dir);
$("new-task-modal").classList.remove("show"); $("new-task-modal").classList.remove("show");
await loadTaskList(); await loadTaskList();
selectTask(t.task_id); selectTask(t.task_id);
@ -63,7 +68,7 @@ $("nt-go").onclick = async () => {
} }
}; };
// 工作目录:拉数据 + 灌两个 select(顶部 filter-wd 和 modal nt-wd-sel) // 工作目录:拉数据 + 灌顶部 filter-wd并刷新自定义创建组合框。
export async function loadFolderSuggestions() { export async function loadFolderSuggestions() {
try { try {
const data = await api("GET", "/v1/folders"); const data = await api("GET", "/v1/folders");
@ -72,9 +77,10 @@ export async function loadFolderSuggestions() {
state.folders = state.folders || []; state.folders = state.folders || [];
} }
populateFolderSelects(); populateFolderSelects();
renderCustomFolderOptions();
} }
// 灌 filter-wd + nt-wd-sel options;保留当前选中值 // 灌顶部 filter-wd自定义创建弹框使用与快速新对话一致的组合框。
function populateFolderSelects() { function populateFolderSelects() {
const folders = state.folders || []; const folders = state.folders || [];
// 顶部 filter:第一项 "(全部目录)" sentinel // 顶部 filter:第一项 "(全部目录)" sentinel
@ -87,17 +93,6 @@ function populateFolderSelects() {
} }
filterSel.innerHTML = filterOpts.join(""); filterSel.innerHTML = filterOpts.join("");
filterSel.value = filterCur; // 重渲后恢复选中 filterSel.value = filterCur; // 重渲后恢复选中
// modal wd:第一项 "+ 新建(跟随任务名)" sentinel(label 由 updateSentinelLabel 实时刷)
const wdSel = $("nt-wd-sel");
const wdCur = wdSel.value || "__new__";
const wdOpts = [`<option value="__new__">+ 新建(跟随任务名)</option>`];
for (const f of folders) {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
wdOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)}${escapeHtml(tag)}</option>`);
}
wdSel.innerHTML = wdOpts.join("");
wdSel.value = wdCur;
updateSentinelLabel(); // 用最新的 name 刷 sentinel
} }
// 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills // 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills
@ -121,66 +116,163 @@ async function loadSkillOptions() {
sel.value = ""; // hd-new 已清空,这里幂等再保一次 sel.value = ""; // hd-new 已清空,这里幂等再保一次
} }
// === modal wd select + 二级 input 联动 === // === 自定义创建目录组合框(与“开始一段新对话”一致)===
// select 选 "__new__" sentinel → 显示二级 input(默认值跟随 name);选已有目录 → 隐藏二级 input // UI 新契约:工作目录始终必填,任务名选填;不再用任务名自动补目录。
// wdManuallyEdited:用户改过二级 input 后置 true,name 不再覆盖;清空二级 input 重置 false function currentCustomWorkingDir() {
return customWorkingDir.trim();
function updateSentinelLabel() {
// sentinel 永远是 select 第一项,labels 实时含 name 让用户一眼知会建什么
const sel = $("nt-wd-sel");
const opt = sel.options[0];
if (!opt || opt.value !== "__new__") return;
const name = $("nt-name").value.trim();
opt.textContent = name ? `+ 新建「${name}` : `+ 新建(跟随任务名)`;
} }
function updateWdHint() { function customDirIsNew() {
const hint = $("nt-wd-hint"); return !!currentCustomWorkingDir() && customDirNewMode;
const sel = $("nt-wd-sel").value; }
if (sel === "__new__") {
const v = $("nt-wd-new").value.trim(); function syncCustomDirHint() {
const name = $("nt-name").value.trim(); const target = currentCustomWorkingDir();
const target = v || name; $("nt-wd-hint").textContent = target
if (!target) { hint.textContent = ""; return; } ? customDirIsNew()
// 用户手输的新名恰好命中已有目录 → 提示会复用而非新建 ? `将新建工作目录「${target}`
const collision = (state.folders || []).find(f => f.name === target); : `文件将保存到「${target}`
if (collision) { : "可选择已有目录,也可以新建目录。";
const n = collision.n_tasks || 0; }
hint.innerHTML = `<span style="color:var(--accent);">! 已有同名目录,将复用</span> · ${n} 个任务`;
} else { function renderCustomFolderOptions() {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(target)}</span>`; const input = $("nt-wd-picker");
} const list = $("nt-wd-options");
} else { if (!input || !list) return;
const f = (state.folders || []).find(x => x.name === sel); const query = input.dataset.searching === "1"
const n = f ? (f.n_tasks || 0) : 0; ? input.value.trim().toLocaleLowerCase()
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`; : "";
const recent = localStorage.getItem(LS_RECENT_WORKING_DIR) || "";
const folders = [...(state.folders || [])];
folders.sort((a, b) => Number(b.name === recent) - Number(a.name === recent));
const visible = query
? folders.filter(f => f.name.toLocaleLowerCase().includes(query))
: folders;
const exact = query
? folders.some(f => f.name.toLocaleLowerCase() === query)
: false;
const selected = customDirIsNew() ? "" : currentCustomWorkingDir();
const existingHtml = visible.map((f, i) => {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : "空目录";
const recentTag = f.name === recent
? `<span class="new-chat-dir-recent">最近使用</span>`
: "";
const chosen = f.name === selected ? " selected" : "";
return `<button type="button" id="nt-wd-option-${i}"
class="new-chat-dir-option${chosen}" role="option"
aria-selected="${f.name === selected}"
data-name="${escapeHtml(f.name)}">
<span>${escapeHtml(f.name)}${recentTag}</span>
<small>${tag}</small>
</button>`;
}).join("");
const createHtml = query && !exact
? `<button type="button" id="nt-wd-create"
class="new-chat-dir-option new-chat-dir-create" role="option"
aria-selected="false" data-create="${escapeHtml(input.value.trim())}">
<span>创建工作目录${escapeHtml(input.value.trim())}</span>
</button>`
: `<div class="new-chat-dir-create-hint">输入新名称可创建工作目录</div>`;
const emptyHtml = !visible.length && !query
? `<div class="new-chat-dir-empty">暂无已有目录</div>`
: "";
list.innerHTML = existingHtml + emptyHtml + createHtml;
if (input.dataset.searching !== "1") input.value = currentCustomWorkingDir();
customDirActiveIndex = -1;
input.setAttribute("aria-activedescendant", "");
list.querySelectorAll(".new-chat-dir-option").forEach(option => {
option.onmousedown = e => e.preventDefault();
option.onclick = () => selectCustomFolder(
option.dataset.create || option.dataset.name || "",
!!option.dataset.create,
);
});
}
function setCustomFolderOpen(open) {
const combo = $("nt-wd-combobox");
const input = $("nt-wd-picker");
combo.classList.toggle("open", open);
input.setAttribute("aria-expanded", String(open));
if (!open) {
input.dataset.searching = "";
input.value = currentCustomWorkingDir();
customDirActiveIndex = -1;
input.setAttribute("aria-activedescendant", "");
} }
} }
// name 改变 → 更新 sentinel label;若未脱钩且当前是 sentinel,二级 input 跟随 name function selectCustomFolder(name, isNew) {
$("nt-name").addEventListener("input", () => { const trimmed = name.trim();
updateSentinelLabel(); if (!trimmed) return;
if (!wdManuallyEdited && $("nt-wd-sel").value === "__new__") { customWorkingDir = trimmed;
$("nt-wd-new").value = $("nt-name").value; customDirNewMode = isNew;
setCustomFolderOpen(false);
syncCustomDirHint();
renderCustomFolderOptions();
}
function moveCustomFolderActive(delta) {
const input = $("nt-wd-picker");
const options = [...document.querySelectorAll("#nt-wd-options .new-chat-dir-option")];
if (!options.length) return;
customDirActiveIndex = (
customDirActiveIndex + delta + options.length
) % options.length;
options.forEach((option, i) => option.classList.toggle("active", i === customDirActiveIndex));
const active = options[customDirActiveIndex];
input.setAttribute("aria-activedescendant", active.id);
active.scrollIntoView({ block: "nearest" });
}
const customPicker = $("nt-wd-picker");
const customCombo = $("nt-wd-combobox");
customPicker.onfocus = () => {
customPicker.dataset.searching = "";
setCustomFolderOpen(true);
renderCustomFolderOptions();
customPicker.select();
};
customPicker.oninput = () => {
customPicker.dataset.searching = "1";
setCustomFolderOpen(true);
renderCustomFolderOptions();
};
customPicker.onkeydown = e => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setCustomFolderOpen(true);
moveCustomFolderActive(e.key === "ArrowDown" ? 1 : -1);
} else if (e.key === "Enter" && customCombo.classList.contains("open")) {
const options = [...document.querySelectorAll("#nt-wd-options .new-chat-dir-option")];
const active = options[customDirActiveIndex];
const typed = customPicker.value.trim().toLocaleLowerCase();
const exact = options.find(
o => (o.dataset.name || "").toLocaleLowerCase() === typed,
);
const target = active || exact || (options.length === 1 ? options[0] : null);
if (target) {
e.preventDefault();
selectCustomFolder(
target.dataset.create || target.dataset.name || "",
!!target.dataset.create,
);
} }
updateWdHint(); } else if (e.key === "Escape") {
}); e.preventDefault();
setCustomFolderOpen(false);
// wd select 切换 → 切显示二级 input + 刷 hint customPicker.blur();
$("nt-wd-sel").addEventListener("change", () => {
const v = $("nt-wd-sel").value;
if (v === "__new__") {
$("nt-wd-new").style.display = "";
if (!wdManuallyEdited) $("nt-wd-new").value = $("nt-name").value;
} else {
$("nt-wd-new").style.display = "none";
} }
updateWdHint(); };
}); customPicker.onblur = () => setTimeout(() => setCustomFolderOpen(false), 0);
$("nt-wd-toggle").onmousedown = e => e.preventDefault();
// 二级 input 改变 → 非空视为手动修改;清空重置 flag 但保持空(避免 backspace 想换名时被打断) $("nt-wd-toggle").onclick = () => {
$("nt-wd-new").addEventListener("input", () => { const open = !customCombo.classList.contains("open");
wdManuallyEdited = $("nt-wd-new").value.trim() !== ""; setCustomFolderOpen(open);
updateWdHint(); if (open) {
}); customPicker.focus();
customPicker.select();
renderCustomFolderOptions();
}
};