feat(model): 重构嵌入模型选择器,新增 Rerank 模型选择器,优化模型提供者管理

This commit is contained in:
Wenjie Zhang 2026-04-26 18:51:38 +08:00
parent f8acc48184
commit e3d312059a
27 changed files with 713 additions and 355 deletions

View File

@ -80,10 +80,7 @@ def load_chat_model(fully_specified_name: str, **kwargs) -> BaseChatModel:
f"Available chat models ({len(available_specs)}): {available_ids}"
)
logger.warning(
f"旧版本的模型选择逻辑已废弃,建议尽快迁移至新的模型配置;"
f"当前模型选择参数: {fully_specified_name=}"
)
logger.warning(f"旧版本的模型选择逻辑已废弃,建议尽快迁移至新的模型配置;当前模型选择参数: {fully_specified_name=}")
# v1 逻辑spec 必须包含 /
if "/" not in fully_specified_name:

View File

@ -14,7 +14,7 @@ from yuxi import config
from yuxi.knowledge.base import FileStatus, KnowledgeBase
from yuxi.knowledge.chunking.ragflow_like.dispatcher import chunk_markdown
from yuxi.knowledge.chunking.ragflow_like.presets import resolve_chunk_processing_params
from yuxi.knowledge.utils.kb_utils import get_embedding_config
from yuxi.models.embed import get_embedding_model_info_by_id
from yuxi.plugins.parser.unified import Parser
from yuxi.utils import hashstr, logger
from yuxi.utils.datetime_utils import utc_isoformat
@ -262,7 +262,7 @@ class LightRagKB(KnowledgeBase):
def _get_embedding_func(self, embed_info: dict):
"""获取 embedding 函数"""
config_dict = get_embedding_config(embed_info)
config_dict = get_embedding_model_info_by_id(embed_info["model_id"])
logger.debug(f"Embedding config dict: {config_dict}")
if config_dict.get("model_id") and config_dict["model_id"].startswith("ollama"):

View File

@ -355,7 +355,7 @@ def merge_processing_params(metadata_params: dict | None, request_params: dict |
def get_embedding_config(embed_info: dict) -> dict:
"""
获取嵌入模型配置
获取嵌入模型配置兼容性入口新代码请直接使用 get_embedding_model_info_by_id
Args:
embed_info: 嵌入信息字典
@ -364,13 +364,12 @@ def get_embedding_config(embed_info: dict) -> dict:
dict: 标准化的嵌入配置
"""
try:
# 使用最新配置
assert isinstance(embed_info, dict), f"embed_info must be a dict, got {type(embed_info)}"
assert "model_id" in embed_info, f"embed_info must contain 'model_id', got {embed_info}"
logger.warning(f"Using model_id: {embed_info['model_id']}")
config_dict = config.embed_model_names[embed_info["model_id"]].model_dump()
config_dict["api_key"] = os.getenv(config_dict["api_key"]) or config_dict["api_key"]
return config_dict
from yuxi.models.embed import get_embedding_model_info_by_id
return get_embedding_model_info_by_id(embed_info["model_id"])
except AssertionError as e:
logger.error(f"AssertionError in get_embedding_config: {e}, embed_info={embed_info}")

View File

@ -1,4 +1,4 @@
from yuxi.models.chat import select_model
from yuxi.models.embed import select_embedding_model
from yuxi.models.embed import get_embedding_model_info_by_id, select_embedding_model
__all__ = ["select_model", "select_embedding_model"]
__all__ = ["select_model", "select_embedding_model", "get_embedding_model_info_by_id"]

View File

@ -276,16 +276,64 @@ async def test_all_embedding_models_status() -> dict:
}
def select_embedding_model(model_id):
provider, model_name = model_id.split("/", 1) if model_id else ("", "")
def get_embedding_model_info_by_id(model_id: str) -> dict:
"""
通过模型ID获取Embedding模型的标准化配置信息统一入口V1/V2 自动识别
V1 格式: provider/model_name "siliconflow/BAAI/bge-m3" config.embed_model_names 查找
V2 格式: provider_id:model_id "siliconflow:BAAI/bge-m3" model_cache 查找
Returns:
dict: 包含 base_urlapi_keynamedimensionmodel_idbatch_size 等字段的配置字典
api_key 已从环境变量解析为实际值
"""
# V2 spec 检测
if isinstance(model_id, str) and ":" in model_id:
from yuxi.services.model_cache import model_cache
info = model_cache.get_model_info(model_id)
if info:
logger.info(f"Loaded v2 embedding model info for {model_id}")
return {
"name": info.display_name,
"dimension": info.dimension,
"base_url": info.base_url,
"api_key": info.api_key,
"model_id": info.spec,
"batch_size": info.batch_size,
}
support_embed_models = config.embed_model_names.keys()
assert model_id in support_embed_models, f"Unsupported embed model: {model_id}, only support {support_embed_models}"
embed_config = config.embed_model_names[model_id].model_dump()
# 解析 api_key如果值本身是环境变量名则从环境变量获取实际值
embed_config["api_key"] = os.getenv(embed_config["api_key"]) or embed_config["api_key"]
logger.info(f"Loaded embedding model info for {model_id}")
return embed_config
def select_embedding_model(model_id):
"""选择 Embedding 模型V1/V2 自动识别)。
V1 格式: provider/model_name斜杠分隔
V2 格式: provider_id:model_id冒号分隔
"""
# V2 spec 检测:包含 ":" 且存在于缓存中
if isinstance(model_id, str) and ":" in model_id:
from yuxi.services.model_cache import model_cache
if model_cache.is_v2_spec(model_id):
return select_embedding_model_v2(model_id)
provider, model_name = model_id.split("/", 1) if model_id else ("", "")
logger.info(f"Loading embedding model {model_id}")
if provider == "local":
raise ValueError("Local embedding model is not supported, please use other embedding models")
# 获取嵌入模型配置并转换为字典
embed_config = config.embed_model_names[model_id].model_dump()
embed_config = get_embedding_model_info_by_id(model_id)
if provider == "ollama":
model = OllamaEmbedding(**embed_config)
@ -293,3 +341,66 @@ def select_embedding_model(model_id):
model = OtherEmbedding(**embed_config)
return model
def select_embedding_model_v2(spec: str):
"""根据 v2 specprovider_id:model_id选择 Embedding 模型。
v2 spec 格式使用冒号分隔: siliconflow:BAAI/bge-m3
数据来源为数据库中的 model_providers 通过全局缓存访问
"""
from yuxi.services.model_cache import model_cache
info = model_cache.get_model_info(spec)
if not info:
raise ValueError(f"Unknown v2 embedding model spec: {spec}")
if info.model_type != "embedding":
raise ValueError(f"Model {spec} is not an embedding model (type={info.model_type})")
logger.info(f"Selecting v2 embedding model: {spec} (provider_type={info.provider_type})")
if info.provider_type == "ollama":
return OllamaEmbedding(
model=info.model_id,
base_url=info.base_url,
api_key=info.api_key,
dimension=info.dimension,
batch_size=info.batch_size,
)
else:
return OtherEmbedding(
model=info.model_id,
base_url=info.base_url,
api_key=info.api_key,
dimension=info.dimension,
batch_size=info.batch_size,
)
async def test_embedding_model_status_by_spec(spec: str) -> dict:
"""根据 full spec 测试 Embedding 模型状态(自动识别 V1/V2
V1 spec 格式: provider/model_name斜杠分隔
V2 spec 格式: provider_id:model_id冒号分隔
"""
try:
if ":" in spec:
from yuxi.services.model_cache import model_cache
if model_cache.is_v2_spec(spec):
model = select_embedding_model_v2(spec)
else:
return {"spec": spec, "status": "unsupported", "message": f"不支持的 V2 模型: {spec}"}
else:
model = select_embedding_model(spec)
success, message = await model.test_connection()
return {
"spec": spec,
"status": "available" if success else "unavailable",
"message": "连接正常" if success else message,
}
except Exception as e:
logger.warning(f"测试 Embedding 模型状态失败 {spec}: {e}")
return {"spec": spec, "status": "error", "message": str(e)}

