fix:优化图片识别,优化webui配置和排版,优化聊天流监控,新增mcp显示,新增prompt修改面板,优化插件状态显示,优化长期记忆控制台,
This commit is contained in:
@@ -8,7 +8,9 @@ from pathlib import Path
|
||||
from typing import Annotated, Any, Dict, List, Tuple
|
||||
|
||||
import tomlkit
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.config.config import CONFIG_DIR, PROJECT_ROOT, Config, ModelConfig
|
||||
@@ -47,9 +49,76 @@ ConfigBody = Annotated[Dict[str, Any], Body()]
|
||||
SectionBody = Annotated[Any, Body()]
|
||||
RawContentBody = Annotated[str, Body(embed=True)]
|
||||
PathBody = Annotated[Dict[str, str], Body()]
|
||||
PromptContentBody = Annotated[str, Body(embed=True)]
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"], dependencies=[Depends(require_auth)])
|
||||
|
||||
PROMPTS_DIR = PROJECT_ROOT / "prompts"
|
||||
MAISAKA_PROMPT_PREVIEW_DIR = (PROJECT_ROOT / "logs" / "maisaka_prompt").resolve()
|
||||
|
||||
|
||||
class PromptFileInfo(BaseModel):
|
||||
"""Prompt 文件信息。"""
|
||||
|
||||
name: str = Field(..., description="Prompt 文件名")
|
||||
size: int = Field(..., description="文件大小")
|
||||
modified_at: float = Field(..., description="最后修改时间戳")
|
||||
|
||||
|
||||
class PromptCatalogResponse(BaseModel):
|
||||
"""Prompt 目录响应。"""
|
||||
|
||||
success: bool = True
|
||||
languages: List[str]
|
||||
files: Dict[str, List[PromptFileInfo]]
|
||||
|
||||
|
||||
class PromptFileResponse(BaseModel):
|
||||
"""Prompt 文件内容响应。"""
|
||||
|
||||
success: bool = True
|
||||
language: str
|
||||
filename: str
|
||||
content: str
|
||||
|
||||
|
||||
def _safe_prompt_path(language: str, filename: str) -> Path:
|
||||
"""校验并解析 prompts 下的文件路径。"""
|
||||
|
||||
normalized_language = language.strip()
|
||||
normalized_filename = filename.strip()
|
||||
|
||||
if not normalized_language or any(part in normalized_language for part in ("..", "/", "\\")):
|
||||
raise HTTPException(status_code=400, detail="无效的 Prompt 语言目录")
|
||||
if not normalized_filename.endswith(".prompt") or any(part in normalized_filename for part in ("..", "/", "\\")):
|
||||
raise HTTPException(status_code=400, detail="无效的 Prompt 文件名")
|
||||
|
||||
prompt_path = (PROMPTS_DIR / normalized_language / normalized_filename).resolve()
|
||||
prompts_root = PROMPTS_DIR.resolve()
|
||||
try:
|
||||
prompt_path.relative_to(prompts_root)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Prompt 路径越界") from exc
|
||||
return prompt_path
|
||||
|
||||
|
||||
def _safe_maisaka_prompt_preview_path(relative_path: str) -> Path:
|
||||
"""校验并解析 MaiSaka Prompt HTML 预览路径。"""
|
||||
|
||||
normalized_path = relative_path.strip().replace("\\", "/")
|
||||
if not normalized_path or normalized_path.startswith("/") or ".." in Path(normalized_path).parts:
|
||||
raise HTTPException(status_code=400, detail="无效的 Prompt 预览路径")
|
||||
|
||||
preview_path = (MAISAKA_PROMPT_PREVIEW_DIR / normalized_path).resolve()
|
||||
try:
|
||||
preview_path.relative_to(MAISAKA_PROMPT_PREVIEW_DIR)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Prompt 预览路径越界") from exc
|
||||
|
||||
if preview_path.suffix.lower() != ".html":
|
||||
raise HTTPException(status_code=400, detail="只允许打开 HTML Prompt 预览")
|
||||
return preview_path
|
||||
|
||||
|
||||
def _toml_to_plain_dict(obj: Any) -> Any:
|
||||
"""递归转换 tomlkit 文档/Table 为纯 Python 字典,避免 from_dict 触发 tomlkit __setitem__"""
|
||||
@@ -63,6 +132,87 @@ def _toml_to_plain_dict(obj: Any) -> Any:
|
||||
# ===== 架构获取接口 =====
|
||||
|
||||
|
||||
@router.get("/prompts", response_model=PromptCatalogResponse)
|
||||
async def list_prompt_files():
|
||||
"""列出 prompts 目录下的语言和 Prompt 文件。"""
|
||||
|
||||
try:
|
||||
if not PROMPTS_DIR.exists():
|
||||
return PromptCatalogResponse(languages=[], files={})
|
||||
|
||||
languages: List[str] = []
|
||||
files: Dict[str, List[PromptFileInfo]] = {}
|
||||
for language_dir in sorted(PROMPTS_DIR.iterdir(), key=lambda item: item.name):
|
||||
if not language_dir.is_dir():
|
||||
continue
|
||||
|
||||
language = language_dir.name
|
||||
prompt_files: List[PromptFileInfo] = []
|
||||
for prompt_file in sorted(language_dir.glob("*.prompt"), key=lambda item: item.name):
|
||||
stat = prompt_file.stat()
|
||||
prompt_files.append(
|
||||
PromptFileInfo(
|
||||
name=prompt_file.name,
|
||||
size=stat.st_size,
|
||||
modified_at=stat.st_mtime,
|
||||
)
|
||||
)
|
||||
|
||||
languages.append(language)
|
||||
files[language] = prompt_files
|
||||
|
||||
return PromptCatalogResponse(languages=languages, files=files)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"列出 Prompt 文件失败: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"列出 Prompt 文件失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.get("/prompts/{language}/{filename}", response_model=PromptFileResponse)
|
||||
async def get_prompt_file(language: str, filename: str):
|
||||
"""读取指定语言下的 Prompt 文件内容。"""
|
||||
|
||||
prompt_path = _safe_prompt_path(language, filename)
|
||||
if not prompt_path.exists() or not prompt_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Prompt 文件不存在")
|
||||
|
||||
try:
|
||||
content = prompt_path.read_text(encoding="utf-8")
|
||||
return PromptFileResponse(language=language, filename=filename, content=content)
|
||||
except Exception as e:
|
||||
logger.error(f"读取 Prompt 文件失败: {prompt_path} {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"读取 Prompt 文件失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.put("/prompts/{language}/{filename}", response_model=PromptFileResponse)
|
||||
async def update_prompt_file(language: str, filename: str, content: PromptContentBody):
|
||||
"""更新指定语言下的 Prompt 文件内容。"""
|
||||
|
||||
prompt_path = _safe_prompt_path(language, filename)
|
||||
if not prompt_path.parent.exists() or not prompt_path.parent.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Prompt 语言目录不存在")
|
||||
if not prompt_path.exists() or not prompt_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Prompt 文件不存在")
|
||||
|
||||
try:
|
||||
prompt_path.write_text(content, encoding="utf-8", newline="\n")
|
||||
return PromptFileResponse(language=language, filename=filename, content=content)
|
||||
except Exception as e:
|
||||
logger.error(f"保存 Prompt 文件失败: {prompt_path} {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"保存 Prompt 文件失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.get("/maisaka-prompt-preview", response_class=FileResponse)
|
||||
async def get_maisaka_prompt_preview(path: str = Query(..., description="logs/maisaka_prompt 下的相对 HTML 路径")):
|
||||
"""打开 MaiSaka 监控中生成的 Prompt HTML 预览。"""
|
||||
|
||||
preview_path = _safe_maisaka_prompt_preview_path(path)
|
||||
if not preview_path.exists() or not preview_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Prompt 预览文件不存在")
|
||||
return FileResponse(preview_path, media_type="text/html")
|
||||
|
||||
|
||||
@router.get("/schema/bot")
|
||||
async def get_bot_config_schema():
|
||||
"""获取麦麦主程序配置架构"""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException
|
||||
import json
|
||||
import tomlkit
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.webui.services.git_mirror_service import get_git_mirror_service
|
||||
@@ -12,6 +13,7 @@ from .schemas import InstallPluginRequest, UninstallPluginRequest, UpdatePluginR
|
||||
from .support import (
|
||||
find_plugin_path_by_id,
|
||||
get_plugin_candidate_paths,
|
||||
get_plugin_config_path,
|
||||
iter_plugin_directories,
|
||||
load_manifest_json,
|
||||
parse_repository_url,
|
||||
@@ -64,6 +66,39 @@ def _infer_plugin_id(folder_name: str, manifest: Dict[str, Any], manifest_path:
|
||||
return plugin_id
|
||||
|
||||
|
||||
def _coerce_enabled_value(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() not in {"false", "0", "no", "off", "disabled"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _read_plugin_enabled(plugin_id: str, plugin_path: Path) -> bool:
|
||||
try:
|
||||
config_path = get_plugin_config_path(plugin_id, plugin_path)
|
||||
if not config_path.exists():
|
||||
return True
|
||||
with open(config_path, "r", encoding="utf-8") as file_obj:
|
||||
config = tomlkit.load(file_obj).unwrap()
|
||||
except Exception as exc:
|
||||
logger.warning(f"读取插件 {plugin_id} 启用状态失败,将按启用处理: {exc}")
|
||||
return True
|
||||
|
||||
plugin_config = config.get("plugin") if isinstance(config, dict) else None
|
||||
if not isinstance(plugin_config, dict):
|
||||
return True
|
||||
return _coerce_enabled_value(plugin_config.get("enabled", True))
|
||||
|
||||
|
||||
def _get_runtime_plugin_load_statuses() -> Dict[str, str]:
|
||||
try:
|
||||
from src.plugin_runtime.integration import get_plugin_runtime_manager
|
||||
|
||||
return get_plugin_runtime_manager().get_plugin_load_statuses()
|
||||
except Exception as exc:
|
||||
logger.warning(f"获取插件运行时加载状态失败: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
async def install_plugin(request: InstallPluginRequest, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]:
|
||||
require_plugin_token(maibot_session)
|
||||
@@ -401,6 +436,7 @@ async def get_installed_plugins(maibot_session: Optional[str] = Cookie(None)) ->
|
||||
|
||||
try:
|
||||
installed_plugins: List[Dict[str, Any]] = []
|
||||
runtime_statuses = _get_runtime_plugin_load_statuses()
|
||||
for plugin_path in iter_plugin_directories():
|
||||
folder_name = plugin_path.name
|
||||
if folder_name.startswith(".") or folder_name.startswith("__"):
|
||||
@@ -420,7 +456,19 @@ async def get_installed_plugins(maibot_session: Optional[str] = Cookie(None)) ->
|
||||
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())})
|
||||
enabled = _read_plugin_enabled(plugin_id, plugin_path)
|
||||
load_status = runtime_statuses.get(plugin_id, "unknown")
|
||||
installed_plugins.append(
|
||||
{
|
||||
"id": plugin_id,
|
||||
"manifest": manifest,
|
||||
"path": str(plugin_path.absolute()),
|
||||
"enabled": enabled,
|
||||
"disabled": not enabled,
|
||||
"loaded": load_status == "success",
|
||||
"load_status": "disabled" if not enabled else load_status,
|
||||
}
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"插件 {folder_name} 的 _manifest.json 解析失败: {e}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -106,7 +106,7 @@ def validate_plugin_id(plugin_id: str) -> str:
|
||||
|
||||
|
||||
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]
|
||||
base_version = re.split(r"[-.](?:snapshot|dev|pre|alpha|beta|rc)", version_str, flags=re.IGNORECASE)[0]
|
||||
parts = base_version.split(".")
|
||||
if len(parts) < 3:
|
||||
parts.extend(["0"] * (3 - len(parts)))
|
||||
|
||||
Reference in New Issue
Block a user