ui: 工作目录回到原生 select + sentinel + 二级 input (modal + 顶部 filter)

combobox 方案推翻 — 即使 show 不过滤,modal wd 因联动有值后用户直觉仍是
"得点开下拉看选项",自实现 panel 不如浏览器原生 select 稳。

- modal nt-wd-sel 第一项 sentinel "+ 新建「<name>」"(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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-21 21:20:34 +08:00
parent 32a8c348a8
commit fa6cb72103
2 changed files with 105 additions and 143 deletions

View File

@ -23,7 +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` 是否定义判断
- **工作目录回到原生 `<select>` + sentinel + 二级 input(modal + 顶部 filter)**:combobox 方案推翻 —— 即使 show 时不过滤,modal 里 wd 因联动有值之后用户的直觉仍然是"我得点开下拉看选项",自己实现的 panel 总不如浏览器原生 select 稳。改回 select 范式:① modal `nt-wd-sel` 第一项 sentinel `+ 新建「<name>」`(label 由 `updateSentinelLabel` 跟 name 实时刷)+ 其后已有目录列表;sentinel 选中时显示二级 `nt-wd-new` 输入框默认值跟随 name,选已有目录时隐藏。`wdManuallyEdited` 锚到二级 input 上(用户改它就脱钩,清空恢复跟随)。② 顶部 `filter-wd` 也改成 `<select>`,首项 `(全部目录)`,onchange → `loadTaskList`;原 input 的 debounce listener 删,搜索 `filter-q` 的 debounce 保留独立写。③ `loadFolderSuggestions` 拉数据 + 新增 `populateFolderSelects` 灌两个 select(保留当前选中值);`enterApp` 启动时 fire-and-forget 预拉一次让左 pane 一打开就有选项。④ hint 在"输入新名恰好命中已有"时提示"将复用而非新建"。combobox 工厂 + .combo CSS + datalist 残留全删
- **新建任务弹窗工作目录改 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,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 @@
</div>
<div class="pane-head" style="gap: 6px;">
<input id="filter-q" 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>
<select id="filter-wd" class="small" style="flex:1; padding: 3px 6px;">
<option value="">(全部目录)</option>
</select>
</div>
<div class="pane-head" style="gap: 6px;">
<span class="small muted" style="white-space:nowrap;">排序</span>
@ -805,11 +786,11 @@
<h3>新建任务</h3>
<label for="nt-name">任务名(必填)</label>
<input id="nt-name" placeholder="例如 初稿大纲" />
<label for="nt-wd-sel">工作目录(默认跟随任务名;可输入新名或选已有目录复用)</label>
<div class="combo">
<input id="nt-wd-sel" placeholder="输入或选已有目录(↑↓ Enter Esc)" autocomplete="off" />
<div class="combo-panel" id="nt-wd-panel"></div>
</div>
<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;" />
<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" />
@ -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() {
`<option value="${escapeHtml(m.profile)}" ${m.is_default ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).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 = ['<option value="">(全部目录)</option>'];
for (const f of folders) {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
filterOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)} — ${escapeHtml(tag)}</option>`);
}
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
@ -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 = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${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 = `<span style="color:var(--accent);">! 已有同名目录,将复用</span> · ${n} 个任务`;
} else {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(target)}</span>`;
}
} else {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(v)}</span>`;
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} 个任务`;
}
}
// === 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
// 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();
});