From fa36e6bbabb6bdde889ac8de8450caf449d9f7e7 Mon Sep 17 00:00:00 2001 From: shaohuzhang1 Date: Wed, 13 Mar 2024 16:07:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B5=8C=E5=85=A5?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E9=99=90=E5=88=B6,=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serializers/application_serializers.py | 30 ++ .../serializers/chat_message_serializers.py | 2 + apps/application/template/embed.js | 294 ++++++++++++++++++ apps/application/urls.py | 1 + apps/application/views/application_views.py | 16 +- apps/application/views/chat_views.py | 12 +- apps/common/auth/authenticate.py | 41 ++- apps/common/exception/app_exception.py | 22 ++ apps/common/util/common.py | 27 ++ apps/smartdoc/settings/base.py | 3 +- pyproject.toml | 1 + ui/src/components/ai-chat/index.vue | 10 +- ui/src/request/index.ts | 5 +- .../component/EmbedDialog.vue | 19 +- 14 files changed, 462 insertions(+), 21 deletions(-) create mode 100644 apps/application/template/embed.js diff --git a/apps/application/serializers/application_serializers.py b/apps/application/serializers/application_serializers.py index 171c2a59e..55114a3f9 100644 --- a/apps/application/serializers/application_serializers.py +++ b/apps/application/serializers/application_serializers.py @@ -17,6 +17,8 @@ from django.core import cache from django.core import signing from django.db import transaction, models from django.db.models import QuerySet +from django.http import HttpResponse +from django.template import Template, Context from rest_framework import serializers from application.models import Application, ApplicationDatasetMapping @@ -26,8 +28,10 @@ from common.constants.authentication_type import AuthenticationType from common.db.search import get_dynamics_model, native_search, native_page_search from common.db.sql_execute import select_list from common.exception.app_exception import AppApiException, NotFound404 +from common.util.common import getRestSeconds, set_embed_identity_cookie from common.util.field_message import ErrMessage from common.util.file_util import get_file_content +from common.util.rsa_util import encrypt from dataset.models import DataSet, Document from dataset.serializers.common_serializers import list_paragraph from setting.models import AuthOperate @@ -38,6 +42,7 @@ from smartdoc.conf import PROJECT_DIR from smartdoc.settings import JWT_AUTH token_cache = cache.caches['token_cache'] +chat_cache = cache.caches['chat_cache'] class ModelDatasetAssociation(serializers.Serializer): @@ -104,6 +109,31 @@ class ApplicationSerializer(serializers.Serializer): ModelDatasetAssociation(data={'user_id': user_id, 'model_id': self.data.get('model_id'), 'dataset_id_list': self.data.get('dataset_id_list')}).is_valid() + class Embed(serializers.Serializer): + host = serializers.CharField(required=True, error_messages=ErrMessage.char("主机")) + protocol = serializers.CharField(required=True, error_messages=ErrMessage.char("协议")) + token = serializers.CharField(required=True, error_messages=ErrMessage.char("token")) + + def get_embed(self, request, with_valid=True): + if with_valid: + self.is_valid(raise_exception=True) + index_path = os.path.join(PROJECT_DIR, 'apps', "application", 'template', 'embed.js') + file = open(index_path, "r", encoding='utf-8') + content = file.read() + file.close() + is_auth = 'true' + try: + ApplicationSerializer.Authentication(data={'access_token': self.data.get('token')}).auth() + except Exception as e: + is_auth = 'false' + t = Template(content) + s = t.render( + Context( + {'is_auth': is_auth, 'protocol': 'http', 'host': 'localhost:8000', 'token': '0a8d892c755f1a75'})) + response = HttpResponse(s, status=200, headers={'Content-Type': 'text/javascript'}) + set_embed_identity_cookie(request, response) + return response + class AccessTokenSerializer(serializers.Serializer): application_id = serializers.UUIDField(required=True, error_messages=ErrMessage.boolean("应用id")) diff --git a/apps/application/serializers/chat_message_serializers.py b/apps/application/serializers/chat_message_serializers.py index d8fc7300e..0b042ab47 100644 --- a/apps/application/serializers/chat_message_serializers.py +++ b/apps/application/serializers/chat_message_serializers.py @@ -24,6 +24,7 @@ from application.chat_pipeline.step.reset_problem_step.impl.base_reset_problem_s from application.chat_pipeline.step.search_dataset_step.impl.base_search_dataset_step import BaseSearchDatasetStep from application.models import ChatRecord, Chat, Application, ApplicationDatasetMapping from common.exception.app_exception import AppApiException +from common.util.field_message import ErrMessage from common.util.rsa_util import decrypt from common.util.split_model import flat_map from dataset.models import Paragraph, Document @@ -31,6 +32,7 @@ from setting.models import Model from setting.models_provider.constants.model_provider_constants import ModelProvideConstants chat_cache = caches['model_cache'] +chat_embed_identity_cache = caches['chat_cache'] class ChatInfo: diff --git a/apps/application/template/embed.js b/apps/application/template/embed.js new file mode 100644 index 000000000..69f1ff4d7 --- /dev/null +++ b/apps/application/template/embed.js @@ -0,0 +1,294 @@ +const guideHtml=` +
+
+
+
+
+ + + +
+ +
🌟 遇见问题,不再是障碍!
+

你好,我是你的智能小助手。
+ 点我,开启高效解答模式,让问题变成过去式。

+
+ +
+ +
+` +const chatButtonHtml= +`
+ + + + + + + + + + + + + + + + + + + + + + + +
` + + + +const getChatContainerHtml=(protocol,host,token)=>{ + return `
+ +
+
+ +
+
+ + +
` +} +/** + * 初始化引导 + * @param {*} root + */ +const initGuide=(root)=>{ + root.insertAdjacentHTML("beforeend",guideHtml) + const button=root.querySelector(".button") + const close_icon=root.querySelector('.close') + const close_func=()=>{ + root.removeChild(root.querySelector('.tips')) + root.removeChild(root.querySelector('.mask')) + localStorage.setItem('maxkbMaskTip',true) + } + button.onclick=close_func + close_icon.onclick=close_func +} +const initChat=(root)=>{ + // 添加对话icon + root.insertAdjacentHTML("beforeend",chatButtonHtml) + // 添加对话框 + root.insertAdjacentHTML('beforeend',getChatContainerHtml('{{protocol}}','{{host}}','{{token}}')) + // 按钮元素 + const chat_button=root.querySelector('.chat_button') + // 对话框元素 + const chat_container=root.querySelector('#chat_container') + + const viewport=root.querySelector('.openviewport') + const closeviewport=root.querySelector('.closeviewport') + const close_func=()=>{ + chat_container.style['display']=chat_container.style['display']=='block'?'none':'block' + } + close_icon=chat_container.querySelector('.close') + chat_button.onclick = close_func + close_icon.onclick=close_func + const viewport_func=()=>{ + if(chat_container.classList.contains('enlarge')){ + chat_container.classList.remove("enlarge"); + viewport.classList.remove('viewportnone') + closeviewport.classList.add('viewportnone') + }else{ + chat_container.classList.add("enlarge"); + viewport.classList.add('viewportnone') + closeviewport.classList.remove('viewportnone') + } + } + viewport.onclick=viewport_func + closeviewport.onclick=viewport_func +} +/** + * 第一次进来的引导提示 + */ +function initMaxkb(){ + initMaxkbStyle() + const root=document.createElement('div') + root.id="maxkb" + document.body.appendChild(root) + const maxkbMaskTip=localStorage.getItem('maxkbMaskTip') + if(maxkbMaskTip==null){ + initGuide(root) + } + initChat(root) +} + + +// 初始化全局样式 +function initMaxkbStyle(){ + style=document.createElement('style') + style.type='text/css' + style.innerText= `/* 放大 */ + #maxkb .enlarge { + width: 50%!important; + height: 100%!important; + bottom: 0!important; + right: 0 !important; + } + @media only screen and (max-width: 768px){ + #maxkb .enlarge { + width: 100%!important; + height: 100%!important; + right: 0 !important; + bottom: 0!important; + } + } + + /* 引导 */ + + #maxkb .mask { + position: fixed; + z-index: 999; + background-color: transparent; + height: 100%; + width: 100%; + top: 0; + left: 0; + } + #maxkb .mask .content { + width: 45px; + height: 50px; + box-shadow: 1px 1px 1px 2000px rgba(0,0,0,.6); + border-radius: 50% 0 0 50%; + position: absolute; + right: 0; + bottom: 42px; + z-index: 1000; + } + #maxkb .tips { + position: absolute; + bottom: 30px; + right: 60px; + padding: 22px 24px 24px; + border-radius: 6px; + color: #ffffff; + font-size: 14px; + background: #3370FF; + z-index: 1000; + } + #maxkb .tips .arrow { + position: absolute; + background: #3370FF; + width: 10px; + height: 10px; + pointer-events: none; + transform: rotate(45deg); + box-sizing: border-box; + /* left */ + right: -5px; + bottom: 33px; + border-left-color: transparent; + border-bottom-color: transparent + } + #maxkb .tips .title { + font-size: 20px; + font-weight: 500; + margin-bottom: 8px; + } + #maxkb .tips .button { + text-align: right; + margin-top: 24px; + } + #maxkb .tips .button button { + border-radius: 4px; + background: #FFF; + padding: 3px 12px; + color: #3370FF; + cursor: pointer; + outline: none; + border: none; + } + #maxkb .tips .button button::after{ + border: none; + } + #maxkb .tips .close { + position: absolute; + right: 20px; + top: 20px; + cursor: pointer; + + } + #chat_container { + width: 420px; + height: 600px; + display:none; + } + @media only screen and (max-width: 768px) { + #chat_container { + width: 100%; + height: 70%; + right: 0 !important; + } + } + + #maxkb .chat_button{ + position: fixed; + bottom: 30px; + right: 0; + cursor: pointer; + } + #maxkb #chat_container{ + z-index:10000;position: relative; + border-radius: 8px; + border: 1px solid var(--N300, #DEE0E3); + background: linear-gradient(188deg, rgba(235, 241, 255, 0.20) 39.6%, rgba(231, 249, 255, 0.20) 94.3%), #EFF0F1; + box-shadow: 0px 4px 8px 0px rgba(31, 35, 41, 0.10); + position: fixed;bottom: 20px;right: 45px;overflow: hidden; + } + #maxkb #chat_container .close{ + position: absolute; + top: 15px; + right: 10px; + cursor: pointer; + } + #maxkb #chat_container .openviewport{ + position: absolute; + top: 15px; + right: 50px; + cursor: pointer; + } + #maxkb #chat_container .closeviewport{ + position: absolute; + top: 15px; + right: 50px; + cursor: pointer; + } + #maxkb #chat_container .viewportnone{ + display:none; + } + #maxkb #chat_container #chat{ + height:100%; + width:100%; + border: none; +} + #maxkb #chat_container { + animation: appear .4s ease-in-out; + } + @keyframes appear { + from { + height: 0;; + } + + to { + height: 600px; + } + }` + document.head.appendChild(style) +} + +function embedChatbot() { + if ({{is_auth}}) { + // 初始化maxkb智能小助手 + initMaxkb() + } else console.error('invalid parameter') +} +window.onload = embedChatbot diff --git a/apps/application/urls.py b/apps/application/urls.py index 7b530eefc..9c70def5b 100644 --- a/apps/application/urls.py +++ b/apps/application/urls.py @@ -6,6 +6,7 @@ app_name = "application" urlpatterns = [ path('application', views.Application.as_view(), name="application"), path('application/profile', views.Application.Profile.as_view()), + path('application/embed', views.Application.Embed.as_view()), path('application/authentication', views.Application.Authentication.as_view()), path('application//model', views.Application.Model.as_view()), path('application//hit_test', views.Application.HitTest.as_view()), diff --git a/apps/application/views/application_views.py b/apps/application/views/application_views.py index f1d7595d6..d894f61fd 100644 --- a/apps/application/views/application_views.py +++ b/apps/application/views/application_views.py @@ -6,6 +6,7 @@ @date:2023/10/27 14:56 @desc: """ + from django.http import HttpResponse from drf_yasg.utils import swagger_auto_schema from rest_framework.decorators import action @@ -20,13 +21,24 @@ from common.constants.permission_constants import CompareConstants, PermissionCo from common.exception.app_exception import AppAuthenticationFailed from common.response import result from common.swagger_api.common_api import CommonApi -from common.util.common import query_params_to_single_dict +from common.util.common import query_params_to_single_dict, set_embed_identity_cookie from dataset.serializers.dataset_serializers import DataSetSerializers class Application(APIView): authentication_classes = [TokenAuth] + class Embed(APIView): + @action(methods=["GET"], detail=False) + @swagger_auto_schema(operation_summary="获取嵌入js", + operation_id="获取嵌入js", + tags=["应用"], + manual_parameters=ApplicationApi.ApiKey.get_request_params_api()) + def get(self, request: Request): + return ApplicationSerializer.Embed( + data={'protocol': request.query_params.get('protocol'), 'token': request.query_params.get('token'), + 'host': request.query_params.get('host'), }).get_embed(request) + class Model(APIView): authentication_classes = [TokenAuth] @@ -185,7 +197,7 @@ class Application(APIView): "Access-Control-Allow-Methods": "POST", "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token"} ) - + set_embed_identity_cookie(request, response) return response @action(methods=['POST'], detail=False) diff --git a/apps/application/views/chat_views.py b/apps/application/views/chat_views.py index 6e1efc45c..936ed4b87 100644 --- a/apps/application/views/chat_views.py +++ b/apps/application/views/chat_views.py @@ -18,7 +18,7 @@ from common.auth import TokenAuth, has_permissions from common.constants.permission_constants import Permission, Group, Operate, \ RoleConstants, ViewPermission, CompareConstants from common.response import result -from common.util.common import query_params_to_single_dict +from common.util.common import query_params_to_single_dict, set_embed_identity_cookie class ChatView(APIView): @@ -71,9 +71,13 @@ class ChatView(APIView): dynamic_tag=keywords.get('application_id'))]) ) def post(self, request: Request, chat_id: str): - return ChatMessageSerializer(data={'chat_id': chat_id}).chat(request.data.get('message'), request.data.get( - 're_chat') if 're_chat' in request.data else False, request.data.get( - 'stream') if 'stream' in request.data else True) + response = ChatMessageSerializer(data={'chat_id': chat_id}).chat(request.data.get('message'), + request.data.get( + 're_chat') if 're_chat' in request.data else False, + request.data.get( + 'stream') if 'stream' in request.data else True) + set_embed_identity_cookie(request, response) + return response @action(methods=['GET'], detail=False) @swagger_auto_schema(operation_summary="获取对话列表", diff --git a/apps/common/auth/authenticate.py b/apps/common/auth/authenticate.py index 4d009bdb5..9cc6cac8d 100644 --- a/apps/common/auth/authenticate.py +++ b/apps/common/auth/authenticate.py @@ -6,21 +6,28 @@ @date:2023/9/4 11:16 @desc: 认证类 """ +import datetime +import traceback +from urllib.parse import urlparse from django.core import cache from django.core import signing from django.db.models import QuerySet +from ipware import get_client_ip from rest_framework.authentication import TokenAuthentication from application.models.api_key_model import ApplicationAccessToken, ApplicationApiKey from common.constants.authentication_type import AuthenticationType from common.constants.permission_constants import Auth, get_permission_list_by_role, RoleConstants, Permission, Group, \ Operate -from common.exception.app_exception import AppAuthenticationFailed +from common.exception.app_exception import AppAuthenticationFailed, AppEmbedIdentityFailed, AppChatNumOutOfBoundsFailed +from common.util.common import getRestSeconds +from common.util.rsa_util import decrypt from smartdoc.settings import JWT_AUTH from users.models.user import User, get_user_dynamics_permission token_cache = cache.caches['token_cache'] +chat_cache = cache.caches['chat_cache'] class AnonymousAuthentication(TokenAuthentication): @@ -80,6 +87,35 @@ class TokenAuth(TokenAuthentication): raise AppAuthenticationFailed(1002, "身份验证信息不正确") if not application_access_token.access_token == auth_details.get('access_token'): raise AppAuthenticationFailed(1002, "身份验证信息不正确") + if application_access_token.white_active: + referer = request.META.get('HTTP_REFERER') + if referer is not None: + client_ip = urlparse(referer).hostname + else: + client_ip = get_client_ip(request) + if not application_access_token.white_list.__contains__(client_ip): + raise AppAuthenticationFailed(1002, "身份验证信息不正确") + if 'embed_identity' in request.COOKIES and request.path.__contains__('/api/application/chat_message/'): + embed_identity = request.COOKIES['embed_identity'] + try: + # 如果无法解密 说明embed_identity并非系统颁发 + value = decrypt(embed_identity) + except Exception as e: + raise AppEmbedIdentityFailed(1004, '嵌入cookie不正确') + embed_identity_number = chat_cache.get(value) + if embed_identity_number is not None: + if application_access_token.access_num <= embed_identity_number: + raise AppChatNumOutOfBoundsFailed(1003, '访问次数超过今日访问量') + # 对话次数+1 + try: + if not chat_cache.incr(value): + # 如果修改失败则设置为1 + chat_cache.set(value, 1, + timeout=getRestSeconds()) + except Exception as e: + # 如果修改失败则设置为1 证明 key不存在 + chat_cache.add(value, 1, + timeout=getRestSeconds()) return application_access_token.application.user, Auth( role_list=[RoleConstants.APPLICATION_ACCESS_TOKEN], permission_list=[ @@ -94,4 +130,7 @@ class TokenAuth(TokenAuthentication): raise AppAuthenticationFailed(1002, "身份验证信息不正确!非法用户") except Exception as e: + traceback.format_exc() + if isinstance(e, AppEmbedIdentityFailed) or isinstance(e, AppChatNumOutOfBoundsFailed): + raise e raise AppAuthenticationFailed(1002, "身份验证信息不正确!非法用户") diff --git a/apps/common/exception/app_exception.py b/apps/common/exception/app_exception.py index ffa9e91e0..3646efb0c 100644 --- a/apps/common/exception/app_exception.py +++ b/apps/common/exception/app_exception.py @@ -51,3 +51,25 @@ class AppUnauthorizedFailed(AppApiException): def __init__(self, code, message): self.code = code self.message = message + + +class AppEmbedIdentityFailed(AppApiException): + """ + 嵌入cookie异常 + """ + status_code = 460 + + def __init__(self, code, message): + self.code = code + self.message = message + + +class AppChatNumOutOfBoundsFailed(AppApiException): + """ + 访问次数超过今日访问量 + """ + status_code = 461 + + def __init__(self, code, message): + self.code = code + self.message = message diff --git a/apps/common/util/common.py b/apps/common/util/common.py index 52d90ec85..d6492e353 100644 --- a/apps/common/util/common.py +++ b/apps/common/util/common.py @@ -6,9 +6,36 @@ @date:2023/10/16 16:42 @desc: """ +import datetime import importlib +import uuid from functools import reduce from typing import Dict, List +from django.core import cache + +from .rsa_util import encrypt + +chat_cache = cache.caches['chat_cache'] + + +def set_embed_identity_cookie(request, response): + if 'embed_identity' in request.COOKIES: + embed_identity = request.COOKIES['embed_identity'] + else: + value = str(uuid.uuid1()) + embed_identity = encrypt(value) + chat_cache.set(value, 0, timeout=getRestSeconds()) + response.set_cookie("embed_identity", embed_identity, max_age=3600 * 24 * 100, samesite='None', + secure=True) + return response + + +def getRestSeconds(): + now = datetime.datetime.now() + today_begin = datetime.datetime(now.year, now.month, now.day, 0, 0, 0) + tomorrow_begin = today_begin + datetime.timedelta(days=1) + rest_seconds = (tomorrow_begin - now).seconds + return rest_seconds def sub_array(array: List, item_num=10): diff --git a/apps/smartdoc/settings/base.py b/apps/smartdoc/settings/base.py index 38f626b6b..53f723a56 100644 --- a/apps/smartdoc/settings/base.py +++ b/apps/smartdoc/settings/base.py @@ -103,8 +103,7 @@ CACHES = { 'LOCATION': os.path.join(PROJECT_DIR, 'data', 'cache', "token_cache") # 文件夹路径 }, "chat_cache": { - 'BACKEND': 'common.cache.file_cache.FileCache', - 'LOCATION': os.path.join(PROJECT_DIR, 'data', 'cache', "chat_cache") # 文件夹路径 + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', } } diff --git a/pyproject.toml b/pyproject.toml index df9fdd696..ca72630cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ pycryptodome = "^3.19.0" beautifulsoup4 = "^4.12.2" html2text = "^2024.2.26" langchain-openai = "^0.0.8" +django-ipware = "^6.0.4" [build-system] requires = ["poetry-core"] diff --git a/ui/src/components/ai-chat/index.vue b/ui/src/components/ai-chat/index.vue index 5ea1d7787..e434a6b61 100644 --- a/ui/src/components/ai-chat/index.vue +++ b/ui/src/components/ai-chat/index.vue @@ -399,10 +399,10 @@ const getWrite = (chat: any, reader: any, stream: boolean) => { } return stream ? write_stream : write_json } -const errorWrite = (chat: any) => { +const errorWrite = (chat: any, message?: string) => { ChatManagement.addChatRecord(chat, 50, loading) ChatManagement.write(chat.id) - ChatManagement.append(chat.id, '抱歉,当前正在维护,无法提供服务,请稍后再试!') + ChatManagement.append(chat.id, message || '抱歉,当前正在维护,无法提供服务,请稍后再试!') ChatManagement.close(chat.id) } function chatMessage(chat?: any, problem?: string) { @@ -444,6 +444,10 @@ function chatMessage(chat?: any, problem?: string) { .catch((err) => { errorWrite(chat) }) + } else if (response.status === 460) { + return Promise.reject('无法识别用户身份') + } else if (response.status === 461) { + return Promise.reject('抱歉,您的提问已达到最大限制,请明天再来吧!') } else { nextTick(() => { // 将滚动条滚动到最下面 @@ -468,7 +472,7 @@ function chatMessage(chat?: any, problem?: string) { ChatManagement.close(chat.id) }) .catch((e: any) => { - MsgError(e) + errorWrite(chat, e + '') }) } } diff --git a/ui/src/request/index.ts b/ui/src/request/index.ts index 1f994ec7f..d89183460 100644 --- a/ui/src/request/index.ts +++ b/ui/src/request/index.ts @@ -58,7 +58,10 @@ instance.interceptors.response.use( } } if (err.response?.status === 401) { - if (!err.response.config.url.includes('chat/open')) { + if ( + !err.response.config.url.includes('chat/open') && + !err.response.config.url.includes('application/profile') + ) { router.push({ name: 'login' }) } } diff --git a/ui/src/views/applicaiton-overview/component/EmbedDialog.vue b/ui/src/views/applicaiton-overview/component/EmbedDialog.vue index 54f4c8610..942b5c62b 100644 --- a/ui/src/views/applicaiton-overview/component/EmbedDialog.vue +++ b/ui/src/views/applicaiton-overview/component/EmbedDialog.vue @@ -29,7 +29,7 @@
-
+
{{ source2 }}
@@ -68,13 +68,13 @@ frameborder="0" allow="microphone"> ` - source2.value = `