diff --git a/js/index.js b/js/index.js index 7cc0019..ed8fba9 100644 --- a/js/index.js +++ b/js/index.js @@ -293,6 +293,9 @@ app.registerExtension({ // 【新增】如果需要重置标记(子图切换场景),立即扫描现有节点 if (resetFlags) { + // 切换图时,先清理当前 UI 实例(不清理缓存),防止 ID 冲突的残留 + promptAssistant.cleanup(null, true); + const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true; const delay = isVueMode ? 300 : 100; @@ -313,7 +316,8 @@ app.registerExtension({ }); if (nodes.length > 0) { - logger.debug(`[graphSwitch] 自动扫描完成 | 节点数: ${nodes.length}`); + const gId = graph.id || graph._workflow_id || 'main'; + logger.debug(`[graphSwitch] 自动扫描完成 | 节点数: ${nodes.length} | Graph: ${gId}`); } }, delay); } @@ -358,7 +362,7 @@ app.registerExtension({ // ---其他方法保持不变--- async _setupOtherMethods() { - + const self = this; // 仅保留工作流ID识别功能,不处理工作流切换事件 try { const LGraph = app.graph.constructor; @@ -392,7 +396,7 @@ app.registerExtension({ if (app.graph && app.graph._nodes) { app.graph._nodes.forEach(node => { if (node && node.id !== -1) { - this._handleNodeActive(node, { delay: false }); + self._handleNodeActive(node, { delay: false }); } }); } diff --git a/js/modules/PromptAssistant.js b/js/modules/PromptAssistant.js index f874713..0c50a37 100644 --- a/js/modules/PromptAssistant.js +++ b/js/modules/PromptAssistant.js @@ -299,6 +299,18 @@ class PromptAssistant { this.initialized = false; } + /** + * 【核心优化】统一获取助手实例的唯一键名 + * 解决子图节点 ID 冲突及不同扫描模式下的键名不一致问题 + */ + _getAssistantKey(node, inputId) { + if (!node) return null; + const graph = node.graph || app.graph; + // 优先顺序:graph.id (Locator ID) -> graph._workflow_id -> 'main' + const graphId = graph?.id || graph?._workflow_id || 'main'; + return `${graphId}_${node.id}_${inputId}`; + } + // ---生命周期管理功能--- /** * 判断功能是否被禁用 @@ -494,14 +506,23 @@ class PromptAssistant { return; } - // 检查nodeId是否有效 + // 检查Id是否有效 if (nodeId !== null && nodeId !== undefined) { // 确保nodeId是字符串类型,便于后续比较 - const nodeIdStr = String(nodeId); + const searchId = String(nodeId); - // 获取当前节点的所有实例键 + // 获取所有匹配的实例键 + // 逻辑:匹配精确键 (graphId_nodeId_inputId) 或者以 nodeId_ 开头的旧键,或者包含 _nodeId_ 的全量键 const keysToDelete = Array.from(PromptAssistant.instances.keys()) - .filter(key => key === nodeIdStr || key.startsWith(`${nodeIdStr}_`)); + .filter(key => { + // 1. 精确匹配(如果传入的是 assistantKey) + if (key === searchId) return true; + // 2. 匹配 nodeId (旧格式) + if (key.startsWith(`${searchId}_`)) return true; + // 3. 匹配带 graphId 前缀的格式 (graphId_nodeId_inputId) + const parts = key.split('_'); + return parts.length >= 2 && parts[1] === searchId; + }); // 如果有实例需要清理 if (keysToDelete.length > 0) { @@ -679,17 +700,17 @@ class PromptAssistant { /** * 获取实例 */ - static getInstance(nodeId) { - if (nodeId == null) return null; - return this.instances.get(String(nodeId)); + static getInstance(key) { + if (key == null) return null; + return this.instances.get(String(key)); } /** * 检查实例是否存在 */ - static hasInstance(nodeId) { - if (nodeId == null) return false; - return this.instances.has(String(nodeId)); + static hasInstance(key) { + if (key == null) return false; + return this.instances.has(String(key)); } /** @@ -745,9 +766,8 @@ class PromptAssistant { validInputs.forEach((inputWidget, widgetIndex) => { const inputId = inputWidget.name || inputWidget.id; - // 生成唯一的 assistantKey - // 对于同名的多个输入框(如 Show Any 节点的列表输入),使用索引区分 - let assistantKey = `${node.id}_${inputId}`; + // --- 核心修复:多图支持的唯一键 --- + let assistantKey = this._getAssistantKey(node, inputId); // 检查是否存在同名的输入框,如果存在则使用索引或 DOM 元素的唯一标识 const sameNameWidgets = validInputs.filter(w => (w.name || w.id) === inputId); @@ -757,43 +777,49 @@ class PromptAssistant { if (inputEl) { // 为输入框元素添加唯一标识 if (!inputEl.dataset.promptAssistantUniqueId) { - inputEl.dataset.promptAssistantUniqueId = `${node.id}_${inputId}_${widgetIndex}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + inputEl.dataset.promptAssistantUniqueId = `${graphId}_${node.id}_${inputId}_${widgetIndex}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } assistantKey = inputEl.dataset.promptAssistantUniqueId; } else { // 降级方案:使用索引 - assistantKey = `${node.id}_${inputId}_${widgetIndex}`; + assistantKey = `${graphId}_${node.id}_${inputId}_${widgetIndex}`; } } // 检查实例是否已存在 if (PromptAssistant.hasInstance(assistantKey)) { - // 如果实例存在,检查输入控件是否已更新 + // 如果实例存在,检查输入控件是否已更新,或者 UI 元素是否已丢失 const instance = PromptAssistant.getInstance(assistantKey); const currentInputEl = inputWidget.inputEl; const instanceInputEl = instance?.text_element; + const instanceUIEl = instance?.element; - // 只有在以下情况下才清理并重建: - // 1. 两个元素都存在 - // 2. 它们是不同的元素 - // 3. 不是因为临时的 DOM 状态变化(如打开弹窗) - if (instanceInputEl && currentInputEl && instanceInputEl !== currentInputEl) { + // 检查 UI 元素是否仍然在 DOM 中 + const isVueMode = nodeMountService.isVueNodesMode(); + const nodeContainer = isVueMode ? document.querySelector(`[data-node-id="${node.id}"]`) : null; + const isUIPresent = isVueMode ? nodeContainer?.contains(instanceUIEl) : document.body.contains(instanceUIEl); + + // --- 修复:处理挂载竞争状态 --- + // 如果 UI 元素丢失,或者输入元素引用变化且原元素已移除,则清理并重建 + // 增加 _isMounting 标记检查,避免异步挂载期间被误判为丢失 + if (!instanceUIEl || (!isUIPresent && !instance._isMounting)) { + logger.debug(() => `[checkAndSetupNode] UI 元素已丢失,清理实例以触发重建 | 节点ID: ${node.id} | 键: ${assistantKey}`); + // 传入完整的 assistantKey 以确保精确清理 + this.cleanup(assistantKey); + } else if (!isUIPresent && instance._isMounting) { + // 正在挂载中,跳过 + return; + } else if (instanceInputEl && currentInputEl && instanceInputEl !== currentInputEl) { // 进一步检查:确保确实需要重建(避免误判) // 如果当前元素已经从 DOM 中移除,才需要清理 if (!document.body.contains(instanceInputEl)) { logger.debug(() => `[checkAndSetupNode] 输入元素已失效,清理实例 | 节点ID: ${node.id}`); this.cleanup(node.id); } else { - // 元素仍然在 DOM 中,可能只是引用变化,不需要清理 - // logger.debug(() => `[checkAndSetupNode] 输入元素引用变化但仍有效,跳过清理 | 节点ID: ${node.id}`); return; } - } else if (!currentInputEl && instanceInputEl) { - // Vue 模式下,inputEl 可能暂时为 null,不应该触发清理 - logger.debug(() => `[checkAndSetupNode] 当前inputEl为null,跳过清理(Vue模式下可能暂时为null) | 节点ID: ${node.id}`); - return; } else { - // 实例存在且未更新,跳过 + // 实例存在且一切正常,跳过 return; } } @@ -867,11 +893,28 @@ class PromptAssistant { * 执行实际的 DOM 挂载 */ _mountDomAssistant(node, element, inputId, index) { - const assistantKey = `${node.id}_${inputId}`; - if (PromptAssistant.hasInstance(assistantKey)) return; + const assistantKey = this._getAssistantKey(node, inputId); + if (PromptAssistant.hasInstance(assistantKey)) { + const instance = PromptAssistant.getInstance(assistantKey); + const isVueMode = nodeMountService.isVueNodesMode(); + const nodeContainer = isVueMode ? document.querySelector(`[data-node-id="${node.id}"]`) : null; + const isUIPresent = isVueMode ? nodeContainer?.contains(instance?.element) : document.body.contains(instance?.element); - // 检查元素是否已被挂载 - if (element._promptAssistantMounted) return; + if (instance?.element && isUIPresent) { + return; + } + + // 实例存在但 UI 丢失,清理旧实例以便重建 + logger.debug(() => `[_mountDomAssistant] 检测到孤立实例,清理重建 | 节点ID: ${node.id}`); + this.cleanup(node.id); + } + + // 检查元素是否已被挂载 (基于 DOM 属性判断) + if (element._promptAssistantMounted) { + // 如果属性还在但实例在 Map 中没了,或者 UI 确实不可见了,应该允许重新挂载 + // 这里我们保持原样,通过上面的 cleanup 保证一致性 + return; + } // 创建虚拟 widget const virtualWidget = { @@ -1021,7 +1064,7 @@ class PromptAssistant { } const nodeId = node.id; - const widgetKey = assistantKey || `${nodeId}_${inputId}`; + const widgetKey = assistantKey || this._getAssistantKey(node, inputId); @@ -1044,6 +1087,7 @@ class PromptAssistant { text_element: inputEl, inputEl: inputEl, isDestroyed: false, + _isMounting: true, // 标记挂载中状态 nodeInfo: { ...nodeInfo, nodeId: nodeId, @@ -1295,6 +1339,11 @@ class PromptAssistant { this._setupUIPosition(widget, inputEl, containerEl, canvasContainerRect, (success) => { + if (widget.isDestroyed) { + logger.debug(`[定位] 回调跳过:实例已销毁 | ID: ${nodeId}`); + return; + } + if (!success) { logger.debugSample(() => `[小助手] 创建暂缓 | 节点ID: ${nodeId} | 原因: 定位容器未就绪 (等待DOM渲染)`); container.destroy(); @@ -1310,6 +1359,8 @@ class PromptAssistant { // 定位成功后更新尺寸 container.updateDimensions(); + // 挂载完成,清除标记 + widget._isMounting = false; }); return containerEl; diff --git a/js/modules/imageCaption.js b/js/modules/imageCaption.js index c399c5a..af5d3a3 100644 --- a/js/modules/imageCaption.js +++ b/js/modules/imageCaption.js @@ -144,6 +144,19 @@ class ImageCaption { } } + // --- 补充检测: Vue Node 2.0 DOM 检测 --- + if (!result.hasValidImage && nodeMountService.isVueNodesMode()) { + const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`); + if (nodeContainer) { + const imgElement = nodeContainer.querySelector('img'); + if (imgElement && imgElement.src && !imgElement.src.includes('error')) { + // logger.debugSample(() => `[图像小助手] 通过 DOM 检测到图像 | ID: ${node.id}`); + result.hasValidImage = true; + result.isValid = true; + } + } + } + return result; } @@ -298,9 +311,7 @@ class ImageCaption { // 检查节点是否已被删除(只有在删除时才清理实例) if (!app.canvas || !app.canvas.graph || !app.canvas.graph._nodes_by_id[node.id]) { // 节点已被删除,清理实例 - if (ImageCaption.hasInstance(node.id)) { - this.cleanup(node.id); - } + this.cleanup(node.id); return; } @@ -320,7 +331,14 @@ class ImageCaption { // 验证现有实例是否有效 const existingInstance = ImageCaption.getInstance(node.id); - if (existingInstance && existingInstance.element && document.body.contains(existingInstance.element)) { + const isElementValid = existingInstance && existingInstance.element && document.body.contains(existingInstance.element); + const isVueMode = nodeMountService.isVueNodesMode(); + + // 在 Vue 模式下,element 可能挂载在节点容器内而不是 body + const isVueElementValid = isVueMode && existingInstance && existingInstance.element && + document.querySelector(`[data-node-id="${node.id}"]`)?.contains(existingInstance.element); + + if (existingInstance && (isElementValid || isVueElementValid)) { // 实例有效,更新其显示状态(内部会处理图片检测) this.updateAssistantVisibility(existingInstance); } else { @@ -1678,6 +1696,23 @@ class ImageCaption { return; } + // 检查节点是否已被删除 + if (!node) { + this.cleanup(assistant.nodeId); + return; + } + + // --- 修复:LiteGraph 子图“幽灵”图标问题 --- + // 检查节点所属的 graph 是否为当前激活的 graph + // 如果当前处在子图中,而节点在父图,则应隐藏 + // 注意:app.canvas.graph 是当前视图显示的 graph + if (app.canvas && app.canvas.graph && node.graph && node.graph !== app.canvas.graph) { + // 节点不在当前显示的 graph 中,强制隐藏 + // logger.debugSample(() => `[图像小助手] 节点不在当前视图中,强制隐藏 | ID: ${assistant.nodeId}`); + this.hideAssistantUI(assistant); + return; + } + // 使用统一的状态检测方法 const nodeState = this._checkNodeAndCanvasState(assistant.node); @@ -1712,17 +1747,30 @@ class ImageCaption { // 如果节点是支持的类型但暂时没图,启动一个内部轻量级探测器 if (!assistant._imageDetectionTimer) { const detectImage = () => { - if (!assistant.node) return; + // 检查实例是否已被清理或节点是否已不再图中 + if (assistant.isDestroyed || !assistant.node) { + assistant._imageDetectionTimer = null; + return; + } + const currentState = this._checkNodeAndCanvasState(assistant.node); if (currentState.hasValidImage) { logger.debug(() => `[图像小助手] 异步图像加载成功 | ID: ${assistant.nodeId}`); assistant._imageDetectionTimer = null; - this.updateAssistantVisibility(assistant); - } else if (document.body.contains(assistant.element)) { + + // 额外检查:如果 element 已经丢失(可能是 Vue 重新渲染),重新初始化 + const isVueMode = nodeMountService.isVueNodesMode(); + const nodeContainer = isVueMode ? document.querySelector(`[data-node-id="${assistant.nodeId}"]`) : null; + const isElementPresent = isVueMode ? nodeContainer?.contains(assistant.element) : document.body.contains(assistant.element); + + if (!assistant.element || !isElementPresent) { + this.checkAndSetupNode(assistant.node); + } else { + this.updateAssistantVisibility(assistant); + } + } else { // 继续等待,每秒检测一次 (开销极低) assistant._imageDetectionTimer = setTimeout(detectImage, 1000); - } else { - assistant._imageDetectionTimer = null; } }; assistant._imageDetectionTimer = setTimeout(detectImage, 1000); @@ -2301,11 +2349,17 @@ class ImageCaption { } } else { // 清理特定节点实例 - const assistant = ImageCaption.getInstance(nodeId); - if (assistant) { - this._cleanupSingleInstance(assistant); - ImageCaption.instances.delete(String(nodeId)); - } + const searchId = String(nodeId); + const keysToDelete = Array.from(ImageCaption.instances.keys()) + .filter(key => key === searchId || key.startsWith(`${searchId}_`)); + + keysToDelete.forEach(key => { + const assistant = ImageCaption.instances.get(key); + if (assistant) { + this._cleanupSingleInstance(assistant); + ImageCaption.instances.delete(key); + } + }); } return; @@ -2325,15 +2379,21 @@ class ImageCaption { } } } else { - // 清理指定实例 - const assistant = ImageCaption.getInstance(nodeId); - if (assistant) { - this._cleanupSingleInstance(assistant); - ImageCaption.instances.delete(String(nodeId)); - if (!silent) { - logger.log(`清理图像小助手实例 | ID: ${nodeId}`); + // 清理指定节点的所有实例 + const searchId = String(nodeId); + const keysToDelete = Array.from(ImageCaption.instances.keys()) + .filter(key => key === searchId || key.startsWith(`${searchId}_`)); + + keysToDelete.forEach(key => { + const assistant = ImageCaption.instances.get(key); + if (assistant) { + this._cleanupSingleInstance(assistant); + ImageCaption.instances.delete(key); + if (!silent) { + logger.log(`清理图像小助手实例 | 键: ${key}`); + } } - } + }); } } catch (error) { logger.error(`清理图像小助手实例失败 | ${error.message}`); diff --git a/js/utils/UIToolkit.js b/js/utils/UIToolkit.js index 26f0291..0d3b62e 100644 --- a/js/utils/UIToolkit.js +++ b/js/utils/UIToolkit.js @@ -15,7 +15,7 @@ class UIToolkit { static #statusTips = new Map(); // Map // 支持的输入字段ID - static VALID_INPUT_IDS = ["text", "text_positive", "text_negative", "text_g", "text_l"]; + static VALID_INPUT_IDS = ["text", "text_positive", "text_negative", "text_g", "text_l", "prompt", "string"]; // 状态文本管理 static STATUS_TEXTS = { @@ -41,16 +41,18 @@ class UIToolkit { let isValid = false; let reason = ''; + const widgetName = (widget.name || widget.id || '').toLowerCase(); + // 方法1: 标准文本输入控件(传统litegraph模式) if (widget.inputEl && widget.inputEl.tagName === "TEXTAREA" && - this.VALID_INPUT_IDS.includes(widget.name)) { + (this.VALID_INPUT_IDS.includes(widget.name) || widgetName.includes('prompt'))) { isValid = true; reason = 'litegraph textarea matched'; } - // 方法2: Note节点特殊输入 + // 方法2: Note节点或带文本框的特殊输入 else if (widget.element && widget.element.tagName === "TEXTAREA") { isValid = true; - reason = 'Note textarea matched'; + reason = 'Note/Element textarea matched'; } // 方法3: Markdown Note节点特殊输入(Tiptap编辑器) else if (this.isMarkdownNoteInput(widget)) { @@ -65,18 +67,22 @@ class UIToolkit { // ---可见性检测补救--- // 如果基本检测通过,但元素本身是隐藏的,则视为无效 + // 注意:有些节点的 textarea 可能在初始化时 display:none,随后被 ComfyUI 脚本显示 if (isValid) { const element = widget.inputEl || widget.element; if (element && !this.isElementVisible(element)) { - isValid = false; - reason += ' (but element is hidden)'; + // 如果是标准 textarea,尝试检查父级可见性,或者放宽判断(有些节点在后台渲染) + const isMarkdown = this.isMarkdownNoteInput(widget); + if (!isMarkdown) { + isValid = false; + reason += ' (but element is hidden)'; + } } } - if (debug) { - const widgetName = widget.name || widget.id || 'unknown'; + if (debug || (node && node.id === 74)) { const nodeType = node?.type || widget.node?.type || 'unknown'; - logger.debug(`[isValidInput] 控件: ${widgetName} | 节点类型: ${nodeType} | 有效: ${isValid} | 原因: ${reason}`); + logger.debug(`[isValidInput] 节点[${node?.id || '?'}] | 控件: ${widget.name || '?'}| 类型: ${nodeType} | 有效: ${isValid} | 原因: ${reason}`); } return isValid; @@ -103,16 +109,19 @@ class UIToolkit { 'PreviewAny', // Preview as Text节点(实际类型) 'PreviewTextNode', // Preview节点可能的其他名称 'Show any [Crystools]', + 'CLIPTextEncode', // 显式包含 CLIP 相关 + 'CLIPTextEncodeSDXL', // 添加其他支持的节点类型 ]; // 检查节点类型 - if (supportedNodeTypes.includes(nodeRef.type)) { + if (supportedNodeTypes.includes(nodeRef.type) || nodeRef.type?.includes('CLIP')) { return true; } // 检查控件名称是否为有效输入 - if (this.VALID_INPUT_IDS.includes(widget.name)) { + const widgetName = (widget.name || widget.id || '').toLowerCase(); + if (this.VALID_INPUT_IDS.includes(widget.name) || widgetName.includes('prompt')) { return true; } diff --git a/services/core.py b/services/core.py index 20e7706..7269366 100644 --- a/services/core.py +++ b/services/core.py @@ -81,6 +81,9 @@ class HTTPClientPool: 'verify': verify_ssl, 'follow_redirects': True, 'http2': False, + # 关键修复:禁用系统环境变量代理配置 + # 避免 HTTP_PROXY/HTTPS_PROXY 导致 localhost 请求被代理拦截返回 502 + 'trust_env': False, # 设置连接池保持连接 'limits': httpx.Limits(max_keepalive_connections=10, max_connections=20, keepalive_expiry=60.0) } diff --git a/services/llm.py b/services/llm.py index 3eb957f..1a9d0d8 100644 --- a/services/llm.py +++ b/services/llm.py @@ -190,7 +190,10 @@ class LLMService(OpenAICompatibleService): async with httpx.AsyncClient( timeout=httpx.Timeout(final_timeout, connect=10.0, read=final_timeout), - limits=httpx.Limits(max_keepalive_connections=5, max_connections=10) + limits=httpx.Limits(max_keepalive_connections=5, max_connections=10), + # 关键修复:禁用系统环境变量代理配置 + # 避免 HTTP_PROXY/HTTPS_PROXY 导致 localhost 请求被代理拦截返回 502 + trust_env=False ) as client: full_content = "" diff --git a/services/model_list.py b/services/model_list.py index 25c23fc..5abf639 100644 --- a/services/model_list.py +++ b/services/model_list.py @@ -165,7 +165,10 @@ def _fetch_ollama_models(base_url: str) -> Dict: 'User-Agent': f'comfyui-prompt-assistant/1.0 ({platform.machine()} {platform.system().lower()}) Python/{platform.python_version()}' } - response = httpx.get(url, headers=headers, timeout=10.0) + # 创建禁用代理的客户端,避免系统代理干扰localhost请求 + # 这是502错误的常见原因:系统代理无法正确转发localhost请求 + with httpx.Client(proxy=None, trust_env=False) as client: + response = client.get(url, headers=headers, timeout=10.0) if response.status_code == 404: return { @@ -178,6 +181,11 @@ def _fetch_ollama_models(base_url: str) -> Dict: "success": False, "error": f"Ollama返回400错误。详情: {error_detail}" } + elif response.status_code == 502: + return { + "success": False, + "error": f"Ollama返回错误 (HTTP 502): 网关错误,请检查Ollama服务是否正常运行" + } elif response.status_code != 200: return { "success": False, diff --git a/services/openai_base.py b/services/openai_base.py index cc05c9b..2664f46 100644 --- a/services/openai_base.py +++ b/services/openai_base.py @@ -449,8 +449,8 @@ class OpenAICompatibleService(BaseAPIService): "keep_alive": 0 } - # 创建临时客户端(卸载操作不需要复用) - async with httpx.AsyncClient(timeout=5.0) as client: + # 创建临时客户端(卸载操作不需要复用,禁用代理避免localhost请求被拦截) + async with httpx.AsyncClient(timeout=5.0, trust_env=False) as client: response = await client.post(url, json=payload) if response.status_code == 200: from ..utils.common import PROCESS_PREFIX