feat: add json-based extensible model registry and dynamic model UI
This commit is contained in:
parent
b63e827ed2
commit
90233690ad
57
config/custom_models.json
Normal file
57
config/custom_models.json
Normal 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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
57
config/custom_models.json.example
Normal file
57
config/custom_models.json.example
Normal 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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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", {})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
# 格式化系统提示
|
||||
|
||||
@ -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,12 +789,14 @@ 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"
|
||||
]
|
||||
if model_supports_image(model_key):
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
@ -810,6 +814,7 @@ class MainTerminalToolsDefinitionMixin:
|
||||
}
|
||||
}
|
||||
})
|
||||
if model_supports_video(model_key):
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
# 图片压缩模式
|
||||
|
||||
@ -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})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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': '图片和视频请分开发送'})
|
||||
|
||||
@ -222,6 +222,7 @@ class TaskManager:
|
||||
raise RuntimeError("系统未初始化")
|
||||
stop_hint = bool(stop_flags.get(rec.task_id, {}).get("stop"))
|
||||
|
||||
def _apply_requested_model_mode():
|
||||
# API 传入的模型/模式配置
|
||||
if rec.model_key:
|
||||
try:
|
||||
@ -238,11 +239,23 @@ class TaskManager:
|
||||
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(
|
||||
|
||||
@ -149,6 +149,7 @@
|
||||
>
|
||||
<div class="dropdown-column" data-tutorial="header-model-options">
|
||||
<div class="dropdown-title">模型</div>
|
||||
<div class="dropdown-list dropdown-list--models">
|
||||
<button
|
||||
v-for="option in modelOptions"
|
||||
:key="option.key"
|
||||
@ -163,6 +164,7 @@
|
||||
<span v-if="option.key === currentModelKey" class="item-check">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-column" data-tutorial="header-runmode-options">
|
||||
<div class="dropdown-title">运行模式</div>
|
||||
@ -462,6 +464,7 @@
|
||||
>
|
||||
<div class="dropdown-column" data-tutorial="header-model-options">
|
||||
<div class="dropdown-title">模型</div>
|
||||
<div class="dropdown-list dropdown-list--models">
|
||||
<button
|
||||
v-for="option in modelOptions"
|
||||
:key="option.key"
|
||||
@ -475,6 +478,7 @@
|
||||
<span v-if="option.key === currentModelKey" class="item-check">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-column" data-tutorial="header-runmode-options">
|
||||
<div class="dropdown-title">运行模式</div>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = "过早的工具结果已经被自动压缩"
|
||||
@ -788,6 +798,19 @@ class ContextManager:
|
||||
try:
|
||||
if model_key:
|
||||
self.main_terminal.set_model(model_key)
|
||||
except Exception:
|
||||
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:
|
||||
@ -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)
|
||||
|
||||
# 格式化系统提示
|
||||
|
||||
Loading…
Reference in New Issue
Block a user