fix:优化图片识别,优化webui配置和排版,优化聊天流监控,新增mcp显示,新增prompt修改面板,优化插件状态显示,优化长期记忆控制台,

This commit is contained in:
SengokuCola
2026-05-04 16:25:31 +08:00
parent c5cd47adc2
commit 120acb835f
51 changed files with 1764 additions and 493 deletions

View File

@@ -13,6 +13,7 @@ from src.common.logger import get_logger
from src.common.database.database import get_db_session
from src.common.database.database_model import Images, ImageType
from src.common.data_models.image_data_model import MaiImage
from src.config.config import config_manager
from src.prompt.prompt_manager import prompt_manager
from src.services.llm_service import LLMServiceClient
@@ -30,6 +31,17 @@ def _ensure_image_dir_exists() -> None:
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
def _is_vlm_task_configured() -> bool:
"""判断是否配置了可用于图片识别的视觉模型任务。"""
try:
vlm_models = config_manager.get_model_config().model_task_config.vlm.model_list
return any(str(model_name).strip() for model_name in vlm_models)
except Exception as exc:
logger.warning(f"读取 VLM 模型配置失败,跳过图片识别: {exc}")
return False
vlm = LLMServiceClient(task_name="vlm", request_type="image")
@@ -111,6 +123,9 @@ class ImageManager:
except Exception as e:
logger.error(f"保存图片文件时发生错误: {e}")
return ""
if not _is_vlm_task_configured():
logger.info("未配置 VLM 模型,跳过图片识别")
return ""
if not wait_for_build:
self._schedule_description_build(hash_str, image_bytes)
return ""
@@ -129,6 +144,10 @@ class ImageManager:
image_hash: 图片哈希值。
image_bytes: 图片字节数据。
"""
if not _is_vlm_task_configured():
logger.info("未配置 VLM 模型,跳过图片后台识别任务")
return
if image_hash in self._pending_description_tasks:
return
@@ -303,6 +322,9 @@ class ImageManager:
await mai_image.calculate_hash_format()
if mai_image.vlm_processed and mai_image.description:
return mai_image
if not _is_vlm_task_configured():
logger.info(f"未配置 VLM 模型,跳过图片识别: {mai_image.file_hash}")
return mai_image
desc = await self._generate_image_description(image_bytes, mai_image.image_format)
mai_image.description = desc

View File

@@ -245,7 +245,7 @@ class SessionMessage(MaiMessage):
except Exception:
desc = None # 失败置空
content = f"[图片:{desc}]" if desc else "[图片]"
content = f"[图片:{desc}]" if desc else ""
component.content = content
component.binary_data = b"" # 处理完就丢掉二进制数据,节省内存
return content

View File

@@ -174,7 +174,7 @@ class BaseMaisakaReplyGenerator:
continue
if isinstance(component, ImageComponent):
rendered_parts.append(component.content.strip() or "[图片]")
rendered_parts.append(component.content.strip() or "[图片,识别中.....]")
continue
if isinstance(component, EmojiComponent):

View File

@@ -348,7 +348,7 @@ class MessageSequence:
if isinstance(item, TextComponent):
return {"type": "text", "data": item.text}
elif isinstance(item, ImageComponent):
return {"type": "image", "data": self._ensure_binary_component_content(item, "[图片]"), "hash": item.binary_hash}
return {"type": "image", "data": item.content.strip(), "hash": item.binary_hash}
elif isinstance(item, EmojiComponent):
return {"type": "emoji", "data": self._ensure_binary_component_content(item, "[表情包]"), "hash": item.binary_hash}
elif isinstance(item, VoiceComponent):
@@ -387,10 +387,8 @@ class MessageSequence:
"""确保二进制组件在序列化时带有稳定的文本占位。"""
normalized_content = item.content.strip()
if normalized_content:
item.content = normalized_content
return item.content
item.content = fallback_text
return item.content
return normalized_content
return fallback_text
@classmethod
def _dict_2_item(cls, item: Dict[str, Any]) -> StandardMessageComponents:

View File

@@ -5,7 +5,6 @@ from typing import Any, Callable, Mapping, Sequence, TypeVar, cast
import asyncio
import copy
import inspect
import sys
import tomlkit
@@ -57,8 +56,8 @@ BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute()
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0"
CONFIG_VERSION: str = "8.10.1"
MMC_VERSION: str = "1.0.0-pre.10"
CONFIG_VERSION: str = "8.10.6"
MODEL_CONFIG_VERSION: str = "1.14.8"
logger = get_logger("config")
@@ -250,7 +249,7 @@ class ConfigManager:
True,
)
if global_updated or model_updated:
sys.exit(0) # 配置已自动升级,退出一次让用户确认新配置后再启动
logger.info("配置已自动升级,将继续使用更新后的配置启动")
self._warn_if_vlm_not_configured(self.model_config)
logger.info(t("config.loaded"))
@@ -263,13 +262,13 @@ class ConfigManager:
def load_global_config(self) -> Config:
config, updated = load_config_from_file(Config, self.bot_config_path, CONFIG_VERSION)
if updated:
sys.exit(0) # 先直接退出
logger.info("bot_config.toml 已自动升级,将继续使用更新后的配置")
return config
def load_model_config(self) -> ModelConfig:
config, updated = load_config_from_file(ModelConfig, self.model_config_path, MODEL_CONFIG_VERSION, True)
if updated:
sys.exit(0) # 先直接退出
logger.info("model_config.toml 已自动升级,将继续使用更新后的配置")
return config
def get_global_config(self) -> Config:

View File

@@ -32,13 +32,6 @@ DEFAULT_TASK_CONFIG_TEMPLATES: dict[str, dict[str, Any]] = {
"slow_threshold": 120.0,
"selection_strategy": "random",
},
"learner": {
"model_list": [],
"max_tokens": 4096,
"temperature": 0.5,
"slow_threshold": 15.0,
"selection_strategy": "random",
},
"planner": {
"model_list": ["deepseek-v4-flash"],
"max_tokens": 8000,
@@ -46,13 +39,6 @@ DEFAULT_TASK_CONFIG_TEMPLATES: dict[str, dict[str, Any]] = {
"slow_threshold": 12.0,
"selection_strategy": "random",
},
"voice": {
"model_list": [""],
"max_tokens": 1024,
"temperature": 0.3,
"slow_threshold": 12.0,
"selection_strategy": "random",
},
}
DEFAULT_MODEL_TEMPLATES: list[dict[str, Any]] = [

View File

@@ -27,6 +27,8 @@ class BotConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "wifi",
"x-layout": "inline-right",
"x-input-width": "12rem",
},
)
"""平台"""
@@ -36,6 +38,8 @@ class BotConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "user",
"x-layout": "inline-right",
"x-input-width": "12rem",
},
)
"""QQ账号"""
@@ -211,6 +215,7 @@ class ChatConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "at-sign",
"advanced": True,
},
)
"""是否允许 replyer 使用 at[msg_id] 标记来发送真正的 at 消息"""
@@ -220,6 +225,7 @@ class ChatConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "quote",
"advanced": True,
},
)
"""是否启用回复时附带引用回复"""
@@ -243,11 +249,12 @@ class ChatConfig(ConfigBase):
"""私聊上下文长度"""
planner_interrupt_max_consecutive_count: int = Field(
default=2,
default=0,
ge=0,
json_schema_extra={
"x-widget": "input",
"x-icon": "pause-circle",
"advanced": True,
},
)
"""Planner 连续被新消息打断的最大次数0 表示不启用打断"""
@@ -453,6 +460,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "messages-square",
"advanced": True,
},
)
"""自动写回聊天摘要的消息窗口阈值"""
@@ -464,6 +472,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "rows-3",
"advanced": True,
},
)
"""自动写回聊天摘要时,从聊天流中回看的消息条数"""
@@ -1127,19 +1136,21 @@ class ExpressionConfig(ConfigBase):
"""是否启用自动表达优化"""
expression_auto_check_interval: int = Field(
default=600,
default=900,
json_schema_extra={
"x-widget": "input",
"x-icon": "clock",
"advanced": True,
},
)
"""表达方式自动检查的间隔时间(秒)"""
expression_auto_check_count: int = Field(
default=20,
default=5,
json_schema_extra={
"x-widget": "input",
"x-icon": "hash",
"advanced": True,
},
)
"""每次自动检查时随机选取的表达方式数量"""
@@ -1149,6 +1160,7 @@ class ExpressionConfig(ConfigBase):
json_schema_extra={
"x-widget": "custom",
"x-icon": "file-text",
"advanced": True,
},
)
"""表达方式自动检查的额外自定义评估标准"""
@@ -1190,6 +1202,7 @@ class EmojiConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "grid",
"advanced": True,
},
)
"""一次从多少个表情包中选择发送,最大为 64"""
@@ -1208,6 +1221,7 @@ class EmojiConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "refresh-cw",
"advanced": True,
},
)
"""达到最大注册数量时替换旧表情包,关闭则达到最大数量时不会继续收集表情包"""
@@ -1445,6 +1459,7 @@ class ResponseSplitterConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "smile",
"advanced": True,
},
)
"""是否启用颜文字保护"""
@@ -1454,6 +1469,7 @@ class ResponseSplitterConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "maximize",
"advanced": True,
},
)
"""是否在句子数量超出回复允许的最大句子数时一次性返回全部内容"""
@@ -1462,7 +1478,7 @@ class ResponseSplitterConfig(ConfigBase):
class LogConfig(ConfigBase):
"""日志配置类"""
__ui_label__ = "日志"
__ui_label__ = "调试与日志"
__ui_icon__ = "file-text"
date_style: str = Field(
@@ -1590,6 +1606,7 @@ class LogConfig(ConfigBase):
json_schema_extra={
"x-widget": "custom",
"x-icon": "volume-x",
"advanced": True,
},
)
"""完全屏蔽日志的第三方库列表"""
@@ -1599,6 +1616,7 @@ class LogConfig(ConfigBase):
json_schema_extra={
"x-widget": "custom",
"x-icon": "sliders-horizontal",
"advanced": True,
},
)
"""特定第三方库的日志级别"""
@@ -1622,6 +1640,7 @@ class TelemetryConfig(ConfigBase):
class DebugConfig(ConfigBase):
"""调试配置类"""
__ui_parent__ = "log"
__ui_label__ = "其他"
__ui_icon__ = "more-horizontal"
@@ -2116,6 +2135,7 @@ class DatabaseConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "save",
"advanced": True,
},
)
"""

