Files
AIDC-AI-ComfyUI-Copilot/backend/controller/conversation_api.py
T
2025-06-24 20:09:15 +08:00

566 lines
21 KiB
Python

# Copyright (C) 2025 AIDC-AI
# Licensed under the MIT License.
import json
import os
import asyncio
import time
from typing import Optional, Dict, Any, TypedDict, List, Union
import server
from aiohttp import web
import aiohttp
import base64
import requests
# Import the MCP client function
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'service'))
try:
# Try importing with underscore (Python module naming convention)
from mcp_client import comfyui_agent_invoke, ImageData
except ImportError:
try:
# Try importing from actual filename mcp-client.py
import importlib.util
mcp_client_path = os.path.join(os.path.dirname(__file__), '..', 'service', 'mcp-client.py')
spec = importlib.util.spec_from_file_location("mcp_client", mcp_client_path)
mcp_client_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mcp_client_module)
comfyui_agent_invoke = mcp_client_module.comfyui_agent_invoke
ImageData = mcp_client_module.ImageData
except Exception as e:
print(f"Warning: Could not import comfyui_agent_invoke: {e}, using mock implementation")
async def comfyui_agent_invoke(prompt, images=None, config=None):
yield f"Mock response for: {prompt}"
class ImageData:
def __init__(self, filename: str, data: str, url: str = None):
self.filename = filename
self.data = data
self.url = url
# 不再使用内存存储会话消息,改为从前端传递历史消息
# 在文件开头添加
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]] # 扩展信息
def get_workflow_templates():
templates = []
workflows_dir = os.path.join(STATIC_DIR, "workflows")
for filename in os.listdir(workflows_dir):
if filename.endswith('.json'):
with open(os.path.join(workflows_dir, filename), 'r') as f:
template = json.load(f)
templates.append(template)
return templates
@server.PromptServer.instance.routes.post("/workspace/workflow_gen")
async def workflow_gen(request):
print("Received workflow_gen request")
req_json = await request.json()
print("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')
user_message = req_json.get('message')
# Create user message
user_msg = {
"id": str(len(session_messages.get(session_id, []))),
"content": user_message,
"role": "user"
}
if "workflow" in user_message.lower():
workflow = {
"name": "basic_image_gen",
"description": "Create a basic image generation workflow",
"image": "https://placehold.co/600x400",
"workflow": """{ ... }""" # Your workflow JSON here
}
chat_response = ChatResponse(
session_id=session_id,
text="",
finished=False,
type="workflow_option",
format="text",
ext=[{"type": "workflows", "data": [workflow]}]
)
await response.write(json.dumps(chat_response).encode() + b"\n")
message = "Let me help you choose a workflow. Here are some options available:"
accumulated = ""
for char in message:
accumulated += char
chat_response["text"] = accumulated
await response.write(json.dumps(chat_response).encode() + b"\n")
await asyncio.sleep(0.01)
chat_response["finished"] = True
chat_response["text"] = message
await response.write(json.dumps(chat_response).encode() + b"\n")
elif "recommend" in user_message.lower():
existing_nodes = [
{
"name": "LoraLoader",
"description": "Load LoRA weights for conditioning.",
"image": "",
"github_url": "https://github.com/CompVis/taming-transformers",
"from_index": 0,
"to_index": 0
},
{
"name": "KSampler",
"description": "Generate images using K-diffusion sampling.",
"image": "",
"github_url": "https://github.com/CompVis/taming-transformers",
"from_index": 0,
"to_index": 0
}
]
missing_nodes = [
{
"name": "CLIPTextEncode",
"description": "Encode text prompts for conditioning.",
"image": "",
"github_url": "https://github.com/CompVis/clip-interrogator",
"from_index": 0,
"to_index": 0
}
]
node_info = {
"existing_nodes": existing_nodes,
"missing_nodes": missing_nodes
}
chat_response = ChatResponse(
session_id=session_id,
text="",
finished=False,
type="downstream_node_recommend",
format="text",
ext=[{"type": "node_info", "data": node_info}]
)
await response.write(json.dumps(chat_response).encode() + b"\n")
message = "Here are some recommended nodes:"
accumulated = ""
for char in message:
accumulated += char
chat_response["text"] = accumulated
await response.write(json.dumps(chat_response).encode() + b"\n")
await asyncio.sleep(0.01)
chat_response["finished"] = True
chat_response["text"] = message
await response.write(json.dumps(chat_response).encode() + b"\n")
else:
chat_response = ChatResponse(
session_id=session_id,
text="",
finished=False,
type="message",
format="text",
ext=[{"type": "guides", "data": ["Create a workflow", "Search for nodes", "Get node recommendations"]}]
)
await response.write(json.dumps(chat_response).encode() + b"\n")
message = "I can help you with workflows, nodes, and more. Try asking about:"
accumulated = ""
for char in message:
accumulated += char
chat_response["text"] = accumulated
await response.write(json.dumps(chat_response).encode() + b"\n")
await asyncio.sleep(0.01)
chat_response["finished"] = True
chat_response["text"] = message
await response.write(json.dumps(chat_response).encode() + b"\n")
# 不再使用内存存储,前端负责消息存储
await response.write_eof()
return response
# 删除fetch_messages相关方法,消息由前端IndexedDB管理
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:
print(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}"
@server.PromptServer.instance.routes.get("/verify_openai_key")
async def verify_openai_key(req):
"""
Verify if an OpenAI API key is valid by calling the OpenAI models endpoint
Returns:
JSON response with success status and message
"""
try:
openai_api_key = req.headers.get('Openai-Api-Key')
openai_base_url = req.headers.get('Openai-Base-Url', 'https://api.openai.com/v1')
if not openai_api_key:
return web.json_response({
"success": False,
"message": "No API key provided"
})
# Use a direct HTTP request instead of the OpenAI client
# This gives us more control over the request method and error handling
headers = {
"Authorization": f"Bearer {openai_api_key}"
}
# Make a simple GET request to the models endpoint
response = requests.get(f"{openai_base_url}/models", headers=headers)
# Check if the request was successful
if response.status_code == 200:
return web.json_response({
"success": True,
"data": True,
"message": "API key is valid"
})
else:
print(f"API key validation failed with status code: {response.status_code}")
return web.json_response({
"success": False,
"data": False,
"message": f"Invalid API key: HTTP {response.status_code} - {response.text}"
})
except Exception as e:
print(f"Error verifying OpenAI API key: {str(e)}")
return web.json_response({
"success": False,
"data": False,
"message": f"Invalid API key: {str(e)}"
})
@server.PromptServer.instance.routes.post("/api/chat/invoke")
async def invoke_chat(request):
print("Received invoke_chat request")
req_json = await request.json()
print("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', [])
# Process images and upload to OSS (similar to reference implementation)
processed_images = []
if images and len(images) > 0:
print(f"Processing {len(images)} images")
for image in images:
try:
filename = image.get('filename', 'uploaded_image.jpg')
image_data = image.get('data', '')
# Extract base64 data after the comma if it's a data URL
if image_data.startswith('data:'):
image_data = image_data.split(',')[1]
# Decode base64 and upload to OSS
image_bytes = base64.b64decode(image_data.encode('utf-8'))
image_url = await upload_to_oss(image_bytes, filename)
# Create ImageData object similar to reference implementation
processed_image = ImageData(
filename=filename,
data=f"data:image/jpeg;base64,{image_data}", # Keep original base64 for fallback
url=image_url
)
processed_images.append(processed_image)
print(f"Processed image: {filename} -> {image_url}")
except Exception as e:
print(f"Error processing image {image.get('filename', 'unknown')}: {str(e)}")
# Continue with other images even if one fails
# 历史消息已经从前端传递过来,格式为OpenAI格式,直接使用
print(f"-- Received {len(historical_messages)} historical messages")
# Add current user message to OpenAI format
current_user_message = {"role": "user", "content": prompt}
# For current message with images, format according to OpenAI multimodal format
if processed_images and len(processed_images) > 0:
content = [{"type": "text", "text": prompt}]
for image in processed_images:
# Use image URL if available (uploaded to OSS), otherwise use base64 data
image_url = image.url if image.url else image.data
content.append({
"type": "image_url",
"image_url": {"url": image_url}
})
current_user_message["content"] = content
# Add current message to historical messages for MCP call
openai_messages = historical_messages + [current_user_message]
# 不再需要创建用户消息存储到后端,前端负责消息存储
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
has_sent_response = False
previous_text_length = 0
config = {
"openai_api_key": request.headers.get('Openai-Api-Key'),
"openai_base_url": request.headers.get('Openai-Base-Url'),
"model_select": next((x['data'][0] for x in ext if x['type'] == 'model_select' and x.get('data')), None)
}
print(f"config: {config}")
# Pass messages in OpenAI format and processed images to comfyui_agent_invoke
async for result in comfyui_agent_invoke(openai_messages, processed_images if processed_images else None, config):
# The MCP client now returns tuples (text, ext) similar to facade.py
if isinstance(result, tuple) and len(result) == 2:
text, ext = result
if text:
accumulated_text = text # text from MCP is already accumulated
print(f"-- Received text update, length: {len(accumulated_text)}")
if ext:
ext_data = ext
print(f"-- Received ext data: {ext_data}")
else:
# Handle single text chunk (backward compatibility)
text_chunk = result
if text_chunk:
accumulated_text += text_chunk
print(f"-- Received text chunk: '{text_chunk}', total length: {len(accumulated_text)}")
# Send streaming response if we have new text content
# Send every time we get new text, regardless of ext_data status
if accumulated_text and len(accumulated_text) > previous_text_length:
print(f"-- Sending stream response: {len(accumulated_text)} chars (previous: {previous_text_length})")
chat_response = ChatResponse(
session_id=session_id,
text=accumulated_text,
finished=False,
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 ext data (similar to facade.py final yield)
print(f"-- Sending final response: {len(accumulated_text)} chars, ext: {bool(ext_data)}")
final_response = ChatResponse(
session_id=session_id,
text=accumulated_text,
finished=True,
type="message",
format="markdown",
ext=ext_data
)
await response.write(json.dumps(final_response).encode() + b"\n")
# AI响应不再存储到后端,前端负责消息存储
except Exception as e:
print(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,
type="message",
format="text",
ext=None
)
await response.write(json.dumps(error_response).encode() + b"\n")
await response.write_eof()
return response
# Import debug agent
try:
from debug_agent import debug_workflow_errors
except ImportError:
try:
import importlib.util
debug_agent_path = os.path.join(os.path.dirname(__file__), '..', 'service', 'debug_agent.py')
spec = importlib.util.spec_from_file_location("debug_agent", debug_agent_path)
debug_agent_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(debug_agent_module)
debug_workflow_errors = debug_agent_module.debug_workflow_errors
except Exception as e:
print(f"Warning: Could not import debug_workflow_errors: {e}")
async def debug_workflow_errors(error_data, workflow_data, config=None):
yield ("I apologize, but the debug agent is not available at the moment.", None)
@server.PromptServer.instance.routes.post("/api/debug-agent")
async def debug_agent_endpoint(request):
"""
Debug agent endpoint for analyzing ComfyUI workflow errors
"""
print("Received debug-agent request")
req_json = await request.json()
print("Debug request JSON:", req_json)
response = web.StreamResponse(
status=200,
reason='OK',
headers={
'Content-Type': 'text/plain; charset=utf-8',
'X-Content-Type-Options': 'nosniff'
}
)
await response.prepare(request)
error_data = req_json.get('error_data')
workflow_data = req_json.get('workflow_data')
if not error_data or not workflow_data:
await response.write(b"data: {\"error\": \"Missing error_data or workflow_data\"}\n\n")
await response.write_eof()
return response
try:
# Get config from headers if available
config = {
"openai_api_key": request.headers.get('Openai-Api-Key'),
"openai_base_url": request.headers.get('Openai-Base-Url'),
"model_select": request.headers.get('Model-Select')
}
print(f"Debug agent config: {config}")
# Call debug agent with streaming response
async for result in debug_workflow_errors(error_data, workflow_data, config):
# The debug agent returns tuples (text, ext) similar to MCP client
if isinstance(result, tuple) and len(result) == 2:
text, ext = result
response_data = {
"text": text,
"ext": ext
}
await response.write(f"data: {json.dumps(response_data)}\n\n".encode())
else:
# Handle single text (backward compatibility)
response_data = {
"text": result,
"ext": None
}
await response.write(f"data: {json.dumps(response_data)}\n\n".encode())
except Exception as e:
print(f"Error in debug_agent_endpoint: {str(e)}")
error_response = {
"error": f"Debug agent error: {str(e)}"
}
await response.write(f"data: {json.dumps(error_response)}\n\n".encode())
await response.write_eof()
return response