From f30f4e71819905f64eb22299cbb88b38aacea403 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Sat, 8 Aug 2026 15:18:44 +0800 Subject: [PATCH] feat: add conservative prompt role labels --- .agents/logs/ai_changelog.md | 13 + .agents/logs/ai_lessons.md | 6 + .../prompt_role_recognition_hardening_plan.md | 14 +- ARCHITECTURE.md | 8 +- api/parameters.py | 37 ++- web/modules/locales.js | 20 ++ web/modules/recipe_parser.js | 130 +++++----- web/modules/ui_doctor.js | 17 +- web/modules/ui_recipe_detail.js | 224 ++++++++++++++---- web/modules/ui_recipes.js | 16 ++ web/styles.css | 80 ++++++- 11 files changed, 434 insertions(+), 131 deletions(-) diff --git a/.agents/logs/ai_changelog.md b/.agents/logs/ai_changelog.md index 3bf07ab..f7a9fbf 100644 --- a/.agents/logs/ai_changelog.md +++ b/.agents/logs/ai_changelog.md @@ -1270,3 +1270,16 @@ - Added regression coverage for negative-role recovery and ambiguous prompt values. Validation: all JS syntax checks passed, the parser test passed, and the Python suite passed (17 tests). + +## [2026-08-08] Conservative Prompt Roles and Manual Labels + +- Limited automatic prompt-role analysis to native `CLIPTextEncode` and an explicit set of official sampler, Guider, and conditioning pass-through nodes. +- Removed title/descriptor guessing and the Node Assistant's default-positive fallback; unresolved and third-party text candidates now remain visibly unknown. +- Added per-node role controls in Recipe Parameters: automatic, positive, negative, shared positive/negative, and ignored. +- Persisted manual labels in `params.promptRoleOverrides`, guarded them with node type, retained valid labels across recipe refreshes, and shared them with the Node Assistant API. +- Fixed the Node Assistant recipe lookup to read the actual `params` field instead of the obsolete `metadata` field. +- Added `both` handling for one native text node connected to both official roles and changed role tracing to inspect all matching linked inputs. + +Validation: affected JavaScript modules passed `node --check`; the local parser regression passed; Python compilation passed; the local Python suite passed 19 tests; `git diff --check` passed. + +Handoff: automatic compatibility with arbitrary third-party text/conditioning nodes is intentionally not claimed. The registry/conflict-evidence stages remain in `.agents/plans/prompt_role_recognition_hardening_plan.md`. Pre-existing untracked `test.mjs` and `test2.mjs` remain untouched. diff --git a/.agents/logs/ai_lessons.md b/.agents/logs/ai_lessons.md index f2f71aa..bda2ea0 100644 --- a/.agents/logs/ai_lessons.md +++ b/.agents/logs/ai_lessons.md @@ -597,3 +597,9 @@ Keep visual identifiers stable during bug fixing. Restore the established 📦 t **The Problem**: A recipe saved before per-node prompt roles could still contain correct positive/negative prompt arrays, but the assistant had no role field to display and defaulted every `CLIPTextEncode` node to positive. **The Practice**: When adding a new derived metadata field, recover it from an existing authoritative value for legacy records. Require an exact unambiguous match, preserve explicit new metadata as the source of truth, and leave collisions unresolved rather than inventing a role. + +## 91. Unknown prompt semantics are a valid product state + +**The Problem**: Treating every text-like or `CLIPTextEncode`-named node as positive made the UI look decisive while silently reversing prompts in third-party workflows. Titles and type-name fragments are presentation data, not proof of graph semantics. + +**The Practice**: Keep the automatic contract as an explicit allowlist, stop traversal at unknown semantic nodes, and show an `unknown` state instead of guessing. Store user corrections separately from derived metadata, guard them with structural identity, and make every consuming surface honor the same override priority. diff --git a/.agents/plans/prompt_role_recognition_hardening_plan.md b/.agents/plans/prompt_role_recognition_hardening_plan.md index 4f74b58..bf2c465 100644 --- a/.agents/plans/prompt_role_recognition_hardening_plan.md +++ b/.agents/plans/prompt_role_recognition_hardening_plan.md @@ -1,9 +1,19 @@ # 工作流配方:提示词正负角色识别加固计划 -状态:规划完成,尚未实施 +状态:第一阶段已实施;第三方注册表与冲突证据留待后续 日期:2026-08-08 范围:工作流配方、参数笔记本、节点助手共用的提示词角色识别链路。 +## 2026-08-08 第一阶段落地范围 + +- 自动识别暂时只承诺 ComfyUI 原生 `CLIPTextEncode`,以及明确列入白名单的官方采样器、Guider 和 conditioning 传递节点。 +- 删除标题关键词和“CLIP 节点默认正面”的猜测逻辑;无法证明角色时统一显示 `unknown`。 +- 配方详情中的文本节点提供“自动、正面、负面、正负共用、忽略”人工标注。 +- 人工结果保存到 `params.promptRoleOverrides`,并由配方详情、参数笔记本与节点助手共同消费。 +- 更新现有配方时,仅在节点 ID 和类型仍匹配时继承人工标注,避免把旧标签错套到新骨架。 + +本阶段明确不承诺自动兼容所有第三方文本/conditioning 节点。第三方节点若能在保存摘要中暴露常见文本控件,会作为“角色未确定”的候选项供用户手动标注;完全不暴露可识别文本控件的节点仍只保留在完整工作流中。 + ## 目标 把“尽量猜出正面/负面”改为“有证据才标注”。系统不必识别所有第三方节点,但绝不能把不确定的提示词错误标为正面或负面。 @@ -22,7 +32,7 @@ ## 已知事实与当前缺口 -当前保存的新配方会在 `metadata.nodes[].role` 中写入角色;旧配方没有这项数据时,后端已增加“完整提示词文本与 `promptPositive` / `promptNegative` 精确且唯一匹配”的兼容恢复。这修复了旧配方被节点助手一律展示为正面的实际问题。 +当前保存的新配方会在 `params.nodes[].role` 中写入角色;旧配方没有这项数据时,后端已增加“完整提示词文本与 `promptPositive` / `promptNegative` 精确且唯一匹配”的兼容恢复。这修复了旧配方被节点助手一律展示为正面的实际问题。 但现有策略仍不能保证完美: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ae7cfd5..872254a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -314,10 +314,16 @@ The Node Assistant consumes parameter notebooks by node type, while the Recipe d Applying a node preset is a transactional live-widget operation: volatile seed slots are ignored, values are cloned safely, widget callbacks and ComfyUI's four-argument `onWidgetChanged(index, value, previousValue, liveWidget)` hook are invoked only when callable, and callback failures roll back the node before the UI reports failure. A successful write marks both graph and canvas dirty and emits `graphChanged` so recipe and assistant surfaces can refresh. -Prompt roles are derived from linked conditioning inputs by bounded upstream traversal. Link storage may be an array, object, or `Map`, and a named input without a link must not mask another linked input with the same semantic role. Saved role metadata is joined to assistant records using string-safe node IDs; legacy recipes still fall back to descriptor-based classification and retain all discoverable positive/negative prompts. +Prompt roles are derived only from an explicit allowlist of ComfyUI prompt nodes, consumers, and conditioning pass-through nodes. Link storage may be an array, object, or `Map`, and every linked role input is inspected. Unknown third-party nodes are opaque boundaries: neither their title nor their type name may turn an unresolved text into a positive or negative prompt. Saved role metadata is joined to assistant records using string-safe node IDs; legacy recipes use exact, unambiguous full-value recovery and otherwise remain unknown. The assistant must not call removed doctor-panel methods from canvas selection hooks. Existing host selection callbacks are preserved, and the assistant hook only updates its own panel. ## 72. Legacy Prompt Roles Are Recovered from Full Prompt Values Older recipes may contain correct `params.promptPositive` and `params.promptNegative` arrays but no per-node `role` field. When the Node Assistant prepares a node-type preset response, the backend compares the node's complete saved text widget value against those arrays and injects a role only when the match is unambiguous. An identical value present in both arrays remains unclassified rather than being guessed; explicit metadata roles always take precedence. + +## 73. Prompt Roles Are Conservative and User-Correctable + +The first supported automatic contract is intentionally narrow: ComfyUI's native `CLIPTextEncode` can receive a role only through allowlisted official sampler/Guider inputs and allowlisted conditioning pass-through nodes. An unresolved native prompt is `unknown`; a third-party text candidate is also `unknown` unless the user labels it. The detail panel states this limitation directly and never uses node titles, prompt content, or a default-positive rule as semantic evidence. + +Manual labels are recipe-skeleton metadata stored in `params.promptRoleOverrides`, keyed by string-safe node ID and guarded by the saved node type. Supported values are `positive`, `negative`, `both`, `ignored`, and `unknown`; removing an override restores the saved automatic result. Recipe updates retain an override only while node ID and type still match. The Parameters view and Node Assistant must consume the recipe override before automatic or legacy metadata, so all surfaces show the same role. Future third-party adapters may expand the allowlist, but unknown nodes must remain conservative until explicitly supported. diff --git a/api/parameters.py b/api/parameters.py index 1ea7ace..573470f 100644 --- a/api/parameters.py +++ b/api/parameters.py @@ -55,9 +55,30 @@ def _invalidate_parameter_notebooks_cache(): _parameter_notebooks_cache_time = 0 def _prompt_role_for_node(node, params): - """Recover prompt roles for legacy recipes that predate metadata node roles.""" + """Return an explicit role, or conservatively recover one from legacy values.""" if not isinstance(node, dict) or not isinstance(params, dict): return None + allowed_roles = {"positive", "negative", "both", "ignored", "unknown"} + node_id = node.get("id") + overrides = params.get("promptRoleOverrides") + if isinstance(overrides, dict): + override = overrides.get(str(node_id)) + if override is None: + override = overrides.get(node_id) + if isinstance(override, dict) and override.get("role") in allowed_roles: + expected_type = override.get("nodeType") + if not expected_type or expected_type == node.get("type"): + return override["role"] + + summaries = params.get("nodes") + if isinstance(summaries, list): + for summary in summaries: + if not isinstance(summary, dict) or str(summary.get("id")) != str(node_id): + continue + if summary.get("role") in allowed_roles: + return summary["role"] + break + values = node.get("widgets_values") if not isinstance(values, list) or not values: return None @@ -148,11 +169,17 @@ async def api_get_parameters_by_type(request): try: r_data = _read_recipe(resolve_within(recipes_dir, r_fn)) recipe_name_cache[r_fn] = r_data.get("name") if r_data else r_fn - if r_data and isinstance(r_data.get("metadata"), dict) and isinstance(r_data["metadata"].get("nodes"), list): + recipe_params = r_data.get("params") if isinstance(r_data, dict) else None + if r_data and isinstance(recipe_params, dict): role_map = {} - for n in r_data["metadata"]["nodes"]: - if isinstance(n, dict) and "id" in n and "role" in n: - role_map[n["id"]] = n["role"] + workflow = r_data.get("workflow") + workflow_nodes = workflow.get("nodes", []) if isinstance(workflow, dict) else [] + for n in workflow_nodes: + if not isinstance(n, dict) or "id" not in n: + continue + role = _prompt_role_for_node(n, recipe_params) + if role is not None: + role_map[n["id"]] = role recipe_role_cache[r_fn] = role_map except Exception: recipe_name_cache[r_fn] = r_fn diff --git a/web/modules/locales.js b/web/modules/locales.js index dcf0ded..0e50197 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -568,6 +568,16 @@ export const i18n = { recipeDetailPositivePrompt: '正向提示词', recipeDetailNegativePrompt: '负面提示词', recipeDetailNoPrompts: '此配方没有可安全展示的提示词摘要。', + recipePromptSupportNotice: '当前仅自动识别 ComfyUI 原生 CLIPTextEncode 与已知官方连接;第三方文本节点请手动标注。', + recipePromptRoleChoose: '选择提示词角色', + recipePromptRoleAutomatic: '自动识别', + recipePromptRoleManual: '用户手动标注', + recipePromptRolePositive: '正面提示词', + recipePromptRoleNegative: '负面提示词', + recipePromptRoleBoth: '正负共用', + recipePromptRoleIgnored: '忽略', + recipePromptRoleUnknown: '角色未确定', + recipePromptRoleSaveError: '无法保存提示词角色,请稍后重试。', recipeDetailVolatileIgnored: '运行时变化,已忽略', recipeDetailExpandValue: '展开完整内容', recipeDetailCollapseValue: '收起内容', @@ -1200,6 +1210,16 @@ export const i18n = { recipeDetailPositivePrompt: 'Positive prompt', recipeDetailNegativePrompt: 'Negative prompt', recipeDetailNoPrompts: 'This recipe has no safe prompt summary to show.', + recipePromptSupportNotice: 'Automatic detection currently supports ComfyUI CLIPTextEncode and known official connections only. Label third-party text nodes manually.', + recipePromptRoleChoose: 'Choose prompt role', + recipePromptRoleAutomatic: 'Automatic', + recipePromptRoleManual: 'Manually labelled', + recipePromptRolePositive: 'Positive prompt', + recipePromptRoleNegative: 'Negative prompt', + recipePromptRoleBoth: 'Shared positive / negative', + recipePromptRoleIgnored: 'Ignore', + recipePromptRoleUnknown: 'Role unknown', + recipePromptRoleSaveError: 'Could not save the prompt role. Please try again.', recipeDetailVolatileIgnored: 'Runtime-changing value ignored', recipeDetailExpandValue: 'Show full value', recipeDetailCollapseValue: 'Collapse value', diff --git a/web/modules/recipe_parser.js b/web/modules/recipe_parser.js index fc7090e..1c90a03 100644 --- a/web/modules/recipe_parser.js +++ b/web/modules/recipe_parser.js @@ -11,6 +11,28 @@ const MAX_WIDGET_TEXT = 320; const MAX_PINNED_VALUE_JSON = 2400; const MAX_PARAMETER_CHOICES = 5000; const SENSITIVE_WIDGET_NAME = /(?:api.?key|access.?token|auth|password|passwd|secret|credential)/i; +const SUPPORTED_PROMPT_NODE_TYPES = new Set([ + 'cliptextencode', +]); +const SUPPORTED_PROMPT_CONSUMERS = new Map([ + ['ksampler', { positive: 'positive', negative: 'negative' }], + ['ksampleradvanced', { positive: 'positive', negative: 'negative' }], + ['cfgguider', { positive: 'positive', negative: 'negative' }], + ['basicguider', { conditioning: 'positive' }], + ['dualcfgguider', { cond1: 'positive', cond2: 'positive', negative: 'negative' }], +]); +const SUPPORTED_CONDITIONING_PASSTHROUGH = new Set([ + 'conditioningaverage', + 'conditioningcombine', + 'conditioningconcat', + 'conditioningmultiply', + 'conditioningsetarea', + 'conditioningsetareapercentage', + 'conditioningsetareastrength', + 'conditioningsetmask', + 'conditioningsettimesteprange', + 'conditioningzeroout', +]); function nodeType(node) { return String(node?.type || node?.comfyClass || ''); @@ -20,6 +42,10 @@ function normaliseName(value) { return String(value || '').trim().toLowerCase(); } +export function isSupportedPromptNodeType(value) { + return SUPPORTED_PROMPT_NODE_TYPES.has(normaliseName(value)); +} + function widgetValue(node, names, fallbackIndex = -1) { const wanted = new Set(names.map(normaliseName)); const widgets = Array.isArray(node?.widgets) ? node.widgets : []; @@ -186,7 +212,7 @@ function syncCommonRecipeMetadata(params, change) { const field = { seed: 'seed', noise_seed: 'seed', steps: 'steps', cfg: 'cfg', cfg_scale: 'cfg', sampler_name: 'sampler_name', sampler: 'sampler_name', scheduler: 'scheduler', denoise: 'denoise' }[widget]; if (field) params[field] = ['steps', 'cfg', 'denoise'].includes(field) ? numberValue(value) : value; } - if (/cliptextencode/.test(type) && /^(text|prompt)$/.test(widget)) { + if (isSupportedPromptNodeType(type) && /^(text|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); @@ -199,10 +225,13 @@ function syncCommonRecipeMetadata(params, change) { negative[negativePosition] = textValue(value); return; } - const descriptor = `${change.nodeTitle || ''} ${change.nodeType || ''}`; - const target = /(negative|neg|负面|反向)/i.test(descriptor) ? negative : positive; + const summary = (params.nodes || []).find((item) => String(item?.id) === String(change.nodeId)); + const override = params.promptRoleOverrides?.[String(change.nodeId)]?.role; + const role = ['positive', 'negative', 'both'].includes(override) ? override : summary?.role; const prompt = textValue(value); - if (prompt && !target.includes(prompt)) target.push(prompt); + if (!prompt) return; + if ((role === 'positive' || role === 'both') && !positive.includes(prompt)) positive.push(prompt); + if ((role === 'negative' || role === 'both') && !negative.includes(prompt)) negative.push(prompt); } } @@ -246,15 +275,15 @@ function findLink(graph, linkId) { return links?.[linkId] || null; } -function inputLinkOrigin(graph, node, inputNames) { +function inputLinkOrigins(graph, node, inputNames) { const wanted = new Set(inputNames.map(normaliseName)); - const input = (node?.inputs || []).find((item) => ( - wanted.has(normaliseName(item?.name)) - && item?.link !== null - && item?.link !== undefined - )); - const link = findLink(graph, input?.link); - return linkOriginId(link); + const origins = []; + for (const input of node?.inputs || []) { + if (!wanted.has(normaliseName(input?.name)) || input?.link === null || input?.link === undefined) continue; + const origin = linkOriginId(findLink(graph, input.link)); + if (origin !== null && origin !== undefined && !origins.includes(origin)) origins.push(origin); + } + return origins; } function collectConditioningNodes(graph, startNodeId, nodeIndex) { @@ -269,12 +298,19 @@ function collectConditioningNodes(graph, startNodeId, nodeIndex) { const node = nodeIndex.get(String(nodeId)); if (!node) continue; - if (/cliptextencode/i.test(nodeType(node))) { + if (isSupportedPromptNodeType(nodeType(node))) { if (!clipNodes.includes(node)) clipNodes.push(node); continue; } + // Unknown third-party conditioning nodes are deliberately opaque. + // Users can label their text nodes manually in the recipe detail view. + if (!SUPPORTED_CONDITIONING_PASSTHROUGH.has(normaliseName(nodeType(node)))) continue; + for (const input of node.inputs || []) { + const inputType = normaliseName(input?.type); + const inputName = normaliseName(input?.name); + if (inputType !== 'conditioning' && !inputName.includes('conditioning')) continue; const originId = linkOriginId(findLink(graph, input?.link)); if (originId !== null && originId !== undefined && !visited.has(originId)) { queue.push(originId); @@ -371,23 +407,15 @@ export function extractRecipeMetadata(graph) { mergeSampler(metadata, recipeSampler(node)); } - const posInputs = []; - const negInputs = []; - for (const input of node.inputs || []) { - const name = (input.name || '').toLowerCase(); - if (name.includes('positive') || name === 'guider' || (name.includes('cond') && !name.includes('uncond'))) posInputs.push(input.name); - if (name.includes('negative') || name.includes('uncond')) negInputs.push(input.name); - } - - if (posInputs.length > 0) { - const origin = inputLinkOrigin(graph, node, posInputs); - const clips = collectConditioningNodes(graph, origin, nodeIndex); - for (const clip of clips) { if (!positiveNodes.includes(clip)) positiveNodes.push(clip); } - } - if (negInputs.length > 0) { - const origin = inputLinkOrigin(graph, node, negInputs); - const clips = collectConditioningNodes(graph, origin, nodeIndex); - for (const clip of clips) { if (!negativeNodes.includes(clip)) negativeNodes.push(clip); } + const roleInputs = SUPPORTED_PROMPT_CONSUMERS.get(normaliseName(type)); + if (roleInputs) { + for (const [inputName, role] of Object.entries(roleInputs)) { + for (const origin of inputLinkOrigins(graph, node, [inputName])) { + const clips = collectConditioningNodes(graph, origin, nodeIndex); + const target = role === 'negative' ? negativeNodes : positiveNodes; + for (const clip of clips) if (!target.includes(clip)) target.push(clip); + } + } } if (/empty.*latent/i.test(type) && !metadata.resolution) { @@ -398,34 +426,30 @@ export function extractRecipeMetadata(graph) { } for (const summary of metadata.nodes) { - if (!/cliptextencode/i.test(summary.type)) continue; + if (!isSupportedPromptNodeType(summary.type)) continue; const node = nodeIndex.get(String(summary.id)); const prompt = textValue(widgetValue(node, ['text', 'prompt'], 0)); - - if (positiveNodes.includes(node)) { - summary.role = 'positive'; - if (prompt) appendUnique(metadata.promptPositive, [prompt]); - } else if (negativeNodes.includes(node)) { - summary.role = 'negative'; - if (prompt) appendUnique(metadata.promptNegative, [prompt]); - } - } + const isPositive = positiveNodes.includes(node); + const isNegative = negativeNodes.includes(node); - if (!metadata.promptPositive.length && !metadata.promptNegative.length) { - for (const summary of metadata.nodes) { - if (!/cliptextencode/i.test(summary.type)) continue; - if (summary.role) continue; - const node = nodeIndex.get(String(summary.id)); - const prompt = textValue(widgetValue(node, ['text', 'prompt'], 0)); - if (!prompt) continue; - const descriptor = `${summary.title || ''} ${summary.type}`; - if (/(negative|neg|负面|反向|uncond)/i.test(descriptor)) { - summary.role = 'negative'; - appendUnique(metadata.promptNegative, [prompt]); - } else { - summary.role = 'positive'; + if (isPositive && isNegative) { + summary.role = 'both'; + summary.roleSource = 'topology'; + if (prompt) { appendUnique(metadata.promptPositive, [prompt]); + appendUnique(metadata.promptNegative, [prompt]); } + } else if (isPositive) { + summary.role = 'positive'; + summary.roleSource = 'topology'; + if (prompt) appendUnique(metadata.promptPositive, [prompt]); + } else if (isNegative) { + summary.role = 'negative'; + summary.roleSource = 'topology'; + if (prompt) appendUnique(metadata.promptNegative, [prompt]); + } else { + summary.role = 'unknown'; + summary.roleSource = 'unresolved'; } } return metadata; diff --git a/web/modules/ui_doctor.js b/web/modules/ui_doctor.js index 6e8885a..fedad7b 100644 --- a/web/modules/ui_doctor.js +++ b/web/modules/ui_doctor.js @@ -1572,14 +1572,15 @@ export function renderParameterPresets(node, container, forceRefresh = false) { summary += ` - ${n.title}${valStr}`; } - let roleTag = ''; - if (n.role === 'positive') roleTag = '[🟢 正面] '; - else if (n.role === 'negative') roleTag = '[🔴 负面] '; - else { - const descriptor = `${n.title || ''} ${n.type || ''}`; - if (/(negative|neg|负面|反向|uncond)/i.test(descriptor)) roleTag = '[🔴 负面] '; - else if (/cliptextencode/i.test(n.type || '')) roleTag = '[🟢 正面] '; - } + const promptRoleTag = { + positive: `[🟢 ${t('recipePromptRolePositive')}] `, + negative: `[🔴 ${t('recipePromptRoleNegative')}] `, + both: `[🟣 ${t('recipePromptRoleBoth')}] `, + ignored: `[⚪ ${t('recipePromptRoleIgnored')}] `, + unknown: `[⚪ ${t('recipePromptRoleUnknown')}] `, + }[n.role]; + const roleTag = promptRoleTag + || (/cliptextencode/i.test(n.type || '') ? `[⚪ ${t('recipePromptRoleUnknown')}] ` : ''); const displayHtml = `✨ ${roleTag}${escapeHtml(summary)}`; applyBtn.innerHTML = displayHtml; diff --git a/web/modules/ui_recipe_detail.js b/web/modules/ui_recipe_detail.js index 2baa9b8..2c53b81 100644 --- a/web/modules/ui_recipe_detail.js +++ b/web/modules/ui_recipe_detail.js @@ -14,8 +14,11 @@ import { applyRecipeParametersToCanvas, assertRecipeSkeleton, } from './recipe_actions.js'; -import { applyRecipeWidgetChanges } from './recipe_parser.js'; -import { captureRecipeDraft } from './recipe_parser.js'; +import { + applyRecipeWidgetChanges, + captureRecipeDraft, + isSupportedPromptNodeType, +} from './recipe_parser.js'; const t = (key, params) => translate(key, params); @@ -228,31 +231,95 @@ function appendValueViewer(parent, value, className = '', options = {}) { return viewer; } -function promptValues(recipe) { - const params = recipe?.params || {}; - const positive = [...new Set((Array.isArray(params?.promptPositive) ? params.promptPositive : [params?.promptPositive]) - .filter((value) => typeof value === 'string' && value.trim()))]; - const negative = [...new Set((Array.isArray(params?.promptNegative) ? params.promptNegative : [params?.promptNegative]) - .filter((value) => typeof value === 'string' && value.trim()))]; - for (const node of params?.nodes || []) { - if (!/cliptextencode/i.test(String(node?.type || ''))) continue; - const widget = (node.widgets || []).find((candidate) => /^(text|prompt)$/i.test(String(candidate?.name || ''))); - const text = widget ? fullWidgetValue(recipe, node, widget) : null; - if (typeof text !== 'string' || !text.trim()) continue; - if (node.role === 'negative') { - if (!negative.includes(text)) negative.push(text); - } else if (node.role === 'positive') { - if (!positive.includes(text)) positive.push(text); - } else { - const descriptor = `${node?.title || ''} ${node?.type || ''}`; - if (/(negative|neg|负面|反向|uncond)/i.test(descriptor)) { - if (!negative.includes(text)) negative.push(text); - } else if (!positive.includes(text)) { - positive.push(text); - } - } +const PROMPT_ROLES = new Set(['positive', 'negative', 'both', 'ignored', 'unknown']); +const PROMPT_WIDGET_NAME = /^(?:text|prompt|text_[gl]|positive|negative)$/i; + +function promptTextForNode(source, node) { + const values = []; + for (const widget of node?.widgets || []) { + if (!PROMPT_WIDGET_NAME.test(String(widget?.name || ''))) continue; + const value = fullWidgetValue(source, node, widget); + if (typeof value === 'string' && value.trim() && !values.includes(value.trim())) values.push(value.trim()); } - return { positive, negative }; + if (!values.length && isSupportedPromptNodeType(node?.type)) { + const workflowNode = (source?.workflow?.nodes || []).find((candidate) => String(candidate?.id) === String(node?.id)); + const value = workflowNode?.widgets_values?.find((candidate) => typeof candidate === 'string' && candidate.trim()); + if (value) values.push(value.trim()); + } + return values.join('\n\n'); +} + +function legacyPromptRole(params, text) { + if (!text) return null; + const positive = new Set(Array.isArray(params?.promptPositive) ? params.promptPositive : []); + const negative = new Set(Array.isArray(params?.promptNegative) ? params.promptNegative : []); + if (positive.has(text) && !negative.has(text)) return 'positive'; + if (negative.has(text) && !positive.has(text)) return 'negative'; + return null; +} + +function promptEntries(source, roleOwner = source) { + const params = source?.params || {}; + const roleParams = roleOwner?.params || {}; + const overrides = roleParams.promptRoleOverrides || {}; + const entries = []; + for (const node of params.nodes || []) { + const text = promptTextForNode(source, node); + if (!text) continue; + const isKnown = isSupportedPromptNodeType(node?.type); + const isTextCandidate = isKnown || (node.widgets || []).some((widget) => PROMPT_WIDGET_NAME.test(String(widget?.name || ''))); + if (!isTextCandidate) continue; + const override = overrides[String(node.id)]?.role; + const automaticRole = PROMPT_ROLES.has(node.role) + ? node.role + : (isKnown ? legacyPromptRole(params, text) : null); + entries.push({ + id: node.id, + type: node.type || 'Unknown', + title: node.title || node.type || t('recipeDetailUnknownNode'), + text, + supported: isKnown, + automaticRole: automaticRole || 'unknown', + role: PROMPT_ROLES.has(override) ? override : (automaticRole || 'unknown'), + manual: PROMPT_ROLES.has(override), + }); + } + return entries; +} + +function promptValues(source, roleOwner = source) { + const positive = []; + const negative = []; + const entries = promptEntries(source, roleOwner); + for (const entry of entries) { + if ((entry.role === 'positive' || entry.role === 'both') && !positive.includes(entry.text)) positive.push(entry.text); + if ((entry.role === 'negative' || entry.role === 'both') && !negative.includes(entry.text)) negative.push(entry.text); + } + return { positive, negative, entries }; +} + +function paramsWithPromptRole(recipe, nodeId, selectedRole) { + const params = JSON.parse(JSON.stringify(recipe?.params || {})); + const overrides = { ...(params.promptRoleOverrides || {}) }; + const key = String(nodeId); + if (selectedRole === 'auto') { + delete overrides[key]; + } else if (PROMPT_ROLES.has(selectedRole)) { + const workflowNode = (recipe?.workflow?.nodes || []).find((node) => String(node?.id) === key); + overrides[key] = { + role: selectedRole, + nodeType: workflowNode?.type || (params.nodes || []).find((node) => String(node?.id) === key)?.type || 'Unknown', + source: 'manual', + }; + } + if (Object.keys(overrides).length) params.promptRoleOverrides = overrides; + else delete params.promptRoleOverrides; + + const owner = { ...recipe, params }; + const resolved = promptEntries(owner, owner); + params.promptPositive = [...new Set(resolved.filter((entry) => entry.role === 'positive' || entry.role === 'both').map((entry) => entry.text))]; + params.promptNegative = [...new Set(resolved.filter((entry) => entry.role === 'negative' || entry.role === 'both').map((entry) => entry.text))]; + return params; } function fullWidgetValue(recipe, node, widget) { @@ -1525,6 +1592,87 @@ function renderParameterNotebookEditor(wrapper, owner, recipe, parameterState, s wrapper.appendChild(editor); } +function promptRoleLabel(role) { + return t({ + positive: 'recipePromptRolePositive', + negative: 'recipePromptRoleNegative', + both: 'recipePromptRoleBoth', + ignored: 'recipePromptRoleIgnored', + unknown: 'recipePromptRoleUnknown', + }[role] || 'recipePromptRoleUnknown'); +} + +function renderPromptSection(parent, owner, recipe, source, rerender) { + const prompts = promptValues(source, recipe); + if (!prompts.entries.length) { + appendText(parent, 'p', t('recipeDetailNoPrompts'), 'anomalous-recipe-detail-muted'); + return; + } + + const heading = document.createElement('div'); + heading.className = 'anomalous-recipe-detail-section-heading'; + appendText(heading, 'h5', t('recipeDetailPrompts')); + appendText(heading, 'small', t('recipePromptSupportNotice'), 'anomalous-recipe-detail-muted'); + parent.appendChild(heading); + + const promptList = document.createElement('div'); + promptList.className = 'anomalous-recipe-detail-prompt-list'; + for (const entry of prompts.entries) { + const card = document.createElement('article'); + card.className = `anomalous-recipe-detail-prompt anomalous-recipe-prompt-role-${entry.role}`; + + const meta = document.createElement('div'); + meta.className = 'anomalous-recipe-prompt-meta'; + appendText(meta, 'strong', entry.title, 'anomalous-recipe-detail-prompt-label'); + appendText(meta, 'small', entry.type, 'anomalous-recipe-detail-muted'); + const badge = appendText(meta, 'span', promptRoleLabel(entry.role), `anomalous-recipe-prompt-role-badge is-${entry.role}`); + badge.title = entry.manual ? t('recipePromptRoleManual') : t('recipePromptRoleAutomatic'); + + const roleSelect = document.createElement('select'); + roleSelect.className = 'anomalous-recipe-prompt-role-select'; + roleSelect.setAttribute('aria-label', t('recipePromptRoleChoose')); + const choices = [ + ['auto', `${t('recipePromptRoleAutomatic')} · ${promptRoleLabel(entry.automaticRole)}`], + ['positive', t('recipePromptRolePositive')], + ['negative', t('recipePromptRoleNegative')], + ['both', t('recipePromptRoleBoth')], + ['unknown', t('recipePromptRoleUnknown')], + ['ignored', t('recipePromptRoleIgnored')], + ]; + for (const [value, label] of choices) { + const option = document.createElement('option'); + option.value = value; + option.textContent = label; + roleSelect.appendChild(option); + } + roleSelect.value = entry.manual ? entry.role : 'auto'; + roleSelect.onchange = async () => { + const previous = entry.manual ? entry.role : 'auto'; + roleSelect.disabled = true; + card.classList.add('is-saving'); + try { + const params = paramsWithPromptRole(recipe, entry.id, roleSelect.value); + await updateInlineRecipeMetadata(owner, recipe, { params }); + rerender?.(); + } catch (error) { + console.error('Could not update prompt role:', error); + roleSelect.value = previous; + roleSelect.disabled = false; + card.classList.remove('is-saving'); + await anomalousAlert(t('recipePromptRoleSaveError')); + } + }; + meta.appendChild(roleSelect); + + const value = document.createElement('div'); + value.className = 'anomalous-recipe-prompt-value'; + appendValueViewer(value, entry.text); + card.append(meta, value); + promptList.appendChild(card); + } + parent.appendChild(promptList); +} + function renderRecipeParameters(content, owner, recipe, gallery, refreshGallery, parameterState, selectParameterTab) { const selectedNotebook = parameterState?.notebooks?.find((item) => item.filename === parameterState.selectedFilename); const baseSource = selectedNotebook?.data?.workflow ? selectedNotebook.data : recipe; @@ -1718,29 +1866,7 @@ function renderRecipeParameters(content, owner, recipe, gallery, refreshGallery, intro.appendChild(introHeading); appendText(intro, 'p', t('recipeDetailParametersHint'), 'anomalous-recipe-detail-muted'); - const prompts = promptValues(source); - if (prompts.positive.length || prompts.negative.length) { - appendText(intro, 'h5', t('recipeDetailPrompts')); - const promptList = document.createElement('div'); - promptList.className = 'anomalous-recipe-detail-prompt-list'; - if (prompts.positive.length) { - const positive = document.createElement('div'); - positive.className = 'anomalous-recipe-detail-prompt'; - appendText(positive, 'span', t('recipeDetailPositivePrompt'), 'anomalous-recipe-detail-prompt-label'); - appendValueViewer(positive, prompts.positive.join('\n\n')); - promptList.appendChild(positive); - } - if (prompts.negative.length) { - const negative = document.createElement('div'); - negative.className = 'anomalous-recipe-detail-prompt anomalous-recipe-detail-prompt-negative'; - appendText(negative, 'span', t('recipeDetailNegativePrompt'), 'anomalous-recipe-detail-prompt-label'); - appendValueViewer(negative, prompts.negative.join('\n\n')); - promptList.appendChild(negative); - } - intro.appendChild(promptList); - } else { - appendText(intro, 'p', t('recipeDetailNoPrompts'), 'anomalous-recipe-detail-muted'); - } + renderPromptSection(intro, owner, recipe, source, selectParameterTab); const params = source?.params || {}; const summary = document.createElement('section'); diff --git a/web/modules/ui_recipes.js b/web/modules/ui_recipes.js index 7c27cf4..fad92b8 100644 --- a/web/modules/ui_recipes.js +++ b/web/modules/ui_recipes.js @@ -859,6 +859,22 @@ export async function handleSaveRecipe() { const details = await showRecipeSaveDialog(this, canvasThumbnail, editing?.data || null); if (!details) return; + // Prompt-role labels belong to the recipe skeleton. Preserve labels whose + // node id and type still match when an existing recipe is refreshed from + // the live canvas; stale labels are discarded safely. + const previousPromptRoles = editing?.data?.params?.promptRoleOverrides; + if (previousPromptRoles && typeof previousPromptRoles === 'object') { + const workflowNodes = new Map((draft.workflow.nodes || []).map((node) => [String(node?.id), node])); + const retained = {}; + for (const [nodeId, override] of Object.entries(previousPromptRoles)) { + const node = workflowNodes.get(String(nodeId)); + if (!node || !override || typeof override !== 'object') continue; + if (override.nodeType && override.nodeType !== node.type) continue; + retained[nodeId] = { ...override, nodeType: node.type || override.nodeType || 'Unknown' }; + } + if (Object.keys(retained).length) draft.metadata.promptRoleOverrides = retained; + } + const saveButton = this.recipeView?.querySelector('[data-recipe-save-current]'); if (saveButton) { saveButton.disabled = true; diff --git a/web/styles.css b/web/styles.css index 35f20b2..1080c52 100644 --- a/web/styles.css +++ b/web/styles.css @@ -2569,19 +2569,73 @@ dialog.anomalous-gallery-viewer[open] { gap: 8px; } -.anomalous-recipe-detail-prompt { - display: grid; - grid-template-columns: minmax(110px, 0.24fr) minmax(0, 1fr); - gap: 9px; - align-items: start; - padding: 9px; - border-left: 3px solid #6ba7ff; - border-radius: 7px; - background: rgba(0, 0, 0, 0.14); -} - -.anomalous-recipe-detail-prompt-negative { border-left-color: #df8d9a; } -.anomalous-recipe-detail-prompt-label { padding-top: 5px; color: #d7e6ff; font-size: 0.85rem; } +.anomalous-recipe-detail-prompt { + display: grid; + grid-template-columns: minmax(170px, 0.28fr) minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 11px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-left: 3px solid #7d8796; + border-radius: 9px; + background: rgba(0, 0, 0, 0.14); + transition: opacity 0.18s ease, border-color 0.18s ease, background 0.18s ease; +} + +.anomalous-recipe-detail-prompt.is-saving { opacity: 0.55; } +.anomalous-recipe-prompt-role-positive { border-left-color: #6ee7b7; } +.anomalous-recipe-prompt-role-negative { border-left-color: #df8d9a; } +.anomalous-recipe-prompt-role-both { border-left-color: #c4a7ff; } +.anomalous-recipe-prompt-role-ignored, +.anomalous-recipe-prompt-role-unknown { border-left-color: #7d8796; } +.anomalous-recipe-detail-prompt-label { padding-top: 5px; color: #d7e6ff; font-size: 0.85rem; } + +.anomalous-recipe-prompt-meta { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.anomalous-recipe-prompt-meta strong, +.anomalous-recipe-prompt-meta small { + max-width: 100%; + overflow-wrap: anywhere; +} + +.anomalous-recipe-prompt-role-badge { + display: inline-flex; + width: fit-content; + padding: 2px 7px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; + color: #cbd5e1; + background: rgba(255, 255, 255, 0.05); + font-size: 0.72rem; +} + +.anomalous-recipe-prompt-role-badge.is-positive { color: #9af0d0; border-color: rgba(110, 231, 183, 0.35); } +.anomalous-recipe-prompt-role-badge.is-negative { color: #f2a8b3; border-color: rgba(223, 141, 154, 0.38); } +.anomalous-recipe-prompt-role-badge.is-both { color: #d6c4ff; border-color: rgba(196, 167, 255, 0.4); } + +.anomalous-recipe-prompt-role-select { + width: 100%; + min-width: 0; + padding: 6px 28px 6px 8px; + border: 1px solid rgba(159, 196, 255, 0.3); + border-radius: 7px; + color: var(--input-text, #e8eef7); + background: var(--comfy-input-bg, #24272d); + cursor: pointer; +} + +.anomalous-recipe-prompt-role-select:focus-visible { + outline: 2px solid rgba(80, 170, 255, 0.7); + outline-offset: 1px; +} + +.anomalous-recipe-prompt-value { min-width: 0; } .anomalous-recipe-version-row { justify-content: space-between;