ui: 工作目录 combobox 从 datalist 改自定义 dropdown (modal + 顶部 filter)

datalist 在 input 非空时下拉被浏览器按前缀过滤丢失,联动 name 后
体验比原 select 还差。改方案:

- 抽 makeFolderCombo({input, panel, onPick}) 工厂:focus/click 显完整列表,
  input 子串过滤,mousedown preventDefault 兜住 blur 提前关 panel,
  键盘 ↑↓ Enter Esc
- modal nt-wd-sel 和顶部 filter-wd 都接工厂,各自传 onPick
  (modal 置 wdManuallyEdited + updateWdHint,filter 走 loadTaskList)
- .combo / .combo-panel 样式提到全局
- 删 <datalist> 元素 + loadFolderSuggestions 灌 datalist 的代码,
  ensureFoldersLoaded 改用 state.folders 判断

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-21 21:03:37 +08:00
parent 8d7f60d899
commit 32a8c348a8
2 changed files with 116 additions and 18 deletions

View File

@ -23,6 +23,7 @@
### 2026-05-21
- **新建任务弹窗 combobox 从 datalist 改自定义 dropdown(modal + 顶部 filter-wd 双处)**:接上一条迭代 —— `<input list="folders-datalist">` 在 wd 因联动有了值之后,浏览器按前缀过滤把下拉过滤没了,用户只能点右侧三角看完整列表,体验比原 `<select>` 还差。改方案:抽 `makeFolderCombo({input, panel, onPick})` 工厂函数 —— input 旁加 absolute 定位的 `.combo-panel`,focus/click 弹出完整列表、input 时子串过滤(不再前缀)、点击项填入(mousedown preventDefault 阻止 input blur 提前关 panel)、blur 延迟 120ms 兜底关闭、键盘 ↑↓ Enter Esc。`.combo` / `.combo-panel` 样式提到全局。modal `nt-wd-sel` 和顶部 `filter-wd` 都走该工厂,各自传 `onPick`(modal 置 `wdManuallyEdited` + `updateWdHint`,filter 走 `loadTaskList`)。datalist 元素 + `loadFolderSuggestions` 灌它的逻辑全删,`ensureFoldersLoaded` 改用 `state.folders` 是否定义判断。
- **新建任务弹窗工作目录改 combobox + name 联动**:`web/static/dev.html` modal 里 `nt-wd-sel``<select>` 改成 `<input list="folders-datalist">`,删 `+ 新建目录…` 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**: \`<name>\` — 用户创建时声明的主 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 提匹配概率。

View File

@ -525,6 +525,24 @@
#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 {
@ -696,7 +714,10 @@
</div>
<div class="pane-head" style="gap: 6px;">
<input id="filter-q" class="small" placeholder="搜索名称/描述..." style="flex:1; padding: 3px 6px;" />
<input id="filter-wd" list="folders-datalist" class="small" placeholder="工作目录" style="flex:1; padding: 3px 6px;" />
<div class="combo" style="flex:1;">
<input id="filter-wd" class="small" placeholder="工作目录" autocomplete="off" style="width:100%; padding: 3px 6px;" />
<div class="combo-panel" id="filter-wd-panel"></div>
</div>
</div>
<div class="pane-head" style="gap: 6px;">
<span class="small muted" style="white-space:nowrap;">排序</span>
@ -785,8 +806,10 @@
<label for="nt-name">任务名(必填)</label>
<input id="nt-name" placeholder="例如 初稿大纲" />
<label for="nt-wd-sel">工作目录(默认跟随任务名;可输入新名或选已有目录复用)</label>
<input id="nt-wd-sel" list="folders-datalist" placeholder="目录名" autocomplete="off" />
<datalist id="folders-datalist"></datalist>
<div class="combo">
<input id="nt-wd-sel" placeholder="输入或选已有目录(↑↓ Enter Esc)" autocomplete="off" />
<div class="combo-panel" id="nt-wd-panel"></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" />
@ -1359,9 +1382,9 @@ const _taskScrollObserver = new IntersectionObserver((entries) => {
}
}, { root: $("pane-left"), rootMargin: "200px 0px" });
_taskScrollObserver.observe($("task-sentinel"));
// 工作目录输入框打开 enterApp 时拉一次 datalist(modal 也复用同一 list)
// 顶部 filter-wd combobox 首次 focus 拉一次 folders;modal 打开会重拉(数据可能变)
async function ensureFoldersLoaded() {
if ($("folders-datalist").children.length === 0) await loadFolderSuggestions();
if (!state.folders) await loadFolderSuggestions();
}
$("filter-wd").addEventListener("focus", ensureFoldersLoaded);
@ -2825,6 +2848,7 @@ $("hd-new").onclick = async () => {
$("nt-err").textContent = "";
$("nt-wd-hint").textContent = "";
wdManuallyEdited = false;
wdCombo.hide();
$("new-task-modal").classList.add("show");
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]);
populateModelSelect();
@ -2841,7 +2865,7 @@ function populateModelSelect() {
`<option value="${escapeHtml(m.profile)}" ${m.is_default ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
}
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-cancel").onclick = () => { wdCombo.hide(); $("new-task-modal").classList.remove("show"); };
$("nt-go").onclick = async () => {
const name = $("nt-name").value.trim();
// wd 走联动 + fallback:正常情况已跟随 name;若用户清空了 wd 也兜底用 name
@ -2854,6 +2878,7 @@ $("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);
@ -2863,22 +2888,14 @@ $("nt-go").onclick = async () => {
}
};
// 工作目录:打开 modal 时拉一次,灌共享的 datalist(顶部筛选 filter-wd 和 modal nt-wd-sel 都用它)
// 同时缓存到 state.folders 供 hint 判断"命中已有"/"新建"
// 工作目录:供 combobox panel 和 hint 共用,缓存到 state.folders
async function loadFolderSuggestions() {
let folders = [];
try {
const data = await api("GET", "/v1/folders");
folders = data.folders || [];
state.folders = data.folders || [];
} catch (e) {
// 静默 — datalist 为空,combobox 仍可手输
state.folders = state.folders || []; // 失败保留旧值;首次失败也置空数组,combobox 显示"无已有目录"
}
state.folders = folders;
const dl = $("folders-datalist");
dl.innerHTML = folders.map((f) => {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
return `<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}" label="${escapeHtml(tag)}"></option>`;
}).join("");
}
// 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills
@ -2919,12 +2936,92 @@ function updateWdHint() {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(v)}</span>`;
}
}
// === 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 = `<div class="empty">${all.length === 0 ? "无已有目录" : "无匹配 · 将新建"}</div>`;
return;
}
panel.innerHTML = filtered.map((x, i) => {
const tag = x.n_tasks ? `${x.n_tasks} 个任务` : `空目录`;
return `<div class="item${i === activeIdx ? " active" : ""}" data-idx="${i}">`
+ `<span class="name">${escapeHtml(x.name)}</span>`
+ `<span class="tag">${escapeHtml(tag)}</span>`
+ `</div>`;
}).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
$("nt-name").addEventListener("input", () => {
if (!wdManuallyEdited) $("nt-wd-sel").value = $("nt-name").value;
updateWdHint();
});
// wd 改变 → 非空视为手动修改;清空视为"恢复跟随",仅重置 flag(不立刻填回,避免打断输入)
// wd 改变(用户打字) → 非空视为手动修改;清空 → 重置 flag 但保持空(避免 backspace 想换名字时被打断)
$("nt-wd-sel").addEventListener("input", () => {
wdManuallyEdited = $("nt-wd-sel").value.trim() !== "";
updateWdHint();