/**
* Image workbench metadata inspector and its tab content.
*/
import { translate } from './locales.js';
import { text } from './ui_dom.js';
import {
fileBaseName,
appendLocalPreview,
modelCustomNotes,
resolveLocalModels,
lookupLocalModel,
detailedBlocksFromWorkflow,
materialNodeHeading,
renderDetailedNodeCards,
applyPromptRolesToBlocks,
mergePromptRoleOverrides,
} from './material_inspector.js';
const t = (key, params) => translate(key, params);
/**
* Build Specs Bento Grid
*/
function buildSpecsGrid(params, blocks, item, suggestedName, context) {
const grid = document.createElement('div');
grid.className = 'anomalous-workbench-specs-grid';
const samplerBlock = (blocks || []).find(block => /^(ksampler|ksampleradvanced)$/i.test(block.type || ''));
const sizeBlock = (blocks || []).find(block => /^(emptylatentimage|emptylatentimage.*)$/i.test(block.type || ''));
const addTile = (labelStr, val, options = {}) => {
if (val == null || val === '') return;
const card = document.createElement('div');
card.className = options.wide ? 'anomalous-workbench-spec-card is-wide' : 'anomalous-workbench-spec-card';
if (options.accent) card.classList.add(`is-${options.accent}`);
const headerRow = document.createElement('div');
headerRow.className = 'anomalous-workbench-spec-header';
text(headerRow, 'span', labelStr, 'anomalous-workbench-spec-label');
let copyIndicator = null;
if (options.copyable) {
card.classList.add('is-copyable');
card.setAttribute('role', 'button');
card.tabIndex = 0;
card.title = t('materialClickToCopy') || '点击复制数值';
copyIndicator = document.createElement('span');
copyIndicator.className = 'anomalous-workbench-copy-indicator';
const renderNormalCopyIcon = () => {
copyIndicator.innerHTML = `
`;
};
renderNormalCopyIcon();
card.appendChild(copyIndicator);
const handleCopy = async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(String(val));
card.classList.remove('is-copy-failed');
card.classList.add('is-copied');
if (copyIndicator) {
copyIndicator.innerHTML = `
${t('materialCopySuccess')}
`;
}
setTimeout(() => {
card.classList.remove('is-copied');
if (copyIndicator) renderNormalCopyIcon();
}, 1500);
} catch (err) {
console.warn('Clipboard copy error:', err);
card.classList.remove('is-copied');
card.classList.add('is-copy-failed');
if (copyIndicator) {
copyIndicator.innerHTML = `
${t('materialCopyFailed')}
`;
}
setTimeout(() => {
card.classList.remove('is-copy-failed');
if (copyIndicator) renderNormalCopyIcon();
}, 2000);
}
};
card.onclick = handleCopy;
card.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCopy(e);
}
};
}
card.appendChild(headerRow);
const valEl = text(card, 'div', String(val), 'anomalous-workbench-spec-val');
if (options.mono) valEl.classList.add('is-mono');
grid.appendChild(card);
};
addTile(t('materialParamSeed') || '种子 (Seed)', params.seed, { copyable: true, mono: true, accent: 'seed' });
addTile(t('recipeCardSpecsSteps') || '采样步数 (Steps)', params.steps != null ? `${params.steps} 步` : null, { });
addTile('CFG Scale', params.cfg, { });
addTile(t('materialDenoise') || '重绘降噪 (Denoise)', params.denoise, { });
addTile(t('recipeCardSpecsSampler') || '采样器 (Sampler)', params.sampler_name, { });
addTile(t('materialScheduler') || '调度器 (Scheduler)', params.scheduler, { });
addTile(t('recipeCardSpecsResolution') || '分辨率 (Resolution)', params.resolution, { wide: true, mono: true, accent: 'res' });
const generationBlocks = [samplerBlock, sizeBlock].filter(Boolean);
if (generationBlocks.length) {
const actions = document.createElement('div');
actions.className = 'anomalous-workbench-generation-save';
const infoBox = document.createElement('div');
infoBox.className = 'anomalous-workbench-generation-info';
const titleEl = document.createElement('div');
titleEl.className = 'anomalous-workbench-generation-title';
titleEl.innerHTML = `
${t('materialSaveGeneration')}
`;
infoBox.appendChild(titleEl);
text(infoBox, 'div', t('materialGenerationScope'), 'anomalous-workbench-generation-hint');
const save = document.createElement('button');
save.type = 'button';
save.className = 'anomalous-workbench-generation-btn';
save.textContent = t('materialSaveGenerationAction');
save.dataset.defaultLabel = save.textContent;
save.onclick = () => context.saveSelectedBlocks(item, suggestedName, generationBlocks, save);
actions.appendChild(infoBox);
actions.appendChild(save);
grid.appendChild(actions);
}
return grid;
}
/**
* Build Prompts Station
*/
function buildPromptsStation(blocks, fallbackPrompt, item, suggestedName, context) {
const { workbench: wb, saveSelectedBlocks, copyToClipboard } = context;
const wrap = document.createElement('div');
wrap.className = 'anomalous-workbench-prompts-wrap';
const promptBlocks = (blocks || []).filter(block =>
/cliptextencode/i.test(block.type || '') &&
(block.widgets_values || []).some(value => typeof value === 'string' && value.trim())
);
if (promptBlocks.length) {
text(wrap, 'p', t('materialPromptRoleHelp'), 'anomalous-workbench-muted');
renderDetailedNodeCards(wrap, promptBlocks, {
onSaveBlock: (block, button) => saveSelectedBlocks(item, suggestedName, [block], button),
onPromptRoleChange: async (block, selectedRole) => {
const key = String(block.node_id);
if (!wb.promptRoleOverrides || typeof wb.promptRoleOverrides !== 'object') wb.promptRoleOverrides = {};
if (selectedRole === 'auto') {
delete wb.promptRoleOverrides[key];
block.promptRole = block.promptRoleAutomatic || 'unknown';
block.promptRoleManual = false;
block.promptRoleSource = 'automatic';
} else {
wb.promptRoleOverrides[key] = { role: selectedRole, nodeType: block.type || null };
block.promptRole = selectedRole;
block.promptRoleManual = true;
block.promptRoleSource = 'manual';
}
},
});
} else if (fallbackPrompt) {
const card = document.createElement('div');
card.className = 'anomalous-workbench-prompt-card';
const topBar = document.createElement('div');
topBar.className = 'anomalous-workbench-prompt-bar';
text(topBar, 'span', t('materialPromptText') || '提示词', 'anomalous-workbench-prompt-title');
const copyBtn = text(topBar, 'button', t('materialCopyPrompt'), 'anomalous-workbench-mini-action-btn');
copyBtn.type = 'button';
copyBtn.onclick = () => copyToClipboard(fallbackPrompt, copyBtn, t('materialCopied'), t('materialCopyPrompt'));
card.appendChild(topBar);
text(card, 'div', fallbackPrompt, 'anomalous-workbench-prompt-content is-expanded');
wrap.appendChild(card);
} else {
text(wrap, 'div', t('materialNoPromptData'), 'anomalous-workbench-muted');
}
return wrap;
}
/**
* Build Models & LoRAs Section
*/
function buildModelsSection(orderedRefs, groups, onOpenModel) {
const wrap = document.createElement('div');
wrap.className = 'anomalous-workbench-models-wrap';
if (!orderedRefs || !orderedRefs.length) {
const empty = text(wrap, 'div', '(未检测到模型引用)', 'anomalous-workbench-muted');
empty.style.padding = '12px';
return wrap;
}
const renderGroup = (title, items, tagClass) => {
if (!items || !items.length) return;
const groupEl = document.createElement('div');
groupEl.className = 'anomalous-workbench-model-group';
text(groupEl, 'div', title, 'anomalous-workbench-model-group-title');
for (const item of items) {
const row = document.createElement('div');
row.className = 'anomalous-workbench-model-card';
appendLocalPreview(row, item.localModel?.preview_url, 'anomalous-workbench-model-thumb');
const info = document.createElement('div');
info.className = 'anomalous-workbench-model-info';
const rawVal = String(item.saved_value || item.name || 'Unknown');
const fileName = fileBaseName(rawVal);
const nameEl = text(info, 'span', fileName, 'anomalous-workbench-model-name');
nameEl.title = rawVal;
if (item.loraWeights) {
const badge = text(info, 'span', `Model: ${item.loraWeights.strengthModel} · CLIP: ${item.loraWeights.strengthClip}`, 'anomalous-workbench-lora-badge');
badge.title = 'LoRA Weight & CLIP Strength';
}
const notes = modelCustomNotes(item.localModel);
if (notes) text(info, 'span', notes, 'anomalous-workbench-model-notes');
row.appendChild(info);
const actions = document.createElement('div');
actions.className = 'anomalous-workbench-model-actions';
text(actions, 'span', item.category || 'model', `anomalous-workbench-model-tag ${tagClass}`);
if (item.localModel) {
const viewBtn = document.createElement('button');
viewBtn.type = 'button';
viewBtn.className = 'anomalous-workbench-mini-action-btn';
viewBtn.textContent = '🔎 定位模型';
viewBtn.onclick = (e) => {
e.stopPropagation();
onOpenModel(item.localModel);
};
actions.appendChild(viewBtn);
}
row.appendChild(actions);
groupEl.appendChild(row);
}
wrap.appendChild(groupEl);
};
renderGroup(t('materialModelBase') || '主模型 / UNet', groups.base, 'is-base');
renderGroup(t('materialModelLora') || 'LoRA 微调层', groups.lora, 'is-lora');
renderGroup(t('materialModelClip') || '文本编码器 (CLIP)', groups.clip, 'is-clip');
renderGroup(t('materialModelVae') || 'VAE 编码器', groups.vae, 'is-vae');
renderGroup(t('materialModelOther') || '其它模型组件', groups.other, 'is-other');
return wrap;
}
/**
* Build Workflow Nodes Section
*/
function buildWorkflowNodesSection(blocks, clientWorkflow, item, suggestedName, context) {
const { workbench: wb, saveSelectedBlocks, copyToClipboard } = context;
const wrap = document.createElement('div');
wrap.className = 'anomalous-workbench-nodes-wrap';
if (!blocks || !blocks.length) {
text(wrap, 'div', '(无底层节点参数数据)', 'anomalous-workbench-muted');
return wrap;
}
// Node count summary chips
const typeCounts = {};
for (const b of blocks) {
const tName = materialNodeHeading(b) || 'Node';
typeCounts[tName] = (typeCounts[tName] || 0) + 1;
}
const chipFlow = document.createElement('div');
chipFlow.className = 'anomalous-material-chip-flow';
for (const [nTitle, cnt] of Object.entries(typeCounts)) {
const chip = document.createElement('span');
chip.className = 'anomalous-material-chip';
text(chip, 'span', nTitle);
if (cnt > 1) text(chip, 'span', `×${cnt}`, 'anomalous-material-chip-count');
chipFlow.appendChild(chip);
}
wrap.appendChild(chipFlow);
const selectionBar = document.createElement('div');
selectionBar.className = 'anomalous-workbench-node-selection-bar';
const selectionText = text(selectionBar, 'span', '', 'anomalous-workbench-node-selection-count');
const selectAllBtn = text(selectionBar, 'button', t('materialSelectAllNodes'), 'anomalous-workbench-mini-action-btn');
selectAllBtn.type = 'button';
const clearBtn = text(selectionBar, 'button', t('materialClearNodeSelection'), 'anomalous-workbench-mini-action-btn');
clearBtn.type = 'button';
const saveSelectedBtn = text(selectionBar, 'button', t('materialSaveSelectedAction', { count: 0 }), 'anomalous-workbench-save-selected-btn');
saveSelectedBtn.type = 'button';
saveSelectedBtn.disabled = true;
wrap.appendChild(selectionBar);
const updateSelection = () => {
const count = wb?.selectedNodeIds?.size || 0;
selectionText.textContent = t('materialSelectedNodeCount', { count });
saveSelectedBtn.textContent = t('materialSaveSelectedAction', { count });
saveSelectedBtn.disabled = count === 0;
};
// Copy full workflow JSON button
const jsonActionRow = document.createElement('div');
jsonActionRow.className = 'anomalous-workbench-action-row';
const copyJsonBtn = document.createElement('button');
copyJsonBtn.type = 'button';
copyJsonBtn.className = 'anomalous-workbench-mini-action-btn';
copyJsonBtn.textContent = `📋 ${t('workbenchCopyWorkflowJson') || '复制完整工作流 JSON'}`;
copyJsonBtn.onclick = () => {
if (clientWorkflow) {
copyToClipboard(JSON.stringify(clientWorkflow, null, 2), copyJsonBtn, '✅ 已复制工作流 JSON', `📋 ${t('workbenchCopyWorkflowJson') || '复制完整工作流 JSON'}`);
}
};
jsonActionRow.appendChild(copyJsonBtn);
wrap.appendChild(jsonActionRow);
// Detailed node cards
const detailedContainer = document.createElement('div');
detailedContainer.className = 'anomalous-workbench-node-list-box';
renderDetailedNodeCards(detailedContainer, blocks, {
selectable: true,
selectedIds: wb?.selectedNodeIds,
onSelectionChange: (block, checked) => {
const key = String(block.node_id);
if (checked) wb?.selectedNodeIds?.add(key);
else wb?.selectedNodeIds?.delete(key);
updateSelection();
},
onSaveBlock: (block, button) => saveSelectedBlocks(item, suggestedName, [block], button),
});
wrap.appendChild(detailedContainer);
const setAllSelections = checked => {
if (!wb?.selectedNodeIds) return;
wb.selectedNodeIds.clear();
if (checked) blocks.forEach(block => wb.selectedNodeIds.add(String(block.node_id)));
detailedContainer.querySelectorAll('.anomalous-material-node-select').forEach(input => {
input.checked = checked;
input.closest('.anomalous-material-node-detail')?.classList.toggle('is-selected', checked);
});
updateSelection();
};
selectAllBtn.onclick = () => setAllSelections(true);
clearBtn.onclick = () => setAllSelections(false);
saveSelectedBtn.onclick = () => {
const selected = blocks.filter(block => wb?.selectedNodeIds?.has(String(block.node_id)));
return saveSelectedBlocks(item, suggestedName, selected, saveSelectedBtn);
};
updateSelection();
return wrap;
}
/**
* Render the Inspector Panel Content
*/
export async function renderImageInspectorContent(context, data, item) {
const {
workbench: wb,
loadWorkflowToComfyCanvas,
openMaterialLocalModel,
renderSaveSnapshotFooter,
} = context;
if (!wb || !wb.sideBodyEl) return;
const { inspectPayload, clientWorkflow, clientDetails, params } = data;
wb.sideBodyEl.replaceChildren();
// Group models
const allModelRefs = Array.isArray(inspectPayload.model_references) ? [...inspectPayload.model_references] : [];
const addIfNotExists = (refItem) => {
const clean = (val) => String(val || '').replace(/\\/g, '/').split('/').pop().toLowerCase();
const target = clean(refItem.saved_value || refItem.name);
if (!target) return;
if (!allModelRefs.some(m => clean(m.saved_value || m.name) === target)) {
allModelRefs.push(refItem);
}
};
if (clientDetails.discoveredModels) {
for (const c of clientDetails.discoveredModels.checkpoints) addIfNotExists({ saved_value: c.name, category: c.category });
for (const l of clientDetails.discoveredModels.loras) addIfNotExists({ saved_value: l.name, category: 'lora' });
for (const tModel of clientDetails.discoveredModels.textEncoders) addIfNotExists({ saved_value: tModel.name, category: 'text_encoder' });
for (const v of clientDetails.discoveredModels.vaes) addIfNotExists({ saved_value: v.name, category: 'vae' });
}
const groups = { base: [], lora: [], clip: [], vae: [], other: [] };
for (const ref of allModelRefs) {
const cat = String(ref.category || '').toLowerCase();
const val = String(ref.saved_value || ref.name || '').toLowerCase();
const rawVal = String(ref.saved_value || ref.name || '');
const fileName = fileBaseName(rawVal);
ref.loraWeights = clientDetails.loraDetailsMap?.get(rawVal) || clientDetails.loraDetailsMap?.get(fileName);
if (cat === 'checkpoint' || cat === 'unet' || val.includes('checkpoint') || val.includes('unet')) {
groups.base.push(ref);
} else if (cat === 'lora' || val.includes('lora')) {
groups.lora.push(ref);
} else if (cat === 'text_encoder' || cat === 'clip' || val.includes('clip') || val.includes('t5')) {
groups.clip.push(ref);
} else if (cat === 'vae' || val.includes('vae')) {
groups.vae.push(ref);
} else {
groups.other.push(ref);
}
}
const orderedRefs = [...groups.base, ...groups.lora, ...groups.clip, ...groups.vae, ...groups.other];
const applyResolvedModels = localModels => {
for (const reference of orderedRefs) {
reference.localModel = lookupLocalModel(localModels, reference.saved_value || reference.name);
}
};
if (data._localModels) applyResolvedModels(data._localModels);
const recipe = wb?.owner?.recipeDetailPayload?.recipe;
const recipeOverrides = recipe?.params?.promptRoleOverrides || recipe?.data?.params?.promptRoleOverrides || {};
const promptSourceKey = `${item.subfolder || ''}/${item.filename || item.url || ''}`;
if (wb.promptRoleSourceKey !== promptSourceKey) {
wb.promptRoleSourceKey = promptSourceKey;
wb.promptRoleOverrides = JSON.parse(JSON.stringify(recipeOverrides));
}
const promptRoles = mergePromptRoleOverrides(
inspectPayload.prompt_roles,
wb.promptRoleOverrides,
);
const blocks = applyPromptRolesToBlocks(
detailedBlocksFromWorkflow(clientWorkflow, inspectPayload.node_blocks),
promptRoles,
);
const fallbackPrompt = (Array.isArray(inspectPayload.prompts) && inspectPayload.prompts.length)
? inspectPayload.prompts.join('\n\n')
: (inspectPayload.prompt_excerpt || '');
// Top Inspector Toolbar: One-click actions
const toolbar = document.createElement('div');
toolbar.className = 'anomalous-workbench-inspector-toolbar';
const loadCanvasBtn = document.createElement('button');
loadCanvasBtn.type = 'button';
loadCanvasBtn.className = 'anomalous-workbench-action-btn is-primary';
loadCanvasBtn.innerHTML = `
${t('materialOpenWorkflow')}
`;
loadCanvasBtn.title = window.anomalous_browser_lang === 'zh'
? '将这张图片中包含的完整工作流直接还原到 ComfyUI 画布'
: 'Restore the full workflow from this image directly to ComfyUI canvas';
loadCanvasBtn.onclick = () => loadWorkflowToComfyCanvas(clientWorkflow || inspectPayload.workflow);
toolbar.appendChild(loadCanvasBtn);
const saveMaterialBtn = document.createElement('button');
saveMaterialBtn.type = 'button';
saveMaterialBtn.className = 'anomalous-workbench-action-btn is-save';
const cleanSaveLabel = (t('materialSaveSnapshotShort') || '保存到素材库').replace(/^[^\w\u4e00-\u9fa5]+/, '').trim();
saveMaterialBtn.innerHTML = `
${cleanSaveLabel}
`;
saveMaterialBtn.title = t('materialSaveSnapshotFocusHint');
saveMaterialBtn.setAttribute('aria-expanded', 'false');
saveMaterialBtn.onclick = () => {
const footer = wb?.sideFooterEl;
if (!footer) return;
footer.hidden = !footer.hidden;
saveMaterialBtn.classList.toggle('is-active', !footer.hidden);
saveMaterialBtn.setAttribute('aria-expanded', String(!footer.hidden));
if (footer.hidden) return;
const nameInput = wb?.sideFooterEl?.querySelector('input[type="text"]');
wb?.sideFooterEl?.scrollIntoView?.({ behavior: 'smooth', block: 'end' });
nameInput?.focus();
nameInput?.select();
};
toolbar.appendChild(saveMaterialBtn);
wb.sideBodyEl.appendChild(toolbar);
// Segmented Tabs
const tabsBar = document.createElement('div');
tabsBar.className = 'anomalous-workbench-tabs-bar';
const tabs = [
{ id: 'specs', label: t('workbenchTabOverview') || '核心参数', icon: '' },
{ id: 'prompts', label: t('workbenchTabPrompts') || '提示词', icon: '' },
{ id: 'models', label: t('workbenchTabModels') || '模型与LoRA', icon: '' },
{ id: 'nodes', label: t('workbenchTabNodes') || '工作流节点', icon: '' },
];
const tabPanels = {};
let ensureModelsResolved = () => {};
tabs.forEach(tDef => {
const tabBtn = document.createElement('button');
tabBtn.type = 'button';
tabBtn.className = (wb.activeTab === tDef.id)
? 'anomalous-workbench-tab-btn is-active'
: 'anomalous-workbench-tab-btn';
tabBtn.innerHTML = `${tDef.icon}${tDef.label}`;
tabBtn.onclick = () => {
wb.activeTab = tDef.id;
tabsBar.querySelectorAll('.anomalous-workbench-tab-btn').forEach(b => b.classList.remove('is-active'));
tabBtn.classList.add('is-active');
Object.values(tabPanels).forEach(p => p.style.display = 'none');
if (tabPanels[tDef.id]) tabPanels[tDef.id].style.display = 'flex';
if (tDef.id === 'models') ensureModelsResolved();
};
tabsBar.appendChild(tabBtn);
});
wb.sideBodyEl.appendChild(tabsBar);
// Panel 1: Specs Grid
const specsPanel = document.createElement('div');
specsPanel.className = 'anomalous-workbench-tab-panel';
specsPanel.style.display = wb.activeTab === 'specs' ? 'flex' : 'none';
specsPanel.appendChild(buildSpecsGrid(params, blocks, item, inspectPayload.suggested_name, context));
tabPanels.specs = specsPanel;
wb.sideBodyEl.appendChild(specsPanel);
// Panel 2: Prompts Station
const promptsPanel = document.createElement('div');
promptsPanel.className = 'anomalous-workbench-tab-panel';
promptsPanel.style.display = wb.activeTab === 'prompts' ? 'flex' : 'none';
promptsPanel.appendChild(buildPromptsStation(blocks, fallbackPrompt, item, inspectPayload.suggested_name, context));
tabPanels.prompts = promptsPanel;
wb.sideBodyEl.appendChild(promptsPanel);
// Panel 3: Models & LoRAs
const modelsPanel = document.createElement('div');
modelsPanel.className = 'anomalous-workbench-tab-panel';
modelsPanel.style.display = wb.activeTab === 'models' ? 'flex' : 'none';
modelsPanel.appendChild(buildModelsSection(orderedRefs, groups, (m) => openMaterialLocalModel(m)));
tabPanels.models = modelsPanel;
wb.sideBodyEl.appendChild(modelsPanel);
ensureModelsResolved = () => {
if (data._localModels) return;
if (!data._localModelsPromise) {
data._localModelsPromise = resolveLocalModels(orderedRefs.map(reference => reference.saved_value || reference.name))
.then(localModels => {
data._localModels = localModels;
return localModels;
})
.catch(() => ({}));
}
data._localModelsPromise.then(localModels => {
if (!modelsPanel.isConnected) return;
applyResolvedModels(localModels);
modelsPanel.replaceChildren(buildModelsSection(orderedRefs, groups, model => openMaterialLocalModel(model)));
});
};
if (wb.activeTab === 'models') ensureModelsResolved();
// Panel 4: Nodes Section
const nodesPanel = document.createElement('div');
nodesPanel.className = 'anomalous-workbench-tab-panel';
nodesPanel.style.display = wb.activeTab === 'nodes' ? 'flex' : 'none';
nodesPanel.appendChild(buildWorkflowNodesSection(blocks, clientWorkflow, item, inspectPayload.suggested_name, context));
tabPanels.nodes = nodesPanel;
wb.sideBodyEl.appendChild(nodesPanel);
// Bottom Snapshot Drawer in Footer
renderSaveSnapshotFooter(inspectPayload, item);
}