Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<datalist>` dropdown and a native `<select size>` list box — browsers draw datalist popups in their own chrome and ignore page CSS entirely, so that control could never match the dashboard. The new combobox and list are ordinary elements: they follow the light/dark theme, and support arrow-key/Enter/Escape navigation and type-to-filter.

## 2.4.0

**Streaming chat.** The dashboard Chat tab renders replies token-by-token instead of waiting for the whole answer, so the response starts appearing at warm time-to-first-token rather than after the full completion.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ Flowkey is a Windows desktop assistant that adds local-LLM hotkeys for grammar f

Everything runs locally through [FastFlowLM](https://fastflowlm.com) (AMD Ryzen AI NPU) or, on machines without the NPU, through [Ollama](https://ollama.com) (CPU/GPU) as a secondary provider. No cloud service, analytics, or telemetry is used by the app.

Current version: `2.4.0`
Current version: `2.4.1`

## What's new in 2.4

- **The model picker shows every model, sized honestly.** Config → **Models** is now one card, styled like the rest of the dashboard (the old picker used a native browser dropdown that ignored the app's theme). Nothing is hidden from it: models too big for your machine are marked rather than silently dropped, and Mixture-of-Experts models are sized by the parameters actually active per token plus FastFlowLM's own measured memory footprint — so a 35B-total / 3B-active model is no longer mistaken for a 35B one. If a FastFlowLM upgrade invalidates your active model, the card now says so instead of letting the next hotkey fail with an opaque error.
- **Chat streams as it thinks.** The dashboard **Chat** tab now fills the reply in token-by-token over a live stream instead of waiting on a blank "Thinking…" until the whole answer is ready — the first words typically appear in ~1.6 s (warm) rather than after the full response. It works on both FastFlowLM and Ollama, and if anything interrupts the stream the partial reply is kept and the error is shown. Grammar, `prompt:`, and the other hotkeys are unchanged (they still paste the finished result).

## What's new in 2.3
Expand Down
10 changes: 10 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb
- V35: default v2 → exactly 1 short LLM draft call; ⊥ anti-echo/rescue calls; V33 finalizer supplies surfaced 4-section contract
- V36: pre-2.3 prompt_builder identity cfg (⊥ `prompt_version`, legacy default fields) → migrate v2+concise; any custom field → preserve
- V37: `chat_send_stream` streams reply deltas as `text/event-stream` (SSE `data:`/`event:` frames), persists the full-or-partial turn exactly once under the daemon write-lock; CSRF `X-FFP-API` + Host gates still apply; dashboard falls back to `chat_send` iff the stream ⊥ opens (never after tokens arrive); `send()`/AHK/grammar/prompt paths unchanged (⊥ streamed)
- V38: `recommend_models` returns EVERY candidate tagged yes/tight/no/unknown + `fit_reason`; UI ⊥ hide any (oversized → marked + confirm-before-pull). Fit signal precedence: provider `footprint_gb` → effective params (MoE active `a<N>b` < total, else total) vs budget
- V39: configured active model ⊥ installed → `model_recommendations.active_model.status="not_installed"` + dashboard warning naming model/remedy; listing error/exception → `"unknown"` (⊥ claim broken)
- V40: model picker + installed list = app-styled elements (⊥ native `<datalist>`/`<select size>`, ∵ browser chrome ignores page CSS); theme-aware + keyboard-navigable (↑↓/Enter/Esc)

## §T tasks

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<datalist>` popup drawn by browser chrome (page CSS inert) + native `<select size>`|V40; custom app-styled combobox + list
B32|2026-07-27|`app.js` set `models-title` = "Installed models — <provider>" ∴ overwrote merged card heading at runtime|V40 caught in browser; title → "Models — <provider>"
B33|2026-07-27|new CSS block referenced undefined `--muted`/`--card`/`--bad` (pre-existing pattern elsewhere in stylesheet: 6+2+3 refs, never defined ∴ inert)|use defined tokens `--text-muted`/`--surface`/`--warn`; pre-existing refs left for separate cleanup
B34|2026-07-27|self-caught: `_active_model_health` tested membership vs `_provider_list("all")`; ollama "all" = installed+suggested (`ffp_provider_runtime:80`) ∴ never-pulled model → false `installed=True` (⊥ warn)|V39; trust `details` (unfiltered, authoritative) else re-list w/ `installed` filter
```
37 changes: 33 additions & 4 deletions docs/prompt-mode-speed-quality-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,39 @@ like the 2.2.0 prompt-builder eval.
- **Phase 4 (optional) — Faster route. ⏸ TRIGGER NOT MET — deferred.** The plan
gates this on "p50 still > 15 s after phases 1–2," but 2.3.0 put prompt p50 at
**3.38 s warm** and Phase 3 streaming already delivers the *perceived* speed
this phase was chasing. A second NPU-decode provider (LM Studio/Lemonade) also
can't co-serve the NPU with FastFlowLM (both wedge — see the benchmark
evidence), so it would add real complexity for no measured need. Revisit only
if a concrete slow surface appears.
this phase was chasing. Revisit only if a concrete slow surface appears.

**Feasibility note (2026-07-27, FLM 0.9.45).** The original blocker recorded
here — "a second runtime can't co-serve the NPU with FastFlowLM (both wedge)" —
applies to another *NPU* runtime (Lemonade/LM Studio on the NPU). It does **not**
apply to an NPU + **discrete/iGPU** split, which is different hardware with no
NPU contention. FLM 0.9.45 adds **per-round KV cache checks**: a conversation
can alternate FLM-on-NPU and llama.cpp-on-GPU rounds and the NPU now recognises
its own already-cached earlier rounds instead of re-prefilling the whole
history from scratch. That makes a hybrid route genuinely practical for the
first time. It changes the *feasibility*, not the decision — the speed goal is
already met, so this stays deferred rather than becoming newly attractive.

### Does FLM 0.9.45's KV cache work change anything today? (assessed 2026-07-27)

**For prompt mode and the other hotkeys: no.** Per-round KV cache checks pay off
only when rounds of *one* conversation alternate between backends. Flowkey never
does that:

- `prompt:`, grammar, summarize, explain, tone are **single-shot and stateless** —
one system+user pair, no prior rounds to reuse. `prompt:` is also decode-bound
(TTFT ≈1.5 s of a ≈3.4 s total), so prefill is not the cost.
- **Chat** is multi-turn but always replays its 12-turn window to a *single*
backend — ordinary sequential prefix caching, which FLM already did. The new
gap-tolerance never triggers.
- **Provider fallback** is all-or-nothing between FastFlowLM and Ollama, which are
separate servers with separate caches — nothing is shared to reuse.
- **Meetings digests** are one large single-shot prefill.

So there is nothing to change in the prompt pipeline for 0.9.45. The upgrade's
real impact on users was operational, not architectural: it **invalidates
already-pulled models** stamped for an older FLM (see 2.4.1 / SPEC V39, which
added detection for exactly that).

## Integration with the existing prompt-builder (2.2.0)

Expand Down
2 changes: 1 addition & 1 deletion installer/installer.iss
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
#define AppURL "https://github.com/agr77one/Fastflow"
#define AppExeName "Flowkey.exe" ; symbolic — actual launchers below
; Keep in lockstep with scripts\_version.py.
#define AppVersion "2.4.0"
#define AppVersion "2.4.1"

[Setup]
AppId={{8A4F1E6C-9B3D-4E62-9F7A-FASTFLOW140}}
Expand Down
2 changes: 1 addition & 1 deletion installer/sign.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
.EXAMPLE
# Sign the installer
$env:FFP_SIGN_PFX_PASSWORD = "ChangeMe!"
.\sign.ps1 -FilePath ..\out\Flowkey-Setup-2.4.0.exe
.\sign.ps1 -FilePath ..\out\Flowkey-Setup-2.4.1.exe
#>

[CmdletBinding(DefaultParameterSetName = "Sign")]
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "fastflowprompt"
version = "2.4.0"
version = "2.4.1"
description = "Local-LLM-powered grammar fix, prompt rewrite, chat, and dashboard for Windows."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion scripts/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Single source of truth for the app version. Read by grammar_fix.py."""

__version__ = "2.4.0"
__version__ = "2.4.1"
86 changes: 81 additions & 5 deletions scripts/ffp_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,16 +340,92 @@ def _act_models_not_installed(_args: dict) -> dict:

def _act_model_recommendations(_args: dict) -> dict:
"""Hardware-aware model suggestions for the active provider: detected
RAM/VRAM, a size budget, and candidate models tagged fits yes/tight/no."""
RAM/VRAM, a size budget, and candidate models tagged fits yes/tight/no.

On FastFlowLM the catalog's own per-model metadata (measured footprint,
install state, minimum FLM version) is forwarded so fit is decided from real
numbers rather than guessed from the tag name."""
import ffp_hardware
provider = grammar_fix.LLM_PROVIDER
if provider == "ollama":
candidates = list(ffp_hardware.OLLAMA_CATALOG)
candidates: list = list(ffp_hardware.OLLAMA_CATALOG)
else:
listing = grammar_fix._provider_list("all")
names = listing.get("models") or []
candidates = [(n, ffp_hardware.parse_params_b(n)) for n in names]
return ffp_hardware.recommend_models(provider, candidates)
details = listing.get("details") or []
if details:
candidates = [dict(entry) for entry in details if isinstance(entry, dict)]
else:
# Older FLM without per-model JSON metadata: fall back to tag parsing.
candidates = [(n, ffp_hardware.parse_params_b(n)) for n in (listing.get("models") or [])]
out = ffp_hardware.recommend_models(provider, candidates)
out["active_model"] = _active_model_health(listing if provider != "ollama" else None)
return out


def _active_model_health(listing: dict | None = None) -> dict:
"""Report whether the configured active model is actually usable.

A FastFlowLM upgrade can invalidate an already-pulled model (FLM 0.9.45
rejects models stamped for 0.9.43), and FLM then reports them as
NOT installed. Nothing used to surface that, so the next hotkey failed with
an opaque provider error. This gives the dashboard a specific, actionable
state: which model is configured, whether it is installed, and the remedy.

``listing`` may be a already-fetched provider listing to avoid a second CLI
call; it is only trusted when it carries per-model ``details`` (whose
``installed`` flags are authoritative and filter-independent). Without
details we must re-list with the ``installed`` filter, because an
``all`` listing includes not-yet-pulled suggestions and would report a
missing model as installed."""
provider = grammar_fix.LLM_PROVIDER
model = str(grammar_fix.FLM_MODEL or "")
health = {"provider": provider, "model": model, "installed": None, "status": "unknown", "detail": ""}
Comment on lines +380 to +382

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check the configured model before effective fallback

When an FLM upgrade invalidates the configured model but another model remains installed, resolve_effective_llm() replaces FLM_MODEL with that installed fallback (grammar_fix.py lines 170–173). This health check therefore examines the fallback, reports ok, and never warns that the persisted configured model was invalidated—the exact upgrade-break case this change is intended to surface. Read the configured provider profile's model for this verdict rather than the already-resolved effective model.

Useful? React with 👍 / 👎.

if not model:
health.update(status="unknown", detail="no active model configured")
return health

details = list((listing or {}).get("details") or [])
if not details:
try:
listing = grammar_fix._provider_list("installed")
except Exception as exc:
health.update(status="unknown", detail=f"could not list models: {exc}")
return health
if listing.get("error"):
health.update(status="unknown", detail=str(listing.get("error")))
return health
details = list(listing.get("details") or [])
elif listing and listing.get("error"):
health.update(status="unknown", detail=str(listing.get("error")))
return health

entry = next((d for d in details if isinstance(d, dict) and d.get("name") == model), None)
if entry is not None:
installed = bool(entry.get("installed"))
health["installed"] = installed
health["flm_min_version"] = entry.get("flm_min_version") or ""
if installed:
health.update(status="ok", detail="")
else:
health.update(
status="not_installed",
detail=f"'{model}' is in the catalog but not installed for this "
f"{provider} version - run a pull to (re)install it.",
)
return health

# No per-model metadata (Ollama, or an older FLM): the names here are
# installed-only, so absence genuinely means "not installed".
names = set((listing or {}).get("models") or [])
if model in names:
health.update(installed=True, status="ok", detail="")
else:
health.update(
installed=False,
status="not_installed",
detail=f"'{model}' is not installed for {provider} - pull it or pick another model.",
)
return health


def _act_pull_model(args: dict) -> str:
Expand Down
27 changes: 26 additions & 1 deletion scripts/ffp_flm_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,14 @@ def flm_list(filter_kind: str, model: str, no_window: int) -> dict:
bare names. The JSON mode (FLM >= 0.9.x) carries an authoritative per-model
`installed` boolean instead, so we parse that and filter client-side. We
force UTF-8 decoding because the human-readable variant embeds emoji.

Also returns ``details``: the per-model metadata the catalog already carries
— ``footprint_gb`` (FLM's own measured memory cost, the honest sizing signal
that name-parsing cannot supply: ``gemma4-it:e4b`` reads as "4B" but is 8B /
9.1 GB, and ``qwen3.6-moe:35b-a3b`` is 35B total / 3B active / 24.3 GB),
``installed``, ``flm_min_version``, ``parameter_size`` and
``context_length``. ``details`` is unfiltered so callers can tell "not in
the catalog" from "in the catalog but not installed".
"""
if filter_kind not in {"installed", "not-installed", "all"}:
return {"error": f"bad filter: {filter_kind}", "models": [], "active": model}
Expand All @@ -276,19 +284,36 @@ def flm_list(filter_kind: str, model: str, no_window: int) -> dict:
return {"error": "could not parse 'flm list --json' output", "models": [], "active": model}

names: list[str] = []
details: list[dict] = []
for entry in payload.get("models") or []:
if not isinstance(entry, dict):
continue
name = str(entry.get("model") or entry.get("name") or "").strip()
if not name:
continue
is_installed = bool(entry.get("installed"))
info = entry.get("details") if isinstance(entry.get("details"), dict) else {}
details.append({
"name": name,
"installed": is_installed,
"footprint_gb": _as_float(entry.get("footprint")),
"flm_min_version": str(entry.get("flm_min_version") or "").strip(),
"parameter_size": str(info.get("parameter_size") or "").strip(),
"context_length": entry.get("default_context_length"),
})
if filter_kind == "installed" and not is_installed:
continue
if filter_kind == "not-installed" and is_installed:
continue
names.append(name)
return {"models": names, "active": model}
return {"models": names, "active": model, "details": details}


def _as_float(value: object) -> float | None:
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None


def flm_version(no_window: int) -> str:
Expand Down
Loading