Files
AIDC-AI-ComfyUI-Copilot/backend/reference.md
T
2025-06-23 15:51:50 +08:00

24 KiB

我需要参考另外一个工程的代码,来实现我当前的后端能力,当前的工程是个ComfyUI的插件,我在这里实现了一个mcp_client,来替代掉原来的workflowChatApi.ts里的${BASE_URL}/api/chat/invoke接口。

核心代码facade.py实现了agent-tools的能力:

from typing import Optional, Generator, Dict
import json
import uuid
from agent.tools.node_explain_agent_tool import explain_node
from agent.tools.node_search_tool import search_node
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import create_react_agent
from langgraph.checkpoint.postgres import PostgresSaver
from context import get_user_id
from logger import logger
from psycopg_pool import ConnectionPool
from agent.llms import llm_4_chatbot, llm_4o, llm_deepseek, llm_4o_mini, llm_dashscope, llm_volcengine, get_openai_client, llm_dashscope_deepseek_v3, llm_qwen3, llm_4_1
from agent.tools.workflow_recall_tool import recall_workflow, generate_workflow_step2
from agent.tools.model_search_tool import search_model
from initializer.db_initializer import Session, db_url
from utils.os_util import get_root_path
from dao.message_dao import Message, MessageSource
from dao.node_dao import Node
from dao.workflow_dao import Workflow

tools = [
    recall_workflow,  # 工作流召回
    search_node,  # 节点搜索
    search_model,  # 模型召回
    explain_node,  # 节点解释
    # analyze_workflow,  # 工作流分析
]
system_prompt_path = get_root_path('prompts/system_prompt.txt')
with open(system_prompt_path, 'r', encoding='utf-8') as f:
    system_prompt_content = f.read()
new_system_prompt = SystemMessage(content=system_prompt_content)

connection_kwargs = {
    "autocommit": True,
    "prepare_threshold": 0,
}

pool = ConnectionPool(db_url, max_size=50, kwargs=connection_kwargs)
checkpointer = PostgresSaver(pool)
checkpointer.setup()


# 修改 LLM 映射字典,使用正确的配置方式
llm_config: Dict = {
    "gpt-4o": {
        "client":llm_4_1,
        "image_enable":True,
    },
    "gpt-4.1": {
        "client":llm_4_1,
        "image_enable":True,
    },
    "gpt-4o-mini": {
        "client":llm_4o_mini,
        "image_enable":True,
    },
    "DeepSeek-V3": {
        "client":llm_deepseek,
        "image_enable":False,
    },
    "qwen-plus": {
        "client":llm_dashscope,
        "image_enable":False,
    }
}

llm_mapping = {k: llm_config[k]["client"] for k in llm_config}


# 获取所有llm列表
def get_model_list():
    model_list = []
    remove_models = ["gpt-4o"]
    for model, config in llm_config.items():
        if model not in remove_models:
            model_list.append({
                "name": model,
                "image_enable": config["image_enable"],
            })
    return model_list

# 创建一个函数用于限制对话历史
def limit_conversation_history(state, max_turns=5):
    """
    限制对话历史只保留最近的几轮对话
    
    Args:
        state: 当前的状态对象
        max_turns: 保留的最大对话轮数
    
    Returns:
        修改后的状态
    """
    messages = state.get("messages", [])
    system_messages = []
    conversation_messages = []
    
    # 分离系统消息和对话消息
    for msg in messages:
        if isinstance(msg, SystemMessage):
            system_messages.append(msg)
        else:
            conversation_messages.append(msg)
    
    # 保留对话消息的最后max_turns*2条(每轮包含用户消息和AI回复)
    limited_conversation = conversation_messages[-max_turns*2:] if len(conversation_messages) > max_turns*2 else conversation_messages
    
    # 重新组合消息
    state["messages"] = system_messages + limited_conversation
    return state

# 将系统提示和历史限制组合在一起
def combined_state_modifier(state):
    """
    组合状态修改器,添加系统提示并限制对话历史
    
    Args:
        state: 当前的状态对象
    
    Returns:
        修改后的消息列表
    """
    messages = state.get("messages", [])
    
    # 分离系统消息和对话消息
    system_messages = []
    conversation_messages = []
    
    for msg in messages:
        if isinstance(msg, SystemMessage):
            system_messages.append(msg)
        else:
            conversation_messages.append(msg)
    
    # 如果没有系统消息,添加系统提示
    if not system_messages and isinstance(new_system_prompt, SystemMessage):
        system_messages = [new_system_prompt]
    
    # 保留对话消息的最后5轮(每轮包含用户消息和AI回复)
    max_turns = 10
    limited_conversation = conversation_messages[-max_turns*2:] if len(conversation_messages) > max_turns*2 else conversation_messages
    
    # 返回系统消息和限制后的对话消息的组合
    return system_messages + limited_conversation

# 创建基础 agent 时使用 combined_state_modifier
agent_executor = create_react_agent(
    llm_4o,
    tools,
    checkpointer=checkpointer,
    state_modifier=combined_state_modifier,
)


def is_return_direct_tool(tool_name: str) -> bool:
    return any([tool_name == t.name and t.return_direct for t in tools])


def is_json_str(value: str) -> bool:
    try:
        json.loads(value)
        return True
    except ValueError:
        return False


def chat(
        query: str,
        config: Optional[RunnableConfig] = None,
        session_id: str=None,
        debug: bool = False,
) -> (Generator[tuple[str, any], None, None]):
    logger.info(f"agent query: {query}")

    assert session_id is not None, "session_id is required"
    common_info = {
        "user_id": get_user_id() or -1,
        "session_id": session_id,
        "sub_session_id": str(uuid.uuid4()).replace('-', ''),
    }

    with Session() as session:
        Message.create(session, **common_info, source=MessageSource.USER.value, content=query)

    if config and config.get("images"):
        images = config["images"]
        content = [{"type": "text", "text": query}]
        for image in images:
            content.append({
                "type": "image_url",
                "image_url": {"url": image.data}
            })
        agent_input = {"messages": [HumanMessage(content=content)]}
    else:
        agent_input = {"messages": [HumanMessage(content=query)]}

    # 检查自定义 OpenAI 凭据
    custom_api_key = config.get("openai_api_key") if config else None
    custom_base_url = config.get("openai_base_url") if config else None

    # 创建 LLM 映射
    if custom_api_key:
        # 使用客户端管理器获取客户端
        custom_llm_mapping = {
            "gpt-4o": get_openai_client("gpt-4o", custom_api_key, custom_base_url),
            "gpt-4o-mini": get_openai_client("gpt-4o-mini", custom_api_key, custom_base_url),
        }
        # 对于其他模型,使用默认实例
        custom_llm_mapping.update({
            "DeepSeek-V3": llm_deepseek,
            "qwen-plus": llm_dashscope
        })
    else:
        custom_llm_mapping = llm_mapping

    # 根据 config 中的 model 参数选择 LLM
    if config and config.get("model") and config["model"] in custom_llm_mapping:
        selected_llm = custom_llm_mapping[config["model"]]
        agent = create_react_agent(selected_llm, tools, checkpointer=checkpointer, state_modifier=combined_state_modifier)
    else:
        agent = agent_executor
    agent.step_timeout = 60  # 将单步超时时间增加到60秒

    try:
        agent_response = agent.stream(agent_input, config, stream_mode="messages")

        current_text = ''
        # data = None
        ext = None
        recall_workflow_data = None
        
        for chunk, _ in agent_response:
            content = chunk.content
            chunk_type = chunk.type
            if not content or not chunk_type:
                continue

            if chunk_type == 'tool':
                tool_name = chunk.name
                attributes = {
                    "tool_name": tool_name,
                }
                with Session() as session:
                    Message.create(session, **common_info, source=MessageSource.TOOL.value, content=content, attributes=attributes)
            
                if is_json_str(content):
                    content_dict = json.loads(content)
                    answer = content_dict.get("answer")
                    data = content_dict.get("data")
                    ext = content_dict.get("ext")
                    
                    # 检查是否是 recall_workflow 的第一步响应
                    step = content_dict.get("step")
                    if tool_name == "recall_workflow" and step == 1:
                        # 保存第一步数据以便后续处理
                        recall_workflow_data = {
                            "instruction": content_dict.get("instruction"),
                            "keywords": content_dict.get("keywords"),
                            "workflows": data
                        }
                        
                else:
                    answer = content
                    data = None
                    ext = None

                if is_return_direct_tool(chunk.name):
                    logger.info(f"agent response answer: {answer}")
                    if ext:
                        logger.info(f"agent response ext: {ext}")
                    yield answer, ext
                    return
            elif chunk_type == 'AIMessageChunk':
                current_text += content
                if not debug:
                    yield current_text, None

        # 处理完第一步后,如果有 recall_workflow 的数据,执行第二步
        if recall_workflow_data:
            logger.info("Executing step 2 of recall_workflow")
            workflow_values = []
            with Session() as session:
                # 获取原始工作流数据
                if recall_workflow_data["workflows"]:
                    workflow_ids = [w.get("id") for w in recall_workflow_data["workflows"]]
                    workflow_dos = Workflow.query_workflow_by_ids(session, workflow_ids)
                    workflow_values = [workflow_do.to_dict().get('workflow') for workflow_do in workflow_dos]
            
            # 执行步骤二:生成工作流
            if workflow_values:
                try:
                    step2_result = generate_workflow_step2(
                        recall_workflow_data["instruction"],
                        recall_workflow_data["keywords"],
                        workflow_values
                    )
                    
                    # 将步骤二的结果添加到原有数据中
                    if recall_workflow_data["workflows"]:
                        recall_workflow_data["workflows"].append(step2_result)
                    else:
                        recall_workflow_data["workflows"] = [step2_result]
                except Exception as e:
                    logger.error(f"Error in step 2 of recall_workflow: {str(e)}", exc_info=True)
                    # 如果步骤2执行过程中报错,就直接使用原始workflows
                
                # 创建新的ext数据
                if ext and isinstance(ext, list):
                    for item in ext:
                        if item.get("type") == "workflow":
                            item["data"] = recall_workflow_data["workflows"]
                
                # 返回完整结果
                yield current_text, ext

        with Session() as session:
            filtered_images = []
            if config and config.get("images"):
                filtered_images = [{k: v for k, v in img.dict().items() if k != 'data'} for img in config["images"]] if config["images"] else []
            Message.create(session, **common_info, source=MessageSource.AI.value, content=current_text, attributes={"ext": ext, "images": filtered_images})

        logger.info(f"agent response answer: {current_text}")
        if ext:
            logger.info(f"agent response ext: {ext}")
        yield current_text, ext
    except TimeoutError:
        logger.error("Agent execution timed out", exc_info=True)
        if config and config.get("model") and config["model"] == "DeepSeek-V3":
            yield "I apologize, but our DeepSeek service provider is currently unstable. Please try using GPT-4 instead.", None
        else:
            yield "I apologize, but the request timed out. Please try again or rephrase your question.", None
        return
    except Exception as e:
        logger.error(f"Agent execution failed: {str(e)}", exc_info=True)
        yield f"I apologize, but an error occurred: {str(e)}", None
        return

为了能跟理解原始的架构,展示一recall_workflow的代码:

from hashlib import md5
import json
from typing import List, Dict, Any

from langchain_core.tools import tool
from pydantic import BaseModel, Field

from agent.tools.tool_funcs.recall_workflow import query_workflows_from_db, generate_workflow_step2, summary
from logger import logger

class RecallWorkflowParamSchema(BaseModel):
    name: str = Field(
        description='''
        Extract the core intent, key technical terms from user input.
        ''')
    description: str = Field(
        description='''
        Concisely describe the user's core requirements, no extra words, including:
        1. Performance requirements (e.g. low VRAM, fast generation)
        2. Source preferences (e.g. official workflows)
        3. Technical specifications and constraints
        4. Model requirements
        ''')

    keywords: List[str] = Field(
        description='''
        Extract all core keywords from user input, make keywords shorter.
        Do not include too generic keywords, like "workflow"|"model"|"apply"|"工作流"|"模型".
        ''')

    instruction: str = Field(
        description='''
    Convert the user's question into a non-technical problem statement from a user-centric perspective, reply use English.
    It articulates the desired functionality or outcome the user wishes to achieve, often in layman terms, 
    without specifying technical or implementation details. The instruction should:
    1. State a concrete creative or computational goal.
    2. Specify input requirements (e.g., "a photo of my cat").
    3. Define desired output characteristics (e.g., "watercolor painting style").
    4. Avoid implementation terms such as nodes, models, or parameters.
    5. Be phrased as a natural language request rather than a technical specification.
    Example: Build a workflow that turns my portrait photos into vintage comic book art. It should preserve facial features accurately while applying bold ink outlines and halftone shading effects.
        ''')

@tool(args_schema=RecallWorkflowParamSchema, return_direct=False)
def recall_workflow(name: str, description: str, keywords: List[str], instruction: str) -> str:
    """
    Recall_workflow Tool is designed to select the most suitable workflow from a pool of candidate workflows based on user requirements.
    Call this tool when the user's intent is to want a workflow or to generate an image with specific requirements.
    Respond in the language used by the user in their question.
    For each workflow, only one representative image will be returned to avoid excessive image data.
    Must add a respond to the last: "You can choose from the recommended workflows above, or let AI generate a personalized solution based on your input (approximately 20 seconds, please wait). If generation fails, the new solution will be automatically hidden."
    """
    # keywords = ['LTX', 'Videos', '高质量']
    logger.info(f"recall_workflow args: {name}| {description} | {keywords}")
    
    # 步骤一:召回工作流
    workflows = query_workflows_from_db(f'{name} | {description}', keywords)
    answer = summary(workflows)

    # Format the response
    if workflows:
        workflow_list = [{
            "type": "workflow",
            "data": workflows
        }]
        ext_items = workflow_list
    else:
        ext_items = None

    return json.dumps({
        "answer": answer,
        "data": workflows,
        "ext": ext_items,
        "instruction": instruction,
        "keywords": keywords,
        "step": 1
    }, ensure_ascii=False)

# 原来的函数内容已经移到 tool_funcs/recall_workflow.py
# 这里只保留对tool的定义和调用

# 导出函数以保持与facade.py的兼容性
__all__ = ['recall_workflow', 'generate_workflow_step2']

接口实现层如下:

import base64
from typing import List, Generator
from agent import facade
from agent.intent_call import intent_call
from agent.tools.workflow_param_optimize2 import get_optimized_params
from fastapi import APIRouter
from starlette.requests import Request
from starlette.responses import StreamingResponse
import time

from base.core import app
from logger import logger

from base.result import Result

from model.chat import *
from agent.facade import chat
from utils import oss_util, crypto_util
import requests
import openai
from dao.track_dao import Track
from initializer.db_initializer import Session

router = APIRouter()
# conversations: dict[str, any] = {}


@router.post('/invoke')
def invoke_post(request: ChatRequest, req: Request):
    return _handle_invoke(request, req)


@router.get('/invoke')
def invoke_get(req: Request):
    request = ChatRequest(**req.query_params)
    return _handle_invoke(request, req)


def _handle_invoke(request: ChatRequest, req: Request):
    # Extract headers
    accept_language = req.headers.get('Accept-Language')
    openai_base_url = req.headers.get('Openai-Base-Url')
    encrypted_api_key = req.headers.get('Encrypted-Openai-Api-Key')
    model = req.headers.get('Model')
    user_type = getattr(req.state, 'user_type', None)
    
    if user_type == "new":
        def new_user_response_generator():
            if accept_language == "zh-CN":
                text = """
您好!我是ComfyUI-Copilot,您的AI工作流助手。我可以帮您快速上手ComfyUI、提高工作流调优效率,包括:

- 创建ComfyUI工作流、提供工作流模板和示例;
- 提供节点使用建议和最佳实践
- 解答ComfyUI相关问题
- 帮助调试和优化现有工作流
- 查询相关模型信息等

请告诉我你需要什么帮助,我会尽我所能协助你完成ComfyUI相关任务。
第一次访问我们的服务请访问[链接](https://form.typeform.com/to/tkg91K8D)完成Key申请(免费),并点击⚙️配置Key。
                """
            else:
                text = """Hello! I'm ComfyUI-Copilot, your AI workflow assistant. I can help you quickly get started with ComfyUI and improve your workflow optimization efficiency, including:

- Creating ComfyUI workflows and providing workflow templates and examples
- Offering node usage recommendations and best practices
- Answering ComfyUI-related questions
- Helping debug and optimize existing workflows
- Querying relevant model information

Please let me know what assistance you need, and I'll do my best to help you with your ComfyUI-related tasks.

For first-time visitors, please visit [link](https://form.typeform.com/to/tkg91K8D) to complete your Key application (free), and click ⚙️ to configure your Key.
                """
            
            response = ChatResponse(
                session_id=request.session_id,
                text=text,
                format="markdown",
                finished=True
            )
            yield response.model_dump_json()
            yield '\n'
        
        return StreamingResponse(new_user_response_generator())
    
    if user_type == "unregister":
        def unregister_response_generator():
            if accept_language == "zh-CN":
                text = "ComfyUI-Copilot服务升级,请访问[链接](https://form.typeform.com/to/tkg91K8D)输入邮箱完成Key申请(免费),并点击⚙️配置Key。若原先已有Key,需要修改成邮箱新收到的Key。"
            else:
                text = "ComfyUI-Copilot service upgrade, please visit [link](https://form.typeform.com/to/tkg91K8D) to input your email and complete your Key application (free), and click 🔨 to configure your Key. If you already have a Key, you need to modify it to the new Key."
            
            response = ChatResponse(
                session_id=request.session_id,
                text=text,
                format="markdown",
                finished=True
            )
            yield response.model_dump_json()
            yield '\n'
        
        return StreamingResponse(unregister_response_generator())
    
    # Build config
    config = {}
    if accept_language:
        config["language"] = accept_language
    if openai_base_url:
        config["openai_base_url"] = openai_base_url
    
    # Handle encrypted API key if provided
    if encrypted_api_key:
        try:
            openai_api_key = crypto_util.decrypt_with_private_key(encrypted_api_key)
            logger.info("Successfully decrypted OpenAI API key")
            config["openai_api_key"] = openai_api_key
        except Exception as e:
            logger.error(f"Failed to decrypt OpenAI API key: {str(e)}")
            # Continue with potentially None openai_api_key
    
    if model:
        config["model"] = model
        
    return do_invoke(request, config)


def do_invoke(request: ChatRequest, params: dict = None) -> StreamingResponse:
    logger.info(f"chat request: {request.model_dump_json()}")

    session_id = request.session_id
    prompt = request.prompt

    if request.mock:
        return StreamingResponse(mock_response(session_id))
    
    if not request.prompt or request.prompt.strip() == "":
        return StreamingResponse(iter([]))

    func_chat = chat
    config = {
        "configurable": {
            "thread_id": session_id,
        }
    }
    
    # Add language from Accept-Language header if available
    if params:
        config.update(params)
    
    if request.images and len(request.images) > 0:
        for image in request.images:
            image_data = image.data
            # Extract base64 data after the comma if it's a data URL
            if image_data.startswith('data:'):
                image_data = image_data.split(',')[1]
            image_data = image_data.encode('utf-8')
            image_data = base64.b64decode(image_data)
            image_url = oss_util.upload_data(image_data, image.filename)
            image.url = image_url
        config["images"] = request.images
    
    if request.ext and isinstance(request.ext, list):
        for ext_item in request.ext:
            if ext_item.type == "model_select" and len(ext_item.data) > 0:
                config["model"] = ext_item.data[0]
    
    if request.intent and request.intent != "":
        config = {"intent": request.intent, "ext": request.ext, "language": config.get("language", "zh-CN")}
        func_chat = intent_call

    def response_generator():
        last_text = None
        ext = None
        for text, ext in func_chat(prompt, config, session_id=session_id):
            response = ChatResponse(
                session_id=session_id,
                text=text,
                finished=ext is not None
            )
            
            # If we have ext information, convert it to list[ExtItem] if needed
            if ext:
                if isinstance(ext, dict):
                    ext = [ExtItem(**ext)]
                elif isinstance(ext, list):
                    ext = [item if isinstance(item, ExtItem) else ExtItem(**item) for item in ext]
                response.ext = ext

            last_text = text
            yield response.model_dump_json()
            yield '\n'

        # Send final response with finished=True if we haven't already
        if not ext:
            final_response = ChatResponse(
                session_id=session_id,
                text=last_text,
                finished=True
            )
            yield final_response.model_dump_json()
            yield '\n'

    return StreamingResponse(response_generator())