diff --git a/apps/chat/api/portal_api.py b/apps/chat/api/portal_api.py new file mode 100644 index 00000000000..db9b1d6fb03 --- /dev/null +++ b/apps/chat/api/portal_api.py @@ -0,0 +1,128 @@ +# coding=utf-8 +""" +@project: MaxKB +@Author:MaxKB +@file: portal.py +@date:2026/8/14 +@desc: 门户API文档 +""" + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter + +from common.mixins.api_mixin import APIMixin +from common.result import DefaultResultSerializer +from users.serializers.login import LoginRequest + + +class PortalAPI(APIMixin): + class Get(APIMixin): + @staticmethod + def get_response(): + return DefaultResultSerializer + + class Save(APIMixin): + @staticmethod + def get_request(): + return { + "multipart/form-data": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "门户名称"}, + "description": {"type": "string", "description": "门户描述"}, + "logo": {"type": "string", "format": "binary", "description": "门户Logo"}, + "tab_logo": {"type": "string", "format": "binary", "description": "浏览器Tab Logo"}, + "enable_public_access": {"type": "boolean", "description": "是否开启公开访问"}, + "enable_api": {"type": "boolean", "description": "是否开启API服务"}, + "enable_auth": {"type": "boolean", "description": "是否开启身份认证"}, + "auth_config": {"type": "object", "description": "身份认证配置"}, + "enable_cors": {"type": "boolean", "description": "是否开启跨域设置"}, + "cors_config": {"type": "object", "description": "跨域配置"}, + }, + } + } + + @staticmethod + def get_response(): + return DefaultResultSerializer + + class Application(APIMixin): + @staticmethod + def get_parameters(): + return [ + OpenApiParameter( + name="current_page", + description="当前页码", + type=OpenApiTypes.INT, + location="path", + required=True, + ), + OpenApiParameter( + name="page_size", + description="每页数量", + type=OpenApiTypes.INT, + location="path", + required=True, + ), + OpenApiParameter( + name="name", + description="应用名称搜索", + type=OpenApiTypes.STR, + location="query", + required=False, + ), + ] + + @staticmethod + def get_response(): + return DefaultResultSerializer + + class Login(APIMixin): + @staticmethod + def get_request(): + return LoginRequest + + @staticmethod + def get_response(): + return DefaultResultSerializer + + class Info(APIMixin): + @staticmethod + def get_response(): + return DefaultResultSerializer + + class Logout(APIMixin): + @staticmethod + def get_response(): + return DefaultResultSerializer + + class Conversation(APIMixin): + @staticmethod + def get_parameters(): + return [ + OpenApiParameter( + name="current_page", + description="当前页码", + type=OpenApiTypes.INT, + location="path", + required=True, + ), + OpenApiParameter( + name="page_size", + description="每页数量", + type=OpenApiTypes.INT, + location="path", + required=True, + ), + OpenApiParameter( + name="name", + description="应用名称搜索", + type=OpenApiTypes.STR, + location="query", + required=False, + ), + ] + + @staticmethod + def get_response(): + return DefaultResultSerializer diff --git a/apps/chat/serializers/portal.py b/apps/chat/serializers/portal.py new file mode 100644 index 00000000000..31a041ec428 --- /dev/null +++ b/apps/chat/serializers/portal.py @@ -0,0 +1,127 @@ +# coding=utf-8 +""" +@project: MaxKB +@Author:MaxKB +@file: portal.py +@date:2026/8/14 +@desc: 门户配置序列化器 +""" + +from django.db.models import Exists, OuterRef +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from application.models import Application, Chat +from application.models.application_access_token import ApplicationAccessToken +from common.db.search import page_search +from system_manage.models.chat_user import ( + ChatUser, + ResourceChatUserAuthorize, + ResourceChatUserGroupAuthorize, + ResourceType, + UserGroupRelation, +) + + +class PortalApplicationAuthMixin: + """门户应用授权过滤公共逻辑""" + + @staticmethod + def get_authorized_application_queryset(user_id): + public_apps = ApplicationAccessToken.objects.filter(application_id=OuterRef("id"), authentication=False) + if not ChatUser.objects.filter(id=user_id).exists(): + return Application.objects.filter(Exists(public_apps)) + authed_token_exists = ApplicationAccessToken.objects.filter(application_id=OuterRef("id"), authentication=True) + direct_auth = ResourceChatUserAuthorize.objects.filter( + resource_id=OuterRef("id"), resource_type=ResourceType.APPLICATION.value, is_auth=True, user_id=user_id + ) + user_groups = UserGroupRelation.objects.filter(user_id=user_id).values_list("group_id", flat=True) + group_auth = ResourceChatUserGroupAuthorize.objects.filter( + resource_id=OuterRef("id"), + resource_type=ResourceType.APPLICATION.value, + is_auth=True, + user_group_id__in=user_groups, + ) + return Application.objects.filter( + Exists(public_apps) | (Exists(authed_token_exists) & (Exists(direct_auth) | Exists(group_auth))) + ) + + +class ApplicationResponseSerializer(serializers.Serializer): + id = serializers.CharField(required=True) + name = serializers.CharField(required=True) + desc = serializers.CharField(required=True) + icon = serializers.CharField(required=True) + type = serializers.CharField(required=True) + dialogue_number = serializers.IntegerField(required=True) + prologue = serializers.CharField(required=True) + is_publish = serializers.BooleanField(required=True) + + +class PortalApplicationSerializer(serializers.Serializer): + class Query(PortalApplicationAuthMixin, serializers.Serializer): + name = serializers.CharField( + required=False, allow_blank=True, label=_("Application Name"), help_text=_("Application name") + ) + + def get_query_set(self): + queryset = Application.objects.filter(is_publish=True) + name = self.data.get("name") + if name: + queryset = queryset.filter(name__icontains=name) + return queryset.order_by("-create_time") + + def page(self, current_page, page_size, user_id, with_valid=True): + if with_valid: + self.is_valid(raise_exception=True) + queryset = self.get_query_set() + queryset = queryset.filter(id__in=self.get_authorized_application_queryset(user_id).values("id")) + return page_search( + current_page, + page_size, + queryset, + post_records_handler=lambda app: ApplicationResponseSerializer(app).data, + ) + + +class PortalHistoricalConversationResponseSerializer(serializers.Serializer): + id = serializers.CharField(required=True) + abstract = serializers.CharField(required=True) + create_time = serializers.CharField(required=True) + update_time = serializers.CharField(required=True) + application = serializers.SerializerMethodField() + + def get_application(self, chat): + return { + "id": str(chat.application_id), + "name": chat.application.name, + "icon": chat.application.icon, + } + + +class PortalHistoricalConversationSerializer(serializers.Serializer): + class Query(PortalApplicationAuthMixin, serializers.Serializer): + name = serializers.CharField( + required=False, allow_blank=True, label=_("Application Name"), help_text=_("Application name") + ) + + def get_query_set(self, user_id): + queryset = Chat.objects.filter( + chat_user_id=user_id, + is_deleted=False, + application_id__in=self.get_authorized_application_queryset(user_id).values("id"), + ) + name = self.data.get("name") + if name: + queryset = queryset.filter(application__name__icontains=name) + return queryset.select_related("application").order_by("-update_time", "id") + + def page(self, current_page, page_size, user_id, with_valid=True): + if with_valid: + self.is_valid(raise_exception=True) + return page_search( + current_page, + page_size, + self.get_query_set(user_id), + post_records_handler=lambda chat: PortalHistoricalConversationResponseSerializer(chat).data, + ) diff --git a/apps/chat/urls.py b/apps/chat/urls.py index 0bababe7646..9576208252c 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -3,7 +3,7 @@ from application.views import ChatRecordDetailView, ChatRecordLinkView from chat.views import v2 as v2_views, v3 as v3_views -app_name = 'chat' +app_name = "chat" # @formatter:off # fmt: off @@ -41,6 +41,8 @@ path('api_key', v3_views.ChatUserApiKeyView.as_view()), path('api_key//', v3_views.ChatUserApiKeyView.Page.as_view()), path('api_key/', v3_views.ChatUserApiKeyView.Operate.as_view()), + path('portal/application//', v3_views.PortalApplicationView.as_view(), name='v3_portal_application'), + path('portal/chat//', v3_views.PortalHistoricalConversationView.as_view(), name='v3_portal_historical_conversation'), ] v2=[ path('embed', v2_views.ChatEmbedView.as_view()), diff --git a/apps/chat/views/v3/__init__.py b/apps/chat/views/v3/__init__.py index 83ed2012d6f..7220a4c91e4 100644 --- a/apps/chat/views/v3/__init__.py +++ b/apps/chat/views/v3/__init__.py @@ -1,13 +1,15 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎虎 - @file: __init__.py - @date:2025/6/6 11:18 - @desc: +@project: MaxKB +@Author:虎虎 +@file: __init__.py +@date:2025/6/6 11:18 +@desc: """ + from .chat_embed import * from .chat import * from .chat_record import * from .chat_user_api_key import * +from .portal import * from .mcp import mcp_view diff --git a/apps/chat/views/v3/portal.py b/apps/chat/views/v3/portal.py new file mode 100644 index 00000000000..3125ddc7b87 --- /dev/null +++ b/apps/chat/views/v3/portal.py @@ -0,0 +1,63 @@ +# coding=utf-8 +""" +@project: MaxKB +@Author:MaxKB +@file: portal.py +@date:2026/8/14 +@desc: 门户视图 +""" + +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema +from rest_framework.request import Request +from rest_framework.views import APIView + +from common import result +from common.auth import ChatTokenAuth +from common.utils.common import query_params_to_single_dict + +from chat.api.portal_api import PortalAPI +from chat.serializers.portal import ( + PortalApplicationSerializer, + PortalHistoricalConversationSerializer, +) + + +class PortalApplicationView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=["GET"], + description=_("Get published application list by page"), + summary=_("Get published application list by page"), + operation_id=_("Get published application list by page"), + parameters=PortalAPI.Application.get_parameters(), + responses=PortalAPI.Application.get_response(), + tags=[_("V3 Chat")], + ) + def get(self, request: Request, current_page: int, page_size: int): + return result.success( + PortalApplicationSerializer.Query(data={**query_params_to_single_dict(request.query_params)}).page( + current_page, page_size, str(request.user.id) + ) + ) + + +class PortalHistoricalConversationView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=["GET"], + description=_("Get portal historical conversation by page"), + summary=_("Get portal historical conversation by page"), + operation_id=_("Get portal historical conversation by page"), + parameters=PortalAPI.Conversation.get_parameters(), + responses=PortalAPI.Conversation.get_response(), + tags=[_("V3 Chat")], + ) + def get(self, request: Request, current_page: int, page_size: int): + return result.success( + PortalHistoricalConversationSerializer.Query( + data={**query_params_to_single_dict(request.query_params)} + ).page(current_page, page_size, str(request.user.id)) + ) diff --git a/apps/common/init/init_doc.py b/apps/common/init/init_doc.py index fb4a4db3451..09154e4fed1 100644 --- a/apps/common/init/init_doc.py +++ b/apps/common/init/init_doc.py @@ -1,11 +1,12 @@ # coding=utf-8 """ - @project: maxkb - @Author:虎 - @file: init_doc.py - @date:2024/5/24 14:11 - @desc: +@project: maxkb +@Author:虎 +@file: init_doc.py +@date:2024/5/24 14:11 +@desc: """ + import hashlib from django.urls import path, URLPattern, URLResolver @@ -13,10 +14,10 @@ from maxkb.const import CONFIG -chat_api_prefix = CONFIG.get_chat_path()[1:] + '/api/' +chat_api_prefix = CONFIG.get_chat_path()[1:] + "/api/" -def flatten_url_patterns(patterns, prefix=''): +def flatten_url_patterns(patterns, prefix=""): """ 递归展开 urlpatterns,遇到 include() 产生的 URLResolver 时向下钻取, 累加各层路由前缀,最终产出 (完整路由字符串, URLPattern) 元组。 @@ -30,50 +31,61 @@ def flatten_url_patterns(patterns, prefix=''): def init_app_doc(system_urlpatterns): system_urlpatterns += [ - path(f'{CONFIG.get_admin_path()[1:]}/api-doc/schema/', SpectacularAPIView.as_view(), name='schema'), + path(f"{CONFIG.get_admin_path()[1:]}/api-doc/schema/", SpectacularAPIView.as_view(), name="schema"), # schema的配置文件的路由,下面两个ui也是根据这个配置文件来生成的 - path(f'{CONFIG.get_admin_path()[1:]}/api-doc/', SpectacularSwaggerView.as_view(url_name='schema'), - name='swagger-ui'), # swagger-ui的路由 + path( + f"{CONFIG.get_admin_path()[1:]}/api-doc/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), # swagger-ui的路由 ] class ChatSpectacularSwaggerView(SpectacularSwaggerView): @staticmethod def _swagger_ui_resource(filename): - return f'{CONFIG.get_chat_path()}/api-doc/swagger-ui-dist/{filename}' + return f"{CONFIG.get_chat_path()}/api-doc/swagger-ui-dist/{filename}" @staticmethod def _swagger_ui_favicon(): - return f'{CONFIG.get_chat_path()}/api-doc/swagger-ui-dist/favicon-32x32.png' + return f"{CONFIG.get_chat_path()}/api-doc/swagger-ui-dist/favicon-32x32.png" def build_curated_patterns(chat_urlpatterns, doc_names): """按 name 集合从(递归展开后的)chat 路由里挑出 curated 端点,重建为带完整 path 的 URLPattern。""" return [ - URLPattern(pattern=f'{chat_api_prefix}{full_path}', callback=url.callback, - default_args=url.default_args, name=url.name) + URLPattern( + pattern=f"{chat_api_prefix}{full_path}", callback=url.callback, default_args=url.default_args, name=url.name + ) for full_path, url in flatten_url_patterns(chat_urlpatterns) - if doc_names.__contains__(getattr(url, 'name', None)) + if doc_names.__contains__(getattr(url, "name", None)) ] def init_chat_doc(system_urlpatterns, chat_urlpatterns): chat_path = CONFIG.get_chat_path()[1:] - v3_patterns = build_curated_patterns(chat_urlpatterns, ['v3_chat', 'v3_open', 'v3_profile']) - v2_patterns = build_curated_patterns(chat_urlpatterns, ['chat', 'open', 'profile', 'anonymous']) + v3_patterns = build_curated_patterns( + chat_urlpatterns, + ["v3_chat", "v3_open", "v3_profile", "v3_portal_application", "v3_portal_historical_conversation"], + ) + v2_patterns = build_curated_patterns(chat_urlpatterns, ["chat", "open", "profile", "anonymous"]) system_urlpatterns += [ # v3 curated 文档(主路径) - path(f'{chat_path}/api-doc/schema/', - SpectacularAPIView.as_view(patterns=v3_patterns), - name='chat_schema'), # schema的配置文件的路由,下面ui根据它生成 - path(f'{chat_path}/api-doc/', ChatSpectacularSwaggerView.as_view(url_name='chat_schema'), - name='swagger-ui'), # swagger-ui的路由 + path( + f"{chat_path}/api-doc/schema/", SpectacularAPIView.as_view(patterns=v3_patterns), name="chat_schema" + ), # schema的配置文件的路由,下面ui根据它生成 + path( + f"{chat_path}/api-doc/", ChatSpectacularSwaggerView.as_view(url_name="chat_schema"), name="swagger-ui" + ), # swagger-ui的路由 # v2 curated 文档(保留) - path(f'{chat_path}/api-doc/v2/schema/', - SpectacularAPIView.as_view(patterns=v2_patterns), - name='chat_schema_v2'), - path(f'{chat_path}/api-doc/v2/', ChatSpectacularSwaggerView.as_view(url_name='chat_schema_v2'), - name='swagger-ui-v2'), + path( + f"{chat_path}/api-doc/v2/schema/", SpectacularAPIView.as_view(patterns=v2_patterns), name="chat_schema_v2" + ), + path( + f"{chat_path}/api-doc/v2/", + ChatSpectacularSwaggerView.as_view(url_name="chat_schema_v2"), + name="swagger-ui-v2", + ), ] @@ -86,23 +98,40 @@ def encrypt(text): def get_call(application_urlpatterns, patterns, params, func): def run(): - if params['valid'](): - func(*params['get_params'](application_urlpatterns, patterns)) + if params["valid"](): + func(*params["get_params"](application_urlpatterns, patterns)) return run -init_list = [(init_app_doc, {'valid': lambda: CONFIG.get('DOC_PASSWORD') is not None and encrypt( - CONFIG.get('DOC_PASSWORD')) == 'd4fc097197b4b90a122b92cbd5bbe867', - 'get_call': get_call, - 'get_params': lambda application_urlpatterns, patterns: (application_urlpatterns,)}), - (init_chat_doc, {'valid': lambda: CONFIG.get('DOC_PASSWORD') is not None and encrypt( - CONFIG.get('DOC_PASSWORD')) == 'd4fc097197b4b90a122b92cbd5bbe867' or True, 'get_call': get_call, - 'get_params': lambda application_urlpatterns, patterns: ( - application_urlpatterns, patterns)})] +init_list = [ + ( + init_app_doc, + { + "valid": lambda: ( + CONFIG.get("DOC_PASSWORD") is not None + and encrypt(CONFIG.get("DOC_PASSWORD")) == "d4fc097197b4b90a122b92cbd5bbe867" + ), + "get_call": get_call, + "get_params": lambda application_urlpatterns, patterns: (application_urlpatterns,), + }, + ), + ( + init_chat_doc, + { + "valid": lambda: ( + CONFIG.get("DOC_PASSWORD") is not None + and encrypt(CONFIG.get("DOC_PASSWORD")) == "d4fc097197b4b90a122b92cbd5bbe867" + or True + ), + "get_call": get_call, + "get_params": lambda application_urlpatterns, patterns: (application_urlpatterns, patterns), + }, + ), +] def init_doc(system_urlpatterns, chat_patterns): for init, params in init_list: - if params['valid'](): + if params["valid"](): get_call(system_urlpatterns, chat_patterns, params, init)()