feat: add json-based extensible model registry and dynamic model UI

This commit is contained in:
JOJO 2026-04-09 20:56:54 +08:00
parent b63e827ed2
commit 90233690ad
28 changed files with 788 additions and 147 deletions

57
config/custom_models.json Normal file
View File

@ -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": {}
}
]
}

View File

@ -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": {}
}
]
}

View File

@ -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", {})

View File

@ -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()

View File

@ -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

View File

@ -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)
# 格式化系统提示

View File

@ -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:

View File

@ -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,
}

View File

@ -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"
# 图片压缩模式

View File

@ -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})

View File

@ -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)

View File

@ -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)

View File

@ -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': '图片和视频请分开发送'})

View File

@ -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(

View File

@ -149,19 +149,21 @@
>
<div class="dropdown-column" data-tutorial="header-model-options">
<div class="dropdown-title">模型</div>
<button
v-for="option in modelOptions"
:key="option.key"
type="button"
class="dropdown-item"
:class="{ active: option.key === currentModelKey, disabled: option.disabled }"
@click.stop="handleHeaderModelSelect(option.key, option.disabled)"
:disabled="streamingMessage || !isConnected || option.disabled"
>
<div class="item-label">{{ option.label }}</div>
<div class="item-desc">{{ option.description }}</div>
<span v-if="option.key === currentModelKey" class="item-check"></span>
</button>
<div class="dropdown-list dropdown-list--models">
<button
v-for="option in modelOptions"
:key="option.key"
type="button"
class="dropdown-item"
:class="{ active: option.key === currentModelKey, disabled: option.disabled }"
@click.stop="handleHeaderModelSelect(option.key, option.disabled)"
:disabled="streamingMessage || !isConnected || option.disabled"
>
<div class="item-label">{{ option.label }}</div>
<div class="item-desc">{{ option.description }}</div>
<span v-if="option.key === currentModelKey" class="item-check"></span>
</button>
</div>
</div>
<div class="dropdown-column" data-tutorial="header-runmode-options">
@ -462,18 +464,20 @@
>
<div class="dropdown-column" data-tutorial="header-model-options">
<div class="dropdown-title">模型</div>
<button
v-for="option in modelOptions"
:key="option.key"
type="button"
class="dropdown-item"
:class="{ active: option.key === currentModelKey, disabled: option.disabled }"
@click.stop="handleHeaderModelSelect(option.key, option.disabled)"
:disabled="streamingMessage || !isConnected || option.disabled"
>
<div class="item-label">{{ option.label }}</div>
<span v-if="option.key === currentModelKey" class="item-check"></span>
</button>
<div class="dropdown-list dropdown-list--models">
<button
v-for="option in modelOptions"
:key="option.key"
type="button"
class="dropdown-item"
:class="{ active: option.key === currentModelKey, disabled: option.disabled }"
@click.stop="handleHeaderModelSelect(option.key, option.disabled)"
:disabled="streamingMessage || !isConnected || option.disabled"
>
<div class="item-label">{{ option.label }}</div>
<span v-if="option.key === currentModelKey" class="item-check"></span>
</button>
</div>
</div>
<div class="dropdown-column" data-tutorial="header-runmode-options">

View File

@ -1,5 +1,6 @@
// @ts-nocheck
import { debugLog } from './common';
import { useModelStore } from '../../stores/model';
export const messageMethods = {
async executeSystemCommand(rawCommand, options = {}) {
@ -157,19 +158,21 @@ export const messageMethods = {
return;
}
if (hasImages && !['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
const modelStore = useModelStore();
const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey);
if (hasImages && !currentModel?.supportsImage) {
this.uiPushToast({
title: '当前模型不支持图片',
message: '请切换到 Qwen3.5 或 Kimi-k2.5 再发送图片',
message: '请切换到支持图片输入的模型再发送图片',
type: 'error'
});
return;
}
if (hasVideos && !['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
if (hasVideos && !currentModel?.supportsVideo) {
this.uiPushToast({
title: '当前模型不支持视频',
message: '请切换到 Qwen3.5 或 Kimi-k2.5 后再发送视频',
message: '请切换到支持视频输入的模型后再发送视频',
type: 'error'
});
return;
@ -269,7 +272,11 @@ export const messageMethods = {
this.clearProcessedEvents();
}
await taskStore.createTask(message, images, videos, targetConversationId);
await taskStore.createTask(message, images, videos, targetConversationId, {
model_key: this.currentModelKey,
run_mode: this.runMode,
thinking_mode: this.thinkingMode
});
debugLog('[Message] 任务已创建,开始轮询');
} catch (error) {

View File

@ -452,15 +452,24 @@ export const uiMethods = {
});
return;
}
if (this.conversationHasImages && !['qwen3-vl-plus', 'kimi-k2.5'].includes(key)) {
const modelStore = useModelStore();
const targetModel = modelStore.models.find((m) => m.key === key);
if (this.conversationHasImages && !targetModel?.supportsImage) {
this.uiPushToast({
title: '切换失败',
message: '当前对话包含图片,仅支持 Qwen3.5 或 Kimi-k2.5',
message: '当前对话包含图片,目标模型不支持图片输入',
type: 'error'
});
return;
}
if (this.conversationHasVideos && !targetModel?.supportsVideo) {
this.uiPushToast({
title: '切换失败',
message: '当前对话包含视频,目标模型不支持视频输入',
type: 'error'
});
return;
}
const modelStore = useModelStore();
const prev = this.currentModelKey;
try {
const resp = await fetch('/api/model', {
@ -1321,6 +1330,14 @@ export const uiMethods = {
this.applyStatusSnapshot(statusData);
// 立即更新配额和运行模式,避免等待其他慢接口
this.fetchUsageQuota();
const modelStore = useModelStore();
await modelStore.fetchModels().catch((err) => {
console.warn('加载模型列表失败,使用前端默认列表:', err);
});
if (statusData && typeof statusData.model_key === 'string') {
modelStore.setModel(statusData.model_key);
this.currentModelKey = modelStore.currentModelKey;
}
// 拉取管理员策略
const policyStore = usePolicyStore();
await policyStore.fetchPolicy();
@ -1345,7 +1362,8 @@ export const uiMethods = {
}
if (defaultModel) {
this.currentModelKey = defaultModel;
modelStore.setModel(defaultModel);
this.currentModelKey = modelStore.currentModelKey;
debugLog('应用默认模型:', defaultModel);
}

View File

@ -1,5 +1,6 @@
// @ts-nocheck
import { usePolicyStore } from '../../stores/policy';
import { useModelStore } from '../../stores/model';
export const uploadMethods = {
triggerFileUpload() {
@ -175,10 +176,12 @@ export const uploadMethods = {
},
async openImagePicker() {
if (!['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
const modelStore = useModelStore();
const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey);
if (!currentModel?.supportsImage) {
this.uiPushToast({
title: '当前模型不支持图片',
message: '请选择 Qwen3.5 或 Kimi-k2.5 后再发送图片',
message: '请选择支持图片输入的模型后再发送图片',
type: 'error'
});
return;
@ -193,10 +196,12 @@ export const uploadMethods = {
},
async openVideoPicker() {
if (!['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
const modelStore = useModelStore();
const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey);
if (!currentModel?.supportsVideo) {
this.uiPushToast({
title: '当前模型不支持视频',
message: '请切换到 Qwen3.5 或 Kimi-k2.5 后再发送视频',
message: '请切换到支持视频输入的模型后再发送视频',
type: 'error'
});
return;

View File

@ -165,7 +165,7 @@ const props = defineProps<{
currentConversationId: string | null;
iconStyle: (key: string) => Record<string, string>;
toolCategoryIcon: (categoryId: string) => string;
modelOptions: Array<{ key: string; label: string; description: string; disabled?: boolean }>;
modelOptions: Array<{ key: string; label: string; description: string; disabled?: boolean; supportsImage?: boolean; supportsVideo?: boolean }>;
currentModelKey: string;
selectedImages?: string[];
selectedVideos?: string[];

View File

@ -20,7 +20,7 @@
对话回顾
</button>
<button
v-if="['qwen3-vl-plus', 'kimi-k2.5'].includes(currentModelKey)"
v-if="currentModelSupportsImage"
type="button"
class="menu-entry"
data-tutorial="quick-send-image"
@ -30,7 +30,7 @@
发送图片
</button>
<button
v-if="['qwen3-vl-plus', 'kimi-k2.5'].includes(currentModelKey)"
v-if="currentModelSupportsVideo"
type="button"
class="menu-entry"
data-tutorial="quick-send-video"
@ -149,7 +149,7 @@ const props = defineProps<{
modeMenuOpen: boolean;
runMode?: 'fast' | 'thinking' | 'deep';
modelMenuOpen: boolean;
modelOptions: Array<{ key: string; label: string; description: string; disabled?: boolean }>;
modelOptions: Array<{ key: string; label: string; description: string; disabled?: boolean; supportsImage?: boolean; supportsVideo?: boolean }>;
currentModelKey: string;
blockUpload?: boolean;
blockToolToggle?: boolean;
@ -203,6 +203,16 @@ const currentModelLabel = computed(() => {
const found = props.modelOptions?.find(m => m.key === props.currentModelKey);
return found ? found.label : '未选择';
});
const currentModelSupportsImage = computed(() => {
const found = props.modelOptions?.find(m => m.key === props.currentModelKey) as any;
return !!found?.supportsImage;
});
const currentModelSupportsVideo = computed(() => {
const found = props.modelOptions?.find(m => m.key === props.currentModelKey) as any;
return !!found?.supportsVideo;
});
</script>
<style scoped>

View File

@ -749,6 +749,23 @@
</span>
<span>子智能体系列工具</span>
</label>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.skill_strict_run_command_foreground_enabled"
@change="personalization.updateField({ key: 'skill_strict_run_command_foreground_enabled', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>run_command 前台模式</span>
</label>
<label class="toggle-row">
<input
type="checkbox"
@ -993,6 +1010,7 @@ import { useResourceStore } from '@/stores/resource';
import { useUiStore } from '@/stores/ui';
import { usePolicyStore } from '@/stores/policy';
import { useTutorialStore } from '@/stores/tutorial';
import { useModelStore } from '@/stores/model';
import { formatTokenCount } from '@/utils/formatters';
import { useTheme } from '@/utils/theme';
import type { ThemeKey } from '@/utils/theme';
@ -1079,20 +1097,22 @@ const runModeOptions: Array<{ id: string; label: string; desc: string; value: Ru
];
const policyStore = usePolicyStore();
const modelOptions = [
{ id: 'deepseek', label: 'DeepSeek', desc: '通用 + 思考强化', value: 'deepseek' },
{ id: 'kimi-k2.5', label: 'Kimi-k2.5', desc: '新版 Kimi思考开关 + 图文多模态', value: 'kimi-k2.5', badge: '图文' },
{ id: 'kimi', label: 'Kimi-k2', desc: '旧版 Kimi-k2兼顾通用对话', value: 'kimi' },
{ id: 'qwen3-vl-plus', label: 'Qwen3.5', desc: '图文多模态 + 深度思考', value: 'qwen3-vl-plus', badge: '图文' },
{ id: 'minimax-m2.5', label: 'MiniMax-M2.5', desc: '仅深度思考,超长上下文', value: 'minimax-m2.5', badge: '深度思考' }
] as const;
const modelStore = useModelStore();
const filteredModelOptions = computed(() =>
modelOptions.map((opt) => ({
...opt,
disabled: policyStore.disabledModelSet.has(opt.value)
}))
(modelStore.models || []).map((opt: any) => {
const multimodal = String(opt.multimodal || 'none');
return {
id: opt.key,
value: opt.key,
label: opt.label,
desc: opt.description || '',
badge: multimodal === 'image,video' ? '图文' : (opt.deepOnly ? '深度思考' : undefined),
deepOnly: !!opt.deepOnly,
fastOnly: !!opt.fastOnly,
disabled: policyStore.disabledModelSet.has(opt.key)
};
})
);
const thinkingPresets = [
@ -1404,9 +1424,13 @@ const setDefaultModel = (value: string) => {
};
const checkModeModelConflict = (mode: RunModeValue, model: string | null): boolean => {
const found = (filteredModelOptions.value || []).find((item: any) => item.value === model);
const warnings: string[] = [];
if (model === 'minimax-m2.5' && mode && mode !== 'deep') {
warnings.push('MiniMax-M2.5 仅支持深度思考模式,已保持原设置。');
if (found?.deepOnly && mode && mode !== 'deep') {
warnings.push(`${found.label} 仅支持深度思考模式,已保持原设置。`);
}
if (found?.fastOnly && mode && mode !== 'fast') {
warnings.push(`${found.label} 仅支持快速模式,已保持原设置。`);
}
if (warnings.length) {
uiStore.pushToast({

View File

@ -1,11 +1,17 @@
import { defineStore } from 'pinia';
export type ModelKey = 'kimi-k2.5' | 'kimi' | 'deepseek' | 'qwen3-vl-plus' | 'minimax-m2.5';
export type ModelKey = string;
export interface ModelOption {
key: ModelKey;
label: string;
description: string;
visible?: boolean;
multimodal?: string;
supportsImage?: boolean;
supportsVideo?: boolean;
contextWindow?: number | null;
maxOutputTokens?: number | null;
fastOnly: boolean;
supportsThinking: boolean;
deepOnly?: boolean;
@ -24,6 +30,9 @@ export const useModelStore = defineStore('model', {
key: 'kimi-k2.5',
label: 'Kimi-k2.5',
description: '新版 Kimi支持图文 & 思考开关',
multimodal: 'image,video',
supportsImage: true,
supportsVideo: true,
fastOnly: false,
supportsThinking: true
},
@ -45,6 +54,9 @@ export const useModelStore = defineStore('model', {
key: 'qwen3-vl-plus',
label: 'Qwen3.5',
description: '图文视频多模态 + 深度思考',
multimodal: 'image,video',
supportsImage: true,
supportsVideo: true,
fastOnly: false,
supportsThinking: true
},
@ -64,12 +76,51 @@ export const useModelStore = defineStore('model', {
}
},
actions: {
setModels(models: ModelOption[]) {
if (!Array.isArray(models) || !models.length) return;
this.models = models;
const exists = this.models.some(m => m.key === this.currentModelKey);
if (!exists && this.models[0]) {
this.currentModelKey = this.models[0].key;
}
},
setModel(key: ModelKey) {
if (this.currentModelKey === key) return;
const exists = this.models.some(m => m.key === key);
if (exists) {
this.currentModelKey = key;
}
},
async fetchModels() {
const resp = await fetch('/api/v1/models', { cache: 'no-store' });
const data = await resp.json();
if (!resp.ok || !data?.success || !Array.isArray(data.items)) {
throw new Error(data?.error || '加载模型列表失败');
}
const mapped: ModelOption[] = data.items
.filter((item: any) => item && item.model_key)
.map((item: any) => {
const multimodal = String(item.multimodal || 'none');
const supportsImage = multimodal === 'image' || multimodal === 'image,video';
const supportsVideo = multimodal === 'video' || multimodal === 'image,video';
return {
key: String(item.model_key),
label: String(item.name || item.model_key),
description: String(item.description || ''),
visible: item.visible !== false,
multimodal,
supportsImage,
supportsVideo,
contextWindow: typeof item.context_window === 'number' ? item.context_window : null,
maxOutputTokens: typeof item.max_output_tokens === 'number' ? item.max_output_tokens : null,
fastOnly: !!item.fast_only,
supportsThinking: !!item.supports_thinking,
deepOnly: !!item.deep_only
};
});
if (mapped.length) {
this.setModels(mapped);
}
}
}
});

View File

@ -1,4 +1,5 @@
import { defineStore } from 'pinia';
import { useModelStore } from './model';
export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal';
type RunMode = 'fast' | 'thinking' | 'deep';
@ -10,6 +11,7 @@ interface PersonalForm {
skill_hints_enabled: boolean;
skill_strict_terminal_enabled: boolean;
skill_strict_sub_agent_enabled: boolean;
skill_strict_run_command_foreground_enabled: boolean;
skill_strict_run_command_background_enabled: boolean;
silent_tool_disable: boolean;
enhanced_tool_display: boolean;
@ -73,6 +75,7 @@ const defaultForm = (): PersonalForm => ({
skill_hints_enabled: false,
skill_strict_terminal_enabled: false,
skill_strict_sub_agent_enabled: false,
skill_strict_run_command_foreground_enabled: false,
skill_strict_run_command_background_enabled: false,
silent_tool_disable: false,
enhanced_tool_display: true,
@ -212,6 +215,7 @@ export const usePersonalizationStore = defineStore('personalization', {
skill_hints_enabled: !!data.skill_hints_enabled,
skill_strict_terminal_enabled: !!data.skill_strict_terminal_enabled,
skill_strict_sub_agent_enabled: !!data.skill_strict_sub_agent_enabled,
skill_strict_run_command_foreground_enabled: !!data.skill_strict_run_command_foreground_enabled,
skill_strict_run_command_background_enabled: !!data.skill_strict_run_command_background_enabled,
silent_tool_disable: !!data.silent_tool_disable,
enhanced_tool_display: data.enhanced_tool_display !== false,
@ -448,8 +452,9 @@ export const usePersonalizationStore = defineStore('personalization', {
this.clearFeedback();
},
setDefaultModel(model: string | null) {
const allowed = ['deepseek', 'kimi-k2.5', 'kimi', 'qwen3-vl-plus', 'minimax-m2.5'];
const target = typeof model === 'string' && allowed.includes(model) ? model : null;
const modelStore = useModelStore();
const allowed = new Set((modelStore.models || []).map((m) => m.key));
const target = typeof model === 'string' && allowed.has(model) ? model : null;
this.form = {
...this.form,
default_model: target

View File

@ -26,7 +26,17 @@ export const useTaskStore = defineStore('task', {
},
actions: {
async createTask(message: string, images: any[] = [], videos: any[] = [], conversationId: string | null = null) {
async createTask(
message: string,
images: any[] = [],
videos: any[] = [],
conversationId: string | null = null,
options: {
model_key?: string | null;
run_mode?: 'fast' | 'thinking' | 'deep' | null;
thinking_mode?: boolean | null;
} = {}
) {
try {
debugLog('[Task] 创建任务:', { message, conversationId });
@ -40,6 +50,9 @@ export const useTaskStore = defineStore('task', {
images,
videos,
conversation_id: conversationId,
model_key: options.model_key ?? undefined,
run_mode: options.run_mode ?? undefined,
thinking_mode: typeof options.thinking_mode === 'boolean' ? options.thinking_mode : undefined,
}),
});

View File

@ -135,7 +135,8 @@ const shouldIncludeStepByCondition = (step: TutorialStep): boolean => {
}
if (step.condition === 'model_supports_media') {
const modelStore = useModelStore();
return ['qwen3-vl-plus', 'kimi-k2.5'].includes(modelStore.currentModelKey);
const current = modelStore.currentModel as any;
return !!(current?.supportsImage || current?.supportsVideo);
}
if (step.condition === 'is_mobile_viewport') {
return detectMobileViewport();

View File

@ -266,6 +266,28 @@
gap: 6px;
}
.dropdown-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.dropdown-list--models {
max-height: 320px; /* 最多显示约 5 个模型 */
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
.dropdown-list--models::-webkit-scrollbar {
width: 0;
height: 0;
}
.model-mode-dropdown--mobile .dropdown-list--models {
max-height: 260px;
}
.dropdown-title {
font-size: 12px;
font-weight: 700;

View File

@ -425,6 +425,20 @@ class DeepSeekClient:
self.api_base_url = self.fast_api_config["base_url"]
self.api_key = self.fast_api_config["api_key"]
self.model_id = self.fast_api_config["model_id"]
try:
self._debug_log({
"event": "apply_profile",
"model_key": self.model_key,
"fast_model_id": self.fast_api_config.get("model_id"),
"thinking_model_id": self.thinking_api_config.get("model_id"),
"fast_max_tokens": self.fast_max_tokens,
"thinking_max_tokens": self.thinking_max_tokens,
"fast_extra_params": self.fast_extra_params,
"thinking_extra_params": self.thinking_extra_params,
"default_context_window": self.default_context_window,
})
except Exception:
pass
def update_context_budget(self, current_tokens: int, max_tokens: Optional[int]):
"""
@ -619,6 +633,25 @@ class DeepSeekClient:
payload["tool_choice"] = "auto"
# 将本次请求落盘,便于出错时快速定位
try:
self._debug_log({
"event": "request_prepare",
"model_key": self.model_key,
"thinking_mode_flag": bool(self.thinking_mode),
"deep_thinking_mode": bool(self.deep_thinking_mode),
"deep_thinking_session": bool(self.deep_thinking_session),
"current_call_use_thinking": bool(current_thinking_mode),
"api_base_url": api_config.get("base_url"),
"api_model_id": api_config.get("model_id"),
"payload_model": payload.get("model"),
"payload_has_thinking": "thinking" in payload,
"payload_thinking": payload.get("thinking"),
"payload_enable_thinking": payload.get("enable_thinking"),
"payload_max_tokens": payload.get("max_tokens"),
"payload_max_completion_tokens": payload.get("max_completion_tokens"),
})
except Exception:
pass
dump_path = self._dump_request_payload(payload, api_config, headers)
try:

View File

@ -24,7 +24,12 @@ try:
TERMINAL_SANDBOX_MODE,
LINUX_SAFETY,
)
from config.model_profiles import get_model_prompt_replacements
from config.model_profiles import (
get_model_prompt_replacements,
get_registered_model_keys,
model_supports_image,
model_supports_video,
)
except ImportError:
import sys
from pathlib import Path
@ -42,7 +47,12 @@ except ImportError:
TERMINAL_SANDBOX_MODE,
LINUX_SAFETY,
)
from config.model_profiles import get_model_prompt_replacements
from config.model_profiles import (
get_model_prompt_replacements,
get_registered_model_keys,
model_supports_image,
model_supports_video,
)
from utils.conversation_manager import ConversationManager
AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩"
@ -789,7 +799,20 @@ class ContextManager:
if model_key:
self.main_terminal.set_model(model_key)
except Exception:
pass
fallback_key = None
for candidate in get_registered_model_keys(visible_only=True):
if self.has_images and not model_supports_image(candidate):
continue
if self.has_videos and not model_supports_video(candidate):
continue
fallback_key = candidate
break
if fallback_key:
try:
self.main_terminal.set_model(fallback_key)
self.conversation_metadata["model_key"] = fallback_key
except Exception:
pass
try:
if run_mode:
self.main_terminal.set_run_mode(run_mode)
@ -1796,7 +1819,8 @@ class ContextManager:
return text
parts: List[Dict[str, Any]] = []
extra_videos: List[Any] = []
supports_video_fps = getattr(getattr(self, "main_terminal", None), "model_key", None) == "qwen3-vl-plus"
current_model = getattr(getattr(self, "main_terminal", None), "model_key", None)
supports_video_fps = current_model == "qwen3-vl-plus"
qwen_video_fps = 2
if text:
parts.append({"type": "text", "text": text})
@ -1898,7 +1922,7 @@ class ContextManager:
"""构建消息列表(添加终端内容注入)"""
# 加载系统提示Qwen3.5 使用专用提示)
model_key = getattr(self.main_terminal, "model_key", "kimi") if hasattr(self, "main_terminal") else "kimi"
prompt_name = "main_system_qwenvl" if model_key in {"qwen3-vl-plus", "kimi-k2.5"} else "main_system"
prompt_name = "main_system_qwenvl" if (model_supports_image(model_key) or model_supports_video(model_key)) else "main_system"
system_prompt = self.load_prompt(prompt_name)
# 格式化系统提示