From e3d312059af4ee2a5d99d1d6e81e8b64eee63dfa Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sun, 26 Apr 2026 18:51:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(model):=20=E9=87=8D=E6=9E=84=E5=B5=8C?= =?UTF-8?q?=E5=85=A5=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Rerank=20=E6=A8=A1=E5=9E=8B=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=EF=BC=8C=E4=BC=98=E5=8C=96=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E8=80=85=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package/yuxi/agents/models.py | 5 +- .../knowledge/implementations/lightrag.py | 4 +- .../package/yuxi/knowledge/utils/kb_utils.py | 9 +- backend/package/yuxi/models/__init__.py | 4 +- backend/package/yuxi/models/embed.py | 119 +++++++- .../yuxi/services/model_provider_service.py | 6 +- backend/server/routers/knowledge_router.py | 26 +- .../server/routers/model_provider_router.py | 17 +- web/src/apis/system_api.js | 3 +- web/src/assets/css/base.css | 3 - web/src/assets/css/markdown-preview.less | 3 - web/src/assets/css/model-selector-common.less | 135 +++++++++ web/src/components/AgentChatComponent.vue | 43 ++- web/src/components/AgentConfigSidebar.vue | 6 +- web/src/components/BasicSettingsSection.vue | 17 +- web/src/components/ConversationNavSection.vue | 1 - web/src/components/EmbeddingModelSelector.vue | 282 +++++++++++++----- .../components/ModelProvidersComponent.vue | 12 +- web/src/components/ModelSelectorComponent.vue | 187 ++---------- web/src/components/RerankModelSelector.vue | 101 +++++++ .../ToolCallingResult/BaseToolCall.vue | 4 +- .../components/ToolCallsGroupComponent.vue | 13 +- .../components/UserManagementComponent.vue | 5 +- web/src/stores/chatThreads.js | 5 +- web/src/utils/modelIcon.js | 42 +-- web/src/views/AgentView.vue | 9 +- web/src/views/ModelConfigView.vue | 7 +- 27 files changed, 713 insertions(+), 355 deletions(-) create mode 100644 web/src/assets/css/model-selector-common.less create mode 100644 web/src/components/RerankModelSelector.vue diff --git a/backend/package/yuxi/agents/models.py b/backend/package/yuxi/agents/models.py index 0b26ec97..3f82efce 100644 --- a/backend/package/yuxi/agents/models.py +++ b/backend/package/yuxi/agents/models.py @@ -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: diff --git a/backend/package/yuxi/knowledge/implementations/lightrag.py b/backend/package/yuxi/knowledge/implementations/lightrag.py index bf725b28..af73a4b4 100644 --- a/backend/package/yuxi/knowledge/implementations/lightrag.py +++ b/backend/package/yuxi/knowledge/implementations/lightrag.py @@ -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"): diff --git a/backend/package/yuxi/knowledge/utils/kb_utils.py b/backend/package/yuxi/knowledge/utils/kb_utils.py index 3641c65e..eb38cd7e 100644 --- a/backend/package/yuxi/knowledge/utils/kb_utils.py +++ b/backend/package/yuxi/knowledge/utils/kb_utils.py @@ -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}") diff --git a/backend/package/yuxi/models/__init__.py b/backend/package/yuxi/models/__init__.py index 2ab5084b..fe29cf43 100644 --- a/backend/package/yuxi/models/__init__.py +++ b/backend/package/yuxi/models/__init__.py @@ -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"] diff --git a/backend/package/yuxi/models/embed.py b/backend/package/yuxi/models/embed.py index 04f2da70..c5a8137c 100644 --- a/backend/package/yuxi/models/embed.py +++ b/backend/package/yuxi/models/embed.py @@ -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_url、api_key、name、dimension、model_id、batch_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 spec(provider_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)} diff --git a/backend/package/yuxi/services/model_provider_service.py b/backend/package/yuxi/services/model_provider_service.py index cbc9b5e9..bdfc276b 100644 --- a/backend/package/yuxi/services/model_provider_service.py +++ b/backend/package/yuxi/services/model_provider_service.py @@ -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" diff --git a/backend/server/routers/knowledge_router.py b/backend/server/routers/knowledge_router.py index bee5c3fd..b18b7f53 100644 --- a/backend/server/routers/knowledge_router.py +++ b/backend/server/routers/knowledge_router.py @@ -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, diff --git a/backend/server/routers/model_provider_router.py b/backend/server/routers/model_provider_router.py index ca1de837..5f4a5eee 100644 --- a/backend/server/routers/model_provider_router.py +++ b/backend/server/routers/model_provider_router.py @@ -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} diff --git a/web/src/apis/system_api.js b/web/src/apis/system_api.js index 67c905da..22850f97 100644 --- a/web/src/apis/system_api.js +++ b/web/src/apis/system_api.js @@ -96,8 +96,7 @@ export const ocrApi = { // === 聊天模型状态检查分组 === // ============================================================================= -export const chatModelApi = { -} +export const chatModelApi = {} // ============================================================================= // === 独立模型供应商配置分组 === diff --git a/web/src/assets/css/base.css b/web/src/assets/css/base.css index 0dccfc52..594b6fbe 100644 --- a/web/src/assets/css/base.css +++ b/web/src/assets/css/base.css @@ -199,11 +199,8 @@ } } - - @media (max-width: 767px) { :root { --page-padding: 12px; } } - diff --git a/web/src/assets/css/markdown-preview.less b/web/src/assets/css/markdown-preview.less index d5f13d97..65f974b3 100644 --- a/web/src/assets/css/markdown-preview.less +++ b/web/src/assets/css/markdown-preview.less @@ -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; // } - } } diff --git a/web/src/assets/css/model-selector-common.less b/web/src/assets/css/model-selector-common.less new file mode 100644 index 00000000..52d0cfa1 --- /dev/null +++ b/web/src/assets/css/model-selector-common.less @@ -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); + } + } +} diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 1d12c3e5..23d9042f 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -120,8 +120,13 @@ size="16" class="agent-switcher-menu-icon" /> - {{ agent.name || 'Unknown' }} - + {{ + agent.name || 'Unknown' + }} + 当前 @@ -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 ) } diff --git a/web/src/components/AgentConfigSidebar.vue b/web/src/components/AgentConfigSidebar.vue index 1ed66c02..f822b5b9 100644 --- a/web/src/components/AgentConfigSidebar.vue +++ b/web/src/components/AgentConfigSidebar.vue @@ -57,11 +57,7 @@ />