diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..501b55a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git -C \".claude/worktrees/thirsty-greider\" status)", + "Bash(git config --global --add safe.directory '%\\(prefix\\)///DESKTOP-2GIRPC5/comfy/comfy_v0.12.3/ComfyUI/custom_nodes/ComfyUI-Ultimate-Auto-Sampler-Config-Grid-Testing-Suite/.claude/worktrees/thirsty-greider')" + ] + } +} diff --git a/config_builder_node.py b/config_builder_node.py index 2475f4e..6bd478c 100644 --- a/config_builder_node.py +++ b/config_builder_node.py @@ -224,6 +224,8 @@ class UltimateConfigBuilder: config = { "session_name": "my_test_session", "include_none": False, + "global_positive_groups": [], + "global_negative": "", "config_arrays": [ { "name": "Config 1", @@ -235,7 +237,10 @@ class UltimateConfigBuilder: "loras": ["None"], "lora_omit_triggers": [], "lora_triggerwords_append_settings": {}, - "combine": True + "combine": True, + "positive_prompt_groups": [], + "negative_prompt": "", + "use_custom_prompts": False } ] } @@ -474,7 +479,11 @@ class UltimateConfigBuilder: actual_session_name = state.get("session_name", session_name) actual_include_none = state.get("include_none", include_none) config_arrays = state.get("config_arrays", []) - + + # Global prompts (used when per-config prompts are not defined) + global_positive_groups = state.get("global_positive_groups", []) + global_negative = state.get("global_negative", "") + if not config_arrays: config_arrays = [{ "name": "Config 1", @@ -486,7 +495,10 @@ class UltimateConfigBuilder: "loras": ["None"], "lora_omit_triggers": [], "lora_triggerwords_append_settings": {}, - "combine": True + "combine": True, + "positive_prompt_groups": [], + "negative_prompt": "", + "use_custom_prompts": False }] configs_output = [] @@ -522,12 +534,30 @@ class UltimateConfigBuilder: # Add omit triggers if present if omit_triggers: config["lora_omit_triggers"] = omit_triggers - + # Add trigger append settings if present if lora_triggerwords_append_settings and any(v != "none" for v in lora_triggerwords_append_settings.values()): config["lora_triggerwords_append_settings"] = lora_triggerwords_append_settings - - + + # ==== PROMPT HANDLING ==== + # Priority: per-config > global > node inputs (omitted = use node inputs) + use_custom = config_array.get("use_custom_prompts", False) + per_config_positive_groups = config_array.get("positive_prompt_groups", []) + per_config_negative = config_array.get("negative_prompt", "") + + if use_custom and per_config_positive_groups: + # Per-config prompts override everything + # Store as nested array format for parse_prompt_input_nested() compatibility + config["positive"] = per_config_positive_groups + if per_config_negative: + config["negative"] = per_config_negative + elif global_positive_groups: + # Global prompts override node inputs + config["positive"] = global_positive_groups + if global_negative: + config["negative"] = global_negative + # If neither, omit "positive"/"negative" keys - node inputs will be used as fallback + configs_output.append(config) json_output = json.dumps(configs_output, indent=2, ensure_ascii=False) diff --git a/config_utils.py b/config_utils.py index 4c34952..a380d78 100644 --- a/config_utils.py +++ b/config_utils.py @@ -260,41 +260,78 @@ def expand_lora_stack(lora_input): def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, extra_seeds, ckpt_name=None): """ Expand raw config entries into full configuration combinations. - + + Supports per-config prompts: if a config entry has "positive" and/or "negative" keys, + those override the external pos_prompts/neg_prompts for that entry. This allows each + config to define its own nested array prompts for Cartesian product expansion. + Args: raw_configs: List of config dictionaries with potentially wildcarded values - pos_prompts: List of positive prompts - neg_prompts: List of negative prompts + pos_prompts: List of positive prompts (fallback when config doesn't define prompts) + neg_prompts: List of negative prompts (fallback when config doesn't define prompts) denoise_values: List of denoise values seed: Base seed value extra_seeds: List of additional random seeds ckpt_name: Checkpoint name from node input (used as default when model is "Default") - + Returns: List of fully expanded config dictionaries """ ALL_SCHEDULERS = comfy.samplers.KSampler.SCHEDULERS ALL_SAMPLERS = comfy.samplers.KSampler.SAMPLERS expanded = [] - - # Determine prompt pairing strategy + + # Default prompt pairing strategy (used when config doesn't define its own prompts) if len(pos_prompts) > 1 and len(neg_prompts) > 1 and len(pos_prompts) == len(neg_prompts): print("[GridTester] Detected matching prompt lists. Using 1-to-1 Pairing.") - prompt_pairs = list(zip(pos_prompts, neg_prompts)) + default_prompt_pairs = list(zip(pos_prompts, neg_prompts)) else: - prompt_pairs = list(itertools.product(pos_prompts, neg_prompts)) - + default_prompt_pairs = list(itertools.product(pos_prompts, neg_prompts)) + def to_list(x): return x if isinstance(x, list) else [x] - + for entry in raw_configs: + # ==== PER-CONFIG PROMPT HANDLING ==== + # If this config defines its own prompts, parse them and use instead of defaults + if "positive" in entry or "negative" in entry: + raw_positive = entry.get("positive", "") + raw_negative = entry.get("negative", "") + + # Handle both string and already-parsed list/nested-list formats + if isinstance(raw_positive, (list, tuple)): + # Already a list/nested structure - pass as JSON string for parse_prompt_input_nested + config_pos = parse_prompt_input_nested(json.dumps(raw_positive)) + elif isinstance(raw_positive, str) and raw_positive.strip(): + config_pos = parse_prompt_input_nested(raw_positive) + else: + config_pos = pos_prompts # Fallback to external + + if isinstance(raw_negative, (list, tuple)): + config_neg = parse_prompt_input_nested(json.dumps(raw_negative)) + elif isinstance(raw_negative, str) and raw_negative.strip(): + config_neg = parse_prompt_input_nested(raw_negative) + else: + config_neg = neg_prompts # Fallback to external + + # Build prompt pairs for this config entry + if len(config_pos) > 1 and len(config_neg) > 1 and len(config_pos) == len(config_neg): + entry_prompt_pairs = list(zip(config_pos, config_neg)) + else: + entry_prompt_pairs = list(itertools.product(config_pos, config_neg)) + + if entry_prompt_pairs: + print(f"[GridTester] Config defines {len(entry_prompt_pairs)} prompt combinations (overriding node inputs)") + else: + entry_prompt_pairs = default_prompt_pairs + # Expand wildcards samplers = ALL_SAMPLERS if entry.get("sampler") == "*" else to_list(entry.get("sampler", "euler")) schedulers = ALL_SCHEDULERS if entry.get("scheduler") == "*" else to_list(entry.get("scheduler", "normal")) steps_l = to_list(entry.get("steps", 20)) cfgs = to_list(entry.get("cfg", 7.0)) clip_skips = to_list(entry.get("clip_skip", 0)) - + # Expand model folders raw_models = to_list(entry.get("model", "Default")) expanded_models = [] @@ -307,24 +344,24 @@ def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, expanded_models.append("Default") else: expanded_models.extend(get_files_from_folder(m, "checkpoints")) - + # Expand LoRA stacks (strengths are now in the lora string itself) expanded_loras = expand_lora_stack(entry.get("lora", "None")) - + # Get LoRA trigger word omissions (if specified) lora_omit_triggers = entry.get("lora_omit_triggers", []) if not isinstance(lora_omit_triggers, list): lora_omit_triggers = [lora_omit_triggers] - + # Get LoRA trigger word append settings (if specified) lora_triggerwords_append_settings = entry.get("lora_triggerwords_append_settings", {}) if not isinstance(lora_triggerwords_append_settings, dict): lora_triggerwords_append_settings = {} - + # Build all combinations base_combos = [] - for combo in itertools.product(samplers, schedulers, steps_l, cfgs, clip_skips, expanded_loras, - denoise_values, prompt_pairs, expanded_models): + for combo in itertools.product(samplers, schedulers, steps_l, cfgs, clip_skips, expanded_loras, + denoise_values, entry_prompt_pairs, expanded_models): base_combos.append({ "sampler": combo[0], "scheduler": combo[1], @@ -340,7 +377,7 @@ def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, "lora_omit_triggers": lora_omit_triggers, "lora_triggerwords_append_settings": lora_triggerwords_append_settings }) - + # Apply base seed and extra seeds for c in base_combos: expanded.append(c) @@ -348,7 +385,7 @@ def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, new_c = c.copy() new_c["seed"] = extra_seed expanded.append(new_c) - + return expanded diff --git a/js/conf_builder/conf-builder-config-management.js b/js/conf_builder/conf-builder-config-management.js index 473c0b8..89d68c8 100644 --- a/js/conf_builder/conf-builder-config-management.js +++ b/js/conf_builder/conf-builder-config-management.js @@ -9,7 +9,9 @@ import { parseLoraString, buildLoraString, getIterationCount, - convertStateToConfigs + convertStateToConfigs, + countPromptCombinations, + expandPromptPreview } from '/ultimate_config_sampler/js/conf_builder/conf-builder-utilities.js'; import { @@ -1254,6 +1256,494 @@ export async function showTriggerLookupModal(node, arrayIdx) { } } +// ============================================================================= +// --- PROMPT BUILDER COMPONENTS --- +// ============================================================================= + +/** + * Creates a reusable prompt group editor. + * Supports visual chip/tag mode and raw JSON mode. + * Shows live preview of Cartesian product combinations. + * + * @param {Array} groups - Nested array of prompt groups: [["a", "b"], ["c", "d"]] + * @param {Function} onChange - Callback when groups change: (newGroups) => void + * @param {string} label - Label for this editor (e.g. "Positive Prompt" or "Negative Prompt") + * @param {string} borderColor - CSS color for left border accent + * @returns {HTMLElement} + */ +function createPromptGroupEditor(groups, onChange, label, borderColor = "#0066cc") { + const container = document.createElement("div"); + container.style.cssText = `display: flex; flex-direction: column; gap: 8px; width: 100%;`; + + // Track mode state locally (visual vs raw) + let isRawMode = false; + + // --- HEADER --- + const header = document.createElement("div"); + header.style.cssText = "display: flex; justify-content: space-between; align-items: center;"; + + const titleEl = document.createElement("div"); + titleEl.style.cssText = `font-size: 12px; font-weight: bold; color: ${borderColor};`; + titleEl.textContent = label; + header.appendChild(titleEl); + + const modeToggleContainer = document.createElement("div"); + modeToggleContainer.style.cssText = "display: flex; gap: 4px;"; + + const visualBtn = document.createElement("button"); + visualBtn.className = "cb-prompt-mode-toggle active"; + visualBtn.textContent = "Visual"; + + const rawBtn = document.createElement("button"); + rawBtn.className = "cb-prompt-mode-toggle"; + rawBtn.textContent = "JSON"; + + modeToggleContainer.appendChild(visualBtn); + modeToggleContainer.appendChild(rawBtn); + header.appendChild(modeToggleContainer); + container.appendChild(header); + + // --- VISUAL MODE CONTAINER --- + const visualContainer = document.createElement("div"); + visualContainer.style.cssText = "display: flex; flex-direction: column; gap: 6px;"; + + // --- RAW MODE CONTAINER --- + const rawContainer = document.createElement("div"); + rawContainer.style.display = "none"; + + const rawTextarea = document.createElement("textarea"); + rawTextarea.className = "cb-prompt-raw-editor"; + rawTextarea.placeholder = '[["variation1", "variation2"], ["subject1", "subject2"]]'; + rawTextarea.value = groups.length > 0 ? JSON.stringify(groups, null, 2) : ""; + + rawTextarea.onchange = () => { + try { + const parsed = JSON.parse(rawTextarea.value); + if (Array.isArray(parsed)) { + // Normalize: ensure each item is an array + const normalized = parsed.map(item => + Array.isArray(item) ? item.map(String) : [String(item)] + ); + onChange(normalized); + rawTextarea.style.borderColor = "#00aa44"; + setTimeout(() => { rawTextarea.style.borderColor = "#3a3a3a"; }, 1000); + renderVisualGroups(normalized); + renderPreview(normalized); + } + } catch (e) { + rawTextarea.style.borderColor = "#cc3333"; + } + }; + rawContainer.appendChild(rawTextarea); + + // --- MODE TOGGLE LOGIC --- + visualBtn.onclick = () => { + isRawMode = false; + visualContainer.style.display = "flex"; + rawContainer.style.display = "none"; + visualBtn.classList.add("active"); + rawBtn.classList.remove("active"); + }; + rawBtn.onclick = () => { + isRawMode = true; + visualContainer.style.display = "none"; + rawContainer.style.display = "block"; + rawBtn.classList.add("active"); + visualBtn.classList.remove("active"); + // Sync raw editor with current groups + rawTextarea.value = groups.length > 0 ? JSON.stringify(groups, null, 2) : ""; + }; + + container.appendChild(visualContainer); + container.appendChild(rawContainer); + + // --- PREVIEW SECTION --- + const previewContainer = document.createElement("div"); + container.appendChild(previewContainer); + + function renderPreview(currentGroups) { + previewContainer.innerHTML = ""; + const validGroups = (currentGroups || []).filter(g => Array.isArray(g) && g.length > 0); + if (validGroups.length === 0) return; + + const count = countPromptCombinations(validGroups); + const previews = expandPromptPreview(validGroups, 20); + + const previewDiv = document.createElement("div"); + previewDiv.className = "cb-prompt-preview"; + + const countLabel = document.createElement("div"); + countLabel.style.cssText = "font-weight: bold; margin-bottom: 4px; color: #00cc88;"; + countLabel.textContent = `${count} combination${count !== 1 ? 's' : ''}${count > 20 ? ' (showing first 20)' : ''}:`; + previewDiv.appendChild(countLabel); + + previews.forEach(preview => { + const item = document.createElement("div"); + item.className = "cb-prompt-preview-item"; + item.textContent = preview; + previewDiv.appendChild(item); + }); + + previewContainer.appendChild(previewDiv); + } + + // --- VISUAL GROUP RENDERING --- + function renderVisualGroups(currentGroups) { + visualContainer.innerHTML = ""; + + (currentGroups || []).forEach((group, groupIdx) => { + const groupDiv = document.createElement("div"); + groupDiv.className = "cb-prompt-group"; + + // Group header + const groupHeader = document.createElement("div"); + groupHeader.className = "cb-prompt-group-header"; + + const groupLabel = document.createElement("span"); + groupLabel.textContent = `Group ${groupIdx + 1} (${group.length} variation${group.length !== 1 ? 's' : ''})`; + groupHeader.appendChild(groupLabel); + + const groupDeleteBtn = document.createElement("button"); + groupDeleteBtn.className = "cb-button danger"; + groupDeleteBtn.style.cssText = "padding: 1px 6px; font-size: 10px;"; + groupDeleteBtn.textContent = "✖"; + groupDeleteBtn.title = "Remove this group"; + groupDeleteBtn.onclick = () => { + const newGroups = [...currentGroups]; + newGroups.splice(groupIdx, 1); + onChange(newGroups); + renderVisualGroups(newGroups); + renderPreview(newGroups); + rawTextarea.value = newGroups.length > 0 ? JSON.stringify(newGroups, null, 2) : ""; + }; + groupHeader.appendChild(groupDeleteBtn); + groupDiv.appendChild(groupHeader); + + // Chips container + const chipsDiv = document.createElement("div"); + chipsDiv.className = "cb-prompt-chips"; + + group.forEach((variation, varIdx) => { + const chip = document.createElement("span"); + chip.className = "cb-prompt-chip"; + + const chipText = document.createElement("span"); + chipText.textContent = variation; + chipText.title = variation; + chip.appendChild(chipText); + + const chipClose = document.createElement("span"); + chipClose.className = "chip-close"; + chipClose.textContent = "×"; + chipClose.onclick = () => { + const newGroups = currentGroups.map(g => [...g]); + newGroups[groupIdx].splice(varIdx, 1); + // Remove group if empty + if (newGroups[groupIdx].length === 0) { + newGroups.splice(groupIdx, 1); + } + onChange(newGroups); + renderVisualGroups(newGroups); + renderPreview(newGroups); + rawTextarea.value = newGroups.length > 0 ? JSON.stringify(newGroups, null, 2) : ""; + }; + chip.appendChild(chipClose); + chipsDiv.appendChild(chip); + }); + + // Add variation input + const addVarBtn = document.createElement("button"); + addVarBtn.className = "cb-prompt-add-variation"; + addVarBtn.textContent = "+ variation"; + addVarBtn.onclick = () => { + // Replace button with inline input + const inputContainer = document.createElement("div"); + inputContainer.style.cssText = "display: inline-flex; gap: 2px;"; + + const input = document.createElement("input"); + input.className = "cb-input"; + input.style.cssText = "width: 180px; padding: 3px 6px; font-size: 12px;"; + input.placeholder = "Enter prompt text..."; + + const confirmBtn = document.createElement("button"); + confirmBtn.className = "cb-button primary"; + confirmBtn.style.cssText = "padding: 2px 8px; font-size: 11px;"; + confirmBtn.textContent = "Add"; + + const addVariation = () => { + const val = input.value.trim(); + if (val) { + const newGroups = currentGroups.map(g => [...g]); + newGroups[groupIdx].push(val); + onChange(newGroups); + renderVisualGroups(newGroups); + renderPreview(newGroups); + rawTextarea.value = newGroups.length > 0 ? JSON.stringify(newGroups, null, 2) : ""; + } + }; + + input.onkeydown = (e) => { + if (e.key === "Enter") addVariation(); + if (e.key === "Escape") { + inputContainer.replaceWith(addVarBtn); + } + }; + confirmBtn.onclick = addVariation; + + inputContainer.appendChild(input); + inputContainer.appendChild(confirmBtn); + addVarBtn.replaceWith(inputContainer); + input.focus(); + }; + chipsDiv.appendChild(addVarBtn); + + groupDiv.appendChild(chipsDiv); + visualContainer.appendChild(groupDiv); + }); + + // Add Group button + const addGroupBtn = document.createElement("button"); + addGroupBtn.className = "cb-prompt-add-group-btn"; + addGroupBtn.textContent = "➕ Add Prompt Group"; + addGroupBtn.onclick = () => { + // Create input for the first variation of the new group + const inputContainer = document.createElement("div"); + inputContainer.style.cssText = "display: flex; gap: 4px; margin-top: 4px;"; + + const input = document.createElement("input"); + input.className = "cb-input"; + input.style.cssText = "flex: 1; padding: 6px 8px; font-size: 12px;"; + input.placeholder = "Enter first variation for new group..."; + + const confirmBtn = document.createElement("button"); + confirmBtn.className = "cb-button primary"; + confirmBtn.style.padding = "4px 12px"; + confirmBtn.textContent = "Add Group"; + + const addGroup = () => { + const val = input.value.trim(); + if (val) { + const newGroups = [...(currentGroups || []), [val]]; + onChange(newGroups); + renderVisualGroups(newGroups); + renderPreview(newGroups); + rawTextarea.value = newGroups.length > 0 ? JSON.stringify(newGroups, null, 2) : ""; + } + }; + + input.onkeydown = (e) => { + if (e.key === "Enter") addGroup(); + if (e.key === "Escape") { + inputContainer.replaceWith(addGroupBtn); + } + }; + confirmBtn.onclick = addGroup; + + inputContainer.appendChild(input); + inputContainer.appendChild(confirmBtn); + addGroupBtn.replaceWith(inputContainer); + input.focus(); + }; + visualContainer.appendChild(addGroupBtn); + } + + // Initial render + renderVisualGroups(groups); + renderPreview(groups); + + return container; +} + +// --- GLOBAL PROMPTS SECTION --- + +export function renderGlobalPromptsSection(node, container) { + const isCollapsed = node.uiState.globalPromptsSectionCollapsed || false; + + const section = document.createElement("div"); + section.className = "cb-section full-width"; + + // Header (collapsible) + const header = document.createElement("div"); + header.className = "cb-section-toggle"; + header.style.cssText = "display: flex; justify-content: space-between; align-items: center; cursor: pointer; user-select: none; margin-bottom: 10px;"; + + const titleSpan = document.createElement("div"); + titleSpan.className = "cb-section-title"; + titleSpan.style.cssText = "margin-bottom: 0; border-bottom: none; padding-bottom: 0; color: #00aa44;"; + titleSpan.textContent = "📝 Global Prompts (Override Node Inputs)"; + header.appendChild(titleSpan); + + const arrowSpan = document.createElement("span"); + arrowSpan.textContent = isCollapsed ? "▶" : "▼"; + arrowSpan.style.cssText = "color: #00aa44; font-size: 12px;"; + header.appendChild(arrowSpan); + + section.appendChild(header); + + // Content container + const contentDiv = document.createElement("div"); + contentDiv.style.display = isCollapsed ? "none" : "flex"; + contentDiv.style.flexDirection = "column"; + contentDiv.style.gap = "12px"; + + header.onclick = () => { + const isNowCollapsed = contentDiv.style.display !== "none"; + contentDiv.style.display = isNowCollapsed ? "none" : "flex"; + arrowSpan.textContent = isNowCollapsed ? "▶" : "▼"; + node.uiState.globalPromptsSectionCollapsed = isNowCollapsed; + }; + + // Info text + const info = document.createElement("div"); + info.style.cssText = "font-size: 11px; color: #888; font-style: italic;"; + info.textContent = "These prompts override the sampler node's positive/negative text inputs for ALL config arrays. Per-config prompts (below) can further override these."; + contentDiv.appendChild(info); + + // Positive Prompt Editor + const positiveSection = document.createElement("div"); + positiveSection.className = "cb-prompt-section global"; + + const positiveEditor = createPromptGroupEditor( + node.state.global_positive_groups || [], + (newGroups) => { + node.state.global_positive_groups = newGroups; + node.saveState(); + }, + "✅ Positive Prompt Groups", + "#00aa44" + ); + positiveSection.appendChild(positiveEditor); + contentDiv.appendChild(positiveSection); + + // Negative Prompt (simple text area, not nested groups) + const negativeSection = document.createElement("div"); + negativeSection.className = "cb-prompt-section global"; + + const negLabel = document.createElement("div"); + negLabel.style.cssText = "font-size: 12px; font-weight: bold; color: #cc4444; margin-bottom: 6px;"; + negLabel.textContent = "❌ Negative Prompt"; + negativeSection.appendChild(negLabel); + + const negInput = document.createElement("textarea"); + negInput.className = "cb-prompt-raw-editor"; + negInput.style.minHeight = "50px"; + negInput.placeholder = "bad quality, worst quality, lowres"; + negInput.value = node.state.global_negative || ""; + negInput.onchange = () => { + node.state.global_negative = negInput.value; + node.saveState(); + }; + negativeSection.appendChild(negInput); + contentDiv.appendChild(negativeSection); + + section.appendChild(contentDiv); + container.appendChild(section); +} + +// --- PER-CONFIG PROMPTS SECTION --- + +export function renderConfigPromptsSection(node, div, configArray, arrayIdx) { + const uid = `prompts_${arrayIdx}`; + const isCollapsed = node.uiState.promptsSectionCollapsed[uid] !== false; // Default collapsed + + const section = document.createElement("div"); + section.style.cssText = "width: 100%; margin-top: 10px; padding-top: 10px; border-top: 1px dashed #444;"; + + // Header + const header = document.createElement("div"); + header.className = "cb-section-toggle"; + header.style.cssText = "padding: 8px; background: #3a3a3a; border-radius: 4px; margin-bottom: 8px; font-weight: bold; color: #9966cc;"; + + const promptCount = configArray.use_custom_prompts && configArray.positive_prompt_groups + ? countPromptCombinations(configArray.positive_prompt_groups) + : 0; + + const titleSpan = document.createElement("span"); + titleSpan.textContent = `Prompts${promptCount > 0 ? ` (${promptCount} combinations)` : ''}`; + header.appendChild(titleSpan); + + const arrowSpan = document.createElement("span"); + arrowSpan.textContent = isCollapsed ? "▶" : "▼"; + header.appendChild(arrowSpan); + + section.appendChild(header); + + // Content + const contentDiv = document.createElement("div"); + contentDiv.style.display = isCollapsed ? "none" : "block"; + + header.onclick = () => { + const isNowCollapsed = contentDiv.style.display !== "none"; + contentDiv.style.display = isNowCollapsed ? "none" : "block"; + arrowSpan.textContent = isNowCollapsed ? "▶" : "▼"; + node.uiState.promptsSectionCollapsed[uid] = isNowCollapsed; + }; + + // Toggle: Use Custom Prompts + const toggleLabel = document.createElement("label"); + toggleLabel.style.cssText = "display: flex; align-items: center; gap: 8px; cursor: pointer; margin-bottom: 10px; font-size: 13px;"; + + const toggleCheck = document.createElement("input"); + toggleCheck.type = "checkbox"; + toggleCheck.checked = configArray.use_custom_prompts || false; + toggleCheck.onchange = () => { + node.state.config_arrays[arrayIdx].use_custom_prompts = toggleCheck.checked; + node.saveState(); + node.renderUI(); + }; + toggleLabel.appendChild(toggleCheck); + toggleLabel.appendChild(document.createTextNode("Use Custom Prompts (Override Global/Node)")); + contentDiv.appendChild(toggleLabel); + + if (configArray.use_custom_prompts) { + // Positive Prompt Editor + const positiveSection = document.createElement("div"); + positiveSection.className = "cb-prompt-section per-config"; + + const positiveEditor = createPromptGroupEditor( + configArray.positive_prompt_groups || [], + (newGroups) => { + node.state.config_arrays[arrayIdx].positive_prompt_groups = newGroups; + node.saveState(); + }, + "✅ Positive Prompt Groups", + "#9966cc" + ); + positiveSection.appendChild(positiveEditor); + contentDiv.appendChild(positiveSection); + + // Negative Prompt (simple text) + const negativeSection = document.createElement("div"); + negativeSection.className = "cb-prompt-section per-config"; + negativeSection.style.marginTop = "8px"; + + const negLabel = document.createElement("div"); + negLabel.style.cssText = "font-size: 12px; font-weight: bold; color: #cc4444; margin-bottom: 6px;"; + negLabel.textContent = "❌ Negative Prompt"; + negativeSection.appendChild(negLabel); + + const negInput = document.createElement("textarea"); + negInput.className = "cb-prompt-raw-editor"; + negInput.style.minHeight = "50px"; + negInput.placeholder = "bad quality, worst quality, lowres"; + negInput.value = configArray.negative_prompt || ""; + negInput.onchange = () => { + node.state.config_arrays[arrayIdx].negative_prompt = negInput.value; + node.saveState(); + }; + negativeSection.appendChild(negInput); + contentDiv.appendChild(negativeSection); + } else { + const infoText = document.createElement("div"); + infoText.style.cssText = "font-size: 11px; color: #666; font-style: italic; padding: 4px;"; + infoText.textContent = "Using global prompts (or node inputs if no global prompts defined)."; + contentDiv.appendChild(infoText); + } + + section.appendChild(contentDiv); + div.appendChild(section); +} + // --- MAIN RENDER UI FUNCTION --- export function renderPreviewSection(container) { @@ -1288,6 +1778,9 @@ export async function renderUI(node, availableLoras, availableModels, loraFolder renderConfigSection(node, topRow, availableConfigs); root.appendChild(topRow); + // Global Prompts Section (between session/config management and config arrays) + renderGlobalPromptsSection(node, root); + const configSection = document.createElement("div"); configSection.className = "cb-section full-width"; configSection.innerHTML = '