From 1c1d8fd3af0167f30f1e342a8221ed770a733e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Mon, 17 Nov 2025 00:09:17 +0800 Subject: [PATCH 01/76] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=AE=A1=E7=90=86API=E8=B7=AF=E7=94=B1=E5=92=8C?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webui/config_routes.py | 312 ++++++++++++++++++++++++++++++++++ src/webui/config_schema.py | 336 +++++++++++++++++++++++++++++++++++++ src/webui/routes.py | 4 + 3 files changed, 652 insertions(+) create mode 100644 src/webui/config_routes.py create mode 100644 src/webui/config_schema.py diff --git a/src/webui/config_routes.py b/src/webui/config_routes.py new file mode 100644 index 00000000..41784d4e --- /dev/null +++ b/src/webui/config_routes.py @@ -0,0 +1,312 @@ +""" +配置管理API路由 +""" + +import os +import tomlkit +from fastapi import APIRouter, HTTPException, Body +from typing import Any + +from src.common.logger import get_logger +from src.config.config import Config, APIAdapterConfig, CONFIG_DIR +from src.config.official_configs import ( + BotConfig, + PersonalityConfig, + RelationshipConfig, + ChatConfig, + MessageReceiveConfig, + EmojiConfig, + ExpressionConfig, + KeywordReactionConfig, + ChineseTypoConfig, + ResponsePostProcessConfig, + ResponseSplitterConfig, + TelemetryConfig, + ExperimentalConfig, + MaimMessageConfig, + LPMMKnowledgeConfig, + ToolConfig, + MemoryConfig, + DebugConfig, + MoodConfig, + VoiceConfig, + JargonConfig, +) +from src.config.api_ada_configs import ( + ModelTaskConfig, + ModelInfo, + APIProvider, +) +from src.webui.config_schema import ConfigSchemaGenerator + +logger = get_logger("webui.config_routes") + +router = APIRouter(prefix="/config", tags=["config"]) + + +# ===== 架构获取接口 ===== + + +@router.get("/schema/bot") +async def get_bot_config_schema(): + """获取麦麦主程序配置架构""" + try: + # Config 类包含所有子配置 + schema = ConfigSchemaGenerator.generate_config_schema(Config) + return {"success": True, "schema": schema} + except Exception as e: + logger.error(f"获取配置架构失败: {e}") + raise HTTPException(status_code=500, detail=f"获取配置架构失败: {str(e)}") + + +@router.get("/schema/model") +async def get_model_config_schema(): + """获取模型配置架构(包含提供商和模型任务配置)""" + try: + schema = ConfigSchemaGenerator.generate_config_schema(APIAdapterConfig) + return {"success": True, "schema": schema} + except Exception as e: + logger.error(f"获取模型配置架构失败: {e}") + raise HTTPException(status_code=500, detail=f"获取模型配置架构失败: {str(e)}") + + +# ===== 子配置架构获取接口 ===== + + +@router.get("/schema/section/{section_name}") +async def get_config_section_schema(section_name: str): + """ + 获取指定配置节的架构 + + 支持的section_name: + - bot: BotConfig + - personality: PersonalityConfig + - relationship: RelationshipConfig + - chat: ChatConfig + - message_receive: MessageReceiveConfig + - emoji: EmojiConfig + - expression: ExpressionConfig + - keyword_reaction: KeywordReactionConfig + - chinese_typo: ChineseTypoConfig + - response_post_process: ResponsePostProcessConfig + - response_splitter: ResponseSplitterConfig + - telemetry: TelemetryConfig + - experimental: ExperimentalConfig + - maim_message: MaimMessageConfig + - lpmm_knowledge: LPMMKnowledgeConfig + - tool: ToolConfig + - memory: MemoryConfig + - debug: DebugConfig + - mood: MoodConfig + - voice: VoiceConfig + - jargon: JargonConfig + - model_task_config: ModelTaskConfig + - api_provider: APIProvider + - model_info: ModelInfo + """ + section_map = { + "bot": BotConfig, + "personality": PersonalityConfig, + "relationship": RelationshipConfig, + "chat": ChatConfig, + "message_receive": MessageReceiveConfig, + "emoji": EmojiConfig, + "expression": ExpressionConfig, + "keyword_reaction": KeywordReactionConfig, + "chinese_typo": ChineseTypoConfig, + "response_post_process": ResponsePostProcessConfig, + "response_splitter": ResponseSplitterConfig, + "telemetry": TelemetryConfig, + "experimental": ExperimentalConfig, + "maim_message": MaimMessageConfig, + "lpmm_knowledge": LPMMKnowledgeConfig, + "tool": ToolConfig, + "memory": MemoryConfig, + "debug": DebugConfig, + "mood": MoodConfig, + "voice": VoiceConfig, + "jargon": JargonConfig, + "model_task_config": ModelTaskConfig, + "api_provider": APIProvider, + "model_info": ModelInfo, + } + + if section_name not in section_map: + raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") + + try: + config_class = section_map[section_name] + schema = ConfigSchemaGenerator.generate_schema(config_class, include_nested=False) + return {"success": True, "schema": schema} + except Exception as e: + logger.error(f"获取配置节架构失败: {e}") + raise HTTPException(status_code=500, detail=f"获取配置节架构失败: {str(e)}") + + +# ===== 配置读取接口 ===== + + +@router.get("/bot") +async def get_bot_config(): + """获取麦麦主程序配置""" + try: + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + return {"success": True, "config": config_data} + except HTTPException: + raise + except Exception as e: + logger.error(f"读取配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"读取配置文件失败: {str(e)}") + + +@router.get("/model") +async def get_model_config(): + """获取模型配置(包含提供商和模型任务配置)""" + try: + config_path = os.path.join(CONFIG_DIR, "model_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + return {"success": True, "config": config_data} + except HTTPException: + raise + except Exception as e: + logger.error(f"读取配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"读取配置文件失败: {str(e)}") + + +# ===== 配置更新接口 ===== + + +@router.post("/bot") +async def update_bot_config(config_data: dict[str, Any] = Body(...)): + """更新麦麦主程序配置""" + try: + # 验证配置数据 + try: + Config.from_dict(config_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") + + # 保存配置文件 + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(config_data, f) + + logger.info("麦麦主程序配置已更新") + return {"success": True, "message": "配置已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"保存配置文件失败: {str(e)}") + + +@router.post("/model") +async def update_model_config(config_data: dict[str, Any] = Body(...)): + """更新模型配置""" + try: + # 验证配置数据 + try: + APIAdapterConfig.from_dict(config_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") + + # 保存配置文件 + config_path = os.path.join(CONFIG_DIR, "model_config.toml") + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(config_data, f) + + logger.info("模型配置已更新") + return {"success": True, "message": "配置已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"保存配置文件失败: {str(e)}") + + +# ===== 配置节更新接口 ===== + + +@router.post("/bot/section/{section_name}") +async def update_bot_config_section(section_name: str, section_data: dict[str, Any] = Body(...)): + """更新麦麦主程序配置的指定节""" + try: + # 读取现有配置 + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + # 更新指定节 + if section_name not in config_data: + raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") + + config_data[section_name] = section_data + + # 验证完整配置 + try: + Config.from_dict(config_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") + + # 保存配置 + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(config_data, f) + + logger.info(f"配置节 '{section_name}' 已更新") + return {"success": True, "message": f"配置节 '{section_name}' 已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"更新配置节失败: {e}") + raise HTTPException(status_code=500, detail=f"更新配置节失败: {str(e)}") + + +@router.post("/model/section/{section_name}") +async def update_model_config_section(section_name: str, section_data: dict[str, Any] = Body(...)): + """更新模型配置的指定节""" + try: + # 读取现有配置 + config_path = os.path.join(CONFIG_DIR, "model_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + # 更新指定节 + if section_name not in config_data: + raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") + + config_data[section_name] = section_data + + # 验证完整配置 + try: + APIAdapterConfig.from_dict(config_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") + + # 保存配置 + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(config_data, f) + + logger.info(f"配置节 '{section_name}' 已更新") + return {"success": True, "message": f"配置节 '{section_name}' 已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"更新配置节失败: {e}") + raise HTTPException(status_code=500, detail=f"更新配置节失败: {str(e)}") diff --git a/src/webui/config_schema.py b/src/webui/config_schema.py new file mode 100644 index 00000000..c1608bc4 --- /dev/null +++ b/src/webui/config_schema.py @@ -0,0 +1,336 @@ +""" +配置架构生成器 - 自动从配置类生成前端表单架构 +""" + +import inspect +from dataclasses import fields, MISSING +from typing import Any, get_origin, get_args, Literal, Optional +from enum import Enum + +from src.config.config_base import ConfigBase + + +class FieldType(str, Enum): + """字段类型枚举""" + + STRING = "string" + NUMBER = "number" + INTEGER = "integer" + BOOLEAN = "boolean" + SELECT = "select" + ARRAY = "array" + OBJECT = "object" + TEXTAREA = "textarea" + + +class FieldSchema: + """字段架构""" + + def __init__( + self, + name: str, + type: FieldType, + label: str, + description: str = "", + default: Any = None, + required: bool = True, + options: Optional[list[str]] = None, + min_value: Optional[float] = None, + max_value: Optional[float] = None, + items: Optional[dict] = None, + properties: Optional[dict] = None, + ): + self.name = name + self.type = type + self.label = label + self.description = description + self.default = default + self.required = required + self.options = options + self.min_value = min_value + self.max_value = max_value + self.items = items + self.properties = properties + + def to_dict(self) -> dict: + """转换为字典""" + result = { + "name": self.name, + "type": self.type.value, + "label": self.label, + "description": self.description, + "required": self.required, + } + + if self.default is not None: + result["default"] = self.default + + if self.options is not None: + result["options"] = self.options + + if self.min_value is not None: + result["minValue"] = self.min_value + + if self.max_value is not None: + result["maxValue"] = self.max_value + + if self.items is not None: + result["items"] = self.items + + if self.properties is not None: + result["properties"] = self.properties + + return result + + +class ConfigSchemaGenerator: + """配置架构生成器""" + + @staticmethod + def _extract_field_description(config_class: type, field_name: str) -> str: + """ + 从类定义中提取字段的文档字符串描述 + + Args: + config_class: 配置类 + field_name: 字段名 + + Returns: + str: 字段描述 + """ + try: + # 获取源代码 + source = inspect.getsource(config_class) + lines = source.split("\n") + + # 查找字段定义 + field_found = False + description_lines = [] + + for i, line in enumerate(lines): + # 匹配字段定义行,例如: platform: str + if f"{field_name}:" in line and "=" in line: + field_found = True + # 查找下一行的文档字符串 + if i + 1 < len(lines): + next_line = lines[i + 1].strip() + if next_line.startswith('"""') or next_line.startswith("'''"): + # 单行文档字符串 + if next_line.count('"""') == 2 or next_line.count("'''") == 2: + description_lines.append(next_line.strip('"""').strip("'''").strip()) + else: + # 多行文档字符串 + quote = '"""' if next_line.startswith('"""') else "'''" + description_lines.append(next_line.strip(quote).strip()) + for j in range(i + 2, len(lines)): + if quote in lines[j]: + description_lines.append(lines[j].split(quote)[0].strip()) + break + description_lines.append(lines[j].strip()) + break + elif f"{field_name}:" in line and "=" not in line: + # 没有默认值的字段 + field_found = True + if i + 1 < len(lines): + next_line = lines[i + 1].strip() + if next_line.startswith('"""') or next_line.startswith("'''"): + if next_line.count('"""') == 2 or next_line.count("'''") == 2: + description_lines.append(next_line.strip('"""').strip("'''").strip()) + else: + quote = '"""' if next_line.startswith('"""') else "'''" + description_lines.append(next_line.strip(quote).strip()) + for j in range(i + 2, len(lines)): + if quote in lines[j]: + description_lines.append(lines[j].split(quote)[0].strip()) + break + description_lines.append(lines[j].strip()) + break + + if field_found and description_lines: + return " ".join(description_lines) + + except Exception: + pass + + return "" + + @staticmethod + def _get_field_type_and_options(field_type: type) -> tuple[FieldType, Optional[list[str]], Optional[dict]]: + """ + 获取字段类型和选项 + + Args: + field_type: 字段类型 + + Returns: + tuple: (FieldType, options, items) + """ + origin = get_origin(field_type) + args = get_args(field_type) + + # 处理 Literal 类型(枚举选项) + if origin is Literal: + return FieldType.SELECT, [str(arg) for arg in args], None + + # 处理 list 类型 + if origin is list: + item_type = args[0] if args else str + if item_type is str: + items = {"type": "string"} + elif item_type is int: + items = {"type": "integer"} + elif item_type is float: + items = {"type": "number"} + elif item_type is bool: + items = {"type": "boolean"} + elif item_type is dict: + items = {"type": "object"} + else: + items = {"type": "string"} + return FieldType.ARRAY, None, items + + # 处理 set 类型(与 list 类似) + if origin is set: + item_type = args[0] if args else str + if item_type is str: + items = {"type": "string"} + else: + items = {"type": "string"} + return FieldType.ARRAY, None, items + + # 处理基本类型 + if field_type is bool or field_type == bool: + return FieldType.BOOLEAN, None, None + elif field_type is int or field_type == int: + return FieldType.INTEGER, None, None + elif field_type is float or field_type == float: + return FieldType.NUMBER, None, None + elif field_type is str or field_type == str: + return FieldType.STRING, None, None + elif field_type is dict or origin is dict: + return FieldType.OBJECT, None, None + + # 默认为字符串 + return FieldType.STRING, None, None + + @staticmethod + def _format_field_name(name: str) -> str: + """ + 格式化字段名为可读的标签 + + Args: + name: 原始字段名 + + Returns: + str: 格式化后的标签 + """ + # 将下划线替换为空格,并首字母大写 + return " ".join(word.capitalize() for word in name.split("_")) + + @staticmethod + def generate_schema(config_class: type[ConfigBase], include_nested: bool = True) -> dict: + """ + 从配置类生成前端表单架构 + + Args: + config_class: 配置类(必须继承自 ConfigBase) + include_nested: 是否包含嵌套的配置对象 + + Returns: + dict: 前端表单架构 + """ + if not issubclass(config_class, ConfigBase): + raise ValueError(f"{config_class.__name__} 必须继承自 ConfigBase") + + schema_fields = [] + nested_schemas = {} + + for field in fields(config_class): + # 跳过私有字段和内部字段 + if field.name.startswith("_") or field.name in ["MMC_VERSION"]: + continue + + # 提取字段描述 + description = ConfigSchemaGenerator._extract_field_description(config_class, field.name) + + # 判断是否必填 + required = field.default is MISSING and field.default_factory is MISSING + + # 获取默认值 + default_value = None + if field.default is not MISSING: + default_value = field.default + elif field.default_factory is not MISSING: + try: + default_value = field.default_factory() + except Exception: + default_value = None + + # 检查是否为嵌套的 ConfigBase + if isinstance(field.type, type) and issubclass(field.type, ConfigBase): + if include_nested: + # 递归生成嵌套配置的架构 + nested_schema = ConfigSchemaGenerator.generate_schema(field.type, include_nested=True) + nested_schemas[field.name] = nested_schema + + field_schema = FieldSchema( + name=field.name, + type=FieldType.OBJECT, + label=ConfigSchemaGenerator._format_field_name(field.name), + description=description or field.type.__doc__ or "", + default=default_value, + required=required, + properties=nested_schema, + ) + else: + continue + else: + # 获取字段类型和选项 + field_type, options, items = ConfigSchemaGenerator._get_field_type_and_options(field.type) + + # 特殊处理:长文本使用 textarea + if field_type == FieldType.STRING and field.name in [ + "personality", + "reply_style", + "interest", + "plan_style", + "visual_style", + "private_plan_style", + "emotion_style", + "reaction", + "filtration_prompt", + ]: + field_type = FieldType.TEXTAREA + + field_schema = FieldSchema( + name=field.name, + type=field_type, + label=ConfigSchemaGenerator._format_field_name(field.name), + description=description, + default=default_value, + required=required, + options=options, + items=items, + ) + + schema_fields.append(field_schema.to_dict()) + + return { + "className": config_class.__name__, + "classDoc": config_class.__doc__ or "", + "fields": schema_fields, + "nested": nested_schemas if nested_schemas else None, + } + + @staticmethod + def generate_config_schema(config_class: type[ConfigBase]) -> dict: + """ + 生成完整的配置架构(包含所有嵌套的子配置) + + Args: + config_class: 配置类 + + Returns: + dict: 完整的配置架构 + """ + return ConfigSchemaGenerator.generate_schema(config_class, include_nested=True) diff --git a/src/webui/routes.py b/src/webui/routes.py index 37f82e5d..64d033e8 100644 --- a/src/webui/routes.py +++ b/src/webui/routes.py @@ -4,12 +4,16 @@ from pydantic import BaseModel, Field from typing import Optional from src.common.logger import get_logger from .token_manager import get_token_manager +from .config_routes import router as config_router logger = get_logger("webui.api") # 创建路由器 router = APIRouter(prefix="/api/webui", tags=["WebUI"]) +# 注册配置管理路由 +router.include_router(config_router) + class TokenVerifyRequest(BaseModel): """Token 验证请求""" From ed12fac50e6b80aed85a0acc02c9173f7cd109ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Mon, 17 Nov 2025 12:06:42 +0800 Subject: [PATCH 02/76] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=AE=B0=E5=BD=95=E5=99=A8=E5=90=8D=E7=A7=B0=E5=92=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=85=8D=E7=BD=AE=E8=8A=82=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webui/config_routes.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/webui/config_routes.py b/src/webui/config_routes.py index 41784d4e..e65538bb 100644 --- a/src/webui/config_routes.py +++ b/src/webui/config_routes.py @@ -39,7 +39,7 @@ from src.config.api_ada_configs import ( ) from src.webui.config_schema import ConfigSchemaGenerator -logger = get_logger("webui.config_routes") +logger = get_logger("webui") router = APIRouter(prefix="/config", tags=["config"]) @@ -239,7 +239,7 @@ async def update_model_config(config_data: dict[str, Any] = Body(...)): @router.post("/bot/section/{section_name}") -async def update_bot_config_section(section_name: str, section_data: dict[str, Any] = Body(...)): +async def update_bot_config_section(section_name: str, section_data: Any = Body(...)): """更新麦麦主程序配置的指定节""" try: # 读取现有配置 @@ -276,7 +276,7 @@ async def update_bot_config_section(section_name: str, section_data: dict[str, A @router.post("/model/section/{section_name}") -async def update_model_config_section(section_name: str, section_data: dict[str, Any] = Body(...)): +async def update_model_config_section(section_name: str, section_data: Any = Body(...)): """更新模型配置的指定节""" try: # 读取现有配置 From ab1ef9536b5675681259dc4d58722ba3a0a89811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Mon, 17 Nov 2025 12:59:02 +0800 Subject: [PATCH 03/76] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=80=92?= =?UTF-8?q?=E5=BD=92=E5=90=88=E5=B9=B6=E5=AD=97=E5=85=B8=E7=9A=84=E8=BE=85?= =?UTF-8?q?=E5=8A=A9=E5=87=BD=E6=95=B0=E4=BB=A5=E4=BF=9D=E7=95=99=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E5=92=8C=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webui/config_routes.py | 70 +++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/src/webui/config_routes.py b/src/webui/config_routes.py index e65538bb..03a4643f 100644 --- a/src/webui/config_routes.py +++ b/src/webui/config_routes.py @@ -44,6 +44,40 @@ logger = get_logger("webui") router = APIRouter(prefix="/config", tags=["config"]) +# ===== 辅助函数 ===== + + +def _update_dict_preserve_comments(target: Any, source: Any) -> None: + """ + 递归合并字典,保留 target 中的注释和格式 + 将 source 的值更新到 target 中(仅更新已存在的键) + + Args: + target: 目标字典(tomlkit 对象,包含注释) + source: 源字典(普通 dict 或 list) + """ + # 如果 source 是列表,直接替换(数组表没有注释保留的意义) + if isinstance(source, list): + return # 调用者需要直接赋值 + + # 如果都是字典,递归合并 + if isinstance(source, dict) and isinstance(target, dict): + for key, value in source.items(): + if key == "version": + continue # 跳过版本号 + if key in target: + target_value = target[key] + # 递归处理嵌套字典 + if isinstance(value, dict) and isinstance(target_value, dict): + _update_dict_preserve_comments(target_value, value) + else: + # 使用 tomlkit.item 保持类型 + try: + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + target[key] = value + + # ===== 架构获取接口 ===== @@ -240,7 +274,7 @@ async def update_model_config(config_data: dict[str, Any] = Body(...)): @router.post("/bot/section/{section_name}") async def update_bot_config_section(section_name: str, section_data: Any = Body(...)): - """更新麦麦主程序配置的指定节""" + """更新麦麦主程序配置的指定节(保留注释和格式)""" try: # 读取现有配置 config_path = os.path.join(CONFIG_DIR, "bot_config.toml") @@ -254,7 +288,17 @@ async def update_bot_config_section(section_name: str, section_data: Any = Body( if section_name not in config_data: raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") - config_data[section_name] = section_data + # 使用递归合并保留注释(对于字典类型) + # 对于数组类型(如 platforms, aliases),直接替换 + if isinstance(section_data, list): + # 列表直接替换 + config_data[section_name] = section_data + elif isinstance(section_data, dict) and isinstance(config_data[section_name], dict): + # 字典递归合并 + _update_dict_preserve_comments(config_data[section_name], section_data) + else: + # 其他类型直接替换 + config_data[section_name] = section_data # 验证完整配置 try: @@ -262,11 +306,11 @@ async def update_bot_config_section(section_name: str, section_data: Any = Body( except Exception as e: raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") - # 保存配置 + # 保存配置(tomlkit.dump 会保留注释) with open(config_path, "w", encoding="utf-8") as f: tomlkit.dump(config_data, f) - logger.info(f"配置节 '{section_name}' 已更新") + logger.info(f"配置节 '{section_name}' 已更新(保留注释)") return {"success": True, "message": f"配置节 '{section_name}' 已保存"} except HTTPException: raise @@ -277,7 +321,7 @@ async def update_bot_config_section(section_name: str, section_data: Any = Body( @router.post("/model/section/{section_name}") async def update_model_config_section(section_name: str, section_data: Any = Body(...)): - """更新模型配置的指定节""" + """更新模型配置的指定节(保留注释和格式)""" try: # 读取现有配置 config_path = os.path.join(CONFIG_DIR, "model_config.toml") @@ -291,7 +335,17 @@ async def update_model_config_section(section_name: str, section_data: Any = Bod if section_name not in config_data: raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") - config_data[section_name] = section_data + # 使用递归合并保留注释(对于字典类型) + # 对于数组表(如 [[models]], [[api_providers]]),直接替换 + if isinstance(section_data, list): + # 列表直接替换 + config_data[section_name] = section_data + elif isinstance(section_data, dict) and isinstance(config_data[section_name], dict): + # 字典递归合并 + _update_dict_preserve_comments(config_data[section_name], section_data) + else: + # 其他类型直接替换 + config_data[section_name] = section_data # 验证完整配置 try: @@ -299,11 +353,11 @@ async def update_model_config_section(section_name: str, section_data: Any = Bod except Exception as e: raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") - # 保存配置 + # 保存配置(tomlkit.dump 会保留注释) with open(config_path, "w", encoding="utf-8") as f: tomlkit.dump(config_data, f) - logger.info(f"配置节 '{section_name}' 已更新") + logger.info(f"配置节 '{section_name}' 已更新(保留注释)") return {"success": True, "message": f"配置节 '{section_name}' 已保存"} except HTTPException: raise From 6307d92acc2e74a59a3b2133e66d4bce3b7eddb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Mon, 17 Nov 2025 15:43:11 +0800 Subject: [PATCH 04/76] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E6=8D=AEAPI=E8=B7=AF=E7=94=B1=E4=BB=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=BB=9F=E8=AE=A1=E4=BF=A1=E6=81=AF=E7=9A=84?= =?UTF-8?q?=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webui/routes.py | 3 + src/webui/statistics_routes.py | 329 +++++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 src/webui/statistics_routes.py diff --git a/src/webui/routes.py b/src/webui/routes.py index 64d033e8..517b77ed 100644 --- a/src/webui/routes.py +++ b/src/webui/routes.py @@ -5,6 +5,7 @@ from typing import Optional from src.common.logger import get_logger from .token_manager import get_token_manager from .config_routes import router as config_router +from .statistics_routes import router as statistics_router logger = get_logger("webui.api") @@ -13,6 +14,8 @@ router = APIRouter(prefix="/api/webui", tags=["WebUI"]) # 注册配置管理路由 router.include_router(config_router) +# 注册统计数据路由 +router.include_router(statistics_router) class TokenVerifyRequest(BaseModel): diff --git a/src/webui/statistics_routes.py b/src/webui/statistics_routes.py new file mode 100644 index 00000000..01d5ea28 --- /dev/null +++ b/src/webui/statistics_routes.py @@ -0,0 +1,329 @@ +"""统计数据 API 路由""" +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import Dict, Any, List +from datetime import datetime, timedelta +from collections import defaultdict + +from src.common.logger import get_logger +from src.common.database.database_model import LLMUsage, OnlineTime, Messages + +logger = get_logger("webui.statistics") + +router = APIRouter(prefix="/statistics", tags=["statistics"]) + + +class StatisticsSummary(BaseModel): + """统计数据摘要""" + total_requests: int = Field(0, description="总请求数") + total_cost: float = Field(0.0, description="总花费") + total_tokens: int = Field(0, description="总token数") + online_time: float = Field(0.0, description="在线时间(秒)") + total_messages: int = Field(0, description="总消息数") + total_replies: int = Field(0, description="总回复数") + avg_response_time: float = Field(0.0, description="平均响应时间") + cost_per_hour: float = Field(0.0, description="每小时花费") + tokens_per_hour: float = Field(0.0, description="每小时token数") + + +class ModelStatistics(BaseModel): + """模型统计""" + model_name: str + request_count: int + total_cost: float + total_tokens: int + avg_response_time: float + + +class TimeSeriesData(BaseModel): + """时间序列数据""" + timestamp: str + requests: int = 0 + cost: float = 0.0 + tokens: int = 0 + + +class DashboardData(BaseModel): + """仪表盘数据""" + summary: StatisticsSummary + model_stats: List[ModelStatistics] + hourly_data: List[TimeSeriesData] + daily_data: List[TimeSeriesData] + recent_activity: List[Dict[str, Any]] + + +@router.get("/dashboard", response_model=DashboardData) +async def get_dashboard_data(hours: int = 24): + """ + 获取仪表盘统计数据 + + Args: + hours: 统计时间范围(小时),默认24小时 + + Returns: + 仪表盘数据 + """ + try: + now = datetime.now() + start_time = now - timedelta(hours=hours) + + # 获取摘要数据 + summary = await _get_summary_statistics(start_time, now) + + # 获取模型统计 + model_stats = await _get_model_statistics(start_time) + + # 获取小时级时间序列数据 + hourly_data = await _get_hourly_statistics(start_time, now) + + # 获取日级时间序列数据(最近7天) + daily_start = now - timedelta(days=7) + daily_data = await _get_daily_statistics(daily_start, now) + + # 获取最近活动 + recent_activity = await _get_recent_activity(limit=10) + + return DashboardData( + summary=summary, + model_stats=model_stats, + hourly_data=hourly_data, + daily_data=daily_data, + recent_activity=recent_activity + ) + except Exception as e: + logger.error(f"获取仪表盘数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e + + +async def _get_summary_statistics(start_time: datetime, end_time: datetime) -> StatisticsSummary: + """获取摘要统计数据""" + summary = StatisticsSummary() + + # 查询 LLM 使用记录 + llm_records = list( + LLMUsage.select() + .where(LLMUsage.timestamp >= start_time) + .where(LLMUsage.timestamp <= end_time) + ) + + total_time_cost = 0.0 + time_cost_count = 0 + + for record in llm_records: + summary.total_requests += 1 + summary.total_cost += record.cost or 0.0 + summary.total_tokens += (record.prompt_tokens or 0) + (record.completion_tokens or 0) + + if record.time_cost and record.time_cost > 0: + total_time_cost += record.time_cost + time_cost_count += 1 + + # 计算平均响应时间 + if time_cost_count > 0: + summary.avg_response_time = total_time_cost / time_cost_count + + # 查询在线时间 + online_records = list( + OnlineTime.select() + .where( + (OnlineTime.start_timestamp >= start_time) | + (OnlineTime.end_timestamp >= start_time) + ) + ) + + for record in online_records: + start = max(record.start_timestamp, start_time) + end = min(record.end_timestamp, end_time) + if end > start: + summary.online_time += (end - start).total_seconds() + + # 查询消息数量 + messages = list( + Messages.select() + .where(Messages.time >= start_time.timestamp()) + .where(Messages.time <= end_time.timestamp()) + ) + + summary.total_messages = len(messages) + # 简单统计:如果 reply_to 不为空,则认为是回复 + summary.total_replies = len([m for m in messages if m.reply_to]) + + # 计算派生指标 + if summary.online_time > 0: + online_hours = summary.online_time / 3600.0 + summary.cost_per_hour = summary.total_cost / online_hours + summary.tokens_per_hour = summary.total_tokens / online_hours + + return summary + + +async def _get_model_statistics(start_time: datetime) -> List[ModelStatistics]: + """获取模型统计数据""" + model_data = defaultdict(lambda: { + 'request_count': 0, + 'total_cost': 0.0, + 'total_tokens': 0, + 'time_costs': [] + }) + + records = list( + LLMUsage.select() + .where(LLMUsage.timestamp >= start_time) + ) + + for record in records: + model_name = record.model_assign_name or record.model_name or "unknown" + model_data[model_name]['request_count'] += 1 + model_data[model_name]['total_cost'] += record.cost or 0.0 + model_data[model_name]['total_tokens'] += (record.prompt_tokens or 0) + (record.completion_tokens or 0) + + if record.time_cost and record.time_cost > 0: + model_data[model_name]['time_costs'].append(record.time_cost) + + # 转换为列表并排序 + result = [] + for model_name, data in model_data.items(): + avg_time = sum(data['time_costs']) / len(data['time_costs']) if data['time_costs'] else 0.0 + result.append(ModelStatistics( + model_name=model_name, + request_count=data['request_count'], + total_cost=data['total_cost'], + total_tokens=data['total_tokens'], + avg_response_time=avg_time + )) + + # 按请求数排序 + result.sort(key=lambda x: x.request_count, reverse=True) + return result[:10] # 返回前10个 + + +async def _get_hourly_statistics(start_time: datetime, end_time: datetime) -> List[TimeSeriesData]: + """获取小时级统计数据""" + # 创建小时桶 + hourly_buckets = defaultdict(lambda: {'requests': 0, 'cost': 0.0, 'tokens': 0}) + + records = list( + LLMUsage.select() + .where(LLMUsage.timestamp >= start_time) + .where(LLMUsage.timestamp <= end_time) + ) + + for record in records: + # 获取小时键(去掉分钟和秒) + hour_key = record.timestamp.replace(minute=0, second=0, microsecond=0) + hour_str = hour_key.isoformat() + + hourly_buckets[hour_str]['requests'] += 1 + hourly_buckets[hour_str]['cost'] += record.cost or 0.0 + hourly_buckets[hour_str]['tokens'] += (record.prompt_tokens or 0) + (record.completion_tokens or 0) + + # 填充所有小时(包括没有数据的) + result = [] + current = start_time.replace(minute=0, second=0, microsecond=0) + while current <= end_time: + hour_str = current.isoformat() + data = hourly_buckets.get(hour_str, {'requests': 0, 'cost': 0.0, 'tokens': 0}) + result.append(TimeSeriesData( + timestamp=hour_str, + requests=data['requests'], + cost=data['cost'], + tokens=data['tokens'] + )) + current += timedelta(hours=1) + + return result + + +async def _get_daily_statistics(start_time: datetime, end_time: datetime) -> List[TimeSeriesData]: + """获取日级统计数据""" + daily_buckets = defaultdict(lambda: {'requests': 0, 'cost': 0.0, 'tokens': 0}) + + records = list( + LLMUsage.select() + .where(LLMUsage.timestamp >= start_time) + .where(LLMUsage.timestamp <= end_time) + ) + + for record in records: + # 获取日期键 + day_key = record.timestamp.replace(hour=0, minute=0, second=0, microsecond=0) + day_str = day_key.isoformat() + + daily_buckets[day_str]['requests'] += 1 + daily_buckets[day_str]['cost'] += record.cost or 0.0 + daily_buckets[day_str]['tokens'] += (record.prompt_tokens or 0) + (record.completion_tokens or 0) + + # 填充所有天 + result = [] + current = start_time.replace(hour=0, minute=0, second=0, microsecond=0) + while current <= end_time: + day_str = current.isoformat() + data = daily_buckets.get(day_str, {'requests': 0, 'cost': 0.0, 'tokens': 0}) + result.append(TimeSeriesData( + timestamp=day_str, + requests=data['requests'], + cost=data['cost'], + tokens=data['tokens'] + )) + current += timedelta(days=1) + + return result + + +async def _get_recent_activity(limit: int = 10) -> List[Dict[str, Any]]: + """获取最近活动""" + records = list( + LLMUsage.select() + .order_by(LLMUsage.timestamp.desc()) + .limit(limit) + ) + + activities = [] + for record in records: + activities.append({ + 'timestamp': record.timestamp.isoformat(), + 'model': record.model_assign_name or record.model_name, + 'request_type': record.request_type, + 'tokens': (record.prompt_tokens or 0) + (record.completion_tokens or 0), + 'cost': record.cost or 0.0, + 'time_cost': record.time_cost or 0.0, + 'status': record.status + }) + + return activities + + +@router.get("/summary") +async def get_summary(hours: int = 24): + """ + 获取统计摘要 + + Args: + hours: 统计时间范围(小时) + """ + try: + now = datetime.now() + start_time = now - timedelta(hours=hours) + summary = await _get_summary_statistics(start_time, now) + return summary + except Exception as e: + logger.error(f"获取统计摘要失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/models") +async def get_model_stats(hours: int = 24): + """ + 获取模型统计 + + Args: + hours: 统计时间范围(小时) + """ + try: + now = datetime.now() + start_time = now - timedelta(hours=hours) + stats = await _get_model_statistics(start_time) + return stats + except Exception as e: + logger.error(f"获取模型统计失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e From e57a9966266c20f3d5e75e40fabcdc80512f06ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Mon, 17 Nov 2025 17:22:57 +0800 Subject: [PATCH 05/76] upload WebUI 0.1.1 Beta DashBoard after Build Files --- .gitignore | 1 - ...anStackRouterDevtools-CXkXeTp3-DxlgenHe.js | Bin 0 -> 56869 bytes webui/dist/assets/index-DnJOmtKJ.css | Bin 0 -> 45479 bytes webui/dist/assets/index-Dq16ignL.js | Bin 0 -> 1108350 bytes webui/dist/index.html | Bin 0 -> 464 bytes webui/dist/maimai.ico | Bin 0 -> 67715 bytes webui/dist/vite.svg | Bin 0 -> 1497 bytes 7 files changed, 1 deletion(-) create mode 100644 webui/dist/assets/FloatingTanStackRouterDevtools-CXkXeTp3-DxlgenHe.js create mode 100644 webui/dist/assets/index-DnJOmtKJ.css create mode 100644 webui/dist/assets/index-Dq16ignL.js create mode 100644 webui/dist/index.html create mode 100644 webui/dist/maimai.ico create mode 100644 webui/dist/vite.svg diff --git a/.gitignore b/.gitignore index 63db1c17..328a86aa 100644 --- a/.gitignore +++ b/.gitignore @@ -69,7 +69,6 @@ elua.confirmed .Python build/ develop-eggs/ -dist/ downloads/ eggs/ .eggs/ diff --git a/webui/dist/assets/FloatingTanStackRouterDevtools-CXkXeTp3-DxlgenHe.js b/webui/dist/assets/FloatingTanStackRouterDevtools-CXkXeTp3-DxlgenHe.js new file mode 100644 index 0000000000000000000000000000000000000000..a875760ab697944626f711058990afc15974a07f GIT binary patch literal 56869 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