Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_node_assistant.js
T
DemonGatanjieuandClaude Opus 5.5 2358649f03 feat(doctor): Model Doctor checks each opened workflow and says what to do
A bar over the canvas appears when an opened workflow uses models this
computer does not have: how many, and how many are here under another
name or folder (same hash), with one press to put those in. The doctor
page is redone on the scan page's layout: what needs you first, why in
plain words, and a button per row (put in, use a same-size file, pick
one, look it up on Civitai); models that are fine fold away.

- model_check.js: the check (no DOM, changes nothing) and the fixes,
  which call the node's widget hooks so ComfyUI drops its red mark;
  the node picker does the same now.
- Opening the doctor no longer rewrites the workflow by itself.
- Removed: the auto-detect switch (nothing read it), the doctor's own
  deep hash scan and its routes (the resolver hashes same-size files on
  demand; the page points to the scan page), the hash dialog, the old
  resolver in hash_resolver.js, the unused pick-for-node path in
  ui_detail.js, 45 old strings.
- Node Assistant's diagnoseNode and LoRA insertion moved to
  ui_node_assistant.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:36:41 +08:00

515 lines
30 KiB
JavaScript

/** Node Assistant panel: the selected node's actions (change its model, insert a LoRA),
* model history cards and parameter presets. */
import { app } from "../../../scripts/app.js";
import { translate } from "./locales.js";
import { analyzeModelChainInsertion, getModelChainInsertionCapabilities } from "./graph_splice.js";
import { formatModelTypeLabel, inferPickerModelType } from "./model_picker.js";
import { escapeHtml } from "./safe_dom.js";
import { findModelComboWidget, getNativeWidgetValues } from "./ui_node_model_picker.js";
import { renderParameterPresets } from "./ui_node_presets.js";
const t = (key, params) => translate(key, params);
export function initAssistantPanel() {
this.assistantPanelInitialized = true;
this.assistantPanel.innerHTML = '';
this.assistantPanel.style.padding = '0';
this.assistantPanel.style.overflow = 'hidden';
if (!this._assistantPanelHooked) {
this._assistantPanelHooked = true;
const self = this;
const originalOnSelected = app.canvas.onNodeSelected;
app.canvas.onNodeSelected = function (node) {
if (originalOnSelected) originalOnSelected.apply(this, arguments);
if (self.assistantPanel && self.assistantPanel.style.display !== 'none') {
self.diagnoseNode(node);
}
};
const originalOnDeselected = app.canvas.onNodeDeselected;
app.canvas.onNodeDeselected = function (node) {
if (originalOnDeselected) originalOnDeselected.apply(this, arguments);
if (self.assistantPanel && self.assistantPanel.style.display !== 'none') {
const stillSelected = Object.values(app.canvas.selected_nodes || {});
if (stillSelected.length > 0) self.diagnoseNode(stillSelected[0]);
else self.diagnoseNode(null);
}
};
}
const placeholder = document.createElement('div');
placeholder.id = 'anomalous-assistant-placeholder';
placeholder.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;color:#666;font-size:15px;gap:12px;padding:40px;text-align:center;';
placeholder.innerHTML = `<div style="font-size:48px;">🤖</div><div>${t('assistantSelectNode')}</div>`;
this.assistantPanel.appendChild(placeholder);
const nodeContent = document.createElement('div');
nodeContent.id = 'anomalous-assistant-node-content';
nodeContent.style.cssText = 'display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden;';
this.assistantPanel.appendChild(nodeContent);
}
export function renderAssistantModelCard(node, w, container) {
const val = w.value;
const filename = val.split(/[\\/]/).pop();
const pickerType = inferPickerModelType(node, w);
const wrapper = document.createElement('div');
wrapper.style.cssText = 'margin:12px 16px 16px;padding:10px;border:1px solid rgba(255,255,255,0.075);border-radius:14px;background:linear-gradient(160deg,rgba(31,33,42,0.96),rgba(20,21,27,0.96));display:flex;flex-direction:column;gap:12px;box-shadow:0 16px 35px rgba(0,0,0,0.2);';
// Preview image
const previewBox = document.createElement('div');
previewBox.style.cssText = 'width:100%;aspect-ratio:1.65;max-height:260px;background:radial-gradient(circle at 50% 20%,#252a3b,#0b0c10 70%);border-radius:10px;overflow:hidden;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid rgba(255,255,255,0.05);';
previewBox.innerHTML = `<span style="color:#444;font-size:13px;">${t('doctorLoadingPreview')}</span>`;
wrapper.appendChild(previewBox);
// Name and path
const identityRow = document.createElement('div');
identityRow.style.cssText = 'display:flex;align-items:flex-start;gap:10px;padding:0 3px;';
const identityCopy = document.createElement('div');
identityCopy.style.cssText = 'display:flex;flex-direction:column;gap:4px;min-width:0;flex:1;';
const nameEl = document.createElement('div');
nameEl.style.cssText = 'color:#fff;font-weight:750;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
nameEl.title = filename;
nameEl.innerText = filename;
const pathEl = document.createElement('div');
pathEl.style.cssText = 'color:#646b7a;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
pathEl.title = val;
pathEl.innerText = val;
const modelTypeBadge = document.createElement('span');
modelTypeBadge.textContent = pickerType.label;
modelTypeBadge.style.cssText = 'padding:5px 8px;border-radius:999px;background:rgba(138,180,248,0.1);border:1px solid rgba(138,180,248,0.22);color:#a9c7ff;font-size:9px;font-weight:750;white-space:nowrap;';
identityCopy.append(nameEl, pathEl);
identityRow.append(identityCopy, modelTypeBadge);
wrapper.appendChild(identityRow);
// Metadata zone (populated async)
const metaZone = document.createElement('div');
metaZone.style.cssText = 'display:flex;flex-direction:column;gap:10px;';
wrapper.appendChild(metaZone);
// Secondary action; model replacement lives in the prominent node toolbar.
const actionRow = document.createElement('div');
actionRow.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;';
const profileBtn = document.createElement('button');
profileBtn.textContent = t('doctorProfile');
profileBtn.style.cssText = 'flex:1;padding:10px 12px;background:rgba(138,180,248,0.09);color:#a9c7ff;border:1px solid rgba(138,180,248,0.22);border-radius:9px;cursor:pointer;font-size:12px;font-weight:700;transition:filter 0.2s;';
profileBtn.onmouseover = () => profileBtn.style.filter = 'brightness(1.2)';
profileBtn.onmouseout = () => profileBtn.style.filter = 'brightness(1)';
actionRow.appendChild(profileBtn);
wrapper.appendChild(actionRow);
container.appendChild(wrapper);
// Async: load preview + metadata
fetch(`/anomalous/find_model?search=${encodeURIComponent(val.replace(/\\/g, '/'))}`)
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(d => {
// Preview
if (d.status === 'success' && d.model && d.model.preview_url) {
previewBox.innerHTML = '';
const pu = d.model.preview_url;
const isVid = /\.(mp4|webm)(?:$|\?|&|#)/i.test(pu);
if (isVid) {
const vid = document.createElement('video');
vid.src = pu; vid.muted = true; vid.loop = true; vid.autoplay = true; vid.playsInline = true;
vid.style.cssText = 'width:100%;height:100%;object-fit:contain;';
previewBox.appendChild(vid);
} else {
const img = document.createElement('img');
img.src = pu;
img.style.cssText = 'width:100%;height:100%;object-fit:contain;';
previewBox.appendChild(img);
}
} else {
previewBox.innerHTML = `<span style="color:#444;font-size:13px;">${t('doctorNoPreview')}</span>`;
}
// Profile button links to detail
if (d.status === 'success' && d.model) {
profileBtn.onclick = () => {
this.assistantPanel.style.display = 'none';
this.currentType = d.type;
this.currentPathIdx = d.path_idx;
this.currentSubfolder = d.subfolder;
this.historyStack = this.historyStack || [];
this.historyStack.push({ type: 'assistant' });
this.showDetail(d.model);
if (this.foldersData) this.renderSidebar();
};
}
// Metadata
if (d.status === 'success' && d.model && d.model.metadata) {
const meta = d.model.metadata;
// Type + base model badges
if (d.type) modelTypeBadge.textContent = formatModelTypeLabel(d.type, pickerType.label);
if (meta.baseModel) {
const badgeRow = document.createElement('div');
badgeRow.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;';
const b = document.createElement('span');
b.style.cssText = 'background:rgba(0,255,204,0.08);border:1px solid rgba(0,255,204,0.15);color:#77e6cf;padding:4px 8px;border-radius:999px;font-size:10px;';
b.textContent = `${t('doctorBase')} · ${meta.baseModel}`;
badgeRow.appendChild(b);
metaZone.appendChild(badgeRow);
}
// Trigger words
const triggers = meta.trainedWords || meta.trigger_words || meta.trained_words;
if (triggers && triggers.length > 0) {
const trigSection = document.createElement('div');
trigSection.style.cssText = 'background:rgba(255,255,255,0.04);border-radius:6px;padding:10px 12px;';
const trigTitle = document.createElement('div');
trigTitle.style.cssText = 'color:#aaa;font-size:11px;margin-bottom:8px;font-weight:bold;text-transform:uppercase;letter-spacing:0.5px;';
trigTitle.textContent = t('doctorTriggerWords');
const tagList = document.createElement('div');
tagList.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px;';
const words = Array.isArray(triggers) ? triggers : [triggers];
words.forEach(word => {
const tag = document.createElement('span');
tag.style.cssText = 'background:rgba(255,193,7,0.12);color:#ffc107;padding:3px 8px;border-radius:4px;font-size:12px;cursor:pointer;';
tag.innerText = word;
tag.title = t('doctorClickCopy');
tag.onclick = () => {
navigator.clipboard.writeText(word).then(() => { const o = tag.innerText; tag.innerText = '✅'; setTimeout(() => tag.innerText = o, 1000); });
};
tagList.appendChild(tag);
});
const copyAll = document.createElement('button');
copyAll.style.cssText = 'background:transparent;border:1px solid #444;color:#888;border-radius:4px;padding:3px 8px;font-size:11px;cursor:pointer;margin-top:4px;';
copyAll.textContent = t('doctorCopyAll');
copyAll.onclick = () => {
navigator.clipboard.writeText(words.join(', ')).then(() => { copyAll.textContent = '✅'; setTimeout(() => copyAll.textContent = t('doctorCopyAll'), 1500); });
};
trigSection.appendChild(trigTitle);
trigSection.appendChild(tagList);
trigSection.appendChild(copyAll);
metaZone.appendChild(trigSection);
}
// Custom notes (parchment)
const textNotes = meta.custom_notes || meta.notes;
if (textNotes) {
const notesCard = document.createElement('div');
notesCard.style.cssText = 'background:linear-gradient(135deg,#262522 0%,#202124 100%);border:1px solid #3c4043;border-left:4px solid #a38d53;border-radius:4px 8px 8px 4px;padding:12px 14px;';
const notesTitle = document.createElement('div');
notesTitle.style.cssText = 'color:#a38d53;font-size:11px;font-weight:bold;margin-bottom:6px;';
notesTitle.textContent = t('doctorNotes');
const notesText = document.createElement('div');
notesText.style.cssText = 'color:#d4c4a0;font-size:13px;line-height:1.6;white-space:pre-wrap;';
notesText.innerText = textNotes;
notesCard.appendChild(notesTitle);
notesCard.appendChild(notesText);
metaZone.appendChild(notesCard);
}
}
// History gallery — always load if we can resolve the filename
const resolvedFilename = (d.status === 'success' && d.model) ? (d.model.filename || filename) : filename;
this._loadAssistantHistory(resolvedFilename, metaZone, d.status === 'success' ? d.model : null);
}).catch(() => {
previewBox.innerHTML = `<span style="color:#444;font-size:13px;">${t('assistantPreviewFailed')}</span>`;
// Still try to load history gallery by filename
this._loadAssistantHistory(filename, metaZone, null);
});
}
export function _loadAssistantHistory(filename, container, model) {
fetch('/anomalous/model_images?model_name=' + encodeURIComponent(filename) + '&t=' + Date.now())
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(data => {
const images = data.images || [];
if (images.length === 0) return;
const section = document.createElement('div');
section.style.cssText = 'display:flex;flex-direction:column;gap:8px;';
// Section header with count + full gallery button
const sectionHeader = document.createElement('div');
sectionHeader.style.cssText = 'display:flex;align-items:center;justify-content:space-between;';
const sectionTitle = document.createElement('div');
sectionTitle.style.cssText = 'color:#aaa;font-size:11px;font-weight:bold;text-transform:uppercase;letter-spacing:0.5px;';
sectionTitle.textContent = t('assistantHistoryTitle', { count: images.length });
sectionHeader.appendChild(sectionTitle);
// "View all" button if model is available
if (model) {
const viewAllBtn = document.createElement('button');
viewAllBtn.textContent = t('assistantViewAll');
viewAllBtn.style.cssText = 'background:transparent;border:1px solid rgba(255,255,255,0.2);color:#e5e7eb;font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer;transition:all 0.2s;';
viewAllBtn.onmouseover = () => { viewAllBtn.style.background = 'rgba(255,255,255,0.1)'; };
viewAllBtn.onmouseout = () => { viewAllBtn.style.background = 'transparent'; };
viewAllBtn.onclick = () => this.showGeneratedGallery(model);
sectionHeader.appendChild(viewAllBtn);
}
section.appendChild(sectionHeader);
const grid = document.createElement('div');
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(88px,1fr));gap:6px;';
images.slice(0, 16).forEach(img => {
const card = document.createElement('div');
card.style.cssText = 'border-radius:6px;overflow:hidden;aspect-ratio:1;background:#111;cursor:pointer;transition:transform 0.15s,box-shadow 0.15s;';
card.onmouseover = () => {
card.style.transform = 'scale(1.05)'; card.style.boxShadow = '0 4px 12px rgba(0,0,0,0.5)';
const v = card.querySelector('video'); if (v) v.play().catch(()=>{});
};
card.onmouseout = () => {
card.style.transform = 'scale(1)'; card.style.boxShadow = 'none';
const v = card.querySelector('video'); if (v) v.pause();
};
const imgUrl = img.url || img;
const isVid = /\.(mp4|webm)(?:$|\?|&|#)/i.test(imgUrl);
if (isVid) {
const vidEl = document.createElement('video');
vidEl.src = imgUrl; vidEl.muted = true; vidEl.loop = true; vidEl.autoplay = false; vidEl.playsInline = true; vidEl.preload = 'metadata';
vidEl.style.cssText = 'width:100%;height:100%;object-fit:cover;';
card.appendChild(vidEl);
} else {
const imgEl = document.createElement('img');
imgEl.src = imgUrl;
imgEl.style.cssText = 'width:100%;height:100%;object-fit:cover;';
imgEl.loading = 'lazy';
card.appendChild(imgEl);
}
if (img.workflow) {
card.title = t('assistantRestoreWorkflow');
card.onclick = () => {
try {
const wf = typeof img.workflow === 'string' ? JSON.parse(img.workflow) : img.workflow;
if (app && app.loadGraphData) app.loadGraphData(wf);
} catch (e) { }
};
} else if (model) {
card.title = t('assistantViewGallery');
card.onclick = () => this.showGeneratedGallery(model);
}
grid.appendChild(card);
});
section.appendChild(grid);
container.appendChild(section);
}).catch(() => { });
}
function getInsertionCapabilityMessage(capability) {
const messages = {
missing_graph_or_node: 'doctorGraphUnavailable',
missing_chain_inputs: 'doctorMissingChainInputs',
unconnected_chain_inputs: 'doctorUnconnectedChainInputs',
missing_chain_outputs: 'doctorMissingChainOutputs',
ambiguous_downstream_branches: 'doctorAmbiguousBranches',
invalid_downstream_link: 'doctorInvalidDownstream',
};
return t(messages[capability?.code] || 'doctorUnsupportedInsertion');
}
export function openLoraInsertionPicker(anchorNode, direction) {
const analysis = analyzeModelChainInsertion(app.graph, anchorNode, direction);
if (!analysis.supported) {
alert(getInsertionCapabilityMessage(analysis));
return;
}
const insertedNode = typeof LiteGraph !== 'undefined' ? LiteGraph.createNode('LoraLoader') : null;
if (!insertedNode) {
alert(t('doctorCreateLoraFailed'));
return;
}
const modelWidget = findModelComboWidget(insertedNode);
if (!modelWidget || getNativeWidgetValues(insertedNode, modelWidget).length === 0) {
alert(t('doctorLoraNotReady'));
return;
}
this._openGalleryReplacer(insertedNode, modelWidget, {
mode: 'insert',
direction,
anchorNode,
analysis,
modelTypeLabel: 'LoRA',
});
}
export function diagnoseNode(node, forceRefresh = false) {
// This method serves the Node Assistant panel only
if (!this.assistantPanelInitialized) {
this.initAssistantPanel();
}
const placeholder = document.getElementById('anomalous-assistant-placeholder');
const nodeContent = document.getElementById('anomalous-assistant-node-content');
if (!placeholder || !nodeContent || !app.graph || !app.graph._nodes) return;
if (!node) {
placeholder.innerHTML = `<div style="font-size:48px;">🤖</div><div style="text-align:center;">${t('assistantSelectNode')}</div>`;
placeholder.style.display = 'flex';
nodeContent.style.display = 'none';
nodeContent.innerHTML = '';
return;
}
const modelWidgets = [];
if (node.widgets) {
for (const w of node.widgets) {
if (w.type === 'combo' && typeof w.value === 'string') {
if (w.value.match(/\.(safetensors|ckpt|pt|bin|pth|sft)$/i)) modelWidgets.push(w);
}
}
}
const insertionCapabilities = getModelChainInsertionCapabilities(app.graph, node);
const canInsert = insertionCapabilities.before.supported || insertionCapabilities.after.supported;
const hasModelOrInsertion = modelWidgets.length > 0 || canInsert;
placeholder.style.display = 'none';
nodeContent.style.display = 'flex';
nodeContent.innerHTML = '';
const titleBar = document.createElement('div');
titleBar.style.cssText = 'margin:14px 16px 0;padding:16px;border:1px solid rgba(255,255,255,0.1);border-radius:12px;background:rgba(255,255,255,0.04);display:flex;align-items:center;gap:12px;flex-shrink:0;box-shadow:0 8px 24px rgba(0,0,0,0.25);';
titleBar.innerHTML = `<span style="width:38px;height:38px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:20px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);">🤖</span><span style="display:flex;flex-direction:column;min-width:0;gap:3px;"><span style="font-size:10px;letter-spacing:0.11em;text-transform:uppercase;color:#9ca3af;">${t('assistantSelectedNode')}</span><span class="ast-title" style="font-weight:700;color:#f3f4f6;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></span></span><span class="ast-type" style="font-size:10px;color:#d1d5db;margin-left:auto;padding:4px 8px;border-radius:999px;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.06);max-width:38%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></span>`;
titleBar.querySelector('.ast-title').textContent = node.title || node.type || 'Node';
titleBar.querySelector('.ast-type').textContent = node.type || '';
const refreshBtn = document.createElement('button');
refreshBtn.title = t('refresh') || 'Refresh';
refreshBtn.innerHTML = '🔄';
refreshBtn.style.cssText = 'background:none; border:none; color:#c9d6ff; cursor:pointer; font-size:14px; padding:4px; margin-left:4px; border-radius:4px; transition:background 0.2s, transform 0.3s; display:flex; align-items:center; justify-content:center;';
refreshBtn.onmouseover = () => refreshBtn.style.background = 'rgba(255,255,255,0.1)';
refreshBtn.onmouseout = () => refreshBtn.style.background = 'none';
refreshBtn.onclick = () => {
refreshBtn.style.transform = 'rotate(180deg)';
setTimeout(() => this.diagnoseNode(node, true), 150);
};
titleBar.appendChild(refreshBtn);
nodeContent.appendChild(titleBar);
const quickActions = document.createElement('div');
quickActions.style.cssText = 'padding:14px 16px 4px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;flex-shrink:0;';
const actionsLabel = document.createElement('div');
actionsLabel.textContent = t('assistantQuickActions');
actionsLabel.style.cssText = 'grid-column:1/-1;color:#8b91a3;font-size:10px;font-weight:750;letter-spacing:0.1em;text-transform:uppercase;padding:0 2px 2px;';
quickActions.appendChild(actionsLabel);
const makeActionButton = ({ icon, label, hint, accent, onClick, capability = null, primary = false }) => {
const button = document.createElement('button');
const enabled = !capability || capability.supported;
button.disabled = !enabled;
const gridPlacement = primary ? 'grid-column:1/-1;' : '';
button.style.cssText = gridPlacement + (enabled
? `min-width:0;padding:${primary ? '13px 14px' : '11px 10px'};background:${accent};color:#fff;border:1px solid rgba(255,255,255,0.14);border-radius:11px;cursor:pointer;text-align:left;display:flex;align-items:center;gap:10px;transition:transform 0.15s,filter 0.15s,box-shadow 0.15s;box-shadow:0 8px 18px rgba(0,0,0,0.14);`
: 'min-width:0;padding:11px 10px;background:rgba(255,255,255,0.035);color:#656b78;border:1px solid rgba(255,255,255,0.055);border-radius:11px;cursor:not-allowed;text-align:left;display:flex;align-items:center;gap:9px;');
const iconEl = document.createElement('span');
iconEl.textContent = icon;
iconEl.style.cssText = `width:${primary ? '34px' : '28px'};height:${primary ? '34px' : '28px'};border-radius:9px;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,${enabled ? '0.14' : '0.04'});font-size:${primary ? '17px' : '14px'};flex-shrink:0;`;
const copy = document.createElement('span');
copy.style.cssText = 'min-width:0;display:flex;flex-direction:column;gap:2px;';
const title = document.createElement('span');
title.textContent = label;
title.style.cssText = `font-weight:750;font-size:${primary ? '13px' : '11px'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const subtitle = document.createElement('span');
subtitle.textContent = enabled ? hint : getInsertionCapabilityMessage(capability);
subtitle.style.cssText = `font-size:9px;color:${enabled ? 'rgba(255,255,255,0.68)' : '#555b66'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
copy.append(title, subtitle);
button.append(iconEl, copy);
if (enabled) {
button.onmouseover = () => { button.style.filter = 'brightness(1.12)'; button.style.transform = 'translateY(-1px)'; };
button.onmouseout = () => { button.style.filter = 'brightness(1)'; button.style.transform = 'none'; };
button.onclick = onClick;
} else if (capability) {
button.title = getInsertionCapabilityMessage(capability);
}
quickActions.appendChild(button);
};
for (const widget of modelWidgets) {
const widgetLabel = modelWidgets.length > 1 && widget.name
? t('assistantChangeWidget', { name: widget.name })
: t('assistantChangeCurrent');
const pickerType = inferPickerModelType(node, widget);
makeActionButton({
icon: '⇄',
label: widgetLabel,
hint: t('assistantVisualPicker', { type: pickerType.label }),
accent: 'linear-gradient(135deg,rgba(245,124,0,0.96),rgba(255,82,82,0.82))',
onClick: () => this._openGalleryReplacer(node, widget),
primary: true,
});
}
makeActionButton({
icon: '←',
label: t('assistantInsertBefore'),
hint: t('assistantConnectInputs'),
accent: 'linear-gradient(135deg,rgba(25,118,210,0.9),rgba(80,110,230,0.82))',
onClick: () => this.openLoraInsertionPicker(node, 'before'),
capability: insertionCapabilities.before,
});
makeActionButton({
icon: '→',
label: t('assistantInsertAfter'),
hint: t('assistantConnectOutputs'),
accent: 'linear-gradient(135deg,rgba(0,137,123,0.92),rgba(67,160,71,0.82))',
onClick: () => this.openLoraInsertionPicker(node, 'after'),
capability: insertionCapabilities.after,
});
const tabsRow = document.createElement('div');
tabsRow.style.cssText = 'display:flex; padding: 10px 16px 0; gap: 8px; flex-shrink:0;';
const btnActions = document.createElement('button');
btnActions.textContent = t('assistantTabActions') || '🛠️ Quick Actions';
btnActions.style.cssText = 'flex:1; padding: 8px; border-radius: 8px; background: rgba(255,255,255,0.1); color: #fff; cursor: pointer; border: none; font-size: 11px; font-weight: bold; transition: background 0.2s;';
const btnPresets = document.createElement('button');
btnPresets.textContent = t('assistantTabPresets') || '📚 Parameter Presets';
btnPresets.style.cssText = 'flex:1; padding: 8px; border-radius: 8px; background: transparent; color: #aaa; cursor: pointer; border: none; font-size: 11px; font-weight: bold; transition: background 0.2s;';
tabsRow.append(btnActions, btnPresets);
nodeContent.appendChild(tabsRow);
const actionsContainer = document.createElement('div');
actionsContainer.style.cssText = 'display:flex; flex-direction:column; flex:1; overflow-y:auto; min-height:0;';
const presetsContainer = document.createElement('div');
presetsContainer.style.cssText = 'display:none; flex-direction:column; flex:1; overflow-y:auto; min-height:0;';
nodeContent.append(actionsContainer, presetsContainer);
btnActions.onclick = () => {
btnActions.style.background = 'rgba(255,255,255,0.1)';
btnActions.style.color = '#fff';
btnPresets.style.background = 'transparent';
btnPresets.style.color = '#aaa';
actionsContainer.style.display = 'flex';
presetsContainer.style.display = 'none';
};
btnPresets.onclick = () => {
btnPresets.style.background = 'rgba(255,255,255,0.1)';
btnPresets.style.color = '#fff';
btnActions.style.background = 'transparent';
btnActions.style.color = '#aaa';
presetsContainer.style.display = 'flex';
actionsContainer.style.display = 'none';
};
if (hasModelOrInsertion) {
actionsContainer.appendChild(quickActions);
for (const w of modelWidgets) {
this.renderAssistantModelCard(node, w, actionsContainer);
}
} else {
const noModelWarning = document.createElement('div');
noModelWarning.style.cssText = 'display:flex; flex-direction:column; align-items:center; justify-content:center; padding: 30px 20px;';
noModelWarning.innerHTML = `<div style="font-size:36px;margin-bottom:10px;">⚠️</div><div style="text-align:center;color:#aaa;font-size:12px;">${t('assistantNoModelParameter')}</div><div style="font-size:10px;color:#555;margin-top:6px;">${escapeHtml(node.type || '')}</div>`;
actionsContainer.appendChild(noModelWarning);
btnPresets.onclick(); // switch to presets by default if no model actions
}
renderParameterPresets.call(this, node, presetsContainer, forceRefresh);
}