From 37ef7322cd32cb50b4e282eb833510f4a49ca2a7 Mon Sep 17 00:00:00 2001 From: CaptainB Date: Thu, 25 Dec 2025 18:16:55 +0800 Subject: [PATCH] feat: add template store functionality and appstore integration --- apps/application/serializers/application.py | 219 ++++++++++-- apps/application/urls.py | 2 +- apps/application/views/application.py | 18 + ui/src/api/type/application.ts | 1 + ui/src/views/application-workflow/index.vue | 20 ++ .../component/CreateApplicationDialog.vue | 10 +- ui/src/views/application/index.vue | 15 + .../template-store/InternalDescDrawer.vue | 86 +++++ .../template-store/TemplateCard.vue | 83 +++++ .../template-store/TemplateStoreDialog.vue | 315 ++++++++++++++++++ 10 files changed, 741 insertions(+), 28 deletions(-) create mode 100644 ui/src/views/application/template-store/InternalDescDrawer.vue create mode 100644 ui/src/views/application/template-store/TemplateCard.vue create mode 100644 ui/src/views/application/template-store/TemplateStoreDialog.vue diff --git a/apps/application/serializers/application.py b/apps/application/serializers/application.py index 659d8e626..0a9876506 100644 --- a/apps/application/serializers/application.py +++ b/apps/application/serializers/application.py @@ -12,9 +12,12 @@ import json import os import pickle import re +import tempfile +import zipfile from functools import reduce from typing import Dict, List +import requests import uuid_utils.compat as uuid from django.core import validators from django.db import models, transaction @@ -37,7 +40,9 @@ from common.database_model_manage.database_model_manage import DatabaseModelMana from common.db.search import native_search, native_page_search from common.exception.app_exception import AppApiException from common.field.common import UploadedFileField -from common.utils.common import get_file_content, restricted_loads, generate_uuid, _remove_empty_lines +from common.utils.common import get_file_content, restricted_loads, generate_uuid, _remove_empty_lines, \ + bytes_to_uploaded_file +from common.utils.logger import maxkb_logger from knowledge.models import Knowledge, KnowledgeScope from knowledge.serializers.knowledge import KnowledgeSerializer, KnowledgeModelSerializer from maxkb.conf import PROJECT_DIR @@ -182,28 +187,29 @@ class ApplicationCreateSerializer(serializers.Serializer): node.get('properties')['node_data']['desc'] = application.get('desc') node.get('properties')['node_data']['name'] = application.get('name') node.get('properties')['node_data']['prologue'] = application.get('prologue') - return Application(id=uuid.uuid7(), - name=application.get('name'), - desc=application.get('desc'), - workspace_id=workspace_id, - folder_id=application.get('folder_id', application.get('workspace_id')), - prologue="", - dialogue_number=0, - user_id=user_id, model_id=None, - knowledge_setting={}, - model_setting={}, - problem_optimization=False, - type=ApplicationTypeChoices.WORK_FLOW, - stt_model_enable=application.get('stt_model_enable', False), - stt_model_id=application.get('stt_model', None), - tts_model_id=application.get('tts_model', None), - tts_model_enable=application.get('tts_model_enable', False), - tts_model_params_setting=application.get('tts_model_params_setting', {}), - tts_type=application.get('tts_type', 'BROWSER'), - file_upload_enable=application.get('file_upload_enable', False), - file_upload_setting=application.get('file_upload_setting', {}), - work_flow=default_workflow - ) + return Application( + id=uuid.uuid7(), + name=application.get('name'), + desc=application.get('desc'), + workspace_id=workspace_id, + folder_id=application.get('folder_id', application.get('workspace_id')), + prologue="", + dialogue_number=0, + user_id=user_id, model_id=None, + knowledge_setting={}, + model_setting={}, + problem_optimization=False, + type=ApplicationTypeChoices.WORK_FLOW, + stt_model_enable=application.get('stt_model_enable', False), + stt_model_id=application.get('stt_model', None), + tts_model_id=application.get('tts_model', None), + tts_model_enable=application.get('tts_model_enable', False), + tts_model_params_setting=application.get('tts_model_params_setting', {}), + tts_type=application.get('tts_type', 'BROWSER'), + file_upload_enable=application.get('file_upload_enable', False), + file_upload_setting=application.get('file_upload_setting', {}), + work_flow=default_workflow + ) class SimplateRequest(serializers.Serializer): name = serializers.CharField(required=True, max_length=64, min_length=1, @@ -459,8 +465,14 @@ class ApplicationSerializer(serializers.Serializer): workspace_id = serializers.CharField(required=True, label=_('workspace id')) user_id = serializers.UUIDField(required=True, label=_("User ID")) + @transaction.atomic def insert(self, instance: Dict): + work_flow_template = instance.get('work_flow_template') application_type = instance.get('type') + + # 处理工作流模板安装逻辑 + if work_flow_template: + return self.insert_template_workflow(instance) if 'WORK_FLOW' == application_type: r = self.insert_workflow(instance) else: @@ -472,6 +484,35 @@ class ApplicationSerializer(serializers.Serializer): }).auth_resource(str(r.get('id'))) return r + def insert_template_workflow(self, instance: Dict): + self.is_valid(raise_exception=True) + work_flow_template = instance.get('work_flow_template') + download_url = work_flow_template.get('downloadUrl') + # 查找匹配的版本名称 + res = requests.get(download_url, timeout=5) + app = ApplicationSerializer( + data={'user_id': self.data.get('user_id'), 'workspace_id': self.data.get('workspace_id')} + ).import_({ + 'file': bytes_to_uploaded_file(res.content, 'file.mk'), + 'folder_id': instance.get('folder_id', instance.get('workspace_id')) + }, True) + work_flow = app.get('work_flow') + for node in work_flow.get('nodes', []): + if node.get('type') == 'base-node': + node_data = node.get('properties').get('node_data') + node_data['name'] = instance.get('name') + node_data['desc'] = instance.get('desc') + QuerySet(Application).filter(id=app.get('id')).update( + name=instance.get('name'), + desc=instance.get('desc'), + work_flow=work_flow + ) + try: + requests.get(work_flow_template.get('downloadCallbackUrl'), timeout=5) + except Exception as e: + maxkb_logger.error(f"callback appstore tool download error: {e}") + return app + def insert_workflow(self, instance: Dict): self.is_valid(raise_exception=True) user_id = self.data.get('user_id') @@ -565,7 +606,8 @@ class ApplicationSerializer(serializers.Serializer): 'user_id': self.data.get('user_id'), 'auth_target_type': AuthTargetType.TOOL.value }).auth_resource_batch([t.id for t in tool_model_list]) - return True + + return ApplicationCreateSerializer.ApplicationResponse(application_model).data @staticmethod def to_tool(tool, workspace_id, user_id): @@ -620,6 +662,55 @@ class ApplicationSerializer(serializers.Serializer): file_upload_setting=application.get('file_upload_setting'), ) + class StoreApplication(serializers.Serializer): + user_id = serializers.UUIDField(required=True, label=_("User ID")) + name = serializers.CharField(required=False, label=_("tool name"), allow_null=True, allow_blank=True) + + def get_appstore_templates(self): + self.is_valid(raise_exception=True) + # 下载zip文件 + try: + res = requests.get('https://apps-assets.fit2cloud.com/stable/maxkb.json.zip', timeout=5) + res.raise_for_status() + # 创建临时文件保存zip + with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_zip: + temp_zip.write(res.content) + temp_zip_path = temp_zip.name + + try: + # 解压zip文件 + with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref: + # 获取zip中的第一个文件(假设只有一个json文件) + json_filename = zip_ref.namelist()[0] + json_content = zip_ref.read(json_filename) + + # 将json转换为字典 + tool_store = json.loads(json_content.decode('utf-8')) + tag_dict = {tag['name']: tag['key'] for tag in tool_store['additionalProperties']['tags']} + filter_apps = [] + for tool in tool_store['apps']: + if self.data.get('name', '') != '': + if self.data.get('name').lower() not in tool.get('name', '').lower(): + continue + if not tool['downloadUrl'].endswith('.mk'): + continue + versions = tool.get('versions', []) + tool['label'] = tag_dict[tool.get('tags')[0]] if tool.get('tags') else '' + tool['version'] = next( + (version.get('name') for version in versions if + version.get('downloadUrl') == tool['downloadUrl']), + ) + filter_apps.append(tool) + + tool_store['apps'] = filter_apps + return tool_store + finally: + # 清理临时文件 + os.unlink(temp_zip_path) + except Exception as e: + maxkb_logger.error(f"fetch appstore tools error: {e}") + return {'apps': [], 'additionalProperties': {'tags': []}} + class TextToSpeechRequest(serializers.Serializer): text = serializers.CharField(required=True, label=_('Text')) @@ -772,7 +863,7 @@ class ApplicationOperateSerializer(serializers.Serializer): work_flow_version.save() access_token = hashlib.md5( str(uuid.uuid7()).encode()).hexdigest()[ - 8:24] + 8:24] application_access_token = QuerySet(ApplicationAccessToken).filter( application_id=application.id).first() if application_access_token is None: @@ -828,6 +919,10 @@ class ApplicationOperateSerializer(serializers.Serializer): application_id = self.data.get("application_id") application = QuerySet(Application).get(id=application_id) + # 处理工作流模板逻辑 + if 'work_flow_template' in instance: + return self.update_template_workflow(instance, application) + if instance.get('model_id') is None or len(instance.get('model_id')) == 0: application.model_id = None else: @@ -880,6 +975,80 @@ class ApplicationOperateSerializer(serializers.Serializer): self.save_application_knowledge_mapping(application_knowledge_id_list, knowledge_id_list, application_id) return self.one(with_valid=False) + def update_template_workflow(self, instance: Dict, app: Application): + self.is_valid(raise_exception=True) + work_flow_template = instance.get('work_flow_template') + download_url = work_flow_template.get('downloadUrl') + # 查找匹配的版本名称 + res = requests.get(download_url, timeout=5) + try: + mk_instance = restricted_loads(res.content) + except Exception as e: + raise AppApiException(1001, _("Unsupported file format")) + application = mk_instance.application + tool_list = mk_instance.get_tool_list() + update_tool_map = {} + if len(tool_list) > 0: + tool_id_list = reduce(lambda x, y: [*x, *y], + [[tool.get('id'), generate_uuid((tool.get('id') + app.workspace_id or ''))] + for tool + in + tool_list], []) + # 存在的工具列表 + exits_tool_id_list = [str(tool.id) for tool in + QuerySet(Tool).filter(id__in=tool_id_list, workspace_id=app.workspace_id)] + # 需要更新的工具集合 + update_tool_map = {tool.get('id'): generate_uuid((tool.get('id') + app.workspace_id or '')) for tool + in + tool_list if + not exits_tool_id_list.__contains__( + tool.get('id'))} + + tool_list = [{**tool, 'id': update_tool_map.get(tool.get('id'))} for tool in tool_list if + not exits_tool_id_list.__contains__( + tool.get('id')) and not exits_tool_id_list.__contains__( + generate_uuid((tool.get('id') + app.workspace_id or '')))] + + tool_model_list = [self.to_tool(f, app.workspace_id, self.data.get('user_id')) for f in tool_list] + work_flow = application.get('work_flow') + for node in work_flow.get('nodes', []): + hand_node(node, update_tool_map) + if node.get('type') == 'loop-node': + for n in node.get('properties', {}).get('node_data', {}).get('loop_body', {}).get('nodes', []): + hand_node(n, update_tool_map) + app.work_flow = work_flow + app.save() + + if len(tool_model_list) > 0: + QuerySet(Tool).bulk_create(tool_model_list) + UserResourcePermissionSerializer(data={ + 'workspace_id': app.workspace_id, + 'user_id': self.data.get('user_id'), + 'auth_target_type': AuthTargetType.TOOL.value + }).auth_resource_batch([t.id for t in tool_model_list]) + try: + requests.get(work_flow_template.get('downloadCallbackUrl'), timeout=5) + except Exception as e: + maxkb_logger.error(f"callback appstore tool download error: {e}") + + return self.one(with_valid=False) + + @staticmethod + def to_tool(tool, workspace_id, user_id): + return Tool( + id=tool.get('id'), + user_id=user_id, + name=tool.get('name'), + code=tool.get('code'), + template_id=tool.get('template_id'), + input_field_list=tool.get('input_field_list'), + init_field_list=tool.get('init_field_list'), + is_active=False if len((tool.get('init_field_list') or [])) > 0 else tool.get('is_active'), + scope=ToolScope.WORKSPACE, + folder_id=workspace_id, + workspace_id=workspace_id + ) + def one(self, with_valid=True): if with_valid: self.is_valid() diff --git a/apps/application/urls.py b/apps/application/urls.py index 34ded9fe0..d5f835aaf 100644 --- a/apps/application/urls.py +++ b/apps/application/urls.py @@ -5,7 +5,7 @@ from . import views app_name = 'application' # @formatter:off urlpatterns = [ - + path('workspace/store/application_template', views.ApplicationAPI.StoreApplication.as_view()), path('workspace//application', views.ApplicationAPI.as_view(), name='application'), path('workspace//application/folder//import', views.ApplicationAPI.Import.as_view()), path('workspace//application//', views.ApplicationAPI.Page.as_view(), name='application_page'), diff --git a/apps/application/views/application.py b/apps/application/views/application.py index 518b74c34..13cc6fe73 100644 --- a/apps/application/views/application.py +++ b/apps/application/views/application.py @@ -23,6 +23,7 @@ from common.auth import TokenAuth from common.auth.authentication import has_permissions, get_is_permissions from common.constants.permission_constants import PermissionConstants, RoleConstants, ViewPermission, CompareConstants from common.log.log import log +from tools.api.tool import GetInternalToolAPI def get_application_operation_object(application_id): @@ -251,6 +252,23 @@ class ApplicationAPI(APIView): data={'application_id': application_id, 'user_id': request.user.id, 'workspace_id': workspace_id, }).publish(request.data)) + class StoreApplication(APIView): + authentication_classes = [TokenAuth] + + @extend_schema( + methods=['GET'], + description=_("Get Appstore apps"), + summary=_("Get Appstore apps"), + operation_id=_("Get Appstore apps"), # type: ignore + responses=GetInternalToolAPI.get_response(), + tags=[_("Application")] # type: ignore + ) + def get(self, request: Request): + return result.success(ApplicationSerializer.StoreApplication(data={ + 'user_id': request.user.id, + 'name': request.query_params.get('name', ''), + }).get_appstore_templates()) + class McpServers(APIView): authentication_classes = [TokenAuth] diff --git a/ui/src/api/type/application.ts b/ui/src/api/type/application.ts index 3fe18567e..49b7ec912 100644 --- a/ui/src/api/type/application.ts +++ b/ui/src/api/type/application.ts @@ -37,6 +37,7 @@ interface ApplicationFormType { application_enable?: boolean application_ids?: string[] mcp_output_enable?: boolean + work_flow_template?: any } interface Chunk { diff --git a/ui/src/views/application-workflow/index.vue b/ui/src/views/application-workflow/index.vue index e22d81b61..f0a3ea64c 100644 --- a/ui/src/views/application-workflow/index.vue +++ b/ui/src/views/application-workflow/index.vue @@ -26,6 +26,14 @@
+ + + {{ $t('workflow.setting.templateCenter') }} + {{ $t('workflow.setting.addComponent') }} @@ -137,6 +145,12 @@ v-click-outside="clickoutsideHistory" @refreshVersion="refreshVersion" /> +
+ diff --git a/ui/src/views/application/template-store/TemplateCard.vue b/ui/src/views/application/template-store/TemplateCard.vue new file mode 100644 index 000000000..16c2c1f9f --- /dev/null +++ b/ui/src/views/application/template-store/TemplateCard.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/ui/src/views/application/template-store/TemplateStoreDialog.vue b/ui/src/views/application/template-store/TemplateStoreDialog.vue new file mode 100644 index 000000000..d3e5cc6ce --- /dev/null +++ b/ui/src/views/application/template-store/TemplateStoreDialog.vue @@ -0,0 +1,315 @@ + + + +