WebUI 前端 & 后端超级大重构

This commit is contained in:
DrSmoothl
2026-03-14 21:06:36 +08:00
parent 6ca5a2939e
commit 172615f18a
69 changed files with 3128 additions and 6581 deletions

View File

@@ -0,0 +1,17 @@
from fastapi import APIRouter
from src.webui.services.git_mirror_service import set_update_progress_callback
from .catalog import router as catalog_router
from .config_routes import router as config_router
from .management import router as management_router
from .progress import get_progress_router, update_progress
router = APIRouter(prefix="/plugins", tags=["插件管理"])
router.include_router(catalog_router)
router.include_router(management_router)
router.include_router(config_router)
set_update_progress_callback(update_progress)
__all__ = ["get_progress_router", "router"]

View File

@@ -0,0 +1,205 @@
from typing import Any, Optional
import json
from fastapi import APIRouter, Cookie, HTTPException
from src.common.logger import get_logger
from src.config.config import MMC_VERSION
from src.webui.services.git_mirror_service import get_git_mirror_service
from .progress import update_progress
from .schemas import (
AddMirrorRequest,
AvailableMirrorsResponse,
CloneRepositoryRequest,
CloneRepositoryResponse,
FetchRawFileRequest,
FetchRawFileResponse,
GitStatusResponse,
MirrorConfigResponse,
UpdateMirrorRequest,
VersionResponse,
)
from .support import get_plugins_dir, parse_version, require_plugin_token, validate_safe_path
logger = get_logger("webui.plugin_routes")
router = APIRouter()
def _mirror_to_response(mirror: dict[str, Any]) -> MirrorConfigResponse:
return MirrorConfigResponse(
id=mirror["id"],
name=mirror["name"],
raw_prefix=mirror["raw_prefix"],
clone_prefix=mirror["clone_prefix"],
enabled=mirror["enabled"],
priority=mirror["priority"],
)
@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)
@router.get("/git-status", response_model=GitStatusResponse)
async def check_git_status() -> GitStatusResponse:
service = get_git_mirror_service()
return GitStatusResponse(**service.check_git_installed())
@router.get("/mirrors", response_model=AvailableMirrorsResponse)
async def get_available_mirrors(maibot_session: Optional[str] = Cookie(None)) -> AvailableMirrorsResponse:
require_plugin_token(maibot_session)
service = get_git_mirror_service()
config = service.get_mirror_config()
mirrors = [_mirror_to_response(mirror) for mirror in config.get_all_mirrors()]
return AvailableMirrorsResponse(mirrors=mirrors, default_priority=config.get_default_priority_list())
@router.post("/mirrors", response_model=MirrorConfigResponse)
async def add_mirror(request: AddMirrorRequest, maibot_session: Optional[str] = Cookie(None)) -> MirrorConfigResponse:
require_plugin_token(maibot_session)
try:
service = get_git_mirror_service()
config = service.get_mirror_config()
mirror = config.add_mirror(
mirror_id=request.id,
name=request.name,
raw_prefix=request.raw_prefix,
clone_prefix=request.clone_prefix,
enabled=request.enabled,
priority=request.priority,
)
return _mirror_to_response(mirror)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
logger.error(f"添加镜像源失败: {e}")
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.put("/mirrors/{mirror_id}", response_model=MirrorConfigResponse)
async def update_mirror(
mirror_id: str,
request: UpdateMirrorRequest,
maibot_session: Optional[str] = Cookie(None),
) -> MirrorConfigResponse:
require_plugin_token(maibot_session)
try:
service = get_git_mirror_service()
config = service.get_mirror_config()
mirror = config.update_mirror(
mirror_id=mirror_id,
name=request.name,
raw_prefix=request.raw_prefix,
clone_prefix=request.clone_prefix,
enabled=request.enabled,
priority=request.priority,
)
if mirror is None:
raise HTTPException(status_code=404, detail=f"未找到镜像源: {mirror_id}")
return _mirror_to_response(mirror)
except HTTPException:
raise
except Exception as e:
logger.error(f"更新镜像源失败: {e}")
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.delete("/mirrors/{mirror_id}")
async def delete_mirror(mirror_id: str, maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
service = get_git_mirror_service()
config = service.get_mirror_config()
if not config.delete_mirror(mirror_id):
raise HTTPException(status_code=404, detail=f"未找到镜像源: {mirror_id}")
return {"success": True, "message": f"已删除镜像源: {mirror_id}"}
@router.post("/fetch-raw", response_model=FetchRawFileResponse)
async def fetch_raw_file(
request: FetchRawFileRequest,
maibot_session: Optional[str] = Cookie(None),
) -> FetchRawFileResponse:
require_plugin_token(maibot_session)
logger.info(f"收到获取 Raw 文件请求: {request.owner}/{request.repo}/{request.branch}/{request.file_path}")
await update_progress(
stage="loading",
progress=10,
message=f"正在获取插件列表: {request.file_path}",
total_plugins=0,
loaded_plugins=0,
)
try:
service = get_git_mirror_service()
result = await service.fetch_raw_file(
owner=request.owner,
repo=request.repo,
branch=request.branch,
file_path=request.file_path,
mirror_id=request.mirror_id,
custom_url=request.custom_url,
)
if result.get("success"):
await update_progress(
stage="loading",
progress=70,
message="正在解析插件数据...",
total_plugins=0,
loaded_plugins=0,
)
try:
data = json.loads(result.get("data", "[]"))
total = len(data) if isinstance(data, list) else 0
await update_progress(
stage="success",
progress=100,
message=f"成功加载 {total} 个插件",
total_plugins=total,
loaded_plugins=total,
)
except Exception:
await update_progress(stage="success", progress=100, message="加载完成", total_plugins=0, loaded_plugins=0)
return FetchRawFileResponse(**result)
except Exception as e:
logger.error(f"获取 Raw 文件失败: {e}")
await update_progress(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
@router.post("/clone", response_model=CloneRepositoryResponse)
async def clone_repository(
request: CloneRepositoryRequest,
maibot_session: Optional[str] = Cookie(None),
) -> CloneRepositoryResponse:
require_plugin_token(maibot_session)
logger.info(f"收到克隆仓库请求: {request.owner}/{request.repo} -> {request.target_path}")
try:
target_path = validate_safe_path(request.target_path, get_plugins_dir())
service = get_git_mirror_service()
result = await service.clone_repository(
owner=request.owner,
repo=request.repo,
target_path=target_path,
branch=request.branch,
mirror_id=request.mirror_id,
custom_url=request.custom_url,
depth=request.depth,
)
return CloneRepositoryResponse(**result)
except Exception as e:
logger.error(f"克隆仓库失败: {e}")
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e

View File

@@ -0,0 +1,333 @@
from typing import Any, Optional, cast
import json
import tomlkit
from fastapi import APIRouter, Cookie, HTTPException
from src.common.logger import get_logger
from src.webui.utils.toml_utils import save_toml_with_format
from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest
from .support import (
backup_file,
coerce_types,
find_plugin_instance,
find_plugin_path_by_id,
normalize_dotted_keys,
require_plugin_token,
)
logger = get_logger("webui.plugin_routes")
router = APIRouter()
def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> dict[str, Any]:
schema: dict[str, Any] = {
"plugin_id": plugin_id,
"plugin_info": {
"name": plugin_id,
"version": "",
"description": "",
"author": "",
},
"sections": {},
"layout": {"type": "auto", "tabs": []},
"_note": "插件未加载,仅返回当前配置结构",
}
for section_name, section_data in current_config.items():
if not isinstance(section_data, dict):
continue
section_fields: dict[str, Any] = {}
for field_name, field_value in section_data.items():
field_type = type(field_value).__name__
ui_type = "text"
item_type = None
item_fields = None
if isinstance(field_value, bool):
ui_type = "switch"
elif isinstance(field_value, (int, float)):
ui_type = "number"
elif isinstance(field_value, list):
ui_type = "list"
if field_value:
first_item = field_value[0]
if isinstance(first_item, dict):
item_type = "object"
item_fields = {
key: {
"type": "number" if isinstance(value, (int, float)) else "string",
"label": key,
"default": "" if isinstance(value, str) else 0,
}
for key, value in first_item.items()
}
elif isinstance(first_item, (int, float)):
item_type = "number"
else:
item_type = "string"
else:
item_type = "string"
elif isinstance(field_value, dict):
ui_type = "json"
section_fields[field_name] = {
"name": field_name,
"type": field_type,
"default": field_value,
"description": field_name,
"label": field_name,
"ui_type": ui_type,
"required": False,
"hidden": False,
"disabled": False,
"order": 0,
"item_type": item_type,
"item_fields": item_fields,
"min_items": None,
"max_items": None,
"placeholder": None,
"hint": None,
"icon": None,
"example": None,
"choices": None,
"min": None,
"max": None,
"step": None,
"pattern": None,
"max_length": None,
"input_type": None,
"rows": 3,
"group": None,
"depends_on": None,
"depends_value": None,
}
schema["sections"][section_name] = {
"name": section_name,
"title": section_name,
"description": None,
"icon": None,
"collapsed": False,
"order": 0,
"fields": section_fields,
}
return schema
@router.get("/config/{plugin_id}/schema")
async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"获取插件配置 Schema: {plugin_id}")
try:
plugin_instance = find_plugin_instance(plugin_id)
if plugin_instance and hasattr(plugin_instance, "get_webui_config_schema"):
return {"success": True, "schema": plugin_instance.get_webui_config_schema()}
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
schema_json_path = plugin_path / "config_schema.json"
if schema_json_path.exists():
try:
with open(schema_json_path, "r", encoding="utf-8") as file_obj:
return {"success": True, "schema": json.load(file_obj)}
except Exception as e:
logger.warning(f"读取 config_schema.json 失败,回退到自动推断: {e}")
current_config: Any = {}
config_path = plugin_path / "config.toml"
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as file_obj:
current_config = tomlkit.load(file_obj)
return {"success": True, "schema": _build_schema_from_current_config(plugin_id, current_config)}
except HTTPException:
raise
except Exception as e:
logger.error(f"获取插件配置 Schema 失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.get("/config/{plugin_id}/raw")
async def get_plugin_config_raw(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"获取插件原始配置: {plugin_id}")
try:
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = plugin_path / "config.toml"
if not config_path.exists():
return {"success": True, "config": "", "message": "配置文件不存在"}
with open(config_path, "r", encoding="utf-8") as file_obj:
return {"success": True, "config": file_obj.read()}
except HTTPException:
raise
except Exception as e:
logger.error(f"获取插件原始配置失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.put("/config/{plugin_id}/raw")
async def update_plugin_config_raw(
plugin_id: str,
request: UpdatePluginRawConfigRequest,
maibot_session: Optional[str] = Cookie(None),
) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"更新插件原始配置: {plugin_id}")
try:
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = plugin_path / "config.toml"
try:
tomlkit.loads(request.config)
except Exception as e:
raise HTTPException(status_code=400, detail=f"TOML 格式错误: {str(e)}") from e
backup_path = backup_file(config_path, "backup")
if backup_path is not None:
logger.info(f"已备份配置文件: {backup_path}")
with open(config_path, "w", encoding="utf-8") as file_obj:
file_obj.write(request.config)
logger.info(f"已更新插件原始配置: {plugin_id}")
return {"success": True, "message": "配置已保存", "note": "配置更改将在插件重新加载后生效"}
except HTTPException:
raise
except Exception as e:
logger.error(f"更新插件原始配置失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.get("/config/{plugin_id}")
async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"获取插件配置: {plugin_id}")
try:
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = plugin_path / "config.toml"
if not config_path.exists():
return {"success": True, "config": {}, "message": "配置文件不存在"}
with open(config_path, "r", encoding="utf-8") as file_obj:
config = tomlkit.load(file_obj)
return {"success": True, "config": dict(config)}
except HTTPException:
raise
except Exception as e:
logger.error(f"获取插件配置失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.put("/config/{plugin_id}")
async def update_plugin_config(
plugin_id: str,
request: UpdatePluginConfigRequest,
maibot_session: Optional[str] = Cookie(None),
) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"更新插件配置: {plugin_id}")
try:
plugin_instance = find_plugin_instance(plugin_id)
config_data = request.config or {}
if plugin_instance and isinstance(config_data, dict):
config_data = normalize_dotted_keys(config_data)
if isinstance(plugin_instance.config_schema, dict):
coerce_types(plugin_instance.config_schema, config_data)
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = plugin_path / "config.toml"
backup_path = backup_file(config_path, "backup")
if backup_path is not None:
logger.info(f"已备份配置文件: {backup_path}")
save_toml_with_format(config_data, str(config_path))
logger.info(f"已更新插件配置: {plugin_id}")
return {"success": True, "message": "配置已保存", "note": "配置更改将在插件重新加载后生效"}
except HTTPException:
raise
except Exception as e:
logger.error(f"更新插件配置失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.post("/config/{plugin_id}/reset")
async def reset_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"重置插件配置: {plugin_id}")
try:
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = plugin_path / "config.toml"
if not config_path.exists():
return {"success": True, "message": "配置文件不存在,无需重置"}
backup_path = backup_file(config_path, "reset", move_file=True)
logger.info(f"已重置插件配置: {plugin_id},备份: {backup_path}")
return {"success": True, "message": "配置已重置,下次加载插件时将使用默认配置", "backup": str(backup_path)}
except HTTPException:
raise
except Exception as e:
logger.error(f"重置插件配置失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.post("/config/{plugin_id}/toggle")
async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"切换插件状态: {plugin_id}")
try:
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = plugin_path / "config.toml"
config = tomlkit.document()
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as file_obj:
config = tomlkit.load(file_obj)
if "plugin" not in config:
config["plugin"] = tomlkit.table()
plugin_config = cast(Any, config["plugin"])
current_enabled = bool(plugin_config.get("enabled", True))
new_enabled = not current_enabled
plugin_config["enabled"] = new_enabled
save_toml_with_format(config, str(config_path))
status = "启用" if new_enabled else "禁用"
logger.info(f"{status}插件: {plugin_id}")
return {"success": True, "enabled": new_enabled, "message": f"插件已{status}", "note": "状态更改将在下次加载插件时生效"}
except HTTPException:
raise
except Exception as e:
logger.error(f"切换插件状态失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e

View File

@@ -0,0 +1,302 @@
from pathlib import Path
from typing import Any, Optional
import json
from fastapi import APIRouter, Cookie, HTTPException
from src.common.logger import get_logger
from src.webui.services.git_mirror_service import get_git_mirror_service
from .progress import update_progress
from .schemas import InstallPluginRequest, UninstallPluginRequest, UpdatePluginRequest
from .support import (
find_plugin_path_by_id,
get_plugin_candidate_paths,
get_plugins_dir,
load_manifest_json,
parse_repository_url,
remove_tree,
require_plugin_token,
resolve_installed_plugin_path,
validate_plugin_id,
)
logger = get_logger("webui.plugin_routes")
router = APIRouter()
def _infer_plugin_id(folder_name: str, manifest: dict[str, Any], manifest_path: Path) -> str:
if "id" in manifest:
return str(manifest["id"])
author_name: Optional[str] = None
repo_name: Optional[str] = None
if "author" in manifest:
author_data = manifest["author"]
if isinstance(author_data, dict) and "name" in author_data:
author_name = str(author_data["name"])
elif isinstance(author_data, str):
author_name = author_data
if "repository_url" in manifest:
repo_url = str(manifest["repository_url"]).rstrip("/").removesuffix(".git")
repo_name = repo_url.split("/")[-1]
if author_name and repo_name:
plugin_id = f"{author_name}.{repo_name}"
elif author_name:
plugin_id = f"{author_name}.{folder_name}"
elif "_" in folder_name and "." not in folder_name:
plugin_id = folder_name.replace("_", ".", 1)
else:
plugin_id = folder_name
logger.info(f"为插件 {folder_name} 自动生成 ID: {plugin_id}")
manifest["id"] = plugin_id
try:
with open(manifest_path, "w", encoding="utf-8") as file_obj:
json.dump(manifest, file_obj, ensure_ascii=False, indent=2)
except Exception as write_error:
logger.warning(f"无法写入 ID 到 manifest: {write_error}")
return plugin_id
@router.post("/install")
async def install_plugin(request: InstallPluginRequest, maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"收到安装插件请求: {request.plugin_id}")
plugin_id = request.plugin_id
try:
plugin_id = validate_plugin_id(request.plugin_id)
await update_progress(stage="loading", progress=5, message=f"开始安装插件: {plugin_id}", operation="install", plugin_id=plugin_id)
repo_url, owner, repo = parse_repository_url(request.repository_url)
await update_progress(stage="loading", progress=10, message=f"解析仓库信息: {owner}/{repo}", operation="install", plugin_id=plugin_id)
target_path, old_format_path = get_plugin_candidate_paths(plugin_id)
if target_path.exists() or old_format_path.exists():
await update_progress(stage="error", progress=0, message="插件已存在", operation="install", plugin_id=plugin_id, error="插件已安装,请先卸载")
raise HTTPException(status_code=400, detail="插件已安装")
await update_progress(stage="loading", progress=15, message=f"准备克隆到: {target_path}", operation="install", plugin_id=plugin_id)
service = get_git_mirror_service()
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)
else:
result = await service.clone_repository(owner=owner, repo=repo, target_path=target_path, branch=request.branch, custom_url=repo_url, depth=1)
if not result.get("success"):
error_msg = str(result.get("error", "克隆失败"))
await update_progress(stage="error", progress=0, message="克隆仓库失败", operation="install", plugin_id=plugin_id, error=error_msg)
raise HTTPException(status_code=500, detail=error_msg)
await update_progress(stage="loading", progress=85, message="验证插件文件...", operation="install", plugin_id=plugin_id)
manifest_path = target_path / "_manifest.json"
if not manifest_path.exists():
remove_tree(target_path)
await update_progress(stage="error", progress=0, message="插件缺少 _manifest.json", operation="install", plugin_id=plugin_id, error="无效的插件格式")
raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json")
await update_progress(stage="loading", progress=90, message="读取插件配置...", operation="install", plugin_id=plugin_id)
try:
with open(manifest_path, "r", encoding="utf-8") as file_obj:
manifest = json.load(file_obj)
for field in ["manifest_version", "name", "version", "author"]:
if field not in manifest:
raise ValueError(f"缺少必需字段: {field}")
manifest["id"] = plugin_id
with open(manifest_path, "w", encoding="utf-8") as file_obj:
json.dump(manifest, file_obj, ensure_ascii=False, indent=2)
except Exception as e:
remove_tree(target_path)
await update_progress(stage="error", progress=0, message="_manifest.json 无效", operation="install", plugin_id=plugin_id, error=str(e))
raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e
await update_progress(stage="success", progress=100, message=f"成功安装插件: {manifest['name']} v{manifest['version']}", operation="install", plugin_id=plugin_id)
return {"success": True, "message": "插件安装成功", "plugin_id": plugin_id, "plugin_name": manifest["name"], "version": manifest["version"], "path": str(target_path)}
except HTTPException:
raise
except Exception as e:
logger.error(f"安装插件失败: {e}", exc_info=True)
await update_progress(stage="error", progress=0, message="安装失败", operation="install", plugin_id=plugin_id, error=str(e))
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.post("/uninstall")
async def uninstall_plugin(request: UninstallPluginRequest, maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"收到卸载插件请求: {request.plugin_id}")
plugin_id = request.plugin_id
try:
plugin_id = validate_plugin_id(request.plugin_id)
await update_progress(stage="loading", progress=10, message=f"开始卸载插件: {plugin_id}", operation="uninstall", plugin_id=plugin_id)
plugin_path = resolve_installed_plugin_path(plugin_id)
if plugin_path is None:
await update_progress(stage="error", progress=0, message="插件不存在", operation="uninstall", plugin_id=plugin_id, error="插件未安装或已被删除")
raise HTTPException(status_code=404, detail="插件未安装")
await update_progress(stage="loading", progress=30, message=f"正在删除插件文件: {plugin_path}", operation="uninstall", plugin_id=plugin_id)
manifest = load_manifest_json(plugin_path / "_manifest.json")
plugin_name = str(manifest.get("name", plugin_id)) if manifest is not None else plugin_id
await update_progress(stage="loading", progress=50, message=f"正在删除 {plugin_name}...", operation="uninstall", plugin_id=plugin_id)
remove_tree(plugin_path)
logger.info(f"成功卸载插件: {plugin_id} ({plugin_name})")
await update_progress(stage="success", progress=100, message=f"成功卸载插件: {plugin_name}", operation="uninstall", plugin_id=plugin_id)
return {"success": True, "message": "插件卸载成功", "plugin_id": plugin_id, "plugin_name": plugin_name}
except HTTPException:
raise
except PermissionError as e:
logger.error(f"卸载插件失败(权限错误): {e}")
await update_progress(stage="error", progress=0, message="卸载失败", operation="uninstall", plugin_id=plugin_id, error="权限不足,无法删除插件文件")
raise HTTPException(status_code=500, detail="权限不足,无法删除插件文件") from e
except Exception as e:
logger.error(f"卸载插件失败: {e}", exc_info=True)
await update_progress(stage="error", progress=0, message="卸载失败", operation="uninstall", plugin_id=plugin_id, error=str(e))
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.post("/update")
async def update_plugin(request: UpdatePluginRequest, maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"收到更新插件请求: {request.plugin_id}")
plugin_id = request.plugin_id
try:
plugin_id = validate_plugin_id(request.plugin_id)
await update_progress(stage="loading", progress=5, message=f"开始更新插件: {plugin_id}", operation="update", plugin_id=plugin_id)
plugin_path = resolve_installed_plugin_path(plugin_id)
if plugin_path is None:
await update_progress(stage="error", progress=0, message="插件不存在", operation="update", plugin_id=plugin_id, error="插件未安装,请先安装")
raise HTTPException(status_code=404, detail="插件未安装")
manifest = load_manifest_json(plugin_path / "_manifest.json")
old_version = str(manifest.get("version", "unknown")) if manifest is not None else "unknown"
await update_progress(stage="loading", progress=10, message=f"当前版本: {old_version},准备更新...", operation="update", plugin_id=plugin_id)
await update_progress(stage="loading", progress=20, message="正在删除旧版本...", operation="update", plugin_id=plugin_id)
remove_tree(plugin_path)
await update_progress(stage="loading", progress=30, message="正在准备下载新版本...", operation="update", plugin_id=plugin_id)
repo_url, owner, repo = parse_repository_url(request.repository_url)
service = get_git_mirror_service()
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)
else:
result = await service.clone_repository(owner=owner, repo=repo, target_path=plugin_path, branch=request.branch, custom_url=repo_url, depth=1)
if not result.get("success"):
error_msg = str(result.get("error", "克隆失败"))
await update_progress(stage="error", progress=0, message="下载新版本失败", operation="update", plugin_id=plugin_id, error=error_msg)
raise HTTPException(status_code=500, detail=error_msg)
await update_progress(stage="loading", progress=90, message="验证新版本...", operation="update", plugin_id=plugin_id)
new_manifest_path = plugin_path / "_manifest.json"
if not new_manifest_path.exists():
remove_tree(plugin_path)
await update_progress(stage="error", progress=0, message="新版本缺少 _manifest.json", operation="update", plugin_id=plugin_id, error="无效的插件格式")
raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json")
try:
with open(new_manifest_path, "r", encoding="utf-8") as file_obj:
new_manifest = json.load(file_obj)
new_version = str(new_manifest.get("version", "unknown"))
new_name = str(new_manifest.get("name", plugin_id))
logger.info(f"成功更新插件: {plugin_id} {old_version}{new_version}")
await update_progress(stage="success", progress=100, message=f"成功更新 {new_name}: {old_version}{new_version}", operation="update", plugin_id=plugin_id)
return {"success": True, "message": "插件更新成功", "plugin_id": plugin_id, "plugin_name": new_name, "old_version": old_version, "new_version": new_version}
except Exception as e:
remove_tree(plugin_path)
await update_progress(stage="error", progress=0, message="_manifest.json 无效", operation="update", plugin_id=plugin_id, error=str(e))
raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e
except HTTPException:
raise
except Exception as e:
logger.error(f"更新插件失败: {e}", exc_info=True)
await update_progress(stage="error", progress=0, message="更新失败", operation="update", plugin_id=plugin_id, error=str(e))
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.get("/installed")
async def get_installed_plugins(maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info("收到获取已安装插件列表请求")
try:
plugins_dir = get_plugins_dir()
installed_plugins: list[dict[str, Any]] = []
for plugin_path in plugins_dir.iterdir():
if not plugin_path.is_dir():
continue
folder_name = plugin_path.name
if folder_name.startswith(".") or folder_name.startswith("__"):
continue
manifest_path = plugin_path / "_manifest.json"
if not manifest_path.exists():
logger.warning(f"插件文件夹 {folder_name} 缺少 _manifest.json跳过")
continue
try:
with open(manifest_path, "r", encoding="utf-8") as file_obj:
manifest = json.load(file_obj)
if "name" not in manifest or "version" not in manifest:
logger.warning(f"插件文件夹 {folder_name} 的 _manifest.json 格式无效,跳过")
continue
plugin_id = _infer_plugin_id(folder_name, manifest, manifest_path)
installed_plugins.append({"id": plugin_id, "manifest": manifest, "path": str(plugin_path.absolute())})
except json.JSONDecodeError as e:
logger.warning(f"插件 {folder_name} 的 _manifest.json 解析失败: {e}")
except Exception as e:
logger.error(f"读取插件 {folder_name} 信息时出错: {e}")
seen_ids: dict[str, str] = {}
unique_plugins: list[dict[str, Any]] = []
duplicates: list[dict[str, Any]] = []
for plugin in installed_plugins:
plugin_id = str(plugin["id"])
plugin_path = str(plugin["path"])
if plugin_id not in seen_ids:
seen_ids[plugin_id] = plugin_path
unique_plugins.append(plugin)
else:
duplicates.append(plugin)
logger.warning(f"重复插件 {plugin_id}: 保留 {seen_ids[plugin_id]}, 跳过 {plugin_path}")
if duplicates:
logger.warning(f"共检测到 {len(duplicates)} 个重复插件已去重")
logger.info(f"找到 {len(unique_plugins)} 个已安装插件")
return {"success": True, "plugins": unique_plugins, "total": len(unique_plugins)}
except Exception as e:
logger.error(f"获取已安装插件列表失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.get("/local-readme/{plugin_id}")
async def get_local_plugin_readme(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
require_plugin_token(maibot_session)
logger.info(f"获取本地插件 README: {plugin_id}")
try:
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
return {"success": False, "error": "插件未安装"}
for readme_name in ["README.md", "readme.md", "Readme.md", "README.MD"]:
readme_path = plugin_path / readme_name
if readme_path.exists():
try:
with open(readme_path, "r", encoding="utf-8") as file_obj:
readme_content = file_obj.read()
logger.info(f"成功读取本地 README: {readme_path}")
return {"success": True, "data": readme_content}
except Exception as e:
logger.warning(f"读取 {readme_path} 失败: {e}")
return {"success": False, "error": "本地未找到 README 文件"}
except Exception as e:
logger.error(f"获取本地 README 失败: {e}", exc_info=True)
return {"success": False, "error": str(e)}

View File

@@ -0,0 +1,128 @@
import asyncio
import json
from typing import Any, Optional, Set
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from src.common.logger import get_logger
from src.webui.core import get_token_manager
from src.webui.routers.websocket.auth import verify_ws_token
logger = get_logger("webui.plugin_progress")
router = APIRouter()
active_connections: Set[WebSocket] = set()
current_progress: dict[str, Any] = {
"operation": "idle",
"stage": "idle",
"progress": 0,
"message": "",
"error": None,
"plugin_id": None,
"total_plugins": 0,
"loaded_plugins": 0,
}
async def broadcast_progress(progress_data: dict[str, Any]) -> None:
global current_progress
current_progress = progress_data.copy()
if not active_connections:
return
message = json.dumps(progress_data, ensure_ascii=False)
disconnected: set[WebSocket] = set()
for websocket in active_connections:
try:
await websocket.send_text(message)
except Exception as e:
logger.error(f"发送进度更新失败: {e}")
disconnected.add(websocket)
for websocket in disconnected:
active_connections.discard(websocket)
async def update_progress(
stage: str,
progress: int,
message: str,
operation: str = "fetch",
error: Optional[str] = None,
plugin_id: Optional[str] = None,
total_plugins: int = 0,
loaded_plugins: int = 0,
) -> None:
progress_data = {
"operation": operation,
"stage": stage,
"progress": progress,
"message": message,
"error": error,
"plugin_id": plugin_id,
"total_plugins": total_plugins,
"loaded_plugins": loaded_plugins,
"timestamp": asyncio.get_event_loop().time(),
}
await broadcast_progress(progress_data)
logger.debug(f"进度更新: [{operation}] {stage} - {progress}% - {message}")
@router.websocket("/ws/plugin-progress")
async def websocket_plugin_progress(websocket: WebSocket, token: Optional[str] = Query(None)) -> None:
is_authenticated = False
if token and verify_ws_token(token):
is_authenticated = True
logger.debug("插件进度 WebSocket 使用临时 token 认证成功")
if not is_authenticated:
cookie_token = websocket.cookies.get("maibot_session")
if cookie_token:
token_manager = get_token_manager()
if token_manager.verify_token(cookie_token):
is_authenticated = True
logger.debug("插件进度 WebSocket 使用 Cookie 认证成功")
if not is_authenticated and token:
token_manager = get_token_manager()
if token_manager.verify_token(token):
is_authenticated = True
logger.debug("插件进度 WebSocket 使用 session token 认证成功")
if not is_authenticated:
logger.warning("插件进度 WebSocket 连接被拒绝:认证失败")
await websocket.close(code=4001, reason="认证失败,请重新登录")
return
await websocket.accept()
active_connections.add(websocket)
logger.info(f"📡 插件进度 WebSocket 客户端已连接(已认证),当前连接数: {len(active_connections)}")
try:
await websocket.send_text(json.dumps(current_progress, ensure_ascii=False))
while True:
try:
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except Exception as e:
logger.error(f"处理客户端消息时出错: {e}")
break
except WebSocketDisconnect:
active_connections.discard(websocket)
logger.info(f"📡 插件进度 WebSocket 客户端已断开,当前连接数: {len(active_connections)}")
except Exception as e:
logger.error(f"❌ WebSocket 错误: {e}")
active_connections.discard(websocket)
def get_progress_router() -> APIRouter:
return router

View File

@@ -0,0 +1,113 @@
from typing import Any, Optional
from pydantic import BaseModel, Field
class FetchRawFileRequest(BaseModel):
owner: str = Field(..., description="仓库所有者", examples=["MaiM-with-u"])
repo: str = Field(..., description="仓库名称", examples=["plugin-repo"])
branch: str = Field(..., description="分支名称", examples=["main"])
file_path: str = Field(..., description="文件路径", examples=["plugin_details.json"])
mirror_id: Optional[str] = Field(None, description="指定镜像源 ID")
custom_url: Optional[str] = Field(None, description="自定义完整 URL")
class FetchRawFileResponse(BaseModel):
success: bool = Field(..., description="是否成功")
data: Optional[str] = Field(None, description="文件内容")
error: Optional[str] = Field(None, description="错误信息")
mirror_used: Optional[str] = Field(None, description="使用的镜像源")
attempts: int = Field(..., description="尝试次数")
url: Optional[str] = Field(None, description="实际请求的 URL")
class CloneRepositoryRequest(BaseModel):
owner: str = Field(..., description="仓库所有者", examples=["MaiM-with-u"])
repo: str = Field(..., description="仓库名称", examples=["plugin-repo"])
target_path: str = Field(..., description="目标路径(相对于插件目录)")
branch: Optional[str] = Field(None, description="分支名称", examples=["main"])
mirror_id: Optional[str] = Field(None, description="指定镜像源 ID")
custom_url: Optional[str] = Field(None, description="自定义克隆 URL")
depth: Optional[int] = Field(None, description="克隆深度(浅克隆)", ge=1)
class CloneRepositoryResponse(BaseModel):
success: bool = Field(..., description="是否成功")
path: Optional[str] = Field(None, description="克隆路径")
error: Optional[str] = Field(None, description="错误信息")
mirror_used: Optional[str] = Field(None, description="使用的镜像源")
attempts: int = Field(..., description="尝试次数")
url: Optional[str] = Field(None, description="实际克隆的 URL")
message: Optional[str] = Field(None, description="附加信息")
class MirrorConfigResponse(BaseModel):
id: str = Field(..., description="镜像源 ID")
name: str = Field(..., description="镜像源名称")
raw_prefix: str = Field(..., description="Raw 文件前缀")
clone_prefix: str = Field(..., description="克隆前缀")
enabled: bool = Field(..., description="是否启用")
priority: int = Field(..., description="优先级(数字越小优先级越高)")
class AvailableMirrorsResponse(BaseModel):
mirrors: list[MirrorConfigResponse] = Field(..., description="镜像源列表")
default_priority: list[str] = Field(..., description="默认优先级顺序ID 列表)")
class AddMirrorRequest(BaseModel):
id: str = Field(..., description="镜像源 ID", examples=["custom-mirror"])
name: str = Field(..., description="镜像源名称", examples=["自定义镜像源"])
raw_prefix: str = Field(..., description="Raw 文件前缀", examples=["https://example.com/raw"])
clone_prefix: str = Field(..., description="克隆前缀", examples=["https://example.com/clone"])
enabled: bool = Field(True, description="是否启用")
priority: Optional[int] = Field(None, description="优先级")
class UpdateMirrorRequest(BaseModel):
name: Optional[str] = Field(None, description="镜像源名称")
raw_prefix: Optional[str] = Field(None, description="Raw 文件前缀")
clone_prefix: Optional[str] = Field(None, description="克隆前缀")
enabled: Optional[bool] = Field(None, description="是否启用")
priority: Optional[int] = Field(None, description="优先级")
class GitStatusResponse(BaseModel):
installed: bool = Field(..., description="是否已安装 Git")
version: Optional[str] = Field(None, description="Git 版本号")
path: Optional[str] = Field(None, description="Git 可执行文件路径")
error: Optional[str] = Field(None, description="错误信息")
class InstallPluginRequest(BaseModel):
plugin_id: str = Field(..., description="插件 ID")
repository_url: str = Field(..., description="插件仓库 URL")
branch: Optional[str] = Field("main", description="分支名称")
mirror_id: Optional[str] = Field(None, description="指定镜像源 ID")
class VersionResponse(BaseModel):
version: str = Field(..., description="麦麦版本号")
version_major: int = Field(..., description="主版本号")
version_minor: int = Field(..., description="次版本号")
version_patch: int = Field(..., description="补丁版本号")
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="分支名称")
mirror_id: Optional[str] = Field(None, description="指定镜像源 ID")
class UpdatePluginConfigRequest(BaseModel):
enabled: Optional[bool] = None
config: Optional[dict[str, Any]] = None
class UpdatePluginRawConfigRequest(BaseModel):
config: str = Field(..., description="原始 TOML 配置内容")

View File

@@ -0,0 +1,221 @@
from datetime import datetime
from pathlib import Path
from typing import Any, Optional, cast, get_origin
import json
import os
import re
import shutil
import stat
from fastapi import HTTPException
from src.common.logger import get_logger
from src.core.config_types import ConfigField
from src.webui.core import get_token_manager
logger = get_logger("webui.plugin_routes")
def require_plugin_token(maibot_session: Optional[str]) -> str:
token_manager = get_token_manager()
if not maibot_session or not token_manager.verify_token(maibot_session):
raise HTTPException(status_code=401, detail="未授权:无效的访问令牌")
return maibot_session
def validate_safe_path(user_path: str, base_path: Path) -> Path:
base_resolved = base_path.resolve()
if any(pattern in user_path for pattern in ["..", "\x00"]):
logger.warning(f"检测到可疑路径: {user_path}")
raise HTTPException(status_code=400, detail="路径包含非法字符")
if user_path.startswith("/") or user_path.startswith("\\") or (len(user_path) > 1 and user_path[1] == ":"):
logger.warning(f"检测到绝对路径: {user_path}")
raise HTTPException(status_code=400, detail="不允许使用绝对路径")
target_path = (base_path / user_path).resolve()
try:
target_path.relative_to(base_resolved)
except ValueError as e:
logger.warning(f"路径遍历攻击检测: {user_path} -> {target_path}")
raise HTTPException(status_code=400, detail="路径超出允许范围") from e
return target_path
def validate_plugin_id(plugin_id: str) -> str:
if not plugin_id or not plugin_id.strip():
logger.warning("非法插件 ID: 空字符串")
raise HTTPException(status_code=400, detail="插件 ID 不能为空")
for pattern in ["/", "\\", "\x00", "..", "\n", "\r", "\t"]:
if pattern in plugin_id:
logger.warning(f"非法插件 ID 格式: {plugin_id} (包含危险字符)")
raise HTTPException(status_code=400, detail="插件 ID 包含非法字符")
if plugin_id.startswith(".") or plugin_id.endswith("."):
logger.warning(f"非法插件 ID: {plugin_id}")
raise HTTPException(status_code=400, detail="插件 ID 不能以点开头或结尾")
if plugin_id in {".", ".."}:
logger.warning(f"非法插件 ID: {plugin_id}")
raise HTTPException(status_code=400, detail="插件 ID 不能为特殊目录名")
return plugin_id
def parse_version(version_str: str) -> tuple[int, int, int]:
base_version = re.split(r"[-.](?:snapshot|dev|alpha|beta|rc)", version_str, flags=re.IGNORECASE)[0]
parts = base_version.split(".")
if len(parts) < 3:
parts.extend(["0"] * (3 - len(parts)))
try:
return int(parts[0]), int(parts[1]), int(parts[2])
except (ValueError, IndexError):
logger.warning(f"无法解析版本号: {version_str},返回默认值 (0, 0, 0)")
return 0, 0, 0
def deep_merge(dst: dict[str, Any], src: dict[str, Any]) -> None:
for key, value in src.items():
if key in dst and isinstance(dst[key], dict) and isinstance(value, dict):
deep_merge(dst[key], value)
else:
dst[key] = value
def normalize_dotted_keys(obj: dict[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = {}
dotted_items: list[tuple[str, Any]] = []
for key, value in obj.items():
if "." in key:
dotted_items.append((key, value))
else:
result[key] = normalize_dotted_keys(value) if isinstance(value, dict) else value
for dotted_key, value in dotted_items:
normalized_value = normalize_dotted_keys(value) if isinstance(value, dict) else value
parts = dotted_key.split(".")
if "" in parts:
logger.warning(f"键路径包含空段: '{dotted_key}'")
parts = [part for part in parts if part]
if not parts:
logger.warning(f"忽略空键路径: '{dotted_key}'")
continue
current = result
for index, part in enumerate(parts[:-1]):
if part in current and not isinstance(current[part], dict):
path_ctx = ".".join(parts[: index + 1])
logger.warning(f"键冲突:{part} 已存在且非字典,覆盖为字典以展开 {dotted_key} (路径 {path_ctx})")
current[part] = {}
current = current.setdefault(part, {})
last_part = parts[-1]
if last_part in current and isinstance(current[last_part], dict) and isinstance(normalized_value, dict):
deep_merge(current[last_part], normalized_value)
else:
current[last_part] = normalized_value
return result
def coerce_types(schema_part: dict[str, Any], config_part: dict[str, Any]) -> None:
def is_list_type(tp: Any) -> bool:
origin = get_origin(tp)
return tp is list or origin is list
for key, schema_val in schema_part.items():
if key not in config_part:
continue
value = config_part[key]
if isinstance(schema_val, ConfigField):
if is_list_type(schema_val.type) and isinstance(value, str):
config_part[key] = [item.strip() for item in value.split(",") if item.strip()]
elif isinstance(schema_val, dict) and isinstance(value, dict):
coerce_types(schema_val, value)
def find_plugin_instance(plugin_id: str) -> Optional[Any]:
from src.plugin_runtime.integration import get_plugin_runtime_manager
manager = get_plugin_runtime_manager()
for supervisor in manager.supervisors:
registered = supervisor._registered_plugins.get(plugin_id)
if registered is not None:
return registered
return None
def get_plugins_dir() -> Path:
plugins_dir = Path("plugins").resolve()
plugins_dir.mkdir(exist_ok=True)
return plugins_dir
def get_plugin_candidate_paths(plugin_id: str) -> tuple[Path, Path]:
plugins_dir = get_plugins_dir()
folder_name = plugin_id.replace(".", "_")
return validate_safe_path(folder_name, plugins_dir), validate_safe_path(plugin_id, plugins_dir)
def resolve_installed_plugin_path(plugin_id: str) -> Optional[Path]:
new_format_path, old_format_path = get_plugin_candidate_paths(plugin_id)
if new_format_path.exists():
return new_format_path
return old_format_path if old_format_path.exists() else None
def parse_repository_url(repository_url: str) -> tuple[str, str, str]:
repo_url = repository_url.rstrip("/").removesuffix(".git")
parts = repo_url.split("/")
if len(parts) < 2:
raise HTTPException(status_code=400, detail="无效的仓库 URL")
return repo_url, parts[-2], parts[-1]
def load_manifest_json(manifest_path: Path) -> Optional[dict[str, Any]]:
if not manifest_path.exists():
return None
try:
with open(manifest_path, "r", encoding="utf-8") as file_obj:
return cast(dict[str, Any], json.load(file_obj))
except Exception:
return None
def iter_plugin_directories() -> list[Path]:
return [path for path in get_plugins_dir().iterdir() if path.is_dir()]
def find_plugin_path_by_id(plugin_id: str) -> Optional[Path]:
for plugin_path in iter_plugin_directories():
manifest = load_manifest_json(plugin_path / "_manifest.json")
if manifest is not None and (manifest.get("id") == plugin_id or plugin_path.name == plugin_id):
return plugin_path
return None
def backup_file(file_path: Path, action: str, move_file: bool = False) -> Optional[Path]:
if not file_path.exists():
return None
backup_name = f"{file_path.name}.{action}.{datetime.now().strftime('%Y%m%d%H%M%S')}"
backup_path = file_path.parent / backup_name
if move_file:
shutil.move(file_path, backup_path)
else:
shutil.copy(file_path, backup_path)
return backup_path
def remove_tree(path: Path) -> None:
def remove_readonly(func: Any, target_path: str, _: Any) -> None:
os.chmod(target_path, stat.S_IWRITE)
func(target_path)
shutil.rmtree(path, onerror=remove_readonly)