feat(web): 模型选择瘦身 — 对话模型常驻 + 生图/生视频收进 ⚙ 弹层 + bump 0.12.4

- meta 行原三个带标签下拉(模型/生图/生视频)占满整行 → 高频对话模型常驻可见可切,
  低频生图/生视频收进一个「⚙ 媒体」fixed 弹层(点开才渲染 select)
- 行为不变:媒体模型选中值仍只进 state.*,随下条消息 image_model/video_model 发;
  send 读 state 不读 DOM,迁移安全;两个 select 都没配时连 ⚙ 都不画

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-13 13:07:51 +08:00
parent 18f702886f
commit d30435198c
4 changed files with 74 additions and 26 deletions

View File

@ -21,6 +21,12 @@
## 已完成关键能力
### 2026-06-13 / 模型选择瘦身:对话模型常驻 + 生图/生视频收进 ⚙ 弹层
- `#chat-meta` 右侧原三个带标签下拉(模型/生图/生视频)占满整行。改为**高频的对话模型下拉常驻**(一眼可见当前模型、直接切),**低频的生图/生视频收进一个「⚙ 媒体」弹层**(fixed 定位逃出 pane overflow,点开才渲染 select)。meta 行从"3 下拉"降到"1 下拉 + 1 齿轮"。
- 行为不变:生图/生视频选中值仍只进 `state.imageModel/videoModel`、随下条消息 POST 的 `image_model/video_model` 发(send 逻辑读 state 不读 DOM,迁移安全);`onChangeImageModel/onChangeVideoModel` 复用。imageModels/videoModels 皆空时连 ⚙ 都不画。
- 改动文件:`dev.html`(弹层元素 + CSS)、`chat.js`(renderMediaModelTrigger / openMediaModelPop + 点外/resize/scroll 关闭)。bump 0.12.3 → 0.12.4。
### 2026-06-13 / 左栏筛选区可折叠(默认展开)
- 左栏顶部原 4 行固定头把任务列表压矮。把搜索/状态/目录/排序四个筛选控件归到两行 `.task-filter-row`,标题行加「筛选 ▾」toggle:**默认展开**,点击折叠只藏 UI(已选条件仍生效),偏好存 `localStorage`(`zcbot.task-filters-collapsed`),与 pane 折叠同套范式。折叠后左栏顶部从 4 行降到 2 行(标题 + 新建),列表可视区更高。

View File

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

View File

