小助手移除兼容代码,不再支持CF前端0.3.27以下的版本;

修复扩写、翻译使用302.ai服务时报错问题,ollama无法自动释放问题;
所有节点添加ollama释放选项
移除llm和vlm的强制直连参数,在设置界面中添加是否直连选项;
优化控制台日志输出格式;
This commit is contained in:
yawiii
2025-10-29 09:54:12 +08:00
parent 017643a9b9
commit 4e76705bd2
10 changed files with 819 additions and 535 deletions
+41
View File
@@ -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()
+81 -358
View File
@@ -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);
}
}
+13
View File
@@ -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",
+109 -23
View File
@@ -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()
+168 -31
View File
@@ -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]
+163 -28
View File
@@ -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]
+126 -45
View File
@@ -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()
+2 -1
View File
@@ -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:
+61 -25
View File
@@ -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
+55 -24
View File
@@ -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':