1083 lines
42 KiB
Python
1083 lines
42 KiB
Python
# Copyright (C) 2025 AIDC-AI
|
||
# Licensed under the MIT License.
|
||
|
||
import json
|
||
import asyncio
|
||
import time
|
||
from typing import Optional, TypedDict, List, Union
|
||
import threading
|
||
from collections import defaultdict
|
||
|
||
from sqlalchemy.orm import identity
|
||
|
||
from ..utils.globals import set_language, apply_llm_env_defaults
|
||
from ..utils.auth_utils import extract_and_store_api_key
|
||
import server
|
||
from aiohttp import web
|
||
import base64
|
||
|
||
# Import the MCP client function
|
||
import os
|
||
import shutil
|
||
|
||
from ..service.debug_agent import debug_workflow_errors
|
||
from ..dao.workflow_table import save_workflow_data, get_workflow_data_by_id, update_workflow_ui_by_id
|
||
from ..service.mcp_client import comfyui_agent_invoke
|
||
from ..utils.request_context import set_request_context, get_session_id
|
||
from ..utils.logger import log
|
||
from ..utils.modelscope_gateway import ModelScopeGateway
|
||
import folder_paths
|
||
|
||
|
||
def get_llm_config_from_headers(request):
|
||
"""Extract LLM-related configuration from request headers."""
|
||
return {
|
||
"openai_api_key": request.headers.get('Openai-Api-Key'),
|
||
"openai_base_url": request.headers.get('Openai-Base-Url'),
|
||
# Workflow LLM settings (optional, used by tools/agents that need a different LLM)
|
||
"workflow_llm_api_key": request.headers.get('Workflow-LLM-Api-Key'),
|
||
"workflow_llm_base_url": request.headers.get('Workflow-LLM-Base-Url'),
|
||
"workflow_llm_model": request.headers.get('Workflow-LLM-Model'),
|
||
}
|
||
|
||
|
||
# 全局下载进度存储
|
||
download_progress = {}
|
||
download_lock = threading.Lock()
|
||
|
||
# 不再使用内存存储会话消息,改为从前端传递历史消息
|
||
|
||
# 在文件开头添加
|
||
STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "public")
|
||
|
||
# Define types using TypedDict
|
||
class Node(TypedDict):
|
||
name: str # 节点名称
|
||
description: str # 节点描述
|
||
image: str # 节点图片url,可为空
|
||
github_url: str # 节点github地址
|
||
from_index: int # 节点在列表中的位置
|
||
to_index: int # 节点在列表中的位置
|
||
|
||
class NodeInfo(TypedDict):
|
||
existing_nodes: List[Node] # 已安装的节点
|
||
missing_nodes: List[Node] # 未安装的节点
|
||
|
||
class Workflow(TypedDict, total=False):
|
||
id: Optional[int] # 工作流id
|
||
name: Optional[str] # 工作流名称
|
||
description: Optional[str] # 工作流描述
|
||
image: Optional[str] # 工作流图片
|
||
workflow: Optional[str] # 工作流
|
||
|
||
class ExtItem(TypedDict):
|
||
type: str # 扩展类型
|
||
data: Union[dict, list] # 扩展数据
|
||
|
||
class ChatResponse(TypedDict):
|
||
session_id: str # 会话id
|
||
text: Optional[str] # 返回文本
|
||
finished: bool # 是否结束
|
||
type: str # 返回的类型
|
||
format: str # 返回的格式
|
||
ext: Optional[List[ExtItem]] # 扩展信息
|
||
|
||
# 下载进度回调类
|
||
class DownloadProgressCallback:
|
||
def __init__(self, id: str, filename: str, file_size: int, download_id: str):
|
||
self.id = id
|
||
self.filename = filename
|
||
self.file_size = file_size
|
||
self.download_id = download_id
|
||
self.progress = 0
|
||
self.status = "downloading" # downloading, completed, failed
|
||
self.error_message = None
|
||
self.start_time = time.time()
|
||
|
||
# 初始化进度记录
|
||
with download_lock:
|
||
download_progress[download_id] = {
|
||
"id": id,
|
||
"filename": filename,
|
||
"file_size": file_size,
|
||
"progress": 0,
|
||
"percentage": 0.0,
|
||
"status": "downloading",
|
||
"start_time": self.start_time,
|
||
"estimated_time": None,
|
||
"speed": 0.0,
|
||
"error_message": None
|
||
}
|
||
|
||
def update(self, size: int):
|
||
"""更新下载进度"""
|
||
self.progress += size
|
||
current_time = time.time()
|
||
elapsed_time = current_time - self.start_time
|
||
|
||
# 计算下载速度和预估时间
|
||
if elapsed_time > 0:
|
||
speed = self.progress / elapsed_time # bytes per second
|
||
if speed > 0 and self.progress < self.file_size:
|
||
remaining_bytes = self.file_size - self.progress
|
||
estimated_time = remaining_bytes / speed
|
||
else:
|
||
estimated_time = None
|
||
else:
|
||
speed = 0.0
|
||
estimated_time = None
|
||
|
||
percentage = (self.progress / self.file_size) * 100 if self.file_size > 0 else 0
|
||
|
||
# 更新全局进度
|
||
with download_lock:
|
||
if self.download_id in download_progress:
|
||
# 直接更新字典的值,而不是调用update方法
|
||
progress_dict = download_progress[self.download_id]
|
||
progress_dict["progress"] = self.progress
|
||
progress_dict["percentage"] = round(percentage, 2)
|
||
progress_dict["speed"] = round(speed, 2)
|
||
progress_dict["estimated_time"] = round(estimated_time, 2) if estimated_time else None
|
||
|
||
def end(self, success: bool = True, error_message: str = None):
|
||
"""下载结束回调"""
|
||
current_time = time.time()
|
||
total_time = current_time - self.start_time
|
||
|
||
if success:
|
||
self.status = "completed"
|
||
# 验证下载完整性
|
||
assert self.progress == self.file_size, f"Download incomplete: {self.progress}/{self.file_size}"
|
||
else:
|
||
self.status = "failed"
|
||
self.error_message = error_message
|
||
|
||
# 更新最终状态
|
||
with download_lock:
|
||
if self.download_id in download_progress:
|
||
# 直接更新字典的值,而不是调用update方法
|
||
progress_dict = download_progress[self.download_id]
|
||
progress_dict["status"] = self.status
|
||
progress_dict["progress"] = self.file_size if success else self.progress
|
||
if self.file_size > 0:
|
||
progress_dict["percentage"] = 100.0 if success else (self.progress / self.file_size) * 100
|
||
else:
|
||
progress_dict["percentage"] = 0.0
|
||
progress_dict["total_time"] = round(total_time, 2)
|
||
progress_dict["error_message"] = self.error_message
|
||
|
||
def fail(self, error_message: str):
|
||
"""下载失败回调"""
|
||
self.end(success=False, error_message=error_message)
|
||
|
||
# 生成唯一下载ID
|
||
def generate_download_id() -> str:
|
||
import uuid
|
||
return str(uuid.uuid4())
|
||
|
||
async def upload_to_oss(file_data: bytes, filename: str) -> str:
|
||
# TODO: Implement your OSS upload logic here
|
||
# For now, save locally and return a placeholder URL
|
||
|
||
try:
|
||
# Create uploads directory if it doesn't exist
|
||
uploads_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'uploads')
|
||
os.makedirs(uploads_dir, exist_ok=True)
|
||
|
||
# Generate unique filename to avoid conflicts
|
||
import uuid
|
||
unique_filename = f"{uuid.uuid4().hex}_{filename}"
|
||
file_path = os.path.join(uploads_dir, unique_filename)
|
||
|
||
# Save file locally
|
||
with open(file_path, 'wb') as f:
|
||
f.write(file_data)
|
||
|
||
# Return a local URL or base64 data URL for now
|
||
# In production, replace this with actual OSS URL
|
||
base64_data = base64.b64encode(file_data).decode('utf-8')
|
||
# Determine MIME type based on file extension
|
||
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
|
||
mime_type = f"image/{filename.split('.')[-1].lower()}"
|
||
if mime_type == "image/jpg":
|
||
mime_type = "image/jpeg"
|
||
else:
|
||
mime_type = "image/jpeg" # Default
|
||
|
||
return f"data:{mime_type};base64,{base64_data}"
|
||
|
||
except Exception as e:
|
||
log.error(f"Error uploading file {filename}: {str(e)}")
|
||
# Return original base64 data if upload fails
|
||
base64_data = base64.b64encode(file_data).decode('utf-8')
|
||
return f"data:image/jpeg;base64,{base64_data}"
|
||
|
||
# 关联用户消息和AI响应的checkpoint信息
|
||
def processMessagesWithCheckpoints(messages):
|
||
# ... existing code ...
|
||
pass
|
||
|
||
@server.PromptServer.instance.routes.post("/api/chat/invoke")
|
||
async def invoke_chat(request):
|
||
log.info("Received invoke_chat request")
|
||
|
||
# Extract and store API key from Authorization header
|
||
extract_and_store_api_key(request)
|
||
|
||
req_json = await request.json()
|
||
log.info("Request JSON:", req_json)
|
||
|
||
response = web.StreamResponse(
|
||
status=200,
|
||
reason='OK',
|
||
headers={
|
||
'Content-Type': 'application/json',
|
||
'X-Content-Type-Options': 'nosniff'
|
||
}
|
||
)
|
||
await response.prepare(request)
|
||
|
||
session_id = req_json.get('session_id')
|
||
prompt = req_json.get('prompt')
|
||
images = req_json.get('images', [])
|
||
intent = req_json.get('intent')
|
||
ext = req_json.get('ext')
|
||
historical_messages = req_json.get('messages', [])
|
||
workflow_checkpoint_id = req_json.get('workflow_checkpoint_id')
|
||
|
||
# 获取当前语言
|
||
language = request.headers.get('Accept-Language', 'en')
|
||
set_language(language)
|
||
|
||
# 构建配置信息
|
||
config = {
|
||
"session_id": session_id,
|
||
"workflow_checkpoint_id": workflow_checkpoint_id,
|
||
**get_llm_config_from_headers(request),
|
||
"model_select": next((x['data'][0] for x in ext if x['type'] == 'model_select' and x.get('data')), None)
|
||
}
|
||
# Apply .env-based defaults for LLM-related fields (config > .env > code defaults)
|
||
config = apply_llm_env_defaults(config)
|
||
|
||
# 设置请求上下文 - 这里建立context隔离
|
||
set_request_context(session_id, workflow_checkpoint_id, config)
|
||
|
||
# 图片处理已移至前端,图片信息现在包含在历史消息的OpenAI格式中
|
||
# 保留空的处理逻辑以向后兼容,但实际不再使用
|
||
processed_images = []
|
||
if images and len(images) > 0:
|
||
log.info(f"Note: Received {len(images)} images in legacy format, but using OpenAI message format instead")
|
||
|
||
# 历史消息已经从前端传递过来,格式为OpenAI格式,直接使用
|
||
log.info(f"-- Received {len(historical_messages)} historical messages")
|
||
|
||
# Log workflow checkpoint ID if provided (workflow is now pre-saved before invoke)
|
||
if workflow_checkpoint_id:
|
||
log.info(f"Using workflow checkpoint ID: {workflow_checkpoint_id} for session {session_id}")
|
||
else:
|
||
log.info(f"No workflow checkpoint ID provided for session {session_id}")
|
||
|
||
# 历史消息已经从前端传递过来,包含了正确格式的OpenAI消息(包括图片)
|
||
# 直接使用前端传递的历史消息,无需重新构建当前消息
|
||
openai_messages = historical_messages
|
||
|
||
# 不再需要创建用户消息存储到后端,前端负责消息存储
|
||
|
||
try:
|
||
# Call the MCP client to get streaming response with historical messages and image support
|
||
# Pass OpenAI-formatted messages and processed images to comfyui_agent_invoke
|
||
accumulated_text = ""
|
||
ext_data = None
|
||
finished = True # Default to True
|
||
has_sent_response = False
|
||
previous_text_length = 0
|
||
|
||
log.info(f"config: {config}")
|
||
|
||
# Pass messages in OpenAI format (images are now included in messages)
|
||
# Config is now available through request context
|
||
async for result in comfyui_agent_invoke(openai_messages, None):
|
||
# The MCP client now returns tuples (text, ext_with_finished) where ext_with_finished includes finished status
|
||
if isinstance(result, tuple) and len(result) == 2:
|
||
text, ext_with_finished = result
|
||
if text:
|
||
accumulated_text = text # text from MCP is already accumulated
|
||
|
||
if ext_with_finished:
|
||
# Extract ext data and finished status from the structured response
|
||
ext_data = ext_with_finished.get("data")
|
||
finished = ext_with_finished.get("finished", True)
|
||
log.info(f"-- Received ext data: {ext_data}, finished: {finished}")
|
||
else:
|
||
# Handle single text chunk (backward compatibility)
|
||
text_chunk = result
|
||
if text_chunk:
|
||
accumulated_text += text_chunk
|
||
log.info(f"-- Received text chunk: '{text_chunk}', total length: {len(accumulated_text)}")
|
||
|
||
# Send streaming response if we have new text content
|
||
# Only send intermediate responses during streaming (not the final one)
|
||
if accumulated_text and len(accumulated_text) > previous_text_length:
|
||
chat_response = ChatResponse(
|
||
session_id=session_id,
|
||
text=accumulated_text,
|
||
finished=False, # Always false during streaming
|
||
type="message",
|
||
format="markdown",
|
||
ext=None # ext is only sent in final response
|
||
)
|
||
|
||
await response.write(json.dumps(chat_response).encode() + b"\n")
|
||
previous_text_length = len(accumulated_text)
|
||
await asyncio.sleep(0.01) # Small delay for streaming effect
|
||
|
||
# Send final response with proper finished logic from MCP client
|
||
log.info(f"-- Sending final response: {len(accumulated_text)} chars, ext: {bool(ext_data)}, finished: {finished}")
|
||
|
||
final_response = ChatResponse(
|
||
session_id=session_id,
|
||
text=accumulated_text,
|
||
finished=finished, # Use finished status from MCP client
|
||
type="message",
|
||
format="markdown",
|
||
ext=ext_data
|
||
)
|
||
|
||
await response.write(json.dumps(final_response).encode() + b"\n")
|
||
|
||
# AI响应不再存储到后端,前端负责消息存储
|
||
|
||
except Exception as e:
|
||
log.error(f"Error in invoke_chat: {str(e)}")
|
||
error_response = ChatResponse(
|
||
session_id=session_id,
|
||
text=f"I apologize, but an error occurred: {str(e)}",
|
||
finished=True, # Always finish on error
|
||
type="message",
|
||
format="text",
|
||
ext=None
|
||
)
|
||
await response.write(json.dumps(error_response).encode() + b"\n")
|
||
|
||
await response.write_eof()
|
||
return response
|
||
|
||
|
||
@server.PromptServer.instance.routes.post("/api/save-workflow-checkpoint")
|
||
async def save_workflow_checkpoint(request):
|
||
"""
|
||
Save workflow checkpoint for restore functionality
|
||
"""
|
||
log.info("Received save-workflow-checkpoint request")
|
||
req_json = await request.json()
|
||
|
||
try:
|
||
session_id = req_json.get('session_id')
|
||
workflow_api = req_json.get('workflow_api') # API format workflow
|
||
workflow_ui = req_json.get('workflow_ui') # UI format workflow
|
||
checkpoint_type = req_json.get('checkpoint_type', 'debug_start') # debug_start, debug_complete, user_message_checkpoint
|
||
message_id = req_json.get('message_id') # User message ID for linking (optional)
|
||
|
||
if not session_id or not workflow_api:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameters: session_id and workflow_api"
|
||
})
|
||
|
||
# Save workflow with checkpoint type in attributes
|
||
attributes = {
|
||
"checkpoint_type": checkpoint_type,
|
||
"timestamp": time.time()
|
||
}
|
||
|
||
# Set description and additional attributes based on checkpoint type
|
||
if checkpoint_type == "user_message_checkpoint" and message_id:
|
||
attributes.update({
|
||
"description": f"Workflow checkpoint before user message {message_id}",
|
||
"message_id": message_id,
|
||
"source": "user_message_pre_invoke"
|
||
})
|
||
else:
|
||
attributes["description"] = f"Workflow checkpoint: {checkpoint_type}"
|
||
|
||
version_id = save_workflow_data(
|
||
session_id=session_id,
|
||
workflow_data=workflow_api,
|
||
workflow_data_ui=workflow_ui,
|
||
attributes=attributes
|
||
)
|
||
|
||
log.info(f"Workflow checkpoint saved with version ID: {version_id}")
|
||
|
||
# Return response format based on checkpoint type
|
||
response_data = {
|
||
"version_id": version_id,
|
||
"checkpoint_type": checkpoint_type
|
||
}
|
||
|
||
if checkpoint_type == "user_message_checkpoint" and message_id:
|
||
response_data.update({
|
||
"checkpoint_id": version_id, # Add checkpoint_id alias for user message checkpoints
|
||
"message_id": message_id
|
||
})
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": response_data,
|
||
"message": f"Workflow checkpoint saved successfully"
|
||
})
|
||
|
||
except Exception as e:
|
||
log.error(f"Error saving workflow checkpoint: {str(e)}")
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Failed to save workflow checkpoint: {str(e)}"
|
||
})
|
||
|
||
|
||
|
||
@server.PromptServer.instance.routes.get("/api/restore-workflow-checkpoint")
|
||
async def restore_workflow_checkpoint(request):
|
||
"""
|
||
Restore workflow checkpoint by version ID
|
||
"""
|
||
log.info("Received restore-workflow-checkpoint request")
|
||
|
||
try:
|
||
version_id = request.query.get('version_id')
|
||
|
||
if not version_id:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameter: version_id"
|
||
})
|
||
|
||
try:
|
||
version_id = int(version_id)
|
||
except ValueError:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Invalid version_id format"
|
||
})
|
||
|
||
# Get workflow data by version ID
|
||
workflow_version = get_workflow_data_by_id(version_id)
|
||
|
||
if not workflow_version:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Workflow version {version_id} not found"
|
||
})
|
||
|
||
log.info(f"Restored workflow checkpoint version ID: {version_id}")
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": {
|
||
"version_id": version_id,
|
||
"workflow_data": workflow_version.get('workflow_data'),
|
||
"workflow_data_ui": workflow_version.get('workflow_data_ui'),
|
||
"attributes": workflow_version.get('attributes'),
|
||
"created_at": workflow_version.get('created_at')
|
||
},
|
||
"message": f"Workflow checkpoint restored successfully"
|
||
})
|
||
|
||
except Exception as e:
|
||
log.error(f"Error restoring workflow checkpoint: {str(e)}")
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Failed to restore workflow checkpoint: {str(e)}"
|
||
})
|
||
|
||
|
||
@server.PromptServer.instance.routes.post("/api/debug-agent")
|
||
async def invoke_debug(request):
|
||
"""
|
||
Debug agent endpoint for analyzing ComfyUI workflow errors
|
||
"""
|
||
log.info("Received debug-agent request")
|
||
|
||
# Extract and store API key from Authorization header
|
||
extract_and_store_api_key(request)
|
||
|
||
req_json = await request.json()
|
||
|
||
response = web.StreamResponse(
|
||
status=200,
|
||
reason='OK',
|
||
headers={
|
||
'Content-Type': 'application/json',
|
||
'X-Content-Type-Options': 'nosniff'
|
||
}
|
||
)
|
||
await response.prepare(request)
|
||
|
||
session_id = req_json.get('session_id')
|
||
workflow_data = req_json.get('workflow_data')
|
||
|
||
# Get configuration from headers (OpenAI settings)
|
||
config = {
|
||
"session_id": session_id,
|
||
"model": "gemini-2.5-flash", # Default model for debug agents
|
||
**get_llm_config_from_headers(request),
|
||
}
|
||
# Apply .env-based defaults for LLM-related fields (config > .env > code defaults)
|
||
config = apply_llm_env_defaults(config)
|
||
|
||
# 获取当前语言
|
||
language = request.headers.get('Accept-Language', 'en')
|
||
set_language(language)
|
||
|
||
# 设置请求上下文 - 为debug请求建立context隔离
|
||
set_request_context(session_id, None, config)
|
||
|
||
log.info(f"Debug agent config: {config}")
|
||
log.info(f"Session ID: {session_id}")
|
||
log.info(f"Workflow nodes: {list(workflow_data.keys()) if workflow_data else 'None'}")
|
||
|
||
try:
|
||
# Call the debug agent with streaming response
|
||
accumulated_text = ""
|
||
final_ext_data = None
|
||
finished = False
|
||
|
||
async for result in debug_workflow_errors(workflow_data):
|
||
# Stream the response
|
||
if isinstance(result, tuple) and len(result) == 2:
|
||
text, ext = result
|
||
if text:
|
||
accumulated_text = text # text is already accumulated from debug agent
|
||
|
||
# Handle new ext format from debug_agent matching mcp-client
|
||
if ext:
|
||
if isinstance(ext, dict) and "data" in ext and "finished" in ext:
|
||
# New format: {"data": ext, "finished": finished}
|
||
final_ext_data = ext["data"]
|
||
finished = ext["finished"]
|
||
|
||
# 检查是否包含需要实时发送的workflow_update或param_update
|
||
has_realtime_ext = False
|
||
if final_ext_data:
|
||
for ext_item in final_ext_data:
|
||
if ext_item.get("type") in ["workflow_update", "param_update"]:
|
||
has_realtime_ext = True
|
||
break
|
||
|
||
# 如果包含实时ext数据或者已完成,则发送响应
|
||
if has_realtime_ext or finished:
|
||
chat_response = ChatResponse(
|
||
session_id=session_id,
|
||
text=accumulated_text,
|
||
finished=finished,
|
||
type="message",
|
||
format="markdown",
|
||
ext=final_ext_data # 发送ext数据
|
||
)
|
||
await response.write(json.dumps(chat_response).encode() + b"\n")
|
||
elif not finished:
|
||
# 只有文本更新,不发送ext数据
|
||
chat_response = ChatResponse(
|
||
session_id=session_id,
|
||
text=accumulated_text,
|
||
finished=False,
|
||
type="message",
|
||
format="markdown",
|
||
ext=None
|
||
)
|
||
await response.write(json.dumps(chat_response).encode() + b"\n")
|
||
else:
|
||
# Legacy format: direct ext data (for backward compatibility)
|
||
final_ext_data = ext
|
||
finished = False
|
||
|
||
# Create streaming response
|
||
chat_response = ChatResponse(
|
||
session_id=session_id,
|
||
text=accumulated_text,
|
||
finished=False,
|
||
type="message",
|
||
format="markdown",
|
||
ext=ext
|
||
)
|
||
await response.write(json.dumps(chat_response).encode() + b"\n")
|
||
else:
|
||
# No ext data, just text streaming
|
||
chat_response = ChatResponse(
|
||
session_id=session_id,
|
||
text=accumulated_text,
|
||
finished=False,
|
||
type="message",
|
||
format="markdown",
|
||
ext=None
|
||
)
|
||
await response.write(json.dumps(chat_response).encode() + b"\n")
|
||
|
||
await asyncio.sleep(0.01) # Small delay for streaming effect
|
||
|
||
# Send final response
|
||
final_response = ChatResponse(
|
||
session_id=session_id,
|
||
text=accumulated_text,
|
||
finished=True,
|
||
type="message",
|
||
format="markdown",
|
||
ext=final_ext_data if final_ext_data else [{"type": "debug_complete", "data": {"status": "completed"}}]
|
||
)
|
||
|
||
# Save workflow checkpoint after debug completion if we have workflow_data
|
||
if workflow_data and accumulated_text:
|
||
try:
|
||
current_session_id = get_session_id()
|
||
checkpoint_id = save_workflow_data(
|
||
session_id=current_session_id,
|
||
workflow_data=workflow_data,
|
||
workflow_data_ui=None, # UI format not available in debug agent
|
||
attributes={
|
||
"checkpoint_type": "debug_complete",
|
||
"description": "Workflow state after debug completion",
|
||
"timestamp": time.time()
|
||
}
|
||
)
|
||
|
||
# Add checkpoint info to ext data
|
||
if final_response["ext"]:
|
||
final_response["ext"].append({
|
||
"type": "debug_checkpoint",
|
||
"data": {
|
||
"checkpoint_id": checkpoint_id,
|
||
"checkpoint_type": "debug_complete"
|
||
}
|
||
})
|
||
else:
|
||
final_response["ext"] = [{
|
||
"type": "debug_checkpoint",
|
||
"data": {
|
||
"checkpoint_id": checkpoint_id,
|
||
"checkpoint_type": "debug_complete"
|
||
}
|
||
}]
|
||
|
||
log.info(f"Debug completion checkpoint saved with ID: {checkpoint_id}")
|
||
except Exception as checkpoint_error:
|
||
log.error(f"Failed to save debug completion checkpoint: {checkpoint_error}")
|
||
|
||
await response.write(json.dumps(final_response).encode() + b"\n")
|
||
log.info("Debug agent processing complete")
|
||
|
||
except Exception as e:
|
||
log.error(f"Error in debug agent: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
error_response = ChatResponse(
|
||
session_id=session_id,
|
||
text=f"❌ Debug agent error: {str(e)}",
|
||
finished=True,
|
||
type="message",
|
||
format="text",
|
||
ext=[{"type": "error", "data": {"error": str(e)}}]
|
||
)
|
||
await response.write(json.dumps(error_response).encode() + b"\n")
|
||
|
||
await response.write_eof()
|
||
return response
|
||
|
||
|
||
@server.PromptServer.instance.routes.post("/api/update-workflow-ui")
|
||
async def update_workflow_ui(request):
|
||
"""
|
||
Update workflow_data_ui field for a specific checkpoint without affecting other fields
|
||
"""
|
||
log.info("Received update-workflow-ui request")
|
||
req_json = await request.json()
|
||
|
||
try:
|
||
checkpoint_id = req_json.get('checkpoint_id')
|
||
workflow_data_ui = req_json.get('workflow_data_ui')
|
||
|
||
if not checkpoint_id or not workflow_data_ui:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameters: checkpoint_id and workflow_data_ui"
|
||
})
|
||
|
||
try:
|
||
checkpoint_id = int(checkpoint_id)
|
||
except ValueError:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Invalid checkpoint_id format"
|
||
})
|
||
|
||
# Update only the workflow_data_ui field
|
||
success = update_workflow_ui_by_id(checkpoint_id, workflow_data_ui)
|
||
|
||
if success:
|
||
log.info(f"Successfully updated workflow_data_ui for checkpoint ID: {checkpoint_id}")
|
||
return web.json_response({
|
||
"success": True,
|
||
"message": f"Workflow UI data updated successfully for checkpoint {checkpoint_id}"
|
||
})
|
||
else:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Checkpoint {checkpoint_id} not found"
|
||
})
|
||
|
||
except Exception as e:
|
||
log.error(f"Error updating workflow UI data: {str(e)}")
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Failed to update workflow UI data: {str(e)}"
|
||
})
|
||
|
||
@server.PromptServer.instance.routes.post("/api/download-model")
|
||
async def download_model(request):
|
||
"""
|
||
Download model from ModelScope using SDK
|
||
"""
|
||
log.info("Received download-model request")
|
||
req_json = await request.json()
|
||
|
||
try:
|
||
id = req_json.get('id')
|
||
model_id = req_json.get('model_id')
|
||
model_type = req_json.get('model_type')
|
||
dest_dir = req_json.get('dest_dir')
|
||
|
||
# 验证必需参数
|
||
if not id:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameter: id"
|
||
})
|
||
|
||
if not model_id:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameter: model_id"
|
||
})
|
||
|
||
if not model_type:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameter: model_type"
|
||
})
|
||
|
||
log.info(f"Downloading model: {model_id} (type: {model_type})")
|
||
|
||
# 生成下载ID
|
||
download_id = generate_download_id()
|
||
|
||
# 计算目标目录:优先使用传入的dest_dir,否则使用ComfyUI的models目录下对应类型
|
||
resolved_dest_dir = None
|
||
if dest_dir:
|
||
resolved_dest_dir = os.path.abspath(os.path.expanduser(f"models/{dest_dir}"))
|
||
else:
|
||
try:
|
||
model_type_paths = folder_paths.get_folder_paths(model_type)
|
||
resolved_dest_dir = model_type_paths[0] if model_type_paths else os.path.join(folder_paths.models_dir, model_type)
|
||
except Exception:
|
||
resolved_dest_dir = os.path.join(folder_paths.models_dir, model_type)
|
||
|
||
# 创建进度回调
|
||
progress_callback = DownloadProgressCallback(
|
||
id=id,
|
||
filename=f"{model_id}.{model_type}",
|
||
file_size=0, # 实际大小会在下载过程中获取
|
||
download_id=download_id
|
||
)
|
||
|
||
# 启动下载任务(异步执行)
|
||
async def download_task():
|
||
try:
|
||
# 调用下载方法 - 使用snapshot_download
|
||
from modelscope import snapshot_download
|
||
|
||
# 创建进度回调包装器 - 实现ModelScope期望的接口
|
||
class ProgressWrapper:
|
||
"""Factory that returns a per-file progress object with update/end."""
|
||
def __init__(self, download_id: str):
|
||
self.download_id = download_id
|
||
|
||
def __call__(self, file_name: str, file_size: int):
|
||
# Create a per-file progress tracker expected by ModelScope
|
||
download_id = self.download_id
|
||
|
||
class _PerFileProgress:
|
||
def __init__(self, fname: str, fsize: int):
|
||
self.file_name = fname
|
||
self.file_size = max(int(fsize or 0), 0)
|
||
self.progress = 0
|
||
self.last_update_time = time.time()
|
||
self.last_downloaded = 0
|
||
with download_lock:
|
||
if download_id in download_progress:
|
||
# If unknown size, keep 0 to avoid div-by-zero
|
||
download_progress[download_id]["file_size"] = self.file_size
|
||
|
||
def update(self, size: int):
|
||
try:
|
||
self.progress += int(size or 0)
|
||
now = time.time()
|
||
# Update global progress
|
||
with download_lock:
|
||
if download_id in download_progress:
|
||
dp = download_progress[download_id]
|
||
dp["progress"] = self.progress
|
||
if self.file_size > 0:
|
||
dp["percentage"] = round(self.progress * 100.0 / self.file_size, 2)
|
||
# speed
|
||
elapsed = max(now - self.last_update_time, 1e-6)
|
||
speed = (self.progress - self.last_downloaded) / elapsed
|
||
dp["speed"] = round(speed, 2)
|
||
self.last_update_time = now
|
||
self.last_downloaded = self.progress
|
||
except Exception as e:
|
||
log.error(f"Error in progress update: {e}")
|
||
|
||
def end(self):
|
||
# Called by modelscope when a file finishes
|
||
with download_lock:
|
||
if download_id in download_progress:
|
||
dp = download_progress[download_id]
|
||
if self.file_size > 0:
|
||
dp["progress"] = self.file_size
|
||
dp["percentage"] = 100.0
|
||
|
||
return _PerFileProgress(file_name, file_size)
|
||
|
||
progress_wrapper = ProgressWrapper(download_id)
|
||
|
||
# 添加调试日志
|
||
log.info(f"Starting download with progress wrapper: {download_id}")
|
||
|
||
# 在线程中执行阻塞的下载,避免阻塞事件循环
|
||
from functools import partial
|
||
local_dir = await asyncio.to_thread(
|
||
partial(
|
||
snapshot_download,
|
||
model_id=model_id,
|
||
cache_dir=resolved_dest_dir,
|
||
progress_callbacks=[progress_wrapper]
|
||
)
|
||
)
|
||
|
||
# 下载完成
|
||
progress_callback.end(success=True)
|
||
log.info(f"Model downloaded successfully to: {local_dir}")
|
||
|
||
# 下载后遍历目录,将所有重要权重/资源文件移动到最外层(与目录同级,即 resolved_dest_dir)
|
||
try:
|
||
moved_count = 0
|
||
allowed_exts = {
|
||
".safetensors", ".ckpt", ".pt", ".pth", ".bin"
|
||
# ".msgpack", ".json", ".yaml", ".yml", ".toml", ".png", ".onnx"
|
||
}
|
||
for root, dirs, files in os.walk(local_dir):
|
||
for name in files:
|
||
ext = os.path.splitext(name)[1].lower()
|
||
if ext in allowed_exts:
|
||
src_path = os.path.join(root, name)
|
||
target_dir = resolved_dest_dir
|
||
os.makedirs(target_dir, exist_ok=True)
|
||
target_path = os.path.join(target_dir, name)
|
||
# 如果已经在目标目录则跳过
|
||
if os.path.abspath(os.path.dirname(src_path)) == os.path.abspath(target_dir):
|
||
continue
|
||
# 处理重名情况:自动追加 _1, _2 ...
|
||
if os.path.exists(target_path):
|
||
base, ext_real = os.path.splitext(name)
|
||
idx = 1
|
||
while True:
|
||
candidate = f"{base}_{idx}{ext_real}"
|
||
candidate_path = os.path.join(target_dir, candidate)
|
||
if not os.path.exists(candidate_path):
|
||
target_path = candidate_path
|
||
break
|
||
idx += 1
|
||
shutil.move(src_path, target_path)
|
||
moved_count += 1
|
||
log.info(f"Moved {moved_count} files with extensions {sorted(list(allowed_exts))} to: {resolved_dest_dir}")
|
||
except Exception as move_err:
|
||
log.error(f"Post-download move failed: {move_err}")
|
||
|
||
except Exception as e:
|
||
progress_callback.fail(str(e))
|
||
log.error(f"Download failed: {str(e)}")
|
||
|
||
# 启动异步下载任务
|
||
asyncio.create_task(download_task())
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": {
|
||
"download_id": download_id,
|
||
"id": id,
|
||
"model_id": model_id,
|
||
"model_type": model_type,
|
||
"dest_dir": resolved_dest_dir,
|
||
"status": "started"
|
||
},
|
||
"message": f"Download started for model '{model_id}'"
|
||
})
|
||
|
||
except ImportError as e:
|
||
log.error(f"ModelScope SDK not installed: {str(e)}")
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "ModelScope SDK not installed. Please install with: pip install modelscope"
|
||
})
|
||
|
||
except Exception as e:
|
||
log.error(f"Error starting download: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Failed to start download: {str(e)}"
|
||
})
|
||
|
||
@server.PromptServer.instance.routes.get("/api/download-progress/{download_id}")
|
||
async def get_download_progress(request):
|
||
"""
|
||
Get download progress by download ID
|
||
"""
|
||
download_id = request.match_info.get('download_id')
|
||
|
||
if not download_id:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing download_id parameter"
|
||
})
|
||
|
||
with download_lock:
|
||
progress_info = download_progress.get(download_id)
|
||
|
||
if not progress_info:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Download ID {download_id} not found"
|
||
})
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": progress_info,
|
||
"message": "Download progress retrieved successfully"
|
||
})
|
||
|
||
@server.PromptServer.instance.routes.get("/api/download-progress")
|
||
async def list_downloads(request):
|
||
"""
|
||
List all active downloads
|
||
"""
|
||
with download_lock:
|
||
downloads = list(download_progress.keys())
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": {
|
||
"downloads": downloads,
|
||
"count": len(downloads)
|
||
},
|
||
"message": "Download list retrieved successfully"
|
||
})
|
||
|
||
@server.PromptServer.instance.routes.delete("/api/download-progress/{download_id}")
|
||
async def clear_download_progress(request):
|
||
"""
|
||
Clear download progress record (for cleanup)
|
||
"""
|
||
download_id = request.match_info.get('download_id')
|
||
|
||
if not download_id:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing download_id parameter"
|
||
})
|
||
|
||
with download_lock:
|
||
if download_id in download_progress:
|
||
del download_progress[download_id]
|
||
return web.json_response({
|
||
"success": True,
|
||
"message": f"Download progress {download_id} cleared successfully"
|
||
})
|
||
else:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Download ID {download_id} not found"
|
||
})
|
||
|
||
@server.PromptServer.instance.routes.get("/api/model-searchs")
|
||
async def model_suggests(request):
|
||
"""
|
||
Get model search list by keyword
|
||
"""
|
||
log.info("Received model-search request")
|
||
try:
|
||
keyword = request.query.get('keyword')
|
||
|
||
if not keyword:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameter: keyword"
|
||
})
|
||
|
||
# 创建ModelScope网关实例
|
||
gateway = ModelScopeGateway()
|
||
|
||
suggests = gateway.search(name=keyword)
|
||
|
||
list = suggests["data"] if suggests.get("data") else []
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": {
|
||
"searchs": list,
|
||
"total": len(list)
|
||
},
|
||
"message": f"Get searchs successfully"
|
||
})
|
||
|
||
except ImportError as e:
|
||
log.error(f"ModelScope SDK not installed: {str(e)}")
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "ModelScope SDK not installed. Please install with: pip install modelscope"
|
||
})
|
||
|
||
except Exception as e:
|
||
log.error(f"Error get model searchs: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Get model searchs failed: {str(e)}"
|
||
})
|
||
|
||
@server.PromptServer.instance.routes.get("/api/model-paths")
|
||
async def model_paths(request):
|
||
"""
|
||
Get model paths by type
|
||
"""
|
||
log.info("Received model-paths request")
|
||
try:
|
||
model_paths = list(folder_paths.folder_names_and_paths.keys())
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": {
|
||
"paths": model_paths,
|
||
},
|
||
"message": f"Get paths successfully"
|
||
})
|
||
|
||
except Exception as e:
|
||
log.error(f"Error get model path: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Get model failed: {str(e)}"
|
||
}) |