feat(tasks): streamline task creation and embedded entry
This commit is contained in:
parent
da265b88d5
commit
afdcf46577
|
|
@ -5,6 +5,11 @@
|
|||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## 0.60.16 — 2026-07-31
|
||||
|
||||
- 从 platform 打开历史对话时会等待本次登录凭证签发完成后再加载,不再因浏览器缓存的旧登录状态一直停在对话和文件“加载中”。
|
||||
- 新建任务统一要求明确选择工作目录,任务名可以留空并在首条消息后自动生成;自定义创建也改用可搜索、可直接新建目录的选择框。
|
||||
|
||||
## 0.60.15 — 2026-07-31
|
||||
|
||||
- 从 platform、企业微信或 App 携带指定对话进入时,现在会直接打开该对话对应的工作目录,右侧文件栏不再停留在用户根目录。
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ zcbot/
|
|||
└── 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`。
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 格式检查通过。
|
||||
|
|
|
|||
2
RUN.md
2
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 切回 | 必填 |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.60.15"
|
||||
__version__ = "0.60.16"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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']}",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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(body.name)
|
||||
name = validate_task_name(name_raw)
|
||||
except InvalidTaskName as e:
|
||||
raise HTTPException(400, f"name 不合法: {e}")
|
||||
# working_dir 留空 → fallback 用 name
|
||||
wd_raw = (body.working_dir or "").strip()
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -1930,13 +1930,23 @@
|
|||
<div id="new-task-modal" class="modal">
|
||||
<div class="card">
|
||||
<h3>新建任务</h3>
|
||||
<label for="nt-name">任务名(必填)</label>
|
||||
<input id="nt-name" placeholder="例如 初稿大纲" />
|
||||
<label for="nt-wd-sel">工作目录</label>
|
||||
<select id="nt-wd-sel">
|
||||
<option value="__new__">+ 新建(跟随任务名)</option>
|
||||
</select>
|
||||
<input id="nt-wd-new" placeholder="新目录名" style="margin-top:6px;" />
|
||||
<label for="nt-name">任务名(可选)</label>
|
||||
<input id="nt-name" placeholder="留空则根据首条消息自动生成" />
|
||||
<label for="nt-wd-picker">工作目录(必填)</label>
|
||||
<div class="new-chat-dir-row">
|
||||
<div id="nt-wd-combobox" class="new-chat-dir-combobox">
|
||||
<div class="new-chat-dir-picker-wrap">
|
||||
<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>
|
||||
<label for="nt-desc">描述(可选,任务长描述)</label>
|
||||
<input id="nt-desc" />
|
||||
|
|
|
|||
|
|
@ -2408,12 +2408,7 @@ async function createTaskFromDraft() {
|
|||
const workingDir = draftWorkingDir();
|
||||
if (!workingDir) throw new Error("请先选择或新建工作目录");
|
||||
const t = await api("POST", "/v1/tasks", {
|
||||
name: "新对话",
|
||||
working_dir: workingDir,
|
||||
auto_title: true,
|
||||
description: "",
|
||||
skill: "",
|
||||
model_profile: "",
|
||||
});
|
||||
state.taskId = t.task_id;
|
||||
state.taskMeta = t;
|
||||
|
|
|
|||
|
|
@ -105,12 +105,12 @@ export function embedInit() {
|
|||
}
|
||||
document.body.classList.add("embed-mode");
|
||||
window.addEventListener("message", embedHandleMessage);
|
||||
if (state.token) {
|
||||
enterApp(takeInitialTaskId());
|
||||
} else {
|
||||
// 普通 platform iframe 始终以父页面本次签发的 token 为准。若复用本地缓存,
|
||||
// 过期/上一用户 token 会抢先触发初始 task 请求;其 401 返回后新 token 虽能
|
||||
// 刷新任务列表,却不会重跑初始 task,页面便会一直停在“加载中”。
|
||||
state.token = "";
|
||||
document.body.classList.add("embed-waiting");
|
||||
embedShowWaiting("等待登录…", false);
|
||||
}
|
||||
embedPostToParent({ type: "zcbot-ready" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// 新建任务弹框:任务名 / 工作目录(新建 sentinel 或复用已有,二级 input 联动)/
|
||||
// 新建任务弹框:可选任务名 / 可搜索工作目录组合框(复用“开始一段新对话”交互)/
|
||||
// 描述 / 智能体(skill)/ 模型 select,提交 POST /v1/tasks。
|
||||
// 顶层自绑 hd-new-custom 打开、nt-go 提交、各 input 联动;唯一对外导出 loadFolderSuggestions
|
||||
//(供 main enterApp 初始化顶部 filter-wd、files 复制/移动后刷新目录列表)。
|
||||
|
|
@ -10,20 +10,24 @@ import { logout } from "./auth.js";
|
|||
import { loadModels, loadTaskList, selectTask } from "./chat.js";
|
||||
|
||||
// ───── new task ─────
|
||||
// wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag
|
||||
let wdManuallyEdited = false;
|
||||
const LS_RECENT_WORKING_DIR = "zcbot.recent-working-dir";
|
||||
let customWorkingDir = "";
|
||||
let customDirNewMode = false;
|
||||
let customDirActiveIndex = -1;
|
||||
|
||||
$("hd-new-custom").onclick = async () => {
|
||||
$("nt-name").value = "";
|
||||
$("nt-wd-sel").value = "__new__"; // 默认选 sentinel
|
||||
$("nt-wd-new").value = "";
|
||||
$("nt-wd-new").style.display = ""; // sentinel 选中态 → 二级 input 可见
|
||||
customWorkingDir = "";
|
||||
customDirNewMode = false;
|
||||
$("nt-wd-picker").value = "";
|
||||
$("nt-wd-picker").dataset.searching = "";
|
||||
setCustomFolderOpen(false);
|
||||
$("nt-desc").value = ""; $("nt-skill").value = "";
|
||||
$("nt-err").textContent = "";
|
||||
$("nt-wd-hint").textContent = "";
|
||||
wdManuallyEdited = false;
|
||||
$("new-task-modal").classList.add("show");
|
||||
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]);
|
||||
$("nt-wd-sel").value = "__new__"; // populateFolderSelects 重渲后再保险一次
|
||||
renderCustomFolderOptions();
|
||||
syncCustomDirHint();
|
||||
populateModelSelect();
|
||||
$("nt-name").focus();
|
||||
};
|
||||
|
|
@ -41,19 +45,20 @@ function populateModelSelect() {
|
|||
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
|
||||
$("nt-go").onclick = async () => {
|
||||
const name = $("nt-name").value.trim();
|
||||
const sel = $("nt-wd-sel").value;
|
||||
// sentinel:用二级 input 值,空则 fallback name;选已有目录:直接用 value
|
||||
const working_dir = sel === "__new__"
|
||||
? ($("nt-wd-new").value.trim() || name)
|
||||
: sel;
|
||||
const working_dir = currentCustomWorkingDir();
|
||||
const desc = $("nt-desc").value.trim();
|
||||
const skill = $("nt-skill").value;
|
||||
const model_profile = $("nt-model").value;
|
||||
$("nt-err").textContent = "";
|
||||
if (!name) { $("nt-err").textContent = "任务名为必填项"; return; }
|
||||
if (!working_dir) {
|
||||
$("nt-err").textContent = "请选择已有工作目录或创建新目录";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const t = await api("POST", "/v1/tasks",
|
||||
{ name, working_dir, description: desc, skill, model_profile });
|
||||
const body = { 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");
|
||||
await loadTaskList();
|
||||
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() {
|
||||
try {
|
||||
const data = await api("GET", "/v1/folders");
|
||||
|
|
@ -72,9 +77,10 @@ export async function loadFolderSuggestions() {
|
|||
state.folders = state.folders || [];
|
||||
}
|
||||
populateFolderSelects();
|
||||
renderCustomFolderOptions();
|
||||
}
|
||||
|
||||
// 灌 filter-wd + nt-wd-sel options;保留当前选中值
|
||||
// 灌顶部 filter-wd;自定义创建弹框使用与快速新对话一致的组合框。
|
||||
function populateFolderSelects() {
|
||||
const folders = state.folders || [];
|
||||
// 顶部 filter:第一项 "(全部目录)" sentinel
|
||||
|
|
@ -87,17 +93,6 @@ function populateFolderSelects() {
|
|||
}
|
||||
filterSel.innerHTML = filterOpts.join("");
|
||||
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
|
||||
|
|
@ -121,66 +116,163 @@ async function loadSkillOptions() {
|
|||
sel.value = ""; // hd-new 已清空,这里幂等再保一次
|
||||
}
|
||||
|
||||
// === modal wd select + 二级 input 联动 ===
|
||||
// select 选 "__new__" sentinel → 显示二级 input(默认值跟随 name);选已有目录 → 隐藏二级 input
|
||||
// wdManuallyEdited:用户改过二级 input 后置 true,name 不再覆盖;清空二级 input 重置 false
|
||||
|
||||
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}」` : `+ 新建(跟随任务名)`;
|
||||
// === 自定义创建目录组合框(与“开始一段新对话”一致)===
|
||||
// UI 新契约:工作目录始终必填,任务名选填;不再用任务名自动补目录。
|
||||
function currentCustomWorkingDir() {
|
||||
return customWorkingDir.trim();
|
||||
}
|
||||
|
||||
function updateWdHint() {
|
||||
const hint = $("nt-wd-hint");
|
||||
const sel = $("nt-wd-sel").value;
|
||||
if (sel === "__new__") {
|
||||
const v = $("nt-wd-new").value.trim();
|
||||
const name = $("nt-name").value.trim();
|
||||
const target = v || name;
|
||||
if (!target) { hint.textContent = ""; return; }
|
||||
// 用户手输的新名恰好命中已有目录 → 提示会复用而非新建
|
||||
const collision = (state.folders || []).find(f => f.name === target);
|
||||
if (collision) {
|
||||
const n = collision.n_tasks || 0;
|
||||
hint.innerHTML = `<span style="color:var(--accent);">! 已有同名目录,将复用</span> · ${n} 个任务`;
|
||||
} else {
|
||||
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(target)}</span>`;
|
||||
}
|
||||
} else {
|
||||
const f = (state.folders || []).find(x => x.name === sel);
|
||||
const n = f ? (f.n_tasks || 0) : 0;
|
||||
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`;
|
||||
}
|
||||
function customDirIsNew() {
|
||||
return !!currentCustomWorkingDir() && customDirNewMode;
|
||||
}
|
||||
|
||||
// name 改变 → 更新 sentinel label;若未脱钩且当前是 sentinel,二级 input 跟随 name
|
||||
$("nt-name").addEventListener("input", () => {
|
||||
updateSentinelLabel();
|
||||
if (!wdManuallyEdited && $("nt-wd-sel").value === "__new__") {
|
||||
$("nt-wd-new").value = $("nt-name").value;
|
||||
function syncCustomDirHint() {
|
||||
const target = currentCustomWorkingDir();
|
||||
$("nt-wd-hint").textContent = target
|
||||
? customDirIsNew()
|
||||
? `将新建工作目录「${target}」`
|
||||
: `文件将保存到「${target}」`
|
||||
: "可选择已有目录,也可以新建目录。";
|
||||
}
|
||||
updateWdHint();
|
||||
|
||||
function renderCustomFolderOptions() {
|
||||
const input = $("nt-wd-picker");
|
||||
const list = $("nt-wd-options");
|
||||
if (!input || !list) return;
|
||||
const query = input.dataset.searching === "1"
|
||||
? input.value.trim().toLocaleLowerCase()
|
||||
: "";
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
// wd select 切换 → 切显示二级 input + 刷 hint
|
||||
$("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();
|
||||
});
|
||||
|
||||
// 二级 input 改变 → 非空视为手动修改;清空重置 flag 但保持空(避免 backspace 想换名时被打断)
|
||||
$("nt-wd-new").addEventListener("input", () => {
|
||||
wdManuallyEdited = $("nt-wd-new").value.trim() !== "";
|
||||
updateWdHint();
|
||||
});
|
||||
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", "");
|
||||
}
|
||||
}
|
||||
|
||||
function selectCustomFolder(name, isNew) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
customWorkingDir = trimmed;
|
||||
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,
|
||||
);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setCustomFolderOpen(false);
|
||||
customPicker.blur();
|
||||
}
|
||||
};
|
||||
customPicker.onblur = () => setTimeout(() => setCustomFolderOpen(false), 0);
|
||||
$("nt-wd-toggle").onmousedown = e => e.preventDefault();
|
||||
$("nt-wd-toggle").onclick = () => {
|
||||
const open = !customCombo.classList.contains("open");
|
||||
setCustomFolderOpen(open);
|
||||
if (open) {
|
||||
customPicker.focus();
|
||||
customPicker.select();
|
||||
renderCustomFolderOptions();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue