diff --git a/apps/application/chat_pipeline/I_base_chat_pipeline.py b/apps/application/chat_pipeline/I_base_chat_pipeline.py deleted file mode 100644 index f231c2c4514..00000000000 --- a/apps/application/chat_pipeline/I_base_chat_pipeline.py +++ /dev/null @@ -1,185 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: I_base_chat_pipeline.py - @date:2024/1/9 17:25 - @desc: -""" -import time -from abc import abstractmethod -from typing import Type -import uuid_utils.compat as uuid -from rest_framework import serializers - -from knowledge.models import Paragraph - - -class ParagraphPipelineModel: - - def __init__(self, _id: str, document_id: str, knowledge_id: str, content: str, title: str, status: str, - is_active: bool, comprehensive_score: float, similarity: float, knowledge_name: str, - document_name: str, - hit_handling_method: str, directly_return_similarity: float, knowledge_type, meta: dict = None): - self.id = _id - self.document_id = document_id - self.knowledge_id = knowledge_id - self.content = content - self.title = title - self.status = status - self.is_active = is_active - self.comprehensive_score = comprehensive_score - self.similarity = similarity - self.knowledge_name = knowledge_name - self.document_name = document_name - self.hit_handling_method = hit_handling_method - self.directly_return_similarity = directly_return_similarity - self.meta = meta - self.knowledge_type = knowledge_type - - def to_dict(self): - return { - 'id': self.id, - 'document_id': self.document_id, - 'knowledge_id': self.knowledge_id, - 'content': self.content, - 'title': self.title, - 'status': self.status, - 'is_active': self.is_active, - 'comprehensive_score': self.comprehensive_score, - 'similarity': self.similarity, - 'knowledge_name': self.knowledge_name, - 'document_name': self.document_name, - 'knowledge_type': self.knowledge_type, - 'meta': self.meta, - } - - class builder: - def __init__(self): - self.similarity = None - self.paragraph = {} - self.comprehensive_score = None - self.document_name = None - self.knowledge_name = None - self.knowledge_type = None - self.hit_handling_method = None - self.directly_return_similarity = 0.9 - self.meta = {} - - def add_paragraph(self, paragraph): - if isinstance(paragraph, Paragraph): - self.paragraph = {'id': paragraph.id, - 'document_id': paragraph.document_id, - 'knowledge_id': paragraph.knowledge_id, - 'content': paragraph.content, - 'title': paragraph.title, - 'status': paragraph.status, - 'is_active': paragraph.is_active, - } - else: - self.paragraph = paragraph - return self - - def add_knowledge_name(self, knowledge_name): - self.knowledge_name = knowledge_name - return self - - def add_knowledge_type(self, knowledge_type): - self.knowledge_type = knowledge_type - return self - - def add_document_name(self, document_name): - self.document_name = document_name - return self - - def add_hit_handling_method(self, hit_handling_method): - self.hit_handling_method = hit_handling_method - return self - - def add_directly_return_similarity(self, directly_return_similarity): - self.directly_return_similarity = directly_return_similarity - return self - - def add_comprehensive_score(self, comprehensive_score: float): - self.comprehensive_score = comprehensive_score - return self - - def add_similarity(self, similarity: float): - self.similarity = similarity - return self - - def add_meta(self, meta: dict): - self.meta = meta - return self - - def build(self): - return ParagraphPipelineModel(str(self.paragraph.get('id')), str(self.paragraph.get('document_id')), - str(self.paragraph.get('knowledge_id')), - self.paragraph.get('content'), self.paragraph.get('title'), - self.paragraph.get('status'), - self.paragraph.get('is_active'), - self.comprehensive_score, self.similarity, self.knowledge_name, - self.document_name, self.hit_handling_method, self.directly_return_similarity, - self.knowledge_type, - self.meta) - - -class IBaseChatPipelineStep: - def __init__(self): - # 当前步骤上下文,用于存储当前步骤信息 - self.context = {} - self.status = 200 - self.err_message = '' - - @abstractmethod - def get_step_serializer(self, manage) -> Type[serializers.Serializer]: - pass - - def valid_args(self, manage): - step_serializer_clazz = self.get_step_serializer(manage) - step_serializer = step_serializer_clazz(data=manage.context) - step_serializer.is_valid(raise_exception=True) - self.context['step_args'] = step_serializer.data - - def run(self, manage): - """ - - :param manage: 步骤管理器 - :return: 执行结果 - """ - try: - start_time = time.time() - self.context['start_time'] = start_time - # 校验参数, - self.valid_args(manage) - self._run(manage) - self.context['run_time'] = time.time() - start_time - except Exception as e: - self.err_message = str(e) - self.status = 500 - chat_record_id = manage.context.get('chat_record_id') or str(uuid.uuid7()) - manage.context['message_tokens'] = 0 - manage.context['answer_tokens'] = 0 - end_time = time.time() - manage.context['run_time'] = end_time - (manage.context.get('start_time') or end_time) - post_response_handler = manage.context.get('post_response_handler') - post_response_handler.handler(manage.context.get('chat_id'), chat_record_id, - manage.context.get('paragraph_list') or [], - manage.context.get('problem_text'), - str(e), manage, self, manage.context.get('padding_problem_text'), - reasoning_content='') - - raise e - - def _run(self, manage): - pass - - def execute(self, **kwargs): - pass - - def get_details(self, manage, **kwargs): - """ - 运行详情 - :return: 步骤详情 - """ - return None diff --git a/apps/application/chat_pipeline/__init__.py b/apps/application/chat_pipeline/__init__.py deleted file mode 100644 index 719a7e29c90..00000000000 --- a/apps/application/chat_pipeline/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: __init__.py.py - @date:2024/1/9 17:23 - @desc: -""" diff --git a/apps/application/chat_pipeline/pipeline_manage.py b/apps/application/chat_pipeline/pipeline_manage.py deleted file mode 100644 index 206df8a399e..00000000000 --- a/apps/application/chat_pipeline/pipeline_manage.py +++ /dev/null @@ -1,66 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: pipeline_manage.py - @date:2024/1/9 17:40 - @desc: -""" -import time -from functools import reduce -from typing import List, Type, Dict - -from application.chat_pipeline.I_base_chat_pipeline import IBaseChatPipelineStep -from common.handle.base_to_response import BaseToResponse -from common.handle.impl.response.system_to_response import SystemToResponse - - -class PipelineManage: - def __init__(self, step_list: List[Type[IBaseChatPipelineStep]], - base_to_response: BaseToResponse = SystemToResponse(), - debug=False): - # 步骤执行器 - self.step_list = [step() for step in step_list] - self.run_step_list = [] - # 上下文 - self.context = {'message_tokens': 0, 'answer_tokens': 0} - self.base_to_response = base_to_response - self.debug = debug - - def run(self, context: Dict = None): - self.context['start_time'] = time.time() - if context is not None: - for key, value in context.items(): - self.context[key] = value - for step in self.step_list: - self.run_step_list.append(step) - step.run(self) - - def get_details(self): - return reduce(lambda x, y: {**x, **y}, [{item.get('step_type'): item} for item in - filter(lambda r: r is not None, - [row.get_details(self) for row in self.run_step_list])], {}) - - def get_base_to_response(self): - return self.base_to_response - - class builder: - def __init__(self): - self.step_list: List[Type[IBaseChatPipelineStep]] = [] - self.base_to_response = SystemToResponse() - self.debug = False - - def append_step(self, step: Type[IBaseChatPipelineStep]): - self.step_list.append(step) - return self - - def add_base_to_response(self, base_to_response: BaseToResponse): - self.base_to_response = base_to_response - return self - - def add_debug(self, debug): - self.debug = debug - return self - - def build(self): - return PipelineManage(step_list=self.step_list, base_to_response=self.base_to_response, debug=self.debug) diff --git a/apps/application/chat_pipeline/step/__init__.py b/apps/application/chat_pipeline/step/__init__.py deleted file mode 100644 index 5d9549cdc64..00000000000 --- a/apps/application/chat_pipeline/step/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: __init__.py.py - @date:2024/1/9 18:23 - @desc: -""" diff --git a/apps/application/chat_pipeline/step/chat_step/__init__.py b/apps/application/chat_pipeline/step/chat_step/__init__.py deleted file mode 100644 index 5d9549cdc64..00000000000 --- a/apps/application/chat_pipeline/step/chat_step/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: __init__.py.py - @date:2024/1/9 18:23 - @desc: -""" diff --git a/apps/application/chat_pipeline/step/chat_step/i_chat_step.py b/apps/application/chat_pipeline/step/chat_step/i_chat_step.py deleted file mode 100644 index 1c2ede64b40..00000000000 --- a/apps/application/chat_pipeline/step/chat_step/i_chat_step.py +++ /dev/null @@ -1,121 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: i_chat_step.py - @date:2024/1/9 18:17 - @desc: 对话 -""" -from abc import abstractmethod -from typing import Type, List - -from django.utils.translation import gettext_lazy as _ -from langchain.chat_models.base import BaseChatModel -from langchain_core.messages import BaseMessage -from rest_framework import serializers - -from application.chat_pipeline.I_base_chat_pipeline import IBaseChatPipelineStep, ParagraphPipelineModel -from application.chat_pipeline.pipeline_manage import PipelineManage -from application.serializers.application import NoReferencesSetting -from common.field.common import InstanceField - - -class ModelField(serializers.Field): - def to_internal_value(self, data): - if not isinstance(data, BaseChatModel): - self.fail(_('Model type error'), value=data) - return data - - def to_representation(self, value): - return value - - -class MessageField(serializers.Field): - def to_internal_value(self, data): - if not isinstance(data, BaseMessage): - self.fail(_('Message type error'), value=data) - return data - - def to_representation(self, value): - return value - - -class PostResponseHandler: - @abstractmethod - def handler(self, chat_id, chat_record_id, paragraph_list: List[ParagraphPipelineModel], problem_text: str, - answer_text, - manage, step, padding_problem_text: str = None, **kwargs): - pass - - -class IChatStep(IBaseChatPipelineStep): - class InstanceSerializer(serializers.Serializer): - # 对话列表 - message_list = serializers.ListField(required=True, child=MessageField(required=True), - label=_("Conversation list")) - model_id = serializers.UUIDField(required=False, allow_null=True, label=_("Model id")) - # 段落列表 - paragraph_list = serializers.ListField(label=_("Paragraph List")) - # 对话id - chat_id = serializers.UUIDField(required=True, label=_("Conversation ID")) - # 用户问题 - problem_text = serializers.CharField(required=True, label=_("User Questions")) - # 后置处理器 - post_response_handler = InstanceField(model_type=PostResponseHandler, - label=_("Post-processor")) - # 补全问题 - padding_problem_text = serializers.CharField(required=False, - label=_("Completion Question")) - # 是否使用流的形式输出 - stream = serializers.BooleanField(required=False, label=_("Streaming Output")) - chat_user_id = serializers.CharField(required=True, label=_("Chat user id")) - chat_record_id = serializers.CharField(required=False, label=_("Chat record id")) - - chat_user_type = serializers.CharField(required=True, label=_("Chat user Type")) - # 未查询到引用分段 - no_references_setting = NoReferencesSetting(required=True, - label=_("No reference segment settings")) - - workspace_id = serializers.CharField(required=True, label=_("Workspace ID")) - - model_setting = serializers.DictField(required=True, allow_null=True, - label=_("Model settings")) - - model_params_setting = serializers.DictField(required=False, allow_null=True, - label=_("Model parameter settings")) - mcp_tool_ids = serializers.JSONField(label="MCP工具ID列表", required=False, default=list) - mcp_servers = serializers.JSONField(label="MCP服务列表", required=False, default=dict) - mcp_source = serializers.CharField(label="MCP Source", required=False, default="referencing") - tool_ids = serializers.JSONField(label="工具ID列表", required=False, default=list) - application_ids = serializers.JSONField(label="应用ID列表", required=False, default=list) - skill_tool_ids = serializers.JSONField(label="技能ID列表", required=False, default=list) - mcp_output_enable = serializers.BooleanField(label="MCP输出是否启用", required=False, default=True) - - def is_valid(self, *, raise_exception=False): - super().is_valid(raise_exception=True) - message_list: List = self.initial_data.get('message_list') - for message in message_list: - if not isinstance(message, BaseMessage): - raise Exception(_("message type error")) - - def get_step_serializer(self, manage: PipelineManage) -> Type[serializers.Serializer]: - return self.InstanceSerializer - - def _run(self, manage: PipelineManage): - chat_result = self.execute(**self.context['step_args'], manage=manage) - manage.context['chat_result'] = chat_result - - @abstractmethod - def execute(self, message_list: List[BaseMessage], - chat_id, problem_text, - post_response_handler: PostResponseHandler, - model_id: str = None, - workspace_id: str = None, - paragraph_list=None, - manage: PipelineManage = None, - padding_problem_text: str = None, stream: bool = True, chat_user_id=None, chat_user_type=None, - no_references_setting=None, model_params_setting=None, model_setting=None, - mcp_tool_ids=None, mcp_servers='', mcp_source="referencing", - tool_ids=None, application_ids=None, skill_tool_ids=None, mcp_output_enable=True, - **kwargs): - pass diff --git a/apps/application/chat_pipeline/step/chat_step/impl/base_chat_step.py b/apps/application/chat_pipeline/step/chat_step/impl/base_chat_step.py deleted file mode 100644 index 0b5d8af6852..00000000000 --- a/apps/application/chat_pipeline/step/chat_step/impl/base_chat_step.py +++ /dev/null @@ -1,799 +0,0 @@ -# coding=utf-8 -""" -@project: maxkb -@Author:虎 -@file: base_chat_step.py -@date:2024/1/9 18:25 -@desc: 对话step Base实现 -""" - -import json -import time -import traceback -from typing import List - -import uuid_utils.compat as uuid -from application.chat_pipeline.I_base_chat_pipeline import ParagraphPipelineModel -from application.chat_pipeline.pipeline_manage import PipelineManage -from application.chat_pipeline.step.chat_step.i_chat_step import IChatStep, PostResponseHandler -from application.flow.tools import Reasoning, get_tools, mcp_response_generator -from application.long_term_memory import extract_long_term_memory -from application.models import ( - Application, - ApplicationAccessToken, - ApplicationApiKey, - ApplicationChatUserStats, - ApplicationLongTermMemory, - ChatUserType, -) -from common.exception.app_exception import AppApiException -from common.utils.logger import maxkb_logger -from common.utils.rsa_util import rsa_long_decrypt -from common.utils.shared_resource_auth import filter_authorized_ids -from common.utils.tool_code import ToolExecutor -from django.db.models import QuerySet -from django.http import StreamingHttpResponse -from django.utils.translation import gettext as _ -from langchain.chat_models.base import BaseChatModel -from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage, SystemMessage -from models_provider.tools import get_model_instance_by_model_workspace_id -from rest_framework import status -from tools.models import Tool, ToolType - - -def add_access_num(chat_user_id=None, chat_user_type=None, application_id=None): - if [ChatUserType.ANONYMOUS_USER.value, ChatUserType.CHAT_USER.value].__contains__( - chat_user_type - ) and application_id is not None: - application_public_access_client = ( - QuerySet(ApplicationChatUserStats) - .filter(chat_user_id=chat_user_id, chat_user_type=chat_user_type, application_id=application_id) - .first() - ) - if application_public_access_client is not None: - application_public_access_client.access_num = application_public_access_client.access_num + 1 - application_public_access_client.intraday_access_num = ( - application_public_access_client.intraday_access_num + 1 - ) - application_public_access_client.save() - - -def write_context(step, manage, request_token, response_token, all_text): - step.context["message_tokens"] = request_token - step.context["answer_tokens"] = response_token - current_time = time.time() - step.context["answer_text"] = all_text - step.context["run_time"] = current_time - step.context["start_time"] - manage.context["run_time"] = current_time - manage.context["start_time"] - manage.context["message_tokens"] = manage.context["message_tokens"] + request_token - manage.context["answer_tokens"] = manage.context["answer_tokens"] + response_token - - -def event_content( - response, - chat_id, - chat_record_id, - paragraph_list: List[ParagraphPipelineModel], - post_response_handler: PostResponseHandler, - manage, - step, - chat_model, - message_list: List[BaseMessage], - problem_text: str, - padding_problem_text: str = None, - chat_user_id=None, - chat_user_type=None, - is_ai_chat: bool = None, - model_setting=None, -): - if model_setting is None: - model_setting = {} - reasoning_content_enable = model_setting.get("reasoning_content_enable", False) - reasoning_content_start = model_setting.get("reasoning_content_start", "") - reasoning_content_end = model_setting.get("reasoning_content_end", "") - reasoning = Reasoning(reasoning_content_start, reasoning_content_end) - all_text = "" - reasoning_content = "" - try: - response_reasoning_content = False - for chunk in response: - reasoning_chunk = reasoning.get_reasoning_content(chunk) - content_chunk = reasoning_chunk.get("content") - if "reasoning_content" in chunk.additional_kwargs: - response_reasoning_content = True - reasoning_content_chunk = chunk.additional_kwargs.get("reasoning_content", "") - else: - reasoning_content_chunk = reasoning_chunk.get("reasoning_content") - content_chunk = reasoning._normalize_content(content_chunk) - all_text += content_chunk - if reasoning_content_chunk is None: - reasoning_content_chunk = "" - reasoning_content += reasoning_content_chunk - yield manage.get_base_to_response().to_stream_chunk_response( - chat_id, - str(chat_record_id), - "ai-chat-node", - [], - content_chunk, - False, - 0, - 0, - { - "node_is_end": False, - "view_type": "many_view", - "node_type": "ai-chat-node", - "real_node_id": "ai-chat-node", - "reasoning_content": reasoning_content_chunk if reasoning_content_enable else "", - }, - ) - reasoning_chunk = reasoning.get_end_reasoning_content() - all_text += reasoning_chunk.get("content") - reasoning_content_chunk = "" - if not response_reasoning_content: - reasoning_content_chunk = reasoning_chunk.get("reasoning_content") - yield manage.get_base_to_response().to_stream_chunk_response( - chat_id, - str(chat_record_id), - "ai-chat-node", - [], - reasoning_chunk.get("content"), - False, - 0, - 0, - { - "node_is_end": False, - "view_type": "many_view", - "node_type": "ai-chat-node", - "real_node_id": "ai-chat-node", - "reasoning_content": reasoning_content_chunk if reasoning_content_enable else "", - }, - ) - # 获取token - if is_ai_chat: - try: - request_token = chat_model.get_num_tokens_from_messages(message_list) - response_token = chat_model.get_num_tokens(all_text) - except Exception as e: - request_token = 0 - response_token = 0 - else: - request_token = 0 - response_token = 0 - write_context(step, manage, request_token, response_token, all_text) - post_response_handler.handler( - chat_id, - chat_record_id, - paragraph_list, - problem_text, - all_text, - manage, - step, - padding_problem_text, - reasoning_content=reasoning_content if reasoning_content_enable else "", - ) - yield manage.get_base_to_response().to_stream_chunk_response( - chat_id, - str(chat_record_id), - "ai-chat-node", - [], - "", - True, - request_token, - response_token, - {"node_is_end": True, "view_type": "many_view", "node_type": "ai-chat-node"}, - ) - if not manage.debug: - add_access_num(chat_user_id, chat_user_type, manage.context.get("application_id")) - except BaseException as e: - if isinstance(e, GeneratorExit): - maxkb_logger.error(f"Generator was closed (client disconnected)") - else: - maxkb_logger.error(f"{str(e)}:{traceback.format_exc()}") - all_text = "Exception:" + str(e) - write_context(step, manage, 0, 0, all_text) - post_response_handler.handler( - chat_id, - chat_record_id, - paragraph_list, - problem_text, - all_text, - manage, - step, - padding_problem_text, - reasoning_content=reasoning_content if reasoning_content_enable else "", - ) - if not manage.debug: - add_access_num(chat_user_id, chat_user_type, manage.context.get("application_id")) - yield manage.get_base_to_response().to_stream_chunk_response( - chat_id, - str(chat_record_id), - "ai-chat-node", - [], - all_text, - False, - 0, - 0, - { - "node_is_end": False, - "view_type": "many_view", - "node_type": "ai-chat-node", - "real_node_id": "ai-chat-node", - "reasoning_content": "", - }, - ) - - -class BaseChatStep(IChatStep): - def execute( - self, - message_list: List[BaseMessage], - chat_id, - problem_text, - post_response_handler: PostResponseHandler, - model_id: str = None, - workspace_id: str = None, - paragraph_list=None, - manage: PipelineManage = None, - padding_problem_text: str = None, - stream: bool = True, - chat_user_id=None, - chat_user_type=None, - no_references_setting=None, - model_params_setting=None, - model_setting=None, - mcp_tool_ids=None, - mcp_servers="", - mcp_source="referencing", - tool_ids=None, - application_ids=None, - skill_tool_ids=None, - mcp_output_enable=True, - **kwargs, - ): - chat_model = ( - get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {})) - if model_id is not None - else None - ) - if stream: - return self.execute_stream( - message_list, - chat_id, - problem_text, - post_response_handler, - chat_model, - paragraph_list, - manage, - padding_problem_text, - chat_user_id, - chat_user_type, - no_references_setting, - model_setting, - mcp_tool_ids, - mcp_servers, - mcp_source, - tool_ids, - application_ids, - skill_tool_ids, - workspace_id, - mcp_output_enable, - ) - else: - return self.execute_block( - message_list, - chat_id, - problem_text, - post_response_handler, - chat_model, - paragraph_list, - manage, - padding_problem_text, - chat_user_id, - chat_user_type, - no_references_setting, - model_setting, - mcp_tool_ids, - mcp_servers, - mcp_source, - tool_ids, - application_ids, - skill_tool_ids, - workspace_id, - mcp_output_enable, - ) - - def get_details(self, manage, **kwargs): - # 提取长期记忆 - extract_long_term_memory.apply_async( - args=( - manage.context.get("workspace_id"), - manage.context.get("application_id"), - manage.context.get("chat_user_id"), - ), - countdown=1, - ) - return { - "status": self.status, - "err_message": self.err_message, - "step_type": "chat_step", - "run_time": self.context.get("run_time") or 0, - "model_id": str(manage.context["model_id"]), - "message_list": self.reset_message_list( - self.context["step_args"].get("message_list"), self.context.get("answer_text") - ), - "message_tokens": self.context.get("message_tokens"), - "answer_tokens": self.context.get("answer_tokens"), - "cost": 0, - } - - @staticmethod - def reset_message_list(message_list: List[BaseMessage], answer_text): - result = [ - { - "role": "user" - if isinstance(message, HumanMessage) - else ("system" if isinstance(message, SystemMessage) else "ai"), - "content": message.content, - } - for message in message_list - ] - result.append({"role": "ai", "content": answer_text}) - return result - - def _handle_mcp_request( - self, - mcp_source, - mcp_servers, - mcp_tool_ids, - tool_ids, - application_ids, - skill_tool_ids, - mcp_output_enable, - chat_model, - system_prompt, - message_list, - agent_id, - chat_id, - workspace_id, - ): - - mcp_servers_config = {} - - # 迁移过来mcp_source是None - if mcp_source is None: - mcp_source = "custom" - # 兼容老数据 - if not mcp_tool_ids: - mcp_tool_ids = [] - if mcp_source == "custom" and mcp_servers: - mcp_servers_config = json.loads(mcp_servers) - elif mcp_tool_ids: - mcp_tools = QuerySet(Tool).filter(id__in=mcp_tool_ids).values() - for mcp_tool in mcp_tools: - if mcp_tool and mcp_tool["is_active"]: - mcp_servers_config = {**mcp_servers_config, **json.loads(mcp_tool["code"])} - # 校验代码是否包括禁止的关键字 - ToolExecutor().validate_mcp_transport(json.dumps(mcp_servers_config)) - - tool_init_params = {} - tools = get_tools("APPLICATION", agent_id, tool_ids, workspace_id) - if tool_ids and len(tool_ids) > 0: # 如果有工具ID,则将其转换为MCP - self.context["tool_ids"] = tool_ids - for tool_id in tool_ids: - tool = QuerySet(Tool).filter(id=tool_id, tool_type=ToolType.CUSTOM).first() - if tool is None or tool.is_active is False: - continue - executor = ToolExecutor() - init_params_default_value = {i["field"]: i.get('default_value') for i in tool.init_field_list} - if tool.init_params is not None: - tool_init_params = init_params_default_value | json.loads(rsa_long_decrypt(tool.init_params)) - else: - tool_init_params = init_params_default_value - tool_config = executor.get_tool_mcp_config(tool, tool_init_params) - - mcp_servers_config[str(tool.id)] = tool_config - - if application_ids and len(application_ids) > 0: - self.context["application_ids"] = application_ids - for application_id in application_ids: - app = QuerySet(Application).filter(id=application_id, is_publish=True).first() - if app is None: - continue - app_key = QuerySet(ApplicationApiKey).filter(application_id=application_id, is_active=True).first() - if app_key is not None: - api_key = app_key.secret_key - application_access_token = ( - QuerySet(ApplicationAccessToken).filter(application_id=app_key.application_id).first() - ) - if application_access_token is not None and application_access_token.authentication: - raise AppApiException( - 500, - _("Agent 【{name}】 access token authentication is not supported for agent tool").format( - name=app.name - ), - ) - else: - raise AppApiException( - 500, _("Agent Key is required for agent tool 【{name}】").format(name=app.name) - ) - executor = ToolExecutor() - app_config = executor.get_app_mcp_config(api_key) - mcp_servers_config[app.name] = app_config - - if skill_tool_ids and len(skill_tool_ids) > 0: - self.context["skill_tool_ids"] = skill_tool_ids - skill_file_items = [] - - for tool_id in skill_tool_ids: - tool = QuerySet(Tool).filter(id=tool_id, is_active=True).first() - if tool is None or tool.is_active is False: - continue - init_params_default_value = {i["field"]: i.get("default_value") for i in tool.init_field_list} - if tool.init_params is not None: - params = init_params_default_value | json.loads(rsa_long_decrypt(tool.init_params)) - else: - params = init_params_default_value - - skill_file_items.append({"tool_id": str(tool.id), "file_id": tool.code, "params": params}) - mcp_servers_config["skills"] = skill_file_items - - if len(mcp_servers_config) > 0 or len(tools) > 0: - source_id = agent_id - source_type = "APPLICATION" - return mcp_response_generator( - chat_model, - system_prompt, - message_list, - json.dumps(mcp_servers_config), - mcp_output_enable, - tool_init_params, - source_id, - source_type, - chat_id, - tools, - ) - - return None - - def get_stream_result( - self, - message_list: List[BaseMessage], - chat_model: BaseChatModel = None, - paragraph_list=None, - no_references_setting=None, - problem_text=None, - mcp_tool_ids=None, - mcp_servers="", - mcp_source="referencing", - tool_ids=None, - application_ids=None, - skill_tool_ids=None, - workspace_id=None, - mcp_output_enable=True, - agent_id=None, - chat_id=None, - chat_user_id=None, - chat_user_type=None, - ): - if paragraph_list is None: - paragraph_list = [] - directly_return_chunk_list = [ - AIMessageChunk(content=paragraph.content) - for paragraph in paragraph_list - if ( - paragraph.hit_handling_method == "directly_return" - and paragraph.similarity >= paragraph.directly_return_similarity - ) - ] - if directly_return_chunk_list is not None and len(directly_return_chunk_list) > 0: - return iter(directly_return_chunk_list), False - elif len(paragraph_list) == 0 and no_references_setting.get("status") == "designated_answer": - return iter( - [AIMessageChunk(content=no_references_setting.get("value").replace("{question}", problem_text))] - ), False - if chat_model is None: - return iter( - [ - AIMessageChunk( - _( - "Sorry, the AI model is not configured. Please go to the application to set up the AI model first." - ) - ) - ] - ), False - else: - user_system_prompt = None - filtered_message_list = [] - long_term_memory = ( - QuerySet(ApplicationLongTermMemory).filter(chat_user_id=chat_user_id, application_id=agent_id).first() - ) - if long_term_memory is not None: - memory = long_term_memory.memory - else: - memory = "" - - # print(chat_user_id, chat_user_type) - for msg in message_list: - if isinstance(msg, SystemMessage): - if isinstance(msg.content, str): - user_system_prompt = msg.content.replace("{memory}", memory) - msg.content = user_system_prompt - elif isinstance(msg.content, list): - user_system_prompt = "".join( - item.get("text", "") if isinstance(item, dict) else str(item) for item in msg.content - ) - else: - user_system_prompt = str(msg.content) - else: - filtered_message_list.append(msg) - # 过滤tool_id - all_tool_ids = list(set((mcp_tool_ids or []) + (tool_ids or []) + (skill_tool_ids or []))) - authorized_set = set(filter_authorized_ids("tool", all_tool_ids, workspace_id)) - - mcp_tool_ids = [i for i in (mcp_tool_ids or []) if i in authorized_set] - tool_ids = [i for i in (tool_ids or []) if i in authorized_set] - skill_tool_ids = [i for i in (skill_tool_ids or []) if i in authorized_set] - # 处理 MCP 请求 - mcp_result = self._handle_mcp_request( - mcp_source, - mcp_servers, - mcp_tool_ids, - tool_ids, - application_ids, - skill_tool_ids, - mcp_output_enable, - chat_model, - user_system_prompt, - filtered_message_list, - agent_id, - chat_id, - workspace_id, - ) - if mcp_result: - return mcp_result, True - return chat_model.stream(message_list), True - - def execute_stream( - self, - message_list: List[BaseMessage], - chat_id, - problem_text, - post_response_handler: PostResponseHandler, - chat_model: BaseChatModel = None, - paragraph_list=None, - manage: PipelineManage = None, - padding_problem_text: str = None, - chat_user_id=None, - chat_user_type=None, - no_references_setting=None, - model_setting=None, - mcp_tool_ids=None, - mcp_servers="", - mcp_source="referencing", - tool_ids=None, - application_ids=None, - skill_tool_ids=None, - workspace_id=None, - mcp_output_enable=True, - ): - chat_result, is_ai_chat = self.get_stream_result( - message_list, - chat_model, - paragraph_list, - no_references_setting, - problem_text, - mcp_tool_ids, - mcp_servers, - mcp_source, - tool_ids, - application_ids, - skill_tool_ids, - workspace_id, - mcp_output_enable, - manage.context.get("application_id"), - chat_id, - chat_user_id, - chat_user_type, - ) - chat_record_id = ( - self.context.get("step_args", {}).get("chat_record_id") - if self.context.get("step_args", {}).get("chat_record_id") - else uuid.uuid7() - ) - r = StreamingHttpResponse( - streaming_content=event_content( - chat_result, - chat_id, - chat_record_id, - paragraph_list, - post_response_handler, - manage, - self, - chat_model, - message_list, - problem_text, - padding_problem_text, - chat_user_id, - chat_user_type, - is_ai_chat, - model_setting, - ), - content_type="text/event-stream;charset=utf-8", - ) - - r["Cache-Control"] = "no-cache" - return r - - def get_block_result( - self, - message_list: List[BaseMessage], - chat_model: BaseChatModel = None, - paragraph_list=None, - no_references_setting=None, - problem_text=None, - mcp_tool_ids=None, - mcp_servers="", - mcp_source="referencing", - tool_ids=None, - application_ids=None, - skill_tool_ids=None, - workspace_id=None, - mcp_output_enable=True, - application_id=None, - chat_id=None, - ): - if paragraph_list is None: - paragraph_list = [] - directly_return_chunk_list = [ - AIMessageChunk(content=paragraph.content) - for paragraph in paragraph_list - if ( - paragraph.hit_handling_method == "directly_return" - and paragraph.similarity >= paragraph.directly_return_similarity - ) - ] - if directly_return_chunk_list is not None and len(directly_return_chunk_list) > 0: - return directly_return_chunk_list[0], False - elif len(paragraph_list) == 0 and no_references_setting.get("status") == "designated_answer": - return AIMessage(no_references_setting.get("value").replace("{question}", problem_text)), False - if chat_model is None: - return AIMessage( - _("Sorry, the AI model is not configured. Please go to the application to set up the AI model first.") - ), False - else: - # 过滤tool_id - all_tool_ids = list(set((mcp_tool_ids or []) + (tool_ids or []) + (skill_tool_ids or []))) - authorized_set = set(filter_authorized_ids("tool", all_tool_ids, workspace_id)) - - mcp_tool_ids = [i for i in (mcp_tool_ids or []) if i in authorized_set] - tool_ids = [i for i in (tool_ids or []) if i in authorized_set] - skill_tool_ids = [i for i in (skill_tool_ids or []) if i in authorized_set] - # 处理 MCP 请求 - mcp_result = self._handle_mcp_request( - mcp_source, - mcp_servers, - mcp_tool_ids, - tool_ids, - application_ids, - skill_tool_ids, - mcp_output_enable, - chat_model, - "", - message_list, - application_id, - chat_id, - workspace_id, - ) - if mcp_result: - return mcp_result, True - return chat_model.invoke(message_list), True - - def execute_block( - self, - message_list: List[BaseMessage], - chat_id, - problem_text, - post_response_handler: PostResponseHandler, - chat_model: BaseChatModel = None, - paragraph_list=None, - manage: PipelineManage = None, - padding_problem_text: str = None, - chat_user_id=None, - chat_user_type=None, - no_references_setting=None, - model_setting=None, - mcp_tool_ids=None, - mcp_servers="", - mcp_source="referencing", - tool_ids=None, - application_ids=None, - skill_tool_ids=None, - workspace_id=None, - mcp_output_enable=True, - ): - reasoning_content_enable = model_setting.get("reasoning_content_enable", False) - reasoning_content_start = model_setting.get("reasoning_content_start", "") - reasoning_content_end = model_setting.get("reasoning_content_end", "") - reasoning = Reasoning(reasoning_content_start, reasoning_content_end) - chat_record_id = uuid.uuid7() - # 调用模型 - try: - chat_result, is_ai_chat = self.get_block_result( - message_list, - chat_model, - paragraph_list, - no_references_setting, - problem_text, - mcp_tool_ids, - mcp_servers, - mcp_source, - tool_ids, - application_ids, - skill_tool_ids, - workspace_id, - mcp_output_enable, - manage.context.get("application_id"), - chat_id, - ) - if is_ai_chat: - request_token = chat_model.get_num_tokens_from_messages(message_list) - response_token = chat_model.get_num_tokens(chat_result.content) - else: - request_token = 0 - response_token = 0 - write_context(self, manage, request_token, response_token, chat_result.content) - reasoning_result = reasoning.get_reasoning_content(chat_result) - reasoning_result_end = reasoning.get_end_reasoning_content() - content = reasoning_result.get("content") + reasoning_result_end.get("content") - if "reasoning_content" in chat_result.response_metadata: - reasoning_content = chat_result.response_metadata.get("reasoning_content", "") or "" - else: - reasoning_content = (reasoning_result.get("reasoning_content") or "") + ( - reasoning_result_end.get("reasoning_content") or "" - ) - post_response_handler.handler( - chat_id, - chat_record_id, - paragraph_list, - problem_text, - content, - manage, - self, - padding_problem_text, - reasoning_content=reasoning_content, - ) - if not manage.debug: - add_access_num(chat_user_id, chat_user_type, manage.context.get("application_id")) - return manage.get_base_to_response().to_block_response( - str(chat_id), - str(chat_record_id), - content, - True, - request_token, - response_token, - { - "reasoning_content": reasoning_content if reasoning_content_enable else "", - "answer_list": [ - {"content": content, "reasoning_content": reasoning_content if reasoning_content_enable else ""} - ], - }, - ) - except Exception as e: - all_text = "Exception:" + str(e) - write_context(self, manage, 0, 0, all_text) - post_response_handler.handler( - chat_id, - chat_record_id, - paragraph_list, - problem_text, - all_text, - manage, - self, - padding_problem_text, - reasoning_content="", - ) - if not manage.debug: - add_access_num(chat_user_id, chat_user_type, manage.context.get("application_id")) - return manage.get_base_to_response().to_block_response( - str(chat_id), str(chat_record_id), all_text, True, 0, 0, _status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) diff --git a/apps/application/chat_pipeline/step/generate_human_message_step/__init__.py b/apps/application/chat_pipeline/step/generate_human_message_step/__init__.py deleted file mode 100644 index 5d9549cdc64..00000000000 --- a/apps/application/chat_pipeline/step/generate_human_message_step/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: __init__.py.py - @date:2024/1/9 18:23 - @desc: -""" diff --git a/apps/application/chat_pipeline/step/generate_human_message_step/i_generate_human_message_step.py b/apps/application/chat_pipeline/step/generate_human_message_step/i_generate_human_message_step.py deleted file mode 100644 index 0d49e9a5e2f..00000000000 --- a/apps/application/chat_pipeline/step/generate_human_message_step/i_generate_human_message_step.py +++ /dev/null @@ -1,82 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: i_generate_human_message_step.py - @date:2024/1/9 18:15 - @desc: 生成对话模板 -""" -from abc import abstractmethod -from typing import Type, List - -from django.utils.translation import gettext_lazy as _ -from langchain_core.messages import BaseMessage -from rest_framework import serializers - -from application.chat_pipeline.I_base_chat_pipeline import IBaseChatPipelineStep, ParagraphPipelineModel -from application.chat_pipeline.pipeline_manage import PipelineManage -from application.models import ChatRecord -from application.serializers.application import NoReferencesSetting -from common.field.common import InstanceField - - -class IGenerateHumanMessageStep(IBaseChatPipelineStep): - class InstanceSerializer(serializers.Serializer): - # 问题 - problem_text = serializers.CharField(required=True, label=_("question")) - # 段落列表 - paragraph_list = serializers.ListField(child=InstanceField(model_type=ParagraphPipelineModel, required=True), - label=_("Paragraph List")) - # 历史对答 - history_chat_record = serializers.ListField(child=InstanceField(model_type=ChatRecord, required=True), - label=_("History Questions")) - # 多轮对话数量 - dialogue_number = serializers.IntegerField(required=True, label=_("Number of multi-round conversations")) - # 最大携带知识库段落长度 - max_paragraph_char_number = serializers.IntegerField(required=True, - label=_("Maximum length of the knowledge base paragraph")) - # 模板 - prompt = serializers.CharField(required=True, label=_("Prompt word")) - system = serializers.CharField(required=False, allow_null=True, allow_blank=True, - label=_("System prompt words (role)")) - # 补齐问题 - padding_problem_text = serializers.CharField(required=False, - label=_("Completion problem")) - # 未查询到引用分段 - no_references_setting = NoReferencesSetting(required=True, - label=_("No reference segment settings")) - - def get_step_serializer(self, manage: PipelineManage) -> Type[serializers.Serializer]: - return self.InstanceSerializer - - def _run(self, manage: PipelineManage): - message_list = self.execute(**self.context['step_args']) - manage.context['message_list'] = message_list - - @abstractmethod - def execute(self, - problem_text: str, - paragraph_list: List[ParagraphPipelineModel], - history_chat_record: List[ChatRecord], - dialogue_number: int, - max_paragraph_char_number: int, - prompt: str, - padding_problem_text: str = None, - no_references_setting=None, - system=None, - **kwargs) -> List[BaseMessage]: - """ - - :param problem_text: 原始问题文本 - :param paragraph_list: 段落列表 - :param history_chat_record: 历史对话记录 - :param dialogue_number: 多轮对话数量 - :param max_paragraph_char_number: 最大段落长度 - :param prompt: 模板 - :param padding_problem_text 用户修改文本 - :param kwargs: 其他参数 - :param no_references_setting: 无引用分段设置 - :param system 系统提示称 - :return: - """ - pass diff --git a/apps/application/chat_pipeline/step/generate_human_message_step/impl/base_generate_human_message_step.py b/apps/application/chat_pipeline/step/generate_human_message_step/impl/base_generate_human_message_step.py deleted file mode 100644 index 2fc62897eee..00000000000 --- a/apps/application/chat_pipeline/step/generate_human_message_step/impl/base_generate_human_message_step.py +++ /dev/null @@ -1,79 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: base_generate_human_message_step.py.py - @date:2024/1/10 17:50 - @desc: -""" -from typing import List, Dict - -from langchain_core.messages import SystemMessage, BaseMessage, HumanMessage - -from application.chat_pipeline.I_base_chat_pipeline import ParagraphPipelineModel -from application.chat_pipeline.step.generate_human_message_step.i_generate_human_message_step import \ - IGenerateHumanMessageStep -from application.models import ChatRecord -from common.utils.common import flat_map - - -class BaseGenerateHumanMessageStep(IGenerateHumanMessageStep): - - def execute(self, problem_text: str, - paragraph_list: List[ParagraphPipelineModel], - history_chat_record: List[ChatRecord], - dialogue_number: int, - max_paragraph_char_number: int, - prompt: str, - padding_problem_text: str = None, - no_references_setting=None, - system=None, - **kwargs) -> List[BaseMessage]: - prompt = prompt if (paragraph_list is not None and len(paragraph_list) > 0) else no_references_setting.get( - 'value') - exec_problem_text = padding_problem_text if padding_problem_text is not None else problem_text - start_index = len(history_chat_record) - dialogue_number - history_message = [[history_chat_record[index].get_human_message(), history_chat_record[index].get_ai_message()] - for index in - range(start_index if start_index > 0 else 0, len(history_chat_record))] - if system is not None and len(system) > 0: - return [SystemMessage(system), *flat_map(history_message), - self.to_human_message(prompt, exec_problem_text, max_paragraph_char_number, paragraph_list, - no_references_setting)] - - return [*flat_map(history_message), - self.to_human_message(prompt, exec_problem_text, max_paragraph_char_number, paragraph_list, - no_references_setting)] - - @staticmethod - def to_human_message(prompt: str, - problem: str, - max_paragraph_char_number: int, - paragraph_list: List[ParagraphPipelineModel], - no_references_setting: Dict): - if paragraph_list is None or len(paragraph_list) == 0: - if no_references_setting.get('status') == 'ai_questioning': - return HumanMessage( - content=no_references_setting.get('value').replace('{question}', problem)) - else: - return HumanMessage(content=prompt.replace('{data}', "").replace('{question}', problem)) - temp_len = 0 - data_list = [] - for p in paragraph_list: - content = f"{p.title}:{p.content}" - temp_len += len(content) - if temp_len > max_paragraph_char_number: - row_data = content[0:max_paragraph_char_number - temp_len] - data_list.append(f"{row_data}") - break - else: - data_list.append(f"{content}") - data = "\n".join(data_list) - return HumanMessage(content=prompt.replace('{data}', data).replace('{question}', problem)) - - def get_details(self, manage, **kwargs): - return { - 'status': self.status, - 'err_message': self.err_message, - 'step_type': 'generate_human_message', - } diff --git a/apps/application/chat_pipeline/step/reset_problem_step/__init__.py b/apps/application/chat_pipeline/step/reset_problem_step/__init__.py deleted file mode 100644 index 5d9549cdc64..00000000000 --- a/apps/application/chat_pipeline/step/reset_problem_step/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: __init__.py.py - @date:2024/1/9 18:23 - @desc: -""" diff --git a/apps/application/chat_pipeline/step/reset_problem_step/i_reset_problem_step.py b/apps/application/chat_pipeline/step/reset_problem_step/i_reset_problem_step.py deleted file mode 100644 index a0e06204364..00000000000 --- a/apps/application/chat_pipeline/step/reset_problem_step/i_reset_problem_step.py +++ /dev/null @@ -1,55 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: i_reset_problem_step.py - @date:2024/1/9 18:12 - @desc: 重写处理问题 -""" -from abc import abstractmethod -from typing import Type, List - -from django.utils.translation import gettext_lazy as _ -from rest_framework import serializers - -from application.chat_pipeline.I_base_chat_pipeline import IBaseChatPipelineStep -from application.chat_pipeline.pipeline_manage import PipelineManage -from application.models import ChatRecord -from common.field.common import InstanceField - - -class IResetProblemStep(IBaseChatPipelineStep): - class InstanceSerializer(serializers.Serializer): - # 问题文本 - problem_text = serializers.CharField(required=True, label=_("question")) - # 历史对答 - history_chat_record = serializers.ListField(child=InstanceField(model_type=ChatRecord, required=True), - label=_("History Questions")) - # 大语言模型 - model_id = serializers.UUIDField(required=False, allow_null=True, label=_("Model id")) - workspace_id = serializers.CharField(required=True, label=_("User ID")) - problem_optimization_prompt = serializers.CharField(required=False, max_length=102400, - label=_("Question completion prompt")) - - def get_step_serializer(self, manage: PipelineManage) -> Type[serializers.Serializer]: - return self.InstanceSerializer - - def _run(self, manage: PipelineManage): - padding_problem = self.execute(**self.context.get('step_args')) - # 用户输入问题 - source_problem_text = self.context.get('step_args').get('problem_text') - self.context['problem_text'] = source_problem_text - self.context['padding_problem_text'] = padding_problem - manage.context['problem_text'] = source_problem_text - manage.context['padding_problem_text'] = padding_problem - # 累加tokens - manage.context['message_tokens'] = manage.context.get('message_tokens', 0) + self.context.get('message_tokens', - 0) - manage.context['answer_tokens'] = manage.context.get('answer_tokens', 0) + self.context.get('answer_tokens', 0) - - @abstractmethod - def execute(self, problem_text: str, history_chat_record: List[ChatRecord] = None, model_id: str = None, - problem_optimization_prompt=None, - workspace_id=None, - **kwargs): - pass diff --git a/apps/application/chat_pipeline/step/reset_problem_step/impl/base_reset_problem_step.py b/apps/application/chat_pipeline/step/reset_problem_step/impl/base_reset_problem_step.py deleted file mode 100644 index 47368ae65f5..00000000000 --- a/apps/application/chat_pipeline/step/reset_problem_step/impl/base_reset_problem_step.py +++ /dev/null @@ -1,69 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: base_reset_problem_step.py - @date:2024/1/10 14:35 - @desc: -""" -from typing import List - -from django.utils.translation import gettext as _ -from langchain_core.messages import HumanMessage - -from application.chat_pipeline.step.reset_problem_step.i_reset_problem_step import IResetProblemStep -from application.models import ChatRecord -from common.utils.split_model import flat_map -from models_provider.tools import get_model_instance_by_model_workspace_id - -prompt = _( - "() contains the user's question. Answer the guessed user's question based on the context ({question}) Requirement: Output a complete question and put it in the tag") - - -class BaseResetProblemStep(IResetProblemStep): - def execute(self, problem_text: str, history_chat_record: List[ChatRecord] = None, model_id: str = None, - problem_optimization_prompt=None, - workspace_id=None, - **kwargs) -> str: - chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id) if model_id is not None else None - if chat_model is None: - return problem_text - start_index = len(history_chat_record) - 3 - history_message = [[history_chat_record[index].get_human_message(), history_chat_record[index].get_ai_message()] - for index in - range(start_index if start_index > 0 else 0, len(history_chat_record))] - reset_prompt = problem_optimization_prompt if problem_optimization_prompt else prompt - message_list = [*flat_map(history_message), - HumanMessage(content=reset_prompt.replace('{question}', problem_text))] - response = chat_model.invoke(message_list) - padding_problem = problem_text - if response.content.__contains__("") and response.content.__contains__(''): - padding_problem_data = response.content[ - response.content.index('') + 6:response.content.index('')] - if padding_problem_data is not None and len(padding_problem_data.strip()) > 0: - padding_problem = padding_problem_data - elif len(response.content) > 0: - padding_problem = response.content - - try: - request_token = chat_model.get_num_tokens_from_messages(message_list) - response_token = chat_model.get_num_tokens(padding_problem) - except Exception as e: - request_token = 0 - response_token = 0 - self.context['message_tokens'] = request_token - self.context['answer_tokens'] = response_token - return padding_problem - - def get_details(self, manage, **kwargs): - return {'status': self.status, - 'err_message': self.err_message, - 'step_type': 'problem_padding', - 'run_time': self.context['run_time'], - 'model_id': str(manage.context['model_id']) if 'model_id' in manage.context else None, - 'message_tokens': self.context.get('message_tokens', 0), - 'answer_tokens': self.context.get('answer_tokens', 0), - 'cost': 0, - 'padding_problem_text': self.context.get('padding_problem_text'), - 'problem_text': self.context.get("step_args").get('problem_text'), - } diff --git a/apps/application/chat_pipeline/step/search_dataset_step/__init__.py b/apps/application/chat_pipeline/step/search_dataset_step/__init__.py deleted file mode 100644 index 023c4bc387d..00000000000 --- a/apps/application/chat_pipeline/step/search_dataset_step/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: __init__.py.py - @date:2024/1/9 18:24 - @desc: -""" diff --git a/apps/application/chat_pipeline/step/search_dataset_step/i_search_dataset_step.py b/apps/application/chat_pipeline/step/search_dataset_step/i_search_dataset_step.py deleted file mode 100644 index 373dc33d44b..00000000000 --- a/apps/application/chat_pipeline/step/search_dataset_step/i_search_dataset_step.py +++ /dev/null @@ -1,77 +0,0 @@ -# coding=utf-8 -""" - @project: maxkb - @Author:虎 - @file: i_search_dataset_step.py - @date:2024/1/9 18:10 - @desc: 检索知识库 -""" -import re -from abc import abstractmethod -from typing import List, Type - -from django.core import validators -from django.utils.translation import gettext_lazy as _ -from rest_framework import serializers - -from application.chat_pipeline.I_base_chat_pipeline import IBaseChatPipelineStep, ParagraphPipelineModel -from application.chat_pipeline.pipeline_manage import PipelineManage - - -class ISearchDatasetStep(IBaseChatPipelineStep): - class InstanceSerializer(serializers.Serializer): - # 原始问题文本 - problem_text = serializers.CharField(required=True, label=_("question")) - # 系统补全问题文本 - padding_problem_text = serializers.CharField(required=False, - label=_("System completes question text")) - # 需要查询的数据集id列表 - knowledge_id_list = serializers.ListField(required=True, child=serializers.UUIDField(required=True), - label=_("Dataset id list")) - # 需要排除的文档id - exclude_document_id_list = serializers.ListField(required=True, child=serializers.UUIDField(required=True), - label=_("List of document ids to exclude")) - # 需要排除向量id - exclude_paragraph_id_list = serializers.ListField(required=True, child=serializers.UUIDField(required=True), - label=_("List of exclusion vector ids")) - # 需要查询的条数 - top_n = serializers.IntegerField(required=True, - label=_("Reference segment number")) - # 相似度 0-1之间 - similarity = serializers.FloatField(required=True, max_value=1, min_value=0, - label=_("Similarity")) - search_mode = serializers.CharField(required=True, validators=[ - validators.RegexValidator(regex=re.compile("^embedding|keywords|blend$"), - message=_("The type only supports embedding|keywords|blend"), code=500) - ], label=_("Retrieval Mode")) - workspace_id = serializers.CharField(required=True, label=_("Workspace ID")) - - def get_step_serializer(self, manage: PipelineManage) -> Type[InstanceSerializer]: - return self.InstanceSerializer - - def _run(self, manage: PipelineManage): - paragraph_list = self.execute(**self.context['step_args'], manage=manage) - manage.context['paragraph_list'] = paragraph_list - self.context['paragraph_list'] = paragraph_list - - @abstractmethod - def execute(self, problem_text: str, knowledge_id_list: list[str], exclude_document_id_list: list[str], - exclude_paragraph_id_list: list[str], top_n: int, similarity: float, padding_problem_text: str = None, - search_mode: str = None, - workspace_id=None, - manage: PipelineManage = None, - **kwargs) -> List[ParagraphPipelineModel]: - """ - 关于 用户和补全问题 说明: 补全问题如果有就使用补全问题去查询 反之就用用户原始问题查询 - :param similarity: 相关性 - :param top_n: 查询多少条 - :param problem_text: 用户问题 - :param knowledge_id_list: 需要查询的数据集id列表 - :param exclude_document_id_list: 需要排除的文档id - :param exclude_paragraph_id_list: 需要排除段落id - :param padding_problem_text 补全问题 - :param search_mode 检索模式 - :param workspace_id 工作空间id - :return: 段落列表 - """ - pass diff --git a/apps/application/chat_pipeline/step/search_dataset_step/impl/base_search_dataset_step.py b/apps/application/chat_pipeline/step/search_dataset_step/impl/base_search_dataset_step.py deleted file mode 100644 index b96c9ba2187..00000000000 --- a/apps/application/chat_pipeline/step/search_dataset_step/impl/base_search_dataset_step.py +++ /dev/null @@ -1,194 +0,0 @@ -# coding=utf-8 -""" -@project: maxkb -@Author:虎 -@file: base_search_dataset_step.py -@date:2024/1/10 10:33 -@desc: -""" - -import os -from typing import List, Dict - -from django.db.models import QuerySet -from django.utils.translation import gettext_lazy as _ -from rest_framework.utils.formatting import lazy_format - -from application.chat_pipeline.I_base_chat_pipeline import ParagraphPipelineModel -from application.chat_pipeline.step.search_dataset_step.i_search_dataset_step import ISearchDatasetStep -from common.config.embedding_config import VectorStore, ModelManage -from common.auth.constants.role_constants import RoleConstants -from common.database_model_manage.database_model_manage import DatabaseModelManage -from common.db.search import native_search -from common.utils.common import get_file_content -from knowledge.models import Paragraph, Knowledge -from knowledge.models import SearchMode -from knowledge.services.retrieval_stats import get_recall_tracker, record_recall_safely -from maxkb.conf import PROJECT_DIR -from models_provider.models import Model -from models_provider.tools import get_model, get_model_by_id, get_model_default_params - - -def reset_meta(meta): - if not meta.get("allow_download", False): - return {"allow_download": False} - return meta - - -def get_embedding_id(knowledge_id_list): - knowledge_list = QuerySet(Knowledge).filter(id__in=knowledge_id_list) - if len(set([knowledge.embedding_model_id for knowledge in knowledge_list])) > 1: - raise Exception( - _( - "The vector model of the associated knowledge base is inconsistent and the segmentation cannot be recalled." - ) - ) - if len(knowledge_list) == 0: - raise Exception(_("The knowledge base setting is wrong, please reset the knowledge base")) - return knowledge_list[0].embedding_model_id - - -class BaseSearchDatasetStep(ISearchDatasetStep): - def execute( - self, - problem_text: str, - knowledge_id_list: list[str], - exclude_document_id_list: list[str], - exclude_paragraph_id_list: list[str], - top_n: int, - similarity: float, - padding_problem_text: str = None, - search_mode: str = None, - workspace_id=None, - manage=None, - **kwargs, - ) -> List[ParagraphPipelineModel]: - get_knowledge_list_of_authorized = DatabaseModelManage.get_model("get_knowledge_list_of_authorized") - chat_user_type = manage.context.get("chat_user_type") - if get_knowledge_list_of_authorized is not None and RoleConstants.CHAT_USER.value.name == chat_user_type: - knowledge_id_list = get_knowledge_list_of_authorized(manage.context.get("chat_user_id"), knowledge_id_list) - if len(knowledge_id_list) == 0: - return [] - exec_problem_text = padding_problem_text if padding_problem_text is not None else problem_text - model_id = get_embedding_id(knowledge_id_list) - model = get_model_by_id(model_id, workspace_id) - if model.model_type != "EMBEDDING": - raise Exception(_("Model does not exist")) - self.context["model_name"] = model.name - default_params = get_model_default_params(model) - embedding_model = ModelManage.get_model(model_id, lambda _id: get_model(model, **{**default_params})) - embedding_value = embedding_model.embed_query(exec_problem_text) - vector = VectorStore.get_embedding_vector() - embedding_list = vector.query( - exec_problem_text, - embedding_value, - knowledge_id_list, - None, - exclude_document_id_list, - exclude_paragraph_id_list, - True, - top_n, - similarity, - SearchMode(search_mode), - ) - if embedding_list is None: - return [] - paragraph_list = self.list_paragraph(embedding_list, vector) - result = [self.reset_paragraph(paragraph, embedding_list) for paragraph in paragraph_list] - if not manage.debug: - recalled_paragraph_ids = {paragraph.id for paragraph in result} - record_recall_safely( - [ - embedding - for embedding in embedding_list - if str(embedding.get("paragraph_id")) in recalled_paragraph_ids - ], - tracker=get_recall_tracker(manage), - ) - return result - - @staticmethod - def reset_paragraph(paragraph: Dict, embedding_list: List) -> ParagraphPipelineModel: - filter_embedding_list = [ - embedding for embedding in embedding_list if str(embedding.get("paragraph_id")) == str(paragraph.get("id")) - ] - if filter_embedding_list is not None and len(filter_embedding_list) > 0: - find_embedding = filter_embedding_list[-1] - return ( - ParagraphPipelineModel.builder() - .add_paragraph(paragraph) - .add_similarity(find_embedding.get("similarity")) - .add_comprehensive_score(find_embedding.get("comprehensive_score")) - .add_knowledge_name(paragraph.get("knowledge_name")) - .add_knowledge_type(paragraph.get("knowledge_type")) - .add_document_name(paragraph.get("document_name")) - .add_hit_handling_method(paragraph.get("hit_handling_method")) - .add_directly_return_similarity(paragraph.get("directly_return_similarity")) - .add_meta(reset_meta(paragraph.get("meta"))) - .build() - ) - - @staticmethod - def get_similarity(paragraph, embedding_list: List): - filter_embedding_list = [ - embedding for embedding in embedding_list if str(embedding.get("paragraph_id")) == str(paragraph.get("id")) - ] - if filter_embedding_list is not None and len(filter_embedding_list) > 0: - find_embedding = filter_embedding_list[-1] - return find_embedding.get("comprehensive_score") - return 0 - - @staticmethod - def list_paragraph(embedding_list: List, vector): - paragraph_id_list = [row.get("paragraph_id") for row in embedding_list] - if paragraph_id_list is None or len(paragraph_id_list) == 0: - return [] - paragraph_list = native_search( - QuerySet(Paragraph).filter(id__in=paragraph_id_list), - get_file_content( - os.path.join(PROJECT_DIR, "apps", "application", "sql", "list_knowledge_paragraph_by_paragraph_id.sql") - ), - with_table_name=True, - ) - # 如果向量库中存在脏数据 直接删除 - if len(paragraph_list) != len(paragraph_id_list): - exist_paragraph_list = [row.get("id") for row in paragraph_list] - for paragraph_id in paragraph_id_list: - if not exist_paragraph_list.__contains__(paragraph_id): - vector.delete_by_paragraph_id(paragraph_id) - # 如果存在直接返回的则取直接返回段落 - hit_handling_method_paragraph = [ - paragraph - for paragraph in paragraph_list - if ( - paragraph.get("hit_handling_method") == "directly_return" - and BaseSearchDatasetStep.get_similarity(paragraph, embedding_list) - >= paragraph.get("directly_return_similarity") - ) - ] - if len(hit_handling_method_paragraph) > 0: - # 找到评分最高的 - return [ - sorted( - hit_handling_method_paragraph, key=lambda p: BaseSearchDatasetStep.get_similarity(p, embedding_list) - )[-1] - ] - return paragraph_list - - def get_details(self, manage, **kwargs): - step_args = self.context.get("step_args") or {} - - return { - "status": self.status, - "err_message": self.err_message, - "step_type": "search_step", - "paragraph_list": [row.to_dict() for row in (self.context.get("paragraph_list") or [])], - "run_time": self.context.get("run_time") or 0, - "problem_text": step_args.get("padding_problem_text") - if "padding_problem_text" in step_args - else step_args.get("problem_text"), - "model_name": self.context.get("model_name"), - "message_tokens": 0, - "answer_tokens": 0, - "cost": 0, - } diff --git a/apps/application/models/application_chat.py b/apps/application/models/application_chat.py index d617b65649a..5b87fb26f38 100644 --- a/apps/application/models/application_chat.py +++ b/apps/application/models/application_chat.py @@ -1,11 +1,12 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎虎 - @file: application_chat_log.py - @date:2025/5/29 17:12 - @desc: +@project: MaxKB +@Author:虎虎 +@file: application_chat_log.py +@date:2025/5/29 17:12 +@desc: """ + import uuid_utils.compat as uuid from django.contrib.postgres.fields import ArrayField from django.db import models @@ -19,12 +20,12 @@ class ChatUserType(models.TextChoices): - ANONYMOUS_USER = "ANONYMOUS_USER", '匿名用户' + ANONYMOUS_USER = "ANONYMOUS_USER", "匿名用户" CHAT_USER = "CHAT_USER", "对话用户" SYSTEM_API_KEY = "SYSTEM_API_KEY", "系统API_KEY" APPLICATION_API_KEY = "APPLICATION_API_KEY", "应用API_KEY" PLATFORM_USER = "PLATFORM_USER", "平台用户" - SYSTEM_USER = "SYSTEM_USER", '系统用户' + SYSTEM_USER = "SYSTEM_USER", "系统用户" class ExecuteType(models.TextChoices): @@ -33,7 +34,7 @@ class ExecuteType(models.TextChoices): def default_asker(): - return {'username': '游客'} + return {"username": "游客"} class Chat(AppModelMixin): @@ -41,10 +42,12 @@ class Chat(AppModelMixin): application = models.ForeignKey(Application, on_delete=models.CASCADE) abstract = models.CharField(max_length=1024, verbose_name="摘要") chat_user_id = models.CharField(verbose_name="对话用户id", default=None, null=True) - chat_user_type = models.CharField(max_length=64, verbose_name="客户端类型", choices=ChatUserType.choices, - default=ChatUserType.ANONYMOUS_USER) - execute_type = models.CharField(max_length=64, verbose_name="执行类型", choices=ChatUserType.choices, - default=ExecuteType.CHAT) + chat_user_type = models.CharField( + max_length=64, verbose_name="客户端类型", choices=ChatUserType.choices, default=ChatUserType.ANONYMOUS_USER + ) + execute_type = models.CharField( + max_length=64, verbose_name="执行类型", choices=ChatUserType.choices, default=ExecuteType.CHAT + ) is_deleted = models.BooleanField(verbose_name="逻辑删除", default=False) asker = models.JSONField(verbose_name="访问者", default=default_asker, encoder=SystemEncoder) meta = models.JSONField(verbose_name="元数据", default=dict) @@ -53,7 +56,7 @@ class Chat(AppModelMixin): chat_record_count = models.IntegerField(verbose_name="对话次数", default=0) mark_sum = models.IntegerField(verbose_name="标记数量", default=0) source = models.JSONField(verbose_name="来源", default=dict) - ip_address = models.CharField(max_length=128, verbose_name="ip地址", default='') + ip_address = models.CharField(max_length=128, verbose_name="ip地址", default="") class Meta: db_table = "application_chat" @@ -61,22 +64,23 @@ class Meta: class VoteChoices(models.TextChoices): """订单类型""" - UN_VOTE = "-1", '未投票' - STAR = "0", '赞同' - TRAMPLE = "1", '反对' + + UN_VOTE = "-1", "未投票" + STAR = "0", "赞同" + TRAMPLE = "1", "反对" class VoteReasonChoices(models.TextChoices): - ACCURATE = 'accurate', '内容准确' - COMPLETE = 'complete', '内容完善' - INACCURATE = 'inaccurate', '内容不准确' - INCOMPLETE = 'incomplete', '内容不完善' - OTHER = 'other', '其他' + ACCURATE = "accurate", "内容准确" + COMPLETE = "complete", "内容完善" + INACCURATE = "inaccurate", "内容不准确" + INCOMPLETE = "incomplete", "内容不完善" + OTHER = "other", "其他" class ShareLinkType(models.TextChoices): - PUBLIC = "PUBLIC", 'public' - PRIVATE = "PRIVATE", 'private' + PUBLIC = "PUBLIC", "public" + PRIVATE = "PRIVATE", "private" class ChatSourceChoices(models.TextChoices): @@ -95,48 +99,66 @@ class ChatRecord(AppModelMixin): """ 对话日志 详情 """ + id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id") chat = models.ForeignKey(Chat, on_delete=models.CASCADE) - vote_status = models.CharField(verbose_name='投票', max_length=10, choices=VoteChoices.choices, - default=VoteChoices.UN_VOTE) - vote_reason = models.CharField(verbose_name='投票原因', max_length=50, choices=VoteReasonChoices.choices, null=True, - blank=True) - vote_other_content = models.CharField(verbose_name='其他原因', max_length=1024, default='') + vote_status = models.CharField( + verbose_name="投票", max_length=10, choices=VoteChoices.choices, default=VoteChoices.UN_VOTE + ) + vote_reason = models.CharField( + verbose_name="投票原因", max_length=50, choices=VoteReasonChoices.choices, null=True, blank=True + ) + vote_other_content = models.CharField(verbose_name="其他原因", max_length=1024, default="") problem_text = models.CharField(max_length=10240, verbose_name="问题") answer_text = models.CharField(max_length=40960, verbose_name="答案") - answer_text_list = ArrayField(verbose_name="改进标注列表", - base_field=models.JSONField() - , default=list) + answer_text_list = ArrayField(verbose_name="改进标注列表", base_field=models.JSONField(), default=list) message_tokens = models.IntegerField(verbose_name="请求token数量", default=0) answer_tokens = models.IntegerField(verbose_name="响应token数量", default=0) const = models.IntegerField(verbose_name="总费用", default=0) details = models.JSONField(verbose_name="对话详情", default=dict, encoder=SystemEncoder) - improve_paragraph_id_list = ArrayField(verbose_name="改进标注列表", - base_field=models.UUIDField(max_length=128, blank=True) - , default=list) + improve_paragraph_id_list = ArrayField( + verbose_name="改进标注列表", base_field=models.UUIDField(max_length=128, blank=True), default=list + ) run_time = models.FloatField(verbose_name="运行时长", default=0) index = models.IntegerField(verbose_name="对话下标") source = models.JSONField(verbose_name="来源", default=dict) - ip_address = models.CharField(max_length=128, verbose_name="ip地址", default='') + ip_address = models.CharField(max_length=128, verbose_name="ip地址", default="") version = models.IntegerField(verbose_name="版本号", default=1) question = models.JSONField(verbose_name="用户的消息", default=dict, encoder=SystemEncoder) - messages = ArrayField(verbose_name="响应message", - base_field=models.JSONField() - , default=list) + messages = ArrayField(verbose_name="响应message", base_field=models.JSONField(), default=list) workflow_context = models.JSONField(verbose_name="工作流上下文", default=dict, null=True, blank=True) def get_human_message(self): - if 'problem_padding' in self.details: - return HumanMessage(content=self.details.get('problem_padding').get('padding_problem_text')) - return HumanMessage(content=self.problem_text) + # 用户消息取自 question({content, image_list, ...}),历史上下文用文本部分 + question = self.question if isinstance(self.question, dict) else {"content": self.question or ""} + return [HumanMessage(content=question.get("content", "") or "")] def get_ai_message(self): - answer_text = self.answer_text - if answer_text is None or len(str(answer_text).strip()) == 0: - answer_text = _( - 'Sorry, no relevant content was found. Please re-describe your problem or provide more information. ') - return AIMessage(content=answer_text) + # 答案取自 messages 中的 TEXT / TOOL 内容块(REASONING/FORM/FAILURE 不进历史),按顺序保留交错。 + # 注意:type 用字面量,避免 models 反向依赖 application.workflow.ContentType + ai_message_list = [] + for m in self.messages or []: + if not isinstance(m, dict): + continue + m_type = m.get("type") + if m_type == "TEXT": + if m.get("content"): + ai_message_list.append(AIMessage(content=m.get("content"))) + elif m_type == "TOOL": + # 工具调用:名称 + 入参 + 结果 拼成一段 + tool_parts = [str(p) for p in (m.get("content"), m.get("arguments"), m.get("result")) if p] + if tool_parts: + ai_message_list.append(AIMessage(content="\n".join(tool_parts))) + if len(ai_message_list) == 0: + ai_message_list = [ + AIMessage( + content=_( + "Sorry, no relevant content was found. Please re-describe your problem or provide more information. " + ) + ) + ] + return ai_message_list def get_node_details_runtime_node_id(self, runtime_node_id): return self.details.get(runtime_node_id, None) @@ -148,8 +170,9 @@ class Meta: class ApplicationChatUserStats(AppModelMixin): id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id") chat_user_id = models.UUIDField(max_length=128, default=uuid.uuid7, verbose_name="对话用户id") - chat_user_type = models.CharField(max_length=64, verbose_name="对话用户类型", choices=ChatUserType.choices, - default=ChatUserType.ANONYMOUS_USER) + chat_user_type = models.CharField( + max_length=64, verbose_name="对话用户类型", choices=ChatUserType.choices, default=ChatUserType.ANONYMOUS_USER + ) application = models.ForeignKey(Application, on_delete=models.CASCADE, verbose_name="应用id") access_num = models.IntegerField(default=0, verbose_name="访问总次数次数") intraday_access_num = models.IntegerField(default=0, verbose_name="当日访问次数") @@ -157,7 +180,7 @@ class ApplicationChatUserStats(AppModelMixin): class Meta: db_table = "application_chat_user_stats" indexes = [ - models.Index(fields=['application_id', 'chat_user_id']), + models.Index(fields=["application_id", "chat_user_id"]), ] @@ -181,7 +204,7 @@ class ApplicationLongTermMemory(AppModelMixin): class Meta: db_table = "application_long_term_memory" - unique_together = [('application', 'chat_user_id')] + unique_together = [("application", "chat_user_id")] indexes = [ - models.Index(fields=['application_id', 'chat_user_id']), + models.Index(fields=["application_id", "chat_user_id"]), ] diff --git a/apps/application/serializers/common.py b/apps/application/serializers/common.py index a19b70760c4..e238984b5ed 100644 --- a/apps/application/serializers/common.py +++ b/apps/application/serializers/common.py @@ -121,6 +121,46 @@ def set_record(self, tool_record): ) +def resolve_chat_user(chat_user_id, chat_user_type, asker=None): + """ + 根据对话用户 id / 类型解析出对话用户信息。 + - 登录的对话用户(CHAT_USER):从 ChatUser 表取真实信息 + - 匿名/其他:优先用 asker(dict 或用户名),否则回退为“游客” + """ + from system_manage.models import ChatUser + + if chat_user_type == ChatUserType.CHAT_USER.value: + chat_user = QuerySet(ChatUser).filter(id=chat_user_id).first() + return { + "id": str(chat_user.id), + "email": chat_user.email, + "phone": chat_user.phone, + "nick_name": chat_user.nick_name, + "username": chat_user.username, + "source": chat_user.source, + } + if asker: + if isinstance(asker, dict): + return asker + return {"username": asker} + return {"username": "游客"} + + +def resolve_chat_user_group(chat_user): + chat_user_id = chat_user.get("id") + if not chat_user_id: + return [] + user_group_relation_model = DatabaseModelManage.get_model("user_group_relation") + if user_group_relation_model: + return [ + {"id": user_group_relation.group_id, "name": user_group_relation.group.name} + for user_group_relation in QuerySet(user_group_relation_model) + .select_related("group") + .filter(user_id=chat_user_id) + ] + return [] + + class ChatInfo: def __init__( self, @@ -207,45 +247,14 @@ def get_application(self): def get_chat_user(self, asker=None): if self.chat_user: return self.chat_user - from system_manage.models import ChatUser - - chat_user_model = ChatUser - if self.chat_user_type == ChatUserType.CHAT_USER.value and chat_user_model: - chat_user = QuerySet(chat_user_model).filter(id=self.chat_user_id).first() - return { - "id": str(chat_user.id), - "email": chat_user.email, - "phone": chat_user.phone, - "nick_name": chat_user.nick_name, - "username": chat_user.username, - "source": chat_user.source, - } - else: - if asker: - if isinstance(asker, dict): - self.chat_user = asker - else: - self.chat_user = {"username": asker} - else: - self.chat_user = {"username": "游客"} - return self.chat_user + chat_user = resolve_chat_user(self.chat_user_id, self.chat_user_type, asker=asker) + # 保持原有语义:仅非登录用户缓存到实例上 + if self.chat_user_type != ChatUserType.CHAT_USER.value: + self.chat_user = chat_user + return chat_user def get_chat_user_group(self, asker=None): - chat_user = self.get_chat_user(asker=asker) - chat_user_id = chat_user.get("id") - - if not chat_user_id: - return [] - - user_group_relation_model = DatabaseModelManage.get_model("user_group_relation") - if user_group_relation_model: - return [ - {"id": user_group_relation.group_id, "name": user_group_relation.group.name} - for user_group_relation in QuerySet(user_group_relation_model) - .select_related("group") - .filter(user_id=chat_user_id) - ] - return [] + return resolve_chat_user_group(self.get_chat_user(asker=asker)) def to_base_pipeline_manage_params(self): self.get_application() diff --git a/apps/application/views/application_chat.py b/apps/application/views/application_chat.py index 4537025d581..01dbcb464ec 100644 --- a/apps/application/views/application_chat.py +++ b/apps/application/views/application_chat.py @@ -22,7 +22,7 @@ from application.serializers.application_chat import ApplicationChatQuerySerializers from chat.api.chat_api import ChatAPI, PromptGenerateAPI, PageHistoricalConversationAPI, HistoricalConversationRecordAPI from chat.api.chat_authentication_api import ChatOpenAPI -from chat.serializers.chat import OpenChatSerializers, DebugChatSerializers, PromptGenerateSerializer, ResumeSerializers +from chat.serializers.chat import DebugChatSerializers, OpenChatSerializers, PromptGenerateSerializer, ResumeSerializers from common.auth import TokenAuth from common.auth.authentication import has_permissions from common.auth.constants.compare_constants import CompareConstants diff --git a/apps/application/workflow/nodes/ai_chat_node/ai_chat_node.py b/apps/application/workflow/nodes/ai_chat_node/ai_chat_node.py index 2b4583324d4..b2205c020a8 100644 --- a/apps/application/workflow/nodes/ai_chat_node/ai_chat_node.py +++ b/apps/application/workflow/nodes/ai_chat_node/ai_chat_node.py @@ -100,7 +100,7 @@ def _get_node_message(chat_record, runtime_node_id): def _get_workflow_message(chat_record): - return [chat_record.get_human_message(), chat_record.get_ai_message()] + return [*chat_record.get_human_message(), *chat_record.get_ai_message()] def _get_message(chat_record, dialogue_type, runtime_node_id): diff --git a/apps/application/workflow/nodes/image_generate_node/image_generate_node.py b/apps/application/workflow/nodes/image_generate_node/image_generate_node.py index 9611864b37c..a5d5a448e5e 100644 --- a/apps/application/workflow/nodes/image_generate_node/image_generate_node.py +++ b/apps/application/workflow/nodes/image_generate_node/image_generate_node.py @@ -130,7 +130,7 @@ def _get_history_message(self, history_chat_record, dialogue_number): [ [ self._generate_history_human_message(history_chat_record[index]), - self._generate_history_ai_message(history_chat_record[index]), + *self._generate_history_ai_message(history_chat_record[index]), ] for index in range(max(start_index, 0), len(history_chat_record)) ], @@ -152,9 +152,11 @@ def _generate_history_ai_message(self, chat_record): if val.get("dialogue_type") == "WORKFLOW": return chat_record.get_ai_message() image_list = val["image_list"] - return AIMessage( - content=[{"type": "image_url", "image_url": {"url": f"{file_url}"}} for file_url in image_list] - ) + return [ + AIMessage( + content=[{"type": "image_url", "image_url": {"url": f"{file_url}"}} for file_url in image_list] + ) + ] return chat_record.get_ai_message() def _upload_file(self, file, workflow_params, workflow_type): diff --git a/apps/application/workflow/nodes/image_to_video_node/image_to_video_node.py b/apps/application/workflow/nodes/image_to_video_node/image_to_video_node.py index 31df25c47b6..7c1ba29c06b 100644 --- a/apps/application/workflow/nodes/image_to_video_node/image_to_video_node.py +++ b/apps/application/workflow/nodes/image_to_video_node/image_to_video_node.py @@ -213,9 +213,13 @@ def _generate_history_ai_message(self, chat_record): if val["dialogue_type"] == "WORKFLOW": return chat_record.get_ai_message() image_list = val["image_list"] - return AIMessage( - content=[*[{"type": "image_url", "image_url": {"url": f"{file_url}"}} for file_url in image_list]] - ) + return [ + AIMessage( + content=[ + *[{"type": "image_url", "image_url": {"url": f"{file_url}"}} for file_url in image_list] + ] + ) + ] return chat_record.get_ai_message() def _get_history_message(self, history_chat_record, dialogue_number): @@ -225,7 +229,7 @@ def _get_history_message(self, history_chat_record, dialogue_number): [ [ self._generate_history_human_message(history_chat_record[index]), - self._generate_history_ai_message(history_chat_record[index]), + *self._generate_history_ai_message(history_chat_record[index]), ] for index in range(start_index if start_index > 0 else 0, len(history_chat_record)) ], diff --git a/apps/application/workflow/nodes/image_understand_node/image_understand_node.py b/apps/application/workflow/nodes/image_understand_node/image_understand_node.py index 6882d3e5fc3..77b3d4d9708 100644 --- a/apps/application/workflow/nodes/image_understand_node/image_understand_node.py +++ b/apps/application/workflow/nodes/image_understand_node/image_understand_node.py @@ -237,7 +237,7 @@ def _get_history_message_for_details(self, history_chat_record, dialogue_number) [ [ self._generate_history_human_message_for_details(history_chat_record[index]), - self._generate_history_ai_message(history_chat_record[index]), + *self._generate_history_ai_message(history_chat_record[index]), ] for index in range(start_index if start_index > 0 else 0, len(history_chat_record)) ], @@ -250,7 +250,7 @@ def _generate_history_ai_message(self, chat_record): if self.node.id == val["node_id"] and "image_list" in val: if val["dialogue_type"] == "WORKFLOW": return chat_record.get_ai_message() - return AIMessage(content=val["answer"]) + return [AIMessage(content=val["answer"])] return chat_record.get_ai_message() def _generate_history_human_message_for_details(self, chat_record): @@ -285,7 +285,7 @@ def _get_history_message(self, history_chat_record, dialogue_number): [ [ self._generate_history_human_message(history_chat_record[index]), - self._generate_history_ai_message(history_chat_record[index]), + *self._generate_history_ai_message(history_chat_record[index]), ] for index in range(start_index if start_index > 0 else 0, len(history_chat_record)) ], diff --git a/apps/application/workflow/nodes/intent_node/intent_node.py b/apps/application/workflow/nodes/intent_node/intent_node.py index 0137b12b3a8..6d1b61aed7b 100644 --- a/apps/application/workflow/nodes/intent_node/intent_node.py +++ b/apps/application/workflow/nodes/intent_node/intent_node.py @@ -134,7 +134,7 @@ def _get_history_message(self, history_chat_record, dialogue_number): history_message = reduce( lambda x, y: [*x, *y], [ - [history_chat_record[index].get_human_message(), history_chat_record[index].get_ai_message()] + [*history_chat_record[index].get_human_message(), *history_chat_record[index].get_ai_message()] for index in range(start_index if start_index > 0 else 0, len(history_chat_record)) ], [], diff --git a/apps/application/workflow/nodes/question_node/question_node.py b/apps/application/workflow/nodes/question_node/question_node.py index c11d568f92d..7509e209bed 100644 --- a/apps/application/workflow/nodes/question_node/question_node.py +++ b/apps/application/workflow/nodes/question_node/question_node.py @@ -47,7 +47,7 @@ def _get_history_message(history_chat_record, dialogue_number): history_message = reduce( lambda x, y: [*x, *y], [ - [history_chat_record[index].get_human_message(), history_chat_record[index].get_ai_message()] + [*history_chat_record[index].get_human_message(), *history_chat_record[index].get_ai_message()] for index in range(start_index if start_index > 0 else 0, len(history_chat_record)) ], [], diff --git a/apps/application/workflow/nodes/text_to_video_node/text_to_video_node.py b/apps/application/workflow/nodes/text_to_video_node/text_to_video_node.py index 0b8afe3b094..5fe0a47d5eb 100644 --- a/apps/application/workflow/nodes/text_to_video_node/text_to_video_node.py +++ b/apps/application/workflow/nodes/text_to_video_node/text_to_video_node.py @@ -178,9 +178,13 @@ def _generate_history_ai_message(self, chat_record): if val["dialogue_type"] == "WORKFLOW": return chat_record.get_ai_message() image_list = val["image_list"] - return AIMessage( - content=[*[{"type": "image_url", "image_url": {"url": f"{file_url}"}} for file_url in image_list]] - ) + return [ + AIMessage( + content=[ + *[{"type": "image_url", "image_url": {"url": f"{file_url}"}} for file_url in image_list] + ] + ) + ] return chat_record.get_ai_message() def _get_history_message(self, history_chat_record, dialogue_number): @@ -190,7 +194,7 @@ def _get_history_message(self, history_chat_record, dialogue_number): [ [ self._generate_history_human_message(history_chat_record[index]), - self._generate_history_ai_message(history_chat_record[index]), + *self._generate_history_ai_message(history_chat_record[index]), ] for index in range(start_index if start_index > 0 else 0, len(history_chat_record)) ], diff --git a/apps/chat/serializers/chat.py b/apps/chat/serializers/chat.py index 918041823c6..c01a0a9f6aa 100644 --- a/apps/chat/serializers/chat.py +++ b/apps/chat/serializers/chat.py @@ -4,171 +4,87 @@ @Author:虎虎 @file: chat.py @date:2025/6/9 11:23 -@desc: +@desc: 对话新实现(统一走 workflow 引擎、去除 ChatInfo 与 Redis 会话缓存)。 """ import json import os +import queue import queue as thread_queue import threading -from gettext import gettext -from typing import List, Dict import uuid_utils import uuid_utils.compat as uuid from django.db.models import QuerySet from django.http import StreamingHttpResponse +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from langchain_core.messages import HumanMessage, AIMessage, SystemMessage from rest_framework import serializers from rest_framework.request import Request -from application.chat_pipeline.pipeline_manage import PipelineManage -from application.chat_pipeline.step.chat_step.i_chat_step import PostResponseHandler -from application.chat_pipeline.step.chat_step.impl.base_chat_step import BaseChatStep -from application.chat_pipeline.step.generate_human_message_step.impl.base_generate_human_message_step import ( - BaseGenerateHumanMessageStep, -) -from application.chat_pipeline.step.reset_problem_step.impl.base_reset_problem_step import BaseResetProblemStep -from application.chat_pipeline.step.search_dataset_step.impl.base_search_dataset_step import BaseSearchDatasetStep -from application.flow.common import Answer from application.flow.tools import to_stream_response_simple from application.models import ( Application, - ApplicationTypeChoices, - ChatUserType, - ApplicationChatUserStats, + ApplicationVersion, ApplicationAccessToken, - ChatRecord, + ApplicationChatUserStats, Chat, - ApplicationVersion, + ChatRecord, + ChatUserType, + ExecuteType, ) from application.serializers.application import ApplicationOperateSerializer -from application.serializers.common import ChatInfo +from application.serializers.application_chat import ChatCountSerializer +from application.serializers.common import resolve_chat_user, resolve_chat_user_group +from chat.serializers.chat_history import ChatHistory from application.workflow.common import WorkflowType, new_instance from application.workflow.message.aggregator import AggregationManager from application.workflow.message.struct.failure_content import FailureContent -from application.workflow.message.struct.form_content import FormContent -from application.workflow.message.struct.reasoning_content import ReasoningContent -from application.workflow.message.struct.text_content import TextContent -from application.workflow.message.struct.tool_content import ToolContent from application.workflow.message_queue import get_message_queue from application.workflow.nodes import get_start_node from application.workflow.workflow_manage import WorkflowManage, CallBack from application.workflow.workflow_run_registry import WorkflowRunRegistry +from chat.template.agent_simple import build_workflow from common import result -from common.database_model_manage.database_model_manage import DatabaseModelManage from common.exception.app_exception import AppApiException, AppChatNumOutOfBoundsFailed, ChatException from common.handle.base_to_response import BaseToResponse from common.handle.impl.response.openai_to_response import OpenaiToResponse from common.handle.impl.response.system_to_response import SystemToResponse -from common.utils.common import flat_map, get_file_content, is_valid_uuid +from common.utils.common import get_file_content from common.utils.logger import maxkb_logger -from knowledge.models import Document, Paragraph from maxkb.conf import PROJECT_DIR from models_provider.models import Model, Status from models_provider.tools import get_model_instance_by_model_workspace_id from system_manage.models.chat_user_token_quota import ChatUserTokenQuota -from system_manage.models.resource_mapping import ResourceMapping +# 「Chat 行尚未查询」的哨兵,区别于「查询过但不存在(None)」 +_CHAT_UNSET = object() -class ChatMessagesSerializers(serializers.Serializer): - role = serializers.CharField(required=True, label=_("Role")) - content = serializers.CharField(required=True, label=_("Content")) - -class GeneratePromptSerializers(serializers.Serializer): - prompt = serializers.CharField(required=True, label=_("Prompt template")) - messages = serializers.ListSerializer(child=ChatMessagesSerializers(), required=True, label=_("Chat context")) - - def is_valid(self, *, raise_exception=False): - super().is_valid(raise_exception=True) - messages = self.data.get("messages") - - if len(messages) > 30: - raise AppApiException(400, _("Too many messages")) - - for index in range(len(messages)): - role = messages[index].get("role") - if role == "ai" and index % 2 != 1: - raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct.")) - if role == "user" and index % 2 != 0: - raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct.")) - if role not in ["user", "ai"]: - raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct.")) +def get_work_flow(application): + if application.type == "WORK_FLOW": + return application.work_flow + return build_workflow(application) class ChatMessageSerializers(serializers.Serializer): + """新流程的对话入参(去掉旧工作流调试字段 node_id/runtime_node_id/node_data/child_node)。""" + message = serializers.DictField(required=True, label=_("User Questions")) - stream = serializers.BooleanField(required=True, label=_("Is the answer in streaming mode")) - re_chat = serializers.BooleanField(required=True, label=_("Do you want to reply again")) + stream = serializers.BooleanField(required=False, default=True, label=_("Is the answer in streaming mode")) + re_chat = serializers.BooleanField(required=False, default=False, label=_("Do you want to reply again")) chat_record_id = serializers.UUIDField(required=False, allow_null=True, label=_("Conversation record id")) - - node_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Node id")) - - runtime_node_id = serializers.CharField( - required=False, allow_null=True, allow_blank=True, label=_("Runtime node id") - ) - - node_data = serializers.DictField(required=False, allow_null=True, label=_("Node parameters")) - form_data = serializers.DictField(required=False, label=_("Global variables")) - child_node = serializers.DictField(required=False, allow_null=True, label=_("Child Nodes")) - - -def get_post_handler(chat_info: ChatInfo): - class PostHandler(PostResponseHandler): - def handler( - self, - chat_id, - chat_record_id, - paragraph_list: List[Paragraph], - problem_text: str, - answer_text, - manage: PipelineManage, - step: BaseChatStep, - padding_problem_text: str = None, - **kwargs, - ): - answer_list = [ - [ - Answer( - answer_text, - "ai-chat-node", - "ai-chat-node", - "ai-chat-node", - {}, - "ai-chat-node", - kwargs.get("reasoning_content", ""), - ).to_dict() - ] - ] - chat_record = ChatRecord( - id=chat_record_id, - chat_id=chat_id, - problem_text=problem_text, - answer_text=answer_text, - details=manage.get_details(), - message_tokens=manage.context["message_tokens"], - answer_tokens=manage.context["answer_tokens"], - answer_text_list=answer_list, - run_time=manage.context["run_time"], - index=len(chat_info.chat_record_list) + 1, - ip_address=chat_info.ip_address, - source=chat_info.source, - ) - chat_info.append_chat_record(chat_record) - # 重新设置缓存 - chat_info.set_cache() - - return PostHandler() + # Form 提交时的定位信息 {id, index, children} + position = serializers.DictField(required=False, allow_null=True, label=_("Form position")) + chunk_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Chunk id")) class DebugChatSerializers(serializers.Serializer): chat_id = serializers.UUIDField(required=True, label=_("Conversation ID")) - # 以下字段用于「缓存缺失时按前端提供的 chat_id 现开会话」(open-if-missing) workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID")) - application_id = serializers.UUIDField(required=False, allow_null=True, label=_("Application ID")) + application_id = serializers.UUIDField(required=True, label=_("Application ID")) chat_user_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Client id")) chat_user_type = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Client Type")) ip_address = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("IP Address")) @@ -176,179 +92,19 @@ class DebugChatSerializers(serializers.Serializer): def chat(self, instance: dict, base_to_response: BaseToResponse = SystemToResponse()): self.is_valid(raise_exception=True) - chat_id = self.data.get("chat_id") - chat_info: ChatInfo = ChatInfo.get_cache(chat_id) - if chat_info is None: - # 前端本地生成的 chat_id 首次发消息时,缓存里还没有会话,按该 id 现开一个 debug 会话。 - OpenChatSerializers( - data={ - "workspace_id": self.data.get("workspace_id"), - "application_id": self.data.get("application_id"), - "chat_user_id": self.data.get("chat_user_id"), - "chat_user_type": self.data.get("chat_user_type"), - "ip_address": self.data.get("ip_address"), - "source": self.data.get("source"), - "debug": True, - } - ).open(chat_id=str(chat_id)) - chat_info = ChatInfo.get_cache(chat_id) - application = QuerySet(Application).filter(id=chat_info.application_id).first() - chat_info.application = application return ChatSerializers( data={ - "chat_id": chat_id, - "chat_user_id": chat_info.chat_user_id, - "chat_user_type": chat_info.chat_user_type, - "application_id": chat_info.application.id, + "chat_id": self.data.get("chat_id"), + "chat_user_id": self.data.get("chat_user_id"), + "chat_user_type": self.data.get("chat_user_type"), + "application_id": self.data.get("application_id"), + "ip_address": self.data.get("ip_address"), + "source": self.data.get("source"), "debug": True, } ).chat(instance, base_to_response) -SYSTEM_ROLE = get_file_content(os.path.join(PROJECT_DIR, "apps", "chat", "template", "generate_prompt_system")) - - -class PromptGenerateSerializer(serializers.Serializer): - workspace_id = serializers.CharField(required=False, label=_("Workspace ID")) - model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model")) - application_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Application")) - - def is_valid(self, *, raise_exception=False): - super().is_valid(raise_exception=True) - workspace_id = self.data.get("workspace_id") - query_set = QuerySet(Application).filter(id=self.data.get("application_id")) - if workspace_id: - query_set = query_set.filter(workspace_id=workspace_id) - application = query_set.first() - if application is None: - raise AppApiException(500, _("Application id does not exist")) - return application - - def generate_prompt(self, instance: dict): - application = self.is_valid(raise_exception=True) - GeneratePromptSerializers(data=instance).is_valid(raise_exception=True) - workspace_id = self.data.get("workspace_id") - model_id = self.data.get("model_id") - prompt = instance.get("prompt") - messages = instance.get("messages") - - message = messages[-1]["content"] - q = prompt.replace("{userInput}", message) - - messages[-1]["content"] = q - SUPPORTED_MODEL_TYPES = ["LLM", "IMAGE"] - model_exist = QuerySet(Model).filter(id=model_id, model_type__in=SUPPORTED_MODEL_TYPES).exists() - if not model_exist: - raise Exception(_("Model does not exists or is not an LLM model")) - - def process(): - model = get_model_instance_by_model_workspace_id( - model_id=model_id, workspace_id=workspace_id, **application.model_params_setting - ) - try: - for r in model.stream( - [ - SystemMessage(content=SYSTEM_ROLE), - *[ - HumanMessage(content=m.get("content")) - if m.get("role") == "user" - else AIMessage(content=m.get("content")) - for m in messages - ], - ] - ): - yield "data: " + json.dumps({"content": r.content}) + "\n\n" - except Exception as e: - yield "data: " + json.dumps({"error": str(e)}) + "\n\n" - - return to_stream_response_simple(process()) - - -class OpenAIMessage(serializers.Serializer): - content = serializers.CharField(required=True, label=_("content")) - role = serializers.CharField(required=True, label=_("Role")) - - -class OpenAIInstanceSerializer(serializers.Serializer): - messages = serializers.ListField(child=OpenAIMessage()) - chat_id = serializers.UUIDField(required=False, label=_("Conversation ID")) - re_chat = serializers.BooleanField(required=False, label=_("Regenerate")) - stream = serializers.BooleanField(required=False, label=_("Streaming Output")) - - -class OpenAIChatSerializer(serializers.Serializer): - application_id = serializers.UUIDField(required=True, label=_("Application ID")) - chat_user_id = serializers.CharField(required=True, label=_("Client id")) - chat_user_type = serializers.CharField(required=True, label=_("Client Type")) - ip_address = serializers.CharField(required=False, label=_("IP Address")) - source = serializers.JSONField(required=False, label=_("Source")) - - @staticmethod - def get_message(instance): - return instance.get("messages")[-1].get("content") - - @staticmethod - def generate_chat(chat_id, application_id, message, chat_user_id, chat_user_type, ip_address, source): - if chat_id is None: - chat_id = str(uuid.uuid1()) - chat_info = ChatInfo(chat_id, chat_user_id, chat_user_type, ip_address, source, [], [], application_id) - chat_info.set_cache() - else: - chat_info = ChatInfo.get_cache(chat_id) - if chat_info is None: - open_chat = ChatSerializers( - data={ - "chat_id": chat_id, - "chat_user_id": chat_user_id, - "chat_user_type": chat_user_type, - "application_id": application_id, - "ip_address": ip_address, - "source": source, - } - ) - open_chat.is_valid(raise_exception=True) - chat_info = open_chat.re_open_chat(chat_id) - chat_info.set_cache() - return chat_id - - def chat(self, instance: Dict, with_valid=True): - if with_valid: - self.is_valid(raise_exception=True) - OpenAIInstanceSerializer(data=instance).is_valid(raise_exception=True) - chat_id = instance.get("chat_id") - message = self.get_message(instance) - re_chat = instance.get("re_chat", False) - stream = instance.get("stream", False) - application_id = self.data.get("application_id") - chat_user_id = self.data.get("chat_user_id") - chat_user_type = self.data.get("chat_user_type") - ip_address = self.data.get("ip_address") - source = self.data.get("source") - chat_id = self.generate_chat(chat_id, application_id, message, chat_user_id, chat_user_type, ip_address, source) - return ChatSerializers( - data={ - "chat_id": chat_id, - "chat_user_id": chat_user_id, - "chat_user_type": chat_user_type, - "application_id": application_id, - "ip_address": ip_address, - "source": source, - } - ).chat( - { - "message": message, - "re_chat": re_chat, - "stream": stream, - "form_data": instance.get("form_data", {}), - "image_list": instance.get("image_list", []), - "document_list": instance.get("document_list", []), - "audio_list": instance.get("audio_list", []), - "other_list": instance.get("other_list", []), - }, - base_to_response=OpenaiToResponse(), - ) - - class ChatSerializers(serializers.Serializer): chat_id = serializers.UUIDField(required=True, label=_("Conversation ID")) chat_user_id = serializers.CharField(required=True, label=_("Client id")) @@ -358,12 +114,27 @@ class ChatSerializers(serializers.Serializer): ip_address = serializers.CharField(required=False, label=_("IP Address"), allow_null=True, allow_blank=True) source = serializers.JSONField(required=False, label=_("Source")) - def is_valid_application_workflow(self, *, raise_exception=False): - self.is_valid_intraday_access_num() - - def is_valid_chat_id(self, chat_info: ChatInfo): - if self.data.get("application_id") is not None and self.data.get("application_id") != str( - chat_info.application_id + # ---------- 会话行(一次查询,全程复用) ---------- + def get_chat(self): + """查询 Chat 行并缓存到实例,全流程只查一次(区分未查询/不存在)。""" + cached = getattr(self, "_chat_cache", _CHAT_UNSET) + if cached is _CHAT_UNSET: + cached = QuerySet(Chat).filter(id=self.data.get("chat_id")).first() + self._chat_cache = cached + return cached + + # ---------- 校验 ---------- + def is_valid_chat(self): + """ + 会话不存在 → 视为新会话,后续 ensure_chat_row 惰性创建,无需前端传标记; + 会话已存在 → 校验归属(必须属于当前应用与当前对话用户),防止越权写入。 + debug 会话同样落库(execute_type=DEBUG)、同样按此校验,不再特殊放行。 + """ + chat = self.get_chat() + if chat is None: + return + if str(chat.application_id) != str(self.data.get("application_id")) or str(chat.chat_user_id) != str( + self.data.get("chat_user_id") ): raise ChatException(500, _("Conversation does not exist")) @@ -393,120 +164,127 @@ def is_valid_intraday_access_num(self): if application_access_token.access_num <= access_client.intraday_access_num: raise AppChatNumOutOfBoundsFailed(1002, _("The number of visits exceeds today's visits")) - def is_valid_application_simple(self, *, chat_info: ChatInfo, raise_exception=False): - self.is_valid_intraday_access_num() - model_id = chat_info.application.model_id - if model_id is None: - return chat_info - model = QuerySet(Model).filter(id=model_id).first() - if model is None: - return chat_info - if model.status == Status.ERROR: - raise ChatException(500, _("The current model is not available")) - if model.status == Status.DOWNLOAD: - raise ChatException(500, _("The model is downloading, please try again later")) - return chat_info - - def chat_simple(self, chat_info: ChatInfo, instance, base_to_response): - message_dict = instance.get("message") - message = message_dict.get("content", "") if isinstance(message_dict, dict) else message_dict - re_chat = instance.get("re_chat") - stream = instance.get("stream") - chat_user_id = self.data.get("chat_user_id") - chat_user_type = self.data.get("chat_user_type") - ip_address = self.data.get("ip_address") - source = self.data.get("source") - form_data = instance.get("form_data") - chat_record_id = instance.get("chat_record_id") - pipeline_manage_builder = PipelineManage.builder() - # 如果开启了问题优化,则添加上问题优化步骤 - if chat_info.application.problem_optimization: - pipeline_manage_builder.append_step(BaseResetProblemStep) - # 构建流水线管理器 - pipeline_message = ( - pipeline_manage_builder.append_step(BaseSearchDatasetStep) - .append_step(BaseGenerateHumanMessageStep) - .append_step(BaseChatStep) - .add_base_to_response(base_to_response) - .add_debug(self.data.get("debug", False)) - .build() - ) - exclude_paragraph_id_list = [] - # 相同问题是否需要排除已经查询到的段落 - if re_chat: - paragraph_id_list = flat_map( - [ - [paragraph.get("id") for paragraph in chat_record.details["search_step"]["paragraph_list"]] - for chat_record in chat_info.chat_record_list - if chat_record.problem_text == message - and "search_step" in chat_record.details - and "paragraph_list" in chat_record.details["search_step"] - ] + # ---------- application ---------- + def get_application(self): + """debug 取 Application 本体;非 debug 取最新发布的 ApplicationVersion。""" + application_id = self.data.get("application_id") + if self.data.get("debug"): + application = QuerySet(Application).filter(id=application_id).first() + if application is None: + raise ChatException(500, _("The application does not exist")) + else: + application = ( + QuerySet(ApplicationVersion).filter(application_id=application_id).order_by("-create_time")[0:1].first() ) - exclude_paragraph_id_list = list(set(paragraph_id_list)) - # 构建运行参数 - params = chat_info.to_pipeline_manage_params( - message, - get_post_handler(chat_info), - exclude_paragraph_id_list, - chat_user_id, - chat_user_type, - ip_address, - source, - stream, - form_data, + if application is None: + raise ChatException(500, _("The application has not been published. Please use it after publishing.")) + return application + + def ensure_chat_row(self, question, asker): + """Chat 行不存在则创建(debug 记为 DEBUG 类型),返回该行。复用 get_chat 的一次查询。""" + chat = self.get_chat() + if chat is not None: + return chat + chat = Chat( + id=self.data.get("chat_id"), + application_id=self.data.get("application_id"), + abstract=(question or "")[0:1024], + execute_type=ExecuteType.DEBUG if self.data.get("debug") else ExecuteType.CHAT, + chat_user_id=self.data.get("chat_user_id"), + chat_user_type=self.data.get("chat_user_type"), + ip_address=self.data.get("ip_address"), + source=self.data.get("source"), + asker=asker, + ) + chat.save() + self._chat_cache = chat + return chat + + def save_chat_record(self, chat_record_id, question, asker, new_record): + """插入一条占位 ChatRecord(workflow 完成后由 update_chat_record 回填)。""" + chat_id = self.data.get("chat_id") + q_text = question.get("content", "") if isinstance(question, dict) else question + self.ensure_chat_row(q_text, asker) + defaults = { + "chat_id": chat_id, + "problem_text": "", + "answer_text": "", + "details": {}, + "message_tokens": 0, + "answer_tokens": 0, + "answer_text_list": [[]], + "run_time": 0, + # index 现在用不上,字段 NOT NULL 故给常量 0 + "index": 0, + "ip_address": self.data.get("ip_address") or "", + "source": self.data.get("source"), + "workflow_context": {}, + "question": question, + "messages": [], + } + if new_record: + # 全新记录:直接插入,省掉 update_or_create 的一次 SELECT + ChatRecord(id=chat_record_id, **defaults).save(force_insert=True) + else: + # Form 提交 / 重答:复用同一 chat_record_id + QuerySet(ChatRecord).update_or_create(id=chat_record_id, defaults=defaults) + + @staticmethod + def _usage_from_context(workflow_context): + """从 workflow_context 汇总 token 用量:prompt=message_tokens, completion=answer_tokens。""" + prompt_tokens = sum( + v.get("message_tokens", 0) + for v in workflow_context.values() + if isinstance(v, dict) and "message_tokens" in v + ) + completion_tokens = sum( + v.get("answer_tokens", 0) for v in workflow_context.values() if isinstance(v, dict) and "answer_tokens" in v ) - if chat_record_id: - params["chat_record_id"] = chat_record_id - chat_info.set_chat(message) - # 运行流水线作业 - pipeline_message.run(params) - return pipeline_message.context["chat_result"] + return {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens} @staticmethod - def get_chat_record(chat_info, chat_record_id): - if chat_info is not None: - chat_record_list = [ - chat_record for chat_record in chat_info.chat_record_list if str(chat_record.id) == str(chat_record_id) - ] - if chat_record_list is not None and len(chat_record_list): - return chat_record_list[-1] - chat_record = QuerySet(ChatRecord).filter(id=chat_record_id, chat_id=chat_info.chat_id).first() - if chat_record is None: - if not is_valid_uuid(chat_record_id): - raise ChatException(500, _("Conversation record does not exist")) - chat_record = QuerySet(ChatRecord).filter(id=chat_record_id).first() - return chat_record - - def chat_work_flow(self, chat_info: ChatInfo, instance: dict, base_to_response): - import queue + def update_chat_record(chat_user_id, chat_record_id, workflow_context, messages): + usage = ChatSerializers._usage_from_context(workflow_context) + message_tokens = usage["prompt_tokens"] + answer_tokens = usage["completion_tokens"] + ChatUserTokenQuota.consume(chat_user_id, message_tokens + answer_tokens) + QuerySet(ChatRecord).filter(id=chat_record_id).update( + workflow_context=workflow_context, + messages=messages, + message_tokens=message_tokens, + answer_tokens=answer_tokens, + ) + # ---------- 执行 ---------- + def chat_work_flow(self, application, instance: dict, base_to_response): message_dict = instance.get("message") message = message_dict.get("content", "") if isinstance(message_dict, dict) else message_dict re_chat = instance.get("re_chat") stream = instance.get("stream") + chat_id = self.data.get("chat_id") chat_user_id = self.data.get("chat_user_id") chat_user_type = self.data.get("chat_user_type") ip_address = self.data.get("ip_address") source = self.data.get("source") - form_data = instance.get("form_data") + form_data = instance.get("form_data") or {} image_list = message_dict.get("image_list", []) if isinstance(message_dict, dict) else [] video_list = message_dict.get("video_list", []) if isinstance(message_dict, dict) else [] document_list = message_dict.get("document_list", []) if isinstance(message_dict, dict) else [] audio_list = message_dict.get("audio_list", []) if isinstance(message_dict, dict) else [] other_list = message_dict.get("other_list", []) if isinstance(message_dict, dict) else [] - workspace_id = chat_info.application.workspace_id + workspace_id = application.workspace_id chat_record_id = instance.get("chat_record_id") position = instance.get("position") chunk_id = instance.get("chunk_id") debug = self.data.get("debug", False) - history_chat_record = chat_info.chat_record_list - if chat_record_id is not None: - chat_record = self.get_chat_record(chat_info, chat_record_id) - if chat_record: - history_chat_record = [r for r in chat_info.chat_record_list if str(r.id) != chat_record_id] - work_flow = chat_info.application.work_flow + # 对话用户信息(asker 取自 form_data) + chat_user = resolve_chat_user(chat_user_id, chat_user_type, asker=form_data.get("asker")) + chat_user_group = resolve_chat_user_group(chat_user) + + history_chat_record = ChatHistory(chat_id).load(exclude_record_id=chat_record_id) + + work_flow = get_work_flow(application) workflow = new_instance(work_flow, WorkflowType.APPLICATION) chat_record_id_str = str(uuid.uuid7()) if chat_record_id is None else str(chat_record_id) @@ -514,7 +292,7 @@ def chat_work_flow(self, chat_info: ChatInfo, instance: dict, base_to_response): parameters = { "history_chat_record": history_chat_record, "question": message, - "chat_id": chat_info.chat_id, + "chat_id": chat_id, "chat_record_id": chat_record_id_str, "stream": stream, "re_chat": re_chat, @@ -524,10 +302,10 @@ def chat_work_flow(self, chat_info: ChatInfo, instance: dict, base_to_response): "source": source, "workspace_id": workspace_id, "debug": debug, - "chat_user": chat_info.get_chat_user(), - "chat_user_group": chat_info.get_chat_user_group(), - "application_id": str(chat_info.application_id), - "form_data": form_data or {}, + "chat_user": chat_user, + "chat_user_group": chat_user_group, + "application_id": str(self.data.get("application_id")), + "form_data": form_data, "position": position, "chunk_id": chunk_id, "image_list": image_list or [], @@ -538,94 +316,18 @@ def chat_work_flow(self, chat_info: ChatInfo, instance: dict, base_to_response): } result_queue = queue.Queue() - aggregation = AggregationManager() - self.save_chat_record(chat_info, chat_info.chat_id, chat_record_id_str, message_dict) + self.save_chat_record(chat_record_id_str, message_dict, chat_user, new_record=chat_record_id is None) def on_next(wf_manage, content): aggregation.aggregate(content) - message_queue = get_message_queue() - message_queue.produce(chat_record_id_str, content.to_dict()) - if isinstance(content, TextContent): - result_queue.put( - ( - "chunk", - { - "content": [ - { - "id": content.id, - "type": "TEXT", - "content": content.content, - } - ] - }, - ) - ) - elif isinstance(content, ReasoningContent): - result_queue.put( - ( - "chunk", - { - "content": [ - { - "id": content.id, - "type": "REASONING", - "content": content.content, - "status": content.status.value if content.status else None, - } - ] - }, - ) - ) - elif isinstance(content, ToolContent): - result_queue.put( - ( - "chunk", - { - "content": [ - { - "id": content.id, - "type": "TOOL", - "content": content.content, - "arguments": content.arguments, - "result": content.result, - "status": content.status.value if content.status else None, - } - ] - }, - ) - ) - elif isinstance(content, FormContent): - - def position_to_dict(pos): - if pos is None: - return None - return {"id": pos.id, "index": pos.index, "children": position_to_dict(pos.children)} - - result_queue.put( - ( - "chunk", - { - "content": [ - { - "id": content.id, - "type": "FORM", - "form_field_list": content.form_field_list, - "form_content_format": content.form_content_format, - "is_submit": content.is_submit, - "form_data": content.form_data, - "status": content.status.value if content.status else None, - "position": position_to_dict(content.position), - "chat_record_id": chat_record_id_str, - } - ] - }, - ) - ) + block = content.to_dict() + # 持久化(resume)与 live 队列都放裸内容块,格式化统一交给消费端的 base_to_response + get_message_queue().produce(chat_record_id_str, block) + result_queue.put(("chunk", block)) def on_complete(wf_manage, error): - # 注销工作流实例 - WorkflowRunRegistry.unregister(chat_record_id_str, str(chat_info.chat_id)) + WorkflowRunRegistry.unregister(chat_record_id_str, str(chat_id)) message_queue = get_message_queue() if error: result_queue.put(("error", error)) @@ -633,9 +335,19 @@ def on_complete(wf_manage, error): chat_record_id_str, FailureContent(str(uuid_utils.uuid7()), str(error), Status.SUCCESS, None, None).to_dict(), ) - QuerySet(ChatRecord).filter(id=chat_record_id).update() - self.update_chat_record( - chat_info, chat_info.chat_id, chat_record_id_str, wf_manage.context, aggregation.get_contents() + messages = aggregation.get_contents() + self.update_chat_record(chat_user_id, chat_record_id_str, wf_manage.context, messages) + # 计数统计放到内容落库之后再更新(挪出进对话前的关键路径) + ChatCountSerializer(data={"chat_id": chat_id}).update_chat() + # 定稿后把本轮记录追加进历史缓存(question 已在建占位时确定,messages 为聚合结果) + ChatHistory(chat_id).append( + ChatRecord( + id=chat_record_id_str, + chat_id=chat_id, + question=message_dict, + messages=messages, + create_time=timezone.now(), + ) ) result_queue.put(("done", None)) message_queue.produce_done(chat_record_id_str) @@ -645,9 +357,8 @@ def on_complete(wf_manage, error): def get_start_node_fn(wf, wm): return get_start_node(wf, wm, WorkflowType.APPLICATION, position) - # 判断是否是 Form 提交(有 position 和 chat_record_id) + # Form 提交(有 position 和 chat_record_id):从历史 context 恢复 if position and chat_record_id: - # 从历史 context 恢复 work_flow_manage = WorkflowManage.from_context( chat_record_id=chat_record_id, workflow=workflow, @@ -657,22 +368,16 @@ def get_start_node_fn(wf, wm): get_start_node=get_start_node_fn, ) if work_flow_manage is None: - # 恢复失败,回退到正常流程 work_flow_manage = WorkflowManage( workflow, parameters, WorkflowType.APPLICATION, call_back, get_start_node_fn ) else: - # 正常创建新的 WorkflowManage work_flow_manage = WorkflowManage( workflow, parameters, WorkflowType.APPLICATION, call_back, get_start_node_fn ) work_flow_manage.start_node.workflow_manage = work_flow_manage - - # 注册工作流实例到注册表 - WorkflowRunRegistry.register(chat_record_id_str, str(chat_info.chat_id), work_flow_manage) - - chat_info.set_chat(message) + WorkflowRunRegistry.register(chat_record_id_str, str(chat_id), work_flow_manage) if stream: @@ -681,27 +386,27 @@ def generate(): while True: msg_type, data = result_queue.get() if msg_type == "done": + end_frame = base_to_response.to_stream_end( + chat_id, + chat_record_id_str, + usage=self._usage_from_context(work_flow_manage.context), + ) + if end_frame is not None: + yield "data: " + end_frame + "\n\n" yield "data: [DONE]\n\n" break if msg_type == "error": - yield ( - "data: " - + json.dumps( - { - "chat_id": str(chat_info.chat_id), - "chat_record_id": chat_record_id_str, - "content": [{"type": "FAILURE", "content": str(data)}], - }, - ensure_ascii=False, - ) - + "\n\n" - ) + error_block = {"id": str(uuid.uuid7()), "type": "FAILURE", "content": str(data)} + frame = base_to_response.to_stream(chat_id, chat_record_id_str, error_block) + if frame is not None: + yield "data: " + frame + "\n\n" yield "data: [DONE]\n\n" break if msg_type == "chunk": - data["chat_id"] = str(chat_info.chat_id) - data["chat_record_id"] = chat_record_id_str - yield "data: " + json.dumps(data, ensure_ascii=False) + "\n\n" + # data 是裸内容块(content.to_dict()),格式化交给 base_to_response + frame = base_to_response.to_stream(chat_id, chat_record_id_str, data) + if frame is not None: + yield "data: " + frame + "\n\n" return to_stream_response_simple(generate()) else: @@ -712,163 +417,127 @@ def generate(): break if msg_type == "error": raise data - return base_to_response.to_block_response(chat_info.chat_id, chat_record_id_str, "", True, 0, 0) + usage = self._usage_from_context(work_flow_manage.context) + return base_to_response.to_block(chat_id, chat_record_id_str, aggregation.get_contents(), usage) - @staticmethod - def save_chat_record(chat_info, chat_id, chat_record_id, question): - chat_record = ChatRecord( - id=chat_record_id, - chat_id=chat_id, - problem_text="", - answer_text="", - details={}, - message_tokens=0, - answer_tokens=0, - answer_text_list=[[]], - run_time=0, - index=len(chat_info.chat_record_list) + 1, - ip_address=chat_info.ip_address, - source=chat_info.source, - workflow_context={}, - question=question, - messages=[], - ) - chat_info.append_chat_record(chat_record) - chat_info.set_cache() + def chat(self, instance: dict, base_to_response: BaseToResponse = SystemToResponse()): + self.is_valid(raise_exception=True) + ChatMessageSerializers(data=instance).is_valid(raise_exception=True) + self.is_valid_chat() + application = self.get_application() + self.is_valid_intraday_access_num() + return self.chat_work_flow(application, instance, base_to_response) + + +class OpenAIMessage(serializers.Serializer): + content = serializers.CharField(required=True, label=_("content")) + role = serializers.CharField(required=True, label=_("Role")) + + +class OpenAIInstanceSerializer(serializers.Serializer): + messages = serializers.ListField(child=OpenAIMessage()) + chat_id = serializers.UUIDField(required=False, label=_("Conversation ID")) + re_chat = serializers.BooleanField(required=False, label=_("Regenerate")) + stream = serializers.BooleanField(required=False, label=_("Streaming Output")) + + +class OpenAIChatSerializer(serializers.Serializer): + """OpenAI 兼容入口:走新 ChatSerializers + OpenaiToResponse,无 ChatInfo/缓存。""" + + application_id = serializers.UUIDField(required=True, label=_("Application ID")) + chat_user_id = serializers.CharField(required=True, label=_("Client id")) + chat_user_type = serializers.CharField(required=True, label=_("Client Type")) + ip_address = serializers.CharField(required=False, label=_("IP Address")) + source = serializers.JSONField(required=False, label=_("Source")) @staticmethod - def update_chat_record(chat_info, chat_id, chat_record_id, workflow_context, messages): - message_tokens = sum( - v.get("message_tokens", 0) - for v in workflow_context.values() - if isinstance(v, dict) and "message_tokens" in v - ) - answer_tokens = sum( - v.get("answer_tokens", 0) for v in workflow_context.values() if isinstance(v, dict) and "answer_tokens" in v - ) - ChatUserTokenQuota.consume(chat_info.chat_user_id, message_tokens + answer_tokens) - QuerySet(ChatRecord).filter(id=chat_record_id).update( - workflow_context=workflow_context, - messages=messages, - message_tokens=message_tokens, - answer_tokens=answer_tokens, + def get_message(instance): + return instance.get("messages")[-1].get("content") + + def chat(self, instance: dict, with_valid=True): + if with_valid: + self.is_valid(raise_exception=True) + OpenAIInstanceSerializer(data=instance).is_valid(raise_exception=True) + # 会话不存在则新开:新 ChatSerializers 会按 chat_id 惰性建 Chat 行,无需缓存 + chat_id = instance.get("chat_id") or str(uuid.uuid7()) + message = self.get_message(instance) + return ChatSerializers( + data={ + "chat_id": chat_id, + "chat_user_id": self.data.get("chat_user_id"), + "chat_user_type": self.data.get("chat_user_type"), + "application_id": self.data.get("application_id"), + "ip_address": self.data.get("ip_address"), + "source": self.data.get("source"), + } + ).chat( + { + "message": { + "content": message, + "image_list": instance.get("image_list", []), + "document_list": instance.get("document_list", []), + "audio_list": instance.get("audio_list", []), + "video_list": instance.get("video_list", []), + "other_list": instance.get("other_list", []), + }, + "re_chat": instance.get("re_chat", False), + "stream": instance.get("stream", False), + "form_data": instance.get("form_data", {}), + }, + base_to_response=OpenaiToResponse(), ) - def is_valid_chat_user(self): - chat_user_id = self.data.get("chat_user_id") - application_id = self.data.get("application_id") - chat_user_type = self.data.get("chat_user_type") - is_auth_chat_user = DatabaseModelManage.get_model("is_auth_chat_user") - application_access_token = QuerySet(ApplicationAccessToken).filter(application_id=application_id).first() - if ( - application_access_token - and application_access_token.authentication - and application_access_token.authentication_value.get("type") == "login" - ): - if chat_user_type == ChatUserType.ANONYMOUS_USER.value: - raise ChatException(500, _("The chat user is not authorized.")) - if chat_user_type == ChatUserType.CHAT_USER.value and is_auth_chat_user: - is_auth = is_auth_chat_user(chat_user_id, application_id) - if not is_auth: - raise ChatException(500, _("The chat user is not authorized.")) - def chat(self, instance: dict, base_to_response: BaseToResponse = SystemToResponse()): +# ==================== 会话创建 ==================== + + +class OpenChatSerializers(serializers.Serializer): + workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID")) + application_id = serializers.UUIDField(required=True) + chat_user_id = serializers.CharField(required=True, label=_("Client id")) + chat_user_type = serializers.CharField(required=True, label=_("Client Type")) + debug = serializers.BooleanField(required=True, label=_("Debug")) + ip_address = serializers.CharField(required=False, label=_("IP Address")) + source = serializers.JSONField(required=False, label=_("Source")) + + def is_valid(self, *, raise_exception=False): super().is_valid(raise_exception=True) - ChatMessageSerializers(data=instance).is_valid(raise_exception=True) - chat_info = self.get_chat_info() - chat_info.get_application() - chat_info.get_chat_user(asker=(instance.get("form_data") or {}).get("asker")) - self.is_valid_chat_id(chat_info) - if not self.data.get("debug"): - self.is_valid_chat_user() - ChatUserTokenQuota.consume(chat_info.chat_user_id, 0) # 触发周期重置 + 配额预校验 - if chat_info.application.type == ApplicationTypeChoices.SIMPLE: - self.is_valid_application_simple(raise_exception=True, chat_info=chat_info) - return self.chat_simple(chat_info, instance, base_to_response) - else: - self.is_valid_application_workflow(raise_exception=True) - return self.chat_work_flow(chat_info, instance, base_to_response) + workspace_id = self.data.get("workspace_id") + application_id = self.data.get("application_id") + query_set = QuerySet(Application).filter(id=application_id) + if workspace_id: + query_set = query_set.filter(workspace_id=workspace_id) + if not query_set.exists(): + raise AppApiException(500, _("Application does not exist")) - def get_chat_info(self): + def open(self, chat_id=None): + """新建会话:直接建 Chat 行(cache-free,无 ChatInfo)。SIMPLE/WORK_FLOW 一视同仁。""" self.is_valid(raise_exception=True) - chat_id = self.data.get("chat_id") - chat_info: ChatInfo = ChatInfo.get_cache(chat_id) - if chat_info is None: - chat_info: ChatInfo = self.re_open_chat(chat_id) - chat_info.set_cache() - return chat_info - - def re_open_chat(self, chat_id: str): - chat = QuerySet(Chat).filter(id=chat_id).first() - if chat is None: - raise ChatException(500, _("Conversation does not exist")) - application = QuerySet(Application).filter(id=chat.application_id).first() - if application is None: - raise ChatException(500, _("Application does not exist")) - application_version = ( - QuerySet(ApplicationVersion).filter(application_id=application.id).order_by("-create_time")[0:1].first() - ) - if application_version is None: - raise ChatException(500, _("The application has not been published. Please use it after publishing.")) - if application.type == ApplicationTypeChoices.SIMPLE: - return self.re_open_chat_simple(chat_id, application) - else: - return self.re_open_chat_work_flow(chat_id, application) - - def re_open_chat_simple(self, chat_id, application): - if self.data.get("debug"): - # 数据集id列表 - knowledge_id_list = [ - str(row.target_id) - for row in QuerySet(ResourceMapping).filter( - source_id=str(application.id), source_type="APPLICATION", target_type="KNOWLEDGE" - ) - ] - else: - application_version = ( - QuerySet(ApplicationVersion).filter(application_id=application.id).order_by("-create_time")[0:1].first() + application_id = self.data.get("application_id") + debug = self.data.get("debug") + if not debug: + published = ( + QuerySet(ApplicationVersion).filter(application_id=application_id).order_by("-create_time")[0:1].first() ) - knowledge_id_list = application_version.knowledge_ids - - # 需要排除的文档 - exclude_document_id_list = [ - str(document.id) - for document in QuerySet(Document).filter(knowledge_id__in=knowledge_id_list, is_active=False) - ] - chat_info = ChatInfo( - chat_id, - self.data.get("chat_user_id"), - self.data.get("chat_user_type"), - self.data.get("ip_address"), - self.data.get("source"), - knowledge_id_list, - exclude_document_id_list, - application.id, - ) - chat_record_list = list(QuerySet(ChatRecord).filter(chat_id=chat_id).order_by("-create_time")[0:5]) - chat_record_list.sort(key=lambda r: r.create_time) - for chat_record in chat_record_list: - chat_info.chat_record_list.append(chat_record) - return chat_info - - def re_open_chat_work_flow(self, chat_id, application): - chat_info = ChatInfo( - chat_id, - self.data.get("chat_user_id"), - self.data.get("chat_user_type"), - self.data.get("ip_address"), - self.data.get("source"), - [], - [], - application.id, - ) - chat_record_list = list(QuerySet(ChatRecord).filter(chat_id=chat_id).order_by("-create_time")[0:5]) - chat_record_list.sort(key=lambda r: r.create_time) - for chat_record in chat_record_list: - chat_info.chat_record_list.append(chat_record) - return chat_info + if published is None: + raise AppApiException(500, _("The application has not been published. Please use it after publishing.")) + chat_id = chat_id or str(uuid.uuid7()) + Chat( + id=chat_id, + application_id=application_id, + abstract="新建对话", + execute_type=ExecuteType.DEBUG if debug else ExecuteType.CHAT, + chat_user_id=self.data.get("chat_user_id"), + chat_user_type=self.data.get("chat_user_type"), + ip_address=self.data.get("ip_address"), + source=self.data.get("source"), + asker=resolve_chat_user(self.data.get("chat_user_id"), self.data.get("chat_user_type")), + ).save() + return chat_id +# ==================== 断点续传 ==================== + # consume 桥接队列的上限:满了会反压 pump 线程,防止慢客户端把消息全堆进内存 _BRIDGE_MAXSIZE = 1000 # 消费上限(秒),与桥接 get 的超时保持一致的量级 @@ -881,9 +550,6 @@ class ResumeSerializers(serializers.Serializer): def resume(self, request): self.is_valid(raise_exception=True) - from application.workflow.message_queue import get_message_queue - from application.models import ChatRecord - chat_record_id = self.data.get("chat_record_id") mq = get_message_queue() @@ -1008,93 +674,94 @@ def _stream_from_db(self, chat_record, start_id: str): yield "data: [DONE]\n\n" -class OpenChatSerializers(serializers.Serializer): - workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID")) - application_id = serializers.UUIDField(required=True) - chat_user_id = serializers.CharField(required=True, label=_("Client id")) - chat_user_type = serializers.CharField(required=True, label=_("Client Type")) - debug = serializers.BooleanField(required=True, label=_("Debug")) - ip_address = serializers.CharField(required=False, label=_("IP Address")) - source = serializers.JSONField(required=False, label=_("Source")) +# ==================== 提示词生成 ==================== + +SYSTEM_ROLE = get_file_content(os.path.join(PROJECT_DIR, "apps", "chat", "template", "generate_prompt_system")) + + +class ChatMessagesSerializers(serializers.Serializer): + role = serializers.CharField(required=True, label=_("Role")) + content = serializers.CharField(required=True, label=_("Content")) + + +class GeneratePromptSerializers(serializers.Serializer): + prompt = serializers.CharField(required=True, label=_("Prompt template")) + messages = serializers.ListSerializer(child=ChatMessagesSerializers(), required=True, label=_("Chat context")) + + def is_valid(self, *, raise_exception=False): + super().is_valid(raise_exception=True) + messages = self.data.get("messages") + + if len(messages) > 30: + raise AppApiException(400, _("Too many messages")) + + for index in range(len(messages)): + role = messages[index].get("role") + if role == "ai" and index % 2 != 1: + raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct.")) + if role == "user" and index % 2 != 0: + raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct.")) + if role not in ["user", "ai"]: + raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct.")) + + +class PromptGenerateSerializer(serializers.Serializer): + workspace_id = serializers.CharField(required=False, label=_("Workspace ID")) + model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model")) + application_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Application")) def is_valid(self, *, raise_exception=False): super().is_valid(raise_exception=True) workspace_id = self.data.get("workspace_id") - application_id = self.data.get("application_id") - query_set = QuerySet(Application).filter(id=application_id) + query_set = QuerySet(Application).filter(id=self.data.get("application_id")) if workspace_id: query_set = query_set.filter(workspace_id=workspace_id) - if not query_set.exists(): - raise AppApiException(500, gettext("Application does not exist")) + application = query_set.first() + if application is None: + raise AppApiException(500, _("Application id does not exist")) + return application - def open(self, chat_id=None): - self.is_valid(raise_exception=True) - application_id = self.data.get("application_id") - application = QuerySet(Application).get(id=application_id) - debug = self.data.get("debug") - if not debug: - application_version = ( - QuerySet(ApplicationVersion).filter(application_id=application_id).order_by("-create_time")[0:1].first() - ) - if application_version is None: - raise AppApiException(500, _("The application has not been published. Please use it after publishing.")) - if application.type == ApplicationTypeChoices.SIMPLE: - return self.open_simple(application, chat_id) - else: - return self.open_work_flow(application, chat_id) + def generate_prompt(self, instance: dict): + application = self.is_valid(raise_exception=True) + GeneratePromptSerializers(data=instance).is_valid(raise_exception=True) + workspace_id = self.data.get("workspace_id") + model_id = self.data.get("model_id") + prompt = instance.get("prompt") + messages = instance.get("messages") - def open_work_flow(self, application, chat_id=None): - self.is_valid(raise_exception=True) - application_id = self.data.get("application_id") - chat_user_id = self.data.get("chat_user_id") - chat_user_type = self.data.get("chat_user_type") - ip_address = self.data.get("ip_address") - source = self.data.get("source") - debug = self.data.get("debug") - chat_id = chat_id or str(uuid.uuid7()) - chat_info = ChatInfo(chat_id, chat_user_id, chat_user_type, ip_address, source, [], [], application_id, debug) - chat_info.save_chat() - chat_info.set_cache() - return chat_id + message = messages[-1]["content"] + q = prompt.replace("{userInput}", message) - def open_simple(self, application, chat_id=None): - application_id = self.data.get("application_id") - chat_user_id = self.data.get("chat_user_id") - chat_user_type = self.data.get("chat_user_type") - ip_address = self.data.get("ip_address") - source = self.data.get("source") - debug = self.data.get("debug") - if debug: - knowledge_id_list = [ - str(row.target_id) - for row in QuerySet(ResourceMapping).filter( - source_id=str(application_id), source_type="APPLICATION", target_type="KNOWLEDGE" - ) - ] - else: - application_version = ( - QuerySet(ApplicationVersion).filter(application_id=application_id).order_by("-create_time")[0:1].first() + messages[-1]["content"] = q + SUPPORTED_MODEL_TYPES = ["LLM", "IMAGE"] + model_exist = QuerySet(Model).filter(id=model_id, model_type__in=SUPPORTED_MODEL_TYPES).exists() + if not model_exist: + raise Exception(_("Model does not exists or is not an LLM model")) + + def process(): + model = get_model_instance_by_model_workspace_id( + model_id=model_id, workspace_id=workspace_id, **application.model_params_setting ) - knowledge_id_list = application_version.knowledge_ids + try: + for r in model.stream( + [ + SystemMessage(content=SYSTEM_ROLE), + *[ + HumanMessage(content=m.get("content")) + if m.get("role") == "user" + else AIMessage(content=m.get("content")) + for m in messages + ], + ] + ): + yield "data: " + json.dumps({"content": r.content}) + "\n\n" + except Exception as e: + yield "data: " + json.dumps({"error": str(e)}) + "\n\n" - chat_id = chat_id or str(uuid.uuid7()) - chat_info = ChatInfo( - chat_id, - chat_user_id, - chat_user_type, - ip_address, - source, - knowledge_id_list, - [ - str(document.id) - for document in QuerySet(Document).filter(knowledge_id__in=knowledge_id_list, is_active=False) - ], - application_id, - debug=debug, - ) - chat_info.save_chat() - chat_info.set_cache() - return chat_id + return to_stream_response_simple(process()) + + +# ==================== 语音 ==================== class TextToSpeechSerializers(serializers.Serializer): diff --git a/apps/chat/serializers/chat_history.py b/apps/chat/serializers/chat_history.py new file mode 100644 index 00000000000..d8c4499238e --- /dev/null +++ b/apps/chat/serializers/chat_history.py @@ -0,0 +1,90 @@ +# coding=utf-8 +""" +@project: MaxKB +@Author:虎虎 +@file: chat_history.py +@date:2025/6/9 11:23 +@desc: 会话历史的滚动窗口缓存(Redis,跨 worker 共享)。 + +- 历史是 append-only:每轮末尾追加一条已完成记录,旧记录不再变。 +- 只缓存最近 LIMIT 条,且只存历史真正要用的字段:question + messages + (新流程用 question/messages 构造 Human/AI message,不再用 problem_text/answer_text)。 +- 缓存缺失时回落 DB 并回填;记录定稿(on_complete)后 append/按 id upsert;清历史时失效。 +""" + +from django.core.cache import cache +from django.db.models import QuerySet + +from application.models import ChatRecord +from common.constants.cache_version import Cache_Version + + +class ChatHistory: + # 最近多少条历史进上下文(注意:若节点 dialogue_number 超过该值会喂不够) + LIMIT = 5 + TIMEOUT = 60 * 30 + + def __init__(self, chat_id): + self.chat_id = str(chat_id) + + def _key(self): + return Cache_Version.CHAT_HISTORY.get_key(key=self.chat_id) + + def _version(self): + return Cache_Version.CHAT_HISTORY.get_version() + + @staticmethod + def _to_map(r): + return { + "id": str(r.id), + "chat_id": str(r.chat_id), + "question": r.question, + "messages": r.messages, + "create_time": r.create_time, + } + + @staticmethod + def _from_map(d): + return ChatRecord( + id=d.get("id"), + chat_id=d.get("chat_id"), + question=d.get("question"), + messages=d.get("messages"), + create_time=d.get("create_time"), + ) + + def _load_from_db(self): + records = list(QuerySet(ChatRecord).filter(chat_id=self.chat_id).order_by("-create_time")[0 : self.LIMIT]) + records.sort(key=lambda r: r.create_time) + return records + + def load(self, exclude_record_id=None): + """ + 读历史:命中缓存则还原,未命中从 DB 取最近 N 条并回填。 + exclude_record_id:重答/Form 提交时把当前这条从历史上下文里剔掉。 + """ + cached = cache.get(self._key(), version=self._version()) + if cached is None: + records = self._load_from_db() + cache.set(self._key(), [self._to_map(r) for r in records], version=self._version(), timeout=self.TIMEOUT) + else: + records = [self._from_map(d) for d in cached] + if exclude_record_id is not None: + records = [r for r in records if str(r.id) != str(exclude_record_id)] + return records + + def append(self, chat_record): + """ + 记录定稿后追加进缓存(按 create_time 天然排在最后)。 + re_chat 复用同一 id → 先按 id 去重再追加,等价 upsert。 + 未预热(缓存为空)则跳过,下次 load 会从 DB 重建。 + """ + cached = cache.get(self._key(), version=self._version()) + if cached is None: + return + cached = [d for d in cached if str(d.get("id")) != str(chat_record.id)] + cached.append(self._to_map(chat_record)) + cache.set(self._key(), cached[-self.LIMIT :], version=self._version(), timeout=self.TIMEOUT) + + def clear(self): + cache.delete(self._key(), version=self._version()) diff --git a/apps/chat/template/agent_simple.py b/apps/chat/template/agent_simple.py new file mode 100644 index 00000000000..45db39983e4 --- /dev/null +++ b/apps/chat/template/agent_simple.py @@ -0,0 +1,655 @@ +from django.db.models import QuerySet + +template = { + "edges": [ + { + "id": "6a8d23d9-5179-424e-80c2-f08d37cdb8d4", + "type": "app-edge", + "endPoint": {"x": 2760, "y": 1054.125}, + "pointsList": [ + {"x": 2620, "y": 1054.125}, + {"x": 2730, "y": 1054.125}, + {"x": 2650, "y": 1054.125}, + {"x": 2760, "y": 1054.125}, + ], + "properties": {}, + "startPoint": {"x": 2620, "y": 1054.125}, + "sourceNodeId": "fd0324fc-f5e4-4fa6-a2d9-cb251b467605", + "targetNodeId": "420a6e4f-44ff-4847-bb81-0923630846b5", + "sourceAnchorId": "fd0324fc-f5e4-4fa6-a2d9-cb251b467605_right", + "targetAnchorId": "420a6e4f-44ff-4847-bb81-0923630846b5_left", + }, + { + "id": "56006748-d9fe-491b-a14b-04fd568cac08", + "type": "app-edge", + "endPoint": {"x": 3610, "y": 149.25}, + "pointsList": [ + {"x": 3340, "y": 913.75}, + {"x": 3450, "y": 913.75}, + {"x": 3500, "y": 149.25}, + {"x": 3610, "y": 149.25}, + ], + "properties": {}, + "startPoint": {"x": 3340, "y": 913.75}, + "sourceNodeId": "420a6e4f-44ff-4847-bb81-0923630846b5", + "targetNodeId": "36a440a9-5b00-4d82-b13a-8e7819112918", + "sourceAnchorId": "420a6e4f-44ff-4847-bb81-0923630846b5_7887_right", + "targetAnchorId": "36a440a9-5b00-4d82-b13a-8e7819112918_left", + }, + { + "id": "9bc8721b-07aa-4730-9347-910ed64e26b9", + "type": "app-edge", + "endPoint": {"x": 3610, "y": 1054.125}, + "pointsList": [ + {"x": 3340, "y": 1043.125}, + {"x": 3450, "y": 1043.125}, + {"x": 3500, "y": 1054.125}, + {"x": 3610, "y": 1054.125}, + ], + "properties": {}, + "startPoint": {"x": 3340, "y": 1043.125}, + "sourceNodeId": "420a6e4f-44ff-4847-bb81-0923630846b5", + "targetNodeId": "f7c3b4a2-cb80-4e47-b050-7fef0315daaf", + "sourceAnchorId": "420a6e4f-44ff-4847-bb81-0923630846b5_6847_right", + "targetAnchorId": "f7c3b4a2-cb80-4e47-b050-7fef0315daaf_left", + }, + { + "id": "e4b4bb4e-35ed-40a4-b4e7-b86f77131d92", + "type": "app-edge", + "endPoint": {"x": 550, "y": 1054.125}, + "pointsList": [ + {"x": 280, "y": 1054.125}, + {"x": 390, "y": 1054.125}, + {"x": 440, "y": 1054.125}, + {"x": 550, "y": 1054.125}, + ], + "properties": {}, + "startPoint": {"x": 280, "y": 1054.125}, + "sourceNodeId": "start-node", + "targetNodeId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94", + "sourceAnchorId": "start-node_right", + "targetAnchorId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94_left", + }, + { + "id": "0ea723ab-bebd-4058-98af-74b6c5f03260", + "type": "app-edge", + "endPoint": {"x": 1270, "y": 1054.125}, + "pointsList": [ + {"x": 1130, "y": 978.4375}, + {"x": 1240, "y": 978.4375}, + {"x": 1160, "y": 1054.125}, + {"x": 1270, "y": 1054.125}, + ], + "properties": {}, + "startPoint": {"x": 1130, "y": 978.4375}, + "sourceNodeId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94", + "targetNodeId": "a0089772-3821-474f-bb4f-9bfe32c1d95f", + "sourceAnchorId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94_gWldyeZ3CMPKS9teLWQeI_right", + "targetAnchorId": "a0089772-3821-474f-bb4f-9bfe32c1d95f_left", + }, + { + "id": "c0c675d3-cb0b-4b67-8009-16951303791d", + "type": "app-edge", + "endPoint": {"x": 1730, "y": 1054.125}, + "pointsList": [ + {"x": 1130, "y": 1069.125}, + {"x": 1240, "y": 1069.125}, + {"x": 1620, "y": 1054.125}, + {"x": 1730, "y": 1054.125}, + ], + "properties": {}, + "startPoint": {"x": 1130, "y": 1069.125}, + "sourceNodeId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94", + "targetNodeId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836", + "sourceAnchorId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94_TvdY3NQkSdYbC8A15VrId_right", + "targetAnchorId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836_left", + }, + { + "id": "0c1d5fc1-6ab2-431e-afdc-9f332ce8b466", + "type": "app-edge", + "endPoint": {"x": 1730, "y": 1054.125}, + "pointsList": [ + {"x": 1590, "y": 1054.125}, + {"x": 1700, "y": 1054.125}, + {"x": 1620, "y": 1054.125}, + {"x": 1730, "y": 1054.125}, + ], + "properties": {}, + "startPoint": {"x": 1590, "y": 1054.125}, + "sourceNodeId": "a0089772-3821-474f-bb4f-9bfe32c1d95f", + "targetNodeId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836", + "sourceAnchorId": "a0089772-3821-474f-bb4f-9bfe32c1d95f_right", + "targetAnchorId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836_left", + }, + { + "id": "422564a4-2b0a-469b-be86-ded4204e7742", + "type": "app-edge", + "endPoint": {"x": 2300, "y": 1054.125}, + "pointsList": [ + {"x": 2160, "y": 1054.125}, + {"x": 2270, "y": 1054.125}, + {"x": 2190, "y": 1054.125}, + {"x": 2300, "y": 1054.125}, + ], + "properties": {}, + "startPoint": {"x": 2160, "y": 1054.125}, + "sourceNodeId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836", + "targetNodeId": "fd0324fc-f5e4-4fa6-a2d9-cb251b467605", + "sourceAnchorId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836_right", + "targetAnchorId": "fd0324fc-f5e4-4fa6-a2d9-cb251b467605_left", + }, + { + "id": "a0cee2ac-4d0d-4b68-8cb2-ca2cb39993e9", + "type": "app-edge", + "endPoint": {"x": 3480, "y": 1973.375}, + "pointsList": [ + {"x": 3340, "y": 1133.8125}, + {"x": 3450, "y": 1133.8125}, + {"x": 3370, "y": 1973.375}, + {"x": 3480, "y": 1973.375}, + ], + "properties": {}, + "startPoint": {"x": 3340, "y": 1133.8125}, + "sourceNodeId": "420a6e4f-44ff-4847-bb81-0923630846b5", + "targetNodeId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4", + "sourceAnchorId": "420a6e4f-44ff-4847-bb81-0923630846b5_2794_right", + "targetAnchorId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4_left", + }, + { + "id": "cd66759a-bcb9-4d61-806b-7bde23ae4582", + "type": "app-edge", + "endPoint": {"x": 4200, "y": 1001.5}, + "pointsList": [ + {"x": 4060, "y": 1897.6875}, + {"x": 4170, "y": 1897.6875}, + {"x": 4090, "y": 1001.5}, + {"x": 4200, "y": 1001.5}, + ], + "properties": {}, + "startPoint": {"x": 4060, "y": 1897.6875}, + "sourceNodeId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4", + "targetNodeId": "dd02a0d8-0ea1-41c4-8b64-0cb7d8963fd9", + "sourceAnchorId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4_Iu8b0BMQU9xXWy5JbcTnz_right", + "targetAnchorId": "dd02a0d8-0ea1-41c4-8b64-0cb7d8963fd9_left", + }, + { + "id": "7113c5b7-d9d6-4f49-a030-24eaeee00e7d", + "type": "app-edge", + "endPoint": {"x": 4200, "y": 1973.375}, + "pointsList": [ + {"x": 4060, "y": 1988.375}, + {"x": 4170, "y": 1988.375}, + {"x": 4090, "y": 1973.375}, + {"x": 4200, "y": 1973.375}, + ], + "properties": {}, + "startPoint": {"x": 4060, "y": 1988.375}, + "sourceNodeId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4", + "targetNodeId": "04dd6c1e-95f9-4757-bb3e-134d503fce54", + "sourceAnchorId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4_s-groW06vt6a7B-aqDqnX_right", + "targetAnchorId": "04dd6c1e-95f9-4757-bb3e-134d503fce54_left", + }, + ], + "nodes": [ + { + "x": 120, + "y": 120, + "id": "base-node", + "type": "base-node", + "properties": { + "config": {}, + "height": 984.25, + "showNode": True, + "stepName": "基本信息", + "node_data": { + "desc": "www", + "name": "www", + "prologue": "您好,我是 XXX 小助手,您可以向我提出 XXX 使用问题。\n- XXX 主要功能有什么?\n- XXX 如何收费?\n- 需要转人工服务", + "tts_type": "BROWSER", + "stt_model_id_type": "default", + "long_term_model_id_type": "default", + }, + "enableException": False, + "input_field_list": [], + "user_input_config": {"title": "用户输入"}, + "api_input_field_list": [], + "chat_input_field_list": [], + "user_input_field_list": [ + { + "attrs": {}, + "field": "problem_optimization", + "label": { + "attrs": {"tooltip": "是否需要问题优化"}, + "label": "问题优化", + "input_type": "TooltipLabel", + "props_info": {}, + }, + "required": True, + "input_type": "SwitchInput", + "default_value": False, + "visibility_rules": { + "action": "show", + "node_id": "base-node", + "condition": "and", + "node_name": "基本信息", + "conditions": [], + }, + "show_default_value": True, + }, + { + "attrs": {}, + "field": "ai_questioning", + "label": { + "attrs": {"tooltip": "是否ai回复"}, + "label": "是否ai回复", + "input_type": "TooltipLabel", + "props_info": {}, + }, + "required": True, + "input_type": "SwitchInput", + "default_value": False, + "visibility_rules": { + "action": "show", + "node_id": "base-node", + "condition": "and", + "node_name": "基本信息", + "conditions": [], + }, + "show_default_value": True, + }, + ], + }, + }, + { + "x": 120, + "y": 1054.125, + "id": "start-node", + "type": "start-node", + "properties": { + "config": { + "fields": [{"label": "用户问题", "value": "question"}], + "chatFields": [], + "globalFields": [ + {"label": "当前时间", "value": "time"}, + {"label": "历史聊天记录", "value": "history_context"}, + {"label": "对话 ID", "value": "chat_id"}, + {"label": "对话用户 ID", "value": "chat_user_id"}, + {"label": "对话用户类型", "value": "chat_user_type"}, + {"label": "对话用户组", "value": "chat_user_group"}, + {"label": "对话用户", "value": "chat_user"}, + {"label": "问题优化", "value": "problem_optimization"}, + {"label": "是否ai回复", "value": "ai_questioning"}, + ], + }, + "fields": [{"label": "用户问题", "value": "question"}], + "height": 644, + "showNode": True, + "stepName": "开始", + "globalFields": [{"label": "当前时间", "value": "time"}], + "enableException": False, + }, + }, + { + "x": 2460, + "y": 1054.125, + "id": "fd0324fc-f5e4-4fa6-a2d9-cb251b467605", + "type": "search-knowledge-node", + "properties": { + "config": { + "fields": [ + {"label": "检索结果的分段列表", "value": "paragraph_list"}, + {"label": "满足直接回答的分段列表", "value": "is_hit_handling_method_list"}, + {"label": "检索结果", "value": "data"}, + {"label": "满足直接回答的分段内容", "value": "directly_return"}, + ] + }, + "height": 806.375, + "showNode": True, + "stepName": "知识库检索", + "condition": "AND", + "node_data": { + "knowledge_list": [], + "show_knowledge": True, + "knowledge_id_list": [], + "knowledge_setting": { + "top_n": 3, + "similarity": 0.6, + "search_mode": "embedding", + "max_paragraph_char_number": 5000, + }, + "search_scope_type": "custom", + "search_scope_source": "knowledge", + "all_knowledge_id_list": [], + "question_reference_address": ["124fe8a0-70fa-42cb-b854-4b6c02ebb836", "Group1"], + "no_permission_knowledge_id_list": [], + }, + "enableException": False, + }, + }, + { + "x": 3050, + "y": 1054.125, + "id": "420a6e4f-44ff-4847-bb81-0923630846b5", + "type": "condition-node", + "properties": { + "width": 600, + "config": {"fields": [{"label": "分支名称", "value": "branch_name"}]}, + "height": 552.125, + "showNode": True, + "stepName": "判断器", + "condition": "AND", + "node_data": { + "branch": [ + { + "id": "7887", + "type": "IF", + "condition": "and", + "conditions": [ + { + "field": ["fd0324fc-f5e4-4fa6-a2d9-cb251b467605", "is_hit_handling_method_list"], + "value": 1, + "compare": "is_not_None", + } + ], + }, + { + "id": "6847", + "type": "ELSE IF 1", + "condition": "and", + "conditions": [ + { + "field": ["fd0324fc-f5e4-4fa6-a2d9-cb251b467605", "paragraph_list"], + "value": 1, + "compare": "is_not_None", + } + ], + }, + {"id": "2794", "type": "ELSE", "condition": "and", "conditions": []}, + ] + }, + "enableException": False, + "branch_condition_list": [ + {"id": "7887", "index": 0, "height": 121.375}, + {"id": "6847", "index": 1, "height": 121.375}, + {"id": "2794", "index": 2, "height": 44}, + ], + }, + }, + { + "x": 3770, + "y": 149.25, + "id": "36a440a9-5b00-4d82-b13a-8e7819112918", + "type": "reply-node", + "properties": { + "config": {"fields": [{"label": "内容", "value": "answer"}]}, + "height": 394, + "showNode": True, + "stepName": "指定回复", + "condition": "AND", + "node_data": { + "fields": ["fd0324fc-f5e4-4fa6-a2d9-cb251b467605", "directly_return"], + "content": "", + "is_result": True, + "reply_type": "referencing", + }, + "enableException": False, + }, + }, + { + "x": 3770, + "y": 1054.125, + "id": "f7c3b4a2-cb80-4e47-b050-7fef0315daaf", + "type": "ai-chat-node", + "properties": { + "config": { + "fields": [ + {"label": "AI 回答内容", "value": "answer"}, + {"label": "思考过程", "value": "reasoning_content"}, + {"label": "历史聊天记录", "value": "history_message"}, + ] + }, + "height": 1175.75, + "showNode": True, + "stepName": "AI 对话", + "condition": "AND", + "node_data": { + "prompt": "已知信息:\n{{知识库检索.data}}\n问题:\n{{开始.question}}", + "system": "", + "model_id": "", + "is_result": True, + "max_tokens": None, + "temperature": None, + "dialogue_type": "WORKFLOW", + "model_id_type": "custom", + "model_setting": { + "reasoning_content_end": "", + "reasoning_content_start": "", + "reasoning_content_enable": False, + }, + "dialogue_number": 1, + "mcp_output_enable": True, + "model_id_reference": [], + }, + "enableException": False, + }, + }, + { + "x": 4360, + "y": 1973.375, + "id": "04dd6c1e-95f9-4757-bb3e-134d503fce54", + "type": "reply-node", + "properties": { + "config": {"fields": [{"label": "内容", "value": "answer"}]}, + "height": 512, + "showNode": True, + "stepName": "指定回复1", + "condition": "AND", + "node_data": { + "fields": [], + "content": "抱歉,没有在知识库查询到相关内容,请提供更详细的信息。", + "is_result": True, + "reply_type": "content", + }, + "enableException": False, + }, + }, + { + "x": 840, + "y": 1054.125, + "id": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94", + "type": "condition-node", + "properties": { + "width": 600, + "config": {"fields": [{"label": "分支名称", "value": "branch_name"}]}, + "height": 422.75, + "showNode": True, + "stepName": "判断器1", + "condition": "AND", + "node_data": { + "branch": [ + { + "id": "gWldyeZ3CMPKS9teLWQeI", + "type": "IF", + "condition": "and", + "conditions": [ + {"field": ["global", "problem_optimization"], "value": 1, "compare": "is_True"} + ], + }, + {"id": "TvdY3NQkSdYbC8A15VrId", "type": "ELSE", "condition": "and", "conditions": []}, + ] + }, + "enableException": False, + "branch_condition_list": [ + {"id": "gWldyeZ3CMPKS9teLWQeI", "index": 0, "height": 121.375}, + {"id": "TvdY3NQkSdYbC8A15VrId", "index": 1, "height": 44}, + ], + }, + }, + { + "x": 1430, + "y": 1054.125, + "id": "a0089772-3821-474f-bb4f-9bfe32c1d95f", + "type": "question-node", + "properties": { + "config": {"fields": [{"label": "问题优化结果", "value": "answer"}]}, + "height": 842, + "showNode": True, + "stepName": "问题优化", + "condition": "AND", + "node_data": { + "prompt": "{{开始.question}}", + "system": "# 角色\n你是一位问题优化大师,擅长根据上下文精准揣测用户意图,并对用户提出的问题进行优化。\n\n## 技能\n### 技能 1: 优化问题\n2. 接收用户输入的问题。\n3. 依据上下文仔细分析问题含义。\n4. 输出优化后的问题。\n\n## 限制:\n- 仅返回优化后的问题,不进行额外解释或说明。\n- 确保优化后的问题准确反映原始问题意图,不得改变原意。", + "model_id": "", + "is_result": False, + "model_id_type": "default", + "dialogue_number": 0, + "model_id_reference": [], + }, + "enableException": False, + }, + }, + { + "x": 1945, + "y": 1054.125, + "id": "124fe8a0-70fa-42cb-b854-4b6c02ebb836", + "type": "variable-aggregation-node", + "properties": { + "config": {"fields": [{"label": "Group1", "value": "Group1"}]}, + "height": 530.75, + "showNode": True, + "stepName": "变量聚合", + "condition": "AND", + "node_data": { + "strategy": "first_non_None", + "is_result": True, + "group_list": [ + { + "id": "A5aBuBrQJ5hq12mKSJNiQ", + "field": "Group1", + "label": "Group1", + "variable_list": [ + { + "v_id": "0bmeMSbo9696jwbfp3jDX", + "variable": ["a0089772-3821-474f-bb4f-9bfe32c1d95f", "answer"], + }, + {"v_id": "1YHRj-fr3_IQpELv_HAdC", "variable": ["start-node", "question"]}, + ], + } + ], + }, + "enableException": False, + }, + }, + { + "x": 4360, + "y": 1001.5, + "id": "dd02a0d8-0ea1-41c4-8b64-0cb7d8963fd9", + "type": "ai-chat-node", + "properties": { + "config": { + "fields": [ + {"label": "AI 回答内容", "value": "answer"}, + {"label": "思考过程", "value": "reasoning_content"}, + {"label": "历史聊天记录", "value": "history_message"}, + ] + }, + "height": 1191.75, + "showNode": True, + "stepName": "AI 对话1", + "condition": "AND", + "node_data": { + "prompt": "{{开始.question}}", + "system": "", + "model_id": "", + "is_result": True, + "max_tokens": None, + "temperature": None, + "dialogue_type": "WORKFLOW", + "model_id_type": "custom", + "model_setting": { + "reasoning_content_end": "", + "reasoning_content_start": "", + "reasoning_content_enable": False, + }, + "dialogue_number": 0, + "mcp_output_enable": True, + "model_id_reference": [], + }, + "enableException": False, + }, + }, + { + "x": 3770, + "y": 1973.375, + "id": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4", + "type": "condition-node", + "properties": { + "width": 600, + "config": {"fields": [{"label": "分支名称", "value": "branch_name"}]}, + "height": 422.75, + "showNode": True, + "stepName": "判断器2", + "condition": "AND", + "node_data": { + "branch": [ + { + "id": "Iu8b0BMQU9xXWy5JbcTnz", + "type": "IF", + "condition": "and", + "conditions": [{"field": ["global", "ai_questioning"], "value": 1, "compare": "is_True"}], + }, + {"id": "s-groW06vt6a7B-aqDqnX", "type": "ELSE", "condition": "and", "conditions": []}, + ] + }, + "enableException": False, + "branch_condition_list": [ + {"id": "Iu8b0BMQU9xXWy5JbcTnz", "index": 0, "height": 121.375}, + {"id": "s-groW06vt6a7B-aqDqnX", "index": 1, "height": 44}, + ], + }, + }, + ], +} + + +def build_workflow(application): + from system_manage.models.resource_mapping import ResourceMapping, ResourceType + + data = template.copy() + if application.knowledge_ids: + knowledge_ids = application.knowledge_ids + else: + knowledge_ids = ( + QuerySet(ResourceMapping) + .filter(source_type=ResourceType.APPLICATION, source_id=application.id, target_type=ResourceType.KNOWLEDGE) + .values_list("target_id", flat=True) + ) + + data["nodes"][0]["properties"]["user_input_field_list"][0]["default_value"] = application.problem_optimization + + data["nodes"][0]["properties"]["user_input_field_list"][1]["default_value"] = ( + application.knowledge_setting.no_references_setting.status == "ai_questioning" + ) + model_id = application.model + model_params_setting = application.model_params_setting or {} + ## 问题优化设置 + data["nodes"][8]["properties"]["node_data"]["model_id"] = model_id + data["nodes"][8]["properties"]["node_data"]["prompt"] = application.problem_optimization_prompt.replace( + "{question}", "{{开始.question}}" + ) + data["nodes"][8]["properties"]["node_data"]["model_params_setting"] = model_params_setting + ## 知识库检索 + data["nodes"][2]["properties"]["node_data"]["knowledge_id_list"] = knowledge_ids + data["nodes"][2]["properties"]["node_data"]["knowledge_setting"] = application.knowledge_setting + ## ai对话 + data["nodes"][5]["properties"]["node_data"]["model_id"] = model_id + data["nodes"][5]["properties"]["node_data"]["model_params_setting"] = model_params_setting + data["nodes"][5]["properties"]["node_data"]["prompt"] = application.model_setting.prompt + ## 未查询到知识库ai 回复 + data["nodes"][10]["properties"]["node_data"]["model_id"] = model_id + data["nodes"][10]["properties"]["node_data"]["model_params_setting"] = model_params_setting + ## 未查询到知识库指定回复 + if application.knowledge_setting.no_references_setting.status == "designated_answer": + data["nodes"][6]["properties"]["node_data"]["content"] = application.knowledge_setting.value + + return data diff --git a/apps/chat/views/v2/chat.py b/apps/chat/views/v2/chat.py index 8f4816d33cf..a816df8a9e1 100644 --- a/apps/chat/views/v2/chat.py +++ b/apps/chat/views/v2/chat.py @@ -1,11 +1,12 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎虎 - @file: chat.py - @date:2025/6/6 11:18 - @desc: +@project: MaxKB +@Author:虎虎 +@file: chat.py +@date:2025/6/6 11:18 +@desc: """ + import json import requests @@ -22,11 +23,24 @@ from application.api.application_api import SpeechToTextAPI, TextToSpeechAPI from application.models import ChatUserType, ChatSourceChoices from chat.api.chat_api import ChatAPI -from chat.api.chat_authentication_api import ChatAuthenticationAPI, ChatAuthenticationProfileAPIV2, ChatOpenAPI, OpenAIAPI -from chat.serializers.chat import OpenChatSerializers, ChatSerializers, SpeechToTextSerializers, \ - TextToSpeechSerializers, OpenAIChatSerializer -from chat.serializers.chat_authentication import AnonymousAuthenticationV2Serializer, ApplicationProfileSerializer, \ - AuthProfileV2Serializer +from chat.api.chat_authentication_api import ( + ChatAuthenticationAPI, + ChatAuthenticationProfileAPIV2, + ChatOpenAPI, + OpenAIAPI, +) +from chat.serializers.chat import ( + ChatSerializers, + OpenAIChatSerializer, + OpenChatSerializers, + SpeechToTextSerializers, + TextToSpeechSerializers, +) +from chat.serializers.chat_authentication import ( + AnonymousAuthenticationV2Serializer, + ApplicationProfileSerializer, + AuthProfileV2Serializer, +) from common.auth import ChatTokenAuth from common.auth.authentication import has_permissions from common.auth.common import FileToken @@ -63,19 +77,18 @@ def get(self, request: Request): if not image_url: return result.error("Missing 'url' parameter") try: - # 发送GET请求,流式获取图片内容 response = requests.get( image_url, stream=True, # 启用流式响应 allow_redirects=True, - timeout=10 + timeout=10, ) - content_type = response.headers.get('Content-Type', '').split(';')[0] + content_type = response.headers.get("Content-Type", "").split(";")[0] # 创建Django流式响应 django_response = StreamingHttpResponse( stream_image(response), # 使用生成器 - content_type=content_type + content_type=content_type, ) return django_response @@ -87,49 +100,59 @@ class OpenAIView(APIView): authentication_classes = [ChatTokenAuth] @extend_schema( - methods=['POST'], - description=_('OpenAI Interface Dialogue'), - summary=_('OpenAI Interface Dialogue'), - operation_id=_('OpenAI Interface Dialogue'), # type: ignore + methods=["POST"], + description=_("OpenAI Interface Dialogue"), + summary=_("OpenAI Interface Dialogue"), + operation_id=_("OpenAI Interface Dialogue"), # type: ignore request=OpenAIAPI.get_request(), responses=None, - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore ) def post(self, request: Request, application_id: str): ip_address = _get_ip_address(request) - if application_id != str(request.user.kwargs.get('application_id')): - raise AppAuthenticationFailed(500, _('Secret key is invalid')) + if application_id != str(request.user.kwargs.get("application_id")): + raise AppAuthenticationFailed(500, _("Secret key is invalid")) return OpenAIChatSerializer( - data={'application_id': application_id, 'chat_user_id': request.user.id, - 'chat_user_type': request.user.type, - 'ip_address': ip_address, - 'source': {"type": ChatSourceChoices.API_CALL.value}}).chat(request.data) + data={ + "application_id": application_id, + "chat_user_id": request.user.id, + "chat_user_type": request.user.type, + "ip_address": ip_address, + "source": {"type": ChatSourceChoices.API_CALL.value}, + } + ).chat(request.data) class AnonymousAuthentication(APIView): def options(self, request, *args, **kwargs): return HttpResponse( - headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Credentials": "true", - "Access-Control-Allow-Methods": "POST", - "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token"}, ) + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token", + }, + ) @extend_schema( - methods=['POST'], - description=_('Application Anonymous Certification'), - summary=_('Application Anonymous Certification'), - operation_id=_('Application Anonymous Certification'), # type: ignore + methods=["POST"], + description=_("Application Anonymous Certification"), + summary=_("Application Anonymous Certification"), + operation_id=_("Application Anonymous Certification"), # type: ignore request=AnonymousAuthenticationV2Serializer, responses=None, - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore ) def post(self, request: Request): - token, f_token = AnonymousAuthenticationV2Serializer(data=request.data).auth( - request) + token, f_token = AnonymousAuthenticationV2Serializer(data=request.data).auth(request) response = result.success( token, - headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Credentials": "true", - "Access-Control-Allow-Methods": "POST", - "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token"} + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token", + }, ) is_https = request.scheme == "https" @@ -137,7 +160,7 @@ def post(self, request: Request): key="mk_file_auth", value=f_token, max_age=7 * 24 * 3600, - path=f'{CONFIG.get_chat_path()}/{request.data.get("access_token")}', + path=f"{CONFIG.get_chat_path()}/{request.data.get('access_token')}", secure=is_https, httponly=True, samesite="None" if is_https else "Lax", @@ -149,160 +172,185 @@ class ApplicationProfile(APIView): authentication_classes = [ChatTokenAuth] @extend_schema( - methods=['GET'], + methods=["GET"], description=_("Get application related information"), summary=_("Get application related information"), operation_id=_("Get application related information"), # type: ignore request=None, responses=None, - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore ) def get(self, request: Request): - return result.success(ApplicationProfileSerializer( - data={'application_id': request.user.kwargs.get('application_id')}).profile()) + return result.success( + ApplicationProfileSerializer(data={"application_id": request.user.kwargs.get("application_id")}).profile() + ) class AuthProfile(APIView): @extend_schema( - methods=['GET'], + methods=["GET"], description=_("Get application authentication information"), summary=_("Get application authentication information"), operation_id=_("Get application authentication information"), # type: ignore parameters=ChatAuthenticationProfileAPIV2.get_parameters(), responses=None, - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore ) def get(self, request: Request): return result.success( - AuthProfileV2Serializer(data={'access_token': request.query_params.get("access_token")}).profile()) + AuthProfileV2Serializer(data={"access_token": request.query_params.get("access_token")}).profile() + ) class ChatView(APIView): authentication_classes = [ChatTokenAuth] @extend_schema( - methods=['POST'], + methods=["POST"], description=_("dialogue"), summary=_("dialogue"), operation_id=_("dialogue"), # type: ignore request=ChatAPI.get_request(), parameters=ChatAPI.get_parameters(), responses=None, - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore ) def post(self, request: Request, chat_id: str): ip_address = _get_ip_address(request) - return ChatSerializers(data={'chat_id': chat_id, - 'chat_user_id': request.user.id, - 'chat_user_type': request.user.type, - 'application_id': request.user.kwargs.get('application_id'), - 'debug': False, - 'ip_address': ip_address, - 'source': { - 'type': ChatSourceChoices.API_CALL.value if request.user.type == ChatUserType.APPLICATION_API_KEY.value else ChatSourceChoices.ONLINE.value} - } - ).chat(request.data) + return ChatSerializers( + data={ + "chat_id": chat_id, + "chat_user_id": request.user.id, + "chat_user_type": request.user.type, + "application_id": request.user.kwargs.get("application_id"), + "debug": False, + "ip_address": ip_address, + "source": { + "type": ChatSourceChoices.API_CALL.value + if request.user.type == ChatUserType.APPLICATION_API_KEY.value + else ChatSourceChoices.ONLINE.value + }, + } + ).chat(request.data) class OpenView(APIView): authentication_classes = [ChatTokenAuth] @extend_schema( - methods=['GET'], + methods=["GET"], description=_("Get the session id according to the application id"), summary=_("Get the session id according to the application id"), operation_id=_("Get the session id according to the application id"), # type: ignore parameters=ChatOpenAPI.get_parameters(), responses=None, - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore ) @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) def get(self, request: Request): ip_address = _get_ip_address(request) - return result.success(OpenChatSerializers( - data={'application_id': request.user.kwargs.get('application_id'), - 'chat_user_id': request.user.id, 'chat_user_type': request.user.type, - 'ip_address': ip_address, - 'source': { - 'type': ChatSourceChoices.API_CALL.value if request.user.type == ChatUserType.APPLICATION_API_KEY.value else ChatSourceChoices.ONLINE.value}, - 'debug': False}).open()) + return result.success( + OpenChatSerializers( + data={ + "application_id": request.user.kwargs.get("application_id"), + "chat_user_id": request.user.id, + "chat_user_type": request.user.type, + "ip_address": ip_address, + "source": { + "type": ChatSourceChoices.API_CALL.value + if request.user.type == ChatUserType.APPLICATION_API_KEY.value + else ChatSourceChoices.ONLINE.value + }, + "debug": False, + } + ).open() + ) class CancelWorkflowView(APIView): authentication_classes = [ChatTokenAuth] @extend_schema( - methods=['POST'], + methods=["POST"], description=_("Cancel running workflow"), summary=_("Cancel running workflow"), operation_id=_("Cancel running workflow"), # type: ignore parameters=[ - OpenApiParameter(name='chat_id', type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, - description=_('Chat ID')), + OpenApiParameter( + name="chat_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, description=_("Chat ID") + ), ], responses=None, - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore ) def post(self, request: Request, chat_id: str): from application.workflow.workflow_run_registry import WorkflowRunRegistry, CancelResult + result_enum = WorkflowRunRegistry.cancel_by_chat_id(chat_id) if result_enum == CancelResult.CANCELLED: - return result.success({'status': 'cancelled', 'chat_id': chat_id}) + return result.success({"status": "cancelled", "chat_id": chat_id}) elif result_enum == CancelResult.NOT_FOUND: - return result.success({'status': 'not_found', 'chat_id': chat_id}) + return result.success({"status": "not_found", "chat_id": chat_id}) else: - return result.fail(500, _('Failed to cancel workflow')) + return result.fail(500, _("Failed to cancel workflow")) class CaptchaView(APIView): - @extend_schema(methods=['GET'], - summary=_("Get Chat captcha"), - description=_("Get Chat captcha"), - operation_id=_("Get Chat captcha"), # type: ignore - tags=[_("Chat")], # type: ignore - responses=CaptchaAPI.get_response()) + @extend_schema( + methods=["GET"], + summary=_("Get Chat captcha"), + description=_("Get Chat captcha"), + operation_id=_("Get Chat captcha"), # type: ignore + tags=[_("Chat")], # type: ignore + responses=CaptchaAPI.get_response(), + ) def get(self, request: Request): - username = request.query_params.get('username', None) - accessToken = request.query_params.get('accessToken', None) - return result.success(CaptchaSerializer().chat_generate(username, 'chat', accessToken)) + username = request.query_params.get("username", None) + accessToken = request.query_params.get("accessToken", None) + return result.success(CaptchaSerializer().chat_generate(username, "chat", accessToken)) class SpeechToText(APIView): authentication_classes = [ChatTokenAuth] @extend_schema( - methods=['POST'], + methods=["POST"], description=_("speech to text"), summary=_("speech to text"), operation_id=_("speech to text"), # type: ignore request=SpeechToTextAPI.get_request(), responses=SpeechToTextAPI.get_response(), - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore ) def post(self, request: Request): return result.success( - SpeechToTextSerializers( - data={'application_id': request.user.kwargs.get('application_id')}) - .speech_to_text({'file': request.FILES.get('file')})) + SpeechToTextSerializers(data={"application_id": request.user.kwargs.get("application_id")}).speech_to_text( + {"file": request.FILES.get("file")} + ) + ) class TextToSpeech(APIView): authentication_classes = [ChatTokenAuth] @extend_schema( - methods=['POST'], + methods=["POST"], description=_("text to speech"), summary=_("text to speech"), operation_id=_("text to speech"), # type: ignore request=TextToSpeechAPI.get_request(), responses=TextToSpeechAPI.get_response(), - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore ) def post(self, request: Request): byte_data = TextToSpeechSerializers( - data={'application_id': request.user.kwargs.get('application_id')}).text_to_speech(request.data) - return HttpResponse(byte_data, status=200, headers={'Content-Type': 'audio/mp3', - 'Content-Disposition': 'attachment; filename="abc.mp3"'}) + data={"application_id": request.user.kwargs.get("application_id")} + ).text_to_speech(request.data) + return HttpResponse( + byte_data, + status=200, + headers={"Content-Type": "audio/mp3", "Content-Disposition": 'attachment; filename="abc.mp3"'}, + ) class UploadFile(APIView): @@ -310,23 +358,28 @@ class UploadFile(APIView): parser_classes = [MultiPartParser] @extend_schema( - methods=['POST'], + methods=["POST"], description=_("Upload files"), summary=_("Upload files"), operation_id=_("Upload files"), # type: ignore request=TextToSpeechAPI.get_request(), responses=TextToSpeechAPI.get_response(), - tags=[_('Application')] # type: ignore + tags=[_("Application")], # type: ignore ) def post(self, request: Request, chat_id: str): - files = request.FILES.getlist('file') + files = request.FILES.getlist("file") file_ids = [] meta = {} for file in files: file_url = FileSerializer( - data={'file': file, 'meta': meta, 'source_id': chat_id, 'source_type': FileSourceType.CHAT, }).upload( - request.user.id) - file_ids.append({'name': file.name, 'url': file_url, 'file_id': file_url.split('/')[-1]}) + data={ + "file": file, + "meta": meta, + "source_id": chat_id, + "source_type": FileSourceType.CHAT, + } + ).upload(request.user.id) + file_ids.append({"name": file.name, "url": file_url, "file_id": file_url.split("/")[-1]}) return result.success(file_ids) @@ -393,7 +446,7 @@ def create_token_and_cache(access_token, user, request): return token, FileToken(str(user.id), AuthenticationType.CHAT_USER.value).to_token() @classmethod - def generate(self, request, f_token: str, response: HttpResponse, path: str = '/chat'): + def generate(self, request, f_token: str, response: HttpResponse, path: str = "/chat"): secure = request.is_secure() response.set_cookie( "mk_file_auth", @@ -422,8 +475,8 @@ def post(self, request: Request, access_token: str = None): user = ChatUserAccessTokenSerializer.local_login(request.data, access_token) user.source = "LOCAL" token, f_token = self.create_token_and_cache(access_token, user, request) - response = result.success({'token': token}) - return self.generate(request, f_token, response, path=f'/chat/{access_token}/') + response = result.success({"token": token}) + return self.generate(request, f_token, response, path=f"/chat/{access_token}/") class Logout(APIView): diff --git a/apps/chat/views/v3/chat.py b/apps/chat/views/v3/chat.py index 8fd53a8baf1..a4c48b7535e 100644 --- a/apps/chat/views/v3/chat.py +++ b/apps/chat/views/v3/chat.py @@ -25,11 +25,11 @@ from chat.api.chat_api import ChatAPI from chat.api.chat_authentication_api import ChatAuthenticationAPI, ChatAuthenticationProfileAPI, ChatOpenAPI, OpenAIAPI from chat.serializers.chat import ( - OpenChatSerializers, + OpenAIChatSerializer, ChatSerializers, + OpenChatSerializers, SpeechToTextSerializers, TextToSpeechSerializers, - OpenAIChatSerializer, ) from chat.serializers.chat_authentication import ( AnonymousAuthenticationSerializer, diff --git a/apps/common/constants/cache_version.py b/apps/common/constants/cache_version.py index 5642b7929ae..64c29498515 100644 --- a/apps/common/constants/cache_version.py +++ b/apps/common/constants/cache_version.py @@ -1,11 +1,12 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎虎 - @file: cache_version.py - @date:2025/4/14 19:09 - @desc: +@project: MaxKB +@Author:虎虎 +@file: cache_version.py +@date:2025/4/14 19:09 +@desc: """ + from enum import Enum @@ -32,6 +33,9 @@ class Cache_Version(Enum): CHAT_INFO = "CHAT_INFO", lambda key: key + # 会话历史滚动窗口缓存(只存最近 N 条已完成记录,append-only) + CHAT_HISTORY = "CHAT_HISTORY", lambda key: key + CHAT_VARIABLE = "CHAT_VARIABLE", lambda key: key # 应用API KEY diff --git a/apps/common/handle/base_to_response.py b/apps/common/handle/base_to_response.py index 376d1a9ddd7..8f03a68f7f2 100644 --- a/apps/common/handle/base_to_response.py +++ b/apps/common/handle/base_to_response.py @@ -1,30 +1,40 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎 - @file: base_to_response.py - @date:2024/9/6 16:04 - @desc: +@project: MaxKB +@Author:虎 +@file: base_to_response.py +@date:2024/9/6 16:04 +@desc: """ + from abc import ABC, abstractmethod from rest_framework import status class BaseToResponse(ABC): + @abstractmethod + def to_stream(self, chat_id, chat_record_id, block: dict): + """ + 把一个内容块(content.to_dict())格式化成一帧 SSE 的 data 载荷(JSON 字符串)。 + 返回 None 表示该块类型在此格式下不表达(消费方跳过)。 + 只返回 data 载荷,不含 'data:'/'id:' 帧壳,帧壳由消费方拼。 + """ + pass @abstractmethod - def to_block_response(self, chat_id, chat_record_id, content, is_end, completion_tokens, - prompt_tokens, other_params: dict = None, - _status=status.HTTP_200_OK): + def to_stream_end(self, chat_id, chat_record_id, usage: dict = None): + """ + 流结束帧(如 OpenAI 的空 delta + finish_reason=stop + 最终用量)。 + 返回 None 表示该格式无需单独结束帧(如系统格式以 [DONE] 收尾)。 + """ pass @abstractmethod - def to_stream_chunk_response(self, chat_id, chat_record_id, node_id, up_node_id_list, content, is_end, - completion_tokens, - prompt_tokens, other_params: dict = None): + def to_block(self, chat_id, chat_record_id, contents: list, usage: dict = None, _status=status.HTTP_200_OK): + """从聚合后的内容块列表(content.to_dict() 的 list)构造非流式响应。""" pass @staticmethod def format_stream_chunk(response_str): - return 'data: ' + response_str + '\n\n' + return "data: " + response_str + "\n\n" diff --git a/apps/common/handle/impl/response/openai_to_response.py b/apps/common/handle/impl/response/openai_to_response.py index b4eda362555..98023aef364 100644 --- a/apps/common/handle/impl/response/openai_to_response.py +++ b/apps/common/handle/impl/response/openai_to_response.py @@ -1,12 +1,11 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎 - @file: openai_to_response.py - @date:2024/9/6 16:08 - @desc: +@project: MaxKB +@Author:虎 +@file: openai_to_response.py +@date:2024/9/6 16:08 +@desc: """ -import datetime from django.http import JsonResponse from django.utils import timezone @@ -20,34 +19,102 @@ class OpenaiToResponse(BaseToResponse): - def to_block_response(self, chat_id, chat_record_id, content, is_end, prompt_tokens, completion_tokens, - other_params: dict = None, - _status=status.HTTP_200_OK): - if other_params is None: - other_params = {} - data = ChatCompletion(id=chat_record_id, choices=[ - BlockChoice(finish_reason='stop', index=0, chat_id=chat_id, - answer_list=other_params.get('answer_list', ""), - message=ChatCompletionMessage(role='assistant', content=content))], - created=timezone.now().second, model='', object='chat.completion', - usage=CompletionUsage(completion_tokens=completion_tokens, - prompt_tokens=prompt_tokens, - total_tokens=completion_tokens + prompt_tokens) - ).dict() - return JsonResponse(data=data, status=_status) + def __init__(self): + # per-response 状态:tool_id -> index,逐帧分配,客户端按 index 累加 arguments + self._tool_index = {} + + def _to_tool_call_delta(self, block: dict) -> dict: + """把一个 ToolContent 块转成 OpenAI 的 delta.tool_calls 项;靠稳定 id 分帧、不缓冲。""" + tool_id = block.get("id") + first = tool_id not in self._tool_index + if first: + self._tool_index[tool_id] = len(self._tool_index) + index = self._tool_index[tool_id] + function = {"arguments": block.get("arguments") or ""} + if first: + function["name"] = block.get("content") or "" # ToolContent.content = 工具名 + tool_call = {"index": index, "type": "function", "function": function} + if first: + tool_call["id"] = tool_id + # 非标扩展:result(与 reasoning_content/chat_id 一致),标准客户端忽略、自家客户端读 + if block.get("result"): + tool_call["result"] = block.get("result") + return tool_call + + def to_stream(self, chat_id, chat_record_id, block: dict): + block_type = block.get("type") + delta_kwargs = {"chat_id": chat_id} + if block_type == "TEXT": + delta_kwargs["content"] = block.get("content", "") + elif block_type == "REASONING": + delta_kwargs["reasoning_content"] = block.get("content", "") + elif block_type == "TOOL": + delta_kwargs["tool_calls"] = [self._to_tool_call_delta(block)] + else: + # FORM / FAILURE 等:OpenAI 流不表达,跳过 + return None + # 内容帧:finish_reason=None、usage=None(用量只在结束帧给,符合 OpenAI 规范) + return ChatCompletionChunk( + id=str(chat_record_id), + model="", + object="chat.completion.chunk", + created=int(timezone.now().timestamp()), + choices=[Choice(delta=ChoiceDelta(**delta_kwargs), finish_reason=None, index=0)], + ).json() - def to_stream_chunk_response(self, chat_id, chat_record_id, node_id, up_node_id_list, content, is_end, - prompt_tokens, - completion_tokens, other_params: dict = None): - if other_params is None: - other_params = {} - chunk = ChatCompletionChunk(id=chat_record_id, model='', object='chat.completion.chunk', - created=timezone.now().second, choices=[ - Choice(delta=ChoiceDelta(content=content, reasoning_content=other_params.get('reasoning_content', ""), - chat_id=chat_id), - finish_reason='stop' if is_end else None, - index=0)], - usage=CompletionUsage(completion_tokens=completion_tokens, - prompt_tokens=prompt_tokens, - total_tokens=completion_tokens + prompt_tokens)).json() - return super().format_stream_chunk(chunk) + def to_stream_end(self, chat_id, chat_record_id, usage: dict = None): + # 结束帧:空 delta + finish_reason=stop + 最终用量 + usage = usage or {} + completion_tokens = usage.get("completion_tokens", 0) + prompt_tokens = usage.get("prompt_tokens", 0) + return ChatCompletionChunk( + id=str(chat_record_id), + model="", + object="chat.completion.chunk", + created=int(timezone.now().timestamp()), + choices=[Choice(delta=ChoiceDelta(chat_id=chat_id), finish_reason="stop", index=0)], + usage=CompletionUsage( + completion_tokens=completion_tokens, + prompt_tokens=prompt_tokens, + total_tokens=completion_tokens + prompt_tokens, + ), + ).json() + + def to_block(self, chat_id, chat_record_id, contents: list, usage: dict = None, _status=status.HTTP_200_OK): + usage = usage or {} + answer = "".join(c.get("content", "") for c in (contents or []) if c.get("type") == "TEXT") + tool_calls = [] + for c in contents or []: + if c.get("type") != "TOOL": + continue + tc = { + "index": len(tool_calls), + "id": c.get("id"), + "type": "function", + "function": {"name": c.get("content") or "", "arguments": c.get("arguments") or ""}, + } + if c.get("result"): + tc["result"] = c.get("result") + tool_calls.append(tc) + message_kwargs = {"role": "assistant", "content": answer} + if tool_calls: + message_kwargs["tool_calls"] = tool_calls + completion_tokens = usage.get("completion_tokens", 0) + prompt_tokens = usage.get("prompt_tokens", 0) + data = ChatCompletion( + id=str(chat_record_id), + choices=[ + BlockChoice( + finish_reason="stop", index=0, chat_id=chat_id, message=ChatCompletionMessage(**message_kwargs) + ) + ], + created=int(timezone.now().timestamp()), + model="", + object="chat.completion", + usage=CompletionUsage( + completion_tokens=completion_tokens, + prompt_tokens=prompt_tokens, + total_tokens=completion_tokens + prompt_tokens, + ), + ).dict() + return JsonResponse(data=data, status=_status) diff --git a/apps/common/handle/impl/response/system_to_response.py b/apps/common/handle/impl/response/system_to_response.py index a1a530dba08..29243613ae4 100644 --- a/apps/common/handle/impl/response/system_to_response.py +++ b/apps/common/handle/impl/response/system_to_response.py @@ -1,11 +1,12 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎 - @file: system_to_response.py - @date:2024/9/6 18:03 - @desc: +@project: MaxKB +@Author:虎 +@file: system_to_response.py +@date:2024/9/6 18:03 +@desc: """ + import json from rest_framework import status @@ -15,27 +16,35 @@ class SystemToResponse(BaseToResponse): - def to_block_response(self, chat_id, chat_record_id, content, is_end, completion_tokens, - prompt_tokens, other_params: dict = None, - _status=status.HTTP_200_OK): - if other_params is None: - other_params = {} - return result.success({'chat_id': str(chat_id), 'id': str(chat_record_id), 'operate': True, - 'content': content, 'is_end': is_end, **other_params, - 'completion_tokens': completion_tokens, 'prompt_tokens': prompt_tokens}, - response_status=_status, - code=_status) + def to_stream(self, chat_id, chat_record_id, block: dict): + # 沿用前端在解析的信封 shape:{chat_id, chat_record_id, content:[block]} + # 系统格式所有块类型都原样下发(block 即 content.to_dict()) + return json.dumps( + { + "chat_id": str(chat_id), + "chat_record_id": str(chat_record_id), + "content": [block], + }, + ensure_ascii=False, + ) + + def to_stream_end(self, chat_id, chat_record_id, usage: dict = None): + # 系统格式以 [DONE] 收尾,无需单独结束帧 + return None - def to_stream_chunk_response(self, chat_id, chat_record_id, node_id, up_node_id_list, content, is_end, - completion_tokens, - prompt_tokens, other_params: dict = None): - if other_params is None: - other_params = {} - chunk = json.dumps({'chat_id': str(chat_id), 'chat_record_id': str(chat_record_id), 'operate': True, - 'content': content, 'node_id': node_id, 'up_node_id_list': up_node_id_list, - 'is_end': is_end, - 'usage': {'completion_tokens': completion_tokens, - 'prompt_tokens': prompt_tokens, - 'total_tokens': completion_tokens + prompt_tokens}, - **other_params}) - return super().format_stream_chunk(chunk) + def to_block(self, chat_id, chat_record_id, contents: list, usage: dict = None, _status=status.HTTP_200_OK): + usage = usage or {} + answer = "".join(c.get("content", "") for c in (contents or []) if c.get("type") == "TEXT") + return result.success( + { + "chat_id": str(chat_id), + "id": str(chat_record_id), + "operate": True, + "content": answer, + "is_end": True, + "completion_tokens": usage.get("completion_tokens", 0), + "prompt_tokens": usage.get("prompt_tokens", 0), + }, + response_status=_status, + code=_status, + )