小助手移除兼容代码,不再支持CF前端0.3.27以下的版本;
修复扩写、翻译使用302.ai服务时报错问题,ollama无法自动释放问题; 所有节点添加ollama释放选项 移除llm和vlm的强制直连参数,在设置界面中添加是否直连选项; 优化控制台日志输出格式;
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user