WebUI 前端 & 后端超级大重构
This commit is contained in:
17
src/webui/routers/plugin/__init__.py
Normal file
17
src/webui/routers/plugin/__init__.py
Normal 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"]
|
||||
205
src/webui/routers/plugin/catalog.py
Normal file
205
src/webui/routers/plugin/catalog.py
Normal 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
|
||||
333
src/webui/routers/plugin/config_routes.py
Normal file
333
src/webui/routers/plugin/config_routes.py
Normal 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
|
||||
302
src/webui/routers/plugin/management.py
Normal file
302
src/webui/routers/plugin/management.py
Normal 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)}
|
||||
128
src/webui/routers/plugin/progress.py
Normal file
128
src/webui/routers/plugin/progress.py
Normal 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
|
||||
113
src/webui/routers/plugin/schemas.py
Normal file
113
src/webui/routers/plugin/schemas.py
Normal 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 配置内容")
|
||||
221
src/webui/routers/plugin/support.py
Normal file
221
src/webui/routers/plugin/support.py
Normal 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)
|
||||
Reference in New Issue
Block a user