diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d03a7..88a966b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +## 2.4.1 + +**Model picker fixes.** The Models card is one card again, the picker follows the app's styling, no model is hidden from it, and an active model invalidated by a FastFlowLM upgrade is now called out instead of failing silently. + +### Fixed + +- **No model is hidden from the picker any more.** Suggestions used to drop every candidate the size heuristic judged unfit, which made real catalog models invisible — `qwen3.6-moe:35b-a3b` never appeared and had to be pulled by hand. Every model FastFlowLM offers is now listed; oversized ones are shown with a "too large" marker and still ask for confirmation before downloading. +- **Mixture-of-Experts models are sized correctly.** `…:35b-a3b` was read as a 35B model when only ~3B parameters are active per token. Sizing now prefers FastFlowLM's own measured **footprint** (so `gemma4-it:e4b`, which reads as "4B" by name but is really 8B / 9.1 GB, is also judged correctly) and falls back to active-parameter counts for MoE tags. Fit labels now show the real memory cost, e.g. "~24.3 GB needs most of ~25.6 GB usable". +- **A model broken by a FastFlowLM upgrade is now reported.** FLM 0.9.45 rejects models whose local weights were stamped for an older version and reports them as not installed; nothing surfaced that, so the next hotkey failed with an opaque provider error. Config → Models now shows a warning naming the model and the remedy. + +### Changed + +- **"Installed models" and "Pull a new model" are a single "Models" card** with `Installed` / `Add a model` / `FastFlowLM runtime` sections, instead of two separate cards. +- **The model picker is styled by the app.** It replaced a native `` dropdown and a native ``, ∵ browser chrome ignores page CSS); theme-aware + keyboard-navigable (↑↓/Enter/Esc) ## §T tasks @@ -121,6 +124,7 @@ T24|x|prompt-v2 default + v1 rollback selector + 240/320/420 caps|V24,V27,V28,V3 T25|x|FastFlowLM startup+idle keep-warm + cold/warm measurement support|V31 T26|x|2.3.0 release evidence + version/docs rebaseline after A/B gate passes|V18,V29,V33,V34,V35,V36 T27|x|streaming Chat tab (SSE): `ffp_chat.stream_send` + daemon `_STREAM_ACTIONS`/`_sse_frame`/`_stream_action` + `app.js` fetch-reader w/ `chat_send` fallback; live FLM first-token 1.58s|V37 +T28|x|model picker 2.4.1: footprint/MoE sizing + never-hide + active-model health + merged single Models card + app-styled combobox|V38,V39,V40 ``` ## §B bugs @@ -155,4 +159,10 @@ B25|2026-07-10|broad V35 patch changed runtime/system branches instead of retry B26|2026-07-10|default v2 input ≥ routing threshold still made compression subcalls; 1 huge clause could exceed V27|V27,V35; bypass routing + bound grounded sections B27|2026-07-10|upgraded 2.2 cfg retained `detail_level=balanced` ∴ v1 selector missed new concise-default identity|V24,V30; `prompt_version=v1` authoritative legacy XML path B28|2026-07-10|2.2 persisted identity cfg lacked `prompt_version` + kept `balanced` ∴ upgrade bypassed v2 path|V36; narrow legacy-identity migration +B29|2026-07-27|`qwen3.6-moe:35b-a3b` invisible in picker: `parse_params_b` read MoE total 35B (⊥ 3B active) → `fits=no` → `app.js` `continue` silently dropped it ∴ manual `flm pull`|V38; footprint-first sizing + MoE active parse + never-hide (marked + confirm) +B30|2026-07-27|FLM 0.9.45 invalidated locally-pulled `qwen3.5:4b` (stamped 0.9.43) → FLM reports ⊥ installed; ⊥ detection ∴ next hotkey failed opaquely (Ollama absent ∴ ⊥ fallback)|V39; `_active_model_health` + dashboard warning +B31|2026-07-27|model picker ⊥ followed app styling ∵ native `` popup drawn by browser chrome (page CSS inert) + native `.value). +let selectedModel = ""; // Mirrors ffp_hardware.parse_params_b: 'qwen3.5:4b' -> 4, 'mistral:7b' -> 7. +// NOTE: total params. MoE tags ('...35b-a3b') are 35 here; the daemon supplies +// the authoritative fit via model_recommendations, so this is only a fallback +// for a free-typed name the catalog has never heard of. function parseParamsB(name) { const m = /(\d+(?:\.\d+)?)\s*b\b/i.exec(name || ""); return m ? parseFloat(m[1]) : null; } -async function loadModels() { +// 'qwen3.6-moe:35b-a3b' -> 3 (active params); null when the tag isn't MoE. +function parseActiveParamsB(name) { + const m = /\ba(\d+(?:\.\d+)?)\s*b\b/i.exec(name || ""); + if (!m) return null; + const active = parseFloat(m[1]); + const total = parseParamsB(name); + return total !== null && active < total ? active : null; +} + +function renderInstalledModels(names, active, errorMessage) { const list = $("models-list"); - const suggestions = $("pull-suggestions"); list.replaceChildren(); - suggestions.replaceChildren(); - const installedNames = new Set(); + if (errorMessage) { + const li = document.createElement("li"); + li.className = "combo-empty"; + li.textContent = `Could not list models: ${errorMessage}`; + list.append(li); + return; + } + if (!names.length) selectedModel = ""; + if (!names.includes(selectedModel)) selectedModel = active && names.includes(active) ? active : (names[0] || ""); + for (const name of names) { + const li = document.createElement("li"); + li.className = "model-row"; + li.setAttribute("role", "option"); + li.setAttribute("aria-selected", String(name === selectedModel)); + const label = document.createElement("span"); + label.textContent = name; + li.append(label); + if (name === active) { + const tag = document.createElement("span"); + tag.className = "tag active"; + tag.textContent = "★ active"; + li.append(tag); + } + li.addEventListener("click", () => { + selectedModel = name; + for (const row of list.children) { + row.setAttribute("aria-selected", String(row === li)); + } + }); + list.append(li); + } +} + +function renderModelAlert(health) { + const el = $("model-alert"); + if (!health || health.status !== "not_installed") { + el.hidden = true; + el.textContent = ""; + return; + } + // A provider upgrade can invalidate an already-pulled model (FLM 0.9.45 + // rejects weights stamped for 0.9.43) and then reports it as not installed. + // Without this the next hotkey just fails with an opaque provider error. + el.hidden = false; + el.textContent = + `⚠ Active model "${health.model}" is not installed for the current ` + + `${health.provider} version. Hotkeys and chat will fail until you ` + + `re-download it below (or pick another installed model).`; +} + +async function loadModels() { + let installedNames = []; + let activeName = ""; try { const installed = await action("models_installed"); - for (const name of installed.models || []) { - installedNames.add(name); - const opt = document.createElement("option"); - opt.value = name; - opt.textContent = name + (name === installed.active ? " ★ active" : ""); - opt.selected = name === installed.active; - list.append(opt); - } + installedNames = installed.models || []; + activeName = installed.active || ""; + renderInstalledModels(installedNames, activeName, ""); } catch (e) { - const opt = document.createElement("option"); - opt.textContent = `(error: ${e.message})`; - list.append(opt); + renderInstalledModels([], "", e.message); } - // Hardware-aware suggestions: detected RAM/VRAM caps the model size, so - // the datalist only offers models this machine can actually run. The input - // still accepts any free-typed name (oversized ones get a confirm). + + // Hardware-aware fit info. Every candidate is listed — oversized ones are + // shown with a warning rather than hidden, because silently filtering them + // made a catalog model invisible and forced users to pull it by hand. let rec = null; try { rec = await action("model_recommendations"); modelBudget = rec.budget || null; + const usable = rec.usable_memory_gb; $("hw-summary").textContent = modelBudget - ? `This machine: ${modelBudget.summary}. Larger models are hidden from suggestions.` + ? `This machine: ${modelBudget.summary}` + (usable ? ` (~${usable} GB usable for weights).` : ".") : ""; + renderModelAlert(rec.active_model); } catch { modelBudget = null; $("hw-summary").textContent = ""; + renderModelAlert(null); } + + const installedSet = new Set(installedNames); if (rec && (rec.models || []).length) { - for (const m of rec.models) { - if (m.fits === "no" || installedNames.has(m.name)) continue; - const opt = document.createElement("option"); - opt.value = m.name; - if (m.fits === "tight") opt.label = `${m.name} — tight fit`; - suggestions.append(opt); - } + modelOptions = rec.models.filter((m) => !installedSet.has(m.name)); } else { try { const avail = await action("models_not_installed"); - for (const name of avail.models || []) { - const opt = document.createElement("option"); - opt.value = name; - suggestions.append(opt); - } + modelOptions = (avail.models || []).map((name) => ({ name, fits: "unknown", fit_reason: "" })); } catch { - /* suggestions stay empty when the provider is unreachable */ + modelOptions = []; + } + } + renderComboOptions(); +} + +// ---- Pull combobox (replaces , which browsers refuse to style) ---- + +let comboIndex = -1; + +function fitLabel(m) { + if (m.fits === "tight") return "tight"; + if (m.fits === "no") return "too large"; + return ""; +} + +function renderComboOptions() { + const box = $("pull-options"); + const query = $("pull-name").value.trim().toLowerCase(); + const matches = modelOptions.filter((m) => !query || m.name.toLowerCase().includes(query)); + box.replaceChildren(); + comboIndex = -1; + if (!matches.length) { + const li = document.createElement("li"); + li.className = "combo-empty"; + li.textContent = modelOptions.length ? "No match — any name can still be typed." : "No suggestions available."; + box.append(li); + return; + } + for (const m of matches) { + const li = document.createElement("li"); + li.className = "combo-option" + (m.fits === "no" ? " oversized" : ""); + li.setAttribute("role", "option"); + li.dataset.name = m.name; + const label = document.createElement("span"); + label.textContent = m.name; + li.append(label); + const tag = fitLabel(m); + if (tag) { + const fit = document.createElement("span"); + fit.className = `fit ${m.fits}`; + fit.textContent = tag; + if (m.fit_reason) fit.title = m.fit_reason; + li.append(fit); + } else if (m.footprint_gb) { + const fit = document.createElement("span"); + fit.className = "fit"; + fit.textContent = `${m.footprint_gb} GB`; + if (m.fit_reason) fit.title = m.fit_reason; + li.append(fit); } + li.addEventListener("mousedown", (e) => { + e.preventDefault(); // keep focus in the input so blur doesn't race the click + $("pull-name").value = m.name; + closeCombo(); + }); + box.append(li); + } +} + +function openCombo() { + renderComboOptions(); + $("pull-options").hidden = false; + $("pull-name").setAttribute("aria-expanded", "true"); +} + +function closeCombo() { + $("pull-options").hidden = true; + $("pull-name").setAttribute("aria-expanded", "false"); + comboIndex = -1; +} + +function moveCombo(delta) { + const rows = [...$("pull-options").querySelectorAll(".combo-option")]; + if (!rows.length) return; + if ($("pull-options").hidden) openCombo(); + comboIndex = (comboIndex + delta + rows.length) % rows.length; + rows.forEach((r, i) => r.classList.toggle("highlighted", i === comboIndex)); + rows[comboIndex].scrollIntoView({ block: "nearest" }); +} + +function commitCombo() { + const rows = [...$("pull-options").querySelectorAll(".combo-option")]; + if (comboIndex >= 0 && rows[comboIndex]) { + $("pull-name").value = rows[comboIndex].dataset.name || ""; + closeCombo(); + return true; } + return false; } async function loadAutostart() { @@ -1111,8 +1254,11 @@ async function saveConfig() { } async function setActiveModel() { - const name = $("models-list").value; - if (!name) return; + const name = selectedModel; + if (!name) { + setStatus("config-status", "Pick an installed model first.", false); + return; + } try { await action("apply_config_patch", { patch: { llm: { model: name } } }); setStatus("config-status", `✅ Active model: ${name}`); @@ -1124,8 +1270,11 @@ async function setActiveModel() { } async function removeModel() { - const name = $("models-list").value; - if (!name) return; + const name = selectedModel; + if (!name) { + setStatus("config-status", "Pick an installed model first.", false); + return; + } if (!(await confirmDialog(`Remove model '${name}' from local storage?`, "Remove"))) return; setStatus("config-status", `Removing ${name}…`); try { @@ -1145,10 +1294,19 @@ async function pullModel() { setText("pull-status", "Type or pick a model name first."); return; } - const params = parseParamsB(name); - if (modelBudget && params && params > modelBudget.max_params_b * 1.5) { - const msg = `'${name}' looks like a ${params}B model — likely too big for this machine (${modelBudget.summary}). Pull anyway?`; - if (!(await confirmDialog(msg, "Pull anyway"))) return; + closeCombo(); + // Prefer the daemon's authoritative verdict (it knows the real footprint and + // MoE active-param count); fall back to tag parsing for an unknown name. + const known = modelOptions.find((m) => m.name === name); + if (known && known.fits === "no") { + const msg = `'${name}' is likely too big for this machine — ${known.fit_reason}. Download anyway?`; + if (!(await confirmDialog(msg, "Download anyway"))) return; + } else if (!known) { + const effective = parseActiveParamsB(name) ?? parseParamsB(name); + if (modelBudget && effective && effective > modelBudget.max_params_b * 1.5) { + const msg = `'${name}' looks like a ${effective}B model — likely too big for this machine (${modelBudget.summary}). Download anyway?`; + if (!(await confirmDialog(msg, "Download anyway"))) return; + } } try { const state = await action("pull_start", { model: name }); @@ -1913,6 +2071,25 @@ document.addEventListener("DOMContentLoaded", () => { $("model-set-active").addEventListener("click", setActiveModel); $("model-remove").addEventListener("click", removeModel); $("pull-btn").addEventListener("click", pullModel); + // Pull combobox: typing filters, ▾ toggles, arrows/Enter/Escape navigate. + $("pull-name").addEventListener("input", openCombo); + $("pull-name").addEventListener("focus", openCombo); + $("pull-name").addEventListener("blur", () => setTimeout(closeCombo, 120)); + $("pull-name").addEventListener("keydown", (e) => { + if (e.key === "ArrowDown") { e.preventDefault(); moveCombo(1); } + else if (e.key === "ArrowUp") { e.preventDefault(); moveCombo(-1); } + else if (e.key === "Escape") { closeCombo(); } + else if (e.key === "Enter") { + // Enter picks the highlighted row; with nothing highlighted it submits. + if (commitCombo()) e.preventDefault(); + else pullModel(); + } + }); + $("pull-toggle").addEventListener("mousedown", (e) => { + e.preventDefault(); + if ($("pull-options").hidden) { $("pull-name").focus(); openCombo(); } + else closeCombo(); + }); $("cfg-provider").addEventListener("change", onProviderChanged); $("provider-start").addEventListener("click", startProviderServer); $("flm-check").addEventListener("click", () => loadFlmVersion(true)); diff --git a/scripts/ui/web/index.html b/scripts/ui/web/index.html index 8051e78..57197ea 100644 --- a/scripts/ui/web/index.html +++ b/scripts/ui/web/index.html @@ -319,25 +319,33 @@

LLM provider & server

FastFlowLM-only; warmups run in the background.

-

Installed models

- +

Models

+ + +

Installed

+
    -
    -
    -

    Pull a new model

    + +

    Add a model

    - - +
    + + + +

    -

    FastFlowLM runtime

    +

    FastFlowLM runtime

    FastFlowLM: checking…

    diff --git a/scripts/ui/web/styles.css b/scripts/ui/web/styles.css index fa501bd..93c30e1 100644 --- a/scripts/ui/web/styles.css +++ b/scripts/ui/web/styles.css @@ -479,6 +479,130 @@ input[type="text"], input[type="number"], textarea, select { } textarea { resize: vertical; font-family: Consolas, monospace; } select[size] { padding: 4px; } + +/* ---- Models card: styled list + combobox ---- + Replaces a native