From fa6cb72103418197c55196aebd83a339d557ed68 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 21 May 2026 21:20:34 +0800 Subject: [PATCH] =?UTF-8?q?ui:=20=E5=B7=A5=E4=BD=9C=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=9B=9E=E5=88=B0=E5=8E=9F=E7=94=9F=20select=20+=20sentinel=20?= =?UTF-8?q?+=20=E4=BA=8C=E7=BA=A7=20input=20(modal=20+=20=E9=A1=B6?= =?UTF-8?q?=E9=83=A8=20filter)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit combobox 方案推翻 — 即使 show 不过滤,modal wd 因联动有值后用户直觉仍是 "得点开下拉看选项",自实现 panel 不如浏览器原生 select 稳。 - modal nt-wd-sel 第一项 sentinel "+ 新建「」"(updateSentinelLabel 跟 name 实时刷),sentinel 选中显示二级 nt-wd-new 默认跟随 name, 选已有目录隐藏;wdManuallyEdited 锚到二级 input - 顶部 filter-wd 改 select,onchange → loadTaskList(无 debounce) - loadFolderSuggestions + populateFolderSelects 灌两个 select,保留当前选中 - enterApp fire-and-forget 预拉 folders 让左 pane 一打开就有选项 - hint 在"新名碰到同名"时提示"将复用而非新建" - combobox 工厂 + .combo CSS + datalist 残留全删 Co-Authored-By: Claude Opus 4.7 (1M context) --- PROGRESS.md | 2 +- web/static/dev.html | 246 +++++++++++++++++++------------------------- 2 files changed, 105 insertions(+), 143 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 7dc5815..90752aa 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,7 +23,7 @@ ### 2026-05-21 -- **新建任务弹窗 combobox 从 datalist 改自定义 dropdown(modal + 顶部 filter-wd 双处)**:接上一条迭代 —— `` 在 wd 因联动有了值之后,浏览器按前缀过滤把下拉过滤没了,用户只能点右侧三角看完整列表,体验比原 `` + sentinel + 二级 input(modal + 顶部 filter)**:combobox 方案推翻 —— 即使 show 时不过滤,modal 里 wd 因联动有值之后用户的直觉仍然是"我得点开下拉看选项",自己实现的 panel 总不如浏览器原生 select 稳。改回 select 范式:① modal `nt-wd-sel` 第一项 sentinel `+ 新建「」`(label 由 `updateSentinelLabel` 跟 name 实时刷)+ 其后已有目录列表;sentinel 选中时显示二级 `nt-wd-new` 输入框默认值跟随 name,选已有目录时隐藏。`wdManuallyEdited` 锚到二级 input 上(用户改它就脱钩,清空恢复跟随)。② 顶部 `filter-wd` 也改成 `` 改成 ``,删 `+ 新建目录…` sentinel + 二级 `nt-wd-new` 输入框;加 `wdManuallyEdited` flag —— name 输入时若 flag=false 自动同步到 wd(programmatic 改 value 不触发 wd input 事件不会假阳性),wd 非空输入置 flag=true 脱钩,wd 清空重置 flag=false 但保持空(避免 backspace 想换名字时被立刻填回打断);submit 保留 `working_dir || name` fallback 兜底空值。`loadFolderSuggestions` 不再渲染 select options,只灌共享 datalist + 缓存到 `state.folders` 供 hint 比对"命中已有/新建"。label 文案 `(可选,留空 → 用任务名...)` → `(默认跟随任务名;可输入新名或选已有目录复用)`,更直观。 - **system prompt 注入 task 预选 skill 提示**:`core/agent_builder.py::_build_system_prompt` 加 `task_skill` 参数,非空时在"工作目录与 task 上下文"段加一行 `- **task 预选 skill**: \`\` — 用户创建时声明的主 skill`;空字符串走老路径,prompt 字节级一致。LLM 拿到这条事实 + `general_v1.md:17-23` 已有的"对应 skill 领域先 load_skill" 规则自然组合 → 主动 load。否决"直接把完整 SKILL.md 预注入 prompt"方案 —— 那会把 `tasks.skill` 从 metadata 升格成 binding,需要同步改 DESIGN.md / 想清楚 PATCH 改 skill 的语义,投入产出比不划算;轻量提示保渐进披露三层架构不动。 - **imagegen skill 加 ⛔ 调 tool 前必须贴 prompt 给用户 + BLOCKING 等确认硬约束**:用户反馈之前流程"模糊就问"不够,清楚的描述也可能模型脑里和用户脑里对不上,事后看图才发现白烧 ¥0.22。改:① 顶部流程一句话加"⛔ 把 prompt 完整贴给用户看 + 问改不改 → 用户明确确认后 → 调 seedream"步骤;② 加「调 tool 前的强制门(铁律)」段定义回复分类(可以/OK/画吧/嗯 算确认;改 X → 重贴重等;沉默/追问别的 → 继续等;模棱两可 → 追问到明确);③ 加「调 tool 前再过一道」段给具体贴 prompt 的对话格式(代码块 + 参数清单 + 预计花费 + 一句"开烧?改什么?");④ 调用范式段加"前置条件:已拿到明确确认才调";⑤ 反模式加两条(没贴就调 / 模棱两可当确认)。本质是把"模型脑内装配"摊到对话层让用户最后过一眼,装配 ≠ 授权调用。:用户反馈 skill 缺图片比例引导。原 SKILL 里 size 表写"比例只能正方形"是基于 doubao.yaml + tool 参数描述只列三个正方形例子的间接推断,无验证。改:① 诊断五维 → 六维,加"比例/尺寸"(ppt 16:9 / 海报 9:16 / 头像 1:1 / 公众号 2.35:1 / 书籍 3:4);② 一次性追问范式加比例项,上下文推断里给"做 ppt/海报/公众号/学术示意"四种用途的默认比例;③ size 参数表重写成"按用途选比例,再选分辨率",列常见 size 参考值 + 明确"非方形是按比例算的参考值,豆包是否原生支持需首次小调用验证";④ 失败解药表加比例错(改 size 不动 prompt)+ API 报错回退默认两条;⑤ 反模式加"不问比例就默认走 yaml 1:1"。承认 unknown:豆包 5.0 实际支持哪些非方形 size 没验证,首次用错就回退默认 + 让用户协商,不臆造。:两根因 —— ① `general_v1.md` 「媒体生成工具」段把 `seedream` 写成一级直觉(列了"画/出/来张"等关键词 + 直接调 tool 的 how-to),压过 skill discovery block 的微弱声音;② imagegen description 关键词覆盖窄(没有"画/绘制/艺术图/图片"等朴素词)。修法:system prompt 那段改成"调 seedream 前**必须先 `load_skill('imagegen')`**",细节判断全移到 skill 里,只留 ¥0.22 计费 + 不装饰生成 + 不连发三条兜底硬约束;imagegen description 扩 17 个触发词(画/绘制/出图/来张/艺术图/写实图/场景图...)。两层联动:一级 prompt 指引到 skill,二级 description 提匹配概率。 diff --git a/web/static/dev.html b/web/static/dev.html index 617d97d..94f6b79 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -525,24 +525,6 @@ #new-task-modal label { display: block; margin-top: 8px; font-size: 12px; color: var(--muted); } #new-task-modal .err { color: var(--accent); font-size: 12px; margin-top: 8px; min-height: 1em; } #new-task-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; } - /* 自定义 combobox(替代原生 datalist —— datalist 在 input 非空时下拉被前缀过滤丢失) */ - .combo { position: relative; } - .combo-panel { - position: absolute; top: calc(100% + 2px); left: 0; right: 0; - background: #fff; border: 1px solid var(--border); border-radius: 6px; - box-shadow: 0 6px 16px rgba(0,0,0,0.12); - max-height: 220px; overflow-y: auto; z-index: 10; - display: none; - } - .combo-panel.show { display: block; } - .combo-panel .item { - display: flex; justify-content: space-between; align-items: baseline; - padding: 6px 10px; cursor: pointer; gap: 12px; font-size: 13px; - } - .combo-panel .item:hover, - .combo-panel .item.active { background: var(--hover); } - .combo-panel .item .tag { color: var(--muted); font-size: 11px; flex-shrink: 0; } - .combo-panel .empty { padding: 8px 10px; color: var(--muted); font-size: 12px; text-align: center; } /* ───── file preview modal ───── */ #file-preview-modal { @@ -714,10 +696,9 @@
-
- -
-
+
排序 @@ -805,11 +786,11 @@

