Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_recipe_catalog.js
T
DemonGatanjieuandClaude Opus 5.5 875715e4a0 feat(share): short AMB2 share codes, one dialog to share and import
share_code.js: a workflow's lean form (node id, type, widget values,
position to 10 px, only what differs from a fresh node; node-pack nodes
keep their ports), links by slot, groups and the anomalous_* extras,
deflated and spelled in Chinese characters (14 bits each) or base64url.
Encoding rebuilds and compares; subgraphs, native reroutes or any
difference share the whole workflow. AMB0/AMB1 still decode. About 4x
shorter than AMB0 on ComfyUI's default workflow (657 vs 2521), 13x on
an Easy-Use/rgthree graph.

ui_share.js replaces the share code dialogs that lived in main.js: the
code is made on opening with both copy buttons and the fingerprint
notes, and pasting a code or JSON, or dropping/choosing a workflow file,
an image or a backup .zip, imports it. tests/share_code.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:22:11 +08:00

417 lines
20 KiB
JavaScript

/** 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';
import { moveWorkflowMaterials, workflowMaterialCount } from './recipe_save.js';
import { openShareDialog } from './ui_share.js';
const t = (key, params) => translate(key, params);
/**
* A strip offering to move the whole workflows kept as materials (before stars made recipes)
* to the recipes; it shows only while there are some.
*/
function buildMoveStrip(owner) {
const strip = document.createElement('div');
strip.className = 'anomalous-recipe-move-strip';
strip.hidden = true;
const copy = appendText(strip, 'span', '');
const move = appendText(strip, 'button', t('recipeMoveKeptButton'), 'anomalous-btn-primary');
move.type = 'button';
move.onclick = async () => {
move.disabled = true;
const moved = await moveWorkflowMaterials((done, total) => { move.textContent = `${done} / ${total}`; });
copy.textContent = t('recipeMovedKept', { count: moved });
move.remove();
await owner.refreshRecipes();
};
workflowMaterialCount().then((count) => {
copy.textContent = t('recipeMoveKept', { count });
strip.hidden = !count;
}).catch(() => {});
return strip;
}
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h7v7H3V3zm11 0h7v7h-7V3zm-11 11h7v7H3v-7zm11 0h7v7h-7v-7z"/></svg>';
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M3 4h18v3H3V4zm0 7h18v3H3v-3zm0 7h18v3H3v-3z"/></svg>';
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);
// Share and import (ui_share.js): share codes, workflow files, images, backups.
const shareButton = appendText(right, 'button', '⇅', 'anomalous-recipe-topbar-btn anomalous-tooltip-target');
shareButton.type = 'button';
shareButton.setAttribute('data-tooltip', t('toolWorkflowTransferTitle'));
shareButton.setAttribute('aria-label', t('toolWorkflowTransferTitle'));
shareButton.onclick = () => openShareDialog(owner);
const saveButton = appendText(right, 'button', '', 'anomalous-recipe-topbar-btn is-primary');
saveButton.dataset.recipeSaveCurrent = 'true';
saveButton.type = 'button';
saveButton.innerHTML = `<svg style="width:14px;height:14px;margin-right:6px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>${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 = `<svg style="width:14px;height:14px;margin-right:6px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg><span>${t('recipeEmptySaveCurrentBtn') || '保存当前工作流'}</span>`;
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.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 = '&times;';
modalClose.title = t('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.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 = `<span class="anomalous-recipe-drag-hint-icon">💡</span><span class="anomalous-recipe-drag-hint-text">${t('recipeDragGlobalHint') || '提示:按住卡片直接拖拽到 ComfyUI 画布空白处释放,即可立即载入工作流'}</span>`;
this.recipeHintStrip.appendChild(hintContent);
const hintClose = document.createElement('button');
hintClose.type = 'button';
hintClose.className = 'anomalous-recipe-drag-hint-close';
hintClose.innerHTML = '&times;';
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.recipeView.appendChild(buildMoveStrip(this));
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);
}