From 44da2da61835ada2ea98a9a394b74c273e8db8aa Mon Sep 17 00:00:00 2001 From: JasonHoku Date: Fri, 13 Feb 2026 16:54:00 -0800 Subject: [PATCH] claude prompt in ui updates 1 --- .claude/settings.local.json | 8 + config_builder_node.py | 42 +- config_utils.py | 75 ++- .../conf-builder-config-management.js | 501 +++++++++++++++++- js/conf_builder/conf-builder-ui-components.js | 150 +++++- js/conf_builder/conf-builder-utilities.js | 116 +++- web/conf_builder/conf-builder-main.js | 29 +- 7 files changed, 884 insertions(+), 37 deletions(-) create mode 100644 .claude/settings.local.json 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 = '
⚙️ Config Arrays
'; @@ -1308,7 +1801,10 @@ export async function renderUI(node, availableLoras, availableModels, loraFolder loras: ["None"], lora_omit_triggers: [], lora_triggerwords_append_settings: {}, - combine: false + combine: false, + positive_prompt_groups: [], + negative_prompt: "", + use_custom_prompts: false }); node.saveState(); node.renderUI(); @@ -1321,6 +1817,7 @@ export async function renderUI(node, availableLoras, availableModels, loraFolder node.state.config_arrays.forEach((configArray, arrayIdx) => { const arrayElement = createConfigArrayElement(node, configArray, arrayIdx); + renderConfigPromptsSection(node, arrayElement, configArray, arrayIdx); renderModelsSection(node, arrayElement, configArray, arrayIdx, availableModels, modelFolders); renderLorasSection(node, arrayElement, configArray, arrayIdx, availableLoras, loraFolders); arraysContainer.appendChild(arrayElement); diff --git a/js/conf_builder/conf-builder-ui-components.js b/js/conf_builder/conf-builder-ui-components.js index f65e4ed..50a405f 100644 --- a/js/conf_builder/conf-builder-ui-components.js +++ b/js/conf_builder/conf-builder-ui-components.js @@ -241,7 +241,155 @@ export function getStyles() { width: 98%; /* Use more screen space on mobile */ } } - + + /* --- Prompt Builder Styles --- */ + .cb-prompt-section { + background: #252525; + border-radius: 4px; + padding: 10px; + margin-top: 8px; + width: 100%; + box-sizing: border-box; + } + .cb-prompt-section.global { + border-left: 3px solid #00aa44; + } + .cb-prompt-section.per-config { + border-left: 3px solid #9966cc; + } + .cb-prompt-group { + background: #1a1a1a; + border: 1px solid #3a3a3a; + border-radius: 4px; + padding: 8px; + margin-bottom: 6px; + display: flex; + flex-direction: column; + gap: 6px; + } + .cb-prompt-group-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: #888; + } + .cb-prompt-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + min-height: 28px; + align-items: center; + } + .cb-prompt-chip { + display: inline-flex; + align-items: center; + background: #0066cc33; + border: 1px solid #0066cc66; + color: #88ccff; + border-radius: 12px; + padding: 3px 8px; + font-size: 12px; + font-family: monospace; + gap: 4px; + max-width: 300px; + word-break: break-all; + } + .cb-prompt-chip .chip-close { + cursor: pointer; + color: #ff8888; + font-weight: bold; + font-size: 14px; + line-height: 1; + margin-left: 2px; + } + .cb-prompt-chip .chip-close:hover { + color: #ff4444; + } + .cb-prompt-preview { + background: #0a0a0a; + border: 1px solid #333; + border-radius: 4px; + padding: 8px; + margin-top: 8px; + max-height: 200px; + overflow-y: auto; + font-family: monospace; + font-size: 11px; + color: #00cc88; + } + .cb-prompt-preview-item { + padding: 2px 0; + border-bottom: 1px solid #1a1a1a; + } + .cb-prompt-preview-item:last-child { + border-bottom: none; + } + .cb-prompt-raw-editor { + width: 100%; + min-height: 80px; + background: #0a0a0a; + border: 1px solid #3a3a3a; + color: #0cc; + padding: 8px; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + resize: vertical; + box-sizing: border-box; + } + .cb-prompt-raw-editor:focus { + outline: none; + border-color: #0066cc; + } + .cb-prompt-mode-toggle { + background: transparent; + border: 1px solid #555; + color: #aaa; + padding: 3px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 10px; + } + .cb-prompt-mode-toggle:hover { + background: #333; + color: #fff; + } + .cb-prompt-mode-toggle.active { + background: #0066cc33; + border-color: #0066cc; + color: #88ccff; + } + .cb-prompt-add-group-btn { + width: 100%; + border: 1px dashed #555; + background: rgba(0,102,204,0.1); + color: #88ccff; + padding: 6px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-top: 4px; + } + .cb-prompt-add-group-btn:hover { + background: rgba(0,102,204,0.2); + border-color: #0066cc; + } + .cb-prompt-add-variation { + background: transparent; + border: 1px dashed #444; + color: #888; + padding: 3px 8px; + border-radius: 12px; + cursor: pointer; + font-size: 11px; + } + .cb-prompt-add-variation:hover { + background: #333; + color: #ccc; + border-color: #666; + } + `; } \ No newline at end of file diff --git a/js/conf_builder/conf-builder-utilities.js b/js/conf_builder/conf-builder-utilities.js index c874489..d820b33 100644 --- a/js/conf_builder/conf-builder-utilities.js +++ b/js/conf_builder/conf-builder-utilities.js @@ -175,6 +175,48 @@ export function buildLoraString(name, modelStr, clipStr) { return `${norm}:${modelStr.toFixed(2)}:${clipStr.toFixed(2)}`; } +// --- PROMPT HELPER FUNCTIONS --- + +/** + * Count the number of Cartesian product combinations from nested prompt groups. + * Each group is an array of variations. The total is the product of all group sizes. + * Example: [["a", "b"], ["c"]] = 2 * 1 = 2 combinations + */ +export function countPromptCombinations(groups) { + if (!groups || !Array.isArray(groups) || groups.length === 0) return 1; + // Filter out empty groups + const validGroups = groups.filter(g => Array.isArray(g) && g.length > 0); + if (validGroups.length === 0) return 1; + return validGroups.reduce((total, group) => total * group.length, 1); +} + +/** + * Generate preview of expanded prompt combinations from nested groups. + * Returns array of strings, capped at `limit` entries. + * Example: [["a", "b"], ["1", "2"]] -> ["a, 1", "a, 2", "b, 1", "b, 2"] + */ +export function expandPromptPreview(groups, limit = 20) { + if (!groups || !Array.isArray(groups) || groups.length === 0) return []; + const validGroups = groups.filter(g => Array.isArray(g) && g.length > 0); + if (validGroups.length === 0) return []; + + // Iterative Cartesian product with early cutoff + let combinations = [[]]; + for (const group of validGroups) { + const newCombinations = []; + for (const existing of combinations) { + for (const item of group) { + newCombinations.push([...existing, item]); + if (newCombinations.length > limit) { + return newCombinations.slice(0, limit).map(combo => combo.join(", ")); + } + } + } + combinations = newCombinations; + } + return combinations.map(combo => combo.join(", ")); +} + // --- ITERATION COUNT CALCULATION --- export function getIterationCount(configArray) { @@ -238,7 +280,13 @@ export function getIterationCount(configArray) { } if (l_count === 0) l_count = 1; - return m_count * l_count * s_count * sch_count * st_count * c_count; + // 4. Prompts (per-config custom prompts multiply the iteration count) + let p_count = 1; + if (configArray.use_custom_prompts && configArray.positive_prompt_groups && configArray.positive_prompt_groups.length > 0) { + p_count = countPromptCombinations(configArray.positive_prompt_groups); + } + + return m_count * l_count * s_count * sch_count * st_count * c_count * p_count; } // --- CONFIG CONVERSION --- @@ -247,10 +295,14 @@ export function convertStateToConfigs(state) { const configs = []; const split = (str) => str.split(",").map(s => s.trim()).filter(s => s); + // Global prompts from state (used when per-config prompts not set) + const globalPositiveGroups = state.global_positive_groups || []; + const globalNegative = state.global_negative || ""; + state.config_arrays.forEach(configArray => { // Process LoRAs - FIXED VERSION let loras = configArray.loras.filter(l => l && l !== "None"); - + // Convert loras array to proper format let loraValue; if (loras.length === 0) { @@ -302,6 +354,20 @@ export function convertStateToConfigs(state) { config.lora_strength_lock = configArray.lora_strength_lock; } + // ==== PROMPT HANDLING ==== + // Priority: per-config > global > omit (node inputs used as fallback) + if (configArray.use_custom_prompts && configArray.positive_prompt_groups && configArray.positive_prompt_groups.length > 0) { + config.positive = configArray.positive_prompt_groups; + if (configArray.negative_prompt) { + config.negative = configArray.negative_prompt; + } + } else if (globalPositiveGroups.length > 0) { + config.positive = globalPositiveGroups; + if (globalNegative) { + config.negative = globalNegative; + } + } + configs.push(config); }); return configs; @@ -321,7 +387,10 @@ export function convertConfigsToConfigArrays(configs) { lora_triggerwords_append_settings: {}, lora_bypass_states: {}, lora_strength_lock: {}, - combine: false + combine: false, + positive_prompt_groups: [], + negative_prompt: "", + use_custom_prompts: false }]; } @@ -383,6 +452,37 @@ export function convertConfigsToConfigArrays(configs) { strengthLock = { ...config.lora_strength_lock }; } + // Parse prompt fields from loaded config + let positivePromptGroups = []; + let negativePrompt = ""; + let useCustomPrompts = false; + + if (config.positive) { + // Config has per-config prompts + useCustomPrompts = true; + if (Array.isArray(config.positive)) { + // Could be nested array [["a", "b"], ["c"]] or simple array ["a", "b"] + if (config.positive.length > 0 && Array.isArray(config.positive[0])) { + // Nested array format - use as-is + positivePromptGroups = config.positive; + } else { + // Simple array - wrap as single group + positivePromptGroups = [config.positive]; + } + } else if (typeof config.positive === 'string' && config.positive.trim()) { + // Plain string - wrap as single variation in single group + positivePromptGroups = [[config.positive]]; + } + } + + if (config.negative) { + if (typeof config.negative === 'string') { + negativePrompt = config.negative; + } else if (Array.isArray(config.negative)) { + negativePrompt = JSON.stringify(config.negative); + } + } + configArrays.push({ name: `Loaded Config ${idx + 1}`, samplers: toString(config.sampler || "euler"), @@ -395,7 +495,10 @@ export function convertConfigsToConfigArrays(configs) { lora_triggerwords_append_settings: triggerPlacements, lora_bypass_states: bypassStates, lora_strength_lock: strengthLock, - combine: hasCombined + combine: hasCombined, + positive_prompt_groups: positivePromptGroups, + negative_prompt: negativePrompt, + use_custom_prompts: useCustomPrompts }); }); @@ -411,6 +514,9 @@ export function convertConfigsToConfigArrays(configs) { lora_triggerwords_append_settings: {}, lora_bypass_states: {}, lora_strength_lock: {}, - combine: false + combine: false, + positive_prompt_groups: [], + negative_prompt: "", + use_custom_prompts: false }]; } \ No newline at end of file diff --git a/web/conf_builder/conf-builder-main.js b/web/conf_builder/conf-builder-main.js index 203324f..b5f6329 100644 --- a/web/conf_builder/conf-builder-main.js +++ b/web/conf_builder/conf-builder-main.js @@ -66,7 +66,9 @@ app.registerExtension({ modelsSectionCollapsed: {}, lorasSectionCollapsed: {}, modelsCollapsed: {}, - lorasCollapsed: {} + lorasCollapsed: {}, + promptsSectionCollapsed: {}, + globalPromptsSectionCollapsed: false }; // Initialize default state structure @@ -75,6 +77,8 @@ app.registerExtension({ config_name: "default_config", auto_save: false, include_none: false, + global_positive_groups: [], + global_negative: "", config_arrays: [{ name: "Config 1", samplers: "euler, dpmpp_2m", @@ -87,7 +91,10 @@ app.registerExtension({ lora_triggerwords_append_settings: {}, lora_bypass_states: {}, lora_strength_lock: {}, - combine: false + combine: false, + positive_prompt_groups: [], + negative_prompt: "", + use_custom_prompts: false }] }; @@ -248,6 +255,8 @@ app.registerExtension({ config_name: "default_config", auto_save: false, include_none: oldState.include_none !== undefined ? oldState.include_none : false, + global_positive_groups: [], + global_negative: "", config_arrays: arrays.map(arr => ({ name: arr.name, samplers: oldState.samplers || "euler", @@ -260,7 +269,10 @@ app.registerExtension({ lora_triggerwords_append_settings: {}, lora_bypass_states: {}, lora_strength_lock: {}, - combine: arr.combine || false + combine: arr.combine || false, + positive_prompt_groups: [], + negative_prompt: "", + use_custom_prompts: false })) }; }; @@ -281,6 +293,10 @@ app.registerExtension({ if (!this.state.config_name) this.state.config_name = "default_config"; if (this.state.auto_save === undefined) this.state.auto_save = false; + // Migration: ensure global prompt fields exist + if (!this.state.global_positive_groups) this.state.global_positive_groups = []; + if (this.state.global_negative === undefined) this.state.global_negative = ""; + // Migration logic requiring utilities this.state.config_arrays.forEach(arr => { if (arr.model && !arr.models) { @@ -293,12 +309,17 @@ app.registerExtension({ const p = utilities.parseLoraString(l); return utilities.buildLoraString(p.name, p.model_str, p.clip_str); }) : ["None"]; - + // Ensure keys exist if (!arr.lora_omit_triggers) arr.lora_omit_triggers = []; if (!arr.lora_triggerwords_append_settings) arr.lora_triggerwords_append_settings = {}; if (!arr.lora_bypass_states) arr.lora_bypass_states = {}; if (!arr.lora_strength_lock) arr.lora_strength_lock = {}; + + // Migration: ensure prompt fields exist + if (!arr.positive_prompt_groups) arr.positive_prompt_groups = []; + if (arr.negative_prompt === undefined) arr.negative_prompt = ""; + if (arr.use_custom_prompts === undefined) arr.use_custom_prompts = false; }); } else if (existing.lora_config) { this.state = this.migrateOldFormat(existing);