Skip to content

fix(webui): print the checkpoint's real name as its display name - #1920

Merged
inureyes merged 6 commits into
mainfrom
fix/issue-1912-webui-verbatim-display-name
Sep 19, 2026
Merged

inureyes merged 6 commits into
mainfrom
fix/issue-1912-webui-verbatim-display-name

Conversation

@inureyes

@inureyes inureyes commented Sep 19, 2026

Copy link
Copy Markdown
Member

Part of epic #1910.

Summary

  • identity.display_name is now the model's inference id with no character rewriting. The catalog no longer turns - and _ into spaces. Cache and preset entries keep their full name (mlx-community/Qwen3-4B-4bit), models-dir entries keep the directory name, and single-model entries keep the last / segment of the served id, as the issue decided. qwen3-0.6b-4bit and qwen3_0.6b_4bit are two distinct rows again. Every surface that prints display_name (library row, inspector heading, toolbar pill, Chat and Activity selectors, operation titles, settings selectors, dialogs) now shows the name passed to -m and the API. The Rust change is the display_name helper in src/server/webui/catalog_metadata.rs and its two call sites.
  • ModelIdentity.display_name.maxLength in docs/webui/api.yaml goes from 128 to 256 to match inference_id, because a cache owner/name can be 193 characters. The field's new description gives the rule for each source. schema_version stays webui.ui-api.v1, and the generated ui-api.d.ts is unchanged.
  • The delete dialog still accepts only identity.id as its token, and the fence test is byte-identical to main. The body now says to type the model ID shown below, not the model name. The field label reads "Opaque model ID shown above (starts with mdl_)" in English and "위에 표시된 모델 ID (mdl_로 시작)" in Korean. The tests/fixtures/webui/strings.json copies of both strings, which had drifted, now match strings.ts.
  • Long ids wrap. The dialog body paragraph and the model names listed in the capacity-recovery dialog now carry models-wrap, like the id already did. Without it, a verbatim underscore name has no break point: at 390px the capacity dialog measured scrollWidth 523 against clientWidth 356.
  • Fixtures and tests that used space-separated names now use hyphenated ids. That includes identity-collision.json, which still collides on inference_id, and the performance fixtures and specs. The models.long_name gallery string is now a long hyphenated id, the same in en and ko. docs/webui/catalog.md and docs/webui/catalog.ko.md describe the new meaning. The committed bundle is regenerated.

Premises checked

  • Search already found the real name before this change: inventory() and the server q filter both match inference_id, which already held qwen3-0.6b-4bit. What changes is that the matched row now prints that name.
  • The issue expected a trailing / to fall back to the whole name through the existing unwrap_or(name), but it never did. "x/".rsplit('/').next() is Some(""), so the old helper returned an empty string. The new helper skips the empty segment, and the test covers that case.
  • The issue did not say how to treat presets; this PR keeps their names whole. A preset's inference id is its section name (config.model_alias = name), and an overlay-only preset takes over a cache owner/name, which the last-segment rule would shorten.
  • Single-model mode keeps the last-segment rule, as the issue specified. The id there is either --alias or the checkpoint directory's file_name(), never a path, so the rule only changes an alias that contains / (for example --alias Qwen/Qwen3-8B displays as Qwen3-8B). The docs and the api.yaml description say so. Showing the alias whole would be a one-line change if that is preferred.

Changes during review

  • Implementation review: the capacity-dialog list items got the wrap class. The capacity-recovery unit test now checks the listed names and the class, and fails without them.
  • Implementation review: the api.yaml description and both catalog docs now give the rule for each source. The earlier wording said "verbatim" everywhere, which is not true for single-model aliases. The Korean confirm label dropped "불투명", a word Settings already uses for the visual Opaque material.
  • Security and performance review: performance.spec.ts searched the 1000-entry catalog for '0999', which only matched the old padded label, so the WebUI bundle engine smoke timed out on every engine. It now searches for perf-model-999. The review found no injection or path exposure: names render as React text or escaped attributes, and display_name is always equal to inference_id or a substring of it. The delete fence still requires the exact id and revision.

Revert-and-fail

With the catalog_metadata.rs change alone reverse-applied, the new contract test fails:

test server::webui::catalog::tests::catalog_contract_tests::display_name_is_the_inference_id_verbatim ... FAILED
panicked at src/server/webui/catalog_contract_tests.rs:543:5:
assertion `left == right` failed
  left: [("Meta-Llama-3.1-8B-Instruct_4bit", "Meta Llama 3.1 8B Instruct 4bit"), ("mlx-community/Qwen3-4B-4bit", "Qwen3 4B 4bit"), ("qwen3-0.6b-4bit", "qwen3 0.6b 4bit"), ("qwen3_0.6b_4bit", "qwen3 0.6b 4bit"), ("team/qwen3-preset", "qwen3 preset")]
 right: [("Meta-Llama-3.1-8B-Instruct_4bit", "Meta-Llama-3.1-8B-Instruct_4bit"), ("mlx-community/Qwen3-4B-4bit", "mlx-community/Qwen3-4B-4bit"), ("qwen3-0.6b-4bit", "qwen3-0.6b-4bit"), ("qwen3_0.6b_4bit", "qwen3_0.6b_4bit"), ("team/qwen3-preset", "team/qwen3-preset")]
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 8596 filtered out

With the patch re-applied, all 34 catalog tests pass. The check was run twice, once by the implementer and once independently. The frontend check was done the same way. Reverting api.yaml, strings.ts and dialogs.tsx makes the new 193-character schema-bound test and the delete-dialog wording test fail. Removing the capacity-list wrap class makes the capacity-recovery test fail.

Test plan

This branch is rebased onto origin/main at fd33ff1, which includes #1921 (catalog backend column) and #1922 (i18n catalog consolidation). The committed bundle was rebuilt by build_bundle.py at each rebased commit rather than merged by hand. One rebase conflict needed both sides: the performance spec's Chat option now reads perf-model-0 · Ready, combining this PR's name with #1922's localized state label. No test or fixture added by #1922 uses a space-separated model name. Results on the rebased head:

  • cargo test --profile test-fast --features metal,accelerate -p mlxcel --lib server::webui: 78 passed, 0 failed, including display_name_is_the_inference_id_verbatim
  • cargo clippy -p mlxcel --lib --tests --features metal,accelerate -- -D warnings and cargo fmt --all -- --check: clean
  • pnpm --dir webui run typecheck and eslint: clean
  • pnpm --dir webui run unit: node 18/18, vitest 340/340 in 39 files
  • Playwright chromium default suite: 30/30. Engines config (engines.spec.ts and performance.spec.ts), chromium: 7/7. All runs used a private port with --strictPort, never the preview server shared with other worktrees
  • check_webui_contract.py (49 fixtures), build_bundle.py --verify, check_binary_assets.py, check_cross_repo_refs.py: pass

Before the rebase, the same gates also passed server::router_server (63 passed, 1 ignored) and the engines config in firefox (7/7).

Real-binary acceptance

The orchestrator built mlxcel-server from 3082e1d, the pre-rebase head carrying the same change (--profile test-fast --features metal,accelerate), and ran it with --webui --models-dir models/mlx --no-models-autoload against the real checkpoint store and the default managed cache. I drove the bundled WebUI in Chromium at 1440px with a read-only Playwright script that aborts any load, unload, download, delete or refresh request. It recorded no mutating request.

  • Hyphenated models-dir entry: qwen3-0.6b-4bit prints as qwen3-0.6b-4bit in the library row, the inspector heading and the toolbar pill (qwen3-0.6b-4bit · Unloaded).
  • Underscored models-dir entry: stablelm-2-1_6b-chat-4bit prints unchanged in the same three places.
  • Cache entry: mlx-community/Qwen3-0.6B-4bit prints as the full owner/name in the row, the inspector heading and the pill. It also appears as mlx-community/Qwen3-0.6B-4bit · unloaded in the Chat "Model for next turn" picker and as mlx-community/Qwen3-0.6B-4bit in the Activity selector (225 options).
  • Search: qwen3-0.6b-4bit returns the qwen3-0.6b-4bit row, plus mlx-community/Qwen3-0.6B-4bit, which contains the same text in a case-insensitive match. qwen3_0.6b_4bit returns no rows ("No matching local models").
  • Delete dialog, opened on the cache entry and cancelled without typing: the body ends "To confirm, type the model ID shown below, not the model name.", the mdl_ id is printed below it, the field label reads "Opaque model ID shown above (starts with mdl_)", and Confirm stays disabled while the field is empty. The long cache name wraps inside the dialog.

Merge with main and CI

Closes #1912

@inureyes inureyes added status:review Under review type:bug Bug fixes, error corrections, or issue resolutions priority:medium Medium priority labels Sep 19, 2026
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

identity.display_name is the inference id verbatim on every WebUI surface, with the delete dialog wording pointing at the opaque model ID.