@ -439,6 +439,21 @@
z-index: 60; padding: 4px 0;
}
#floating-menu.show { display: block; transform-origin: top right; animation: menu-in .14s cubic-bezier(.2,.7,.2,1); }
/* 生图/生视频 模型弹层:同 floating-menu 的 fixed 定位骨架,内容是带标签的 select 行 */
#media-model-pop {
display: none; position: fixed;
min-width: 220px; background: #fff;
border: 1px solid var(--border); border-radius: var(--r-md);
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
z-index: 60; padding: 8px;
}
#media-model-pop.show { display: block; transform-origin: top right; animation: menu-in .14s cubic-bezier(.2,.7,.2,1); }
#media-model-pop .mm-row { display: flex; align-items: center; gap: 8px; }
#media-model-pop .mm-row + .mm-row { margin-top: 6px; }
#media-model-pop .mm-label { font-size: 12px; color: var(--muted); white-space: nowrap; min-width: 48px; display: inline-flex; align-items: center; gap: 4px; }
#media-model-pop select { font-size: 12px; padding: 3px 6px; flex: 1; min-width: 0; }
/* meta 行的 ⚙ 触发按钮 */
#media-model-btn { line-height: 1; padding: 2px 7px; }
.dd-item {
display: block; width: 100%; text-align: left;
padding: 6px 14px; font-size: 13px; line-height: 1.4;
@ -1310,6 +1325,9 @@
<!-- ───── floating dropdown menu (single instance) ───── -->
<div id="floating-menu"></div>
<!-- ───── 生图 / 生视频 模型弹层(meta 行 ⚙ 触发,fixed 逃出 pane overflow) ───── -->
<div id="media-model-pop"></div>
<!-- ───── new task modal ───── -->
<div id="new-task-modal" class="modal">
<div class="card">

View File

@ -315,15 +315,13 @@ function renderChatMeta() {
${t.description ? `<span class="muted desc">${escapeHtml(t.description)}</span>` : ""}
<span class="spacer"></span>
${renderModelDropdown(t)}
${renderImageModelDropdown()}
${renderVideoModelDropdown()}
${renderMediaModelTrigger()}
`;
const sel = $("chat-model-sel");
if (sel) sel.onchange = onChangeModel;
const imgSel = $("chat-image-model-sel");
if (imgSel) imgSel.onchange = onChangeImageModel;
const vidSel = $("chat-video-model-sel");
if (vidSel) vidSel.onchange = onChangeVideoModel;
// 生图/生视频 收进 ⚙ 弹层(低频),点开时再渲染 select 并接 onChange
const mmBtn = $("media-model-btn");
if (mmBtn) mmBtn.onclick = (e) => { e.stopPropagation(); openMediaModelPop(mmBtn); };
const active = t.status === "active";
$("chat-form").style.display = active ? "flex" : "none";
syncOptimizeBtn();
@ -343,35 +341,61 @@ function renderModelDropdown(t) {
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">模型</span><span class="mdl-icon" aria-hidden="true">💬</span><select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
}
function renderImageModelDropdown() {
// imageModels 为空(yaml 无 image variant)→ 不画下拉。注意不依赖 ARK_API_KEY 是否设了
// —— 这里只是展示元数据,真正调用时 backend 那边没 key 自然 tool 不挂(用户不会
// 在没 key 的环境点出图,prompt 里 seedream 工具压根不在 schema)。
if (!state.imageModels || state.imageModels.length === 0) return "";
const cur = state.imageModel || "";
const opts = state.imageModels.map(m =>
// 生图/生视频 是低频情景操作 → 不在 meta 行常驻,收进一个 ⚙ 弹层。
// imageModels/videoModels 均为空(yaml 无 image/video variant)时连 ⚙ 都不画。
function renderMediaModelTrigger() {
const hasImg = state.imageModels && state.imageModels.length > 0;
const hasVid = state.videoModels && state.videoModels.length > 0;
if (!hasImg && !hasVid) return "";
return `<button id="media-model-btn" class="small" title="生图 / 生视频 模型选择">⚙ 媒体</button>`;
}
// 弹层里一行 = 标签(icon + 文字) + select。沿用原 onChange,选中值仍只进 state.* 随下条消息发。
function mediaSelectRow(icon, label, id, list, cur, title) {
const opts = list.map(m =>
`<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">生图</span><span class="mdl-icon" aria-hidden="true">🖼</span><select id="chat-image-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生图时使用的模型(本地选择,不入库)">${opts}</select></span>`;
return `<div class="mm-row"><span class="mm-label">${icon} ${label}</span>`
+ `<select id="${id}" title="${title}">${opts}</select></div>`;
}
function openMediaModelPop(btn) {
const pop = $("media-model-pop");
let html = "";
if (state.imageModels && state.imageModels.length)
html += mediaSelectRow("🖼", "生图", "chat-image-model-sel", state.imageModels, state.imageModel || "", "下一条消息触发生图时使用的模型(本地选择,不入库)");
if (state.videoModels && state.videoModels.length)
html += mediaSelectRow("🎬", "生视频", "chat-video-model-sel", state.videoModels, state.videoModel || "", "下一条消息触发生视频时使用的模型(本地选择,不入库)");
pop.innerHTML = html;
const imgSel = $("chat-image-model-sel"); if (imgSel) imgSel.onchange = onChangeImageModel;
const vidSel = $("chat-video-model-sel"); if (vidSel) vidSel.onchange = onChangeVideoModel;
// 定位:右上角对齐触发按钮,向下展开;下方空间不足则向上(同 showMenu 思路)
const rect = btn.getBoundingClientRect();
pop.style.visibility = "hidden";
pop.classList.add("show");
const ph = pop.offsetHeight || 100;
pop.style.right = Math.max(4, window.innerWidth - rect.right) + "px";
pop.style.left = "auto";
pop.style.top = (rect.bottom + ph + 8 > window.innerHeight)
? Math.max(4, rect.top - ph - 4) + "px"
: (rect.bottom + 4) + "px";
pop.style.visibility = "";
}
function closeMediaModelPop() { $("media-model-pop").classList.remove("show"); }
// 点弹层外 / resize / 滚动 → 关(选 select 选项的点击落在弹层内,不会误关)
document.addEventListener("click", (e) => {
if (e.target.closest("#media-model-btn") || e.target.closest("#media-model-pop")) return;
closeMediaModelPop();
}, true);
window.addEventListener("resize", closeMediaModelPop);
document.addEventListener("scroll", closeMediaModelPop, true);
function onChangeImageModel(ev) {
// 纯前端 state,不 PATCH;选中值随下一次 POST /v1/tasks/{id}/messages 的 image_model 字段一起发
state.imageModel = ev.target.value || "";
$("chat-hint").textContent = `生图模型 → ${ev.target.options[ev.target.selectedIndex].text}`;
}
function renderVideoModelDropdown() {
// 同 renderImageModelDropdown:videoModels 为空 → 不画。yaml 无 video 段 / 后端
// /v1/video_models 返空时下拉不出现,seedance tool 也不会在 schema 里。
if (!state.videoModels || state.videoModels.length === 0) return "";
const cur = state.videoModel || "";
const opts = state.videoModels.map(m =>
`<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">生视频</span><span class="mdl-icon" aria-hidden="true">🎬</span><select id="chat-video-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生视频时使用的模型(本地选择,不入库)">${opts}</select></span>`;
}
function onChangeVideoModel(ev) {
state.videoModel = ev.target.value || "";
$("chat-hint").textContent = `生视频模型 → ${ev.target.options[ev.target.selectedIndex].text}`;