feat(recipes): show the recipe base model in the modal header

Adds a base model pill at the front of the recipe modal's tags row,
showing the full base model name (cards keep the abbreviation since
their overlay width is constrained). Falls back to a dimmed Unknown so
the header layout does not shift when hydration fills the value in.
Hydration now also merges base_model. Translated in all 9 locales.
This commit is contained in:
Will Miao
2026-09-16 19:58:44 +08:00
parent a0a5b13ab0
commit 1b1a8d63db
14 changed files with 335 additions and 12 deletions
+3 -1
View File
@@ -920,7 +920,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Basismodell",
"unknown": "Unbekannt"
},
"actions": {
"openFileLocation": "Dateispeicherort öffnen",
+3 -1
View File
@@ -920,7 +920,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Base Model",
"unknown": "Unknown"
},
"actions": {
"openFileLocation": "Open File Location",
+3 -1
View File
@@ -920,7 +920,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Modelo base",
"unknown": "Desconocido"
},
"actions": {
"openFileLocation": "Abrir ubicación del archivo",
+3 -1
View File
@@ -920,7 +920,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Modèle de base",
"unknown": "Inconnu"
},
"actions": {
"openFileLocation": "Ouvrir lemplacement du fichier",
+3 -1
View File
@@ -920,7 +920,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "מודל בסיס",
"unknown": "לא ידוע"
},
"actions": {
"openFileLocation": "פתח מיקום קובץ",
+3 -1
View File
@@ -920,7 +920,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "ベースモデル",
"unknown": "不明"
},
"actions": {
"openFileLocation": "ファイルの場所を開く",
+3 -1
View File
@@ -920,7 +920,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "베이스 모델",
"unknown": "알 수 없음"
},
"actions": {
"openFileLocation": "파일 위치 열기",
+3 -1
View File
@@ -920,7 +920,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Базовая модель",
"unknown": "Неизвестно"
},
"actions": {
"openFileLocation": "Открыть расположение файла",
+3 -1
View File
@@ -920,7 +920,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "基础模型",
"unknown": "未知"
},
"actions": {
"openFileLocation": "打开文件位置",
+3 -1
View File
@@ -920,7 +920,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "基礎模型",
"unknown": "未知"
},
"actions": {
"openFileLocation": "開啟檔案位置",
+32
View File
@@ -28,6 +28,38 @@
width: 100%;
}
/* Tags row: the base model badge shares one line with the compact tags. */
.recipe-tags-row {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
}
.recipe-tags-row #recipeTagsContainer {
flex: 1;
min-width: 0;
}
/* Header base model badge: reuses the card .base-model-label pill shape but
swaps the on-image overlay styling (text shadow, backdrop blur) for the
accent-tinted chip look used by resource rows in this modal. */
.recipe-base-model-badge {
flex-shrink: 0;
max-width: 160px;
text-shadow: none;
backdrop-filter: none;
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
}
.recipe-base-model-badge.is-unknown {
background: var(--surface-subtle);
color: var(--text-color);
opacity: 0.6;
}
.recipe-modal-header h2 {
margin: 0 0 var(--space-1);
padding: var(--space-1);
+32
View File
@@ -494,6 +494,7 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe);
this.syncHeaderActions();
this.syncBaseModelBadge();
this.syncMetaFooter();
// Show the modal
@@ -520,6 +521,32 @@ class RecipeModal {
}
}
/**
* Render the recipe-level base model badge in the header tags row.
* Unlike the width-constrained card overlay (which abbreviates), the
* modal has room for the full base model name matching the model
* modal's info grid and this modal's resource rows. Falls back to a
* dimmed "Unknown" instead of hiding so the header layout does not
* shift when hydration fills the value in.
*/
syncBaseModelBadge() {
const badge = document.getElementById('recipeBaseModelBadge');
if (!badge) {
return;
}
const rawLabel = (this.currentRecipe?.base_model || '').trim();
const unknownLabel = translate('recipes.modal.metadata.unknown', {}, 'Unknown');
const baseModelLabel = rawLabel || unknownLabel;
const fieldLabel = translate('recipes.modal.metadata.baseModel', {}, 'Base Model');
badge.textContent = baseModelLabel;
badge.title = `${fieldLabel}: ${baseModelLabel}`;
badge.setAttribute('aria-label', badge.title);
badge.classList.toggle('is-unknown', !rawLabel);
badge.hidden = false;
}
/**
* Render the meta footer: clickable file location (opens the recipe JSON
* in the OS file manager) plus the truncated recipe ID with copy button.
@@ -661,6 +688,10 @@ class RecipeModal {
nextRecipe.has_workflow = fullRecipe.has_workflow;
}
if (fullRecipe.base_model !== undefined) {
nextRecipe.base_model = fullRecipe.base_model;
}
if (fullRecipe.checkpoint !== undefined) {
nextRecipe.checkpoint = fullRecipe.checkpoint;
} else {
@@ -718,6 +749,7 @@ class RecipeModal {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
}
this.syncHeaderActions();
this.syncBaseModelBadge();
this.syncMetaFooter();
}
+6 -1
View File
@@ -26,8 +26,13 @@
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
<!-- Tags row: the base model badge is an independent sibling of the
tags container so renderCompactTags re-renders and tag edit mode
never touch it. Badge is populated by RecipeModal.syncBaseModelBadge(). -->
<div class="recipe-tags-row">
<span id="recipeBaseModelBadge" class="base-model-label recipe-base-model-badge" hidden></span>
<div id="recipeTagsContainer"></div>
</div>
</header>
<div class="modal-body">
@@ -0,0 +1,234 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const copyToClipboardMock = vi.fn();
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
const loadingManagerStub = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
show: vi.fn(),
restoreProgressBar: vi.fn(),
};
const recipeItem = {
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
file_path: '/recipes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png',
title: 'Demo Recipe',
tags: [],
loras: [],
base_model: 'Illustrious',
};
const virtualScrollerStub = {
updateSingleItem: vi.fn(),
getNavigationState: vi.fn(() => ({
index: 0,
hasPrev: false,
hasNext: false,
loadedItems: 1,
totalItems: 1,
})),
getAdjacentItemByFilePath: vi.fn(async () => null),
};
const stateStub = {
global: { settings: {}, loadingManager: loadingManagerStub },
loadingManager: loadingManagerStub,
virtualScroller: virtualScrollerStub,
};
const modalManagerMock = {
showModal: vi.fn(),
closeModal: vi.fn(),
};
const fetchRecipeDetailsMock = vi.fn(async () => ({}));
const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true }));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
copyToClipboard: copyToClipboardMock,
sendLoraToWorkflow: vi.fn(),
sendModelPathToWorkflow: vi.fn(),
openCivitaiByMetadata: vi.fn(),
stripLoraTags: vi.fn((text) => text),
sendPromptToWorkflow: vi.fn(),
sendGenParamsToWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
getStorageItem: vi.fn(() => null),
setStorageItem: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
fetchRecipeDetails: fetchRecipeDetailsMock,
updateRecipeMetadata: updateRecipeMetadataMock,
sendRecipeWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings',
},
}));
function recipeModalFixture() {
return `
<div id="recipeModal" class="modal">
<div class="modal-content">
<header class="recipe-modal-header">
<div class="recipe-modal-header-row">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="modal-nav-controls">
<button class="modal-nav-btn" id="recipeNavPrevBtn" disabled></button>
<button class="modal-nav-btn" id="recipeNavNextBtn" disabled></button>
</div>
</div>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn"><i class="fas fa-paper-plane"></i></button>
<button class="modal-copy-btn" id="copyRecipeSyntaxBtn"><i class="fas fa-copy"></i></button>
</div>
<div class="recipe-tags-row">
<span id="recipeBaseModelBadge" class="base-model-label recipe-base-model-badge" hidden></span>
<div id="recipeTagsContainer"></div>
</div>
</header>
<div class="modal-body">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
<div class="param-content" id="recipePrompt"></div>
<div class="param-editor" id="recipePromptEditor">
<textarea class="param-textarea" id="recipePromptInput"></textarea>
</div>
</div>
<div class="param-group info-item">
<div class="param-content" id="recipeNegativePrompt"></div>
<div class="param-editor" id="recipeNegativePromptEditor">
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
</div>
</div>
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-actions">
<span id="recipeLorasCount"></span>
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn"></button>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
</div>
</div>
<footer class="recipe-meta-footer" id="recipeMetaFooter" hidden></footer>
</div>
</div>
`;
}
async function flushAsyncTasks() {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
const createdModals = [];
async function createRecipeModal() {
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
const recipeModal = new RecipeModal();
createdModals.push(recipeModal);
return recipeModal;
}
describe('RecipeModal base model badge', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = recipeModalFixture();
global.modalManager = modalManagerMock;
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({}),
}));
});
afterEach(() => {
createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts());
createdModals.length = 0;
document.body.innerHTML = '';
delete global.modalManager;
delete global.fetch;
});
it('shows the full base model name with a labeled tooltip', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItem);
const badge = document.getElementById('recipeBaseModelBadge');
expect(badge.hidden).toBe(false);
expect(badge.textContent).toBe('Illustrious');
expect(badge.title).toBe('Base Model: Illustrious');
expect(badge.getAttribute('aria-label')).toBe('Base Model: Illustrious');
expect(badge.classList.contains('is-unknown')).toBe(false);
});
it('falls back to a dimmed Unknown badge when no base model is set', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails({ ...recipeItem, base_model: '' });
const badge = document.getElementById('recipeBaseModelBadge');
expect(badge.hidden).toBe(false);
expect(badge.textContent).toBe('Unknown');
expect(badge.title).toBe('Base Model: Unknown');
expect(badge.classList.contains('is-unknown')).toBe(true);
});
it('updates the badge once hydration provides the base model', async () => {
fetchRecipeDetailsMock.mockResolvedValueOnce({
id: recipeItem.id,
file_path: recipeItem.file_path,
base_model: 'Pony',
});
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails({ ...recipeItem, base_model: '' });
await flushAsyncTasks();
const badge = document.getElementById('recipeBaseModelBadge');
expect(badge.textContent).toBe('Pony');
expect(badge.title).toBe('Base Model: Pony');
expect(badge.classList.contains('is-unknown')).toBe(false);
});
it('keeps the list-provided base model when hydration omits it', async () => {
fetchRecipeDetailsMock.mockResolvedValueOnce({
id: recipeItem.id,
file_path: recipeItem.file_path,
});
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItem);
await flushAsyncTasks();
const badge = document.getElementById('recipeBaseModelBadge');
expect(badge.textContent).toBe('Illustrious');
expect(badge.title).toBe('Base Model: Illustrious');
});
});