Findings Addressed

  • Capacity recovery dialog listed loaded models in <li> items with no wrap rule. Verbatim underscore names have no break points, so at 390px the modal scrolled sideways (Chromium: dialog scrollWidth 523 vs clientWidth 356). The items now carry models-wrap (356 vs 356), the capacity unit test asserts it and fails without it, and the bundle is rebuilt. Commit 69b3ba7. (HIGH)

Remaining Items

  • Single-model mode with a / in --alias (for example --alias Qwen/Qwen3-8B) still prints only Qwen3-8B, while the new api.yaml description says the field is the inference id verbatim. Single-model inference ids are --alias or the checkpoint basename, never a path, so the last-segment rule only ever strips an operator-chosen owner. Left as is because the issue and the brief keep that rule; either make SingleModel verbatim or qualify the description. (MEDIUM)
  • Korean confirm label 위에 표시된 불투명 모델 ID (mdl_로 시작): 불투명 is already the Settings translation of the visual "Opaque" material, so it reads oddly for an identifier. 위에 표시된 모델 ID (mdl_로 시작) is clearer. Left because it is wording only and strings.json is shared with refactor(webui): move every user-facing string into the i18n catalog and remove native confirm dialogs #1913. (LOW)

Verification

  • All stated requirements implemented (real-binary acceptance still pending orchestrator verification)
  • No placeholder/mock code remaining
  • Integrated into project code flow
  • Project conventions followed
  • Existing modules reused where applicable
  • No unintended structural changes
  • Tests pass

Also checked: no client code rewrites names; the preset rule matches model_alias = name in router_models.rs; the fence test is byte-identical to main; the catalog_metadata.rs diff is only the helper and its two call sites. After the fix: typecheck, eslint, unit (node 18/18, vitest 319/319), build_bundle.py --verify, check_binary_assets.py, the contract check, and Playwright models.spec.ts 3/3 all pass.

@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review of f6a1db7. One fix pushed in 3082e1d.

Fixed (HIGH, merge-blocking): webui/tests/performance.spec.ts:332 searched the 1000-entry catalog for 0999, which only matched the old padded label. After the fixture rename to perf-model-${index} it matched no row, and the WebUI bundle job failed that test on chromium, firefox and webkit-headed-performance on every retry. It now searches perf-model-999. Locally on chromium all four performance tests pass (search-to-paint 14.4 ms, budget 100 ms).

Checked, no finding:

  • Injection: every display_name sink is React text or a React-set attribute (aria-label, toolbar title, option labels). Neither webui/src nor @lablup/ui-common uses dangerouslySetInnerHTML or innerHTML, and t() interpolates through a replacer function. A browser probe rendered a markup-shaped name as literal text.
  • Paths: display_name never carries more than inference_id in the same object. Models-dir names come from file_name(); single-model ids from file_name() or the operator's alias.
  • Delete fence: still token === identity.id plus instance and revision checks. The fence test is unchanged and passes.
  • Length: display_name is schema-valid exactly when inference_id is, so 256 adds no new rejection and removes the old one for 129-255 character directory names. A 256-character unbroken name at 390 and 1440 px caused no horizontal scroll in the library, inspector, delete dialog, Chat, Activity or Settings. Without models-wrap the dialog body overflows, so the class is load-bearing.

Left (pre-existing, out of scope): operator-supplied names are not bidi-isolated, so U+202E can reorder the rest of the delete sentence (the id fence still holds). One inference id over 256 code points still rejects the whole catalog page.

WebUI installed artifact failed at link time on the self-hosted runner's shared target (undefined reference in a stale rlib), unrelated to this diff.

@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 19, 2026
The catalog built identity.display_name by taking the last path segment and replacing every - and _ with a space, so qwen3-0.6b-4bit printed as "qwen3 0.6b 4bit", a hyphen directory and an underscore directory collapsed to one label, and cache entries lost their owner/. No surface showed the name a user passes to -m or the API.

