From 90233690adbb9fe836837d92d75cd1121e7d679f Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Thu, 9 Apr 2026 20:56:54 +0800 Subject: [PATCH] feat: add json-based extensible model registry and dynamic model UI --- config/custom_models.json | 57 +++++ config/custom_models.json.example | 57 +++++ config/model_profiles.py | 231 +++++++++++++++++- core/main_terminal.py | 3 + core/main_terminal_parts/commands.py | 40 ++- core/main_terminal_parts/context.py | 5 +- core/main_terminal_parts/tools_definition.py | 73 +++--- modules/admin_policy_manager.py | 13 +- modules/personalization_manager.py | 13 +- server/api_v1.py | 11 +- server/app_legacy.py | 4 +- server/context.py | 3 +- server/socket_handlers.py | 10 +- server/tasks.py | 66 +++-- static/src/App.vue | 54 ++-- static/src/app/methods/message.ts | 17 +- static/src/app/methods/ui.ts | 26 +- static/src/app/methods/upload.ts | 13 +- static/src/components/input/InputComposer.vue | 2 +- static/src/components/input/QuickMenu.vue | 16 +- .../personalization/PersonalizationDrawer.vue | 52 ++-- static/src/stores/model.ts | 53 +++- static/src/stores/personalization.ts | 9 +- static/src/stores/task.ts | 15 +- static/src/stores/tutorial.ts | 3 +- .../styles/components/chat/_chat-area.scss | 22 ++ utils/api_client.py | 33 +++ utils/context_manager.py | 34 ++- 28 files changed, 788 insertions(+), 147 deletions(-) create mode 100644 config/custom_models.json create mode 100644 config/custom_models.json.example diff --git a/config/custom_models.json b/config/custom_models.json new file mode 100644 index 0000000..009dc41 --- /dev/null +++ b/config/custom_models.json @@ -0,0 +1,57 @@ +{ + "models": [ + { + "model_name": "kimi-k2.6", + "description": "Kimi-k2.6(测试别名),配置与 Kimi-k2.5 一致", + "visible": true, + "url": "${API_BASE_KIMI_OFFICIAL}", + "apikey": "${API_KEY_KIMI_OFFICIAL}", + "multimodal": "image,video", + "reasoning_capability": "fast,thinking", + "context_window": 256000, + "max_output_tokens": 32768, + "thinkmode_status": { + "type": "param_toggle", + "model_id": "kimi-k2.5", + "fast_extra_parameter": { + "thinking": { + "type": "disabled" + } + }, + "thinking_extra_parameter": { + "thinking": { + "type": "enabled" + }, + "enable_thinking": true + } + }, + "extra_parameter": {} + }, + { + "model_name": "kimi-k2.7", + "description": "Kimi-k2.7(测试模型),仅快速模式", + "visible": true, + "url": "${API_BASE_KIMI_OFFICIAL}", + "apikey": "${API_KEY_KIMI_OFFICIAL}", + "multimodal": "none", + "reasoning_capability": "fast", + "context_window": 256000, + "max_output_tokens": 32768, + "thinkmode_status": { + "type": "param_toggle", + "model_id": "kimi-k2.5", + "fast_extra_parameter": { + "thinking": { + "type": "disabled" + } + }, + "thinking_extra_parameter": { + "thinking": { + "type": "disabled" + } + } + }, + "extra_parameter": {} + } + ] +} diff --git a/config/custom_models.json.example b/config/custom_models.json.example new file mode 100644 index 0000000..009dc41 --- /dev/null +++ b/config/custom_models.json.example @@ -0,0 +1,57 @@ +{ + "models": [ + { + "model_name": "kimi-k2.6", + "description": "Kimi-k2.6(测试别名),配置与 Kimi-k2.5 一致", + "visible": true, + "url": "${API_BASE_KIMI_OFFICIAL}", + "apikey": "${API_KEY_KIMI_OFFICIAL}", + "multimodal": "image,video", + "reasoning_capability": "fast,thinking", + "context_window": 256000, + "max_output_tokens": 32768, + "thinkmode_status": { + "type": "param_toggle", + "model_id": "kimi-k2.5", + "fast_extra_parameter": { + "thinking": { + "type": "disabled" + } + }, + "thinking_extra_parameter": { + "thinking": { + "type": "enabled" + }, + "enable_thinking": true + } + }, + "extra_parameter": {} + }, + { + "model_name": "kimi-k2.7", + "description": "Kimi-k2.7(测试模型),仅快速模式", + "visible": true, + "url": "${API_BASE_KIMI_OFFICIAL}", + "apikey": "${API_KEY_KIMI_OFFICIAL}", + "multimodal": "none", + "reasoning_capability": "fast", + "context_window": 256000, + "max_output_tokens": 32768, + "thinkmode_status": { + "type": "param_toggle", + "model_id": "kimi-k2.5", + "fast_extra_parameter": { + "thinking": { + "type": "disabled" + } + }, + "thinking_extra_parameter": { + "thinking": { + "type": "disabled" + } + } + }, + "extra_parameter": {} + } + ] +} diff --git a/config/model_profiles.py b/config/model_profiles.py index b894efa..c7cc113 100644 --- a/config/model_profiles.py +++ b/config/model_profiles.py @@ -1,6 +1,7 @@ +import json import os from pathlib import Path -from typing import Optional +from typing import Any, Dict, List, Optional def _env(name: str, default: str = "") -> str: return os.environ.get(name, default) @@ -89,7 +90,9 @@ MODEL_PROFILES = { }, "supports_thinking": True, "fast_only": False, - "name": "Kimi-k2" + "name": "Kimi-k2", + "description": "综合能力较强", + "multimodal": "none", }, "kimi-k2.5": { "context_window": CONTEXT_WINDOWS["kimi-k2.5"], @@ -111,7 +114,9 @@ MODEL_PROFILES = { }, "supports_thinking": True, "fast_only": False, - "name": "Kimi-k2.5" + "name": "Kimi-k2.5", + "description": "新版 Kimi,支持图文 & 思考开关", + "multimodal": "image,video", }, "deepseek": { "context_window": CONTEXT_WINDOWS["deepseek"], @@ -131,7 +136,9 @@ MODEL_PROFILES = { }, "supports_thinking": True, "fast_only": False, - "name": "DeepSeek" + "name": "DeepSeek", + "description": "数学能力较强", + "multimodal": "none", }, "qwen3-max": { "context_window": CONTEXT_WINDOWS["qwen3-max"], @@ -146,7 +153,9 @@ MODEL_PROFILES = { "supports_thinking": False, "fast_only": True, "name": "Qwen3-Max", - "hidden": True + "description": "仅快速模式", + "multimodal": "none", + "hidden": True, }, "qwen3-vl-plus": { "context_window": CONTEXT_WINDOWS["qwen3-vl-plus"], @@ -168,7 +177,9 @@ MODEL_PROFILES = { }, "supports_thinking": True, "fast_only": False, - "name": "Qwen3.5" + "name": "Qwen3.5", + "description": "图文视频多模态 + 深度思考", + "multimodal": "image,video", }, "minimax-m2.5": { "context_window": CONTEXT_WINDOWS["minimax-m2.5"], @@ -191,7 +202,9 @@ MODEL_PROFILES = { "supports_thinking": True, "fast_only": False, "deep_only": True, - "name": "MiniMax-M2.5" + "name": "MiniMax-M2.5", + "description": "仅深度思考,超长上下文", + "multimodal": "none", } } @@ -230,9 +243,10 @@ MODEL_PROMPT_OVERRIDES = { def get_model_profile(key: str) -> dict: - if key not in MODEL_PROFILES: + profiles = get_registered_model_profiles() + if key not in profiles: raise ValueError(f"未知模型 key: {key}") - profile = MODEL_PROFILES[key] + profile = profiles[key] try: from utils.aliyun_fallback import is_fallback_active except Exception: @@ -279,6 +293,205 @@ def get_model_profile(key: str) -> dict: return profile +def _normalize_multimodal(value: Any) -> str: + text = str(value or "none").strip().lower() + if text in {"image", "video", "none", "image,video", "video,image"}: + return "image,video" if "image" in text and "video" in text else text + return "none" + + +def _parse_env_ref(raw: Any) -> Optional[str]: + text = str(raw or "").strip() + if not text: + return None + # 1) ${ENV_KEY} + if text.startswith("${") and text.endswith("}") and len(text) > 3: + key = text[2:-1].strip() + return _env_optional(key) + # 2) env:ENV_KEY + if text.lower().startswith("env:") and len(text) > 4: + key = text[4:].strip() + resolved = _env_optional(key) + return resolved if resolved is not None else None + # 3) $ENV_KEY + if text.startswith("$") and len(text) > 1: + key = text[1:].strip() + resolved = _env_optional(key) + return resolved if resolved is not None else None + # 4) 直接写 ENV_KEY(若环境/.env存在同名键则读取其值,否则按字面量) + if text.replace("_", "").isalnum() and text.upper() == text: + resolved = _env_optional(text) + if resolved is not None: + return resolved + return text + + +def _reasoning_flags(capability: str) -> Dict[str, bool]: + normalized = str(capability or "fast").replace(" ", "").lower() + if normalized == "thinking": + return {"supports_thinking": True, "fast_only": False, "deep_only": True} + if normalized == "fast,thinking": + return {"supports_thinking": True, "fast_only": False, "deep_only": False} + return {"supports_thinking": False, "fast_only": True, "deep_only": False} + + +def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]: + key = str(item.get("model_name") or "").strip() + if not key: + return None + base_url = _parse_env_ref(item.get("url")) + api_key = _parse_env_ref(item.get("apikey")) + if not base_url or not api_key: + return None + context_window = item.get("context_window") + max_output_tokens = item.get("max_output_tokens") + try: + context_window = int(context_window) if context_window is not None else None + except (TypeError, ValueError): + context_window = None + try: + max_output_tokens = int(max_output_tokens) if max_output_tokens is not None else None + except (TypeError, ValueError): + max_output_tokens = None + + capability = str(item.get("reasoning_capability") or "fast").replace(" ", "").lower() + flags = _reasoning_flags(capability) + thinkmode = item.get("thinkmode_status") or {} + mode_type = str((thinkmode.get("type") or thinkmode.get("mode") or "")).strip().lower() + extra_global = item.get("extra_parameter") if isinstance(item.get("extra_parameter"), dict) else {} + fast_extra = thinkmode.get("fast_extra_parameter") if isinstance(thinkmode.get("fast_extra_parameter"), dict) else {} + if not fast_extra and isinstance(thinkmode.get("fast_parameter"), dict): + fast_extra = thinkmode.get("fast_parameter") + thinking_extra = thinkmode.get("thinking_extra_parameter") if isinstance(thinkmode.get("thinking_extra_parameter"), dict) else {} + if not thinking_extra and isinstance(thinkmode.get("thinking_parameter"), dict): + thinking_extra = thinkmode.get("thinking_parameter") + + fast_model_id = None + thinking_model_id = None + if mode_type == "switch_model": + fast_model_id = str(thinkmode.get("fast_model_id") or "").strip() + thinking_model_id = str(thinkmode.get("thinking_model_id") or "").strip() + elif mode_type == "param_toggle": + common_model = str(thinkmode.get("model_id") or "").strip() + fast_model_id = common_model + thinking_model_id = common_model + else: + common_model = str(thinkmode.get("model_id") or "").strip() + if not common_model: + common_model = str(item.get("model_id") or "").strip() + fast_model_id = common_model + thinking_model_id = common_model + + if not fast_model_id: + fast_model_id = thinking_model_id + if not thinking_model_id: + thinking_model_id = fast_model_id + if not fast_model_id: + return None + + fast_config = { + "base_url": base_url, + "api_key": api_key, + "model_id": fast_model_id, + "max_tokens": max_output_tokens, + "context_window": context_window, + "extra_params": {**extra_global, **(fast_extra or {})}, + } + profile: Dict[str, Any] = { + "name": key, + "description": str(item.get("description") or ""), + "hidden": not bool(item.get("visible", True)), + "multimodal": _normalize_multimodal(item.get("multimodal")), + "context_window": context_window, + "fast": fast_config, + "supports_thinking": bool(flags["supports_thinking"]), + "fast_only": bool(flags["fast_only"]), + } + if flags["deep_only"]: + profile["deep_only"] = True + if flags["supports_thinking"]: + profile["thinking"] = { + "base_url": base_url, + "api_key": api_key, + "model_id": thinking_model_id or fast_model_id, + "max_tokens": max_output_tokens, + "context_window": context_window, + "extra_params": {**extra_global, **(thinking_extra or {})}, + } + else: + profile["thinking"] = None + return profile + + +def _load_custom_models() -> Dict[str, Dict[str, Any]]: + custom_path = Path(__file__).resolve().parent / "custom_models.json" + if not custom_path.exists(): + return {} + try: + data = json.loads(custom_path.read_text(encoding="utf-8")) + except Exception: + return {} + items = data if isinstance(data, list) else data.get("models", []) + if not isinstance(items, list): + return {} + output: Dict[str, Dict[str, Any]] = {} + for item in items: + if not isinstance(item, dict): + continue + profile = _build_custom_profile(item) + if not profile: + continue + model_name = str(item.get("model_name") or "").strip() + if not model_name: + continue + output[model_name] = profile + return output + + +def get_registered_model_profiles() -> Dict[str, Dict[str, Any]]: + merged = dict(MODEL_PROFILES) + merged.update(_load_custom_models()) # 同名覆盖内置,不报错 + return merged + + +def get_registered_model_keys(visible_only: bool = False) -> List[str]: + keys: List[str] = [] + for key, profile in get_registered_model_profiles().items(): + if visible_only and profile.get("hidden"): + continue + keys.append(key) + return keys + + +def get_model_capabilities(key: str) -> Dict[str, Any]: + profile = get_model_profile(key) + multimodal = _normalize_multimodal(profile.get("multimodal")) + supports_image = multimodal in {"image", "image,video"} + supports_video = multimodal in {"video", "image,video"} + return { + "supports_image": supports_image, + "supports_video": supports_video, + "multimodal": multimodal, + "supports_thinking": bool(profile.get("supports_thinking", False)), + "fast_only": bool(profile.get("fast_only", False)), + "deep_only": bool(profile.get("deep_only", False)), + } + + +def model_supports_image(key: str) -> bool: + try: + return bool(get_model_capabilities(key).get("supports_image")) + except Exception: + return False + + +def model_supports_video(key: str) -> bool: + try: + return bool(get_model_capabilities(key).get("supports_video")) + except Exception: + return False + + def get_model_prompt_replacements(key: str) -> dict: """获取模型相关的提示词替换字段,若缺失则回退到 Kimi 版本。""" fallback = MODEL_PROMPT_OVERRIDES.get("kimi", {}) diff --git a/core/main_terminal.py b/core/main_terminal.py index bb8c89d..cd66c73 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -81,6 +81,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi self.run_mode = initial_mode self.thinking_mode = initial_mode != "fast" # False=快速模式, True=思考模式 self.deep_thinking_mode = initial_mode == "deep" + # 记录因 fast-only 模型被强制切到 fast 前的模式,便于切回可思考模型时恢复 + self._mode_before_fast_only: Optional[str] = None self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve() self.usage_tracker = usage_tracker @@ -172,6 +174,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi # Skill 强约束开关(按个人空间配置) self.skill_strict_terminal_enabled: bool = False self.skill_strict_sub_agent_enabled: bool = False + self.skill_strict_run_command_foreground_enabled: bool = False self.skill_strict_run_command_background_enabled: bool = False # 当前生效的启用 skills(用于强约束判定) self.enabled_skill_ids: Set[str] = set() diff --git a/core/main_terminal_parts/commands.py b/core/main_terminal_parts/commands.py index 0b1788c..114a0d3 100644 --- a/core/main_terminal_parts/commands.py +++ b/core/main_terminal_parts/commands.py @@ -79,6 +79,8 @@ from config.model_profiles import ( get_model_profile, get_model_prompt_replacements, get_model_context_window, + model_supports_image, + model_supports_video, ) logger = setup_logger(__name__) @@ -747,10 +749,11 @@ class MainTerminalCommandMixin: def set_model(self, model_key: str) -> str: profile = get_model_profile(model_key) - if getattr(self.context_manager, "has_images", False) and model_key not in {"qwen3-vl-plus", "kimi-k2.5"}: - raise ValueError("当前对话包含图片,仅支持 Qwen3.5 或 Kimi-k2.5") - if getattr(self.context_manager, "has_videos", False) and model_key not in {"qwen3-vl-plus", "kimi-k2.5"}: - raise ValueError("当前对话包含视频,仅支持 Qwen3.5 或 Kimi-k2.5") + if getattr(self.context_manager, "has_images", False) and not model_supports_image(model_key): + raise ValueError("当前对话包含图片,目标模型不支持图片输入") + if getattr(self.context_manager, "has_videos", False) and not model_supports_video(model_key): + raise ValueError("当前对话包含视频,目标模型不支持视频输入") + previous_mode = self.run_mode self.model_key = model_key self.model_profile = profile # 将模型标识传递给底层 API 客户端,便于按模型做兼容处理 @@ -759,10 +762,39 @@ class MainTerminalCommandMixin: self.apply_model_profile(profile) # fast-only 模型强制快速模式 if profile.get("fast_only") and self.run_mode != "fast": + # 记住被强制前的模式,切回支持思考的模型时可恢复 + if previous_mode in {"thinking", "deep"}: + self._mode_before_fast_only = previous_mode self.set_run_mode("fast") # 仅深度思考模型强制 deep if profile.get("deep_only") and self.run_mode != "deep": self.set_run_mode("deep") + # 从 fast-only 模型切回支持思考的模型时,恢复先前模式(若可用) + if ( + not profile.get("fast_only") + and self.run_mode == "fast" + and profile.get("supports_thinking") + ): + restore_mode = getattr(self, "_mode_before_fast_only", None) + if restore_mode in {"thinking", "deep"}: + try: + self.set_run_mode(restore_mode) + except ValueError: + pass + if not profile.get("fast_only"): + self._mode_before_fast_only = None + try: + logger.info( + "[ModelSwitch] model=%s run_mode=%s thinking=%s deep=%s fast_only=%s deep_only=%s", + self.model_key, + self.run_mode, + self.thinking_mode, + self.deep_thinking_mode, + bool(profile.get("fast_only")), + bool(profile.get("deep_only")), + ) + except Exception: + pass # 如果模型支持思考,但当前 run_mode 为 thinking/deep,则保持;否则无需调整 self.api_client.start_new_task(force_deep=self.deep_thinking_mode) return self.model_key diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index caba145..7e6ad0a 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -79,6 +79,8 @@ from config.model_profiles import ( get_model_profile, get_model_prompt_replacements, get_model_context_window, + model_supports_image, + model_supports_video, ) logger = setup_logger(__name__) @@ -120,7 +122,8 @@ class MainTerminalContextMixin: def build_messages(self, context: Dict, user_input: str) -> List[Dict]: """构建消息列表(添加终端内容注入)""" # 加载系统提示(Qwen3.5 使用专用提示) - prompt_name = "main_system_qwenvl" if getattr(self, "model_key", "kimi") in {"qwen3-vl-plus", "kimi-k2.5"} else "main_system" + current_model = getattr(self, "model_key", "kimi") + prompt_name = "main_system_qwenvl" if (model_supports_image(current_model) or model_supports_video(current_model)) else "main_system" system_prompt = self.load_prompt(prompt_name) # 格式化系统提示 diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index e6753e1..c225392 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -79,6 +79,8 @@ from config.model_profiles import ( get_model_profile, get_model_prompt_replacements, get_model_context_window, + model_supports_image, + model_supports_video, ) logger = setup_logger(__name__) @@ -787,46 +789,49 @@ class MainTerminalToolsDefinitionMixin: } } ] - # 视觉模型(Qwen3.5 / Kimi-k2.5)自带多模态能力,不再暴露 vlm_analyze,改为 view_image / view_video - if getattr(self, "model_key", None) in {"qwen3-vl-plus", "kimi-k2.5"}: + # 多模态模型自带能力,不再暴露 vlm_analyze,改为 view_image / view_video + model_key = getattr(self, "model_key", None) + if model_key and (model_supports_image(model_key) or model_supports_video(model_key)): tools = [ tool for tool in tools if (tool.get("function") or {}).get("name") != "vlm_analyze" ] - tools.append({ - "type": "function", - "function": { - "name": "view_image", - "description": "将指定本地图片附加到工具结果中(tool 消息携带 image_url),便于模型主动查看图片内容。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "path": { - "type": "string", - "description": "项目内的图片相对路径(不要以 /workspace 开头);宿主机模式可用绝对路径。支持 png/jpg/webp/gif/bmp/svg。" - } - }), - "required": ["path"] + if model_supports_image(model_key): + tools.append({ + "type": "function", + "function": { + "name": "view_image", + "description": "将指定本地图片附加到工具结果中(tool 消息携带 image_url),便于模型主动查看图片内容。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "path": { + "type": "string", + "description": "项目内的图片相对路径(不要以 /workspace 开头);宿主机模式可用绝对路径。支持 png/jpg/webp/gif/bmp/svg。" + } + }), + "required": ["path"] + } } - } - }) - tools.append({ - "type": "function", - "function": { - "name": "view_video", - "description": "将指定本地视频附加到工具结果中(tool 消息携带 video_url),便于模型查看视频内容。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "path": { - "type": "string", - "description": "项目内的视频相对路径(不要以 /workspace 开头);宿主机模式可用绝对路径。支持 mp4/mov/mkv/avi/webm。" - } - }), - "required": ["path"] + }) + if model_supports_video(model_key): + tools.append({ + "type": "function", + "function": { + "name": "view_video", + "description": "将指定本地视频附加到工具结果中(tool 消息携带 video_url),便于模型查看视频内容。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "path": { + "type": "string", + "description": "项目内的视频相对路径(不要以 /workspace 开头);宿主机模式可用绝对路径。支持 mp4/mov/mkv/avi/webm。" + } + }), + "required": ["path"] + } } - } - }) + }) # 附加自定义工具(仅管理员可见) custom_tools = self._build_custom_tools() if custom_tools: diff --git a/modules/admin_policy_manager.py b/modules/admin_policy_manager.py index 9fe38c8..9e957c4 100644 --- a/modules/admin_policy_manager.py +++ b/modules/admin_policy_manager.py @@ -14,10 +14,11 @@ from pathlib import Path from typing import Dict, Any, Tuple from config.paths import ADMIN_POLICY_FILE +from config.model_profiles import get_registered_model_keys from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category -# 可用的模型 key(与前端、model_profiles 保持一致) -ALLOWED_MODELS = {"kimi", "deepseek", "qwen3-vl-plus", "minimax-m2.5"} +def _allowed_models() -> set[str]: + return set(get_registered_model_keys(visible_only=False)) # UI 禁用项键名,前后端统一 UI_BLOCK_KEYS = [ @@ -94,8 +95,8 @@ def _merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, A **(override.get("forced_category_states") or {}), } merged["disabled_models"] = list({ - *[m for m in base.get("disabled_models") or [] if m in ALLOWED_MODELS], - *[m for m in override.get("disabled_models") or [] if m in ALLOWED_MODELS], + *[m for m in base.get("disabled_models") or [] if m in _allowed_models()], + *[m for m in override.get("disabled_models") or [] if m in _allowed_models()], }) ui_base = base.get("ui_blocks") or {} ui_override = override.get("ui_blocks") or {} @@ -210,7 +211,7 @@ def get_effective_policy(username: str | None, role: str | None, invite_code: st for key, val in (merged.get("forced_category_states") or {}).items() if key } - disabled_models = [m for m in merged.get("disabled_models") or [] if m in ALLOWED_MODELS] + disabled_models = [m for m in merged.get("disabled_models") or [] if m in _allowed_models()] ui_blocks = {k: bool(v) for k, v in (merged.get("ui_blocks") or {}).items() if k in UI_BLOCK_KEYS} return { @@ -249,6 +250,6 @@ def describe_defaults() -> Dict[str, Any]: return { "categories": categories, - "models": sorted(list(ALLOWED_MODELS)), + "models": sorted(list(_allowed_models())), "ui_block_keys": UI_BLOCK_KEYS, } diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 657bf83..6c547cc 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -13,6 +13,7 @@ except ImportError: THINKING_FAST_INTERVAL = 10 from core.tool_config import TOOL_CATEGORIES +from config.model_profiles import get_registered_model_keys ALLOWED_RUN_MODES = {"fast", "thinking", "deep"} @@ -55,6 +56,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "skill_hints_enabled": False, # Skill 提示系统开关(默认关闭) "skill_strict_terminal_enabled": False, # 强约束:terminal 系列工具需先阅读 terminal-guide "skill_strict_sub_agent_enabled": False, # 强约束:子智能体系列工具需先阅读 sub-agent-guide + "skill_strict_run_command_foreground_enabled": False, # 强约束:run_command 前台模式需先阅读 run-command-guide "skill_strict_run_command_background_enabled": False, # 强约束:run_command 后台模式需先阅读 run-command-guide "default_model": "kimi-k2.5", "image_compression": "original", # original / 1080p / 720p / 540p @@ -141,7 +143,7 @@ def sanitize_personalization_payload( base.update(fallback) data = payload or {} allowed_tool_categories = set(TOOL_CATEGORIES.keys()) - allowed_models = {"kimi", "kimi-k2.5", "deepseek", "qwen3-vl-plus", "minimax-m2.5"} + allowed_models = set(get_registered_model_keys(visible_only=True)) allowed_image_modes = {"original", "1080p", "720p", "540p"} def _resolve_short_field(key: str) -> str: @@ -188,6 +190,13 @@ def sanitize_personalization_payload( else: base["skill_strict_sub_agent_enabled"] = bool(base.get("skill_strict_sub_agent_enabled", False)) + if "skill_strict_run_command_foreground_enabled" in data: + base["skill_strict_run_command_foreground_enabled"] = bool(data.get("skill_strict_run_command_foreground_enabled")) + else: + base["skill_strict_run_command_foreground_enabled"] = bool( + base.get("skill_strict_run_command_foreground_enabled", False) + ) + if "skill_strict_run_command_background_enabled" in data: base["skill_strict_run_command_background_enabled"] = bool(data.get("skill_strict_run_command_background_enabled")) else: @@ -219,7 +228,7 @@ def sanitize_personalization_payload( chosen_model = data.get("default_model", base.get("default_model")) if isinstance(chosen_model, str) and chosen_model in allowed_models: base["default_model"] = chosen_model - elif base.get("default_model") not in allowed_models: + elif allowed_models and base.get("default_model") not in allowed_models: base["default_model"] = "kimi-k2.5" # 图片压缩模式 diff --git a/server/api_v1.py b/server/api_v1.py index 1b4ed34..15ff669 100644 --- a/server/api_v1.py +++ b/server/api_v1.py @@ -14,7 +14,7 @@ from .tasks import task_manager from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides from .files import sanitize_filename_preserve_unicode from .utils_common import debug_log -from config.model_profiles import MODEL_PROFILES +from config.model_profiles import get_registered_model_profiles from core.tool_config import TOOL_CATEGORIES from . import state from modules.personalization_manager import sanitize_personalization_payload @@ -676,15 +676,22 @@ def create_personalization_api(): @api_v1_bp.route("/models", methods=["GET"]) def list_models_api(): items = [] - for key, profile in MODEL_PROFILES.items(): + for key, profile in get_registered_model_profiles().items(): if profile.get("hidden"): continue + multimodal = str(profile.get("multimodal") or "none") + description = profile.get("description") or "" items.append({ "model_key": key, "name": profile.get("name", key), + "description": description, + "visible": not bool(profile.get("hidden")), "supports_thinking": profile.get("supports_thinking", False), "fast_only": profile.get("fast_only", False), "deep_only": profile.get("deep_only", False), + "multimodal": multimodal, + "context_window": profile.get("context_window"), + "max_output_tokens": (profile.get("fast") or {}).get("max_tokens"), }) return jsonify({"success": True, "items": items}) diff --git a/server/app_legacy.py b/server/app_legacy.py index 01c340d..fd001db 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -20,7 +20,7 @@ from datetime import timedelta import time from datetime import datetime from collections import defaultdict, deque, Counter -from config.model_profiles import get_model_profile +from config.model_profiles import get_model_profile, get_registered_model_keys from modules import admin_policy_manager, balance_client from modules.custom_tool_registry import CustomToolRegistry import server.state as state # 共享单例 @@ -922,7 +922,7 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm terminal.admin_policy_version = policy.get("updated_at") # 若当前模型被禁用,则回退到第一个可用模型 if terminal.model_key in disabled_models: - for candidate in ["kimi-k2.5", "kimi", "deepseek", "qwen3-vl-plus", "minimax-m2.5"]: + for candidate in get_registered_model_keys(visible_only=True): if candidate not in disabled_models: try: terminal.set_model(candidate) diff --git a/server/context.py b/server/context.py index ef8c44c..6fc10f5 100644 --- a/server/context.py +++ b/server/context.py @@ -18,6 +18,7 @@ from config import ( TERMINAL_SANDBOX_MODE, UPLOAD_QUARANTINE_SUBDIR, ) +from config.model_profiles import get_registered_model_keys from . import state from .utils_common import debug_log @@ -218,7 +219,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st terminal.admin_policy_ui_blocks = policy.get("ui_blocks") or {} terminal.admin_policy_version = policy.get("updated_at") if terminal.model_key in disabled_models: - for candidate in ["kimi-k2.5", "kimi", "deepseek", "qwen3-vl-plus", "minimax-m2.5"]: + for candidate in get_registered_model_keys(visible_only=True): if candidate not in disabled_models: try: terminal.set_model(candidate) diff --git a/server/socket_handlers.py b/server/socket_handlers.py index 12d54fb..81b8a84 100644 --- a/server/socket_handlers.py +++ b/server/socket_handlers.py @@ -18,6 +18,7 @@ from .usage import record_user_activity from .chat_flow import start_chat_task from .security import consume_socket_token, prune_socket_tokens from config import OUTPUT_FORMATS, AGENT_VERSION +from config.model_profiles import model_supports_image, model_supports_video @socketio.on('connect') def handle_connect(auth): @@ -264,11 +265,12 @@ def handle_message(data): if not message and not images and not videos: emit('error', {'message': '消息不能为空'}) return - if images and getattr(terminal, "model_key", None) not in {"qwen3-vl-plus", "kimi-k2.5"}: - emit('error', {'message': '当前模型不支持图片,请切换到 Qwen3.5 或 Kimi-k2.5'}) + current_model = getattr(terminal, "model_key", None) or "" + if images and not model_supports_image(current_model): + emit('error', {'message': '当前模型不支持图片,请切换到支持图片的模型'}) return - if videos and getattr(terminal, "model_key", None) not in {"qwen3-vl-plus", "kimi-k2.5"}: - emit('error', {'message': '当前模型不支持视频,请切换到 Qwen3.5 或 Kimi-k2.5'}) + if videos and not model_supports_video(current_model): + emit('error', {'message': '当前模型不支持视频,请切换到支持视频的模型'}) return if images and videos: emit('error', {'message': '图片和视频请分开发送'}) diff --git a/server/tasks.py b/server/tasks.py index f3e779d..6bf7c1f 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -222,27 +222,40 @@ class TaskManager: raise RuntimeError("系统未初始化") stop_hint = bool(stop_flags.get(rec.task_id, {}).get("stop")) - # API 传入的模型/模式配置 - if rec.model_key: - try: - terminal.set_model(rec.model_key) - except Exception as exc: - debug_log(f"[Task] 设置模型失败 {rec.model_key}: {exc}") - if rec.run_mode: - try: - terminal.set_run_mode(rec.run_mode) - except Exception as exc: - debug_log(f"[Task] 设置运行模式失败 {rec.run_mode}: {exc}") - elif rec.thinking_mode is not None: - try: - terminal.set_run_mode("thinking" if rec.thinking_mode else "fast") - except Exception as exc: - debug_log(f"[Task] 设置思考模式失败: {exc}") + def _apply_requested_model_mode(): + # API 传入的模型/模式配置 + if rec.model_key: + try: + terminal.set_model(rec.model_key) + except Exception as exc: + debug_log(f"[Task] 设置模型失败 {rec.model_key}: {exc}") + if rec.run_mode: + try: + terminal.set_run_mode(rec.run_mode) + except Exception as exc: + debug_log(f"[Task] 设置运行模式失败 {rec.run_mode}: {exc}") + elif rec.thinking_mode is not None: + try: + terminal.set_run_mode("thinking" if rec.thinking_mode else "fast") + except Exception as exc: + debug_log(f"[Task] 设置思考模式失败: {exc}") + + _apply_requested_model_mode() if rec.max_iterations: try: terminal.max_iterations_override = int(rec.max_iterations) except Exception: terminal.max_iterations_override = None + try: + debug_log( + "[Task] effective terminal state " + f"model_key={getattr(terminal, 'model_key', None)!r} " + f"run_mode={getattr(terminal, 'run_mode', None)!r} " + f"thinking_mode={getattr(terminal, 'thinking_mode', None)!r} " + f"deep_mode={getattr(terminal, 'deep_thinking_mode', None)!r}" + ) + except Exception: + pass # 确保会话加载 conversation_id = rec.conversation_id @@ -252,6 +265,19 @@ class TaskManager: except Exception as exc: raise RuntimeError(f"对话加载失败: {exc}") from exc + # 对话加载会按会话元数据恢复历史模型/模式,这里再覆盖一次用户本次请求参数 + _apply_requested_model_mode() + try: + debug_log( + "[Task] post-conversation effective state " + f"model_key={getattr(terminal, 'model_key', None)!r} " + f"run_mode={getattr(terminal, 'run_mode', None)!r} " + f"thinking_mode={getattr(terminal, 'thinking_mode', None)!r} " + f"deep_mode={getattr(terminal, 'deep_thinking_mode', None)!r}" + ) + except Exception: + pass + # 仅对“后台通知触发的新任务”补发 user_message 事件到任务事件流。 # 这样前端轮询能即时看到这条 user 消息,而不是刷新后才从历史中看到。 try: @@ -392,6 +418,14 @@ def create_task_api(): thinking_mode = payload.get("thinking_mode") run_mode = payload.get("run_mode") max_iterations = payload.get("max_iterations") + try: + debug_log( + "[TaskAPI] create_task payload " + f"model_key={model_key!r} run_mode={run_mode!r} thinking_mode={thinking_mode!r} " + f"conversation_id={conversation_id!r} images={len(images)} videos={len(videos)}" + ) + except Exception: + pass try: rec = task_manager.create_chat_task( diff --git a/static/src/App.vue b/static/src/App.vue index 42e70b2..8575a29 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -149,19 +149,21 @@ >