diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 10fd0fe..3537152 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -360,10 +360,13 @@ covered by the `styles.css` manifest. full detail surface, and `ui_material_application.js` owns selected-node tracking and explicit material application (same-type block application first, then cross-node prompt injection). - `node_material_actions.js` inspects prompt slots (`inspectNodePromptSlots`), - extracts prompt envelopes without model file paths, and plans injection - (`planPromptInjection`): both texts into two-slot nodes, otherwise the matching - role or a role-neutral slot. Text never crosses roles; writes are one undo step. + `prompt_boxes.js` is the one place that finds prompt boxes on the live canvas: a + multiline STRING input by the node's ComfyUI definition, whatever it is called; its + role is the box's own name (positive / negative) or else the wiring (outputs followed + to an input named positive / negative). It also plans which box takes which text and + finds a box's opposite-role partner on the same sampler. `node_material_actions.js` + extracts prompt envelopes without model file paths and writes them (`fillPrompt`); + text never crosses roles unless the user picked the box; writes are one undo step. Within recipe detail, `ui_recipe_versions.js` owns history comparison/restore, `ui_recipe_gallery.js` owns result cards and direct Image Detail Workbench handoff, `ui_recipe_model_matching.js` owns preview resolution and explicit local replacement, diff --git a/CHANGELOG.md b/CHANGELOG.md index f61d119..5221300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - **Faster rescans (重复扫描更快)**: a scan no longer downloads every cover and Civitai model page again for models whose information is already on disk; only new or missing ones go to the network. - **Model types at the top (模型页顶部类型标签)**: the models page starts with a chip per model type (Checkpoint, LoRA, VAE…) and how many models it holds; a chip shows that whole type, subfolders included, so the grid no longer looks like it holds one folder's models. Picking a folder in the list narrows the grid to that folder, shown as a chip with ✕ that goes back to the whole type. The browser remembers the type you picked last. - **Model Doctor redone (模型医生重做)**: when a workflow opens with models this computer does not have, a bar over the canvas says how many, and how many are here under another name or folder; **Put in** swaps those in and **Show** opens the doctor. The doctor page lists what needs you first and says why in plain words (same file found elsewhere, same size only, not on this computer, no fingerprint saved in the workflow), with a button for each: put in, use it, pick one yourself, or look it up on Civitai; the models that are fine fold away. Opening the doctor no longer changes the workflow by itself, and a model put in by the doctor or its picker no longer stays marked red by ComfyUI. The "auto-detect on startup" switch (it did nothing) and the doctor's own deep hash scan are gone: the check hashes same-size files itself, and the page points to the scan page when unscanned models could be the missing ones. +- **Prompt boxes found by what they are (提示词框按节点定义识别)**: dropping or writing a prompt (material library, Prompt Studio, translator) now finds a node's prompt boxes by ComfyUI's own definition (multiline text inputs) instead of guessing from names, so a filename or "value" box no longer takes a prompt and third-party prompt nodes are recognised. Whether a box is positive or negative follows its wiring to the sampler, not the node's title: a CLIP Text Encode connected to the negative input gets the negative prompt even if it is still called "CLIP Text Encode". Prompt Studio's "take prompts from the selected node" also reads the roles right again (it could not follow links in current ComfyUI). ### 🪶 Lighter on memory (更省内存) - The output gallery shows 512 px thumbnails instead of full images (cached like model cards), so scrolling through hundreds of outputs no longer fills the browser's memory and graphics memory with full-size pictures. Dragging an image to the canvas and the large viewer still use the original. When the browser has been closed for 90 seconds, its gallery images are released too, like the model grid. diff --git a/docs/architecture/material-library.md b/docs/architecture/material-library.md index 0b42345..8819737 100644 --- a/docs/architecture/material-library.md +++ b/docs/architecture/material-library.md @@ -316,14 +316,16 @@ When dropped onto a compatible node: compatible widget values through the transactional parameter path, preserving seed, node position, and links. - Prompt-bearing materials (prompt notes, prompt plans, materials with a `copy_prompt` - capability, or blocks of prompt node types) can be dropped onto any node with - prompt slots. `extractMaterialPromptEnvelope` (in `node_material_actions.js`) - builds `{positive, negative, primaryRole}` from plans, notes, `prompt_groups`, - prompt node blocks or the summary, never from model file paths. `planPromptInjection` - writes both texts into nodes with positive and negative slots (e.g. `easy a1111Loader`), - otherwise the matching role slot or a role-neutral slot (`CLIPTextEncode` `text`). - Negative text never goes into a positive slot and vice versa; if no slot fits, the - drop fails with `materialNoCompatibleValues`. Text is inserted without synthetic + capability, or blocks of node types that take a prompt) can be dropped onto any node + with prompt boxes. A prompt box is a multiline STRING input by the node's definition + (`prompt_boxes.js`); names do not make a box. `extractMaterialPromptEnvelope` (in + `node_material_actions.js`) builds `{positive, negative, primaryRole}` from plans, + notes, `prompt_groups`, prompt node blocks or the summary, never from model file paths. + Each box's role is its own name (e.g. `easy a1111Loader`'s positive / negative) or the + wiring: a `CLIPTextEncode` feeding a sampler's negative input is a negative box, + whatever its title. Each box takes its own role's text; a box nothing tells the role + of takes the main text. Negative text never goes into a positive box and vice versa; + if no box fits, the drop fails with `materialNoCompatibleValues`. Text is inserted without synthetic prefixes, and every injection is one undo step. - Parameter blocks are never applied across node types. diff --git a/web/modules/locales.js b/web/modules/locales.js index b4af78a..52ca29a 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -690,6 +690,8 @@ export const i18n = { doctorUnsupportedInsertion: '当前节点不支持此插入方式。', doctorCreateLoraFailed: '无法创建标准 LoRA Loader 节点。', doctorLoraNotReady: 'LoRA 列表尚未就绪,请刷新 ComfyUI 模型列表后重试。', + promptSelectNodeFirst: '请先在画布上选中一个有提示词框的节点(例如 CLIP 文本编码)。', + promptNodeHasNoBox: '选中的节点没有提示词框(多行文本输入)。', doctorLead: '检查当前工作流用到的模型在本机找不找得到。工作流里记着每个模型的文件指纹,改过名、换过文件夹的模型也能认出来;只有指纹对得上的文件才会被换上。', doctorChecking: '正在检查…', doctorStatUsed: '用到的模型', @@ -2568,6 +2570,8 @@ export const i18n = { doctorUnsupportedInsertion: 'This insertion is not available for the selected node.', doctorCreateLoraFailed: 'Unable to create a standard LoRA Loader node.', doctorLoraNotReady: 'The LoRA list is not ready. Refresh ComfyUI model lists and try again.', + promptSelectNodeFirst: 'Select a node with a prompt box on the canvas first (e.g. CLIP Text Encode).', + promptNodeHasNoBox: 'The selected node has no prompt box (multiline text input).', doctorLead: 'Checks whether this computer has the models the open workflow uses. The workflow carries each model\'s file fingerprint (hash), so a model you renamed or moved is still recognised; only a file with the same fingerprint is put in.', doctorChecking: 'Checking…', doctorStatUsed: 'Models used', diff --git a/web/modules/node_material_actions.js b/web/modules/node_material_actions.js index 951640b..2b9e407 100644 --- a/web/modules/node_material_actions.js +++ b/web/modules/node_material_actions.js @@ -1,4 +1,5 @@ import { composePromptPlan } from './prompt_composition.js'; +import { planPromptFill, typeTakesPrompt } from './prompt_boxes.js'; // Shared by Node Assistant and Material Library. No node creation or link edits. const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value)); @@ -80,7 +81,7 @@ export function applyNodeMaterialValues(app, node, entries, options = {}) { widget.value = clone(value); if (Array.isArray(node.widgets_values)) node.widgets_values[index] = clone(value); widget.callback?.call(widget, widget.value, app.canvas, node); - node.onWidgetChanged?.(index, widget.value, previous[index], widget); + node.onWidgetChanged?.(widget.name, widget.value, previous[index], widget); } if (transportsHashes) replaceHashes(graph, node.id, mapped); notify(); @@ -114,86 +115,6 @@ export function applyMaterialBlock(app, node, block, workflowHashes) { { sourceNodeId: block.node_id, workflowHashes }); } -export const POSITIVE_PROMPT_REGEX = /^(positive|positive_prompt|text_positive|text_g|text_l|正面|正向|正面提示词|正向提示词)$/i; -export const NEGATIVE_PROMPT_REGEX = /^(negative|negative_prompt|text_negative|负面|反向|负面提示词|反向提示词)$/i; - -export function isPositivePromptWidget(name) { - return POSITIVE_PROMPT_REGEX.test(String(name || '').trim()); -} - -export function isNegativePromptWidget(name) { - return NEGATIVE_PROMPT_REGEX.test(String(name || '').trim()); -} - -export function classifyPromptWidgetRole(name) { - const trimmed = String(name || '').trim(); - if (POSITIVE_PROMPT_REGEX.test(trimmed)) return 'positive'; - if (NEGATIVE_PROMPT_REGEX.test(trimmed)) return 'negative'; - return null; -} - -export function promptWidgetTargets(node) { - const promptNameRegex = /^(text|text_g|text_l|prompt|positive|positive_prompt|negative|negative_prompt|text_positive|text_negative|caption|string|value|文本|提示词|正面|负面|正向|反向|正面提示词|负面提示词|正向提示词|反向提示词|描述|内容)$/i; - return (node?.widgets || []).flatMap((widget, index) => { - if (!widget) return []; - const name = String(widget.name || ''); - const label = String(widget.label || ''); - const isNotCombo = !widget.options?.values || !Array.isArray(widget.options.values); - const matchesName = promptNameRegex.test(name) || promptNameRegex.test(label) || widget.type === 'customtext' || widget.type === 'text' || !!widget.options?.multiline; - return typeof widget.value === 'string' && matchesName && isNotCombo ? [{ index, name: name || label || 'text' }] : []; - }); -} - -export function inspectNodePromptSlots(node) { - const targets = promptWidgetTargets(node); - if (!targets || !targets.length) { - return { - hasSlots: false, - positiveSlot: null, - negativeSlot: null, - generalSlots: [], - targets: [], - }; - } - - let positiveSlot = null; - let negativeSlot = null; - const generalSlots = []; - - const nodeTitle = String(node?.title || node?.type || '').toLowerCase(); - const isNegativeTitle = /^(negative|负面|反向|负向)/i.test(nodeTitle) || /negative/i.test(nodeTitle); - - for (const target of targets) { - const role = classifyPromptWidgetRole(target.name); - if (role === 'positive' && !positiveSlot) { - positiveSlot = target; - } else if (role === 'negative' && !negativeSlot) { - negativeSlot = target; - } else { - generalSlots.push(target); - } - } - - const isPositiveTitle = /^(positive|正面|正向)/i.test(nodeTitle) || /positive/i.test(nodeTitle); - if (!positiveSlot && !negativeSlot && generalSlots.length === 1) { - if (isNegativeTitle) { - negativeSlot = generalSlots[0]; - generalSlots.length = 0; - } else if (isPositiveTitle) { - positiveSlot = generalSlots[0]; - generalSlots.length = 0; - } - } - - return { - hasSlots: true, - positiveSlot, - negativeSlot, - generalSlots, - targets, - }; -} - export const MODEL_EXTENSIONS_REGEX = /\.(safetensors|ckpt|pt|bin|pth|sft|onnx|engine|gguf)$/i; export function isModelFilePath(value) { @@ -204,15 +125,6 @@ export function isModelFilePath(value) { return lines.length > 0 && lines.every(line => MODEL_EXTENSIONS_REGEX.test(line)); } -export function isPromptNodeType(type) { - const norm = String(type || '').trim().toLowerCase(); - if (!norm) return false; - if (/lora|checkpoint|unet|vae|controlnet|sampler|latent|saveimage|previewimage|loadimage/i.test(norm)) { - return false; - } - return /cliptextencode|prompt|text_box|showtext|easy positive|easy negative|easy wildcards/i.test(norm); -} - export function sanitizePromptText(value) { if (typeof value !== 'string') return ''; const trimmed = value.trim(); @@ -297,7 +209,7 @@ function extractFromNodeBlocks(payload) { const blockType = String(block.type || '').toLowerCase(); const role = promptRoles?.[String(block.node_id)]?.role || block.promptRole; const isExplicitPrompt = role === 'positive' || role === 'negative' || role === 'both'; - const isPromptType = isPromptNodeType(blockType); + const isPromptType = typeTakesPrompt(block.type); if (!isExplicitPrompt && !isPromptType) continue; @@ -376,44 +288,12 @@ export function getMaterialPromptInfo(material) { } /** - * Choose prompt widgets for an envelope. Text never crosses roles: a negative - * prompt is not written into a positive slot (or the reverse). Role-neutral - * slots (e.g. CLIPTextEncode `text`) accept the envelope's primary text. + * Writes an envelope's prompt text into `node`'s prompt boxes (prompt_boxes.js decides + * which box takes which text); `box`, one of the node's boxes, limits it to that box. */ -export function planPromptInjection(slots, envelope) { - const { positiveSlot, negativeSlot } = slots; - const generalSlot = slots.generalSlots?.[0] || null; - const positive = envelope.positive || ''; - const negative = envelope.negative || ''; - - // Strategy 1: both texts into a node that has both role slots. - if (positive && negative && positiveSlot && negativeSlot) { - return [{ index: positiveSlot.index, value: positive }, { index: negativeSlot.index, value: negative }]; - } - // Strategy 2: a single-role node receives the matching text only. - if (negativeSlot && !positiveSlot && !generalSlot) return negative ? [{ index: negativeSlot.index, value: negative }] : []; - if (positiveSlot && !negativeSlot && !generalSlot) return positive ? [{ index: positiveSlot.index, value: positive }] : []; - // Strategy 3: role-matched slot for a one-sided envelope. - const wantsNegative = envelope.primaryRole === 'negative' || (negative && !positive); - if (wantsNegative && negativeSlot) return [{ index: negativeSlot.index, value: negative }]; - if (!wantsNegative && positive && positiveSlot) return [{ index: positiveSlot.index, value: positive }]; - // Strategy 4: role-neutral slot takes the primary text. - if (generalSlot) { - const value = wantsNegative ? negative : (positive || envelope.singleText || ''); - return value ? [{ index: generalSlot.index, value }] : []; - } - return []; -} - -export function dispatchPromptInjection(app, node, envelope, options = {}) { - if (!node || !envelope || !envelope.hasPrompt) { - throw new Error('materialNoCompatibleValues'); - } - const slots = inspectNodePromptSlots(node); - if (!slots.hasSlots) throw new Error('materialNoCompatibleValues'); - const entries = planPromptInjection(slots, envelope); +export function fillPrompt(app, node, envelope, box = null, options = {}) { + if (!node || !envelope?.hasPrompt) throw new Error('materialNoCompatibleValues'); + const entries = planPromptFill(node, envelope, box); if (!entries.length) throw new Error('materialNoCompatibleValues'); - return applyNodeMaterialValues(app, node, entries, options); + return applyNodeMaterialValues(app, node, entries.map(({ index, value }) => ({ index, value })), options); } - - diff --git a/web/modules/prompt_boxes.js b/web/modules/prompt_boxes.js new file mode 100644 index 0000000..faa1dd7 --- /dev/null +++ b/web/modules/prompt_boxes.js @@ -0,0 +1,144 @@ +/** + * Prompt boxes on the live canvas: which text boxes of a node take a prompt, and whether + * that prompt is positive or negative. A prompt box is a multiline STRING input by the + * node's own definition (ComfyUI's object_info), whatever the box is called. Its role is + * the box's name when the node's author named it positive / negative; otherwise it comes + * from the wiring: the node's outputs are followed down to an input named positive or + * negative (a guider's single conditioning input counts as positive). No DOM here. + */ + +const POSITIVE_NAME = /^(positive|positive_prompt|text_positive|正面|正向|正面提示词|正向提示词)$/i; +const NEGATIVE_NAME = /^(negative|negative_prompt|text_negative|负面|反向|负面提示词|反向提示词)$/i; +const MAX_HOPS = 16; + +/** 'positive' | 'negative' | '' for a box (or serialized widget) name that says so itself. */ +export function promptNameRole(name) { + const trimmed = String(name || '').trim(); + if (POSITIVE_NAME.test(trimmed)) return 'positive'; + if (NEGATIVE_NAME.test(trimmed)) return 'negative'; + return ''; +} + +function definition(type) { + return globalThis.LiteGraph?.registered_node_types?.[type]?.nodeData || null; +} + +function inputSpec(nodeData, name) { + const input = nodeData?.input || {}; + return input.required?.[name] || input.optional?.[name] || null; +} + +const isMultilineString = (spec) => Array.isArray(spec) && spec[0] === 'STRING' && Boolean(spec[1]?.multiline); + +/** Whether nodes of this type have a prompt box (by their definition). */ +export function typeTakesPrompt(type) { + const input = definition(type)?.input || {}; + return [...Object.values(input.required || {}), ...Object.values(input.optional || {})].some(isMultilineString); +} + +export function isPromptBox(node, widget) { + const nodeData = node?.constructor?.nodeData || definition(node?.type); + return typeof widget?.value === 'string' && isMultilineString(inputSpec(nodeData, widget.name)); +} + +function downstream(node, graph) { + const found = []; + for (const output of node.outputs || []) { + for (const linkId of output.links || []) { + const link = graph.getLink?.(linkId) ?? graph.links?.[linkId]; + const target = link && graph.getNodeById(link.target_id); + if (target) found.push({ target, input: String(target.inputs?.[link.target_slot]?.name || '') }); + } + } + return found; +} + +/** + * Where a node's output ends up: { role: 'positive' | 'negative' | 'both' | '', samplers } + * with the ids of the nodes whose positive / negative input it reaches. + */ +export function wiredRole(node, graph = node?.graph) { + const roles = new Set(); + const samplers = new Set(); + if (!node || !graph) return { role: '', samplers }; + const seen = new Set([node.id]); + let frontier = [node]; + for (let hop = 0; hop < MAX_HOPS && frontier.length; hop++) { + const next = []; + for (const current of frontier) { + for (const { target, input } of downstream(current, graph)) { + const guider = /guider/i.test(target.type) && /^(conditioning|cond\d*)$/i.test(input); + const role = /positive/i.test(input) || guider ? 'positive' : /negative/i.test(input) ? 'negative' : ''; + if (role) { + roles.add(role); + samplers.add(target.id); + } else if (!seen.has(target.id)) { + seen.add(target.id); + next.push(target); + } + } + } + frontier = next; + } + return { role: roles.size === 2 ? 'both' : [...roles][0] || '', samplers }; +} + +/** A node's prompt boxes: [{ index, widget, name, role }], role '' when nothing tells. */ +export function promptBoxes(node) { + const boxes = (node?.widgets || []).flatMap((widget, index) => ( + isPromptBox(node, widget) ? [{ index, widget, name: widget.name, role: '' }] : [] + )); + let wired = null; + for (const box of boxes) { + box.role = promptNameRole(box.name) || (wired ||= wiredRole(node)).role; + } + return boxes; +} + +/** The text of a prompt envelope ({ positive, negative, singleText, primaryRole }) for a box of `role`. */ +export function textForRole(envelope, role) { + if (role === 'negative') return envelope.negative || ''; + if (role === 'positive' || role === 'both') return envelope.positive || ''; + return envelope.primaryRole === 'negative' ? envelope.negative : (envelope.positive || envelope.singleText || envelope.negative || ''); +} + +/** + * What filling `node` with an envelope writes: [{ index, value, role }]. With `box` (one of + * the node's boxes, chosen by the user) only that box, with the text of its role, or the + * envelope's main text when the envelope has none of that role. Without it: every box that + * has text of its own role; a node with one box of unknown role takes the main text. + */ +export function planPromptFill(node, envelope, box = null) { + if (box) { + const value = textForRole(envelope, box.role) || textForRole(envelope, ''); + return value ? [{ index: box.index, value, role: box.role }] : []; + } + const boxes = promptBoxes(node); + const known = boxes.filter(item => item.role && textForRole(envelope, item.role)); + if (known.length) return known.map(item => ({ index: item.index, value: textForRole(envelope, item.role), role: item.role })); + if (boxes.length === 1 && !boxes[0].role) { + const value = textForRole(envelope, ''); + return value ? [{ index: boxes[0].index, value, role: '' }] : []; + } + return []; +} + +/** + * The one box on another node that takes the opposite role for the same sampler as + * `node`'s output, or null (none, or more than one). + */ +export function partnerBox(node, role, graph = node?.graph) { + if (!graph || (role !== 'positive' && role !== 'negative')) return null; + const { samplers } = wiredRole(node, graph); + if (!samplers.size) return null; + const wanted = role === 'positive' ? 'negative' : 'positive'; + const found = []; + for (const other of graph._nodes || []) { + if (other === node) continue; + const boxes = promptBoxes(other).filter(box => box.role === wanted); + if (!boxes.length) continue; + const reach = wiredRole(other, graph).samplers; + if ([...reach].some(id => samplers.has(id))) found.push(...boxes.map(box => ({ node: other, box }))); + } + return found.length === 1 ? found[0] : null; +} diff --git a/web/modules/recipe_parser.js b/web/modules/recipe_parser.js index c3f633b..029ec1d 100644 --- a/web/modules/recipe_parser.js +++ b/web/modules/recipe_parser.js @@ -1,9 +1,4 @@ -import { - promptWidgetTargets, - isPositivePromptWidget, - isNegativePromptWidget, - classifyPromptWidgetRole, -} from './node_material_actions.js'; +import { promptBoxes, promptNameRole } from './prompt_boxes.js'; import { isAllInOneLoaderType, isPlaceholderModelValue } from './recipe_identity.js'; /** @@ -262,9 +257,9 @@ function syncCommonRecipeMetadata(params, change) { params[samplingField] = ['steps', 'cfg', 'denoise'].includes(samplingField) ? numberValue(value) : value; } - const isPos = isPositivePromptWidget(widget); - const isNeg = isNegativePromptWidget(widget); - if (isPos || isNeg || (isSupportedPromptNodeType(type) && /^(text|prompt)$/.test(widget))) { + const isPos = promptNameRole(widget) === 'positive'; + const isNeg = promptNameRole(widget) === 'negative'; + if (isPos || isNeg || (isSupportedPromptNodeType(type) && /^(text|text_g|text_l|prompt)$/.test(widget))) { const positive = Array.isArray(params.promptPositive) ? params.promptPositive : (params.promptPositive = []); const negative = Array.isArray(params.promptNegative) ? params.promptNegative : (params.promptNegative = []); const position = positive.findIndex((item) => item === change.previousValue); @@ -554,7 +549,7 @@ export function extractRecipeMetadata(graph) { // Precedence: External Link > Embedded fallback (only if promptPositive/promptNegative are empty) for (const node of graph._nodes) { if (isSupportedPromptNodeType(nodeType(node))) continue; - const targets = promptWidgetTargets(node); + const targets = promptBoxes(node); if (!targets || targets.length === 0) continue; const summary = metadata.nodes.find((item) => String(item?.id) === String(node.id)); @@ -564,7 +559,7 @@ export function extractRecipeMetadata(graph) { const val = textValue(widget?.value); if (!val) continue; - const role = classifyPromptWidgetRole(target.name); + const role = promptNameRole(target.name); if (role === 'positive' && metadata.promptPositive.length === 0) { appendUnique(metadata.promptPositive, [val]); if (summary) { diff --git a/web/modules/ui_material_application.js b/web/modules/ui_material_application.js index 17c31f2..4472d25 100644 --- a/web/modules/ui_material_application.js +++ b/web/modules/ui_material_application.js @@ -8,12 +8,11 @@ import { applyMaterialBlock, applyNodeMaterialValues, selectedMaterialNode, - inspectNodePromptSlots, extractMaterialPromptEnvelope, - dispatchPromptInjection, + fillPrompt, isModelFilePath, - isPromptNodeType, } from './node_material_actions.js'; +import { promptBoxes, typeTakesPrompt } from './prompt_boxes.js'; export function showMaterialApplication(parent, result, node) { parent.querySelector('.anomalous-material-application-result')?.remove(); @@ -135,30 +134,26 @@ export async function applyLibraryMaterial(owner, material, droppedNode = null, return; } - // Path 2: Cross-Node Prompt Injection Protocol (CNPIP) - const slots = inspectNodePromptSlots(node); - if (!slots.hasSlots) throw new Error('materialNoCompatibleValues'); + // Path 2: the material's prompt text into the node's prompt boxes. + const boxes = promptBoxes(node); + if (!boxes.length) throw new Error('materialNoCompatibleValues'); const envelope = extractMaterialPromptEnvelope(material, payload); if (!envelope.hasPrompt) throw new Error('materialNoCompatibleValues'); - const hasBothSlots = Boolean(slots.positiveSlot && slots.negativeSlot); - const hasSingleRoleSlot = Boolean((slots.positiveSlot && !slots.negativeSlot) || (slots.negativeSlot && !slots.positiveSlot)); - const promptBlocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles) .filter(b => { - const isPrompt = b.promptRole === 'positive' || b.promptRole === 'negative' || b.promptRole === 'both' || isPromptNodeType(b.type); + const isPrompt = b.promptRole === 'positive' || b.promptRole === 'negative' || b.promptRole === 'both' || typeTakesPrompt(b.type); return isPrompt && Array.isArray(b.widgets_values) && b.widgets_values.some(v => typeof v === 'string' && v.trim() && !isModelFilePath(v)); }); - if (!hasBothSlots && !hasSingleRoleSlot && promptBlocks.length > 1) { + // Boxes nothing tells the role of: the user picks which of the material's prompts. + if (boxes.every(box => !box.role) && promptBlocks.length > 1) { openMaterialChoiceDialog(owner, node, promptBlocks, payload, droppedNode, graph); return; } - const result = dispatchPromptInjection(app, node, envelope, { - workflowHashes: payload.workflow_hashes, - }); + const result = fillPrompt(app, node, envelope); showMaterialApplication(owner.materialContext, result, node); return result; } catch (error) { @@ -175,8 +170,7 @@ function openMaterialChoiceDialog(owner, node, blocks, payload, droppedNode, gra return apply(node, targetBlock, payload.workflow_hashes, owner.materialContext); } if (selectedMaterialNode(app) !== node && !droppedNode) throw new Error('materialTargetChanged'); - const slots = inspectNodePromptSlots(node); - if (!slots.hasSlots) throw new Error('materialNoCompatibleValues'); + const boxes = promptBoxes(node); let textVal = ''; if (Array.isArray(targetBlock.widgets_values)) { for (const v of targetBlock.widgets_values) { @@ -187,12 +181,11 @@ function openMaterialChoiceDialog(owner, node, blocks, payload, droppedNode, gra } } if (!textVal) throw new Error('materialNoCompatibleValues'); - // Never write a block into the opposite role's slot. - const general = slots.generalSlots[0] || null; - let targetWidget = null; - if (targetBlock.promptRole === 'negative') targetWidget = slots.negativeSlot || general; - else if (targetBlock.promptRole === 'positive') targetWidget = slots.positiveSlot || general; - else targetWidget = general || slots.positiveSlot || slots.negativeSlot; + // Never write a block into the opposite role's box. + const role = targetBlock.promptRole; + const targetWidget = role === 'positive' || role === 'negative' + ? boxes.find(box => box.role === role) || boxes.find(box => !box.role) + : boxes.find(box => !box.role) || boxes[0]; if (!targetWidget) throw new Error('materialNoCompatibleValues'); const result = applyNodeMaterialValues(app, node, [{ index: targetWidget.index, value: textVal }], { sourceNodeId: targetBlock.node_id, diff --git a/web/modules/ui_material_cards.js b/web/modules/ui_material_cards.js index b3b343b..6e648bc 100644 --- a/web/modules/ui_material_cards.js +++ b/web/modules/ui_material_cards.js @@ -6,7 +6,7 @@ import { translate } from './locales.js'; import { anomalousAlert } from './ui_dialog.js'; import { text, jsonResponse } from './ui_dom.js'; import { materialNodeHeading } from './material_inspector.js'; -import { inspectNodePromptSlots, isPromptNodeType } from './node_material_actions.js'; +import { promptBoxes, typeTakesPrompt } from './prompt_boxes.js'; import { applyLibraryMaterial } from './ui_material_application.js'; import { deleteMaterial, @@ -119,10 +119,10 @@ function bindPolymorphicMaterialCardDrag(card, owner, material) { accepts: (node, source) => { if (!node) return false; if ((source.node_types || []).includes(node.type)) return true; - if (!inspectNodePromptSlots(node).hasSlots) return false; + if (!promptBoxes(node).length) return false; // Cross-node drops only carry prompt text; parameter blocks stay same-type. if (isPromptMaterial(source) || source.kind === 'prompt_plan' || source.capabilities?.includes('copy_prompt')) return true; - return (source.node_types || []).some(type => isPromptNodeType(type)); + return (source.node_types || []).some(type => typeTakesPrompt(type)); }, drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph), dropOnCanvas: async (event, source, graph, position) => { diff --git a/web/modules/ui_prompt_source_deck.js b/web/modules/ui_prompt_source_deck.js index 9d042f6..b36301c 100644 --- a/web/modules/ui_prompt_source_deck.js +++ b/web/modules/ui_prompt_source_deck.js @@ -3,7 +3,8 @@ import { translate as t } from './locales.js'; import { text, jsonResponse } from './ui_dom.js'; import { materialNodeHeading } from './material_inspector.js'; import { anomalousAlert } from './ui_dialog.js'; -import { promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js'; +import { selectedMaterialNode } from './node_material_actions.js'; +import { promptBoxes } from './prompt_boxes.js'; import { categorizePromptSnippet } from './prompt_composition.js'; import { CATEGORY_META, STARTER_SOURCE_PROMPTS } from './prompt_studio_data.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; @@ -195,34 +196,6 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa } }, { passive: true }); - function detectNodePromptRole(node, widgetName = '') { - if (/neg|negative|反向|负向/i.test(widgetName)) return 'negative'; - if (/pos|positive|正面|正向/i.test(widgetName)) return 'positive'; - - if (app?.graph && Array.isArray(node?.outputs)) { - for (const output of node.outputs) { - if (!Array.isArray(output.links)) continue; - for (const linkId of output.links) { - const link = app.graph.links?.[linkId]; - if (!link) continue; - const targetNode = app.graph.getNodeById(link.target_id); - if (targetNode?.inputs && targetNode.inputs[link.target_slot]) { - const targetInput = targetNode.inputs[link.target_slot]; - const inputName = String(targetInput.name || '').toLowerCase(); - if (inputName.includes('neg') || inputName.includes('负')) return 'negative'; - if (inputName.includes('pos') || inputName.includes('正')) return 'positive'; - } - } - } - } - - const nodeText = `${node?.title || ''} ${node?.type || ''}`.toLowerCase(); - if (/neg|negative|反向|负向/.test(nodeText)) return 'negative'; - if (/pos|positive|正面|正向/.test(nodeText)) return 'positive'; - - return 'positive'; - } - // Extract prompts from selected canvas node function extractPromptsFromSelectedNode(intoRightMixer = false) { const node = selectedMaterialNode(app); @@ -234,24 +207,7 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa } const heading = materialNodeHeading(node) || node.title || node.type || `Node #${node.id}`; - let targets = promptWidgetTargets(node); - - // Safe fallback: only prompt/caption/text widgets or textarea, exclude settings/file paths - if (!targets.length && Array.isArray(node.widgets)) { - targets = node.widgets.flatMap((w, idx) => { - const name = String(w.name || '').toLowerCase(); - if (/filename|prefix|path|directory|save|load|ckpt|model|vae|seed|steps|cfg|denoise|sampler|scheduler/i.test(name)) { - return []; - } - if (typeof w.value === 'string' && w.value.trim().length > 0 && !w.options?.values) { - if (/prompt|caption|text|description|words|tags/i.test(name) || w.type === 'customtext' || w.type === 'string') { - return [{ index: idx, name: w.name || 'text' }]; - } - } - return []; - }); - } - + const targets = promptBoxes(node); if (!targets.length) { anomalousAlert(window.anomalous_browser_lang === 'zh' ? `⚠️ 选中的节点【${heading}】中未检测到有效的提示词文本输入!` @@ -263,7 +219,7 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa targets.forEach(t => { const rawVal = String(node.widgets[t.index]?.value || '').trim(); if (!rawVal) return; - const role = detectNodePromptRole(node, t.name); + const role = t.role === 'negative' ? 'negative' : 'positive'; const cat = role === 'negative' ? 'base' : categorizePromptSnippet(rawVal); const cardTitle = `${heading} · ${t.name}`; diff --git a/web/modules/ui_prompt_translator.js b/web/modules/ui_prompt_translator.js index a20b1b0..0694159 100644 --- a/web/modules/ui_prompt_translator.js +++ b/web/modules/ui_prompt_translator.js @@ -7,7 +7,8 @@ import { app } from '../../../scripts/app.js'; import { translatePromptText, splitPromptTags, normalizePromptFormatting, hasChinese } from './translation_service.js'; -import { selectedMaterialNode, promptWidgetTargets, applyNodeMaterialValues } from './node_material_actions.js'; +import { selectedMaterialNode, applyNodeMaterialValues } from './node_material_actions.js'; +import { promptBoxes } from './prompt_boxes.js'; import { createViewScope, bindDrawerResize } from './ui_lifecycle.js'; import { appendPromptToStudio } from './ui_prompt_composer.js'; @@ -52,85 +53,13 @@ function getCanvasSelectedNodes() { * @returns {{ text: string, widgetIndex: number, widgetName: string, nodeTitle: string } | null} */ export function extractPromptFromNode(node) { - if (!node || !Array.isArray(node.widgets)) return null; - - // 1. Try standard promptWidgetTargets - try { - const targets = promptWidgetTargets(node); - if (targets.length) { - const widget = node.widgets[targets[0].index]; - const val = typeof widget?.value === 'string' ? widget.value.trim() : ''; - if (val) { - return { - text: val, - widgetIndex: targets[0].index, - widgetName: targets[0].name || 'text', - nodeTitle: node.title || node.type || 'Node', - }; - } - } - } catch { - // ignore - } - - // 2. Check English and Chinese text widget names & labels - const promptNameRegex = /^(text|text_g|text_l|prompt|positive|negative|caption|string|value|文本|提示词|正面|负面|正向|反向|正面提示词|负面提示词|正向提示词|反向提示词|描述|内容)/i; - for (let i = 0; i < node.widgets.length; i++) { - const w = node.widgets[i]; - if (!w) continue; - const name = String(w.name || ''); - const label = String(w.label || ''); - const notCombo = !w.options?.values || !Array.isArray(w.options.values); - const isStringVal = typeof w.value === 'string'; - - if (isStringVal && notCombo && (promptNameRegex.test(name) || promptNameRegex.test(label) || w.type === 'customtext' || w.type === 'text' || !!w.options?.multiline)) { - const val = w.value.trim(); - if (val) { - return { - text: val, - widgetIndex: i, - widgetName: w.name || w.label || 'text', - nodeTitle: node.title || node.type || 'Node', - }; - } - } - } - - // 3. Fallback: Any string widget that is not a dropdown combo and has non-empty text - for (let i = 0; i < node.widgets.length; i++) { - const w = node.widgets[i]; - if (!w) continue; - const notCombo = !w.options?.values || !Array.isArray(w.options.values); - if (typeof w.value === 'string' && notCombo && w.value.trim().length > 0) { - return { - text: w.value.trim(), - widgetIndex: i, - widgetName: w.name || w.label || 'text', - nodeTitle: node.title || node.type || 'Node', - }; - } - } - - // 4. Fallback: node.widgets_values - if (Array.isArray(node.widgets_values)) { - for (let i = 0; i < node.widgets_values.length; i++) { - const val = node.widgets_values[i]; - if (typeof val === 'string' && val.trim().length > 0) { - const w = node.widgets[i]; - const notCombo = !w?.options?.values || !Array.isArray(w.options.values); - if (notCombo) { - return { - text: val.trim(), - widgetIndex: i, - widgetName: w?.name || w?.label || 'text', - nodeTitle: node.title || node.type || 'Node', - }; - } - } - } - } - - return null; + const box = promptBoxes(node).find(item => item.widget.value.trim()); + return box ? { + text: box.widget.value.trim(), + widgetIndex: box.index, + widgetName: box.name, + nodeTitle: node.title || node.type || 'Node', + } : null; } export function getCurrentlySelectedNode() { @@ -187,20 +116,9 @@ export function writeToSelectedNode(text) { }; } - const promptInfo = extractPromptFromNode(node); - let targetIndex = promptInfo ? promptInfo.widgetIndex : -1; - let targetName = promptInfo ? promptInfo.widgetName : 'text'; - - if (targetIndex < 0) { - const textWidgetIndex = (node.widgets || []).findIndex(w => - typeof w?.value === 'string' && - (!w.options?.values || Array.isArray(w.options.values) === false) - ); - if (textWidgetIndex >= 0) { - targetIndex = textWidgetIndex; - targetName = node.widgets[textWidgetIndex].name || node.widgets[textWidgetIndex].label || 'text'; - } - } + const box = promptBoxes(node).find(item => item.widget.value.trim()) || promptBoxes(node)[0]; + const targetIndex = box ? box.index : -1; + const targetName = box ? box.name : 'text'; if (targetIndex < 0) { return { @@ -643,7 +561,7 @@ export function openPromptTranslator(owner) { // Capture the exact destination before awaiting a translation. const graph = app.graph; const node = selectedMaterialNode(app); - const target = node && promptWidgetTargets(node)[0]; + const target = node && promptBoxes(node)[0]; if (!target) { showTranslatorToast(modal, t('请先选中可写入的提示词节点', 'Select a writable prompt node first'), true); return; diff --git a/web/modules/ui_prompt_workbench.js b/web/modules/ui_prompt_workbench.js index 9036d49..27c9a23 100644 --- a/web/modules/ui_prompt_workbench.js +++ b/web/modules/ui_prompt_workbench.js @@ -3,7 +3,8 @@ import { translate as t } from './locales.js'; import { text, jsonResponse } from './ui_dom.js'; import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; import { joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, workbenchDraftToSavedPlan } from './prompt_composition.js'; -import { applyNodeMaterialValues, promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js'; +import { applyNodeMaterialValues, selectedMaterialNode } from './node_material_actions.js'; +import { promptBoxes } from './prompt_boxes.js'; import { showMaterialApplication } from './ui_material_application.js'; import { showMaterialSaved } from './material_feedback.js'; import { translatePromptText } from './translation_service.js'; @@ -226,19 +227,15 @@ export function createPromptWorkbench(owner, container, scope, options) { const applyPromptToCurrentNode = async (role, promptContent, triggerBtn = null) => { const node = selectedMaterialNode(app); if (!node) { - anomalousAlert(window.anomalous_browser_lang === 'zh' - ? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode)!' - : '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!'); + anomalousAlert(t('promptSelectNodeFirst')); return; } - const targets = promptWidgetTargets(node); + const targets = promptBoxes(node); if (!targets.length) { - anomalousAlert(window.anomalous_browser_lang === 'zh' - ? '⚠️ 选中的节点中未找到可写入的提示词文本输入!' - : '⚠️ No writable prompt text widget found in selected node!'); + anomalousAlert(t('promptNodeHasNoBox')); return; } - const widgetIndex = targets[0].index; + const widgetIndex = (targets.find(box => box.role === role) || targets.find(box => !box.role) || targets[0]).index; try { const value = joinPromptText(node.widgets[widgetIndex].value, promptContent, posSelect.value); showMaterialApplication(view, applyNodeMaterialValues(app, node, [{ index: widgetIndex, value }]), node);