feat(model): 增强模型配置功能,支持手动添加模型及源字段,优化远端模型加载逻辑

This commit is contained in:
Wenjie Zhang 2026-04-26 15:03:20 +08:00
parent 129f8860c9
commit db13abaa63
7 changed files with 396 additions and 73 deletions

View File

@ -17,10 +17,11 @@ from yuxi.repositories.model_provider_repository import (
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 = "/models"
DEFAULT_EMBEDDING_MODELS_ENDPOINT = "/embeddings/models"
DEFAULT_MODELS_ENDPOINT = ""
DEFAULT_EMBEDDING_MODELS_ENDPOINT = ""
_DEFAULT_BUILTIN_PROVIDERS: list[dict[str, Any]] = [
{
@ -28,117 +29,140 @@ _DEFAULT_BUILTIN_PROVIDERS: list[dict[str, Any]] = [
"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/anthropic",
"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/anthropic",
"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",
@ -148,9 +172,9 @@ _DEFAULT_BUILTIN_PROVIDERS: list[dict[str, Any]] = [
"rerank_base_url": "https://api.siliconflow.cn/v1/rerank",
"api_key_env": "SILICONFLOW_API_KEY",
"capabilities": ["chat", "embedding", "rerank"],
"models_endpoint": "/models?sub_type=chat",
"embedding_models_endpoint": "/models?sub_type=embedding",
"rerank_models_endpoint": "/models?sub_type=reranker",
"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"},
@ -195,9 +219,9 @@ _DEFAULT_BUILTIN_PROVIDERS: list[dict[str, Any]] = [
"rerank_base_url": "https://api.siliconflow.com/v1/rerank",
"api_key_env": "SILICONFLOW_API_KEY",
"capabilities": ["chat", "embedding", "rerank"],
"models_endpoint": "/models?sub_type=chat",
"embedding_models_endpoint": "/models?sub_type=embedding",
"rerank_models_endpoint": "/models?sub_type=reranker",
"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"},
@ -260,9 +284,15 @@ def _normalize_model_item(model: dict[str, Any]) -> dict[str, Any]:
if model_type not in VALID_MODEL_TYPES:
raise ValueError(f"启用模型 {model_id} 的 type 必须是 chat、embedding 或 rerank")
# source 区分手动添加 vs 远端拉取,用于跳过远端清单存在性的视觉警告。
source = str(model.get("source") or "remote").strip()
if source not in VALID_MODEL_SOURCES:
raise ValueError(f"模型 {model_id} 的 source 必须是 manual 或 remote")
normalized = dict(model)
normalized["id"] = model_id
normalized["type"] = model_type
normalized["source"] = source
normalized["display_name"] = str(model.get("display_name") or model.get("name") or model_id)
normalized["extra"] = _normalize_dict(model.get("extra"))
@ -290,6 +320,15 @@ def _normalize_model_list(models: Any) -> list[dict[str, Any]]:
return normalized_models
def _validate_models_capabilities(enabled_models: list[dict], capabilities: set[str]) -> None:
"""校验 enabled_models 中所有模型的 type 都在 provider capabilities 范围内。"""
for model in enabled_models or []:
if model["type"] not in capabilities:
raise ValueError(
f"模型 {model['id']} 的 type={model['type']} 不在 provider 能力 {sorted(capabilities)}"
)
def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[str, Any]:
payload = dict(data)
if not partial or "provider_id" in payload:
@ -339,8 +378,8 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
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 not embedding_endpoint:
raise ValueError("embedding provider 必须配置 embedding_models_endpoint")
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()
@ -348,8 +387,8 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
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 not rerank_endpoint:
raise ValueError("rerank provider 必须配置 rerank_models_endpoint")
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:
@ -377,6 +416,14 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
elif not partial:
payload["is_builtin"] = False
# 仅当本次 payload 同时携带 capabilities 与 enabled_models 时做一致性校验,
# 防止前端把超出 provider.capabilities 的模型 type 写入。
# partial 模式下若只更新其中一项跳过校验避免误判DB 已有值不可见)。
if "capabilities" in payload and "enabled_models" in payload:
capabilities_set = set(payload.get("capabilities") or [])
if capabilities_set:
_validate_models_capabilities(payload.get("enabled_models"), capabilities_set)
return payload
@ -470,10 +517,6 @@ async def ensure_builtin_model_providers_in_db(db: AsyncSession) -> None:
payload = {key: value for key, value in provider_def.items() if value is not None}
payload["enabled_models"] = provider_def.get("enabled_models", [])
payload["models_endpoint"] = payload.get("models_endpoint") or DEFAULT_MODELS_ENDPOINT
payload["embedding_models_endpoint"] = (
payload.get("embedding_models_endpoint") or DEFAULT_EMBEDDING_MODELS_ENDPOINT
)
payload["headers_json"] = payload.get("headers_json") or {}
payload["extra_json"] = payload.get("extra_json") or {}
payload["is_enabled"] = False
@ -504,6 +547,11 @@ async def update_provider_config(
if provider is None:
return None
payload = _normalize_payload(data, partial=True)
# partial 更新时仅传 enabled_models结合 DB 中现有 capabilities 校验
if "enabled_models" in payload and "capabilities" not in payload:
existing_caps = set(provider.capabilities or [])
if existing_caps:
_validate_models_capabilities(payload.get("enabled_models"), existing_caps)
payload["updated_by"] = username
return await update_model_provider(db, provider, payload)

View File

@ -180,12 +180,21 @@ async def get_remote_models(
detail = e.response.text
if e.response.status_code == 401:
raise HTTPException(status_code=502, detail="远端 API 认证失败,请检查 API Key 配置")
raise HTTPException(status_code=e.response.status_code, detail=f"远端 /models 请求失败: {detail}")
raise HTTPException(status_code=e.response.status_code, detail=f"Models 请求失败: {detail}")
except Exception as e:
logger.error(f"拉取远端模型失败 {provider_id}: {e}")
raise HTTPException(status_code=400, detail=f"拉取远端模型失败: {e}")
@model_providers.post("/models/cache/refresh")
async def refresh_model_cache(
current_user: User = Depends(get_admin_user),
):
"""强制刷新模型缓存,从数据库重新加载所有供应商配置到 Redis。"""
await _refresh_model_cache()
return {"success": True, "message": "缓存已刷新", "model_count": len(model_cache.get_all_specs())}
@model_providers.get("/models/v2")
async def get_v2_models(
model_type: str = "chat",

View File

@ -194,3 +194,96 @@ def test_check_credential_status_both_empty_warning():
api_key_env = None
assert _check_credential_status(Provider()) == "warning"
# ==================== 手动添加模型 / source 字段 ====================
def test_normalize_payload_default_model_source_is_remote():
"""未显式指定 source 时,规范化后默认填入 remote向后兼容旧数据。"""
payload = _normalize_payload(
{
"provider_id": "openrouter-local",
"display_name": "OpenRouter Local",
"base_url": "https://openrouter.ai/api/v1",
"enabled_models": [{"id": "anthropic/claude-sonnet-4.5", "type": "chat"}],
}
)
assert payload["enabled_models"][0]["source"] == "remote"
def test_normalize_payload_accepts_manual_source():
"""source=manual 表示管理员手动添加的模型,规范化保留该标签。"""
payload = _normalize_payload(
{
"provider_id": "custom-local",
"display_name": "Custom Local",
"base_url": "https://example.com/v1",
"capabilities": ["chat"],
"enabled_models": [
{"id": "my-chat-model", "type": "chat", "source": "manual"}
],
}
)
assert payload["enabled_models"][0]["source"] == "manual"
def test_normalize_payload_rejects_invalid_source():
"""source 仅允许 manual 或 remote其他取值视为非法。"""
with pytest.raises(ValueError, match="source 必须是"):
_normalize_payload(
{
"provider_id": "custom-local",
"display_name": "Custom Local",
"base_url": "https://example.com/v1",
"enabled_models": [
{"id": "x", "type": "chat", "source": "custom"}
],
}
)
def test_normalize_payload_rejects_model_type_not_in_capabilities():
"""provider 仅声明 chat 能力时,不允许写入 embedding 类型的模型。"""
with pytest.raises(ValueError, match="不在 provider 能力"):
_normalize_payload(
{
"provider_id": "chat-only",
"display_name": "Chat Only",
"base_url": "https://example.com/v1",
"capabilities": ["chat"],
"enabled_models": [
{"id": "rogue-embedding", "type": "embedding", "dimension": 1024}
],
}
)
def test_normalize_payload_allows_model_type_within_capabilities():
"""provider 同时声明 chat + embedding 时,两类模型均可正常写入。"""
payload = _normalize_payload(
{
"provider_id": "multi-cap",
"display_name": "Multi Cap",
"base_url": "https://example.com/v1",
"capabilities": ["chat", "embedding"],
"embedding_base_url": "https://example.com/v1/embeddings",
"embedding_models_endpoint": "/embeddings/models",
"enabled_models": [
{"id": "chat-1", "type": "chat", "source": "manual"},
{
"id": "embed-1",
"type": "embedding",
"source": "manual",
"dimension": 1024,
},
],
}
)
types = [model["type"] for model in payload["enabled_models"]]
sources = [model["source"] for model in payload["enabled_models"]]
assert types == ["chat", "embedding"]
assert sources == ["manual", "manual"]

View File

@ -34,7 +34,7 @@
<!-- 0.6.2 的内容请放在这里 -->
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开避免空白区域误触发。
- 合并智能体对话导航:移除 `AgentChatComponent` 内部聊天侧边栏,将新建对话入口和对话历史移动到 `AppLayout` 主侧边栏,并通过共享线程 store 统一管理历史列表、当前线程、重命名、删除、置顶和分页加载。
- 新增独立模型配置模块:增加 `model_providers` 表、独立管理接口和”模型配置”页面,支持 provider 基础信息、可配置模型列表端点、远端候选模型、`enabled_models` 的早期配置验证;启动时会补齐内置 provider 模板,`provider_type` 暂统一默认为 `openai`,该模块暂不接入现有运行时模型选择逻辑。远端模型加载默认使用 `/models` 获取 chat/通用模型provider 声明 `embedding` 能力时使用 `/embeddings/models` 获取 embedding 候选rerank 模型列表端点按供应商文档显式配置后加载;修复路由请求模型未接收 `embedding_base_url`/`rerank_base_url` 导致前端已填写仍被后端校验拦截的问题。
- 新增独立模型配置模块:增加 `model_providers` 表、独立管理接口和”模型配置”页面,支持 provider 基础信息、可配置模型列表端点、远端候选模型、`enabled_models` 的早期配置验证;启动时会补齐内置 provider 模板,`provider_type` 暂统一默认为 `openai`,该模块暂不接入现有运行时模型选择逻辑。远端模型加载默认使用 `/models` 获取 chat/通用模型provider 声明 `embedding` 能力时使用 `/embeddings/models` 获取 embedding 候选rerank 模型列表端点按供应商文档显式配置后加载;修复路由请求模型未接收 `embedding_base_url`/`rerank_base_url` 导致前端已填写仍被后端校验拦截的问题。补充手动添加模型能力:`enabled_models[i]` 新增可选 `source: "manual"|"remote"` 字段(默认 `remote`),管理员可通过”+ 手动添加”入口录入远端清单未覆盖的模型(典型:自部署 embedding/rerank手动模型在前端跳过”远端不存在”的 stale 警告并显示「手动」标签type 选项受 `provider.capabilities` 约束,后端在 `_normalize_payload``update_provider_config` 双层一致性校验中拦截越权写入。
---

View File

@ -165,6 +165,10 @@ export const modelProviderApi = {
return apiAdminGet('/api/system/model-providers/models/cache-status')
},
refreshModelCache: async () => {
return apiAdminPost('/api/system/model-providers/models/cache/refresh')
},
getModelStatusBySpec: async (spec) => {
return apiAdminGet(`/api/system/model-providers/models/status?spec=${encodeURIComponent(spec)}`)
},

View File

@ -1,5 +1,5 @@
<template>
<a-dropdown trigger="click">
<a-dropdown trigger="click" @open-change="handleOpenChange">
<div class="model-select" :class="modelSelectClasses" @click.prevent>
<div class="model-select-content">
<div class="model-info">
@ -16,6 +16,19 @@
>
{{ modelStatusIcon }}
</span>
<a-tooltip title="刷新缓存">
<a-button
type="text"
:loading="state.refreshingCache"
@click.stop="refreshCache"
:disabled="state.refreshingCache"
class="cache-refresh-button"
>
<template #icon>
<RefreshCw :size="13" :class="{ 'spin': state.refreshingCache }" />
</template>
</a-button>
</a-tooltip>
<a-button
:size="buttonSize"
type="text"
@ -66,9 +79,10 @@
</template>
<script setup>
import { computed, reactive, ref, onMounted } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useConfigStore } from '@/stores/config'
import { modelProviderApi } from '@/apis/system_api'
import { RefreshCw } from 'lucide-vue-next'
const props = defineProps({
model_spec: {
@ -89,11 +103,14 @@ const props = defineProps({
const configStore = useConfigStore()
const emit = defineEmits(['select-model'])
// v2
// v2
const v2Models = ref({})
const loadingV2Models = ref(false)
// v2
onMounted(async () => {
// v2
const fetchV2Models = async () => {
if (loadingV2Models.value) return
loadingV2Models.value = true
try {
const response = await modelProviderApi.getV2Models('chat')
if (response.success) {
@ -101,13 +118,36 @@ onMounted(async () => {
}
} catch (error) {
console.warn('Failed to load v2 models:', error)
} finally {
loadingV2Models.value = false
}
})
}
//
const handleOpenChange = (open) => {
if (open) fetchV2Models()
}
//
const refreshCache = async () => {
if (state.refreshingCache) return
state.refreshingCache = true
try {
await modelProviderApi.refreshModelCache()
//
await fetchV2Models()
} catch (error) {
console.error('Failed to refresh cache:', error)
} finally {
state.refreshingCache = false
}
}
//
const state = reactive({
currentModelStatus: null, //
checkingStatus: false //
checkingStatus: false, //
refreshingCache: false //
})
// configStore
@ -291,10 +331,34 @@ const handleSelectV2Model = (spec) => {
font-size: @status-check-button-font-size;
padding: @status-check-button-padding;
}
//
.cache-refresh-button {
font-size: @status-check-button-font-size;
padding: @status-check-button-padding;
display: flex;
align-items: center;
width: 24px;
background-color: transparent;
}
}
}
}
//
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// Provider
.provider-tag {
margin-left: 6px;

View File

@ -46,10 +46,12 @@ const providerForm = reactive({
// Model form state
const showModelModal = ref(false)
const isCreating = ref(false) // true=false=
const editingModel = reactive({
id: '',
display_name: '',
type: 'chat',
source: 'remote', // 'manual'|'remote' stale
protocol_override: null,
base_url_override: null,
context_length: null,
@ -92,12 +94,17 @@ const filteredProviders = computed(() => {
})
})
const providerStats = computed(() => ({
total: providers.value.length,
enabled: providers.value.filter((p) => p.is_enabled).length,
warning: providers.value.filter((p) => p.is_enabled && p.credential_status === 'warning').length,
models: providers.value.reduce((sum, p) => sum + (p.enabled_models?.length || 0), 0)
}))
const providerStats = computed(() => {
let enabled = 0, warning = 0, models = 0
for (const p of providers.value) {
if (p.is_enabled) {
enabled++
if (p.credential_status === 'warning') warning++
}
models += p.enabled_models?.length || 0
}
return { total: providers.value.length, enabled, warning, models }
})
// ============ Helpers ============
const getProviderIcon = (provider) => {
@ -155,11 +162,18 @@ const getInputModalities = (model) => {
return []
}
const remoteIdsMap = computed(() => {
const map = {}
for (const [providerId, models] of Object.entries(remoteModelsMap.value)) {
map[providerId] = new Set(models.map((m) => m.id))
}
return map
})
const isModelStale = (model, providerId) => {
// Only check stale if remote models have been loaded
if (model.source === 'manual') return false
if (!remoteModelsLoaded.value[providerId]) return false
const remoteIds = new Set((remoteModelsMap.value[providerId] || []).map((m) => m.id))
return model.enabled && !remoteIds.has(model.id)
return model.enabled && !remoteIdsMap.value[providerId]?.has(model.id)
}
// Remote models filtered by search query per provider
@ -193,6 +207,14 @@ const remoteModelTypeOptions = computed(() => {
]
})
// Model Config Modal type provider.capabilities
// capabilities 退
const editingModelTypeOptions = computed(() => {
const caps = currentProviderForModels.value?.capabilities
const types = Array.isArray(caps) && caps.length ? caps : ['chat', 'embedding', 'rerank']
return types.map((c) => ({ value: c, label: c }))
})
const parseJsonObject = (text, label) => {
try {
const parsed = JSON.parse(text || '{}')
@ -353,7 +375,8 @@ const openModelsModal = (provider) => {
if (!remoteModelsLoaded.value[provider.provider_id]) {
remoteModelsMap.value[provider.provider_id] = []
}
remoteModelSearch.value[provider.provider_id] = remoteModelSearch.value[provider.provider_id] || ''
remoteModelSearch.value[provider.provider_id] =
remoteModelSearch.value[provider.provider_id] || ''
remoteModelTypeFilter.value[provider.provider_id] = 'all'
showModelsModal.value = true
}
@ -381,6 +404,7 @@ const normalizeModel = (model = {}) => ({
id: model.id || '',
display_name: model.display_name || model.name || model.id || '',
type: model.type && model.type !== 'unknown' ? model.type : 'chat',
source: model.source || 'remote',
protocol_override: model.protocol_override || null,
base_url_override: model.base_url_override || null,
context_length: model.context_length || null,
@ -401,6 +425,7 @@ const addModelFromRemote = async (providerId, remoteModel) => {
}
const newModel = normalizeModel(remoteModel)
newModel.source = 'remote' //
newModel.enabled = true
const newEnabledModels = [...enabledModels, newModel]
@ -418,7 +443,30 @@ const addModelFromRemote = async (providerId, remoteModel) => {
}
const openModelConfigModal = (model) => {
Object.assign(editingModel, { ...model })
Object.assign(editingModel, normalizeModel(model))
isCreating.value = false
showModelModal.value = true
}
// Model Config Modal id type provider
const openCreateModal = (provider) => {
if (!provider) return
const types = provider.capabilities?.length ? provider.capabilities : ['chat']
const defaultType = types[0]
Object.assign(editingModel, {
id: '',
display_name: '',
type: defaultType,
source: 'manual',
protocol_override: null,
base_url_override: null,
context_length: null,
dimension: null,
batch_size: null,
supported_parameters: [],
extra: {}
})
isCreating.value = true
showModelModal.value = true
}
@ -431,15 +479,31 @@ const saveModelConfig = async () => {
)
if (!provider) return
const enabledModels = (provider.enabled_models || []).map((m) =>
m.id === editingModel.id ? { ...editingModel } : m
)
let enabledModels
if (isCreating.value) {
const newId = (editingModel.id || '').trim()
if (!newId) {
message.error('请填写模型 ID')
return
}
if ((provider.enabled_models || []).some((m) => m.id === newId)) {
message.error('模型 ID 已存在')
return
}
const newModel = { ...editingModel, id: newId, source: 'manual', enabled: true }
enabledModels = [...(provider.enabled_models || []), newModel]
} else {
enabledModels = (provider.enabled_models || []).map((m) =>
m.id === editingModel.id ? { ...editingModel } : m
)
}
await modelProviderApi.updateProvider(currentProviderForModels.value.provider_id, {
enabled_models: enabledModels
})
message.success('模型配置已保存')
message.success(isCreating.value ? '模型已添加' : '模型配置已保存')
showModelModal.value = false
isCreating.value = false
await loadProviders()
// Refresh current provider reference
currentProviderForModels.value = providers.value.find(
@ -778,9 +842,30 @@ onMounted(loadProviders)
<div v-if="currentProviderForModels" class="models-modal-content">
<!-- Enabled Models Section -->
<div class="models-section">
<h4 class="models-section-title">
已启用模型 ({{ currentProviderForModels.enabled_models?.length || 0 }})
</h4>
<div class="enabled-header">
<h4 class="models-section-title">
已启用模型 ({{ currentProviderForModels.enabled_models?.length || 0 }})
</h4>
<div class="actions">
<a-button
size="small"
type="primary"
class="lucide-icon-btn"
:loading="remoteLoading"
@click="fetchRemoteModels(currentProviderForModels.provider_id)"
>
获取远程模型
</a-button>
<a-button
size="small"
class="lucide-icon-btn"
@click="openCreateModal(currentProviderForModels)"
>
<Plus :size="14" />
<span>手动添加</span>
</a-button>
</div>
</div>
<div class="models-table" v-if="currentProviderForModels.enabled_models?.length">
<div class="table-head">
<span class="col-name">模型</span>
@ -801,6 +886,12 @@ onMounted(loadProviders)
</div>
<span class="col-type">
<span class="type-tag" :class="model.type">{{ model.type }}</span>
<span
v-if="model.source === 'manual'"
class="type-tag manual"
title="管理员手动添加"
>手动</span
>
</span>
<span class="col-context">{{ formatContextLength(model.context_length) }}</span>
<span class="col-dim">
@ -827,7 +918,7 @@ onMounted(loadProviders)
</span>
</div>
</div>
<a-empty v-else :image="false" description="暂无已启用模型" />
<a-empty v-else description="暂无已启用模型" />
</div>
<!-- Remote Models Section -->
@ -864,10 +955,7 @@ onMounted(loadProviders)
<span class="type-tag" :class="remoteModel.type || 'chat'">
{{ remoteModel.type || 'chat' }}
</span>
<template
v-for="mod in (getInputModalities(remoteModel) || [])"
:key="mod"
>
<template v-for="mod in getInputModalities(remoteModel) || []" :key="mod">
<span class="modality-tag">{{ mod }}</span>
</template>
</div>
@ -902,15 +990,6 @@ onMounted(loadProviders)
</div>
</div>
<div class="remote-fetch-actions">
<a-button
type="primary"
class="lucide-icon-btn"
:loading="remoteLoading"
@click="fetchRemoteModels(currentProviderForModels.provider_id)"
>
<RefreshCw :size="14" v-if="!remoteLoading" />
获取远程模型
</a-button>
</div>
</div>
</div>
@ -919,13 +998,19 @@ onMounted(loadProviders)
<!-- Model Config Modal -->
<a-modal
v-model:open="showModelModal"
title="模型配置"
:title="isCreating ? '手动添加模型' : '模型配置'"
:width="520"
:confirm-loading="saving"
@ok="saveModelConfig"
>
<div class="modal-form">
<div class="model-id-display">
<div v-if="isCreating" class="form-row">
<label class="form-label">
<span>模型 ID <span class="required-mark">*</span></span>
<a-input v-model:value="editingModel.id" placeholder="例如 BAAI/bge-m3" allow-clear />
</label>
</div>
<div v-else class="model-id-display">
<span class="info-label">模型 ID</span>
<code>{{ editingModel.id }}</code>
</div>
@ -937,11 +1022,11 @@ onMounted(loadProviders)
</label>
<label class="form-label">
<span>模型类型</span>
<a-select v-model:value="editingModel.type">
<a-select-option value="chat">chat</a-select-option>
<a-select-option value="embedding">embedding</a-select-option>
<a-select-option value="rerank">rerank</a-select-option>
</a-select>
<a-select
v-model:value="editingModel.type"
:options="editingModelTypeOptions"
:disabled="editingModelTypeOptions.length === 1"
/>
</label>
</div>
@ -1386,6 +1471,10 @@ onMounted(loadProviders)
font-size: 11px;
font-weight: 500;
& + & {
margin-left: 4px;
}
&.chat {
background: var(--color-info-50);
color: var(--color-info-700);
@ -1400,6 +1489,11 @@ onMounted(loadProviders)
background: var(--color-warning-50);
color: var(--color-warning-900);
}
&.manual {
background: var(--gray-200);
color: var(--gray-700);
}
}
// ============ Status Badges ============
@ -1449,6 +1543,24 @@ onMounted(loadProviders)
border-top: 1px solid var(--gray-100);
}
.enabled-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
.models-section-title {
margin: 0;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.remote-header {
display: flex;
align-items: center;
@ -1541,13 +1653,6 @@ onMounted(loadProviders)
padding: 24px;
}
.remote-fetch-actions {
display: flex;
justify-content: flex-start;
padding-top: 8px;
border-top: 1px solid var(--gray-100);
}
// ============ Modal Form ============
.modal-form {
display: flex;