display_name is now the inference id verbatim. Cache and preset names are kept whole (a preset's inference id is its section name, and an overlay-only preset re-sources a cache owner/name); models-dir and single-model names keep their last / segment. A trailing / now falls back to the whole name: the old unwrap_or never fired because rsplit yields Some(""), so the old helper returned an empty string.

ModelIdentity.display_name.maxLength widens from 128 to 256 to match inference_id, since a cache owner/name can reach 193 characters. schema_version stays webui.ui-api.v1. The delete dialog keeps identity.id as the confirmation token; its body and field label now say to type the model ID shown in the dialog rather than the model name, and the body paragraph wraps. Fixtures and tests that encoded space-separated names use hyphenated ids.

New tests: a catalog contract test that fails on the old helper with every row rewritten, a policy test for the hyphen and underscore spellings, a schema bound test at 193 and 257 characters, and a delete-dialog wording test.

Refs #1912
Output of python3 scripts/webui/build_bundle.py after the display-name, delete-dialog wording and test changes; build_bundle.py --verify passes on the result.

Refs #1912
The capacity recovery dialog lists every loaded model by display_name in plain list items with no wrap rule. While names were humanized, the spaces gave the browser break points; a verbatim name such as Meta_Llama_3.1_8B_Instruct_abliterated_4bit_mixed_quant_v2 has none, so at a 390px viewport the modal scrolled horizontally (dialog scrollWidth 523 against clientWidth 356 in Chromium). Give each list item the same models-wrap treatment as the dialog body and the id, so the dialog wraps a long id as #1912 requires. With the class the same measurement is 356 against 356.

The capacity recovery unit test now asserts the listed names and the wrap class; it fails with the class removed. The committed bundle is rebuilt with build_bundle.py and verified.

Refs #1912
The api.yaml description and docs/webui/catalog.md said display_name is the inference id verbatim, but single-model mode keeps the last /-separated segment of the served id, as #1912 decided, so an --alias such as Qwen/Qwen3-8B displays as Qwen3-8B. Spell out the rule per source (cache and preset whole, models_dir the directory name, single_model the last segment) in api.yaml, catalog.md and catalog.ko.md so the contract says what the server does.

Drop "불투명" from the Korean delete-confirmation label: Settings already uses that word for the visual Opaque material, and "위에 표시된 모델 ID (mdl_로 시작)" says what to type without it. The committed bundle is rebuilt with build_bundle.py and verified.

Refs #1912
The 1000-entry catalog performance test searched for `0999`, which only matched the old zero-padded label `Catalog performance model 0999`. The verbatim display-name change renamed the fixture to `perf-model-${index}`, so the query matched no row and the test timed out waiting for one filtered row on chromium, firefox and webkit-headed-performance in CI, on every retry. The only 1000-entry search-latency budget had stopped measuring anything.

Search for `perf-model-999`, which matches exactly one of the 1000 entries by its real name. Locally on chromium all four performance.spec.ts tests pass, with search-to-paint at 14.4 ms against the 100 ms budget; the same test fails at f6a1db7 with the original timeout.

Refs #1912
@inureyes
inureyes force-pushed the fix/issue-1912-webui-verbatim-display-name branch from 3082e1d to 371c20f Compare September 19, 2026 05:57
…verbatim-display-name

# Conflicts:
#	src/webui/assets/assets/code-highlight-CS7S6vIv.js
#	src/webui/assets/assets/code-highlight-CzWARrNf.js
#	src/webui/assets/assets/code-highlight-DYgjCuFT.js
#	src/webui/assets/assets/history-B1QtkSeo.js
#	src/webui/assets/assets/history-BhdDGTqa.js
#	src/webui/assets/assets/history-DTnpX_xH.js
#	src/webui/assets/assets/index-CWwuIjIj.js
#	src/webui/assets/index.html
#	src/webui/assets/mlxcel-webui-manifest.json
@inureyes
inureyes merged commit 31c0459 into main Sep 19, 2026
18 checks passed
@inureyes
inureyes deleted the fix/issue-1912-webui-verbatim-display-name branch September 19, 2026 10:40
inureyes added a commit that referenced this pull request Sep 19, 2026
…1923)

Closes #1903. Part of #1910.

## What changes

- **Theme ids.** `data-theme` always holds `<family>-<scheme>`: `mlxcel-light`, `mlxcel-dark` (the approved baseline, still the default), `glass-light`, `glass-dark`. `webui/src/design-system/theme.ts` resolves it; the stored `themeFamily` and `colorScheme` (`system`/`light`/`dark`) land in `data-theme-family` and `data-color-scheme`. The old flat `theme` field is migrated, and `system` follows host changes live. Settings gets Theme (Standard, Glass) and Color scheme selects.
- **ui-common mechanism.** `main.tsx` imports one theme entry (`themes/index.css`) after `base.css`, and no ui-common theme file. The color, shadow and button `--token-*` values moved from `:root` into `[data-theme]` blocks unchanged; `common-tokens.css` keeps the theme-independent half.
- **Glass theme.** Translucent chrome with a light reflection, tinted buttons, highlighted primary and danger fills, near-opaque content surfaces, no per-button blur. It reads the existing intensity, material, transparency, backdrop support, contrast and motion switches plus the `prefers-*` queries; any transparency or contrast preference makes it opaque at any intensity. Values derive from lablup/backend.ai-go (derivation line, `NOTICE`); the tokens stay here because they bind to this product's semantic token layer.
- **No flash.** `webui/public/theme-bootstrap.js` is a classic render-blocking script in `<head>`, external because the CSP is `script-src 'self'`. `build_bundle.py` rejects a bundle where it is missing, duplicated, deferred in any way, or after the app module, and any inline script.
- **Gate.** `check:theme-selectors` (in the `webui-bundle` job and `make verify-webui-frontend`) evaluates every `data-theme`/`data-theme-family` selector against the shipped ids and rejects `data-color-scheme` selectors, `prefers-color-scheme` outside the resolver, stray theme imports and ids with no theme block; exemptions need a written reason. Against `main` it reports `tokens.css:47`, `:48`, `:50`, `:51`, `:75`.

