This commit is contained in:
yawiii
2025-12-21 22:52:38 +08:00
parent 9fe8541817
commit dcd215438f
63 changed files with 28694 additions and 13115 deletions
+14
View File
@@ -0,0 +1,14 @@
"""
节点基类模块
提供所有节点的通用基础能力
"""
from .base_node import BaseNode
from .llm_node_base import LLMNodeBase
from .vlm_node_base import VLMNodeBase
__all__ = [
'BaseNode', 'LLMNodeBase', 'VLMNodeBase'
]
+235
View File
@@ -0,0 +1,235 @@
"""
节点抽象基类
提供所有节点的通用基础能力,包括线程管理、中断检测、Provider配置等
"""
import asyncio
import threading
import time
from typing import Any, Callable, Dict, Optional, Tuple
from comfy.model_management import InterruptProcessingException
from ...utils.common import PREFIX as LOG_PREFIX, REQUEST_PREFIX, PROCESS_PREFIX
class BaseNode:
"""
所有节点的抽象基类
提供通用功能:
- 线程执行与中断管理
- Provider配置覆盖
- 规则模板获取
"""
# 子类应该覆盖这些常量
LOG_PREFIX = LOG_PREFIX
REQUEST_PREFIX = REQUEST_PREFIX
PROCESS_PREFIX = PROCESS_PREFIX
def _run_thread_with_interrupt(
self,
target_func: Callable,
args: Tuple,
task_name: str = "任务"
) -> Dict[str, Any]:
"""
在独立线程中运行任务,并支持中断检测
参数:
target_func: 要在线程中执行的函数
args: 传递给函数的参数元组
task_name: 任务名称,用于日志输出(已废弃,保留参数以兼容旧代码)
返回:
result_container中的'result'字段内容
异常:
InterruptProcessingException: 当检测到用户中断时
"""
result_container = {}
# 启动线程
thread = threading.Thread(target=target_func, args=args)
thread.start()
# 等待完成,同时检查中断
while thread.is_alive():
try:
import nodes
nodes.before_node_execution()
except:
# 检测到中断,立即抛出异常
raise InterruptProcessingException()
time.sleep(0.1)
return result_container.get('result')
def _run_async_in_thread(
self,
async_func: Callable,
result_container: Dict[str, Any],
*args,
**kwargs
) -> None:
"""
在独立线程中运行异步任务的辅助方法
这个方法会:
1. 创建新的事件循环
2. 运行异步函数
3. 将结果存入result_container
4. 正确处理中断异常
参数:
async_func: 异步函数
result_container: 结果容器字典
*args, **kwargs: 传递给async_func的参数
结果:
result_container['result'] = 函数返回值或错误信息
"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(async_func(*args, **kwargs))
result_container['result'] = result
except (asyncio.CancelledError, KeyboardInterrupt):
# 捕获中断异常,不格式化错误
print(f"{self.LOG_PREFIX} 检测到异步任务被取消")
result_container['result'] = {"success": False, "error": "任务被中断"}
except Exception as e:
# 捕获其他异常,让子类处理格式化
result_container['result'] = {"success": False, "error": str(e)}
finally:
loop.close()
def _execute_with_interrupt(
self,
thread_func: Callable,
thread_args: Tuple,
cancel_event: Optional[Any] = None
) -> Any:
"""
执行带中断检测的线程任务(通用封装)
参数:
thread_func: 线程中执行的函数(通常是包含异步逻辑的函数)
thread_args: 传递给thread_func的参数
cancel_event: 可选的取消事件,用于通知异步任务中断
返回:
result_container['result']的内容
异常:
InterruptProcessingException: 当检测到用户中断时
"""
result_container = {}
# 如果提供了cancel_event,将其传递给thread_func
if cancel_event is not None:
thread = threading.Thread(target=thread_func, args=(result_container, cancel_event) + thread_args)
else:
thread = threading.Thread(target=thread_func, args=(result_container,) + thread_args)
thread.start()
# 等待完成,同时检查中断
while thread.is_alive():
is_interrupted = False
try:
import nodes
nodes.before_node_execution()
# 双重检查:额外检查 PromptServer 的全局中断状态
# 某些情况下 nodes.before_node_execution() 可能不会抛出异常
from server import PromptServer
if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted:
is_interrupted = True
except:
is_interrupted = True
if is_interrupted:
# 检测到中断,设置取消事件(如果提供)
if cancel_event is not None:
try:
cancel_event.set()
except:
pass
raise InterruptProcessingException()
time.sleep(0.1)
return result_container.get('result')
def _override_ollama_config(
self,
provider_config: Dict[str, Any],
auto_unload: bool
) -> Dict[str, Any]:
"""
覆盖Ollama配置中的auto_unload参数
参数:
provider_config: 原始Provider配置
auto_unload: 节点级别的自动释放设置
返回:
新的配置字典(不修改原始配置)
"""
config_copy = provider_config.copy()
config_copy['auto_unload'] = auto_unload
return config_copy
def _get_prompt_template(
self,
template_name: str,
prompt_type: str,
use_temp_rule: bool,
temp_rule_content: str,
default_content: str
) -> Tuple[str, str]:
"""
获取提示词模板内容
参数:
template_name: 模板名称
prompt_type: 模板类型('expand_prompts', 'vision_prompts'等)
use_temp_rule: 是否使用临时规则
temp_rule_content: 临时规则内容
default_content: 默认提示词内容
返回:
(prompt_content, rule_name) 元组
"""
# 使用临时规则
if use_temp_rule and temp_rule_content:
return temp_rule_content, "临时规则"
# 从配置获取模板
from ...config_manager import config_manager
system_prompts = config_manager.get_system_prompts()
if not system_prompts:
return default_content, "默认规则"
prompts = system_prompts.get(prompt_type, {})
if not prompts:
return default_content, "默认规则"
# 按显示名称匹配
for key, value in prompts.items():
if value.get('name') == template_name:
content = value.get('content', '')
if content:
return content, template_name
# 按键名匹配
for key, value in prompts.items():
if key == template_name:
content = value.get('content', '')
if content:
return content, template_name
# 未找到,使用默认
return default_content, "默认规则"
+221
View File
@@ -0,0 +1,221 @@
"""
LLM节点基类
为LLM类节点(扩写、翻译等)提供专用基础能力
"""
from typing import Any, Callable, Dict, Optional
import asyncio
from .base_node import BaseNode
from ...utils.common import format_api_error
class LLMNodeBase(BaseNode):
"""
LLM节点基类
提供LLM节点专用功能:
- LLM Provider配置获取
- LLM相关的通用逻辑
- 动态服务/模型列表生成
"""
@staticmethod
def get_llm_service_options():
"""
获取所有可用的LLM服务/模型选项列表
返回格式: ["百度翻译", "智谱/glm-4-flash", "Ollama/qwen3:14b", ...]
百度翻译服务无模型概念,仅显示服务名
其他服务显示为"服务名/模型名"格式
返回:
List[str]: 服务/模型选项列表
"""
from ...config_manager import config_manager
options = []
services = config_manager.get_all_services()
for service in services:
service_name = service.get('name', '')
service_id = service.get('id', '')
service_type = service.get('type', '')
# 百度翻译特殊处理:仅显示服务名
if service_type == 'baidu':
options.append(service_name)
continue
# 其他服务:遍历llm_models
llm_models = service.get('llm_models', [])
for model in llm_models:
model_name = model.get('name', '')
if model_name:
# 格式: "服务名/模型名"
options.append(f"{service_name}/{model_name}")
# 如果没有任何选项,返回默认值避免ComfyUI报错
if not options:
options = ["智谱"]
return options
@staticmethod
def get_translate_service_options():
"""
获取翻译服务专用的服务/模型选项列表
与get_llm_service_options的区别:
- 硬编码添加"百度翻译"选项(百度翻译使用独立配置,不在model_services中)
- 专门用于翻译节点和翻译按钮
返回格式: ["百度翻译", "智谱/glm-4-flash", "Ollama/qwen3:14b", ...]
返回:
List[str]: 服务/模型选项列表(包含百度翻译)
"""
from ...config_manager import config_manager
options = []
# ---硬编码添加百度翻译---
# 百度翻译使用独立的baidu_translate配置,不在model_services列表中
baidu_config = config_manager.load_config().get('baidu_translate', {})
# 即使没有配置app_id,也要显示百度选项
options.append("百度翻译")
# ---动态获取其他LLM服务---
services = config_manager.get_all_services()
for service in services:
service_name = service.get('name', '')
service_type = service.get('type', '')
# 遍历llm_models
llm_models = service.get('llm_models', [])
for model in llm_models:
model_name = model.get('name', '')
if model_name:
# 格式: "服务名/模型名"
options.append(f"{service_name}/{model_name}")
return options
@staticmethod
def parse_service_model(service_model_str: str):
"""
解析"服务名/模型名"格式的字符串
特殊处理:
- "百度翻译": 返回 ('baidu', None) - 百度翻译使用独立配置
参数:
service_model_str: 服务/模型字符串,例如 "智谱/glm-4-flash" 或 "百度翻译"
返回:
Tuple[str, Optional[str]]: (service_id, model_name)
- service_id: 服务ID (如 'zhipu', 'baidu')
- model_name: 模型名称,如果没有则为None
"""
from ...config_manager import config_manager
# 分割字符串
if '/' in service_model_str:
service_name, model_name = service_model_str.split('/', 1)
else:
service_name = service_model_str
model_name = None
# ---特殊处理:百度翻译---
if service_name in ['百度翻译', '百度', 'baidu']:
return 'baidu', None
# 查找对应的service_id
services = config_manager.get_all_services()
for service in services:
if service.get('name') == service_name:
return service.get('id'), model_name
# 未找到,返回None
return None, None
def _get_provider_config(
self,
config_manager: Any,
provider: str
) -> Optional[Dict[str, Any]]:
"""
获取指定LLM Provider的配置
参数:
config_manager: 配置管理器实例
provider: Provider标识符(如 'zhipu', 'ollama'等)
返回:
Provider配置字典,如果未找到则返回 None
"""
llm_config = config_manager.get_llm_config()
if 'providers' in llm_config and provider in llm_config['providers']:
return llm_config['providers'][provider]
return None
def _run_llm_task(
self,
llm_service_func: Callable,
provider: str,
*args,
**kwargs
) -> Dict[str, Any]:
"""
执行LLM任务(扩写/翻译)的统一方法
封装了:
1. 异步任务的线程执行
2. 中断异常的捕获和处理
3. API异常的格式化
参数:
llm_service_func: LLMService的异步方法
provider: Provider名称(用于错误格式化)
*args, **kwargs: 传递给llm_service_func的参数
返回:
{"success": bool, "data": dict, "error": str} 格式的结果
"""
def thread_task(result_container, cancel_event):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# 将cancel_event传递给服务函数
kwargs['cancel_event'] = cancel_event
result = loop.run_until_complete(llm_service_func(*args, **kwargs))
result_container['result'] = result
except (asyncio.CancelledError, KeyboardInterrupt):
print(f"{self.LOG_PREFIX} 异步任务被取消")
result_container['result'] = {"success": False, "error": "任务被中断"}
except Exception as e:
# 捕获其他异常,格式化错误信息
error_message = format_api_error(e, provider)
result_container['result'] = {"success": False, "error": error_message}
finally:
# 清理所有未完成的任务,消除 "Task was destroyed but it is pending" 警告
try:
pending = asyncio.all_tasks(loop)
if pending:
for task in pending:
task.cancel()
# 等待任务取消完成,忽略取消错误
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
finally:
loop.close()
# 创建取消事件
import threading
cancel_event = threading.Event()
return self._execute_with_interrupt(thread_task, (), cancel_event)
+279
View File
@@ -0,0 +1,279 @@
"""
VLM节点基类
为VLM类节点(图像反推、视频反推等)提供专用基础能力
"""
import asyncio
import threading
from typing import Any, Callable, Dict, Optional
import torch
from comfy.model_management import InterruptProcessingException
from .base_node import BaseNode
from ...utils.image import tensor_to_base64, compute_image_hash
from ...utils.common import format_api_error
class VLMNodeBase(BaseNode):
"""
VLM节点基类
提供VLM节点专用功能:
- VLM Provider配置获取
- 图像处理工具方法
- 图像哈希计算
- 统一的异步任务执行与中断处理
- 动态服务/模型列表生成
"""
@staticmethod
def get_vlm_service_options():
"""
获取所有可用的VLM服务/模型选项列表
返回格式: ["智谱/glm-4v-flash", "Ollama/llava:Q6_K", ...]
所有服务显示为"服务名/模型名"格式
返回:
List[str]: 服务/模型选项列表
"""
from ...config_manager import config_manager
options = []
services = config_manager.get_all_services()
for service in services:
service_name = service.get('name', '')
service_id = service.get('id', '')
service_type = service.get('type', '')
# 百度翻译没有vlm_models,跳过
if service_type == 'baidu':
continue
# 遍历vlm_models
vlm_models = service.get('vlm_models', [])
for model in vlm_models:
model_name = model.get('name', '')
if model_name:
# 格式: "服务名/模型名"
options.append(f"{service_name}/{model_name}")
# 如果没有任何选项,返回默认值避免ComfyUI报错
if not options:
options = ["智谱"]
return options
@staticmethod
def parse_service_model(service_model_str: str):
"""
解析"服务名/模型名"格式的字符串
参数:
service_model_str: 服务/模型字符串,例如 "智谱/glm-4v-flash"
返回:
Tuple[str, Optional[str]]: (service_id, model_name)
- service_id: 服务ID (如 'zhipu', 'ollama')
- model_name: 模型名称,如果没有则为None
"""
from ...config_manager import config_manager
# 分割字符串
if '/' in service_model_str:
service_name, model_name = service_model_str.split('/', 1)
else:
service_name = service_model_str
model_name = None
# 查找对应的service_id
services = config_manager.get_all_services()
for service in services:
if service.get('name') == service_name:
return service.get('id'), model_name
# 未找到,返回None
return None, None
def _get_provider_config(
self,
config_manager: Any,
provider: str
) -> Optional[Dict[str, Any]]:
"""
获取指定VLM Provider的配置
参数:
config_manager: 配置管理器实例
provider: Provider标识符(如 'zhipu', 'ollama'等)
返回:
Provider配置字典,如果未找到则返回 None
"""
vision_config = config_manager.get_vision_config()
if 'providers' in vision_config and provider in vision_config['providers']:
return vision_config['providers'][provider]
return None
def _image_to_base64(self, image_tensor: torch.Tensor, quality: int = 95) -> str:
"""
将图像tensor转换为base64编码
参数:
image_tensor: 图像tensor
quality: JPEG压缩质量(1-100)
返回:
base64编码的data URL
"""
return tensor_to_base64(image_tensor, quality)
def _compute_image_hash(self, image_tensor: Optional[torch.Tensor]) -> str:
"""
计算图像tensor的哈希值(用于IS_CHANGED)
参数:
image_tensor: 图像tensor或None
返回:
MD5哈希值的十六进制字符串
"""
return compute_image_hash(image_tensor)
def _run_async_task(
self,
async_func: Callable,
provider: str,
*args,
**kwargs
) -> Dict[str, Any]:
"""
在独立线程中运行异步任务,并处理中断
这个方法封装了:
1. 异步事件循环的创建和清理
2. 中断异常的捕获和处理
3. 普通异常的格式化
参数:
async_func: 异步函数(coroutine function)
provider: Provider名称(用于错误格式化)
*args, **kwargs: 传递给async_func的参数
返回:
{"success": bool, "data": dict, "error": str} 格式的结果
示例:
result = self._run_async_task(
VisionService.analyze_image,
"zhipu",
image_data=data,
request_id=req_id,
...
)
"""
result_container = {}
def thread_target():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# 执行异步任务
result = loop.run_until_complete(async_func(*args, **kwargs))
result_container['result'] = result
except (asyncio.CancelledError, KeyboardInterrupt):
# 捕获中断异常,不格式化错误
print(f"{self.LOG_PREFIX} 检测到异步任务被取消")
result_container['result'] = {"success": False, "error": "任务被中断"}
except Exception as e:
# 捕获其他异常,格式化错误信息
error_message = format_api_error(e, provider)
result_container['result'] = {"success": False, "error": error_message}
finally:
loop.close()
# 使用基类的线程中断检测方法
self._run_thread_with_interrupt(
thread_target,
(),
task_name="异步任务"
)
def _run_vision_task(
self,
vision_service_func: Callable,
provider: str,
*args,
**kwargs
) -> Dict[str, Any]:
"""
执行视觉任务(图像/视频分析)的统一方法
封装了:
1. 异步任务的线程执行
2. 中断异常的捕获和处理
3. API异常的格式化
参数:
vision_service_func: VisionService的异步方法(如analyze_image, analyze_images)
provider: Provider名称(用于错误格式化,如"zhipu")
*args, **kwargs: 传递给vision_service_func的参数
返回:
{"success": bool, "data": dict, "error": str} 格式的结果
异常:
InterruptProcessingException: 当检测到用户中断时
示例:
result = self._run_vision_task(
VisionService.analyze_image,
"zhipu",
image_data=data,
request_id=req_id,
prompt_content=prompt,
custom_provider="zhipu",
custom_provider_config=config
)
"""
def thread_task(result_container, cancel_event):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# 将cancel_event传递给服务函数
kwargs['cancel_event'] = cancel_event
result = loop.run_until_complete(vision_service_func(*args, **kwargs))
result_container['result'] = result
except (asyncio.CancelledError, KeyboardInterrupt):
# 捕获中断异常,不格式化错误
print(f"{self.LOG_PREFIX} 检测到异步任务被取消")
result_container['result'] = {"success": False, "error": "任务被中断"}
except Exception as e:
# 捕获其他异常,格式化错误信息
error_message = format_api_error(e, provider)
result_container['result'] = {"success": False, "error": error_message}
finally:
# 清理所有未完成的任务,消除 "Task was destroyed but it is pending" 警告
try:
pending = asyncio.all_tasks(loop)
if pending:
for task in pending:
task.cancel()
# 等待任务取消完成,忽略取消错误
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
finally:
loop.close()
# 创建取消事件
import threading
cancel_event = threading.Event()
# 使用基类的中断检测执行
return self._execute_with_interrupt(thread_task, (), cancel_event)
+157 -241
View File
@@ -8,35 +8,17 @@ import re
from comfy.model_management import InterruptProcessingException
from ..services.llm import LLMService
from ..services.error_util import format_api_error
from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_EXPAND, SOURCE_NODE
from ..services.thinking_control import build_thinking_suppression
from .base import LLMNodeBase
# 定义ANSI颜色代码常量
GREEN = "\033[32m"
BLUE = "\033[34m"
YELLOW = "\033[33m"
RESET = "\033[0m"
class PromptExpand:
class PromptExpand(LLMNodeBase):
"""
文本扩写节点
- 输入"原文",根据所选扩写规则模板或临时规则进行扩写
提示词增强节点
- 输入"source_text",根据所选规则模板或自定义规则进行增强/扩写
- 仅包含一个字符串输入和一个字符串输出
"""
# 定义日志前缀
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):
@@ -51,97 +33,100 @@ class PromptExpand:
expand_prompts = system_prompts.get('expand_prompts', {}) or {}
active_expand_id = system_prompts.get('active_prompts', {}).get('expand')
# 构建提示词模板选项(显示name)
# 构建提示词模板选项(支持分类格式:类别/规则名称)
prompt_template_options = []
id_to_name = {}
id_to_display_name = {}
for key, value in expand_prompts.items():
# 过滤掉不在后端显示的规则
show_in = value.get('showIn', ["frontend", "node"])
if 'node' not in show_in:
continue
name = value.get('name', key)
id_to_name[key] = name
prompt_template_options.append(name)
category = value.get('category', '')
# 如果有分类,显示为 "类别/规则名称",否则直接显示规则名称
display_name = f"{category}/{name}" if category else name
id_to_display_name[key] = display_name
prompt_template_options.append(display_name)
# 默认选项回退
default_template_name = prompt_template_options[0] if prompt_template_options else "扩写-自然语言"
if active_expand_id and active_expand_id in id_to_name:
default_template_name = id_to_name[active_expand_id]
if active_expand_id and active_expand_id in id_to_display_name:
default_template_name = id_to_display_name[active_expand_id]
# ---动态获取LLM服务/模型列表---
service_options = cls.get_llm_service_options()
default_service = service_options[0] if service_options else "智谱"
return {
"required": {
# 规则模板:来自系统配置的所有扩写规则
"规则模板": (prompt_template_options or ["扩写-自然语言"], {"default": default_template_name}),
# 临时规则开关:BOOLEAN,并添加中文开关标签
"临时规则": ("BOOLEAN", {"default": False, "label_on": "启用", "label_off": "禁用"}),
# 临时规则内容输入框:仅在启用临时规则时生效
"临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}),
# 用户提示词:与原文合并后提交
"用户提示词": ("STRING", {"multiline": True, "default": "", "placeholder": "填写的要扩写的提示词原文,若存在原文端口输入和内容输入,将合并提交"}),
# 扩写服务(使用LLM提供商)
"扩写服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}),
# Ollama自动释放:仅对Ollama服务生效
"Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}),
"rule": (prompt_template_options or ["扩写-自然语言"], {"default": default_template_name, "tooltip": "Choose a preset rule for prompt enhancement"}),
# 临时规则开关
"custom_rule": ("BOOLEAN", {"default": False, "label_on": "Enable", "label_off": "Disable", "tooltip": "Enable to use custom rule content below instead of preset"}),
# 临时规则内容输入框
"custom_rule_content": ("STRING", {"multiline": True, "default": "", "placeholder": "在此输入临时规则,仅在启用'临时规则'时生效"}),
# 用户提示词
"user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "填写的要优化的提示词原文,若存在原文端口输入和内容输入,将合并提交"}),
# 扩写服务
"llm_service": (service_options, {"default": default_service, "tooltip": "Select LLM service and model"}),
# Ollama自动释放显存
"ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}),
},
"optional": {
# 原文输入端口:非必选
"原文": ("STRING", {"default": "", "multiline": True, "defaultInput": True, "placeholder": "输入需要扩写的文本..."}),
# 原文输入端口
"source_text": ("STRING", {"default": "", "multiline": True, "defaultInput": True, "placeholder": "Input text to enhance..."}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("扩写结果",)
FUNCTION = "expand"
CATEGORY = "✨提示词小助手"
RETURN_NAMES = ("enhanced_text",)
FUNCTION = "enhance"
CATEGORY = "✨Prompt Assistant"
OUTPUT_NODE = False
@classmethod
def IS_CHANGED(cls, 规则模板=None, 临时规则=None, 临时规则内容=None, 用户提示词=None, 扩写服务=None, Ollama自动释放=None, 原文=None):
def IS_CHANGED(cls, rule=None, custom_rule=None, custom_rule_content=None, user_prompt=None, llm_service=None, ollama_auto_unload=None, source_text=None, unique_id=None):
"""
只在输入内容真正变化时才触发重新执行
使用输入参数的哈希值作为判断依据
"""
text_hash = hashlib.md5(((原文 or "")).encode('utf-8')).hexdigest()
temp_rule_hash = hashlib.md5((临时规则内容 or "").encode('utf-8')).hexdigest()
user_hint_hash = hashlib.md5((用户提示词 or "").encode('utf-8')).hexdigest()
text_hash = hashlib.md5(((source_text or "")).encode('utf-8')).hexdigest()
temp_rule_hash = hashlib.md5((custom_rule_content or "").encode('utf-8')).hexdigest()
user_hint_hash = hashlib.md5((user_prompt or "").encode('utf-8')).hexdigest()
input_hash = hash((
规则模板,
bool(临时规则),
rule,
bool(custom_rule),
temp_rule_hash,
user_hint_hash,
扩写服务,
bool(Ollama自动释放),
llm_service,
bool(ollama_auto_unload),
text_hash,
))
return input_hash
def expand(self, 规则模板, 临时规则, 临时规则内容, 用户提示词, 扩写服务, Ollama自动释放, 原文=None):
def enhance(self, rule, custom_rule, custom_rule_content, user_prompt, llm_service, ollama_auto_unload, source_text=None, unique_id=None):
"""
扩写文本函数
Args:
原文: 输入的文本(可为空)
规则模板: 选择的扩写规则模板名称(来自系统配置)
临时规则: 是否启用临时规则(BOOLEAN)
临时规则内容: 临时规则的内容(启用时生效)
用户提示词: 附加的用户提示信息,将与原文合并提交
扩写服务: LLM提供商(智谱/硅基流动/自定义)
Ollama自动释放: 是否在调用完成后自动释放Ollama模型(仅对Ollama生效)
Returns:
tuple: 扩写结果
增强/扩写文本函数
"""
try:
# 允许原文为空,但原文与用户提示词至少有一项非空
原文 = (原文 or "").strip()
用户提示词 = (用户提示词 or "").strip()
if not 原文 and not 用户提示词:
source_text = (source_text or "").strip()
user_prompt = (user_prompt or "").strip()
if not source_text and not user_prompt:
return ("",)
# 准备系统提示词(规则)
system_message = None
rule_name = "临时规则" if (临时规则 and 临时规则内容) else 规则模板
rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule
if 临时规则 and 临时规则内容:
if custom_rule and custom_rule_content:
# 使用临时规则
system_message = {"role": "system", "content": 临时规则内容}
system_message = {"role": "system", "content": custom_rule_content}
else:
# 使用模板:从config_manager获取系统提示词配置
from ..config_manager import config_manager
@@ -149,206 +134,138 @@ class PromptExpand:
expand_prompts = system_prompts.get('expand_prompts', {}) if system_prompts else {}
# 查找选定的提示词模板(按显示名称匹配)
# 显示名称格式:有分类时为 "类别/规则名称",无分类时为 "规则名称"
template_found = False
for key, value in expand_prompts.items():
if value.get('name') == 规则模板:
name = value.get('name', key)
category = value.get('category', '')
# 构建与下拉列表一致的显示名称
display_name = f"{category}/{name}" if category else name
if display_name == rule:
system_message = {"role": value.get('role', 'system'), "content": value.get('content', '')}
template_found = True
break
if not template_found:
# 允许用键名直接匹配
# 允许用规则名称或键名直接匹配(兼容旧格式)
for key, value in expand_prompts.items():
if key == 规则模板:
if value.get('name') == rule or key == rule:
system_message = {"role": value.get('role', 'system'), "content": value.get('content', '')}
template_found = True
break
if not template_found or not system_message or not system_message.get('content'):
# 回退到默认
system_message = {"role": "system", "content": "你是一名提示词扩写专家,请将用户给定文本扩写为更完整、更具可读性和可执行性的提示词。"}
rule_name = "默认规则"
rule_name = "Default Rule"
# 选择服务提供商
provider_map = {
"智谱": "zhipu",
"硅基流动": "siliconflow",
"302.AI": "302ai",
"Ollama": "ollama",
"自定义": "custom",
}
selected_provider = provider_map.get(扩写服务)
if not selected_provider:
raise ValueError(f"不支持的扩写服务: {扩写服务}")
# 获取对应provider的配置
# ---解析服务/模型字符串---
service_id, model_name = self.parse_service_model(llm_service)
if not service_id:
raise ValueError(f"Invalid service selection: {llm_service}")
# ---获取服务配置---
from ..config_manager import config_manager
provider_config = self._get_provider_config(config_manager, selected_provider)
if not provider_config:
provider_display = {"zhipu": "智谱", "siliconflow": "硅基流动", "302ai": "302.AI", "ollama": "Ollama", "custom": "自定义"}.get(selected_provider, selected_provider)
raise ValueError(f"未找到{provider_display}的配置,请先完成API配置")
service = config_manager.get_service(service_id)
if not service:
raise ValueError(f"Service config not found: {llm_service}")
# ---构建provider_config---
# 查找指定的模型或默认模型
llm_models = service.get('llm_models', [])
target_model = None
if model_name:
# 查找指定的模型
target_model = next((m for m in llm_models if m.get('name') == model_name), None)
if not target_model:
# 使用默认模型或第一个模型
target_model = next((m for m in llm_models if m.get('is_default')),
llm_models[0] if llm_models else None)
if not target_model:
raise ValueError(f"Service {llm_service} has no available models")
# 构建配置对象
provider_config = {
'provider': service_id,
'model': target_model.get('name', ''),
'base_url': service.get('base_url', ''),
'api_key': service.get('api_key', ''),
'temperature': target_model.get('temperature', 0.7),
'max_tokens': target_model.get('max_tokens', 1000),
'top_p': target_model.get('top_p', 0.9),
}
# Ollama特殊处理:添加auto_unload配置
if service.get('type') == 'ollama':
provider_config['auto_unload'] = ollama_auto_unload
# 执行扩写(异步线程 + 可中断)
request_id = f"expand_{int(time.time())}_{random.randint(1000, 9999)}"
request_id = generate_request_id("exp", None, unique_id)
# 合并原文与用户提示词
# 合并顺序:输入端口(原文)在前,节点输入框(用户提示词)在后
combined_text = 用户提示词 if not 原文 else (f"{原文}\n\n{用户提示词}" if 用户提示词 else 原文)
# 合并顺序:输入端口(source_text)在前,节点输入框(user_prompt)在后
combined_text = user_prompt if not source_text else (f"{source_text}\n\n{user_prompt}" if user_prompt else source_text)
# 准备阶段日志(合并为一条)
print(f"{self.REQUEST_PREFIX} 扩写准备 | 服务:{扩写服务} | 模型:{provider_config.get('model')} | 规则:{rule_name} | 长度:{len(combined_text)} | 请求ID:{request_id}")
# 检查是否关闭思维链
model_name = provider_config.get('model')
disable_thinking_enabled = service.get('disable_thinking', True)
thinking_extra = build_thinking_suppression(service_id, model_name) if disable_thinking_enabled else None
model_display = format_model_with_thinking(model_name, bool(thinking_extra))
# 获取服务显示名称
service_display_name = service.get('name', service_id)
result_container = {}
thread = threading.Thread(
target=self._run_llm_expand,
args=(combined_text, system_message, request_id, result_container, selected_provider, provider_config, 扩写服务, Ollama自动释放)
# 准备阶段日志
log_prepare(TASK_EXPAND, request_id, SOURCE_NODE, service_display_name, model_display, rule_name, {"长度": len(combined_text)})
# 检查API密钥和模型
api_key = provider_config.get('api_key', '')
model = provider_config.get('model', '')
if not api_key or not model:
raise ValueError(f"Please configure API key and model for {llm_service}")
# 执行扩写(异步线程 + 可中断)
result = self._run_llm_task(
LLMService.expand_prompt,
service_id,
prompt=combined_text,
request_id=request_id,
stream_callback=None,
custom_provider=service_id,
custom_provider_config=provider_config,
system_message_override=system_message,
task_type=TASK_EXPAND,
source=SOURCE_NODE
)
thread.start()
# 等待扩写完成,同时检查中断
while thread.is_alive():
try:
import nodes
nodes.before_node_execution()
except:
print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止扩写任务...")
result_container['interrupted'] = True
thread.join(timeout=1.0)
if thread.is_alive():
print(f"{self.LOG_PREFIX} 扩写线程未能及时响应中断")
raise InterruptProcessingException()
time.sleep(0.1)
result = result_container.get('result')
if result and result.get('success'):
expanded_text = result.get('data', {}).get('expanded', '').strip()
if not expanded_text:
error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接'
print(f"{self.LOG_PREFIX} 扩写失败 | 错误:{error_msg}")
raise RuntimeError(f"扩写失败: {error_msg}")
error_msg = 'API returned empty result'
log_error(TASK_EXPAND, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Enhancement failed: {error_msg}")
# 结果阶段日志由服务层统一输出,节点层不再重复打印
return (expanded_text,)
else:
error_msg = result.get('error', '扩写失败,未知错误') if result else '扩写服务未返回结果'
print(f"{self.LOG_PREFIX} 扩写失败: {error_msg}")
raise RuntimeError(f"扩写失败: {error_msg}")
error_msg = result.get('error', 'Unknown error') if result else 'No result returned'
# 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印)
if error_msg == "任务被中断":
raise InterruptProcessingException()
log_error(TASK_EXPAND, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Enhancement failed: {error_msg}")
except InterruptProcessingException:
print(f"{self.LOG_PREFIX} 扩写任务被用户取消")
# 不打印日志,由基类统一打印
raise
except Exception as e:
error_msg = format_api_error(e, 扩写服务)
print(f"{self.LOG_PREFIX} 扩写异常: {error_msg}")
raise RuntimeError(f"扩写异常: {error_msg}")
error_msg = format_api_error(e, llm_service)
log_error(TASK_EXPAND, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Enhancement error: {error_msg}")
def _get_provider_config(self, config_manager, provider):
"""获取指定provider的配置(LLM)"""
llm_config = config_manager.get_llm_config()
if 'providers' in llm_config and provider in llm_config['providers']:
return llm_config['providers'][provider]
return None
# _get_provider_config 方法已由基类 LLMNodeBase 提供
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, service_name, auto_unload):
"""在独立线程中运行LLM扩写"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
return
# 获取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', 1500)
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 = PromptExpand.PROVIDER_DISPLAY_MAP.get(provider, provider)
# 统一走服务层(含Gemini/网关兼容、思维链抑制、降级与重试)
service_result = loop.run_until_complete(
LLMService.expand_prompt(
prompt=text,
request_id=request_id,
stream_callback=None,
custom_provider=provider,
custom_provider_config=provider_config,
system_message_override=system_message
)
)
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
return
result_container['result'] = service_result
# 结果日志由服务层统一打印
except Exception as e:
result_container['result'] = {
"success": False,
"error": format_api_error(e, provider_display_name)
}
print(f"{self.LOG_PREFIX} 扩写失败 | 服务:{provider_display_name} | 错误:{result_container['result']['error']}")
finally:
loop.close()
# 节点映射,用于向ComfyUI注册节点
@@ -358,6 +275,5 @@ NODE_CLASS_MAPPINGS = {
# 节点显示名称映射
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptExpand": "✨提示词扩写",
"PromptExpand": "✨Prompt Enhance",
}
+136 -231
View File
@@ -14,33 +14,16 @@ from PIL import Image
from comfy.model_management import InterruptProcessingException
from ..services.vlm import VisionService
from ..services.error_util import format_api_error
# 定义ANSI颜色代码常量
GREEN = "\033[32m"
BLUE = "\033[34m"
YELLOW = "\033[33m"
RESET = "\033[0m"
from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_IMAGE_CAPTION, SOURCE_NODE
from ..services.thinking_control import build_thinking_suppression
from .base import VLMNodeBase
class ImageCaptionNode:
class ImageCaptionNode(VLMNodeBase):
"""
图像反推提示词节点
分析输入图像并生成描述性提示词
"""
# 定义日志前缀
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):
@@ -53,74 +36,76 @@ class ImageCaptionNode:
if system_prompts and 'vision_prompts' in system_prompts:
vision_prompts = system_prompts['vision_prompts']
# 构建提示词模板选项
# 构建提示词模板选项(支持分类格式:类别/规则名称)
prompt_template_options = []
for key, value in vision_prompts.items():
# 过滤掉不在后端显示的规则
show_in = value.get('showIn', ["frontend", "node"])
if 'node' not in show_in:
continue
name = value.get('name', key)
prompt_template_options.append(name)
category = value.get('category', '')
# 如果有分类,显示为 "类别/规则名称",否则直接显示规则名称
display_name = f"{category}/{name}" if category else name
prompt_template_options.append(display_name)
# 如果没有选项,添加一个默认选项
if not prompt_template_options:
prompt_template_options = ["默认中文反推提示词"]
# ---动态获取VLM服务/模型列表---
service_options = cls.get_vlm_service_options()
default_service = service_options[0] if service_options else "智谱"
return {
"required": {
"图像": ("IMAGE",),
"规则模板": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认中文反推提示词"}),
"临时规则": ("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服务时生效"}),
"image": ("IMAGE",),
"rule": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认中文反推提示词", "tooltip": "Choose a preset rule for image captioning"}),
"custom_rule": ("BOOLEAN", {"default": False, "label_on": "Enable", "label_off": "Disable", "tooltip": "Enable to use custom rule content below"}),
"custom_rule_content": ("STRING", {"multiline": True, "default": "", "placeholder": "在此输入临时规则,仅在启用'临时规则'时生效"}),
"user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "输入额外的具体要求,将与规则一起发送给模型"}),
"vlm_service": (service_options, {"default": default_service, "tooltip": "Select VLM service and model"}),
# Ollama Automatic VRAM Unload
"ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("提示词输出",)
RETURN_NAMES = ("prompt_output",)
FUNCTION = "analyze_image"
CATEGORY = "✨提示词小助手"
CATEGORY = "✨Prompt Assistant"
OUTPUT_NODE = False
@classmethod
def IS_CHANGED(cls, 图像=None, 规则模板=None, 临时规则=None, 临时规则内容=None, 视觉服务=None, Ollama自动释放=None):
def IS_CHANGED(cls, image=None, rule=None, custom_rule=None, custom_rule_content=None, user_prompt=None, vlm_service=None, ollama_auto_unload=None, unique_id=None):
"""
只在输入内容真正变化时才触发重新执行
使用输入参数的哈希值作为判断依据
"""
# 计算图像的哈希值(只使用第一帧的部分数据,避免计算量过大)
img_hash = ""
if 图像 is not None:
try:
if len(图像.shape) == 4:
# 取第一帧的中心区域作为哈希计算依据
h, w = 图像.shape[1:3]
center_h, center_w = h // 2, w // 2
size = min(100, h // 4, w // 4) # 限制计算区域大小
img_data = 图像[0,
max(0, center_h - size):min(h, center_h + size),
max(0, center_w - size):min(w, center_w + size),
0].cpu().numpy().tobytes()
img_hash = hashlib.md5(img_data).hexdigest()
else:
# 如果不是4D张量,使用整个张量的哈希
img_data = 图像.cpu().numpy().tobytes()
img_hash = hashlib.md5(img_data).hexdigest()
except Exception:
img_hash = "0"
# 导入图像哈希工具函数
from ..utils.image import compute_image_hash
# 计算图像的哈希值
img_hash = compute_image_hash(image)
# 组合所有输入的哈希值
input_hash = hash((
img_hash,
规则模板,
bool(临时规则),
临时规则内容,
视觉服务,
bool(Ollama自动释放)
rule,
bool(custom_rule),
custom_rule_content,
user_prompt,
vlm_service,
bool(ollama_auto_unload)
))
return input_hash
def analyze_image(self, 图像, 规则模板, 临时规则, 临时规则内容, 视觉服务, Ollama自动释放):
def analyze_image(self, image, rule, custom_rule, custom_rule_content, user_prompt, vlm_service, ollama_auto_unload, unique_id=None):
"""
分析图像并生成提示词
@@ -129,6 +114,7 @@ class ImageCaptionNode:
规则模板: 选择的提示词模板
临时规则: 是否启用临时规则
临时规则内容: 临时规则的内容
用户提示词: 用户补充的提示词
视觉服务: 选择的视觉服务
Returns:
@@ -136,20 +122,20 @@ class ImageCaptionNode:
"""
try:
# 检查输入
if 图像 is None:
if image is None:
raise ValueError("输入图像不能为空")
# 将图像转换为base64编码
image_data = self._image_to_base64(图像)
image_data = self._image_to_base64(image)
# 获取提示词模板内容
prompt_template = None
# 确定使用的规则
rule_name = "临时规则" if (临时规则 and 临时规则内容) else 规则模板
rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule
if 临时规则 and 临时规则内容:
prompt_template = 临时规则内容
if custom_rule and custom_rule_content:
prompt_template = custom_rule_content
else:
# 从config_manager获取系统提示词配置
from ..config_manager import config_manager
@@ -160,18 +146,23 @@ class ImageCaptionNode:
if system_prompts and 'vision_prompts' in system_prompts:
vision_prompts = system_prompts['vision_prompts']
# 查找选定的提示词模板
# 查找选定的提示词模板(按显示名称匹配)
# 显示名称格式:有分类时为 "类别/规则名称",无分类时为 "规则名称"
template_found = False
for key, value in vision_prompts.items():
if value.get('name') == 规则模板:
name = value.get('name', key)
category = value.get('category', '')
# 构建与下拉列表一致的显示名称
display_name = f"{category}/{name}" if category else name
if display_name == rule:
prompt_template = value.get('content')
template_found = True
break
if not template_found:
# 尝试直接匹配键名
# 允许用规则名称或键名直接匹配(兼容旧格式)
for key, value in vision_prompts.items():
if key == 规则模板:
if value.get('name') == rule or key == rule:
prompt_template = value.get('content')
template_found = True
break
@@ -179,195 +170,109 @@ class ImageCaptionNode:
# 如果没有找到提示词模板,使用默认值
if not template_found or not prompt_template:
prompt_template = "请详细描述这张图片的内容,包括主体、场景、风格、色彩等要素。"
rule_name = "默认规则"
rule_name = "Default Rule"
# 映射视觉服务选项到provider
provider_map = {
"智谱": "zhipu",
"硅基流动": "siliconflow",
"302.AI": "302ai",
"Ollama": "ollama",
"自定义": "custom"
}
# 拼接用户提示词
if user_prompt and user_prompt.strip():
prompt_template = f"{prompt_template}\n\n用户补充要求:\n{user_prompt}"
# 获取选定的provider
selected_provider = provider_map.get(视觉服务)
if not selected_provider:
raise ValueError(f"不支持的视觉服务: {视觉服务}")
# 获取对应provider的配置
# ---解析服务/模型字符串---
service_id, model_name = self.parse_service_model(vlm_service)
if not service_id:
raise ValueError(f"Invalid service selection: {vlm_service}")
# ---获取服务配置---
from ..config_manager import config_manager
provider_config = self._get_provider_config(config_manager, selected_provider)
if not provider_config:
raise ValueError(f"未找到{视觉服务}的配置,请先完成API配置")
service = config_manager.get_service(service_id)
if not service:
raise ValueError(f"Service config not found: {vlm_service}")
# ---构建provider_config---
# 查找指定的模型或默认模型
vlm_models = service.get('vlm_models', [])
target_model = None
if model_name:
# 查找指定的模型
target_model = next((m for m in vlm_models if m.get('name') == model_name), None)
if not target_model:
# 使用默认模型或第一个模型
target_model = next((m for m in vlm_models if m.get('is_default')),
vlm_models[0] if vlm_models else None)
if not target_model:
raise ValueError(f"Service {vlm_service} has no available models")
# 构建配置对象
provider_config = {
'provider': service_id,
'model': target_model.get('name', ''),
'base_url': service.get('base_url', ''),
'api_key': service.get('api_key', ''),
'temperature': target_model.get('temperature', 0.7),
'max_tokens': target_model.get('max_tokens', 500),
'top_p': target_model.get('top_p', 0.9),
}
# Ollama特殊处理:添加auto_unload配置
if service.get('type') == 'ollama':
provider_config['auto_unload'] = ollama_auto_unload
# 创建请求ID
request_id = f"image_caption_{int(time.time())}_{random.randint(1000, 9999)}"
request_id = generate_request_id("icap", None, unique_id)
# 准备阶段日志(合并为一条)
print(f"{self.REQUEST_PREFIX} 图像反推准备 | 服务:{视觉服务} | 模型:{provider_config.get('model')} | 规则:{rule_name} | 请求ID:{request_id}")
# 检查是否关闭思维链
model_full_name = provider_config.get('model')
disable_thinking_enabled = service.get('disable_thinking', True)
thinking_extra = build_thinking_suppression(service_id, model_full_name) if disable_thinking_enabled else None
model_display = format_model_with_thinking(model_full_name, bool(thinking_extra))
# 获取服务显示名称
service_display_name = service.get('name', service_id)
# 准备阶段日志
log_prepare(TASK_IMAGE_CAPTION, request_id, SOURCE_NODE, service_display_name, model_display, rule_name)
# 执行图像分析
result = self._analyze_with_vision_service(image_data, prompt_template, selected_provider, provider_config, request_id, Ollama自动释放)
result = self._run_vision_task(
VisionService.analyze_image,
service_id,
image_data=image_data,
request_id=request_id,
stream_callback=None,
prompt_content=prompt_template,
custom_provider=service_id,
custom_provider_config=provider_config,
task_type=TASK_IMAGE_CAPTION,
source=SOURCE_NODE
)
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}")
log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"分析失败: {error_msg}")
# 服务层已经打印了完成日志,这里不再重复
return (description,)
else:
error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果'
print(f"{self.LOG_PREFIX} 图像反推分析失败: {error_msg}")
# 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印)
if error_msg == "任务被中断":
raise InterruptProcessingException()
log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"分析失败: {error_msg}")
except InterruptProcessingException:
print(f"{self.LOG_PREFIX} 图像反推任务被用户取消")
# 不打印日志,由基类统一打印
raise
except Exception as e:
error_msg = format_api_error(e, 视觉服务)
print(f"{self.LOG_PREFIX} 图像反推节点异常: {error_msg}")
error_msg = format_api_error(e, vlm_service)
log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"分析异常: {error_msg}")
def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config, request_id, auto_unload):
"""使用视觉服务分析图像"""
try:
result_container = {}
# 在独立线程中运行图像分析
thread = threading.Thread(
target=self._run_async_vision_analysis,
args=(image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload)
)
thread.start()
# 等待分析完成,同时检查中断
while thread.is_alive():
# 检查是否被中断 - 这会抛出 InterruptProcessingException
try:
import nodes
nodes.before_node_execution()
except:
# 如果检查中断时出现异常,说明被中断了
print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止分析任务...")
# 设置结果容器为中断状态,让线程知道要停止
result_container['interrupted'] = True
# 等待线程结束或超时
thread.join(timeout=1.0)
if thread.is_alive():
print(f"{self.LOG_PREFIX} 分析线程未能及时响应中断")
raise InterruptProcessingException()
time.sleep(0.1)
return result_container.get('result')
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, auto_unload):
"""在独立线程中运行异步图像分析任务(改为复用服务层以获得Gemini兼容与降级能力)"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# 开始前的中断检测
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
return
# 直接复用服务层:包括图像预处理、流式/非流式降级、max_tokens 兼容与思维链抑制
service_call = VisionService.analyze_image(
image_data=image_data,
request_id=request_id,
stream_callback=None,
prompt_content=prompt_template,
custom_provider=provider,
custom_provider_config=provider_config
)
result = loop.run_until_complete(service_call)
# 中断检测
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
return
result_container['result'] = result
except Exception as e:
error_message = format_api_error(e, provider)
result_container['result'] = {"success": False, "error": error_message}
finally:
loop.close()
def _get_provider_config(self, config_manager, provider):
"""获取指定provider的配置"""
vision_config = config_manager.get_vision_config()
if 'providers' in vision_config and provider in vision_config['providers']:
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]
if len(image_tensor.shape) == 4:
# 取第一张图片
image_tensor = image_tensor[0]
# 将图像转换为numpy数组并缩放到0-255范围
image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8)
# 创建PIL图像
image = Image.fromarray(image_np)
# 将图像转换为JPEG格式的字节流
buffer = BytesIO()
image.save(buffer, format="JPEG", quality=95)
# 将字节流转换为base64编码
encoded_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
# 返回带有MIME类型的data URL
return f"data:image/jpeg;base64,{encoded_image}"
# 节点映射,用于向ComfyUI注册节点
NODE_CLASS_MAPPINGS = {
+117 -229
View File
@@ -14,33 +14,15 @@ from PIL import Image
from comfy.model_management import InterruptProcessingException
from ..services.vlm import VisionService
from ..services.error_util import format_api_error
# 定义ANSI颜色代码常量
GREEN = "\033[32m"
BLUE = "\033[34m"
YELLOW = "\033[33m"
RESET = "\033[0m"
from ..utils.common import format_api_error, log_prepare, log_error, SOURCE_NODE, generate_request_id
from .base import VLMNodeBase
class KontextPresetNode:
class KontextPresetNode(VLMNodeBase):
"""
Kontext预设助手节点
使用Kontext预设分析图像并生成创意转换指令
"""
# 定义日志前缀
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
@@ -50,8 +32,10 @@ class KontextPresetNode:
"""加载Kontext配置,使用缓存避免重复读取文件"""
if cls._kontext_config is None:
try:
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
kontext_presets_path = os.path.join(config_dir, "kontext_presets.json")
from ..config_manager import config_manager
# 使用 config_manager 的 kontext_presets_path (指向 rules 目录)
kontext_presets_path = config_manager.kontext_presets_path
if os.path.exists(kontext_presets_path):
with open(kontext_presets_path, "r", encoding="utf-8") as f:
cls._kontext_config = json.load(f)
@@ -61,6 +45,7 @@ class KontextPresetNode:
print(f"{cls.LOG_PREFIX} 加载Kontext配置失败: {str(e)}")
cls._kontext_config = {}
return cls._kontext_config
@classmethod
def INPUT_TYPES(cls):
@@ -79,79 +64,71 @@ class KontextPresetNode:
# 如果没有选项,添加一个默认选项
if not prompt_template_options:
prompt_template_options = ["情境深度融合"]
# ---动态获取VLM服务/模型列表---
service_options = cls.get_vlm_service_options()
default_service = service_options[0] if service_options else "智谱"
return {
"required": {
"图像": ("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服务时生效"}),
"image": ("IMAGE",),
"kontext_preset": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "情境深度融合"}),
"user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "输入额外的具体要求,将与预设一起发送给模型"}),
"vlm_service": (service_options, {"default": default_service, "tooltip": "Select VLM service and model"}),
# Ollama Automatic VRAM Unload
"ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("创意指令",)
RETURN_NAMES = ("creative_instruction",)
FUNCTION = "analyze_image"
CATEGORY = "✨提示词小助手"
CATEGORY = "✨Prompt Assistant"
OUTPUT_NODE = False
@classmethod
def IS_CHANGED(cls, 图像=None, Kontext预设=None, 视觉服务=None, Ollama自动释放=None):
def IS_CHANGED(cls, image=None, kontext_preset=None, user_prompt=None, vlm_service=None, ollama_auto_unload=None):
"""
只在输入内容真正变化时才触发重新执行
使用输入参数的哈希值作为判断依据
"""
# 计算图像的哈希值(只使用第一帧的部分数据,避免计算量过大)
img_hash = ""
if 图像 is not None:
try:
if len(图像.shape) == 4:
# 取第一帧的中心区域作为哈希计算依据
h, w = 图像.shape[1:3]
center_h, center_w = h // 2, w // 2
size = min(100, h // 4, w // 4) # 限制计算区域大小
img_data = 图像[0,
max(0, center_h - size):min(h, center_h + size),
max(0, center_w - size):min(w, center_w + size),
0].cpu().numpy().tobytes()
img_hash = hashlib.md5(img_data).hexdigest()
else:
# 如果不是4D张量,使用整个张量的哈希
img_data = 图像.cpu().numpy().tobytes()
img_hash = hashlib.md5(img_data).hexdigest()
except Exception:
img_hash = "0"
# 导入图像哈希工具函数
from ..utils.image import compute_image_hash
# 计算图像的哈希值
img_hash = compute_image_hash(image)
# 组合所有输入的哈希值
input_hash = hash((
img_hash,
Kontext预设,
视觉服务,
bool(Ollama自动释放)
kontext_preset,
user_prompt,
vlm_service,
bool(ollama_auto_unload)
))
return input_hash
def analyze_image(self, 图像, Kontext预设, 视觉服务, Ollama自动释放):
def analyze_image(self, image, kontext_preset, user_prompt, vlm_service, ollama_auto_unload):
"""
使用Kontext预设分析图像并生成创意转换指令
Args:
图像: 输入的图像数据
Kontext预设: 选择的Kontext预设
视觉服务: 选择的视觉服务
image: 输入的图像数据
kontext_preset: 选择的Kontext预设
user_prompt: 用户补充的提示词
vlm_service: 选择的视觉服务
Returns:
tuple: 分析结果
"""
try:
# 检查输入
if 图像 is None:
if image is None:
raise ValueError("输入图像不能为空")
# 将图像转换为base64编码
image_data = self._image_to_base64(图像)
image_data = self._image_to_base64(image)
# 获取kontext配置
config_data = self.__class__._load_kontext_config()
@@ -163,10 +140,10 @@ class KontextPresetNode:
prompt_template = None
# 查找选定的提示词模板
preset_name = Kontext预设
preset_name = kontext_preset
template_found = False
for key, value in kontext_presets.items():
if value.get('name') == Kontext预设:
if value.get('name') == kontext_preset:
prompt_template = value.get('content')
template_found = True
break
@@ -174,7 +151,7 @@ class KontextPresetNode:
if not template_found:
# 尝试直接匹配键名
for key, value in kontext_presets.items():
if key == Kontext预设 or key == f"kontext_{Kontext预设}":
if key == kontext_preset or key == f"kontext_{kontext_preset}":
prompt_template = value.get('content')
template_found = True
break
@@ -188,192 +165,103 @@ class KontextPresetNode:
final_prompt = prompt_template
if kontext_prefix and kontext_suffix:
final_prompt = f"{kontext_prefix}\n\nThe Brief: {prompt_template}\n\n{kontext_suffix}"
# 拼接用户提示词
if user_prompt and user_prompt.strip():
final_prompt = f"{final_prompt}\n\n用户补充要求:\n{user_prompt}"
# 映射视觉服务选项到provider
provider_map = {
"智谱": "zhipu",
"硅基流动": "siliconflow",
"302.AI": "302ai",
"Ollama": "ollama",
"自定义": "custom"
}
# 获取选定的provider
selected_provider = provider_map.get(视觉服务)
if not selected_provider:
raise ValueError(f"不支持的视觉服务: {视觉服务}")
# 获取对应provider的配置
# ---解析服务/模型字符串---
service_id, model_name = self.parse_service_model(vlm_service)
if not service_id:
raise ValueError(f"Invalid service selection: {vlm_service}")
# ---获取服务配置---
from ..config_manager import config_manager
provider_config = self._get_provider_config(config_manager, selected_provider)
if not provider_config:
raise ValueError(f"未找到{视觉服务}的配置,请先完成API配置")
service = config_manager.get_service(service_id)
if not service:
raise ValueError(f"Service config not found: {vlm_service}")
# ---构建provider_config---
# 查找指定的模型或默认模型
vlm_models = service.get('vlm_models', [])
target_model = None
if model_name:
# 查找指定的模型
target_model = next((m for m in vlm_models if m.get('name') == model_name), None)
if not target_model:
# 使用默认模型或第一个模型
target_model = next((m for m in vlm_models if m.get('is_default')),
vlm_models[0] if vlm_models else None)
if not target_model:
raise ValueError(f"Service {vlm_service} has no available models")
# 构建配置对象
provider_config = {
'provider': service_id,
'model': target_model.get('name', ''),
'base_url': service.get('base_url', ''),
'api_key': service.get('api_key', ''),
'temperature': target_model.get('temperature', 0.7),
'max_tokens': target_model.get('max_tokens', 500),
'top_p': target_model.get('top_p', 0.9),
}
# Ollama特殊处理:添加auto_unload配置
if service.get('type') == 'ollama':
provider_config['auto_unload'] = ollama_auto_unload
# 创建请求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}")
# 获取服务显示名称
service_display_name = service.get('name', service_id)
# 准备阶段日志
log_prepare("Kontext预设", request_id, SOURCE_NODE, service_display_name, provider_config.get('model'), preset_name)
# 执行图像分析
result = self._analyze_with_vision_service(image_data, final_prompt, selected_provider, provider_config, request_id, Ollama自动释放)
result = self._run_vision_task(
VisionService.analyze_image,
service_id,
image_data=image_data,
request_id=request_id,
stream_callback=None,
prompt_content=final_prompt,
custom_provider=service_id,
custom_provider_config=provider_config,
task_type="Kontext预设",
source=SOURCE_NODE
)
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}")
raise RuntimeError(f"分析失败: {error_msg}")
error_msg = 'API returned empty result'
log_error("Kontext预设", request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Analysis failed: {error_msg}")
# 服务层已经打印了完成日志,这里不再重复
return (description,)
else:
error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果'
print(f"{self.LOG_PREFIX} Kontext预设分析失败: {error_msg}")
raise RuntimeError(f"分析失败: {error_msg}")
error_msg = result.get('error', 'Unknown error') if result else 'No result returned'
# 如果是中断错误,直接打印日志并抛出InterruptProcessingException
if error_msg == "任务被中断":
print(f"{self.LOG_PREFIX} ⛔️Task cancelled by user | RequestID:{request_id}")
raise InterruptProcessingException()
log_error("Kontext预设", request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Analysis failed: {error_msg}")
except InterruptProcessingException:
print(f"{self.LOG_PREFIX} Kontext预设任务被用户取消")
print(f"{self.LOG_PREFIX} ⛔️Task cancelled by user | RequestID:{request_id}")
raise
except Exception as e:
error_msg = format_api_error(e, 视觉服务)
print(f"{self.LOG_PREFIX} Kontext预设节点异常: {error_msg}")
raise RuntimeError(f"分析异常: {error_msg}")
error_msg = format_api_error(e, vlm_service)
log_error("Kontext预设", request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Analysis error: {error_msg}")
def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config, request_id, auto_unload):
"""使用视觉服务分析图像"""
try:
result_container = {}
# 在独立线程中运行图像分析
thread = threading.Thread(
target=self._run_async_vision_analysis,
args=(image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload)
)
thread.start()
# 等待分析完成,同时检查中断
while thread.is_alive():
# 检查是否被中断 - 这会抛出 InterruptProcessingException
try:
import nodes
nodes.before_node_execution()
except:
# 如果检查中断时出现异常,说明被中断了
print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止分析任务...")
# 设置结果容器为中断状态,让线程知道要停止
result_container['interrupted'] = True
# 等待线程结束或超时
thread.join(timeout=1.0)
if thread.is_alive():
print(f"{self.LOG_PREFIX} 分析线程未能及时响应中断")
raise InterruptProcessingException()
time.sleep(0.1)
return result_container.get('result')
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, auto_unload):
"""在独立线程中运行异步图像分析任务(改为复用服务层,获得Gemini兼容与降级能力)"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
return
result = loop.run_until_complete(
VisionService.analyze_image(
image_data=image_data,
request_id=request_id,
stream_callback=None,
prompt_content=prompt_template,
custom_provider=provider,
custom_provider_config=provider_config
)
)
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
return
result_container['result'] = result
except Exception as e:
error_message = format_api_error(e, provider)
result_container['result'] = {"success": False, "error": error_message}
finally:
loop.close()
def _get_provider_config(self, config_manager, provider):
"""获取指定provider的配置"""
vision_config = config_manager.get_vision_config()
if 'providers' in vision_config and provider in vision_config['providers']:
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]
if len(image_tensor.shape) == 4:
# 取第一张图片
image_tensor = image_tensor[0]
# 将图像转换为numpy数组并缩放到0-255范围
image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8)
# 创建PIL图像
image = Image.fromarray(image_np)
# 将图像转换为JPEG格式的字节流
buffer = BytesIO()
image.save(buffer, format="JPEG", quality=95)
# 将字节流转换为base64编码
encoded_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
# 返回带有MIME类型的data URL
return f"data:image/jpeg;base64,{encoded_image}"
# 节点映射,用于向ComfyUI注册节点
NODE_CLASS_MAPPINGS = {
@@ -382,5 +270,5 @@ NODE_CLASS_MAPPINGS = {
# 节点显示名称映射
NODE_DISPLAY_NAME_MAPPINGS = {
"KontextPresetNode": "✨Kontext预设",
"KontextPresetNode": "✨Kontext Preset",
}
+151 -302
View File
@@ -10,71 +10,60 @@ from comfy.model_management import InterruptProcessingException
from ..services.llm import LLMService
from ..services.baidu import BaiduTranslateService
from ..services.error_util import format_api_error
from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_TRANSLATE, SOURCE_NODE
from ..services.thinking_control import build_thinking_suppression
from .base import LLMNodeBase
# 定义ANSI颜色代码常量
GREEN = "\033[32m"
BLUE = "\033[34m"
YELLOW = "\033[33m"
RESET = "\033[0m"
class PromptTranslate:
class PromptTranslate(LLMNodeBase):
"""
文本翻译节点
提示词翻译节点
自动识别输入语言并翻译成目标语言,支持多种翻译服务
"""
# 定义日志前缀
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):
# ---动态获取翻译服务/模型列表(包含硬编码的百度翻译)---
service_options = cls.get_translate_service_options()
default_service = service_options[0] if service_options else "百度翻译"
return {
"required": {
"原文": ("STRING", {"forceInput": True, "default": "", "multiline": True, "placeholder": "输入要翻译的文本..."}),
"目标语言": (["英文", "中文"], {"default": "英文"}),
"翻译服务": (["百度翻译", "智谱翻译", "硅基流动翻译", "302.AI翻译", "Ollama翻译", "自定义翻译"], {"default": "百度翻译"}),
# Ollama自动释放:仅对Ollama翻译服务生效
"Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}),
"source_text": ("STRING", {"forceInput": True, "default": "", "multiline": True, "placeholder": "Input text to translate..."}),
"target_language": (["English", "Chinese"], {"default": "English"}),
"translate_service": (service_options, {"default": default_service, "tooltip": "Select translation service and model"}),
# Ollama Automatic VRAM Unload
"ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("翻译输出",)
RETURN_NAMES = ("translated_text",)
FUNCTION = "translate"
CATEGORY = "✨提示词小助手"
CATEGORY = "✨Prompt Assistant"
OUTPUT_NODE = False
@classmethod
def IS_CHANGED(cls, 原文=None, 目标语言=None, 翻译服务=None, Ollama自动释放=None):
def IS_CHANGED(cls, source_text=None, target_language=None, translate_service=None, ollama_auto_unload=None, unique_id=None):
"""
只在输入内容真正变化时才触发重新执行
使用输入参数的哈希值作为判断依据
"""
# 计算文本的哈希值
text_hash = ""
if 原文:
if source_text:
# 使用hashlib计算文本的哈希值,更安全和一致
text_hash = hashlib.md5(原文.encode('utf-8')).hexdigest()
text_hash = hashlib.md5(source_text.encode('utf-8')).hexdigest()
# 组合所有输入的哈希值
input_hash = hash((
text_hash,
目标语言,
翻译服务,
bool(Ollama自动释放)
target_language,
translate_service,
bool(ollama_auto_unload)
))
return input_hash
@@ -102,315 +91,175 @@ class PromptTranslate:
else:
return "auto"
def translate(self, 原文, 目标语言, 翻译服务, Ollama自动释放):
def translate(self, source_text, target_language, translate_service, ollama_auto_unload, unique_id=None):
"""
翻译文本函数
Args:
原文: 输入的文本
目标语言: 目标语言 ("英文", "中文")
翻译服务: 翻译服务 ("百度翻译", "智谱翻译")
Ollama自动释放: 是否在调用完成后自动释放Ollama模型(仅对Ollama翻译生效)
Returns:
tuple: 翻译结果
"""
request_id = None # 提升到方法级别作用域
try:
# 检查输入
if not 原文 or not 原文.strip():
if not source_text or not source_text.strip():
return ("",)
# 自动检测源语言
detected_lang = self._detect_language(原文)
to_lang = "en" if 目标语言 == "英文" else "zh"
detected_lang = self._detect_language(source_text)
to_lang = "en" if target_language == "English" else "zh"
# 智能跳过翻译逻辑
skip_translation = False
if to_lang == 'en' and detected_lang == 'en':
print(f"{self.REQUEST_PREFIX} 检测到英文输入,目标为英文,无需翻译")
from ..utils.common import _ANSI_CLEAR_EOL
print(f"\r{_ANSI_CLEAR_EOL}{self.REQUEST_PREFIX} 检测到英文输入,目标为英文,无需翻译", flush=True)
skip_translation = True
elif to_lang == 'zh' and detected_lang == 'zh':
print(f"{self.REQUEST_PREFIX} 检测到中文输入,目标为中文,无需翻译")
from ..utils.common import _ANSI_CLEAR_EOL
print(f"\r{_ANSI_CLEAR_EOL}{self.REQUEST_PREFIX} 检测到中文输入,目标为中文,无需翻译", flush=True)
skip_translation = True
if skip_translation:
return (原文,)
return (source_text,)
# 映射语言名称
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, 翻译服务, from_lang_name, to_lang_name)
elif 翻译服务 == "智谱翻译":
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", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放)
elif 翻译服务 == "302.AI翻译":
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", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放)
elif 翻译服务 == "自定义翻译":
result = self._translate_with_llm(原文, detected_lang, to_lang, "custom", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放)
# ---解析服务/模型字符串---
service_id, model_name = self.parse_service_model(translate_service)
if not service_id:
raise ValueError(f"Invalid service selection: {translate_service}")
# ---百度翻译特殊处理---
if service_id == 'baidu':
request_id, result = self._translate_with_baidu(source_text, detected_lang, to_lang, translate_service, from_lang_name, to_lang_name, unique_id)
else:
raise ValueError(f"不支持的翻译服务: {翻译服务}")
# ---LLM翻译:获取服务配置---
from ..config_manager import config_manager
service = config_manager.get_service(service_id)
if not service:
raise ValueError(f"Service config not found: {translate_service}")
request_id, result = self._translate_with_llm(source_text, detected_lang, to_lang, service_id, model_name, service, translate_service, from_lang_name, to_lang_name, ollama_auto_unload, unique_id)
if result and result.get('success'):
translated_text = result.get('data', {}).get('translated', '').strip()
if not translated_text:
error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接'
print(f"{self.LOG_PREFIX} 翻译失败 | 错误:{error_msg}")
raise RuntimeError(f"翻译失败: {error_msg}")
error_msg = 'API returned empty result'
raise RuntimeError(f"❌Translation failed: {error_msg}")
# 结果阶段日志由服务层统一输出,节点层不再重复打印
return (translated_text,)
else:
error_msg = result.get('error', '翻译失败,未知错误') if result else '翻译服务未返回结果'
print(f"{self.LOG_PREFIX} 翻译失败: {error_msg}")
raise RuntimeError(f"翻译失败: {error_msg}")
error_msg = result.get('error', 'Unknown error') if result else 'No result returned'
# 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印)
if error_msg == "任务被中断":
raise InterruptProcessingException()
log_error(TASK_TRANSLATE, request_id, error_msg)
raise RuntimeError(f"Translation failed: {error_msg}")
except InterruptProcessingException:
print(f"{self.LOG_PREFIX} 翻译任务被用户取消")
# 不打印日志,由基类统一打印
raise
except Exception as e:
error_msg = format_api_error(e, 翻译服务)
print(f"{self.LOG_PREFIX} 翻译异常: {error_msg}")
raise RuntimeError(f"翻译异常: {error_msg}")
error_msg = format_api_error(e, translate_service)
log_error(TASK_TRANSLATE, request_id, error_msg)
raise RuntimeError(f"Translation error: {error_msg}")
def _translate_with_baidu(self, text, from_lang, to_lang, service_name, from_lang_name, to_lang_name):
def _translate_with_baidu(self, text, from_lang, to_lang, service_name, from_lang_name, to_lang_name, unique_id):
"""使用百度翻译服务"""
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 = {}
# 在独立线程中运行异步翻译
thread = threading.Thread(
target=self._run_async_translation,
args=(BaiduTranslateService.translate, text, from_lang, to_lang, request_id, result_container)
)
thread.start()
# 等待翻译完成,同时检查中断
while thread.is_alive():
# 检查是否被中断 - 这会抛出 InterruptProcessingException
try:
import nodes
nodes.before_node_execution()
except:
# 如果检查中断时出现异常,说明被中断了
print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止翻译任务...")
# 设置结果容器为中断状态,让线程知道要停止
result_container['interrupted'] = True
# 等待线程结束或超时
thread.join(timeout=1.0)
if thread.is_alive():
print(f"{self.LOG_PREFIX} 翻译线程未能及时响应中断")
raise InterruptProcessingException()
time.sleep(0.1)
return result_container.get('result')
except Exception as e:
return {"success": False, "error": str(e)}
def _translate_with_llm(self, text, from_lang, to_lang, provider, service_name, from_lang_name, to_lang_name, auto_unload):
"""使用LLM翻译服务"""
try:
# 获取配置
from ..config_manager import config_manager
provider_config = self._get_provider_config(config_manager, provider)
if not provider_config:
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, auto_unload)
)
thread.start()
# 等待翻译完成,同时检查中断
while thread.is_alive():
# 检查是否被中断 - 这会抛出 InterruptProcessingException
try:
import nodes
nodes.before_node_execution()
except:
# 如果检查中断时出现异常,说明被中断了
print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止翻译任务...")
# 设置结果容器为中断状态,让线程知道要停止
result_container['interrupted'] = True
# 等待线程结束或超时
thread.join(timeout=1.0)
if thread.is_alive():
print(f"{self.LOG_PREFIX} 翻译线程未能及时响应中断")
raise InterruptProcessingException()
time.sleep(0.1)
return result_container.get('result')
except Exception as e:
return {"success": False, "error": str(e)}
def _run_async_translation(self, service_func, text, from_lang, to_lang, request_id, result_container, **kwargs):
"""在独立线程中运行异步翻译任务"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# 检查是否在开始前就被中断了
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
return
result = loop.run_until_complete(
service_func(text, from_lang, to_lang, request_id, **kwargs)
)
# 检查是否在执行过程中被中断了
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
return
result_container['result'] = result
except Exception as e:
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
else:
result_container['result'] = {"success": False, "error": str(e)}
finally:
loop.close()
def _get_provider_config(self, config_manager, provider):
"""获取指定provider的配置"""
llm_config = config_manager.get_llm_config()
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模型以释放显存和内存
# 创建请求ID
request_id = generate_request_id("trans", "baidu", unique_id)
参数:
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]}")
# 准备阶段日志
log_prepare(TASK_TRANSLATE, request_id, SOURCE_NODE, "百度翻译", None, None, {"方向": f"{from_lang_name}→{to_lang_name}", "长度": len(text)})
# 执行翻译(异步线程 + 可中断)
result = self._run_llm_task(
BaiduTranslateService.translate,
service_name,
text=text,
from_lang=from_lang,
to_lang=to_lang,
request_id=request_id,
task_type=TASK_TRANSLATE,
source=SOURCE_NODE
)
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)
try:
# 检查是否在开始前就被中断了
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
return
return request_id, result
# 获取API密钥和模型
api_key = provider_config.get('api_key', '')
model = provider_config.get('model', '')
def _translate_with_llm(self, text, from_lang, to_lang, service_id, model_name, service, service_display_name, from_lang_name, to_lang_name, auto_unload, unique_id):
"""使用LLM翻译服务"""
# ---构建provider_config---
# 查找指定的模型或默认模型
llm_models = service.get('llm_models', [])
target_model = None
if model_name:
# 查找指定的模型
target_model = next((m for m in llm_models if m.get('name') == model_name), None)
if not target_model:
# 使用默认模型或第一个模型
target_model = next((m for m in llm_models if m.get('is_default')),
llm_models[0] if llm_models else None)
if not target_model:
return {"success": False, "error": f"Service {service_display_name} has no available models"}
# 构建配置对象
provider_config = {
'provider': service_id,
'model': target_model.get('name', ''),
'base_url': service.get('base_url', ''),
'api_key': service.get('api_key', ''),
'temperature': target_model.get('temperature', 0.7),
'max_tokens': target_model.get('max_tokens', 1000),
'top_p': target_model.get('top_p', 0.9),
}
# Ollama特殊处理:添加auto_unload配置
if service.get('type') == 'ollama':
provider_config['auto_unload'] = auto_unload
if not api_key or not model:
result_container['result'] = {
"success": False,
"error": f"请先配置{provider}的API密钥和模型"
}
return
# 创建请求ID
request_id = generate_request_id("trans", "llm", unique_id)
# 检查是否关闭思维链
model_full_name = provider_config.get('model')
disable_thinking_enabled = service.get('disable_thinking', True)
thinking_extra = build_thinking_suppression(service_id, model_full_name) if disable_thinking_enabled else None
model_display = format_model_with_thinking(model_full_name, bool(thinking_extra))
# 获取服务显示名称
service_display_name = service.get('name', service_id)
# 准备阶段日志
log_prepare(TASK_TRANSLATE, request_id, SOURCE_NODE, service_display_name, model_display, None, {"方向": f"{from_lang_name}→{to_lang_name}", "长度": len(text)})
# 检查API密钥和模型
api_key = provider_config.get('api_key', '')
model = provider_config.get('model', '')
if not api_key or not model:
return {"success": False, "error": f"Please configure API key and model for {service_display_name}"}
# 检查是否启用直连模式
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 ""
# 执行翻译(异步线程 + 可中断)
result = self._run_llm_task(
LLMService.translate,
service_id,
text=text,
from_lang=from_lang,
to_lang=to_lang,
request_id=request_id,
stream_callback=None,
custom_provider=service_id,
custom_provider_config=provider_config,
task_type=TASK_TRANSLATE,
source=SOURCE_NODE
)
# 获取提供商显示名称
provider_display_name = PromptTranslate.PROVIDER_DISPLAY_MAP.get(provider, provider)
return request_id, result
# 统一走服务层(含兼容/降级处理)
service_result = loop.run_until_complete(
LLMService.translate(
text,
from_lang,
to_lang,
request_id,
False,
None,
custom_provider=provider,
custom_provider_config=provider_config
)
)
# 检查是否在执行过程中被中断了
if result_container.get('interrupted'):
result_container['result'] = {"success": False, "error": "任务被中断"}
return
result_container['result'] = service_result
# 结果日志已在translate方法中统一打印,这里不再重复
except Exception as e:
# 格式化错误信息
error_message = format_api_error(e, provider_display_name)
result_container['result'] = {
"success": False,
"error": error_message
}
print(f"{PromptTranslate.LOG_PREFIX} 翻译失败 | 服务:{provider_display_name} | 错误:{error_message}")
finally:
loop.close()
# 节点映射,用于向ComfyUI注册节点
NODE_CLASS_MAPPINGS = {
@@ -419,5 +268,5 @@ NODE_CLASS_MAPPINGS = {
# 节点显示名称映射
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptTranslate": "✨提示词翻译",
}
"PromptTranslate": "✨Prompt Translate",
}
+541
View File
@@ -0,0 +1,541 @@
import asyncio
import random
import time
import threading
import hashlib
import base64
import os
import tempfile
import shutil
from io import BytesIO
from typing import List, Optional, Union, Tuple
import torch
import numpy as np
from PIL import Image
import imageio
from comfy.model_management import InterruptProcessingException
from ..services.vlm import VisionService
from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_VIDEO_CAPTION, SOURCE_NODE
from ..services.thinking_control import build_thinking_suppression
from .base import VLMNodeBase
class VideoCaptionNode(VLMNodeBase):
"""
视频反推提示词节点
分析输入视频或图像序列并生成描述性提示词
"""
@classmethod
def INPUT_TYPES(cls):
# 从config_manager获取系统提示词配置
from ..config_manager import config_manager
system_prompts = config_manager.get_system_prompts()
# ---获取所有 video_prompts 作为选项---
video_prompts = {}
if system_prompts and 'video_prompts' in system_prompts:
video_prompts = system_prompts['video_prompts']
# 构建提示词模板选项(支持分类格式:类别/规则名称)
prompt_template_options = []
for key, value in video_prompts.items():
# 过滤掉不在后端显示的规则
show_in = value.get('showIn', ["frontend", "node"])
if 'node' not in show_in:
continue
name = value.get('name', key)
category = value.get('category', '')
# 如果有分类,显示为 "类别/规则名称",否则直接显示规则名称
display_name = f"{category}/{name}" if category else name
prompt_template_options.append(display_name)
# 如果没有选项,添加一个默认选项
if not prompt_template_options:
prompt_template_options = ["默认视频反推提示词"]
# ---动态获取VLM服务/模型列表---
service_options = cls.get_vlm_service_options()
default_service = service_options[0] if service_options else "智谱"
return {
"required": {
"rule": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认视频反推提示词", "tooltip": "💡Template Config: Settings -> ✨Prompt Assistant -> Rule Editor"}),
"custom_rule": ("BOOLEAN", {"default": False, "label_on": "Enable", "label_off": "Disable", "tooltip": "⚠️ Enable to use custom rule content below instead of preset"}),
"custom_rule_content": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}),
"user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "输入额外的具体要求,将与规则一起发送给模型"}),
"vlm_service": (service_options, {"default": default_service, "tooltip": "Select VLM service and model"}),
"sampling_mode": (["Auto (Uniform)", "Manual (Indices)"], {"default": "Auto (Uniform)"}),
"frame_count": ("INT", {"default": 8, "min": 1, "max": 32, "step": 1, "tooltip": "💡Only for 'Auto' mode. Number of frames to sample."}),
"manual_indices": ("STRING", {"default": "", "placeholder": "Input indices (e.g. 0,10,20) or range (e.g. 0-10)", "tooltip": "💡Only for 'Manual' mode. Supports comma-separated or range. Negative indices allowed."}),
# Ollama Automatic VRAM Unload
"ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
"optional": {
"video": ("VIDEO",),
"image_sequence": ("IMAGE",),
}
}
RETURN_TYPES = ("STRING", "IMAGE")
RETURN_NAMES = ("prompt_output", "preview_frames")
FUNCTION = "analyze_video_content"
CATEGORY = "✨Prompt Assistant"
OUTPUT_NODE = False
@classmethod
def IS_CHANGED(cls, video=None, image_sequence=None, rule=None, custom_rule=None, custom_rule_content=None, user_prompt=None, vlm_service=None, sampling_mode=None, frame_count=None, manual_indices=None, ollama_auto_unload=None, unique_id=None):
"""
只在输入内容真正变化时才触发重新执行
"""
# 提取实际的tensor数据
target_input = None
if video is not None:
if isinstance(video, dict):
target_input = video.get('frames') or video.get('video')
elif hasattr(video, 'video'):
target_input = video.video
elif hasattr(video, 'frames'):
target_input = video.frames
elif isinstance(video, torch.Tensor):
target_input = video
elif image_sequence is not None:
target_input = image_sequence
# 计算哈希值
input_data_hash = ""
if target_input is not None and isinstance(target_input, torch.Tensor):
try:
# 取部分帧和中心区域计算哈希,避免全量计算
if len(target_input.shape) == 4:
frames = target_input.shape[0]
# 取首尾和中间帧
indices = [0, frames // 2, frames - 1] if frames > 2 else range(frames)
hash_data = b""
for idx in indices:
h, w = target_input.shape[1:3]
center_h, center_w = h // 2, w // 2
size = min(50, h // 4, w // 4)
frame_data = target_input[idx,
max(0, center_h - size):min(h, center_h + size),
max(0, center_w - size):min(w, center_w + size),
0].cpu().numpy().tobytes()
hash_data += frame_data
input_data_hash = hashlib.md5(hash_data).hexdigest()
else:
input_data_hash = "invalid_shape"
except Exception:
input_data_hash = "hash_error"
# 组合所有输入的哈希值
input_hash = hash((
input_data_hash,
rule,
bool(custom_rule),
custom_rule_content,
user_prompt,
vlm_service,
sampling_mode,
frame_count,
manual_indices,
bool(ollama_auto_unload)
))
return input_hash
def analyze_video_content(self, rule, custom_rule, custom_rule_content, user_prompt, vlm_service, sampling_mode, frame_count, manual_indices, ollama_auto_unload, video=None, image_sequence=None, unique_id=None):
"""
分析视频或图像序列并生成提示词(使用抽帧模式)
"""
temp_video_path = None
try:
# 1. 验证输入
if video is None and image_sequence is None:
raise ValueError("Video Input or Image Sequence Input is required")
# 2. 提取tensor数据
input_tensor = None
is_pre_sampled = False
if video is not None:
# 处理VideoFromFile对象(需要先保存再加载)
if hasattr(video, 'save_to') and callable(getattr(video, 'save_to')):
try:
fd, temp_video_path = tempfile.mkstemp(suffix='.mp4')
os.close(fd)
video.save_to(temp_video_path)
print(f"{self.PROCESS_PREFIX} VideoFromFile saved to temp")
#从文件加载为tensor,传入抽帧参数进行预采样优化
input_tensor = self._load_video_as_tensor(
temp_video_path,
target_count=frame_count if sampling_mode == "Auto (Uniform)" else None,
target_indices_str=manual_indices if sampling_mode == "Manual (Indices)" else None
)
is_pre_sampled = True
except Exception as e:
if temp_video_path and os.path.exists(temp_video_path):
os.unlink(temp_video_path)
raise RuntimeError(f"VideoFromFile processing failed: {str(e)}")
# 处理字典格式
elif isinstance(video, dict):
input_tensor = video.get('frames') or video.get('video')
if input_tensor is None:
for v in video.values():
if isinstance(v, torch.Tensor):
input_tensor = v
break
# 处理tensor
elif isinstance(video, torch.Tensor):
input_tensor = video
# 尝试索引访问
elif hasattr(video, '__getitem__'):
try:
first_item = video[0]
if isinstance(first_item, torch.Tensor):
input_tensor = first_item
elif isinstance(first_item, dict):
input_tensor = first_item.get('frames') or first_item.get('video')
except Exception:
pass
if input_tensor is None:
raise ValueError(f"Failed to extract tensor from VIDEO input")
else:
input_tensor = image_sequence
# 3. 准备提示词
prompt_template = None
rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule
if custom_rule and custom_rule_content:
prompt_template = custom_rule_content
else:
from ..config_manager import config_manager
system_prompts = config_manager.get_system_prompts()
video_prompts = {}
if system_prompts and 'video_prompts' in system_prompts:
video_prompts = system_prompts['video_prompts']
# 查找模板(按显示名称匹配)
# 显示名称格式:有分类时为 "类别/规则名称",无分类时为 "规则名称"
for key, value in video_prompts.items():
name = value.get('name', key)
category = value.get('category', '')
# 构建与下拉列表一致的显示名称
display_name = f"{category}/{name}" if category else name
if display_name == rule:
prompt_template = value.get('content')
break
# 允许用规则名称或键名直接匹配(兼容旧格式)
if not prompt_template:
for key, value in video_prompts.items():
if value.get('name') == rule or key == rule:
prompt_template = value.get('content')
break
if not prompt_template:
prompt_template = "请详细描述这段视频的内容,包括主要事件、场景变化、人物动作和视觉风格。"
rule_name = "Default Rule"
# 拼接用户提示词
if user_prompt and user_prompt.strip():
prompt_template = f"{prompt_template}\n\n用户补充要求:\n{user_prompt}"
# ---解析服务/模型字符串---
service_id, model_name = self.parse_service_model(vlm_service)
if not service_id:
raise ValueError(f"Invalid service selection: {vlm_service}")
# ---获取服务配置---
from ..config_manager import config_manager
service = config_manager.get_service(service_id)
if not service:
raise ValueError(f"Service config not found: {vlm_service}")
# ---构建provider_config---
# 查找指定的模型或默认模型
vlm_models = service.get('vlm_models', [])
target_model = None
if model_name:
# 查找指定的模型
target_model = next((m for m in vlm_models if m.get('name') == model_name), None)
if not target_model:
# 使用默认模型或第一个模型
target_model = next((m for m in vlm_models if m.get('is_default')),
vlm_models[0] if vlm_models else None)
if not target_model:
raise ValueError(f"Service {vlm_service} has no available models")
# 构建配置对象
provider_config = {
'provider': service_id,
'model': target_model.get('name', ''),
'base_url': service.get('base_url', ''),
'api_key': service.get('api_key', ''),
'temperature': target_model.get('temperature', 0.7),
'max_tokens': target_model.get('max_tokens', 500),
'top_p': target_model.get('top_p', 0.9),
}
# Ollama特殊处理:添加auto_unload配置
if service.get('type') == 'ollama':
provider_config['auto_unload'] = ollama_auto_unload
model = provider_config.get('model', '')
# 5. 抽帧处理
request_id = generate_request_id("vcap", None, unique_id)
# 检查是否关闭思维链
disable_thinking_enabled = service.get('disable_thinking', True)
thinking_extra = build_thinking_suppression(service_id, model) if disable_thinking_enabled else None
model_display = format_model_with_thinking(model, bool(thinking_extra))
# 获取服务显示名称
service_display_name = service.get('name', service_id)
# 准备阶段日志
log_prepare(TASK_VIDEO_CAPTION, request_id, SOURCE_NODE, service_display_name, model_display, rule_name, {"模式": sampling_mode})
# [Debug] 输出抽帧参数详情
print(f"{self.PROCESS_PREFIX} [video-caption-debug] 输入tensor形状:{input_tensor.shape} | is_pre_sampled:{is_pre_sampled}")
print(f"{self.PROCESS_PREFIX} [video-caption-debug] sampling_mode:{sampling_mode} | frame_count:{frame_count} | manual_indices:{manual_indices}")
# 准备抽帧参数
sampling_kwargs = {}
if not is_pre_sampled:
if sampling_mode == "Auto (Uniform)":
sampling_kwargs['target_count'] = frame_count
elif sampling_mode == "Manual (Indices)":
sampling_kwargs['target_indices_str'] = manual_indices
# 从tensor中提取帧并转为base64,同时获取预览tensor
frames_data, preview_tensor = self._extract_frames_and_tensor(
input_tensor,
**sampling_kwargs
)
# [Debug] 输出抽帧结果
print(f"{self.PROCESS_PREFIX} [video-caption] 抽帧完成 | 帧数量:{len(frames_data)} | 预览tensor:{preview_tensor.shape}")
# 调用多图像分析 - 使用基类方法
result = self._run_vision_task(
VisionService.analyze_images,
service_id,
images_data=frames_data,
request_id=request_id,
prompt_content=prompt_template,
custom_provider=service_id,
custom_provider_config=provider_config,
task_type=TASK_VIDEO_CAPTION,
source=SOURCE_NODE
)
# 6. 处理结果
if result and result.get('success'):
description = result.get('data', {}).get('description', '').strip()
if not description:
raise RuntimeError("API returned empty result")
return (description, preview_tensor)
else:
error_msg = result.get('error', 'Unknown error') if result else 'No result returned'
# 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印)
if error_msg == "任务被中断":
raise InterruptProcessingException()
raise RuntimeError(f"Analysis failed: {error_msg}")
except InterruptProcessingException:
# 不打印日志,由基类统一打印
raise
except Exception as e:
error_msg = format_api_error(e, vlm_service)
log_error(TASK_VIDEO_CAPTION, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Analysis error: {error_msg}")
finally:
# 清理临时视频文件
if temp_video_path and os.path.exists(temp_video_path):
try:
os.unlink(temp_video_path)
except Exception:
pass
def _uniform_sample(self, l, n):
"""
从列表中均匀采样 n 个元素 (参考 video_sampling_guide.md)
算法: 将列表分成 n 个等长区间,从每个区间的中心位置取样
"""
if n >= len(l):
return l
gap = len(l) / n
idxs = [int(i * gap + gap / 2) for i in range(n)]
# 确保索引不越界
idxs = [min(i, len(l) - 1) for i in idxs]
return [l[i] for i in idxs]
def _parse_frame_indices(self, indices_str, total_frames):
"""解析手动输入的帧索引字符串"""
indices = set()
if not indices_str:
return []
parts = indices_str.split(',')
for part in parts:
part = part.strip()
if not part:
continue
try:
if '-' in part:
# Range: 0-10
start_str, end_str = part.split('-')
start = int(start_str)
end = int(end_str)
# Handle negative indices
if start < 0: start += total_frames
if end < 0: end += total_frames
start = max(0, min(start, total_frames - 1))
end = max(0, min(end, total_frames - 1))
if start <= end:
indices.update(range(start, end + 1))
else:
# Single index
idx = int(part)
if idx < 0: idx += total_frames
idx = max(0, min(idx, total_frames - 1))
indices.add(idx)
except ValueError:
print(f"{self.LOG_PREFIX} 忽略无效的帧索引格式: {part}")
return sorted(list(indices))
def _extract_frames_and_tensor(self, tensor, target_count=None, target_indices_str=None):
"""从tensor中提取指定数量或指定索引的帧,返回(base64列表, 预览tensor)"""
total_frames = tensor.shape[0]
# 生成所有帧的索引列表
all_indices = list(range(total_frames))
selected_indices = []
if target_indices_str is not None:
# 手动模式
selected_indices = self._parse_frame_indices(target_indices_str, total_frames)
if not selected_indices:
print(f"{self.PROCESS_PREFIX} ⚠️ 手动帧索引无效或为空,回退到自动采样")
selected_indices = self._uniform_sample(all_indices, 8) # Default fallback
elif target_count is not None:
# 自动模式
selected_indices = self._uniform_sample(all_indices, target_count)
else:
# 默认全量
selected_indices = all_indices
# 提取选中的帧 [N, H, W, C]
selected_tensor = tensor[selected_indices]
frames_base64 = []
# 遍历选中的tensor进行转换
for i in range(selected_tensor.shape[0]):
frame_tensor = selected_tensor[i]
# 转为numpy [H, W, C] (0-255)
frame_np = (frame_tensor.cpu().numpy() * 255).astype(np.uint8)
# 转PIL
image = Image.fromarray(frame_np)
# 转Base64
buffer = BytesIO()
image.save(buffer, format="JPEG", quality=85)
encoded = base64.b64encode(buffer.getvalue()).decode('utf-8')
frames_base64.append(f"data:image/jpeg;base64,{encoded}")
return frames_base64, selected_tensor
def _load_video_as_tensor(self, video_path, target_count=None, target_indices_str=None):
"""
从视频文件加载为tensor
优化: 如果指定了target_count或target_indices_str,则只读取需要的帧 (Sample before Load)
"""
try:
import imageio
# 读取视频
reader = imageio.get_reader(video_path, 'ffmpeg')
# 获取总帧数
# 尝试从元数据获取,如果失败则使用count_frames (较慢)
try:
total_frames = reader.count_frames()
except Exception:
# 如果无法获取帧数,回退到读取所有帧
print(f"{self.PROCESS_PREFIX} 无法获取视频总帧数,将读取所有帧")
frames = []
for frame in reader:
frame_float = frame.astype(np.float32) / 255.0
frames.append(frame_float)
reader.close()
if not frames:
raise RuntimeError("视频文件中没有帧")
frames_array = np.stack(frames, axis=0)
tensor = torch.from_numpy(frames_array)
# 如果有采样要求,进行后处理采样
if target_count is not None or target_indices_str is not None:
print(f"{self.PROCESS_PREFIX} 视频加载回退: 读取全量帧后进行采样")
_, tensor = self._extract_frames_and_tensor(tensor, target_count, target_indices_str)
return tensor
# 计算需要读取的帧索引
indices_to_read = list(range(total_frames))
if target_indices_str is not None:
indices_to_read = self._parse_frame_indices(target_indices_str, total_frames)
print(f"{self.PROCESS_PREFIX} 视频加载优化(手动): 从 {total_frames} 帧中提取 {len(indices_to_read)} 帧")
elif target_count and total_frames > target_count:
indices_to_read = self._uniform_sample(indices_to_read, target_count)
print(f"{self.PROCESS_PREFIX} 视频加载优化(自动): 从 {total_frames} 帧中采样 {len(indices_to_read)} 帧")
# 优化读取: 只读取需要的帧
frames = []
# imageio的get_data(index)支持随机访问
for idx in indices_to_read:
try:
frame = reader.get_data(idx)
frame_float = frame.astype(np.float32) / 255.0
frames.append(frame_float)
except IndexError:
break
reader.close()
if not frames:
raise RuntimeError("未能读取到有效帧")
# 转换为torch tensor [N, H, W, C]
frames_array = np.stack(frames, axis=0)
tensor = torch.from_numpy(frames_array)
return tensor
except Exception as e:
raise RuntimeError(f"视频文件加载失败: {str(e)}")
# 节点映射,用于ComfyUI注册节点
NODE_CLASS_MAPPINGS = {
"VideoCaptionNode": VideoCaptionNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"VideoCaptionNode": "✨Video Caption",
}