This commit is contained in:
yawiii
2026-02-13 16:40:56 +08:00
parent b590f33c61
commit ff15de8abe
8 changed files with 211 additions and 73 deletions
+7 -3
View File
@@ -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 });
}
});
}
+84 -33
View File
@@ -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;
+82 -22
View File
@@ -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}`);
+20 -11
View File
@@ -15,7 +15,7 @@ class UIToolkit {
static #statusTips = new Map(); // Map<buttonElement, tipElement>
// 支持的输入字段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;
}
+3
View File
@@ -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)
}
+4 -1
View File
@@ -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 = ""
+9 -1
View File
@@ -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,
+2 -2
View File
@@ -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