Skip to content

Commit c8bf272

Browse files
committed
feat(app): select an LLM and an image model together (hybrid serve)
The server already supports LUMEN_SERVE=chat|image|hybrid, but the desktop app stored a single `active_model` and launched with a binary `image_mode`, so picking an image model overwrote the LLM and hybrid was unreachable. - Split selection into two independent config slots: `active_model` (LLM) and `active_image_model` (diffusion). `#[serde(default)]` keeps old configs loadable. Each card toggles its own slot (click again to deselect), so all four combinations are reachable: chat, image, hybrid, or none. - `start_server` derives the serve mode from the two slots and launches the server accordingly (both set → hybrid). New `ServeKind` enum replaces the `image_mode` bool through `Supervisor::start` / `apply_env`. - `apply_env` now passes `IMAGE_MODEL_ID` (the server reads this — not MODEL_ID — for the diffusion backend). This also fixes a latent bug where selecting the bf16 `black-forest-labs/FLUX.2-dev` silently fell back to the 4-bit default because the id never reached the server. The wired-memory cap is skipped whenever diffusion is loaded (image or hybrid), not just image. - Start button is disabled (with a tooltip) when neither slot is set, matching the server-side guard. en/ko strings for the new deselect / no-model states. chore(release): v0.11.1
1 parent c354303 commit c8bf272

10 files changed

Lines changed: 207 additions & 88 deletions

File tree

crates/lumen-app/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "lumen-app"
3-
version = "0.11.0"
3+
version = "0.11.1"
44
edition.workspace = true
55
rust-version.workspace = true
66
license.workspace = true