## Found along the way

- Chrome blur never rendered in Chromium or Firefox (Lightning CSS drops a standard `backdrop-filter` that precedes its `-webkit-` twin), and the primary and danger fills were hard-coded over ui-common's rules. Both are one-line fixes in `components.css`, and both survive the merge with #1902's rewrite of that file, alongside its `:where(:not(:disabled))` hover rules.
- `features/chat/style.test.ts` passed vacuously (Vitest blanks CSS imports); it now reads from disk. `models.css` read two undefined tokens; inspector labels are now muted and the selected row highlighted.
- Review fixed rendered glass fills and a bootstrap check that let `type="module"` through; the security pass fixed the gate skipping its check through a symlink and two super-linear patterns.

## Validation

Rebased onto `fd33ff15` (#1919, #1921, #1922) with an unchanged source diff, then merged with main at `5616f86b` (#1924, #1902's component adoption); `app.tsx` keeps #1902's PageHeader Settings screen with the theme selects, and the bundle was regenerated. On `cdb80fe7`: typecheck, lint, the gate, unit (35 node:test, 470 Vitest), Chromium suite (88, including the glass button fill case and #1902's button hover cases; axe on all four ids), `build_bundle.py --verify`, contract, binary-asset and cross-repo checks. The engines smoke on Chromium, Firefox and WebKit passed before the rebase. Each new check was reverted to confirm it fails.

## Bundled-binary checks

Playwright Chromium against `mlxcel-server` built from `c26e3247` (before the rebase and the #1924 merge) with the embedded bundle and no model loaded.

1. **No flash: pass.** `theme-bootstrap.js` is served 200, `text/javascript`, `no-cache`, under the strict CSP, ahead of the app module. With the app module held, all nine cases (cold profile under light and dark hosts, each id stored under the opposite host, glass `system` under both hosts, legacy `{theme: dark}`) had the right `data-theme` and canvas with nothing mounted, and no CSP violation.
2. **Settings: pass.** Glass then Dark gives `glass-dark`, persists without the legacy field, is restored before mount on reload; `system` follows a host flip live.
3. **390x844: pass** in both families: 0 px overflow on all four routes; the sheet opens, navigates, closes on Escape and returns focus.
4. **200 percent zoom: not native** (headless Chromium cannot zoom; a half-width CSS viewport at deviceScaleFactor 2 stood in). 320 and 640 CSS px: 0 px overflow everywhere. 195 CSS px (390 px window): 125 px on every route from `body { min-width: 320px }`; main's bundle measures the same, so it predates this PR.
5. **Keyboard: pass** for `mlxcel-light` and `glass-dark`: 18 Tab stops in DOM order, each with a visible indicator; Escape restores focus from the command palette (button and Cmd+K), a Select popup and the sheet.
6. **Contrast: pass.** axe WCAG 2.2 AA on all four routes for all four ids at intensity 100: 0 violations normally, under `prefers-contrast: more` and with High contrast on; the chrome drops to `blur(0px)` with no gradient.
7. **Served-CSP spec: pass** (both variants).




## Merge with main

`origin/main` 31c0459 (#1920, verbatim display names) was merged into this branch as 83fc058 by the epic #1910 orchestrator. Only the generated bundle conflicted; it was regenerated with `scripts/webui/build_bundle.py` (`--verify` digest 986294f2) and not hand-merged. On the merged tree: `typecheck`, `lint`, `check:theme-selectors` (152 files, 4 theme ids), `unit` (Vitest 473 in 51 files) and the Chromium Playwright suite on a private port (88 passed) all pass. The live-server script above was also re-run against a server built from cdb80fe (after #1924 was merged in): 23 of 25 cases pass, and the two failures are the pre-existing 195 CSS px overflow from `body { min-width: 320px }` described above.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority:medium Medium priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(webui): catalog display names must keep the checkpoint's real name

1 participant