Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
1346 lines
58 KiB
Python
1346 lines
58 KiB
Python
"""
|
||
Comfyui_o1key - ComfyUI 自定义节点集合
|
||
通过 api.o1key.cn 调用 AI 模型进行图像生成和文本生成
|
||
|
||
项目结构:
|
||
├── nodes/ # 节点实现
|
||
├── utils/ # 工具模块
|
||
├── clients/ # API 客户端
|
||
└── __init__.py # 节点注册入口
|
||
"""
|
||
|
||
|
||
import ssl
|
||
import logging
|
||
import asyncio
|
||
|
||
# 屏蔽 ComfyUI 资产扫描的终端日志输出
|
||
_seeder_filter = lambda record: not any(
|
||
kw in record.getMessage()
|
||
for kw in ("Seeder start", "Asset scan", "Scan(", "Fast scan")
|
||
)
|
||
logging.getLogger().addFilter(_seeder_filter)
|
||
|
||
|
||
def _is_ignored_asyncio_win10054(context):
|
||
exc = context.get("exception")
|
||
if not (
|
||
isinstance(exc, ConnectionResetError)
|
||
and getattr(exc, "winerror", None) == 10054
|
||
):
|
||
return False
|
||
|
||
handle = str(context.get("handle", ""))
|
||
message = str(context.get("message", ""))
|
||
marker = "_ProactorBasePipeTransport._call_connection_lost"
|
||
return marker in handle or marker in message
|
||
|
||
|
||
def _install_asyncio_win10054_filter(loop):
|
||
if getattr(loop, "_o1key_win10054_filter_installed", False):
|
||
return loop
|
||
|
||
previous_handler = loop.get_exception_handler()
|
||
|
||
def _o1key_asyncio_exception_handler(loop, context):
|
||
if _is_ignored_asyncio_win10054(context):
|
||
return
|
||
if previous_handler is not None:
|
||
previous_handler(loop, context)
|
||
else:
|
||
loop.default_exception_handler(context)
|
||
|
||
loop.set_exception_handler(_o1key_asyncio_exception_handler)
|
||
setattr(loop, "_o1key_win10054_filter_installed", True)
|
||
return loop
|
||
|
||
|
||
try:
|
||
_install_asyncio_win10054_filter(asyncio.get_event_loop())
|
||
except RuntimeError:
|
||
pass
|
||
|
||
if not getattr(asyncio, "_o1key_new_event_loop_patched", False):
|
||
_o1key_original_new_event_loop = asyncio.new_event_loop
|
||
|
||
def _o1key_new_event_loop(*args, **kwargs):
|
||
return _install_asyncio_win10054_filter(
|
||
_o1key_original_new_event_loop(*args, **kwargs)
|
||
)
|
||
|
||
asyncio.new_event_loop = _o1key_new_event_loop
|
||
asyncio._o1key_new_event_loop_patched = True
|
||
|
||
from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, LoadImagesFromFolder, ImageStitchPro, BatchCleanMetadata, VideoPreview, GoogleVeo, Google31Video, MiniMaxH3Video, FluxImageEdit, UniversalLLMChat, BatchImagesO1key, SeedanceMultiModal, StreamPreview, DoubaoImage, O1keyGPTImage, O1keyGPTImageBatch, O1keyGrokImage, O1keyGrokVideo, O1keyGrokVideoEdit
|
||
from .nodes import K3Video, K3MotionControl, SaveImageFormat
|
||
from .nodes import O1keySavePSD
|
||
from .nodes import O1keyRemoveBackground
|
||
from .nodes import O1keyGridSplitter
|
||
from .nodes import O1keyPromptMultiFunction
|
||
from .nodes import O1keyVideoTrim
|
||
from .nodes import SeedanceElementCreate
|
||
from .nodes import SeedanceAutoPass
|
||
from .nodes import SeedanceAutoPassBatch
|
||
from .nodes import O1keyAutoRedCast
|
||
from .nodes import O1keyImageGenerator, O1keyImageSave
|
||
from .nodes import O1keyVideoGenerator, O1keyVideoResult
|
||
from .nodes import O1keyOmniFlashVideo
|
||
|
||
# 报错弹框友好文案(不修改原节点代码,仅在外层统一处理)
|
||
_MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。"
|
||
_MSG_SSL_NETWORK = (
|
||
"本地网络不太稳定!解决方案如下:\n"
|
||
"1. 重启程序再试试看 (优先)\n"
|
||
"2. 调整一下网络环境,如wifi或宽带等\n"
|
||
"3. 切换VPN节点,或更换代理模式\n"
|
||
"4. 关掉杀毒软件或防火墙\n"
|
||
"5. 关掉浏览器VPN插件,避免冲突"
|
||
)
|
||
|
||
def _wrap_generate_for_error_display(cls, attr="generate"):
|
||
original = getattr(cls, attr, None)
|
||
if original is None:
|
||
return
|
||
def wrapped(self, *args, **kwargs):
|
||
try:
|
||
return original(self, *args, **kwargs)
|
||
except TimeoutError as e:
|
||
msg = (str(e) or "").strip()
|
||
if not msg:
|
||
msg = _MSG_TIMEOUT
|
||
raise TimeoutError(msg) from None
|
||
except (ssl.SSLError, OSError) as e:
|
||
err_str = str(e)
|
||
if "DECRYPTION_FAILED_OR_BAD_RECORD_MAC" in err_str or "decryption failed or bad record mac" in err_str.lower():
|
||
raise RuntimeError(_MSG_SSL_NETWORK) from None
|
||
raise
|
||
setattr(cls, attr, wrapped)
|
||
|
||
_wrap_generate_for_error_display(NanoBananaPro)
|
||
_wrap_generate_for_error_display(BatchNanoBananaPro)
|
||
|
||
# ComfyUI 节点注册
|
||
NODE_CLASS_MAPPINGS = {
|
||
"NanoBanana": NanoBananaPro,
|
||
"BatchNanoBananaPro": BatchNanoBananaPro,
|
||
"GoogleGemini": GoogleGemini,
|
||
"LoadFile": LoadFile,
|
||
"O1keyLoadImagesFromFolder": LoadImagesFromFolder,
|
||
"ImageStitchPro": ImageStitchPro,
|
||
|
||
"BatchCleanMetadata": BatchCleanMetadata,
|
||
"VideoPreview": VideoPreview,
|
||
"GoogleVeo": GoogleVeo,
|
||
"Google31Video": Google31Video,
|
||
"MiniMaxH3Video": MiniMaxH3Video,
|
||
"FluxImageEdit": FluxImageEdit,
|
||
"UniversalLLMChat": UniversalLLMChat,
|
||
|
||
"BatchImagesO1key": BatchImagesO1key,
|
||
"SeedanceMultiModal": SeedanceMultiModal,
|
||
"StreamPreview": StreamPreview,
|
||
"DoubaoImage": DoubaoImage,
|
||
"O1keyGPTImage": O1keyGPTImage,
|
||
"O1keyGPTImageBatch": O1keyGPTImageBatch,
|
||
"O1keyGrokImage": O1keyGrokImage,
|
||
"O1keyGrokVideo": O1keyGrokVideo,
|
||
"O1keyGrokVideoEdit": O1keyGrokVideoEdit,
|
||
"K3Video": K3Video,
|
||
"K3MotionControl": K3MotionControl,
|
||
"SaveImageFormat": SaveImageFormat,
|
||
"O1keySavePSD": O1keySavePSD,
|
||
"O1keyRemoveBackground": O1keyRemoveBackground,
|
||
"O1keyGridSplitter": O1keyGridSplitter,
|
||
"O1keyPromptMultiFunction": O1keyPromptMultiFunction,
|
||
"O1keyVideoTrim": O1keyVideoTrim,
|
||
"SeedanceElementCreate": SeedanceElementCreate,
|
||
"SeedanceAutoPass": SeedanceAutoPass,
|
||
"SeedanceAutoPassBatch": SeedanceAutoPassBatch,
|
||
"O1keyAutoRedCast": O1keyAutoRedCast,
|
||
"O1keyImageGenerator": O1keyImageGenerator,
|
||
"O1keyImageSave": O1keyImageSave,
|
||
"O1keyVideoGenerator": O1keyVideoGenerator,
|
||
"O1keyVideoResult": O1keyVideoResult,
|
||
"O1keyOmniFlashVideo": O1keyOmniFlashVideo,
|
||
}
|
||
|
||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||
"O1keyVideoGenerator": "o1key 视频生成",
|
||
"O1keyVideoResult": "o1key 视频结果",
|
||
"O1keyOmniFlashVideo": "Omni Flash 视频生成",
|
||
"NanoBanana": "Nano Banana",
|
||
"BatchNanoBananaPro": "Nano Banana 批量跑图",
|
||
"GoogleGemini": "Google Gemini",
|
||
"LoadFile": "加载文件",
|
||
"O1keyLoadImagesFromFolder": "加载图像(文件夹)",
|
||
"ImageStitchPro": "图像拼接 Pro",
|
||
|
||
"BatchCleanMetadata": "批量任务(防AI识别)",
|
||
"VideoPreview": "预览视频",
|
||
"GoogleVeo": "Google Veo - ab",
|
||
"Google31Video": "Google 3.1 Video",
|
||
"MiniMaxH3Video": "MiniMax H3 / H3 Max 视频生成",
|
||
"FluxImageEdit": "Flux2 图像编辑",
|
||
"UniversalLLMChat": "提示词专家",
|
||
|
||
"BatchImagesO1key": "加载图像(批量)",
|
||
"SeedanceMultiModal": "Seedance 多模态参考生视频",
|
||
"StreamPreview": "流式文本预览",
|
||
"DoubaoImage": "豆包生图",
|
||
"O1keyGPTImage": "gpt image",
|
||
"O1keyGPTImageBatch": "o1key GPT Image(批量)",
|
||
"O1keyGrokImage": "Grok Image",
|
||
"O1keyGrokVideo": "Grok Video",
|
||
"O1keyGrokVideoEdit": "Grok Video Edit",
|
||
"K3Video": "K 视频生成",
|
||
"K3MotionControl": "K 动作模仿",
|
||
"SaveImageFormat": "保存图像(格式转换)",
|
||
"O1keySavePSD": "保存 PSD(分层)",
|
||
"O1keyRemoveBackground": "去背景(rembg)",
|
||
"O1keyGridSplitter": "合并图智能切割",
|
||
"O1keyPromptMultiFunction": "提示词(多功能)",
|
||
"O1keyVideoTrim": "视频裁剪",
|
||
"SeedanceElementCreate": "Seedance 创建素材",
|
||
"SeedanceAutoPass": "Seedance 全能生成视频",
|
||
"SeedanceAutoPassBatch": "Seedance 全能生成视频(批量)",
|
||
"O1keyAutoRedCast": "自动红偏校正",
|
||
"O1keyImageGenerator": "o1key 图片生成",
|
||
"O1keyImageSave": "o1key 保存图像",
|
||
}
|
||
|
||
WEB_DIRECTORY = "./web"
|
||
|
||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||
|
||
# 注册 /o1key/input_dir 接口,供前端文件上传按钮获取 input 目录绝对路径
|
||
try:
|
||
from aiohttp import web
|
||
from server import PromptServer
|
||
import folder_paths
|
||
from .utils.config import (
|
||
DEFAULT_NETWORK_ROUTE,
|
||
NETWORK_ROUTE_CONFIG_KEY,
|
||
NETWORK_ROUTE_OPTIONS,
|
||
NETWORK_ROUTES,
|
||
get_api_key,
|
||
get_network_route,
|
||
load_config,
|
||
update_config,
|
||
)
|
||
from .utils.chat_support import (
|
||
PROMPT_OPTIMIZER_TIMEOUT_SECONDS,
|
||
build_search_context,
|
||
expand_xlsx_attachments,
|
||
extract_search_query,
|
||
optimize_image_prompt,
|
||
rewrite_search_query,
|
||
web_search,
|
||
write_video_prompt,
|
||
)
|
||
from .utils.o1key_image_jobs import register_o1key_image_job_routes
|
||
from .utils.o1key_image_thumbnail import register_o1key_image_thumbnail_route
|
||
from .utils.o1key_video_jobs import register_o1key_video_job_routes
|
||
from .utils.updater import UpdateError, update_package
|
||
|
||
_O1KEY_IMAGE_JOB_MANAGER = register_o1key_image_job_routes(
|
||
PromptServer,
|
||
web,
|
||
folder_paths,
|
||
)
|
||
register_o1key_image_thumbnail_route(PromptServer, web, folder_paths)
|
||
_O1KEY_VIDEO_JOB_MANAGER = register_o1key_video_job_routes(
|
||
PromptServer,
|
||
web,
|
||
folder_paths,
|
||
)
|
||
|
||
# 每次 ComfyUI 进程启动都会生成新的标识。前端据此确认后端确实完成了
|
||
# 重启,而不是仅仅重新加载了浏览器页面。
|
||
import os as _restart_os
|
||
import sys as _restart_sys
|
||
import threading as _restart_threading
|
||
import time as _restart_time
|
||
import uuid as _restart_uuid
|
||
|
||
_O1KEY_BOOT_ID = _restart_uuid.uuid4().hex
|
||
_o1key_restart_pending = _restart_threading.Event()
|
||
_o1key_update_lock = _restart_threading.Lock()
|
||
|
||
@PromptServer.instance.routes.post("/o1key/update")
|
||
async def update_o1key_package(request):
|
||
if request.headers.get("X-O1Key-Update") != "1":
|
||
return web.json_response(
|
||
{"code": "invalid_request", "error": "无效的更新请求。", "suggestion": "请从 O1Key 更新面板重新操作。"},
|
||
status=403,
|
||
)
|
||
if not _o1key_update_lock.acquire(blocking=False):
|
||
return web.json_response(
|
||
{"code": "update_in_progress", "error": "更新正在进行。", "suggestion": "请等待当前操作完成,不要重复点击。"},
|
||
status=409,
|
||
)
|
||
try:
|
||
result = await asyncio.to_thread(update_package)
|
||
return web.json_response(result)
|
||
except UpdateError as exc:
|
||
return web.json_response(exc.as_dict(), status=exc.status)
|
||
except Exception:
|
||
logging.exception("O1Key 更新失败")
|
||
return web.json_response(
|
||
{"code": "internal_error", "error": "更新失败。", "suggestion": "请查看 ComfyUI 日志,并在确认本地文件安全后重试。"},
|
||
status=500,
|
||
)
|
||
finally:
|
||
_o1key_update_lock.release()
|
||
|
||
def _o1key_restart_command():
|
||
"""复用当前解释器和启动参数,并禁止重启时额外打开浏览器。"""
|
||
auto_launch_flags = {"--auto-launch", "--auto_launch", "--launch"}
|
||
# orig_argv 包含嵌入式 Python 的 -s 等解释器参数;普通 sys.argv 不包含。
|
||
# 保留这些参数可确保便携版重启前后的运行环境完全一致。
|
||
original = getattr(_restart_sys, "orig_argv", None)
|
||
source_arguments = original[1:] if original else _restart_sys.argv
|
||
arguments = [arg for arg in source_arguments if arg not in auto_launch_flags]
|
||
if "--disable-auto-launch" not in arguments:
|
||
arguments.append("--disable-auto-launch")
|
||
return [_restart_sys.executable, *arguments]
|
||
|
||
def _restart_o1key_comfyui_process(delay=1.25):
|
||
"""在响应发送完成后,用相同终端进程重新启动 ComfyUI。"""
|
||
try:
|
||
_restart_time.sleep(delay)
|
||
try:
|
||
_restart_sys.stdout.flush()
|
||
_restart_sys.stderr.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
command = _o1key_restart_command()
|
||
print("[O1Key] 正在重启 ComfyUI...", flush=True)
|
||
_restart_os.execv(command[0], command)
|
||
except Exception:
|
||
_o1key_restart_pending.clear()
|
||
logging.exception("O1Key 无法重启 ComfyUI 进程")
|
||
|
||
@PromptServer.instance.routes.get("/o1key/restart/status")
|
||
async def get_o1key_restart_status(request):
|
||
return web.json_response(
|
||
{
|
||
"ready": True,
|
||
"boot_id": _O1KEY_BOOT_ID,
|
||
"pid": _restart_os.getpid(),
|
||
},
|
||
headers={"Cache-Control": "no-store"},
|
||
)
|
||
|
||
@PromptServer.instance.routes.post("/o1key/restart")
|
||
async def restart_o1key_comfyui(request):
|
||
if _o1key_restart_pending.is_set():
|
||
return web.json_response(
|
||
{
|
||
"success": False,
|
||
"error": "ComfyUI 正在重启,请稍候。",
|
||
"boot_id": _O1KEY_BOOT_ID,
|
||
},
|
||
status=409,
|
||
)
|
||
|
||
_o1key_restart_pending.set()
|
||
worker = _restart_threading.Thread(
|
||
target=_restart_o1key_comfyui_process,
|
||
name="o1key-comfyui-restart",
|
||
daemon=True,
|
||
)
|
||
worker.start()
|
||
return web.json_response(
|
||
{
|
||
"success": True,
|
||
"message": "ComfyUI 正在重启。",
|
||
"boot_id": _O1KEY_BOOT_ID,
|
||
"pid": _restart_os.getpid(),
|
||
},
|
||
headers={"Cache-Control": "no-store"},
|
||
)
|
||
|
||
def _get_o1key_server_port():
|
||
try:
|
||
import comfy.cli_args as _cli_args
|
||
args = getattr(_cli_args, "args", None)
|
||
port = getattr(args, "port", None) if args else None
|
||
port = port or getattr(_cli_args, "server_port", None) or getattr(_cli_args, "port", None)
|
||
if port is not None:
|
||
return str(int(port))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
import sys as _sys
|
||
for idx, arg in enumerate(_sys.argv):
|
||
if arg in ("--port", "--listen-port") and idx + 1 < len(_sys.argv):
|
||
return str(int(_sys.argv[idx + 1]))
|
||
for prefix in ("--port=", "--listen-port="):
|
||
if arg.startswith(prefix):
|
||
return str(int(arg.split("=", 1)[1]))
|
||
except Exception:
|
||
pass
|
||
return "8188"
|
||
|
||
def _get_o1key_history_meta_file(output_dir):
|
||
import os as _os_history
|
||
return _os_history.path.join(
|
||
output_dir,
|
||
f".o1key_history_{_get_o1key_server_port()}.json",
|
||
)
|
||
|
||
def _get_o1key_notes_file():
|
||
# 笔记固定存 ComfyUI input 目录,插件更新/替换不会清空笔记
|
||
import os as _os_notes
|
||
input_dir = _os_notes.path.abspath(folder_paths.get_input_directory())
|
||
_os_notes.makedirs(input_dir, exist_ok=True)
|
||
notes_file = _os_notes.path.join(input_dir, "o1key-notes.json")
|
||
|
||
return notes_file
|
||
|
||
def _get_o1key_cases_dir():
|
||
import os as _os_cases
|
||
cases_dir = _os_cases.path.join(_os_cases.path.dirname(__file__), "cases")
|
||
_os_cases.makedirs(cases_dir, exist_ok=True)
|
||
return cases_dir
|
||
|
||
def _get_o1key_case_file(filename):
|
||
import os as _os_cases
|
||
safe_name = _os_cases.path.basename(filename or "")
|
||
if not safe_name.lower().endswith(".json"):
|
||
return None
|
||
cases_dir = _get_o1key_cases_dir()
|
||
path = _os_cases.path.abspath(_os_cases.path.join(cases_dir, safe_name))
|
||
if not path.startswith(_os_cases.path.abspath(cases_dir) + _os_cases.sep):
|
||
return None
|
||
return path
|
||
|
||
def _extract_o1key_notes(payload):
|
||
if isinstance(payload, list):
|
||
return payload
|
||
if isinstance(payload, dict) and isinstance(payload.get("notes"), list):
|
||
return payload["notes"]
|
||
return None
|
||
|
||
@PromptServer.instance.routes.get("/o1key/cases")
|
||
async def get_o1key_cases(request):
|
||
import os as _os_cases
|
||
import json as _json_cases
|
||
|
||
cases_dir = _get_o1key_cases_dir()
|
||
cases = []
|
||
for filename in sorted(_os_cases.listdir(cases_dir), key=str.lower):
|
||
if not filename.lower().endswith(".json"):
|
||
continue
|
||
path = _get_o1key_case_file(filename)
|
||
if not path or not _os_cases.path.isfile(path):
|
||
continue
|
||
title = _os_cases.path.splitext(filename)[0]
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as cf:
|
||
data = _json_cases.load(cf)
|
||
if isinstance(data, dict):
|
||
title = str(data.get("title") or data.get("name") or title)
|
||
except Exception:
|
||
pass
|
||
cases.append({"id": filename, "filename": filename, "title": title})
|
||
return web.json_response({"cases": cases, "path": str(cases_dir)})
|
||
|
||
@PromptServer.instance.routes.get("/o1key/case")
|
||
async def get_o1key_case(request):
|
||
import os as _os_cases
|
||
import json as _json_cases
|
||
|
||
filename = request.query.get("file", "")
|
||
path = _get_o1key_case_file(filename)
|
||
if not path or not _os_cases.path.isfile(path):
|
||
return web.json_response({"error": "case not found"}, status=404)
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as cf:
|
||
data = _json_cases.load(cf)
|
||
except Exception as e:
|
||
return web.json_response({"error": str(e)}, status=500)
|
||
return web.json_response({"filename": _os_cases.path.basename(path), "case": data})
|
||
|
||
@PromptServer.instance.routes.get("/o1key/notes")
|
||
async def get_o1key_notes(request):
|
||
import os as _os_notes
|
||
import json as _json_notes
|
||
|
||
notes_file = _get_o1key_notes_file()
|
||
exists = _os_notes.path.isfile(notes_file)
|
||
notes = []
|
||
|
||
if exists:
|
||
try:
|
||
with open(notes_file, "r", encoding="utf-8") as nf:
|
||
loaded = _json_notes.load(nf)
|
||
notes = _extract_o1key_notes(loaded)
|
||
if notes is None:
|
||
return web.json_response(
|
||
{"error": "invalid notes file", "path": notes_file},
|
||
status=500,
|
||
)
|
||
except Exception as e:
|
||
return web.json_response(
|
||
{"error": str(e), "path": notes_file},
|
||
status=500,
|
||
)
|
||
|
||
return web.json_response({"notes": notes, "path": notes_file, "exists": exists})
|
||
|
||
@PromptServer.instance.routes.post("/o1key/notes")
|
||
async def save_o1key_notes(request):
|
||
import os as _os_notes
|
||
import json as _json_notes
|
||
|
||
try:
|
||
payload = await request.json()
|
||
notes = _extract_o1key_notes(payload)
|
||
if notes is None:
|
||
return web.json_response({"error": "notes must be a list"}, status=400)
|
||
except Exception as e:
|
||
return web.json_response({"error": f"invalid notes payload: {str(e)}"}, status=400)
|
||
|
||
notes_file = _get_o1key_notes_file()
|
||
temp_file = notes_file + ".tmp"
|
||
try:
|
||
with open(temp_file, "w", encoding="utf-8") as nf:
|
||
_json_notes.dump(notes, nf, ensure_ascii=False, indent=2)
|
||
nf.write("\n")
|
||
_os_notes.replace(temp_file, notes_file)
|
||
except Exception as e:
|
||
return web.json_response({"error": f"save notes failed: {str(e)}"}, status=500)
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"path": notes_file,
|
||
"count": len(notes),
|
||
})
|
||
|
||
@PromptServer.instance.routes.get("/o1key/input_dir")
|
||
async def get_input_dir(request):
|
||
import os
|
||
path = os.path.abspath(folder_paths.get_input_directory())
|
||
return web.json_response({"path": path})
|
||
|
||
@PromptServer.instance.routes.get("/o1key/api_key")
|
||
async def get_api_key_route(request):
|
||
config = load_config()
|
||
key = config.get("O1KEY_API_KEY", "")
|
||
masked = ""
|
||
if key:
|
||
if len(key) > 8:
|
||
masked = key[:3] + "****" + key[-4:]
|
||
else:
|
||
masked = "****"
|
||
return web.json_response({
|
||
"has_key": bool(key),
|
||
"masked": masked,
|
||
"network_route": get_network_route(),
|
||
"network_route_options": NETWORK_ROUTE_OPTIONS,
|
||
})
|
||
|
||
@PromptServer.instance.routes.post("/o1key/config")
|
||
async def set_o1key_config(request):
|
||
data = await request.json()
|
||
route = str(data.get("network_route", "")).strip()
|
||
if route not in NETWORK_ROUTES:
|
||
return web.json_response({"error": "网络线路无效"}, status=400)
|
||
|
||
updates = {NETWORK_ROUTE_CONFIG_KEY: route}
|
||
if data.get("api_key") is not None:
|
||
new_key = str(data.get("api_key", "")).strip()
|
||
if not new_key:
|
||
return web.json_response({"error": "API Key 不能为空"}, status=400)
|
||
if "\n" in new_key or "\r" in new_key:
|
||
return web.json_response({"error": "API Key 格式无效"}, status=400)
|
||
updates["O1KEY_API_KEY"] = new_key
|
||
|
||
config = update_config(updates=updates)
|
||
key = config.get("O1KEY_API_KEY", "")
|
||
masked = key[:3] + "****" + key[-4:] if len(key) > 8 else ("****" if key else "")
|
||
return web.json_response({
|
||
"success": True,
|
||
"has_key": bool(key),
|
||
"masked": masked,
|
||
"network_route": route,
|
||
})
|
||
|
||
@PromptServer.instance.routes.post("/o1key/network_route")
|
||
async def set_network_route(request):
|
||
data = await request.json()
|
||
route = str(data.get("network_route", "")).strip()
|
||
if route not in NETWORK_ROUTES:
|
||
return web.json_response({"error": "网络线路无效"}, status=400)
|
||
update_config(updates={NETWORK_ROUTE_CONFIG_KEY: route})
|
||
return web.json_response({"success": True, "network_route": route})
|
||
|
||
@PromptServer.instance.routes.post("/o1key/api_key")
|
||
async def set_api_key_route(request):
|
||
data = await request.json()
|
||
new_key = data.get("api_key", "").strip()
|
||
if not new_key:
|
||
return web.json_response({"error": "API Key 不能为空"}, status=400)
|
||
if "\n" in new_key or "\r" in new_key:
|
||
return web.json_response({"error": "API Key 格式无效"}, status=400)
|
||
update_config(updates={"O1KEY_API_KEY": new_key})
|
||
return web.json_response({"success": True})
|
||
|
||
@PromptServer.instance.routes.post("/o1key/test_key")
|
||
async def test_api_key_route(request):
|
||
import aiohttp as _aiohttp
|
||
data = await request.json()
|
||
test_key = str(data.get("api_key") or "").strip() or get_api_key()
|
||
if not test_key:
|
||
return web.json_response({"valid": False, "error": "请先输入或保存 API Key"})
|
||
requested_route = str(data.get("network_route", "")).strip()
|
||
route = requested_route if requested_route in NETWORK_ROUTES else get_network_route()
|
||
base_url = NETWORK_ROUTES.get(route, NETWORK_ROUTES[DEFAULT_NETWORK_ROUTE])
|
||
url = f"{base_url}/v1/models"
|
||
headers = {"Authorization": f"Bearer {test_key}"}
|
||
try:
|
||
async with _aiohttp.ClientSession() as session:
|
||
async with session.get(url, headers=headers, timeout=_aiohttp.ClientTimeout(total=10)) as resp:
|
||
if resp.status == 200:
|
||
return web.json_response({"valid": True})
|
||
elif resp.status == 401:
|
||
return web.json_response({"valid": False, "error": "密钥无效或已过期"})
|
||
else:
|
||
text = await resp.text()
|
||
return web.json_response({"valid": False, "error": f"验证失败 ({resp.status})"})
|
||
except Exception as e:
|
||
return web.json_response({"valid": False, "error": f"网络错误: {str(e)}"})
|
||
|
||
@PromptServer.instance.routes.delete("/o1key/api_key")
|
||
async def delete_api_key_route(request):
|
||
update_config(remove=["O1KEY_API_KEY"])
|
||
return web.json_response({"success": True})
|
||
|
||
# === 主体(Element)代理:转发到 {base}/kling/v1/general/*,后端注入令牌 ===
|
||
_ELEMENT_PREFIX = "/kling/v1/general"
|
||
|
||
def _element_base_url(_route=None):
|
||
return NETWORK_ROUTES[get_network_route()].rstrip("/")
|
||
|
||
def _element_headers():
|
||
config = load_config()
|
||
key = config.get("O1KEY_API_KEY", "")
|
||
if not key:
|
||
return None
|
||
return {"Authorization": f"Bearer {key}"}
|
||
|
||
@PromptServer.instance.routes.get("/o1key/element/mine")
|
||
async def o1key_element_mine(request):
|
||
"""列表接口:GET /kling/v1/general/advanced-custom-elements"""
|
||
import aiohttp as _aiohttp
|
||
headers = _element_headers()
|
||
if not headers:
|
||
return web.json_response({"success": False, "message": "未配置 API Key"}, status=401)
|
||
base = _element_base_url(request.query.get("route"))
|
||
url = f"{base}{_ELEMENT_PREFIX}/advanced-custom-elements"
|
||
params = {}
|
||
# 支持分页参数
|
||
page_num = request.query.get("pageNum", "1")
|
||
page_size = request.query.get("pageSize", "100")
|
||
params["pageNum"] = page_num
|
||
params["pageSize"] = page_size
|
||
try:
|
||
async with _aiohttp.ClientSession() as session:
|
||
async with session.get(url, headers=headers, params=params,
|
||
timeout=_aiohttp.ClientTimeout(total=30)) as up:
|
||
result = await up.json()
|
||
# 新API返回: {"success": true, "data": {"code": 0, "data": [...], "total": N}, "message": ""}
|
||
# 转换为前端期望的格式: {"success": true, "data": [...]}
|
||
if result.get("success") and isinstance(result.get("data"), dict):
|
||
elements = result["data"].get("data", [])
|
||
return web.json_response({"success": True, "data": elements, "message": ""})
|
||
return web.json_response(result, status=up.status)
|
||
except Exception as e:
|
||
return web.json_response({"success": False, "message": str(e)}, status=502)
|
||
|
||
@PromptServer.instance.routes.post("/o1key/element/upload")
|
||
async def o1key_element_upload(request):
|
||
"""转发 multipart 文件上传:POST /kling/v1/general/upload
|
||
视频可达 200MB,固定总超时会截断大文件上传,改用:不限总时长 +
|
||
读空闲 120s 超时(连接卡死才超时,慢速大文件不会被一刀切断)。"""
|
||
import aiohttp as _aiohttp
|
||
headers = _element_headers()
|
||
if not headers:
|
||
return web.json_response({"success": False, "message": "未配置 API Key"}, status=401)
|
||
base = _element_base_url(request.query.get("route"))
|
||
url = f"{base}{_ELEMENT_PREFIX}/upload"
|
||
try:
|
||
reader = await request.multipart()
|
||
field = await reader.next()
|
||
if field is None or field.name != "file":
|
||
return web.json_response({"success": False, "message": "缺少 file 字段"}, status=400)
|
||
file_bytes = await field.read(decode=False)
|
||
filename = field.filename or "image.png"
|
||
form = _aiohttp.FormData()
|
||
form.add_field("file", file_bytes, filename=filename,
|
||
content_type=field.headers.get("Content-Type", "application/octet-stream"))
|
||
timeout = _aiohttp.ClientTimeout(total=None, sock_connect=30, sock_read=120)
|
||
async with _aiohttp.ClientSession() as session:
|
||
async with session.post(url, headers=headers, data=form,
|
||
timeout=timeout) as up:
|
||
data = await up.json()
|
||
return web.json_response(data, status=up.status)
|
||
except Exception as e:
|
||
return web.json_response({"success": False, "message": str(e)}, status=502)
|
||
|
||
@PromptServer.instance.routes.post("/o1key/element/create")
|
||
async def o1key_element_create(request):
|
||
"""创建主体:POST /kling/v1/general/advanced-custom-elements
|
||
|
||
新API字段映射:
|
||
- name -> element_name
|
||
- description -> element_description
|
||
- reference_type -> reference_type (image_refer / video_refer)
|
||
- frontal_image -> frontal_image
|
||
- refer_images -> refer_images
|
||
- video_list -> video_list
|
||
- element_voice_id, tag_ids, channel_id 保持不变
|
||
"""
|
||
import aiohttp as _aiohttp
|
||
import json as _json_element
|
||
headers = _element_headers()
|
||
if not headers:
|
||
return web.json_response({"success": False, "message": "未配置 API Key"}, status=401)
|
||
try:
|
||
payload = await request.json()
|
||
except Exception as e:
|
||
return web.json_response({"success": False, "message": f"请求体无效: {e}"}, status=400)
|
||
route = payload.pop("route", None)
|
||
base = _element_base_url(route)
|
||
url = f"{base}{_ELEMENT_PREFIX}/advanced-custom-elements"
|
||
|
||
# 字段映射:前端使用旧字段名,转换为新API字段名
|
||
api_payload = {}
|
||
if "name" in payload:
|
||
api_payload["element_name"] = payload["name"]
|
||
if "description" in payload:
|
||
api_payload["element_description"] = payload["description"]
|
||
# 其他字段直接透传
|
||
for key in ["reference_type", "frontal_image", "refer_images", "video_list",
|
||
"element_voice_id", "tag_ids", "channel_id"]:
|
||
if key in payload:
|
||
api_payload[key] = payload[key]
|
||
|
||
send_headers = {**headers, "Content-Type": "application/json"}
|
||
# 打印创建主体的请求信息
|
||
try:
|
||
print(f"[主体创建] 请求 URL: {url}")
|
||
print("[主体创建] 请求体: " + _json_element.dumps(api_payload, ensure_ascii=False, indent=2))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
async with _aiohttp.ClientSession() as session:
|
||
async with session.post(url, headers=send_headers, json=api_payload,
|
||
timeout=_aiohttp.ClientTimeout(total=60)) as up:
|
||
data = await up.json()
|
||
# 打印创建主体的响应信息
|
||
try:
|
||
print("[主体创建] 响应体: " + _json_element.dumps(data, ensure_ascii=False, indent=2))
|
||
except Exception:
|
||
pass
|
||
return web.json_response(data, status=up.status)
|
||
except Exception as e:
|
||
return web.json_response({"success": False, "message": str(e)}, status=502)
|
||
|
||
@PromptServer.instance.routes.post("/o1key/element/refresh")
|
||
async def o1key_element_refresh(request):
|
||
"""查询主体:GET /kling/v1/general/advanced-custom-elements/{task_id}
|
||
|
||
前端传 id(数据库主键),需要先查本地库拿到 job_id(即 task_id),再查询上游。
|
||
为了简化,这里改为前端直接传 task_id(即创建时返回的 job_id)。
|
||
"""
|
||
import aiohttp as _aiohttp
|
||
import json as _json_refresh
|
||
headers = _element_headers()
|
||
if not headers:
|
||
return web.json_response({"success": False, "message": "未配置 API Key"}, status=401)
|
||
try:
|
||
payload = await request.json()
|
||
except Exception:
|
||
payload = {}
|
||
task_id = payload.get("task_id") or payload.get("id")
|
||
if not task_id:
|
||
return web.json_response({"success": False, "message": "缺少 task_id"}, status=400)
|
||
base = _element_base_url(payload.get("route"))
|
||
url = f"{base}{_ELEMENT_PREFIX}/advanced-custom-elements/{task_id}"
|
||
try:
|
||
async with _aiohttp.ClientSession() as session:
|
||
async with session.get(url, headers=headers,
|
||
timeout=_aiohttp.ClientTimeout(total=60)) as up:
|
||
data = await up.json()
|
||
# 打印查询响应
|
||
try:
|
||
print(f"[主体查询] task_id={task_id}")
|
||
print("[主体查询] 响应体: " + _json_refresh.dumps(data, ensure_ascii=False, indent=2))
|
||
except Exception:
|
||
pass
|
||
# 新API返回嵌套结构,需要提取 task_status 和 element_id
|
||
# 响应: {"success": true, "data": {"code": 0, "data": {"task_status": "succeed", "task_result": {"elements": [...]}}}}
|
||
if data.get("success") and isinstance(data.get("data"), dict):
|
||
inner = data["data"].get("data", {})
|
||
task_status = inner.get("task_status", "")
|
||
# 转换为前端期望的格式
|
||
element = {
|
||
"id": task_id,
|
||
"job_id": task_id,
|
||
"status": task_status,
|
||
"task_status": task_status,
|
||
}
|
||
if task_status == "succeed":
|
||
elements = inner.get("task_result", {}).get("elements", [])
|
||
if elements:
|
||
first = elements[0]
|
||
element["element_id"] = str(first.get("element_id", ""))
|
||
element["name"] = first.get("element_name", "")
|
||
element["frontal_image"] = first.get("element_image_list", {}).get("frontal_image", "")
|
||
elif task_status == "failed":
|
||
element["fail_reason"] = inner.get("task_status_msg", "")
|
||
return web.json_response({"success": True, "data": {"element": element, "detail": data["data"]}})
|
||
return web.json_response(data, status=up.status)
|
||
except Exception as e:
|
||
return web.json_response({"success": False, "message": str(e)}, status=502)
|
||
|
||
@PromptServer.instance.routes.post("/o1key/element/delete")
|
||
async def o1key_element_delete(request):
|
||
"""删除主体:POST /kling/v1/general/delete-advanced-elements
|
||
|
||
请求体: {"element_id": "315320838184520"}
|
||
"""
|
||
import aiohttp as _aiohttp
|
||
headers = _element_headers()
|
||
if not headers:
|
||
return web.json_response({"success": False, "message": "未配置 API Key"}, status=401)
|
||
try:
|
||
payload = await request.json()
|
||
except Exception:
|
||
payload = {}
|
||
element_id = payload.get("element_id") or payload.get("id")
|
||
if not element_id:
|
||
return web.json_response({"success": False, "message": "缺少 element_id"}, status=400)
|
||
base = _element_base_url(payload.get("route"))
|
||
url = f"{base}{_ELEMENT_PREFIX}/delete-advanced-elements"
|
||
delete_payload = {"element_id": str(element_id)}
|
||
send_headers = {**headers, "Content-Type": "application/json"}
|
||
try:
|
||
async with _aiohttp.ClientSession() as session:
|
||
async with session.post(url, headers=send_headers, json=delete_payload,
|
||
timeout=_aiohttp.ClientTimeout(total=60)) as up:
|
||
data = await up.json()
|
||
return web.json_response(data, status=up.status)
|
||
except Exception as e:
|
||
return web.json_response({"success": False, "message": str(e)}, status=502)
|
||
|
||
@PromptServer.instance.routes.get("/o1key/element/image")
|
||
async def o1key_element_image(request):
|
||
"""图片同源代理:ComfyUI 的 CSP 限制 img-src 'self',外链缩略图无法直接显示。
|
||
前端把缩略图 src 指向本路由,后端取回字节再吐给浏览器,对浏览器即同源。
|
||
仅允许 o1key 资源域,避免被当成任意 URL 抓取的 SSRF 跳板。"""
|
||
import aiohttp as _aiohttp
|
||
from urllib.parse import urlparse, unquote
|
||
raw = request.query.get("url", "")
|
||
if not raw:
|
||
return web.json_response({"success": False, "message": "缺少 url"}, status=400)
|
||
target = unquote(raw)
|
||
try:
|
||
parsed = urlparse(target)
|
||
except Exception:
|
||
parsed = None
|
||
if not parsed or parsed.scheme not in ("http", "https"):
|
||
return web.json_response({"success": False, "message": "非法 url"}, status=400)
|
||
host = (parsed.hostname or "").lower()
|
||
if not (host.endswith(".o1key.com") or host.endswith(".o1key.cn")
|
||
or host in ("o1key.com", "o1key.cn")):
|
||
return web.json_response({"success": False, "message": "不允许的图片来源"}, status=403)
|
||
try:
|
||
async with _aiohttp.ClientSession() as session:
|
||
async with session.get(target, timeout=_aiohttp.ClientTimeout(total=30)) as up:
|
||
if up.status != 200:
|
||
return web.Response(status=up.status)
|
||
body = await up.read()
|
||
ctype = up.headers.get("Content-Type", "image/jpeg").split(";")[0].strip()
|
||
return web.Response(body=body, content_type=ctype or "image/jpeg",
|
||
headers={"Cache-Control": "max-age=3600"})
|
||
except Exception as e:
|
||
return web.json_response({"success": False, "message": str(e)}, status=502)
|
||
|
||
@PromptServer.instance.routes.get("/o1key/output_history")
|
||
async def get_output_history(request):
|
||
"""读取 output 目录文件,按执行分组返回 /api/jobs 兼容格式"""
|
||
import os, json as _json
|
||
limit = int(request.query.get("limit", "200"))
|
||
offset = int(request.query.get("offset", "0"))
|
||
output_dir = os.path.abspath(folder_paths.get_output_directory())
|
||
meta_file = _get_o1key_history_meta_file(output_dir)
|
||
meta = {}
|
||
if os.path.isfile(meta_file):
|
||
try:
|
||
with open(meta_file, "r", encoding="utf-8") as mf:
|
||
meta = _json.load(mf)
|
||
except Exception:
|
||
pass
|
||
supported_ext = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.webm'}
|
||
# 收集所有文件并按 workflow_id 分组
|
||
all_files = []
|
||
for fname in meta.keys():
|
||
ext = os.path.splitext(fname)[1].lower()
|
||
if ext not in supported_ext:
|
||
continue
|
||
fpath = os.path.join(output_dir, fname)
|
||
if not os.path.isfile(fpath):
|
||
continue
|
||
mtime = os.path.getmtime(fpath)
|
||
media = "images" if ext in {'.png','.jpg','.jpeg','.webp','.gif'} else "video"
|
||
all_files.append({"name": fname, "mtime": mtime, "media": media})
|
||
# 按 workflow_id 分组(同一次执行合并为一个 job)
|
||
groups = {}
|
||
for f in all_files:
|
||
m = meta.get(f["name"], {})
|
||
wid = m.get("workflow_id")
|
||
if wid:
|
||
groups.setdefault(wid, []).append((f, m))
|
||
# 构建 job 列表
|
||
jobs = []
|
||
for wid, items in groups.items():
|
||
items.sort(key=lambda x: x[0]["mtime"], reverse=True)
|
||
latest = items[0]
|
||
f, m = latest
|
||
start_ms = int(m.get("start_time", f["mtime"]) * 1000)
|
||
end_ms = int(m.get("end_time", f["mtime"]) * 1000)
|
||
jobs.append({
|
||
"id": wid,
|
||
"status": "completed",
|
||
"create_time": start_ms,
|
||
"execution_start_time": start_ms,
|
||
"execution_end_time": end_ms,
|
||
"preview_output": {
|
||
"filename": f["name"],
|
||
"subfolder": "",
|
||
"type": "output",
|
||
"nodeId": "0",
|
||
"mediaType": f["media"],
|
||
},
|
||
"outputs_count": len(items),
|
||
"execution_error": None,
|
||
"workflow_id": wid,
|
||
})
|
||
# 按时间倒序排列,分页
|
||
jobs.sort(key=lambda x: x["create_time"], reverse=True)
|
||
total = len(jobs)
|
||
page = jobs[offset:offset+limit]
|
||
return web.json_response({
|
||
"jobs": page,
|
||
"pagination": {"offset": offset, "limit": limit, "total": total, "has_more": offset + limit < total}
|
||
})
|
||
|
||
@PromptServer.instance.routes.get("/o1key/output_workflow")
|
||
async def get_output_workflow(request):
|
||
"""从 PNG 元数据中读取工作流,供前端恢复使用"""
|
||
import os, struct, json as _json
|
||
filename = request.query.get("filename", "")
|
||
if not filename:
|
||
return web.json_response({"error": "missing filename"}, status=400)
|
||
output_dir = os.path.abspath(folder_paths.get_output_directory())
|
||
fpath = os.path.join(output_dir, filename)
|
||
if not os.path.isfile(fpath) or not fpath.lower().endswith(".png"):
|
||
return web.json_response({"error": "file not found"}, status=404)
|
||
workflow = None
|
||
prompt_data = None
|
||
try:
|
||
with open(fpath, "rb") as pf:
|
||
pf.read(8) # PNG signature
|
||
while True:
|
||
raw = pf.read(8)
|
||
if len(raw) < 8:
|
||
break
|
||
length = struct.unpack(">I", raw[:4])[0]
|
||
chunk_type = raw[4:8]
|
||
data = pf.read(length)
|
||
pf.read(4) # CRC
|
||
if chunk_type == b"tEXt":
|
||
key, val = data.split(b"\x00", 1)
|
||
k = key.decode("ascii", errors="replace")
|
||
if k == "workflow":
|
||
workflow = _json.loads(val)
|
||
elif k == "prompt":
|
||
prompt_data = _json.loads(val)
|
||
elif chunk_type == b"IEND":
|
||
break
|
||
except Exception:
|
||
pass
|
||
return web.json_response({"workflow": workflow, "prompt": prompt_data})
|
||
|
||
@PromptServer.instance.routes.get("/o1key/job_detail/{job_id}")
|
||
async def get_job_detail(request):
|
||
"""根据 job_id 返回当前端口持久化历史中的 job 详情"""
|
||
import os, struct, json as _json
|
||
job_id = request.match_info["job_id"]
|
||
output_dir = os.path.abspath(folder_paths.get_output_directory())
|
||
meta_file = _get_o1key_history_meta_file(output_dir)
|
||
meta = {}
|
||
if os.path.isfile(meta_file):
|
||
try:
|
||
with open(meta_file, "r", encoding="utf-8") as mf:
|
||
meta = _json.load(mf)
|
||
except Exception:
|
||
pass
|
||
supported_ext = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.webm'}
|
||
# 只在当前端口的持久化记录中查找该 job 的文件
|
||
matched_files = []
|
||
for fname in meta.keys():
|
||
ext = os.path.splitext(fname)[1].lower()
|
||
if ext not in supported_ext:
|
||
continue
|
||
m = meta.get(fname, {})
|
||
if m.get("workflow_id") == job_id:
|
||
matched_files.append(fname)
|
||
if not matched_files:
|
||
return web.json_response({"error": "not found"}, status=404)
|
||
# 用最新文件作为代表
|
||
matched_files.sort(key=lambda f: os.path.getmtime(os.path.join(output_dir, f)), reverse=True)
|
||
target_file = matched_files[0]
|
||
fpath = os.path.join(output_dir, target_file)
|
||
m = meta.get(target_file, {})
|
||
mtime = os.path.getmtime(fpath)
|
||
start_ms = int(m.get("start_time", mtime) * 1000)
|
||
end_ms = int(m.get("end_time", mtime) * 1000)
|
||
ext = os.path.splitext(target_file)[1].lower()
|
||
media = "images" if ext in {'.png','.jpg','.jpeg','.webp','.gif'} else "video"
|
||
# 读取 PNG 工作流元数据
|
||
workflow = None
|
||
if ext == ".png":
|
||
try:
|
||
with open(fpath, "rb") as pf:
|
||
pf.read(8)
|
||
while True:
|
||
raw = pf.read(8)
|
||
if len(raw) < 8:
|
||
break
|
||
length = struct.unpack(">I", raw[:4])[0]
|
||
chunk_type = raw[4:8]
|
||
data = pf.read(length)
|
||
pf.read(4)
|
||
if chunk_type == b"tEXt":
|
||
key, val = data.split(b"\x00", 1)
|
||
k = key.decode("ascii", errors="replace")
|
||
if k == "workflow":
|
||
workflow = _json.loads(val)
|
||
elif chunk_type == b"IEND":
|
||
break
|
||
except Exception:
|
||
pass
|
||
# 构建 outputs:包含该执行的所有文件
|
||
outputs = {}
|
||
for i, fname in enumerate(matched_files):
|
||
e = os.path.splitext(fname)[1].lower()
|
||
mt = "images" if e in {'.png','.jpg','.jpeg','.webp','.gif'} else "gifs"
|
||
outputs.setdefault(str(i), {}).setdefault(mt, []).append(
|
||
{"filename": fname, "subfolder": "", "type": "output"}
|
||
)
|
||
job_detail = {
|
||
"id": job_id,
|
||
"status": "completed",
|
||
"create_time": start_ms,
|
||
"execution_start_time": start_ms,
|
||
"execution_end_time": end_ms,
|
||
"preview_output": {
|
||
"filename": target_file,
|
||
"subfolder": "",
|
||
"type": "output",
|
||
"nodeId": "0",
|
||
"mediaType": media,
|
||
},
|
||
"outputs_count": len(matched_files),
|
||
"execution_error": None,
|
||
"workflow_id": job_id,
|
||
"workflow": {
|
||
"extra_data": {
|
||
"extra_pnginfo": {"workflow": workflow}
|
||
}
|
||
} if workflow else None,
|
||
"outputs": outputs,
|
||
}
|
||
return web.json_response(job_detail)
|
||
|
||
@PromptServer.instance.routes.post("/o1key/delete_history")
|
||
async def delete_history_item(request):
|
||
"""删除持久化历史记录及对应的输出文件"""
|
||
import os, json as _json
|
||
body = await request.json()
|
||
job_ids = body.get("delete", [])
|
||
if not job_ids:
|
||
return web.json_response({"success": False, "error": "missing ids"}, status=400)
|
||
output_dir = os.path.abspath(folder_paths.get_output_directory())
|
||
meta_file = _get_o1key_history_meta_file(output_dir)
|
||
meta = {}
|
||
if os.path.isfile(meta_file):
|
||
try:
|
||
with open(meta_file, "r", encoding="utf-8") as mf:
|
||
meta = _json.load(mf)
|
||
except Exception:
|
||
pass
|
||
deleted_files = []
|
||
for job_id in job_ids:
|
||
files_to_remove = []
|
||
for fname, m in list(meta.items()):
|
||
if m.get("workflow_id") == job_id:
|
||
files_to_remove.append(fname)
|
||
for fname in files_to_remove:
|
||
meta.pop(fname, None)
|
||
fpath = os.path.join(output_dir, fname)
|
||
if os.path.isfile(fpath):
|
||
try:
|
||
os.remove(fpath)
|
||
deleted_files.append(fname)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
with open(meta_file, "w", encoding="utf-8") as mf:
|
||
_json.dump(meta, mf, ensure_ascii=False)
|
||
except Exception:
|
||
pass
|
||
return web.json_response({"success": True, "deleted": deleted_files})
|
||
|
||
# === 图片生成提示词优化(服务端读取参考图,避免前端接触 API Key) ===
|
||
@PromptServer.instance.routes.post("/o1key/image/prompt-optimize")
|
||
async def optimize_o1key_image_prompt(request):
|
||
import aiohttp as _aiohttp
|
||
|
||
try:
|
||
data = await request.json()
|
||
if not isinstance(data, dict):
|
||
raise ValueError("请求体必须是对象")
|
||
prompt = data.get("prompt", "")
|
||
references = data.get("references", [])
|
||
api_key = get_api_key() or ""
|
||
if not api_key:
|
||
return web.json_response({"error": "未配置 API Key"}, status=401)
|
||
|
||
timeout = _aiohttp.ClientTimeout(total=PROMPT_OPTIMIZER_TIMEOUT_SECONDS + 15)
|
||
async with _aiohttp.ClientSession(timeout=timeout) as session:
|
||
optimized = await optimize_image_prompt(
|
||
session,
|
||
NETWORK_ROUTES[get_network_route()],
|
||
api_key,
|
||
prompt,
|
||
references,
|
||
folder_paths.get_input_directory(),
|
||
)
|
||
return web.json_response(
|
||
{
|
||
"prompt": optimized,
|
||
"model": "gpt-5.6-sol",
|
||
"reasoning_effort": "high",
|
||
},
|
||
headers={"Cache-Control": "no-store"},
|
||
)
|
||
except ValueError as exc:
|
||
return web.json_response({"error": str(exc)}, status=400)
|
||
except RuntimeError as exc:
|
||
return web.json_response({"error": str(exc)}, status=502)
|
||
except Exception:
|
||
return web.json_response({"error": "提示词优化失败,请稍后重试"}, status=500)
|
||
|
||
# === 视频生成 AI帮写(独立视频预设,仅分析安全的 input 图片描述) ===
|
||
@PromptServer.instance.routes.post("/o1key/video/prompt-write")
|
||
async def write_o1key_video_prompt(request):
|
||
import aiohttp as _aiohttp
|
||
|
||
try:
|
||
data = await request.json()
|
||
if not isinstance(data, dict):
|
||
raise ValueError("请求体必须是对象")
|
||
api_key = get_api_key() or ""
|
||
if not api_key:
|
||
return web.json_response({"error": "未配置 API Key"}, status=401)
|
||
|
||
context = {
|
||
"generation_mode": data.get("generation_mode", "text"),
|
||
"duration": data.get("duration", "auto"),
|
||
"aspect_ratio": data.get("aspect_ratio", "auto"),
|
||
"generate_audio": data.get("generate_audio", False),
|
||
"reference_video_count": data.get("reference_video_count", 0),
|
||
"reference_audio_count": data.get("reference_audio_count", 0),
|
||
}
|
||
timeout = _aiohttp.ClientTimeout(total=PROMPT_OPTIMIZER_TIMEOUT_SECONDS + 15)
|
||
async with _aiohttp.ClientSession(timeout=timeout) as session:
|
||
written = await write_video_prompt(
|
||
session,
|
||
NETWORK_ROUTES[get_network_route()],
|
||
api_key,
|
||
data.get("prompt", ""),
|
||
data.get("references", []),
|
||
folder_paths.get_input_directory(),
|
||
context,
|
||
)
|
||
return web.json_response(
|
||
{
|
||
"prompt": written,
|
||
"model": "gpt-5.6-sol",
|
||
"reasoning_effort": "high",
|
||
"preset": "video-default",
|
||
},
|
||
headers={"Cache-Control": "no-store"},
|
||
)
|
||
except ValueError as exc:
|
||
return web.json_response({"error": str(exc)}, status=400)
|
||
except RuntimeError as exc:
|
||
return web.json_response({"error": str(exc)}, status=502)
|
||
except Exception:
|
||
return web.json_response({"error": "视频 AI帮写失败,请稍后重试"}, status=500)
|
||
|
||
# === AI 聊天代理(流式 SSE 透传) ===
|
||
@PromptServer.instance.routes.post("/o1key/chat/completions")
|
||
async def chat_completions_proxy(request):
|
||
import aiohttp as _aiohttp
|
||
import json as _cjson
|
||
|
||
data = await request.json()
|
||
config = load_config()
|
||
api_key = config.get("O1KEY_API_KEY", "")
|
||
if not api_key:
|
||
return web.json_response({"error": "未配置 API Key"}, status=401)
|
||
|
||
base_url = NETWORK_ROUTES[get_network_route()]
|
||
model = data.get("model", "gpt-6-sol")
|
||
messages = data.get("messages", [])
|
||
if not isinstance(messages, list) or not messages:
|
||
return web.json_response({"error": "缺少对话内容"}, status=400)
|
||
|
||
try:
|
||
messages = expand_xlsx_attachments(messages)
|
||
except ValueError as exc:
|
||
return web.json_response({"error": str(exc)}, status=400)
|
||
|
||
url = f"{base_url}/v1/chat/completions"
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {api_key}",
|
||
}
|
||
reasoning = data.get("reasoning_effort", "high")
|
||
if reasoning not in ("low", "medium", "high"):
|
||
reasoning = "high"
|
||
body = {"model": model, "messages": messages, "stream": True}
|
||
if model == "claude-fable-5":
|
||
budgets = {"low": 2048, "medium": 8192, "high": 16384}
|
||
budget = budgets[reasoning]
|
||
body["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||
body["max_tokens"] = budget + 8192
|
||
elif model in ("gpt-5.5", "gpt-5.6-sol", "gpt-6-astra", "gpt-6-sol", "gemini-3.1-pro-preview"):
|
||
body["reasoning_effort"] = reasoning
|
||
|
||
search_trace = None
|
||
timeout = _aiohttp.ClientTimeout(total=120)
|
||
async with _aiohttp.ClientSession(timeout=timeout) as session:
|
||
if data.get("web_search") is True:
|
||
raw_query = extract_search_query(messages)
|
||
if raw_query:
|
||
query = await rewrite_search_query(session, base_url, api_key, raw_query) or raw_query
|
||
try:
|
||
results = await web_search(session, query)
|
||
search_trace = {
|
||
"query": query,
|
||
"results": [
|
||
{"title": item["title"], "url": item["url"]}
|
||
for item in results
|
||
],
|
||
}
|
||
messages = list(messages)
|
||
messages.insert(max(0, len(messages) - 1), {
|
||
"role": "system",
|
||
"content": build_search_context(query, results),
|
||
})
|
||
body["messages"] = messages
|
||
except Exception as exc:
|
||
search_trace = {"query": query, "results": [], "error": str(exc)}
|
||
|
||
resp = web.StreamResponse(
|
||
status=200, reason="OK",
|
||
headers={
|
||
"Content-Type": "text/event-stream",
|
||
"Cache-Control": "no-cache",
|
||
"X-Accel-Buffering": "no",
|
||
}
|
||
)
|
||
await resp.prepare(request)
|
||
|
||
try:
|
||
if search_trace:
|
||
event = _cjson.dumps({"o1key_search": search_trace}, ensure_ascii=False)
|
||
await resp.write(f"data: {event}\n\n".encode("utf-8"))
|
||
async with session.post(url, headers=headers, json=body) as upstream:
|
||
if upstream.status != 200:
|
||
err = await upstream.text()
|
||
event = _cjson.dumps({"error": err}, ensure_ascii=False)
|
||
await resp.write(f"data: {event}\n\n".encode("utf-8"))
|
||
await resp.write(b"data: [DONE]\n\n")
|
||
return resp
|
||
async for chunk in upstream.content.iter_any():
|
||
await resp.write(chunk)
|
||
except Exception as e:
|
||
event = _cjson.dumps({"error": str(e)}, ensure_ascii=False)
|
||
await resp.write(f"data: {event}\n\n".encode("utf-8"))
|
||
await resp.write(b"data: [DONE]\n\n")
|
||
|
||
return resp
|
||
# === 执行事件 Hook:持久化耗时元数据 ===
|
||
import time as _time, json as _json2, os as _os
|
||
_execution_tracker = {}
|
||
|
||
_orig_send_sync = PromptServer.instance.send_sync
|
||
|
||
def _patched_send_sync(event, data, *args, **kwargs):
|
||
try:
|
||
if event == "execution_start":
|
||
pid = data.get("prompt_id", "")
|
||
if pid:
|
||
_execution_tracker[pid] = {"start": _time.time(), "outputs": []}
|
||
elif event == "executed":
|
||
pid = data.get("prompt_id", "")
|
||
output = data.get("output") or {}
|
||
if pid and pid in _execution_tracker:
|
||
for img in output.get("images", []) + output.get("gifs", []):
|
||
if img.get("type") == "output" and img.get("filename"):
|
||
_execution_tracker[pid]["outputs"].append(img["filename"])
|
||
elif event == "executing" and data.get("node") is None:
|
||
pid = data.get("prompt_id", "")
|
||
tracker = _execution_tracker.pop(pid, None)
|
||
if tracker and tracker["outputs"]:
|
||
end_time = _time.time()
|
||
start_time = tracker["start"]
|
||
output_dir = _os.path.abspath(folder_paths.get_output_directory())
|
||
meta_file = _get_o1key_history_meta_file(output_dir)
|
||
meta = {}
|
||
if _os.path.isfile(meta_file):
|
||
try:
|
||
with open(meta_file, "r", encoding="utf-8") as mf:
|
||
meta = _json2.load(mf)
|
||
except Exception:
|
||
pass
|
||
for fname in tracker["outputs"]:
|
||
meta[fname] = {
|
||
"start_time": start_time,
|
||
"end_time": end_time,
|
||
"outputs_count": len(tracker["outputs"]),
|
||
"workflow_id": pid,
|
||
}
|
||
try:
|
||
with open(meta_file, "w", encoding="utf-8") as mf:
|
||
_json2.dump(meta, mf, ensure_ascii=False)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return _orig_send_sync(event, data, *args, **kwargs)
|
||
|
||
PromptServer.instance.send_sync = _patched_send_sync
|
||
|
||
except Exception:
|
||
pass
|