View File

@ -324,9 +324,7 @@ def _validate_models_capabilities(enabled_models: list[dict], capabilities: set[
"""校验 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)}"
)
raise ValueError(f"模型 {model['id']} 的 type={model['type']} 不在 provider 能力 {sorted(capabilities)}")
def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[str, Any]:
@ -519,7 +517,7 @@ async def ensure_builtin_model_providers_in_db(db: AsyncSession) -> None:
payload["enabled_models"] = provider_def.get("enabled_models", [])
payload["headers_json"] = payload.get("headers_json") or {}
payload["extra_json"] = payload.get("extra_json") or {}
payload["is_enabled"] = False
payload["is_enabled"] = provider_id == "siliconflow-cn"
payload["is_builtin"] = True
payload["created_by"] = "system"
payload["updated_by"] = "system"

View File

@ -148,11 +148,27 @@ async def create_database(
else:
if not embed_model_name:
raise HTTPException(status_code=400, detail="embed_model_name 不能为空")
if embed_model_name not in config.embed_model_names:
raise HTTPException(status_code=400, detail=f"不支持的 embedding 模型: {embed_model_name}")
embed_info = config.embed_model_names[embed_model_name]
# 将Pydantic模型转换为字典以便JSON序列化
embed_info_dict = embed_info.model_dump() if hasattr(embed_info, "model_dump") else embed_info.dict()
# V2 embedding model (spec 格式: provider_id:model_id使用冒号分隔)
if ":" in embed_model_name:
from yuxi.services.model_cache import model_cache
info = model_cache.get_model_info(embed_model_name)
if not info or info.model_type != "embedding":
raise HTTPException(status_code=400, detail=f"不支持的 embedding 模型: {embed_model_name}")
embed_info_dict = {
"name": info.display_name,
"dimension": info.dimension,
"base_url": info.base_url,
"api_key": info.api_key,
"model_id": info.spec,
"batch_size": info.batch_size,
}
else:
if embed_model_name not in config.embed_model_names:
raise HTTPException(status_code=400, detail=f"不支持的 embedding 模型: {embed_model_name}")
embed_info = config.embed_model_names[embed_model_name]
embed_info_dict = embed_info.model_dump() if hasattr(embed_info, "model_dump") else embed_info.dict()
database_info = await knowledge_base.create_database(
database_name,

View File

@ -234,8 +234,23 @@ async def get_model_status_by_spec(
spec: str,
current_user: User = Depends(get_admin_user),
):
"""根据 full spec 检查模型状态(自动识别 V1/V2"""
"""根据 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}

View File

@ -96,8 +96,7 @@ export const ocrApi = {
// === 聊天模型状态检查分组 ===
// =============================================================================
export const chatModelApi = {
}
export const chatModelApi = {}
// =============================================================================
// === 独立模型供应商配置分组 ===

View File

@ -199,11 +199,8 @@
}
}
@media (max-width: 767px) {
:root {
--page-padding: 12px;
}
}

View File

@ -30,7 +30,6 @@
background-color: transparent !important;
}
thead th,
tbody th {
border: none;
@ -161,7 +160,6 @@
margin-bottom: 0;
}
th,
td {
padding: 0.5rem 0rem;
@ -186,6 +184,5 @@
// tbody tr:last-child td {
// border-bottom: none;
// }
}
}

