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:
parent
18f702886f
commit
d30435198c
|
|
@ -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 行(标题 + 新建),列表可视区更高。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.12.3"
|
||||
__version__ = "0.12.4"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue