From 74df19af2d381bd12c065a610ceb3618e6f00ffe Mon Sep 17 00:00:00 2001 From: shaohuzhang1 Date: Mon, 31 Aug 2026 17:04:32 +0800 Subject: [PATCH] feat: Add a debug dialog box --- .../serializers/application_chat_record.py | 37 +- apps/application/serializers/common.py | 109 ++- apps/application/views/application_chat.py | 346 ++++--- apps/application/workflow/workflow_manage.py | 7 +- apps/chat/serializers/chat.py | 35 +- ui/package.json | 1 + ui/src/api/chat/core/request.ts | 153 ++++ ui/src/api/chat/core/types.ts | 30 + ui/src/components/conversation/api/index.ts | 160 ++++ .../conversation/chat-panel/index.vue | 856 ++++++++++++++++++ .../components/conversation/common/types.ts | 23 + .../common/use-chat-store/chat/index.ts | 195 ++++ .../common/use-chat-store/debug/index.ts | 188 ++++ .../common/use-chat-store/index.ts | 7 + .../shared/use-conversation-crud.ts | 46 + .../shared/use-message-pagination.ts | 73 ++ .../shared/use-stream-manager.ts | 139 +++ .../conversation/content-list/index.vue | 42 + .../components/conversation/content/index.vue | 24 + .../conversation/content/items/failure.vue | 34 + .../conversation/content/items/form.vue | 85 ++ .../conversation/content/items/question.vue | 149 +++ .../conversation/content/items/reasoning.vue | 83 ++ .../conversation/content/items/text.vue | 20 + .../conversation/content/items/tool.vue | 74 ++ .../components/conversation/editor/index.vue | 154 ++++ ui/src/components/conversation/index.ts | 112 +++ ui/src/components/conversation/index.vue | 160 ++++ .../components/conversation/loading/index.vue | 56 ++ .../components/conversation/sidebar/index.vue | 373 ++++++++ ui/src/components/conversation/stream.ts | 111 +++ .../workflow/ApplicationWorkflowView.vue | 136 ++- 32 files changed, 3801 insertions(+), 217 deletions(-) create mode 100644 ui/src/api/chat/core/request.ts create mode 100644 ui/src/api/chat/core/types.ts create mode 100644 ui/src/components/conversation/api/index.ts create mode 100644 ui/src/components/conversation/chat-panel/index.vue create mode 100644 ui/src/components/conversation/common/types.ts create mode 100644 ui/src/components/conversation/common/use-chat-store/chat/index.ts create mode 100644 ui/src/components/conversation/common/use-chat-store/debug/index.ts create mode 100644 ui/src/components/conversation/common/use-chat-store/index.ts create mode 100644 ui/src/components/conversation/common/use-chat-store/shared/use-conversation-crud.ts create mode 100644 ui/src/components/conversation/common/use-chat-store/shared/use-message-pagination.ts create mode 100644 ui/src/components/conversation/common/use-chat-store/shared/use-stream-manager.ts create mode 100644 ui/src/components/conversation/content-list/index.vue create mode 100644 ui/src/components/conversation/content/index.vue create mode 100644 ui/src/components/conversation/content/items/failure.vue create mode 100644 ui/src/components/conversation/content/items/form.vue create mode 100644 ui/src/components/conversation/content/items/question.vue create mode 100644 ui/src/components/conversation/content/items/reasoning.vue create mode 100644 ui/src/components/conversation/content/items/text.vue create mode 100644 ui/src/components/conversation/content/items/tool.vue create mode 100644 ui/src/components/conversation/editor/index.vue create mode 100644 ui/src/components/conversation/index.ts create mode 100644 ui/src/components/conversation/index.vue create mode 100644 ui/src/components/conversation/loading/index.vue create mode 100644 ui/src/components/conversation/sidebar/index.vue create mode 100644 ui/src/components/conversation/stream.ts diff --git a/apps/application/serializers/application_chat_record.py b/apps/application/serializers/application_chat_record.py index 3222916d996..c91c564f228 100644 --- a/apps/application/serializers/application_chat_record.py +++ b/apps/application/serializers/application_chat_record.py @@ -11,7 +11,7 @@ from typing import Dict import uuid_utils.compat as uuid -from application.models import Application, ApplicationAccessToken, ChatRecord +from application.models import Application, ApplicationAccessToken, ChatRecord, Chat from application.serializers.application_chat import ChatCountSerializer from application.serializers.common import ChatInfo from common.auth.authentication import get_is_permissions @@ -58,7 +58,7 @@ class Meta: "update_time", "version", "question", - "messages" + "messages", ] @@ -76,9 +76,12 @@ def is_valid(self, *, debug=False, raise_exception=False): query_set = query_set.filter(workspace_id=workspace_id) if not query_set.exists(): raise AppApiException(500, _("Application id does not exist")) - if not ChatRecord.objects.filter( - chat_id=self.data.get("chat_id"), chat__application_id=self.data.get("application_id") - ).exists() and not debug: + if ( + not ChatRecord.objects.filter( + chat_id=self.data.get("chat_id"), chat__application_id=self.data.get("application_id") + ).exists() + and not debug + ): raise AppApiException(500, _("Chat records for the application do not exist")) application_access_token = ( QuerySet(ApplicationAccessToken).filter(application_id=self.data.get("application_id")).first() @@ -96,9 +99,11 @@ def get_chat_record(self): ] if chat_record_list is not None and len(chat_record_list): return chat_record_list[-1] - return QuerySet(ChatRecord).filter( - id=chat_record_id, chat_id=chat_id, chat__application_id=self.data.get("application_id") - ).first() + return ( + QuerySet(ChatRecord) + .filter(id=chat_record_id, chat_id=chat_id, chat__application_id=self.data.get("application_id")) + .first() + ) def one(self, debug): self.is_valid(debug=debug, raise_exception=True) @@ -132,8 +137,8 @@ def is_valid(self, *, raise_exception=False): query_set = query_set.filter(workspace_id=workspace_id) if not query_set.exists(): raise AppApiException(500, _("Application id does not exist")) - if not ChatRecord.objects.filter( - chat_id=self.data.get("chat_id"), chat__application_id=self.data.get("application_id") + if not Chat.objects.filter( + id=self.data.get("chat_id"), application_id=self.data.get("application_id") ).exists(): raise AppApiException(500, _("Chat records for the application do not exist")) @@ -143,9 +148,9 @@ def list(self, with_valid=True): order_by = "create_time" if self.data.get("order_asc") is None or self.data.get("order_asc") else "-create_time" return [ ChatRecordSerializerModel(chat_record).data - for chat_record in QuerySet(ChatRecord).filter( - chat_id=self.data.get("chat_id"), chat__application_id=self.data.get("application_id") - ).order_by(order_by) + for chat_record in QuerySet(ChatRecord) + .filter(chat_id=self.data.get("chat_id"), chat__application_id=self.data.get("application_id")) + .order_by(order_by) ] @staticmethod @@ -240,9 +245,9 @@ def page(self, current_page: int, page_size: int, with_valid=True, show_source=N page = page_search( current_page, page_size, - QuerySet(ChatRecord).filter( - chat_id=self.data.get("chat_id"), chat__application_id=self.data.get("application_id") - ).order_by(order_by), + QuerySet(ChatRecord) + .filter(chat_id=self.data.get("chat_id"), chat__application_id=self.data.get("application_id")) + .order_by(order_by), post_records_handler=lambda chat_record: self.reset_chat_record(chat_record, show_source, show_exec), ) return page diff --git a/apps/application/serializers/common.py b/apps/application/serializers/common.py index 3311a64491e..a19b70760c4 100644 --- a/apps/application/serializers/common.py +++ b/apps/application/serializers/common.py @@ -400,61 +400,60 @@ def append_chat_record(self, chat_record: ChatRecord): break if is_save: self.chat_record_list.append(chat_record) - if not self.debug: - if not QuerySet(Chat).filter(id=self.chat_id).exists(): - Chat( - id=self.chat_id, - application_id=self.application_id, - abstract=chat_record.problem_text[0:1024], - chat_user_id=self.chat_user_id, - chat_user_type=self.chat_user_type, - ip_address=self.ip_address, - source=self.source, - asker=self.get_chat_user(), - ).save() - else: - QuerySet(Chat).filter(id=self.chat_id).update(update_time=timezone.now()) - # 记录Token消耗 - total_tokens = (chat_record.message_tokens or 0) + (chat_record.answer_tokens or 0) - if total_tokens > 0: - ChatUserTokenQuota.consume(self.chat_user_id, total_tokens) - # 插入会话记录 - QuerySet(ChatRecord).update_or_create( - id=chat_record.id, - create_defaults={ - "id": chat_record.id, - "chat_id": chat_record.chat_id, - "vote_status": chat_record.vote_status, - "problem_text": chat_record.problem_text, - "answer_text": chat_record.answer_text, - "answer_text_list": chat_record.answer_text_list, - "message_tokens": chat_record.message_tokens, - "answer_tokens": chat_record.answer_tokens, - "const": chat_record.const, - "details": chat_record.details, - "improve_paragraph_id_list": chat_record.improve_paragraph_id_list, - "run_time": chat_record.run_time, - "source": chat_record.source, - "ip_address": chat_record.ip_address or "", - "index": chat_record.index, - }, - defaults={ - "vote_status": chat_record.vote_status, - "problem_text": chat_record.problem_text, - "answer_text": chat_record.answer_text, - "answer_text_list": chat_record.answer_text_list, - "message_tokens": chat_record.message_tokens, - "answer_tokens": chat_record.answer_tokens, - "const": chat_record.const, - "details": chat_record.details, - "improve_paragraph_id_list": chat_record.improve_paragraph_id_list, - "run_time": chat_record.run_time, - "index": chat_record.index, - "source": chat_record.source, - "ip_address": chat_record.ip_address or "", - }, - ) - ChatCountSerializer(data={"chat_id": self.chat_id}).update_chat() + if not QuerySet(Chat).filter(id=self.chat_id).exists(): + Chat( + id=self.chat_id, + application_id=self.application_id, + abstract=chat_record.problem_text[0:1024], + chat_user_id=self.chat_user_id, + chat_user_type=self.chat_user_type, + ip_address=self.ip_address, + source=self.source, + asker=self.get_chat_user(), + ).save() + else: + QuerySet(Chat).filter(id=self.chat_id).update(update_time=timezone.now()) + # 记录Token消耗 + total_tokens = (chat_record.message_tokens or 0) + (chat_record.answer_tokens or 0) + if total_tokens > 0: + ChatUserTokenQuota.consume(self.chat_user_id, total_tokens) + # 插入会话记录 + QuerySet(ChatRecord).update_or_create( + id=chat_record.id, + create_defaults={ + "id": chat_record.id, + "chat_id": chat_record.chat_id, + "vote_status": chat_record.vote_status, + "problem_text": chat_record.problem_text, + "answer_text": chat_record.answer_text, + "answer_text_list": chat_record.answer_text_list, + "message_tokens": chat_record.message_tokens, + "answer_tokens": chat_record.answer_tokens, + "const": chat_record.const, + "details": chat_record.details, + "improve_paragraph_id_list": chat_record.improve_paragraph_id_list, + "run_time": chat_record.run_time, + "source": chat_record.source, + "ip_address": chat_record.ip_address or "", + "index": chat_record.index, + }, + defaults={ + "vote_status": chat_record.vote_status, + "problem_text": chat_record.problem_text, + "answer_text": chat_record.answer_text, + "answer_text_list": chat_record.answer_text_list, + "message_tokens": chat_record.message_tokens, + "answer_tokens": chat_record.answer_tokens, + "const": chat_record.const, + "details": chat_record.details, + "improve_paragraph_id_list": chat_record.improve_paragraph_id_list, + "run_time": chat_record.run_time, + "index": chat_record.index, + "source": chat_record.source, + "ip_address": chat_record.ip_address or "", + }, + ) + ChatCountSerializer(data={"chat_id": self.chat_id}).update_chat() def to_dict(self): diff --git a/apps/application/views/application_chat.py b/apps/application/views/application_chat.py index 6ee7befddfe..4537025d581 100644 --- a/apps/application/views/application_chat.py +++ b/apps/application/views/application_chat.py @@ -1,10 +1,10 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎虎 - @file: application_chat.py - @date:2025/6/10 11:00 - @desc: +@project: MaxKB +@Author:虎虎 +@file: application_chat.py +@date:2025/6/10 11:00 +@desc: """ from django.db.models import QuerySet @@ -13,8 +13,11 @@ from rest_framework.request import Request from rest_framework.views import APIView -from application.api.application_chat import ApplicationChatQueryAPI, ApplicationChatQueryPageAPI, \ - ApplicationChatExportAPI +from application.api.application_chat import ( + ApplicationChatQueryAPI, + ApplicationChatQueryPageAPI, + ApplicationChatExportAPI, +) from application.models import ChatUserType, Application, ChatSourceChoices from application.serializers.application_chat import ApplicationChatQuerySerializers from chat.api.chat_api import ChatAPI, PromptGenerateAPI, PageHistoricalConversationAPI, HistoricalConversationRecordAPI @@ -34,9 +37,7 @@ def get_application_operation_object(application_id): application_model = QuerySet(model=Application).filter(id=application_id).first() if application_model is not None: - return { - 'name': application_model.name - } + return {"name": application_model.name} return {} @@ -44,202 +45,268 @@ class ApplicationChat(APIView): authentication_classes = [TokenAuth] @extend_schema( - methods=['GET'], + methods=["GET"], description=_("Get the conversation list"), summary=_("Get the conversation list"), operation_id=_("Get the conversation list"), # type: ignore request=ApplicationChatQueryAPI.get_request(), parameters=ApplicationChatQueryAPI.get_parameters(), responses=ApplicationChatQueryAPI.get_response(), - tags=[_("Application/Conversation Log")] # type: ignore + tags=[_("Application/Conversation Log")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), ) - @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) def get(self, request: Request, workspace_id: str, application_id: str): - return result.success(ApplicationChatQuerySerializers( - data={**query_params_to_single_dict(request.query_params), 'workspace_id': workspace_id, - 'application_id': application_id, - }).list()) + return result.success( + ApplicationChatQuerySerializers( + data={ + **query_params_to_single_dict(request.query_params), + "workspace_id": workspace_id, + "application_id": application_id, + } + ).list() + ) class Page(APIView): authentication_classes = [TokenAuth] @extend_schema( - methods=['GET'], + methods=["GET"], description=_("Get the conversation list by page"), summary=_("Get the conversation list by page"), operation_id=_("Get the conversation list by page"), # type: ignore request=ApplicationChatQueryPageAPI.get_request(), parameters=ApplicationChatQueryPageAPI.get_parameters(), responses=ApplicationChatQueryPageAPI.get_response(), - tags=[_("Application/Conversation Log")] # type: ignore + tags=[_("Application/Conversation Log")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), ) - @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) def get(self, request: Request, workspace_id: str, application_id: str, current_page: int, page_size: int): - return result.success(ApplicationChatQuerySerializers( - data={**query_params_to_single_dict(request.query_params), 'workspace_id': workspace_id, - 'application_id': application_id, - }).page(current_page=current_page, - page_size=page_size)) + return result.success( + ApplicationChatQuerySerializers( + data={ + **query_params_to_single_dict(request.query_params), + "workspace_id": workspace_id, + "application_id": application_id, + } + ).page(current_page=current_page, page_size=page_size) + ) class Export(APIView): authentication_classes = [TokenAuth] @extend_schema( - methods=['POST'], + methods=["POST"], description=_("Export conversation"), summary=_("Export conversation"), operation_id=_("Export conversation"), # type: ignore request=ApplicationChatExportAPI.get_request(), parameters=ApplicationChatExportAPI.get_parameters(), responses=ApplicationChatExportAPI.get_response(), - tags=[_("Application/Conversation Log")] # type: ignore + tags=[_("Application/Conversation Log")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_CHAT_LOG_EXPORT.get_workspace_application_permission(), + PermissionConstants.APPLICATION_CHAT_LOG_EXPORT.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), ) - @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_EXPORT.get_workspace_application_permission(), - PermissionConstants.APPLICATION_CHAT_LOG_EXPORT.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) def post(self, request: Request, workspace_id: str, application_id: str): return ApplicationChatQuerySerializers( - data={**query_params_to_single_dict(request.query_params), 'workspace_id': workspace_id, - 'application_id': application_id, - }).export(request.data) + data={ + **query_params_to_single_dict(request.query_params), + "workspace_id": workspace_id, + "application_id": application_id, + } + ).export(request.data) class OpenView(APIView): authentication_classes = [TokenAuth] @extend_schema( - methods=['GET'], + methods=["GET"], description=_("Get a temporary session id based on the application id"), summary=_("Get a temporary session id based on the application id"), operation_id=_("Get a temporary session id based on the application id"), # type: ignore parameters=ChatOpenAPI.get_parameters(), responses=None, - tags=[_('Application')] # type: ignore + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), ) - @has_permissions(PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) def get(self, request: Request, workspace_id: str, application_id: str): ip_address = _get_ip_address(request) - return result.success(OpenChatSerializers( - data={'workspace_id': workspace_id, 'application_id': application_id, - 'chat_user_id': str(request.user.id), 'chat_user_type': ChatUserType.SYSTEM_USER, - 'ip_address': ip_address, - 'source': { - 'type': ChatSourceChoices.ONLINE.value}, - 'debug': True}).open()) + return result.success( + OpenChatSerializers( + data={ + "workspace_id": workspace_id, + "application_id": application_id, + "chat_user_id": str(request.user.id), + "chat_user_type": ChatUserType.SYSTEM_USER, + "ip_address": ip_address, + "source": {"type": ChatSourceChoices.ONLINE.value}, + "debug": True, + } + ).open() + ) class ChatView(APIView): authentication_classes = [TokenAuth] @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=[_('Application')] # type: ignore + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), ) - @has_permissions(PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) def post(self, request: Request, workspace_id: str, application_id: str, chat_id: str): - return DebugChatSerializers(data={'chat_id': chat_id}).chat(request.data) + # 携带 open 上下文:前端本地生成 chat_id 首次发消息时,缓存缺失则按该 id 现开会话。 + return DebugChatSerializers( + data={ + "chat_id": chat_id, + "workspace_id": workspace_id, + "application_id": application_id, + "chat_user_id": str(request.user.id), + "chat_user_type": ChatUserType.SYSTEM_USER, + "ip_address": _get_ip_address(request), + "source": {"type": ChatSourceChoices.ONLINE.value}, + } + ).chat(request.data) class CancelWorkflowView(APIView): authentication_classes = [TokenAuth] @extend_schema( - methods=['POST'], + methods=["POST"], description=_("Cancel running workflow"), summary=_("Cancel running workflow"), operation_id=_("Cancel running workflow"), # type: ignore - tags=[_('Application')] # type: ignore + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), ) - @has_permissions(PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) def post(self, request: Request, workspace_id: str, application_id: str, 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.error(_('Failed to cancel workflow')) + return result.error(_("Failed to cancel workflow")) class ResumeStreamView(APIView): authentication_classes = [TokenAuth] @extend_schema( - methods=['POST'], + methods=["POST"], description=_("Resume stream for workflow"), summary=_("Resume stream for workflow"), operation_id=_("Resume stream for workflow"), # type: ignore - tags=[_('Application')] # type: ignore + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), ) - @has_permissions(PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) def post(self, request: Request, workspace_id: str, application_id: str, chat_id: str, chat_record_id: str): - return ResumeSerializers(data={'chat_id': chat_id, 'chat_record_id': chat_record_id}).resume(request) + return ResumeSerializers(data={"chat_id": chat_id, "chat_record_id": chat_record_id}).resume(request) class PromptGenerateView(APIView): authentication_classes = [TokenAuth] @extend_schema( - methods=['POST'], + methods=["POST"], description=_("generate prompt"), summary=_("generate prompt"), operation_id=_("generate prompt"), # type: ignore request=PromptGenerateAPI.get_request(), parameters=PromptGenerateAPI.get_parameters(), responses=None, - tags=[_('Application')] # type: ignore + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) + @log( + menu="Application", + operate="Generate prompt", + get_operation_object=lambda r, k: get_application_operation_object(k.get("application_id")), ) - @has_permissions(PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) - @log(menu='Application', operate='Generate prompt', - get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id'))) def post(self, request: Request, workspace_id: str, model_id: str, application_id: str): - return PromptGenerateSerializer(data={'workspace_id': workspace_id, 'model_id': model_id, - 'application_id': application_id}).generate_prompt(instance=request.data) + return PromptGenerateSerializer( + data={"workspace_id": workspace_id, "model_id": model_id, "application_id": application_id} + ).generate_prompt(instance=request.data) class DebugHistoricalConversation(APIView): @@ -249,55 +316,74 @@ class PageView(APIView): authentication_classes = [TokenAuth] @extend_schema( - methods=['GET'], + methods=["GET"], description=_("Get historical conversation by page"), summary=_("Get historical conversation by page"), operation_id=_("Get historical conversation by page"), # type: ignore parameters=PageHistoricalConversationAPI.get_parameters(), responses=PageHistoricalConversationAPI.get_response(), - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), ) - @has_permissions(PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) def get(self, request: Request, workspace_id: str, application_id: str, current_page: int, page_size: int): from chat.serializers.chat_record import HistoricalConversationSerializer - return result.success(HistoricalConversationSerializer( - data={ - 'application_id': application_id, - 'chat_user_id': str(request.user.id), - }).page(current_page, page_size)) + + return result.success( + HistoricalConversationSerializer( + data={ + "application_id": application_id, + "chat_user_id": str(request.user.id), + } + ).page(current_page, page_size) + ) class RecordPageView(APIView): authentication_classes = [TokenAuth] @extend_schema( - methods=['GET'], + methods=["GET"], description=_("Get historical conversation records"), summary=_("Get historical conversation records"), operation_id=_("Get historical conversation records"), # type: ignore parameters=HistoricalConversationRecordAPI.get_parameters(), responses=HistoricalConversationRecordAPI.get_response(), - tags=[_('Chat')] # type: ignore + tags=[_("Chat")], # type: ignore ) - @has_permissions(PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), - PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), - ViewPermission([RoleConstants.USER.get_workspace_role()], - [PermissionConstants.APPLICATION.get_workspace_application_permission()], - compare=CompareConstants.AND), - RoleConstants.WORKSPACE_MANAGE.get_workspace_role()) - def get(self, request: Request, workspace_id: str, application_id: str, chat_id: str, current_page: int, - page_size: int): + @has_permissions( + PermissionConstants.APPLICATION_READ.get_workspace_application_permission(), + PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) + def get( + self, + request: Request, + workspace_id: str, + application_id: str, + chat_id: str, + current_page: int, + page_size: int, + ): from chat.serializers.chat_record import HistoricalConversationRecordSerializer serializer = HistoricalConversationRecordSerializer( data={ - 'application_id': application_id, - 'chat_id': chat_id, - 'chat_user_id': str(request.user.id), + "application_id": application_id, + "chat_id": chat_id, + "chat_user_id": str(request.user.id), } ) return result.success(serializer.page(current_page, page_size)) @@ -308,12 +394,14 @@ class Operate(APIView): def delete(self, request: Request, workspace_id: str, application_id: str, chat_id: str): from django.db.models import QuerySet from application.models import Chat + QuerySet(Chat).filter(id=chat_id, application_id=application_id).update(is_deleted=True) return result.success(True) def put(self, request: Request, workspace_id: str, application_id: str, chat_id: str): from django.db.models import QuerySet from application.models import Chat - abstract = request.data.get('abstract', '') + + abstract = request.data.get("abstract", "") QuerySet(Chat).filter(id=chat_id, application_id=application_id).update(abstract=abstract) return result.success(True) diff --git a/apps/application/workflow/workflow_manage.py b/apps/application/workflow/workflow_manage.py index 89185f1fd91..a4294b4313c 100644 --- a/apps/application/workflow/workflow_manage.py +++ b/apps/application/workflow/workflow_manage.py @@ -183,7 +183,6 @@ def end(self, error=None): 工作流输出结束的时候调用 @return: None """ - self.details = self.get_details() self.call_back.on_complete(self, error) def get_parameters(self): @@ -193,7 +192,7 @@ def get_parameters(self): """ return self.parameters - def get_details(self, position: Position = None, old_details=None): + def get_details(self, position: Dict = None, old_details=None): """ 获取所有节点的运行详情 @param position: 位置信息,用于表单节点等需要断点续跑的场景 @@ -205,10 +204,10 @@ def get_details(self, position: Position = None, old_details=None): if old_details and position: for index, value in enumerate(old_details): details_result.append(value) - if position.id == value.get("node_id"): + if position.get("id") == value.get("node_id"): position_index = index for index, node in enumerate(self.nodes): - if node.node.id == position.id and index == 0: + if position is not None and node.node.id == position.get("id") and index == 0: details = node.get_details( index + position_index, position=position, old_details=old_details[position_index] ) diff --git a/apps/chat/serializers/chat.py b/apps/chat/serializers/chat.py index 02d53545e57..918041823c6 100644 --- a/apps/chat/serializers/chat.py +++ b/apps/chat/serializers/chat.py @@ -166,11 +166,32 @@ def handler( 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")) + 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")) + source = serializers.JSONField(required=False, allow_null=True, label=_("Source")) 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( @@ -1006,7 +1027,7 @@ def is_valid(self, *, raise_exception=False): if not query_set.exists(): raise AppApiException(500, gettext("Application does not exist")) - def open(self): + 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) @@ -1018,11 +1039,11 @@ def open(self): 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) + return self.open_simple(application, chat_id) else: - return self.open_work_flow(application) + return self.open_work_flow(application, chat_id) - def open_work_flow(self, application): + 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") @@ -1030,13 +1051,13 @@ def open_work_flow(self, application): ip_address = self.data.get("ip_address") source = self.data.get("source") debug = self.data.get("debug") - chat_id = str(uuid.uuid7()) + 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 - def open_simple(self, application): + 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") @@ -1056,7 +1077,7 @@ def open_simple(self, application): ) knowledge_id_list = application_version.knowledge_ids - chat_id = str(uuid.uuid7()) + chat_id = chat_id or str(uuid.uuid7()) chat_info = ChatInfo( chat_id, chat_user_id, diff --git a/ui/package.json b/ui/package.json index e0c86e1b427..2bfb0877ca5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -33,6 +33,7 @@ "element-plus": "^2.14.2", "jsencrypt": "^3.5.4", "lodash": "^4.18.1", + "md-editor-v3": "^6.5.6", "nanoid": "^6.0.1", "nprogress": "^0.2.0", "pinia": "^3.0.4", diff --git a/ui/src/api/chat/core/request.ts b/ui/src/api/chat/core/request.ts new file mode 100644 index 00000000000..b9728d770bb --- /dev/null +++ b/ui/src/api/chat/core/request.ts @@ -0,0 +1,153 @@ +/** 提供 Chat API 的 Axios 实例与常用 HTTP 请求封装。 */ + +import axios, { + AxiosHeaders, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from 'axios' +import { useStore } from '@/stores' +import type { ApiResponse, LoadingTarget } from './types' +import type { Dict } from '@/api/types' +import { MsgError } from '@/utils/message' + +const DEFAULT_TIMEOUT = 30 * 60 * 1_000 // 30 minutes +const CHAT_BASE_PATH = window.MaxKB?.chatPrefix || import.meta.env.VITE_BASE_PATH || '/chat/' + +function setRequestHeaders(config: InternalAxiosRequestConfig) { + const { auth, user } = useStore() + + if (!(config.headers instanceof AxiosHeaders)) { + config.headers = new AxiosHeaders(config.headers) + } + if (auth.token) { + config.headers.set('Authorization', `Bearer ${auth.token}`) + } + if (user.language) { + config.headers.set('Accept-Language', user.language) + } + + return config +} + +function startLoading(loading?: LoadingTarget) { + if (!loading) { + return + } + if ('start' in loading) { + loading.start() + return + } + loading.value = true +} + +function finishLoading(loading?: LoadingTarget) { + if (!loading) { + return + } + if ('done' in loading) { + loading.done() + return + } + loading.value = false +} + +async function getResponseErrorMessage(error: unknown) { + if (!axios.isAxiosError | string>(error)) { + return undefined + } + + const responseData = error.response?.data + if (typeof responseData === 'string') { + return responseData + } + return responseData?.message +} + +export const request = axios.create({ + baseURL: `${CHAT_BASE_PATH.replace(/\/+$/, '')}/api`, + timeout: DEFAULT_TIMEOUT, + withCredentials: false, +}) + +request.interceptors.request.use(setRequestHeaders) + +request.interceptors.response.use( + (response) => { + const responseData = response.data as ApiResponse + if (responseData.code !== 200) { + MsgError(responseData.message) + return Promise.reject(responseData) + } + return response + }, + async (error: unknown) => { + if (!axios.isAxiosError>(error)) { + return Promise.reject(error) + } + + const responseMessage = await getResponseErrorMessage(error) + MsgError(responseMessage || error.message) + return Promise.reject(error) + }, +) + +/** + * 统一解包标准 API 响应,并同步可选的 loading 状态。 + */ +export async function promise( + requestPromise: Promise>>, + loading?: LoadingTarget, +) { + startLoading(loading) + try { + const response = await requestPromise + return response.data.data + } finally { + finishLoading(loading) + } +} + +/** 发送 GET 请求。 */ +export function get( + url: string, + params?: Dict, + loading?: LoadingTarget, + timeout?: number, +) { + return promise(request.get>(url, { params, timeout }), loading) +} + +/** 发送 POST 请求。 */ +export function post( + url: string, + data?: TData, + params?: Dict, + loading?: LoadingTarget, + timeout?: number, +) { + return promise(request.post>(url, data, { params, timeout }), loading) +} + +/** 发送 PUT 请求。 */ +export function put( + url: string, + data?: TData, + params?: Dict, + loading?: LoadingTarget, + timeout?: number, +) { + return promise(request.put>(url, data, { params, timeout }), loading) +} + +/** 发送 DELETE 请求。 */ +export function del( + url: string, + params?: Dict, + data?: TData, + loading?: LoadingTarget, + timeout?: number, +) { + return promise(request.delete>(url, { params, data, timeout }), loading) +} + +export default request diff --git a/ui/src/api/chat/core/types.ts b/ui/src/api/chat/core/types.ts new file mode 100644 index 00000000000..4040dd2fc85 --- /dev/null +++ b/ui/src/api/chat/core/types.ts @@ -0,0 +1,30 @@ +/** Chat 请求基础设施内部使用的协议和 loading 类型。 */ + +export interface ApiResponse { + code: number + message: string + data: T +} + +interface LoadingRef { + value: boolean +} + +interface LoadingProgress { + start(): void + done(): void +} + +export type LoadingTarget = LoadingRef | LoadingProgress + +export interface ResponsePage { + total: number + records: T[] + current: number + size: number +} + +export interface ParamsPage { + currentPage: number + pageSize: number +} diff --git a/ui/src/components/conversation/api/index.ts b/ui/src/components/conversation/api/index.ts new file mode 100644 index 00000000000..6d1df02cb67 --- /dev/null +++ b/ui/src/components/conversation/api/index.ts @@ -0,0 +1,160 @@ +/** + * 会话组件的请求封装。 + * + * 普通请求统一走各自应用的 core 请求客户端(admin:`@/api/admin/core/request`, + * chat:`@/api/chat/core/request`),返回已解包的业务数据。 + * 唯一的例外是流式对话:core 客户端基于 axios,无法读取 SSE 流,因此这里就地用 fetch + * 封装 `postStream`,返回原始 `Response` 交给 `ConversationStream` 解析。 + */ + +import { + get as adminGet, + post as adminPost, + put as adminPut, + del as adminDel, +} from '@/api/admin/core/request' +import { + get as chatGet, + post as chatPost, + put as chatPut, + del as chatDel, +} from '@/api/chat/core/request' +import { useStore } from '@/stores' +import { getWorkspaceId } from '@/utils/resource-context' + +const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '') +const adminApiBase = + trimTrailingSlash(window.MaxKB?.prefix || import.meta.env.VITE_BASE_PATH || '/admin/') + '/api' + +const chatApiBase = + trimTrailingSlash(window.MaxKB?.chatPrefix || import.meta.env.VITE_BASE_PATH || '/chat/') + '/api' + +/** 发送流式 POST 请求,返回原始 `Response` 供 SSE 读取。 */ +function postStream(base: string, path: string, data?: unknown) { + const { auth, user } = useStore() + const headers: Record = { 'Content-Type': 'application/json' } + if (auth.token) { + headers['Authorization'] = `Bearer ${auth.token}` + } + if (user.language) { + headers['Accept-Language'] = user.language + } + return fetch(`${base}${path.startsWith('/') ? path : `/${path}`}`, { + method: 'POST', + headers, + body: data === undefined ? undefined : JSON.stringify(data), + }) +} + +export const debugApi = { + open: (applicationId: string) => + adminGet(`/workspace/${getWorkspaceId()}/application/${applicationId}/open`), + + chat: (chatId: string, data: any, applicationId?: string) => + postStream( + adminApiBase, + `/workspace/${getWorkspaceId()}/application/${applicationId}/chat/${chatId}/chat_message`, + data, + ), + + cancelChat: (chatId: string, applicationId?: string) => + adminPost( + `/workspace/${getWorkspaceId()}/application/${applicationId}/chat/${chatId}/cancel_chat_message`, + {}, + ), + + resumeStream: (chatId: string, chatRecordId: string, applicationId?: string) => + postStream( + adminApiBase, + `/workspace/${getWorkspaceId()}/application/${applicationId}/chat/${chatId}/chat_record/${chatRecordId}/resume_chat_message`, + ), + + history: (page: number, size: number, applicationId?: string) => { + const wsId = getWorkspaceId() + if (applicationId) { + return adminGet( + `/workspace/${wsId}/application/${applicationId}/historical_conversation/${page}/${size}`, + ) + } + return adminGet(`/workspace/${wsId}/historical_conversation/${page}/${size}`) + }, + + records: (chatId: string, page: number, size: number, applicationId?: string) => { + const wsId = getWorkspaceId() + if (applicationId) { + return adminGet( + `/workspace/${wsId}/application/${applicationId}/historical_conversation_record/${chatId}/${page}/${size}`, + ) + } + return adminGet(`/workspace/${wsId}/historical_conversation_record/${chatId}/${page}/${size}`) + }, + + deleteChat: (chatId: string, applicationId?: string) => { + const wsId = getWorkspaceId() + if (applicationId) { + return adminDel( + `/workspace/${wsId}/application/${applicationId}/historical_conversation/${chatId}`, + ) + } + return adminDel(`/workspace/${wsId}/historical_conversation/${chatId}`) + }, + + modifyChat: (chatId: string, data: any, applicationId?: string) => { + const wsId = getWorkspaceId() + if (applicationId) { + return adminPut( + `/workspace/${wsId}/application/${applicationId}/historical_conversation/${chatId}`, + data, + ) + } + return adminPut(`/workspace/${wsId}/historical_conversation/${chatId}`, data) + }, + + uploadFile: (file: File, chatId: string) => { + const fd = new FormData() + fd.append('file', file) + fd.append('source_id', chatId) + fd.append('source_type', 'CHAT') + return adminPost('/oss/file', fd) + }, + + speechToText: (data: any) => adminPost('/speech_to_text', data), +} + +export const chatApi = { + open: () => chatGet('/open'), + + chat: (chatId: string, data: any) => postStream(chatApiBase, `/chat_message/${chatId}`, data), + + cancelChat: (chatId: string) => chatPost(`/chat_message/${chatId}/cancel`, {}), + + resumeStream: (chatId: string, chatRecordId: string) => + postStream(chatApiBase, `/chat_message/${chatId}/resume/${chatRecordId}`), + + history: (page: number, size: number) => + chatGet(`/historical_conversation/${page}/${size}`), + + records: (chatId: string, page: number, size: number) => + chatGet(`/historical_conversation_record/${chatId}/${page}/${size}`), + + deleteChat: (chatId: string) => chatDel(`/historical_conversation/${chatId}`), + + modifyChat: (chatId: string, data: any) => + chatPut(`/historical_conversation/${chatId}`, data), + + uploadFile: (file: File, chatId: string) => { + const fd = new FormData() + fd.append('file', file) + fd.append('source_id', chatId) + fd.append('source_type', 'CHAT') + return chatPost('/oss/file', fd) + }, + + speechToText: (data: any) => chatPost('/speech_to_text', data), +} + +export type ChatType = 'CHAT' | 'DEBUG' + +export function getApi(type: ChatType) { + return type === 'DEBUG' ? debugApi : chatApi +} diff --git a/ui/src/components/conversation/chat-panel/index.vue b/ui/src/components/conversation/chat-panel/index.vue new file mode 100644 index 00000000000..43e93d5b71a --- /dev/null +++ b/ui/src/components/conversation/chat-panel/index.vue @@ -0,0 +1,856 @@ + + + + + diff --git a/ui/src/components/conversation/common/types.ts b/ui/src/components/conversation/common/types.ts new file mode 100644 index 00000000000..91103ac9578 --- /dev/null +++ b/ui/src/components/conversation/common/types.ts @@ -0,0 +1,23 @@ +export interface Conversation { + id: string + abstract: string + application_id?: string + create_time?: string + update_time?: string +} + +export interface ChatMessage { + role: 'USER' | 'ASSISTANT' + content: any[] + id: string + write_ed?: boolean +} + +export interface StreamChunk { + content?: any[] + chat_id?: string + chat_record_id?: string + [key: string]: any +} + +export type ChatType = 'CHAT' | 'DEBUG' diff --git a/ui/src/components/conversation/common/use-chat-store/chat/index.ts b/ui/src/components/conversation/common/use-chat-store/chat/index.ts new file mode 100644 index 00000000000..35d1807c9b8 --- /dev/null +++ b/ui/src/components/conversation/common/use-chat-store/chat/index.ts @@ -0,0 +1,195 @@ +import { ref, computed } from 'vue' +import { useRoute } from 'vue-router' +import { chatApi } from '../../../api' +import { useStreamManager } from '../shared/use-stream-manager' +import { useMessagePagination } from '../shared/use-message-pagination' +import { useConversationCrud } from '../shared/use-conversation-crud' +import { aggregators } from '../../../index' +import type { ChatMessage } from '../../types' + +// ── 共享状态(单例) ───────────────────────────────────── +const appInfo = ref<{ name: string; icon: string } | null>(null) +const currentChatId = ref('') + +// ── 会话 CRUD ───────────────────────────────────────── +const { conversations, loadConversations, loadMore } = useConversationCrud({ + pageConversationAPI: (query: any) => chatApi.history(query.currentPage, query.pageSize), +}) + +// ── 消息分页 ───────────────────────────────────────── +const { + messages, + loading, + hasMore, + loadMessages, + loadMoreMessages, + pushMessage, + resetMsgState, +} = useMessagePagination({ + pageConversationMessage: (cid: string, query: any) => + chatApi.records(cid, query.currentPage, query.pageSize), +}) + +// ── 流式管理 ───────────────────────────────────────── +const streamManager = useStreamManager() + +// ── 计算属性 ───────────────────────────────────────── +const currentConversation = computed(() => conversations.value.find(c => c.id === currentChatId.value) || null) + +export function useChatStore() { + const route = useRoute() + const applicationId = computed(() => route.params.id as string || route.params.applicationId as string || '') + + const fetchAppInfo = async (applicationId?: string) => { + try { + // 对话模式下,应用信息通常由外部传入或从路由获取 + } catch (e) { + // 静默处理 + } + } + + // ── 流式聚合(内部) ───────────────────────────────── + const appendChunk = (message: ChatMessage, chunk: any) => { + if (!chunk) return + const contentArray = Array.isArray(chunk.content) + ? chunk.content + : chunk.type ? [chunk] : null + if (!contentArray) return + + contentArray.forEach((item: any) => { + if (!item?.type) return + const aggregator = aggregators[item.type] + if (!aggregator) return + const index = message.content.findIndex( + (c: any) => c.id === item.id && c.type === item.type, + ) + if (index >= 0) { + message.content[index] = aggregator(message.content[index], item) + } else { + message.content.push(aggregator({}, item)) + } + }) + } + + const createAnswerMessage = (): ChatMessage => ({ + role: 'ASSISTANT', + content: [], + id: '', + }) + + // ── 会话操作 ───────────────────────────────────────── + // 本地新建:前端直接生成 chat_id(草稿),不预先请求后端 open; + // 首次发消息 / 上传时后端会按该 id 现开会话(open-if-missing)。 + const newChat = (): string => { + const id = crypto.randomUUID() + currentChatId.value = id + resetMsgState() + if (!conversations.value.some((c) => c.id === id)) { + conversations.value.unshift({ id, abstract: '新建对话' }) + } + return id + } + + const openChat = async (appId?: string) => { + return await chatApi.open() + } + + const deleteChat = async (id: string) => { + await chatApi.deleteChat(id) + const idx = conversations.value.findIndex((c) => c.id === id) + if (idx >= 0) conversations.value.splice(idx, 1) + } + + const renameChat = async (id: string, name: string) => { + await chatApi.modifyChat(id, { abstract: name }) + const c = conversations.value.find((x) => x.id === id) + if (c) c.abstract = name + } + + const chat = (chatId: string, data: any) => chatApi.chat(chatId, data) + + // ── 文件上传 ───────────────────────────────────────── + const uploadFile = async (file: File): Promise<{ url: string; name: string }> => { + const res = await chatApi.uploadFile(file, '') + return { url: res, name: file.name } + } + + // ── 发送消息 ───────────────────────────────────────── + const sendMessage = (cid: string, payload: any, aiMsg: ChatMessage) => { + loading.value = true + streamManager.startStream({ + cid, + request: () => chat(cid, payload), + onStream: (chunk) => { + appendChunk(aiMsg, chunk) + }, + onFinish: () => { + aiMsg.write_ed = true + loading.value = false + }, + onFailure: () => { + aiMsg.write_ed = true + loading.value = false + } + }) + } + + // ── 切换对话 ───────────────────────────────────────── + const switchConversation = async (cid: string) => { + await streamManager.switchConversation({ + cid, + loadMessages, + resumeStream: (chatRecordId: string) => chatApi.resumeStream(cid, chatRecordId), + getLastMessage: () => messages.value[messages.value.length - 1] ?? null, + onStream: (chunk) => { + const lastMsg = messages.value[messages.value.length - 1] + if (lastMsg) appendChunk(lastMsg, chunk) + }, + onFinish: () => { + const lastMsg = messages.value[messages.value.length - 1] + if (lastMsg) lastMsg.write_ed = true + }, + onFailure: () => { + const lastMsg = messages.value[messages.value.length - 1] + if (lastMsg) lastMsg.write_ed = true + } + }) + } + + return { + // 状态 + appInfo, + currentChatId, + currentConversation, + conversations, + messages, + loading, + // 路由 + applicationId, + // 会话 + loadConversations, + loadMore, + newChat, + openChat, + deleteChat, + renameChat, + fetchAppInfo, + // 消息 + loadMessages, + loadMoreMessages, + pushMessage, + resetMsgState, + createAnswerMessage, + appendChunk, + // 流式 + chat, + sendMessage, + startStream: streamManager.startStream, + switchConversation, + closeStream: streamManager.closeStream, + stopWorkflow: (cid: string) => streamManager.stopWorkflow(cid, 'chat'), + cancelWorkflow: (cid: string) => streamManager.cancelWorkflow(cid, 'chat'), + // 文件 + uploadFile, + } +} diff --git a/ui/src/components/conversation/common/use-chat-store/debug/index.ts b/ui/src/components/conversation/common/use-chat-store/debug/index.ts new file mode 100644 index 00000000000..90ec0982995 --- /dev/null +++ b/ui/src/components/conversation/common/use-chat-store/debug/index.ts @@ -0,0 +1,188 @@ +import { ref, computed, watch } from 'vue' +import { useRoute } from 'vue-router' +import { debugApi } from '../../../api' +import { useStreamManager } from '../shared/use-stream-manager' +import { useMessagePagination } from '../shared/use-message-pagination' +import { useConversationCrud } from '../shared/use-conversation-crud' +import { aggregators, Scroll } from '../../../index' +import type { ChatMessage } from '../../types' + +// ── 共享状态(单例) ───────────────────────────────────── +const appInfo = ref<{ name: string; icon: string } | null>(null) +const currentChatId = ref('') +let currentApplicationId = '' +const loading = ref(false) + +// ── 会话 CRUD ───────────────────────────────────────── +const { conversations, loadConversations, loadMore } = useConversationCrud({ + pageConversationAPI: (query: any) => + debugApi.history(query.currentPage, query.pageSize, currentApplicationId), +}) + +// ── 消息分页 ───────────────────────────────────────── +const { messages, hasMore, loadMessages, loadMoreMessages, pushMessage, resetMsgState } = + useMessagePagination( + { + pageConversationMessage: (cid: string, query: any) => + debugApi.records(cid, query.currentPage, query.pageSize, currentApplicationId), + }, + loading, + ) + +// ── 流式管理 ───────────────────────────────────────── +const streamManager = useStreamManager() + +// ── 计算属性 ───────────────────────────────────────── +const currentConversation = computed( + () => conversations.value.find((c) => c.id === currentChatId.value) || null, +) + +export function useDebugStore() { + const route = useRoute() + const applicationId = computed( + () => (route.params.id as string) || (route.params.applicationId as string) || '', + ) + + // 更新当前 applicationId + watch( + applicationId, + (newId) => { + currentApplicationId = newId + }, + { immediate: true }, + ) + + const fetchAppInfo = async (appId?: string) => { + try { + const { getApi } = await import('../../../api') + const api = getApi('DEBUG') + } catch (e) { + // 静默处理 + } + } + + // ── 流式聚合(内部) ───────────────────────────────── + const appendChunk = (message: ChatMessage, chunk: any) => { + if (!chunk) return + const contentArray = Array.isArray(chunk.content) ? chunk.content : chunk.type ? [chunk] : null + if (!contentArray) return + + contentArray.forEach((item: any) => { + if (!item?.type) return + const aggregator = aggregators[item.type] + if (!aggregator) return + const index = message.content.findIndex((c: any) => c.id === item.id && c.type === item.type) + if (index >= 0) { + message.content[index] = aggregator(message.content[index], item) + } else { + message.content.push(aggregator({}, item)) + } + }) + } + + const createAnswerMessage = (): ChatMessage => ({ + role: 'ASSISTANT', + content: [], + id: '', + }) + + // ── 会话操作 ───────────────────────────────────────── + // 本地新建:前端直接生成 chat_id(草稿),不预先请求后端 open; + // 首次发消息 / 上传时后端会按该 id 现开会话(open-if-missing)。 + const newChat = (): string => { + const id = crypto.randomUUID() + currentChatId.value = id + resetMsgState() + if (!conversations.value.some((c) => c.id === id)) { + conversations.value.unshift({ id, abstract: '新建对话' }) + } + return id + } + + const openChat = async (appId?: string) => { + return await debugApi.open(appId || applicationId.value) + } + + const deleteChat = async (id: string) => { + await debugApi.deleteChat(id, applicationId.value) + const idx = conversations.value.findIndex((c) => c.id === id) + if (idx >= 0) conversations.value.splice(idx, 1) + } + + const renameChat = async (id: string, name: string) => { + await debugApi.modifyChat(id, { abstract: name }, applicationId.value) + const c = conversations.value.find((x) => x.id === id) + if (c) c.abstract = name + } + + const chat = (chatId: string, data: any) => debugApi.chat(chatId, data, currentApplicationId) + + // ── 文件上传 ───────────────────────────────────────── + const uploadFile = async (file: File, chatId: string): Promise<{ url: string; name: string }> => { + loading.value = true + let cid = chatId + if (!cid) { + cid = await openChat(applicationId.value) + } + const res = await debugApi.uploadFile(file, cid) + loading.value = false + return { url: res, name: file.name } + } + + // ── 切换对话 ───────────────────────────────────────── + const switchConversation = async (cid: string) => { + loading.value = true + await streamManager.switchConversation({ + cid, + loadMessages, + resumeStream: (chatRecordId: string) => debugApi.resumeStream(cid, chatRecordId, currentApplicationId), + getLastMessage: () => messages.value[messages.value.length - 1] ?? null, + onStream: (chunk) => { + const lastMsg = messages.value[messages.value.length - 1] + if (lastMsg) appendChunk(lastMsg, chunk) + }, + onFinish: () => { + loading.value = false + }, + onFailure: () => { + loading.value = false + }, + }) + } + + return { + // 状态 + appInfo, + currentChatId, + currentConversation, + conversations, + messages, + loading, + // 路由 + applicationId, + // 会话 + loadConversations, + loadMore, + newChat, + openChat, + deleteChat, + renameChat, + fetchAppInfo, + // 消息 + loadMessages, + loadMoreMessages, + pushMessage, + resetMsgState, + createAnswerMessage, + appendChunk, + // 流式 + chat, + startStream: streamManager.startStream, + switchConversation, + closeStream: streamManager.closeStream, + stopWorkflow: (cid: string) => streamManager.stopWorkflow(cid, 'debug', currentApplicationId), + cancelWorkflow: (cid: string) => streamManager.cancelWorkflow(cid, 'debug', currentApplicationId), + // 文件 + uploadFile, + } +} diff --git a/ui/src/components/conversation/common/use-chat-store/index.ts b/ui/src/components/conversation/common/use-chat-store/index.ts new file mode 100644 index 00000000000..cc8a2bc66a5 --- /dev/null +++ b/ui/src/components/conversation/common/use-chat-store/index.ts @@ -0,0 +1,7 @@ +import { useChatStore } from './chat' +import { useDebugStore } from './debug' +import type { ChatType } from '../types' + +export function useChatStoreByType(type: ChatType) { + return type === 'DEBUG' ? useDebugStore() : useChatStore() +} diff --git a/ui/src/components/conversation/common/use-chat-store/shared/use-conversation-crud.ts b/ui/src/components/conversation/common/use-chat-store/shared/use-conversation-crud.ts new file mode 100644 index 00000000000..d840e1340df --- /dev/null +++ b/ui/src/components/conversation/common/use-chat-store/shared/use-conversation-crud.ts @@ -0,0 +1,46 @@ +import { ref, type Ref } from 'vue' +import type { Conversation } from '../../types' + +export function useConversationCrud( + opts: { + pageConversationAPI: (query: any) => Promise + }, + loading: Ref = ref(false), +) { + const conversations = ref([]) + const currentPage = ref(1) + const pageSize = 50 + const hasMore = ref(true) + + const loadConversations = async (page = 1) => { + loading.value = true + try { + const res = await opts.pageConversationAPI({ currentPage: page, pageSize }) + const records = res?.records || [] + if (page === 1) { + conversations.value = records + } else { + conversations.value = [...conversations.value, ...records] + } + currentPage.value = page + hasMore.value = records.length >= pageSize + } catch (e) { + // debug 模式下历史接口可能不存在,静默处理 + } finally { + loading.value = false + } + } + + const loadMore = async () => { + if (!hasMore.value || loading.value) return + await loadConversations(currentPage.value + 1) + } + + return { + conversations, + loading, + hasMore, + loadConversations, + loadMore, + } +} diff --git a/ui/src/components/conversation/common/use-chat-store/shared/use-message-pagination.ts b/ui/src/components/conversation/common/use-chat-store/shared/use-message-pagination.ts new file mode 100644 index 00000000000..fc74e8fd837 --- /dev/null +++ b/ui/src/components/conversation/common/use-chat-store/shared/use-message-pagination.ts @@ -0,0 +1,73 @@ +import { ref, type Ref } from 'vue' +import type { ChatMessage } from '../../types' + +export function useMessagePagination( + opts: { + pageConversationMessage: (cid: string, query: any) => Promise + }, + loading: Ref = ref(false), +) { + const messages = ref([]) + + const currentPage = ref(1) + const pageSize = 20 + const hasMore = ref(true) + + const loadMessages = async (cid: string, page = 1) => { + loading.value = true + try { + const res = await opts.pageConversationMessage(cid, { currentPage: page, pageSize }) + const records = (res?.records || []).reverse() + const result = records.flatMap((record: any) => { + return [ + { + role: 'USER', + content: [record.question], + id: record.id + '_USER', + }, + { + role: 'ASSISTANT', + content: record.messages, + id: record.id + '_ASSISTANT', + }, + ] + }) + if (page === 1) { + messages.value = result + } else { + messages.value = [...result, ...messages.value] + } + currentPage.value = page + hasMore.value = records.length >= pageSize + } catch (e) { + // 静默处理 + } finally { + loading.value = false + } + } + + const loadMoreMessages = async (cid: string) => { + if (!hasMore.value || loading.value) return + await loadMessages(cid, currentPage.value + 1) + } + + const pushMessage = (msg: ChatMessage) => { + messages.value.push(msg) + } + + const resetMsgState = () => { + messages.value = [] + currentPage.value = 1 + hasMore.value = true + } + + return { + messages, + loading, + hasMore, + loadMessages, + loadMoreMessages, + pushMessage, + resetMsgState, + } +} diff --git a/ui/src/components/conversation/common/use-chat-store/shared/use-stream-manager.ts b/ui/src/components/conversation/common/use-chat-store/shared/use-stream-manager.ts new file mode 100644 index 00000000000..f30091754c1 --- /dev/null +++ b/ui/src/components/conversation/common/use-chat-store/shared/use-stream-manager.ts @@ -0,0 +1,139 @@ +import type { ChatMessage } from '../../types' +import { ConversationStream } from '../../../stream' +import { chatApi, debugApi } from '../../../api' + +export interface StartStreamOptions { + cid: string + request: () => Promise + onStream: (chunk: any) => void + onFinish?: () => void + onFailure?: (e: any) => void +} + +export interface SwitchOptions { + cid: string + loadMessages: (cid: string) => Promise + resumeStream: (chatRecordId: string) => Promise + getLastMessage: () => ChatMessage | null + onStream: (chunk: any) => void + onFinish?: () => void + onFailure?: () => void + skipLoadMessages?: boolean +} + +export function useStreamManager() { + let currentStream: ConversationStream | null = null + + // 返回值表示本次是否真的关闭了一个进行中的流,供调用方判断是否需要通知后端取消 + const closeStream = () => { + if (currentStream) { + currentStream.cancel() + currentStream = null + return true + } + return false + // 不在这里增加 streamToken,由调用方决定 + } + + const startStream = ({ cid, request, onStream, onFinish, onFailure }: StartStreamOptions) => { + closeStream() + + request() + .then((response: any) => { + currentStream = new ConversationStream( + response, + onStream, + () => { + currentStream = null + onFinish?.() + }, + (e) => { + currentStream = null + onFailure?.(e) + }, + ) + currentStream.start() + }) + .catch((e: any) => { + onFailure?.(e) + }) + } + + const switchConversation = async (opts: SwitchOptions) => { + const { + cid, + loadMessages, + resumeStream, + getLastMessage, + onStream, + onFinish, + onFailure, + skipLoadMessages, + } = opts + + // 先关闭旧流 + closeStream() + if (!skipLoadMessages) { + await loadMessages(cid) + } + + const lastMsg = getLastMessage() + // 没有需要续传的流(无消息或最后一条消息已完成)时,同样要结束 loading 契约。 + if (!lastMsg || (lastMsg.content && lastMsg.content.length > 0)) { + onFinish?.() + return + } + try { + const response = await resumeStream(lastMsg.id.replace('_USER', '').replace('_ASSISTANT', '')) + if (!response.ok) { + onFailure?.() + return + } + currentStream = new ConversationStream( + response, + onStream, + () => { + currentStream = null + onFinish?.() + }, + () => { + currentStream = null + onFailure?.() + }, + ) + currentStream.start() + } catch (e) { + console.error('resume stream failed', e) + onFailure?.() + } + } + + // 优雅停止:仅通知后端取消,本地 SSE 不断开,继续读取。 + // 后端收到取消后会触发 on_complete 往队列放入 "done" 并 yield [DONE], + // 前端读到 [DONE] 由 ConversationStream 自行 finish(loading 交给 onFinish 复位)。 + // 若此刻就 abort 本地流,服务端 generator 不再被消费,[DONE] 那段永远跑不到。 + const stopWorkflow = (cid: string, apiType: 'chat' | 'debug' = 'chat', applicationId?: string) => { + if (!currentStream || !cid) return + const api = apiType === 'debug' ? debugApi : chatApi + api.cancelChat(cid, applicationId).catch(() => {}) + } + + const cancelWorkflow = (cid: string, apiType: 'chat' | 'debug' = 'chat', applicationId?: string) => { + // 硬取消:用于卸载/离开页面,必须断开本地流避免后台泄漏,同时通知后端。 + const wasStreaming = closeStream() + // 仅当确实中断了一个进行中的流、且已有会话 id 时才通知后端取消。 + // debug 的 chat_id 是前端本地草稿,首发消息前服务端并不存在该会话, + // 此时调用 cancel_chat_message 只会命中不存在的 chat_id,必须跳过。 + if (!wasStreaming || !cid) return + const api = apiType === 'debug' ? debugApi : chatApi + api.cancelChat(cid, applicationId).catch(() => {}) + } + + return { + closeStream, + startStream, + switchConversation, + stopWorkflow, + cancelWorkflow, + } +} diff --git a/ui/src/components/conversation/content-list/index.vue b/ui/src/components/conversation/content-list/index.vue new file mode 100644 index 00000000000..2fdbc6f473c --- /dev/null +++ b/ui/src/components/conversation/content-list/index.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/ui/src/components/conversation/content/index.vue b/ui/src/components/conversation/content/index.vue new file mode 100644 index 00000000000..a852e68385d --- /dev/null +++ b/ui/src/components/conversation/content/index.vue @@ -0,0 +1,24 @@ + + + diff --git a/ui/src/components/conversation/content/items/failure.vue b/ui/src/components/conversation/content/items/failure.vue new file mode 100644 index 00000000000..8bd507a09b5 --- /dev/null +++ b/ui/src/components/conversation/content/items/failure.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/ui/src/components/conversation/content/items/form.vue b/ui/src/components/conversation/content/items/form.vue new file mode 100644 index 00000000000..65944120234 --- /dev/null +++ b/ui/src/components/conversation/content/items/form.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/ui/src/components/conversation/content/items/question.vue b/ui/src/components/conversation/content/items/question.vue new file mode 100644 index 00000000000..7314a163d6f --- /dev/null +++ b/ui/src/components/conversation/content/items/question.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/ui/src/components/conversation/content/items/reasoning.vue b/ui/src/components/conversation/content/items/reasoning.vue new file mode 100644 index 00000000000..da98ad0e708 --- /dev/null +++ b/ui/src/components/conversation/content/items/reasoning.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/ui/src/components/conversation/content/items/text.vue b/ui/src/components/conversation/content/items/text.vue new file mode 100644 index 00000000000..b344aa416b8 --- /dev/null +++ b/ui/src/components/conversation/content/items/text.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/ui/src/components/conversation/content/items/tool.vue b/ui/src/components/conversation/content/items/tool.vue new file mode 100644 index 00000000000..1ce46d862ab --- /dev/null +++ b/ui/src/components/conversation/content/items/tool.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/ui/src/components/conversation/editor/index.vue b/ui/src/components/conversation/editor/index.vue new file mode 100644 index 00000000000..3f0a429129c --- /dev/null +++ b/ui/src/components/conversation/editor/index.vue @@ -0,0 +1,154 @@ +