Skip to content

Commit 14bf8c2

Browse files
committed
feat(lumen-app): embedding visibility — hf-hub cache scan + MODELS card mini-row
Fix the empty/missing UI signal users were getting for an embedding that the server has actually been auto-loading. Backend (models.rs): - Add hf_hub_cache_dir() honouring HF_HOME, falling back to the platform cache dir. - Add scan_hf_hub_cache(catalog), filtered to ids that match the catalog (chat families OR embeddings). Catalog-bound on purpose so unrelated cache downloads don't surface as noise. - scan_local() now merges hf-hub cache entries after the flat layout scan; flat layout wins on id collisions. This surfaces models the server auto-fetched via hf-hub (e.g. the embedding when only EMBEDDING_MODEL_ID is set) so the ApiTabs embedding picker and the new MODELS row both stop misreporting them as missing. Frontend (App.svelte): - Add a one-row embedding status footer to the MODELS card: shows the active embedding id with ✓ (downloaded) or ⚠ (server- auto-fetched only), and points to the API tab for the picker. Frontend (env-schema + i18n): - Generalize the "Thinking (Gemma 4 reasoning)" section header to just "Reasoning" / "추론" since the per-request opt-in mechanism is not Gemma-specific — future families can plug in. - Add bilingual labels for the new MODELS embedding row. Verified: cargo check (lumen-app) and frontend production build both pass. Existing svelte-check warnings are pre-existing (App.svelte:483 status type, main.ts:3 css module) and unrelated.
1 parent dd7c4a5 commit 14bf8c2

5 files changed

