From 11ba82d0244641308a6856abb09582a227e16f9f Mon Sep 17 00:00:00 2001 From: 2829798842 <2829798842@qq.com> Date: Thu, 20 Nov 2025 02:13:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0webui=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=8F=92=E4=BB=B6=E7=9A=84=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin_system/core/dependency_manager.py | 368 +++++++++++++++++++ src/plugin_system/core/plugin_manager.py | 111 +++++- src/webui/plugin_routes.py | 354 +++++++++++------- 3 files changed, 701 insertions(+), 132 deletions(-) create mode 100644 src/plugin_system/core/dependency_manager.py diff --git a/src/plugin_system/core/dependency_manager.py b/src/plugin_system/core/dependency_manager.py new file mode 100644 index 00000000..fc69b37d --- /dev/null +++ b/src/plugin_system/core/dependency_manager.py @@ -0,0 +1,368 @@ +import importlib.metadata +import sys +import asyncio +import shutil +import time +import os +import urllib.parse +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass +import aiohttp +from packaging.specifiers import SpecifierSet +from packaging.utils import canonicalize_name + +from src.common.logger import get_logger +from src.plugin_system.base.component_types import PythonDependency + +logger = get_logger("dependency_manager") + +@dataclass +class DependencyInfo: + """依赖信息 + + Attributes: + name (str): 依赖包名称 + required_version (str): 要求的版本范围 + installed_version (Optional[str]): 已安装的版本 + is_satisfied (bool): 是否满足依赖要求 + install_name (str): 用于pip安装的名称 + """ + name: str + required_version: str + installed_version: Optional[str] = None + is_satisfied: bool = False + install_name: str = "" # 用于pip安装的名称 + +class MirrorManager: + """PyPI镜像源管理器""" + + MIRRORS = { + "aliyun": "https://mirrors.aliyun.com/pypi/simple", + "tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple", + "tencent": "https://mirrors.cloud.tencent.com/pypi/simple", + "huawei": "https://repo.huaweicloud.com/repository/pypi/simple", + "official": "https://pypi.org/simple" + } + + def __init__(self): + self._fastest_mirror: Optional[str] = None + + async def get_fastest_mirror(self) -> str: + """获取最快的镜像源 + + 如果已经测试过,直接返回缓存的结果。 + 否则并发测试所有镜像源的响应速度。 + """ + if self._fastest_mirror: + return self._fastest_mirror + + logger.info("正在测试PyPI镜像源速度...") + + async def test_mirror(url: str) -> Tuple[str, float]: + try: + start = time.time() + # 禁用 SSL 验证以避免证书问题干扰测速 + # trust_env=True 让 aiohttp 读取系统代理设置 (HTTP_PROXY/HTTPS_PROXY),解决 pip 能连但测速连不上的问题 + async with aiohttp.ClientSession( + connector=aiohttp.TCPConnector(ssl=False), + trust_env=True + ) as session: + async with session.head(url, timeout=3, allow_redirects=True) as response: + if response.status == 200: + latency = time.time() - start + logger.debug(f"镜像源 {url} 测速成功,延迟: {latency*1000:.2f}ms") + return url, latency + logger.debug(f"镜像源 {url} 测速失败,状态码: {response.status}") + except Exception as e: # pylint: disable=broad-except + logger.debug(f"镜像源 {url} 连接异常: {repr(e)}") + pass + return url, float('inf') + + tasks = [test_mirror(url) for _, url in self.MIRRORS.items()] + results = await asyncio.gather(*tasks) + + # 过滤掉超时的inf,按时间排序 + valid_results = sorted([r for r in results if r[1] != float('inf')], key=lambda x: x[1]) + + if valid_results: + best_url, latency = valid_results[0] + logger.info(f"选用最快镜像源: {best_url} (延迟: {latency*1000:.2f}ms)") + self._fastest_mirror = best_url + return best_url + + # 默认回退到阿里云 + logger.warning("所有镜像源测速失败,默认使用阿里云源") + return self.MIRRORS["aliyun"] + +class PluginDependencyManager: + """插件依赖管理器 + + 负责管理插件的Python依赖,包括扫描已安装包、检查依赖状态以及自动安装缺失依赖。 + """ + + def __init__(self): + self._installed_packages: Dict[str, str] = {} + self.mirror_manager = MirrorManager() + self.uv_path = shutil.which("uv") + if self.uv_path: + logger.info(f"检测到 uv 工具: {self.uv_path}") + else: + logger.info("未检测到 uv 工具,将使用 pip") + self.scan_installed_packages() + + def scan_installed_packages(self) -> Dict[str, str]: + """扫描已安装的所有Python包。 + + 使用 importlib.metadata.distributions() 获取所有已安装的包, + 并将包名规范化以便后续匹配。 + + Returns: + Dict[str, str]: 包含 {规范化包名: 版本号} 的字典。 + """ + self._installed_packages = {} + try: + for dist in importlib.metadata.distributions(): + # 使用 packaging.utils.canonicalize_name 规范化包名 + name = canonicalize_name(dist.metadata['Name']) + version = dist.version + self._installed_packages[name] = version + except Exception as e: # pylint: disable=broad-except + logger.error(f"扫描已安装包失败: {e}") + return self._installed_packages + + def parse_plugin_dependencies( + self, + python_dependencies: List[PythonDependency] + ) -> List[DependencyInfo]: + """解析插件配置中的依赖信息。 + + Args: + python_dependencies: 插件定义的Python依赖列表。 + + Returns: + List[DependencyInfo]: 解析后的依赖详细信息列表。 + """ + dependencies = [] + for dep in python_dependencies: + # 使用 install_name 进行查找,因为它是 PyPI 包名 + # 并使用 canonicalize_name 规范化 + target_name = dep.install_name or dep.package_name + pkg_name_canonical = canonicalize_name(target_name) + + installed_version = self._installed_packages.get(pkg_name_canonical) + + # 构建版本要求字符串 + specifier_str = dep.version if dep.version else "" + + is_satisfied = False + if installed_version: + if not specifier_str: + is_satisfied = True + else: + try: + spec = SpecifierSet(specifier_str) + is_satisfied = spec.contains(installed_version) + except Exception as e: # pylint: disable=broad-except + logger.warning(f"版本说明符解析失败 {dep.package_name} {specifier_str}: {e}") + # 如果解析失败,保守起见认为不满足,或者根据策略处理 + is_satisfied = False + + dependencies.append(DependencyInfo( + name=dep.package_name, + required_version=specifier_str, + installed_version=installed_version, + is_satisfied=is_satisfied, + install_name=target_name + )) + return dependencies + + def check_dependencies( + self, + python_dependencies: List[PythonDependency] + ) -> Tuple[List[DependencyInfo], List[DependencyInfo]]: + """检查插件依赖是否满足。 + + Args: + python_dependencies: 插件定义的Python依赖列表。 + + Returns: + Tuple[List[DependencyInfo], List[DependencyInfo]]: + 返回一个元组 (satisfied, unsatisfied), + 分别包含满足和不满足的依赖信息列表。 + """ + dependencies = self.parse_plugin_dependencies(python_dependencies) + satisfied = [] + unsatisfied = [] + + for dep in dependencies: + if dep.is_satisfied: + satisfied.append(dep) + else: + unsatisfied.append(dep) + + return satisfied, unsatisfied + + async def _get_install_command(self, args: List[str], upgrade: bool = False) -> List[str]: + """构建安装命令,自动选择 uv 或 pip,并添加镜像源""" + mirror_url = await self.mirror_manager.get_fastest_mirror() + + cmd = [] + if self.uv_path: + # uv pip install ... + cmd = [self.uv_path, "pip", "install"] + else: + # python -m pip install ... + cmd = [sys.executable, "-m", "pip", "install"] + # 添加 pip 的安全/静默参数 + cmd.extend(["--disable-pip-version-check", "--no-input"]) + + if upgrade: + cmd.append("--upgrade") + + cmd.extend(["-i", mirror_url]) + + # 自动添加 trusted-host 参数,解决部分镜像源 SSL 问题 + if mirror_url: + try: + host = urllib.parse.urlparse(mirror_url).hostname + if host: + cmd.extend(["--trusted-host", host]) + except Exception: # pylint: disable=broad-except + pass + + cmd.extend(args) + return cmd + + def _parse_pip_error(self, output: str) -> str: + """解析并简化 pip 错误信息""" + patterns = { + "No matching distribution": "包不存在或版本不可用", + "Could not find a version": "找不到指定版本", + "SSL: CERTIFICATE_VERIFY_FAILED": "SSL 验证失败,尝试检查网络或镜像源", + "403": "访问被拒绝(镜像源可能需要认证)", + "404": "资源不存在", + "Connection refused": "连接被拒绝,检查镜像源地址", + "Network is unreachable": "网络不可达", + "Permission denied": "权限不足,请尝试使用管理员权限运行", + } + for k, v in patterns.items(): + if k in output: + return f"{v} (原始错误: {k})" + + # 如果没有匹配到已知模式,返回截断的错误信息 + return output[:300] + "..." if len(output) > 300 else output + + async def install_from_file(self, file_path: str) -> bool: + """从 requirements.txt 或 pyproject.toml 安装依赖 + + Args: + file_path: 依赖文件路径 + + Returns: + bool: 安装是否成功 + """ + if not file_path: + return False + + logger.info(f"正在从文件安装依赖: {file_path}") + + # 构建参数 + args = ["-r", file_path] + + # 如果是 pyproject.toml 且使用 pip,可能需要不同的处理 + # 但 uv pip install -r pyproject.toml 是支持的 + # 对于 pyproject.toml,通常是 pip install . + # 但这里我们假设用户希望安装依赖,而不是安装包本身 + # 如果是 pyproject.toml,尝试直接作为 requirements 传入 + + cmd = await self._get_install_command(args, upgrade=True) + + return await self._run_install_command(cmd) + + async def install_auto_from_directory(self, plugin_dir: str) -> bool: + """自动检测并安装插件目录下的依赖文件 + 优先检查 pyproject.toml,其次检查 requirements.txt + + Args: + plugin_dir: 插件目录路径 + + Returns: + bool: 如果找到文件且安装成功返回 True,未找到文件返回 True (视为成功),安装失败返回 False + """ + + + dependency_file = None + pyproject_path = os.path.join(plugin_dir, "pyproject.toml") + requirements_path = os.path.join(plugin_dir, "requirements.txt") + + if os.path.exists(pyproject_path): + dependency_file = pyproject_path + elif os.path.exists(requirements_path): + dependency_file = requirements_path + + if not dependency_file: + return True + + logger.info(f"在 {plugin_dir} 检测到依赖文件: {dependency_file}") + return await self.install_from_file(dependency_file) + + async def install_dependencies( + self, + dependencies: List[DependencyInfo], + *, + upgrade: bool = False + ) -> bool: + """安装缺失或版本不匹配的依赖。 + + Args: + dependencies: 需要安装的依赖信息列表。 + upgrade: 是否使用 --upgrade 参数升级包。 + + Returns: + bool: 安装是否成功。 + """ + if not dependencies: + return True + + packages_to_install = [] + for dep in dependencies: + pkg_str = dep.install_name + if dep.required_version: + if any(op in dep.required_version for op in ['=', '>', '<', '~', '!']): + pkg_str += dep.required_version + else: + pkg_str += f"=={dep.required_version}" + packages_to_install.append(pkg_str) + + cmd = await self._get_install_command(packages_to_install, upgrade=upgrade) + + logger.info(f"正在自动安装依赖: {' '.join(packages_to_install)}") + return await self._run_install_command(cmd) + + async def _run_install_command(self, cmd: List[str]) -> bool: + """执行安装命令""" + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + _, stderr = await process.communicate() + + if process.returncode == 0: + logger.info("依赖安装成功") + # 安装成功后重新扫描已安装包 + self.scan_installed_packages() + return True + else: + error_msg = stderr.decode() + friendly_error = self._parse_pip_error(error_msg) + logger.error(f"依赖安装失败: {friendly_error}") + logger.debug(f"完整错误日志: {error_msg}") + return False + except Exception as e: # pylint: disable=broad-except + logger.error(f"执行安装命令失败: {e}") + return False + +# 全局依赖管理器实例 +plugin_dependency_manager = PluginDependencyManager() diff --git a/src/plugin_system/core/plugin_manager.py b/src/plugin_system/core/plugin_manager.py index 122a9ea2..1b935156 100644 --- a/src/plugin_system/core/plugin_manager.py +++ b/src/plugin_system/core/plugin_manager.py @@ -1,5 +1,6 @@ import os import traceback +import asyncio from typing import Dict, List, Optional, Tuple, Type, Any from importlib.util import spec_from_file_location, module_from_spec @@ -11,6 +12,7 @@ from src.plugin_system.base.plugin_base import PluginBase from src.plugin_system.base.component_types import ComponentType from src.plugin_system.utils.manifest_utils import VersionComparator from .component_registry import component_registry +from .dependency_manager import plugin_dependency_manager logger = get_logger("plugin_manager") @@ -73,7 +75,13 @@ class PluginManager: total_registered = 0 total_failed_registration = 0 - for plugin_name in self.plugin_classes.keys(): + # 获取所有已注册的插件名称 + all_plugin_names = list(self.plugin_classes.keys()) + + # 对插件进行拓扑排序 + sorted_plugin_names = self._sort_plugins_by_dependency(all_plugin_names) + + for plugin_name in sorted_plugin_names: load_status, count = self.load_registered_plugin_classes(plugin_name) if load_status: total_registered += 1 @@ -84,6 +92,60 @@ class PluginManager: return total_registered, total_failed_registration + def _sort_plugins_by_dependency(self, plugin_names: List[str]) -> List[str]: + """对插件进行拓扑排序,确保依赖加载顺序 + + Args: + plugin_names: 待排序的插件名称列表。 + + Returns: + List[str]: 排序后的插件名称列表,依赖项排在前面。 + """ + # 预实例化所有插件以获取依赖信息 + temp_instances = {} + for name in plugin_names: + cls = self.plugin_classes.get(name) + path = self.plugin_paths.get(name) + if cls and path: + try: + # 实例化插件以获取依赖信息,但不注册 + instance = cls(plugin_dir=path) + temp_instances[name] = instance + except Exception as e: # pylint: disable=broad-except + logger.warning(f"预加载插件 {name} 以获取依赖信息失败: {e}") + + # 构建依赖图 + graph = {name: set() for name in plugin_names} + for name, instance in temp_instances.items(): + for dep in instance.dependencies: + if dep in plugin_names: + graph[name].add(dep) + else: + logger.warning(f"插件 {name} 依赖未知的插件: {dep}") + + # 拓扑排序 + result = [] + visited = set() + temp_mark = set() + + def visit(n): + if n in temp_mark: + logger.error(f"检测到循环依赖: {n}") + return + if n not in visited: + temp_mark.add(n) + for m in graph.get(n, []): + visit(m) + temp_mark.remove(n) + visited.add(n) + result.append(n) + + for name in plugin_names: + if name not in visited: + visit(name) + + return result + def load_registered_plugin_classes(self, plugin_name: str) -> Tuple[bool, int]: # sourcery skip: extract-duplicate-method, extract-method """ @@ -110,6 +172,53 @@ class PluginManager: logger.info(f"插件 {plugin_name} 已禁用,跳过加载") return False, 0 + # 优先检查并安装配置文件定义的依赖 + try: + # 检查是否有正在运行的事件循环 + try: + asyncio.get_running_loop() + + pass + except RuntimeError: + # 没有运行的循环,可以使用 asyncio.run + asyncio.run( + plugin_dependency_manager.install_auto_from_directory(plugin_dir) + ) + except Exception as e: # pylint: disable=broad-except + logger.error(f"自动安装依赖文件失败: {e}") + + if plugin_instance.python_dependencies: + _, unsatisfied = plugin_dependency_manager.check_dependencies( + plugin_instance.python_dependencies) + if unsatisfied: + logger.warning(f"插件 {plugin_name} 缺少Python依赖:" + f" {[d.name for d in unsatisfied]},尝试自动安装...") + + # 尝试自动安装 + install_success = False + try: + # 检查是否有正在运行的事件循环 + try: + asyncio.get_running_loop() + # 如果已经在循环中(例如 WebUI 调用),我们无法在同步方法中阻塞等待异步安装 + # 记录警告并跳过自动安装,尝试继续加载(如果真的缺依赖,后续会抛出 ImportError) + logger.warning(f"检测到运行中的事件循环,跳过插件 {plugin_name} 的元数据依赖自动安装。如果加载失败,请手动安装: {[d.name for d in unsatisfied]}") + install_success = False # 标记为未执行安装,但允许后续流程尝试加载 + except RuntimeError: + # 没有运行的循环,可以使用 asyncio.run + install_success = asyncio.run( + plugin_dependency_manager.install_dependencies(unsatisfied)) + except Exception as e: # pylint: disable=broad-except + logger.error(f"自动安装依赖失败: {e}") + install_success = False + + if install_success: + logger.info(f"插件 {plugin_name} 依赖安装成功,继续加载") + elif unsatisfied: + logger.warning(f"插件 {plugin_name} 存在未满足的依赖且未能自动安装,将尝试强制加载...") + + + # 检查版本兼容性 is_compatible, compatibility_error = self._check_plugin_version_compatibility( plugin_name, plugin_instance.manifest_data diff --git a/src/webui/plugin_routes.py b/src/webui/plugin_routes.py index cb559fb7..d353627e 100644 --- a/src/webui/plugin_routes.py +++ b/src/webui/plugin_routes.py @@ -5,6 +5,7 @@ from pathlib import Path import json from src.common.logger import get_logger from src.config.config import MMC_VERSION +from src.plugin_system.core.dependency_manager import plugin_dependency_manager from .git_mirror_service import get_git_mirror_service, set_update_progress_callback from .token_manager import get_token_manager from .plugin_progress_ws import update_progress @@ -21,21 +22,21 @@ set_update_progress_callback(update_progress) def parse_version(version_str: str) -> tuple[int, int, int]: """ 解析版本号字符串 - + 支持格式: - 0.11.2 -> (0, 11, 2) - 0.11.2.snapshot.2 -> (0, 11, 2) - + Returns: (major, minor, patch) 三元组 """ # 移除 snapshot 等后缀 - base_version = version_str.split(".snapshot")[0].split(".dev")[0].split(".alpha")[0].split(".beta")[0] + base_version = version_str.split('.snapshot')[0].split('.dev')[0].split('.alpha')[0].split('.beta')[0] - parts = base_version.split(".") + parts = base_version.split('.') if len(parts) < 3: # 补齐到 3 位 - parts.extend(["0"] * (3 - len(parts))) + parts.extend(['0'] * (3 - len(parts))) try: major = int(parts[0]) @@ -49,10 +50,8 @@ def parse_version(version_str: str) -> tuple[int, int, int]: # ============ 请求/响应模型 ============ - class FetchRawFileRequest(BaseModel): """获取 Raw 文件请求""" - owner: str = Field(..., description="仓库所有者", example="MaiM-with-u") repo: str = Field(..., description="仓库名称", example="plugin-repo") branch: str = Field(..., description="分支名称", example="main") @@ -63,7 +62,6 @@ class FetchRawFileRequest(BaseModel): class FetchRawFileResponse(BaseModel): """获取 Raw 文件响应""" - success: bool = Field(..., description="是否成功") data: Optional[str] = Field(None, description="文件内容") error: Optional[str] = Field(None, description="错误信息") @@ -74,7 +72,6 @@ class FetchRawFileResponse(BaseModel): class CloneRepositoryRequest(BaseModel): """克隆仓库请求""" - owner: str = Field(..., description="仓库所有者", example="MaiM-with-u") repo: str = Field(..., description="仓库名称", example="plugin-repo") target_path: str = Field(..., description="目标路径(相对于插件目录)") @@ -86,7 +83,6 @@ class CloneRepositoryRequest(BaseModel): class CloneRepositoryResponse(BaseModel): """克隆仓库响应""" - success: bool = Field(..., description="是否成功") path: Optional[str] = Field(None, description="克隆路径") error: Optional[str] = Field(None, description="错误信息") @@ -98,7 +94,6 @@ class CloneRepositoryResponse(BaseModel): class MirrorConfigResponse(BaseModel): """镜像源配置响应""" - id: str = Field(..., description="镜像源 ID") name: str = Field(..., description="镜像源名称") raw_prefix: str = Field(..., description="Raw 文件前缀") @@ -109,14 +104,12 @@ class MirrorConfigResponse(BaseModel): class AvailableMirrorsResponse(BaseModel): """可用镜像源列表响应""" - mirrors: List[MirrorConfigResponse] = Field(..., description="镜像源列表") default_priority: List[str] = Field(..., description="默认优先级顺序(ID 列表)") class AddMirrorRequest(BaseModel): """添加镜像源请求""" - id: str = Field(..., description="镜像源 ID", example="custom-mirror") name: str = Field(..., description="镜像源名称", example="自定义镜像源") raw_prefix: str = Field(..., description="Raw 文件前缀", example="https://example.com/raw") @@ -127,7 +120,6 @@ class AddMirrorRequest(BaseModel): class UpdateMirrorRequest(BaseModel): """更新镜像源请求""" - name: Optional[str] = Field(None, description="镜像源名称") raw_prefix: Optional[str] = Field(None, description="Raw 文件前缀") clone_prefix: Optional[str] = Field(None, description="克隆前缀") @@ -137,7 +129,6 @@ class UpdateMirrorRequest(BaseModel): class GitStatusResponse(BaseModel): """Git 安装状态响应""" - installed: bool = Field(..., description="是否已安装 Git") version: Optional[str] = Field(None, description="Git 版本号") path: Optional[str] = Field(None, description="Git 可执行文件路径") @@ -146,7 +137,6 @@ class GitStatusResponse(BaseModel): class InstallPluginRequest(BaseModel): """安装插件请求""" - plugin_id: str = Field(..., description="插件 ID") repository_url: str = Field(..., description="插件仓库 URL") branch: Optional[str] = Field("main", description="分支名称") @@ -155,7 +145,6 @@ class InstallPluginRequest(BaseModel): class VersionResponse(BaseModel): """麦麦版本响应""" - version: str = Field(..., description="麦麦版本号") version_major: int = Field(..., description="主版本号") version_minor: int = Field(..., description="次版本号") @@ -164,13 +153,11 @@ class VersionResponse(BaseModel): class UninstallPluginRequest(BaseModel): """卸载插件请求""" - plugin_id: str = Field(..., description="插件 ID") class UpdatePluginRequest(BaseModel): """更新插件请求""" - plugin_id: str = Field(..., description="插件 ID") repository_url: str = Field(..., description="插件仓库 URL") branch: Optional[str] = Field("main", description="分支名称") @@ -179,24 +166,28 @@ class UpdatePluginRequest(BaseModel): # ============ API 路由 ============ - @router.get("/version", response_model=VersionResponse) async def get_maimai_version() -> VersionResponse: """ 获取麦麦版本信息 - + 此接口无需认证,用于前端检查插件兼容性 """ major, minor, patch = parse_version(MMC_VERSION) - return VersionResponse(version=MMC_VERSION, version_major=major, version_minor=minor, version_patch=patch) + return VersionResponse( + version=MMC_VERSION, + version_major=major, + version_minor=minor, + version_patch=patch + ) @router.get("/git-status", response_model=GitStatusResponse) async def check_git_status() -> GitStatusResponse: """ 检查本机 Git 安装状态 - + 此接口无需认证,用于前端快速检测是否可以使用插件安装功能 """ service = get_git_mirror_service() @@ -206,7 +197,9 @@ async def check_git_status() -> GitStatusResponse: @router.get("/mirrors", response_model=AvailableMirrorsResponse) -async def get_available_mirrors(authorization: Optional[str] = Header(None)) -> AvailableMirrorsResponse: +async def get_available_mirrors( + authorization: Optional[str] = Header(None) +) -> AvailableMirrorsResponse: """ 获取所有可用的镜像源配置 """ @@ -227,16 +220,22 @@ async def get_available_mirrors(authorization: Optional[str] = Header(None)) -> raw_prefix=m["raw_prefix"], clone_prefix=m["clone_prefix"], enabled=m["enabled"], - priority=m["priority"], + priority=m["priority"] ) for m in all_mirrors ] - return AvailableMirrorsResponse(mirrors=mirrors, default_priority=config.get_default_priority_list()) + return AvailableMirrorsResponse( + mirrors=mirrors, + default_priority=config.get_default_priority_list() + ) @router.post("/mirrors", response_model=MirrorConfigResponse) -async def add_mirror(request: AddMirrorRequest, authorization: Optional[str] = Header(None)) -> MirrorConfigResponse: +async def add_mirror( + request: AddMirrorRequest, + authorization: Optional[str] = Header(None) +) -> MirrorConfigResponse: """ 添加新的镜像源 """ @@ -256,7 +255,7 @@ async def add_mirror(request: AddMirrorRequest, authorization: Optional[str] = H raw_prefix=request.raw_prefix, clone_prefix=request.clone_prefix, enabled=request.enabled, - priority=request.priority, + priority=request.priority ) return MirrorConfigResponse( @@ -265,7 +264,7 @@ async def add_mirror(request: AddMirrorRequest, authorization: Optional[str] = H raw_prefix=mirror["raw_prefix"], clone_prefix=mirror["clone_prefix"], enabled=mirror["enabled"], - priority=mirror["priority"], + priority=mirror["priority"] ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e @@ -276,7 +275,9 @@ async def add_mirror(request: AddMirrorRequest, authorization: Optional[str] = H @router.put("/mirrors/{mirror_id}", response_model=MirrorConfigResponse) async def update_mirror( - mirror_id: str, request: UpdateMirrorRequest, authorization: Optional[str] = Header(None) + mirror_id: str, + request: UpdateMirrorRequest, + authorization: Optional[str] = Header(None) ) -> MirrorConfigResponse: """ 更新镜像源配置 @@ -297,7 +298,7 @@ async def update_mirror( raw_prefix=request.raw_prefix, clone_prefix=request.clone_prefix, enabled=request.enabled, - priority=request.priority, + priority=request.priority ) if not mirror: @@ -309,7 +310,7 @@ async def update_mirror( raw_prefix=mirror["raw_prefix"], clone_prefix=mirror["clone_prefix"], enabled=mirror["enabled"], - priority=mirror["priority"], + priority=mirror["priority"] ) except HTTPException: raise @@ -319,7 +320,10 @@ async def update_mirror( @router.delete("/mirrors/{mirror_id}") -async def delete_mirror(mirror_id: str, authorization: Optional[str] = Header(None)) -> Dict[str, Any]: +async def delete_mirror( + mirror_id: str, + authorization: Optional[str] = Header(None) +) -> Dict[str, Any]: """ 删除镜像源 """ @@ -337,18 +341,22 @@ async def delete_mirror(mirror_id: str, authorization: Optional[str] = Header(No if not success: raise HTTPException(status_code=404, detail=f"未找到镜像源: {mirror_id}") - return {"success": True, "message": f"已删除镜像源: {mirror_id}"} + return { + "success": True, + "message": f"已删除镜像源: {mirror_id}" + } @router.post("/fetch-raw", response_model=FetchRawFileResponse) async def fetch_raw_file( - request: FetchRawFileRequest, authorization: Optional[str] = Header(None) + request: FetchRawFileRequest, + authorization: Optional[str] = Header(None) ) -> FetchRawFileResponse: """ 获取 GitHub 仓库的 Raw 文件内容 - + 支持多镜像源自动切换和错误重试 - + 注意:此接口可公开访问,用于获取插件仓库等公开资源 """ # Token 验证(可选,用于日志记录) @@ -369,7 +377,7 @@ async def fetch_raw_file( progress=10, message=f"正在获取插件列表: {request.file_path}", total_plugins=0, - loaded_plugins=0, + loaded_plugins=0 ) try: @@ -382,19 +390,22 @@ async def fetch_raw_file( branch=request.branch, file_path=request.file_path, mirror_id=request.mirror_id, - custom_url=request.custom_url, + custom_url=request.custom_url ) if result.get("success"): # 更新进度:成功获取 await update_progress( - stage="loading", progress=70, message="正在解析插件数据...", total_plugins=0, loaded_plugins=0 + stage="loading", + progress=70, + message="正在解析插件数据...", + total_plugins=0, + loaded_plugins=0 ) # 尝试解析插件数量 try: import json - data = json.loads(result.get("data", "[]")) total = len(data) if isinstance(data, list) else 0 @@ -404,12 +415,16 @@ async def fetch_raw_file( progress=100, message=f"成功加载 {total} 个插件", total_plugins=total, - loaded_plugins=total, + loaded_plugins=total ) except Exception: # 如果解析失败,仍然发送成功状态 await update_progress( - stage="success", progress=100, message="加载完成", total_plugins=0, loaded_plugins=0 + stage="success", + progress=100, + message="加载完成", + total_plugins=0, + loaded_plugins=0 ) return FetchRawFileResponse(**result) @@ -419,7 +434,12 @@ async def fetch_raw_file( # 发送错误进度 await update_progress( - stage="error", progress=0, message="加载失败", error=str(e), total_plugins=0, loaded_plugins=0 + stage="error", + progress=0, + message="加载失败", + error=str(e), + total_plugins=0, + loaded_plugins=0 ) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e @@ -427,11 +447,12 @@ async def fetch_raw_file( @router.post("/clone", response_model=CloneRepositoryResponse) async def clone_repository( - request: CloneRepositoryRequest, authorization: Optional[str] = Header(None) + request: CloneRepositoryRequest, + authorization: Optional[str] = Header(None) ) -> CloneRepositoryResponse: """ 克隆 GitHub 仓库到本地 - + 支持多镜像源自动切换和错误重试 """ # Token 验证 @@ -440,7 +461,9 @@ async def clone_repository( if not token or not token_manager.verify_token(token): raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") - logger.info(f"收到克隆仓库请求: {request.owner}/{request.repo} -> {request.target_path}") + logger.info( + f"收到克隆仓库请求: {request.owner}/{request.repo} -> {request.target_path}" + ) try: # TODO: 验证 target_path 的安全性,防止路径遍历攻击 @@ -456,7 +479,7 @@ async def clone_repository( branch=request.branch, mirror_id=request.mirror_id, custom_url=request.custom_url, - depth=request.depth, + depth=request.depth ) return CloneRepositoryResponse(**result) @@ -467,10 +490,13 @@ async def clone_repository( @router.post("/install") -async def install_plugin(request: InstallPluginRequest, authorization: Optional[str] = Header(None)) -> Dict[str, Any]: +async def install_plugin( + request: InstallPluginRequest, + authorization: Optional[str] = Header(None) +) -> Dict[str, Any]: """ 安装插件 - + 从 Git 仓库克隆插件到本地插件目录 """ # Token 验证 @@ -488,16 +514,16 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ progress=5, message=f"开始安装插件: {request.plugin_id}", operation="install", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) # 1. 解析仓库 URL # repository_url 格式: https://github.com/owner/repo - repo_url = request.repository_url.rstrip("/") - if repo_url.endswith(".git"): + repo_url = request.repository_url.rstrip('/') + if repo_url.endswith('.git'): repo_url = repo_url[:-4] - parts = repo_url.split("/") + parts = repo_url.split('/') if len(parts) < 2: raise HTTPException(status_code=400, detail="无效的仓库 URL") @@ -509,7 +535,7 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ progress=10, message=f"解析仓库信息: {owner}/{repo}", operation="install", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) # 2. 确定插件安装路径 @@ -523,10 +549,10 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ await update_progress( stage="error", progress=0, - message="插件已存在", + message=f"插件已存在", operation="install", plugin_id=request.plugin_id, - error="插件已安装,请先卸载", + error="插件已安装,请先卸载" ) raise HTTPException(status_code=400, detail="插件已安装") @@ -535,26 +561,31 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ progress=15, message=f"准备克隆到: {target_path}", operation="install", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) # 3. 克隆仓库(这里会自动推送 20%-80% 的进度) service = get_git_mirror_service() # 如果是 GitHub 仓库,使用镜像源 - if "github.com" in repo_url: + if 'github.com' in repo_url: result = await service.clone_repository( owner=owner, repo=repo, target_path=target_path, branch=request.branch, mirror_id=request.mirror_id, - depth=1, # 浅克隆,节省时间和空间 + depth=1 # 浅克隆,节省时间和空间 ) else: # 自定义仓库,直接使用 URL result = await service.clone_repository( - owner=owner, repo=repo, target_path=target_path, branch=request.branch, custom_url=repo_url, depth=1 + owner=owner, + repo=repo, + target_path=target_path, + branch=request.branch, + custom_url=repo_url, + depth=1 ) if not result.get("success"): @@ -565,20 +596,23 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ message="克隆仓库失败", operation="install", plugin_id=request.plugin_id, - error=error_msg, + error=error_msg ) raise HTTPException(status_code=500, detail=error_msg) # 4. 验证插件完整性 await update_progress( - stage="loading", progress=85, message="验证插件文件...", operation="install", plugin_id=request.plugin_id + stage="loading", + progress=85, + message="验证插件文件...", + operation="install", + plugin_id=request.plugin_id ) manifest_path = target_path / "_manifest.json" if not manifest_path.exists(): # 清理失败的安装 import shutil - shutil.rmtree(target_path, ignore_errors=True) await update_progress( @@ -587,23 +621,26 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ message="插件缺少 _manifest.json", operation="install", plugin_id=request.plugin_id, - error="无效的插件格式", + error="无效的插件格式" ) raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json") # 5. 读取并验证 manifest await update_progress( - stage="loading", progress=90, message="读取插件配置...", operation="install", plugin_id=request.plugin_id + stage="loading", + progress=90, + message="读取插件配置...", + operation="install", + plugin_id=request.plugin_id ) try: import json as json_module - - with open(manifest_path, "r", encoding="utf-8") as f: + with open(manifest_path, 'r', encoding='utf-8') as f: manifest = json_module.load(f) # 基本验证 - required_fields = ["manifest_version", "name", "version", "author"] + required_fields = ['manifest_version', 'name', 'version', 'author'] for field in required_fields: if field not in manifest: raise ValueError(f"缺少必需字段: {field}") @@ -611,7 +648,6 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ except Exception as e: # 清理失败的安装 import shutil - shutil.rmtree(target_path, ignore_errors=True) await update_progress( @@ -620,26 +656,42 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ message="_manifest.json 无效", operation="install", plugin_id=request.plugin_id, - error=str(e), + error=str(e) ) raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e + # 5.5 安装依赖 + await update_progress( + stage="loading", + progress=95, + message="正在检查并安装依赖...", + operation="install", + plugin_id=request.plugin_id + ) + + try: + # 自动检测并安装 requirements.txt 或 pyproject.toml + await plugin_dependency_manager.install_auto_from_directory(str(target_path)) + except Exception as e: + logger.error(f"依赖安装过程出错: {e}") + # 不中断安装流程,但记录日志 + # 6. 安装成功 await update_progress( stage="success", progress=100, message=f"成功安装插件: {manifest['name']} v{manifest['version']}", operation="install", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) return { "success": True, "message": "插件安装成功", "plugin_id": request.plugin_id, - "plugin_name": manifest["name"], - "version": manifest["version"], - "path": str(target_path), + "plugin_name": manifest['name'], + "version": manifest['version'], + "path": str(target_path) } except HTTPException: @@ -653,7 +705,7 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ message="安装失败", operation="install", plugin_id=request.plugin_id, - error=str(e), + error=str(e) ) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e @@ -661,11 +713,12 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ @router.post("/uninstall") async def uninstall_plugin( - request: UninstallPluginRequest, authorization: Optional[str] = Header(None) + request: UninstallPluginRequest, + authorization: Optional[str] = Header(None) ) -> Dict[str, Any]: """ 卸载插件 - + 删除插件目录及其所有文件 """ # Token 验证 @@ -683,7 +736,7 @@ async def uninstall_plugin( progress=10, message=f"开始卸载插件: {request.plugin_id}", operation="uninstall", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) # 1. 检查插件是否存在 @@ -697,7 +750,7 @@ async def uninstall_plugin( message="插件不存在", operation="uninstall", plugin_id=request.plugin_id, - error="插件未安装或已被删除", + error="插件未安装或已被删除" ) raise HTTPException(status_code=404, detail="插件未安装") @@ -706,7 +759,7 @@ async def uninstall_plugin( progress=30, message=f"正在删除插件文件: {plugin_path}", operation="uninstall", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) # 2. 读取插件信息(用于日志) @@ -716,8 +769,7 @@ async def uninstall_plugin( if manifest_path.exists(): try: import json as json_module - - with open(manifest_path, "r", encoding="utf-8") as f: + with open(manifest_path, 'r', encoding='utf-8') as f: manifest = json_module.load(f) plugin_name = manifest.get("name", request.plugin_id) except Exception: @@ -728,7 +780,7 @@ async def uninstall_plugin( progress=50, message=f"正在删除 {plugin_name}...", operation="uninstall", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) # 3. 删除插件目录 @@ -738,7 +790,6 @@ async def uninstall_plugin( def remove_readonly(func, path, _): """清除只读属性并删除文件""" import os - os.chmod(path, stat.S_IWRITE) func(path) @@ -752,10 +803,15 @@ async def uninstall_plugin( progress=100, message=f"成功卸载插件: {plugin_name}", operation="uninstall", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) - return {"success": True, "message": "插件卸载成功", "plugin_id": request.plugin_id, "plugin_name": plugin_name} + return { + "success": True, + "message": "插件卸载成功", + "plugin_id": request.plugin_id, + "plugin_name": plugin_name + } except HTTPException: raise @@ -768,7 +824,7 @@ async def uninstall_plugin( message="卸载失败", operation="uninstall", plugin_id=request.plugin_id, - error="权限不足,无法删除插件文件", + error="权限不足,无法删除插件文件" ) raise HTTPException(status_code=500, detail="权限不足,无法删除插件文件") from e @@ -781,17 +837,20 @@ async def uninstall_plugin( message="卸载失败", operation="uninstall", plugin_id=request.plugin_id, - error=str(e), + error=str(e) ) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e @router.post("/update") -async def update_plugin(request: UpdatePluginRequest, authorization: Optional[str] = Header(None)) -> Dict[str, Any]: +async def update_plugin( + request: UpdatePluginRequest, + authorization: Optional[str] = Header(None) +) -> Dict[str, Any]: """ 更新插件 - + 删除旧版本,重新克隆新版本 """ # Token 验证 @@ -809,7 +868,7 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st progress=5, message=f"开始更新插件: {request.plugin_id}", operation="update", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) # 1. 检查插件是否已安装 @@ -823,23 +882,21 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st message="插件不存在", operation="update", plugin_id=request.plugin_id, - error="插件未安装,请先安装", + error="插件未安装,请先安装" ) raise HTTPException(status_code=404, detail="插件未安装") # 2. 读取旧版本信息 manifest_path = plugin_path / "_manifest.json" old_version = "unknown" - plugin_name = request.plugin_id if manifest_path.exists(): try: import json as json_module - - with open(manifest_path, "r", encoding="utf-8") as f: + with open(manifest_path, 'r', encoding='utf-8') as f: manifest = json_module.load(f) old_version = manifest.get("version", "unknown") - _plugin_name = manifest.get("name", request.plugin_id) + # plugin_name = manifest.get("name", request.plugin_id) except Exception: pass @@ -848,12 +905,16 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st progress=10, message=f"当前版本: {old_version},准备更新...", operation="update", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) # 3. 删除旧版本 await update_progress( - stage="loading", progress=20, message="正在删除旧版本...", operation="update", plugin_id=request.plugin_id + stage="loading", + progress=20, + message="正在删除旧版本...", + operation="update", + plugin_id=request.plugin_id ) import shutil @@ -862,7 +923,6 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st def remove_readonly(func, path, _): """清除只读属性并删除文件""" import os - os.chmod(path, stat.S_IWRITE) func(path) @@ -876,14 +936,14 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st progress=30, message="正在准备下载新版本...", operation="update", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) - repo_url = request.repository_url.rstrip("/") - if repo_url.endswith(".git"): + repo_url = request.repository_url.rstrip('/') + if repo_url.endswith('.git'): repo_url = repo_url[:-4] - parts = repo_url.split("/") + parts = repo_url.split('/') if len(parts) < 2: raise HTTPException(status_code=400, detail="无效的仓库 URL") @@ -893,18 +953,23 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st # 5. 克隆新版本(这里会推送 35%-85% 的进度) service = get_git_mirror_service() - if "github.com" in repo_url: + if 'github.com' in repo_url: result = await service.clone_repository( owner=owner, repo=repo, target_path=plugin_path, branch=request.branch, mirror_id=request.mirror_id, - depth=1, + depth=1 ) else: result = await service.clone_repository( - owner=owner, repo=repo, target_path=plugin_path, branch=request.branch, custom_url=repo_url, depth=1 + owner=owner, + repo=repo, + target_path=plugin_path, + branch=request.branch, + custom_url=repo_url, + depth=1 ) if not result.get("success"): @@ -915,13 +980,17 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st message="下载新版本失败", operation="update", plugin_id=request.plugin_id, - error=error_msg, + error=error_msg ) raise HTTPException(status_code=500, detail=error_msg) # 6. 验证新版本 await update_progress( - stage="loading", progress=90, message="验证新版本...", operation="update", plugin_id=request.plugin_id + stage="loading", + progress=90, + message="验证新版本...", + operation="update", + plugin_id=request.plugin_id ) new_manifest_path = plugin_path / "_manifest.json" @@ -930,7 +999,6 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st def remove_readonly(func, path, _): """清除只读属性并删除文件""" import os - os.chmod(path, stat.S_IWRITE) func(path) @@ -942,18 +1010,31 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st message="新版本缺少 _manifest.json", operation="update", plugin_id=request.plugin_id, - error="无效的插件格式", + error="无效的插件格式" ) raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json") # 7. 读取新版本信息 try: - with open(new_manifest_path, "r", encoding="utf-8") as f: + with open(new_manifest_path, 'r', encoding='utf-8') as f: new_manifest = json_module.load(f) new_version = new_manifest.get("version", "unknown") new_name = new_manifest.get("name", request.plugin_id) + # 7.5 安装依赖 + await update_progress( + stage="loading", + progress=95, + message="正在更新依赖...", + operation="update", + plugin_id=request.plugin_id + ) + try: + await plugin_dependency_manager.install_auto_from_directory(str(plugin_path)) + except Exception as e: + logger.error(f"依赖更新过程出错: {e}") + logger.info(f"成功更新插件: {request.plugin_id} {old_version} → {new_version}") # 8. 推送成功状态 @@ -962,7 +1043,7 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st progress=100, message=f"成功更新 {new_name}: {old_version} → {new_version}", operation="update", - plugin_id=request.plugin_id, + plugin_id=request.plugin_id ) return { @@ -971,7 +1052,7 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st "plugin_id": request.plugin_id, "plugin_name": new_name, "old_version": old_version, - "new_version": new_version, + "new_version": new_version } except Exception as e: @@ -984,7 +1065,7 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st message="_manifest.json 无效", operation="update", plugin_id=request.plugin_id, - error=str(e), + error=str(e) ) raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e @@ -994,17 +1075,24 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st logger.error(f"更新插件失败: {e}", exc_info=True) await update_progress( - stage="error", progress=0, message="更新失败", operation="update", plugin_id=request.plugin_id, error=str(e) + stage="error", + progress=0, + message="更新失败", + operation="update", + plugin_id=request.plugin_id, + error=str(e) ) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e @router.get("/installed") -async def get_installed_plugins(authorization: Optional[str] = Header(None)) -> Dict[str, Any]: +async def get_installed_plugins( + authorization: Optional[str] = Header(None) +) -> Dict[str, Any]: """ 获取已安装的插件列表 - + 扫描 plugins 目录,返回所有已安装插件的 ID 和基本信息 """ # Token 验证 @@ -1022,7 +1110,10 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) -> if not plugins_dir.exists(): logger.info("插件目录不存在,创建目录") plugins_dir.mkdir(exist_ok=True) - return {"success": True, "plugins": []} + return { + "success": True, + "plugins": [] + } installed_plugins = [] @@ -1036,7 +1127,7 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) -> plugin_id = plugin_path.name # 跳过隐藏目录和特殊目录 - if plugin_id.startswith(".") or plugin_id.startswith("__"): + if plugin_id.startswith('.') or plugin_id.startswith('__'): continue # 读取 _manifest.json @@ -1048,23 +1139,20 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) -> try: import json as json_module - - with open(manifest_path, "r", encoding="utf-8") as f: + with open(manifest_path, 'r', encoding='utf-8') as f: manifest = json_module.load(f) # 基本验证 - if "name" not in manifest or "version" not in manifest: + if 'name' not in manifest or 'version' not in manifest: logger.warning(f"插件 {plugin_id} 的 _manifest.json 格式无效,跳过") continue # 添加到已安装列表(返回完整的 manifest 信息) - installed_plugins.append( - { - "id": plugin_id, - "manifest": manifest, # 返回完整的 manifest 对象 - "path": str(plugin_path.absolute()), - } - ) + installed_plugins.append({ + "id": plugin_id, + "manifest": manifest, # 返回完整的 manifest 对象 + "path": str(plugin_path.absolute()) + }) except json.JSONDecodeError as e: logger.warning(f"插件 {plugin_id} 的 _manifest.json 解析失败: {e}") @@ -1075,7 +1163,11 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) -> logger.info(f"找到 {len(installed_plugins)} 个已安装插件") - return {"success": True, "plugins": installed_plugins, "total": len(installed_plugins)} + return { + "success": True, + "plugins": installed_plugins, + "total": len(installed_plugins) + } except Exception as e: logger.error(f"获取已安装插件列表失败: {e}", exc_info=True)