diff --git a/backend/artifacts/utils.py b/backend/artifacts/utils.py index 2bcb481e0f19756312e52e622ce3bbde032204c3..c12a1a749991176c60e83b06a721e3f878637bb9 100644 --- a/backend/artifacts/utils.py +++ b/backend/artifacts/utils.py @@ -15,7 +15,11 @@ import os import subprocess import yaml +import operator +from functools import reduce from django.db import connection +from django.db.models import Q, Case, When, Value, IntegerField,FloatField,F +from django.db.models.functions import Length from tasks.models import Task from artifacts.models import MCPServer from artifacts.serializers import PluginItemSerializer @@ -26,6 +30,18 @@ from utils.logger import init_log logger = init_log('run.log') +# 权重配置 +SEARCH_WEIGHTS = { + 'exact_match': 10.0, + 'phrase_match': 5.0, + 'name_field': 3.0, + 'position_bonus': 3.0, + 'length_factor': 2.0, + 'coverage': 1.5 +} + + + def clear_table(table_name): """清空指定数据库表并重置自增主键""" logger.info(f"Start to clear table '{table_name}'") @@ -149,3 +165,141 @@ def check_system_rpm_installed(package_name: str) -> bool: except Exception: return False +def calculate_weighted_relevance(queryset, search_value, weights=None): + """基于权重的相关性算法""" + if not search_value: + return queryset.annotate(relevance_score=Value(0.0, output_field=FloatField())) + + weights = weights or SEARCH_WEIGHTS + search_terms = [term.strip() for term in search_value.lower().split() if term.strip()] + full_search = search_value.lower().strip() + + annotations = {} + annotations.update(_build_basic_factors(full_search)) + annotations.update(_build_position_factor(full_search, search_terms)) + annotations.update(_build_complex_factors(search_terms)) + annotations['relevance_score'] = _build_relevance_score(weights) + + return queryset.annotate(**annotations) + +def _build_basic_factors(full_search): + """构建基础匹配因子""" + return { + 'exact_match_factor': Case( + When(name__iexact=full_search, then=Value(1.0)), + default=Value(0.0), output_field=FloatField() + ), + 'phrase_match_factor': Case( + When(name__icontains=full_search, then=Value(1.0)), + When(description__icontains=full_search, then=Value(0.7)), + default=Value(0.0), output_field=FloatField() + ), + 'length_factor': Case( + When(name__icontains=full_search, + then=Value(1.0) / (Length('name') / len(full_search))), + default=Value(0.0), output_field=FloatField() + ) + } + +def _build_position_factor(full_search, search_terms): + """构建位置匹配因子 """ + position_kwargs = { + f'name_startswith_{i}': Case( + When(name__istartswith=term, then=Value(0.8 / (i + 1))), + default=Value(0.0), output_field=FloatField() + ) for i, term in enumerate(search_terms) + } + + position_factor = Case( + When(name__istartswith=full_search, then=Value(1.0)), + When(name__icontains=full_search, then=Value(0.6)), + **position_kwargs, + default=Value(0.0), output_field=FloatField() + ) + + return {'position_factor': position_factor} + +def _build_complex_factors(search_terms): + """构建复杂匹配因子""" + if not search_terms: + return { + 'word_match_factor': Value(0.0, output_field=FloatField()), + 'coverage_factor': Value(0.0, output_field=FloatField()) + } + + # 构建单词匹配的Case列表 + word_cases = _build_word_cases(search_terms) + + total_score = sum(word_cases) + max_possible_score = len(search_terms) * 1.0 + word_match_factor = total_score / max_possible_score if max_possible_score > 0 else Value(0.0) + + coverage_cases = _build_coverage_cases(search_terms) + coverage_factor = sum(coverage_cases) / len(search_terms) + + return { + 'word_match_factor': word_match_factor, + 'coverage_factor': coverage_factor + } + +def _build_word_cases(search_terms): + """构建单词匹配案例""" + word_cases = [] + for term in search_terms: + case = Case( + When(name__icontains=term, then=Value(1.0)), + When(description__icontains=term, then=Value(0.5)), + default=Value(0.0), output_field=FloatField() + ) + word_cases.append(case) + return word_cases + +def _build_coverage_cases(search_terms): + """构建覆盖率案例""" + coverage_cases = [] + for term in search_terms: + case = Case( + When(Q(name__icontains=term) | Q(description__icontains=term), + then=Value(1.0)), + default=Value(0.0), output_field=FloatField() + ) + coverage_cases.append(case) + return coverage_cases + +def _build_relevance_score(weights): + """构建最终相关性得分""" + return ( + F('exact_match_factor') * weights['exact_match'] + + F('phrase_match_factor') * weights['phrase_match'] + + F('position_factor') * weights['position_bonus'] + + F('word_match_factor') * weights['name_field'] + + F('coverage_factor') * weights['coverage'] + + F('length_factor') * weights['length_factor'] + ) + +def process_search_with_relevance(queryset, search_value, sort='rec'): + """处理多关键词搜索""" + + if search_value: + # 构建搜索条件:任意词匹配名称或描述 + search_terms = [term.strip() for term in search_value.split() if term.strip()] + conditions = [] + + for term in search_terms: + conditions.append(Q(name__icontains=term) | Q(description__icontains=term)) + + # 使用OR连接所有条件 + if conditions: + search_filter = reduce(operator.or_, conditions) + queryset = queryset.filter(search_filter) + + # 计算相关性 + queryset = calculate_weighted_relevance(queryset, search_value) + else: + queryset = queryset.annotate(relevance_score=Value(0.0, output_field=FloatField())) + + # 排序 + if sort == 'new': + return queryset.order_by('-relevance_score', '-updated_at') if search_value else queryset.order_by('-updated_at') + else: + return queryset.order_by('-relevance_score', 'name') if search_value else queryset.order_by('name') \ No newline at end of file diff --git a/backend/artifacts/views.py b/backend/artifacts/views.py index a0bfb4b2392a63e6c96f371e41e01fac36ecbe77..65b912130dde2d00ab6ee3b074d71e9e82f858a1 100644 --- a/backend/artifacts/views.py +++ b/backend/artifacts/views.py @@ -28,7 +28,7 @@ from artifacts.serializers import ( PluginDetailSerializer, ) from artifacts.tasks.install_mcp_task import InstallMCPTask -from artifacts.utils import get_devstore_log +from artifacts.utils import get_devstore_log,process_search_with_relevance from utils.mcp_tools import manage_mcp_config from constants.choices import ArtifactTag from tasks.models import Task @@ -165,29 +165,49 @@ class ArtifactViewSet(viewsets.GenericViewSet): def list(self, request): - """获取插件和MCP服务列表 - """ - logger.info("==== API: [GET] /v1.0/artifacts/ ====") + """获取插件和MCP服务列表(支持搜索与排序)""" + logger.info(f"==== API: [GET] /v1.0/artifacts/ ====") + tag = request.query_params.get('tag') - oedp_queryset = OEDPPlugin.objects.all() - mcp_queryset = MCPServer.objects.all() - oedp_count = oedp_queryset.count() - mcp_count = mcp_queryset.count() + search_value = request.query_params.get('searchValue', '').strip() + sort = request.query_params.get('sort', 'rec') + + # 全量汇总(不随搜索变化,与原 list 保持一致) + oedp_count = OEDPPlugin.objects.count() + mcp_count = MCPServer.objects.count() + + # tag 校验(与原 list 保持一致的错误处理) if tag == ArtifactTag.OEDP: - queryset = oedp_queryset + queryset = OEDPPlugin.objects.all() elif tag == ArtifactTag.MCP: - queryset = mcp_queryset + queryset = MCPServer.objects.all() else: msg = 'The query parameter [tag] is missing, or the value of the query parameter [tag] is invalid.' logger.error(msg) return Response({'is_success': False, 'message': msg}, status=status.HTTP_400_BAD_REQUEST) - queryset = self.paginate_queryset(queryset) + + queryset = process_search_with_relevance(queryset, search_value, sort) + total_count = queryset.count() + queryset = self.paginate_queryset(queryset) serializer = ArtifactSerializer(queryset, many=True) response = self.get_paginated_response(serializer.data) - data = { 'oedp_count': oedp_count, 'mcp_count': mcp_count } - data.update(response.data) - msg = "Get list information successfully." - response.data = { 'is_success': True, 'message': msg, 'data': data } + + data = { + 'oedp_count': oedp_count, + 'mcp_count': mcp_count + } + data.update(response.data) + if search_value: + data['search_keyword'] = search_value + data['search_count'] = total_count + msg = f'Search with keyword "{search_value}", found {total_count} results.' + else: + msg = "Get list information successfully." + response.data = { + 'is_success': True, + 'message': msg, + 'data': data + } logger.debug(msg) return response diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f428c62365b690a43d8cc06a9681396f282e691f..790ecb29c628b23c1222ad824e632730a51ab3c2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,7 +33,7 @@ "@vue/tsconfig": "^0.7.0", "concurrently": "^9.2.0", "cross-env": "^7.0.3", - "electron": "^37.3.1", + "electron": "^37.4.0", "electron-builder": "^26.0.12", "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.8", @@ -4704,9 +4704,9 @@ } }, "node_modules/electron": { - "version": "37.3.1", - "resolved": "https://registry.npmmirror.com/electron/-/electron-37.3.1.tgz", - "integrity": "sha512-7DhktRLqhe6OJh/Bo75bTI0puUYEmIwSzMinocgO63mx3MVjtIn2tYMzLmAleNIlud2htkjpsMG2zT4PiTCloA==", + "version": "37.4.0", + "resolved": "https://registry.npmmirror.com/electron/-/electron-37.4.0.tgz", + "integrity": "sha512-HhsSdWowE5ODOeWNc/323Ug2C52mq/TqNBG+4uMeOA3G2dMXNc/nfyi0RYu1rJEgiaJLEjtHveeZZaYRYFsFCQ==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/frontend/package.json b/frontend/package.json index 0b4d5ba0c152e8af3c7826b0affc519dc0397564..c1076c051acd456ee3685cbeae20b5a9f2e709ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,7 +73,7 @@ "@vue/tsconfig": "^0.7.0", "concurrently": "^9.2.0", "cross-env": "^7.0.3", - "electron": "^37.3.1", + "electron": "^37.4.0", "electron-builder": "^26.0.12", "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.8", diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index df8de14a26ad5d909b378516dd1d8630d3347ce4..a07e4e98a08688f90becb8a647a14e1aa6253bcb 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -60,12 +60,12 @@
{{ count }}
- + @@ -81,8 +81,10 @@ import GridDisplay from '@/views/components/GridDisplay.vue'; import { queryList, Tag, type QueryListResponse } from '@/api/index.ts'; import { eventBus, EVENT_TYPES } from '@/utils/eventBus'; import { updateRouteQuery } from '@/utils/index'; +import { Search } from '@element-plus/icons-vue'; const route = useRoute(); +const showList = ref(true); const router = useRouter(); const {t} = useI18n(); @@ -101,12 +103,13 @@ const mcpCount = ref(0); const oedpCount = ref(0); // 查询首页列表信息 -const itemList = ref(); +const itemList = ref([]); // 分页 - 从URL参数初始化 const currentPage = ref(parseInt(route.query.curPage as string) || 1); const pageSize = ref(parseInt(route.query.pageSize as string) || 10); // 右侧 tab - 从URL参数初始化 const activeSortTab = ref((route.query.sort as string) || 'rec'); + const getList = async () => { try { const res: QueryListResponse = await queryList({ @@ -116,10 +119,17 @@ const getList = async () => { searchValue: searchValue.value, sort: activeSortTab.value as 'recommended' | 'newest', }); + if (res && res.is_success && res.data) { itemList.value = res.data.results; mcpCount.value = res.data.mcp_count; oedpCount.value = res.data.oedp_count; + + // 添加:正确更新count变量 + count.value = searchValue.value && searchValue.value.trim() !== '' + ? res.data.search_count || 0 + : tag.value === 'mcp' ? res.data.mcp_count : res.data.oedp_count; + } else if (res) { console.log(res.message); } @@ -151,7 +161,7 @@ const handleCurrentChange = async (val: number) => { // 仅在 搜索 且 有搜索值 时,更新 searchValue,并查询 const handleSearch = async () => { - searchInput.value.trim(); + searchInput.value = searchInput.value.trim(); if (searchInput.value !== '') { searchValue.value = searchInput.value; currentPage.value = 1; // 搜索时重置到第一页 @@ -162,6 +172,16 @@ const handleSearch = async () => { }); await getList(); } + else { + // 添加:处理清空搜索的情况 + searchValue.value = ''; + currentPage.value = 1; + await updateRouteQuery(router, route, { + searchValue: undefined, // 移除搜索参数 + curPage: 1 + }); + await getList(); + } }; // 添加节流:搜索 diff --git a/frontend/src/views/components/GridDisplay.vue b/frontend/src/views/components/GridDisplay.vue index c23105fa1ab0872181ffe58600ba83a8710387f6..e3f8f212213b24be2f1b2e36cee56750255a44fb 100644 --- a/frontend/src/views/components/GridDisplay.vue +++ b/frontend/src/views/components/GridDisplay.vue @@ -16,7 +16,7 @@
-
+
diff --git a/frontend/src/views/components/McpCli.vue b/frontend/src/views/components/McpCli.vue index 0d0cc9ad83d545e6f54c6c13b87b95aa79bd169b..0d87848f907143165b4dd344c5969d1e7ec67d35 100644 --- a/frontend/src/views/components/McpCli.vue +++ b/frontend/src/views/components/McpCli.vue @@ -44,14 +44,23 @@ const {t} = useI18n(); const props = withDefaults( defineProps<{ - status: 'not yet' | 'in process' | 'success'; + status?: 'not yet' | 'in process' | 'success'; cmdList: string[]; - mcpJson: string; + mcpJson: Record; + // 根据错误信息补充缺失的属性 + keyValue?: string; + downloadStatus?: string; + actionList?: string[]; + getDetail?: Function; }>(), { status: 'not yet', cmdList: () => [], - mcpJson: '', + mcpJson: () => ({}), + keyValue: '', + downloadStatus: 'pending', + actionList: () => [], + getDetail: undefined, } ); diff --git a/frontend/src/views/components/McpQuick.vue b/frontend/src/views/components/McpQuick.vue index ea0c741a902f16fecf82c57b58fd71643ac3925b..0cd5935a3995239c2e90ab511219ddd026584fba 100644 --- a/frontend/src/views/components/McpQuick.vue +++ b/frontend/src/views/components/McpQuick.vue @@ -121,17 +121,34 @@ const props = withDefaults( name: string; installStatus: 'not yet' | 'in process' | 'success'; appList: [] | {'name': string; 'status': 'added' | 'removed';}[]; - mcpJson: string; + mcpJson: Record; addApp: Function; deleteApp: Function; installPackage: Function; uninstallPackage: Function; + // 根据错误信息补充缺失的属性 + keyValue?: string; + cmdList?: string[]; + downloadStatus?: string; + actionList?: string[]; + getDetail?: Function; }>(), { name: 'mcp', installStatus: 'not yet', appList: () => [], - mcpJson: '', + mcpJson: () => ({}), + // 为Function类型添加默认值 + addApp: () => {}, + deleteApp: () => {}, + installPackage: () => {}, + uninstallPackage: () => {}, + // 为额外属性添加默认值 + keyValue: '', + cmdList: () => [], + downloadStatus: 'pending', + actionList: () => [], + getDetail: () => {}, } );