refactor: 将 A_Memorix 重构为主线长期记忆子系统并重建管理界面
- 将 A_Memorix 从旧 submodule / 插件形态迁入主线源码,主体落到 src/A_memorix - 调整主程序接入方式,使 A_Memorix 作为源码内长期记忆子系统运行 - 回收父项目插件体系中针对 A_Memorix 的特判,减少对 plugin 通用层的侵入 - 将长期记忆配置、运行时、自检、导入、调优等能力收口到 memory 路由与主线服务层 - 重做长期记忆控制台与图谱页面,按 MaiBot 现有 dashboard 风格接入 - 补充实体关系图与证据视图双视图能力,支持查看节点、关系、段落及其证据链路 - 新增长期记忆配置编辑器与 memory-api,支持主线内配置管理 - 补齐删除管理能力:删除预览、混合删除、来源批量删除、删除操作恢复 - 优化删除预览与删除操作详情的前端展示,支持分页、检索,并以实体名/关系内容/段落摘要替代单纯 hash 展示 - 修复图谱与控制台相关前端问题,包括证据视图切换、查询触发时机、删除弹层空值保护等 - 新增或更新 A_Memorix 相关测试、WebUI 路由测试、前端 vitest 测试与辅助验证脚本 - 移除旧 plugins/A_memorix、.gitmodules 及相关历史维护文档
This commit is contained in:
@@ -6,14 +6,16 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, Form, Query, UploadFile
|
||||
import tomlkit
|
||||
from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.A_memorix.host_service import a_memorix_host_service
|
||||
from src.services.memory_service import MemorySearchResult, memory_service
|
||||
from src.webui.dependencies import require_auth
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/webui/memory", tags=["memory"], dependencies=[Depends(require_auth)])
|
||||
router = APIRouter(prefix="/memory", tags=["memory"], dependencies=[Depends(require_auth)])
|
||||
compat_router = APIRouter(prefix="/api", tags=["memory-compat"], dependencies=[Depends(require_auth)])
|
||||
STAGING_ROOT = Path(__file__).resolve().parents[3] / "data" / "memory_upload_staging"
|
||||
|
||||
@@ -82,6 +84,14 @@ class AutoSaveRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class MemoryConfigUpdateRequest(BaseModel):
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MemoryRawConfigUpdateRequest(BaseModel):
|
||||
config: str = ""
|
||||
|
||||
|
||||
class TuningApplyProfileRequest(BaseModel):
|
||||
profile: dict[str, Any] = Field(default_factory=dict)
|
||||
reason: str = "manual"
|
||||
@@ -158,6 +168,44 @@ async def _graph_get(limit: int) -> dict:
|
||||
return await memory_service.graph_admin(action="get_graph", limit=limit)
|
||||
|
||||
|
||||
async def _graph_get_node_detail(
|
||||
node_id: str,
|
||||
*,
|
||||
relation_limit: int,
|
||||
paragraph_limit: int,
|
||||
evidence_node_limit: int,
|
||||
) -> dict:
|
||||
payload = await memory_service.graph_admin(
|
||||
action="node_detail",
|
||||
node_id=node_id,
|
||||
relation_limit=relation_limit,
|
||||
paragraph_limit=paragraph_limit,
|
||||
evidence_node_limit=evidence_node_limit,
|
||||
)
|
||||
if not bool(payload.get("success", False)):
|
||||
raise HTTPException(status_code=404, detail=str(payload.get("error", "未找到节点详情")))
|
||||
return payload
|
||||
|
||||
|
||||
async def _graph_get_edge_detail(
|
||||
source: str,
|
||||
target: str,
|
||||
*,
|
||||
paragraph_limit: int,
|
||||
evidence_node_limit: int,
|
||||
) -> dict:
|
||||
payload = await memory_service.graph_admin(
|
||||
action="edge_detail",
|
||||
source=source,
|
||||
target=target,
|
||||
paragraph_limit=paragraph_limit,
|
||||
evidence_node_limit=evidence_node_limit,
|
||||
)
|
||||
if not bool(payload.get("success", False)):
|
||||
raise HTTPException(status_code=404, detail=str(payload.get("error", "未找到边详情")))
|
||||
return payload
|
||||
|
||||
|
||||
async def _graph_create_node(payload: NodeRequest) -> dict:
|
||||
return await memory_service.graph_admin(action="create_node", name=payload.name)
|
||||
|
||||
@@ -325,6 +373,42 @@ async def _runtime_auto_save(enabled: bool | None = None) -> dict:
|
||||
return await memory_service.runtime_admin(action="set_auto_save", enabled=enabled)
|
||||
|
||||
|
||||
async def _memory_config_schema() -> dict:
|
||||
return {
|
||||
"success": True,
|
||||
"schema": a_memorix_host_service.get_config_schema(),
|
||||
"path": str(a_memorix_host_service.get_config_path()),
|
||||
}
|
||||
|
||||
|
||||
async def _memory_config_get() -> dict:
|
||||
return {
|
||||
"success": True,
|
||||
"config": a_memorix_host_service.get_config(),
|
||||
"path": str(a_memorix_host_service.get_config_path()),
|
||||
}
|
||||
|
||||
|
||||
async def _memory_config_get_raw() -> dict:
|
||||
return {
|
||||
"success": True,
|
||||
"config": a_memorix_host_service.get_raw_config(),
|
||||
"path": str(a_memorix_host_service.get_config_path()),
|
||||
}
|
||||
|
||||
|
||||
async def _memory_config_update(payload: MemoryConfigUpdateRequest) -> dict:
|
||||
return await a_memorix_host_service.update_config(payload.config)
|
||||
|
||||
|
||||
async def _memory_config_update_raw(payload: MemoryRawConfigUpdateRequest) -> dict:
|
||||
try:
|
||||
tomlkit.loads(payload.config)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"TOML 格式错误: {exc}") from exc
|
||||
return await a_memorix_host_service.update_raw_config(payload.config)
|
||||
|
||||
|
||||
async def _maintenance_recycle_bin(limit: int) -> dict:
|
||||
return await memory_service.get_recycle_bin(limit=limit)
|
||||
|
||||
@@ -565,6 +649,36 @@ async def get_memory_graph(limit: int = Query(200, ge=1, le=5000)):
|
||||
return await _graph_get(limit)
|
||||
|
||||
|
||||
@router.get("/graph/node-detail")
|
||||
async def get_memory_graph_node_detail(
|
||||
node_id: str = Query(..., min_length=1),
|
||||
relation_limit: int = Query(20, ge=1, le=100),
|
||||
paragraph_limit: int = Query(20, ge=1, le=100),
|
||||
evidence_node_limit: int = Query(80, ge=12, le=200),
|
||||
):
|
||||
return await _graph_get_node_detail(
|
||||
node_id,
|
||||
relation_limit=relation_limit,
|
||||
paragraph_limit=paragraph_limit,
|
||||
evidence_node_limit=evidence_node_limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/graph/edge-detail")
|
||||
async def get_memory_graph_edge_detail(
|
||||
source: str = Query(..., min_length=1),
|
||||
target: str = Query(..., min_length=1),
|
||||
paragraph_limit: int = Query(20, ge=1, le=100),
|
||||
evidence_node_limit: int = Query(80, ge=12, le=200),
|
||||
):
|
||||
return await _graph_get_edge_detail(
|
||||
source,
|
||||
target,
|
||||
paragraph_limit=paragraph_limit,
|
||||
evidence_node_limit=evidence_node_limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/graph/node")
|
||||
async def create_memory_node(payload: NodeRequest):
|
||||
return await _graph_create_node(payload)
|
||||
@@ -703,6 +817,31 @@ async def save_memory_runtime():
|
||||
return await _runtime_save()
|
||||
|
||||
|
||||
@router.get("/config/schema")
|
||||
async def get_memory_config_schema():
|
||||
return await _memory_config_schema()
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_memory_config():
|
||||
return await _memory_config_get()
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def update_memory_config(payload: MemoryConfigUpdateRequest):
|
||||
return await _memory_config_update(payload)
|
||||
|
||||
|
||||
@router.get("/config/raw")
|
||||
async def get_memory_config_raw():
|
||||
return await _memory_config_get_raw()
|
||||
|
||||
|
||||
@router.put("/config/raw")
|
||||
async def update_memory_config_raw(payload: MemoryRawConfigUpdateRequest):
|
||||
return await _memory_config_update_raw(payload)
|
||||
|
||||
|
||||
@router.get("/runtime/config")
|
||||
async def get_memory_runtime_config():
|
||||
return await _runtime_config()
|
||||
|
||||
@@ -13,6 +13,7 @@ from .support import (
|
||||
coerce_types,
|
||||
find_plugin_instance,
|
||||
find_plugin_path_by_id,
|
||||
get_plugin_config_path,
|
||||
normalize_dotted_keys,
|
||||
require_plugin_token,
|
||||
resolve_plugin_file_path,
|
||||
@@ -23,6 +24,20 @@ logger = get_logger("webui.plugin_routes")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_builtin_data(obj: Any) -> Any:
|
||||
if hasattr(obj, "unwrap"):
|
||||
try:
|
||||
obj = obj.unwrap()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return {str(key): _to_builtin_data(value) for key, value in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_to_builtin_data(value) for value in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Dict[str, Any]:
|
||||
schema: Dict[str, Any] = {
|
||||
"plugin_id": plugin_id,
|
||||
@@ -142,7 +157,7 @@ async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str]
|
||||
logger.warning(f"读取 config_schema.json 失败,回退到自动推断: {e}")
|
||||
|
||||
current_config: Any = {}
|
||||
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
|
||||
config_path = get_plugin_config_path(plugin_id, plugin_path)
|
||||
if config_path.exists():
|
||||
with open(config_path, "r", encoding="utf-8") as file_obj:
|
||||
current_config = tomlkit.load(file_obj)
|
||||
@@ -165,7 +180,7 @@ async def get_plugin_config_raw(plugin_id: str, maibot_session: Optional[str] =
|
||||
if plugin_path is None:
|
||||
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
|
||||
|
||||
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
|
||||
config_path = get_plugin_config_path(plugin_id, plugin_path)
|
||||
if not config_path.exists():
|
||||
return {"success": True, "config": "", "message": "配置文件不存在"}
|
||||
|
||||
@@ -192,7 +207,7 @@ async def update_plugin_config_raw(
|
||||
if plugin_path is None:
|
||||
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
|
||||
|
||||
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
|
||||
config_path = get_plugin_config_path(plugin_id, plugin_path)
|
||||
try:
|
||||
tomlkit.loads(request.config)
|
||||
except Exception as e:
|
||||
@@ -202,6 +217,7 @@ async def update_plugin_config_raw(
|
||||
if backup_path is not None:
|
||||
logger.info(f"已备份配置文件: {backup_path}")
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, "w", encoding="utf-8") as file_obj:
|
||||
file_obj.write(request.config)
|
||||
|
||||
@@ -224,13 +240,13 @@ async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cook
|
||||
if plugin_path is None:
|
||||
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
|
||||
|
||||
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
|
||||
config_path = get_plugin_config_path(plugin_id, plugin_path)
|
||||
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)}
|
||||
return {"success": True, "config": _to_builtin_data(config)}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -259,11 +275,12 @@ async def update_plugin_config(
|
||||
if plugin_path is None:
|
||||
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
|
||||
|
||||
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
|
||||
config_path = get_plugin_config_path(plugin_id, plugin_path)
|
||||
backup_path = backup_file(config_path, "backup")
|
||||
if backup_path is not None:
|
||||
logger.info(f"已备份配置文件: {backup_path}")
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
save_toml_with_format(config_data, str(config_path))
|
||||
logger.info(f"已更新插件配置: {plugin_id}")
|
||||
return {"success": True, "message": "配置已保存", "note": "配置更改将自动热更新到对应插件"}
|
||||
@@ -284,7 +301,7 @@ async def reset_plugin_config(plugin_id: str, maibot_session: Optional[str] = Co
|
||||
if plugin_path is None:
|
||||
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
|
||||
|
||||
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
|
||||
config_path = get_plugin_config_path(plugin_id, plugin_path)
|
||||
if not config_path.exists():
|
||||
return {"success": True, "message": "配置文件不存在,无需重置"}
|
||||
|
||||
@@ -308,7 +325,7 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N
|
||||
if plugin_path is None:
|
||||
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
|
||||
|
||||
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
|
||||
config_path = get_plugin_config_path(plugin_id, plugin_path)
|
||||
config = tomlkit.document()
|
||||
if config_path.exists():
|
||||
with open(config_path, "r", encoding="utf-8") as file_obj:
|
||||
@@ -321,6 +338,7 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N
|
||||
current_enabled = bool(plugin_config.get("enabled", True))
|
||||
new_enabled = not current_enabled
|
||||
plugin_config["enabled"] = new_enabled
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
save_toml_with_format(config, str(config_path))
|
||||
|
||||
status = "启用" if new_enabled else "禁用"
|
||||
|
||||
@@ -195,6 +195,9 @@ def get_plugins_dir() -> Path:
|
||||
plugins_dir.mkdir(exist_ok=True)
|
||||
return plugins_dir
|
||||
|
||||
def get_plugin_config_path(plugin_id: str, plugin_path: Path) -> Path:
|
||||
return resolve_plugin_file_path(plugin_path, "config.toml")
|
||||
|
||||
|
||||
def get_plugin_candidate_paths(plugin_id: str) -> Tuple[Path, Path]:
|
||||
plugins_dir = get_plugins_dir()
|
||||
|
||||
Reference in New Issue
Block a user