refactor: 增强模型提供程序管理和 UI 组件

- 在 PostgresManager 中添加了用于嵌入和重新排序基本 URL 的新列。
- 更新了模型提供程序数据结构,以包含新的端点。
- 重构了模型提供程序服务和路由器中的凭据状态检查。
- 引入了一个可重用的组件,用于跨组件进行模型状态检查。
- 通过调整页面内边距的 CSS 变量,提高了 UI 的响应速度。
- 重构了模型选择器组件,以利用新的模型状态组件。
- 更新了模型配置视图,以处理响应式编辑模型状态。
- 清理了未使用的代码,并改进了模型状态检查中的错误处理。
This commit is contained in:
Wenjie Zhang 2026-04-26 19:59:15 +08:00
parent 2920cbeec3
commit 2e0b4a8358
14 changed files with 471 additions and 463 deletions

View File

@ -0,0 +1,205 @@
"""内置模型供应商定义。"""
from typing import Any
BUILTIN_PROVIDERS: list[dict[str, Any]] = [
{
"provider_id": "openai",
"display_name": "OpenAI",
"base_url": "https://api.openai.com/v1",
"api_key_env": "OPENAI_API_KEY",
"models_endpoint": "https://api.openai.com/v1/models",
},
# {
# "provider_id": "anthropic",
# "display_name": "Anthropic",
# "base_url": "https://api.anthropic.com",
# "api_key_env": "ANTHROPIC_API_KEY",
# "models_endpoint": "https://api.anthropic.com/models",
# },
# {
# "provider_id": "google",
# "display_name": "Google Gemini",
# "base_url": "https://generativelanguage.googleapis.com",
# "api_key_env": "GEMINI_API_KEY",
# "models_endpoint": "https://generativelanguage.googleapis.com/v1beta/models",
# },
# {
# "provider_id": "ollama-cloud",
# "display_name": "Ollama",
# "base_url": "http://localhost:11434",
# "models_endpoint": "http://localhost:11434/api/tags",
# },
# {
# "provider_id": "lmstudio",
# "display_name": "LM Studio",
# "base_url": "http://localhost:1234/v1",
# "models_endpoint": "http://localhost:1234/v1/models",
# },
{
"provider_id": "deepseek",
"display_name": "DeepSeek",
"base_url": "https://api.deepseek.com",
"api_key_env": "DEEPSEEK_API_KEY",
"models_endpoint": "https://api.deepseek.com/models",
},
{
"provider_id": "alibaba",
"display_name": "DashScope",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key_env": "DASHSCOPE_API_KEY",
"models_endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
},
{
"provider_id": "alibaba-coding-plan-cn",
"display_name": "Aliyun Coding Plan",
"base_url": "https://coding.dashscope.aliyuncs.com/v1",
"api_key_env": "DASHSCOPE_API_KEY",
"models_endpoint": "https://coding.dashscope.aliyuncs.com/v1/models",
},
{
"provider_id": "alibaba-coding-plan",
"display_name": "Aliyun Coding Plan (International)",
"base_url": "https://coding-intl.dashscope.aliyuncs.com/v1",
"api_key_env": "DASHSCOPE_API_KEY",
"models_endpoint": "https://coding-intl.dashscope.aliyuncs.com/v1/models",
},
{
"provider_id": "zhipuai",
"display_name": "Zhipu (BigModel)",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"api_key_env": "ZHIPUAI_API_KEY",
"models_endpoint": "https://open.bigmodel.cn/api/paas/v4/models",
},
{
"provider_id": "zhipuai-coding-plan",
"display_name": "Zhipu Coding Plan (BigModel)",
"base_url": "https://open.bigmodel.cn/api/coding/paas/v4",
"api_key_env": "ZHIPUAI_API_KEY",
"models_endpoint": "https://open.bigmodel.cn/api/coding/paas/v4/models",
},
{
"provider_id": "zai",
"display_name": "Zhipu (Z.AI)",
"base_url": "https://api.z.ai/api/paas/v4",
"api_key_env": "ZAI_API_KEY",
"models_endpoint": "https://api.z.ai/api/paas/v4/models",
},
{
"provider_id": "zai-coding-plan",
"display_name": "Zhipu Coding Plan (Z.AI)",
"base_url": "https://api.z.ai/api/coding/paas/v4",
"api_key_env": "ZAI_API_KEY",
"models_endpoint": "https://api.z.ai/api/coding/paas/v4/models",
},
{
"provider_id": "moonshotai-cn",
"display_name": "Moonshot",
"base_url": "https://api.moonshot.cn/v1",
"api_key_env": "MOONSHOT_API_KEY",
"models_endpoint": "https://api.moonshot.cn/v1/models",
},
{
"provider_id": "moonshotai",
"display_name": "Moonshot (International)",
"base_url": "https://api.moonshot.ai/v1",
"api_key_env": "MOONSHOT_API_KEY",
"models_endpoint": "https://api.moonshot.ai/v1/models",
},
{
"provider_id": "minimax-cn",
"display_name": "MiniMax",
"base_url": "https://api.minimaxi.com/v1",
"api_key_env": "MINIMAX_API_KEY",
"models_endpoint": "https://api.minimaxi.com/v1/models",
},
{
"provider_id": "minimax",
"display_name": "MiniMax (International)",
"base_url": "https://api.minimax.io/v1",
"api_key_env": "MINIMAX_API_KEY",
"models_endpoint": "https://api.minimax.io/v1/models",
},
{
"provider_id": "openrouter",
"display_name": "OpenRouter",
"base_url": "https://openrouter.ai/api/v1",
"api_key_env": "OPENROUTER_API_KEY",
"capabilities": ["chat", "embedding"],
"embedding_base_url": "https://openrouter.ai/api/v1/embeddings",
"models_endpoint": "https://openrouter.ai/api/v1/models",
"embedding_models_endpoint": "https://openrouter.ai/api/v1/embeddings/models",
},
{
"provider_id": "modelscope",
"display_name": "ModelScope",
"base_url": "https://api-inference.modelscope.cn/v1",
"api_key_env": "MODELSCOPE_ACCESS_TOKEN",
"models_endpoint": "https://api-inference.modelscope.cn/v1/models",
},
{
"provider_id": "opencode",
"display_name": "OpenCode",
"base_url": "https://opencode.ai/zen/v1",
"models_endpoint": "https://opencode.ai/zen/v1/models",
},
{
"provider_id": "siliconflow-cn",
"display_name": "SiliconFlow",
"base_url": "https://api.siliconflow.cn/v1",
"embedding_base_url": "https://api.siliconflow.cn/v1/embeddings",
"rerank_base_url": "https://api.siliconflow.cn/v1/rerank",
"api_key_env": "SILICONFLOW_API_KEY",
"capabilities": ["chat", "embedding", "rerank"],
"models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=chat",
"embedding_models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=embedding",
"rerank_models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=reranker",
"enabled_models": [
{"id": "Pro/deepseek-ai/DeepSeek-V3.2", "type": "chat", "display_name": "Pro/deepseek-ai/DeepSeek-V3.2"},
{"id": "Pro/MiniMaxAI/MiniMax-M2.5", "type": "chat", "display_name": "Pro/MiniMaxAI/MiniMax-M2.5"},
{
"id": "Pro/BAAI/bge-m3",
"type": "embedding",
"display_name": "Pro/BAAI/bge-m3",
"dimension": 1024,
"batch_size": 40,
},
{
"id": "BAAI/bge-m3",
"type": "embedding",
"display_name": "BAAI/bge-m3",
"dimension": 1024,
"batch_size": 40,
},
{
"id": "Qwen/Qwen3-Embedding-0.6B",
"type": "embedding",
"display_name": "Qwen/Qwen3-Embedding-0.6B",
"dimension": 1024,
"batch_size": 40,
},
{
"id": "Pro/BAAI/bge-reranker-v2-m3",
"type": "rerank",
"display_name": "Pro/BAAI/bge-reranker-v2-m3",
},
{
"id": "BAAI/bge-reranker-v2-m3",
"type": "rerank",
"display_name": "BAAI/bge-reranker-v2-m3",
},
],
},
{
"provider_id": "siliconflow",
"display_name": "SiliconFlow (International)",
"base_url": "https://api.siliconflow.com/v1",
"embedding_base_url": "https://api.siliconflow.com/v1/embeddings",
"rerank_base_url": "https://api.siliconflow.com/v1/rerank",
"api_key_env": "SILICONFLOW_GLOBAL_API_KEY",
"capabilities": ["chat", "embedding", "rerank"],
"models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=chat",
"embedding_models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=embedding",
"rerank_models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=reranker",
},
]