Lines changed: 146 additions & 3 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,24 @@
11511151
{/each}
11521152
</div>
11531153
{/if}
1154+
{#if catalog && catalog.embeddings.length > 0}
1155+
{@const activeEmb = config?.server.embedding_model_id ?? null}
1156+
{@const embDownloaded = activeEmb ? models.some((m) => m.id === activeEmb) : false}
1157+
<div class="mt-3 pt-2.5 border-t border-border flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
1158+
<span class="dim">{t("models.embedding.label")}</span>
1159+
{#if !activeEmb}
1160+
<span class="dim">{t("models.embedding.disabled")}</span>
1161+
{:else if embDownloaded}
1162+
<span class="mono text-ok">✓</span>
1163+
<span class="mono">{activeEmb}</span>
1164+
{:else}
1165+
<span class="mono text-warn">⚠</span>
1166+
<span class="mono">{activeEmb}</span>
1167+
<span class="text-warn text-[11px]">{t("models.embedding.autoFetched")}</span>
1168+
{/if}
1169+
<span class="dim ml-auto text-[11px]">{t("models.embedding.pickHint")}</span>
1170+
</div>
1171+
{/if}
11541172
</section>
11551173

11561174
<!-- SERVER -->

crates/lumen-app/frontend/src/lib/env-schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export type EnvEntry = {
4242
* 3. Server-side reader already exists or is added in the same PR.
4343
*/
4444
export const ENV_SCHEMA: EnvEntry[] = [
45-
// ── Thinking (Gemma 4 reasoning behavior) ──────────────────────────
45+
// ── Reasoning (per-request opt-in default) ────────────────────────
4646
{
4747
key: "LUMEN_BACKEND_THINKING_DEFAULT",
4848
labelKey: "env.entry.LUMEN_BACKEND_THINKING_DEFAULT.label",

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,12 @@ export const en: Record<string, string> = {
247247
"context.banner.smallerThanBf16": "× smaller than bf16",
248248
"context.banner.kvCache": "KV cache ~",
249249

250+
// ── MODELS card embedding mini-row ─────────────────────────────
251+
"models.embedding.label": "Embedding:",
252+
"models.embedding.disabled": "(disabled)",
253+
"models.embedding.autoFetched": "(server auto-fetched from cache)",
254+
"models.embedding.pickHint": "Pick / change in the API tab",
255+
250256
// ── EnvOverrides panel ──────────────────────────────────────────
251257
"env.title": "Environment overrides",
252258
"env.intro":
@@ -269,7 +275,7 @@ export const en: Record<string, string> = {
269275
"env.empty2": "No overrides set.",
270276

271277
// ── Section groupings ────────────────────────────────────────────
272-
"env.section.thinking": "Thinking (Gemma 4 reasoning)",
278+
"env.section.thinking": "Reasoning",
273279
"env.section.safety": "Safety net",
274280
"env.section.advanced": "Advanced",
275281
"env.section.debug": "Debug / triage",

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,12 @@ export const ko: Record<string, string> = {
239239
"context.banner.smallerThanBf16": "× bf16 대비 축소",
240240
"context.banner.kvCache": "KV 캐시 약 ",
241241

242+
// ── 모델 카드 임베딩 미니행 ─────────────────────────────────
243+
"models.embedding.label": "임베딩:",
244+
"models.embedding.disabled": "(비활성)",
245+
"models.embedding.autoFetched": "(서버가 캐시에서 자동 로드)",
246+
"models.embedding.pickHint": "API 탭에서 선택 / 변경",
247+
242248
// ── 환경 변수 패널 ──────────────────────────────────────────────
243249
"env.title": "환경 변수 오버라이드",
244250
"env.intro":
@@ -260,7 +266,7 @@ export const ko: Record<string, string> = {
260266
"env.empty2": "설정된 오버라이드가 없습니다.",
261267

262268
// ── 섹션 그루핑 ────────────────────────────────────────────
263-
"env.section.thinking": "씽킹 (Gemma 4 추론)",
269+
"env.section.thinking": "추론",
264270
"env.section.safety": "안전망",
265271
"env.section.advanced": "고급",
266272
"env.section.debug": "디버그 / 진단",

crates/lumen-app/src/models.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,109 @@ fn is_hf_segment(s: &str) -> bool {
102102
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
103103
}
104104

105+
/// HuggingFace's default on-disk cache layout, where `hf-hub` (and the
106+
/// `huggingface-cli download` CLI without `--local-dir`) writes downloads.
107+
/// Honours `HF_HOME` if set, falling back to the platform cache dir.
108+
/// Returns `None` only when the platform doesn't expose a cache dir at all,
109+
/// which is exceedingly rare on macOS / Linux.
110+
fn hf_hub_cache_dir() -> Option<PathBuf> {
111+
if let Ok(home) = std::env::var("HF_HOME") {
112+
return Some(PathBuf::from(home).join("hub"));
113+
}
114+
directories::BaseDirs::new().map(|d| d.cache_dir().join("huggingface").join("hub"))
115+
}
116+
117+
/// Scan the hf-hub cache directory for entries whose id matches any model
118+
/// in the catalog (chat families OR embeddings). Catalog-bound on purpose:
119+
/// the cache also accumulates unrelated downloads from other tools, and
120+
/// surfacing them in the UI would be noise.
121+
///
122+
/// Returns one [`ModelEntry`] per matched cache entry. Cache layout:
123+
/// `models--<org>--<repo>/snapshots/<sha>/<files>`.
124+
fn scan_hf_hub_cache(catalog: &Catalog) -> Result<Vec<ModelEntry>> {
125+
let Some(cache_dir) = hf_hub_cache_dir() else {
126+
return Ok(Vec::new());
127+
};
128+
if !cache_dir.exists() {
129+
return Ok(Vec::new());
130+
}
131+
132+
// Build the set of catalog ids we'll surface. Empty catalog = nothing
133+
// to surface; that's still a valid call (e.g. before the catalog has
134+
// been refreshed) so we just return early.
135+
let known_ids: std::collections::HashSet<String> = catalog
136+
.recommended
137+
.iter()
138+
.map(|r| r.id.clone())
139+
.chain(catalog.embeddings.iter().map(|e| e.id.clone()))
140+
.collect();
141+
if known_ids.is_empty() {
142+
return Ok(Vec::new());
143+
}
144+
145+
let mut out = Vec::new();
146+
for entry in std::fs::read_dir(&cache_dir)
147+
.with_context(|| format!("read_dir {}", cache_dir.display()))?
148+
{
149+
let Ok(entry) = entry else { continue };
150+
let path = entry.path();
151+
if !path.is_dir() {
152+
continue;
153+
}
154+
let name = entry.file_name().to_string_lossy().to_string();
155+
let Some(rest) = name.strip_prefix("models--") else {
156+
continue;
157+
};
158+
let id = rest.replacen("--", "/", 1);
159+
if !is_valid_hf_repo_id(&id) || !known_ids.contains(&id) {
160+
continue;
161+
}
162+
let Some(snap) = resolve_hf_snapshot(&path) else {
163+
continue;
164+
};
165+
let config_present = snap.join("config.json").exists();
166+
let weight_present = WalkDir::new(&snap)
167+
.max_depth(2)
168+
.into_iter()
169+
.filter_map(|e| e.ok())
170+
.any(|e| {
171+
e.path()
172+
.extension()
173+
.and_then(|x| x.to_str())
174+
.map(|ext| ext == "safetensors" || ext == "gguf")
175+
.unwrap_or(false)
176+
});
177+
let shards_complete = verify_shards_complete(&snap);
178+
let size_bytes: u64 = WalkDir::new(&path)
179+
.max_depth(4)
180+
.into_iter()
181+
.filter_map(|e| e.ok())
182+
.filter(|e| e.file_type().is_file())
183+
.filter_map(|e| e.metadata().ok())
184+
.map(|m| m.len())
185+
.sum();
186+
let rec = catalog.find_recommended(&id);
187+
let local_sha = read_sha_marker(&snap);
188+
out.push(ModelEntry {
189+
id,
190+
path,
191+
size_bytes,
192+
ready: config_present && weight_present && shards_complete,
193+
supported: rec.is_some(),
194+
label: rec.map(|r| r.label.clone()),
195+
local_sha,
196+
});
197+
}
198+
Ok(out)
199+
}
200+
105201
/// `catalog` is consulted to set the `supported` / `label` flags.
202+
///
203+
/// Also scans the hf-hub cache directory (`HF_HOME/hub` or platform default)
204+
/// for entries whose id matches the catalog. Without this, models that were
205+
/// auto-fetched by the server (e.g. the embedding model whose
206+
/// `EMBEDDING_MODEL_ID` is read by `hf-hub`) would never appear in the
207+
/// "downloaded" UI even though they're sitting on disk and live in use.
106208
pub fn scan_local(models_dir: &Path, catalog: &Catalog) -> Result<Vec<ModelEntry>> {
107209
if !models_dir.exists() {
108210
return Ok(Vec::new());
@@ -200,6 +302,17 @@ pub fn scan_local(models_dir: &Path, catalog: &Catalog) -> Result<Vec<ModelEntry
200302
local_sha,
201303
});
202304
}
305+
// Merge in hf-hub cache entries that aren't already represented under
306+
// `models_dir`. id-collisions (rare — would require both layouts to
307+
// store the same repo) prefer the flat-layout copy because that's
308+
// what the user explicitly fetched via the UI.
309+
let seen_ids: std::collections::HashSet<String> =
310+
out.iter().map(|e| e.id.clone()).collect();
311+
for cached in scan_hf_hub_cache(catalog)? {
312+
if !seen_ids.contains(&cached.id) {
313+
out.push(cached);
314+
}
315+
}
203316
out.sort_by(|a, b| a.id.cmp(&b.id));
204317
Ok(out)
205318
}

0 commit comments

Comments
 (0)