/** Workflow Recipe catalog shell: navigation, filters, view mode, and loading. */ import { translate } from './locales.js'; import { appendText } from './ui_recipe_detail_dom.js'; import { createRecipeCard, ensureRecipeGuideStyles } from './ui_recipe_cards.js'; const t = (key, params) => translate(key, params); export function updateRecipeFilterControls(owner, recipes) { if (!owner.recipeTagSelect) return; const tags = [...new Set((recipes || []).flatMap(item => item?.data?.tags || []))] .filter(Boolean) .sort((a, b) => a.localeCompare(b)); const selected = [...(owner.recipeSelectedTags || [])][0] || ''; owner.recipeSelectedTags = new Set(selected ? [selected] : []); owner.recipeTagSelect.replaceChildren(); appendText(owner.recipeTagSelect, 'option', t('materialAllTags')).value = ''; if (selected && !tags.includes(selected)) tags.push(selected); for (const tag of tags) appendText(owner.recipeTagSelect, 'option', tag).value = tag; owner.recipeTagSelect.value = selected; } function buildRecipeStudioTopbar(owner) { const topbar = document.createElement('div'); topbar.className = 'anomalous-recipe-topbar'; const left = document.createElement('div'); left.className = 'anomalous-recipe-topbar-left'; const pillsWrap = document.createElement('div'); pillsWrap.className = 'anomalous-recipe-pills'; const scopes = [ { id: 'all', label: t('recipeScopePill_all') }, { id: 'complete', label: t('recipeScopePill_complete') }, { id: 'partial', label: t('recipeScopePill_partial') }, ]; owner.recipeScopeFilter = owner.recipeScopeFilter || 'all'; for (const { id, label } of scopes) { const scopeButton = appendText(pillsWrap, 'button', label, 'anomalous-recipe-pill'); scopeButton.type = 'button'; if (owner.recipeScopeFilter === id) scopeButton.classList.add('is-active'); scopeButton.onclick = () => { if (owner.recipeScopeFilter === id) return; owner.recipeScopeFilter = id; pillsWrap.querySelectorAll('.anomalous-recipe-pill').forEach(element => element.classList.remove('is-active')); scopeButton.classList.add('is-active'); owner.renderRecipeList(owner.recipeRecords || []); }; } left.appendChild(pillsWrap); topbar.appendChild(left); const center = document.createElement('div'); center.className = 'anomalous-recipe-topbar-center'; const searchWrap = document.createElement('div'); searchWrap.className = 'anomalous-recipe-search-wrap'; appendText(searchWrap, 'span', '🔍', 'anomalous-recipe-search-icon'); const searchInput = document.createElement('input'); searchInput.type = 'search'; searchInput.className = 'anomalous-recipe-search-input'; searchInput.placeholder = t('recipeSearchPlaceholder'); searchInput.value = owner.recipeSearchQuery || ''; searchInput.oninput = () => { owner.recipeSearchQuery = searchInput.value; owner.renderRecipeList(owner.recipeRecords || []); }; owner.recipeSearchInput = searchInput; searchWrap.appendChild(searchInput); center.appendChild(searchWrap); const tagSelect = document.createElement('select'); tagSelect.className = 'anomalous-recipe-tag-select'; tagSelect.onchange = () => { owner.recipeSelectedTags = new Set(tagSelect.value ? [tagSelect.value] : []); owner.renderRecipeList(owner.recipeRecords || []); }; owner.recipeTagSelect = tagSelect; center.appendChild(tagSelect); owner.recipeFilterSummary = appendText(center, 'small', '0/0', 'anomalous-recipe-filter-summary'); topbar.appendChild(center); const right = document.createElement('div'); right.className = 'anomalous-recipe-topbar-right'; const viewSwitch = document.createElement('div'); viewSwitch.className = 'anomalous-recipe-view-switch'; owner.recipeViewMode = owner.recipeViewMode || 'grid'; const gridButton = document.createElement('button'); gridButton.type = 'button'; gridButton.className = 'anomalous-recipe-view-btn' + (owner.recipeViewMode === 'grid' ? ' is-active' : ''); gridButton.title = t('recipeViewGrid'); gridButton.innerHTML = ''; const listButton = document.createElement('button'); listButton.type = 'button'; listButton.className = 'anomalous-recipe-view-btn' + (owner.recipeViewMode === 'list' ? ' is-active' : ''); listButton.title = t('recipeViewList'); listButton.innerHTML = ''; const setViewMode = mode => { if (owner.recipeViewMode === mode) return; owner.recipeViewMode = mode; gridButton.classList.toggle('is-active', mode === 'grid'); listButton.classList.toggle('is-active', mode === 'list'); owner.recipeListContainer?.classList.toggle('is-grid', mode === 'grid'); owner.recipeListContainer?.classList.toggle('is-list', mode === 'list'); }; gridButton.onclick = () => setViewMode('grid'); listButton.onclick = () => setViewMode('list'); viewSwitch.append(gridButton, listButton); right.appendChild(viewSwitch); const importButton = appendText(right, 'button', '⇧', 'anomalous-recipe-topbar-btn anomalous-tooltip-target'); importButton.type = 'button'; importButton.disabled = true; importButton.setAttribute('data-tooltip', t('recipeImportUnavailable')); importButton.setAttribute('aria-label', t('recipeImportUnavailable')); const saveButton = appendText(right, 'button', '', 'anomalous-recipe-topbar-btn is-primary'); saveButton.dataset.recipeSaveCurrent = 'true'; saveButton.type = 'button'; saveButton.innerHTML = `${t('recipeSaveCurrent')}`; saveButton.onclick = () => owner.handleSaveRecipe(); topbar.appendChild(right); return topbar; } function normaliseSearchText(value) { const text = String(value || '').trim().toLocaleLowerCase(); try { return text.normalize('NFKC'); } catch (error) { return text; } } export function recipeMatchesFilter(data, query, selectedTags, scope = 'all') { if (scope !== 'all' && (data?.workflow_scope || 'complete') !== scope) return false; const terms = normaliseSearchText(query).split(/\s+/).filter(Boolean); const haystack = normaliseSearchText([ data?.name || '', data?.notes || '', ...(Array.isArray(data?.tags) ? data.tags : []), ].join(' ')); if (terms.some((term) => !haystack.includes(term))) return false; const tags = new Set((Array.isArray(data?.tags) ? data.tags : []).map(normaliseSearchText)); for (const tag of selectedTags || []) if (!tags.has(normaliseSearchText(tag))) return false; return true; } function renderRecipeEmptyGuide(owner) { const guide = appendText(owner.recipeListContainer, 'div', '', 'anomalous-recipe-empty-guide'); appendText(guide, 'div', '⚡', 'anomalous-recipe-empty-icon'); appendText(guide, 'h3', t('recipeEmptyGuideTitle') || '工作流配方工坊暂无配方', 'anomalous-recipe-empty-title'); appendText(guide, 'p', t('recipeEmptyGuideSubtitle') || '保存、管理与复用 ComfyUI 完整或局部工作流,支持画布直接拖拽载入与模型自愈。', 'anomalous-recipe-empty-subtitle'); const steps = appendText(guide, 'div', '', 'anomalous-recipe-empty-steps'); const step1 = appendText(steps, 'div', '', 'anomalous-recipe-empty-step'); appendText(step1, 'div', '💾', 'anomalous-recipe-step-icon'); appendText(step1, 'strong', t('recipeEmptyStep1Title') || '1. 保存当前工作流'); appendText(step1, 'p', t('recipeEmptyStep1Desc') || '点击右上角“保存当前工作流”按钮,即可将当前画布节点网络与参数保存为配方。'); const step2 = appendText(steps, 'div', '', 'anomalous-recipe-empty-step'); appendText(step2, 'div', '🎯', 'anomalous-recipe-step-icon'); appendText(step2, 'strong', t('recipeEmptyStep2Title') || '2. 画布直接拖拽'); appendText(step2, 'p', t('recipeEmptyStep2Desc') || '按住配方卡片直接拖拽到 ComfyUI 画布空白处释放,瞬间载入整套工作流,无需重新接线。'); const step3 = appendText(steps, 'div', '', 'anomalous-recipe-empty-step'); appendText(step3, 'div', '🩺', 'anomalous-recipe-step-icon'); appendText(step3, 'strong', t('recipeEmptyStep3Title') || '3. 缺失模型自愈'); appendText(step3, 'p', t('recipeEmptyStep3Desc') || '若配方中的模型在本地缺失,点击卡片进入详情可智能比对并一键替换为同类可用模型。'); const actionBtn = appendText(guide, 'button', '', 'anomalous-recipe-empty-action-btn'); actionBtn.type = 'button'; actionBtn.innerHTML = `${t('recipeEmptySaveCurrentBtn') || '保存当前工作流'}`; actionBtn.onclick = () => owner.handleSaveRecipe?.(); } export function renderRecipeList(recipes, services) { this.recipeListContainer.replaceChildren(); const records = Array.isArray(recipes) ? recipes : []; const selectedTags = this.recipeSelectedTags || new Set(); const scopeFilter = this.recipeScopeFilter || 'all'; const filtered = records.filter((recipe) => { const data = recipe?.data || {}; return recipeMatchesFilter( data, this.recipeSearchQuery || '', selectedTags, scopeFilter, ); }); if (this.recipeFilterSummary) this.recipeFilterSummary.textContent = `${filtered.length}/${records.length}`; if (this.recipeHintStrip) { const isDismissed = (() => { try { return localStorage.getItem('amb_hide_recipe_drag_hint') === 'true'; } catch (_) { return false; } })(); this.recipeHintStrip.style.display = (records.length > 0 && !isDismissed) ? 'flex' : 'none'; } if (!records.length) { ensureRecipeGuideStyles(); if (this.recipeSearchQuery || (this.recipeSelectedTags && this.recipeSelectedTags.size > 0) || (this.recipeScopeFilter && this.recipeScopeFilter !== 'all')) { appendText(this.recipeListContainer, 'p', t('recipeNoMatches'), 'anomalous-recipe-empty'); } else { renderRecipeEmptyGuide(this); } return; } if (!filtered.length) { appendText(this.recipeListContainer, 'p', t('recipeNoMatches'), 'anomalous-recipe-empty'); return; } for (const recipe of filtered) { try { this.recipeListContainer.appendChild(createRecipeCard(this, recipe, services)); } catch (cardError) { console.error('Could not render Workflow Recipe card:', recipe?.filename, cardError); const notice = appendText(this.recipeListContainer, 'p', `${recipe?.data?.name || t('recipeUntitled')} — ${t('recipeLoadError')}`, 'anomalous-recipe-empty'); notice.setAttribute('role', 'alert'); } } } export async function showRecipes() { if (this.materialContainer) this.materialContainer.style.display = 'none'; if (this.notebookContainer) this.notebookContainer.style.display = 'none'; if (this.nbPanel) this.nbPanel.style.display = 'flex'; if (!this.recipeContainer) { this.recipeContainer = document.createElement('div'); this.recipeContainer.className = 'anomalous-nb-container anomalous-recipe-container'; const modalClose = document.createElement('button'); modalClose.type = 'button'; modalClose.className = 'anomalous-recipe-modal-close'; modalClose.innerHTML = '×'; modalClose.title = t('workspaceClose') || (window.anomalous_browser_lang === 'zh' ? '关闭' : 'Close'); modalClose.onclick = () => this.closeWorkspace(); this.recipeContainer.appendChild(modalClose); this.nbPanel.appendChild(this.recipeContainer); } this.recipeContainer.style.display = 'flex'; if (typeof this.recipeModelReturn === 'function') { const returnToRecipe = this.recipeModelReturn; this.recipeModelReturn = null; returnToRecipe(); return; } this.recipeDetailFinish?.('closed'); if (this.notebookBody) this.notebookBody.style.display = 'none'; if (this.materialView) this.materialView.style.display = 'none'; this.notebookNotesTab?.classList.remove('active'); this.notebookRecipesTab?.classList.add('active'); if (this.recipeDetailView) { this.recipeDetailView.remove(); this.recipeDetailView = null; if (this.recipeListContainer) this.recipeListContainer.style.display = ''; const topbar = this.recipeView?.querySelector('.anomalous-recipe-topbar'); if (topbar) topbar.style.display = ''; } if (this.recipeView) { this.recipeView.style.display = 'flex'; if (!this.recipeDetailView) { if (this.recipeListContainer) this.recipeListContainer.style.display = ''; const topbar = this.recipeView?.querySelector('.anomalous-recipe-topbar'); if (topbar) topbar.style.display = ''; const isDismissed = (() => { try { return localStorage.getItem('amb_hide_recipe_drag_hint') === 'true'; } catch (_) { return false; } })(); if (this.recipeHintStrip && (this.recipeRecords || []).length > 0 && !isDismissed) { this.recipeHintStrip.style.display = 'flex'; } this.recipeReturnState = null; delete this.recipeDetailPayload; } } if (this.recipesInitialized) { await this.refreshRecipes(); return; } ensureRecipeGuideStyles(); this.recipesInitialized = true; this.recipeSelectedTags = this.recipeSelectedTags || new Set(); this.recipeSearchQuery = this.recipeSearchQuery || ''; this.recipeScopeFilter = this.recipeScopeFilter || 'all'; this.recipeViewMode = this.recipeViewMode || 'grid'; this.recipeView = document.createElement('div'); this.recipeView.className = 'anomalous-recipe-body'; this.recipeView.appendChild(buildRecipeStudioTopbar(this)); this.recipeHintStrip = document.createElement('div'); this.recipeHintStrip.className = 'anomalous-recipe-actionbar anomalous-recipe-drag-hint-strip'; this.recipeHintStrip.style.display = 'none'; const hintContent = document.createElement('div'); hintContent.className = 'anomalous-recipe-drag-hint-content'; hintContent.innerHTML = `💡${t('recipeDragGlobalHint') || '提示:按住卡片直接拖拽到 ComfyUI 画布空白处释放,即可立即载入工作流'}`; this.recipeHintStrip.appendChild(hintContent); const hintClose = document.createElement('button'); hintClose.type = 'button'; hintClose.className = 'anomalous-recipe-drag-hint-close'; hintClose.innerHTML = '×'; hintClose.title = t('close') || (window.anomalous_browser_lang === 'zh' ? '关闭提示' : 'Dismiss tip'); hintClose.onclick = (e) => { e.stopPropagation(); this.recipeHintStrip.style.display = 'none'; try { localStorage.setItem('amb_hide_recipe_drag_hint', 'true'); } catch (_) {} }; this.recipeHintStrip.appendChild(hintClose); this.recipeView.appendChild(this.recipeHintStrip); this.recipeListContainer = document.createElement('div'); this.recipeListContainer.className = `anomalous-recipe-list ${this.recipeViewMode === 'list' ? 'is-list' : 'is-grid'}`; this.recipeView.appendChild(this.recipeListContainer); this.recipeContainer.appendChild(this.recipeView); await this.refreshRecipes(); } export async function resolveCatalogRecipeReadiness(owner, recipes) { if (!owner || !Array.isArray(recipes) || !recipes.length) return; const refMap = new Map(); for (const item of recipes) { const refs = item?.data?.params?.model_references; if (!Array.isArray(refs)) continue; for (const ref of refs) { if (ref?.currentAvailability) continue; const val = ref?.saved_value; if (typeof val === 'string' && val.trim()) { const key = `${ref.node_id}:${ref.widget_index}:${val}`; if (!refMap.has(key)) { refMap.set(key, { node_id: ref.node_id, widget_index: ref.widget_index, saved_value: val, category: ref.category, }); } } } } if (!refMap.size) return; try { const references = [...refMap.values()].slice(0, 128); const response = await fetch('/anomalous/refresh_recipe_identity', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ references }), }); if (!response.ok) return; const payload = await response.json(); if (payload.status !== 'success' || !Array.isArray(payload.results)) return; const resultMap = new Map(payload.results.map((r) => [ `${r.node_id}:${r.widget_index}:${r.saved_value}`, r.availability, ])); let updated = false; for (const item of recipes) { const refs = item?.data?.params?.model_references; if (!Array.isArray(refs)) continue; for (const ref of refs) { const key = `${ref.node_id}:${ref.widget_index}:${ref.saved_value}`; const avail = resultMap.get(key); if (avail && ref.currentAvailability !== avail) { ref.currentAvailability = avail; updated = true; } } } if (updated && owner.recipeListContainer && !owner.recipeDetailView) { owner.renderRecipeList(owner.recipeRecords || []); } } catch (err) { console.warn('Could not pre-resolve recipe readiness:', err); } } export async function refreshRecipes() { if (!this.recipeListContainer) return; try { const response = await fetch('/anomalous/recipes'); if (!response.ok) throw new Error('recipe list request failed'); const payload = await response.json(); this.recipeRecords = payload.recipes || []; updateRecipeFilterControls(this, this.recipeRecords); } catch (error) { console.error('Could not load Workflow Recipes from server:', error); this.recipeListContainer.replaceChildren(); appendText(this.recipeListContainer, 'p', t('recipeLoadError'), 'anomalous-recipe-empty'); return; } this.renderRecipeList(this.recipeRecords); void resolveCatalogRecipeReadiness(this, this.recipeRecords); }