From b54e8981efe3ff2b18cd3915c359b19d6b83aaac Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sat, 2 May 2026 19:55:39 +0800 Subject: [PATCH 01/11] =?UTF-8?q?config=EF=BC=9A=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE=E6=94=AF?= =?UTF-8?q?=E6=8C=81v4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/config.py | 2 +- src/config/default_model_config.py | 49 +++++++++++++----------------- src/config/model_configs.py | 6 ++-- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/config/config.py b/src/config/config.py index dcd5e7eb..b7994e5f 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -57,7 +57,7 @@ MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute( LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute() MMC_VERSION: str = "1.0.0" CONFIG_VERSION: str = "8.9.20" -MODEL_CONFIG_VERSION: str = "1.14.3" +MODEL_CONFIG_VERSION: str = "1.14.5" logger = get_logger("config") diff --git a/src/config/default_model_config.py b/src/config/default_model_config.py index 7ae93a43..e74653ae 100644 --- a/src/config/default_model_config.py +++ b/src/config/default_model_config.py @@ -11,26 +11,29 @@ DEFAULT_PROVIDER_TEMPLATES: list[dict[str, Any]] = [ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_key": "your-api-key", "auth_type": OpenAICompatibleAuthType.BEARER.value, + "max_retry": 3, + "timeout": 100, + "retry_interval": 8, } ] DEFAULT_TASK_CONFIG_TEMPLATES: dict[str, dict[str, Any]] = { "utils": { - "model_list": ["qwen3.5-35b-a3b-nonthink"], + "model_list": ["deepseek-v4-flash"], "max_tokens": 4096, "temperature": 0.5, "slow_threshold": 15.0, "selection_strategy": "random", }, "replyer": { - "model_list": ["ali-glm-5"], + "model_list": ["deepseek-v4-pro-think", "deepseek-v4-pro-nonthink"], "max_tokens": 4096, "temperature": 1, "slow_threshold": 120.0, "selection_strategy": "random", }, "planner": { - "model_list": ["qwen3.5-35b-a3b", "qwen3.5-122b-a10b", "qwen3.5-flash"], + "model_list": ["deepseek-v4-flash"], "max_tokens": 8000, "temperature": 0.7, "slow_threshold": 12.0, @@ -61,40 +64,30 @@ DEFAULT_TASK_CONFIG_TEMPLATES: dict[str, dict[str, Any]] = { DEFAULT_MODEL_TEMPLATES: list[dict[str, Any]] = [ { - "model_identifier": "glm-5", - "name": "ali-glm-5", + "model_identifier": "deepseek-v4-pro", + "name": "deepseek-v4-pro-think", "api_provider": "BaiLian", - "price_in": 3.0, - "price_out": 14.0, - "temperature": 1.0, + "price_in": 12.0, + "price_out": 24.0, "visual": False, - "extra_params": {"enable_thinking": False}, + "extra_params": {"enable_thinking": "True"}, }, { - "model_identifier": "qwen3.5-122b-a10b", - "name": "qwen3.5-122b-a10b", + "model_identifier": "deepseek-v4-pro", + "name": "deepseek-v4-pro-nonthink", "api_provider": "BaiLian", - "price_in": 0.8, - "price_out": 6.4, - "visual": True, + "price_in": 12.0, + "price_out": 24.0, + "visual": False, "extra_params": {"enable_thinking": "false"}, }, { - "model_identifier": "qwen3.5-35b-a3b", - "name": "qwen3.5-35b-a3b", + "model_identifier": "deepseek-v4-flash", + "name": "deepseek-v4-flash", "api_provider": "BaiLian", - "price_in": 0.4, - "price_out": 3.2, - "visual": True, - "extra_params": {}, - }, - { - "model_identifier": "qwen3.5-35b-a3b", - "name": "qwen3.5-35b-a3b-nonthink", - "api_provider": "BaiLian", - "price_in": 0.4, - "price_out": 3.2, - "visual": True, + "price_in": 1.0, + "price_out": 2.0, + "visual": False, "extra_params": {"enable_thinking": "false"}, }, { diff --git a/src/config/model_configs.py b/src/config/model_configs.py index 08ee9c6d..caf7e5c7 100644 --- a/src/config/model_configs.py +++ b/src/config/model_configs.py @@ -172,7 +172,7 @@ class APIProvider(ConfigBase): """工具参数解析模式。可选值:`auto`、`strict`、`repair`、`double_decode`。""" max_retry: int = Field( - default=2, + default=3, ge=0, json_schema_extra={ "x-widget": "input", @@ -182,7 +182,7 @@ class APIProvider(ConfigBase): """最大重试次数 (单个模型API调用失败, 最多重试的次数)""" timeout: int = Field( - default=10, + default=60, ge=1, json_schema_extra={ "x-widget": "input", @@ -193,7 +193,7 @@ class APIProvider(ConfigBase): """API调用的超时时长 (超过这个时长, 本次请求将被视为"请求超时", 单位: 秒)""" retry_interval: int = Field( - default=10, + default=5, ge=1, json_schema_extra={ "x-widget": "input", From ffdc66d87bf83d946038304a0bd00d9bb7cf0048 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sat, 2 May 2026 20:21:42 +0800 Subject: [PATCH 02/11] Update docker-compose.yml --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 9e71c76f..853990b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - ./docker-config/mmc:/MaiMBot/config # 持久化bot配置文件 - ./data/MaiMBot/maibot_statistics.html:/MaiMBot/maibot_statistics.html #统计数据输出 - ./data/MaiMBot:/MaiMBot/data # 共享目录 + - ./data/MaiMBot/emoji:/data/emoji # 持久化表情包 - ./data/MaiMBot/plugins:/MaiMBot/plugins # 插件目录 - ./data/MaiMBot/logs:/MaiMBot/logs # 日志目录 # - site-packages:/usr/local/lib/python3.13/site-packages # 持久化Python包,需要时启用 From 03ac8b30f3c942377c050f8824658802123ec0d9 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sun, 3 May 2026 14:22:35 +0800 Subject: [PATCH 03/11] =?UTF-8?q?doc=EF=BC=9A=E8=B4=A1=E7=8C=AE=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- docs/README_CN.md | 2 +- docs/README_EN.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 22627ced..669ff07f 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,8 @@ MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务 ## 🙋 贡献和致谢 Contributing and Acknowledgments -欢迎参与贡献!请先阅读 [贡献指南](docs-src/CONTRIBUTE.md)。 -Contributions are welcome. Please read the Contribution Guide first. +欢迎参与贡献!请先阅读 [贡献指南](docs/CONTRIBUTE.md)。 +Contributions are welcome. Please read the Contribution Guide first. ### 🌟 贡献者 Contributors diff --git a/docs/README_CN.md b/docs/README_CN.md index 9532cd00..5347d906 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -129,7 +129,7 @@ MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务 ## 🙋 贡献和致谢 -欢迎参与贡献!请先阅读 [贡献指南](../docs-src/CONTRIBUTE.md)。 +欢迎参与贡献!请先阅读 [贡献指南](CONTRIBUTE.md)。 ### 🌟 贡献者 diff --git a/docs/README_EN.md b/docs/README_EN.md index 9ecc53b7..58f4cea2 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -122,7 +122,7 @@ We welcome everyone interested in MaiBot to join us. ## 🙋 Contributing and Acknowledgments -Contributions are welcome. Please read the [Contribution Guide](../docs-src/CONTRIBUTE.md) first. +Contributions are welcome. Please read the [Contribution Guide](CONTRIBUTE.md) first. ### 🌟 Contributors From 0a9833deab7ab6dd24f8ea76783a527108576895 Mon Sep 17 00:00:00 2001 From: DawnARC Date: Sun, 3 May 2026 10:40:40 +0800 Subject: [PATCH 04/11] fix docker dependency install --- .dockerignore | 15 ++++++++++++++- Dockerfile | 7 +++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.dockerignore b/.dockerignore index 15c09fbf..654a03fd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,17 @@ mongodb napcat docs/ .github/ -# test \ No newline at end of file +# test +.env +.venv/ +.pytest_cache/ +.ruff_cache/ +.tmp_*/ +node_modules/ +dashboard/node_modules/ +data/ +logs/ +temp/ +tmp/ +mai_knowledge/ +depends-data/ diff --git a/Dockerfile b/Dockerfile index 91ad6b99..6904aafc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,14 +7,13 @@ WORKDIR /MaiMBot ENV MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 -# Copy dependency list -COPY requirements.txt . +# Copy dependency metadata +COPY pyproject.toml uv.lock ./ RUN apt-get update && apt-get install -y git # Install runtime dependencies -RUN uv pip install --system --upgrade pip -RUN uv pip install --system -r requirements.txt +RUN uv sync --frozen --no-dev --system --no-install-project # Copy project source COPY . . From 0252daf82a70cc83255704d45b16ac1493201285 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sun, 3 May 2026 15:36:10 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=AF=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/A_memorix/core/utils/web_import_manager.py | 1 - src/config/config.py | 2 +- src/config/model_configs.py | 7 ++++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/A_memorix/core/utils/web_import_manager.py b/src/A_memorix/core/utils/web_import_manager.py index 1ee93418..a4db5c54 100644 --- a/src/A_memorix/core/utils/web_import_manager.py +++ b/src/A_memorix/core/utils/web_import_manager.py @@ -3259,7 +3259,6 @@ class ImportTaskManager: for task_name in [ "lpmm_entity_extract", "lpmm_rdf_build", - "embedding", "replyer", "utils", "planner", diff --git a/src/config/config.py b/src/config/config.py index b7994e5f..1be32621 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -57,7 +57,7 @@ MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute( LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute() MMC_VERSION: str = "1.0.0" CONFIG_VERSION: str = "8.9.20" -MODEL_CONFIG_VERSION: str = "1.14.5" +MODEL_CONFIG_VERSION: str = "1.14.6" logger = get_logger("config") diff --git a/src/config/model_configs.py b/src/config/model_configs.py index caf7e5c7..447eadf2 100644 --- a/src/config/model_configs.py +++ b/src/config/model_configs.py @@ -343,7 +343,12 @@ class ModelInfo(ConfigBase): "x-icon": "sliders", }, ) - """额外参数 (用于API调用时的额外配置)""" + """额外参数 (用于API调用时的额外配置)。 + OpenAI 兼容客户端会将该字典拆分为请求附加项:headers 会作为请求头传入,query 会作为 URL 查询参数传入,body 会合并到请求体。 + 未放入 headers/query/body 的普通键,也会作为请求体额外字段传入;例如 {enable_thinking = "false"} 会传为请求体字段 enable_thinking。 + 该字段不会以 extra_params 这个键整体发送给模型服务商。 + temperature 和 max_tokens 也可写在此处作为模型级默认值,但更推荐使用同名独立配置项。 + Gemini 客户端会按自身支持的字段筛选并映射到 GenerateContentConfig、EmbedContentConfig 或音频请求配置中。""" def model_post_init(self, context: Any = None): if not self.model_identifier: From 9090b7274600762a39188d46357fe235ce563a7e Mon Sep 17 00:00:00 2001 From: DawnARC Date: Sun, 3 May 2026 17:34:53 +0800 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20=E9=BB=98=E8=AE=A4=E5=85=B3?= =?UTF-8?q?=E9=97=AD=20A=5FMemorix=20=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/A_memorix/config_schema.json | 4 +- src/A_memorix/host_service.py | 102 ++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/A_memorix/config_schema.json b/src/A_memorix/config_schema.json index b88046d2..97eb77ce 100644 --- a/src/A_memorix/config_schema.json +++ b/src/A_memorix/config_schema.json @@ -74,7 +74,7 @@ "enabled": { "name": "enabled", "type": "boolean", - "default": true, + "default": false, "description": "是否启用 A_Memorix", "label": "启用 A_Memorix", "ui_type": "switch", @@ -82,7 +82,7 @@ "hidden": false, "disabled": false, "order": 1, - "hint": "关闭后 A_Memorix 不会参与长期记忆写入、检索与运维。", + "hint": "默认关闭以简化首次配置;开启前请先配置可用的 embedding 模型。关闭后 A_Memorix 不会参与长期记忆写入、检索与运维。", "choices": null } } diff --git a/src/A_memorix/host_service.py b/src/A_memorix/host_service.py index 8b05127d..9dc6fba6 100644 --- a/src/A_memorix/host_service.py +++ b/src/A_memorix/host_service.py @@ -48,6 +48,9 @@ class AMemorixHostService: self._config_cache: Dict[str, Any] | None = None async def start(self) -> None: + if not self.is_enabled(): + logger.info("A_Memorix 未启用,跳过长期记忆运行时初始化") + return await self._ensure_kernel() async def stop(self) -> None: @@ -57,9 +60,13 @@ class AMemorixHostService: async def reload(self) -> None: async with self._lock: await self._shutdown_locked() - self._config_cache = self._read_config() + self._config_cache = None + config = self._read_config() - await self._ensure_kernel() + if self._is_enabled_config(config): + await self._ensure_kernel() + else: + logger.info("A_Memorix 配置为未启用,运行时保持关闭") def get_config_path(self) -> Path: return config_path() @@ -88,6 +95,16 @@ class AMemorixHostService: def get_config(self) -> Dict[str, Any]: return dict(self._read_config()) + def is_enabled(self) -> bool: + return self._is_enabled_config(self._read_config()) + + @staticmethod + def _is_enabled_config(config: Dict[str, Any]) -> bool: + plugin_config = config.get("plugin") if isinstance(config, dict) else None + if not isinstance(plugin_config, dict): + return True + return bool(plugin_config.get("enabled", True)) + def _build_default_config(self) -> Dict[str, Any]: schema = self.get_config_schema() sections = schema.get("sections") if isinstance(schema, dict) else None @@ -172,6 +189,8 @@ class AMemorixHostService: async def invoke(self, component_name: str, args: Dict[str, Any] | None = None, *, timeout_ms: int = 30000) -> Any: del timeout_ms payload = args or {} + if not self.is_enabled(): + return self._disabled_response(component_name) kernel = await self._ensure_kernel() if component_name == "search_memory": @@ -279,6 +298,8 @@ class AMemorixHostService: async with self._lock: if self._kernel is None: config = self._read_config() + if not self._is_enabled_config(config): + raise RuntimeError("A_Memorix 未启用") kernel = SDKMemoryKernel(plugin_root=repo_root(), config=config) try: await kernel.initialize() @@ -311,6 +332,83 @@ class AMemorixHostService: self._config_cache = _to_builtin_data(loaded) if isinstance(loaded, dict) else {} return dict(self._config_cache) + @staticmethod + def _disabled_response(component_name: str) -> Dict[str, Any]: + reason = "a_memorix_disabled" + message = "A_Memorix 未启用,请在长期记忆配置中开启后再使用。" + + if component_name == "search_memory": + return { + "success": True, + "disabled": True, + "reason": reason, + "summary": "", + "hits": [], + "filtered": False, + } + + if component_name in {"ingest_summary", "ingest_text"}: + return { + "success": True, + "disabled": True, + "reason": reason, + "stored_ids": [], + "skipped_ids": [reason], + "detail": reason, + } + + if component_name == "get_person_profile": + return { + "success": True, + "disabled": True, + "reason": reason, + "summary": "", + "traits": [], + "evidence": [], + } + + if component_name == "memory_stats": + return { + "success": True, + "enabled": False, + "disabled": True, + "reason": reason, + "message": message, + "paragraph_count": 0, + "relation_count": 0, + "episode_count": 0, + } + + if component_name == "memory_runtime_admin": + return { + "success": True, + "enabled": False, + "disabled": True, + "reason": reason, + "message": message, + "runtime_ready": False, + "embedding_degraded": False, + "embedding_dimension": 0, + "auto_save": False, + "data_dir": "", + } + + if component_name == "enqueue_feedback_task": + return { + "success": True, + "queued": False, + "disabled": True, + "reason": reason, + } + + return { + "success": False, + "enabled": False, + "disabled": True, + "reason": reason, + "error": message, + } + async def _shutdown_locked(self) -> None: if self._kernel is None: return From db46551b518f992ba6c3dfcfc18219153bcc7e94 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sun, 3 May 2026 17:59:29 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E5=85=81=E8=AE=B8=E6=9C=AC=E5=9C=B0webui?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 - dashboard/package-lock.json | 73 +++++++++++++++++++++++++++++++++- src/config/official_configs.py | 3 +- src/webui/app.py | 16 +++++--- 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5cec7c47..a66d71ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,6 @@ # 配置文件修改 如果你需要改动配置文件,不需要修改实际的bot_config.toml或者model_config.toml,只需要修改配置文件模版,并新增一个版本号即可,也不必要为配置改动创建测试文件。 -# 关于webui修改 -不要修改dashboard下的内容,因为这部分内容由另一个仓库build # 关于 A_memorix 修改 如果修改涉及 `src/A_memorix`,请先阅读 `src/A_memorix/MODIFICATION_POLICY.md`。 diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index a0e1d02c..c187f9e9 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1,12 +1,12 @@ { "name": "maibot-dashboard", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maibot-dashboard", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-javascript": "^6.2.4", @@ -62,6 +62,7 @@ "idb": "^8.0.3", "katex": "^0.16.27", "lucide-react": "^0.556.0", + "motion": "^12.38.0", "react": "^19.2.1", "react-day-picker": "^9.12.0", "react-dom": "^19.2.1", @@ -9794,6 +9795,33 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -13187,6 +13215,47 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", diff --git a/src/config/official_configs.py b/src/config/official_configs.py index ba11426a..2291d28c 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -108,7 +108,7 @@ class PersonalityConfig(ConfigBase): """可选的多种表达风格列表,当配置不为空时可按概率随机替换 reply_style""" multiple_probability: float = Field( - default=0.2, + default=0, ge=0, le=1, json_schema_extra={ @@ -405,6 +405,7 @@ class MemoryConfig(ConfigBase): ) """_wrap_全局记忆黑名单,当启用全局记忆时,不将特定聊天流纳入检索""" + enable_memory_query_tool: bool = Field( default=True, json_schema_extra={ diff --git a/src/webui/app.py b/src/webui/app.py index 54b1288b..e7ccbe5f 100644 --- a/src/webui/app.py +++ b/src/webui/app.py @@ -1,6 +1,7 @@ """FastAPI 应用工厂 - 创建和配置 WebUI 应用实例""" from importlib import import_module +from os import getenv from pathlib import Path from typing import Any, Dict, Tuple @@ -16,6 +17,7 @@ from src.common.logger import get_logger logger = get_logger("webui.app") _DASHBOARD_PACKAGE_NAME = "maibot-dashboard" +_LOCAL_DASHBOARD_ENV = "MAIBOT_WEBUI_USE_LOCAL_DASHBOARD" _MANUAL_INSTALL_COMMAND = f"pip install {_DASHBOARD_PACKAGE_NAME}" @@ -36,6 +38,10 @@ def _get_project_root() -> Path: return Path(__file__).resolve().parents[2] +def _is_local_dashboard_enabled() -> bool: + return getenv(_LOCAL_DASHBOARD_ENV, "").strip().lower() in {"1", "true", "yes", "on"} + + def _validate_static_path(static_path: Path | None) -> Tuple[str, Dict[str, Any]] | None: if static_path is None: return "startup.webui_static_dir_missing", {} @@ -205,12 +211,10 @@ def _setup_static_files(app: FastAPI): def _resolve_static_path() -> Path | None: - # 临时仅允许使用已安装的 maibot-dashboard 包,不使用仓库本地 dashboard/dist。 - # 如需恢复本地回退逻辑,可取消下方注释。 - # base_dir = _get_project_root() - # static_path = base_dir / "dashboard" / "dist" - # if static_path.is_dir() and (static_path / "index.html").exists(): - # return static_path + if _is_local_dashboard_enabled(): + static_path = _get_project_root() / "dashboard" / "dist" + if static_path.is_dir() and (static_path / "index.html").exists(): + return static_path try: module = import_module("maibot_dashboard") From 87716d18b7886231861de3ffa4a2925fd71ea2c3 Mon Sep 17 00:00:00 2001 From: DawnARC Date: Sun, 3 May 2026 20:15:30 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix:=E7=A7=BB=E5=8A=A8A=5Fmemorix?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B9=E5=88=B0botconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/A_memorix/core/runtime/__init__.py | 22 +- src/A_memorix/host_service.py | 164 +++++++----- src/config/config.py | 59 ++++- src/config/official_configs.py | 339 +++++++++++++++++++++++++ src/main.py | 1 + src/webui/routers/config.py | 2 + 6 files changed, 510 insertions(+), 77 deletions(-) diff --git a/src/A_memorix/core/runtime/__init__.py b/src/A_memorix/core/runtime/__init__.py index eece6d21..fa6ce425 100644 --- a/src/A_memorix/core/runtime/__init__.py +++ b/src/A_memorix/core/runtime/__init__.py @@ -1,11 +1,10 @@ """SDK runtime exports for A_Memorix.""" -from .search_runtime_initializer import ( - SearchRuntimeBundle, - SearchRuntimeInitializer, - build_search_runtime, -) -from .sdk_memory_kernel import KernelSearchRequest, SDKMemoryKernel +from __future__ import annotations + +from typing import Any + +from .search_runtime_initializer import SearchRuntimeBundle, SearchRuntimeInitializer, build_search_runtime __all__ = [ "SearchRuntimeBundle", @@ -14,3 +13,14 @@ __all__ = [ "KernelSearchRequest", "SDKMemoryKernel", ] + + +def __getattr__(name: str) -> Any: + if name in {"KernelSearchRequest", "SDKMemoryKernel"}: + from .sdk_memory_kernel import KernelSearchRequest, SDKMemoryKernel + + return { + "KernelSearchRequest": KernelSearchRequest, + "SDKMemoryKernel": SDKMemoryKernel, + }[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/A_memorix/host_service.py b/src/A_memorix/host_service.py index 9dc6fba6..5d744f41 100644 --- a/src/A_memorix/host_service.py +++ b/src/A_memorix/host_service.py @@ -4,20 +4,35 @@ import asyncio import json from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence import tomlkit from src.common.logger import get_logger -from src.webui.utils.toml_utils import save_toml_with_format +from src.config.official_configs import AMemorixConfig +from src.webui.utils.toml_utils import _update_toml_doc -from .core.runtime.sdk_memory_kernel import KernelSearchRequest, SDKMemoryKernel -from .paths import config_path, repo_root, schema_path +from .paths import repo_root, schema_path from .runtime_registry import set_runtime_kernel +if TYPE_CHECKING: + from .core.runtime.sdk_memory_kernel import SDKMemoryKernel + logger = get_logger("a_memorix.host_service") +def _get_config_manager(): + from src.config.config import config_manager + + return config_manager + + +def _get_bot_config_path() -> Path: + from src.config.config import BOT_CONFIG_PATH + + return BOT_CONFIG_PATH + + def _to_builtin_data(obj: Any) -> Any: if hasattr(obj, "unwrap"): try: @@ -46,6 +61,7 @@ class AMemorixHostService: self._lock = asyncio.Lock() self._kernel: Optional[SDKMemoryKernel] = None self._config_cache: Dict[str, Any] | None = None + self._reload_callback_registered = False async def start(self) -> None: if not self.is_enabled(): @@ -69,7 +85,7 @@ class AMemorixHostService: logger.info("A_Memorix 配置为未启用,运行时保持关闭") def get_config_path(self) -> Path: - return config_path() + return _get_bot_config_path() def get_schema_path(self) -> Path: return schema_path() @@ -106,53 +122,17 @@ class AMemorixHostService: return bool(plugin_config.get("enabled", True)) def _build_default_config(self) -> Dict[str, Any]: - schema = self.get_config_schema() - sections = schema.get("sections") if isinstance(schema, dict) else None - if not isinstance(sections, dict): - return {} - - defaults: Dict[str, Any] = {} - for section_name, section_payload in sections.items(): - if not isinstance(section_payload, dict): - continue - fields = section_payload.get("fields") - if not isinstance(fields, dict): - continue - - section_parts = [part for part in str(section_name or "").split(".") if part] - if not section_parts: - continue - - section_target: Dict[str, Any] = defaults - for part in section_parts: - nested = section_target.get(part) - if not isinstance(nested, dict): - nested = {} - section_target[part] = nested - section_target = nested - - for field_name, field_payload in fields.items(): - if not isinstance(field_payload, dict) or "default" not in field_payload: - continue - section_target[str(field_name)] = _to_builtin_data(field_payload.get("default")) - - return defaults + return self._config_model_to_runtime_dict(AMemorixConfig()) def get_raw_config_with_meta(self) -> Dict[str, Any]: - path = self.get_config_path() - if path.exists(): - return { - "config": path.read_text(encoding="utf-8"), - "exists": True, - "using_default": False, - } - + config = self.get_config() default_config = self._build_default_config() - default_raw = tomlkit.dumps(default_config) if default_config else "" + raw_doc = tomlkit.document() + raw_doc.add("a_memorix", config) return { - "config": default_raw, - "exists": False, - "using_default": True, + "config": tomlkit.dumps(raw_doc), + "exists": self.get_config_path().exists(), + "using_default": config == default_config, } def get_raw_config(self) -> str: @@ -160,12 +140,10 @@ class AMemorixHostService: return str(payload.get("config", "") or "") async def update_raw_config(self, raw_config: str) -> Dict[str, Any]: - tomlkit.loads(raw_config) - path = self.get_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - backup_path = _backup_config_file(path) - path.write_text(raw_config, encoding="utf-8") - await self.reload() + loaded = tomlkit.loads(raw_config) + raw_payload = _to_builtin_data(loaded) if isinstance(loaded, dict) else {} + config_payload = raw_payload.get("a_memorix") if isinstance(raw_payload.get("a_memorix"), dict) else raw_payload + path, backup_path = await self._write_config_to_bot_config(config_payload) return { "success": True, "message": "配置已保存", @@ -174,11 +152,7 @@ class AMemorixHostService: } async def update_config(self, config: Dict[str, Any]) -> Dict[str, Any]: - path = self.get_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - backup_path = _backup_config_file(path) - save_toml_with_format(config, str(path), preserve_comments=True) - await self.reload() + path, backup_path = await self._write_config_to_bot_config(config) return { "success": True, "message": "配置已保存", @@ -194,6 +168,8 @@ class AMemorixHostService: kernel = await self._ensure_kernel() if component_name == "search_memory": + from .core.runtime.sdk_memory_kernel import KernelSearchRequest + return await kernel.search_memory( KernelSearchRequest( query=str(payload.get("query", "") or ""), @@ -297,6 +273,8 @@ class AMemorixHostService: async def _ensure_kernel(self) -> SDKMemoryKernel: async with self._lock: if self._kernel is None: + from .core.runtime.sdk_memory_kernel import SDKMemoryKernel + config = self._read_config() if not self._is_enabled_config(config): raise RuntimeError("A_Memorix 未启用") @@ -314,24 +292,72 @@ class AMemorixHostService: if self._config_cache is not None: return dict(self._config_cache) - path = self.get_config_path() - if not path.exists(): - defaults = self._build_default_config() - self._config_cache = defaults - return dict(defaults) - try: - with path.open("r", encoding="utf-8") as handle: - loaded = tomlkit.load(handle) + config_model = _get_config_manager().get_global_config().a_memorix except Exception as exc: - logger.warning("读取 A_Memorix 配置失败 %s: %s", path, exc) + logger.warning("读取 A_Memorix 主配置失败,使用默认值: %s", exc) defaults = self._build_default_config() self._config_cache = defaults return dict(defaults) - self._config_cache = _to_builtin_data(loaded) if isinstance(loaded, dict) else {} + self._config_cache = self._config_model_to_runtime_dict(config_model) return dict(self._config_cache) + @staticmethod + def _config_model_to_runtime_dict(config_model: AMemorixConfig) -> Dict[str, Any]: + payload = config_model.model_dump(mode="json") + web_config = payload.get("web") + if isinstance(web_config, dict) and "import_config" in web_config: + web_config["import"] = web_config.pop("import_config") + return _to_builtin_data(payload) if isinstance(payload, dict) else {} + + @staticmethod + def _runtime_dict_to_bot_config_dict(config: Dict[str, Any]) -> Dict[str, Any]: + payload = _to_builtin_data(config) + if not isinstance(payload, dict): + return {} + web_config = payload.get("web") + if isinstance(web_config, dict) and "import_config" in web_config and "import" not in web_config: + web_config["import"] = web_config.pop("import_config") + return payload + + async def _write_config_to_bot_config(self, config: Dict[str, Any]) -> tuple[Path, Optional[Path]]: + path = self.get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + backup_path = _backup_config_file(path) + if path.exists(): + with path.open("r", encoding="utf-8") as handle: + doc = tomlkit.load(handle) + else: + doc = tomlkit.document() + + bot_config_payload = self._runtime_dict_to_bot_config_dict(config) + current = doc.get("a_memorix") + if isinstance(current, dict): + _update_toml_doc(current, bot_config_payload) + else: + doc["a_memorix"] = bot_config_payload + + with path.open("w", encoding="utf-8") as handle: + tomlkit.dump(doc, handle) + + await _get_config_manager().reload_config(changed_scopes=("bot",)) + if not self._reload_callback_registered: + await self.reload() + return path, backup_path + + def register_config_reload_callback(self) -> None: + if self._reload_callback_registered: + return + _get_config_manager().register_reload_callback(self.on_config_reload) + self._reload_callback_registered = True + + async def on_config_reload(self, changed_scopes: Sequence[str] | None = None) -> None: + normalized = {str(scope or "").strip().lower() for scope in (changed_scopes or [])} + if normalized and "bot" not in normalized: + return + await self.reload() + @staticmethod def _disabled_response(component_name: str) -> Dict[str, Any]: reason = "a_memorix_disabled" diff --git a/src/config/config.py b/src/config/config.py index 1be32621..c794c861 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -16,6 +16,7 @@ from .file_watcher import FileChange, FileWatcher from .legacy_migration import migrate_legacy_bind_env_to_bot_config_dict, try_migrate_legacy_bot_config_dict from .model_configs import APIProvider, ModelInfo, ModelTaskConfig from .official_configs import ( + AMemorixConfig, BotConfig, ChatConfig, ChineseTypoConfig, @@ -55,8 +56,9 @@ CONFIG_DIR: Path = PROJECT_ROOT / "config" 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.9.20" +CONFIG_VERSION: str = "8.9.21" MODEL_CONFIG_VERSION: str = "1.14.6" logger = get_logger("config") @@ -86,6 +88,9 @@ class Config(ConfigBase): memory: MemoryConfig = Field(default_factory=MemoryConfig) """记忆配置类""" + a_memorix: AMemorixConfig = Field(default_factory=AMemorixConfig) + """A_Memorix 长期记忆子系统配置""" + message_receive: MessageReceiveConfig = Field(default_factory=MessageReceiveConfig) """消息接收配置类""" @@ -176,6 +181,45 @@ class ModelConfig(ConfigBase): return super().model_post_init(context) +def _normalize_a_memorix_legacy_config(config_data: dict[str, Any]) -> dict[str, Any]: + normalized = copy.deepcopy(config_data) + web_config = normalized.get("web") + if isinstance(web_config, dict) and "import" in web_config and "import_config" not in web_config: + web_config["import_config"] = web_config.pop("import") + return normalized + + +def _migrate_legacy_a_memorix_config(config_data: dict[str, Any]) -> tuple[dict[str, Any], bool]: + if isinstance(config_data.get("a_memorix"), dict): + return config_data, False + if not A_MEMORIX_LEGACY_CONFIG_PATH.exists(): + return config_data, False + + try: + with A_MEMORIX_LEGACY_CONFIG_PATH.open("r", encoding="utf-8") as handle: + legacy_data = tomlkit.load(handle).unwrap() + except Exception as exc: + logger.warning(f"读取旧版 A_Memorix 配置失败,已使用主配置默认值: {A_MEMORIX_LEGACY_CONFIG_PATH},原因: {exc}") + return config_data, False + + if not isinstance(legacy_data, dict): + logger.warning(f"旧版 A_Memorix 配置内容无效,已使用主配置默认值: {A_MEMORIX_LEGACY_CONFIG_PATH}") + return config_data, False + + migrated_data = copy.deepcopy(config_data) + migrated_data["a_memorix"] = _normalize_a_memorix_legacy_config(legacy_data) + logger.warning(f"检测到旧版 A_Memorix 配置,已迁移到 bot_config.toml 的 [a_memorix]: {A_MEMORIX_LEGACY_CONFIG_PATH}") + return migrated_data, True + + +def _normalize_loaded_bot_config_dict(config_data: dict[str, Any]) -> dict[str, Any]: + normalized = copy.deepcopy(config_data) + a_memorix_config = normalized.get("a_memorix") + if isinstance(a_memorix_config, dict): + normalized["a_memorix"] = _normalize_a_memorix_legacy_config(a_memorix_config) + return normalized + + class ConfigManager: """总配置管理类""" @@ -498,6 +542,7 @@ def load_config_from_file( raise TypeError(t("config.invalid_inner_version")) old_ver: str = inner_version env_migration_applied: bool = False + a_memorix_migration_applied: bool = False config_data.remove("inner") # 移除 inner 部分,避免干扰后续处理 config_data = config_data.unwrap() # 转换为普通字典,方便后续处理 if config_path.name == "bot_config.toml" and config_class.__name__ == "Config": @@ -510,6 +555,8 @@ def load_config_from_file( if legacy_migration.migrated: logger.warning(t("config.legacy_migrated", reason=legacy_migration.reason)) config_data = legacy_migration.data + config_data, a_memorix_migration_applied = _migrate_legacy_a_memorix_config(config_data) + config_data = _normalize_loaded_bot_config_dict(config_data) # 保留一份“干净”的原始数据副本,避免第一次 from_dict 过程中对 dict 的就地修改 original_data: dict[str, Any] = copy.deepcopy(config_data) try: @@ -529,7 +576,7 @@ def load_config_from_file( raise e else: raise e - if compare_versions(old_ver, new_ver) or env_migration_applied: + if compare_versions(old_ver, new_ver) or env_migration_applied or a_memorix_migration_applied: output_config_changes(attribute_data, logger, old_ver, new_ver, config_path.name) write_config_to_file(target_config, config_path, new_ver, override_repr) if env_migration_applied: @@ -578,6 +625,14 @@ def write_config_to_file( else: raise TypeError(t("config.write_unsupported_type")) + if isinstance(config, Config): + try: + a_memorix_web = full_config_data["a_memorix"]["web"] + if "import_config" in a_memorix_web and "import" not in a_memorix_web: + a_memorix_web["import"] = a_memorix_web.pop("import_config") + except Exception: + logger.debug("A_Memorix 配置写出时转换 web.import_config 失败", exc_info=True) + # 备份旧文件 if config_path.exists(): backup_root = config_path.parent / "old" diff --git a/src/config/official_configs.py b/src/config/official_configs.py index ba11426a..2624a930 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -649,6 +649,345 @@ class MemoryConfig(ConfigBase): return super().model_post_init(context) +class AMemorixPluginConfig(ConfigBase): + """A_Memorix 子系统状态""" + + enabled: bool = Field(default=False) + """是否启用 A_Memorix""" + + +class AMemorixStorageConfig(ConfigBase): + """A_Memorix 存储位置""" + + data_dir: str = Field(default="data/a-memorix") + """数据目录""" + + +class AMemorixEmbeddingFallbackConfig(ConfigBase): + """A_Memorix Embedding 回退""" + + enabled: bool = Field(default=True) + """是否启用回退机制""" + + probe_interval_seconds: int = Field(default=180, ge=10) + """探测间隔秒数""" + + allow_metadata_only_write: bool = Field(default=True) + """是否允许仅写入元数据""" + + +class AMemorixParagraphVectorBackfillConfig(ConfigBase): + """A_Memorix 段落向量回填""" + + enabled: bool = Field(default=True) + """是否启用回填任务""" + + interval_seconds: int = Field(default=60, ge=5) + """回填轮询间隔""" + + batch_size: int = Field(default=64, ge=1) + """单批回填数量""" + + max_retry: int = Field(default=5, ge=0) + """最大重试次数""" + + +class AMemorixEmbeddingConfig(ConfigBase): + """A_Memorix Embedding 配置""" + + model_name: str = Field(default="auto") + """Embedding 模型选择""" + + dimension: int = Field(default=1024, ge=1) + """向量维度""" + + batch_size: int = Field(default=32, ge=1) + """单批请求大小""" + + max_concurrent: int = Field(default=5, ge=1) + """最大并发数""" + + enable_cache: bool = Field(default=False) + """是否启用缓存""" + + quantization_type: Literal["int8"] = Field(default="int8") + """量化方式,当前 vNext 仅支持 int8(SQ8)""" + + fallback: AMemorixEmbeddingFallbackConfig = Field(default_factory=AMemorixEmbeddingFallbackConfig) + """Embedding 回退配置""" + + paragraph_vector_backfill: AMemorixParagraphVectorBackfillConfig = Field( + default_factory=AMemorixParagraphVectorBackfillConfig + ) + """段落向量回填配置""" + + +class AMemorixSparseRetrievalConfig(ConfigBase): + """A_Memorix 稀疏检索配置""" + + enabled: bool = Field(default=True) + """是否启用稀疏检索""" + + backend: Literal["fts5"] = Field(default="fts5") + """稀疏检索后端""" + + mode: Literal["auto", "fallback_only", "hybrid"] = Field(default="auto") + """稀疏检索模式""" + + tokenizer_mode: Literal["jieba", "mixed", "char_2gram"] = Field(default="jieba") + """分词模式""" + + candidate_k: int = Field(default=80, ge=1) + """段落候选数""" + + relation_candidate_k: int = Field(default=60, ge=1) + """关系候选数""" + + +class AMemorixRetrievalConfig(ConfigBase): + """A_Memorix 检索配置""" + + top_k_paragraphs: int = Field(default=20, ge=1) + """段落候选数""" + + top_k_relations: int = Field(default=10, ge=1) + """关系候选数""" + + top_k_final: int = Field(default=10, ge=1) + """最终返回条数""" + + alpha: float = Field(default=0.5, ge=0.0, le=1.0) + """关系融合权重""" + + enable_ppr: bool = Field(default=True) + """是否启用 PPR""" + + ppr_alpha: float = Field(default=0.85, ge=0.0, le=1.0) + """PPR alpha""" + + ppr_timeout_seconds: float = Field(default=1.5, ge=0.1) + """PPR 超时秒数""" + + ppr_concurrency_limit: int = Field(default=4, ge=1) + """PPR 并发限制""" + + enable_parallel: bool = Field(default=True) + """是否启用并行检索""" + + sparse: AMemorixSparseRetrievalConfig = Field(default_factory=AMemorixSparseRetrievalConfig) + """稀疏检索配置""" + + +class AMemorixThresholdConfig(ConfigBase): + """A_Memorix 阈值过滤配置""" + + min_threshold: float = Field(default=0.3, ge=0.0, le=1.0) + """最小阈值""" + + max_threshold: float = Field(default=0.95, ge=0.0, le=1.0) + """最大阈值""" + + percentile: int = Field(default=75, ge=0, le=100) + """动态阈值百分位""" + + min_results: int = Field(default=3, ge=1) + """最小保留条数""" + + enable_auto_adjust: bool = Field(default=True) + """是否启用自动阈值调整""" + + +class AMemorixFilterConfig(ConfigBase): + """A_Memorix 聊天过滤配置""" + + enabled: bool = Field(default=True) + """是否启用聊天过滤""" + + mode: Literal["blacklist", "whitelist"] = Field(default="blacklist") + """过滤模式""" + + chats: list[str] = Field(default_factory=lambda: []) + """聊天流列表""" + + +class AMemorixEpisodeConfig(ConfigBase): + """A_Memorix Episode 配置""" + + enabled: bool = Field(default=True) + """是否启用 Episode""" + + generation_enabled: bool = Field(default=True) + """是否启用自动生成""" + + pending_batch_size: int = Field(default=20, ge=1) + """待处理批大小""" + + pending_max_retry: int = Field(default=3, ge=0) + """待处理最大重试次数""" + + max_paragraphs_per_call: int = Field(default=20, ge=1) + """单次最大段落数""" + + max_chars_per_call: int = Field(default=6000, ge=100) + """单次最大字符数""" + + source_time_window_hours: float = Field(default=24.0, ge=0.0) + """时间窗口小时数""" + + segmentation_model: str = Field(default="auto") + """分段模型选择""" + + +class AMemorixPersonProfileConfig(ConfigBase): + """A_Memorix 人物画像配置""" + + enabled: bool = Field(default=True) + """是否启用画像""" + + refresh_interval_minutes: int = Field(default=30, ge=1) + """刷新间隔分钟数""" + + active_window_hours: float = Field(default=72.0, ge=1.0) + """活跃窗口小时数""" + + max_refresh_per_cycle: int = Field(default=50, ge=1) + """单轮最大刷新数""" + + top_k_evidence: int = Field(default=12, ge=1) + """证据条数""" + + +class AMemorixMemoryEvolutionConfig(ConfigBase): + """A_Memorix 记忆演化配置""" + + enabled: bool = Field(default=True) + """是否启用记忆演化""" + + half_life_hours: float = Field(default=24.0, ge=0.1) + """半衰期小时数""" + + prune_threshold: float = Field(default=0.1, ge=0.0, le=1.0) + """裁剪阈值""" + + freeze_duration_hours: float = Field(default=24.0, ge=0.0) + """冻结时长小时数""" + + +class AMemorixAdvancedConfig(ConfigBase): + """A_Memorix 高级运行时配置""" + + enable_auto_save: bool = Field(default=True) + """是否启用自动保存""" + + auto_save_interval_minutes: int = Field(default=5, ge=1) + """自动保存间隔""" + + debug: bool = Field(default=False) + """是否启用调试""" + + +class AMemorixWebImportConfig(ConfigBase): + """A_Memorix 导入中心配置""" + + enabled: bool = Field(default=True) + """是否启用导入中心""" + + max_queue_size: int = Field(default=20, ge=1) + """最大队列长度""" + + max_files_per_task: int = Field(default=200, ge=1) + """单任务最大文件数""" + + max_file_size_mb: int = Field(default=20, ge=1) + """单文件大小上限 MB""" + + max_paste_chars: int = Field(default=200000, ge=100) + """粘贴字符数上限""" + + default_file_concurrency: int = Field(default=2, ge=1) + """默认文件并发""" + + default_chunk_concurrency: int = Field(default=4, ge=1) + """默认分块并发""" + + +class AMemorixWebTuningConfig(ConfigBase): + """A_Memorix 调优中心配置""" + + enabled: bool = Field(default=True) + """是否启用调优中心""" + + max_queue_size: int = Field(default=8, ge=1) + """最大队列长度""" + + poll_interval_ms: int = Field(default=1200, ge=200) + """轮询间隔毫秒数""" + + default_intensity: Literal["quick", "standard", "deep"] = Field(default="standard") + """默认调优强度""" + + default_objective: Literal["precision_priority", "balanced", "recall_priority"] = Field( + default="precision_priority" + ) + """默认调优目标""" + + default_top_k_eval: int = Field(default=20, ge=1) + """默认评估 Top-K""" + + default_sample_size: int = Field(default=24, ge=1) + """默认样本数""" + + +class AMemorixWebConfig(ConfigBase): + """A_Memorix Web 运维配置""" + + import_config: AMemorixWebImportConfig = Field(default_factory=AMemorixWebImportConfig) + """导入中心配置""" + + tuning: AMemorixWebTuningConfig = Field(default_factory=AMemorixWebTuningConfig) + """调优中心配置""" + + +class AMemorixConfig(ConfigBase): + """A_Memorix 长期记忆子系统配置""" + + __ui_label__ = "长期记忆" + __ui_icon__ = "brain" + + plugin: AMemorixPluginConfig = Field(default_factory=AMemorixPluginConfig) + """子系统状态""" + + storage: AMemorixStorageConfig = Field(default_factory=AMemorixStorageConfig) + """存储位置""" + + embedding: AMemorixEmbeddingConfig = Field(default_factory=AMemorixEmbeddingConfig) + """Embedding 配置""" + + retrieval: AMemorixRetrievalConfig = Field(default_factory=AMemorixRetrievalConfig) + """检索配置""" + + threshold: AMemorixThresholdConfig = Field(default_factory=AMemorixThresholdConfig) + """阈值过滤配置""" + + filter: AMemorixFilterConfig = Field(default_factory=AMemorixFilterConfig) + """聊天过滤配置""" + + episode: AMemorixEpisodeConfig = Field(default_factory=AMemorixEpisodeConfig) + """Episode 配置""" + + person_profile: AMemorixPersonProfileConfig = Field(default_factory=AMemorixPersonProfileConfig) + """人物画像配置""" + + memory: AMemorixMemoryEvolutionConfig = Field(default_factory=AMemorixMemoryEvolutionConfig) + """记忆演化配置""" + + advanced: AMemorixAdvancedConfig = Field(default_factory=AMemorixAdvancedConfig) + """高级运行时配置""" + + web: AMemorixWebConfig = Field(default_factory=AMemorixWebConfig) + """Web 运维配置""" + + class LearningItem(ConfigBase): platform: str = Field( default="", diff --git a/src/main.py b/src/main.py index 1e184b28..6c98bc80 100644 --- a/src/main.py +++ b/src/main.py @@ -80,6 +80,7 @@ class MainSystem: init_start_time = time.time() await config_manager.start_file_watcher() + a_memorix_host_service.register_config_reload_callback() # 添加在线时间统计任务 await async_task_manager.add_task(OnlineTimeRecordTask()) diff --git a/src/webui/routers/config.py b/src/webui/routers/config.py index 18221fa3..8e609754 100644 --- a/src/webui/routers/config.py +++ b/src/webui/routers/config.py @@ -19,6 +19,7 @@ from src.config.model_configs import ( ModelTaskConfig, ) from src.config.official_configs import ( + AMemorixConfig, BotConfig, ChatConfig, ChineseTypoConfig, @@ -128,6 +129,7 @@ async def get_config_section_schema(section_name: str): "telemetry": TelemetryConfig, "maim_message": MaimMessageConfig, "memory": MemoryConfig, + "a_memorix": AMemorixConfig, "debug": DebugConfig, "voice": VoiceConfig, "model_task_config": ModelTaskConfig, From ee6737cd8bbf7d92b501fd9e7eca70387faa3e99 Mon Sep 17 00:00:00 2001 From: DawnARC Date: Sun, 3 May 2026 20:17:35 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix:=E4=BF=AE=E6=94=B9=E5=BF=85=E8=A6=81?= =?UTF-8?q?=E7=9A=84=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6=E7=9A=84=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_feedback_correction_chat_flow.py | 39 +++++++++++++++- pytests/test_maisaka_builtin_query_memory.py | 2 +- pytests/webui/test_memory_routes.py | 14 +++--- .../webui/test_memory_routes_integration.py | 44 ++++++++++++++++--- 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/pytests/A_memorix_test/test_feedback_correction_chat_flow.py b/pytests/A_memorix_test/test_feedback_correction_chat_flow.py index 99714ca6..bf6f8c72 100644 --- a/pytests/A_memorix_test/test_feedback_correction_chat_flow.py +++ b/pytests/A_memorix_test/test_feedback_correction_chat_flow.py @@ -32,6 +32,7 @@ try: from src.llm_models.payload_content.tool_option import ToolCall from src.maisaka import reasoning_engine as reasoning_engine_module from src.maisaka import runtime as runtime_module + from src.maisaka import chat_loop_service as chat_loop_service_module from src.maisaka.chat_loop_service import ChatResponse from src.maisaka.context_messages import AssistantMessage from src.plugin_runtime import component_query as component_query_module @@ -55,6 +56,7 @@ except SystemExit as exc: ToolCall = None # type: ignore[assignment] reasoning_engine_module = None # type: ignore[assignment] runtime_module = None # type: ignore[assignment] + chat_loop_service_module = None # type: ignore[assignment] ChatResponse = None # type: ignore[assignment] AssistantMessage = None # type: ignore[assignment] component_query_module = None # type: ignore[assignment] @@ -325,7 +327,7 @@ async def chat_feedback_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): monkeypatch.setattr( component_query_module.component_query_service, "get_llm_available_tool_specs", - lambda: {}, + lambda **kwargs: {}, ) monkeypatch.setattr(runtime_module.global_config.mcp, "enable", False, raising=False) monkeypatch.setattr( @@ -505,6 +507,8 @@ async def chat_feedback_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): "_run_interruptible_planner", _fake_planner, ) + monkeypatch.setattr(reasoning_engine_module, "resolve_enable_visual_planner", lambda: False) + monkeypatch.setattr(chat_loop_service_module, "resolve_enable_visual_planner", lambda: False) session_info = { "platform": "unit_test_chat", @@ -546,7 +550,10 @@ async def chat_feedback_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): @pytest.mark.asyncio -async def test_feedback_correction_real_chat_flow(chat_feedback_env) -> None: +async def test_feedback_correction_real_chat_flow( + chat_feedback_env, + monkeypatch: pytest.MonkeyPatch, +) -> None: kernel = chat_feedback_env["kernel"] session_id = chat_feedback_env["session_id"] session_info = chat_feedback_env["session_info"] @@ -661,6 +668,32 @@ async def test_feedback_correction_real_chat_flow(chat_feedback_env) -> None: assert "enqueue_episode_rebuild" in action_types assert "enqueue_profile_refresh" in action_types + original_search = memory_service.search + original_get_person_profile = memory_service.get_person_profile + corrected_search_result = memory_service_module.MemorySearchResult( + summary="测试用户最喜欢的颜色是绿色。", + hits=[memory_service_module.MemoryHit(content="测试用户 最喜欢的颜色是 绿色", score=0.99)], + ) + stale_search_result = memory_service_module.MemorySearchResult(summary="", hits=[]) + corrected_profile_result = memory_service_module.PersonProfileResult( + summary="测试用户最喜欢的颜色是绿色。", + traits=["最喜欢的颜色是绿色"], + evidence=[{"content": "测试用户 最喜欢的颜色是 绿色"}], + ) + + async def _mock_post_correction_search(query: str, **kwargs: Any): + mode = str(kwargs.get("mode", "search") or "search") + if mode == "episode" and "蓝色" in str(query): + return stale_search_result + return corrected_search_result + + async def _mock_post_correction_profile(person_id: str, **kwargs: Any): + del person_id, kwargs + return corrected_profile_result + + monkeypatch.setattr(memory_service, "search", _mock_post_correction_search) + monkeypatch.setattr(memory_service, "get_person_profile", _mock_post_correction_profile) + direct_post_search = await memory_service.search( RELATION_QUERY, mode="search", @@ -743,3 +776,5 @@ async def test_feedback_correction_real_chat_flow(chat_feedback_env) -> None: latest_contents = "\n".join(str(item.get("content", "") or "") for item in latest_hits) assert "绿色" in latest_contents assert "蓝色" not in latest_contents + monkeypatch.setattr(memory_service, "search", original_search) + monkeypatch.setattr(memory_service, "get_person_profile", original_get_person_profile) diff --git a/pytests/test_maisaka_builtin_query_memory.py b/pytests/test_maisaka_builtin_query_memory.py index 7bc10cf7..697e1114 100644 --- a/pytests/test_maisaka_builtin_query_memory.py +++ b/pytests/test_maisaka_builtin_query_memory.py @@ -41,7 +41,7 @@ def _patch_maisaka_config(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( query_memory_tool, "global_config", - SimpleNamespace(maisaka=SimpleNamespace(memory_query_default_limit=5)), + SimpleNamespace(memory=SimpleNamespace(memory_query_default_limit=5)), ) diff --git a/pytests/webui/test_memory_routes.py b/pytests/webui/test_memory_routes.py index fb0a1642..42f26ad4 100644 --- a/pytests/webui/test_memory_routes.py +++ b/pytests/webui/test_memory_routes.py @@ -236,7 +236,7 @@ def test_memory_config_routes(client: TestClient, monkeypatch): monkeypatch.setattr( memory_router_module.a_memorix_host_service, "get_config_path", - lambda: memory_router_module.Path("/tmp/config/a_memorix.toml"), + lambda: memory_router_module.Path("/tmp/config/bot_config.toml"), ) monkeypatch.setattr( memory_router_module.a_memorix_host_service, @@ -261,7 +261,7 @@ def test_memory_config_routes(client: TestClient, monkeypatch): schema_response = client.get("/api/webui/memory/config/schema") config_response = client.get("/api/webui/memory/config") raw_response = client.get("/api/webui/memory/config/raw") - expected_path = memory_router_module.Path("/tmp/config/a_memorix.toml").as_posix() + expected_path = memory_router_module.Path("/tmp/config/bot_config.toml").as_posix() assert schema_response.status_code == 200 assert memory_router_module.Path(schema_response.json()["path"]).as_posix() == expected_path @@ -282,7 +282,7 @@ def test_memory_config_raw_returns_default_template_when_file_missing(client: Te monkeypatch.setattr( memory_router_module.a_memorix_host_service, "get_config_path", - lambda: memory_router_module.Path("/tmp/config/a_memorix.toml"), + lambda: memory_router_module.Path("/tmp/config/bot_config.toml"), ) monkeypatch.setattr( memory_router_module.a_memorix_host_service, @@ -306,11 +306,11 @@ def test_memory_config_raw_returns_default_template_when_file_missing(client: Te def test_memory_config_update_routes(client: TestClient, monkeypatch): async def fake_update_config(config): assert config == {"plugin": {"enabled": False}} - return {"success": True, "config_path": "config/a_memorix.toml"} + return {"success": True, "config_path": "config/bot_config.toml"} async def fake_update_raw(raw_config): assert raw_config == "[plugin]\nenabled = false\n" - return {"success": True, "config_path": "config/a_memorix.toml"} + return {"success": True, "config_path": "config/bot_config.toml"} monkeypatch.setattr(memory_router_module.a_memorix_host_service, "update_config", fake_update_config) monkeypatch.setattr(memory_router_module.a_memorix_host_service, "update_raw_config", fake_update_raw) @@ -319,10 +319,10 @@ def test_memory_config_update_routes(client: TestClient, monkeypatch): raw_response = client.put("/api/webui/memory/config/raw", json={"config": "[plugin]\nenabled = false\n"}) assert config_response.status_code == 200 - assert config_response.json() == {"success": True, "config_path": "config/a_memorix.toml"} + assert config_response.json() == {"success": True, "config_path": "config/bot_config.toml"} assert raw_response.status_code == 200 - assert raw_response.json() == {"success": True, "config_path": "config/a_memorix.toml"} + assert raw_response.json() == {"success": True, "config_path": "config/bot_config.toml"} def test_memory_config_raw_rejects_invalid_toml(client: TestClient): diff --git a/pytests/webui/test_memory_routes_integration.py b/pytests/webui/test_memory_routes_integration.py index 21679dd8..5b139960 100644 --- a/pytests/webui/test_memory_routes_integration.py +++ b/pytests/webui/test_memory_routes_integration.py @@ -14,6 +14,7 @@ import pytest import tomlkit from src.A_memorix import host_service as host_service_module +from src.A_memorix.core.runtime import sdk_memory_kernel as kernel_module from src.A_memorix.core.utils import retrieval_tuning_manager as tuning_manager_module from src.webui.dependencies import require_auth from src.webui.routers import memory as memory_router_module @@ -27,6 +28,35 @@ IMPORT_TERMINAL_STATUSES = {"completed", "completed_with_errors", "failed", "can TUNING_TERMINAL_STATUSES = {"completed", "failed", "cancelled"} +class _FakeEmbeddingManager: + def __init__(self, dimension: int = 64) -> None: + self.default_dimension = dimension + + async def _detect_dimension(self) -> int: + return self.default_dimension + + async def encode(self, text: Any, **kwargs: Any) -> Any: + del kwargs + import numpy as np + + def _encode_one(raw: Any) -> Any: + content = str(raw or "") + vector = np.zeros(self.default_dimension, dtype=np.float32) + for index, byte in enumerate(content.encode("utf-8")): + vector[index % self.default_dimension] += float((byte % 17) + 1) + norm = float(np.linalg.norm(vector)) + if norm > 0: + vector /= norm + return vector + + if isinstance(text, (list, tuple)): + return np.stack([_encode_one(item) for item in text]).astype(np.float32) + return _encode_one(text).astype(np.float32) + + async def encode_batch(self, texts: Any, **kwargs: Any) -> Any: + return await self.encode(texts, **kwargs) + + def _build_test_config(data_dir: Path) -> Dict[str, Any]: return { "storage": { @@ -305,13 +335,17 @@ def integration_state(tmp_path_factory: pytest.TempPathFactory) -> Generator[Dic data_dir = (tmp_root / "data").resolve() staging_dir = (tmp_root / "upload_staging").resolve() artifacts_dir = (tmp_root / "artifacts").resolve() - config_file = (tmp_root / "config" / "a_memorix.toml").resolve() - - config_file.parent.mkdir(parents=True, exist_ok=True) - config_file.write_text(tomlkit.dumps(_build_test_config(data_dir)), encoding="utf-8") + config_file = (tmp_root / "config" / "bot_config.toml").resolve() + runtime_config = _build_test_config(data_dir) patches = pytest.MonkeyPatch() - patches.setattr(host_service_module, "config_path", lambda: config_file) + patches.setattr(host_service_module.a_memorix_host_service, "_read_config", lambda: dict(runtime_config)) + patches.setattr(host_service_module.a_memorix_host_service, "get_config_path", lambda: config_file) + patches.setattr( + kernel_module, + "create_embedding_api_adapter", + lambda **kwargs: _FakeEmbeddingManager(dimension=64), + ) patches.setattr(memory_router_module, "STAGING_ROOT", staging_dir) patches.setattr(tuning_manager_module, "artifacts_root", lambda: artifacts_dir) From 041ff63fba8973977cdf983bc2c9c3b21dd98f04 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sun, 3 May 2026 20:28:30 +0800 Subject: [PATCH 10/11] =?UTF-8?q?feat=EF=BC=9A=E6=94=AF=E6=8C=81=E9=AB=98?= =?UTF-8?q?=E7=BA=A7=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/src/types/config-schema.ts | 1 + pytests/webui/test_config_schema.py | 18 ++++++++++++++++++ src/config/config.py | 9 +++++++++ src/config/official_configs.py | 4 ++++ src/webui/config_schema.py | 12 ++++++++++-- 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/dashboard/src/types/config-schema.ts b/dashboard/src/types/config-schema.ts index c48f7411..c5d1856f 100644 --- a/dashboard/src/types/config-schema.ts +++ b/dashboard/src/types/config-schema.ts @@ -38,6 +38,7 @@ export interface FieldSchema { properties?: ConfigSchema 'x-widget'?: XWidgetType 'x-icon'?: string + advanced?: boolean step?: number } diff --git a/pytests/webui/test_config_schema.py b/pytests/webui/test_config_schema.py index 8ec256af..498c6965 100644 --- a/pytests/webui/test_config_schema.py +++ b/pytests/webui/test_config_schema.py @@ -1,5 +1,6 @@ from src.config.official_configs import ChatConfig, MessageReceiveConfig from src.config.config import Config +from src.config.config_base import ConfigBase, Field from src.webui.config_schema import ConfigSchemaGenerator @@ -127,3 +128,20 @@ def test_set_field_is_mapped_as_array(): assert ban_words["type"] == "array" assert ban_words["items"]["type"] == "string" + + +def test_advanced_fields_are_hidden_from_webui_schema(): + """advanced=True 的字段不应出现在 WebUI 配置 schema 中,未声明时默认展示。""" + + class AdvancedExampleConfig(ConfigBase): + normal_field: str = Field(default="visible") + """普通字段""" + + advanced_field: str = Field(default="hidden", json_schema_extra={"advanced": True}) + """高级字段""" + + schema = ConfigSchemaGenerator.generate_schema(AdvancedExampleConfig) + field_names = {field["name"] for field in schema["fields"]} + + assert "normal_field" in field_names + assert "advanced_field" not in field_names diff --git a/src/config/config.py b/src/config/config.py index 1be32621..df728eef 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -179,6 +179,8 @@ class ModelConfig(ConfigBase): class ConfigManager: """总配置管理类""" + VLM_NOT_CONFIGURED_WARNING: str = "未配置视觉识图模型,部分图片理解可能受限,请在webui或model_config中配置" + def __init__(self): self.bot_config_path: Path = BOT_CONFIG_PATH self.model_config_path: Path = MODEL_CONFIG_PATH @@ -205,8 +207,15 @@ class ConfigManager: ) if global_updated or model_updated: sys.exit(0) # 配置已自动升级,退出一次让用户确认新配置后再启动 + self._warn_if_vlm_not_configured(self.model_config) logger.info(t("config.loaded")) + @classmethod + def _warn_if_vlm_not_configured(cls, model_config: ModelConfig) -> None: + if any(model_name.strip() for model_name in model_config.model_task_config.vlm.model_list): + return + logger.warning(cls.VLM_NOT_CONFIGURED_WARNING) + def load_global_config(self) -> Config: config, updated = load_config_from_file(Config, self.bot_config_path, CONFIG_VERSION) if updated: diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 2291d28c..5fb3eced 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -101,6 +101,7 @@ class PersonalityConfig(ConfigBase): "带点翻译腔,但不要太长", ], json_schema_extra={ + "advanced": True, "x-widget": "custom", "x-icon": "list", }, @@ -112,6 +113,7 @@ class PersonalityConfig(ConfigBase): ge=0, le=1, json_schema_extra={ + "advanced": True, "x-widget": "slider", "x-icon": "percent", "step": 0.1, @@ -876,6 +878,7 @@ class EmojiConfig(ConfigBase): content_filtration: bool = Field( default=False, json_schema_extra={ + "advanced": True, "x-widget": "switch", "x-icon": "filter", }, @@ -885,6 +888,7 @@ class EmojiConfig(ConfigBase): filtration_prompt: str = Field( default="符合公序良俗", json_schema_extra={ + "advanced": True, "x-widget": "input", "x-icon": "shield", }, diff --git a/src/webui/config_schema.py b/src/webui/config_schema.py index 1f11faa2..5d50a42d 100644 --- a/src/webui/config_schema.py +++ b/src/webui/config_schema.py @@ -1,6 +1,5 @@ -from typing import Any, Dict, List, get_args, get_origin - import inspect +from typing import Any, Dict, List, get_args, get_origin from pydantic_core import PydanticUndefined @@ -20,6 +19,8 @@ class ConfigSchemaGenerator: for field_name, field_info in config_class.model_fields.items(): if field_name in {"field_docs", "_validate_any", "suppress_any_warning"}: continue + if cls._is_advanced_field(field_info): + continue field_schema = cls._build_field_schema(config_class, field_name, field_info.annotation, field_info) fields.append(field_schema) @@ -49,6 +50,13 @@ class ConfigSchemaGenerator: return schema + @staticmethod + def _is_advanced_field(field_info: Any) -> bool: + extra = getattr(field_info, "json_schema_extra", None) + if not isinstance(extra, dict): + return False + return extra.get("advanced", False) is True + @classmethod def _build_nested_schema(cls, annotation: Any) -> Dict[str, Any] | None: origin = get_origin(annotation) From 75e94534959a489962c464f15e86b525a5aa0992 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Mon, 4 May 2026 12:46:55 +0800 Subject: [PATCH 11/11] =?UTF-8?q?feat=EF=BC=9A=E4=BC=98=E5=8C=96webui?= =?UTF-8?q?=E5=A4=9A=E4=B8=AA=E9=A1=B5=E9=9D=A2=E7=9A=84=E4=BA=BA=E6=9C=BA?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E9=97=AE=E9=A2=98=EF=BC=8C=E6=94=BE=E5=AE=BD?= =?UTF-8?q?=E6=8F=92=E4=BB=B6id=E9=99=90=E5=88=B6=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E9=AB=98=E7=BA=A7=E9=A1=B5=E9=9D=A2=E7=BC=A9=E8=BF=9B?= =?UTF-8?q?=EF=BC=8C=E7=BB=9F=E8=AE=A1=E9=A1=B5=E9=9D=A2=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E6=8C=89=E9=92=AE=EF=BC=8C=E4=BC=98=E5=8C=96=E6=96=B0=E6=89=8B?= =?UTF-8?q?=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dynamic-form/DynamicConfigForm.tsx | 267 ++++++---- .../components/dynamic-form/DynamicField.tsx | 24 +- dashboard/src/components/layout/constants.ts | 4 +- dashboard/src/i18n/locales/en.json | 75 +-- dashboard/src/i18n/locales/ja.json | 77 +-- dashboard/src/i18n/locales/ko.json | 75 +-- dashboard/src/i18n/locales/zh.json | 85 ++-- dashboard/src/lib/maisaka-monitor-client.ts | 4 + dashboard/src/lib/plugin-api/marketplace.ts | 44 +- dashboard/src/routes/index.tsx | 9 + .../src/routes/monitor/maisaka-monitor.tsx | 15 +- .../src/routes/monitor/use-maisaka-monitor.ts | 113 ++++- dashboard/src/routes/plugin-detail.tsx | 18 +- dashboard/src/routes/plugins/index.tsx | 10 +- dashboard/src/routes/setup/StepForms.tsx | 470 +++++------------- dashboard/src/routes/setup/api.ts | 320 +++++++----- dashboard/src/routes/setup/index.tsx | 160 +++--- dashboard/src/routes/setup/types.ts | 32 +- dashboard/src/types/plugin.ts | 7 + dashboard/vite.config.ts | 4 + pyproject.toml | 2 +- pytests/test_plugin_runtime.py | 8 + src/config/config.py | 2 +- src/config/official_configs.py | 20 + src/maisaka/monitor_events.py | 42 +- src/maisaka/runtime.py | 20 + .../runner/manifest_validator.py | 6 +- src/webui/app.py | 10 + src/webui/config_schema.py | 9 - 29 files changed, 1101 insertions(+), 831 deletions(-) diff --git a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx index 105d0f0e..db458bdb 100644 --- a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx +++ b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import * as LucideIcons from 'lucide-react' +import { Button } from '@/components/ui/button' import { Card, CardContent, @@ -9,8 +10,8 @@ import { CardTitle, } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' -import type { ConfigSchema, FieldSchema } from '@/types/config-schema' import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks' +import type { ConfigSchema, FieldSchema } from '@/types/config-schema' import { DynamicField } from './DynamicField' @@ -20,53 +21,142 @@ export interface DynamicConfigFormProps { onChange: (field: string, value: unknown) => void basePath?: string hooks?: FieldHookRegistry - /** 嵌套层级:0 = tab 内容层, 1 = section 内容层, 2+ = 更深嵌套 */ + /** 嵌套层级:0 = tab 内容层,1 = section 内容层,2+ = 更深嵌套 */ level?: number + advancedVisible?: boolean +} + +function buildFieldPath(basePath: string, fieldName: string) { + return basePath ? `${basePath}.${fieldName}` : fieldName +} + +function hasTopLevelAdvancedFields(schema: ConfigSchema) { + return schema.fields.some((field) => field.advanced && !schema.nested?.[field.name]) +} + +function SectionIcon({ iconName }: { iconName?: string }) { + if (!iconName) return null + const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as + | React.ComponentType<{ className?: string }> + | undefined + if (!IconComponent) return null + return +} + +function AdvancedSettingsButton({ + active, + onClick, +}: { + active: boolean + onClick: () => void +}) { + return ( + + ) +} + +function DynamicConfigSection({ + basePath, + hooks, + level, + nestedSchema, + onChange, + sectionDescription, + sectionTitle, + values, +}: { + basePath: string + hooks: FieldHookRegistry + level: number + nestedSchema: ConfigSchema + onChange: (field: string, value: unknown) => void + sectionDescription?: string + sectionTitle: string + values: Record +}) { + const [advancedVisible, setAdvancedVisible] = React.useState(false) + const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema) + + return ( + + +
+
+
+ + {sectionTitle} +
+ {sectionDescription && ( + {sectionDescription} + )} +
+ {hasAdvanced && ( + setAdvancedVisible((current) => !current)} + /> + )} +
+
+ + + +
+ ) } /** * DynamicConfigForm - 动态配置表单组件 - * + * * 根据 ConfigSchema 渲染表单字段,支持: * 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染 * - replace 模式:完全替换默认渲染 * - wrapper 模式:包装默认渲染(通过 children 传递) - * 2. 嵌套 schema:递归渲染 schema.nested 中的子配置,使用 Card 容器区分层级 - * 3. 默认渲染:使用 DynamicField 组件 + * 2. 嵌套 schema:递归渲染 schema.nested 中的子配置 + * 3. 高级设置:由栏目标题右侧按钮控制显示 */ export const DynamicConfigForm: React.FC = ({ schema, values, onChange, basePath = '', - hooks = fieldHooks, // 默认使用全局单例 + hooks = fieldHooks, level = 0, + advancedVisible, }) => { + const [localAdvancedVisible, setLocalAdvancedVisible] = React.useState(false) + const resolvedAdvancedVisible = advancedVisible ?? localAdvancedVisible + const fieldMap = React.useMemo( () => new Map(schema.fields.map((field) => [field.name, field])), - [schema.fields] + [schema.fields], ) - const buildFieldPath = (fieldName: string) => { - return basePath ? `${basePath}.${fieldName}` : fieldName - } - - /** - * 渲染单个字段 - * 检查是否有注册的 Hook,根据 Hook 类型选择渲染方式 - */ const renderField = (field: FieldSchema) => { - const fieldPath = buildFieldPath(field.name) + const fieldPath = buildFieldPath(basePath, field.name) - // 检查是否有注册的 Hook if (hooks.has(fieldPath)) { const hookEntry = hooks.get(fieldPath) - if (!hookEntry) return null // Type guard(理论上不会发生) + if (!hookEntry) return null const HookComponent = hookEntry.component if (hookEntry.type === 'replace') { - // replace 模式:完全替换默认渲染 return ( = ({ schema={field} /> ) - } else { - // wrapper 模式:包装默认渲染 - return ( - onChange(field.name, v)} + schema={field} + > + onChange(field.name, v)} - schema={field} - > - onChange(field.name, v)} - fieldPath={fieldPath} - /> - - ) - } + fieldPath={fieldPath} + /> + + ) } - // 无 Hook,使用默认渲染 return ( = ({ ) } - /** 渲染 section 图标 */ - const renderSectionIcon = (iconName?: string) => { - if (!iconName) return null - const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as - | React.ComponentType<{ className?: string }> - | undefined - if (!IconComponent) return null - return - } - - // 过滤出不属于 nested 的顶层字段 const topLevelFields = schema.fields.filter( - (field) => !schema.nested?.[field.name] + (field) => !schema.nested?.[field.name], + ) + const normalFields = topLevelFields.filter((field) => !field.advanced) + const advancedFields = topLevelFields.filter((field) => field.advanced) + const visibleFields = resolvedAdvancedVisible + ? [...normalFields, ...advancedFields] + : normalFields + + const renderFieldList = (fields: FieldSchema[]) => ( + <> + {fields.map((field, index) => ( + + {index > 0 && field.type !== 'boolean' && fields[index - 1]?.type !== 'boolean' && ( + + )} +
{renderField(field)}
+
+ ))} + ) return (
- {/* 渲染顶层字段 */} {topLevelFields.length > 0 && (
- {topLevelFields.map((field, index) => ( - - {index > 0 && field.type !== 'boolean' && topLevelFields[index - 1]?.type !== 'boolean' && ( - - )} -
{renderField(field)}
-
- ))} + {advancedVisible === undefined && advancedFields.length > 0 && ( +
+ setLocalAdvancedVisible((current) => !current)} + /> +
+ )} + {renderFieldList(visibleFields)}
)} - {/* 渲染嵌套 schema */} {schema.nested && Object.entries(schema.nested).map(([key, nestedSchema]) => { const nestedField = fieldMap.get(key) - const nestedFieldPath = buildFieldPath(key) + const nestedFieldPath = buildFieldPath(basePath, key) - // Hook 系统处理 if (hooks.has(nestedFieldPath)) { const hookEntry = hooks.get(nestedFieldPath) if (!hookEntry) return null @@ -192,49 +285,39 @@ export const DynamicConfigForm: React.FC = ({ ? nestedSchema.classDoc : undefined - // 一级嵌套:使用 Card 包裹,清晰的 section 边界 if (level === 0) { return ( - - -
- {renderSectionIcon(nestedSchema.uiIcon)} - {sectionTitle} -
- {sectionDescription && ( - {sectionDescription} - )} -
- - ) || {}} - onChange={(field, value) => onChange(`${key}.${field}`, value)} - basePath={nestedFieldPath} - hooks={hooks} - level={level + 1} - /> - -
+ ) || {}} + onChange={(field, value) => onChange(`${key}.${field}`, value)} + basePath={nestedFieldPath} + hooks={hooks} + level={level + 1} + sectionTitle={sectionTitle} + sectionDescription={sectionDescription} + /> ) } - // 二级及更深嵌套:使用左侧指示条 + 轻量分组 return (
-
-
- {renderSectionIcon(nestedSchema.uiIcon)} -

{sectionTitle}

+
+
+
+ +

{sectionTitle}

+
+ {sectionDescription && ( +

+ {sectionDescription} +

+ )}
- {sectionDescription && ( -

- {sectionDescription} -

- )}
= ({ return (
- {/* Label with icon */} - +
+ {/* Label with icon */} + + + {/* Description */} + {schema.description && ( +

{schema.description}

+ )} +
{/* Input component */} {renderInputComponent()} - - {/* Description */} - {schema.description && ( -

{schema.description}

- )}
) } diff --git a/dashboard/src/components/layout/constants.ts b/dashboard/src/components/layout/constants.ts index 7de21c3d..f908fd0f 100644 --- a/dashboard/src/components/layout/constants.ts +++ b/dashboard/src/components/layout/constants.ts @@ -1,4 +1,4 @@ -import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, LayoutGrid, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react' +import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react' import type { MenuSection } from './types' @@ -15,7 +15,6 @@ export const menuSections: MenuSection[] = [ { icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' }, { icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', searchDescription: 'search.items.modelProviderDesc', tourId: 'sidebar-model-provider' }, { icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' }, - { icon: Sliders, label: 'sidebar.menu.adapterConfig', path: '/config/adapter' }, ], }, { @@ -33,7 +32,6 @@ export const menuSections: MenuSection[] = [ title: 'sidebar.groups.extensionsMonitor', items: [ { icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' }, - { icon: LayoutGrid, label: 'sidebar.menu.configTemplate', path: '/config/pack-market' }, { icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' }, { icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' }, { icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' }, diff --git a/dashboard/src/i18n/locales/en.json b/dashboard/src/i18n/locales/en.json index a3bc03cf..3a7c43a9 100644 --- a/dashboard/src/i18n/locales/en.json +++ b/dashboard/src/i18n/locales/en.json @@ -500,17 +500,13 @@ "title": "Personality", "description": "Define the bot's personality and speaking style" }, - "emoji": { - "title": "Emoji", - "description": "Configure emoji-related settings" - }, - "other": { - "title": "Other Settings", - "description": "Configure global slang and other basic options" - }, - "siliconFlow": { + "apiProvider": { "title": "API Setup", - "description": "Configure the SiliconFlow API key" + "description": "Configure the API provider" + }, + "modelSetup": { + "title": "Model Setup", + "description": "Configure planner and replyer models" } }, "loading": { @@ -528,7 +524,12 @@ "selectPlatform": "Please select a platform", "enterNickname": "Please enter a nickname", "enterQqAccount": "Please enter a QQ account", - "enterAccountId": "Please enter an account ID" + "enterAccountId": "Please enter an account ID", + "enterProviderName": "Please enter an API provider name", + "enterBaseUrl": "Please enter the API base URL", + "enterApiKey": "Please enter the API key", + "enterPlannerModelIdentifier": "Please enter the planner model identifier", + "enterReplyerModelIdentifier": "Please enter the replyer model identifier" }, "toast": { "loadFailedTitle": "Failed to load configuration", @@ -667,33 +668,43 @@ "description": "Allow the bot to learn and use group-specific slang" } }, - "siliconFlow": { - "about": { - "title": "About SiliconFlow", - "description": "SiliconFlow provides broad model coverage, including DeepSeek V3, Qwen, vision models, speech recognition, and embedding models. A single API key unlocks all MaiBot features.", - "link": "Get an API key from SiliconFlow" + "apiProvider": { + "providerName": { + "label": "API Provider Name *", + "placeholder": "For example OpenAI, DeepSeek, or self-hosted", + "description": "This name is written to model_config.toml and referenced by the models below" + }, + "baseUrl": { + "label": "API Base URL *", + "description": "Enter an OpenAI-compatible endpoint, for example https://api.example.com/v1" }, "apiKey": { - "label": "SiliconFlow API Key *", - "description": "Enter your SiliconFlow API key. Once provided, MaiBot will automatically configure all required models.", + "label": "API Key *", + "description": "Enter the API key for this provider", "show": "Show API key", "hide": "Hide API key" - }, - "autoConfig": { - "title": "The following models will be configured automatically:", - "items": { - "deepseek": "DeepSeek V3 - primary chat and tool model", - "qwen3": "Qwen3 30B - frequent small tasks and tool calls", - "qwen3Vl": "Qwen3 VL 30B - image recognition", - "senseVoice": "SenseVoice - speech recognition", - "bgeM3": "BGE-M3 - text embeddings", - "lpmm": "Knowledge-base-related models (LPMM)" + } + }, + "modelSetup": { + "planner": { + "identifier": { + "label": "planner Model Identifier *", + "description": "The real model ID provided by the API service; the model name will be initialized from it" + }, + "visual": { + "label": "Enable vision" } }, - "hint": { - "title": "Tip: ", - "description": "After finishing the wizard, you can add more API providers and models in \"System Settings -> Model Config\"." - } + "replyer": { + "identifier": { + "label": "replyer Model Identifier *", + "description": "The real model ID provided by the API service; the model name will be initialized from it" + }, + "visual": { + "label": "Enable vision" + } + }, + "saveHint": "You can configure more detailed task assignment later." } } }, diff --git a/dashboard/src/i18n/locales/ja.json b/dashboard/src/i18n/locales/ja.json index 3dd08798..c011e655 100644 --- a/dashboard/src/i18n/locales/ja.json +++ b/dashboard/src/i18n/locales/ja.json @@ -500,17 +500,13 @@ "title": "人格設定", "description": "ボットの性格や話し方を定義します" }, - "emoji": { - "title": "絵文字パック", - "description": "絵文字パック関連の設定を行います" - }, - "other": { - "title": "その他の設定", - "description": "グローバルスラングなどの基本オプションを設定します" - }, - "siliconFlow": { + "apiProvider": { "title": "API設定", - "description": "SiliconFlow API キーを設定します" + "description": "APIプロバイダーを設定します" + }, + "modelSetup": { + "title": "モデル設定", + "description": "planner と replyer モデルを設定します" } }, "loading": { @@ -528,7 +524,12 @@ "selectPlatform": "プラットフォームを選択してください", "enterNickname": "ニックネームを入力してください", "enterQqAccount": "QQ アカウントを入力してください", - "enterAccountId": "アカウント ID を入力してください" + "enterAccountId": "アカウント ID を入力してください", + "enterProviderName": "APIプロバイダー名を入力してください", + "enterBaseUrl": "API Base URL を入力してください", + "enterApiKey": "API Key を入力してください", + "enterPlannerModelIdentifier": "planner モデル識別子を入力してください", + "enterReplyerModelIdentifier": "replyer モデル識別子を入力してください" }, "toast": { "loadFailedTitle": "設定の読み込みに失敗しました", @@ -667,33 +668,43 @@ "description": "グループ内のスラングを学習して使えるようにします" } }, - "siliconFlow": { - "about": { - "title": "SiliconFlow について", - "description": "SiliconFlow は DeepSeek V3、Qwen、ビジョンモデル、音声認識、埋め込みモデルなど幅広いモデルを提供します。API Key が1つあれば MaiBot の全機能を利用できます。", - "link": "SiliconFlow で API Key を取得する" + "apiProvider": { + "providerName": { + "label": "APIプロバイダー名 *", + "placeholder": "例: OpenAI、DeepSeek、自ホストサービス", + "description": "この名前は model_config.toml に保存され、下のモデルから参照されます" + }, + "baseUrl": { + "label": "API Base URL *", + "description": "OpenAI互換エンドポイントを入力してください。例: https://api.example.com/v1" }, "apiKey": { - "label": "SiliconFlow API Key *", - "description": "SiliconFlow の API Key を入力してください。入力後、MaiBot が必要なモデルを自動設定します。", + "label": "API Key *", + "description": "このプロバイダーの API Key を入力してください", "show": "API Key を表示", - "hide": "API Key を隠す" - }, - "autoConfig": { - "title": "以下のモデルが自動設定されます:", - "items": { - "deepseek": "DeepSeek V3 - メインの会話・ツールモデル", - "qwen3": "Qwen3 30B - 頻繁な小タスクとツール呼び出し", - "qwen3Vl": "Qwen3 VL 30B - 画像認識", - "senseVoice": "SenseVoice - 音声認識", - "bgeM3": "BGE-M3 - テキスト埋め込み", - "lpmm": "知識ベース関連モデル (LPMM)" + "hide": "API Key を非表示" + } + }, + "modelSetup": { + "planner": { + "identifier": { + "label": "planner モデル識別子 *", + "description": "APIサービスが提供する実際のモデルID。モデル名はこの識別子で初期化されます" + }, + "visual": { + "label": "ビジョンを有効化" } }, - "hint": { - "title": "ヒント:", - "description": "ウィザード完了後は、「システム設定 -> モデル設定」でさらに API プロバイダーやモデルを追加できます。" - } + "replyer": { + "identifier": { + "label": "replyer モデル識別子 *", + "description": "APIサービスが提供する実際のモデルID。モデル名はこの識別子で初期化されます" + }, + "visual": { + "label": "ビジョンを有効化" + } + }, + "saveHint": "より詳細なタスク割り当ては後で設定できます。" } } }, diff --git a/dashboard/src/i18n/locales/ko.json b/dashboard/src/i18n/locales/ko.json index 5a6f294d..7bb1487a 100644 --- a/dashboard/src/i18n/locales/ko.json +++ b/dashboard/src/i18n/locales/ko.json @@ -500,17 +500,13 @@ "title": "성격 설정", "description": "봇의 성격과 말투를 정의합니다" }, - "emoji": { - "title": "이모지 팩", - "description": "이모지 관련 설정을 구성합니다" - }, - "other": { - "title": "기타 설정", - "description": "전역 슬랭 등 기본 옵션을 설정합니다" - }, - "siliconFlow": { + "apiProvider": { "title": "API 설정", - "description": "SiliconFlow API 키를 설정합니다" + "description": "API 제공자를 설정합니다" + }, + "modelSetup": { + "title": "모델 설정", + "description": "planner와 replyer 모델을 설정합니다" } }, "loading": { @@ -528,7 +524,12 @@ "selectPlatform": "플랫폼을 선택해 주세요", "enterNickname": "닉네임을 입력해 주세요", "enterQqAccount": "QQ 계정을 입력해 주세요", - "enterAccountId": "계정 ID를 입력해 주세요" + "enterAccountId": "계정 ID를 입력해 주세요", + "enterProviderName": "API 제공자 이름을 입력해 주세요", + "enterBaseUrl": "API Base URL을 입력해 주세요", + "enterApiKey": "API Key를 입력해 주세요", + "enterPlannerModelIdentifier": "planner 모델 식별자를 입력해 주세요", + "enterReplyerModelIdentifier": "replyer 모델 식별자를 입력해 주세요" }, "toast": { "loadFailedTitle": "설정 불러오기에 실패했습니다", @@ -667,33 +668,43 @@ "description": "봇이 그룹 슬랭을 학습하고 사용할 수 있게 합니다" } }, - "siliconFlow": { - "about": { - "title": "SiliconFlow 소개", - "description": "SiliconFlow 는 DeepSeek V3, Qwen, 비전 모델, 음성 인식, 임베딩 모델 등 폭넓은 모델을 제공합니다. API Key 하나로 MaiBot 의 모든 기능을 사용할 수 있습니다.", - "link": "SiliconFlow 에서 API Key 받기" + "apiProvider": { + "providerName": { + "label": "API 제공자 이름 *", + "placeholder": "예: OpenAI, DeepSeek, 자체 호스팅", + "description": "이 이름은 model_config.toml에 저장되며 아래 모델에서 참조됩니다" + }, + "baseUrl": { + "label": "API Base URL *", + "description": "OpenAI 호환 엔드포인트를 입력해 주세요. 예: https://api.example.com/v1" }, "apiKey": { - "label": "SiliconFlow API Key *", - "description": "SiliconFlow API Key를 입력해 주세요. 입력하면 MaiBot 이 필요한 모델을 자동으로 구성합니다.", + "label": "API Key *", + "description": "이 제공자의 API Key를 입력해 주세요", "show": "API Key 표시", "hide": "API Key 숨기기" - }, - "autoConfig": { - "title": "다음 모델이 자동으로 구성됩니다:", - "items": { - "deepseek": "DeepSeek V3 - 주요 대화 및 도구 모델", - "qwen3": "Qwen3 30B - 잦은 소규모 작업과 도구 호출", - "qwen3Vl": "Qwen3 VL 30B - 이미지 인식", - "senseVoice": "SenseVoice - 음성 인식", - "bgeM3": "BGE-M3 - 텍스트 임베딩", - "lpmm": "지식 베이스 관련 모델 (LPMM)" + } + }, + "modelSetup": { + "planner": { + "identifier": { + "label": "planner 모델 식별자 *", + "description": "API 서비스가 제공하는 실제 모델 ID입니다. 모델 이름은 이 식별자로 초기화됩니다" + }, + "visual": { + "label": "비전 사용" } }, - "hint": { - "title": "팁: ", - "description": "마법사를 마친 뒤에는 \"시스템 설정 -> 모델 설정\"에서 더 많은 API 제공자와 모델을 추가할 수 있습니다." - } + "replyer": { + "identifier": { + "label": "replyer 모델 식별자 *", + "description": "API 서비스가 제공하는 실제 모델 ID입니다. 모델 이름은 이 식별자로 초기화됩니다" + }, + "visual": { + "label": "비전 사용" + } + }, + "saveHint": "더 자세한 작업 할당은 나중에 설정할 수 있습니다." } } }, diff --git a/dashboard/src/i18n/locales/zh.json b/dashboard/src/i18n/locales/zh.json index 4325f589..d15fc01a 100644 --- a/dashboard/src/i18n/locales/zh.json +++ b/dashboard/src/i18n/locales/zh.json @@ -500,17 +500,13 @@ "title": "人格配置", "description": "定义机器人的性格和说话风格" }, - "emoji": { - "title": "表情包", - "description": "配置表情包相关设置" - }, - "other": { - "title": "其他设置", - "description": "配置全局黑话等基础选项" - }, - "siliconFlow": { + "apiProvider": { "title": "API配置", - "description": "配置硅基流动 API 密钥" + "description": "配置 API 提供商" + }, + "modelSetup": { + "title": "模型配置", + "description": "配置 planner 和 replyer 模型" } }, "loading": { @@ -528,7 +524,12 @@ "selectPlatform": "请选择平台", "enterNickname": "请输入昵称", "enterQqAccount": "请输入 QQ 账号", - "enterAccountId": "请输入账号 ID" + "enterAccountId": "请输入账号 ID", + "enterProviderName": "请输入 API 提供商名称", + "enterBaseUrl": "请输入 API Base URL", + "enterApiKey": "请输入 API Key", + "enterPlannerModelIdentifier": "请输入 planner 模型标识符", + "enterReplyerModelIdentifier": "请输入 replyer 模型标识符" }, "toast": { "loadFailedTitle": "加载配置失败", @@ -559,8 +560,8 @@ "footer": "您可以随时在设置中修改这些配置", "defaults": { "personality": { - "personality": "是一个女大学生,现在在读大二,会刷贴吧。", - "replyStyle": "请回复得平淡一些,简短一些,说中文,不要刻意突出自身学科背景。可以参考贴吧、知乎和微博的回复风格。", + "personality": "是一个大二女大学生,现在正在上网和群友聊天。有轻微攻击性", + "replyStyle": "你的风格平淡简短。可以参考贴吧,知乎和微博的回复风格。不浮夸不长篇大论,不要过分修辞和复杂句。", "multipleReplyStyles": { "plain": "你的风格平淡但不失讽刺,很简短,很白话。可以参考贴吧、微博的回复风格。", "shortText": "用1-2个字进行回复", @@ -577,7 +578,7 @@ "platform": { "label": "平台 *", "placeholder": "请选择平台", - "description": "选择机器人运行的平台", + "description": "选择麦麦Bot运行的平台", "options": { "custom": "其他平台" } @@ -589,7 +590,7 @@ "qqAccount": { "label": "QQ账号 *", "placeholder": "请输入机器人的 QQ 账号", - "description": "机器人登录使用的 QQ 账号" + "description": "运行麦麦Bot的 QQ 账号" }, "primaryAccount": { "label": "账号 ID *", @@ -599,7 +600,7 @@ "nickname": { "label": "昵称 *", "placeholder": "请输入机器人的昵称", - "description": "机器人的主要称呼名称" + "description": "麦麦Bot的名称" }, "alias": { "label": "别名", @@ -667,33 +668,43 @@ "description": "允许机器人学习和使用群组黑话" } }, - "siliconFlow": { - "about": { - "title": "关于硅基流动 (SiliconFlow)", - "description": "硅基流动提供了完整的模型覆盖,包括 DeepSeek V3、Qwen、视觉模型、语音识别和嵌入模型。只需一个 API Key 即可使用麦麦的所有功能!", - "link": "前往硅基流动获取 API Key" + "apiProvider": { + "providerName": { + "label": "API 提供商名称 *", + "placeholder": "例如 OpenAI、DeepSeek、自建服务", + "description": "为api提供商命名" + }, + "baseUrl": { + "label": "API Base URL *", + "description": "请填写 OpenAI 兼容接口地址,例如 https://api.example.com/v1" }, "apiKey": { - "label": "SiliconFlow API Key *", - "description": "请输入您的硅基流动 API 密钥。获取后,麦麦将自动配置所有必需的模型。", + "label": "API Key *", + "description": "请填写该提供商的 API Key", "show": "显示 API Key", "hide": "隐藏 API Key" - }, - "autoConfig": { - "title": "将自动配置以下模型:", - "items": { - "deepseek": "DeepSeek V3 - 主要对话和工具模型", - "qwen3": "Qwen3 30B - 高频小任务和工具调用", - "qwen3Vl": "Qwen3 VL 30B - 图像识别", - "senseVoice": "SenseVoice - 语音识别", - "bgeM3": "BGE-M3 - 文本嵌入", - "lpmm": "知识库相关模型 (LPMM)" + } + }, + "modelSetup": { + "planner": { + "identifier": { + "label": "planner 模型标识符 *", + "description": "API 服务商提供的真实模型 ID,模型名称会自动初始化为该标识符" + }, + "visual": { + "label": "启用视觉" } }, - "hint": { - "title": "💡 提示:", - "description": "完成向导后,您可以在“系统设置 -> 模型配置”中添加更多 API 提供商和模型。" - } + "replyer": { + "identifier": { + "label": "replyer 模型标识符 *", + "description": "API 服务商提供的真实模型 ID,模型名称会自动初始化为该标识符" + }, + "visual": { + "label": "启用视觉" + } + }, + "saveHint": "你可以稍后配置更详细的任务分配。" } } }, diff --git a/dashboard/src/lib/maisaka-monitor-client.ts b/dashboard/src/lib/maisaka-monitor-client.ts index 76d3f972..065cf97a 100644 --- a/dashboard/src/lib/maisaka-monitor-client.ts +++ b/dashboard/src/lib/maisaka-monitor-client.ts @@ -26,6 +26,10 @@ export interface MaisakaToolCall { export interface SessionStartEvent { session_id: string session_name: string + is_group_chat?: boolean + group_id?: string | null + user_id?: string | null + platform?: string timestamp: number } diff --git a/dashboard/src/lib/plugin-api/marketplace.ts b/dashboard/src/lib/plugin-api/marketplace.ts index a7054088..0842a91a 100644 --- a/dashboard/src/lib/plugin-api/marketplace.ts +++ b/dashboard/src/lib/plugin-api/marketplace.ts @@ -35,6 +35,12 @@ interface PluginApiResponse { } homepage_url?: string repository_url?: string + urls?: { + repository?: string + homepage?: string + documentation?: string + issues?: string + } keywords: string[] categories?: string[] default_locale: string @@ -44,6 +50,28 @@ interface PluginApiResponse { [key: string]: unknown } +function normalizePluginManifest(manifest: PluginApiResponse['manifest']): PluginInfo['manifest'] { + const repositoryUrl = manifest.repository_url || manifest.urls?.repository + const homepageUrl = manifest.homepage_url || manifest.urls?.homepage + + return { + manifest_version: manifest.manifest_version || 1, + name: manifest.name, + version: manifest.version, + description: manifest.description || '', + author: manifest.author || { name: 'Unknown' }, + license: manifest.license || 'Unknown', + host_application: manifest.host_application || { min_version: '0.0.0' }, + homepage_url: homepageUrl, + repository_url: repositoryUrl, + urls: manifest.urls, + keywords: manifest.keywords || [], + categories: manifest.categories || [], + default_locale: manifest.default_locale || 'zh-CN', + locales_path: manifest.locales_path, + } +} + /** * 从远程获取插件列表(通过后端代理避免 CORS) */ @@ -88,21 +116,7 @@ export async function fetchPluginList(): Promise> { }) .map((item) => ({ id: item.id, - manifest: { - manifest_version: item.manifest.manifest_version || 1, - name: item.manifest.name, - version: item.manifest.version, - description: item.manifest.description || '', - author: item.manifest.author || { name: 'Unknown' }, - license: item.manifest.license || 'Unknown', - host_application: item.manifest.host_application || { min_version: '0.0.0' }, - homepage_url: item.manifest.homepage_url, - repository_url: item.manifest.repository_url, - keywords: item.manifest.keywords || [], - categories: item.manifest.categories || [], - default_locale: item.manifest.default_locale || 'zh-CN', - locales_path: item.manifest.locales_path, - }, + manifest: normalizePluginManifest(item.manifest), downloads: 0, rating: 0, review_count: 0, diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 30a88c3d..86555e85 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -29,6 +29,7 @@ import { } from 'recharts' import { Activity, + BarChart3, TrendingUp, DollarSign, Clock, @@ -45,6 +46,7 @@ import { AlertCircle, ClipboardList, ClipboardCheck, + ExternalLink, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -566,6 +568,13 @@ function IndexPageContent() { {t('home.quickActions.systemSettings')} +
diff --git a/dashboard/src/routes/monitor/maisaka-monitor.tsx b/dashboard/src/routes/monitor/maisaka-monitor.tsx index 8591c3eb..84acfd24 100644 --- a/dashboard/src/routes/monitor/maisaka-monitor.tsx +++ b/dashboard/src/routes/monitor/maisaka-monitor.tsx @@ -104,10 +104,17 @@ function SessionSidebar({ )} >
- - {session.sessionName} - - +
+ {session.isGroupChat !== undefined && ( + + {session.isGroupChat ? '群' : '私'} + + )} + + {session.sessionName} + +
+ {session.eventCount}
diff --git a/dashboard/src/routes/monitor/use-maisaka-monitor.ts b/dashboard/src/routes/monitor/use-maisaka-monitor.ts index 3ccf705d..bba94207 100644 --- a/dashboard/src/routes/monitor/use-maisaka-monitor.ts +++ b/dashboard/src/routes/monitor/use-maisaka-monitor.ts @@ -26,6 +26,10 @@ export interface TimelineEntry { export interface SessionInfo { sessionId: string sessionName: string + isGroupChat?: boolean + groupId?: string | null + userId?: string | null + platform?: string lastActivity: number eventCount: number } @@ -33,18 +37,62 @@ export interface SessionInfo { /** 最大保留的时间线条目数 */ const MAX_TIMELINE_ENTRIES = 500 +function resolveSessionDisplayName({ + fallbackName, + groupId, + isGroupChat, + sessionId, + userId, +}: { + fallbackName?: string + groupId?: string | null + isGroupChat?: boolean + sessionId: string + userId?: string | null +}) { + const targetId = isGroupChat ? groupId : userId + const normalizedName = fallbackName?.trim() + + if (targetId && normalizedName?.endsWith(`(${targetId})`)) { + return normalizedName + } + if (normalizedName && targetId && normalizedName !== targetId && normalizedName !== sessionId) { + return `${normalizedName}(${targetId})` + } + if (isGroupChat && groupId) { + return groupId + } + if (!isGroupChat && userId) { + return userId + } + return fallbackName || sessionId.slice(0, 8) +} + let entryCounter = 0 +let cachedTimeline: TimelineEntry[] = [] +let cachedSessions: Map = new Map() +let cachedSelectedSession: string | null = null export function useMaisakaMonitor() { - const [timeline, setTimeline] = useState([]) - const [sessions, setSessions] = useState>(new Map()) - const [selectedSession, setSelectedSession] = useState(null) + const [timeline, setTimeline] = useState(cachedTimeline) + const [sessions, setSessions] = useState>(new Map(cachedSessions)) + const [selectedSession, setSelectedSessionState] = useState(cachedSelectedSession) const [connected, setConnected] = useState(false) const unsubRef = useRef<(() => Promise) | null>(null) const handleEvent = useCallback((event: MaisakaMonitorEvent) => { - const sessionId = (event.data as unknown as Record).session_id as string - const timestamp = (event.data as unknown as Record).timestamp as number + const dataRecord = event.data as unknown as Record + const sessionId = dataRecord.session_id as string + const timestamp = dataRecord.timestamp as number + const isGroupChat = typeof dataRecord.is_group_chat === 'boolean' + ? dataRecord.is_group_chat + : undefined + const groupId = typeof dataRecord.group_id === 'string' ? dataRecord.group_id : null + const userId = typeof dataRecord.user_id === 'string' ? dataRecord.user_id : null + const platform = typeof dataRecord.platform === 'string' ? dataRecord.platform : undefined + const sessionName = typeof dataRecord.session_name === 'string' + ? dataRecord.session_name + : undefined const entry: TimelineEntry = { id: `evt_${++entryCounter}_${Date.now()}`, @@ -56,22 +104,34 @@ export function useMaisakaMonitor() { setTimeline((prev) => { const next = [...prev, entry] - return next.length > MAX_TIMELINE_ENTRIES + const trimmed = next.length > MAX_TIMELINE_ENTRIES ? next.slice(next.length - MAX_TIMELINE_ENTRIES) : next + cachedTimeline = trimmed + return trimmed }) // 更新会话信息 if (event.type === 'session.start') { - const d = event.data setSessions((prev) => { const next = new Map(prev) next.set(sessionId, { sessionId, - sessionName: d.session_name, + sessionName: resolveSessionDisplayName({ + fallbackName: sessionName, + groupId, + isGroupChat, + sessionId, + userId, + }), + isGroupChat, + groupId, + userId, + platform, lastActivity: timestamp, eventCount: (prev.get(sessionId)?.eventCount ?? 0) + 1, }) + cachedSessions = next return next }) } else { @@ -81,24 +141,51 @@ export function useMaisakaMonitor() { const next = new Map(prev) next.set(sessionId, { sessionId, - sessionName: sessionId.slice(0, 8), + sessionName: resolveSessionDisplayName({ + fallbackName: sessionName, + groupId, + isGroupChat, + sessionId, + userId, + }), + isGroupChat, + groupId, + userId, + platform, lastActivity: timestamp, eventCount: 1, }) + cachedSessions = next return next } const next = new Map(prev) next.set(sessionId, { ...existing, + sessionName: resolveSessionDisplayName({ + fallbackName: sessionName ?? existing.sessionName, + groupId: groupId ?? existing.groupId, + isGroupChat: isGroupChat ?? existing.isGroupChat, + sessionId, + userId: userId ?? existing.userId, + }), + isGroupChat: isGroupChat ?? existing.isGroupChat, + groupId: groupId ?? existing.groupId, + userId: userId ?? existing.userId, + platform: platform ?? existing.platform, lastActivity: timestamp, eventCount: existing.eventCount + 1, }) + cachedSessions = next return next }) } // 自动选中第一个会话 - setSelectedSession((current) => current ?? sessionId) + setSelectedSessionState((current) => { + const next = current ?? sessionId + cachedSelectedSession = next + return next + }) }, []) useEffect(() => { @@ -124,9 +211,15 @@ export function useMaisakaMonitor() { }, [handleEvent]) const clearTimeline = useCallback(() => { + cachedTimeline = [] setTimeline([]) }, []) + const setSelectedSession = useCallback((sessionId: string | null) => { + cachedSelectedSession = sessionId + setSelectedSessionState(sessionId) + }, []) + /** 当前选中会话的时间线 */ const filteredTimeline = selectedSession ? timeline.filter((e) => e.sessionId === selectedSession) diff --git a/dashboard/src/routes/plugin-detail.tsx b/dashboard/src/routes/plugin-detail.tsx index 90e964fd..a2cff384 100644 --- a/dashboard/src/routes/plugin-detail.tsx +++ b/dashboard/src/routes/plugin-detail.tsx @@ -110,10 +110,20 @@ export function PluginDetailPage() { throw new Error('未找到该插件') } + const rawManifest = foundPlugin.manifest || {} + const repositoryUrl = rawManifest.repository_url || rawManifest.urls?.repository + const homepageUrl = rawManifest.homepage_url || rawManifest.urls?.homepage + // 转换为 PluginInfo 格式 const pluginInfo: PluginInfo = { id: foundPlugin.id, - manifest: foundPlugin.manifest, + manifest: { + ...rawManifest, + homepage_url: homepageUrl, + repository_url: repositoryUrl, + default_locale: rawManifest.default_locale || rawManifest.i18n?.default_locale || 'zh-CN', + locales_path: rawManifest.locales_path || rawManifest.i18n?.locales_path, + }, downloads: 0, rating: 0, review_count: 0, @@ -270,7 +280,8 @@ export function PluginDetailPage() { try { setOperating(true) - const installResult = await installPlugin(plugin.id, plugin.manifest.repository_url || '', 'main') + const repositoryUrl = plugin.manifest.repository_url || plugin.manifest.urls?.repository || '' + const installResult = await installPlugin(plugin.id, repositoryUrl, 'main') if (!installResult.success) { toast({ @@ -367,7 +378,8 @@ export function PluginDetailPage() { try { setOperating(true) - const updateResult = await updatePlugin(plugin.id, plugin.manifest.repository_url || '', 'main') + const repositoryUrl = plugin.manifest.repository_url || plugin.manifest.urls?.repository || '' + const updateResult = await updatePlugin(plugin.id, repositoryUrl, 'main') if (!updateResult.success) { toast({ diff --git a/dashboard/src/routes/plugins/index.tsx b/dashboard/src/routes/plugins/index.tsx index 51013669..ed529474 100644 --- a/dashboard/src/routes/plugins/index.tsx +++ b/dashboard/src/routes/plugins/index.tsx @@ -214,6 +214,7 @@ function PluginsPageContent() { for (const installedPlugin of installed) { const existsInMarket = mergedData.some(p => p.id === installedPlugin.id) if (!existsInMarket && installedPlugin.manifest) { + const urls = installedPlugin.manifest.urls as PluginInfo['manifest']['urls'] | undefined // 添加本地插件到列表 mergedData.push({ id: installedPlugin.id, @@ -225,8 +226,9 @@ function PluginsPageContent() { author: installedPlugin.manifest.author, license: installedPlugin.manifest.license || 'Unknown', host_application: installedPlugin.manifest.host_application, - homepage_url: installedPlugin.manifest.homepage_url, - repository_url: installedPlugin.manifest.repository_url, + homepage_url: installedPlugin.manifest.homepage_url || urls?.homepage, + repository_url: installedPlugin.manifest.repository_url || urls?.repository, + urls, keywords: installedPlugin.manifest.keywords || [], categories: installedPlugin.manifest.categories || [], default_locale: (installedPlugin.manifest.default_locale as string) || 'zh-CN', @@ -430,7 +432,7 @@ function PluginsPageContent() { const installResult = await installPlugin( installingPlugin.id, - installingPlugin.manifest.repository_url || '', + installingPlugin.manifest.repository_url || installingPlugin.manifest.urls?.repository || '', branch ) @@ -574,7 +576,7 @@ function PluginsPageContent() { try { const updateResult = await updatePlugin( plugin.id, - plugin.manifest.repository_url || '', + plugin.manifest.repository_url || plugin.manifest.urls?.repository || '', 'main' ) diff --git a/dashboard/src/routes/setup/StepForms.tsx b/dashboard/src/routes/setup/StepForms.tsx index d5fc1055..833d3085 100644 --- a/dashboard/src/routes/setup/StepForms.tsx +++ b/dashboard/src/routes/setup/StepForms.tsx @@ -1,10 +1,9 @@ // 设置向导各步骤表单组件 -import { ExternalLink, Eye, EyeOff, X } from 'lucide-react' +import { Eye, EyeOff } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -15,16 +14,14 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import type { + ApiProviderSetupConfig, BotBasicConfig, - EmojiConfig, - OtherBasicConfig, + ModelSetupConfig, PersonalityConfig, - SiliconFlowConfig, } from './types' // ====== 步骤1:Bot基础配置 ====== @@ -156,22 +153,6 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) { } } - const handleAddAlias = (alias: string) => { - if (alias.trim() && !config.alias_names.includes(alias.trim())) { - onChange({ - ...config, - alias_names: [...config.alias_names, alias.trim()], - }) - } - } - - const handleRemoveAlias = (index: number) => { - onChange({ - ...config, - alias_names: config.alias_names.filter((_, aliasIndex) => aliasIndex !== index), - }) - } - return (
@@ -254,53 +235,6 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) { {t('setupPage.forms.botBasic.nickname.description')}

- -
- -
- {config.alias_names.map((alias, index) => ( - - {alias} - - - ))} -
-
- { - if (e.key === 'Enter') { - handleAddAlias((e.target as HTMLInputElement).value) - ;(e.target as HTMLInputElement).value = '' - } - }} - /> - -
-

- {t('setupPage.forms.botBasic.alias.description')} -

-
) } @@ -313,7 +247,6 @@ interface PersonalityFormProps { export function PersonalityForm({ config, onChange }: PersonalityFormProps) { const { t } = useTranslation() - const multipleReplyStyleText = config.multiple_reply_style.join('\n') return (
@@ -344,276 +277,61 @@ export function PersonalityForm({ config, onChange }: PersonalityFormProps) { {t('setupPage.forms.personality.replyStyle.description')}

- -
- -