fix:优化图片识别,优化webui配置和排版,优化聊天流监控,新增mcp显示,新增prompt修改面板,优化插件状态显示,优化长期记忆控制台,
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]] = [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "[表情包]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 启动时应拒绝加载的插件列表。
|
||||
|
||||
|
||||
@@ -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 可用的外部插件版本映射。"""
|
||||
|
||||
|
||||
@@ -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