View File

@ -0,0 +1,135 @@
// 模型选择器公共样式 - 供 Chat/Embedding/Rerank 三个模型选择器共用
@scrollbar-width: 6px;
@border-radius: 8px;
// 下拉触发器样式
.model-select {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 4px 8px;
cursor: pointer;
border: 1px solid var(--gray-200);
border-radius: @border-radius;
background-color: var(--gray-0);
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
&.borderless {
border: none;
}
&.max-width {
max-width: 380px;
}
&.model-select--middle {
font-size: 15px;
}
&.model-select--large {
font-size: 16px;
}
}
// 内容布局
.model-select-content {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
}
// 模型名称区域
.model-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.model-text {
overflow: hidden;
text-overflow: ellipsis;
color: var(--gray-1000);
white-space: nowrap;
}
// 状态控制区域(检查按钮、刷新按钮等)
.model-status-controls {
display: flex;
align-items: center;
gap: 4px;
flex: 0;
margin-left: auto;
}
// 状态指示器
.model-status-indicator {
font-size: 11px;
font-weight: bold;
padding: 2px 4px;
border-radius: 3px;
&.available {
color: var(--color-success-500);
}
&.unavailable {
color: var(--color-error-500);
}
&.error {
color: var(--color-warning-500);
}
}
// Provider 标签样式
.provider-tag {
padding: 2px 4px;
margin-left: 6px;
font-size: 10px;
line-height: 1;
vertical-align: middle;
}
// 刷新图标旋转动画
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 滚动菜单样式
.scrollable-menu {
max-height: 300px;
overflow-y: auto;
&::-webkit-scrollbar {
width: @scrollbar-width;
}
&::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--gray-400);
border-radius: 3px;
&:hover {
background: var(--gray-500);
}
}
}

View File

