diff --git a/prototype/ui-redesign/src/api.ts b/prototype/ui-redesign/src/api.ts index a73ebedbbc..52acfbb686 100644 --- a/prototype/ui-redesign/src/api.ts +++ b/prototype/ui-redesign/src/api.ts @@ -4,7 +4,7 @@ * and model downloads, and health polling. */ -import { recipeOptionsForModel, samplingForModel } from './presetStore'; +import { recipeOptionsForModel, samplingForModel, type RecipeOptions } from './presetStore'; const DEFAULT_BASE_URL = 'http://localhost:13305'; const LS_BASE_URL = 'lemonade_base_url'; @@ -948,7 +948,7 @@ class LemonadeAPI { async loadModel(modelName: string, recipeOptions?: Record, modelInfo?: ModelInfo | null): Promise { const target = modelName.trim().toLowerCase(); const cachedModelInfo = modelInfo || this.allModels.find(model => modelInfoKey(model).toLowerCase() === target) || null; - const stagedOptions = recipeOptionsForModel(modelName, cachedModelInfo); + const stagedOptions = recipeOptionsForModel(modelName, cachedModelInfo, recipeOptions as RecipeOptions | undefined, this._systemInfoData); const body: Record = { model_name: modelName, ...(stagedOptions || {}), ...recipeOptions }; const result = await this._json('/api/v1/load', { method: 'POST', body }); this._notifyModelsChanged(); diff --git a/prototype/ui-redesign/src/components/BackendManager.tsx b/prototype/ui-redesign/src/components/BackendManager.tsx index 21fa2a94fd..aef4ea4589 100644 --- a/prototype/ui-redesign/src/components/BackendManager.tsx +++ b/prototype/ui-redesign/src/components/BackendManager.tsx @@ -8,9 +8,7 @@ import { STARTERS, loadBackendApplied, loadUserPresets, - presetParamPreviewLines, presetSupportsCapability, - saveBackendApplied, } from '../presetStore'; import { Icon, PresetIcon } from './Icon'; import { DownloadListItem, downloadStore, isDownloadActive } from '../features/downloadManager/downloadStore'; @@ -172,11 +170,6 @@ function presetCompatibleWithBackend(preset: Preset, key: string): boolean { } -function backendLabelFromKey(key: string): string { - const [recipe, backend] = key.split(':'); - return `${RECIPE_LABELS[recipe] || recipe} · ${backend || 'auto'}`; -} - /* ── Helpers ─────────────────────────────────────────────── */ function stateBadge(state: BackendInfo['state']): { label: string; cls: string } { @@ -276,12 +269,6 @@ const BackendManager: React.FC = () => { const [toastMsg, setToastMsg] = useState(null); const [userPresets, setUserPresets] = useState(loadUserPresets); const [backendPresets, setBackendPresets] = useState>(loadBackendApplied); - const [presetRailCollapsed, setPresetRailCollapsed] = useState(false); - const [selectedBackendKey, setSelectedBackendKey] = useState(''); - const [selectedRailPresetId, setSelectedRailPresetId] = useState(DEFAULT_PRESET.id); - const [presetRailHovered, setPresetRailHovered] = useState(false); - const [hoveredRailPresetId, setHoveredRailPresetId] = useState(null); - const [presetNotice, setPresetNotice] = useState(null); const [downloadItems, setDownloadItems] = useState(() => downloadStore.snapshot()); const terminalBackendRefreshRef = useRef>(new Set()); @@ -502,50 +489,19 @@ const BackendManager: React.FC = () => { const allPresets = useMemo(() => [DEFAULT_PRESET, ...STARTERS, ...userPresets], [userPresets]); - const activePresetForBackendKey = useCallback((key: string): Preset => { - const presetId = backendPresets[key] || DEFAULT_PRESET.id; - const preset = allPresets.find(p => p.id === presetId) || DEFAULT_PRESET; - return presetCompatibleWithBackend(preset, key) ? preset : DEFAULT_PRESET; + // #2432: Backend presets are OPTIONAL global runtime-argument defaults. A backend + // has NO preset by default — that is the normal state. The Backend view only + // DISPLAYS an assigned preset read-only; *assignment* lives in the global Presets + // view. Returns null when no compatible preset is assigned, so a backend never + // looks like it owns/requires a preset. + const assignedPresetForBackendKey = useCallback((key: string): Preset | null => { + const presetId = backendPresets[key]; + if (!presetId || presetId === DEFAULT_PRESET.id) return null; + const preset = allPresets.find(p => p.id === presetId); + if (!preset) return null; + return presetCompatibleWithBackend(preset, key) ? preset : null; }, [allPresets, backendPresets]); - const selectedRailPreset = allPresets.find(p => p.id === selectedRailPresetId) || DEFAULT_PRESET; - const selectedBackendPreset = selectedBackendKey ? activePresetForBackendKey(selectedBackendKey) : null; - const railSummaryPreset = selectedBackendPreset || selectedRailPreset; - const highlightedPresetId = presetRailHovered ? (hoveredRailPresetId || railSummaryPreset.id) : null; - - const allBackendKeys = useMemo(() => { - if (!sysInfo?.recipes) return [] as string[]; - const keys: string[] = []; - for (const [recipe, recipeInfo] of Object.entries(sysInfo.recipes)) { - for (const backend of Object.keys(recipeInfo.backends)) keys.push(backendKey(recipe, backend)); - } - return keys.sort((a, b) => backendLabelFromKey(a).localeCompare(backendLabelFromKey(b))); - }, [sysInfo]); - - const backendsUsingSelectedPreset = useMemo( - () => allBackendKeys.filter(key => activePresetForBackendKey(key).id === railSummaryPreset.id), - [allBackendKeys, activePresetForBackendKey, railSummaryPreset.id], - ); - - const handlePresetRailPick = useCallback((preset: Preset) => { - setSelectedRailPresetId(preset.id); - if (!selectedBackendKey) return; - if (!presetCompatibleWithBackend(preset, selectedBackendKey)) { - setPresetNotice(`“${preset.name}” does not apply to ${backendLabelFromKey(selectedBackendKey)}.`); - window.setTimeout(() => setPresetNotice(null), 2800); - return; - } - setBackendPresets(prev => { - const next = { ...prev }; - if (preset.id === DEFAULT_PRESET.id) delete next[selectedBackendKey]; - else next[selectedBackendKey] = preset.id; - saveBackendApplied(next); - return next; - }); - setPresetNotice(`${backendLabelFromKey(selectedBackendKey)} → ${preset.name}`); - window.setTimeout(() => setPresetNotice(null), 2200); - }, [selectedBackendKey]); - /* ── Device detail row ────────────────────────────────── */ const deviceDetail = useCallback((deviceKey: DeviceKey): string => { @@ -560,70 +516,6 @@ const BackendManager: React.FC = () => { } }, [sysInfo]); - const renderPresetRail = () => ( - - ); - /* ── Render ───────────────────────────────────────────── */ @@ -641,8 +533,7 @@ const BackendManager: React.FC = () => { } return ( -
- {renderPresetRail()} +
@@ -734,27 +625,23 @@ const BackendManager: React.FC = () => { const isInstalling = installing === cellKey; const backendDownload = downloadItems.find(download => backendDownloadMatches(download, recipe, backend)); const showBackendProgress = Boolean(backendDownload && (isDownloadActive(backendDownload) || backendDownload.status === 'paused')); - const activePreset = activePresetForBackendKey(cellKey); - const isSelectedBackend = selectedBackendKey === cellKey; - const isPresetHighlighted = Boolean(highlightedPresetId && activePreset.id === highlightedPresetId); + const assignedPreset = assignedPresetForBackendKey(cellKey); return ( -
- {/* #2343: overlay button provides keyboard + SR selection; covers the full cell area */} -
)} + + {appliedBackendKeys.length > 0 && ( +
+
+ + Applied to backends + {appliedBackendKeys.length} + +
+

+ Backend presets are global runtime defaults. Each one applies to all models that use that backend and merges with the model’s own preset. +

+
+ {appliedBackendKeys.map(key => { + const preset = lookupPreset(backendPresets[key]); + const backendLabel = backendLabelFromKey(key); + return ( +
+
+ + {backendLabel} +
+
+ + {preset?.name || 'Missing preset'} + Global · applies to all models on this backend +
+
+ {preset && } + +
+
+ ); + })} +
+
+ )}
@@ -679,6 +832,11 @@ const PresetManager: React.FC = ({ loadedModels }) => { onApplyTargetChange={setApplyTarget} onApply={handleApply} applySuccess={applySuccess} + backendOptions={backendOptions} + applyBackendTarget={applyBackendTarget} + onApplyBackendTargetChange={setApplyBackendTarget} + onApplyBackend={handleApplyBackend} + applyBackendSuccess={applyBackendSuccess} onSave={handleSave} onClone={handleClone} onExport={handleExport} @@ -758,13 +916,18 @@ const SlideoverContent: React.FC<{ onApplyTargetChange: (v: string) => void; onApply: (presetId: string, model: ModelInfo) => void; applySuccess: string | null; + backendOptions: BackendOption[]; + applyBackendTarget: string; + onApplyBackendTargetChange: (v: string) => void; + onApplyBackend: (presetId: string, backendKey: string) => void; + applyBackendSuccess: string | null; onSave: (updated: Preset) => void; onClone: (preset: Preset) => void; onExport: (preset: Preset) => void; onDelete: (preset: Preset) => void; onClose: () => void; autoRuns: AutoOptRun[]; -}> = ({ preset, models, applyTarget, onApplyTargetChange, onApply, applySuccess, onSave, onClone, onExport, onDelete, onClose, autoRuns }) => { +}> = ({ preset, models, applyTarget, onApplyTargetChange, onApply, applySuccess, backendOptions, applyBackendTarget, onApplyBackendTargetChange, onApplyBackend, applyBackendSuccess, onSave, onClone, onExport, onDelete, onClose, autoRuns }) => { const isReadOnly = preset.starter; const ro = preset.recipe_options || {}; const sp = preset.sampling || {}; @@ -860,6 +1023,14 @@ const ctxSliderMax = selectedModelContextLimit || DEFAULT_CONTEXT_LIMIT; const ctxOptions = useMemo(() => contextSizeOptions(ctxSliderMax), [ctxSliderMax]); const ctxSliderIndex = useMemo(() => contextSizeIndex(ctxSize, ctxOptions), [ctxSize, ctxOptions]); const canApply = !!selectedModel && isCompatible(currentPreset, selectedModel); +const selectedBackendOption = backendOptions.find(b => b.key === applyBackendTarget) || null; +// #2432 review (GAP 2): the Default preset represents the "no backend preset" +// state, which is exactly what the Backend view shows when a backend has no chip. +// Assigning Default as a real backend binding would create an inconsistent +// "Applied to backends" row with no matching chip, so backend assignment is +// disabled for Default. Users pick/clone another preset to set a backend default. +const isDefaultBackendPreset = currentPreset.id === DEFAULT_PRESET.id; +const canApplyBackend = !isDefaultBackendPreset && !!selectedBackendOption && selectedBackendOption.supported && presetCompatibleWithBackend(currentPreset, selectedBackendOption.key); useEffect(() => { const nextCtxSize = nearestContextSize( @@ -1101,6 +1272,65 @@ const canApply = !!selectedModel && isCompatible(currentPreset, selectedModel); {selectedModel && !canApply &&

Incompatible preset for this model.

} {applySuccess &&

✓ {applySuccess}

} + + {/* #2432: assign this preset to a BACKEND. Global backend/runtime default + that merges with model presets — it does NOT replace them. */} +
+

Apply to a backend

+

+ This preset applies globally to all models using this backend. Backend presets are optional + runtime defaults that merge with each model’s own preset — they do not replace it. +

+ {isDefaultBackendPreset && ( +

+ The Default preset is the “no backend preset” state — every backend already falls back + to it. To set a global backend default, pick or clone a different preset first. +

+ )} +
+ + +
+ {backendOptions.length === 0 &&

No backends reported by this server yet.

} + {!isDefaultBackendPreset && selectedBackendOption && !selectedBackendOption.supported && ( +

+ {selectedBackendOption.label} is unsupported on this server’s hardware, so a preset cannot be assigned to it. +

+ )} + {!isDefaultBackendPreset && selectedBackendOption && selectedBackendOption.supported && !canApplyBackend &&

Incompatible preset for this backend.

} +

+ {applyBackendSuccess ? `✓ ${applyBackendSuccess}` : ''} +

+
diff --git a/prototype/ui-redesign/src/presetStore.ts b/prototype/ui-redesign/src/presetStore.ts index be2f3eecea..4a8ed351e9 100644 --- a/prototype/ui-redesign/src/presetStore.ts +++ b/prototype/ui-redesign/src/presetStore.ts @@ -497,6 +497,102 @@ export function activePresetForBackend(key: string): Preset { return allStoredPresets().find(p => p.id === presetId) || DEFAULT_PRESET; } +/** + * Per-recipe field in RecipeOptions that names the concrete backend the runtime + * will bind at load time (e.g. `llamacpp_backend: 'vulkan'`). Used to resolve the + * EXACT backend a load will use so we only merge the matching `recipe:backend` + * binding (#2432 round-3). + */ +const BACKEND_FIELD_BY_RECIPE: Record = { + llamacpp: 'llamacpp_backend', + vllm: 'vllm_backend', + whispercpp: 'whispercpp_backend', + moonshine: 'moonshine_backend', +}; + +function normalizeBackendValue(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim().toLowerCase(); + return trimmed ? trimmed : undefined; +} + +/** Read `recipes[recipe].default_backend` from a /system-info payload. */ +function recipeDefaultBackend(systemInfo: Record | null | undefined, recipe: string): string | undefined { + const recipes = (systemInfo?.recipes ?? null) as Record | null; + if (!recipes) return undefined; + return normalizeBackendValue(recipes[recipe]?.default_backend); +} + +/** + * Resolve the CONCRETE backend that a load for `recipe` will actually use, in + * sensible precedence: explicit load options → model-preset recipe_options → + * recipe default backend (from /system-info). The backend preset's own value is + * deliberately NOT consulted here — that would be circular (we're deciding + * whether the backend preset even applies). + */ +function concreteBackendForRecipe( + recipe: string, + explicitOptions: RecipeOptions | null | undefined, + modelPresetOptions: RecipeOptions | null | undefined, + systemInfo: Record | null | undefined, +): string | undefined { + const field = BACKEND_FIELD_BY_RECIPE[recipe]; + if (!field) return undefined; + return normalizeBackendValue(explicitOptions?.[field]) + ?? normalizeBackendValue(modelPresetOptions?.[field]) + ?? recipeDefaultBackend(systemInfo, recipe); +} + +export interface BackendResolutionContext { + /** Options passed explicitly to the load call (highest precedence). */ + explicitOptions?: RecipeOptions | null; + /** The model preset's recipe_options (model-specific defaults). */ + modelPresetOptions?: RecipeOptions | null; + /** /system-info payload, used for the recipe's default_backend. */ + systemInfo?: Record | null; +} + +/** + * #2432 (round-3): resolve the GLOBAL backend preset that applies to a model's + * load, if any. + * + * Backend presets are keyed by the EXACT `recipe:backend` pair (e.g. + * `llamacpp:vulkan`). A binding therefore only applies when the CONCRETE backend + * that this load will use equals the binding's backend part — NOT merely when the + * recipe matches. We resolve the concrete backend the same way the load does + * (explicit options → model preset → recipe default_backend) and only merge the + * preset bound to that exact key. This keeps the semantics "all models using this + * BACKEND" rather than the wrong "all models using this RECIPE". + * + * Default is treated as "no backend preset" (returns null) so it never + * contributes args — consistent with the Backend view hiding Default. + */ +export function activePresetForModelBackend(model?: ModelInfo | null, ctx?: BackendResolutionContext): Preset | null { + if (!model) return null; + const applied = loadBackendApplied(); + if (Object.keys(applied).length === 0) return null; + + // Ordered list of the model's recipes (active recipe first). + const recipes: string[] = []; + const active = String((model as Record).recipe || '').trim().toLowerCase(); + if (active) recipes.push(active); + for (const recipe of recipesForModel(model)) { + const name = recipeName(recipe); + if (name && !recipes.includes(name)) recipes.push(name); + } + if (recipes.length === 0) return null; + + for (const recipe of recipes) { + const backend = concreteBackendForRecipe(recipe, ctx?.explicitOptions, ctx?.modelPresetOptions, ctx?.systemInfo); + if (!backend) continue; + const presetId = applied[`${recipe}:${backend}`]; + if (!presetId || presetId === DEFAULT_PRESET.id) continue; + const preset = allStoredPresets().find(p => p.id === presetId); + if (preset) return preset; + } + return null; +} + /* ── Running-preset store (#2356: update preset while loaded) ───── * * Tracks the preset a *loaded* model is currently running with. When a model @@ -617,13 +713,39 @@ export function recipeOptionsForCapability(options: RecipeOptions, capability: M } } -export function recipeOptionsForModel(modelName: string, model?: ModelInfo | null): RecipeOptions | undefined { - const preset = activePresetForModel(modelName); - const options = preset.recipe_options || {}; - const scopedOptions = model - ? recipeOptionsForCapability(options, capabilityFromModelInfo(model)) - : options; - return Object.keys(scopedOptions || {}).length > 0 ? scopedOptions : undefined; +export function recipeOptionsForModel( + modelName: string, + model?: ModelInfo | null, + explicitOptions?: RecipeOptions | null, + systemInfo?: Record | null, +): RecipeOptions | undefined { + const capability = model ? capabilityFromModelInfo(model) : undefined; + + // Model preset = model-specific defaults. + const modelPreset = activePresetForModel(modelName); + const modelPresetOptions = modelPreset.recipe_options || {}; + const modelOptions = model + ? recipeOptionsForCapability(modelPresetOptions, capability!) + : modelPresetOptions; + + // Backend preset = GLOBAL backend/runtime defaults (#2432). It only applies + // when the CONCRETE backend this load resolves to (explicit options → model + // preset → recipe default_backend) matches its exact `recipe:backend` binding. + // Merge precedence: start from the backend preset's args as the base/global + // defaults, then layer the model preset's args on top so model-specific values + // WIN on conflict. This matches `main`'s "more specific source wins" merge. + // Default backend preset contributes nothing (resolver returns null). + const backendPreset = activePresetForModelBackend(model, { + explicitOptions, + modelPresetOptions, + systemInfo, + }); + const backendOptions = backendPreset && model + ? recipeOptionsForCapability(backendPreset.recipe_options || {}, capability!) + : (backendPreset ? (backendPreset.recipe_options || {}) : {}); + + const merged: RecipeOptions = { ...backendOptions, ...modelOptions }; + return Object.keys(merged || {}).length > 0 ? merged : undefined; } export function samplingForModel(modelName: string): SamplingParams { diff --git a/prototype/ui-redesign/src/styles/styles.css b/prototype/ui-redesign/src/styles/styles.css index 40d2829554..b2a5dc5e52 100644 --- a/prototype/ui-redesign/src/styles/styles.css +++ b/prototype/ui-redesign/src/styles/styles.css @@ -3301,6 +3301,8 @@ input, textarea { } .preset-error { color: var(--danger); font-size: 12px; margin: var(--space-2) 0 0; } .preset-success { color: var(--success); font-size: 12px; margin: var(--space-2) 0 0; } +.preset-backend-success { color: var(--success); font-size: 12px; margin: var(--space-2) 0 0; } +.preset-backend-success:empty { margin: 0; } .preset-status-chip { padding: 3px 8px; border-radius: 999px; diff --git a/prototype/ui-redesign/tests/a11y.spec.ts b/prototype/ui-redesign/tests/a11y.spec.ts index bed6c6d4fa..8d9e5d4c19 100644 --- a/prototype/ui-redesign/tests/a11y.spec.ts +++ b/prototype/ui-redesign/tests/a11y.spec.ts @@ -870,42 +870,9 @@ test.describe('Accessibility — Backend Manager (#2343 #2344 #2351)', () => { await page.waitForSelector('[data-backends-matrix]', { timeout: 5000 }); }); - // ── #2343 — matrix cells are keyboard-focusable with selected state ──────── - - test('A51 — each matrix cell contains a button with aria-pressed (selected state)', async ({ page }) => { - const cellBtn = page.locator('.cell__select-btn').first(); - await expect(cellBtn).toBeVisible(); - const pressed = await cellBtn.getAttribute('aria-pressed'); - expect(['true', 'false']).toContain(pressed); - }); - - test('A52 — clicking the cell select button toggles aria-pressed between "true" and "false"', async ({ page }) => { - const cellBtn = page.locator('[data-cell="llamacpp:vulkan"] .cell__select-btn'); - await expect(cellBtn).toHaveAttribute('aria-pressed', 'false'); - await cellBtn.click(); - await expect(cellBtn).toHaveAttribute('aria-pressed', 'true'); - await cellBtn.click(); - await expect(cellBtn).toHaveAttribute('aria-pressed', 'false'); - }); - - test('A53 — cell select button is keyboard-focusable (tabIndex >= 0, is a