View File

@ -85,6 +85,9 @@ class ModelCache:
"""基于 Redis 的模型缓存,所有写入均走 Redis保证跨进程一致。
查询接口使用本地内存缓存 TTL避免热路径反复反序列化 JSON
注意本类使用同步 Redis 客户端redis-py因为 LangGraph 在初始化模型时
通过同步路径调用 get_model_info()后续可统一升级为异步客户端redis.asyncio
"""
def __init__(self) -> None:
@ -170,7 +173,7 @@ class ModelCache:
所有修改操作启动初始化CRUD都通过此方法写入
保证 Redis 中的数据始终是最新的
"""
from yuxi.services.model_provider_service import _resolve_api_key
from yuxi.services.model_provider_service import resolve_api_key
new_cache: dict[str, ModelInfo] = {}
@ -178,7 +181,7 @@ class ModelCache:
if not provider.is_enabled:
continue
api_key = _resolve_api_key(provider)
api_key = resolve_api_key(provider)
for model in provider.enabled_models or []:
model_type = model.get("type", "chat")
@ -228,3 +231,51 @@ class ModelCache:
# 全局单例
model_cache = ModelCache()
def resolve_model_spec(spec: str) -> ModelInfo:
"""统一入口:根据 spec 自动识别 V1/V2 格式并返回 ModelInfo。
V2 格式优先: provider_id:model_id冒号分隔 model_cache 查找
V1 格式: provider/model_name斜杠分隔 config.model_names 查找
Raises:
ValueError: spec 格式无效或模型未找到
"""
if not spec:
raise ValueError("spec 不能为空")
# V2: 优先查找缓存
if ":" in spec:
info = model_cache.get_model_info(spec)
if info:
return info
raise ValueError(f"未找到 V2 模型: {spec}")
# V1: 从 config 查找(兼容旧版)
if "/" in spec:
from yuxi import config
provider, model_name = spec.split("/", 1)
model_info = config.model_names.get(provider)
if not model_info:
raise ValueError(f"未找到 V1 模型提供者: {provider}")
import os
from yuxi.utils import get_docker_safe_url
api_key = os.getenv(model_info.env) or model_info.env
return ModelInfo(
provider_id=provider,
model_id=model_name,
model_type="chat",
display_name=f"{provider}/{model_name}",
api_key=api_key,
base_url=get_docker_safe_url(model_info.base_url),
provider_type="openai",
)
raise ValueError(
f"无效的模型 spec: '{spec}'。V1 格式要求 'provider/model_name'V2 格式要求 'provider_id:model_id'"
)

View File

@ -14,251 +14,13 @@ from yuxi.repositories.model_provider_repository import (
list_model_providers,
update_model_provider,
)
from yuxi.config.builtin_providers import BUILTIN_PROVIDERS
from yuxi.storage.postgres.models_business import ModelProvider
VALID_MODEL_TYPES = {"chat", "embedding", "rerank"}
VALID_MODEL_SOURCES = {"manual", "remote"}
VALID_PROVIDER_TYPES = {"openai", "anthropic", "gemini", "ollama", "openrouter", "lmstudio"}
_PROVIDER_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{1,99}$")
DEFAULT_MODELS_ENDPOINT = ""
DEFAULT_EMBEDDING_MODELS_ENDPOINT = ""
_DEFAULT_BUILTIN_PROVIDERS: list[dict[str, Any]] = [
{
"provider_id": "openai",
"display_name": "OpenAI",
"base_url": "https://api.openai.com/v1",
"api_key_env": "OPENAI_API_KEY",
"models_endpoint": "https://api.openai.com/v1/models",
},
{
"provider_id": "anthropic",
"display_name": "Anthropic",
"base_url": "https://api.anthropic.com",
"api_key_env": "ANTHROPIC_API_KEY",
"models_endpoint": "https://api.anthropic.com/models",
},
{
"provider_id": "google",
"display_name": "Google Gemini",
"base_url": "https://generativelanguage.googleapis.com",
"api_key_env": "GEMINI_API_KEY",
"models_endpoint": "https://generativelanguage.googleapis.com/v1beta/models",
},
{
"provider_id": "ollama-cloud",
"display_name": "Ollama",
"base_url": "http://localhost:11434",
"models_endpoint": "http://localhost:11434/api/tags",
},
{
"provider_id": "lmstudio",
"display_name": "LM Studio",
"base_url": "http://localhost:1234/v1",
"models_endpoint": "http://localhost:1234/v1/models",
},
{
"provider_id": "deepseek",
"display_name": "DeepSeek",
"base_url": "https://api.deepseek.com",
"api_key_env": "DEEPSEEK_API_KEY",
"models_endpoint": "https://api.deepseek.com/models",
},
{
"provider_id": "alibaba",
"display_name": "DashScope",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key_env": "DASHSCOPE_API_KEY",
"models_endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
},
{
"provider_id": "alibaba-coding-plan-cn",
"display_name": "Aliyun Coding Plan",
"base_url": "https://coding.dashscope.aliyuncs.com/v1",
"api_key_env": "DASHSCOPE_API_KEY",
"models_endpoint": "https://coding.dashscope.aliyuncs.com/v1/models",
},
{
"provider_id": "alibaba-coding-plan",
"display_name": "Aliyun Coding Plan (International)",
"base_url": "https://coding-intl.dashscope.aliyuncs.com/v1",
"api_key_env": "DASHSCOPE_API_KEY",
"models_endpoint": "https://coding-intl.dashscope.aliyuncs.com/v1/models",
},
{
"provider_id": "zhipuai",
"display_name": "Zhipu (BigModel)",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"api_key_env": "ZHIPUAI_API_KEY",
"models_endpoint": "https://open.bigmodel.cn/api/paas/v4/models",
},
{
"provider_id": "zhipuai-coding-plan",
"display_name": "Zhipu Coding Plan (BigModel)",
"base_url": "https://open.bigmodel.cn/api/coding/paas/v4",
"api_key_env": "ZHIPUAI_API_KEY",
"models_endpoint": "https://open.bigmodel.cn/api/coding/paas/v4/models",
},
{
"provider_id": "zai",
"display_name": "Zhipu (Z.AI)",
"base_url": "https://api.z.ai/api/paas/v4",
"api_key_env": "ZAI_API_KEY",
"models_endpoint": "https://api.z.ai/api/paas/v4/models",
},
{
"provider_id": "zai-coding-plan",
"display_name": "Zhipu Coding Plan (Z.AI)",
"base_url": "https://api.z.ai/api/coding/paas/v4",
"api_key_env": "ZAI_API_KEY",
"models_endpoint": "https://api.z.ai/api/coding/paas/v4/models",
},
{
"provider_id": "moonshotai-cn",
"display_name": "Moonshot",
"base_url": "https://api.moonshot.cn/v1",
"api_key_env": "MOONSHOT_API_KEY",
"models_endpoint": "https://api.moonshot.cn/v1/models",
},
{
"provider_id": "moonshotai",
"display_name": "Moonshot (International)",
"base_url": "https://api.moonshot.ai/v1",
"api_key_env": "MOONSHOT_API_KEY",
"models_endpoint": "https://api.moonshot.ai/v1/models",
},
{
"provider_id": "minimax-cn",
"display_name": "MiniMax",
"base_url": "https://api.minimaxi.com/v1",
"api_key_env": "MINIMAX_API_KEY",
"models_endpoint": "https://api.minimaxi.com/v1/models",
},
{
"provider_id": "minimax",
"display_name": "MiniMax (International)",
"base_url": "https://api.minimax.io/v1",
"api_key_env": "MINIMAX_API_KEY",
"models_endpoint": "https://api.minimax.io/v1/models",
},
{
"provider_id": "openrouter",
"display_name": "OpenRouter",
"base_url": "https://openrouter.ai/api/v1",
"api_key_env": "OPENROUTER_API_KEY",
"capabilities": ["chat", "embedding"],
"embedding_base_url": "https://openrouter.ai/api/v1/embeddings",
"models_endpoint": "https://openrouter.ai/api/v1/models",
"embedding_models_endpoint": "https://openrouter.ai/api/v1/embeddings/models",
},
{
"provider_id": "modelscope",
"display_name": "ModelScope",
"base_url": "https://api-inference.modelscope.cn/v1",
"api_key_env": "MODELSCOPE_ACCESS_TOKEN",
"models_endpoint": "https://api-inference.modelscope.cn/v1/models",
},
{
"provider_id": "opencode",
"display_name": "OpenCode",
"base_url": "https://opencode.ai/zen/v1",
"models_endpoint": "https://opencode.ai/zen/v1/models",
},
{
"provider_id": "siliconflow-cn",
"display_name": "SiliconFlow",
"base_url": "https://api.siliconflow.cn/v1",
"embedding_base_url": "https://api.siliconflow.cn/v1/embeddings",
"rerank_base_url": "https://api.siliconflow.cn/v1/rerank",
"api_key_env": "SILICONFLOW_API_KEY",
"capabilities": ["chat", "embedding", "rerank"],
"models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=chat",
"embedding_models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=embedding",
"rerank_models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=reranker",
"enabled_models": [
{"id": "Pro/deepseek-ai/DeepSeek-V3.2", "type": "chat", "display_name": "Pro/deepseek-ai/DeepSeek-V3.2"},
{"id": "Pro/MiniMaxAI/MiniMax-M2.5", "type": "chat", "display_name": "Pro/MiniMaxAI/MiniMax-M2.5"},
{
"id": "Pro/BAAI/bge-m3",
"type": "embedding",
"display_name": "Pro/BAAI/bge-m3",
"dimension": 1024,
"batch_size": 40,
},
{
"id": "BAAI/bge-m3",
"type": "embedding",
"display_name": "BAAI/bge-m3",
"dimension": 1024,
"batch_size": 40,
},
{
"id": "Qwen/Qwen3-Embedding-0.6B",
"type": "embedding",
"display_name": "Qwen/Qwen3-Embedding-0.6B",
"dimension": 1024,
"batch_size": 40,
},
{
"id": "Pro/BAAI/bge-reranker-v2-m3",
"type": "rerank",
"display_name": "Pro/BAAI/bge-reranker-v2-m3",
},
{
"id": "BAAI/bge-reranker-v2-m3",
"type": "rerank",
"display_name": "BAAI/bge-reranker-v2-m3",
},
],
},
{
"provider_id": "siliconflow",
"display_name": "SiliconFlow (International)",
"base_url": "https://api.siliconflow.com/v1",
"embedding_base_url": "https://api.siliconflow.com/v1/embeddings",
"rerank_base_url": "https://api.siliconflow.com/v1/rerank",
"api_key_env": "SILICONFLOW_API_KEY",
"capabilities": ["chat", "embedding", "rerank"],
"models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=chat",
"embedding_models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=embedding",
"rerank_models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=reranker",
"enabled_models": [
{"id": "deepseek-ai/DeepSeek-V3.2", "type": "chat", "display_name": "deepseek-ai/DeepSeek-V3.2"},
{"id": "MiniMaxAI/MiniMax-M2.5", "type": "chat", "display_name": "MiniMaxAI/MiniMax-M2.5"},
{
"id": "Pro/BAAI/bge-m3",
"type": "embedding",
"display_name": "Pro/BAAI/bge-m3",
"dimension": 1024,
"batch_size": 40,
},
{
"id": "BAAI/bge-m3",
"type": "embedding",
"display_name": "BAAI/bge-m3",
"dimension": 1024,
"batch_size": 40,
},
{
"id": "Qwen/Qwen3-Embedding-0.6B",
"type": "embedding",
"display_name": "Qwen/Qwen3-Embedding-0.6B",
"dimension": 1024,
"batch_size": 40,
},
{
"id": "Pro/BAAI/bge-reranker-v2-m3",
"type": "rerank",
"display_name": "Pro/BAAI/bge-reranker-v2-m3",
},
{
"id": "BAAI/bge-reranker-v2-m3",
"type": "rerank",
"display_name": "BAAI/bge-reranker-v2-m3",
},
],
},
]
def _normalize_list(value: Any) -> list:
@ -297,11 +59,12 @@ def _normalize_model_item(model: dict[str, Any]) -> dict[str, Any]:
normalized["extra"] = _normalize_dict(model.get("extra"))
if model_type == "embedding":
dimension = normalized.get("dimension")
dimension = model.get("dimension")
if dimension not in (None, ""):
normalized["dimension"] = int(dimension)
if normalized.get("batch_size") not in (None, ""):
normalized["batch_size"] = int(normalized["batch_size"])
batch_size = model.get("batch_size")
if batch_size not in (None, ""):
normalized["batch_size"] = int(batch_size)
return normalized
@ -346,16 +109,14 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
raise ValueError("base_url 不能为空")
payload["base_url"] = base_url
for endpoint_field, default_endpoint in (
("models_endpoint", DEFAULT_MODELS_ENDPOINT),
("embedding_models_endpoint", DEFAULT_EMBEDDING_MODELS_ENDPOINT),
("rerank_models_endpoint", None),
for endpoint_field in (
"models_endpoint",
"embedding_models_endpoint",
"rerank_models_endpoint",
):
if endpoint_field in payload:
endpoint = str(payload.get(endpoint_field) or "").strip()
payload[endpoint_field] = endpoint or default_endpoint
elif not partial and default_endpoint is not None:
payload[endpoint_field] = default_endpoint
payload[endpoint_field] = endpoint
provider_type = payload.get("provider_type")
if provider_type is None and not partial:
@ -364,55 +125,29 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
if provider_type not in VALID_PROVIDER_TYPES:
raise ValueError(f"provider_type 必须是 {', '.join(sorted(VALID_PROVIDER_TYPES))} 之一")
if "capabilities" in payload:
payload["capabilities"] = _normalize_list(payload.get("capabilities"))
elif not partial:
payload["capabilities"] = []
capabilities = set(payload.get("capabilities") or [])
if "embedding" in capabilities:
embedding_base_url = str(payload.get("embedding_base_url") or "").strip()
if not embedding_base_url:
raise ValueError("embedding provider 必须配置 embedding_base_url")
payload["embedding_base_url"] = embedding_base_url
embedding_endpoint = str(payload.get("embedding_models_endpoint") or "").strip()
if embedding_endpoint and not embedding_endpoint.startswith(("http://", "https://")):
raise ValueError("embedding_models_endpoint 必须是完整的 HTTP URL")
payload["embedding_models_endpoint"] = embedding_endpoint
if "rerank" in capabilities:
rerank_base_url = str(payload.get("rerank_base_url") or "").strip()
if not rerank_base_url:
raise ValueError("rerank provider 必须配置 rerank_base_url")
payload["rerank_base_url"] = rerank_base_url
rerank_endpoint = str(payload.get("rerank_models_endpoint") or "").strip()
if rerank_endpoint and not rerank_endpoint.startswith(("http://", "https://")):
raise ValueError("rerank_models_endpoint 必须是完整的 HTTP URL")
payload["rerank_models_endpoint"] = rerank_endpoint
if "enabled_models" in payload:
payload["enabled_models"] = _normalize_model_list(payload.get("enabled_models"))
elif not partial:
payload["enabled_models"] = []
if "headers_json" in payload:
payload["headers_json"] = _normalize_dict(payload.get("headers_json"))
elif not partial:
payload["headers_json"] = {}
if "extra_json" in payload:
payload["extra_json"] = _normalize_dict(payload.get("extra_json"))
elif not partial:
payload["extra_json"] = {}
if "is_enabled" in payload:
payload["is_enabled"] = bool(payload["is_enabled"])
elif not partial:
payload["is_enabled"] = True
if "is_builtin" in payload:
payload["is_builtin"] = bool(payload["is_builtin"])
elif not partial:
payload["is_builtin"] = False
# 声明式字段默认值partial 模式下仅规范化传入值,非 partial 补全默认值
_FIELD_DEFAULTS: dict[str, Any] = {
"capabilities": [],
"enabled_models": [],
"headers_json": {},
"extra_json": {},
"is_enabled": True,
"is_builtin": False,
}
_FIELD_NORMALIZERS = {
"capabilities": _normalize_list,
"enabled_models": _normalize_model_list,
"headers_json": _normalize_dict,
"extra_json": _normalize_dict,
"is_enabled": bool,
"is_builtin": bool,
}
for field, default in _FIELD_DEFAULTS.items():
if field in payload:
normalizer = _FIELD_NORMALIZERS.get(field)
payload[field] = normalizer(payload[field]) if normalizer else payload[field]
elif not partial:
payload[field] = default
# 仅当本次 payload 同时携带 capabilities 与 enabled_models 时做一致性校验,
# 防止前端把超出 provider.capabilities 的模型 type 写入。
@ -425,7 +160,8 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
return payload
def _resolve_api_key(provider: ModelProvider) -> str | None:
def resolve_api_key(provider: ModelProvider) -> str | None:
"""解析 provider 的 API Key优先直接配置其次从环境变量读取。"""
if provider.api_key:
return provider.api_key
if provider.api_key_env:
@ -433,7 +169,7 @@ def _resolve_api_key(provider: ModelProvider) -> str | None:
return None
def _check_credential_status(provider: ModelProvider) -> str:
def check_credential_status(provider: ModelProvider) -> str:
"""检查 provider 的凭证配置状态。仅对启用的 provider 做校验。"""
if not provider.is_enabled:
return "ok"
@ -444,7 +180,7 @@ def _check_credential_status(provider: ModelProvider) -> str:
return "warning"
def _models_url(base_url: str, endpoint: str | None = DEFAULT_MODELS_ENDPOINT) -> str:
def _models_url(base_url: str, endpoint: str | None = None) -> str:
base = base_url.rstrip("/")
if not endpoint:
return base
@ -502,7 +238,7 @@ async def ensure_builtin_model_providers_in_db(db: AsyncSession) -> None:
existing = await list_model_providers(db)
existing_ids = {p.provider_id: p for p in existing}
for provider_def in _DEFAULT_BUILTIN_PROVIDERS:
for provider_def in BUILTIN_PROVIDERS:
provider_id = provider_def["provider_id"]
existing_provider = existing_ids.get(provider_id)
if existing_provider:
@ -598,22 +334,22 @@ async def fetch_remote_models(provider: ModelProvider) -> list[dict[str, Any]]:
/embeddings/modelsrerank 供应商没有稳定通用端点配置了 endpoint 才拉取
"""
headers = dict(provider.headers_json or {})
api_key = _resolve_api_key(provider)
api_key = resolve_api_key(provider)
if api_key:
headers.setdefault("Authorization", f"Bearer {api_key}")
capabilities = set(provider.capabilities or [])
endpoint_specs = [
(provider.models_endpoint or DEFAULT_MODELS_ENDPOINT, "chat"),
(provider.models_endpoint, "chat"),
]
if "embedding" in capabilities:
endpoint_specs.append((provider.embedding_models_endpoint or DEFAULT_EMBEDDING_MODELS_ENDPOINT, "embedding"))
endpoint_specs.append((provider.embedding_models_endpoint, "embedding"))
if "rerank" in capabilities and provider.rerank_models_endpoint:
endpoint_specs.append((provider.rerank_models_endpoint, "rerank"))
seen_ids: set[tuple[str, str]] = set()
models: list[dict[str, Any]] = []
async with httpx.AsyncClient(timeout=20.0) as client:
async with httpx.AsyncClient(timeout=40.0) as client:
results = await asyncio.gather(
*[
_fetch_models_from_endpoint(client, provider, headers, endpoint, model_type)
@ -628,3 +364,50 @@ async def fetch_remote_models(provider: ModelProvider) -> list[dict[str, Any]]:
seen_ids.add(model_key)
models.append(model)
return models
async def test_model_status_by_spec(spec: str) -> dict:
"""根据 full spec 测试模型连接状态(自动识别 Chat/Embedding
V2 spec 格式: provider_id:model_id冒号分隔
V1 spec 格式: provider/model_name斜杠分隔
"""
from yuxi.services.model_cache import model_cache
# V2: 从缓存识别模型类型并分派
if ":" in spec:
info = model_cache.get_model_info(spec)
if info:
if info.model_type == "embedding":
from yuxi.models.embed import select_embedding_model_v2
model = select_embedding_model_v2(spec)
success, message = await model.test_connection()
return {
"spec": spec,
"status": "available" if success else "unavailable",
"message": "连接正常" if success else message,
"model_type": "embedding",
}
# chat 或其他类型走 chat 测试
from yuxi.models.chat import select_model_v2
model = select_model_v2(spec)
test_messages = [{"role": "user", "content": "Say 1"}]
response = await model.call(test_messages, stream=False)
if response and response.content:
return {"spec": spec, "status": "available", "message": "连接正常", "model_type": "chat"}
return {"spec": spec, "status": "unavailable", "message": "响应无效", "model_type": "chat"}
# V1 兼容:尝试旧的模型选择逻辑
from yuxi.models.chat import select_model
try:
model = select_model(model_spec=spec)
test_messages = [{"role": "user", "content": "Say 1"}]
response = await model.call(test_messages, stream=False)
if response and response.content:
return {"spec": spec, "status": "available", "message": "连接正常"}
return {"spec": spec, "status": "unavailable", "message": "响应无效"}
except Exception as e:
return {"spec": spec, "status": "error", "message": str(e)}

View File

@ -202,6 +202,8 @@ class PostgresManager(metaclass=SingletonMeta):
provider_type VARCHAR(32) NOT NULL DEFAULT 'openai',
default_protocol VARCHAR(64),
base_url VARCHAR(500) NOT NULL,
embedding_base_url VARCHAR(500),
rerank_base_url VARCHAR(500),
models_endpoint VARCHAR(200),
embedding_models_endpoint VARCHAR(200),
rerank_models_endpoint VARCHAR(200),
@ -242,29 +244,6 @@ class PostgresManager(metaclass=SingletonMeta):
"CREATE INDEX IF NOT EXISTS ix_conversations_is_pinned ON conversations(is_pinned)",
"CREATE UNIQUE INDEX IF NOT EXISTS ix_model_providers_provider_id ON model_providers(provider_id)",
"CREATE INDEX IF NOT EXISTS ix_model_providers_is_enabled ON model_providers(is_enabled)",
"ALTER TABLE IF EXISTS model_providers ALTER COLUMN provider_type SET DEFAULT 'openai'",
"ALTER TABLE IF EXISTS model_providers ADD COLUMN IF NOT EXISTS models_endpoint VARCHAR(200)",
"ALTER TABLE IF EXISTS model_providers ADD COLUMN IF NOT EXISTS embedding_models_endpoint VARCHAR(200)",
"ALTER TABLE IF EXISTS model_providers ADD COLUMN IF NOT EXISTS rerank_models_endpoint VARCHAR(200)",
"ALTER TABLE IF EXISTS model_providers ADD COLUMN IF NOT EXISTS embedding_base_url VARCHAR(500)",
"ALTER TABLE IF EXISTS model_providers ADD COLUMN IF NOT EXISTS rerank_base_url VARCHAR(500)",
"ALTER TABLE IF EXISTS model_providers ALTER COLUMN models_endpoint SET DEFAULT '/models'",
(
"ALTER TABLE IF EXISTS model_providers "
"ALTER COLUMN embedding_models_endpoint SET DEFAULT '/embeddings/models'"
),
(
"UPDATE model_providers SET models_endpoint = '/models' "
"WHERE models_endpoint IS NULL OR models_endpoint = ''"
),
(
"UPDATE model_providers SET embedding_models_endpoint = '/embeddings/models' "
"WHERE embedding_models_endpoint IS NULL "
"OR embedding_models_endpoint = '' "
"OR embedding_models_endpoint = '/embedding/models'"
),
"ALTER TABLE IF EXISTS model_providers DROP COLUMN IF EXISTS model_cache",
"ALTER TABLE IF EXISTS model_providers DROP COLUMN IF EXISTS sync_source",
]
async with self.async_engine.begin() as conn:
for stmt in stmts:

View File

@ -568,8 +568,8 @@ class ModelProvider(Base):
"base_url": self.base_url,
"embedding_base_url": self.embedding_base_url,
"rerank_base_url": self.rerank_base_url,
"models_endpoint": self.models_endpoint or "/models",
"embedding_models_endpoint": self.embedding_models_endpoint or "/embeddings/models",
"models_endpoint": self.models_endpoint,
"embedding_models_endpoint": self.embedding_models_endpoint,
"rerank_models_endpoint": self.rerank_models_endpoint,
"api_key_env": self.api_key_env,
"api_key": self.api_key,

View File

@ -9,12 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from server.utils.auth_middleware import get_admin_user, get_db
from yuxi.services.model_provider_service import (
_check_credential_status,
check_credential_status,
create_provider_config,
delete_provider_config,
fetch_remote_models,
get_all_model_providers,
get_model_provider_by_id,
test_model_status_by_spec,
update_provider_config,
)
from yuxi.storage.postgres.models_business import User
@ -68,7 +69,7 @@ async def list_providers(
data = []
for p in providers:
d = p.to_dict()
d["credential_status"] = _check_credential_status(p)
d["credential_status"] = check_credential_status(p)
data.append(d)
return {"success": True, "data": data}
@ -106,7 +107,7 @@ async def get_provider(
if provider is None:
raise HTTPException(status_code=404, detail=f"供应商 {provider_id} 不存在")
data = provider.to_dict()
data["credential_status"] = _check_credential_status(provider)
data["credential_status"] = check_credential_status(provider)
return {"success": True, "data": data}
@ -234,23 +235,10 @@ async def get_model_status_by_spec(
spec: str,
current_user: User = Depends(get_admin_user),
):
"""根据 full spec 检查模型状态(自动识别 V1/V2、Chat/Embedding
V2 spec 格式: provider_id:model_id冒号分隔
优先从缓存获取模型类型根据类型分派到对应的测试函数
"""
from yuxi.models.chat import test_chat_model_status_by_spec
# 尝试从缓存获取模型类型,区分 Chat 和 Embedding
if ":" in spec:
from yuxi.services.model_cache import model_cache
info = model_cache.get_model_info(spec)
if info and info.model_type == "embedding":
from yuxi.models.embed import test_embedding_model_status_by_spec
result = await test_embedding_model_status_by_spec(spec)
return {"success": True, "data": result}
result = await test_chat_model_status_by_spec(spec)
return {"success": True, "data": result}
"""根据 full spec 检查模型状态(自动识别 V1/V2、Chat/Embedding"""
try:
result = await test_model_status_by_spec(spec)
return {"success": True, "data": result}
except Exception as e:
logger.error(f"测试模型状态失败 {spec}: {e}")
return {"success": False, "data": {"spec": spec, "status": "error", "message": str(e)}}

View File

@ -1,3 +1,4 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
@ -59,8 +60,6 @@ async def lifespan(app: FastAPI):
raise
# 初始化知识库管理器
import os
if os.environ.get("LITE_MODE", "").lower() in ("true", "1"):
logger.info("LITE_MODE enabled, skipping knowledge base initialization")
else:

View File

@ -4,11 +4,9 @@ import pytest
os.environ.setdefault("OPENAI_API_KEY", "test-key")
from yuxi.config.builtin_providers import BUILTIN_PROVIDERS
from yuxi.services.model_provider_service import (
_DEFAULT_BUILTIN_PROVIDERS,
DEFAULT_EMBEDDING_MODELS_ENDPOINT,
DEFAULT_MODELS_ENDPOINT,
_check_credential_status,
check_credential_status,
_normalize_payload,
_normalize_remote_model,
fetch_remote_models,
@ -27,8 +25,8 @@ def test_normalize_payload_accepts_enabled_chat_model():
assert payload["provider_id"] == "openrouter-local"
assert payload["provider_type"] == "openai"
assert payload["models_endpoint"] == DEFAULT_MODELS_ENDPOINT
assert payload["embedding_models_endpoint"] == DEFAULT_EMBEDDING_MODELS_ENDPOINT
assert "models_endpoint" not in payload
assert "embedding_models_endpoint" not in payload
assert payload["enabled_models"][0]["display_name"] == "anthropic/claude-sonnet-4.5"
@ -44,16 +42,20 @@ def test_normalize_payload_rejects_unknown_enabled_model_type():
)
def test_normalize_payload_requires_embedding_dimension():
with pytest.raises(ValueError, match="dimension"):
_normalize_payload(
{
"provider_id": "embedding-local",
"display_name": "Embedding Local",
"base_url": "https://example.com/v1",
"enabled_models": [{"id": "text-embedding", "type": "embedding"}],
}
)
def test_normalize_payload_allows_embedding_without_dimension():
"""embedding 模型的 dimension 是可选字段,不提供也不会报错。"""
payload = _normalize_payload(
{
"provider_id": "embedding-local",
"display_name": "Embedding Local",
"base_url": "https://example.com/v1",
"capabilities": ["embedding"],
"embedding_base_url": "https://example.com/v1/embeddings",
"enabled_models": [{"id": "text-embedding", "type": "embedding"}],
}
)
assert payload["provider_id"] == "embedding-local"
assert payload["enabled_models"][0].get("dimension") is None
def test_normalize_remote_model_preserves_detailed_model_config():
@ -113,7 +115,7 @@ async def test_fetch_remote_models_loads_embedding_only_when_capability_enabled(
def test_builtin_provider_templates_default_to_openai_provider_type():
assert len(_DEFAULT_BUILTIN_PROVIDERS) >= 20
assert len(BUILTIN_PROVIDERS) >= 16
provider_types = {
_normalize_payload(
{
@ -123,13 +125,13 @@ def test_builtin_provider_templates_default_to_openai_provider_type():
"provider_type": provider.get("provider_type"),
}
)["provider_type"]
for provider in _DEFAULT_BUILTIN_PROVIDERS
for provider in BUILTIN_PROVIDERS
}
assert provider_types == {"openai"}
def test_builtin_siliconflow_provider_includes_default_runnable_models():
provider = next(item for item in _DEFAULT_BUILTIN_PROVIDERS if item["provider_id"] == "siliconflow-cn")
provider = next(item for item in BUILTIN_PROVIDERS if item["provider_id"] == "siliconflow-cn")
models = {model["id"]: model for model in provider["enabled_models"]}
assert provider["capabilities"] == ["chat", "embedding", "rerank"]
@ -142,27 +144,27 @@ def test_builtin_siliconflow_provider_includes_default_runnable_models():
assert "base_url_override" not in models["Pro/BAAI/bge-reranker-v2-m3"]
def test_check_credential_status_disabled_provider_always_ok():
def testcheck_credential_status_disabled_provider_always_ok():
"""未启用的 provider 无论凭证如何配置,状态始终为 ok。"""
class Provider:
is_enabled = False
api_key = None
api_key_env = None
assert _check_credential_status(Provider()) == "ok"
assert check_credential_status(Provider()) == "ok"
def test_check_credential_status_direct_api_key_ok():
def testcheck_credential_status_direct_api_key_ok():
"""直接配置了 api_key 的启用 provider 状态为 ok。"""
class Provider:
is_enabled = True
api_key = "sk-test"
api_key_env = None
assert _check_credential_status(Provider()) == "ok"
assert check_credential_status(Provider()) == "ok"
def test_check_credential_status_env_key_exists_ok(monkeypatch):
def testcheck_credential_status_env_key_exists_ok(monkeypatch):
"""api_key_env 对应的环境变量存在时状态为 ok。"""
monkeypatch.setenv("TEST_API_KEY", "exists")
@ -171,10 +173,10 @@ def test_check_credential_status_env_key_exists_ok(monkeypatch):
api_key = None
api_key_env = "TEST_API_KEY"
assert _check_credential_status(Provider()) == "ok"
assert check_credential_status(Provider()) == "ok"
def test_check_credential_status_env_key_missing_warning(monkeypatch):
def testcheck_credential_status_env_key_missing_warning(monkeypatch):
"""api_key_env 对应的环境变量不存在时状态为 warning。"""
monkeypatch.delenv("MISSING_KEY", raising=False)
@ -183,17 +185,17 @@ def test_check_credential_status_env_key_missing_warning(monkeypatch):
api_key = None
api_key_env = "MISSING_KEY"
assert _check_credential_status(Provider()) == "warning"
assert check_credential_status(Provider()) == "warning"
def test_check_credential_status_both_empty_warning():
def testcheck_credential_status_both_empty_warning():
"""api_key 和 api_key_env 都未配置时状态为 warning。"""
class Provider:
is_enabled = True
api_key = None
api_key_env = None
assert _check_credential_status(Provider()) == "warning"
assert check_credential_status(Provider()) == "warning"
# ==================== 手动添加模型 / source 字段 ====================

View File

@ -142,7 +142,7 @@
--min-width: 400px;
/* Page Padding - 响应式页面内边距 */
--page-padding: 20px;
--page-padding: 22px;
/* Ant Design 兼容变量 */
--color-bg-container: var(--main-0);
@ -189,18 +189,18 @@
/* Page Padding 响应式断点 */
@media (min-width: 1440px) {
:root {
--page-padding: 24px;
--page-padding: 30px;
}
}
@media (max-width: 1199px) {
:root {
--page-padding: 16px;
--page-padding: 18px;
}
}
@media (max-width: 767px) {
:root {
--page-padding: 12px;
--page-padding: 14px;
}
}

View File

@ -69,6 +69,7 @@ import { useConfigStore } from '@/stores/config'
import { embeddingApi } from '@/apis/knowledge_api'
import { modelProviderApi } from '@/apis/system_api'
import { message } from 'ant-design-vue'
import { useModelStatus } from '@/composables/useModelStatus'
const configStore = useConfigStore()
@ -99,9 +100,9 @@ const props = defineProps({
const emit = defineEmits(['update:value', 'change'])
const v2Models = ref({})
const v1ModelStatuses = reactive({})
const { statusMap: v2ModelStatuses, getStatusIcon: getV2StatusIcon, getStatusClass: getV2StatusClass, getStatusTooltip: getV2StatusTooltip, checkV2Status, checkV2Statuses } = useModelStatus()
const state = reactive({
v1ModelStatuses: {},
v2ModelStatuses: {},
checkingStatus: false
})
@ -140,20 +141,7 @@ const checkV2ModelStatuses = async () => {
state.checkingStatus = true
try {
for (const providerData of Object.values(v2Models.value)) {
for (const model of providerData.models || []) {
try {
const response = await modelProviderApi.getModelStatusBySpec(model.spec)
if (response.data) {
state.v2ModelStatuses[model.spec] = response.data
}
} catch {
state.v2ModelStatuses[model.spec] = {
spec: model.spec,
status: 'error',
message: '检查失败'
}
}
}
await checkV2Statuses(providerData.models || [])
}
} catch (error) {
console.error('检查 V2 模型状态失败:', error)
@ -166,7 +154,7 @@ const checkV1ModelStatuses = async () => {
try {
const response = await embeddingApi.getAllModelsStatus()
if (response.status.models) {
state.v1ModelStatuses = response.status.models
Object.assign(v1ModelStatuses, response.status.models)
}
} catch (error) {
console.error('检查 V1 模型状态失败:', error)
@ -176,7 +164,7 @@ const checkV1ModelStatuses = async () => {
// V1
const getV1StatusIcon = (modelId) => {
const status = state.v1ModelStatuses[modelId]
const status = v1ModelStatuses[modelId]
if (!status) return '○'
if (status.status === 'available') return '✓'
if (status.status === 'unavailable') return '✗'
@ -185,35 +173,12 @@ const getV1StatusIcon = (modelId) => {
}
const getV1StatusClass = (modelId) => {
const status = state.v1ModelStatuses[modelId]
const status = v1ModelStatuses[modelId]
return status?.status || ''
}
const getV1StatusTooltip = (modelId) => {
const status = state.v1ModelStatuses[modelId]
if (!status) return '状态未知'
let statusText =
{ available: '可用', unavailable: '不可用', error: '错误' }[status.status] || '未知'
return `${statusText}: ${status.message || '无详细信息'}`
}
// V2
const getV2StatusIcon = (spec) => {
const status = state.v2ModelStatuses[spec]
if (!status) return '○'
if (status.status === 'available') return '✓'
if (status.status === 'unavailable') return '✗'
if (status.status === 'error') return '⚠'
return '○'
}
const getV2StatusClass = (spec) => {
const status = state.v2ModelStatuses[spec]
return status?.status || ''
}
const getV2StatusTooltip = (spec) => {
const status = state.v2ModelStatuses[spec]
const status = v1ModelStatuses[modelId]
if (!status) return '状态未知'
let statusText =
{ available: '可用', unavailable: '不可用', error: '错误' }[status.status] || '未知'

View File

@ -64,6 +64,7 @@
import { computed, reactive, ref } from 'vue'
import { modelProviderApi } from '@/apis/system_api'
import { RefreshCw } from 'lucide-vue-next'
import { useModelStatus } from '@/composables/useModelStatus'
const props = defineProps({
model_spec: {
@ -124,10 +125,11 @@ const refreshCache = async () => {
}
//
const { statusMap: modelStatusMap, getStatusIcon, getStatusTooltip } = useModelStatus()
const state = reactive({
currentModelStatus: null, //
checkingStatus: false, //
refreshingCache: false //
currentModelStatus: null,
checkingStatus: false,
refreshingCache: false
})
const resolvedSize = computed(() => props.size || 'small')
@ -143,8 +145,6 @@ const buttonSize = computed(() => {
const displayModelText = computed(() => props.model_spec || props.placeholder)
//
//
const checkCurrentModelStatus = async () => {
const spec = props.model_spec
@ -175,7 +175,6 @@ const modelStatusIcon = computed(() => {
return '○'
})
//
const getCurrentModelStatusTooltip = () => {
const status = state.currentModelStatus
if (!status) return '状态未知'

View File

@ -0,0 +1,48 @@
import { reactive } from 'vue'
import { modelProviderApi } from '@/apis/system_api'
/**
* 模型状态检查 composable Chat/Embedding/Rerank 模型选择器共用
*/
export function useModelStatus() {
const statusMap = reactive({})
const getStatusIcon = (key) => {
const status = statusMap[key]
if (!status) return '○'
if (status.status === 'available') return '✓'
if (status.status === 'unavailable') return '✗'
if (status.status === 'error') return '⚠'
return '○'
}
const getStatusClass = (key) => {
return statusMap[key]?.status || ''
}
const getStatusTooltip = (key) => {
const status = statusMap[key]
if (!status) return '状态未知'
const text = { available: '可用', unavailable: '不可用', error: '错误' }[status.status] || '未知'
return `${text}: ${status.message || '无详细信息'}`
}
const checkV2Status = async (spec) => {
try {
const response = await modelProviderApi.getModelStatusBySpec(spec)
if (response.data) {
statusMap[spec] = response.data
}
} catch {
statusMap[spec] = { spec, status: 'error', message: '检查失败' }
}
}
const checkV2Statuses = async (models) => {
for (const model of models || []) {
await checkV2Status(model.spec)
}
}
return { statusMap, getStatusIcon, getStatusClass, getStatusTooltip, checkV2Status, checkV2Statuses }
}

View File

@ -29,4 +29,4 @@ export const modelIcons = {
zhipu: `${ICON_BASE}/zhipu-color.svg`,
zhipuai: `${ICON_BASE}/zhipu-color.svg`,
'zhipuai-coding-plan': `${ICON_BASE}/zhipu-color.svg`
} // ESLint-disable-line
} // eslint-disable-line

View File

@ -47,11 +47,11 @@ const providerForm = reactive({
// Model form state
const showModelModal = ref(false)
const isCreating = ref(false) // true=false=
const editingModel = reactive({
const editingModel = ref({
id: '',
display_name: '',
type: 'chat',
source: 'remote', // 'manual'|'remote' stale
source: 'remote',
protocol_override: null,
base_url_override: null,
context_length: null,
@ -445,7 +445,7 @@ const addModelFromRemote = async (providerId, remoteModel) => {
}
const openModelConfigModal = (model) => {
Object.assign(editingModel, normalizeModel(model))
Object.assign(editingModel.value, normalizeModel(model))
isCreating.value = false
showModelModal.value = true
}
@ -455,7 +455,7 @@ const openCreateModal = (provider) => {
if (!provider) return
const types = provider.capabilities?.length ? provider.capabilities : ['chat']
const defaultType = types[0]
Object.assign(editingModel, {
Object.assign(editingModel.value, {
id: '',
display_name: '',
type: defaultType,
@ -483,7 +483,7 @@ const saveModelConfig = async () => {
let enabledModels
if (isCreating.value) {
const newId = (editingModel.id || '').trim()
const newId = (editingModel.value.id || '').trim()
if (!newId) {
message.error('请填写模型 ID')
return
@ -492,11 +492,11 @@ const saveModelConfig = async () => {
message.error('模型 ID 已存在')
return
}
const newModel = { ...editingModel, id: newId, source: 'manual', enabled: true }
const newModel = { ...editingModel.value, id: newId, source: 'manual', enabled: true }
enabledModels = [...(provider.enabled_models || []), newModel]
} else {
enabledModels = (provider.enabled_models || []).map((m) =>
m.id === editingModel.id ? { ...editingModel } : m
m.id === editingModel.value.id ? { ...editingModel.value } : m
)
}
@ -554,7 +554,6 @@ onMounted(loadProviders)
<!-- Page Header -->
<div class="page-header">
<div>
<p class="eyebrow">Model Registry</p>
<h1>模型配置</h1>
</div>
<div class="summary-strip">
@ -1075,26 +1074,17 @@ onMounted(loadProviders)
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px 32px 18px;
padding: 28px var(--page-padding) 18px;
border-bottom: 1px solid var(--gray-100);
h1 {
margin: 0;
font-size: 28px;
font-size: 24px;
font-weight: 720;
line-height: 34px;
}
}
.eyebrow {
margin: 0 0 4px;
color: var(--main-700);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.summary-strip {
display: flex;
gap: 8px;
@ -1122,8 +1112,7 @@ onMounted(loadProviders)
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 32px;
border-bottom: 1px solid var(--gray-100);
padding: 16px var(--page-padding) 0;
}
.search-input {
@ -1154,7 +1143,7 @@ onMounted(loadProviders)
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
padding: 24px 32px;
padding: 16px var(--page-padding);
}
// ============ Provider Card ============