View File

@@ -215,6 +215,17 @@ def _is_available_emoji_record(record: Images) -> bool:
return record_path.exists() and record_path.is_file()
def _is_vlm_task_configured() -> bool:
"""判断是否配置了可用于表情包识别和审核的视觉模型任务。"""
try:
vlm_models = config_manager.get_model_config().model_task_config.vlm.model_list
return any(str(model_name).strip() for model_name in vlm_models)
except Exception as exc:
logger.warning(f"读取 VLM 模型配置失败,跳过表情包识别和审核: {exc}")
return False
# TODO: 修改这个vlm为获取的vlm client暂时使用这个VLM方法
emoji_manager_vlm = LLMServiceClient(task_name="vlm", request_type="emoji.see")
emoji_manager_emotion_judge_llm = LLMServiceClient(
@@ -316,6 +327,10 @@ class EmojiManager:
# 如果提供了字节数据但数据库中没有找到,尝试构建
if not emoji_bytes:
return None
if not _is_vlm_task_configured():
await self.ensure_emoji_saved(emoji_bytes, emoji_hash=emoji_hash)
logger.info("未配置 VLM 模型,跳过表情包识别、打标签和审核")
return None
if not wait_for_build:
await self.ensure_emoji_saved(emoji_bytes, emoji_hash=emoji_hash)
self._schedule_description_build(emoji_hash, emoji_bytes)
@@ -386,6 +401,10 @@ class EmojiManager:
emoji_hash: 表情包哈希值。
emoji_bytes: 表情包字节数据。
"""
if not _is_vlm_task_configured():
logger.info("未配置 VLM 模型,跳过表情包后台识别任务")
return
if emoji_hash in self._pending_description_tasks:
return
@@ -826,6 +845,12 @@ class EmojiManager:
Returns:
return (Tuple[bool, MaiEmoji]): 返回是否成功构建描述,及表情包对象
"""
if not _is_vlm_task_configured():
logger.info(
f"[构建描述] 未配置 VLM 模型,跳过表情包识别、打标签和审核: {target_emoji.file_name}"
)
return False, target_emoji
if not target_emoji.file_hash or not target_emoji.image_format:
# Should not happen, but just in case
await target_emoji.calculate_hash_format()

View File

@@ -91,7 +91,7 @@ def _should_refresh_image_component(component: ImageComponent) -> bool:
"""判断图片组件当前是否仍处于待补全文本的占位状态。"""
normalized_content = component.content.strip()
return not normalized_content or normalized_content == "[图片]"
return not normalized_content or normalized_content == "[图片,识别中.....]"
def _should_refresh_emoji_component(component: EmojiComponent) -> bool:

View File

@@ -63,6 +63,7 @@ class ChatResponse:
completion_tokens: int
total_tokens: int
prompt_section: Optional[RenderableType] = None
prompt_html_uri: Optional[str] = None
logger = get_logger("maisaka_chat_loop")
@@ -585,8 +586,9 @@ class MaisakaChatLoopService:
all_tools = [item for item in raw_tool_definitions if isinstance(item, dict)]
prompt_section: RenderableType | None = None
prompt_html_uri: str | None = None
if global_config.debug.show_maisaka_thinking:
prompt_section = PromptCLIVisualizer.build_prompt_section(
prompt_section_result = PromptCLIVisualizer.build_prompt_section_result(
built_messages,
category="planner" if request_kind != "timing_gate" else "timing_gate",
chat_id=self._session_id,
@@ -595,6 +597,9 @@ class MaisakaChatLoopService:
folded=global_config.debug.fold_maisaka_thinking,
tool_definitions=list(all_tools),
)
prompt_section = prompt_section_result.panel
if prompt_section_result.preview_access is not None:
prompt_html_uri = prompt_section_result.preview_access.viewer_web_uri
llm_chat = self._get_llm_chat_client(request_kind)
generation_result = await llm_chat.generate_response_with_messages(
@@ -660,6 +665,7 @@ class MaisakaChatLoopService:
completion_tokens=completion_tokens,
total_tokens=total_tokens,
prompt_section=prompt_section,
prompt_html_uri=prompt_html_uri,
)
@staticmethod

View File

@@ -83,7 +83,7 @@ def _append_image_component(
builder.add_text_content(normalized_content)
return True
builder.add_text_content("[图片]")
builder.add_text_content("[图片,识别中.....]")
return True
@@ -147,7 +147,7 @@ def _render_component_for_prompt(component: StandardMessageComponents) -> str:
return (component.text or "").strip()
if isinstance(component, ImageComponent):
return component.content.strip() if component.content else "[图片]"
return component.content.strip() if component.content else "[图片,识别中.....]"
if isinstance(component, EmojiComponent):
return component.content.strip() if component.content else "[表情包]"

View File

@@ -7,6 +7,7 @@ from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Literal
from urllib.parse import quote
import hashlib
import html
@@ -32,6 +33,36 @@ from .prompt_preview_logger import PromptPreviewLogger
DATA_IMAGE_DIR = REPO_ROOT / "data" / "images"
def _build_prompt_preview_web_uri(file_path: Path) -> str:
"""构建 WebUI 可访问的 Prompt 预览地址。"""
try:
relative_path = file_path.resolve().relative_to(PromptPreviewLogger._BASE_DIR.resolve())
except ValueError:
return build_file_uri(file_path)
return f"/api/webui/config/maisaka-prompt-preview?path={quote(relative_path.as_posix(), safe='')}"
@dataclass(frozen=True)
class PromptPreviewAccess:
"""Prompt 预览文件的展示入口和可直接打开的路径。"""
body: RenderableType
viewer_path: Path
viewer_uri: str
viewer_web_uri: str
dump_path: Path
dump_uri: str
@dataclass(frozen=True)
class PromptSectionResult:
"""Prompt 面板及其可选 HTML 预览入口。"""
panel: Panel
preview_access: PromptPreviewAccess | None = None
class PromptImageDisplayMode(str, Enum):
"""图片在终端中的展示模式。"""
@@ -470,6 +501,77 @@ class PromptCLIVisualizer:
),
)
@classmethod
def build_prompt_preview_access(
cls,
messages: list[Any],
*,
category: str,
chat_id: str,
request_kind: str,
selection_reason: str,
tool_definitions: list[dict[str, Any]] | None = None,
) -> PromptPreviewAccess:
"""保存 Prompt 预览文件,并返回 CLI 展示入口与浏览器可打开的 URI。"""
viewer_messages: list[dict[str, Any]] = []
for message in messages:
if isinstance(message, dict):
viewer_messages.append(dict(message))
continue
normalized_message = {
"content": getattr(message, "content", None),
"role": getattr(getattr(message, "role", "unknown"), "value", getattr(message, "role", "unknown")),
}
tool_call_id = getattr(message, "tool_call_id", None)
if tool_call_id:
normalized_message["tool_call_id"] = tool_call_id
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
normalized_message["tool_calls"] = [
cls.format_tool_call_for_display(tool_call) for tool_call in tool_calls
]
viewer_messages.append(normalized_message)
prompt_dump_text = cls._build_prompt_dump_text(messages)
tool_definition_dump_text = cls._build_tool_definition_dump_text(tool_definitions)
if tool_definition_dump_text:
prompt_dump_text = f"{prompt_dump_text}\n\n{'=' * 80}\n\n{tool_definition_dump_text}"
viewer_html_text = cls._build_prompt_viewer_html(
viewer_messages,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
saved_paths = PromptPreviewLogger.save_preview_files(
chat_id,
category,
{
".html": viewer_html_text,
".txt": prompt_dump_text,
},
)
viewer_html_path = saved_paths[".html"]
prompt_dump_path = saved_paths[".txt"]
body = cls._build_preview_access_body(
viewer_label="html预览",
viewer_path=viewer_html_path,
viewer_link_text="在浏览器打开 Prompt",
dump_label="原始文本",
dump_path=prompt_dump_path,
dump_link_text="点击打开 Prompt 文本",
)
return PromptPreviewAccess(
body=body,
viewer_path=viewer_html_path,
viewer_uri=build_file_uri(viewer_html_path),
viewer_web_uri=_build_prompt_preview_web_uri(viewer_html_path),
dump_path=prompt_dump_path,
dump_uri=build_file_uri(prompt_dump_path),
)
@classmethod
def _build_html_role_class(cls, role: str) -> str:
return {
@@ -804,56 +906,14 @@ class PromptCLIVisualizer:
) -> RenderableType:
"""构建用于查看完整 prompt 的折叠入口内容。"""
viewer_messages: list[dict[str, Any]] = []
for message in messages:
if isinstance(message, dict):
viewer_messages.append(dict(message))
continue
normalized_message = {
"content": getattr(message, "content", None),
"role": getattr(getattr(message, "role", "unknown"), "value", getattr(message, "role", "unknown")),
}
tool_call_id = getattr(message, "tool_call_id", None)
if tool_call_id:
normalized_message["tool_call_id"] = tool_call_id
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
normalized_message["tool_calls"] = [
cls.format_tool_call_for_display(tool_call) for tool_call in tool_calls
]
viewer_messages.append(normalized_message)
prompt_dump_text = cls._build_prompt_dump_text(messages)
tool_definition_dump_text = cls._build_tool_definition_dump_text(tool_definitions)
if tool_definition_dump_text:
prompt_dump_text = f"{prompt_dump_text}\n\n{'=' * 80}\n\n{tool_definition_dump_text}"
viewer_html_text = cls._build_prompt_viewer_html(
viewer_messages,
return cls.build_prompt_preview_access(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
saved_paths = PromptPreviewLogger.save_preview_files(
chat_id,
category,
{
".html": viewer_html_text,
".txt": prompt_dump_text,
},
)
viewer_html_path = saved_paths[".html"]
prompt_dump_path = saved_paths[".txt"]
body = cls._build_preview_access_body(
viewer_label="html预览",
viewer_path=viewer_html_path,
viewer_link_text="在浏览器打开 Prompt",
dump_label="原始文本",
dump_path=prompt_dump_path,
dump_link_text="点击打开 Prompt 文本",
)
return body
).body
@classmethod
def build_prompt_section(
@@ -870,26 +930,56 @@ class PromptCLIVisualizer:
) -> Panel:
"""构建用于嵌入结果面板中的 Prompt 区块。"""
return cls.build_prompt_section_result(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
image_display_mode=image_display_mode,
folded=folded,
tool_definitions=tool_definitions,
).panel
@classmethod
def build_prompt_section_result(
cls,
messages: list[Any],
*,
category: str,
chat_id: str,
request_kind: str,
selection_reason: str,
image_display_mode: Literal["legacy", "path_link"] = "path_link",
folded: bool,
tool_definitions: list[dict[str, Any]] | None = None,
) -> PromptSectionResult:
"""构建 Prompt 面板,并在折叠模式下返回对应的 HTML 预览入口。"""
panel_title, panel_border_style = cls.get_request_panel_style(request_kind)
preview_access = cls.build_prompt_preview_access(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
if folded:
prompt_renderable = cls.build_prompt_access_panel(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
prompt_renderable = preview_access.body
else:
ordered_panels = cls.build_prompt_panels(messages)
prompt_renderable = Group(*ordered_panels)
return Panel(
prompt_renderable,
title=panel_title,
subtitle=selection_reason,
border_style=panel_border_style,
padding=(0, 1),
return PromptSectionResult(
panel=Panel(
prompt_renderable,
title=panel_title,
subtitle=selection_reason,
border_style=panel_border_style,
padding=(0, 1),
),
preview_access=preview_access,
)
@classmethod

View File

@@ -95,7 +95,7 @@ def build_visible_text_from_sequence(message_sequence: MessageSequence) -> str:
continue
if isinstance(component, ImageComponent):
append_visible_part(component.content.strip() or "[图片]")
append_visible_part(component.content.strip() or "[图片,识别中.....]")
continue
if isinstance(component, AtComponent):

View File

@@ -4,8 +4,9 @@
"""
from datetime import datetime
import time
from typing import Any, Dict, List, Optional
import json
import time
from src.common.logger import get_logger
@@ -57,7 +58,7 @@ def _extract_text_content(content: Any) -> Optional[str]:
if block_type == "text":
text_parts.append(str(block.get("text", "")))
elif block_type == "image_url":
text_parts.append("[图片]")
text_parts.append("[图片,识别中.....]")
else:
text_parts.append(f"[{block_type}]")
elif isinstance(block, str):
@@ -66,43 +67,65 @@ def _extract_text_content(content: Any) -> Optional[str]:
return str(content)
def _normalize_tool_call_arguments(arguments: Any) -> tuple[Any, Optional[str]]:
"""标准化工具调用参数,兼容 JSON 字符串和对象。"""
if isinstance(arguments, str):
raw_arguments = arguments
try:
parsed_arguments = json.loads(arguments) if arguments.strip() else {}
except json.JSONDecodeError:
return {}, raw_arguments
return _normalize_payload_value(parsed_arguments), raw_arguments
return _normalize_payload_value(arguments or {}), None
def _serialize_single_tool_call(tool_call: Any) -> Dict[str, Any]:
"""将不同来源的 tool_call 标准化为前端可直接展示的结构。"""
if isinstance(tool_call, dict):
function_info = tool_call.get("function")
if isinstance(function_info, dict):
raw_arguments = function_info.get("arguments", tool_call.get("arguments", tool_call.get("args", {})))
name = function_info.get("name", tool_call.get("name", tool_call.get("func_name", "unknown")))
else:
raw_arguments = tool_call.get("arguments", tool_call.get("args", {}))
name = tool_call.get("name", tool_call.get("func_name", "unknown"))
arguments, arguments_raw = _normalize_tool_call_arguments(raw_arguments)
serialized: Dict[str, Any] = {
"id": str(tool_call.get("id", tool_call.get("call_id", ""))),
"name": str(name or "unknown"),
"arguments": arguments,
}
if arguments_raw is not None:
serialized["arguments_raw"] = arguments_raw
return serialized
raw_arguments = getattr(tool_call, "args", None)
if raw_arguments is None:
raw_arguments = getattr(tool_call, "arguments", None)
arguments, arguments_raw = _normalize_tool_call_arguments(raw_arguments)
serialized = {
"id": str(getattr(tool_call, "id", None) or getattr(tool_call, "call_id", "")),
"name": str(getattr(tool_call, "func_name", None) or getattr(tool_call, "name", "unknown")),
"arguments": arguments,
}
if arguments_raw is not None:
serialized["arguments_raw"] = arguments_raw
return serialized
def _serialize_tool_calls_from_objects(tool_calls: List[Any]) -> List[Dict[str, Any]]:
"""将工具调用对象列表序列化为字典列表。"""
result: List[Dict[str, Any]] = []
for tool_call in tool_calls:
serialized: Dict[str, Any] = {
"id": getattr(tool_call, "id", None) or getattr(tool_call, "call_id", ""),
"name": getattr(tool_call, "func_name", None) or getattr(tool_call, "name", "unknown"),
}
args = getattr(tool_call, "args", None) or getattr(tool_call, "arguments", None)
if isinstance(args, dict):
serialized["arguments"] = _normalize_payload_value(args)
elif isinstance(args, str):
serialized["arguments_raw"] = args
result.append(serialized)
return result
return [_serialize_single_tool_call(tool_call) for tool_call in tool_calls]
def _serialize_tool_calls_from_dicts(tool_calls: List[Any]) -> List[Dict[str, Any]]:
"""将工具调用字典列表标准化为可传输格式。"""
result: List[Dict[str, Any]] = []
for tool_call in tool_calls:
if isinstance(tool_call, dict):
result.append({
"id": str(tool_call.get("id", "")),
"name": str(tool_call.get("name", tool_call.get("func_name", "unknown"))),
"arguments": _normalize_payload_value(tool_call.get("arguments", tool_call.get("args", {}))),
})
continue
result.append({
"id": str(getattr(tool_call, "id", getattr(tool_call, "call_id", ""))),
"name": str(getattr(tool_call, "func_name", getattr(tool_call, "name", "unknown"))),
"arguments": _normalize_payload_value(getattr(tool_call, "args", getattr(tool_call, "arguments", {}))),
})
return result
return [_serialize_single_tool_call(tool_call) for tool_call in tool_calls]
def _serialize_message(message: Any) -> Dict[str, Any]:
@@ -214,6 +237,7 @@ def _serialize_planner_block(
completion_tokens: Optional[int],
total_tokens: Optional[int],
duration_ms: Optional[float],
prompt_html_uri: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""标准化 planner 结果区块。"""
@@ -224,6 +248,7 @@ def _serialize_planner_block(
and completion_tokens is None
and total_tokens is None
and duration_ms is None
and prompt_html_uri is None
):
return None
@@ -234,6 +259,7 @@ def _serialize_planner_block(
"completion_tokens": int(completion_tokens or 0),
"total_tokens": int(total_tokens or 0),
"duration_ms": float(duration_ms or 0.0),
"prompt_html_uri": str(prompt_html_uri or ""),
}
@@ -429,6 +455,7 @@ async def emit_planner_finalized(
planner_completion_tokens: Optional[int],
planner_total_tokens: Optional[int],
planner_duration_ms: Optional[float],
planner_prompt_html_uri: Optional[str],
tools: Optional[List[Dict[str, Any]]],
time_records: Dict[str, float],
agent_state: str,
@@ -464,6 +491,7 @@ async def emit_planner_finalized(
planner_completion_tokens,
planner_total_tokens,
planner_duration_ms,
planner_prompt_html_uri,
),
"tools": _serialize_tool_results(list(tools or [])),
"final_state": {

View File

@@ -709,6 +709,7 @@ class MaisakaReasoningEngine:
),
planner_total_tokens=response.total_tokens if response is not None else None,
planner_duration_ms=planner_duration_ms if response is not None else None,
planner_prompt_html_uri=response.prompt_html_uri if response is not None else None,
tools=tool_monitor_results,
time_records=dict(completed_cycle.time_records),
agent_state=self._runtime._agent_state,

View File

@@ -216,6 +216,20 @@ class PluginRunnerSupervisor:
"""
return {plugin_id: registration.plugin_version for plugin_id, registration in self._registered_plugins.items()}
def get_plugin_load_statuses(self) -> Dict[str, str]:
"""返回 Runner 最近一次上报的插件加载状态。"""
statuses: Dict[str, str] = {}
for plugin_id in self._runner_ready_payloads.loaded_plugins:
statuses[plugin_id] = "success"
for plugin_id in self._runner_ready_payloads.failed_plugins:
statuses[plugin_id] = "failed"
for plugin_id in self._runner_ready_payloads.inactive_plugins:
statuses.setdefault(plugin_id, "inactive")
for plugin_id in self._registered_plugins:
statuses[plugin_id] = "success"
return statuses
def set_blocked_plugin_reasons(self, blocked_plugin_reasons: Dict[str, str]) -> None:
"""设置当前 Runner 启动时应拒绝加载的插件列表。

View File

@@ -657,6 +657,14 @@ class PluginRuntimeManager(
plugin_id: supervisor for supervisor in self.supervisors for plugin_id in supervisor.get_loaded_plugin_ids()
}
def get_plugin_load_statuses(self) -> Dict[str, str]:
"""汇总所有 Supervisor 上报的插件加载状态。"""
statuses: Dict[str, str] = {}
for supervisor in self.supervisors:
statuses.update(supervisor.get_plugin_load_statuses())
return statuses
def _build_external_available_plugins_for_supervisor(self, target_supervisor: "PluginSupervisor") -> Dict[str, str]:
"""收集某个 Supervisor 可用的外部插件版本映射。"""

View File

@@ -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():
"""获取麦麦主程序配置架构"""

View File

@@ -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:

View File

@@ -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)))