@ -120,8 +120,13 @@
size="16"
class="agent-switcher-menu-icon"
/>
<span class="agent-switcher-menu-text">{{ agent.name || 'Unknown' }}</span>
<span v-if="agent.id === currentAgentId" class="agent-switcher-menu-badge">
<span class="agent-switcher-menu-text">{{
agent.name || 'Unknown'
}}</span>
<span
v-if="agent.id === currentAgentId"
class="agent-switcher-menu-badge"
>
当前
</span>
</div>
@ -528,7 +533,10 @@ const conversationRows = computed(() => {
if (currentThreadConfigNotice.value) {
const insertAfterCount = Math.max(
0,
Math.min(Number(currentThreadConfigNotice.value.insertAfterConversationCount) || 0, rows.length)
Math.min(
Number(currentThreadConfigNotice.value.insertAfterConversationCount) || 0,
rows.length
)
)
rows.splice(insertAfterCount, 0, {
type: 'notice',
@ -569,7 +577,10 @@ const showStartAgentSelector = computed(() => {
})
const showStartAgentDropdown = computed(() => {
return showStartAgentSelector.value && (startAgents.value.length >= 4 || localUIState.chatMainWidth < 380)
return (
showStartAgentSelector.value &&
(startAgents.value.length >= 4 || localUIState.chatMainWidth < 380)
)
})
const showStartAgentSegment = computed(() => {
@ -766,7 +777,11 @@ const queuePendingThreadConfigNotice = (threadId) => {
}
const flushPendingThreadConfigNotice = (threadId) => {
if (!threadId || !currentThreadHasHistory.value || !threadPendingConfigNoticeMap.value[threadId]) {
if (
!threadId ||
!currentThreadHasHistory.value ||
!threadPendingConfigNoticeMap.value[threadId]
) {
return
}
@ -1184,7 +1199,11 @@ const selectChat = async (chatId) => {
// 线
setCurrentThreadId(chatId)
if (!props.singleMode && targetChat?.agent_id && targetChat.agent_id !== currentAgentId.value) {
if (
!props.singleMode &&
targetChat?.agent_id &&
targetChat.agent_id !== currentAgentId.value
) {
await agentStore.selectAgent(targetChat.agent_id)
}
@ -1613,10 +1632,10 @@ const hasVisibleAssistantBody = (message) => {
const { content, reasoningContent } = extractAssistantMessageBody(message)
return Boolean(
content ||
reasoningContent ||
message.error_type ||
message.extra_metadata?.error_type ||
message.isStoppedByUser
reasoningContent ||
message.error_type ||
message.extra_metadata?.error_type ||
message.isStoppedByUser
)
}
@ -1698,9 +1717,7 @@ const isDisplayMessageProcessing = (conv, displayItem) => {
const isToolGroupActive = (conv, itemIndex, displayItems) => {
return (
isReplyLoading.value &&
conv?.status === 'streaming' &&
itemIndex === displayItems.length - 1
isReplyLoading.value && conv?.status === 'streaming' && itemIndex === displayItems.length - 1
)
}

View File

@ -57,11 +57,7 @@
/>
<!-- 统一显示所有配置项 -->
<template v-for="(value, key) in filteredConfigurableItems" :key="key">
<a-form-item
:label="getConfigLabel(key, value)"
:name="key"
class="config-item"
>
<a-form-item :label="getConfigLabel(key, value)" :name="key" class="config-item">
<p v-if="value.description" class="config-description">{{ value.description }}</p>
<!-- <div>{{ value }}</div> -->

View File

@ -40,16 +40,11 @@
<div class="col-item">
<div class="setting-label">{{ items?.reranker?.des }}</div>
<div class="setting-content">
<a-select
class="full-width"
<RerankModelSelector
:value="configStore.config?.reranker"
@change="handleChange('reranker', $event)"
placeholder="请选择重排序模型"
>
<a-select-option v-for="(name, idx) in rerankerChoices" :key="idx" :value="name"
>{{ name }}
</a-select-option>
</a-select>
style="width: 100%"
/>
</div>
</div>
</div>
@ -187,17 +182,13 @@ import { useUserStore } from '@/stores/user'
import { Globe } from 'lucide-vue-next'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import EmbeddingModelSelector from '@/components/EmbeddingModelSelector.vue'
import RerankModelSelector from '@/components/RerankModelSelector.vue'
const configStore = useConfigStore()
const agentStore = useAgentStore()
const userStore = useUserStore()
const items = computed(() => configStore.config?._config_items || {})
const isSettingDefaultAgent = ref(false)
const rerankerChoices = computed(() => {
return Object.keys(configStore?.config?.reranker_names || {}) || []
})
const agentOptions = computed(() =>
(agentStore.agents || []).map((agent) => ({
label: agent.name || 'Unknown',

View File

@ -343,5 +343,4 @@ const renameChat = async (chatId) => {
color: var(--main-color);
font-size: 12px;
}
</style>

View File

@ -1,68 +1,94 @@
<template>
<a-select
:style="style"
:size="size"
:value="value"
@change="handleSelect"
@dropdownVisibleChange="checkAllModelStatus"
:placeholder="placeholder"
:disabled="disabled"
>
<a-select-option v-for="(name, idx) in embedModelChoices" :key="idx" :value="name">
<div style="display: flex; align-items: center; gap: 8px; min-width: 0">
<span
style="
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
"
>
{{ name }} ({{ configStore.config?.embed_model_names[name]?.dimension }})
</span>
<span
:style="{
color: getModelStatusColor(name),
fontSize: '11px',
fontWeight: 'bold',
flexShrink: 0,
borderRadius: '3px'
}"
:title="getModelStatusTooltip(name)"
>
{{ getModelStatusIcon(name) }}
</span>
<a-dropdown trigger="click" @open-change="handleOpenChange">
<div class="model-select" :class="modelSelectClasses" @click.prevent>
<div class="model-select-content">
<div class="model-info">
<a-tooltip :title="displayText" placement="right">
<span class="model-text">{{ displayText }}</span>
</a-tooltip>
</div>
</div>
</a-select-option>
</a-select>
</div>
<template #overlay>
<a-menu class="scrollable-menu">
<!-- V2 模型列表 provider 分组 -->
<a-menu-item-group v-for="(providerData, providerId) in v2Models" :key="`v2-${providerId}`">
<template #title>
<span>{{ providerId }}</span>
<a-tag color="success" size="small" class="provider-tag"></a-tag>
</template>
<a-menu-item
v-for="model in providerData.models"
:key="model.spec"
@click="handleSelect(model.spec)"
>
<div class="model-option">
<span class="model-option-name">
{{ model.display_name }}
<span class="model-dimension">({{ model.dimension }})</span>
</span>
<span
class="model-status-icon"
:class="getV2StatusClass(model.spec)"
:title="getV2StatusTooltip(model.spec)"
>{{ getV2StatusIcon(model.spec) }}</span
>
</div>
</a-menu-item>
</a-menu-item-group>
<!-- V1 模型列表过时仅保留兼容 -->
<a-menu-item-group v-if="v1EmbedModels.length" key="v1" title="V1 版本(过时)">
<a-menu-item v-for="name in v1EmbedModels" :key="name" @click="handleSelect(name)">
<div class="model-option">
<span class="model-option-name">
{{ name }}
<span class="model-dimension"
>({{ configStore.config?.embed_model_names[name]?.dimension }})</span
>
</span>
<a-tag color="default" size="small" class="provider-tag">过时</a-tag>
<span
class="model-status-icon"
:class="getV1StatusClass(name)"
:title="getV1StatusTooltip(name)"
>{{ getV1StatusIcon(name) }}</span
>
</div>
</a-menu-item>
</a-menu-item-group>
</a-menu>
</template>
</a-dropdown>
</template>
<script setup>
import { computed, reactive } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useConfigStore } from '@/stores/config'
import { embeddingApi } from '@/apis/knowledge_api'
import { modelProviderApi } from '@/apis/system_api'
import { message } from 'ant-design-vue'
const configStore = useConfigStore()
defineProps({
const props = defineProps({
value: {
type: String,
default: ''
},
size: {
type: String,
default: 'default',
validator: (value) => ['default', 'large', 'small'].includes(value)
},
placeholder: {
type: String,
default: '请选择嵌入模型'
},
size: {
type: String,
default: 'small',
validator: (value) => ['default', 'small', 'middle', 'large'].includes(value)
},
style: {
type: Object,
default: () => ({ width: '320px' })
default: () => ({ width: '100%' })
},
disabled: {
type: Boolean,
@ -72,34 +98,85 @@ defineProps({
const emit = defineEmits(['update:value', 'change'])
const v2Models = ref({})
const state = reactive({
modelStatuses: {},
v1ModelStatuses: {},
v2ModelStatuses: {},
checkingStatus: false
})
const embedModelChoices = computed(() => {
return Object.keys(configStore?.config?.embed_model_names || {}) || []
const displayText = computed(() => props.value || props.placeholder)
const resolvedSize = computed(() => props.size || 'small')
const modelSelectClasses = computed(() => ({
'model-select--middle': resolvedSize.value === 'middle',
'model-select--large': resolvedSize.value === 'large'
}))
const v1EmbedModels = computed(() => {
return Object.keys(configStore?.config?.embed_model_names || {})
})
// embedding
const checkAllModelStatus = async () => {
const handleOpenChange = async (open) => {
if (!open) return
fetchV2Models()
checkV1ModelStatuses()
}
const fetchV2Models = async () => {
try {
state.checkingStatus = true
const response = await embeddingApi.getAllModelsStatus()
if (response.status.models) {
state.modelStatuses = response.status.models
const response = await modelProviderApi.getV2Models('embedding')
if (response.success) {
v2Models.value = response.data || {}
// V2
await checkV2ModelStatuses()
}
} catch (error) {
console.error('检查所有模型状态失败:', error)
message.error('获取模型状态失败')
console.error('获取 V2 embedding 模型失败:', error)
}
}
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: '检查失败'
}
}
}
}
} catch (error) {
console.error('检查 V2 模型状态失败:', error)
} finally {
state.checkingStatus = false
}
}
//
const getModelStatusIcon = (modelId) => {
const status = state.modelStatuses[modelId]
const checkV1ModelStatuses = async () => {
try {
const response = await embeddingApi.getAllModelsStatus()
if (response.status.models) {
state.v1ModelStatuses = response.status.models
}
} catch (error) {
console.error('检查 V1 模型状态失败:', error)
message.error('获取模型状态失败')
}
}
// V1
const getV1StatusIcon = (modelId) => {
const status = state.v1ModelStatuses[modelId]
if (!status) return '○'
if (status.status === 'available') return '✓'
if (status.status === 'unavailable') return '✗'
@ -107,28 +184,40 @@ const getModelStatusIcon = (modelId) => {
return '○'
}
//
const getModelStatusColor = (modelId) => {
const status = state.modelStatuses[modelId]
if (!status) return 'var(--gray-500)'
if (status.status === 'available') return 'var(--color-success-500)'
if (status.status === 'unavailable') return 'var(--color-error-500)'
if (status.status === 'error') return 'var(--color-warning-500)'
return 'var(--gray-500)'
const getV1StatusClass = (modelId) => {
const status = state.v1ModelStatuses[modelId]
return status?.status || ''
}
//
const getModelStatusTooltip = (modelId) => {
const status = state.modelStatuses[modelId]
const getV1StatusTooltip = (modelId) => {
const status = state.v1ModelStatuses[modelId]
if (!status) return '状态未知'
let statusText =
{ available: '可用', unavailable: '不可用', error: '错误' }[status.status] || '未知'
return `${statusText}: ${status.message || '无详细信息'}`
}
let statusText = ''
if (status.status === 'available') statusText = '可用'
else if (status.status === 'unavailable') statusText = '不可用'
else if (status.status === 'error') statusText = '错误'
// 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 message = status.message || '无详细信息'
return `${statusText}: ${message}`
const getV2StatusClass = (spec) => {
const status = state.v2ModelStatuses[spec]
return status?.status || ''
}
const getV2StatusTooltip = (spec) => {
const status = state.v2ModelStatuses[spec]
if (!status) return '状态未知'
let statusText =
{ available: '可用', unavailable: '不可用', error: '错误' }[status.status] || '未知'
return `${statusText}: ${status.message || '无详细信息'}`
}
const handleSelect = (value) => {
@ -138,5 +227,46 @@ const handleSelect = (value) => {
</script>
<style lang="less" scoped>
//
@import '@/assets/css/model-selector-common.less';
.model-option {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
}
.model-option-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-dimension {
color: var(--gray-500);
font-size: 12px;
margin-left: 4px;
}
.model-status-icon {
font-size: 11px;
font-weight: bold;
flex-shrink: 0;
color: var(--gray-500);
&.available {
color: var(--color-success-500);
}
&.unavailable {
color: var(--color-error-500);
}
&.error {
color: var(--color-warning-500);
}
}
</style>

View File

@ -14,8 +14,8 @@
<template #description>
项目已启用新版的模型配置自定义程度更高且支持 Embedding / Rerank 模型您可以
<a @click="goToNewModelConfig">点击此处</a>
跳转到新版模型配置页面
已配置模型将会在 0.6.x 期间继续使用0.7 版本将不再支持老版的模型调用
跳转到新版模型配置页面 已配置模型将会在 0.6.x 期间继续使用0.7
版本将不再支持老版的模型调用
为了更加稳定的模型调用和更好的用户体验建议尽快迁移到新的模型配置
</template>
</a-alert>
@ -25,7 +25,9 @@
</div>
<div v-for="item in tableData" :key="item.id" class="provider-block">
<h4 class="provider-name">{{ item.name }} <span class="provider-id">({{ item.id }})</span></h4>
<h4 class="provider-name">
{{ item.name }} <span class="provider-id">({{ item.id }})</span>
</h4>
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item label="API地址">{{ item.base_url }}</a-descriptions-item>
<a-descriptions-item label="默认模型">{{ item.default }}</a-descriptions-item>
@ -34,7 +36,9 @@
<a v-if="item.url" :href="item.url" target="_blank">{{ item.url }}</a>
<span v-else></span>
</a-descriptions-item>
<a-descriptions-item label="可用模型">{{ item.models?.join(', ') || '无' }}</a-descriptions-item>
<a-descriptions-item label="可用模型">{{
item.models?.join(', ') || '无'
}}</a-descriptions-item>
</a-descriptions>
</div>
</div>

View File

@ -17,17 +17,14 @@
{{ modelStatusIcon }}
</span>
<a-tooltip title="刷新缓存">
<a-button
type="text"
<button
: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>
<RefreshCw :size="13" :class="{ spin: state.refreshingCache }" />
</button>
</a-tooltip>
<a-button
:size="buttonSize"
@ -48,7 +45,7 @@
<a-menu-item-group v-for="(providerData, providerId) in v2Models" :key="`v2-${providerId}`">
<template #title>
<span>{{ providerId }}</span>
<a-tag color="success" size="small" class="provider-tag">v2</a-tag>
<a-tag color="success" size="small" class="provider-tag"></a-tag>
</template>
<a-menu-item
v-for="model in providerData.models"
@ -199,170 +196,28 @@ const handleSelectV2Model = (spec) => {
</script>
<style lang="less" scoped>
//
@status-success: var(--color-success-500);
@status-error: var(--color-error-500);
@status-warning: var(--color-warning-500);
@status-default: var(--gray-500);
@border-radius: 8px;
@scrollbar-width: 6px;
@status-indicator-padding: 2px 4px;
@status-check-button-padding: 0 4px;
@status-check-button-font-size: 12px;
@status-indicator-font-size: 11px;
@import '@/assets/css/model-selector-common.less';
//
.model-select {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 4px 8px;
cursor: pointer;
border: 1px solid var(--gray-200);
border-radius: @border-radius;
background-color: var(--gray-0);
min-width: 0;
//
.status-check-button {
font-size: 12px;
padding: 0 4px;
}
//
.cache-refresh-button {
font-size: 12px;
padding: 0 4px;
display: flex;
align-items: center;
gap: 0.5rem;
//
&.borderless {
border: none;
}
&.max-width {
max-width: 380px;
}
&.model-select--middle {
font-size: 15px;
}
&.model-select--large {
font-size: 16px;
}
//
.model-select-content {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
//
.model-info {
flex: 1;
min-width: 0;
overflow: hidden;
.model-text {
overflow: hidden;
text-overflow: ellipsis;
color: var(--gray-1000);
white-space: nowrap;
}
}
//
.model-status-controls {
display: flex;
align-items: center;
gap: 4px;
flex: 0;
margin-left: auto;
//
.model-status-indicator {
font-size: @status-indicator-font-size;
font-weight: bold;
padding: @status-indicator-padding;
border-radius: 3px;
//
&.available {
color: @status-success;
}
&.unavailable {
color: @status-error;
}
&.error {
color: @status-warning;
}
}
//
.status-check-button {
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;
}
}
}
width: 24px;
background-color: transparent;
border: none;
outline: none;
cursor: pointer;
}
//
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// Provider
.provider-tag {
margin-left: 6px;
font-size: 10px;
line-height: 1;
vertical-align: middle;
}
//
.scrollable-menu {
max-height: 300px;
overflow-y: auto;
//
&::-webkit-scrollbar {
width: @scrollbar-width;
}
&::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--gray-400);
border-radius: 3px;
&:hover {
background: var(--gray-500);
}
}
}
</style>
<style lang="less" scoped>
// scoped
:deep(.ant-dropdown-menu) {
&.scrollable-menu {
max-height: 300px;

View File

@ -0,0 +1,101 @@
<template>
<a-dropdown trigger="click" @open-change="handleOpenChange">
<div class="model-select" @click.prevent>
<div class="model-select-content">
<div class="model-info">
<a-tooltip :title="displayText" placement="right">
<span class="model-text">{{ displayText }}</span>
</a-tooltip>
</div>
</div>
</div>
<template #overlay>
<a-menu class="scrollable-menu">
<a-menu-item-group v-for="(providerData, providerId) in v2Models" :key="`v2-${providerId}`">
<template #title>
<span>{{ providerId }}</span>
<a-tag color="success" size="small" class="provider-tag"></a-tag>
</template>
<a-menu-item
v-for="model in providerData.models"
:key="model.spec"
@click="handleSelect(model.spec)"
>
{{ model.display_name }}
</a-menu-item>
</a-menu-item-group>
<a-menu-item-group v-if="v1Models.length" key="v1" title="V1 版本(过时)">
<a-menu-item v-for="name in v1Models" :key="name" @click="handleSelect(name)">
<span>{{ name }}</span>
<a-tag color="default" size="small" class="provider-tag">过时</a-tag>
</a-menu-item>
</a-menu-item-group>
</a-menu>
</template>
</a-dropdown>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useConfigStore } from '@/stores/config'
import { modelProviderApi } from '@/apis/system_api'
const configStore = useConfigStore()
const props = defineProps({
value: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请选择重排序模型'
},
size: {
type: String,
default: 'small',
validator: (value) => ['small', 'middle', 'large'].includes(value)
},
style: {
type: Object,
default: () => ({ width: '100%' })
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:value', 'change'])
const v2Models = ref({})
const displayText = computed(() => props.value || props.placeholder)
const v1Models = computed(() => {
return Object.keys(configStore?.config?.reranker_names || {})
})
const handleOpenChange = async (open) => {
if (!open) return
try {
const response = await modelProviderApi.getV2Models('rerank')
if (response.success) {
v2Models.value = response.data || {}
}
} catch (error) {
console.error('获取 V2 rerank 模型失败:', error)
}
}
const handleSelect = (spec) => {
emit('update:value', spec)
emit('change', spec)
}
</script>
<style lang="less" scoped>
@import '@/assets/css/model-selector-common.less';
</style>

View File

@ -77,7 +77,7 @@
</div>
<!-- Result Slot -->
<div class="tool-result" style="opacity: 0.8;" v-if="hasResult">
<div class="tool-result" style="opacity: 0.8" v-if="hasResult">
<slot name="result" :tool-call="toolCall" :result-content="resultContent">
<div class="tool-result-content" :data-tool-call-id="toolCall.id">
<!-- Default rendering -->
@ -234,7 +234,7 @@ const formatResultData = (data) => {
background-color: var(--gray-25);
}
&>span {
& > span {
display: flex;
align-items: center;
}

View File

@ -13,8 +13,12 @@
</span>
<span class="summary-content">
<span class="summary-title">{{ toolCallsSummaryTitle }}</span>
<span class="summary-separator" v-if="normalizedToolCalls.length > 1 && toolCallsNamesMeta">·</span>
<span class="summary-meta" v-if="normalizedToolCalls.length > 1 && toolCallsNamesMeta">{{ toolCallsNamesMeta }}</span>
<span class="summary-separator" v-if="normalizedToolCalls.length > 1 && toolCallsNamesMeta"
>·</span
>
<span class="summary-meta" v-if="normalizedToolCalls.length > 1 && toolCallsNamesMeta">{{
toolCallsNamesMeta
}}</span>
<span class="summary-status-tag" v-if="statusSummary">{{ statusSummary }}</span>
</span>
<span class="summary-trailing">
@ -123,8 +127,9 @@ const statusSummary = computed(() => {
(toolCall) =>
toolCall.status !== 'success' && toolCall.status !== 'error' && !toolCall.tool_call_result
).length
const errorCount = normalizedToolCalls.value.filter((toolCall) => toolCall.status === 'error')
.length
const errorCount = normalizedToolCalls.value.filter(
(toolCall) => toolCall.status === 'error'
).length
const parts = []
if (successCount > 0 && successCount === normalizedToolCalls.value.length) {

View File

@ -208,10 +208,7 @@
<!-- 部门选择器仅超级管理员可见 -->
<a-form-item v-if="userStore.isSuperAdmin" label="部门" class="form-item">
<a-select
v-model:value="userManagement.form.departmentId"
placeholder="请选择部门"
>
<a-select v-model:value="userManagement.form.departmentId" placeholder="请选择部门">
<a-select-option
v-for="dept in departmentManagement.departments"
:key="dept.id"

View File

@ -36,7 +36,10 @@ export const useChatThreadsStore = defineStore('chatThreads', () => {
const fetchedThreads = await threadApi.getThreads(agentId, PAGE_SIZE, 0)
threads.value = fetchedThreads || []
hasMoreThreads.value = Boolean(fetchedThreads && fetchedThreads.length >= PAGE_SIZE)
if (currentThreadId.value && !threads.value.find((thread) => thread.id === currentThreadId.value)) {
if (
currentThreadId.value &&
!threads.value.find((thread) => thread.id === currentThreadId.value)
) {
currentThreadId.value = null
}
return threads.value

View File

@ -1,32 +1,32 @@
const ICON_BASE = 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons'
export const modelIcons = {
'default': `${ICON_BASE}/default.svg`,
'alibaba': `${ICON_BASE}/bailian-color.svg`,
default: `${ICON_BASE}/default.svg`,
alibaba: `${ICON_BASE}/bailian-color.svg`,
'alibaba-coding-plan': `${ICON_BASE}/alibabacloud-color.svg`,
'alibaba-coding-plan-cn': `${ICON_BASE}/alibabacloud-color.svg`,
'anthropic': `${ICON_BASE}/anthropic.svg`,
'ark': `${ICON_BASE}/volcengine-color.svg`,
'dashscope': `${ICON_BASE}/bailian-color.svg`,
'deepseek': `${ICON_BASE}/deepseek-color.svg`,
'google': `${ICON_BASE}/google-color.svg`,
'lmstudio': `${ICON_BASE}/lmstudio.svg`,
'minimax': `${ICON_BASE}/minimax-color.svg`,
anthropic: `${ICON_BASE}/anthropic.svg`,
ark: `${ICON_BASE}/volcengine-color.svg`,
dashscope: `${ICON_BASE}/bailian-color.svg`,
deepseek: `${ICON_BASE}/deepseek-color.svg`,
google: `${ICON_BASE}/google-color.svg`,
lmstudio: `${ICON_BASE}/lmstudio.svg`,
minimax: `${ICON_BASE}/minimax-color.svg`,
'minimax-cn': `${ICON_BASE}/minimax-color.svg`,
'modelscope': `${ICON_BASE}/modelscope-color.svg`,
'moonshotai': `${ICON_BASE}/moonshot.svg`,
modelscope: `${ICON_BASE}/modelscope-color.svg`,
moonshotai: `${ICON_BASE}/moonshot.svg`,
'moonshotai-cn': `${ICON_BASE}/moonshot.svg`,
'ollama': `${ICON_BASE}/ollama.svg`,
ollama: `${ICON_BASE}/ollama.svg`,
'ollama-cloud': `${ICON_BASE}/ollama.svg`,
'opencode': `${ICON_BASE}/opencode.svg`,
'openai': `${ICON_BASE}/openai.svg`,
'openrouter': `${ICON_BASE}/openrouter.svg`,
'siliconflow': `${ICON_BASE}/siliconcloud-color.svg`,
opencode: `${ICON_BASE}/opencode.svg`,
openai: `${ICON_BASE}/openai.svg`,
openrouter: `${ICON_BASE}/openrouter.svg`,
siliconflow: `${ICON_BASE}/siliconcloud-color.svg`,
'siliconflow-cn': `${ICON_BASE}/siliconcloud-color.svg`,
'together': `${ICON_BASE}/together-color.svg`,
'zai': `${ICON_BASE}/zai.svg`,
together: `${ICON_BASE}/together-color.svg`,
zai: `${ICON_BASE}/zai.svg`,
'zai-coding-plan': `${ICON_BASE}/zai.svg`,
'zhipu': `${ICON_BASE}/zhipu-color.svg`,
'zhipuai': `${ICON_BASE}/zhipu-color.svg`,
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

@ -167,13 +167,8 @@ const route = useRoute()
const router = useRouter()
// agentStore
const {
selectedAgentId,
defaultAgentId,
selectedAgentConfigId,
agentConfigs,
isLoadingConfig
} = storeToRefs(agentStore)
const { selectedAgentId, defaultAgentId, selectedAgentConfigId, agentConfigs, isLoadingConfig } =
storeToRefs(agentStore)
const syncingRouteThread = ref(false)

View File

@ -95,7 +95,9 @@ const filteredProviders = computed(() => {
})
const providerStats = computed(() => {
let enabled = 0, warning = 0, models = 0
let enabled = 0,
warning = 0,
models = 0
for (const p of providers.value) {
if (p.is_enabled) {
enabled++
@ -989,8 +991,7 @@ onMounted(loadProviders)
</a-button>
</div>
</div>
<div class="remote-fetch-actions">
</div>
<div class="remote-fetch-actions"></div>
</div>
</div>
</a-modal>