crates/lumen-app/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "lumen-app-frontend",
33
"private": true,
4-
"version": "0.11.0",
4+
"version": "0.11.1",
55
"type": "module",
66
"scripts": {
77
"predev": "cargo build --manifest-path ../../../Cargo.toml -p lumen-server --release",

crates/lumen-app/frontend/src/App.svelte

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,13 @@
185185
config?.active_model != null && outdatedModels.has(config.active_model)
186186
);
187187
188+
// No model in either slot → nothing to serve. Blocks the Start button (the
189+
// server would otherwise reject the launch). Both slots are independent, so
190+
// this is only true when chat AND image are both unselected.
191+
let noModelSelected = $derived(
192+
config?.active_model == null && config?.active_image_model == null
193+
);
194+
188195
// True when the configured active model exists on disk but its
189196
// download is incomplete (truncated shard, missing index, stray
190197
// .part file). Block the Start button so the server doesn't crash
@@ -231,7 +238,7 @@
231238
// The active model id, if it is a diffusion image model. Drives both the
232239
// image-models card's ✓ state and the generation panel's visibility.
233240
let activeImageModelId = $derived.by(() => {
234-
const id = config?.active_model;
241+
const id = config?.active_image_model;
235242
if (!id) return null;
236243
return imageModels.some((m) => m.id === id) ? id : null;
237244
});
@@ -995,14 +1002,16 @@
9951002
class={status.state === "running" || status.state === "starting" ? "danger" : "primary"}
9961003
onclick={toggleServer}
9971004
disabled={status.state === "starting" || status.state === "stopping" ||
998-
(status.state !== "running" && (activeOutdated || activeBroken))}
1005+
(status.state !== "running" && (noModelSelected || activeOutdated || activeBroken))}
9991006
title={status.state === "running"
10001007
? ""
1001-
: activeBroken
1002-
? t("header.title.brokenActive")
1003-
: activeOutdated
1004-
? t("header.title.outdatedActive")
1005-
: ""}
1008+
: noModelSelected
1009+
? t("header.title.noModel")
1010+
: activeBroken
1011+
? t("header.title.brokenActive")
1012+
: activeOutdated
1013+
? t("header.title.outdatedActive")
1014+
: ""}
10061015
>
10071016
{status.state === "running" || status.state === "starting" ? t("header.stop") : t("header.start")}
10081017
</button>
@@ -1401,9 +1410,13 @@
14011410
{:else}
14021411
<button
14031412
onclick={() => setActive(m.id)}
1404-
disabled={isActive || !m.supported}
1405-
title={!m.supported ? t("models.action.title.unsupported") : ""}
1406-
>{t("action.use")}</button>
1413+
disabled={!m.supported}
1414+
title={!m.supported
1415+
? t("models.action.title.unsupported")
1416+
: isActive
1417+
? t("models.action.title.deselect")
1418+
: ""}
1419+
>{isActive ? t("action.deselect") : t("action.use")}</button>
14071420
{/if}
14081421
<button class="danger" onclick={() => removeModel(m.id)}>{t("action.delete")}</button>
14091422
</div>
@@ -1490,7 +1503,7 @@
14901503
<h2 class={cardH2}>{t("imageModels.title")} <span class="dim">{t("imageModels.titleHint")}</span></h2>
14911504
<div class="flex flex-col gap-2">
14921505
{#each imageModels as im}
1493-
{@const isActive = config?.active_model === im.id}
1506+
{@const isActive = config?.active_image_model === im.id}
14941507
{@const fits = !systemInfo || im.min_ram_gb <= systemInfo.ram_gb}
14951508
<div
14961509
class={`image-card relative overflow-hidden flex flex-col gap-1.5 px-3 py-2.5 rounded-md bg-panel-2 border ${
@@ -1508,10 +1521,13 @@
15081521
<div class="dim mono text-[11px]">{im.id} · {im.approx_size_gb}GB · ≥{im.min_ram_gb}GB RAM</div>
15091522
</div>
15101523
<button
1511-
class="primary"
1524+
class={isActive ? "" : "primary"}
15121525
onclick={() => setActive(im.id)}
1513-
disabled={isActive}
1514-
title={fits ? "" : t("imageModels.action.title.overBudget")}
1526+
title={isActive
1527+
? t("imageModels.action.title.deselect")
1528+
: fits
1529+
? ""
1530+
: t("imageModels.action.title.overBudget")}
15151531
>{isActive ? t("imageModels.active") : t("action.use")}</button>
15161532
</div>
15171533
{#if !fits}

crates/lumen-app/frontend/src/lib/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ export interface PersistentConfig {
8181
advanced: AdvancedConfig;
8282
env_overrides: Record<string, string>;
8383
active_model: string | null;
84+
/** Active diffusion image model id; independent of `active_model` so a chat
85+
* model and an image model can be selected together (→ hybrid serve). */
86+
active_image_model: string | null;
8487
server_binary_path: string | null;
8588
models_dir: string;
8689
}

crates/lumen-app/frontend/src/messages/en.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export const en: Record<string, string> = {
2828
"Active model's download is incomplete. Re-download it first (MODELS card → Re-download).",
2929
"header.title.outdatedActive":
3030
"Active model has a newer version on Hub. Update it first (MODELS card → Update).",
31+
"header.title.noModel":
32+
"Select a chat and/or image model in the MODELS card first.",
3133

3234
// ── Status indicators ───────────────────────────────────────────
3335
"status.stopped": "stopped",
@@ -40,6 +42,7 @@ export const en: Record<string, string> = {
4042
"action.download": "Download",
4143
"action.delete": "Delete",
4244
"action.use": "Use",
45+
"action.deselect": "Deselect",
4346
"action.update": "Update",
4447
"action.redownload": "Re-download",
4548
"action.downloading": "Downloading…",
@@ -213,6 +216,8 @@ export const en: Record<string, string> = {
213216
"models.action.title.update": "Re-download with the latest Hub weights",
214217
"models.action.title.unsupported":
215218
"Not in the server-side supported catalog",
219+
"models.action.title.deselect":
220+
"Click to deselect — run image-only, or pick a different LLM",
216221

217222
// ── Header memory bar ───────────────────────────────────────────
218223
"header.memory.title":
@@ -273,7 +278,8 @@ export const en: Record<string, string> = {
273278
"imageModels.active": "Active",
274279
"imageModels.overBudget": "Larger than this Mac's RAM — generation may swap or fail.",
275280
"imageModels.action.title.overBudget": "Exceeds this Mac's RAM budget",
276-
"imageModels.startHint": "Press Start to launch the server in image mode, then a generation panel appears below.",
281+
"imageModels.action.title.deselect": "Click to deselect this image model",
282+
"imageModels.startHint": "Press Start to launch the server in image mode, then a generation panel appears below. Selecting an LLM as well launches hybrid mode (both serve at once).",
277283

278284
// ── Image generation panel ──────────────────────────────────────
279285
"image.title": "IMAGE GENERATION",

crates/lumen-app/frontend/src/messages/ko.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const ko: Record<string, string> = {
2323
"활성 모델의 다운로드가 완료되지 않았습니다. 모델 카드에서 재다운로드를 먼저 진행하세요.",
2424
"header.title.outdatedActive":
2525
"활성 모델의 최신 버전이 Hub에 있습니다. 모델 카드에서 업데이트를 먼저 진행하세요.",
26+
"header.title.noModel": "모델 카드에서 채팅 또는 이미지 모델을 먼저 선택하세요.",
2627

2728
// ── 상태 표시 ───────────────────────────────────────────────────
2829
"status.stopped": "중지됨",
@@ -35,6 +36,7 @@ export const ko: Record<string, string> = {
3536
"action.download": "다운로드",
3637
"action.delete": "삭제",
3738
"action.use": "사용",
39+
"action.deselect": "선택 해제",
3840
"action.update": "업데이트",
3941
"action.redownload": "재다운로드",
4042
"action.downloading": "다운로드 중…",
@@ -207,6 +209,7 @@ export const ko: Record<string, string> = {
207209
"누락 또는 잘린 파일을 검증 후 재다운로드",
208210
"models.action.title.update": "최신 Hub 가중치로 재다운로드",
209211
"models.action.title.unsupported": "서버 측 지원 카탈로그에 없음",
212+
"models.action.title.deselect": "클릭하여 선택 해제 — 이미지 전용으로 실행하거나 다른 LLM 선택",
210213

211214
// ── 헤더 메모리 바 ──────────────────────────────────────────────
212215
"header.memory.title": "시스템 메모리 — wired + active + compressor",
@@ -266,7 +269,8 @@ export const ko: Record<string, string> = {
266269
"imageModels.active": "사용 중",
267270
"imageModels.overBudget": "이 Mac의 RAM보다 큽니다 — 생성 시 스왑되거나 실패할 수 있습니다.",
268271
"imageModels.action.title.overBudget": "이 Mac의 RAM 예산을 초과합니다",
269-
"imageModels.startHint": "시작을 눌러 서버를 이미지 모드로 실행하면 아래에 생성 패널이 나타납니다.",
272+
"imageModels.action.title.deselect": "클릭하여 이 이미지 모델 선택 해제",
273+
"imageModels.startHint": "시작을 눌러 서버를 이미지 모드로 실행하면 아래에 생성 패널이 나타납니다. LLM도 함께 선택하면 하이브리드 모드(둘 다 동시 서빙)로 실행됩니다.",
270274

271275
// ── 이미지 생성 패널 ────────────────────────────────────────────
272276
"image.title": "이미지 생성",

crates/lumen-app/src/commands.rs

Lines changed: 79 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,14 @@ pub async fn set_active_model(
172172
if cat.is_image_model(&model_id) {
173173
drop(cat);
174174
let mut g = state.config.lock().await;
175-
g.active_model = Some(model_id);
175+
// Image models occupy their own slot (independent of the chat model)
176+
// so a chat + image pair can be active at once → hybrid serve.
177+
// Clicking the already-active image model toggles it back off.
178+
g.active_image_model = if g.active_image_model.as_deref() == Some(model_id.as_str()) {
179+
None
180+
} else {
181+
Some(model_id)
182+
};
176183
g.save().map_err(err)?;
177184
return Ok(g.clone());
178185
}
@@ -203,7 +210,13 @@ pub async fn set_active_model(
203210
}
204211
}
205212
let mut g = state.config.lock().await;
206-
g.active_model = Some(model_id);
213+
// Clicking the already-active chat model toggles it off (lets the user run
214+
// image-only by deselecting the LLM).
215+
g.active_model = if g.active_model.as_deref() == Some(model_id.as_str()) {
216+
None
217+
} else {
218+
Some(model_id)
219+
};
207220
g.save().map_err(err)?;
208221
Ok(g.clone())
209222
}
@@ -357,72 +370,88 @@ pub async fn check_model_updates(
357370
pub async fn start_server(app: AppHandle, state: State<'_, AppState>) -> CmdResult<ServerStatus> {
358371
let g = state.config.lock().await;
359372
let mut cfg = g.clone();
360-
let active_id = g
361-
.active_model
362-
.clone()
363-
.ok_or_else(|| "no active model — pick one in the MODELS card first".to_string())?;
373+
// Two independent slots: a chat/LLM model and an image/diffusion model.
374+
// Either or both may be set → chat, image, or hybrid serve.
375+
let chat_id = g.active_model.clone();
376+
let image_id = g.active_image_model.clone();
364377
let models_dir = g.models_dir.clone();
365378
let sup = state.supervisor.clone();
366379
drop(g);
367380

381+
if chat_id.is_none() && image_id.is_none() {
382+
return Err(
383+
"no active model — pick a chat and/or image model in the MODELS card first".to_string(),
384+
);
385+
}
386+
368387
// Hard-gate the launch when the most-recent revision check flagged the
369-
// active model as out of date. The frontend already greys out the Start
370-
// button in that state — this is the belt-and-suspenders guard for direct
371-
// RPC bypass (CLI testing, future plugin, etc.) so the engine never loads
372-
// weights against a tokenizer/config that has since been re-uploaded.
373-
if state.outdated_models.lock().await.contains(&active_id) {
374-
return Err(format!(
375-
"active model `{active_id}` is out of date — open the MODELS card \
376-
and click Update first, then start the server"
377-
));
388+
// active chat model as out of date. The frontend already greys out the
389+
// Start button in that state — this is the belt-and-suspenders guard for
390+
// direct RPC bypass (CLI testing, future plugin, etc.) so the engine never
391+
// loads weights against a tokenizer/config that has since been re-uploaded.
392+
if let Some(ref cid) = chat_id {
393+
if state.outdated_models.lock().await.contains(cid) {
394+
return Err(format!(
395+
"active model `{cid}` is out of date — open the MODELS card \
396+
and click Update first, then start the server"
397+
));
398+
}
378399
}
379400

380-
// Resolve the on-disk path of the active model. Flat-layout dirs use the
381-
// dir name (e.g. `gemma-4-26b-a4b-mlx-imatrix3plus-awq`) which won't match the HF Hub
382-
// id (`hsng95/gemma-4-26b-a4b-mlx-imatrix3plus-awq`). When the model is found
383-
// locally we pass the absolute path as MODEL_ID — the MLX native runner
384-
// and tokenizer loader both detect `is_dir()` and skip HF Hub entirely.
401+
// Resolve the on-disk path of the active chat model. Flat-layout dirs use
402+
// the dir name (e.g. `gemma-4-26b-a4b-mlx-imatrix3plus-awq`) which won't
403+
// match the HF Hub id (`hsng95/gemma-4-26b-a4b-mlx-imatrix3plus-awq`). When
404+
// the model is found locally we pass the absolute path as MODEL_ID — the MLX
405+
// native runner and tokenizer loader both detect `is_dir()` and skip HF Hub
406+
// entirely. The diffusion backend resolves its own component repos from
407+
// IMAGE_MODEL_ID, so the image slot needs no path resolution here.
385408
let cat = state.catalog.lock().await;
386-
// Diffusion image model → launch the server in dedicated image mode and skip
387-
// the chat-model path/byte resolution (the diffusion backend loads its own
388-
// hardcoded component repos; MODEL_ID is unused in image mode).
389-
if cat.is_image_model(&active_id) {
390-
drop(cat);
391-
return sup
392-
.start(app, &cfg, &active_id, None, /* image_mode */ true)
393-
.await
394-
.map_err(err);
395-
}
396-
let entries = models::scan_local(&models_dir, &cat).map_err(err)?;
397-
let active_entry = entries.iter().find(|m| m.id == active_id);
398-
399-
let (model_arg, active_bytes) = if let Some(entry) = active_entry {
400-
// Mirror into local_model_dir too — engine.rs reads LUMEN_GEMMA4_DIR /
401-
// LUMEN_QWEN35_SHARDS for the non-MLX (Candle) Gemma4Native /
402-
// Qwen35Moe paths.
403-
if cfg.server.local_model_dir.is_none() {
404-
cfg.server.local_model_dir = Some(entry.path.clone());
409+
let (model_arg, active_bytes) = if let Some(ref cid) = chat_id {
410+
let entries = models::scan_local(&models_dir, &cat).map_err(err)?;
411+
match entries.iter().find(|m| m.id == *cid) {
412+
Some(entry) => {
413+
// Mirror into local_model_dir too — engine.rs reads
414+
// LUMEN_GEMMA4_DIR / LUMEN_QWEN35_SHARDS for the non-MLX
415+
// (Candle) Gemma4Native / Qwen35Moe paths.
416+
if cfg.server.local_model_dir.is_none() {
417+
cfg.server.local_model_dir = Some(entry.path.clone());
418+
}
419+
(
420+
entry.path.to_string_lossy().into_owned(),
421+
Some(entry.size_bytes),
422+
)
423+
}
424+
None => {
425+
// Not on disk — resolve to the canonical HF id so the server
426+
// can fetch.
427+
let id = cat
428+
.find_recommended(cid)
429+
.map(|r| r.id.clone())
430+
.unwrap_or_else(|| cid.clone());
431+
(id, None)
432+
}
405433
}
406-
(
407-
entry.path.to_string_lossy().into_owned(),
408-
Some(entry.size_bytes),
409-
)
410434
} else {
411-
// Not on disk — resolve to the canonical HF id so the server can fetch.
412-
let id = cat
413-
.find_recommended(&active_id)
414-
.map(|r| r.id.clone())
415-
.unwrap_or_else(|| active_id.clone());
416-
(id, None)
435+
// Image-only: no LLM to resolve. Empty MODEL_ID → server omits it.
436+
(String::new(), None)
417437
};
418438
drop(cat);
419439

440+
let serve = match (chat_id.is_some(), image_id.is_some()) {
441+
(true, true) => server::ServeKind::Hybrid,
442+
(false, true) => server::ServeKind::Image,
443+
(true, false) => server::ServeKind::Chat,
444+
// Guarded by the early `is_none() && is_none()` return above.
445+
(false, false) => unreachable!(),
446+
};
447+
420448
sup.start(
421449
app,
422450
&cfg,
423451
&model_arg,
424452
active_bytes,
425-
/* image_mode */ false,
453+
image_id.as_deref(),
454+
serve,
426455
)
427456
.await
428457
.map_err(err)

crates/lumen-app/src/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,16 @@ pub struct PersistentConfig {
3535
/// write wins, with a warning displayed in the UI).
3636
#[serde(default)]
3737
pub env_overrides: BTreeMap<String, String>,
38+
/// Active chat / LLM model id. Independent of `active_image_model` so a
39+
/// chat model and an image model can be selected at once (→ hybrid serve).
3840
pub active_model: Option<String>,
41+
/// Active text-to-image diffusion model id (e.g. `flux2-dev`). Kept in a
42+
/// separate slot from `active_model` so selecting an image model no longer
43+
/// overwrites the chat model — with both set, the server launches in
44+
/// `LUMEN_SERVE=hybrid` (LLM + diffusion co-resident). `#[serde(default)]`
45+
/// keeps older configs (which lack the field) loadable.
46+
#[serde(default)]
47+
pub active_image_model: Option<String>,
3948
/// Optional override for the `lumen-server` binary path. When `None` the
4049
/// app searches PATH and (in bundled builds) the sidecar location.
4150
pub server_binary_path: Option<PathBuf>,
@@ -296,6 +305,7 @@ impl Default for PersistentConfig {
296305
},
297306
env_overrides: BTreeMap::new(),
298307
active_model: None,
308+
active_image_model: None,
299309
server_binary_path: None,
300310
models_dir,
301311
}

0 commit comments

Comments
 (0)