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/22] =?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/22] =?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/22] =?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/22] =?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/22] upload WebUI 0.1.1 Beta DashBoard after Build Files --- .gitignore | 1 - ...anStackRouterDevtools-CXkXeTp3-DxlgenHe.js | 486 ++++++++++++++++++ webui/dist/assets/index-DnJOmtKJ.css | 1 + webui/dist/assets/index-Dq16ignL.js | 145 ++++++ webui/dist/index.html | 14 + webui/dist/maimai.ico | Bin 0 -> 67715 bytes webui/dist/vite.svg | 1 + 7 files changed, 647 insertions(+), 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 00000000..a875760a --- /dev/null +++ b/webui/dist/assets/FloatingTanStackRouterDevtools-CXkXeTp3-DxlgenHe.js @@ -0,0 +1,486 @@ +import{c as ne,a as Ue,b as w,u as bt,d as _t,i as Ft,e as zt,f as K,t as O,s as tt,m as je,g as u,h as Mt,j as d,k as F,l as Z,M as Qe,r as Re,n as H,o as s,F as Ut,S as Bt,p as pt,q as Et,v as Ot,w as Dt,x as yt,y as rt,D as Tt,z as It,A as Gt}from"./index-Dq16ignL.js";let At={data:""},Pt=e=>{if(typeof window=="object"){let t=(e?e.querySelector("#_goober"):window._goober)||Object.assign(document.createElement("style"),{innerHTML:" ",id:"_goober"});return t.nonce=window.__nonce__,t.parentNode||(e||document.head).appendChild(t),t.firstChild}return e||At},Rt=/(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g,Lt=/\/\*[^]*?\*\/| +/g,ht=/\n+/g,we=(e,t)=>{let n="",i="",p="";for(let l in e){let a=e[l];l[0]=="@"?l[1]=="i"?n=l+" "+a+";":i+=l[1]=="f"?we(a,l):l+"{"+we(a,l[1]=="k"?"":t)+"}":typeof a=="object"?i+=we(a,t?t.replace(/([^,])+/g,f=>l.replace(/([^,]*:\S+\([^)]*\))|([^,])+/g,g=>/&/.test(g)?g.replace(/&/g,f):f?f+" "+g:g)):l):a!=null&&(l=/^--/.test(l)?l:l.replace(/[A-Z]/g,"-$&").toLowerCase(),p+=we.p?we.p(l,a):l+":"+a+";")}return n+(t&&p?t+"{"+p+"}":p)+i},$e={},kt=e=>{if(typeof e=="object"){let t="";for(let n in e)t+=n+kt(e[n]);return t}return e},jt=(e,t,n,i,p)=>{let l=kt(e),a=$e[l]||($e[l]=(g=>{let o=0,r=11;for(;o>>0;return"go"+r})(l));if(!$e[a]){let g=l!==e?e:(o=>{let r,v,h=[{}];for(;r=Rt.exec(o.replace(Lt,""));)r[4]?h.shift():r[3]?(v=r[3].replace(ht," ").trim(),h.unshift(h[0][v]=h[0][v]||{})):h[0][r[1]]=r[2].replace(ht," ").trim();return h[0]})(e);$e[a]=we(p?{["@keyframes "+a]:g}:g,n?"":"."+a)}let f=n&&$e.g?$e.g:null;return n&&($e.g=$e[a]),((g,o,r,v)=>{v?o.data=o.data.replace(v,g):o.data.indexOf(g)===-1&&(o.data=r?g+o.data:o.data+g)})($e[a],t,i,f),a},Ht=(e,t,n)=>e.reduce((i,p,l)=>{let a=t[l];if(a&&a.call){let f=a(n),g=f&&f.props&&f.props.className||/^go/.test(f)&&f;a=g?"."+g:f&&typeof f=="object"?f.props?"":we(f,""):f===!1?"":f}return i+p+(a??"")},"");function Be(e){let t=this||{},n=e.call?e(t.p):e;return jt(n.unshift?n.raw?Ht(n,[].slice.call(arguments,1),t.p):n.reduce((i,p)=>Object.assign(i,p&&p.call?p(t.p):p),{}):n,Pt(t.target),t.g,t.o,t.k)}Be.bind({g:1});Be.bind({k:1});const Nt=typeof window>"u";function et(e){const t={pending:"yellow",success:"green",error:"red",notFound:"purple",redirected:"gray"};return e.isFetching&&e.status==="success"?e.isFetching==="beforeLoad"?"purple":"blue":t[e.status]}function Vt(e,t){const n=e.find(i=>i.routeId===t.id);return n?et(n):"gray"}function Jt(){const[e,t]=ne(!1);return(Nt?Ue:w)(()=>{t(!0)}),e}const Yt=e=>{const t=Object.getOwnPropertyNames(Object(e)),n=typeof e=="bigint"?`${e.toString()}n`:e;try{return JSON.stringify(n,t)}catch{return"unable to stringify"}};function qt(e,t=[n=>n]){return e.map((n,i)=>[n,i]).sort(([n,i],[p,l])=>{for(const a of t){const f=a(n),g=a(p);if(typeof f>"u"){if(typeof g>"u")continue;return 1}if(f!==g)return f>g?1:-1}return i-l}).map(([n])=>n)}const I={colors:{inherit:"inherit",current:"currentColor",transparent:"transparent",black:"#000000",white:"#ffffff",neutral:{50:"#f9fafb",100:"#f2f4f7",200:"#eaecf0",300:"#d0d5dd",400:"#98a2b3",500:"#667085",600:"#475467",700:"#344054",800:"#1d2939",900:"#101828"},darkGray:{50:"#525c7a",100:"#49536e",200:"#414962",300:"#394056",400:"#313749",500:"#292e3d",600:"#212530",700:"#191c24",800:"#111318",900:"#0b0d10"},gray:{50:"#f9fafb",100:"#f2f4f7",200:"#eaecf0",300:"#d0d5dd",400:"#98a2b3",500:"#667085",600:"#475467",700:"#344054",800:"#1d2939",900:"#101828"},blue:{25:"#F5FAFF",50:"#EFF8FF",100:"#D1E9FF",200:"#B2DDFF",300:"#84CAFF",400:"#53B1FD",500:"#2E90FA",600:"#1570EF",700:"#175CD3",800:"#1849A9",900:"#194185"},green:{25:"#F6FEF9",50:"#ECFDF3",100:"#D1FADF",200:"#A6F4C5",300:"#6CE9A6",400:"#32D583",500:"#12B76A",600:"#039855",700:"#027A48",800:"#05603A",900:"#054F31"},red:{50:"#fef2f2",100:"#fee2e2",200:"#fecaca",300:"#fca5a5",400:"#f87171",500:"#ef4444",600:"#dc2626",700:"#b91c1c",800:"#991b1b",900:"#7f1d1d",950:"#450a0a"},yellow:{25:"#FFFCF5",50:"#FFFAEB",100:"#FEF0C7",200:"#FEDF89",300:"#FEC84B",400:"#FDB022",500:"#F79009",600:"#DC6803",700:"#B54708",800:"#93370D",900:"#7A2E0E"},purple:{25:"#FAFAFF",50:"#F4F3FF",100:"#EBE9FE",200:"#D9D6FE",300:"#BDB4FE",400:"#9B8AFB",500:"#7A5AF8",600:"#6938EF",700:"#5925DC",800:"#4A1FB8",900:"#3E1C96"},teal:{25:"#F6FEFC",50:"#F0FDF9",100:"#CCFBEF",200:"#99F6E0",300:"#5FE9D0",400:"#2ED3B7",500:"#15B79E",600:"#0E9384",700:"#107569",800:"#125D56",900:"#134E48"},pink:{25:"#fdf2f8",50:"#fce7f3",100:"#fbcfe8",200:"#f9a8d4",300:"#f472b6",400:"#ec4899",500:"#db2777",600:"#be185d",700:"#9d174d",800:"#831843",900:"#500724"},cyan:{25:"#ecfeff",50:"#cffafe",100:"#a5f3fc",200:"#67e8f9",300:"#22d3ee",400:"#06b6d4",500:"#0891b2",600:"#0e7490",700:"#155e75",800:"#164e63",900:"#083344"}},alpha:{90:"e5",70:"b3",20:"33"},font:{size:{"2xs":"calc(var(--tsrd-font-size) * 0.625)",xs:"calc(var(--tsrd-font-size) * 0.75)",sm:"calc(var(--tsrd-font-size) * 0.875)",md:"var(--tsrd-font-size)"},lineHeight:{xs:"calc(var(--tsrd-font-size) * 1)",sm:"calc(var(--tsrd-font-size) * 1.25)"},weight:{normal:"400",medium:"500",semibold:"600",bold:"700"},fontFamily:{sans:"ui-sans-serif, Inter, system-ui, sans-serif, sans-serif",mono:"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"}},border:{radius:{xs:"calc(var(--tsrd-font-size) * 0.125)",sm:"calc(var(--tsrd-font-size) * 0.25)",md:"calc(var(--tsrd-font-size) * 0.375)",full:"9999px"}},size:{0:"0px",.5:"calc(var(--tsrd-font-size) * 0.125)",1:"calc(var(--tsrd-font-size) * 0.25)",1.5:"calc(var(--tsrd-font-size) * 0.375)",2:"calc(var(--tsrd-font-size) * 0.5)",2.5:"calc(var(--tsrd-font-size) * 0.625)",3:"calc(var(--tsrd-font-size) * 0.75)",3.5:"calc(var(--tsrd-font-size) * 0.875)",4:"calc(var(--tsrd-font-size) * 1)",5:"calc(var(--tsrd-font-size) * 1.25)",8:"calc(var(--tsrd-font-size) * 2)"}},Kt=e=>{const{colors:t,font:n,size:i,alpha:p,border:l}=I,{fontFamily:a,lineHeight:f,size:g}=n,o=e?Be.bind({target:e}):Be;return{devtoolsPanelContainer:o` + direction: ltr; + position: fixed; + bottom: 0; + right: 0; + z-index: 99999; + width: 100%; + max-height: 90%; + border-top: 1px solid ${t.gray[700]}; + transform-origin: top; + `,devtoolsPanelContainerVisibility:r=>o` + visibility: ${r?"visible":"hidden"}; + `,devtoolsPanelContainerResizing:r=>r()?o` + transition: none; + `:o` + transition: all 0.4s ease; + `,devtoolsPanelContainerAnimation:(r,v)=>r?o` + pointer-events: auto; + transform: translateY(0); + `:o` + pointer-events: none; + transform: translateY(${v}px); + `,logo:o` + cursor: pointer; + display: flex; + flex-direction: column; + background-color: transparent; + border: none; + font-family: ${a.sans}; + gap: ${I.size[.5]}; + padding: 0px; + &:hover { + opacity: 0.7; + } + &:focus-visible { + outline-offset: 4px; + border-radius: ${l.radius.xs}; + outline: 2px solid ${t.blue[800]}; + } + `,tanstackLogo:o` + font-size: ${n.size.md}; + font-weight: ${n.weight.bold}; + line-height: ${n.lineHeight.xs}; + white-space: nowrap; + color: ${t.gray[300]}; + `,routerLogo:o` + font-weight: ${n.weight.semibold}; + font-size: ${n.size.xs}; + background: linear-gradient(to right, #84cc16, #10b981); + background-clip: text; + -webkit-background-clip: text; + line-height: 1; + -webkit-text-fill-color: transparent; + white-space: nowrap; + `,devtoolsPanel:o` + display: flex; + font-size: ${g.sm}; + font-family: ${a.sans}; + background-color: ${t.darkGray[700]}; + color: ${t.gray[300]}; + + @media (max-width: 700px) { + flex-direction: column; + } + @media (max-width: 600px) { + font-size: ${g.xs}; + } + `,dragHandle:o` + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 4px; + cursor: row-resize; + z-index: 100000; + &:hover { + background-color: ${t.purple[400]}${p[90]}; + } + `,firstContainer:o` + flex: 1 1 500px; + min-height: 40%; + max-height: 100%; + overflow: auto; + border-right: 1px solid ${t.gray[700]}; + display: flex; + flex-direction: column; + `,routerExplorerContainer:o` + overflow-y: auto; + flex: 1; + `,routerExplorer:o` + padding: ${I.size[2]}; + `,row:o` + display: flex; + align-items: center; + padding: ${I.size[2]} ${I.size[2.5]}; + gap: ${I.size[2.5]}; + border-bottom: ${t.darkGray[500]} 1px solid; + align-items: center; + `,detailsHeader:o` + font-family: ui-sans-serif, Inter, system-ui, sans-serif, sans-serif; + position: sticky; + top: 0; + z-index: 2; + background-color: ${t.darkGray[600]}; + padding: 0px ${I.size[2]}; + font-weight: ${n.weight.medium}; + font-size: ${n.size.xs}; + min-height: ${I.size[8]}; + line-height: ${n.lineHeight.xs}; + text-align: left; + display: flex; + align-items: center; + `,maskedBadge:o` + background: ${t.yellow[900]}${p[70]}; + color: ${t.yellow[300]}; + display: inline-block; + padding: ${I.size[0]} ${I.size[2.5]}; + border-radius: ${l.radius.full}; + font-size: ${n.size.xs}; + font-weight: ${n.weight.normal}; + border: 1px solid ${t.yellow[300]}; + `,maskedLocation:o` + color: ${t.yellow[300]}; + `,detailsContent:o` + padding: ${I.size[1.5]} ${I.size[2]}; + display: flex; + align-items: center; + justify-content: space-between; + font-size: ${n.size.xs}; + `,routeMatchesToggle:o` + display: flex; + align-items: center; + border: 1px solid ${t.gray[500]}; + border-radius: ${l.radius.sm}; + overflow: hidden; + `,routeMatchesToggleBtn:(r,v)=>{const C=[o` + appearance: none; + border: none; + font-size: 12px; + padding: 4px 8px; + background: transparent; + cursor: pointer; + font-family: ${a.sans}; + font-weight: ${n.weight.medium}; + `];if(r){const m=o` + background: ${t.darkGray[400]}; + color: ${t.gray[300]}; + `;C.push(m)}else{const m=o` + color: ${t.gray[500]}; + background: ${t.darkGray[800]}${p[20]}; + `;C.push(m)}return v&&C.push(o` + border-right: 1px solid ${I.colors.gray[500]}; + `),C},detailsHeaderInfo:o` + flex: 1; + justify-content: flex-end; + display: flex; + align-items: center; + font-weight: ${n.weight.normal}; + color: ${t.gray[400]}; + `,matchRow:r=>{const h=[o` + display: flex; + border-bottom: 1px solid ${t.darkGray[400]}; + cursor: pointer; + align-items: center; + padding: ${i[1]} ${i[2]}; + gap: ${i[2]}; + font-size: ${g.xs}; + color: ${t.gray[300]}; + `];if(r){const C=o` + background: ${t.darkGray[500]}; + `;h.push(C)}return h},matchIndicator:r=>{const h=[o` + flex: 0 0 auto; + width: ${i[3]}; + height: ${i[3]}; + background: ${t[r][900]}; + border: 1px solid ${t[r][500]}; + border-radius: ${l.radius.full}; + transition: all 0.25s ease-out; + box-sizing: border-box; + `];if(r==="gray"){const C=o` + background: ${t.gray[700]}; + border-color: ${t.gray[400]}; + `;h.push(C)}return h},matchID:o` + flex: 1; + line-height: ${f.xs}; + `,ageTicker:r=>{const h=[o` + display: flex; + gap: ${i[1]}; + font-size: ${g.xs}; + color: ${t.gray[400]}; + font-variant-numeric: tabular-nums; + line-height: ${f.xs}; + `];if(r){const C=o` + color: ${t.yellow[400]}; + `;h.push(C)}return h},secondContainer:o` + flex: 1 1 500px; + min-height: 40%; + max-height: 100%; + overflow: auto; + border-right: 1px solid ${t.gray[700]}; + display: flex; + flex-direction: column; + `,thirdContainer:o` + flex: 1 1 500px; + overflow: auto; + display: flex; + flex-direction: column; + height: 100%; + border-right: 1px solid ${t.gray[700]}; + + @media (max-width: 700px) { + border-top: 2px solid ${t.gray[700]}; + } + `,fourthContainer:o` + flex: 1 1 500px; + min-height: 40%; + max-height: 100%; + overflow: auto; + display: flex; + flex-direction: column; + `,routesContainer:o` + overflow-x: auto; + overflow-y: visible; + `,routesRowContainer:(r,v)=>{const C=[o` + display: flex; + border-bottom: 1px solid ${t.darkGray[400]}; + align-items: center; + padding: ${i[1]} ${i[2]}; + gap: ${i[2]}; + font-size: ${g.xs}; + color: ${t.gray[300]}; + cursor: ${v?"pointer":"default"}; + line-height: ${f.xs}; + `];if(r){const m=o` + background: ${t.darkGray[500]}; + `;C.push(m)}return C},routesRow:r=>{const h=[o` + flex: 1 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + font-size: ${g.xs}; + line-height: ${f.xs}; + `];if(!r){const C=o` + color: ${t.gray[400]}; + `;h.push(C)}return h},routesRowInner:o` + display: 'flex'; + align-items: 'center'; + flex-grow: 1; + min-width: 0; + `,routeParamInfo:o` + color: ${t.gray[400]}; + font-size: ${g.xs}; + line-height: ${f.xs}; + `,nestedRouteRow:r=>o` + margin-left: ${r?0:i[3.5]}; + border-left: ${r?"":`solid 1px ${t.gray[700]}`}; + `,code:o` + font-size: ${g.xs}; + line-height: ${f.xs}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + `,matchesContainer:o` + flex: 1 1 auto; + overflow-y: auto; + `,cachedMatchesContainer:o` + flex: 1 1 auto; + overflow-y: auto; + max-height: 50%; + `,historyContainer:o` + display: flex; + flex: 1 1 auto; + overflow-y: auto; + max-height: 50%; + `,historyOverflowContainer:o` + padding: ${i[1]} ${i[2]}; + font-size: ${I.font.size.xs}; + `,maskedBadgeContainer:o` + flex: 1; + justify-content: flex-end; + display: flex; + `,matchDetails:o` + display: flex; + flex-direction: column; + padding: ${I.size[2]}; + font-size: ${I.font.size.xs}; + color: ${I.colors.gray[300]}; + line-height: ${I.font.lineHeight.sm}; + `,matchStatus:(r,v)=>{const C=v&&r==="success"?v==="beforeLoad"?"purple":"blue":{pending:"yellow",success:"green",error:"red",notFound:"purple",redirected:"gray"}[r];return o` + display: flex; + justify-content: center; + align-items: center; + height: 40px; + border-radius: ${I.border.radius.sm}; + font-weight: ${I.font.weight.normal}; + background-color: ${I.colors[C][900]}${I.alpha[90]}; + color: ${I.colors[C][300]}; + border: 1px solid ${I.colors[C][600]}; + margin-bottom: ${I.size[2]}; + transition: all 0.25s ease-out; + `},matchDetailsInfo:o` + display: flex; + justify-content: flex-end; + flex: 1; + `,matchDetailsInfoLabel:o` + display: flex; + `,mainCloseBtn:o` + background: ${t.darkGray[700]}; + padding: ${i[1]} ${i[2]} ${i[1]} ${i[1.5]}; + border-radius: ${l.radius.md}; + position: fixed; + z-index: 99999; + display: inline-flex; + width: fit-content; + cursor: pointer; + appearance: none; + border: 0; + gap: 8px; + align-items: center; + border: 1px solid ${t.gray[500]}; + font-size: ${n.size.xs}; + cursor: pointer; + transition: all 0.25s ease-out; + + &:hover { + background: ${t.darkGray[500]}; + } + `,mainCloseBtnPosition:r=>o` + ${r==="top-left"?`top: ${i[2]}; left: ${i[2]};`:""} + ${r==="top-right"?`top: ${i[2]}; right: ${i[2]};`:""} + ${r==="bottom-left"?`bottom: ${i[2]}; left: ${i[2]};`:""} + ${r==="bottom-right"?`bottom: ${i[2]}; right: ${i[2]};`:""} + `,mainCloseBtnAnimation:r=>r?o` + opacity: 0; + pointer-events: none; + visibility: hidden; + `:o` + opacity: 1; + pointer-events: auto; + visibility: visible; + `,routerLogoCloseButton:o` + font-weight: ${n.weight.semibold}; + font-size: ${n.size.xs}; + background: linear-gradient(to right, #98f30c, #00f4a3); + background-clip: text; + -webkit-background-clip: text; + line-height: 1; + -webkit-text-fill-color: transparent; + white-space: nowrap; + `,mainCloseBtnDivider:o` + width: 1px; + background: ${I.colors.gray[600]}; + height: 100%; + border-radius: 999999px; + color: transparent; + `,mainCloseBtnIconContainer:o` + position: relative; + width: ${i[5]}; + height: ${i[5]}; + background: pink; + border-radius: 999999px; + overflow: hidden; + `,mainCloseBtnIconOuter:o` + width: ${i[5]}; + height: ${i[5]}; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + filter: blur(3px) saturate(1.8) contrast(2); + `,mainCloseBtnIconInner:o` + width: ${i[4]}; + height: ${i[4]}; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + `,panelCloseBtn:o` + position: absolute; + cursor: pointer; + z-index: 100001; + display: flex; + align-items: center; + justify-content: center; + outline: none; + background-color: ${t.darkGray[700]}; + &:hover { + background-color: ${t.darkGray[500]}; + } + + top: 0; + right: ${i[2]}; + transform: translate(0, -100%); + border-right: ${t.darkGray[300]} 1px solid; + border-left: ${t.darkGray[300]} 1px solid; + border-top: ${t.darkGray[300]} 1px solid; + border-bottom: none; + border-radius: ${l.radius.sm} ${l.radius.sm} 0px 0px; + padding: ${i[1]} ${i[1.5]} ${i[.5]} ${i[1.5]}; + + &::after { + content: ' '; + position: absolute; + top: 100%; + left: -${i[2.5]}; + height: ${i[1.5]}; + width: calc(100% + ${i[5]}); + } + `,panelCloseBtnIcon:o` + color: ${t.gray[400]}; + width: ${i[2]}; + height: ${i[2]}; + `,navigateButton:o` + background: none; + border: none; + padding: 0 0 0 4px; + margin: 0; + color: ${t.gray[400]}; + font-size: ${g.md}; + cursor: pointer; + line-height: 1; + vertical-align: middle; + margin-right: 0.5ch; + flex-shrink: 0; + &:hover { + color: ${t.blue[300]}; + } + `}};function Ee(){const e=bt(yt),[t]=ne(Kt(e));return t}const Wt=e=>{try{const t=localStorage.getItem(e);return typeof t=="string"?JSON.parse(t):void 0}catch{return}};function Ze(e,t){const[n,i]=ne();return Ue(()=>{const l=Wt(e);i(typeof l>"u"||l===null?typeof t=="function"?t():t:l)}),[n,l=>{i(a=>{let f=l;typeof l=="function"&&(f=l(a));try{localStorage.setItem(e,JSON.stringify(f))}catch{}return f})}]}var Zt=O(''),Je=O("
"),Qt=O("
Pathname
age / staleTime / gcTime
'),pr=O("
masked"),$t=O("
"),hr=O("
  • "),vr=O("
  • This panel displays the most recent 15 navigations."),$r=O("
    Cached Matches
    age / staleTime / gcTime
    "),mr=O("
    Match Details
    ID:
    State:
    Last Updated:
    Explorer
    "),xr=O("
    Loader Data"),br=O("
    Search Params
    "),yr=O(""),kr=O('
    Pathname
    age / staleTime / gcTime
    '),pr=O("
    masked"),$t=O("
    "),hr=O("
  • "),vr=O("
  • This panel displays the most recent 15 navigations."),$r=O("
    Cached Matches
    age / staleTime / gcTime
    "),mr=O("
    Match Details
    ID:
    State:
    Last Updated:
    Explorer
    "),xr=O("
    Loader Data"),br=O("
    Search Params
    "),yr=O(""),kr=O('