Files
2026-09-14 15:29:57 +08:00

323 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import requests
from typing_extensions import override
from comfy_api.latest import ComfyExtension, io
from ..utils.config_utils import get_config_section, get_models_list
def _load_config_credentials(config_options=None):
"""
从config.json中加载并验证API凭据,如果提供了config_options则优先使用
返回 (base_url, api_key, timeout) 元组
"""
# 如果提供了配置覆盖,则使用覆盖配置
if config_options is not None:
base_url = config_options.get('base_url', '').strip()
api_key = config_options.get('api_key', '').strip()
timeout = config_options.get('timeout', 120)
# 如果覆盖配置中有有效的 base_url,则直接返回
if base_url:
return base_url, api_key, timeout
try:
ollama_llm_config = get_config_section('ollama-llm')
# 获取并验证base_url
if 'base_url' not in ollama_llm_config:
raise ValueError("Missing 'base_url' in ollama-llm section")
base_url = ollama_llm_config['base_url'].strip() if isinstance(ollama_llm_config['base_url'], str) else str(ollama_llm_config['base_url']).strip()
if not base_url:
raise ValueError("base_url cannot be empty")
# 对于Ollama,api_key是可选的
api_key = ollama_llm_config.get('api_key', '')
api_key = api_key.strip() if isinstance(api_key, str) else str(api_key).strip()
# 获取timeout参数,默认值为120秒
timeout = ollama_llm_config.get('timeout', 120)
if isinstance(timeout, str):
try:
timeout = int(timeout)
except ValueError:
timeout = 120
# 如果有配置覆盖,则使用覆盖的值(如果提供了)
if config_options is not None:
if config_options.get('base_url', '').strip():
base_url = config_options['base_url'].strip()
if config_options.get('api_key', '').strip():
api_key = config_options['api_key'].strip()
if config_options.get('timeout'):
timeout = config_options['timeout']
return base_url, api_key, timeout
except Exception as e:
raise ValueError(f"Failed to load Ollama LLM config section: {str(e)}")
def _get_proxy_config(proxy_options=None):
"""
从config.json中获取代理配置,如果提供了proxy_options则优先使用
返回 proxies 字典或 None
"""
# 如果提供了代理覆盖配置
if proxy_options is not None:
if not proxy_options.get('enable', False):
return None
proxies = {}
if proxy_options.get('http', '').strip():
proxies['http'] = proxy_options['http'].strip()
if proxy_options.get('https', '').strip():
proxies['https'] = proxy_options['https'].strip()
return proxies if proxies else None
# 否则从配置文件加载
try:
proxy_config = get_config_section('proxy')
if not proxy_config or not proxy_config.get('enable', False):
return None
proxies = {}
if proxy_config.get('http'):
proxies['http'] = proxy_config['http']
if proxy_config.get('https'):
proxies['https'] = proxy_config['https']
return proxies if proxies else None
except Exception:
return None
class OllamaLLM(io.ComfyNode):
"""
这个节点使用 Ollama LLM 模型进行对话
"""
# 类级别的对话历史存储,按节点实例ID存储
_conversation_history = {}
@classmethod
def define_schema(cls) -> io.Schema:
# 从配置文件加载模型列表
model_options = get_models_list("ollama-llm")
default_model = model_options[0]
return io.Schema(
node_id="YCYY_Ollama_LLM_API",
display_name="Ollama LLM API",
category="YCYY/API/text",
inputs=[
io.Custom("YCYY_API_CONFIG_OPTIONS").Input(
id="config_options",
optional=True,
tooltip="Optional configuration override from YCYY API Config Options"
),
io.Custom("YCYY_API_PROXY_OPTIONS").Input(
id="proxy_options",
optional=True,
tooltip="Optional proxy configuration override from YCYY API Proxy Options"
),
io.Custom("YCYY_OLLAMA_LLM_ADVANCED_OPTIONS").Input(
id="advanced_options",
optional=True,
tooltip="Optional configuration for the model.Accepts inputs from the Ollama LLM Advanced Options node."
),
io.String.Input(
id="system_prompt",
multiline=True,
),
io.String.Input(
id="user_prompt",
multiline=True,
),
io.Combo.Input(
id="model",
options=model_options,
default=default_model
),
io.Boolean.Input(
id="persist_context",
default=True,
tooltip="Persist chat context between calls (multi-turn conversation)"
),
io.Boolean.Input(
id="clear_history",
default=False,
tooltip="Clear conversation history and start a new conversation"
)
],
outputs=[
io.String.Output(
id="Result",
display_name="Result",
tooltip="Return result"
),
io.String.Output(
id="conversation",
display_name="Conversation",
tooltip="All historical conversation"
)
],
description="This node uses the Ollama LLM model for conversation."
)
@classmethod
def _apply_advanced_options(cls, payload, advanced_options):
"""
根据 advanced_options 的属性添加高级参数到 payload
Args:
payload: 基础的 API 请求 payload
advanced_options: 从 Ollama LLM Advanced Options 节点传入的高级选项字典
Returns:
更新后的 payload
"""
if not advanced_options or not isinstance(advanced_options, dict):
return payload
# 如果启用了 max_tokens,添加到 payload
if advanced_options.get("enable_max_tokens", False) and "max_tokens" in advanced_options:
payload["max_tokens"] = advanced_options["max_tokens"]
# 如果启用了 temperathinkture,添加到 payload
if advanced_options.get("enable_temperature", False) and "temperature" in advanced_options:
payload["temperature"] = advanced_options["temperature"]
# 如果启用了 top_p,添加到 payload
if advanced_options.get("enable_top_p", False) and "top_p" in advanced_options:
payload["top_p"] = advanced_options["top_p"]
# 如果启用了 thinking,添加到 payload
if advanced_options.get("enable_thinking", False):
payload["think"] = True
return payload
# 执行 OllamaLLM 节点
@classmethod
def execute(cls, system_prompt, user_prompt, model, persist_context, config_options=None, proxy_options=None, advanced_options=None, clear_history=False) -> io.NodeOutput:
if not user_prompt:
raise ValueError("User prompt cannot be empty")
base_url, api_key, timeout = _load_config_credentials(config_options)
proxies = _get_proxy_config(proxy_options)
api_url = base_url
# 生成会话标识符(基于模型和系统提示词)
session_key = f"{model}_{hash(system_prompt) if system_prompt else 'no_system'}"
# 如果用户要求清空历史,删除该会话的历史记录
if clear_history and session_key in cls._conversation_history:
del cls._conversation_history[session_key]
# 根据persist_context决定是否使用历史消息
if persist_context:
# 如果会话不存在,初始化历史记录
if session_key not in cls._conversation_history:
cls._conversation_history[session_key] = []
# 如果有系统提示词,添加到历史记录开头
if system_prompt:
cls._conversation_history[session_key].append({
"role": "system",
"content": system_prompt
})
# 添加当前用户消息到历史
cls._conversation_history[session_key].append({
"role": "user",
"content": user_prompt
})
# 使用完整的历史消息
messages = cls._conversation_history[session_key].copy()
else:
# 不持久化上下文,清空历史并只使用当前消息
if session_key in cls._conversation_history:
del cls._conversation_history[session_key]
messages = []
if system_prompt:
messages.append({
"role": "system",
"content": system_prompt
})
messages.append({
"role": "user",
"content": user_prompt
})
payload = {
"model": model,
"messages": messages,
"stream": False
}
# 根据 advanced_options 添加高级参数
payload = cls._apply_advanced_options(payload, advanced_options)
try:
if api_key:
headers = {
"Authorization": "Bearer "+api_key
}
resp = requests.post(api_url, headers=headers, json=payload, timeout=timeout, proxies=proxies)
else:
resp = requests.post(api_url, json=payload, timeout=timeout, proxies=proxies)
return cls._parse_response(resp, persist_context, session_key)
except Exception as e:
raise ValueError(f'The API request failed:{e}')
# 解析response 返回内容
@classmethod
def _parse_response(cls, resp, persist_context, session_key):
# 检查HTTP状态码
if resp.status_code != 200:
raise ValueError(f'API request returns an error.status_code:{resp.status_code}.error_reason:{resp.text}')
# 检查返回内容是否为空
if not resp.text.strip():
raise ValueError(f'The API returns an empty content')
try:
data = resp.json()
except Exception as json_exception:
# print(f"JSON解析失败:{json_exception}")
raise ValueError(f'The API returned a JSON parsing failure')
# 解析响应数据 - OpenAI兼容接口格式
if "choices" not in data:
raise ValueError(f'Missing "choices" field in API response')
choices = data["choices"]
if not choices or not isinstance(choices, list) or len(choices) == 0:
raise ValueError(f'Empty or invalid "choices" array in API response')
first_choice = choices[0]
if not isinstance(first_choice, dict):
raise ValueError(f'Invalid choice format in API response')
message = first_choice.get("message", {})
if not message:
raise ValueError(f'Missing "message" field in API response')
content = message.get("content", "")
if not content:
raise ValueError(f'Empty content in API response')
# 如果启用了上下文持久化,将助手的回复添加到历史记录
if persist_context and session_key in cls._conversation_history:
cls._conversation_history[session_key].append({
"role": "assistant",
"content": content
})
# 获取历史对话记录并转换为JSON字符串
history_conversation = ""
if persist_context and session_key in cls._conversation_history:
history_conversation = json.dumps(cls._conversation_history[session_key], ensure_ascii=False)
else:
history_conversation = "[]"
# 返回当前内容和历史对话JSON字符串
return io.NodeOutput(content, history_conversation)
# 设置 web 目录,该目录中的任何 .js 文件都将被前端加载为前端扩展
# WEB_DIRECTORY = "./somejs"