新建任务

- -
- -
-
+ + +
@@ -1202,6 +1183,7 @@ function enterApp() { loadTaskList(); loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标 + loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项) } async function loadModels() { @@ -1363,16 +1345,15 @@ function taskMenuItems(t) { // 筛选 / 排序 / 刷新 一律 reset(loadTaskList 默认 append=false);追加由 sentinel observer 触发 $("filter-status").onchange = () => loadTaskList(); $("filter-order").onchange = () => loadTaskList(); +$("filter-wd").onchange = () => loadTaskList(); // select 选完立即筛 $("btn-refresh-tasks").onclick = () => loadTaskList(); -// 搜索 / 工作目录筛选:debounce 300ms,避免每个字符都打 API +// 搜索 q 是 text input → 300ms debounce 避免每字符打 API let _filterDebounce = null; -function scheduleFilter() { +$("filter-q").addEventListener("input", () => { clearTimeout(_filterDebounce); _filterDebounce = setTimeout(() => loadTaskList(), 300); -} -$("filter-q").addEventListener("input", scheduleFilter); -$("filter-wd").addEventListener("input", scheduleFilter); +}); // 滚动加载:左 pane 整体是 scroll 容器(.pane{overflow:auto}),用 #pane-left 作 root // rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖) @@ -1382,11 +1363,6 @@ const _taskScrollObserver = new IntersectionObserver((entries) => { } }, { root: $("pane-left"), rootMargin: "200px 0px" }); _taskScrollObserver.observe($("task-sentinel")); -// 顶部 filter-wd combobox 首次 focus 拉一次 folders;modal 打开会重拉(数据可能变) -async function ensureFoldersLoaded() { - if (!state.folders) await loadFolderSuggestions(); -} -$("filter-wd").addEventListener("focus", ensureFoldersLoaded); // ───── select task ───── async function selectTask(tid) { @@ -2839,18 +2815,20 @@ async function uploadSelected() { } // ───── new task ───── -// wd 跟随 name 自动同步;用户一旦手动改 wd → 脱钩;若再清空 wd → 恢复跟随 +// wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag let wdManuallyEdited = false; $("hd-new").onclick = async () => { $("nt-name").value = ""; - $("nt-wd-sel").value = ""; + $("nt-wd-sel").value = "__new__"; // 默认选 sentinel + $("nt-wd-new").value = ""; + $("nt-wd-new").style.display = ""; // sentinel 选中态 → 二级 input 可见 $("nt-desc").value = ""; $("nt-skill").value = ""; $("nt-err").textContent = ""; $("nt-wd-hint").textContent = ""; wdManuallyEdited = false; - wdCombo.hide(); $("new-task-modal").classList.add("show"); await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]); + $("nt-wd-sel").value = "__new__"; // populateFolderSelects 重渲后再保险一次 populateModelSelect(); $("nt-name").focus(); }; @@ -2865,11 +2843,14 @@ function populateModelSelect() { `` ).join(""); } -$("nt-cancel").onclick = () => { wdCombo.hide(); $("new-task-modal").classList.remove("show"); }; +$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show"); $("nt-go").onclick = async () => { const name = $("nt-name").value.trim(); - // wd 走联动 + fallback:正常情况已跟随 name;若用户清空了 wd 也兜底用 name - const working_dir = $("nt-wd-sel").value.trim() || name; + const sel = $("nt-wd-sel").value; + // sentinel:用二级 input 值,空则 fallback name;选已有目录:直接用 value + const working_dir = sel === "__new__" + ? ($("nt-wd-new").value.trim() || name) + : sel; const desc = $("nt-desc").value.trim(); const skill = $("nt-skill").value; const model_profile = $("nt-model").value; @@ -2878,7 +2859,6 @@ $("nt-go").onclick = async () => { try { const t = await api("POST", "/v1/tasks", { name, working_dir, description: desc, skill, model_profile }); - wdCombo.hide(); $("new-task-modal").classList.remove("show"); await loadTaskList(); selectTask(t.task_id); @@ -2888,14 +2868,41 @@ $("nt-go").onclick = async () => { } }; -// 工作目录:供 combobox panel 和 hint 共用,缓存到 state.folders +// 工作目录:拉数据 + 灌两个 select(顶部 filter-wd 和 modal nt-wd-sel) async function loadFolderSuggestions() { try { const data = await api("GET", "/v1/folders"); state.folders = data.folders || []; } catch (e) { - state.folders = state.folders || []; // 失败保留旧值;首次失败也置空数组,combobox 显示"无已有目录" + state.folders = state.folders || []; } + populateFolderSelects(); +} + +// 灌 filter-wd + nt-wd-sel options;保留当前选中值 +function populateFolderSelects() { + const folders = state.folders || []; + // 顶部 filter:第一项 "(全部目录)" sentinel + const filterSel = $("filter-wd"); + const filterCur = filterSel.value; + const filterOpts = ['']; + for (const f of folders) { + const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`; + filterOpts.push(``); + } + filterSel.innerHTML = filterOpts.join(""); + filterSel.value = filterCur; // 重渲后恢复选中 + // modal wd:第一项 "+ 新建(跟随任务名)" sentinel(label 由 updateSentinelLabel 实时刷) + const wdSel = $("nt-wd-sel"); + const wdCur = wdSel.value || "__new__"; + const wdOpts = [``]; + for (const f of folders) { + const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`; + wdOpts.push(``); + } + wdSel.innerHTML = wdOpts.join(""); + wdSel.value = wdCur; + updateSentinelLabel(); // 用最新的 name 刷 sentinel } // 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills @@ -2919,111 +2926,66 @@ 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}」` : `+ 新建(跟随任务名)`; +} + function updateWdHint() { - const v = $("nt-wd-sel").value.trim(); const hint = $("nt-wd-hint"); - if (!v) { - // wd 空 → 提交时会 fallback 到 name(见 nt-go);若 name 也空则不显示 - const fallback = $("nt-name").value.trim(); - hint.textContent = fallback ? `→ 用任务名「${fallback}」作目录` : ""; - return; - } - const hit = (state.folders || []).find(f => f.name === v); - if (hit) { - const n = hit.n_tasks || 0; - hint.innerHTML = `→ 复用已有目录 · ${n} 个任务`; + 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 = `! 已有同名目录,将复用 · ${n} 个任务`; + } else { + hint.innerHTML = `→ 新建目录 ${escapeHtml(target)}`; + } } else { - hint.innerHTML = `→ 新建目录 ${escapeHtml(v)}`; + const f = (state.folders || []).find(x => x.name === sel); + const n = f ? (f.n_tasks || 0) : 0; + hint.innerHTML = `→ 复用已有目录 · ${n} 个任务`; } } -// === folder combobox 工厂(自定义,代替 datalist —— datalist 在 input 非空时下拉被前缀过滤丢失) === -// 数据源统一走 state.folders(loadFolderSuggestions 维护);onPick(name) 是选中后的回调 -function makeFolderCombo({ input, panel, onPick }) { - let filtered = []; - let activeIdx = -1; - function render() { - const all = state.folders || []; - const f = (input.value || "").trim().toLowerCase(); - filtered = all.filter(x => !f || x.name.toLowerCase().includes(f)); - if (filtered.length === 0) { - panel.innerHTML = `
${all.length === 0 ? "无已有目录" : "无匹配 · 将新建"}
`; - return; - } - panel.innerHTML = filtered.map((x, i) => { - const tag = x.n_tasks ? `${x.n_tasks} 个任务` : `空目录`; - return `
` - + `${escapeHtml(x.name)}` - + `${escapeHtml(tag)}` - + `
`; - }).join(""); - } - function show() { activeIdx = -1; render(); panel.classList.add("show"); } - function hide() { panel.classList.remove("show"); activeIdx = -1; } - function pick(idx) { - const f = filtered[idx]; - if (!f) return; - input.value = f.name; - hide(); - if (onPick) onPick(f.name); - } - // 内部:打字 → 实时过滤 + 保持显示(外部 input listener 仍可注册业务副作用,顺序无依赖) - input.addEventListener("input", () => { activeIdx = -1; render(); panel.classList.add("show"); }); - input.addEventListener("focus", show); - input.addEventListener("click", show); - // blur 延迟关闭,让 panel item 的 mousedown 来得及触发(mousedown 也 preventDefault 双保险) - input.addEventListener("blur", () => setTimeout(hide, 120)); - panel.addEventListener("mousedown", (e) => { - const item = e.target.closest(".item"); - if (!item) return; - e.preventDefault(); // 阻止 input 失焦提前关 panel - pick(parseInt(item.dataset.idx)); - }); - // 键盘:↑↓ 移动 active,Enter 选中,Esc 关闭 - input.addEventListener("keydown", (e) => { - if (!panel.classList.contains("show")) return; - if (e.key === "ArrowDown") { - e.preventDefault(); - if (filtered.length === 0) return; - activeIdx = (activeIdx + 1) % filtered.length; - render(); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - if (filtered.length === 0) return; - activeIdx = activeIdx <= 0 ? filtered.length - 1 : activeIdx - 1; - render(); - } else if (e.key === "Enter" && activeIdx >= 0) { - e.preventDefault(); - pick(activeIdx); - } else if (e.key === "Escape") { - e.preventDefault(); - hide(); - } - }); - return { show, hide, render }; -} - -// modal 新建任务的工作目录:选中后置 manual flag + 刷新 hint -const wdCombo = makeFolderCombo({ - input: $("nt-wd-sel"), - panel: $("nt-wd-panel"), - onPick: () => { wdManuallyEdited = true; updateWdHint(); }, -}); -// 顶部 filter-wd:选中后立即触发筛选(打字仍走 scheduleFilter debounce) -makeFolderCombo({ - input: $("filter-wd"), - panel: $("filter-wd-panel"), - onPick: () => loadTaskList(), -}); - -// name 改变 → 若 wd 未被手动改,同步;并刷新 hint +// name 改变 → 更新 sentinel label;若未脱钩且当前是 sentinel,二级 input 跟随 name $("nt-name").addEventListener("input", () => { - if (!wdManuallyEdited) $("nt-wd-sel").value = $("nt-name").value; + updateSentinelLabel(); + if (!wdManuallyEdited && $("nt-wd-sel").value === "__new__") { + $("nt-wd-new").value = $("nt-name").value; + } updateWdHint(); }); -// wd 改变(用户打字) → 非空视为手动修改;清空 → 重置 flag 但保持空(避免 backspace 想换名字时被打断) -$("nt-wd-sel").addEventListener("input", () => { - wdManuallyEdited = $("nt-wd-sel").value.trim() !== ""; + +// 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(); });