From a88740bb84229187d00de2db2779dff2ddafa27d Mon Sep 17 00:00:00 2001 From: shaohuzhang1 Date: Fri, 9 Oct 2026 19:34:34 +0800 Subject: [PATCH] feat: Initialize portal and conversation --- .../step/chat_step/impl/base_chat_step.py | 0 apps/chat/serializers/chat_authentication.py | 104 ++++++------- apps/chat/urls.py | 4 +- apps/chat/views/v2/chat.py | 12 +- apps/chat/views/v3/chat.py | 58 +++++++- apps/chat/views/v3/portal.py | 15 ++ apps/common/auth/authenticate.py | 86 ++++++----- apps/common/auth/common.py | 21 ++- .../constants/chat_permission_constants.py | 2 +- apps/common/log/log.py | 35 +++-- apps/common/utils/signing.py | 21 +++ ui/src/api/API_README.md | 7 +- ui/src/api/chat/README.md | 13 +- ui/src/api/chat/application.ts | 11 ++ ui/src/api/chat/auth.ts | 59 ++++++++ ui/src/api/chat/chat-user.ts | 11 ++ ui/src/api/chat/conversation.ts | 35 +++-- ui/src/api/chat/core/request.ts | 30 +++- ui/src/api/chat/portal.ts | 17 +++ ui/src/api/types/chat-application.ts | 26 ++++ ui/src/api/types/chat-auth.ts | 45 ++++++ ui/src/api/types/index.ts | 2 + ui/src/chat.ts | 2 +- .../components/header/chat/index.vue | 13 +- .../components/message-input/index.ts | 58 +++++--- .../components/message-input/index.vue | 9 +- .../components/message-list/index.ts | 2 +- .../portal-application-cards/index.vue | 40 +++++ ui/src/conversation-panel/core/types.ts | 8 +- .../conversation-list/AccountMenu.vue | 8 +- .../left-sidebar/conversation-list/index.ts | 9 +- .../SelectApplicationDrawer.vue | 74 ++++++++++ .../portal-conversation-list/index.ts | 139 ++++++++++++++++++ .../portal-conversation-list/index.vue | 136 +++++++++++++++++ .../main/portal-application-panel/index.vue | 52 +++++++ .../main/portal-chat-panel/index.vue | 30 ++++ ui/src/conversation-panel/view/chat/index.ts | 102 +++++++++++-- ui/src/conversation-panel/view/debug/index.ts | 5 +- .../conversation-panel/view/portal/index.ts | 86 +++++++++++ .../conversation-panel/view/portal/index.vue | 79 ++++++++++ ui/src/main.ts | 2 +- ui/src/router/ROUTE_README.md | 41 +++++- ui/src/router/chat/index.ts | 87 ++++++++++- ui/src/router/chat/routes.ts | 68 ++++++++- ui/src/stores/{ => admin}/auth.ts | 0 ui/src/stores/admin/index.ts | 25 ++++ ui/src/stores/{ => admin}/theme.ts | 0 ui/src/stores/{ => admin}/user.ts | 0 ui/src/stores/chat/auth.ts | 124 ++++++++++++++++ ui/src/stores/chat/index.ts | 21 +++ ui/src/stores/chat/user.ts | 41 ++++++ ui/src/stores/index.ts | 28 +--- ui/src/views/VIEW_README.md | 43 ++++++ ui/src/views/chat/conversation/index.vue | 9 ++ ui/src/views/chat/error/index.vue | 5 + ui/src/views/chat/index.vue | 25 +++- ui/src/views/chat/login/index.vue | 123 ++++++++++++++++ ui/src/views/portal/index.vue | 26 ++++ 58 files changed, 1906 insertions(+), 228 deletions(-) delete mode 100644 apps/application/chat_pipeline/step/chat_step/impl/base_chat_step.py create mode 100644 apps/common/utils/signing.py create mode 100644 ui/src/api/chat/application.ts create mode 100644 ui/src/api/chat/auth.ts create mode 100644 ui/src/api/chat/chat-user.ts create mode 100644 ui/src/api/chat/portal.ts create mode 100644 ui/src/api/types/chat-application.ts create mode 100644 ui/src/api/types/chat-auth.ts create mode 100644 ui/src/conversation-panel/components/portal-application-cards/index.vue create mode 100644 ui/src/conversation-panel/left-sidebar/portal-conversation-list/SelectApplicationDrawer.vue create mode 100644 ui/src/conversation-panel/left-sidebar/portal-conversation-list/index.ts create mode 100644 ui/src/conversation-panel/left-sidebar/portal-conversation-list/index.vue create mode 100644 ui/src/conversation-panel/main/portal-application-panel/index.vue create mode 100644 ui/src/conversation-panel/main/portal-chat-panel/index.vue create mode 100644 ui/src/conversation-panel/view/portal/index.ts create mode 100644 ui/src/conversation-panel/view/portal/index.vue rename ui/src/stores/{ => admin}/auth.ts (100%) create mode 100644 ui/src/stores/admin/index.ts rename ui/src/stores/{ => admin}/theme.ts (100%) rename ui/src/stores/{ => admin}/user.ts (100%) create mode 100644 ui/src/stores/chat/auth.ts create mode 100644 ui/src/stores/chat/index.ts create mode 100644 ui/src/stores/chat/user.ts create mode 100644 ui/src/views/chat/conversation/index.vue create mode 100644 ui/src/views/chat/error/index.vue create mode 100644 ui/src/views/chat/login/index.vue create mode 100644 ui/src/views/portal/index.vue 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 e69de29bb2d..00000000000 diff --git a/apps/chat/serializers/chat_authentication.py b/apps/chat/serializers/chat_authentication.py index 7db073c478f..528222a1b81 100644 --- a/apps/chat/serializers/chat_authentication.py +++ b/apps/chat/serializers/chat_authentication.py @@ -15,22 +15,21 @@ from rest_framework import serializers from application.models import ApplicationAccessToken, Application, ApplicationVersion -from common.auth.common import ChatToken +from common.auth.common import ChatToken, parse_token from common.auth.constants.operate_constants import Operate from common.constants.authentication_type import AuthenticationType from common.constants.cache_version import Cache_Version from common.database_model_manage.database_model_manage import DatabaseModelManage from common.exception.app_exception import NotFound404, AppUnauthorizedFailed, AppApiException from common.utils.rsa_util import get_key_pair_by_sql +from portal.models import Portal class AnonymousAuthenticationSerializer(serializers.Serializer): - """v3 匿名认证:application_id 为可选 query 参数。 - 传入时颁发应用级令牌,未传入时颁发全局令牌。""" - - application_id = serializers.UUIDField(required=False, label=_("application_id")) + access_token = serializers.CharField(required=False, label=_("access_token")) def auth(self, request): + self.is_valid(raise_exception=True) token = request.META.get("HTTP_AUTHORIZATION") token_details = {} try: @@ -42,60 +41,29 @@ def auth(self, request): chat_user_id = token_details.get("id") or str(uuid.uuid7()) _type = AuthenticationType.CHAT_USER - application_id = self.validated_data.get("application_id") - if application_id: - application_access_token = QuerySet(ApplicationAccessToken).filter(application_id=application_id).first() + access_token = self.data.get("access_token") + if access_token: + application_access_token = QuerySet(ApplicationAccessToken).filter(access_token=access_token).first() if application_access_token is None or not application_access_token.is_active: raise AppApiException(500, _("Invalid application_id")) - application_id = str(application_id) + application_id = str(application_access_token.application_id) return ChatToken( chat_user_id, _type, str(Operate.ANNOTATION_AUTH), application_id=application_id ).to_token() - return (ChatToken(chat_user_id, _type, str(Operate.ANNOTATION_AUTH)).to_token(),) + return ChatToken(chat_user_id, _type, str(Operate.ANNOTATION_AUTH)).to_token() -class AnonymousAuthenticationV2Serializer(serializers.Serializer): - """v2 匿名认证:application_id 不在 path,从 access_token 解出并写进 token, - 供 ChatUserToken handler 收窄到该应用。""" - +class ChatAuthProfileSerializer(serializers.Serializer): access_token = serializers.CharField(required=True, label=_("access_token")) - def auth(self, request, with_valid=True): - token = request.META.get("HTTP_AUTHORIZATION") - token_details = {} - try: - # 校验token - if token is not None: - token_details = signing.loads(token[7:]) - except Exception: - pass - if with_valid: - self.is_valid(raise_exception=True) - access_token = self.data.get("access_token") - application_access_token = QuerySet(ApplicationAccessToken).filter(access_token=access_token).first() - if application_access_token is None or not application_access_token.is_active: - raise NotFound404(404, _("Invalid access_token")) - chat_user_id = token_details.get("user_id") or token_details.get("id") or str(uuid.uuid7()) - _type = AuthenticationType.CHAT_USER - application_id = str(application_access_token.application_id) - return ChatToken( - chat_user_id, _type, str(Operate.ANNOTATION_AUTH), application_id=application_id - ).to_token(), FileToken(chat_user_id, _type, application_id=application_id).to_token() - - -class AuthProfileSerializer(serializers.Serializer): - """v3: 直接通过 application_id 获取认证 profile""" - - application_id = serializers.UUIDField(required=True, label=_("application_id")) - def profile(self): self.is_valid(raise_exception=True) - application_id = self.validated_data.get("application_id") - application_access_token = QuerySet(ApplicationAccessToken).filter(application_id=application_id).first() + access_token = self.data.get("access_token") + application_access_token = QuerySet(ApplicationAccessToken).filter(access_token=access_token).first() if application_access_token is None: - raise NotFound404(404, _("Invalid application_id")) + raise NotFound404(404, _("Invalid access_token")) if not application_access_token.is_active: - raise NotFound404(404, _("Invalid application_id")) + raise NotFound404(404, _("Invalid access_token")) login_value = application_access_token.authentication_value.get("login_value", []) chat_platform = DatabaseModelManage.get_model("chat_platform") if chat_platform is not None: @@ -104,29 +72,39 @@ def profile(self): if "LOCAL" in application_access_token.authentication_value.get("login_value", []): login_value.insert(0, "LOCAL") return { - "application_name": application_access_token.application.name, - "authentication": application_access_token.authentication, - "authentication_type": application_access_token.authentication_value.get("type", "password"), "max_attempts": application_access_token.authentication_value.get("max_attempts", 1), + "enable_auth": application_access_token.authentication, "login_value": login_value, - "rsaKey": get_key_pair_by_sql().get("key"), + "rsa_key": get_key_pair_by_sql().get("key"), + "meta": { + "application_name": application_access_token.application.name, + "application_id": str(application_access_token.application_id), + }, } -class AuthProfileV2Serializer(serializers.Serializer): - """v2: 通过 access_token 查表得到 application_id,委托给 AuthProfileSerializer""" - - access_token = serializers.CharField(required=True, label=_("access_token")) - - def profile(self): - self.is_valid(raise_exception=True) - access_token = self.validated_data.get("access_token") - application_access_token = QuerySet(ApplicationAccessToken).filter(access_token=access_token).first() - if application_access_token is None: - raise NotFound404(404, _("Invalid access_token")) - if not application_access_token.is_active: +class PortalAuthProfileSerializer(serializers.Serializer): + @staticmethod + def profile(): + portal = Portal.objects.first() + if portal is None: + return { + "max_attempts": 1, + "enable_auth": False, + "login_value": ["LOCAL"], + "rsa_key": get_key_pair_by_sql().get("key"), + "meta": {}, + } + if not portal.enable_public_access: raise NotFound404(404, _("Invalid access_token")) - return AuthProfileSerializer(data={"application_id": application_access_token.application_id}).profile() + auth_config = portal.auth_config + return { + "max_attempts": auth_config.get("max_attempts", 1), + "enable_auth": portal.enable_auth, + "login_value": auth_config.get("login_value", []), + "rsa_key": get_key_pair_by_sql().get("key"), + "meta": {}, + } class ApplicationProfileSerializer(serializers.Serializer): diff --git a/apps/chat/urls.py b/apps/chat/urls.py index b898adf6b66..da14872bd94 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -24,6 +24,7 @@ path('chat//chat_record//', v3_views.HistoricalConversationRecordView.PageView.as_view(), name='v3_historical_conversation_record_page'), path('chat//chat_record/',v3_views.ChatRecordView.as_view(),name='v3_conversation_details'), path('chat//chat_record//vote',v3_views.VoteView.as_view(), name='v3_vote'), + path('chat//chat_record//resume_chat_message',v3_views.ResumeStreamView.as_view(), name='v3_resume_chat_message'), path('chat//share_chat',ChatRecordLinkView.as_view(),name='v3_share_chat'), path('chat/',v3_views.HistoricalConversationView.Operate.as_view(), name='v3_historical_conversation_operate'), ])), @@ -33,7 +34,8 @@ path('auth/anonymous', v3_views.AnonymousAuthentication.as_view()), path('auth/login', v3_views.LocalLoginView.as_view()), path('auth/logout', v3_views.Logout.as_view(), name='v3_logout'), - path('profile', v3_views.AuthProfile.as_view()), + path('profile', v3_views.ChatAuthProfile.as_view()), + path('portal/profile', v3_views.PortalAuthProfile.as_view()), path('captcha', v3_views.CaptchaView.as_view(), name='v3_captcha'), path('share/', ChatRecordDetailView.as_view()), path('chat_message//cancel', v3_views.CancelWorkflowView.as_view(), name='v3_cancel_workflow'), diff --git a/apps/chat/views/v2/chat.py b/apps/chat/views/v2/chat.py index df7add2982f..fd76e575bb8 100644 --- a/apps/chat/views/v2/chat.py +++ b/apps/chat/views/v2/chat.py @@ -37,9 +37,9 @@ TextToSpeechSerializers, ) from chat.serializers.chat_authentication import ( - AnonymousAuthenticationV2Serializer, + AnonymousAuthenticationSerializer, ApplicationProfileSerializer, - AuthProfileV2Serializer, + ChatAuthProfileSerializer, ) from common.auth import ChatTokenAuth from common.auth.authentication import has_permissions @@ -137,12 +137,12 @@ def options(self, request, *args, **kwargs): description=_("Application Anonymous Certification"), summary=_("Application Anonymous Certification"), operation_id=_("Application Anonymous Certification"), # type: ignore - request=AnonymousAuthenticationV2Serializer, + request=AnonymousAuthenticationSerializer, responses=None, tags=[_("Chat")], # type: ignore ) def post(self, request: Request): - token, f_token = AnonymousAuthenticationV2Serializer(data=request.data).auth(request) + token = AnonymousAuthenticationSerializer(data=request.data).auth(request) response = result.success( token, headers={ @@ -156,7 +156,7 @@ def post(self, request: Request): response.set_cookie( key="mk_file_auth", - value=f_token, + value=token, max_age=7 * 24 * 3600, path=f"{CONFIG.get_chat_path()}/{request.data.get('access_token')}", secure=is_https, @@ -196,7 +196,7 @@ class AuthProfile(APIView): ) def get(self, request: Request): return result.success( - AuthProfileV2Serializer(data={"access_token": request.query_params.get("access_token")}).profile() + ChatAuthProfileSerializer(data={"access_token": request.query_params.get("access_token")}).profile() ) diff --git a/apps/chat/views/v3/chat.py b/apps/chat/views/v3/chat.py index 6a8083ec679..7bbbd7af6ad 100644 --- a/apps/chat/views/v3/chat.py +++ b/apps/chat/views/v3/chat.py @@ -21,20 +21,21 @@ from rest_framework.views import APIView from application.api.application_api import SpeechToTextAPI, TextToSpeechAPI -from application.models import ChatUserType, ChatSourceChoices, ApplicationAccessToken +from application.models import ChatUserType, ChatSourceChoices, ApplicationAccessToken, ChatRecord from chat.api.chat_api import ChatAPI from chat.api.chat_authentication_api import ChatAuthenticationAPI, ChatAuthenticationProfileAPI, ChatOpenAPI, OpenAIAPI from chat.serializers.chat import ( OpenAIChatSerializer, ChatSerializers, OpenChatSerializers, + ResumeSerializers, SpeechToTextSerializers, TextToSpeechSerializers, ) from chat.serializers.chat_authentication import ( AnonymousAuthenticationSerializer, ApplicationProfileSerializer, - AuthProfileSerializer, + ChatAuthProfileSerializer, ) from common.auth import ChatTokenAuth from common.auth.authentication import has_permissions @@ -138,7 +139,7 @@ def options(self, request, *args, **kwargs): tags=[_("V3 Chat")], # type: ignore ) def post(self, request: Request): - serializer = AnonymousAuthenticationSerializer(data=request.query_params) + serializer = AnonymousAuthenticationSerializer(data=request.data) serializer.is_valid(raise_exception=True) token = serializer.auth(request) response = result.success( @@ -152,8 +153,10 @@ def post(self, request: Request): ) is_https = request.scheme == "https" - application_id = serializer.validated_data.get("application_id") - cookie_path = f"{CONFIG.get_chat_path()}/{application_id}" if application_id else CONFIG.get_chat_path() + access_token = request.data.get("access_token") + cookie_path = ( + f"{CONFIG.get_chat_path()}/{access_token}/" if access_token else f"{CONFIG.get_chat_path()}/portal/" + ) response.set_cookie( key="mk_file_auth", value=token, @@ -183,7 +186,7 @@ def get(self, request: Request, application_id: str): return result.success(ApplicationProfileSerializer(data={"application_id": application_id}).profile()) -class AuthProfile(APIView): +class ChatAuthProfile(APIView): @extend_schema( methods=["GET"], description=_("Get application authentication information"), @@ -195,7 +198,7 @@ class AuthProfile(APIView): ) def get(self, request: Request): return result.success( - AuthProfileSerializer(data={"application_id": request.query_params.get("application_id")}).profile() + ChatAuthProfileSerializer(data={"access_token": request.query_params.get("access_token")}).profile() ) @@ -293,6 +296,47 @@ def post(self, request: Request, chat_id: str): return result.fail(500, _("Failed to cancel workflow")) +class ResumeStreamView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=["POST"], + description=_("Resume stream for workflow"), + summary=_("Resume stream for workflow"), + operation_id=_("V3 Resume stream for workflow"), # type: ignore + parameters=[ + OpenApiParameter( + name="application_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + description=_("Application ID"), + ), + OpenApiParameter( + name="chat_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, description=_("Chat ID") + ), + OpenApiParameter( + name="chat_record_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + description=_("Conversation record id"), + ), + ], + responses=None, + tags=[_("V3 Chat")], # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def post(self, request: Request, application_id: str, chat_id: str, chat_record_id: str): + # 对话端只能续传当前对话用户在该应用下的会话记录 + if not ChatRecord.objects.filter( + id=chat_record_id, + chat_id=chat_id, + chat__application_id=application_id, + chat__chat_user_id=str(request.user.id), + ).exists(): + raise AppApiException(500, _("Conversation does not exist")) + return ResumeSerializers(data={"chat_id": chat_id, "chat_record_id": chat_record_id}).resume(request) + + class CaptchaView(APIView): @extend_schema( methods=["GET"], diff --git a/apps/chat/views/v3/portal.py b/apps/chat/views/v3/portal.py index 18a6479ce4b..1f5ec5614dc 100644 --- a/apps/chat/views/v3/portal.py +++ b/apps/chat/views/v3/portal.py @@ -12,6 +12,7 @@ from rest_framework.request import Request from rest_framework.views import APIView +from chat.serializers.chat_authentication import PortalAuthProfileSerializer from common import result from common.auth import ChatTokenAuth from common.utils.common import query_params_to_single_dict @@ -23,6 +24,20 @@ ) +class PortalAuthProfile(APIView): + @extend_schema( + methods=["GET"], + description=_("Get portal authentication information"), + summary=_("Get portal authentication information"), + operation_id=_("Get portal authentication information"), # type: ignore + parameters=None, + responses=None, + tags=[_("V3 Chat")], # type: ignore + ) + def get(self, request: Request): + return result.success(PortalAuthProfileSerializer.profile()) + + class PortalApplicationView(APIView): authentication_classes = [ChatTokenAuth] diff --git a/apps/common/auth/authenticate.py b/apps/common/auth/authenticate.py index 9bf27c420aa..8efb216e126 100644 --- a/apps/common/auth/authenticate.py +++ b/apps/common/auth/authenticate.py @@ -1,11 +1,12 @@ # coding=utf-8 """ - @project: qabot - @Author:虎虎 - @file: authenticate.py - @date:2023/9/4 11:16 - @desc: 认证类 +@project: qabot +@Author:虎虎 +@file: authenticate.py +@date:2023/9/4 11:16 +@desc: 认证类 """ + from importlib import import_module from django.conf import settings @@ -15,11 +16,16 @@ from drf_spectacular.extensions import OpenApiAuthenticationExtension from rest_framework.authentication import TokenAuthentication -from common.exception.app_exception import AppAuthenticationFailed, AppEmbedIdentityFailed, AppChatNumOutOfBoundsFailed, \ - AppApiException +from common.auth.common import get_key +from common.exception.app_exception import ( + AppAuthenticationFailed, + AppEmbedIdentityFailed, + AppChatNumOutOfBoundsFailed, + AppApiException, +) from common.utils.logger import maxkb_logger -token_cache = cache.caches['default'] +token_cache = cache.caches["default"] class AnonymousAuthentication(TokenAuthentication): @@ -33,8 +39,7 @@ class AnonymousAuthenticationScheme(OpenApiAuthenticationExtension): def get_security_definition(self, auto_schema): # 定义认证方式,这里假设匿名认证不需要凭证 - return { - } + return {} def get_security_requirement(self, auto_schema): # 返回安全要求(空字典表示无需认证) @@ -42,7 +47,7 @@ def get_security_requirement(self, auto_schema): def new_instance_by_class_path(class_path: str): - parts = class_path.rpartition('.') + parts = class_path.rpartition(".") package_path = parts[0] class_name = parts[2] module = import_module(package_path) @@ -86,7 +91,7 @@ def __init__(self, token: str): def get_token_details(self): if self.token_details is None and not self.is_load: try: - self.token_details = signing.loads(self.token) + self.token_details = signing.loads(self.token, get_key()) except Exception as e: self.is_load = True return self.token_details @@ -97,25 +102,28 @@ class TokenAuth(TokenAuthentication): # 重新 authenticate 方法,自定义认证规则 def authenticate(self, request): - auth = request.META.get('HTTP_AUTHORIZATION') + auth = request.META.get("HTTP_AUTHORIZATION") # 未认证 if auth is None: - raise AppAuthenticationFailed(1003, _('Not logged in, please log in first')) + raise AppAuthenticationFailed(1003, _("Not logged in, please log in first")) if not auth.startswith("Bearer "): - raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user')) + raise AppAuthenticationFailed(1002, _("Authentication information is incorrect! illegal user")) try: token = auth[7:] token_details = TokenDetails(token) for handle in get_handles(): if handle.support(request, token, token_details.get_token_details): return handle.handle(request, token, token_details.get_token_details) - raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user')) + raise AppAuthenticationFailed(1002, _("Authentication information is incorrect! illegal user")) except Exception as e: - maxkb_logger.error(f'Exception: {e}', exc_info=True) - if isinstance(e, AppEmbedIdentityFailed) or isinstance(e, AppChatNumOutOfBoundsFailed) or isinstance(e, - AppApiException): + maxkb_logger.error(f"Exception: {e}", exc_info=True) + if ( + isinstance(e, AppEmbedIdentityFailed) + or isinstance(e, AppChatNumOutOfBoundsFailed) + or isinstance(e, AppApiException) + ): raise e - raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user')) + raise AppAuthenticationFailed(1002, _("Authentication information is incorrect! illegal user")) class ChatTokenAuth(TokenAuthentication): @@ -123,25 +131,28 @@ class ChatTokenAuth(TokenAuthentication): # 重新 authenticate 方法,自定义认证规则 def authenticate(self, request): - auth = request.META.get('HTTP_AUTHORIZATION') + auth = request.META.get("HTTP_AUTHORIZATION") # 未认证 if auth is None: - raise AppAuthenticationFailed(1003, _('Not logged in, please log in first')) + raise AppAuthenticationFailed(1003, _("Not logged in, please log in first")) if not auth.startswith("Bearer "): - raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user')) + raise AppAuthenticationFailed(1002, _("Authentication information is incorrect! illegal user")) try: token = auth[7:] token_details = TokenDetails(token) for handle in get_chat_handles(): if handle.support(request, token, token_details.get_token_details): return handle.handle(request, token, token_details.get_token_details) - raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user')) + raise AppAuthenticationFailed(1002, _("Authentication information is incorrect! illegal user")) except Exception as e: - maxkb_logger.error(f'Exception: {e}', exc_info=True) - if isinstance(e, AppEmbedIdentityFailed) or isinstance(e, AppChatNumOutOfBoundsFailed) or isinstance(e, - AppApiException): + maxkb_logger.error(f"Exception: {e}", exc_info=True) + if ( + isinstance(e, AppEmbedIdentityFailed) + or isinstance(e, AppChatNumOutOfBoundsFailed) + or isinstance(e, AppApiException) + ): raise e - raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user')) + raise AppAuthenticationFailed(1002, _("Authentication information is incorrect! illegal user")) class AllTokenAuth(TokenAuthentication): @@ -149,25 +160,28 @@ class AllTokenAuth(TokenAuthentication): # 重新 authenticate 方法,自定义认证规则 def authenticate(self, request): - auth = request.META.get('HTTP_AUTHORIZATION') + auth = request.META.get("HTTP_AUTHORIZATION") # 未认证 if auth is None: - raise AppAuthenticationFailed(1003, _('Not logged in, please log in first')) + raise AppAuthenticationFailed(1003, _("Not logged in, please log in first")) if not auth.startswith("Bearer "): - raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user')) + raise AppAuthenticationFailed(1002, _("Authentication information is incorrect! illegal user")) try: token = auth[7:] token_details = TokenDetails(token) for handle in get_all_handles(): if handle.support(request, token, token_details.get_token_details): return handle.handle(request, token, token_details.get_token_details) - raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user')) + raise AppAuthenticationFailed(1002, _("Authentication information is incorrect! illegal user")) except Exception as e: - maxkb_logger.error(f'Exception: {e}', exc_info=True) - if isinstance(e, AppEmbedIdentityFailed) or isinstance(e, AppChatNumOutOfBoundsFailed) or isinstance(e, - AppApiException): + maxkb_logger.error(f"Exception: {e}", exc_info=True) + if ( + isinstance(e, AppEmbedIdentityFailed) + or isinstance(e, AppChatNumOutOfBoundsFailed) + or isinstance(e, AppApiException) + ): raise e - raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user')) + raise AppAuthenticationFailed(1002, _("Authentication information is incorrect! illegal user")) class WebhookAuth(TokenAuthentication): diff --git a/apps/common/auth/common.py b/apps/common/auth/common.py index 98832c885ef..c05bf64c154 100644 --- a/apps/common/auth/common.py +++ b/apps/common/auth/common.py @@ -7,11 +7,26 @@ @desc: """ +import hashlib + from django.core import signing from common.constants.authentication_type import AuthenticationType from common.exception.app_exception import AppAuthenticationFailed +_key = None + + +def get_key(): + global _key + if _key: + return _key + from common.utils.rsa_util import get_key_pair + + value = get_key_pair().get("value") + _key = hashlib.md5(value.encode("utf-8")).hexdigest() + return _key + class SystemToken: def __init__(self, user_id, _type: AuthenticationType, **kwargs): @@ -25,7 +40,7 @@ def to_dict(self): return {"id": str(self.id), "type": str(self.type.value)} def to_token(self): - return signing.dumps(self.to_dict()) + return signing.dumps(self.to_dict(), key=get_key()) class ChatToken: @@ -50,11 +65,11 @@ def to_dict(self): } def to_token(self): - return signing.dumps(self.to_dict()) + return signing.dumps(self.to_dict(), key=get_key()) def parse_token(token): - details = signing.loads(token) + details = signing.loads(token, key=get_key()) _type = details.get("type") if _type: if _type == AuthenticationType.SYSTEM_USER.value: diff --git a/apps/common/auth/constants/chat_permission_constants.py b/apps/common/auth/constants/chat_permission_constants.py index 160639d386a..feb65482562 100644 --- a/apps/common/auth/constants/chat_permission_constants.py +++ b/apps/common/auth/constants/chat_permission_constants.py @@ -30,7 +30,7 @@ def get_permission(self): return self._build_workspace_permission("application_id") def _build_workspace_permission(self, resource_id_key=None): - def permission_factory(_, **kwargs): + def permission_factory(_, kwargs): return Permission( group=self.value.group, sub_group=self.value.sub_group, diff --git a/apps/common/log/log.py b/apps/common/log/log.py index 272acef689d..fc4bbf39364 100644 --- a/apps/common/log/log.py +++ b/apps/common/log/log.py @@ -7,6 +7,8 @@ @desc: """ +from application.models import ChatUserType +from common.constants.authentication_type import UserType, AuthenticationType from system_manage.models.log_management import Log @@ -33,18 +35,27 @@ def _get_user(request): user = request.user if user is None: return {} - if hasattr(user, 'profile') and user.profile is not None: - user = user.profile - user_info = { - "id": str(user.id), - "email": user.email, - "phone": user.phone, - "nick_name": user.nick_name, - "username": user.username, - } - if hasattr(user, "role"): - user_info["role"] = user.role - return user_info + if user.type in [str(UserType.SYSTEM_USER), str(ChatUserType.CHAT_USER)]: + if hasattr(user, "profile") and user.profile is not None: + user = user.profile + user_info = { + "id": str(user.id), + "email": user.email, + "phone": user.phone, + "nick_name": user.nick_name, + "username": user.username, + } + if hasattr(user, "role"): + user_info["role"] = user.role + return user_info + else: + return { + "id": str(user.id), + "email": "", + "phone": "", + "nick_name": "Anonymous User", + "username": "Anonymous User", + } def _get_details(request): diff --git a/apps/common/utils/signing.py b/apps/common/utils/signing.py new file mode 100644 index 00000000000..6fc57e9e8ad --- /dev/null +++ b/apps/common/utils/signing.py @@ -0,0 +1,21 @@ +# coding=utf-8 +""" +@project: MaxKB +@Author: 虎虎虎 +@file: rsa_util.py +@date: 2026/09/21 13:57 +@desc: +""" + +from common.utils.rsa_util import get_key_pair +from django.core import signing + + +def loads(s: str): + value = get_key_pair().get("value") + return signing.loads(s, key=value) + + +def dumps(obj): + value = get_key_pair().get("value") + return signing.dumps(obj, key=value) diff --git a/ui/src/api/API_README.md b/ui/src/api/API_README.md index 6e36c56307f..fbe331423c6 100644 --- a/ui/src/api/API_README.md +++ b/ui/src/api/API_README.md @@ -33,6 +33,10 @@ src/api/ │ ├── core/request.ts # JSON 与流式请求 │ ├── core/types.ts # Chat 请求协议类型 │ ├── file.ts # Chat 文件上传、进度与取消 +│ ├── auth.ts # 门户与单应用的认证前置配置、匿名认证、登录与登出 +│ ├── chat-user.ts # 当前对话用户档案 +│ ├── application.ts # 对话端智能体信息(v3 application//profile) +│ ├── portal.ts # 门户全部智能体(v3 portal/application)与按智能体分组的历史对话(v3 portal/chat) │ ├── conversation.ts # 正式对话、历史会话与语音接口 │ └── README.md # Chat API 边界说明 ├── enums/ # 后端固定枚举值 @@ -463,7 +467,8 @@ Workspace 智能体分页方法只接收分页与查询条件,从当前资源 记录分页、删除、修改和语音识别接口,保留可选 `applicationId` 对历史资源范围的选择。 其中 `postSpeechToText(applicationId, data)` 请求指定智能体的 `/workspace//application//speech_to_text`,loading 由调用方管理。 -`chat/conversation.ts` 维护正式对话对应的接口。两者使用各自 `core/request.ts` 的请求方法; +`chat/conversation.ts` 维护正式对话对应的 v3 接口,路径为 `/v3/application//...`,除取消生成外均以 +`applicationId` 为首个参数;chat 模式的 `createChatConversation(getApplicationId)` 由使用方提供当前智能体 ID。两者使用各自 `core/request.ts` 的请求方法; `postStream` 返回原始 `Response`,由 `conversation-panel/stream.ts` 解析。 面板内部的 `conversation-panel/common/get-api.ts` 通过 `ChatType` 选择完整 API 对象, diff --git a/ui/src/api/chat/README.md b/ui/src/api/chat/README.md index bd6a1e5a759..e96779e854e 100644 --- a/ui/src/api/chat/README.md +++ b/ui/src/api/chat/README.md @@ -1,15 +1,26 @@ # Chat API `core/request.ts` 提供独立的 Axios 客户端、JSON 响应解包和 `postStream` 流式请求, -不复用 Admin 请求客户端。当前 JSON 与流式请求均通过 `useStore()` 获取已有 token 和语言。 +不复用 Admin 请求客户端。JSON 与流式请求通过 `@/stores/chat` 的 `useStore()` 获取当前认证场景 +(门户或单应用)的 token 和语言。 +401 响应(`/auth/` 认证接口除外)不弹出通用提示,调用 `@/router/chat` 的 `reauthenticate()` 重新认证。 流式请求返回原始 `Response`,由对话面板解析,不在请求层维护消息或 loading。 `conversation.ts` 维护正式对话的打开、发送、取消、续传、历史分页、记录分页、删除、修改、 语音识别接口,默认导出完整 API 对象。调试对话接口位于 `admin/workspace/conversation.ts`,面板模式选择位于 `conversation-panel/common/get-api.ts`。 +Chat 业务接口统一调用后端 v3 路由(`/chat/api/v3/...`);文件上传 `/oss/file` 由 oss 模块提供,不在 v3 下。 +通用错误提示使用响应的 `message`;响应为 HTML 时按 HTTP 状态显示通用文案。 + 接口命名、类型和请求约定统一遵循 `../API_README.md`。 `file.ts` 提供通用 `postUploadFile`,通过本应用的 `core/request.ts` 中 `postUpload` 支持 可选进度回调及取消,统一返回 `{ request, abort }`。`request` 解包得到文件地址; 取消仍拒绝 Promise,但不弹出通用错误提示。loading 由调用方管理。 + +`auth.ts` 维护门户与单应用的认证:`getApplicationAuthProfile(accessToken)` 请求 `/profile`, +`getPortalAuthProfile()` 请求 `/portal/profile`,两者返回结构相同的 `AuthProfile`,差异字段在 `meta`。 +`postAnonymousAuth(accessToken?)` 与 `postLogin(loginRequest, accessToken?)` 传入 accessToken 时为应用认证, +否则为门户认证。`getCaptcha(username, accessToken)` 获取应用登录验证码,后端目前以 `application_id` +查询参数接收 accessToken,暂不支持门户。`chat-user.ts` 的 `getChatUserProfile()` 获取当前对话用户。 diff --git a/ui/src/api/chat/application.ts b/ui/src/api/chat/application.ts new file mode 100644 index 00000000000..43d177195da --- /dev/null +++ b/ui/src/api/chat/application.ts @@ -0,0 +1,11 @@ +/** 提供对话端智能体信息接口。 */ + +import { get } from './core/request' +import type { ChatApplicationProfile } from '@/api/types' + +/** 获取智能体的名称、图标、开场白等对话所需信息。 */ +const getApplicationProfile = (applicationId: string) => { + return get(`/v3/application/${applicationId}/profile`) +} + +export default { getApplicationProfile } diff --git a/ui/src/api/chat/auth.ts b/ui/src/api/chat/auth.ts new file mode 100644 index 00000000000..ed2589ce984 --- /dev/null +++ b/ui/src/api/chat/auth.ts @@ -0,0 +1,59 @@ +/** 提供单应用对话与门户的认证前置配置、匿名认证、登录和登出接口。 */ + +import { get, post, promise, request } from './core/request' +import type { ApiResponse } from './core/types' +import type { ApplicationAuthProfile, ChatLoginRequest, ChatLoginResponse, PortalAuthProfile } from '@/api/types' + +interface ChatCaptchaResponse { + captcha: string +} + +/** 获取应用的认证前置配置。 */ +const getApplicationAuthProfile = (accessToken: string) => { + return get('/v3/profile', { access_token: accessToken }) +} + +/** 获取门户的认证前置配置。 */ +const getPortalAuthProfile = () => { + return get('/v3/portal/profile') +} + +/** 认证接口携带指定场景本地保存的 token;没有时不附带当前场景的 token。 */ +const getSavedTokenHeaders = (savedToken?: string) => ({ Authorization: savedToken ? `Bearer ${savedToken}` : false }) + +/** + * 获取匿名访问 token;传入 accessToken 时颁发应用 token,否则颁发门户 token。 + * 携带本地保存的 token 时,后端沿用其中的对话用户,保留历史对话。 + */ +const postAnonymousAuth = (accessToken?: string, savedToken?: string) => { + return promise( + request.post>('/v3/auth/anonymous', accessToken ? { access_token: accessToken } : {}, { + headers: getSavedTokenHeaders(savedToken), + }), + ) +} + +/** 使用对话用户账号登录;传入 accessToken 时登录到对应应用,否则登录门户。携带本地保存的 token。 */ +const postLogin = (loginRequest: ChatLoginRequest, accessToken?: string, savedToken?: string) => { + return promise( + request.post>('/v3/auth/login', loginRequest, { + params: accessToken ? { accessToken } : undefined, + headers: getSavedTokenHeaders(savedToken), + }), + ) +} + +/** + * 获取应用登录验证码;无需验证码时返回空字符串。 + * 后端当前以 `application_id` 查询参数接收 accessToken,且仅支持应用场景。 + */ +const getCaptcha = (username: string, accessToken: string) => { + return get('/v3/captcha', { username, application_id: accessToken }) +} + +/** 退出当前对话用户登录状态。 */ +const postLogout = () => { + return post('/v3/auth/logout') +} + +export default { getApplicationAuthProfile, getPortalAuthProfile, postAnonymousAuth, postLogin, getCaptcha, postLogout } diff --git a/ui/src/api/chat/chat-user.ts b/ui/src/api/chat/chat-user.ts new file mode 100644 index 00000000000..16ff5f427e7 --- /dev/null +++ b/ui/src/api/chat/chat-user.ts @@ -0,0 +1,11 @@ +/** 提供当前对话用户档案接口。 */ + +import { get } from './core/request' +import type { ChatUserProfile } from '@/api/types' + +/** 获取当前登录的对话用户档案。 */ +const getChatUserProfile = () => { + return get('/v3/chat_user/profile') +} + +export default { getChatUserProfile } diff --git a/ui/src/api/chat/conversation.ts b/ui/src/api/chat/conversation.ts index 48895ce788d..362dc617729 100644 --- a/ui/src/api/chat/conversation.ts +++ b/ui/src/api/chat/conversation.ts @@ -1,37 +1,46 @@ import { get, post, put, del, postStream } from './core/request' +import type { ResponsePage } from './core/types' import { CHAT_API_BASE_PATH as chatApiBase } from '@/api/constants' +import type { PortalConversationSummary } from '@/api/types' + +/** v3 对话接口均按智能体划分资源路径。 */ +const getApplicationPath = (applicationId: string) => `/v3/application/${applicationId}` /** 打开对话。 */ -const getConversationOpen = () => get('/open') +const getConversationOpen = (applicationId: string) => get(`${getApplicationPath(applicationId)}/open`) /** 发送对话消息并返回原始流式响应。 */ -const postConversationMessage = (chatId: string, data: unknown) => postStream(chatApiBase, `/chat_message/${chatId}`, data) +const postConversationMessage = (applicationId: string, chatId: string, data: unknown) => + postStream(chatApiBase, `${getApplicationPath(applicationId)}/chat/${chatId}/chat_message`, data) /** 取消对话消息生成。 */ -const postCancelConversationMessage = (chatId: string) => post(`/chat_message/${chatId}/cancel`, {}) +const postCancelConversationMessage = (chatId: string) => post(`/v3/chat_message/${chatId}/cancel`, {}) -/** 恢复对话消息流。 */ -const postResumeConversationMessage = (chatId: string, chatRecordId: string) => - postStream(chatApiBase, `/chat_message/${chatId}/resume/${chatRecordId}`) +/** 恢复对话消息流。对话端后端尚未提供该接口,路径与 Admin 调试对话保持一致。 */ +const postResumeConversationMessage = (applicationId: string, chatId: string, chatRecordId: string) => + postStream(chatApiBase, `${getApplicationPath(applicationId)}/chat/${chatId}/chat_record/${chatRecordId}/resume_chat_message`) /** 获取历史会话分页。 */ -const getConversationPage = (page: number, size: number) => get(`/historical_conversation/${page}/${size}`) +const getConversationPage = (applicationId: string, page: number, size: number) => + get>(`${getApplicationPath(applicationId)}/chat/${page}/${size}`) /** 获取单条会话记录详情(含执行详情 execution_details、知识来源、tokens、耗时)。 */ -const getConversationRecordDetail = (chatId: string, chatRecordId: string) => - get(`/historical_conversation/${chatId}/record/${chatRecordId}`) +const getConversationRecordDetail = (applicationId: string, chatId: string, chatRecordId: string) => + get(`${getApplicationPath(applicationId)}/chat/${chatId}/chat_record/${chatRecordId}`) /** 获取会话记录分页。 */ -const getConversationRecordPage = (chatId: string, page: number, size: number) => get(`/historical_conversation_record/${chatId}/${page}/${size}`) +const getConversationRecordPage = (applicationId: string, chatId: string, page: number, size: number) => + get(`${getApplicationPath(applicationId)}/chat/${chatId}/chat_record/${page}/${size}`) /** 删除会话。 */ -const deleteConversation = (chatId: string) => del(`/historical_conversation/${chatId}`) +const deleteConversation = (applicationId: string, chatId: string) => del(`${getApplicationPath(applicationId)}/chat/${chatId}`) /** 修改会话信息。 */ -const putConversation = (chatId: string, data: unknown) => put(`/historical_conversation/${chatId}`, data) +const putConversation = (applicationId: string, chatId: string, data: unknown) => + put(`${getApplicationPath(applicationId)}/chat/${chatId}`, data) /** 将语音转换为文字。 */ -const postSpeechToText = (data: unknown) => post('/speech_to_text', data) +const postSpeechToText = (applicationId: string, data: unknown) => post(`${getApplicationPath(applicationId)}/speech_to_text`, data) export default { getConversationOpen, diff --git a/ui/src/api/chat/core/request.ts b/ui/src/api/chat/core/request.ts index d49446e1cb6..a2e0fb217e2 100644 --- a/ui/src/api/chat/core/request.ts +++ b/ui/src/api/chat/core/request.ts @@ -1,7 +1,8 @@ /** 提供 Chat API 的 Axios 实例与常用 HTTP 请求封装。 */ import axios, { AxiosHeaders, type AxiosResponse, type AxiosProgressEvent, type InternalAxiosRequestConfig } from 'axios' -import { useStore } from '@/stores' +import { reauthenticate } from '@/router/chat' +import { useStore } from '@/stores/chat' import type { ApiResponse } from './types' import type { Dict } from '@/api/types' import { MsgError } from '@/utils/message' @@ -15,7 +16,8 @@ function setRequestHeaders(config: InternalAxiosRequestConfig) { if (!(config.headers instanceof AxiosHeaders)) { config.headers = new AxiosHeaders(config.headers) } - if (auth.token) { + // 请求已指定 Authorization 时不覆盖,如匿名认证携带上次的匿名 token + if (auth.token && !config.headers.has('Authorization')) { config.headers.set('Authorization', `Bearer ${auth.token}`) } if (user.language) { @@ -32,11 +34,23 @@ async function getResponseErrorMessage(error: unknown) { const responseData = error.response?.data if (typeof responseData === 'string') { - return responseData + // 网关或后端调试页返回的 HTML 不适合直接展示,交由调用方使用通用提示 + return isHtmlResponse(responseData) ? undefined : responseData } return responseData?.message } +function isHtmlResponse(responseText: string) { + return /^\s*<(!doctype|html)/i.test(responseText) +} + +/** 根据 HTTP 状态生成通用错误提示。 */ +function getStatusErrorMessage(status?: number) { + if (status === 404) return '请求的资源不存在' + if (status && status >= 500) return '服务异常,请稍后重试' + return '请求失败,请稍后重试' +} + export const request = axios.create({ baseURL: CHAT_API_BASE_PATH, timeout: DEFAULT_TIMEOUT, @@ -63,8 +77,16 @@ request.interceptors.response.use( return Promise.reject(error) } + // token 失效时重新认证;认证接口自身的 401(如登录失败)仍按普通错误提示 + const requestUrl = error.config?.url ?? '' + const status = error.response?.status + if (status === 401 && !requestUrl.startsWith('/v3/auth/')) { + reauthenticate() + return Promise.reject(error) + } + const responseMessage = await getResponseErrorMessage(error) - MsgError(responseMessage || error.message) + MsgError(responseMessage || getStatusErrorMessage(status)) return Promise.reject(error) }, ) diff --git a/ui/src/api/chat/portal.ts b/ui/src/api/chat/portal.ts new file mode 100644 index 00000000000..c49c7db56d9 --- /dev/null +++ b/ui/src/api/chat/portal.ts @@ -0,0 +1,17 @@ +/** 提供门户的智能体与历史对话接口。 */ + +import { get } from './core/request' +import type { ResponsePage } from './core/types' +import type { ChatApplicationProfile, PortalApplicationConversations } from '@/api/types' + +/** 分页获取门户可访问的智能体,支持按名称搜索。 */ +const getPortalApplicationPage = (page: number, size: number, name?: string) => { + return get>(`/v3/portal/application/${page}/${size}`, name ? { name } : undefined) +} + +/** 分页获取门户可访问的智能体及各自最近的历史对话。 */ +const getPortalConversationPage = (page: number, size: number) => { + return get>(`/v3/portal/chat/${page}/${size}`) +} + +export default { getPortalApplicationPage, getPortalConversationPage } diff --git a/ui/src/api/types/chat-application.ts b/ui/src/api/types/chat-application.ts new file mode 100644 index 00000000000..52ef6c8dac9 --- /dev/null +++ b/ui/src/api/types/chat-application.ts @@ -0,0 +1,26 @@ +/** 对话端智能体信息类型。 */ + +/** 对话端可见的智能体基础信息,仅声明当前使用的字段。 */ +export interface ChatApplicationProfile { + id: string + name: string + icon: string + desc?: string + prologue?: string +} + +/** 门户历史对话中单条会话的摘要。 */ +export interface PortalConversationSummary { + id: string + abstract: string + create_time: string + update_time: string +} + +/** 门户历史对话按智能体分组,每个智能体最多返回最近 5 条会话;未开启历史记录的智能体不返回会话。 */ +export interface PortalApplicationConversations { + id: string + name: string + icon: string + conversations: PortalConversationSummary[] +} diff --git a/ui/src/api/types/chat-auth.ts b/ui/src/api/types/chat-auth.ts new file mode 100644 index 00000000000..77888573170 --- /dev/null +++ b/ui/src/api/types/chat-auth.ts @@ -0,0 +1,45 @@ +/** Chat 与 Portal 入口共用的认证前置配置、登录与对话用户档案类型。 */ + +import type { ChatUserTokenQuota } from './chat-user' +import type { LoginMethod } from './login' + +/** 认证前置配置,应用与门户共用结构,差异字段放在 `meta`。 */ +export interface AuthProfile> { + max_attempts: number + enable_auth: boolean + login_value: LoginMethod[] + rsa_key: string + meta: Meta +} + +export interface ApplicationAuthProfileMeta { + application_id: string + application_name: string +} + +/** 单应用对话的认证前置配置。 */ +export type ApplicationAuthProfile = AuthProfile + +/** 门户的认证前置配置。 */ +export type PortalAuthProfile = AuthProfile + +export interface ChatLoginRequest { + username: string + password?: string + captcha?: string + encryptedData?: string +} + +export interface ChatLoginResponse { + token: string +} + +/** 当前登录的对话用户档案。 */ +export interface ChatUserProfile { + id: string + username: string + nick_name: string + email: string + source: string + token_quota: Omit | null +} diff --git a/ui/src/api/types/index.ts b/ui/src/api/types/index.ts index c99fd1f083d..7852540e43c 100644 --- a/ui/src/api/types/index.ts +++ b/ui/src/api/types/index.ts @@ -2,6 +2,8 @@ export type * from './application' export type * from './common' export type * from './chat-user' +export type * from './chat-auth' +export type * from './chat-application' export type * from './chat-user-groups' export type * from './login' export type * from './system-user' diff --git a/ui/src/chat.ts b/ui/src/chat.ts index 0c104dd9a76..380017756ec 100644 --- a/ui/src/chat.ts +++ b/ui/src/chat.ts @@ -5,7 +5,7 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn' import App from './App.vue' import { configureMarkdownEditor } from '@/components/global/markdown-editor/config' import router from '@/router/chat' -import { pinia } from '@/stores' +import { pinia } from '@/stores/chat' import 'element-plus/dist/index.css' import '@/styles/tailwind.css' import '@/styles/index.scss' diff --git a/ui/src/conversation-panel/components/header/chat/index.vue b/ui/src/conversation-panel/components/header/chat/index.vue index 2b81a10027b..5e6e305450d 100644 --- a/ui/src/conversation-panel/components/header/chat/index.vue +++ b/ui/src/conversation-panel/components/header/chat/index.vue @@ -10,7 +10,7 @@ - @@ -26,12 +26,17 @@ diff --git a/ui/src/conversation-panel/core/types.ts b/ui/src/conversation-panel/core/types.ts index 0b94252632d..bd169cc5959 100644 --- a/ui/src/conversation-panel/core/types.ts +++ b/ui/src/conversation-panel/core/types.ts @@ -1,5 +1,11 @@ import type { CHAT_TYPE } from './enums' +/** 侧栏账户入口展示的当前用户,由 Chat / Debug 入口按各自的用户 Store 提供。 */ +export interface ConversationAccount { + nickName?: string + username?: string +} + export interface Conversation { id: string abstract: string @@ -59,7 +65,7 @@ export interface ConversationApiAdapter { rename: (chatId: string, data: { abstract: string }) => Promise chatMessage: (chatId: string, data: any) => Promise resumeMessage: (chatId: string, chatRecordId: string) => Promise - uploadFile: (file: File, chatId: string) => Promise + uploadFile: (file: File, chatId: string, onProgress?: (percent: number) => void) => Promise cancel: (chatId: string) => Promise } diff --git a/ui/src/conversation-panel/left-sidebar/conversation-list/AccountMenu.vue b/ui/src/conversation-panel/left-sidebar/conversation-list/AccountMenu.vue index 3c20a5fe24d..45b1c4c19c8 100644 --- a/ui/src/conversation-panel/left-sidebar/conversation-list/AccountMenu.vue +++ b/ui/src/conversation-panel/left-sidebar/conversation-list/AccountMenu.vue @@ -1,15 +1,15 @@ + + diff --git a/ui/src/conversation-panel/left-sidebar/portal-conversation-list/index.ts b/ui/src/conversation-panel/left-sidebar/portal-conversation-list/index.ts new file mode 100644 index 00000000000..58156aaa48f --- /dev/null +++ b/ui/src/conversation-panel/left-sidebar/portal-conversation-list/index.ts @@ -0,0 +1,139 @@ +import { inject, ref, type InjectionKey, type Ref } from 'vue' +import type { ChatApplicationProfile, PortalApplicationConversations } from '@/api/types' +import type { Conversation } from '../../core/types' + +/** 门户侧栏中按智能体分组的历史对话。 */ +export interface PortalApplicationGroup { + id: string + name: string + icon: string + conversations: Conversation[] + expanded: boolean +} + +// 由 Portal 入口注入本组件需要的接口与导航。 +export interface PortalConversationListDeps { + pageApplicationConversations: (page: number, size: number) => Promise<{ records?: PortalApplicationConversations[] }> + pageApplications: (page: number, size: number, name: string) => Promise<{ records?: ChatApplicationProfile[] }> + /** 分页获取某个智能体的历史会话,展开分组时刷新。 */ + pageGroupConversations: (applicationId: string, page: number, size: number) => Promise<{ records?: Conversation[] } | null | undefined> + removeConversation: (applicationId: string, chatId: string) => Promise + /** 当前对话的智能体 ID;门户首页与全部智能体页为空。 */ + currentApplicationId: Readonly> + /** 当前是否处于全部智能体页。 */ + isApplicationListActive: Readonly> + /** 打开智能体的新建对话。 */ + openApplication: (applicationId: string) => void + /** 打开其他智能体的会话。 */ + openConversation: (applicationId: string, chatId: string) => void + /** 打开全部智能体页。 */ + openApplicationList: () => void + /** 打开选择智能体抽屉。 */ + openApplicationSelector: () => void +} + +// 门户可访问的智能体数量有限,一次加载全部 +const APPLICATION_PAGE_SIZE = 100 +// 每个智能体分组最多展示最近 5 条会话 +const GROUP_CONVERSATION_LIMIT = 5 + +/** + * 管理门户侧栏的智能体分组与全部智能体列表。当前智能体的会话由会话列表 Store 维护, + * 侧栏展示时以其为准,保证新建、重命名和删除即时生效;其他智能体使用分组接口返回的最近会话。 + */ +export function createPortalConversationListStore(deps: PortalConversationListDeps) { + const { pageApplicationConversations, pageApplications, pageGroupConversations, removeConversation, currentApplicationId } = deps + + // 历史对话分组 + const applicationGroups = ref([]) + const isApplicationGroupsLoaded = ref(false) + + const loadApplicationGroups = () => + pageApplicationConversations(1, APPLICATION_PAGE_SIZE).then((response) => { + applicationGroups.value = (response.records ?? []).map((application) => ({ + ...application, + expanded: application.conversations.length > 0 || application.id === currentApplicationId.value, + })) + isApplicationGroupsLoaded.value = true + }) + + // 展开其他智能体时查询其最近会话;当前智能体以会话列表 Store 为准,无需查询 + const loadGroupConversations = (group: PortalApplicationGroup) => + pageGroupConversations(group.id, 1, GROUP_CONVERSATION_LIMIT) + .then((response) => { + group.conversations = response?.records ?? [] + }) + .catch(() => { + // 查询失败时保留分组接口返回的会话 + }) + + const toggleGroup = (applicationId: string) => { + const group = applicationGroups.value.find((applicationGroup) => applicationGroup.id === applicationId) + if (!group) return + group.expanded = !group.expanded + if (group.expanded && group.id !== currentApplicationId.value) loadGroupConversations(group) + } + + // 删除其他智能体的会话:请求成功后更新本地分组 + const deleteGroupConversation = async (applicationId: string, chatId: string) => { + await removeConversation(applicationId, chatId) + const group = applicationGroups.value.find((applicationGroup) => applicationGroup.id === applicationId) + if (group) group.conversations = group.conversations.filter((conversation) => conversation.id !== chatId) + } + + // 按名称查询可访问的智能体,全部智能体页与选择智能体抽屉各自维护搜索条件与结果 + const fetchApplications = (keyword: string) => + pageApplications(1, APPLICATION_PAGE_SIZE, keyword.trim()).then((response) => response.records ?? []) + + // 全部智能体页 + const applications = ref([]) + const applicationKeyword = ref('') + const isApplicationsLoading = ref(false) + + const loadApplications = () => { + isApplicationsLoading.value = true + return fetchApplications(applicationKeyword.value) + .then((records) => { + applications.value = records + }) + .finally(() => { + isApplicationsLoading.value = false + }) + } + + // 新建对话:进入当前智能体的新建对话地址,会话 ID 同步随之新建;没有当前智能体时选择智能体 + const newConversation = () => { + if (currentApplicationId.value) deps.openApplication(currentApplicationId.value) + else deps.openApplicationSelector() + } + + return { + applicationGroups, + isApplicationGroupsLoaded, + loadApplicationGroups, + toggleGroup, + deleteGroupConversation, + applications, + applicationKeyword, + isApplicationsLoading, + loadApplications, + fetchApplications, + currentApplicationId, + isApplicationListActive: deps.isApplicationListActive, + openApplication: deps.openApplication, + openConversation: deps.openConversation, + openApplicationList: deps.openApplicationList, + newConversation, + } +} + +export type PortalConversationListStore = ReturnType + +// 门户视图内共享 Store +export const PORTAL_CONVERSATION_LIST_KEY: InjectionKey = Symbol('portal-conversation-list') + +export function usePortalConversationListStore(): PortalConversationListStore { + const store = inject(PORTAL_CONVERSATION_LIST_KEY) + if (!store) throw new Error('usePortalConversationListStore 必须在门户会话视图内使用') + return store +} diff --git a/ui/src/conversation-panel/left-sidebar/portal-conversation-list/index.vue b/ui/src/conversation-panel/left-sidebar/portal-conversation-list/index.vue new file mode 100644 index 00000000000..fc700ed3258 --- /dev/null +++ b/ui/src/conversation-panel/left-sidebar/portal-conversation-list/index.vue @@ -0,0 +1,136 @@ + + + diff --git a/ui/src/conversation-panel/main/portal-application-panel/index.vue b/ui/src/conversation-panel/main/portal-application-panel/index.vue new file mode 100644 index 00000000000..4f7dccff56d --- /dev/null +++ b/ui/src/conversation-panel/main/portal-application-panel/index.vue @@ -0,0 +1,52 @@ + + + diff --git a/ui/src/conversation-panel/main/portal-chat-panel/index.vue b/ui/src/conversation-panel/main/portal-chat-panel/index.vue new file mode 100644 index 00000000000..174ffd00f44 --- /dev/null +++ b/ui/src/conversation-panel/main/portal-chat-panel/index.vue @@ -0,0 +1,30 @@ + + diff --git a/ui/src/conversation-panel/view/chat/index.ts b/ui/src/conversation-panel/view/chat/index.ts index 748371e2fdd..c9cd0ab5c5f 100644 --- a/ui/src/conversation-panel/view/chat/index.ts +++ b/ui/src/conversation-panel/view/chat/index.ts @@ -1,4 +1,8 @@ +import { computed, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import ApplicationApi from '@/api/chat/application' import ConversationApi from '@/api/chat/conversation' +import { useStore } from '@/stores/chat' import FileApi from '@/api/chat/file' import { FILE_SOURCE_TYPE } from '@/api/enums' import type { ConversationApiAdapter } from '../../core/types' @@ -8,17 +12,50 @@ import { createMessageInputStore } from '../../components/message-input/index' import { createExecutionDetailStore } from '../../right-sidebar/execution-detail/index' import type { ConversationBundle } from '../types' -/** chat 模式:装配各组件 store。模式差异只在这里的 adapter。 */ -export function createChatConversation(): ConversationBundle { +/** chat 模式的智能体与会话地址来源;单应用与门户的路由不同,由使用方提供。 */ +export interface ChatConversationRouting { + /** 当前对话的智能体 ID,v3 对话接口按智能体划分路径。 */ + getApplicationId: () => string + /** 地址中的会话 ID,没有时为空字符串。 */ + getRouteChatId: () => string + /** 将当前会话写入地址;会话 ID 为空表示新建对话。 */ + replaceChatId: (chatId: string) => void +} + +/** 单应用对话:智能体 ID 取自守卫写入的应用认证前置配置,会话地址为 /:accessToken/c/:chatId。 */ +function useApplicationChatRouting(): ChatConversationRouting { + const route = useRoute() + const router = useRouter() + const { auth } = useStore() + + return { + getApplicationId: () => auth.applicationId ?? '', + getRouteChatId: () => (typeof route.params.chatId === 'string' ? route.params.chatId : ''), + replaceChatId: (chatId) => { + const { accessToken } = route.params + void router.replace(chatId ? { name: 'chat-home-detail', params: { accessToken, chatId } } : { name: 'chat-home', params: { accessToken } }) + }, + } +} + +/** + * chat 模式:装配各组件 store。模式差异只在这里的 adapter。 + * 当前会话与地址双向同步,切换智能体时重新加载智能体信息与会话。 + */ +export function createChatConversation(routing: ChatConversationRouting = useApplicationChatRouting()): ConversationBundle { + const { getApplicationId, getRouteChatId, replaceChatId } = routing + const { user } = useStore() + const api: ConversationApiAdapter = { - pageConversations: (p, s) => ConversationApi.getConversationPage(p, s), - pageRecords: (cid, p, s) => ConversationApi.getConversationRecordPage(cid, p, s), - recordDetail: (cid, rid) => ConversationApi.getConversationRecordDetail(cid, rid), - remove: (id) => ConversationApi.deleteConversation(id), - rename: (id, data) => ConversationApi.putConversation(id, data), - chatMessage: (cid, data) => ConversationApi.postConversationMessage(cid, data), - resumeMessage: (cid, rid) => ConversationApi.postResumeConversationMessage(cid, rid), - uploadFile: (file) => FileApi.postUploadFile(file, '', FILE_SOURCE_TYPE.CHAT).request, + // 门户首页尚未确定智能体时没有会话可加载 + pageConversations: (p, s) => (getApplicationId() ? ConversationApi.getConversationPage(getApplicationId(), p, s) : Promise.resolve({ records: [] })), + pageRecords: (cid, p, s) => ConversationApi.getConversationRecordPage(getApplicationId(), cid, p, s), + recordDetail: (cid, rid) => ConversationApi.getConversationRecordDetail(getApplicationId(), cid, rid), + remove: (id) => ConversationApi.deleteConversation(getApplicationId(), id), + rename: (id, data) => ConversationApi.putConversation(getApplicationId(), id, data), + chatMessage: (cid, data) => ConversationApi.postConversationMessage(getApplicationId(), cid, data), + resumeMessage: (cid, rid) => ConversationApi.postResumeConversationMessage(getApplicationId(), cid, rid), + uploadFile: (file, cid, onProgress) => FileApi.postUploadFile(file, cid, FILE_SOURCE_TYPE.CHAT, onProgress).request, cancel: (cid) => ConversationApi.postCancelConversationMessage(cid), } @@ -26,6 +63,10 @@ export function createChatConversation(): ConversationBundle { pageConversations: api.pageConversations, remove: api.remove, rename: api.rename, + account: computed(() => { + const profile = user.chatUserProfile + return profile?.username ? { nickName: profile.nick_name, username: profile.username } : null + }), }) const msgs = createMessageListStore({ pageRecords: api.pageRecords, @@ -47,7 +88,48 @@ export function createChatConversation(): ConversationBundle { sendMessage: msgs.sendMessage, uploadFile: msgs.uploadFile, stop: msgs.stop, + // 门户首页、全部智能体页尚未确定智能体时禁止发送 + disabled: computed(() => !getApplicationId()), }) const detail = createExecutionDetailStore() + + // 智能体名称与图标展示在侧栏与消息列表 + const loadApplicationProfile = () => { + if (!getApplicationId()) return + ApplicationApi.getApplicationProfile(getApplicationId()).then(({ name, icon }) => { + list.appInfo.value = { name, icon } + }) + } + loadApplicationProfile() + + // 切换智能体(门户中打开其他智能体的会话)时停止当前生成,重新加载智能体信息与会话列表。 + // 必须先于会话 ID 监听创建:这里先打开新地址的会话,会话 ID 监听随后比较时即可跳过。 + watch(getApplicationId, () => { + msgs.cancel() + list.appInfo.value = null + loadApplicationProfile() + list.loadConversations() + const chatId = getRouteChatId() + if (chatId) list.selectConversation(chatId) + else list.newConversation() + }) + + // 会话与地址同步:会话列表 Store 不感知路由,由 chat 模式在此处理;debug 模式的地址不含会话 ID,无需同步。 + // 新建对话时 currentChatId 置空;首条消息生成 ID 或切换会话时写入会话 ID。 + watch( + () => list.currentChatId.value, + (chatId) => { + if (chatId !== getRouteChatId()) replaceChatId(chatId) + }, + ) + // 浏览器前进、后退等地址变化时切换到对应会话 + watch(getRouteChatId, (chatId) => { + if (chatId === list.currentChatId.value) return + if (chatId) list.selectConversation(chatId) + else list.newConversation() + }) + // 地址中带会话 ID 时打开该会话 + if (getRouteChatId()) list.selectConversation(getRouteChatId()) + return { list, msgs, input, detail } } diff --git a/ui/src/conversation-panel/view/debug/index.ts b/ui/src/conversation-panel/view/debug/index.ts index 8ab0bd88e6a..08253ab7f78 100644 --- a/ui/src/conversation-panel/view/debug/index.ts +++ b/ui/src/conversation-panel/view/debug/index.ts @@ -1,6 +1,7 @@ import { computed } from 'vue' import { useRoute } from 'vue-router' import ConversationApi from '@/api/admin/workspace/conversation' +import { useStore } from '@/stores' import FileApi from '@/api/admin/file' import { FILE_SOURCE_TYPE } from '@/api/enums' import type { ConversationApiAdapter } from '../../core/types' @@ -24,14 +25,16 @@ export function createDebugConversation(): ConversationBundle { rename: (id, data) => ConversationApi.putConversation(id, data, appId.value), chatMessage: (cid, data) => ConversationApi.postConversationMessage(cid, data, appId.value), resumeMessage: (cid, rid) => ConversationApi.postResumeConversationMessage(cid, rid, appId.value), - uploadFile: (file, cid) => FileApi.postUploadFile(file, cid, FILE_SOURCE_TYPE.CHAT).request, + uploadFile: (file, cid, onProgress) => FileApi.postUploadFile(file, cid, FILE_SOURCE_TYPE.CHAT, onProgress).request, cancel: (cid) => ConversationApi.postCancelConversationMessage(cid, appId.value), } // currentChatId 单一数据源在 list;msgs 只读借用,list 通过 bindLoader 主动驱动加载 + const { user } = useStore() const list = createConversationListStore({ pageConversations: api.pageConversations, remove: api.remove, rename: api.rename, + account: computed(() => (user.userInfo ? { nickName: user.userInfo.nick_name, username: user.userInfo.username } : null)), }) const msgs = createMessageListStore({ pageRecords: api.pageRecords, diff --git a/ui/src/conversation-panel/view/portal/index.ts b/ui/src/conversation-panel/view/portal/index.ts new file mode 100644 index 00000000000..6efb1d19445 --- /dev/null +++ b/ui/src/conversation-panel/view/portal/index.ts @@ -0,0 +1,86 @@ +import { computed, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import ConversationApi from '@/api/chat/conversation' +import PortalApi from '@/api/chat/portal' +import { createPortalConversationListStore, type PortalConversationListStore } from '../../left-sidebar/portal-conversation-list/index' +import { createChatConversation, type ChatConversationRouting } from '../chat/index' +import type { ConversationBundle } from '../types' + +export interface PortalConversationBundle extends ConversationBundle { + portalList: PortalConversationListStore +} + +/** 门户对话:智能体 ID 取自路由,会话地址为 /portal/a/:applicationId/c/:chatId,新建对话为 /portal/a/:applicationId。 */ +function usePortalChatRouting(): ChatConversationRouting { + const route = useRoute() + const router = useRouter() + + const getApplicationId = () => (typeof route.params.applicationId === 'string' ? route.params.applicationId : '') + + return { + getApplicationId, + getRouteChatId: () => (typeof route.params.chatId === 'string' ? route.params.chatId : ''), + replaceChatId: (chatId) => { + const applicationId = getApplicationId() + // 门户首页与全部智能体页没有智能体,不同步会话地址 + if (!applicationId) return + void router.replace( + chatId + ? { name: 'portal-application-chat', params: { applicationId, chatId } } + : { name: 'portal-application', params: { applicationId } }, + ) + }, + } +} + +/** + * portal 模式:复用 chat 模式的会话装配,左侧改为按智能体分组的门户历史对话,主区域可切换为全部智能体。 + * 当前智能体的会话仍由会话列表 Store 维护,门户侧栏 Store 只管理分组、全部智能体与跨智能体导航。 + */ +export interface PortalConversationOptions { + /** 打开选择智能体抽屉,抽屉由门户视图渲染。 */ + openApplicationSelector: () => void +} + +export function createPortalConversation(options: PortalConversationOptions): PortalConversationBundle { + const route = useRoute() + const router = useRouter() + const routing = usePortalChatRouting() + const bundle = createChatConversation(routing) + const currentApplicationId = computed(routing.getApplicationId) + const isApplicationListActive = computed(() => route.name === 'portal-application-list') + + const openApplication = (applicationId: string) => void router.push({ name: 'portal-application', params: { applicationId } }) + + const portalList = createPortalConversationListStore({ + pageApplicationConversations: (page, size) => PortalApi.getPortalConversationPage(page, size), + pageApplications: (page, size, name) => PortalApi.getPortalApplicationPage(page, size, name), + pageGroupConversations: (applicationId, page, size) => ConversationApi.getConversationPage(applicationId, page, size), + removeConversation: (applicationId, chatId) => ConversationApi.deleteConversation(applicationId, chatId), + currentApplicationId, + isApplicationListActive, + openApplication, + openConversation: (applicationId, chatId) => void router.push({ name: 'portal-application-chat', params: { applicationId, chatId } }), + openApplicationList: () => void router.push({ name: 'portal-application-list' }), + openApplicationSelector: options.openApplicationSelector, + }) + + // 切换智能体时会话列表重新加载是异步的,此时列表仍是离开前智能体的会话: + // 先把它写回离开的分组,再用进入分组已有的会话填充列表,避免侧栏先显示上一个智能体的会话。 + watch(currentApplicationId, (applicationId, previousApplicationId) => { + const findGroup = (id: string) => portalList.applicationGroups.value.find((applicationGroup) => applicationGroup.id === id) + const previousGroup = findGroup(previousApplicationId) + if (previousGroup) previousGroup.conversations = [...bundle.list.conversations.value] + bundle.list.conversations.value = [...(findGroup(applicationId)?.conversations ?? [])] + }) + + // 门户首页(/portal)未指定智能体:分组加载后进入第一个智能体的新建对话;没有可用智能体时保持空状态 + watch(portalList.applicationGroups, (applicationGroups) => { + const firstApplication = applicationGroups[0] + if (route.name === 'portal-home' && firstApplication) { + void router.replace({ name: 'portal-application', params: { applicationId: firstApplication.id } }) + } + }) + + return { ...bundle, portalList } +} diff --git a/ui/src/conversation-panel/view/portal/index.vue b/ui/src/conversation-panel/view/portal/index.vue new file mode 100644 index 00000000000..25f6c026e0a --- /dev/null +++ b/ui/src/conversation-panel/view/portal/index.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/ui/src/main.ts b/ui/src/main.ts index d6918c5b6f1..a70fa88fb78 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -5,7 +5,7 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn' import App from './App.vue' import { configureMarkdownEditor } from '@/components/global/markdown-editor/config' import router from '@/router/admin' -import { pinia } from '@/stores' +import { pinia } from '@/stores/admin' import permission from '@/permission/install' import '@/styles/tailwind.css' import '@/styles/index.scss' diff --git a/ui/src/router/ROUTE_README.md b/ui/src/router/ROUTE_README.md index ec32b43f315..3175e2ff95a 100644 --- a/ui/src/router/ROUTE_README.md +++ b/ui/src/router/ROUTE_README.md @@ -33,8 +33,8 @@ src/router/ │ ├── model.ts # 模型 │ └── trigger.ts # 触发器 └── chat/ - ├── index.ts # Chat Router - └── routes.ts # Chat 路由表 + ├── index.ts # Chat Router 与认证守卫 + └── routes.ts # Chat 路由表(门户与单应用对话) ``` ## 路由分类 @@ -115,7 +115,42 @@ Admin Router 在 `admin/index.ts` 中统一处理导航初始化: ### Chat -Chat 使用独立入口 `src/chat.ts` 和独立 Router,不要把 Chat 路由加入 Admin 路由表。 +Chat 使用独立入口 `src/chat.ts` 和独立 Router,不要把 Chat 路由加入 Admin 路由表。门户与单应用对话 +共用这一个入口,门户部署在 `/chat/portal/` 下。 + +门户路由统一放在 `/portal` 下并配置 `meta.portal: true`:`portal-home`(`/portal`)、 +`portal-login`(`/portal/login`)、`portal-application-list`(`/portal/applications`,全部智能体)、 +`portal-application`(`/portal/a/:applicationId`,新建对话)、 +`portal-application-chat`(`/portal/a/:applicationId/c/:chatId`), +`portal-home`、`portal-application-list`、`portal-application`、`portal-application-chat` 共用门户对话页 `views/portal/index.vue`, +访问 `portal-home` 时由页面在分组加载后进入第一个智能体的新建对话。`portal-login` 与 `chat-login` 共用 `views/chat/login/index.vue`,页面按 Store +当前认证场景显示门户或应用名称,并使用对应场景登录。不要把门户页面放到 `/login` 等一级路径,否则会与 `/:accessToken/` 冲突。 + +单应用对话路由以 `/:accessToken/` 开头,兼容 v2 对话地址:`chat-home`(`/:accessToken/`)、 +`chat-home-detail`(`/:accessToken/c/:chatId`)和 `chat-login`(`/:accessToken/login`)。 +静态段 `/portal` 优先于动态参数匹配;后端生成的 accessToken 为 16 位十六进制字符串,不会等于 `portal`。 + +根路径 `/` 重定向到 `portal-home`;匹配不到的地址由末尾 catch-all 路由 `chat-not-found` 渲染 +`views/chat/error/index.vue`,保留原地址。Router base 优先读取 `window.MaxKB.prefix`(后端按 `CHAT_PATH` +替换),再回退到 `VITE_BASE_PATH`。 + +认证场景(scope)由路由决定:门户路由为 `PORTAL_AUTH_SCOPE`,单应用路由为其 accessToken。 +Chat 导航守卫(`chat/index.ts`): + +1. `/portal` 下未定义的子路径会被单应用路由匹配为 `accessToken = 'portal'`,重定向到 `portal-home`。 +2. 获取目标场景的认证前置配置(`AuthProfile`),与当前场景相同时复用。accessToken 无效、应用已停用或门户 + 未开放导致获取失败时,进入 `chat-not-found` 并保留原地址;匿名认证失败时同样处理。 +3. 判断时只读取目标场景在本地存储中的 token,不切换 Store 的当前场景。 +4. 进入登录页或已有 token 时,调用 `auth.activate()` 切换当前场景后放行。 +5. `enable_auth` 为 false 时获取匿名 token,切换当前场景后放行。匿名认证与登录都携带该场景本地保存的 + token,后端据此沿用同一对话用户。401 时 token 只在 Store 中清除并标记为失效,本地存储保留以便 + 重新认证时携带;退出登录才从本地存储删除。 +6. 否则跳转到对应登录页(`portal-login` 或 `chat-login`),并通过 `redirect` 查询参数保留原地址。 + +当前场景只在放行前切换,请求层直接读取 `auth.token`,导航未完成时不会带上目标场景的 token。 + +接口返回 401(`/auth/` 认证接口除外)时,请求层调用 `reauthenticate()`:将当前场景的 token 标记为失效(Store 中清除,本地存储保留),并以 +`force: true` 重新导航到当前地址,由守卫重新匿名认证或跳转登录页。同一时间只处理一次。 ## RouteMeta 字段 diff --git a/ui/src/router/chat/index.ts b/ui/src/router/chat/index.ts index 44afe2440ba..5b061da6746 100644 --- a/ui/src/router/chat/index.ts +++ b/ui/src/router/chat/index.ts @@ -1,10 +1,93 @@ -import { createRouter, createWebHistory } from 'vue-router' +import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' +import { useStore } from '@/stores/chat' +import { PORTAL_AUTH_SCOPE } from '@/stores/chat/auth' +import type { AuthProfile } from '@/api/types' import { chatRoutes } from './routes' -const chatRouter = createRouter({ history: createWebHistory(import.meta.env.VITE_BASE_PATH || '/chat/'), routes: chatRoutes }) +const CHAT_BASE_PATH = window.MaxKB?.prefix || import.meta.env.VITE_BASE_PATH || '/chat/' + +const chatRouter = createRouter({ history: createWebHistory(CHAT_BASE_PATH), routes: chatRoutes }) + +const LOGIN_ROUTE_NAMES = new Set(['portal-login', 'chat-login']) + +/** 根据路由得到认证场景:门户路由为门户场景,单应用路由为其 accessToken。 */ +function getAuthScope(to: RouteLocationNormalized) { + if (to.meta.portal) return PORTAL_AUTH_SCOPE + + const { accessToken } = to.params + return typeof accessToken === 'string' && accessToken ? accessToken : undefined +} + +/** `/portal` 下未定义的子路径会被单应用路由匹配为 accessToken = 'portal'。 */ +function isUnknownPortalPath(to: RouteLocationNormalized) { + return !to.meta.portal && to.params.accessToken === PORTAL_AUTH_SCOPE +} + +function getLoginRoute(scope: string, to: RouteLocationNormalized) { + const query = { redirect: to.fullPath } + return scope === PORTAL_AUTH_SCOPE + ? { name: 'portal-login', query } + : { name: 'chat-login', params: { accessToken: scope }, query } +} + +/** 渲染 404 页面并保留原地址,刷新时可重新尝试。 */ +function getNotFoundRoute(to: RouteLocationNormalized) { + return { name: 'chat-not-found', params: { pathMatch: to.path.substring(1).split('/') }, query: to.query, hash: to.hash } +} + +chatRouter.beforeEach(async (to) => { + if (isUnknownPortalPath(to)) return { name: 'portal-home', replace: true } + + const scope = getAuthScope(to) + if (!scope) return true + + const { auth } = useStore() + + // accessToken 无效、应用已停用或门户未开放时,认证前置配置与匿名认证会失败,统一进入 404 页面 + let authProfile: AuthProfile + try { + authProfile = await auth.fetchAuthProfile(scope) + } catch { + return getNotFoundRoute(to) + } + + // 判断时只读取目标场景已保存的 token,放行前再切换当前场景,避免导航未完成时请求带上目标场景的 token + if (LOGIN_ROUTE_NAMES.has(String(to.name)) || auth.getStoredToken(scope)) { + auth.activate(scope, authProfile) + return true + } + + if (!authProfile.enable_auth) { + try { + await auth.postAnonymousAuth(scope) + } catch { + return getNotFoundRoute(to) + } + auth.activate(scope, authProfile) + return true + } + + return getLoginRoute(scope, to) +}) chatRouter.afterEach((to) => { document.title = to.meta.title ? `${to.meta.title} - MaxKB` : 'MaxKB' }) +let isReauthenticating = false + +/** + * token 失效(401)时将当前场景的 token 标记为失效,并重新导航到当前地址,由守卫决定匿名认证或跳转登录页。 + * 同一时间只处理一次,避免并发请求重复触发导航。 + */ +export function reauthenticate() { + if (isReauthenticating) return + + isReauthenticating = true + useStore().auth.expireToken() + chatRouter.replace({ path: chatRouter.currentRoute.value.fullPath, force: true }).finally(() => { + isReauthenticating = false + }) +} + export default chatRouter diff --git a/ui/src/router/chat/routes.ts b/ui/src/router/chat/routes.ts index 7efe4f37666..3a927c0444c 100644 --- a/ui/src/router/chat/routes.ts +++ b/ui/src/router/chat/routes.ts @@ -1,4 +1,68 @@ import type { RouteRecordRaw } from 'vue-router' -/** Chat 独立路由,后续的登录、对话分享页等统一在这里扩展。 */ -export const chatRoutes: RouteRecordRaw[] = [{ path: '/', name: 'chat-home', component: () => import('@/views/chat/index.vue'), meta: { title: '对话' } }] +const portalRoutes: RouteRecordRaw[] = [ + { + path: '/portal', + name: 'portal-home', + component: () => import('@/views/portal/index.vue'), + meta: { title: '门户', portal: true } + }, + { + path: '/portal/login', + name: 'portal-login', + component: () => import('@/views/chat/login/index.vue'), + meta: { title: '登录', portal: true } + }, + { + path: '/portal/applications', + name: 'portal-application-list', + component: () => import('@/views/portal/index.vue'), + meta: { title: '全部智能体', portal: true } + }, + { + path: '/portal/a/:applicationId', + name: 'portal-application', + component: () => import('@/views/portal/index.vue'), + meta: { title: '对话', portal: true } + }, + { + path: '/portal/a/:applicationId/c/:chatId', + name: 'portal-application-chat', + component: () => import('@/views/portal/index.vue'), + meta: { title: '对话', portal: true } + } +] + + +const applicationRoutes: RouteRecordRaw[] = [ + { + path: '/:accessToken/login', + name: 'chat-login', + component: () => import('@/views/chat/login/index.vue'), + meta: { title: '登录' } + }, + { + path: '/:accessToken/', + name: 'chat-home', + component: () => import('@/views/chat/index.vue'), + meta: { title: '应用对话' } + }, + { + path: '/:accessToken/c/:chatId', + name: 'chat-home-detail', + component: () => import('@/views/chat/index.vue'), + meta: { title: '对话' } + } +] + +export const chatRoutes: RouteRecordRaw[] = [ + { path: '/', redirect: { name: 'portal-home' } }, + ...portalRoutes, + ...applicationRoutes, + { + path: '/:pathMatch(.*)*', + name: 'chat-not-found', + component: () => import('@/views/chat/error/index.vue'), + meta: { title: '页面不存在' } + } +] diff --git a/ui/src/stores/auth.ts b/ui/src/stores/admin/auth.ts similarity index 100% rename from ui/src/stores/auth.ts rename to ui/src/stores/admin/auth.ts diff --git a/ui/src/stores/admin/index.ts b/ui/src/stores/admin/index.ts new file mode 100644 index 00000000000..1f37f4ee450 --- /dev/null +++ b/ui/src/stores/admin/index.ts @@ -0,0 +1,25 @@ +/** 提供应用 Pinia 实例和组件外的统一 Store 访问入口。 */ + +import { createPinia } from 'pinia' +import { useAuthStore } from './auth' +import { useThemeStore } from './theme' +import { useUserStore } from './user' + +export const pinia = createPinia() + +const stores = { + get auth() { + return useAuthStore(pinia) + }, + get user() { + return useUserStore(pinia) + }, + get theme() { + return useThemeStore(pinia) + }, +} + +/** 在 Router、Axios 等组件外代码中按需访问应用 Store。 */ +export function useStore() { + return stores +} diff --git a/ui/src/stores/theme.ts b/ui/src/stores/admin/theme.ts similarity index 100% rename from ui/src/stores/theme.ts rename to ui/src/stores/admin/theme.ts diff --git a/ui/src/stores/user.ts b/ui/src/stores/admin/user.ts similarity index 100% rename from ui/src/stores/user.ts rename to ui/src/stores/admin/user.ts diff --git a/ui/src/stores/chat/auth.ts b/ui/src/stores/chat/auth.ts new file mode 100644 index 00000000000..f4d1c780716 --- /dev/null +++ b/ui/src/stores/chat/auth.ts @@ -0,0 +1,124 @@ +/** + * 管理对话端的认证前置配置与当前 token。 + * + * 门户与各单应用是不同的认证场景(scope):门户为 `PORTAL_AUTH_SCOPE`,单应用为其 accessToken。 + * Store 只保存当前场景的一份 token,各场景的 token 在本地存储中分别保存。 + */ + +import { defineStore } from 'pinia' +import ChatAuthApi from '@/api/chat/auth' +import type { ApplicationAuthProfileMeta, AuthProfile, ChatLoginRequest } from '@/api/types' +import { useChatUserStore } from './user' + +/** 门户的认证场景;accessToken 为 16 位十六进制字符串,不会与之重名。 */ +export const PORTAL_AUTH_SCOPE = 'portal' + +function isPortalScope(scope: string) { + return scope === PORTAL_AUTH_SCOPE +} + +/** 门户场景不向接口传 accessToken。 */ +function getAccessToken(scope: string) { + return isPortalScope(scope) ? undefined : scope +} + +function getTokenStorageKey(scope: string) { + return isPortalScope(scope) ? 'chat-portal-token' : `chat-application-token-${scope}` +} + +/** 读取本地保存的 token,包括已失效的;匿名认证与登录时携带,后端据此沿用同一对话用户。 */ +function getSavedToken(scope: string) { + return localStorage.getItem(getTokenStorageKey(scope)) || undefined +} + +interface ChatAuthState { + /** 当前认证场景,只在守卫放行前切换。 */ + scope: string + token: string + /** 本次页面中已失效(401)的 token;仍保留在本地存储中,供重新认证时携带。 */ + expiredToken: string + authProfile: AuthProfile | null +} + +export const useChatAuthStore = defineStore('chat-auth', { + state: (): ChatAuthState => ({ scope: '', token: '', expiredToken: '', authProfile: null }), + + getters: { + isAuthenticated: (state) => Boolean(state.token), + isPortal: (state) => isPortalScope(state.scope), + /** 当前单应用的 accessToken;门户场景为 undefined。 */ + accessToken: (state) => getAccessToken(state.scope), + /** 当前单应用的智能体 ID;门户场景为 undefined,由路由参数提供。 */ + applicationId(state): string | undefined { + if (isPortalScope(state.scope)) return undefined + return (state.authProfile?.meta as ApplicationAuthProfileMeta | undefined)?.application_id + }, + }, + + actions: { + /** 读取指定场景已保存的有效 token,不改变当前场景;已失效的 token 视为没有。 */ + getStoredToken(scope: string) { + const token = getSavedToken(scope) ?? '' + return token === this.expiredToken ? '' : token + }, + + /** 获取指定场景的认证前置配置;与当前场景相同时复用。 */ + fetchAuthProfile(scope: string): Promise> { + if (scope === this.scope && this.authProfile) return Promise.resolve(this.authProfile) + + return isPortalScope(scope) ? ChatAuthApi.getPortalAuthProfile() : ChatAuthApi.getApplicationAuthProfile(scope) + }, + + /** 切换当前场景,由守卫在放行前调用。 */ + activate(scope: string, authProfile: AuthProfile) { + if (scope !== this.scope) useChatUserStore().clearCurrentUser() + this.scope = scope + this.authProfile = authProfile + this.token = this.getStoredToken(scope) + }, + + /** 获取并保存指定场景的匿名访问 token,携带本地保存的 token 以沿用同一对话用户。 */ + postAnonymousAuth(scope: string) { + return ChatAuthApi.postAnonymousAuth(getAccessToken(scope), getSavedToken(scope)).then((token) => { + this.setToken(token, scope) + return token + }) + }, + + /** 使用账号登录当前场景,携带本地保存的 token。 */ + login(loginRequest: ChatLoginRequest) { + return ChatAuthApi.postLogin(loginRequest, getAccessToken(this.scope), getSavedToken(this.scope)).then(({ token }) => { + this.setToken(token, this.scope) + return token + }) + }, + + /** 退出当前场景的登录状态。 */ + logout() { + return ChatAuthApi.postLogout().then(() => this.clearToken()) + }, + + /** 保存指定场景的 token;是当前场景时同步到 Store。 */ + setToken(token: string, scope: string) { + localStorage.setItem(getTokenStorageKey(scope), token) + if (scope !== this.scope) return + + if (token !== this.token) useChatUserStore().clearCurrentUser() + this.token = token + }, + + /** 当前 token 失效(401):Store 中清除,本地存储保留,重新认证时携带。 */ + expireToken() { + this.expiredToken = getSavedToken(this.scope) ?? '' + this.token = '' + useChatUserStore().clearCurrentUser() + }, + + /** 清除当前场景的 token,如退出登录。 */ + clearToken() { + localStorage.removeItem(getTokenStorageKey(this.scope)) + this.token = '' + useChatUserStore().clearCurrentUser() + }, + }, +}) diff --git a/ui/src/stores/chat/index.ts b/ui/src/stores/chat/index.ts new file mode 100644 index 00000000000..c930b22b3a9 --- /dev/null +++ b/ui/src/stores/chat/index.ts @@ -0,0 +1,21 @@ +/** 提供 Chat 入口的 Pinia 实例和组件外的统一 Store 访问入口。 */ + +import { createPinia } from 'pinia' +import { useChatAuthStore } from './auth' +import { useChatUserStore } from './user' + +export const pinia = createPinia() + +const stores = { + get auth() { + return useChatAuthStore(pinia) + }, + get user() { + return useChatUserStore(pinia) + }, +} + +/** 在 Router、Axios 等组件外代码中按需访问 Chat Store。 */ +export function useStore() { + return stores +} diff --git a/ui/src/stores/chat/user.ts b/ui/src/stores/chat/user.ts new file mode 100644 index 00000000000..2214854caff --- /dev/null +++ b/ui/src/stores/chat/user.ts @@ -0,0 +1,41 @@ +/** 管理当前对话用户档案与语言。 */ + +import { defineStore } from 'pinia' +import ChatUserApi from '@/api/chat/chat-user' +import type { ChatUserProfile } from '@/api/types' + +const LANGUAGE_STORAGE_KEY = 'MaxKB-locale' + +function getDefaultLanguage() { + return localStorage.getItem(LANGUAGE_STORAGE_KEY) || navigator.language || 'en-US' +} + +interface ChatUserState { + language: string + chatUserProfile: ChatUserProfile | null +} + +export const useChatUserStore = defineStore('chat-user', { + state: (): ChatUserState => ({ language: getDefaultLanguage(), chatUserProfile: null }), + + actions: { + /** 加载当前对话用户档案。 */ + loadCurrentUser() { + return ChatUserApi.getChatUserProfile().then((chatUserProfile) => { + this.chatUserProfile = chatUserProfile + return chatUserProfile + }) + }, + + /** 清除仅在当前 token 下有效的用户档案。 */ + clearCurrentUser() { + this.chatUserProfile = null + }, + + /** 更新并持久化当前语言。 */ + setLanguage(language: string) { + this.language = language + localStorage.setItem(LANGUAGE_STORAGE_KEY, language) + }, + }, +}) diff --git a/ui/src/stores/index.ts b/ui/src/stores/index.ts index 1f37f4ee450..239ab55db9a 100644 --- a/ui/src/stores/index.ts +++ b/ui/src/stores/index.ts @@ -1,25 +1,7 @@ -/** 提供应用 Pinia 实例和组件外的统一 Store 访问入口。 */ - -import { createPinia } from 'pinia' -import { useAuthStore } from './auth' -import { useThemeStore } from './theme' -import { useUserStore } from './user' - -export const pinia = createPinia() - -const stores = { - get auth() { - return useAuthStore(pinia) - }, - get user() { - return useUserStore(pinia) - }, - get theme() { - return useThemeStore(pinia) - }, -} - -/** 在 Router、Axios 等组件外代码中按需访问应用 Store。 */ +import {useStore as useAdminStore} from "@/stores/admin/index" export function useStore() { - return stores + return useAdminStore() } +export function useChatStore() { + return useAdminStore() +} \ No newline at end of file diff --git a/ui/src/views/VIEW_README.md b/ui/src/views/VIEW_README.md index 9f720782948..d92ccf21224 100644 --- a/ui/src/views/VIEW_README.md +++ b/ui/src/views/VIEW_README.md @@ -1055,3 +1055,46 @@ Workspace `CommonApi`;范围变化同时清空搜索控件、筛选、创建 跨页成功后同步列表页码并切换至首条/末条,失败保留原记录和页码,加载期间禁用切换。 正文暂时留空,不查询或展示聊天内容,也不显示聊天记录分页器。 添加到知识库的页面组件尚未接入。 + +## 对话端单应用与门户对话页面 + +`views/chat/index.vue`(`chat-home`、`chat-home-detail`)与 `views/portal/index.vue` +(`portal-application`、`portal-application-chat`)分别渲染对话面板的 chat、portal 视图。页面只负责: + +- 以 `auth.token` 作为面板的 `key`,token 变化(401 重新认证、登录)时重建面板并重新加载数据; + token 被清除后不渲染面板,等待守卫重新认证。 +- token 变化时重新加载当前对话用户档案,供侧栏账户入口展示。 + +对话面板 chat 模式的装配函数 `view/chat/index.ts` 通过 `ChatConversationRouting` 获取智能体 ID +与会话地址,单应用使用默认实现(智能体 ID 取自 `auth.applicationId`,地址为 `/:accessToken/c/:chatId`), +portal 模式传入门户实现(智能体 ID 取自路由 `applicationId`,地址为 `/portal/a/:applicationId/c/:chatId`): + +- 加载智能体名称与图标;切换智能体时停止当前生成,重新加载智能体信息与会话列表。 +- 当前会话与地址双向同步:`currentChatId` 变化时替换地址,新建对话(ID 为空)去掉会话 ID; + 地址变化(含浏览器前进、后退)时切换到对应会话;初始地址带会话 ID 时打开该会话。 +- 会话列表 Store 不感知路由;debug 模式的地址不含会话 ID,不做同步。 +- 侧栏账户信息由 chat、debug 装配函数分别从各自入口的用户 Store 提供,侧栏组件不直接读取 Store。 + +portal 模式(`view/portal/`)复用 chat 模式装配,左侧使用 `left-sidebar/portal-conversation-list/`: +按智能体分组展示门户历史对话(`/v3/portal/chat`),每组最多显示最近 5 条。当前智能体的分组以会话列表 +Store 为准,新建、重命名、删除即时生效;离开某个智能体时把其最新会话写回分组,并先用进入分组已有的会话填充会话列表, +避免列表加载完成前显示上一个智能体的会话。展开其他智能体的分组时 +按智能体查询最近 5 条会话(`/v3/application/:id/chat/1/5`)刷新分组,失败时保留分组接口返回的会话。点击当前智能体的会话 +直接切换,点击其他智能体的会话跳转到对应地址。 +点击智能体行选中该智能体并进入其新建对话(`portal-application`),没有选中会话时该行高亮;点击右侧箭头只展开或收起会话。 + +门户首页 `portal-home` 也渲染该页面:地址中没有智能体时不请求会话与智能体信息,分组加载后替换到第一个 +智能体的 `portal-application`;没有可访问的智能体时消息区显示“暂无可用智能体”空状态。 +门户主区域使用 `main/portal-chat-panel`,不修改共用的 `main/chat-panel`:标题栏的新建对话使用门户侧栏 Store 的 +`newConversation`,没有当前智能体时以空状态代替消息列表。没有当前智能体时, +chat 装配函数通过 `disabled` 禁用输入框与附件。 + +“全部智能体”(`portal-application-list`)在同一门户布局中把主区域换成 `main/portal-application-panel`: +按名称搜索(输入防抖 300ms)并以卡片展示智能体,点击卡片或悬停显示的“去对话”进入该智能体的新建对话。 +卡片由 `components/portal-application-cards` 提供,全部智能体页与选择智能体抽屉共用。 + +侧栏“新建对话”与侧栏收起时标题栏的“新建对话”(门户视图向 `ChatHeader` 传入 `newConversation`) +在当前智能体中新建;没有当前智能体时(全部智能体页、门户首页空状态)打开 `SelectApplicationDrawer` 选择智能体,抽屉的搜索条件与结果独立于全部智能体页,每次打开重新查询。 +抽屉自己管理显隐并暴露 `open()`;门户视图持有抽屉的 ref,通过 `createPortalConversation` 的 +`openApplicationSelector` 传入门户侧栏 Store。Store 的 `newConversation` 有当前智能体时打开其 +`portal-application` 地址,地址中会话 ID 清空后由 chat 装配函数的会话同步新建对话。 diff --git a/ui/src/views/chat/conversation/index.vue b/ui/src/views/chat/conversation/index.vue new file mode 100644 index 00000000000..bf40fa9039e --- /dev/null +++ b/ui/src/views/chat/conversation/index.vue @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/ui/src/views/chat/error/index.vue b/ui/src/views/chat/error/index.vue new file mode 100644 index 00000000000..bd94c07c1cf --- /dev/null +++ b/ui/src/views/chat/error/index.vue @@ -0,0 +1,5 @@ + diff --git a/ui/src/views/chat/index.vue b/ui/src/views/chat/index.vue index 9a5b54a30a2..76c99ce25c3 100644 --- a/ui/src/views/chat/index.vue +++ b/ui/src/views/chat/index.vue @@ -1,3 +1,26 @@ + + diff --git a/ui/src/views/chat/login/index.vue b/ui/src/views/chat/login/index.vue new file mode 100644 index 00000000000..058e87ca091 --- /dev/null +++ b/ui/src/views/chat/login/index.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/ui/src/views/portal/index.vue b/ui/src/views/portal/index.vue new file mode 100644 index 00000000000..dbabf1627b0 --- /dev/null +++ b/ui/src/views/portal/index.vue @@ -0,0 +1,26 @@ + + +