diff --git a/config_manager.py b/config_manager.py index cd3e91a..ed19bcd 100644 --- a/config_manager.py +++ b/config_manager.py @@ -405,6 +405,47 @@ class ConfigManager: "providers": vision_config.get("providers", {}) } + def get_settings(self): + """获取ComfyUI用户设置(从设置文件读取)""" + try: + # ComfyUI的设置文件通常位于 user/default/comfy.settings.json + # 需要找到ComfyUI的根目录 + import sys + + # 尝试从多个可能的路径查找设置文件 + possible_paths = [] + + # 方法1: 通过当前文件路径向上查找 + current_dir = os.path.dirname(os.path.abspath(__file__)) + # custom_nodes/comfyui_prompt_assistant -> custom_nodes -> ComfyUI + comfyui_root = os.path.dirname(os.path.dirname(current_dir)) + possible_paths.append(os.path.join(comfyui_root, "user", "default", "comfy.settings.json")) + + # 方法2: 通过sys.path查找 + for path in sys.path: + if 'ComfyUI' in path: + possible_paths.append(os.path.join(path, "user", "default", "comfy.settings.json")) + + # 尝试读取设置文件 + for settings_path in possible_paths: + if os.path.exists(settings_path): + try: + with open(settings_path, 'r', encoding='utf-8') as f: + settings_data = json.load(f) + # 返回设置数据 + return settings_data + except Exception as e: + self._log(f"读取设置文件失败: {settings_path}, 错误: {str(e)}") + continue + + # 如果都找不到,返回空字典 + return {} + + except Exception as e: + # 如果无法获取,返回空字典 + self._log(f"获取用户设置失败: {str(e)}") + return {} + def update_baidu_translate_config(self, app_id=None, secret_key=None): """更新百度翻译配置""" config = self.load_config() diff --git a/js/modules/PromptAssistant.js b/js/modules/PromptAssistant.js index 72fe625..5914407 100644 --- a/js/modules/PromptAssistant.js +++ b/js/modules/PromptAssistant.js @@ -598,13 +598,12 @@ class PromptAssistant { if (assistant) { // 初始化显示状态,始终显示 this.showAssistantUI(assistant); - logger.log("小助手创建并显示 | 节点ID:" + nodeId); return assistant; } return null; } catch (error) { - logger.error(`创建小助手 | 结果:异常 | 节点ID: ${node.id}, 错误: ${error.message}`); + logger.error(`创建小助手失败 | 节点ID: ${node.id} | 原因: ${error.message}`); return null; } } @@ -794,36 +793,72 @@ class PromptAssistant { // 初始化UI组件和事件 this.addFunctionButtons(widget); this._setupUIEventHandling(widget, inputEl, containerDiv); - this._setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect); - - // 立即设置预设的展开宽度,避免首次展开时需要测量 - const presetWidth = calculateAssistantWidth(); - containerDiv.style.setProperty('--expanded-width', `${presetWidth}px`); - logger.debug(`[宽度预设] 节点:${nodeId} | 宽度:${presetWidth}px`); - - // 初始滚动条检测和位置调整 - setTimeout(() => { - this._adjustPositionForScrollbar(widget, inputEl); - }, 100); // 延迟执行,确保DOM完全渲染 - - // 设置初始折叠状态 - containerDiv.classList.add('collapsed'); - hoverAreaDiv.style.display = 'block'; // 显示悬停区域以便用户展开 - - // 默认隐藏状态 - containerDiv.style.display = 'none'; - - // 移除动画类的定时器 - setTimeout(() => { - if (indicatorDiv && indicatorDiv.classList.contains('animate-creation')) { - indicatorDiv.classList.remove('animate-creation'); + + // 异步设置UI位置,并在定位失败时清理资源 + this._setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect, (success) => { + if (!success) { + // 定位失败,清理所有资源 + logger.error(`创建小助手失败 | 节点ID: ${nodeId} | 原因: 定位失败,未找到dom-widget容器`); + + // 清理DOM元素 + if (containerDiv.parentNode) { + containerDiv.parentNode.removeChild(containerDiv); + } + + // 清理事件监听器 + if (widget.cleanupListeners && typeof widget.cleanupListeners === 'function') { + widget.cleanupListeners(); + } + + // 清理图标 + if (indicatorDiv) { + indicatorDiv.innerHTML = ''; + } + + // 从实例集合中移除 + const widgetKey = widget.widgetKey; + if (widgetKey && PromptAssistant.instances.has(widgetKey)) { + PromptAssistant.instances.delete(widgetKey); + } + + // 清理输入框映射 + if (window.PromptAssistantInputWidgetMap && widgetKey) { + delete window.PromptAssistantInputWidgetMap[widgetKey]; + } + + return; } - }, 1000); + + // 定位成功,继续初始化 + // 立即设置预设的展开宽度,避免首次展开时需要测量 + const presetWidth = calculateAssistantWidth(); + containerDiv.style.setProperty('--expanded-width', `${presetWidth}px`); + logger.debug(`[宽度预设] 节点:${nodeId} | 宽度:${presetWidth}px`); - logger.log(`UI创建 | 结果:完成 | 节点ID: ${nodeId}`); + // 初始滚动条检测和位置调整 + setTimeout(() => { + this._adjustPositionForScrollbar(widget, inputEl); + }, 100); // 延迟执行,确保DOM完全渲染 + + // 设置初始折叠状态 + containerDiv.classList.add('collapsed'); + hoverAreaDiv.style.display = 'block'; // 显示悬停区域以便用户展开 + + // 默认隐藏状态 + containerDiv.style.display = 'none'; + + // 移除动画类的定时器 + setTimeout(() => { + if (indicatorDiv && indicatorDiv.classList.contains('animate-creation')) { + indicatorDiv.classList.remove('animate-creation'); + } + }, 1000); + }); + + // 立即返回容器,定位将异步完成 return containerDiv; } catch (error) { - logger.error(`UI创建 | 结果:异常 | 节点ID: ${nodeId}, 错误: ${error.message}`); + logger.error(`创建小助手失败 | 节点ID: ${nodeId} | 原因: ${error.message}`); return null; } } @@ -2182,41 +2217,19 @@ class PromptAssistant { } containerDiv.dataset.hasScrollbar = String(hasScrollbar); - // 检查当前定位模式 - const isStandardMode = containerDiv.style.position !== 'fixed'; - - if (isStandardMode) { - // ---标准定位模式调整--- - const rightOffset = hasScrollbar ? '16px' : '4px'; // 有滚动条时向左偏移10px - containerDiv.style.right = rightOffset; - logger.debug(`[位置调整] 标准方案 | 滚动条: ${hasScrollbar} → 偏移: ${rightOffset}`); - } else { - // ---兼容定位模式调整--- - // 需要重新计算位置,因为固定定位使用的是left属性 - const inputRect = inputEl.getBoundingClientRect(); - const offsetRight = hasScrollbar ? 18 : 6; // 有滚动条时增加10px偏移 - - // 临时隐藏以获取正确尺寸 - containerDiv.style.visibility = 'hidden'; - void containerDiv.offsetWidth; - - // 计算新位置 - const newLeft = inputRect.right - containerDiv.offsetWidth - offsetRight; - containerDiv.style.left = `${newLeft}px`; - containerDiv.style.visibility = 'visible'; - - logger.debug(`[位置调整] 兼容方案 | 滚动条: ${hasScrollbar} → 左:${newLeft}px`); - } + // 标准定位模式调整 + const rightOffset = hasScrollbar ? '16px' : '4px'; // 有滚动条时向左偏移12px + containerDiv.style.right = rightOffset; + logger.debug(`[位置调整] 标准方案 | 滚动条: ${hasScrollbar} → 偏移: ${rightOffset}`); } /** * 设置UI位置 - * 使用绝对定位方式 + * 使用标准定位方式(仅支持 ComfyUI 0.3.27 及以上版本) + * @param {Function} onComplete - 定位完成回调,接收boolean参数,true表示成功,false表示失败 */ - _setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect) { + _setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect, onComplete) { const _applyStandardPositioning = (containerDiv, domWidgetContainer) => { - logger.log("定位方式 - 标准方案"); - // 标准模式使用默认的绝对定位,添加位置参数 containerDiv.style.right = '4px'; containerDiv.style.bottom = '4px'; @@ -2254,23 +2267,11 @@ class PromptAssistant { // 清理函数列表 widget._eventCleanupFunctions = widget._eventCleanupFunctions || []; - // 检查是否需要使用兼容模式 - let needCompatibilityMode = !domWidgetContainer; - - // 如果初次判断需要使用兼容模式,添加延迟重试机制 + // 如果初次查找失败,添加延迟重试机制 // 这是为了处理新创建节点时DOM可能尚未完全渲染的情况 - if (needCompatibilityMode) { - // 先创建一个临时容器,避免显示延迟 - const tempDiv = document.createElement('div'); - tempDiv.className = 'prompt-assistant-container prompt-assistant-temp'; - tempDiv.style.display = 'none'; - document.body.appendChild(tempDiv); - - // 保存临时容器引用,以便后续移除 - widget._tempContainer = tempDiv; - + if (!domWidgetContainer) { // 设置重试次数和间隔 - const maxRetries = 2; + const maxRetries = 3; const retryInterval = 500; // 毫秒 let retryCount = 0; @@ -2281,309 +2282,31 @@ class PromptAssistant { if (domWidgetContainer) { // 找到了dom-widget容器,使用标准定位方案 - logger.log("定位方式 - 延迟检测后使用标准方案"); - needCompatibilityMode = false; - - // 移除临时容器 - if (widget._tempContainer && document.body.contains(widget._tempContainer)) { - document.body.removeChild(widget._tempContainer); - delete widget._tempContainer; - } - // 使用标准定位方案 _applyStandardPositioning(containerDiv, domWidgetContainer); - return true; + // 通知定位成功 + if (onComplete) onComplete(true); } else if (retryCount < maxRetries) { // 继续重试 retryCount++; logger.debug(`定位方式 - 重试查找dom-widget容器 (${retryCount}/${maxRetries})`); setTimeout(retrySetupPosition, retryInterval); - return false; } else { - // 达到最大重试次数,使用兼容定位方案 - logger.log("定位方式 - 重试失败,使用兼容方案"); - - // 移除临时容器 - if (widget._tempContainer && document.body.contains(widget._tempContainer)) { - document.body.removeChild(widget._tempContainer); - delete widget._tempContainer; - } - - // 使用兼容定位方案 - setupCompatibilityMode(); - return true; + // 达到最大重试次数,定位失败 + // 通知定位失败 + if (onComplete) onComplete(false); } }; - // 定义兼容模式设置函数 - const setupCompatibilityMode = () => { - // === 兼容方案定位 (ComfyUI 0.3.26及以下) === - logger.log("定位方式 - 兼容方案"); - - // 使用固定定位样式,不添加额外类 - containerDiv.style.position = 'fixed'; - containerDiv.style.zIndex = '9999'; - document.body.appendChild(containerDiv); - - // 设置初始显示 - containerDiv.style.display = 'flex'; - - // 更新位置的函数,确保跟随输入框并位于右下角 - const updatePosition = () => { - if (!widget.element || !inputEl || !containerDiv) return; - - try { - // 获取输入框的位置信息 - const inputRect = inputEl.getBoundingClientRect(); - - // 设置容器样式 - 首先确保容器可见以便获得正确的尺寸 - containerDiv.style.display = 'flex'; - containerDiv.style.visibility = 'hidden'; // 暂时隐藏以避免闪烁 - - // 先设置样式,使其能正确计算尺寸 - Object.assign(containerDiv.style, { - width: 'auto', - height: '24px', - pointerEvents: 'none' - }); - - // 确保小部件样式正确 - Object.assign(widget.element.style, { - transformOrigin: 'right center', - margin: '0', - pointerEvents: 'auto' - }); - - // 强制回流以获取正确尺寸 - void containerDiv.offsetWidth; - - // 现在设置位置 - 放置在输入框右下角,考虑滚动条状态 - const hasScrollbar = inputEl.tagName === 'TEXTAREA' && inputEl.scrollHeight > inputEl.clientHeight; - const offsetRight = hasScrollbar ? 16 : 6; // 有滚动条时增加10px偏移 - const offsetBottom = 4; - - containerDiv.style.left = `${inputRect.right - containerDiv.offsetWidth - offsetRight}px`; - containerDiv.style.top = `${inputRect.bottom - containerDiv.offsetHeight - offsetBottom}px`; - containerDiv.style.visibility = 'visible'; // 恢复可见性 - - // 最终触发回流,确保样式更新 - void containerDiv.offsetWidth; - } catch (error) { - logger.error("更新小助手位置出错:", error); - // 错误恢复 - 确保组件仍然可见 - if (containerDiv) containerDiv.style.visibility = 'visible'; - } - }; - - // 初始更新位置 - updatePosition(); - - // 使用防抖函数优化位置更新,但降低延迟提高流畅度 - const debouncedUpdatePosition = EventManager.debounce(updatePosition, 16); - - // 添加窗口resize事件监听 - const removeResizeListener = EventManager.addDOMListener(window, 'resize', debouncedUpdatePosition); - widget._eventCleanupFunctions.push(removeResizeListener); - - // 监听画布变化 - const app = window.app || null; - - if (!app || !app.canvas) { - logger.error("错误:无法获取app对象,小助手无法正常工作"); - return; - } - - // 1. 使用MutationObserver监听画布变化 - const observer = new MutationObserver(debouncedUpdatePosition); - const canvasParent = app.canvas.canvas.parentElement; - - if (canvasParent) { - observer.observe(canvasParent, { - attributes: true, - attributeFilter: ['style', 'transform'] - }); - - // 添加Observer清理函数 - widget._eventCleanupFunctions.push(() => observer.disconnect()); - } - - // 2. 监听画布重绘 - 使用直接更新而不是防抖版本,确保重绘时位置准确 - const originalDrawBackground = app.canvas.onDrawBackground; - app.canvas.onDrawBackground = function () { - const ret = originalDrawBackground?.apply(this, arguments); - // 直接调用updatePosition而不是防抖版本 - updatePosition(); - return ret; - }; - - // 添加画布重绘清理函数 - widget._eventCleanupFunctions.push(() => { - if (originalDrawBackground) { - app.canvas.onDrawBackground = originalDrawBackground; - } - }); - - // 3. 监听节点移动 - 使用 nodeInfo 而不是直接的 node 引用 - const nodeId = widget.nodeInfo?.nodeId; - if (nodeId && app.canvas && app.canvas.graph) { - const node = app.canvas.graph.getNodeById(nodeId); - if (node) { - // 使用LiteGraph提供的onNodeMoved事件 - const originalOnNodeMoved = app.canvas.onNodeMoved; - app.canvas.onNodeMoved = function (node_dragged) { - if (originalOnNodeMoved) { - originalOnNodeMoved.apply(this, arguments); - } - - // 仅当移动的是当前节点时更新位置 - if (node_dragged && node_dragged.id === nodeId) { - // 直接调用updatePosition而不是防抖版本,确保拖动时UI跟随节点 - updatePosition(); - } - }; - - // 添加节点移动清理函数 - widget._eventCleanupFunctions.push(() => { - if (app.canvas) { - app.canvas.onNodeMoved = originalOnNodeMoved; - } - }); - - // 为节点本身添加移动监听(兼容性处理) - const nodeOriginalOnNodeMoved = node.onNodeMoved; - node.onNodeMoved = function () { - const ret = nodeOriginalOnNodeMoved?.apply(this, arguments); - // 直接调用updatePosition而不是防抖版本 - updatePosition(); - return ret; - }; - - // 添加节点自身移动清理函数 - widget._eventCleanupFunctions.push(() => { - if (node && nodeOriginalOnNodeMoved) { - node.onNodeMoved = nodeOriginalOnNodeMoved; - } - }); - } - } - - // 统一的画布事件处理函数 - const handleCanvasEvent = () => { - if (widget.element && widget.element.style.display !== 'none') { - updatePosition(); - } - }; - - // 监听画布相关事件 - if (!app || !app.canvas) { - logger.error("错误:无法获取app对象,小助手无法正常工作"); - return; - } - - // 保存原始方法引用 - const originalMethods = { - setDirty: app.canvas.setDirty, - drawBackground: app.canvas.onDrawBackground, - dsModified: app.canvas.ds.onModified, - setTransform: app.canvas.ds.setTransform, - resize: app.canvas.resize - }; - - // 1. 监听画布变换(包括缩放、平移等) - app.canvas.setDirty = function (value, skipEvents) { - const ret = originalMethods.setDirty?.apply(this, arguments); - if (!skipEvents) { - handleCanvasEvent(); - } - return ret; - }; - - // 2. 监听画布重绘和缩放 - app.canvas.onDrawBackground = function () { - const ret = originalMethods.drawBackground?.apply(this, arguments); - handleCanvasEvent(); - return ret; - }; - - app.canvas.ds.onModified = function (...args) { - if (originalMethods.dsModified) { - originalMethods.dsModified.apply(this, args); - } - handleCanvasEvent(); - }; - - app.canvas.ds.setTransform = function () { - const ret = originalMethods.setTransform?.apply(this, arguments); - handleCanvasEvent(); - return ret; - }; - - // 3. 监听画布大小变化 - app.canvas.resize = function () { - const ret = originalMethods.resize?.apply(this, arguments); - handleCanvasEvent(); - return ret; - }; - - // 4. 使用MutationObserver监听画布容器变化 - const canvasContainer = app.canvas.canvas.parentElement; - if (canvasContainer) { - const observer = new MutationObserver(() => handleCanvasEvent()); - observer.observe(canvasContainer, { - attributes: true, - attributeFilter: ['style', 'transform'] - }); - widget._eventCleanupFunctions.push(() => observer.disconnect()); - } - - // 5. 使用requestAnimationFrame实现平滑更新 - let rafId = null; - const smoothUpdate = () => { - handleCanvasEvent(); - rafId = requestAnimationFrame(smoothUpdate); - }; - rafId = requestAnimationFrame(smoothUpdate); - - // 添加所有清理函数 - widget._eventCleanupFunctions.push( - () => { - // 清理画布相关事件监听 - if (app.canvas) { - app.canvas.setDirty = originalMethods.setDirty; - app.canvas.onDrawBackground = originalMethods.drawBackground; - app.canvas.resize = originalMethods.resize; - } - if (app.canvas?.ds) { - app.canvas.ds.onModified = originalMethods.dsModified; - app.canvas.ds.setTransform = originalMethods.setTransform; - } - // 清理requestAnimationFrame - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - } - ); - - // 添加DOM元素清理函数 - widget._eventCleanupFunctions.push(() => { - // 移除DOM元素 - if (containerDiv && document.body.contains(containerDiv)) { - document.body.removeChild(containerDiv); - } - }); - - // 强制应用容器高度 - containerDiv.style.height = '20px'; - containerDiv.style.minHeight = '20px'; - void containerDiv.offsetWidth; // 触发回流,确保样式应用 - }; - // 开始重试流程 setTimeout(retrySetupPosition, retryInterval); } else { // === 标准方案定位 (ComfyUI 0.3.27及以上) === _applyStandardPositioning(containerDiv, domWidgetContainer); + + // 通知定位成功 + if (onComplete) onComplete(true); } } diff --git a/js/modules/settings.js b/js/modules/settings.js index df3d9e7..4871b9f 100644 --- a/js/modules/settings.js +++ b/js/modules/settings.js @@ -971,6 +971,19 @@ export function registerSettings() { } }, + // 跳过代理直连开关 + { + id: "PromptAssistant.Settings.BypassProxy", + name: "跳过代理直连", + category: ["✨提示词小助手", " 配置", "网络设置"], + type: "boolean", + defaultValue: false, + tooltip: "当使用硅基和智谱时,推荐打开,使用外国服务时推荐关闭", + onChange: (value) => { + logger.log(`跳过代理直连 - 已${value ? "启用" : "禁用"}`); + } + }, + // 历史功能(包含历史、撤销、重做按钮) { id: "PromptAssistant.Features.History", diff --git a/node/expand_node.py b/node/expand_node.py index bac4740..aa8f56c 100644 --- a/node/expand_node.py +++ b/node/expand_node.py @@ -12,18 +12,31 @@ from ..services.error_util import format_api_error # 定义ANSI颜色代码常量 -GREEN = "\033[92m" +GREEN = "\033[32m" +BLUE = "\033[34m" +YELLOW = "\033[33m" RESET = "\033[0m" class PromptExpand: """ 文本扩写节点 - - 输入“原文”,根据所选扩写规则模板或临时规则进行扩写 + - 输入"原文",根据所选扩写规则模板或临时规则进行扩写 - 仅包含一个字符串输入和一个字符串输出 """ - # 定义日志前缀(带绿色) - LOG_PREFIX = f"{GREEN}[PromptAssistant]{RESET}" + # 定义日志前缀 + LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段 + REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段 + PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段 + + # 提供商显示名称映射 + PROVIDER_DISPLAY_MAP = { + "zhipu": "智谱", + "siliconflow": "硅基流动", + "302ai": "302.AI", + "ollama": "Ollama", + "custom": "自定义" + } @classmethod def INPUT_TYPES(cls): @@ -63,6 +76,8 @@ class PromptExpand: "用户提示词": ("STRING", {"multiline": True, "default": "", "placeholder": "填写的要扩写的提示词原文,若存在原文端口输入和内容输入,将合并提交"}), # 扩写服务(使用LLM提供商) "扩写服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}), + # Ollama自动释放:仅对Ollama服务生效 + "Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}), }, "optional": { # 原文输入端口:非必选 @@ -77,7 +92,7 @@ class PromptExpand: OUTPUT_NODE = False @classmethod - def IS_CHANGED(cls, 规则模板, 临时规则, 临时规则内容, 用户提示词, 扩写服务, 原文=None): + def IS_CHANGED(cls, 规则模板, 临时规则, 临时规则内容, 用户提示词, 扩写服务, Ollama自动释放, 原文=None): """ 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 @@ -92,11 +107,12 @@ class PromptExpand: temp_rule_hash, user_hint_hash, 扩写服务, + bool(Ollama自动释放), text_hash, )) return input_hash - def expand(self, 规则模板, 临时规则, 临时规则内容, 用户提示词, 扩写服务, 原文=None): + def expand(self, 规则模板, 临时规则, 临时规则内容, 用户提示词, 扩写服务, Ollama自动释放, 原文=None): """ 扩写文本函数 @@ -107,6 +123,7 @@ class PromptExpand: 临时规则内容: 临时规则的内容(启用时生效) 用户提示词: 附加的用户提示信息,将与原文合并提交 扩写服务: LLM提供商(智谱/硅基流动/自定义) + Ollama自动释放: 是否在调用完成后自动释放Ollama模型(仅对Ollama生效) Returns: tuple: 扩写结果 @@ -120,11 +137,11 @@ class PromptExpand: # 准备系统提示词(规则) system_message = None + rule_name = "临时规则" if (临时规则 and 临时规则内容) else 规则模板 if 临时规则 and 临时规则内容: # 使用临时规则 system_message = {"role": "system", "content": 临时规则内容} - print(f"{self.LOG_PREFIX} 扩写: 使用临时规则") else: # 使用模板:从config_manager获取系统提示词配置 from ..config_manager import config_manager @@ -137,7 +154,6 @@ class PromptExpand: if value.get('name') == 规则模板: system_message = {"role": value.get('role', 'system'), "content": value.get('content', '')} template_found = True - print(f"{self.LOG_PREFIX} 扩写: 使用模板 '{规则模板}'") break if not template_found: # 允许用键名直接匹配 @@ -145,12 +161,11 @@ class PromptExpand: if key == 规则模板: system_message = {"role": value.get('role', 'system'), "content": value.get('content', '')} template_found = True - print(f"{self.LOG_PREFIX} 扩写: 使用模板 '{规则模板}'(按键名)") break if not template_found or not system_message or not system_message.get('content'): # 回退到默认 system_message = {"role": "system", "content": "你是一名提示词扩写专家,请将用户给定文本扩写为更完整、更具可读性和可执行性的提示词。"} - print(f"{self.LOG_PREFIX} 扩写: 未找到模板 '{规则模板}',使用默认扩写提示词") + rule_name = "默认规则" # 选择服务提供商 provider_map = { @@ -171,18 +186,20 @@ class PromptExpand: provider_display = {"zhipu": "智谱", "siliconflow": "硅基流动", "302ai": "302.AI", "ollama": "Ollama", "custom": "自定义"}.get(selected_provider, selected_provider) raise ValueError(f"未找到{provider_display}的配置,请先完成API配置") - print(f"{self.LOG_PREFIX} 扩写: 使用{扩写服务}服务, 模型: {provider_config.get('model')}") - # 执行扩写(异步线程 + 可中断) request_id = f"expand_{int(time.time())}_{random.randint(1000, 9999)}" - result_container = {} + # 合并原文与用户提示词 # 合并顺序:输入端口(原文)在前,节点输入框(用户提示词)在后 combined_text = 用户提示词 if not 原文 else (f"{原文}\n\n{用户提示词}" if 用户提示词 else 原文) + + # 准备阶段日志(合并为一条) + print(f"{self.REQUEST_PREFIX} 扩写准备 | 服务:{扩写服务} | 模型:{provider_config.get('model')} | 规则:{rule_name} | 长度:{len(combined_text)} | 请求ID:{request_id}") + result_container = {} thread = threading.Thread( target=self._run_llm_expand, - args=(combined_text, system_message, request_id, result_container, selected_provider, provider_config) + args=(combined_text, system_message, request_id, result_container, selected_provider, provider_config, 扩写服务, Ollama自动释放) ) thread.start() @@ -206,9 +223,10 @@ class PromptExpand: expanded_text = result.get('data', {}).get('expanded', '').strip() if not expanded_text: error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接' - print(f"{self.LOG_PREFIX} 扩写失败: {error_msg}") + print(f"{self.LOG_PREFIX} 扩写失败 | 错误:{error_msg}") raise RuntimeError(f"扩写失败: {error_msg}") - print(f"{self.LOG_PREFIX} 扩写完成,结果长度: {len(expanded_text)}") + # 结果阶段日志(合并为一条) + print(f"{self.LOG_PREFIX} 扩写完成 | 服务:{扩写服务} | 请求ID:{request_id} | 结果字符数:{len(expanded_text)}") return (expanded_text,) else: error_msg = result.get('error', '扩写失败,未知错误') if result else '扩写服务未返回结果' @@ -229,8 +247,45 @@ class PromptExpand: if 'providers' in llm_config and provider in llm_config['providers']: return llm_config['providers'][provider] return None + + async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool): + """ + 卸载Ollama模型以释放显存和内存 + + 参数: + model: 模型名称 + provider_config: 提供商配置字典 + auto_unload: 是否启用自动释放(来自节点参数) + """ + try: + # 检查是否启用自动释放(使用节点参数,不从provider_config读取) + if not auto_unload: + return + + # 获取base_url + base_url = provider_config.get('base_url', 'http://localhost:11434') + # 确保URL不以/v1结尾(Ollama原生API不需要/v1) + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + # 调用Ollama API卸载模型 + url = f"{base_url}/api/generate" + payload = { + "model": model, + "keep_alive": 0 # 立即卸载模型 + } + + print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") + + import httpx + async with httpx.AsyncClient(timeout=5.0) as client: + await client.post(url, json=payload) + + except Exception as e: + # 释放失败不影响主流程,只记录警告 + print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") - def _run_llm_expand(self, text, system_message, request_id, result_container, provider, provider_config): + def _run_llm_expand(self, text, system_message, request_id, result_container, provider, provider_config, service_name, auto_unload): """在独立线程中运行LLM扩写""" loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -253,6 +308,20 @@ class PromptExpand: } return + # 检查是否启用直连模式 + bypass_proxy = False + try: + from ..config_manager import config_manager as cm + settings = cm.get_settings() + bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) + except Exception: + pass + + direct_mode_tag = "(直连)" if bypass_proxy else "" + + # 获取提供商显示名称 + provider_display_name = PromptExpand.PROVIDER_DISPLAY_MAP.get(provider, provider) + # 创建客户端 client = LLMService.get_openai_client(api_key, provider) @@ -267,7 +336,8 @@ class PromptExpand: {"role": "user", "content": text}, ] - print(f"{self.LOG_PREFIX} 调用{provider}扩写API | 模型:{model}") + # API调用阶段日志(由LLMService打印,这里不重复) + print(f"{PromptExpand.PROCESS_PREFIX} 调用{provider_display_name}扩写API{direct_mode_tag} | 模型:{model}") stream = loop.run_until_complete(client.chat.completions.create( model=model, @@ -286,8 +356,15 @@ class PromptExpand: async for chunk in stream: if result_container.get('interrupted'): break - if chunk.choices[0].delta.content: - full_content += chunk.choices[0].delta.content + # 兼容部分第三方网关返回空 choices 的异常片段,做健壮性判断 + choices = getattr(chunk, "choices", None) or [] + for ch in choices: + delta = getattr(ch, "delta", None) + if not delta: + continue + content = getattr(delta, "content", None) + if content: + full_content += content loop.run_until_complete(process_stream()) @@ -302,14 +379,23 @@ class PromptExpand: "expanded": full_content, }, } - print(f"{self.LOG_PREFIX} 扩写完成 | 服务:{provider} | 结果字符数:{len(full_content)}") + + # Ollama自动释放显存 + if provider == 'ollama': + try: + loop.run_until_complete(self._unload_ollama_model(model, provider_config, auto_unload)) + except Exception as e: + # 释放失败不影响结果 + pass + + # 结果日志已在expand方法中统一打印,这里不再重复 except Exception as e: result_container['result'] = { "success": False, - "error": format_api_error(e, provider) + "error": format_api_error(e, provider_display_name) } - print(f"{self.LOG_PREFIX} 扩写失败 | 服务:{provider} | 错误:{result_container['result']['error']}") + print(f"{self.LOG_PREFIX} 扩写失败 | 服务:{provider_display_name} | 错误:{result_container['result']['error']}") finally: loop.close() diff --git a/node/image_caption_node.py b/node/image_caption_node.py index 73ab41d..6c81527 100644 --- a/node/image_caption_node.py +++ b/node/image_caption_node.py @@ -17,7 +17,9 @@ from ..services.vlm import VisionService from ..services.error_util import format_api_error # 定义ANSI颜色代码常量 -GREEN = "\033[92m" +GREEN = "\033[32m" +BLUE = "\033[34m" +YELLOW = "\033[33m" RESET = "\033[0m" @@ -26,8 +28,19 @@ class ImageCaptionNode: 图像反推提示词节点 分析输入图像并生成描述性提示词 """ - # 定义日志前缀(带绿色) - LOG_PREFIX = f"{GREEN}[PromptAssistant]{RESET}" + # 定义日志前缀 + LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段 + REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段 + PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段 + + # 提供商显示名称映射 + PROVIDER_DISPLAY_MAP = { + "zhipu": "智谱", + "siliconflow": "硅基流动", + "302ai": "302.AI", + "ollama": "Ollama", + "custom": "自定义" + } @classmethod def INPUT_TYPES(cls): @@ -57,6 +70,8 @@ class ImageCaptionNode: "临时规则": ("BOOLEAN", {"default": False, "label_on": "启用", "label_off": "禁用"}), "临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}), "视觉服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}), + # Ollama自动释放:仅对Ollama视觉服务生效 + "Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}), }, } @@ -67,7 +82,7 @@ class ImageCaptionNode: OUTPUT_NODE = False @classmethod - def IS_CHANGED(cls, 图像, 规则模板, 临时规则, 临时规则内容, 视觉服务): + def IS_CHANGED(cls, 图像, 规则模板, 临时规则, 临时规则内容, 视觉服务, Ollama自动释放): """ 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 @@ -99,12 +114,13 @@ class ImageCaptionNode: 规则模板, bool(临时规则), 临时规则内容, - 视觉服务 + 视觉服务, + bool(Ollama自动释放) )) return input_hash - def analyze_image(self, 图像, 规则模板, 临时规则, 临时规则内容, 视觉服务): + def analyze_image(self, 图像, 规则模板, 临时规则, 临时规则内容, 视觉服务, Ollama自动释放): """ 分析图像并生成提示词 @@ -129,9 +145,11 @@ class ImageCaptionNode: # 获取提示词模板内容 prompt_template = None + # 确定使用的规则 + rule_name = "临时规则" if (临时规则 and 临时规则内容) else 规则模板 + if 临时规则 and 临时规则内容: prompt_template = 临时规则内容 - print(f"{self.LOG_PREFIX} 图像反推: 使用临时规则") else: # 从config_manager获取系统提示词配置 from ..config_manager import config_manager @@ -148,26 +166,20 @@ class ImageCaptionNode: if value.get('name') == 规则模板: prompt_template = value.get('content') template_found = True - print(f"{self.LOG_PREFIX} 图像反推: 使用模板 '{规则模板}'") break if not template_found: - print(f"{self.LOG_PREFIX} 图像反推: 未找到模板 '{规则模板}',尝试直接匹配键名") # 尝试直接匹配键名 for key, value in vision_prompts.items(): if key == 规则模板: prompt_template = value.get('content') template_found = True - print(f"{self.LOG_PREFIX} 图像反推: 使用模板 '{规则模板}'") break - # 如果没有找到提示词模板,使用默认值 - if not prompt_template: - prompt_template = "请详细描述这张图片的内容,包括主体、场景、风格、色彩等要素。" - print(f"{self.LOG_PREFIX} 图像反推: 未找到模板 '{规则模板}',使用默认提示词") - - # 执行图像分析 - print(f"{self.LOG_PREFIX} 开始图像反推分析: {视觉服务}") + # 如果没有找到提示词模板,使用默认值 + if not template_found or not prompt_template: + prompt_template = "请详细描述这张图片的内容,包括主体、场景、风格、色彩等要素。" + rule_name = "默认规则" # 映射视觉服务选项到provider provider_map = { @@ -189,19 +201,23 @@ class ImageCaptionNode: if not provider_config: raise ValueError(f"未找到{视觉服务}的配置,请先完成API配置") - print(f"{self.LOG_PREFIX} 图像反推: 使用{视觉服务}服务, API: {provider_config.get('model')}") + # 创建请求ID + request_id = f"image_caption_{int(time.time())}_{random.randint(1000, 9999)}" + + # 准备阶段日志(合并为一条) + print(f"{self.REQUEST_PREFIX} 图像反推准备 | 服务:{视觉服务} | 模型:{provider_config.get('model')} | 规则:{rule_name} | 请求ID:{request_id}") # 执行图像分析 - result = self._analyze_with_vision_service(image_data, prompt_template, selected_provider, provider_config) + result = self._analyze_with_vision_service(image_data, prompt_template, selected_provider, provider_config, request_id, Ollama自动释放) if result and result.get('success'): description = result.get('data', {}).get('description', '').strip() if not description: error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接' - print(f"{self.LOG_PREFIX} 图像反推分析失败: {error_msg}") + print(f"{self.LOG_PREFIX} 图像反推失败 | 错误:{error_msg}") raise RuntimeError(f"分析失败: {error_msg}") - print(f"{self.LOG_PREFIX} 图像反推分析完成,结果长度: {len(description)}") + # 服务层已经打印了完成日志,这里不再重复 return (description,) else: error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果' @@ -216,17 +232,15 @@ class ImageCaptionNode: print(f"{self.LOG_PREFIX} 图像反推节点异常: {error_msg}") raise RuntimeError(f"分析异常: {error_msg}") - def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config): + def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config, request_id, auto_unload): """使用视觉服务分析图像""" try: - # 创建请求ID - request_id = f"image_caption_{int(time.time())}_{random.randint(1000, 9999)}" result_container = {} # 在独立线程中运行图像分析 thread = threading.Thread( target=self._run_async_vision_analysis, - args=(image_data, prompt_template, request_id, result_container, provider, provider_config) + args=(image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload) ) thread.start() @@ -254,7 +268,7 @@ class ImageCaptionNode: except Exception as e: return {"success": False, "error": str(e)} - def _run_async_vision_analysis(self, image_data, prompt_template, request_id, result_container, provider, provider_config): + def _run_async_vision_analysis(self, image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload): """在独立线程中运行异步图像分析任务""" loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -264,21 +278,107 @@ class ImageCaptionNode: result_container['result'] = {"success": False, "error": "任务被中断"} return - result = loop.run_until_complete( - VisionService.analyze_image(image_data, request_id, None, prompt_template, provider, provider_config) - ) + # 获取API密钥和模型 + api_key = provider_config.get('api_key', '') + model = provider_config.get('model', '') + temperature = provider_config.get('temperature', 0.7) + top_p = provider_config.get('top_p', 0.9) + max_tokens = provider_config.get('max_tokens', 500) + + if not api_key or not model: + result_container['result'] = { + "success": False, + "error": f"请先配置{provider}的API密钥和模型" + } + return + + # 检查是否启用直连模式 + bypass_proxy = False + try: + from ..config_manager import config_manager as cm + settings = cm.get_settings() + bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) + except Exception: + pass + + direct_mode_tag = "(直连)" if bypass_proxy else "" + + # 获取提供商显示名称 + provider_display_name = ImageCaptionNode.PROVIDER_DISPLAY_MAP.get(provider, provider) + + # 创建客户端(复用VisionService的工具方法) + client = VisionService.get_openai_client(api_key, provider) + + # 预处理图像(复用VisionService的工具方法) + image_data = VisionService.preprocess_image(image_data, request_id) + + # 构建消息 + messages = [{ + "role": "user", + "content": [ + {"type": "text", "text": prompt_template}, + {"type": "image_url", "image_url": {"url": image_data}} + ] + }] + + print(f"{ImageCaptionNode.PROCESS_PREFIX} 调用{provider_display_name}视觉API{direct_mode_tag} | 模型:{model}") + + # 调用API + stream = loop.run_until_complete(client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + stream=True + )) + + # 处理流式响应 + full_content = "" + + async def process_stream(): + nonlocal full_content + async for chunk in stream: + if result_container.get('interrupted'): + break + # 兼容部分第三方网关返回空 choices 的异常片段 + choices = getattr(chunk, "choices", None) or [] + for ch in choices: + delta = getattr(ch, "delta", None) + if not delta: + continue + content = getattr(delta, "content", None) + if content: + full_content += content + + loop.run_until_complete(process_stream()) # 检查是否在执行过程中被中断了 if result_container.get('interrupted'): result_container['result'] = {"success": False, "error": "任务被中断"} return - result_container['result'] = result + result_container['result'] = { + "success": True, + "data": { + "description": full_content + } + } + + # Ollama自动释放显存 + if provider == 'ollama': + try: + loop.run_until_complete(self._unload_ollama_model(model, provider_config, auto_unload)) + except Exception as e: + # 释放失败不影响结果 + pass + except Exception as e: if result_container.get('interrupted'): result_container['result'] = {"success": False, "error": "任务被中断"} else: - result_container['result'] = {"success": False, "error": str(e)} + error_message = format_api_error(e, provider_display_name if 'provider_display_name' in locals() else provider) + result_container['result'] = {"success": False, "error": error_message} finally: loop.close() @@ -289,6 +389,43 @@ class ImageCaptionNode: return vision_config['providers'][provider] return None + async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool): + """ + 卸载Ollama模型以释放显存和内存 + + 参数: + model: 模型名称 + provider_config: 提供商配置字典 + auto_unload: 是否启用自动释放(来自节点参数) + """ + try: + # 检查是否启用自动释放(使用节点参数,不从provider_config读取) + if not auto_unload: + return + + # 获取base_url + base_url = provider_config.get('base_url', 'http://localhost:11434') + # 确保URL不以/v1结尾(Ollama原生API不需要/v1) + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + # 调用Ollama API卸载模型 + url = f"{base_url}/api/generate" + payload = { + "model": model, + "keep_alive": 0 # 立即卸载模型 + } + + print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") + + import httpx + async with httpx.AsyncClient(timeout=5.0) as client: + await client.post(url, json=payload) + + except Exception as e: + # 释放失败不影响主流程,只记录警告 + print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") + def _image_to_base64(self, image_tensor): """将图像张量转换为base64编码""" # 确保图像是正确的形状 [batch, height, width, channels] diff --git a/node/kontext_preset_node.py b/node/kontext_preset_node.py index d79d90e..0bafbc9 100644 --- a/node/kontext_preset_node.py +++ b/node/kontext_preset_node.py @@ -17,7 +17,9 @@ from ..services.vlm import VisionService from ..services.error_util import format_api_error # 定义ANSI颜色代码常量 -GREEN = "\033[92m" +GREEN = "\033[32m" +BLUE = "\033[34m" +YELLOW = "\033[33m" RESET = "\033[0m" @@ -26,8 +28,19 @@ class KontextPresetNode: Kontext预设助手节点 使用Kontext预设分析图像并生成创意转换指令 """ - # 定义日志前缀(带绿色) - LOG_PREFIX = f"{GREEN}[PromptAssistant]{RESET}" + # 定义日志前缀 + LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段 + REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段 + PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段 + + # 提供商显示名称映射 + PROVIDER_DISPLAY_MAP = { + "zhipu": "智谱", + "siliconflow": "硅基流动", + "302ai": "302.AI", + "ollama": "Ollama", + "custom": "自定义" + } # 缓存配置数据,避免重复从文件系统读取 _kontext_config = None @@ -72,6 +85,8 @@ class KontextPresetNode: "图像": ("IMAGE",), "Kontext预设": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "情境深度融合"}), "视觉服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}), + # Ollama自动释放:仅对Ollama视觉服务生效 + "Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}), }, } @@ -82,7 +97,7 @@ class KontextPresetNode: OUTPUT_NODE = False @classmethod - def IS_CHANGED(cls, 图像, Kontext预设, 视觉服务): + def IS_CHANGED(cls, 图像, Kontext预设, 视觉服务, Ollama自动释放): """ 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 @@ -112,12 +127,13 @@ class KontextPresetNode: input_hash = hash(( img_hash, Kontext预设, - 视觉服务 + 视觉服务, + bool(Ollama自动释放) )) return input_hash - def analyze_image(self, 图像, Kontext预设, 视觉服务): + def analyze_image(self, 图像, Kontext预设, 视觉服务, Ollama自动释放): """ 使用Kontext预设分析图像并生成创意转换指令 @@ -147,37 +163,31 @@ class KontextPresetNode: prompt_template = None # 查找选定的提示词模板 + preset_name = Kontext预设 template_found = False for key, value in kontext_presets.items(): if value.get('name') == Kontext预设: prompt_template = value.get('content') template_found = True - print(f"{self.LOG_PREFIX} Kontext预设: 使用预设 '{Kontext预设}'") break if not template_found: - print(f"{self.LOG_PREFIX} Kontext预设: 未找到预设 '{Kontext预设}',尝试直接匹配键名") # 尝试直接匹配键名 for key, value in kontext_presets.items(): if key == Kontext预设 or key == f"kontext_{Kontext预设}": prompt_template = value.get('content') template_found = True - print(f"{self.LOG_PREFIX} Kontext预设: 使用预设 '{Kontext预设}'") break # 如果没有找到提示词模板,使用默认值 if not prompt_template: prompt_template = "Transform the image into a detailed pencil sketch with fine lines and careful shading." - print(f"{self.LOG_PREFIX} Kontext预设: 未找到预设 '{Kontext预设}',使用默认提示词") + preset_name = "默认预设" # 构建最终提示词,添加前缀和后缀 final_prompt = prompt_template if kontext_prefix and kontext_suffix: final_prompt = f"{kontext_prefix}\n\nThe Brief: {prompt_template}\n\n{kontext_suffix}" - print(f"{self.LOG_PREFIX} Kontext预设: 添加前缀和后缀") - - # 执行图像分析 - print(f"{self.LOG_PREFIX} 开始Kontext预设分析: {视觉服务}") # 映射视觉服务选项到provider provider_map = { @@ -199,19 +209,23 @@ class KontextPresetNode: if not provider_config: raise ValueError(f"未找到{视觉服务}的配置,请先完成API配置") - print(f"{self.LOG_PREFIX} Kontext预设: 使用{视觉服务}服务, API: {provider_config.get('model')}") + # 创建请求ID + request_id = f"kontext_preset_{int(time.time())}_{random.randint(1000, 9999)}" + + # 准备阶段日志(合并为一条) + print(f"{self.REQUEST_PREFIX} Kontext预设准备 | 服务:{视觉服务} | 模型:{provider_config.get('model')} | 预设:{preset_name} | 请求ID:{request_id}") # 执行图像分析 - result = self._analyze_with_vision_service(image_data, final_prompt, selected_provider, provider_config) + result = self._analyze_with_vision_service(image_data, final_prompt, selected_provider, provider_config, request_id, Ollama自动释放) if result and result.get('success'): description = result.get('data', {}).get('description', '').strip() if not description: error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接' - print(f"{self.LOG_PREFIX} Kontext预设分析失败: {error_msg}") + print(f"{self.LOG_PREFIX} Kontext预设失败 | 错误:{error_msg}") raise RuntimeError(f"分析失败: {error_msg}") - print(f"{self.LOG_PREFIX} Kontext预设分析完成,结果长度: {len(description)}") + # 服务层已经打印了完成日志,这里不再重复 return (description,) else: error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果' @@ -226,17 +240,15 @@ class KontextPresetNode: print(f"{self.LOG_PREFIX} Kontext预设节点异常: {error_msg}") raise RuntimeError(f"分析异常: {error_msg}") - def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config): + def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config, request_id, auto_unload): """使用视觉服务分析图像""" try: - # 创建请求ID - request_id = f"kontext_preset_{int(time.time())}_{random.randint(1000, 9999)}" result_container = {} # 在独立线程中运行图像分析 thread = threading.Thread( target=self._run_async_vision_analysis, - args=(image_data, prompt_template, request_id, result_container, provider, provider_config) + args=(image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload) ) thread.start() @@ -264,7 +276,7 @@ class KontextPresetNode: except Exception as e: return {"success": False, "error": str(e)} - def _run_async_vision_analysis(self, image_data, prompt_template, request_id, result_container, provider, provider_config): + def _run_async_vision_analysis(self, image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload): """在独立线程中运行异步图像分析任务""" loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -274,21 +286,107 @@ class KontextPresetNode: result_container['result'] = {"success": False, "error": "任务被中断"} return - result = loop.run_until_complete( - VisionService.analyze_image(image_data, request_id, None, prompt_template, provider, provider_config) - ) + # 获取API密钥和模型 + api_key = provider_config.get('api_key', '') + model = provider_config.get('model', '') + temperature = provider_config.get('temperature', 0.7) + top_p = provider_config.get('top_p', 0.9) + max_tokens = provider_config.get('max_tokens', 500) + + if not api_key or not model: + result_container['result'] = { + "success": False, + "error": f"请先配置{provider}的API密钥和模型" + } + return + + # 检查是否启用直连模式 + bypass_proxy = False + try: + from ..config_manager import config_manager as cm + settings = cm.get_settings() + bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) + except Exception: + pass + + direct_mode_tag = "(直连)" if bypass_proxy else "" + + # 获取提供商显示名称 + provider_display_name = KontextPresetNode.PROVIDER_DISPLAY_MAP.get(provider, provider) + + # 创建客户端(复用VisionService的工具方法) + client = VisionService.get_openai_client(api_key, provider) + + # 预处理图像(复用VisionService的工具方法) + image_data = VisionService.preprocess_image(image_data, request_id) + + # 构建消息 + messages = [{ + "role": "user", + "content": [ + {"type": "text", "text": prompt_template}, + {"type": "image_url", "image_url": {"url": image_data}} + ] + }] + + print(f"{KontextPresetNode.PROCESS_PREFIX} 调用{provider_display_name}视觉API{direct_mode_tag} | 模型:{model}") + + # 调用API + stream = loop.run_until_complete(client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + stream=True + )) + + # 处理流式响应 + full_content = "" + + async def process_stream(): + nonlocal full_content + async for chunk in stream: + if result_container.get('interrupted'): + break + # 兼容部分第三方网关返回空 choices 的异常片段 + choices = getattr(chunk, "choices", None) or [] + for ch in choices: + delta = getattr(ch, "delta", None) + if not delta: + continue + content = getattr(delta, "content", None) + if content: + full_content += content + + loop.run_until_complete(process_stream()) # 检查是否在执行过程中被中断了 if result_container.get('interrupted'): result_container['result'] = {"success": False, "error": "任务被中断"} return - result_container['result'] = result + result_container['result'] = { + "success": True, + "data": { + "description": full_content + } + } + + # Ollama自动释放显存 + if provider == 'ollama': + try: + loop.run_until_complete(self._unload_ollama_model(model, provider_config, auto_unload)) + except Exception as e: + # 释放失败不影响结果 + pass + except Exception as e: if result_container.get('interrupted'): result_container['result'] = {"success": False, "error": "任务被中断"} else: - result_container['result'] = {"success": False, "error": str(e)} + error_message = format_api_error(e, provider_display_name if 'provider_display_name' in locals() else provider) + result_container['result'] = {"success": False, "error": error_message} finally: loop.close() @@ -299,6 +397,43 @@ class KontextPresetNode: return vision_config['providers'][provider] return None + async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool): + """ + 卸载Ollama模型以释放显存和内存 + + 参数: + model: 模型名称 + provider_config: 提供商配置字典 + auto_unload: 是否启用自动释放(来自节点参数) + """ + try: + # 检查是否启用自动释放(使用节点参数,不从provider_config读取) + if not auto_unload: + return + + # 获取base_url + base_url = provider_config.get('base_url', 'http://localhost:11434') + # 确保URL不以/v1结尾(Ollama原生API不需要/v1) + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + # 调用Ollama API卸载模型 + url = f"{base_url}/api/generate" + payload = { + "model": model, + "keep_alive": 0 # 立即卸载模型 + } + + print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") + + import httpx + async with httpx.AsyncClient(timeout=5.0) as client: + await client.post(url, json=payload) + + except Exception as e: + # 释放失败不影响主流程,只记录警告 + print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") + def _image_to_base64(self, image_tensor): """将图像张量转换为base64编码""" # 确保图像是正确的形状 [batch, height, width, channels] diff --git a/node/translate_node.py b/node/translate_node.py index 40e26e2..263ceb8 100644 --- a/node/translate_node.py +++ b/node/translate_node.py @@ -14,7 +14,9 @@ from ..services.error_util import format_api_error # 定义ANSI颜色代码常量 -GREEN = "\033[92m" +GREEN = "\033[32m" +BLUE = "\033[34m" +YELLOW = "\033[33m" RESET = "\033[0m" @@ -23,8 +25,19 @@ class PromptTranslate: 文本翻译节点 自动识别输入语言并翻译成目标语言,支持多种翻译服务 """ - # 定义日志前缀(带绿色) - LOG_PREFIX = f"{GREEN}[PromptAssistant]{RESET}" + # 定义日志前缀 + LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段 + REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段 + PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段 + + # 提供商显示名称映射 + PROVIDER_DISPLAY_MAP = { + "zhipu": "智谱", + "siliconflow": "硅基流动", + "302ai": "302.AI", + "ollama": "Ollama", + "custom": "自定义" + } @classmethod def INPUT_TYPES(cls): @@ -33,6 +46,8 @@ class PromptTranslate: "原文": ("STRING", {"forceInput": True, "default": "", "multiline": True, "placeholder": "输入要翻译的文本..."}), "目标语言": (["英文", "中文"], {"default": "英文"}), "翻译服务": (["百度翻译", "智谱翻译", "硅基流动翻译", "302.AI翻译", "Ollama翻译", "自定义翻译"], {"default": "百度翻译"}), + # Ollama自动释放:仅对Ollama翻译服务生效 + "Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}), }, } @@ -43,7 +58,7 @@ class PromptTranslate: OUTPUT_NODE = False @classmethod - def IS_CHANGED(cls, 原文, 目标语言, 翻译服务): + def IS_CHANGED(cls, 原文, 目标语言, 翻译服务, Ollama自动释放): """ 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 @@ -58,7 +73,8 @@ class PromptTranslate: input_hash = hash(( text_hash, 目标语言, - 翻译服务 + 翻译服务, + bool(Ollama自动释放) )) return input_hash @@ -86,7 +102,7 @@ class PromptTranslate: else: return "auto" - def translate(self, 原文, 目标语言, 翻译服务): + def translate(self, 原文, 目标语言, 翻译服务, Ollama自动释放): """ 翻译文本函数 @@ -94,6 +110,7 @@ class PromptTranslate: 原文: 输入的文本 目标语言: 目标语言 ("英文", "中文") 翻译服务: 翻译服务 ("百度翻译", "智谱翻译") + Ollama自动释放: 是否在调用完成后自动释放Ollama模型(仅对Ollama翻译生效) Returns: tuple: 翻译结果 @@ -110,30 +127,32 @@ class PromptTranslate: # 智能跳过翻译逻辑 skip_translation = False if to_lang == 'en' and detected_lang == 'en': - print(f"{self.LOG_PREFIX} 检测到英文输入,目标为英文,无需翻译。") + print(f"{self.REQUEST_PREFIX} 检测到英文输入,目标为英文,无需翻译") skip_translation = True elif to_lang == 'zh' and detected_lang == 'zh': - print(f"{self.LOG_PREFIX} 检测到中文输入,目标为中文,无需翻译。") + print(f"{self.REQUEST_PREFIX} 检测到中文输入,目标为中文,无需翻译") skip_translation = True if skip_translation: return (原文,) - # 执行翻译 - print(f"{self.LOG_PREFIX} 开始翻译: {detected_lang} -> {to_lang}, 服务: {翻译服务}") - + # 映射语言名称 + lang_map = {'zh': '中文', 'en': '英文', 'auto': '原文'} + from_lang_name = lang_map.get(detected_lang, detected_lang) + to_lang_name = lang_map.get(to_lang, to_lang) + if 翻译服务 == "百度翻译": - result = self._translate_with_baidu(原文, detected_lang, to_lang) + result = self._translate_with_baidu(原文, detected_lang, to_lang, 翻译服务, from_lang_name, to_lang_name) elif 翻译服务 == "智谱翻译": - result = self._translate_with_llm(原文, detected_lang, to_lang, "zhipu") + result = self._translate_with_llm(原文, detected_lang, to_lang, "zhipu", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放) elif 翻译服务 == "硅基流动翻译": - result = self._translate_with_llm(原文, detected_lang, to_lang, "siliconflow") + result = self._translate_with_llm(原文, detected_lang, to_lang, "siliconflow", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放) elif 翻译服务 == "302.AI翻译": - result = self._translate_with_llm(原文, detected_lang, to_lang, "302ai") + result = self._translate_with_llm(原文, detected_lang, to_lang, "302ai", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放) elif 翻译服务 == "Ollama翻译": - result = self._translate_with_llm(原文, detected_lang, to_lang, "ollama") + result = self._translate_with_llm(原文, detected_lang, to_lang, "ollama", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放) elif 翻译服务 == "自定义翻译": - result = self._translate_with_llm(原文, detected_lang, to_lang, "custom") + result = self._translate_with_llm(原文, detected_lang, to_lang, "custom", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放) else: raise ValueError(f"不支持的翻译服务: {翻译服务}") @@ -141,10 +160,13 @@ class PromptTranslate: translated_text = result.get('data', {}).get('translated', '').strip() if not translated_text: error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接' - print(f"{self.LOG_PREFIX} 翻译失败: {error_msg}") + print(f"{self.LOG_PREFIX} 翻译失败 | 错误:{error_msg}") raise RuntimeError(f"翻译失败: {error_msg}") - print(f"{self.LOG_PREFIX} 翻译完成,结果长度: {len(translated_text)}") + # 结果阶段日志(合并为一条) + # 获取request_id(从result或重新生成时间戳) + request_id = result.get('request_id', 'unknown') + print(f"{self.LOG_PREFIX} 翻译完成 | 服务:{翻译服务} | 结果字符数:{len(translated_text)}") return (translated_text,) else: error_msg = result.get('error', '翻译失败,未知错误') if result else '翻译服务未返回结果' @@ -159,11 +181,15 @@ class PromptTranslate: print(f"{self.LOG_PREFIX} 翻译异常: {error_msg}") raise RuntimeError(f"翻译异常: {error_msg}") - def _translate_with_baidu(self, text, from_lang, to_lang): + def _translate_with_baidu(self, text, from_lang, to_lang, service_name, from_lang_name, to_lang_name): """使用百度翻译服务""" try: # 创建请求ID request_id = f"translate_{int(time.time())}_{random.randint(1000, 9999)}" + + # 准备阶段日志(合并为一条) + print(f"{PromptTranslate.REQUEST_PREFIX} 翻译准备 | 服务:{service_name} | 方向:{from_lang_name} → {to_lang_name} | 长度:{len(text)} | 请求ID:{request_id}") + result_container = {} # 在独立线程中运行异步翻译 @@ -197,7 +223,7 @@ class PromptTranslate: except Exception as e: return {"success": False, "error": str(e)} - def _translate_with_llm(self, text, from_lang, to_lang, provider): + def _translate_with_llm(self, text, from_lang, to_lang, provider, service_name, from_lang_name, to_lang_name, auto_unload): """使用LLM翻译服务""" try: # 获取配置 @@ -205,24 +231,21 @@ class PromptTranslate: provider_config = self._get_provider_config(config_manager, provider) if not provider_config: - provider_display_map = { - "zhipu": "智谱翻译", - "siliconflow": "硅基流动翻译", - "302ai": "302.AI翻译", - "ollama": "Ollama翻译", - "custom": "自定义翻译" - } - provider_display_name = provider_display_map.get(provider, provider) + provider_display_name = self.PROVIDER_DISPLAY_MAP.get(provider, provider) return {"success": False, "error": f"未找到{provider_display_name}的配置,请先完成API配置"} # 创建请求ID request_id = f"translate_{int(time.time())}_{random.randint(1000, 9999)}" + + # 准备阶段日志(合并为一条) + print(f"{PromptTranslate.REQUEST_PREFIX} 翻译准备 | 服务:{service_name} | 模型:{provider_config.get('model')} | 方向:{from_lang_name} → {to_lang_name} | 长度:{len(text)} | 请求ID:{request_id}") + result_container = {} # 在独立线程中运行LLM翻译 thread = threading.Thread( target=self._run_llm_translation, - args=(text, from_lang, to_lang, request_id, result_container, provider, provider_config) + args=(text, from_lang, to_lang, request_id, result_container, provider, provider_config, auto_unload) ) thread.start() @@ -284,8 +307,45 @@ class PromptTranslate: if 'providers' in llm_config and provider in llm_config['providers']: return llm_config['providers'][provider] return None + + async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool): + """ + 卸载Ollama模型以释放显存和内存 + + 参数: + model: 模型名称 + provider_config: 提供商配置字典 + auto_unload: 是否启用自动释放(来自节点参数) + """ + try: + # 检查是否启用自动释放(使用节点参数,不从provider_config读取) + if not auto_unload: + return + + # 获取base_url + base_url = provider_config.get('base_url', 'http://localhost:11434') + # 确保URL不以/v1结尾(Ollama原生API不需要/v1) + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + # 调用Ollama API卸载模型 + url = f"{base_url}/api/generate" + payload = { + "model": model, + "keep_alive": 0 # 立即卸载模型 + } + + print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") + + import httpx + async with httpx.AsyncClient(timeout=5.0) as client: + await client.post(url, json=payload) + + except Exception as e: + # 释放失败不影响主流程,只记录警告 + print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") - def _run_llm_translation(self, text, from_lang, to_lang, request_id, result_container, provider, provider_config): + def _run_llm_translation(self, text, from_lang, to_lang, request_id, result_container, provider, provider_config, auto_unload): """在独立线程中运行LLM翻译""" loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -306,6 +366,20 @@ class PromptTranslate: } return + # 检查是否启用直连模式 + bypass_proxy = False + try: + from ..config_manager import config_manager as cm + settings = cm.get_settings() + bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) + except Exception: + pass + + direct_mode_tag = "(直连)" if bypass_proxy else "" + + # 获取提供商显示名称 + provider_display_name = PromptTranslate.PROVIDER_DISPLAY_MAP.get(provider, provider) + # 创建临时客户端 client = LLMService.get_openai_client(api_key, provider) @@ -339,7 +413,7 @@ class PromptTranslate: {"role": "user", "content": text} ] - print(f"{PromptTranslate.LOG_PREFIX} 调用{provider}翻译API | 模型:{model}") + print(f"{PromptTranslate.PROCESS_PREFIX} 调用{provider_display_name}翻译API{direct_mode_tag} | 模型:{model}") # 调用API stream = loop.run_until_complete(client.chat.completions.create( @@ -359,9 +433,15 @@ class PromptTranslate: # 检查是否被中断 if result_container.get('interrupted'): break - if chunk.choices[0].delta.content: - content = chunk.choices[0].delta.content - full_content += content + # 兼容部分第三方网关返回空 choices 的异常片段,做健壮性判断 + choices = getattr(chunk, "choices", None) or [] + for ch in choices: + delta = getattr(ch, "delta", None) + if not delta: + continue + content = getattr(delta, "content", None) + if content: + full_content += content loop.run_until_complete(process_stream()) @@ -379,25 +459,26 @@ class PromptTranslate: "translated": full_content } } - print(f"{PromptTranslate.LOG_PREFIX} 翻译完成 | 服务:{provider} | 结果字符数:{len(full_content)}") + + # Ollama自动释放显存 + if provider == 'ollama': + try: + loop.run_until_complete(self._unload_ollama_model(model, provider_config, auto_unload)) + except Exception as e: + # 释放失败不影响结果 + pass + + # 结果日志已在translate方法中统一打印,这里不再重复 except Exception as e: # 格式化错误信息 - provider_display_map = { - "zhipu": "智谱翻译", - "siliconflow": "硅基流动翻译", - "302ai": "302.AI翻译", - "ollama": "Ollama翻译", - "custom": "自定义翻译" - } - provider_display_name = provider_display_map.get(provider, provider) error_message = format_api_error(e, provider_display_name) result_container['result'] = { "success": False, "error": error_message } - print(f"{PromptTranslate.LOG_PREFIX} 翻译失败 | 服务:{provider} | 错误:{error_message}") + print(f"{PromptTranslate.LOG_PREFIX} 翻译失败 | 服务:{provider_display_name} | 错误:{error_message}") finally: loop.close() diff --git a/services/baidu.py b/services/baidu.py index 42a7fea..f0d836c 100644 --- a/services/baidu.py +++ b/services/baidu.py @@ -149,9 +149,10 @@ class BaiduTranslateService: from ..server import PREFIX, AUTO_TRANSLATE_PREFIX # 请求阶段:蓝色 + # 百度翻译始终使用直连模式(trust_env=False) from ..server import AUTO_TRANSLATE_REQUEST_PREFIX, REQUEST_PREFIX prefix = AUTO_TRANSLATE_REQUEST_PREFIX if is_auto else REQUEST_PREFIX - print(f"{prefix} {'工作流自动翻译' if is_auto else '翻译请求'} | 服务:百度翻译 | 请求ID:{request_id} | 原文长度:{len(text)} | 方向:{from_lang}->{to_lang}") + print(f"{prefix} {'工作流自动翻译' if is_auto else '翻译请求'}(直连) | 服务:百度翻译 | 请求ID:{request_id} | 原文长度:{len(text)} | 方向:{from_lang}->{to_lang}") text_chunks = BaiduTranslateService.split_text_by_paragraphs(text) if not text_chunks: diff --git a/services/llm.py b/services/llm.py index c7d111c..7c90dd8 100644 --- a/services/llm.py +++ b/services/llm.py @@ -20,6 +20,16 @@ class LLMService: 'ollama': 'http://localhost:11434/v1', 'custom': None # 使用配置中的自定义URL } + + # 提供商显示名称映射 + _provider_display_names = { + 'zhipu': '智谱', + 'siliconflow': '硅基流动', + 'openai': 'OpenAI', + '302ai': '302.AI', + 'ollama': 'Ollama', + 'custom': '自定义' + } @classmethod def get_openai_client(cls, api_key: str, provider: str) -> AsyncOpenAI: @@ -53,6 +63,15 @@ class LLMService: else: base_url = cls._provider_base_urls.get(provider) + # 检查是否启用"跳过代理直连" + bypass_proxy = False + try: + from ..config_manager import config_manager + settings = config_manager.get_settings() + bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) + except Exception: + pass + # 创建简化的httpx客户端,明确禁用HTTP/2以提高国内服务商兼容性 # 仅在"发起请求"阶段打印一条请求日志,避免重复 from ..server import PROCESS_PREFIX @@ -68,18 +87,23 @@ class LLMService: # read: 等待服务器响应的超时时间 # write: 发送请求数据的超时时间 # pool: 从连接池获取连接的超时时间 - http_client = httpx.AsyncClient( - timeout=httpx.Timeout( + http_client_kwargs = { + 'timeout': httpx.Timeout( connect=10.0, # 连接超时:10秒 read=60.0, # 读取超时:60秒(流式响应需要较长时间) write=10.0, # 写入超时:10秒 pool=5.0 # 连接池超时:5秒 ), - event_hooks={'request': [_on_request]}, - http2=False, # 明确禁用HTTP/2,提高国内服务商兼容性 - proxy=None, # 明确禁用代理,避免连接问题 - verify=True # 启用SSL验证,但使用系统证书 - ) + 'event_hooks': {'request': [_on_request]}, + 'http2': False, # 明确禁用HTTP/2,提高国内服务商兼容性 + 'verify': True # 启用SSL验证,但使用系统证书 + } + + # 如果启用"跳过代理直连",则设置 proxy=None + if bypass_proxy: + http_client_kwargs['proxy'] = None + + http_client = httpx.AsyncClient(**http_client_kwargs) kwargs = { "api_key": api_key, @@ -194,15 +218,21 @@ class LLMService: from ..server import PREFIX, ERROR_PREFIX # 获取提供商显示名称 - provider_display_name = { - 'zhipu': '智谱', - 'siliconflow': '硅基流动', - 'openai': 'OpenAI', - 'custom': '自定义' - }.get(provider, provider) + provider_display_name = LLMService._provider_display_names.get(provider, provider) + + # 检查是否启用直连模式 + bypass_proxy = False + try: + from ..config_manager import config_manager as cm + settings = cm.get_settings() + bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) + except Exception: + pass + + direct_mode_tag = "(直连)" if bypass_proxy else "" from ..server import REQUEST_PREFIX - print(f"{REQUEST_PREFIX} LLM扩写请求 | 服务:{provider_display_name} | ID:{request_id} | 内容:{prompt[:30]}...") + print(f"{REQUEST_PREFIX} LLM扩写请求{direct_mode_tag} | 服务:{provider_display_name} | ID:{request_id} | 内容:{prompt[:30]}...") # 加载系统提示词 from ..config_manager import config_manager @@ -258,9 +288,9 @@ class LLMService: _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" from ..server import PROCESS_PREFIX if attempt > 0: - print(f"{PROCESS_PREFIX} 重试LLM API调用 | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + print(f"{PROCESS_PREFIX} 重试LLM API调用{direct_mode_tag} | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") else: - print(f"{PROCESS_PREFIX} 调用LLM API | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + print(f"{PROCESS_PREFIX} 调用LLM API{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") start_time = time.perf_counter() # 兼容不同提供商:将 reasoning_effort 放到顶层,其它参数放入 extra_body @@ -406,17 +436,23 @@ class LLMService: from ..server import PREFIX, AUTO_TRANSLATE_PREFIX # 获取提供商显示名称 - provider_display_name = { - 'zhipu': '智谱', - 'siliconflow': '硅基流动', - 'openai': 'OpenAI', - 'custom': '自定义' - }.get(provider, provider) + provider_display_name = LLMService._provider_display_names.get(provider, provider) + + # 检查是否启用直连模式 + bypass_proxy = False + try: + from ..config_manager import config_manager as cm + settings = cm.get_settings() + bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) + except Exception: + pass + + direct_mode_tag = "(直连)" if bypass_proxy else "" # 使用统一的前缀(请求阶段使用蓝色) from ..server import AUTO_TRANSLATE_REQUEST_PREFIX, REQUEST_PREFIX prefix = AUTO_TRANSLATE_REQUEST_PREFIX if is_auto else REQUEST_PREFIX - print(f"{prefix} {'工作流自动翻译' if is_auto else '翻译请求'} | 服务:{provider_display_name}翻译 | 请求ID:{request_id} | 原文长度:{len(text)} | 方向:{from_lang}->{to_lang}") + print(f"{prefix} {'工作流自动翻译' if is_auto else '翻译请求'}{direct_mode_tag} | 服务:{provider_display_name}翻译 | 请求ID:{request_id} | 原文长度:{len(text)} | 方向:{from_lang}->{to_lang}") # 加载系统提示词 from ..config_manager import config_manager @@ -461,9 +497,9 @@ class LLMService: _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" from ..server import PROCESS_PREFIX if attempt > 0: - print(f"{PROCESS_PREFIX} 重试LLM API调用 | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + print(f"{PROCESS_PREFIX} 重试LLM API调用{direct_mode_tag} | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") else: - print(f"{PROCESS_PREFIX} 调用LLM API | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + print(f"{PROCESS_PREFIX} 调用LLM API{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") start_time = time.perf_counter() # 兼容不同提供商:将 reasoning_effort 放到顶层,其它参数放入 extra_body diff --git a/services/vlm.py b/services/vlm.py index 75dc4dd..8e5c8ef 100644 --- a/services/vlm.py +++ b/services/vlm.py @@ -21,6 +21,16 @@ class VisionService: 'ollama': 'http://localhost:11434/v1', 'custom': None # 使用配置中的自定义URL } + + # 提供商显示名称映射 + _provider_display_names = { + 'zhipu': '智谱', + 'siliconflow': '硅基流动', + 'openai': 'OpenAI', + '302ai': '302.AI', + 'ollama': 'Ollama', + 'custom': '自定义' + } @classmethod def get_openai_client(cls, api_key: str, provider: str) -> AsyncOpenAI: @@ -52,6 +62,15 @@ class VisionService: else: base_url = cls._provider_base_urls.get(provider) + # 检查是否启用"跳过代理直连" + bypass_proxy = False + try: + from ..config_manager import config_manager + settings = config_manager.get_settings() + bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) + except Exception: + pass + # 创建简化的httpx客户端,明确禁用HTTP/2以提高国内服务商兼容性 # 视觉模型需要较长的超时时间 # 仅在"发起请求"阶段打印一条请求日志,避免重复 @@ -65,18 +84,23 @@ class VisionService: # 使用细粒度的超时配置,提高网络波动下的稳定性 # 视觉模型处理需要较长时间,因此读取超时设置更长 - http_client = httpx.AsyncClient( - timeout=httpx.Timeout( + http_client_kwargs = { + 'timeout': httpx.Timeout( connect=15.0, # 连接超时:15秒 read=120.0, # 读取超时:120秒(视觉模型分析需要更长时间) write=15.0, # 写入超时:15秒 pool=10.0 # 连接池超时:10秒 ), - event_hooks={'request': [_on_request]}, - http2=False, # 明确禁用HTTP/2,提高国内服务商兼容性 - proxy=None, # 明确禁用代理,避免连接问题 - verify=True # 启用SSL验证,但使用系统证书 - ) + 'event_hooks': {'request': [_on_request]}, + 'http2': False, # 明确禁用HTTP/2,提高国内服务商兼容性 + 'verify': True # 启用SSL验证,但使用系统证书 + } + + # 如果启用"跳过代理直连",则设置 proxy=None + if bypass_proxy: + http_client_kwargs['proxy'] = None + + http_client = httpx.AsyncClient(**http_client_kwargs) kwargs = { "api_key": api_key, @@ -290,12 +314,7 @@ class VisionService: image_data = VisionService.preprocess_image(image_data, request_id) # 获取提供商显示名称 - provider_display_name = { - 'zhipu': '智谱', - 'siliconflow': '硅基流动', - 'openai': 'OpenAI', - 'custom': '自定义' - }.get(provider, provider) + provider_display_name = VisionService._provider_display_names.get(provider, provider) # 直接使用传入的提示词内容 system_prompt = prompt_content @@ -308,14 +327,8 @@ class VisionService: from ..config_manager import config_manager system_prompts_all = config_manager.get_system_prompts() vision_prompts = (system_prompts_all or {}).get('vision_prompts', {}) - matched_name = None - for pid, pdata in vision_prompts.items(): - if isinstance(pdata, dict) and pdata.get('content') == system_prompt: - matched_name = pdata.get('name', pid) - break - if matched_name: - from ..server import REQUEST_PREFIX - print(f"{REQUEST_PREFIX} 使用视觉反推规则:{matched_name}") + # 规则匹配不再打印,准备阶段已经显示过规则信息 + pass except Exception: pass @@ -336,14 +349,25 @@ class VisionService: max_retries = 3 retry_delay = 1.0 # 初始重试延迟(秒) + # 检查是否启用直连模式 + bypass_proxy = False + try: + from ..config_manager import config_manager as cm + settings = cm.get_settings() + bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) + except Exception: + pass + + direct_mode_tag = "(直连)" if bypass_proxy else "" + for attempt in range(max_retries): try: # 添加调试信息 from ..server import PROCESS_PREFIX if attempt > 0: - print(f"{PROCESS_PREFIX} 重试视觉模型API调用 | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + print(f"{PROCESS_PREFIX} 重试视觉模型API调用{direct_mode_tag} | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") else: - print(f"{PROCESS_PREFIX} 调用视觉模型API | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + print(f"{PROCESS_PREFIX} 调用视觉模型API{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") start_time = time.perf_counter() # 使用配置中的参数 @@ -434,7 +458,14 @@ class VisionService: # 输出结构化成功日志 elapsed_ms = int((time.perf_counter() - start_time) * 1000) - print(f"{PREFIX} 视觉模型分析成功 | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") + # 根据request_id前缀判断节点类型 + if request_id.startswith('image_caption_'): + success_msg = "图像反推完成" + elif request_id.startswith('kontext_preset_'): + success_msg = "Kontext预设完成" + else: + success_msg = "图像反推成功" + print(f"{PREFIX} {success_msg} | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") # Ollama自动释放显存 if provider == 'ollama':